From 79c9cf3f3c3624c3becb352d2efb290826040d22 Mon Sep 17 00:00:00 2001 From: Hung Le Date: Tue, 30 Jun 2026 02:07:52 +0530 Subject: [PATCH 01/50] feat(messaging): add Google Chat to messaging channel --- .../policies/presets/googlechat.yaml | 40 +++ src/lib/actions/sandbox/rebuild.ts | 5 + src/lib/messaging-channel-config.test.ts | 5 + .../applier/secret-file-delivery.test.ts | 98 +++++++ .../messaging/applier/secret-file-delivery.ts | 159 +++++++++++ src/lib/messaging/channels/built-ins.ts | 3 + .../channels/googlechat/hooks/index.ts | 33 +++ .../hooks/tunnel-audience-gate.test.ts | 170 ++++++++++++ .../googlechat/hooks/tunnel-audience-gate.ts | 164 ++++++++++++ .../googlechat/hooks/tunnel-runtime.ts | 40 +++ .../messaging/channels/googlechat/manifest.ts | 251 ++++++++++++++++++ .../googlechat/template-resolver.test.ts | 85 ++++++ .../channels/googlechat/template-resolver.ts | 57 ++++ src/lib/messaging/channels/manifests.test.ts | 106 ++++++++ src/lib/messaging/channels/metadata.test.ts | 9 +- .../messaging/channels/template-resolver.ts | 2 + src/lib/messaging/diagnostics.test.ts | 1 + src/lib/messaging/hooks/builtins.ts | 6 + src/lib/messaging/hooks/hook-runner.test.ts | 1 + src/lib/messaging/manifest/types.ts | 26 ++ src/lib/messaging/utils.test.ts | 1 + src/lib/onboard.ts | 9 + .../onboard/messaging-secret-file-delivery.ts | 90 +++++++ src/lib/sandbox/channels.test.ts | 4 +- src/lib/tunnel/services.ts | 22 ++ 25 files changed, 1385 insertions(+), 2 deletions(-) create mode 100644 nemoclaw-blueprint/policies/presets/googlechat.yaml create mode 100644 src/lib/messaging/applier/secret-file-delivery.test.ts create mode 100644 src/lib/messaging/applier/secret-file-delivery.ts create mode 100644 src/lib/messaging/channels/googlechat/hooks/index.ts create mode 100644 src/lib/messaging/channels/googlechat/hooks/tunnel-audience-gate.test.ts create mode 100644 src/lib/messaging/channels/googlechat/hooks/tunnel-audience-gate.ts create mode 100644 src/lib/messaging/channels/googlechat/hooks/tunnel-runtime.ts create mode 100644 src/lib/messaging/channels/googlechat/manifest.ts create mode 100644 src/lib/messaging/channels/googlechat/template-resolver.test.ts create mode 100644 src/lib/messaging/channels/googlechat/template-resolver.ts create mode 100644 src/lib/onboard/messaging-secret-file-delivery.ts diff --git a/nemoclaw-blueprint/policies/presets/googlechat.yaml b/nemoclaw-blueprint/policies/presets/googlechat.yaml new file mode 100644 index 0000000000..ac426de400 --- /dev/null +++ b/nemoclaw-blueprint/policies/presets/googlechat.yaml @@ -0,0 +1,40 @@ +# 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, service-account token exchange, and inbound JWT verification certs" + +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 + rules: + - allow: { method: GET, path: "/**" } + - allow: { method: POST, path: "/**" } + - allow: { method: PUT, path: "/**" } + - allow: { method: DELETE, path: "/**" } + # Service-account JWT -> OAuth access token exchange (google-auth-library). + - host: oauth2.googleapis.com + port: 443 + protocol: rest + enforcement: enforce + request_body_credential_rewrite: true + rules: + - allow: { method: POST, path: "/**" } + # Public certificates used to verify inbound Chat / add-on JWT signatures. + - 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/actions/sandbox/rebuild.ts b/src/lib/actions/sandbox/rebuild.ts index 47274b48ad..be1bd043d9 100644 --- a/src/lib/actions/sandbox/rebuild.ts +++ b/src/lib/actions/sandbox/rebuild.ts @@ -1175,6 +1175,11 @@ export async function rebuildSandbox( if (!ensureMessagingHostForwardAfterRebuild(sandboxName, rebuildMessagingPlan)) { messagingHostForwardUnverified = true; } + // Re-deliver in-process secret files (e.g. the Google Chat service account) + // that the rebuilt image references by path, then restart the gateway. + ( + require("../../onboard/messaging-secret-file-delivery") as typeof import("../../onboard/messaging-secret-file-delivery") + ).deliverSandboxMessagingSecretFilesForPlan(sandboxName, rebuildMessagingPlan); console.log(""); if ( diff --git a/src/lib/messaging-channel-config.test.ts b/src/lib/messaging-channel-config.test.ts index a9678dac99..beb4064519 100644 --- a/src/lib/messaging-channel-config.test.ts +++ b/src/lib/messaging-channel-config.test.ts @@ -31,6 +31,11 @@ describe("messaging channel config", () => { "MSTEAMS_APP_ID", "MSTEAMS_TENANT_ID", "MSTEAMS_PORT", + "GOOGLECHAT_AUDIENCE_TYPE", + "GOOGLECHAT_AUDIENCE", + "GOOGLECHAT_APP_PRINCIPAL", + "GOOGLECHAT_WEBHOOK_PATH", + "GOOGLECHAT_ALLOWED_USERS", ]); }); diff --git a/src/lib/messaging/applier/secret-file-delivery.test.ts b/src/lib/messaging/applier/secret-file-delivery.test.ts new file mode 100644 index 0000000000..4ed3863639 --- /dev/null +++ b/src/lib/messaging/applier/secret-file-delivery.test.ts @@ -0,0 +1,98 @@ +// 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 { googlechatManifest } from "../channels/built-ins"; +import { + collectMessagingSecretFiles, + deliverMessagingSecretFiles, + type MessagingSecretFileTarget, + type SecretFileDeliveryDeps, +} from "./secret-file-delivery"; + +const SA_TARGET = "/sandbox/.openclaw/secrets/googlechat-service-account.json"; + +const GOOGLECHAT_TARGET: MessagingSecretFileTarget = { + channelId: "googlechat", + secretFileId: "serviceAccountFile", + envKey: "GOOGLECHAT_SERVICE_ACCOUNT", + target: SA_TARGET, + mode: "640", +}; + +function makeDeps(overrides: Partial = {}): SecretFileDeliveryDeps { + return { + readSecret: vi.fn(() => '{"type":"service_account"}'), + uploadToSandbox: vi.fn(() => true), + execInSandbox: vi.fn(() => true), + restartGateway: vi.fn(), + log: () => {}, + warn: () => {}, + writeTempFile: vi.fn(() => "/tmp/nemoclaw-secret-x/secret"), + removeTempFile: vi.fn(), + ...overrides, + }; +} + +describe("collectMessagingSecretFiles", () => { + it("maps the googlechat service-account file from the manifest", () => { + expect(collectMessagingSecretFiles([googlechatManifest], ["googlechat"], "openclaw")).toEqual([ + GOOGLECHAT_TARGET, + ]); + }); + + it("skips inactive channels and non-matching agents", () => { + expect(collectMessagingSecretFiles([googlechatManifest], [], "openclaw")).toEqual([]); + expect(collectMessagingSecretFiles([googlechatManifest], ["googlechat"], "hermes")).toEqual([]); + }); +}); + +describe("deliverMessagingSecretFiles", () => { + it("uploads, chmods, and restarts the gateway once on success", () => { + const deps = makeDeps(); + const result = deliverMessagingSecretFiles("sbx", [GOOGLECHAT_TARGET], deps); + + expect(result.delivered).toEqual(["serviceAccountFile"]); + expect(result.skipped).toEqual([]); + expect(deps.uploadToSandbox).toHaveBeenCalledWith( + "sbx", + "/tmp/nemoclaw-secret-x/secret", + SA_TARGET, + ); + expect(deps.execInSandbox).toHaveBeenCalledWith("sbx", ["chmod", "640", SA_TARGET]); + expect(deps.restartGateway).toHaveBeenCalledTimes(1); + expect(deps.removeTempFile).toHaveBeenCalledWith("/tmp/nemoclaw-secret-x/secret"); + }); + + it("skips and does not restart when the secret is unavailable", () => { + const deps = makeDeps({ readSecret: vi.fn(() => null) }); + const result = deliverMessagingSecretFiles("sbx", [GOOGLECHAT_TARGET], deps); + + expect(result.skipped).toEqual(["serviceAccountFile"]); + expect(deps.uploadToSandbox).not.toHaveBeenCalled(); + expect(deps.restartGateway).not.toHaveBeenCalled(); + }); + + it("skips (and cleans up) when the upload fails, without chmod or restart", () => { + const deps = makeDeps({ uploadToSandbox: vi.fn(() => false) }); + const result = deliverMessagingSecretFiles("sbx", [GOOGLECHAT_TARGET], deps); + + expect(result.skipped).toEqual(["serviceAccountFile"]); + // Only the mkdir exec ran; chmod must not run after a failed upload. + expect(deps.execInSandbox).toHaveBeenCalledTimes(1); + expect(deps.restartGateway).not.toHaveBeenCalled(); + expect(deps.removeTempFile).toHaveBeenCalledTimes(1); + }); + + it("restarts the gateway only once even for multiple files", () => { + const second: MessagingSecretFileTarget = { + ...GOOGLECHAT_TARGET, + secretFileId: "second", + target: "/sandbox/.openclaw/secrets/second.json", + }; + const deps = makeDeps(); + deliverMessagingSecretFiles("sbx", [GOOGLECHAT_TARGET, second], deps); + expect(deps.restartGateway).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/lib/messaging/applier/secret-file-delivery.ts b/src/lib/messaging/applier/secret-file-delivery.ts new file mode 100644 index 0000000000..f76e3d1f7a --- /dev/null +++ b/src/lib/messaging/applier/secret-file-delivery.ts @@ -0,0 +1,159 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import type { ChannelManifest, MessagingAgentId, MessagingChannelId } from "../manifest"; + +// Some channel secrets must be consumed by the agent IN-process (e.g. a Google +// service-account JSON the channel signs JWTs with). NemoClaw's normal secret +// path — an OpenShell provider + `openshell:resolve:env:` placeholder — only +// materializes the real value in OUTBOUND HTTP, never in-process, so it cannot +// satisfy local signing. `secretFiles` instead uploads the raw secret to a file +// inside the sandbox after create/rebuild. The value never touches the agent +// config or the image; the file lives in the sandbox volume. + +const DEFAULT_MODE = "600"; + +/** One resolved secret-file delivery for an active channel. */ +export interface MessagingSecretFileTarget { + readonly channelId: MessagingChannelId; + readonly secretFileId: string; + /** Env key of the captured secret value to deliver. */ + readonly envKey: string; + /** Absolute destination path inside the sandbox. */ + readonly target: string; + /** Octal mode applied to the delivered file. */ + readonly mode: string; +} + +/** Resolve the in-sandbox secret-file deliveries for the active channels + agent. */ +export function collectMessagingSecretFiles( + manifests: readonly ChannelManifest[], + activeChannelIds: readonly MessagingChannelId[], + agent: MessagingAgentId, +): MessagingSecretFileTarget[] { + const active = new Set(activeChannelIds); + const targets: MessagingSecretFileTarget[] = []; + for (const manifest of manifests) { + if (!active.has(manifest.id)) continue; + for (const file of manifest.secretFiles ?? []) { + if (file.agent !== agent) continue; + const input = manifest.inputs.find( + (entry) => entry.id === file.sourceInput && entry.kind === "secret", + ); + if (!input?.envKey) continue; + targets.push({ + channelId: manifest.id, + secretFileId: file.id, + envKey: input.envKey, + target: file.target, + mode: file.mode ?? DEFAULT_MODE, + }); + } + } + return targets; +} + +export interface SecretFileDeliveryDeps { + /** Read the captured secret value (process env / credential store). */ + readonly readSecret: (envKey: string) => string | null | undefined; + /** Upload a host file to the sandbox path. Return false on failure. */ + readonly uploadToSandbox: (sandboxName: string, localPath: string, target: string) => boolean; + /** Run a command inside the sandbox (mkdir/chmod). Return false on failure. */ + readonly execInSandbox: (sandboxName: string, argv: readonly string[]) => boolean; + /** Restart the in-sandbox gateway so it re-reads config + the new files. */ + readonly restartGateway: (sandboxName: string) => void; + readonly log?: (message: string) => void; + readonly warn?: (message: string) => void; + /** Test seam: write the secret to a host temp file, returning its path. */ + readonly writeTempFile?: (contents: string) => string; + readonly removeTempFile?: (path: string) => void; +} + +export interface SecretFileDeliveryResult { + readonly delivered: readonly string[]; + readonly skipped: readonly string[]; +} + +/** + * Deliver each secret file to the sandbox, then restart the gateway once if any + * file was written (so the channel can read it on the next boot). Missing + * secrets are skipped with a warning rather than aborting the run. + */ +export function deliverMessagingSecretFiles( + sandboxName: string, + targets: readonly MessagingSecretFileTarget[], + deps: SecretFileDeliveryDeps, +): SecretFileDeliveryResult { + const log = deps.log ?? (() => {}); + const warn = deps.warn ?? log; + const writeTempFile = deps.writeTempFile ?? defaultWriteTempFile; + const removeTempFile = deps.removeTempFile ?? defaultRemoveTempFile; + + const delivered: string[] = []; + const skipped: string[] = []; + let changed = false; + + for (const target of targets) { + const value = normalizeSecret(deps.readSecret(target.envKey)); + if (!value) { + warn( + ` ⚠ ${target.channelId}: secret ${target.envKey} is unavailable — skipped ${target.target}`, + ); + skipped.push(target.secretFileId); + continue; + } + const tempPath = writeTempFile(value); + try { + const dir = dirname(target.target); + deps.execInSandbox(sandboxName, [ + "sh", + "-c", + `mkdir -p ${shellQuote(dir)} && chmod 750 ${shellQuote(dir)}`, + ]); + if (!deps.uploadToSandbox(sandboxName, tempPath, target.target)) { + warn(` ⚠ ${target.channelId}: failed to upload secret file to ${target.target}`); + skipped.push(target.secretFileId); + continue; + } + deps.execInSandbox(sandboxName, ["chmod", target.mode, target.target]); + delivered.push(target.secretFileId); + changed = true; + log(` ✓ ${target.channelId}: delivered service account file to the sandbox`); + } finally { + removeTempFile(tempPath); + } + } + + if (changed) { + log(" Restarting in-sandbox gateway to load the delivered secret file(s)…"); + deps.restartGateway(sandboxName); + } + return { delivered, skipped }; +} + +function normalizeSecret(value: string | null | undefined): string { + return typeof value === "string" ? value.trim() : ""; +} + +// Single-quote for `sh -c`; embedded single quotes are closed/escaped/reopened. +function shellQuote(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'`; +} + +function defaultWriteTempFile(contents: string): string { + const dir = mkdtempSync(join(tmpdir(), "nemoclaw-secret-")); + const file = join(dir, "secret"); + writeFileSync(file, contents, { mode: 0o600 }); + return file; +} + +function defaultRemoveTempFile(path: string): void { + try { + rmSync(dirname(path), { recursive: true, force: true }); + } catch { + /* best-effort cleanup */ + } +} diff --git a/src/lib/messaging/channels/built-ins.ts b/src/lib/messaging/channels/built-ins.ts index aeb8439114..f79ea3f812 100644 --- a/src/lib/messaging/channels/built-ins.ts +++ b/src/lib/messaging/channels/built-ins.ts @@ -4,6 +4,7 @@ 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"; @@ -11,6 +12,7 @@ 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"; @@ -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..1d978d413f --- /dev/null +++ b/src/lib/messaging/channels/googlechat/hooks/tunnel-audience-gate.test.ts @@ -0,0 +1,170 @@ +// 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("skips (throws) in non-interactive mode without an explicit audience", async () => { + const hook = createGooglechatTunnelAudienceGateHook(baseOptions()); + await expect(hook(gateContext({}, false))).rejects.toThrow(/non-interactive/); + }); + + 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("honors a custom webhook path when deriving the audience", async () => { + let running = false; + const hook = createGooglechatTunnelAudienceGateHook( + baseOptions({ + readTunnelState: () => ({ running }), + startTunnel: async () => { + running = true; + }, + getTunnelUrl: () => "https://abc.trycloudflare.com", + prompt: async () => "y", + }), + ); + + const result = await hook(gateContext({ webhookPath: "/gchat" })); + + expect(result).toEqual({ + outputs: { audience: { kind: "config", value: "https://abc.trycloudflare.com/gchat" } }, + }); + }); + + 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..59ea0d8704 --- /dev/null +++ b/src/lib/messaging/channels/googlechat/hooks/tunnel-audience-gate.ts @@ -0,0 +1,164 @@ +// 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"; + +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 normalizeWebhookPath(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed || /\s/.test(trimmed)) return DEFAULT_WEBHOOK_PATH; + const withLeadingSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`; + const withoutTrailingSlash = withLeadingSlash.replace(/\/+$/, ""); + return withoutTrailingSlash || DEFAULT_WEBHOOK_PATH; +} + +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)); + const audienceType = readString(context.inputs?.audienceType) || "app-url"; + const webhookPath = normalizeWebhookPath( + readString(context.inputs?.webhookPath) || readString(env.GOOGLECHAT_WEBHOOK_PATH), + ); + + // 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 {}; + + // Non-interactive (CI/E2E): never spawn a tunnel; require an explicit audience. + if (context.isInteractive === false) { + log(" Skipped googlechat (set GOOGLECHAT_AUDIENCE to use Google Chat non-interactively)"); + throw new Error( + "Google Chat app-url audience requires GOOGLECHAT_AUDIENCE in non-interactive mode.", + ); + } + + 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 tunnel (nemoclaw tunnel start)…"); + 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(/\/+$/, "")}${webhookPath}`; + printEndpointInstructions(log, audience); + + // Non-interactive app-url already returned/threw above, 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.ts b/src/lib/messaging/channels/googlechat/hooks/tunnel-runtime.ts new file mode 100644 index 0000000000..16925f90c0 --- /dev/null +++ b/src/lib/messaging/channels/googlechat/hooks/tunnel-runtime.ts @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { execSync } from "node:child_process"; +import { DASHBOARD_PORT } from "../../../../core/ports"; +import { prompt } from "../../../../credentials/store"; +import { + getTunnelUrl as getServiceTunnelUrl, + readCloudflaredState, + resolveServicePidDir, + startAll, + stopCloudflared, +} from "../../../../tunnel/services"; +import type { GooglechatTunnelAudienceGateHookOptions } from "./tunnel-audience-gate"; + +// Side-effectful defaults for the tunnel/audience gate, kept out of the hook +// file itself. The gate composes these with the same service helpers +// `nemoclaw tunnel start/status/stop` use, so it targets the same tunnel. +export function createDefaultGooglechatTunnelGateOptions(): GooglechatTunnelAudienceGateHookOptions { + const dashboardPort = DASHBOARD_PORT; + return { + hasCloudflared: () => { + try { + execSync("command -v cloudflared", { stdio: ["ignore", "ignore", "ignore"] }); + return true; + } catch { + return false; + } + }, + readTunnelState: () => ({ + running: readCloudflaredState(resolveServicePidDir()).kind === "running", + }), + startTunnel: () => startAll(), + stopTunnel: () => { + stopCloudflared(); + }, + getTunnelUrl: () => getServiceTunnelUrl(resolveServicePidDir(), dashboardPort), + prompt: (question: string) => 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..6d10fb50eb --- /dev/null +++ b/src/lib/messaging/channels/googlechat/manifest.ts @@ -0,0 +1,251 @@ +// 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` — the same port `nemoclaw tunnel start` already +// exposes. So there is no `hostForward`; the tunnel/audience enroll hook derives +// the public webhook URL from the existing cloudflared tunnel instead. +export const googlechatManifest = { + schemaVersion: 1, + id: "googlechat", + displayName: "Google Chat", + description: "Google Chat (Chat API) bot messaging", + enrollmentNotes: [ + "Google Workspace accounts need no appPrincipal.", + "Personal/standalone Google accounts are served as Workspace add-ons and also need channels.googlechat.appPrincipal — the add-on's numeric OAuth client ID (~21 digits, not an email). Paste it at the appPrincipal prompt if you know it.", + "To capture appPrincipal: once the bot is live, send ONE direct message to it, then read the gateway log for `unexpected add-on principal: ` (set a placeholder appPrincipal first, or the log reports `missing add-on principal binding` with no number). Set GOOGLECHAT_APP_PRINCIPAL= (or paste it) and rebuild to persist.", + "The cloudflared tunnel exposes the whole dashboard port publicly; open the Control UI from http://127.0.0.1:18789 (localhost), not the public 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: "webhookPath", + kind: "config", + required: false, + envKey: "GOOGLECHAT_WEBHOOK_PATH", + statePath: "googlechatConfig.webhookPath", + defaultValue: "/googlechat", + }, + { + 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", + }, + }, + ], + // No outbound provider/placeholder for the service account: Google Chat signs + // its auth JWT with the private key IN-process (local signing), so the secret + // must reach the agent as a real file, not an `openshell:resolve:env:` outbound + // rewrite (which only materializes in outbound HTTP). Delivered via secretFiles. + credentials: [], + secretFiles: [ + { + id: "serviceAccountFile", + sourceInput: "serviceAccount", + agent: "openclaw", + target: "/sandbox/.openclaw/secrets/googlechat-service-account.json", + mode: "640", + }, + ], + 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, + serviceAccountFile: "/sandbox/.openclaw/secrets/googlechat-service-account.json", + audienceType: "{{googlechatConfig.audienceType}}", + audience: "{{googlechatConfig.audience}}", + appPrincipal: "{{googlechatConfig.appPrincipal}}", + webhookPath: "{{googlechatConfig.webhookPath}}", + 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, + }, + }, + }, + ], + runtime: { + openclaw: { + channelName: "googlechat", + visibility: { + configKeys: ["googlechat"], + logPatterns: ["googlechat"], + }, + 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, + required: true, + }, + ], + state: { + persist: { + googlechatConfig: ["audienceType", "audience", "appPrincipal", "webhookPath"], + allowedIds: ["allowFrom"], + }, + rebuildHydration: [ + { + statePath: "googlechatConfig.audienceType", + env: "GOOGLECHAT_AUDIENCE_TYPE", + }, + { + statePath: "googlechatConfig.audience", + env: "GOOGLECHAT_AUDIENCE", + }, + { + statePath: "googlechatConfig.appPrincipal", + env: "GOOGLECHAT_APP_PRINCIPAL", + }, + { + statePath: "googlechatConfig.webhookPath", + env: "GOOGLECHAT_WEBHOOK_PATH", + }, + { + statePath: "allowedIds.googlechat", + env: "GOOGLECHAT_ALLOWED_USERS", + }, + ], + }, + hooks: [ + { + id: "googlechat-tunnel-audience-gate", + phase: "enroll", + handler: "googlechat.tunnelAudienceGate", + inputs: ["audienceType", "audience", "webhookPath"], + 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/template-resolver.test.ts b/src/lib/messaging/channels/googlechat/template-resolver.test.ts new file mode 100644 index 0000000000..83dce2b99c --- /dev/null +++ b/src/lib/messaging/channels/googlechat/template-resolver.test.ts @@ -0,0 +1,85 @@ +// 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 and webhookPath when unset", () => { + const inputs: SandboxMessagingInputReference[] = []; + expect( + resolveGooglechatTemplateReference("googlechatConfig.audienceType", { inputs })?.value, + ).toBe("app-url"); + expect( + resolveGooglechatTemplateReference("googlechatConfig.webhookPath", { inputs })?.value, + ).toBe("/googlechat"); + }); + + it("passes through configured values and drops audience/appPrincipal when unset", () => { + const set: SandboxMessagingInputReference[] = [ + configInput("audience", "googlechatConfig.audience", "https://x.example/googlechat"), + configInput("appPrincipal", "googlechatConfig.appPrincipal", "103987852733692332624"), + configInput("audienceType", "googlechatConfig.audienceType", "project-number"), + configInput("webhookPath", "googlechatConfig.webhookPath", "/gchat"), + ]; + 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"); + expect( + resolveGooglechatTemplateReference("googlechatConfig.webhookPath", { inputs: set })?.value, + ).toBe("/gchat"); + + // Unset → undefined so the render engine drops the key entirely. + const empty: SandboxMessagingInputReference[] = []; + expect( + resolveGooglechatTemplateReference("googlechatConfig.audience", { inputs: empty })?.value, + ).toBeUndefined(); + expect( + resolveGooglechatTemplateReference("googlechatConfig.appPrincipal", { inputs: empty })?.value, + ).toBeUndefined(); + }); + + 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..32dde2f520 --- /dev/null +++ b/src/lib/messaging/channels/googlechat/template-resolver.ts @@ -0,0 +1,57 @@ +// 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"; +const DEFAULT_WEBHOOK_PATH = "/googlechat"; + +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")), + ); + case "googlechatConfig.webhookPath": + return resolvedRenderTemplateReference( + nonEmptyString(stateValue(context, "googlechatConfig.webhookPath")) ?? DEFAULT_WEBHOOK_PATH, + ); + 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 8d08c248b1..5f4d477b24 100644 --- a/src/lib/messaging/channels/manifests.test.ts +++ b/src/lib/messaging/channels/manifests.test.ts @@ -20,12 +20,14 @@ import { BUILT_IN_CHANNEL_MANIFESTS, createBuiltInChannelManifestRegistry, discordManifest, + googlechatManifest, slackManifest, teamsManifest, telegramManifest, wechatManifest, whatsappManifest, } from "./index"; +import { GOOGLECHAT_TUNNEL_AUDIENCE_GATE_HOOK_ID } from "./googlechat/hooks"; import { SLACK_SOCKET_MODE_GATEWAY_CONFLICT_HOOK_HANDLER_ID, SLACK_SOCKET_MODE_GATEWAY_STATUS_HOOK_HANDLER_ID, @@ -204,6 +206,7 @@ describe("built-in channel manifests", () => { "slack", "whatsapp", "teams", + "googlechat", ]); expect(registry.listAvailable({ agent: "hermes" }).map((manifest) => manifest.id)).toEqual([ "telegram", @@ -810,4 +813,107 @@ describe("built-in channel manifests", () => { ], }); }); + + it("declares Google Chat as an OpenClaw-only inbound-webhook channel", () => { + const serviceAccount = findInput(googlechatManifest, "serviceAccount"); + const audienceType = findInput(googlechatManifest, "audienceType"); + const audience = findInput(googlechatManifest, "audience"); + const appPrincipal = findInput(googlechatManifest, "appPrincipal"); + const webhookPath = findInput(googlechatManifest, "webhookPath"); + const allowFrom = findInput(googlechatManifest, "allowFrom"); + + expect(googlechatManifest.supportedAgents).toEqual(["openclaw"]); + expect(googlechatManifest.auth.mode).toBe("token-paste"); + expect("hostForward" in googlechatManifest).toBe(false); + + expect(serviceAccount).toMatchObject({ kind: "secret", envKey: "GOOGLECHAT_SERVICE_ACCOUNT" }); + expect(audienceType).toMatchObject({ + kind: "config", + envKey: "GOOGLECHAT_AUDIENCE_TYPE", + defaultValue: "app-url", + validValues: ["app-url", "project-number"], + }); + expect(audience).toMatchObject({ kind: "config", envKey: "GOOGLECHAT_AUDIENCE" }); + expect(appPrincipal).toMatchObject({ + kind: "config", + required: false, + envKey: "GOOGLECHAT_APP_PRINCIPAL", + }); + expect(webhookPath).toMatchObject({ kind: "config", defaultValue: "/googlechat" }); + expect(allowFrom).toMatchObject({ kind: "config", statePath: "allowedIds.googlechat" }); + + // Service account is consumed locally (JWT signing), so it is delivered as a + // file, not an outbound provider placeholder — no credential binding. + expect(googlechatManifest.credentials).toEqual([]); + expect(googlechatManifest.secretFiles).toEqual([ + { + id: "serviceAccountFile", + sourceInput: "serviceAccount", + agent: "openclaw", + target: "/sandbox/.openclaw/secrets/googlechat-service-account.json", + mode: "640", + }, + ]); + expect(policyPresetNames(googlechatManifest)).toEqual(["googlechat"]); + + const render = renderJson(googlechatManifest); + expect(render).toContain('"path":"channels.googlechat"'); + expect(render).toContain('"path":"plugins.entries.googlechat"'); + // Service account is delivered as an in-sandbox file, never inline or via an + // outbound placeholder (a private key cannot be signed with via outbound rewrite). + expect(render).toContain( + '"serviceAccountFile":"/sandbox/.openclaw/secrets/googlechat-service-account.json"', + ); + expect(render).not.toContain("openshell:resolve:env:GOOGLECHAT_SERVICE_ACCOUNT"); + expect(render).not.toContain("credential.googlechatServiceAccount"); + // DM allowlist must render to OpenClaw's nested dm.policy / dm.allowFrom shape. + expect(render).toContain("allowedIds.googlechat.dmPolicy"); + expect(render).toContain("allowedIds.googlechat.values"); + expect(render).toContain("googlechatConfig.appPrincipal"); + // OpenClaw-only: no Hermes env-lines / platform render. + expect(render).not.toContain("~/.hermes"); + + expect(findHook(googlechatManifest, "googlechat-tunnel-audience-gate")).toMatchObject({ + phase: "enroll", + handler: GOOGLECHAT_TUNNEL_AUDIENCE_GATE_HOOK_ID, + inputs: ["audienceType", "audience", "webhookPath"], + outputs: [{ id: "audience", kind: "config" }], + onFailure: "skip-channel", + }); + expect(findHook(googlechatManifest, "googlechat-service-account")).toMatchObject({ + phase: "enroll", + handler: COMMON_TOKEN_PASTE_HOOK_HANDLER_ID, + outputs: [{ id: "serviceAccount", kind: "secret", required: true }], + onFailure: "skip-channel", + }); + expectConfigPromptEnrollHook(googlechatManifest, ["audience", "appPrincipal", "allowFrom"]); + + // The tunnel/audience gate must run before the service-account paste, so a + // skip-channel gate never makes the operator paste JSON first. + const enrollHandlers = googlechatManifest.hooks + .filter((hook) => hook.phase === "enroll") + .map((hook) => hook.handler); + expect(enrollHandlers).toEqual([ + GOOGLECHAT_TUNNEL_AUDIENCE_GATE_HOOK_ID, + COMMON_TOKEN_PASTE_HOOK_HANDLER_ID, + COMMON_CONFIG_PROMPT_HOOK_HANDLER_ID, + ]); + + expectOpenClawRuntimeVisibility(googlechatManifest, ["googlechat"], ["googlechat"]); + expect(JSON.stringify(googlechatManifest.runtime?.openclaw?.secretScans)).toContain( + "BEGIN (?:RSA )?PRIVATE KEY", + ); + expect(googlechatManifest.agentPackages).toContainEqual({ + id: "openclawPluginPackage", + agent: "openclaw", + manager: "openclaw-plugin", + spec: "npm:@openclaw/googlechat@{{openclaw.version}}", + pin: true, + required: true, + }); + expect(googlechatManifest.state.persist).toEqual({ + googlechatConfig: ["audienceType", "audience", "appPrincipal", "webhookPath"], + allowedIds: ["allowFrom"], + }); + }); }); diff --git a/src/lib/messaging/channels/metadata.test.ts b/src/lib/messaging/channels/metadata.test.ts index ff25fe4c6e..1ff2459ad1 100644 --- a/src/lib/messaging/channels/metadata.test.ts +++ b/src/lib/messaging/channels/metadata.test.ts @@ -31,6 +31,7 @@ describe("built-in messaging channel metadata", () => { "slack", "whatsapp", "teams", + "googlechat", ]); expect(listAvailableMessagingChannelIds({ agent: "hermes" })).toEqual([ "telegram", @@ -64,7 +65,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 and aliases from manifest inputs", () => { @@ -87,6 +88,11 @@ describe("built-in messaging channel metadata", () => { "TEAMS_ALLOWED_USERS", "MSTEAMS_PORT", "TEAMS_REQUIRE_MENTION", + "GOOGLECHAT_AUDIENCE_TYPE", + "GOOGLECHAT_AUDIENCE", + "GOOGLECHAT_APP_PRINCIPAL", + "GOOGLECHAT_WEBHOOK_PATH", + "GOOGLECHAT_ALLOWED_USERS", ]); expect(getMessagingConfigEnvAliases()).toEqual({ DISCORD_SERVER_ID: ["DISCORD_SERVER_IDS"], @@ -126,6 +132,7 @@ describe("built-in messaging channel metadata", () => { "slack", "whatsapp", "msteams", + "googlechat", ]); expect( Object.fromEntries( diff --git a/src/lib/messaging/channels/template-resolver.ts b/src/lib/messaging/channels/template-resolver.ts index 11c1190b3b..80cd0fbdb7 100644 --- a/src/lib/messaging/channels/template-resolver.ts +++ b/src/lib/messaging/channels/template-resolver.ts @@ -2,6 +2,7 @@ // 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"; @@ -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/manifest/types.ts b/src/lib/messaging/manifest/types.ts index aa455658ee..1518ceff26 100644 --- a/src/lib/messaging/manifest/types.ts +++ b/src/lib/messaging/manifest/types.ts @@ -43,6 +43,14 @@ export interface ChannelManifest { readonly policyPresets?: readonly ChannelPolicyPresetReference[]; readonly render: readonly ChannelRenderSpec[]; readonly hostForward?: ChannelHostForwardSpec; + /** + * Secrets that must be delivered as a FILE inside the sandbox (consumed + * in-process by the agent, e.g. a Google service-account JSON the channel + * signs JWTs with) rather than as an outbound provider placeholder. The + * applier uploads the secret value to `target` post-create; the value is + * never written into the agent config or the image. + */ + readonly secretFiles?: readonly ChannelSecretFileSpec[]; readonly runtime?: ChannelRuntimeByAgentSpec; readonly agentPackages?: readonly ChannelAgentPackageSpec[]; readonly state: ChannelStateSpec; @@ -151,6 +159,24 @@ export interface ChannelHostForwardSpec { readonly when?: MessagingTemplateString; } +/** + * Declaration for a secret delivered into the sandbox as a file. Unlike a + * `ChannelCredentialSpec` (which becomes an outbound provider placeholder), the + * raw secret value is uploaded to `target` for the agent to read locally. Use + * only for secrets the agent must consume in-process (e.g. a private key used + * for local signing), never for outbound bearer tokens. + */ +export interface ChannelSecretFileSpec { + readonly id: string; + /** Secret input id whose captured value is delivered. */ + readonly sourceInput: string; + readonly agent: MessagingAgentId; + /** Absolute destination path inside the sandbox. */ + readonly target: MessagingTemplateString; + /** Octal file mode applied after upload (default "600"). */ + readonly mode?: string; +} + /** Agent-runtime metadata consumed by shared runtime setup and diagnostics. */ export interface ChannelRuntimeByAgentSpec extends Partial> { 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.ts b/src/lib/onboard.ts index 442b83c3ee..f182922be4 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -5278,6 +5278,15 @@ async function onboard(opts: OnboardOptions = {}): Promise { }, }); completed = true; + // Deliver in-process secret files (e.g. the Google Chat service account) into + // the sandbox and restart the gateway so channels that sign locally can read them. + ( + require("./onboard/messaging-secret-file-delivery") as typeof import("./onboard/messaging-secret-file-delivery") + ).deliverSandboxMessagingSecretFiles( + sandboxName, + liveFinalFlowContext.selectedMessagingChannels ?? [], + agent, + ); traceCompleted = true; } finally { releaseOnboardLock(); diff --git a/src/lib/onboard/messaging-secret-file-delivery.ts b/src/lib/onboard/messaging-secret-file-delivery.ts new file mode 100644 index 0000000000..bcd63600d6 --- /dev/null +++ b/src/lib/onboard/messaging-secret-file-delivery.ts @@ -0,0 +1,90 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { runOpenshell } from "../adapters/openshell/runtime"; +import { getCredential } from "../credentials/store"; +import { createBuiltInChannelManifestRegistry, tryGetMessagingAgentId } from "../messaging"; +import { + collectMessagingSecretFiles, + deliverMessagingSecretFiles, +} from "../messaging/applier/secret-file-delivery"; +import type { MessagingAgentId, SandboxMessagingPlan } from "../messaging/manifest"; +import { getActiveChannelsFromPlan } from "./messaging-plan-session"; + +interface MessagingAgentDescriptor { + readonly name?: string; +} + +function logDeliveryError(error: unknown): void { + console.error( + ` ⚠ Messaging secret-file delivery failed: ${ + error instanceof Error ? error.message : String(error) + }`, + ); +} + +function deliverForAgentId( + sandboxName: string, + activeChannelIds: readonly string[], + agentId: MessagingAgentId | null, +): void { + if (!agentId || activeChannelIds.length === 0) return; + const manifests = createBuiltInChannelManifestRegistry().list(); + const targets = collectMessagingSecretFiles(manifests, activeChannelIds, agentId); + if (targets.length === 0) return; + + deliverMessagingSecretFiles(sandboxName, targets, { + readSecret: (key) => process.env[key] ?? getCredential(key), + uploadToSandbox: (sandbox, localPath, target) => + runOpenshell(["sandbox", "upload", sandbox, localPath, target], { ignoreError: true }) + .status === 0, + execInSandbox: (sandbox, argv) => + runOpenshell(["sandbox", "exec", "--name", sandbox, "--", ...argv], { ignoreError: true }) + .status === 0, + restartGateway: (sandbox) => { + // Lazy require avoids pulling tunnel/services (and its process-level + // color/TTY probes) into this module's import graph at load time. + const { stopSandboxChannels } = require("../tunnel/services"); + stopSandboxChannels(sandbox); + }, + log: (message) => console.log(message), + warn: (message) => console.log(message), + }); +} + +/** + * Onboard entry: deliver in-process secret files (e.g. the Google Chat + * service-account JSON) for the selected agent, then restart the gateway. + * Best-effort — never throws, so it cannot fail onboarding. + */ +export function deliverSandboxMessagingSecretFiles( + sandboxName: string, + activeChannelIds: readonly string[], + agent: MessagingAgentDescriptor | null, +): void { + try { + const agentId = agent + ? tryGetMessagingAgentId(agent, createBuiltInChannelManifestRegistry().list()) + : "openclaw"; + deliverForAgentId(sandboxName, activeChannelIds, agentId); + } catch (error) { + logDeliveryError(error); + } +} + +/** + * Rebuild entry: derive the active channels + agent from the compiled plan and + * re-deliver secret files (the rebuilt image points at the file path, so it must + * be re-uploaded). Best-effort — never throws. + */ +export function deliverSandboxMessagingSecretFilesForPlan( + sandboxName: string, + plan: SandboxMessagingPlan | null | undefined, +): void { + try { + if (!plan) return; + deliverForAgentId(sandboxName, getActiveChannelsFromPlan(plan), plan.agent); + } catch (error) { + logDeliveryError(error); + } +} 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/tunnel/services.ts b/src/lib/tunnel/services.ts index 705db4ce19..ccafe606cc 100644 --- a/src/lib/tunnel/services.ts +++ b/src/lib/tunnel/services.ts @@ -622,6 +622,28 @@ export function stopAll(opts: ServiceOptions = {}): void { info("All services stopped."); } +/** + * Resolve the PID directory for host-side services without starting or stopping + * anything. Lets callers (e.g. the Google Chat tunnel/audience enroll gate) read + * cloudflared state and the tunnel URL via the same resolution `start`/`stop`/ + * `status` use, so they all target the same tunnel. + */ +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"); +} + export async function startAll(opts: ServiceOptions = {}): Promise { const pidDir = resolvePidDir(opts); const dashboardPort = opts.dashboardPort ?? DASHBOARD_PORT; From ddbb8c5e51cda2ad4274aaa25715baa7741448b6 Mon Sep 17 00:00:00 2001 From: Hung Le Date: Tue, 30 Jun 2026 10:18:51 +0530 Subject: [PATCH 02/50] feat(messaging): unblock Google Chat replies in the proxy-only sandbox --- .../policies/presets/googlechat.yaml | 19 ++- .../messaging/channels/googlechat/manifest.ts | 19 +++ .../runtime/googlechat-dns-resolve.ts | 140 ++++++++++++++++++ src/lib/messaging/channels/manifests.test.ts | 6 + 4 files changed, 180 insertions(+), 4 deletions(-) create mode 100644 src/lib/messaging/channels/googlechat/runtime/googlechat-dns-resolve.ts diff --git a/nemoclaw-blueprint/policies/presets/googlechat.yaml b/nemoclaw-blueprint/policies/presets/googlechat.yaml index ac426de400..baecb9949e 100644 --- a/nemoclaw-blueprint/policies/presets/googlechat.yaml +++ b/nemoclaw-blueprint/policies/presets/googlechat.yaml @@ -15,11 +15,22 @@ network_policies: 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: - - allow: { method: GET, path: "/**" } - - allow: { method: POST, path: "/**" } - - allow: { method: PUT, path: "/**" } - - allow: { method: DELETE, path: "/**" } + # 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/**" } # Service-account JWT -> OAuth access token exchange (google-auth-library). - host: oauth2.googleapis.com port: 443 diff --git a/src/lib/messaging/channels/googlechat/manifest.ts b/src/lib/messaging/channels/googlechat/manifest.ts index 6d10fb50eb..836b2c9261 100644 --- a/src/lib/messaging/channels/googlechat/manifest.ts +++ b/src/lib/messaging/channels/googlechat/manifest.ts @@ -152,6 +152,25 @@ export const googlechatManifest = { 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 cert + // verification does a LOCAL getaddrinfo first and fails with EAI_AGAIN, so + // the bot can never verify inbound JWTs. This boot preload answers ONLY the + // googleapis hosts with a public sentinel IP so the SSRF gate passes; the + // real connection is still made by the proxy by hostname. Remove once the + // upstream OpenClaw fix (trusted-env-proxy cert fetch, like web_fetch + // openclaw#50650) ships. See runtime/googlechat-dns-resolve.ts for details. + nodePreloads: [ + { + module: "googlechat-dns-resolve", + injectInto: ["boot"], + optional: false, + installMessage: + "[channels] Installing Google Chat DNS resolver shim (interim sandbox DNS workaround)", + installedMessage: + "[channels] Google Chat DNS resolver shim installed (NODE_OPTIONS updated)", + }, + ], secretScans: [ { path: "/sandbox/.openclaw/openclaw.json", diff --git a/src/lib/messaging/channels/googlechat/runtime/googlechat-dns-resolve.ts b/src/lib/messaging/channels/googlechat/runtime/googlechat-dns-resolve.ts new file mode 100644 index 0000000000..faaad4a356 --- /dev/null +++ b/src/lib/messaging/channels/googlechat/runtime/googlechat-dns-resolve.ts @@ -0,0 +1,140 @@ +// @ts-nocheck +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// googlechat-dns-resolve.ts — TEMPORARY interim workaround that lets the Google +// Chat channel verify inbound request JWTs inside the proxy-only NemoClaw +// sandbox. Boot-injected into the gateway via runtime.nodePreloads, gated to +// when the googlechat channel is active. +// +// ── The story: what breaks, and why ────────────────────────────────────────── +// NemoClaw runs the OpenClaw gateway inside an OpenShell sandbox network +// namespace that is intentionally DNS-LESS: it has only `lo` plus a veth to the +// L7 egress proxy (10.200.0.1). There is no eth0 and no reachable resolver — +// Docker's embedded resolver (127.0.0.11) lives in the *main* container netns, +// which the sandbox netns cannot reach (its 127/8 loopback is empty). By design +// every name lookup is the proxy's job: the gateway dials the proxy and the +// proxy resolves + policy-checks the hostname (this is why inference and +// web_fetch work). `scripts/nemoclaw-start.sh` even warns that forcing a direct +// in-sandbox DNS lookup is broken. +// +// Google Chat verifies each inbound webhook by fetching Google's signing certs +// (google-auth-library `getFederatedSignonCerts`). OpenClaw runs that fetch +// through `fetchWithSsrFGuard`, which — in its default STRICT / managed-proxy +// modes — performs a LOCAL `getaddrinfo(www.googleapis.com)` to SSRF-check the +// target BEFORE the proxied request. In this DNS-less netns that local lookup +// fails with `EAI_AGAIN`, so cert retrieval fails, every inbound JWT is +// rejected, and the bot silently never replies. +// +// ── What we tried / ruled out ───────────────────────────────────────────────── +// • Confirmed the secret-file delivery + webhook registration already work; +// only the cert-fetch DNS step fails. +// • Confirmed the proxied fetch itself works: a `fetch()` to +// https://www.googleapis.com/oauth2/v1/certs through the proxy returns 200, +// and the channel's network-policy preset already allows the host. So once +// resolution succeeds the cert fetch goes through the proxy by hostname. +// • Confirmed only OpenClaw's TRUSTED_ENV_PROXY guard mode skips the local +// lookup — that is a caller (OpenClaw) decision; no NemoClaw env/config knob +// flips it. +// • Rejected a UDP DNS forwarder / resolv.conf repoint: it would re-introduce +// general local DNS into a deliberately DNS-less sandbox — a DNS-exfil / +// policy-bypass egress regression. The entrypoint also runs as the +// unprivileged `sandbox` user here and cannot write /etc/hosts. +// +// ── What this preload does (narrow + isolation-preserving) ──────────────────── +// It patches Node's DNS `lookup` to answer ONLY the three Google Chat API hosts +// with a fixed public sentinel IP, so OpenClaw's SSRF guard sees a public +// address and proceeds. The address is never dialed: the real connection still +// goes through the L7 proxy BY HOSTNAME (proven), so this opens no new DNS or +// egress channel — every other hostname still resolves the normal way (i.e. +// still has no local DNS and still goes via the proxy). The sentinel only has to +// pass OpenClaw's "is this a public IP?" gate. +// +// ── Long-term fix (remove this once it lands) ───────────────────────────────── +// The correct fix is upstream in OpenClaw: make the Google Chat / google-auth +// cert fetch use the trusted-env-proxy guard mode (no local pre-resolution) when +// a managed/env proxy is configured — the same change OpenClaw already shipped +// for web_fetch (openclaw#50650). When that ships, delete this preload module +// and its `runtime.openclaw.nodePreloads` entry in the googlechat manifest. + +(function () { + "use strict"; + + // Any stable public IPv4 works: it is only used to pass OpenClaw's SSRF + // "public address" gate. The actual TLS connection is made by the L7 proxy + // using the hostname, so this address is never connected to. + var SENTINEL_PUBLIC_IPV4 = "142.250.190.78"; + + // Hosts the Google Chat channel resolves: cert verification + OAuth token + + // outbound message send. Keep in sync with the googlechat network-policy + // preset (nemoclaw-blueprint/policies/presets/googlechat.yaml). + var GOOGLECHAT_HOSTS = { + "www.googleapis.com": true, + "oauth2.googleapis.com": true, + "chat.googleapis.com": true, + }; + + function isGooglechatHost(hostname) { + return Object.prototype.hasOwnProperty.call(GOOGLECHAT_HOSTS, String(hostname || "").toLowerCase()); + } + + function patchCallbackLookup(mod) { + if (!mod || mod.__nemoclawGooglechatDnsPatched) return; + var orig = mod.lookup; + if (typeof orig === "function") { + mod.lookup = function (hostname, options, callback) { + var cb = typeof options === "function" ? options : callback; + var opts = typeof options === "function" ? {} : options || {}; + if (typeof cb === "function" && isGooglechatHost(hostname)) { + var record = { address: SENTINEL_PUBLIC_IPV4, family: 4 }; + if (opts && opts.all) { + process.nextTick(cb, null, [record]); + } else { + process.nextTick(cb, null, record.address, record.family); + } + return; + } + return orig.call(this, hostname, options, callback); + }; + } + try { + Object.defineProperty(mod, "__nemoclawGooglechatDnsPatched", { value: true }); + } catch (_e) { + mod.__nemoclawGooglechatDnsPatched = true; + } + } + + function patchPromiseLookup(mod) { + if (!mod || mod.__nemoclawGooglechatDnsPatched) return; + var orig = mod.lookup; + if (typeof orig === "function") { + mod.lookup = function (hostname, options) { + var opts = options || {}; + if (isGooglechatHost(hostname)) { + var record = { address: SENTINEL_PUBLIC_IPV4, family: 4 }; + return Promise.resolve(opts.all ? [record] : record); + } + return orig.call(this, hostname, options); + }; + } + try { + Object.defineProperty(mod, "__nemoclawGooglechatDnsPatched", { value: true }); + } catch (_e) { + mod.__nemoclawGooglechatDnsPatched = true; + } + } + + try { + var dns = require("node:dns"); + patchCallbackLookup(dns); + if (dns && dns.promises) patchPromiseLookup(dns.promises); + patchPromiseLookup(require("node:dns/promises")); + process.stderr.write( + "[channels] [googlechat] DNS resolver shim active for googleapis hosts " + + "(interim sandbox DNS workaround; resolution still proxied by hostname)\n", + ); + } catch (_e) { + // Never break gateway startup: if the shim cannot install, the channel + // simply remains in its pre-fix (cert-fetch EAI_AGAIN) state. + } +})(); diff --git a/src/lib/messaging/channels/manifests.test.ts b/src/lib/messaging/channels/manifests.test.ts index 5f4d477b24..c3ee68efdc 100644 --- a/src/lib/messaging/channels/manifests.test.ts +++ b/src/lib/messaging/channels/manifests.test.ts @@ -900,6 +900,12 @@ describe("built-in channel manifests", () => { ]); expectOpenClawRuntimeVisibility(googlechatManifest, ["googlechat"], ["googlechat"]); + expectOpenClawNodePreload(googlechatManifest, "googlechat-dns-resolve"); + expect( + googlechatManifest.runtime?.openclaw?.nodePreloads?.find( + (preload) => preload.module === "googlechat-dns-resolve", + )?.injectInto, + ).toEqual(["boot"]); expect(JSON.stringify(googlechatManifest.runtime?.openclaw?.secretScans)).toContain( "BEGIN (?:RSA )?PRIVATE KEY", ); From e4b71219a009b7602fb5665a4f67714e72029ec9 Mon Sep 17 00:00:00 2001 From: Hung Le Date: Wed, 1 Jul 2026 13:29:53 +0530 Subject: [PATCH 03/50] feat(messaging): gateway-mint Google Chat token, key out of sandbox --- .../provider-profiles/google-chat-bridge.yaml | 58 +++++ .../applier/secret-file-delivery.test.ts | 30 ++- .../messaging/channels/googlechat/manifest.ts | 50 ++-- .../runtime/googlechat-outbound-auth.ts | 206 +++++++++++++++++ src/lib/messaging/channels/manifests.test.ts | 28 +-- src/lib/onboard.ts | 13 ++ src/lib/onboard/googlechat-bridge-provider.ts | 214 ++++++++++++++++++ src/lib/onboard/messaging-prep.ts | 16 ++ 8 files changed, 584 insertions(+), 31 deletions(-) create mode 100644 nemoclaw-blueprint/provider-profiles/google-chat-bridge.yaml create mode 100644 src/lib/messaging/channels/googlechat/runtime/googlechat-outbound-auth.ts create mode 100644 src/lib/onboard/googlechat-bridge-provider.ts diff --git a/nemoclaw-blueprint/provider-profiles/google-chat-bridge.yaml b/nemoclaw-blueprint/provider-profiles/google-chat-bridge.yaml new file mode 100644 index 0000000000..5a7a51fa52 --- /dev/null +++ b/nemoclaw-blueprint/provider-profiles/google-chat-bridge.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 (client_email, private_key, scope) are supplied at +# onboard/rebuild time via `openshell provider refresh configure --material ...` +# (see src/lib/onboard/googlechat-bridge-provider.ts); the block below only +# declares their shape so the gateway knows which keys are required/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/applier/secret-file-delivery.test.ts b/src/lib/messaging/applier/secret-file-delivery.test.ts index 4ed3863639..469878ebf5 100644 --- a/src/lib/messaging/applier/secret-file-delivery.test.ts +++ b/src/lib/messaging/applier/secret-file-delivery.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vitest"; -import { googlechatManifest } from "../channels/built-ins"; +import type { ChannelManifest } from "../manifest"; import { collectMessagingSecretFiles, deliverMessagingSecretFiles, @@ -35,16 +35,36 @@ function makeDeps(overrides: Partial = {}): SecretFileDe }; } +// Synthetic manifest exercising the generic secret-file delivery contract. +// Google Chat itself no longer ships secretFiles (its service account is minted +// gateway-side and the key never enters the sandbox), so the delivery path is +// covered here with a fixture rather than a built-in manifest. +const FIXTURE_MANIFEST = { + id: "googlechat", + inputs: [ + { id: "serviceAccount", kind: "secret", required: true, envKey: "GOOGLECHAT_SERVICE_ACCOUNT" }, + ], + secretFiles: [ + { + id: "serviceAccountFile", + sourceInput: "serviceAccount", + agent: "openclaw", + target: SA_TARGET, + mode: "640", + }, + ], +} as unknown as ChannelManifest; + describe("collectMessagingSecretFiles", () => { - it("maps the googlechat service-account file from the manifest", () => { - expect(collectMessagingSecretFiles([googlechatManifest], ["googlechat"], "openclaw")).toEqual([ + it("maps a service-account file from a manifest's secretFiles", () => { + expect(collectMessagingSecretFiles([FIXTURE_MANIFEST], ["googlechat"], "openclaw")).toEqual([ GOOGLECHAT_TARGET, ]); }); it("skips inactive channels and non-matching agents", () => { - expect(collectMessagingSecretFiles([googlechatManifest], [], "openclaw")).toEqual([]); - expect(collectMessagingSecretFiles([googlechatManifest], ["googlechat"], "hermes")).toEqual([]); + expect(collectMessagingSecretFiles([FIXTURE_MANIFEST], [], "openclaw")).toEqual([]); + expect(collectMessagingSecretFiles([FIXTURE_MANIFEST], ["googlechat"], "hermes")).toEqual([]); }); }); diff --git a/src/lib/messaging/channels/googlechat/manifest.ts b/src/lib/messaging/channels/googlechat/manifest.ts index 836b2c9261..126a549e9b 100644 --- a/src/lib/messaging/channels/googlechat/manifest.ts +++ b/src/lib/messaging/channels/googlechat/manifest.ts @@ -92,20 +92,18 @@ export const googlechatManifest = { }, }, ], - // No outbound provider/placeholder for the service account: Google Chat signs - // its auth JWT with the private key IN-process (local signing), so the secret - // must reach the agent as a real file, not an `openshell:resolve:env:` outbound - // rewrite (which only materializes in outbound HTTP). Delivered via secretFiles. + // 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: [], - secretFiles: [ - { - id: "serviceAccountFile", - sourceInput: "serviceAccount", - agent: "openclaw", - target: "/sandbox/.openclaw/secrets/googlechat-service-account.json", - mode: "640", - }, - ], policyPresets: [{ name: "googlechat", policyKeys: ["googlechat"] }], render: [ { @@ -117,6 +115,13 @@ export const googlechatManifest = { path: "channels.googlechat", value: { enabled: true, + // Configured-marker ONLY — the file is never delivered (no secretFiles) + // and never read. OpenClaw's channel-start gate requires a serviceAccount* + // (isConfigured: credentialSource !== "none") to start the webhook, but the + // actual token is gateway-minted and proxy-injected, and the + // googlechat-outbound-auth preload short-circuits the token producer before + // this path is read — so the SA key never enters the sandbox. (Clean fix is + // upstream: a non-SA "configured"/accessToken credential source in @openclaw/googlechat.) serviceAccountFile: "/sandbox/.openclaw/secrets/googlechat-service-account.json", audienceType: "{{googlechatConfig.audienceType}}", audience: "{{googlechatConfig.audience}}", @@ -160,6 +165,16 @@ export const googlechatManifest = { // real connection is still made by the proxy by hostname. Remove once the // upstream OpenClaw fix (trusted-env-proxy cert fetch, like web_fetch // openclaw#50650) ships. See runtime/googlechat-dns-resolve.ts for details. + // + // 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. Inert until the B-side provider is wired (falls back to the + // in-process mint when the env var is unset). See + // runtime/googlechat-outbound-auth.ts. nodePreloads: [ { module: "googlechat-dns-resolve", @@ -170,6 +185,15 @@ export const googlechatManifest = { installedMessage: "[channels] Google Chat DNS resolver shim 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: [ { 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..9ab05f8207 --- /dev/null +++ b/src/lib/messaging/channels/googlechat/runtime/googlechat-outbound-auth.ts @@ -0,0 +1,206 @@ +// @ts-nocheck +// 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. +// +// ── 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 (delivered today as a file), 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 (it still relies on the separate googlechat-dns-resolve shim). +// +// ── 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 (provider not wired) the patch falls through to the original in-process mint, +// so the channel keeps working on the legacy SA-file path — inert until the B-side lands. +// +// ── 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). + +(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"; + + if (process.__nemoclawGooglechatOutboundAuthInstalled) return; + try { + Object.defineProperty(process, "__nemoclawGooglechatOutboundAuthInstalled", { value: true }); + } catch (_e) { + process.__nemoclawGooglechatOutboundAuthInstalled = true; + } + + function isOpenClawGooglechatFile(filename) { + var normalized = String(filename || "").replace(/\\/g, "/"); + return normalized.indexOf("/@openclaw/googlechat/") !== -1 && normalized.endsWith(".js"); + } + + // 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). Falls back to the raw value for non-OpenShell deployments. Built as + // a single line (no template-literal escaping) for a clean source rewrite. + function buildBearerShortCircuitSource() { + var canonical = "openshell:resolve:env:" + ENV_VAR; + return ( + "try { 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; } } catch (_e) {} /* " + + CALL_MARKER + + " */" + ); + } + + function patchGooglechatOutboundAuthSource(source, filename) { + // 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 + // leave outbound auth on the in-process SA path. + 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 isGooglechatOutboundAuthPatchError(reason) { + var msg = String((reason && reason.message) || reason || ""); + return ( + msg.indexOf("OpenClaw Google Chat getGoogleChatAccessToken") !== -1 && + msg.indexOf("shape not recognized") !== -1 + ); + } + + function fileNameFromModuleUrl(urlValue) { + if (typeof urlValue !== "string" || !urlValue.startsWith("file:")) return ""; + try { + return require("url").fileURLToPath(urlValue); + } catch (_e) { + return ""; + } + } + + function sourceToText(source) { + 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; + } + + function installGooglechatOutboundAuthPatch() { + var Module = require("module"); + var fs = require("fs"); + var originalJsLoader = Module._extensions && Module._extensions[".js"]; + if (typeof originalJsLoader === "function") { + Module._extensions[".js"] = function nemoclawGooglechatJsLoader(mod, filename) { + if (isOpenClawGooglechatFile(filename)) { + var source = fs.readFileSync(filename, "utf8"); + var patched = patchGooglechatOutboundAuthSource(source, filename); + if (patched !== source) { + return mod._compile(patched, filename); + } + } + return originalJsLoader.apply(this, arguments); + }; + } + + if (typeof Module.registerHooks === "function") { + Module.registerHooks({ + load: function nemoclawGooglechatLoadHook(urlValue, context, nextLoad) { + 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 }); + }, + }); + } + } + + try { + installGooglechatOutboundAuthPatch(); + process.stderr.write( + "[channels] [googlechat] outbound-auth patch active " + + "(gateway-minted bearer via " + + ENV_VAR + + " when wired; falls back to in-process mint otherwise)\n", + ); + } catch (e) { + if (isGooglechatOutboundAuthPatchError(e)) { + // Shape drift: surface loudly but do not crash gateway startup — the + // channel degrades to the legacy in-process SA mint. + process.stderr.write( + "[channels] [googlechat] outbound-auth patch NOT applied: " + String(e && e.message) + "\n", + ); + } + // Any other failure: never break gateway boot. + } +})(); diff --git a/src/lib/messaging/channels/manifests.test.ts b/src/lib/messaging/channels/manifests.test.ts index 7150dae08b..5dcceb293a 100644 --- a/src/lib/messaging/channels/manifests.test.ts +++ b/src/lib/messaging/channels/manifests.test.ts @@ -843,25 +843,21 @@ describe("built-in channel manifests", () => { expect(webhookPath).toMatchObject({ kind: "config", defaultValue: "/googlechat" }); expect(allowFrom).toMatchObject({ kind: "config", statePath: "allowedIds.googlechat" }); - // Service account is consumed locally (JWT signing), so it is delivered as a - // file, not an outbound provider placeholder — no credential binding. + // Outbound auth is gateway-minted (google-service-account-jwt bridge provider) + // and the L7 proxy injects the bearer; the SA private key stays gateway-side and + // is never delivered into the sandbox — so no credential binding and no secretFiles. expect(googlechatManifest.credentials).toEqual([]); - expect(googlechatManifest.secretFiles).toEqual([ - { - id: "serviceAccountFile", - sourceInput: "serviceAccount", - agent: "openclaw", - target: "/sandbox/.openclaw/secrets/googlechat-service-account.json", - mode: "640", - }, - ]); + expect((googlechatManifest as ChannelManifest).secretFiles).toBeUndefined(); expect(policyPresetNames(googlechatManifest)).toEqual(["googlechat"]); const render = renderJson(googlechatManifest); expect(render).toContain('"path":"channels.googlechat"'); expect(render).toContain('"path":"plugins.entries.googlechat"'); - // Service account is delivered as an in-sandbox file, never inline or via an - // outbound placeholder (a private key cannot be signed with via outbound rewrite). + // serviceAccountFile is a start-gate marker only (OpenClaw requires a + // serviceAccount* to start the channel); the file is never delivered (no + // secretFiles) and never read (the outbound-auth preload short-circuits the + // token producer), so the SA key never enters the sandbox. No inline JSON and + // no outbound SA placeholder. expect(render).toContain( '"serviceAccountFile":"/sandbox/.openclaw/secrets/googlechat-service-account.json"', ); @@ -907,6 +903,12 @@ describe("built-in channel manifests", () => { (preload) => preload.module === "googlechat-dns-resolve", )?.injectInto, ).toEqual(["boot"]); + expectOpenClawNodePreload(googlechatManifest, "googlechat-outbound-auth"); + expect( + googlechatManifest.runtime?.openclaw?.nodePreloads?.find( + (preload) => preload.module === "googlechat-outbound-auth", + )?.injectInto, + ).toEqual(["boot"]); expect(JSON.stringify(googlechatManifest.runtime?.openclaw?.secretScans)).toContain( "BEGIN (?:RSA )?PRIVATE KEY", ); diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index f182922be4..d28b78b1ea 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -163,6 +163,7 @@ const ANSI_RE = /\x1B(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1B\\)|[@-_])/g; const runner: typeof import("./runner") = require("./runner"); const { ROOT, SCRIPTS, redact, run, runCapture, runFile, validateName } = runner; const braveProviderProfile: typeof import("./onboard/brave-provider-profile") = require("./onboard/brave-provider-profile"); +const googlechatBridgeProvider: typeof import("./onboard/googlechat-bridge-provider") = require("./onboard/googlechat-bridge-provider"); const { runSandboxProviderPreDeleteCleanup } = require("./onboard/sandbox-provider-cleanup") as typeof import("./onboard/sandbox-provider-cleanup"); const nameValidation: typeof import("./name-validation") = require("./name-validation"); @@ -947,7 +948,19 @@ function upsertMessagingProviders( options: { replaceExisting?: boolean } = {}, ) { braveProviderProfile.ensureBraveProviderProfile(tokenDefs, { root: ROOT, runOpenshell, redact }); + googlechatBridgeProvider.ensureGooglechatBridgeProfile(tokenDefs, { + root: ROOT, + runOpenshell, + redact, + }); const upserted = onboardProviders.upsertMessagingProviders(tokenDefs, runOpenshell, options); + // Gateway-side token minting must be configured AFTER the provider exists. + // Supplies the service-account material (key stays gateway-side); best-effort. + googlechatBridgeProvider.configureGooglechatBridgeRefresh(tokenDefs, { + runOpenshell, + redact, + getCredential, + }); // upsertMessagingProviders process.exits on failure, so reaching this // point means every entry in tokenDefs that had a token was registered. // Mark migrated only when the registered token equals the staged legacy diff --git a/src/lib/onboard/googlechat-bridge-provider.ts b/src/lib/onboard/googlechat-bridge-provider.ts new file mode 100644 index 0000000000..bba40501a3 --- /dev/null +++ b/src/lib/onboard/googlechat-bridge-provider.ts @@ -0,0 +1,214 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import path from "node:path"; + +import { compactText } from "../core/url-utils"; + +// Profile id registered with OpenShell (nemoclaw-blueprint/provider-profiles/ +// google-chat-bridge.yaml) and passed as `provider create --type`. +export const GOOGLECHAT_BRIDGE_PROFILE_ID = "google-chat-bridge"; + +// Injectable credential key the gateway mints + the L7 proxy injects as +// `Authorization: Bearer` on chat.googleapis.com. MUST match the env var the +// googlechat-outbound-auth runtime preload reads. +export const GOOGLECHAT_BRIDGE_CREDENTIAL_KEY = "GOOGLE_CHAT_ACCESS_TOKEN"; + +// Where the pasted service-account JSON is stored (the tokenPaste enroll hook +// saves it under this env key). Used only as gateway-side refresh MATERIAL — +// it is never delivered into the sandbox. +export const GOOGLECHAT_SERVICE_ACCOUNT_ENV = "GOOGLECHAT_SERVICE_ACCOUNT"; + +// Scope the bot token is minted for (Google Chat bot scope). +export const GOOGLECHAT_BRIDGE_SCOPE = "https://www.googleapis.com/auth/chat.bot"; + +// Sentinel credential value used at `provider create`. 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 GOOGLECHAT_BRIDGE_PENDING_VALUE = "openshell-managed-pending-mint"; + +const GOOGLECHAT_CHANNEL = "googlechat"; + +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 }; + +export type GooglechatBridgeProfileDeps = { + root: string; + runOpenshell: RunOpenshell; + redact: (input: string) => string; + log?: (message?: string) => void; + exit?: (code?: number) => never; +}; + +export type GooglechatBridgeRefreshDeps = { + runOpenshell: RunOpenshell; + redact: (input: string) => string; + getCredential: (envKey: string) => string | null; + log?: (message?: string) => void; +}; + +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 ""; +} + +export function googlechatBridgeProfilePath(root: string): string { + return path.join(root, "nemoclaw-blueprint", "provider-profiles", "google-chat-bridge.yaml"); +} + +/** + * Build the messaging token definition for the Google Chat outbound-auth bridge + * provider, or null when it does not apply. + * + * Unlike a normal channel credential the value is NOT pasted — it is minted + * gateway-side — so the token is a non-empty sentinel (overwritten by the first + * refresh) and the real service-account material is supplied separately via + * {@link configureGooglechatBridgeRefresh}. Emitted only when the Google Chat + * service account was captured and the channel is enabled (mirrors how the Brave + * provider is pushed in messaging-prep). + */ +export function maybeGooglechatBridgeTokenDef(input: { + sandboxName: string; + getCredential: (envKey: string) => string | null; + enabledChannels: readonly string[] | null; + disabledChannelNames: ReadonlySet; +}): { name: string; envKey: string; token: string; providerType: string } | null { + if (input.disabledChannelNames.has(GOOGLECHAT_CHANNEL)) return null; + if (input.enabledChannels != null && !input.enabledChannels.includes(GOOGLECHAT_CHANNEL)) { + return null; + } + const serviceAccount = input.getCredential(GOOGLECHAT_SERVICE_ACCOUNT_ENV); + if (!serviceAccount) return null; + return { + name: `${input.sandboxName}-googlechat-bridge`, + envKey: GOOGLECHAT_BRIDGE_CREDENTIAL_KEY, + token: GOOGLECHAT_BRIDGE_PENDING_VALUE, + providerType: GOOGLECHAT_BRIDGE_PROFILE_ID, + }; +} + +/** + * Register the Google Chat bridge provider profile with OpenShell so providers + * created with `--type google-chat-bridge` drive the L7 proxy's outbound bearer + * injection. Skipped unless a bridge-typed token definition is present. + * Idempotent: tolerates OpenShell reporting the custom profile already exists. + */ +export function ensureGooglechatBridgeProfile( + tokenDefs: readonly TokenDefShape[], + deps: GooglechatBridgeProfileDeps, +): void { + const needs = tokenDefs.some( + ({ providerType, token }) => providerType === GOOGLECHAT_BRIDGE_PROFILE_ID && Boolean(token), + ); + if (!needs) return; + + const errorLog = deps.log ?? console.error; + const exit = deps.exit ?? ((code?: number) => process.exit(code)); + + const result = deps.runOpenshell( + ["provider", "profile", "import", "--file", googlechatBridgeProfilePath(deps.root)], + { ignoreError: true, stdio: ["ignore", "pipe", "pipe"] }, + ); + if (result.status === 0) return; + + const rawDiagnostic = `${bufferOrStringToText(result.stderr)} ${bufferOrStringToText(result.stdout)}`; + if (/already exists/i.test(rawDiagnostic)) return; + + const diagnostic = compactText(deps.redact(rawDiagnostic)); + errorLog("\n ✗ Failed to register the Google Chat 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); +} + +/** + * Configure gateway-side credential refresh for the Google Chat bridge provider: + * the gateway mints (and rotates) the bot access token from the service-account + * key via the google_service_account_jwt strategy. Must run AFTER the provider + * is created. The service-account private key is passed as refresh material and + * stays gateway-side — it is never written into the sandbox. + * + * Best-effort: a failure here means outbound replies will not authenticate + * until fixed, but it must not abort onboarding of other channels. Inbound + * webhook verification is unaffected. The private key is never logged. + */ +export function configureGooglechatBridgeRefresh( + tokenDefs: readonly TokenDefShape[], + deps: GooglechatBridgeRefreshDeps, +): void { + const bridge = tokenDefs.find( + ({ providerType, token }) => providerType === GOOGLECHAT_BRIDGE_PROFILE_ID && Boolean(token), + ); + if (!bridge) return; + + const warn = deps.log ?? console.error; + const serviceAccount = deps.getCredential(GOOGLECHAT_SERVICE_ACCOUNT_ENV); + if (!serviceAccount) { + warn( + "\n ✗ Google Chat bridge: service account JSON unavailable; cannot configure gateway token minting.", + ); + return; + } + + let clientEmail: unknown; + let privateKey: unknown; + try { + const parsed = JSON.parse(serviceAccount) as Record; + clientEmail = parsed.client_email; + privateKey = parsed.private_key; + } catch { + warn( + "\n ✗ Google Chat bridge: service account JSON could not be parsed; cannot configure gateway token minting.", + ); + return; + } + if (typeof clientEmail !== "string" || !clientEmail || typeof privateKey !== "string" || !privateKey) { + warn( + "\n ✗ Google Chat bridge: service account JSON missing client_email/private_key; cannot configure gateway token minting.", + ); + return; + } + + const result = deps.runOpenshell( + [ + "provider", + "refresh", + "configure", + "--credential-key", + GOOGLECHAT_BRIDGE_CREDENTIAL_KEY, + "--strategy", + "google-service-account-jwt", + "--material", + `client_email=${clientEmail}`, + "--material", + `private_key=${privateKey}`, + "--material", + `scope=${GOOGLECHAT_BRIDGE_SCOPE}`, + "--secret-material-key", + "private_key", + bridge.name, + ], + { ignoreError: true, stdio: ["ignore", "pipe", "pipe"] }, + ); + if (result.status === 0) return; + + // Redact before logging — never echo the private key material. + const diagnostic = compactText( + deps.redact(`${bufferOrStringToText(result.stderr)} ${bufferOrStringToText(result.stdout)}`), + ); + warn( + `\n ✗ Google Chat bridge: failed to configure gateway token minting for '${bridge.name}'.`, + ); + if (diagnostic) warn(` ${diagnostic.slice(0, 500)}`); + warn(" Outbound Google Chat replies will not authenticate until this is resolved."); +} diff --git a/src/lib/onboard/messaging-prep.ts b/src/lib/onboard/messaging-prep.ts index 1c59fd84e8..f318e4787e 100644 --- a/src/lib/onboard/messaging-prep.ts +++ b/src/lib/onboard/messaging-prep.ts @@ -6,6 +6,7 @@ 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 * as googlechatBridge from "./googlechat-bridge-provider"; export type NamedMessagingChannel = { name: string } & ChannelDef; @@ -104,6 +105,21 @@ export function prepareCreateSandboxMessaging( }); } + // Google Chat outbound-auth bridge: when the service account was captured and + // the channel is enabled, register a refresh-minted provider so the gateway + // mints the bot token (key stays gateway-side) and the L7 proxy injects it on + // chat.googleapis.com. The credential value is a sentinel (minted by refresh, + // configured post-create in onboard's upsertMessagingProviders wrapper). + const googlechatBridgeTokenDef = googlechatBridge.maybeGooglechatBridgeTokenDef({ + sandboxName: input.sandboxName, + getCredential: input.getCredential, + enabledChannels: input.enabledChannels, + disabledChannelNames, + }); + if (googlechatBridgeTokenDef) { + messagingTokenDefs.push(googlechatBridgeTokenDef); + } + const extraPlaceholderKeys = input.registerExtraPlaceholderProviders( input.sandboxName, messagingTokenDefs, From bd4f48417f8a05fb969a25d19718ea8507f67fa8 Mon Sep 17 00:00:00 2001 From: Hung Le Date: Wed, 1 Jul 2026 17:44:45 +0530 Subject: [PATCH 04/50] feat(messaging): route Google Chat googleapis fetches via trusted env proxy --- .../messaging/channels/googlechat/manifest.ts | 51 +--- .../runtime/googlechat-dns-resolve.ts | 140 ----------- .../runtime/googlechat-outbound-auth.ts | 11 +- .../runtime/googlechat-trusted-proxy-fetch.ts | 227 ++++++++++++++++++ src/lib/messaging/channels/manifests.test.ts | 10 +- 5 files changed, 249 insertions(+), 190 deletions(-) delete mode 100644 src/lib/messaging/channels/googlechat/runtime/googlechat-dns-resolve.ts create mode 100644 src/lib/messaging/channels/googlechat/runtime/googlechat-trusted-proxy-fetch.ts diff --git a/src/lib/messaging/channels/googlechat/manifest.ts b/src/lib/messaging/channels/googlechat/manifest.ts index 126a549e9b..3c0296c752 100644 --- a/src/lib/messaging/channels/googlechat/manifest.ts +++ b/src/lib/messaging/channels/googlechat/manifest.ts @@ -158,13 +158,16 @@ export const googlechatManifest = { logPatterns: ["googlechat"], }, // Interim sandbox-DNS workaround: the sandbox netns is DNS-less (all - // resolution goes through the L7 proxy), but OpenClaw's Google Chat cert - // verification does a LOCAL getaddrinfo first and fails with EAI_AGAIN, so - // the bot can never verify inbound JWTs. This boot preload answers ONLY the - // googleapis hosts with a public sentinel IP so the SSRF gate passes; the - // real connection is still made by the proxy by hostname. Remove once the - // upstream OpenClaw fix (trusted-env-proxy cert fetch, like web_fetch - // openclaw#50650) ships. See runtime/googlechat-dns-resolve.ts for details. + // 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 @@ -177,13 +180,13 @@ export const googlechatManifest = { // runtime/googlechat-outbound-auth.ts. nodePreloads: [ { - module: "googlechat-dns-resolve", + module: "googlechat-trusted-proxy-fetch", injectInto: ["boot"], optional: false, installMessage: - "[channels] Installing Google Chat DNS resolver shim (interim sandbox DNS workaround)", + "[channels] Installing Google Chat trusted-proxy-fetch patch (route googleapis via trusted env proxy)", installedMessage: - "[channels] Google Chat DNS resolver shim installed (NODE_OPTIONS updated)", + "[channels] Google Chat trusted-proxy-fetch patch installed (NODE_OPTIONS updated)", }, { module: "googlechat-outbound-auth", @@ -216,34 +219,6 @@ export const googlechatManifest = { required: true, }, ], - state: { - persist: { - googlechatConfig: ["audienceType", "audience", "appPrincipal", "webhookPath"], - allowedIds: ["allowFrom"], - }, - rebuildHydration: [ - { - statePath: "googlechatConfig.audienceType", - env: "GOOGLECHAT_AUDIENCE_TYPE", - }, - { - statePath: "googlechatConfig.audience", - env: "GOOGLECHAT_AUDIENCE", - }, - { - statePath: "googlechatConfig.appPrincipal", - env: "GOOGLECHAT_APP_PRINCIPAL", - }, - { - statePath: "googlechatConfig.webhookPath", - env: "GOOGLECHAT_WEBHOOK_PATH", - }, - { - statePath: "allowedIds.googlechat", - env: "GOOGLECHAT_ALLOWED_USERS", - }, - ], - }, hooks: [ { id: "googlechat-tunnel-audience-gate", diff --git a/src/lib/messaging/channels/googlechat/runtime/googlechat-dns-resolve.ts b/src/lib/messaging/channels/googlechat/runtime/googlechat-dns-resolve.ts deleted file mode 100644 index faaad4a356..0000000000 --- a/src/lib/messaging/channels/googlechat/runtime/googlechat-dns-resolve.ts +++ /dev/null @@ -1,140 +0,0 @@ -// @ts-nocheck -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 -// -// googlechat-dns-resolve.ts — TEMPORARY interim workaround that lets the Google -// Chat channel verify inbound request JWTs inside the proxy-only NemoClaw -// sandbox. Boot-injected into the gateway via runtime.nodePreloads, gated to -// when the googlechat channel is active. -// -// ── The story: what breaks, and why ────────────────────────────────────────── -// NemoClaw runs the OpenClaw gateway inside an OpenShell sandbox network -// namespace that is intentionally DNS-LESS: it has only `lo` plus a veth to the -// L7 egress proxy (10.200.0.1). There is no eth0 and no reachable resolver — -// Docker's embedded resolver (127.0.0.11) lives in the *main* container netns, -// which the sandbox netns cannot reach (its 127/8 loopback is empty). By design -// every name lookup is the proxy's job: the gateway dials the proxy and the -// proxy resolves + policy-checks the hostname (this is why inference and -// web_fetch work). `scripts/nemoclaw-start.sh` even warns that forcing a direct -// in-sandbox DNS lookup is broken. -// -// Google Chat verifies each inbound webhook by fetching Google's signing certs -// (google-auth-library `getFederatedSignonCerts`). OpenClaw runs that fetch -// through `fetchWithSsrFGuard`, which — in its default STRICT / managed-proxy -// modes — performs a LOCAL `getaddrinfo(www.googleapis.com)` to SSRF-check the -// target BEFORE the proxied request. In this DNS-less netns that local lookup -// fails with `EAI_AGAIN`, so cert retrieval fails, every inbound JWT is -// rejected, and the bot silently never replies. -// -// ── What we tried / ruled out ───────────────────────────────────────────────── -// • Confirmed the secret-file delivery + webhook registration already work; -// only the cert-fetch DNS step fails. -// • Confirmed the proxied fetch itself works: a `fetch()` to -// https://www.googleapis.com/oauth2/v1/certs through the proxy returns 200, -// and the channel's network-policy preset already allows the host. So once -// resolution succeeds the cert fetch goes through the proxy by hostname. -// • Confirmed only OpenClaw's TRUSTED_ENV_PROXY guard mode skips the local -// lookup — that is a caller (OpenClaw) decision; no NemoClaw env/config knob -// flips it. -// • Rejected a UDP DNS forwarder / resolv.conf repoint: it would re-introduce -// general local DNS into a deliberately DNS-less sandbox — a DNS-exfil / -// policy-bypass egress regression. The entrypoint also runs as the -// unprivileged `sandbox` user here and cannot write /etc/hosts. -// -// ── What this preload does (narrow + isolation-preserving) ──────────────────── -// It patches Node's DNS `lookup` to answer ONLY the three Google Chat API hosts -// with a fixed public sentinel IP, so OpenClaw's SSRF guard sees a public -// address and proceeds. The address is never dialed: the real connection still -// goes through the L7 proxy BY HOSTNAME (proven), so this opens no new DNS or -// egress channel — every other hostname still resolves the normal way (i.e. -// still has no local DNS and still goes via the proxy). The sentinel only has to -// pass OpenClaw's "is this a public IP?" gate. -// -// ── Long-term fix (remove this once it lands) ───────────────────────────────── -// The correct fix is upstream in OpenClaw: make the Google Chat / google-auth -// cert fetch use the trusted-env-proxy guard mode (no local pre-resolution) when -// a managed/env proxy is configured — the same change OpenClaw already shipped -// for web_fetch (openclaw#50650). When that ships, delete this preload module -// and its `runtime.openclaw.nodePreloads` entry in the googlechat manifest. - -(function () { - "use strict"; - - // Any stable public IPv4 works: it is only used to pass OpenClaw's SSRF - // "public address" gate. The actual TLS connection is made by the L7 proxy - // using the hostname, so this address is never connected to. - var SENTINEL_PUBLIC_IPV4 = "142.250.190.78"; - - // Hosts the Google Chat channel resolves: cert verification + OAuth token + - // outbound message send. Keep in sync with the googlechat network-policy - // preset (nemoclaw-blueprint/policies/presets/googlechat.yaml). - var GOOGLECHAT_HOSTS = { - "www.googleapis.com": true, - "oauth2.googleapis.com": true, - "chat.googleapis.com": true, - }; - - function isGooglechatHost(hostname) { - return Object.prototype.hasOwnProperty.call(GOOGLECHAT_HOSTS, String(hostname || "").toLowerCase()); - } - - function patchCallbackLookup(mod) { - if (!mod || mod.__nemoclawGooglechatDnsPatched) return; - var orig = mod.lookup; - if (typeof orig === "function") { - mod.lookup = function (hostname, options, callback) { - var cb = typeof options === "function" ? options : callback; - var opts = typeof options === "function" ? {} : options || {}; - if (typeof cb === "function" && isGooglechatHost(hostname)) { - var record = { address: SENTINEL_PUBLIC_IPV4, family: 4 }; - if (opts && opts.all) { - process.nextTick(cb, null, [record]); - } else { - process.nextTick(cb, null, record.address, record.family); - } - return; - } - return orig.call(this, hostname, options, callback); - }; - } - try { - Object.defineProperty(mod, "__nemoclawGooglechatDnsPatched", { value: true }); - } catch (_e) { - mod.__nemoclawGooglechatDnsPatched = true; - } - } - - function patchPromiseLookup(mod) { - if (!mod || mod.__nemoclawGooglechatDnsPatched) return; - var orig = mod.lookup; - if (typeof orig === "function") { - mod.lookup = function (hostname, options) { - var opts = options || {}; - if (isGooglechatHost(hostname)) { - var record = { address: SENTINEL_PUBLIC_IPV4, family: 4 }; - return Promise.resolve(opts.all ? [record] : record); - } - return orig.call(this, hostname, options); - }; - } - try { - Object.defineProperty(mod, "__nemoclawGooglechatDnsPatched", { value: true }); - } catch (_e) { - mod.__nemoclawGooglechatDnsPatched = true; - } - } - - try { - var dns = require("node:dns"); - patchCallbackLookup(dns); - if (dns && dns.promises) patchPromiseLookup(dns.promises); - patchPromiseLookup(require("node:dns/promises")); - process.stderr.write( - "[channels] [googlechat] DNS resolver shim active for googleapis hosts " + - "(interim sandbox DNS workaround; resolution still proxied by hostname)\n", - ); - } catch (_e) { - // Never break gateway startup: if the shim cannot install, the channel - // simply remains in its pre-fix (cert-fetch EAI_AGAIN) state. - } -})(); diff --git a/src/lib/messaging/channels/googlechat/runtime/googlechat-outbound-auth.ts b/src/lib/messaging/channels/googlechat/runtime/googlechat-outbound-auth.ts index 9ab05f8207..0e9770221a 100644 --- a/src/lib/messaging/channels/googlechat/runtime/googlechat-outbound-auth.ts +++ b/src/lib/messaging/channels/googlechat/runtime/googlechat-outbound-auth.ts @@ -32,7 +32,8 @@ // // 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 (it still relies on the separate googlechat-dns-resolve shim). +// 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 @@ -92,13 +93,13 @@ function buildBearerShortCircuitSource() { var canonical = "openshell:resolve:env:" + ENV_VAR; return ( - "try { var __nemoGcRaw = (typeof process !== \"undefined\" && process.env) " + + 'try { 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 ? \"" + + ' : void 0; if (typeof __nemoGcRaw === "string" && __nemoGcRaw.length > 0) { ' + + 'return __nemoGcRaw.indexOf("openshell:resolve:env:") === 0 ? "' + canonical + - "\" : __nemoGcRaw; } } catch (_e) {} /* " + + '" : __nemoGcRaw; } } catch (_e) {} /* ' + CALL_MARKER + " */" ); 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..43bcbfc649 --- /dev/null +++ b/src/lib/messaging/channels/googlechat/runtime/googlechat-trusted-proxy-fetch.ts @@ -0,0 +1,227 @@ +// @ts-nocheck +// 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. +// +// ── 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 loader-hook coverage is proven. + +(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"; + + if (process.__nemoclawGooglechatTrustedProxyFetchInstalled) return; + try { + Object.defineProperty(process, "__nemoclawGooglechatTrustedProxyFetchInstalled", { + value: true, + }); + } catch (_e) { + process.__nemoclawGooglechatTrustedProxyFetchInstalled = true; + } + + function isOpenClawGooglechatFile(filename) { + var normalized = String(filename || "").replace(/\\/g, "/"); + return normalized.indexOf("/@openclaw/googlechat/") !== -1 && normalized.endsWith(".js"); + } + + // 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, filename) { + // 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 = []; + 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 isTrustedProxyFetchPatchError(reason) { + var msg = String((reason && reason.message) || reason || ""); + return ( + msg.indexOf("OpenClaw Google Chat trusted-proxy fetch anchors") !== -1 && + msg.indexOf("not recognized") !== -1 + ); + } + + function fileNameFromModuleUrl(urlValue) { + if (typeof urlValue !== "string" || !urlValue.startsWith("file:")) return ""; + try { + return require("url").fileURLToPath(urlValue); + } catch (_e) { + return ""; + } + } + + function sourceToText(source) { + 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; + } + + function installTrustedProxyFetchPatch() { + var Module = require("module"); + var fs = require("fs"); + var originalJsLoader = Module._extensions && Module._extensions[".js"]; + if (typeof originalJsLoader === "function") { + Module._extensions[".js"] = function nemoclawGooglechatTrustedProxyJsLoader(mod, filename) { + if (isOpenClawGooglechatFile(filename)) { + var source = fs.readFileSync(filename, "utf8"); + var patched = patchTrustedProxyFetchSource(source, filename); + if (patched !== source) { + return mod._compile(patched, filename); + } + } + return originalJsLoader.apply(this, arguments); + }; + } + + if (typeof Module.registerHooks === "function") { + Module.registerHooks({ + load: function nemoclawGooglechatTrustedProxyLoadHook(urlValue, context, nextLoad) { + 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 }); + }, + }); + } + } + + 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: " + + String(e && e.message) + + "\n", + ); + } + // Any other failure: never break gateway boot. + } +})(); diff --git a/src/lib/messaging/channels/manifests.test.ts b/src/lib/messaging/channels/manifests.test.ts index c4e522b4ee..cfe129285c 100644 --- a/src/lib/messaging/channels/manifests.test.ts +++ b/src/lib/messaging/channels/manifests.test.ts @@ -17,6 +17,7 @@ import type { ChannelManifest, ChannelRenderSpec, } from "../manifest"; +import { GOOGLECHAT_TUNNEL_AUDIENCE_GATE_HOOK_ID } from "./googlechat/hooks"; import { BUILT_IN_CHANNEL_MANIFESTS, createBuiltInChannelManifestRegistry, @@ -28,7 +29,6 @@ import { wechatManifest, whatsappManifest, } from "./index"; -import { GOOGLECHAT_TUNNEL_AUDIENCE_GATE_HOOK_ID } from "./googlechat/hooks"; import { SLACK_SOCKET_MODE_GATEWAY_CONFLICT_HOOK_HANDLER_ID, SLACK_SOCKET_MODE_GATEWAY_STATUS_HOOK_HANDLER_ID, @@ -956,10 +956,10 @@ describe("built-in channel manifests", () => { ]); expectOpenClawRuntimeVisibility(googlechatManifest, ["googlechat"], ["googlechat"]); - expectOpenClawNodePreload(googlechatManifest, "googlechat-dns-resolve"); + expectOpenClawNodePreload(googlechatManifest, "googlechat-trusted-proxy-fetch"); expect( googlechatManifest.runtime?.openclaw?.nodePreloads?.find( - (preload) => preload.module === "googlechat-dns-resolve", + (preload) => preload.module === "googlechat-trusted-proxy-fetch", )?.injectInto, ).toEqual(["boot"]); expectOpenClawNodePreload(googlechatManifest, "googlechat-outbound-auth"); @@ -979,9 +979,5 @@ describe("built-in channel manifests", () => { pin: true, required: true, }); - expect(googlechatManifest.state.persist).toEqual({ - googlechatConfig: ["audienceType", "audience", "appPrincipal", "webhookPath"], - allowedIds: ["allowFrom"], - }); }); }); From 953603bc995b6dc3fb6612cfd9eb27192752281f Mon Sep 17 00:00:00 2001 From: Hung Le Date: Wed, 1 Jul 2026 20:21:00 +0530 Subject: [PATCH 05/50] feat(messaging): apply biome and keep onboard.ts net-neutral for Google Chat bridge wiring --- src/lib/messaging/channels/built-ins.ts | 4 ++-- .../messaging/channels/template-resolver.ts | 2 +- src/lib/onboard.ts | 22 ------------------- src/lib/onboard/googlechat-bridge-provider.ts | 11 ++++++---- src/lib/onboard/providers.ts | 22 +++++++++++++++++-- test/channels-add-preset.test.ts | 10 ++++++++- test/policies.test.ts | 1 + 7 files changed, 40 insertions(+), 32 deletions(-) diff --git a/src/lib/messaging/channels/built-ins.ts b/src/lib/messaging/channels/built-ins.ts index f79ea3f812..ad7b26a90a 100644 --- a/src/lib/messaging/channels/built-ins.ts +++ b/src/lib/messaging/channels/built-ins.ts @@ -6,16 +6,16 @@ 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"; diff --git a/src/lib/messaging/channels/template-resolver.ts b/src/lib/messaging/channels/template-resolver.ts index 80cd0fbdb7..87090c1861 100644 --- a/src/lib/messaging/channels/template-resolver.ts +++ b/src/lib/messaging/channels/template-resolver.ts @@ -4,8 +4,8 @@ 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"; diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index bf166d22a8..5d934c08c6 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -163,7 +163,6 @@ const ANSI_RE = /\x1B(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1B\\)|[@-_])/g; const runner: typeof import("./runner") = require("./runner"); const { ROOT, SCRIPTS, redact, run, runCapture, runFile, validateName } = runner; const braveProviderProfile: typeof import("./onboard/brave-provider-profile") = require("./onboard/brave-provider-profile"); -const googlechatBridgeProvider: typeof import("./onboard/googlechat-bridge-provider") = require("./onboard/googlechat-bridge-provider"); const { runSandboxProviderPreDeleteCleanup } = require("./onboard/sandbox-provider-cleanup") as typeof import("./onboard/sandbox-provider-cleanup"); const nameValidation: typeof import("./name-validation") = require("./name-validation"); @@ -948,19 +947,7 @@ function upsertMessagingProviders( options: { replaceExisting?: boolean } = {}, ) { braveProviderProfile.ensureBraveProviderProfile(tokenDefs, { root: ROOT, runOpenshell, redact }); - googlechatBridgeProvider.ensureGooglechatBridgeProfile(tokenDefs, { - root: ROOT, - runOpenshell, - redact, - }); const upserted = onboardProviders.upsertMessagingProviders(tokenDefs, runOpenshell, options); - // Gateway-side token minting must be configured AFTER the provider exists. - // Supplies the service-account material (key stays gateway-side); best-effort. - googlechatBridgeProvider.configureGooglechatBridgeRefresh(tokenDefs, { - runOpenshell, - redact, - getCredential, - }); // upsertMessagingProviders process.exits on failure, so reaching this // point means every entry in tokenDefs that had a token was registered. // Mark migrated only when the registered token equals the staged legacy @@ -5290,15 +5277,6 @@ async function onboard(opts: OnboardOptions = {}): Promise { }, }); completed = true; - // Deliver in-process secret files (e.g. the Google Chat service account) into - // the sandbox and restart the gateway so channels that sign locally can read them. - ( - require("./onboard/messaging-secret-file-delivery") as typeof import("./onboard/messaging-secret-file-delivery") - ).deliverSandboxMessagingSecretFiles( - sandboxName, - liveFinalFlowContext.selectedMessagingChannels ?? [], - agent, - ); traceCompleted = true; } finally { releaseOnboardLock(); diff --git a/src/lib/onboard/googlechat-bridge-provider.ts b/src/lib/onboard/googlechat-bridge-provider.ts index bba40501a3..96a8c2544a 100644 --- a/src/lib/onboard/googlechat-bridge-provider.ts +++ b/src/lib/onboard/googlechat-bridge-provider.ts @@ -172,7 +172,12 @@ export function configureGooglechatBridgeRefresh( ); return; } - if (typeof clientEmail !== "string" || !clientEmail || typeof privateKey !== "string" || !privateKey) { + if ( + typeof clientEmail !== "string" || + !clientEmail || + typeof privateKey !== "string" || + !privateKey + ) { warn( "\n ✗ Google Chat bridge: service account JSON missing client_email/private_key; cannot configure gateway token minting.", ); @@ -206,9 +211,7 @@ export function configureGooglechatBridgeRefresh( const diagnostic = compactText( deps.redact(`${bufferOrStringToText(result.stderr)} ${bufferOrStringToText(result.stdout)}`), ); - warn( - `\n ✗ Google Chat bridge: failed to configure gateway token minting for '${bridge.name}'.`, - ); + warn(`\n ✗ Google Chat bridge: failed to configure gateway token minting for '${bridge.name}'.`); if (diagnostic) warn(` ${diagnostic.slice(0, 500)}`); warn(" Outbound Google Chat replies will not authenticate until this is resolved."); } diff --git a/src/lib/onboard/providers.ts b/src/lib/onboard/providers.ts index 77a5eaceed..30e46acedd 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, @@ -443,6 +443,15 @@ function upsertProvider(name, type, credentialEnv, baseUrl, env, _runOpenshell, * @returns {string[]} Provider names that were upserted. */ function upsertMessagingProviders(tokenDefs, _runOpenshell, options = {}) { + const googlechatBridgeProvider = require("./googlechat-bridge-provider"); + // Register the Google Chat bridge provider profile before creating providers + // (the bridge is created with --type google-chat-bridge). Self-gates when no + // googlechat bridge token def is present. + googlechatBridgeProvider.ensureGooglechatBridgeProfile(tokenDefs, { + root: ROOT, + runOpenshell: _runOpenshell, + redact, + }); const upserted = []; const failures = []; for (const { name, envKey, token, providerType } of tokenDefs) { @@ -469,6 +478,15 @@ function upsertMessagingProviders(tokenDefs, _runOpenshell, options = {}) { if (failures.length > 0) { throw new Error(failures.join("; ")); } + // Gateway-side token minting for the Google Chat bridge is configured AFTER + // the provider exists (best-effort; self-gates without a bridge token def). + // The service-account private key is passed as refresh material and stays + // gateway-side — never written into the sandbox. + googlechatBridgeProvider.configureGooglechatBridgeRefresh(tokenDefs, { + runOpenshell: _runOpenshell, + redact, + getCredential, + }); return upserted; } diff --git a/test/channels-add-preset.test.ts b/test/channels-add-preset.test.ts index 6a7ed85ee8..acdebf6499 100644 --- a/test/channels-add-preset.test.ts +++ b/test/channels-add-preset.test.ts @@ -308,7 +308,15 @@ const ctx = module.exports; isInteractive: false, configuredChannels: ["slack"], disabledChannels: [], - supportedChannelIds: ["telegram", "discord", "wechat", "slack", "whatsapp", "teams"], + supportedChannelIds: [ + "telegram", + "discord", + "wechat", + "slack", + "whatsapp", + "teams", + "googlechat", + ], }, ]); }); diff --git a/test/policies.test.ts b/test/policies.test.ts index df83d4922c..1dc67f6697 100644 --- a/test/policies.test.ts +++ b/test/policies.test.ts @@ -118,6 +118,7 @@ describe("policies", () => { "claude-code", "discord", "github", + "googlechat", "huggingface", "jira", "local-inference", From 04399bf9b89f4d96e4b8de1b240c6d59a7d051ce Mon Sep 17 00:00:00 2001 From: Hung Le Date: Wed, 1 Jul 2026 20:48:59 +0530 Subject: [PATCH 06/50] feat(messaging): keep googlechat test updates within the size budget in test --- ci/test-file-size-budget.json | 2 +- test/channels-add-preset.test.ts | 12 ++---------- test/policies.test.ts | 6 ++---- 3 files changed, 5 insertions(+), 15 deletions(-) diff --git a/ci/test-file-size-budget.json b/ci/test-file-size-budget.json index ee67851ca9..ef7f67d3fa 100644 --- a/ci/test-file-size-budget.json +++ b/ci/test-file-size-budget.json @@ -12,6 +12,6 @@ "test/onboard-messaging.test.ts": 2062, "test/onboard-selection.test.ts": 6867, "test/onboard.test.ts": 4774, - "test/policies.test.ts": 2489 + "test/policies.test.ts": 2488 } } diff --git a/test/channels-add-preset.test.ts b/test/channels-add-preset.test.ts index acdebf6499..4c3f066aa6 100644 --- a/test/channels-add-preset.test.ts +++ b/test/channels-add-preset.test.ts @@ -12,6 +12,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { describe, it } from "vitest"; +const OPENCLAW_IDS = ["telegram", "discord", "wechat", "slack", "whatsapp", "teams", "googlechat"]; const repoRoot = path.join(import.meta.dirname, ".."); const j = (p: string) => @@ -299,7 +300,6 @@ const ctx = module.exports; const result = runScript(script); assert.equal(result.status, 0, `script failed: ${result.stderr}\n${result.stdout}`); const payload = parseResultPayload(result); - assert.deepEqual(payload.buildPlanCalls, [ { sandboxName: "test-sb", @@ -308,15 +308,7 @@ const ctx = module.exports; isInteractive: false, configuredChannels: ["slack"], disabledChannels: [], - supportedChannelIds: [ - "telegram", - "discord", - "wechat", - "slack", - "whatsapp", - "teams", - "googlechat", - ], + supportedChannelIds: OPENCLAW_IDS, }, ]); }); diff --git a/test/policies.test.ts b/test/policies.test.ts index 1dc67f6697..c7ab68810b 100644 --- a/test/policies.test.ts +++ b/test/policies.test.ts @@ -108,10 +108,8 @@ describe("policies", () => { }); it("returns expected preset names", () => { - const names = policies - .listPresets() - .map((p: { name: string }) => p.name) - .sort(); + const names = policies.listPresets().map((p: { name: string }) => p.name); + names.sort(); const expected = [ "brave", "brew", From 63a4352c9211ee234411e9a2d5f349ca8a360515 Mon Sep 17 00:00:00 2001 From: Hung Le Date: Wed, 1 Jul 2026 22:57:42 +0530 Subject: [PATCH 07/50] feat(messaging): drop unused in-sandbox secretFiles delivery subsystem --- src/lib/actions/sandbox/rebuild.ts | 5 - .../applier/secret-file-delivery.test.ts | 118 ------------- .../messaging/applier/secret-file-delivery.ts | 159 ------------------ src/lib/messaging/channels/manifests.test.ts | 3 +- src/lib/messaging/manifest/types.ts | 26 --- .../onboard/messaging-secret-file-delivery.ts | 90 ---------- 6 files changed, 1 insertion(+), 400 deletions(-) delete mode 100644 src/lib/messaging/applier/secret-file-delivery.test.ts delete mode 100644 src/lib/messaging/applier/secret-file-delivery.ts delete mode 100644 src/lib/onboard/messaging-secret-file-delivery.ts diff --git a/src/lib/actions/sandbox/rebuild.ts b/src/lib/actions/sandbox/rebuild.ts index eb04755ab9..309843fd9a 100644 --- a/src/lib/actions/sandbox/rebuild.ts +++ b/src/lib/actions/sandbox/rebuild.ts @@ -1180,11 +1180,6 @@ export async function rebuildSandbox( if (!ensureMessagingHostForwardAfterRebuild(sandboxName, rebuildMessagingPlan)) { messagingHostForwardUnverified = true; } - // Re-deliver in-process secret files (e.g. the Google Chat service account) - // that the rebuilt image references by path, then restart the gateway. - ( - require("../../onboard/messaging-secret-file-delivery") as typeof import("../../onboard/messaging-secret-file-delivery") - ).deliverSandboxMessagingSecretFilesForPlan(sandboxName, rebuildMessagingPlan); console.log(""); if ( diff --git a/src/lib/messaging/applier/secret-file-delivery.test.ts b/src/lib/messaging/applier/secret-file-delivery.test.ts deleted file mode 100644 index 469878ebf5..0000000000 --- a/src/lib/messaging/applier/secret-file-delivery.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -// 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 { ChannelManifest } from "../manifest"; -import { - collectMessagingSecretFiles, - deliverMessagingSecretFiles, - type MessagingSecretFileTarget, - type SecretFileDeliveryDeps, -} from "./secret-file-delivery"; - -const SA_TARGET = "/sandbox/.openclaw/secrets/googlechat-service-account.json"; - -const GOOGLECHAT_TARGET: MessagingSecretFileTarget = { - channelId: "googlechat", - secretFileId: "serviceAccountFile", - envKey: "GOOGLECHAT_SERVICE_ACCOUNT", - target: SA_TARGET, - mode: "640", -}; - -function makeDeps(overrides: Partial = {}): SecretFileDeliveryDeps { - return { - readSecret: vi.fn(() => '{"type":"service_account"}'), - uploadToSandbox: vi.fn(() => true), - execInSandbox: vi.fn(() => true), - restartGateway: vi.fn(), - log: () => {}, - warn: () => {}, - writeTempFile: vi.fn(() => "/tmp/nemoclaw-secret-x/secret"), - removeTempFile: vi.fn(), - ...overrides, - }; -} - -// Synthetic manifest exercising the generic secret-file delivery contract. -// Google Chat itself no longer ships secretFiles (its service account is minted -// gateway-side and the key never enters the sandbox), so the delivery path is -// covered here with a fixture rather than a built-in manifest. -const FIXTURE_MANIFEST = { - id: "googlechat", - inputs: [ - { id: "serviceAccount", kind: "secret", required: true, envKey: "GOOGLECHAT_SERVICE_ACCOUNT" }, - ], - secretFiles: [ - { - id: "serviceAccountFile", - sourceInput: "serviceAccount", - agent: "openclaw", - target: SA_TARGET, - mode: "640", - }, - ], -} as unknown as ChannelManifest; - -describe("collectMessagingSecretFiles", () => { - it("maps a service-account file from a manifest's secretFiles", () => { - expect(collectMessagingSecretFiles([FIXTURE_MANIFEST], ["googlechat"], "openclaw")).toEqual([ - GOOGLECHAT_TARGET, - ]); - }); - - it("skips inactive channels and non-matching agents", () => { - expect(collectMessagingSecretFiles([FIXTURE_MANIFEST], [], "openclaw")).toEqual([]); - expect(collectMessagingSecretFiles([FIXTURE_MANIFEST], ["googlechat"], "hermes")).toEqual([]); - }); -}); - -describe("deliverMessagingSecretFiles", () => { - it("uploads, chmods, and restarts the gateway once on success", () => { - const deps = makeDeps(); - const result = deliverMessagingSecretFiles("sbx", [GOOGLECHAT_TARGET], deps); - - expect(result.delivered).toEqual(["serviceAccountFile"]); - expect(result.skipped).toEqual([]); - expect(deps.uploadToSandbox).toHaveBeenCalledWith( - "sbx", - "/tmp/nemoclaw-secret-x/secret", - SA_TARGET, - ); - expect(deps.execInSandbox).toHaveBeenCalledWith("sbx", ["chmod", "640", SA_TARGET]); - expect(deps.restartGateway).toHaveBeenCalledTimes(1); - expect(deps.removeTempFile).toHaveBeenCalledWith("/tmp/nemoclaw-secret-x/secret"); - }); - - it("skips and does not restart when the secret is unavailable", () => { - const deps = makeDeps({ readSecret: vi.fn(() => null) }); - const result = deliverMessagingSecretFiles("sbx", [GOOGLECHAT_TARGET], deps); - - expect(result.skipped).toEqual(["serviceAccountFile"]); - expect(deps.uploadToSandbox).not.toHaveBeenCalled(); - expect(deps.restartGateway).not.toHaveBeenCalled(); - }); - - it("skips (and cleans up) when the upload fails, without chmod or restart", () => { - const deps = makeDeps({ uploadToSandbox: vi.fn(() => false) }); - const result = deliverMessagingSecretFiles("sbx", [GOOGLECHAT_TARGET], deps); - - expect(result.skipped).toEqual(["serviceAccountFile"]); - // Only the mkdir exec ran; chmod must not run after a failed upload. - expect(deps.execInSandbox).toHaveBeenCalledTimes(1); - expect(deps.restartGateway).not.toHaveBeenCalled(); - expect(deps.removeTempFile).toHaveBeenCalledTimes(1); - }); - - it("restarts the gateway only once even for multiple files", () => { - const second: MessagingSecretFileTarget = { - ...GOOGLECHAT_TARGET, - secretFileId: "second", - target: "/sandbox/.openclaw/secrets/second.json", - }; - const deps = makeDeps(); - deliverMessagingSecretFiles("sbx", [GOOGLECHAT_TARGET, second], deps); - expect(deps.restartGateway).toHaveBeenCalledTimes(1); - }); -}); diff --git a/src/lib/messaging/applier/secret-file-delivery.ts b/src/lib/messaging/applier/secret-file-delivery.ts deleted file mode 100644 index f76e3d1f7a..0000000000 --- a/src/lib/messaging/applier/secret-file-delivery.ts +++ /dev/null @@ -1,159 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; -import type { ChannelManifest, MessagingAgentId, MessagingChannelId } from "../manifest"; - -// Some channel secrets must be consumed by the agent IN-process (e.g. a Google -// service-account JSON the channel signs JWTs with). NemoClaw's normal secret -// path — an OpenShell provider + `openshell:resolve:env:` placeholder — only -// materializes the real value in OUTBOUND HTTP, never in-process, so it cannot -// satisfy local signing. `secretFiles` instead uploads the raw secret to a file -// inside the sandbox after create/rebuild. The value never touches the agent -// config or the image; the file lives in the sandbox volume. - -const DEFAULT_MODE = "600"; - -/** One resolved secret-file delivery for an active channel. */ -export interface MessagingSecretFileTarget { - readonly channelId: MessagingChannelId; - readonly secretFileId: string; - /** Env key of the captured secret value to deliver. */ - readonly envKey: string; - /** Absolute destination path inside the sandbox. */ - readonly target: string; - /** Octal mode applied to the delivered file. */ - readonly mode: string; -} - -/** Resolve the in-sandbox secret-file deliveries for the active channels + agent. */ -export function collectMessagingSecretFiles( - manifests: readonly ChannelManifest[], - activeChannelIds: readonly MessagingChannelId[], - agent: MessagingAgentId, -): MessagingSecretFileTarget[] { - const active = new Set(activeChannelIds); - const targets: MessagingSecretFileTarget[] = []; - for (const manifest of manifests) { - if (!active.has(manifest.id)) continue; - for (const file of manifest.secretFiles ?? []) { - if (file.agent !== agent) continue; - const input = manifest.inputs.find( - (entry) => entry.id === file.sourceInput && entry.kind === "secret", - ); - if (!input?.envKey) continue; - targets.push({ - channelId: manifest.id, - secretFileId: file.id, - envKey: input.envKey, - target: file.target, - mode: file.mode ?? DEFAULT_MODE, - }); - } - } - return targets; -} - -export interface SecretFileDeliveryDeps { - /** Read the captured secret value (process env / credential store). */ - readonly readSecret: (envKey: string) => string | null | undefined; - /** Upload a host file to the sandbox path. Return false on failure. */ - readonly uploadToSandbox: (sandboxName: string, localPath: string, target: string) => boolean; - /** Run a command inside the sandbox (mkdir/chmod). Return false on failure. */ - readonly execInSandbox: (sandboxName: string, argv: readonly string[]) => boolean; - /** Restart the in-sandbox gateway so it re-reads config + the new files. */ - readonly restartGateway: (sandboxName: string) => void; - readonly log?: (message: string) => void; - readonly warn?: (message: string) => void; - /** Test seam: write the secret to a host temp file, returning its path. */ - readonly writeTempFile?: (contents: string) => string; - readonly removeTempFile?: (path: string) => void; -} - -export interface SecretFileDeliveryResult { - readonly delivered: readonly string[]; - readonly skipped: readonly string[]; -} - -/** - * Deliver each secret file to the sandbox, then restart the gateway once if any - * file was written (so the channel can read it on the next boot). Missing - * secrets are skipped with a warning rather than aborting the run. - */ -export function deliverMessagingSecretFiles( - sandboxName: string, - targets: readonly MessagingSecretFileTarget[], - deps: SecretFileDeliveryDeps, -): SecretFileDeliveryResult { - const log = deps.log ?? (() => {}); - const warn = deps.warn ?? log; - const writeTempFile = deps.writeTempFile ?? defaultWriteTempFile; - const removeTempFile = deps.removeTempFile ?? defaultRemoveTempFile; - - const delivered: string[] = []; - const skipped: string[] = []; - let changed = false; - - for (const target of targets) { - const value = normalizeSecret(deps.readSecret(target.envKey)); - if (!value) { - warn( - ` ⚠ ${target.channelId}: secret ${target.envKey} is unavailable — skipped ${target.target}`, - ); - skipped.push(target.secretFileId); - continue; - } - const tempPath = writeTempFile(value); - try { - const dir = dirname(target.target); - deps.execInSandbox(sandboxName, [ - "sh", - "-c", - `mkdir -p ${shellQuote(dir)} && chmod 750 ${shellQuote(dir)}`, - ]); - if (!deps.uploadToSandbox(sandboxName, tempPath, target.target)) { - warn(` ⚠ ${target.channelId}: failed to upload secret file to ${target.target}`); - skipped.push(target.secretFileId); - continue; - } - deps.execInSandbox(sandboxName, ["chmod", target.mode, target.target]); - delivered.push(target.secretFileId); - changed = true; - log(` ✓ ${target.channelId}: delivered service account file to the sandbox`); - } finally { - removeTempFile(tempPath); - } - } - - if (changed) { - log(" Restarting in-sandbox gateway to load the delivered secret file(s)…"); - deps.restartGateway(sandboxName); - } - return { delivered, skipped }; -} - -function normalizeSecret(value: string | null | undefined): string { - return typeof value === "string" ? value.trim() : ""; -} - -// Single-quote for `sh -c`; embedded single quotes are closed/escaped/reopened. -function shellQuote(value: string): string { - return `'${value.replace(/'/g, "'\\''")}'`; -} - -function defaultWriteTempFile(contents: string): string { - const dir = mkdtempSync(join(tmpdir(), "nemoclaw-secret-")); - const file = join(dir, "secret"); - writeFileSync(file, contents, { mode: 0o600 }); - return file; -} - -function defaultRemoveTempFile(path: string): void { - try { - rmSync(dirname(path), { recursive: true, force: true }); - } catch { - /* best-effort cleanup */ - } -} diff --git a/src/lib/messaging/channels/manifests.test.ts b/src/lib/messaging/channels/manifests.test.ts index cfe129285c..73c5c86c81 100644 --- a/src/lib/messaging/channels/manifests.test.ts +++ b/src/lib/messaging/channels/manifests.test.ts @@ -904,9 +904,8 @@ describe("built-in channel manifests", () => { // Outbound auth is gateway-minted (google-service-account-jwt bridge provider) // and the L7 proxy injects the bearer; the SA private key stays gateway-side and - // is never delivered into the sandbox — so no credential binding and no secretFiles. + // is never delivered into the sandbox — so no credential binding. expect(googlechatManifest.credentials).toEqual([]); - expect((googlechatManifest as ChannelManifest).secretFiles).toBeUndefined(); expect(policyPresetNames(googlechatManifest)).toEqual(["googlechat"]); const render = renderJson(googlechatManifest); diff --git a/src/lib/messaging/manifest/types.ts b/src/lib/messaging/manifest/types.ts index 5099ace896..379a50e1c9 100644 --- a/src/lib/messaging/manifest/types.ts +++ b/src/lib/messaging/manifest/types.ts @@ -43,14 +43,6 @@ export interface ChannelManifest { readonly policyPresets?: readonly ChannelPolicyPresetReference[]; readonly render: readonly ChannelRenderSpec[]; readonly hostForward?: ChannelHostForwardSpec; - /** - * Secrets that must be delivered as a FILE inside the sandbox (consumed - * in-process by the agent, e.g. a Google service-account JSON the channel - * signs JWTs with) rather than as an outbound provider placeholder. The - * applier uploads the secret value to `target` post-create; the value is - * never written into the agent config or the image. - */ - readonly secretFiles?: readonly ChannelSecretFileSpec[]; readonly runtime?: ChannelRuntimeByAgentSpec; readonly agentPackages?: readonly ChannelAgentPackageSpec[]; readonly hooks: readonly ChannelHookSpec[]; @@ -157,24 +149,6 @@ export interface ChannelHostForwardSpec { readonly when?: MessagingTemplateString; } -/** - * Declaration for a secret delivered into the sandbox as a file. Unlike a - * `ChannelCredentialSpec` (which becomes an outbound provider placeholder), the - * raw secret value is uploaded to `target` for the agent to read locally. Use - * only for secrets the agent must consume in-process (e.g. a private key used - * for local signing), never for outbound bearer tokens. - */ -export interface ChannelSecretFileSpec { - readonly id: string; - /** Secret input id whose captured value is delivered. */ - readonly sourceInput: string; - readonly agent: MessagingAgentId; - /** Absolute destination path inside the sandbox. */ - readonly target: MessagingTemplateString; - /** Octal file mode applied after upload (default "600"). */ - readonly mode?: string; -} - /** Agent-runtime metadata consumed by shared runtime setup and diagnostics. */ export interface ChannelRuntimeByAgentSpec extends Partial> { diff --git a/src/lib/onboard/messaging-secret-file-delivery.ts b/src/lib/onboard/messaging-secret-file-delivery.ts deleted file mode 100644 index bcd63600d6..0000000000 --- a/src/lib/onboard/messaging-secret-file-delivery.ts +++ /dev/null @@ -1,90 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { runOpenshell } from "../adapters/openshell/runtime"; -import { getCredential } from "../credentials/store"; -import { createBuiltInChannelManifestRegistry, tryGetMessagingAgentId } from "../messaging"; -import { - collectMessagingSecretFiles, - deliverMessagingSecretFiles, -} from "../messaging/applier/secret-file-delivery"; -import type { MessagingAgentId, SandboxMessagingPlan } from "../messaging/manifest"; -import { getActiveChannelsFromPlan } from "./messaging-plan-session"; - -interface MessagingAgentDescriptor { - readonly name?: string; -} - -function logDeliveryError(error: unknown): void { - console.error( - ` ⚠ Messaging secret-file delivery failed: ${ - error instanceof Error ? error.message : String(error) - }`, - ); -} - -function deliverForAgentId( - sandboxName: string, - activeChannelIds: readonly string[], - agentId: MessagingAgentId | null, -): void { - if (!agentId || activeChannelIds.length === 0) return; - const manifests = createBuiltInChannelManifestRegistry().list(); - const targets = collectMessagingSecretFiles(manifests, activeChannelIds, agentId); - if (targets.length === 0) return; - - deliverMessagingSecretFiles(sandboxName, targets, { - readSecret: (key) => process.env[key] ?? getCredential(key), - uploadToSandbox: (sandbox, localPath, target) => - runOpenshell(["sandbox", "upload", sandbox, localPath, target], { ignoreError: true }) - .status === 0, - execInSandbox: (sandbox, argv) => - runOpenshell(["sandbox", "exec", "--name", sandbox, "--", ...argv], { ignoreError: true }) - .status === 0, - restartGateway: (sandbox) => { - // Lazy require avoids pulling tunnel/services (and its process-level - // color/TTY probes) into this module's import graph at load time. - const { stopSandboxChannels } = require("../tunnel/services"); - stopSandboxChannels(sandbox); - }, - log: (message) => console.log(message), - warn: (message) => console.log(message), - }); -} - -/** - * Onboard entry: deliver in-process secret files (e.g. the Google Chat - * service-account JSON) for the selected agent, then restart the gateway. - * Best-effort — never throws, so it cannot fail onboarding. - */ -export function deliverSandboxMessagingSecretFiles( - sandboxName: string, - activeChannelIds: readonly string[], - agent: MessagingAgentDescriptor | null, -): void { - try { - const agentId = agent - ? tryGetMessagingAgentId(agent, createBuiltInChannelManifestRegistry().list()) - : "openclaw"; - deliverForAgentId(sandboxName, activeChannelIds, agentId); - } catch (error) { - logDeliveryError(error); - } -} - -/** - * Rebuild entry: derive the active channels + agent from the compiled plan and - * re-deliver secret files (the rebuilt image points at the file path, so it must - * be re-uploaded). Best-effort — never throws. - */ -export function deliverSandboxMessagingSecretFilesForPlan( - sandboxName: string, - plan: SandboxMessagingPlan | null | undefined, -): void { - try { - if (!plan) return; - deliverForAgentId(sandboxName, getActiveChannelsFromPlan(plan), plan.agent); - } catch (error) { - logDeliveryError(error); - } -} From f6eb4d83cc049a297b0e2f68d4ded157a2153a73 Mon Sep 17 00:00:00 2001 From: Hung Le Date: Thu, 2 Jul 2026 08:26:54 +0530 Subject: [PATCH 08/50] feat(messaging): explain the host-local argv exposure of the Google Chat refresh key --- src/lib/onboard/googlechat-bridge-provider.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/lib/onboard/googlechat-bridge-provider.ts b/src/lib/onboard/googlechat-bridge-provider.ts index 96a8c2544a..f0a7e99236 100644 --- a/src/lib/onboard/googlechat-bridge-provider.ts +++ b/src/lib/onboard/googlechat-bridge-provider.ts @@ -184,6 +184,16 @@ export function configureGooglechatBridgeRefresh( return; } + // SECURITY (host-local, tracked upstream): OpenShell `provider refresh configure` + // ingests refresh material only via `--material KEY=VALUE` argv — it has no stdin, + // file, or env-ref transport for secret material today (openshell-cli + // parse_key_value_pairs stores values verbatim; the JWT strategy reads private_key + // from the material map). So the SA private key transits this argv. Accepted risk: + // it never enters the sandbox (the key-out-of-sandbox boundary holds), and the + // exposure is transient (this one configure call) and host-local (ps //proc// + // cmdline on the trusted host that already holds the key to mint tokens). Tracked + // upstream to add a non-argv transport (--secret-material-file/stdin); switch to it + // when released — runOpenshell already supports env/stdin here. const result = deps.runOpenshell( [ "provider", From c06f9d45d22fec408a5346a3425f092a0821aa3f Mon Sep 17 00:00:00 2001 From: Hung Le Date: Thu, 2 Jul 2026 09:09:54 +0530 Subject: [PATCH 09/50] feat(messaging): harden Google Chat bridge onboarding (env resolution + fail-closed) --- src/lib/onboard/googlechat-bridge-provider.ts | 60 +++++++++++++++---- src/lib/onboard/messaging-prep.ts | 2 + src/lib/onboard/providers.ts | 16 ++++- 3 files changed, 67 insertions(+), 11 deletions(-) diff --git a/src/lib/onboard/googlechat-bridge-provider.ts b/src/lib/onboard/googlechat-bridge-provider.ts index f0a7e99236..c18157fc48 100644 --- a/src/lib/onboard/googlechat-bridge-provider.ts +++ b/src/lib/onboard/googlechat-bridge-provider.ts @@ -52,9 +52,24 @@ export type GooglechatBridgeRefreshDeps = { runOpenshell: RunOpenshell; redact: (input: string) => string; getCredential: (envKey: string) => string | null; + env?: NodeJS.ProcessEnv | Record; + normalizeCredentialValue?: (value: unknown) => string; log?: (message?: string) => void; }; +// Result of gateway-refresh configuration. `ok:false` when a bridge token def is +// present (Google Chat active) but minting could not be configured, so the caller +// fails onboarding instead of leaving the channel silently unable to reply. +export type GooglechatBridgeRefreshResult = { ok: boolean; reason?: string }; + +// Credentials the service-account resolver consults, in the same order the rest +// of onboarding uses (credential store first, then the injected env map). +type ServiceAccountResolveDeps = { + getCredential: (envKey: string) => string | null; + env?: NodeJS.ProcessEnv | Record; + normalizeCredentialValue?: (value: unknown) => string; +}; + function bufferOrStringToText(value: string | Buffer | null | undefined): string { if (typeof value === "string") return value; if (value && typeof (value as Buffer).toString === "function") @@ -66,6 +81,23 @@ export function googlechatBridgeProfilePath(root: string): string { return path.join(root, "nemoclaw-blueprint", "provider-profiles", "google-chat-bridge.yaml"); } +/** + * Resolve the Google Chat service-account JSON with the same order the rest of + * onboarding uses (mirrors the Brave key resolution in messaging-prep): the + * credential store first, then the injected env map. Using `getCredential` alone + * misses setups where the value arrives through the passed-in env (e.g. + * non-interactive runs), which would enable the channel with no bridge provider. + */ +export function resolveGooglechatServiceAccount(deps: ServiceAccountResolveDeps): string | null { + const fromCredential = deps.getCredential(GOOGLECHAT_SERVICE_ACCOUNT_ENV); + if (fromCredential) return fromCredential; + if (deps.env && deps.normalizeCredentialValue) { + const fromEnv = deps.normalizeCredentialValue(deps.env[GOOGLECHAT_SERVICE_ACCOUNT_ENV]); + if (fromEnv) return fromEnv; + } + return null; +} + /** * Build the messaging token definition for the Google Chat outbound-auth bridge * provider, or null when it does not apply. @@ -80,6 +112,8 @@ export function googlechatBridgeProfilePath(root: string): string { export function maybeGooglechatBridgeTokenDef(input: { sandboxName: string; getCredential: (envKey: string) => string | null; + env?: NodeJS.ProcessEnv | Record; + normalizeCredentialValue?: (value: unknown) => string; enabledChannels: readonly string[] | null; disabledChannelNames: ReadonlySet; }): { name: string; envKey: string; token: string; providerType: string } | null { @@ -87,7 +121,7 @@ export function maybeGooglechatBridgeTokenDef(input: { if (input.enabledChannels != null && !input.enabledChannels.includes(GOOGLECHAT_CHANNEL)) { return null; } - const serviceAccount = input.getCredential(GOOGLECHAT_SERVICE_ACCOUNT_ENV); + const serviceAccount = resolveGooglechatServiceAccount(input); if (!serviceAccount) return null; return { name: `${input.sandboxName}-googlechat-bridge`, @@ -138,26 +172,28 @@ export function ensureGooglechatBridgeProfile( * is created. The service-account private key is passed as refresh material and * stays gateway-side — it is never written into the sandbox. * - * Best-effort: a failure here means outbound replies will not authenticate - * until fixed, but it must not abort onboarding of other channels. Inbound + * Fail-closed: when a bridge token def is present (Google Chat active) and + * minting cannot be configured, returns { ok: false } so the caller aborts + * onboarding rather than leaving the channel able to receive but not reply. + * Returns { ok: true } as a no-op when no bridge token def is present. Inbound * webhook verification is unaffected. The private key is never logged. */ export function configureGooglechatBridgeRefresh( tokenDefs: readonly TokenDefShape[], deps: GooglechatBridgeRefreshDeps, -): void { +): GooglechatBridgeRefreshResult { const bridge = tokenDefs.find( ({ providerType, token }) => providerType === GOOGLECHAT_BRIDGE_PROFILE_ID && Boolean(token), ); - if (!bridge) return; + if (!bridge) return { ok: true }; const warn = deps.log ?? console.error; - const serviceAccount = deps.getCredential(GOOGLECHAT_SERVICE_ACCOUNT_ENV); + const serviceAccount = resolveGooglechatServiceAccount(deps); if (!serviceAccount) { warn( "\n ✗ Google Chat bridge: service account JSON unavailable; cannot configure gateway token minting.", ); - return; + return { ok: false, reason: "service account JSON unavailable" }; } let clientEmail: unknown; @@ -170,7 +206,7 @@ export function configureGooglechatBridgeRefresh( warn( "\n ✗ Google Chat bridge: service account JSON could not be parsed; cannot configure gateway token minting.", ); - return; + return { ok: false, reason: "service account JSON could not be parsed" }; } if ( typeof clientEmail !== "string" || @@ -181,7 +217,7 @@ export function configureGooglechatBridgeRefresh( warn( "\n ✗ Google Chat bridge: service account JSON missing client_email/private_key; cannot configure gateway token minting.", ); - return; + return { ok: false, reason: "service account JSON missing client_email/private_key" }; } // SECURITY (host-local, tracked upstream): OpenShell `provider refresh configure` @@ -215,7 +251,7 @@ export function configureGooglechatBridgeRefresh( ], { ignoreError: true, stdio: ["ignore", "pipe", "pipe"] }, ); - if (result.status === 0) return; + if (result.status === 0) return { ok: true }; // Redact before logging — never echo the private key material. const diagnostic = compactText( @@ -224,4 +260,8 @@ export function configureGooglechatBridgeRefresh( warn(`\n ✗ Google Chat bridge: failed to configure gateway token minting for '${bridge.name}'.`); if (diagnostic) warn(` ${diagnostic.slice(0, 500)}`); warn(" Outbound Google Chat replies will not authenticate until this is resolved."); + return { + ok: false, + reason: diagnostic || `provider refresh configure exited with status ${result.status}`, + }; } diff --git a/src/lib/onboard/messaging-prep.ts b/src/lib/onboard/messaging-prep.ts index f318e4787e..9bb7412e4a 100644 --- a/src/lib/onboard/messaging-prep.ts +++ b/src/lib/onboard/messaging-prep.ts @@ -113,6 +113,8 @@ export function prepareCreateSandboxMessaging( const googlechatBridgeTokenDef = googlechatBridge.maybeGooglechatBridgeTokenDef({ sandboxName: input.sandboxName, getCredential: input.getCredential, + env: input.env, + normalizeCredentialValue: input.normalizeCredentialValue, enabledChannels: input.enabledChannels, disabledChannelNames, }); diff --git a/src/lib/onboard/providers.ts b/src/lib/onboard/providers.ts index 30e46acedd..c4c9f7a625 100644 --- a/src/lib/onboard/providers.ts +++ b/src/lib/onboard/providers.ts @@ -482,11 +482,25 @@ function upsertMessagingProviders(tokenDefs, _runOpenshell, options = {}) { // the provider exists (best-effort; self-gates without a bridge token def). // The service-account private key is passed as refresh material and stays // gateway-side — never written into the sandbox. - googlechatBridgeProvider.configureGooglechatBridgeRefresh(tokenDefs, { + const refreshResult = googlechatBridgeProvider.configureGooglechatBridgeRefresh(tokenDefs, { runOpenshell: _runOpenshell, redact, getCredential, + env: process.env, + normalizeCredentialValue, }); + // Fail-closed: an active Google Chat 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 Google Chat gateway token minting."); + } + console.error("\n ✗ Google Chat gateway token minting was not configured; aborting."); + process.exit(1); + } return upserted; } From 16d4a70458c8e7990a64b77e571a2b341c4c6a4e Mon Sep 17 00:00:00 2001 From: Hung Le Date: Thu, 2 Jul 2026 10:34:36 +0530 Subject: [PATCH 10/50] feat(messaging): drop the Google Chat in-process mint fallback + dead OAuth egress --- .../policies/presets/googlechat.yaml | 10 +----- .../messaging/channels/googlechat/manifest.ts | 4 +-- .../runtime/googlechat-outbound-auth.ts | 35 +++++++++++-------- 3 files changed, 23 insertions(+), 26 deletions(-) diff --git a/nemoclaw-blueprint/policies/presets/googlechat.yaml b/nemoclaw-blueprint/policies/presets/googlechat.yaml index baecb9949e..fc8a419568 100644 --- a/nemoclaw-blueprint/policies/presets/googlechat.yaml +++ b/nemoclaw-blueprint/policies/presets/googlechat.yaml @@ -3,7 +3,7 @@ preset: name: googlechat - description: "Google Chat (Chat API) message send, service-account token exchange, and inbound JWT verification certs" + 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: @@ -31,14 +31,6 @@ network_policies: - allow: { method: PATCH, path: "/v1/spaces/**" } # Delete a message: DELETE /v1/spaces/{space}/messages/{id}. - allow: { method: DELETE, path: "/v1/spaces/**" } - # Service-account JWT -> OAuth access token exchange (google-auth-library). - - host: oauth2.googleapis.com - port: 443 - protocol: rest - enforcement: enforce - request_body_credential_rewrite: true - rules: - - allow: { method: POST, path: "/**" } # Public certificates used to verify inbound Chat / add-on JWT signatures. - host: www.googleapis.com port: 443 diff --git a/src/lib/messaging/channels/googlechat/manifest.ts b/src/lib/messaging/channels/googlechat/manifest.ts index 3c0296c752..9cf55f02f3 100644 --- a/src/lib/messaging/channels/googlechat/manifest.ts +++ b/src/lib/messaging/channels/googlechat/manifest.ts @@ -175,9 +175,7 @@ export const googlechatManifest = { // 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. Inert until the B-side provider is wired (falls back to the - // in-process mint when the env var is unset). See - // runtime/googlechat-outbound-auth.ts. + // sandbox. See runtime/googlechat-outbound-auth.ts. nodePreloads: [ { module: "googlechat-trusted-proxy-fetch", diff --git a/src/lib/messaging/channels/googlechat/runtime/googlechat-outbound-auth.ts b/src/lib/messaging/channels/googlechat/runtime/googlechat-outbound-auth.ts index 0e9770221a..a6e14a1ba4 100644 --- a/src/lib/messaging/channels/googlechat/runtime/googlechat-outbound-auth.ts +++ b/src/lib/messaging/channels/googlechat/runtime/googlechat-outbound-auth.ts @@ -10,10 +10,10 @@ // ── 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 (delivered today as a file), which -// deviates from NemoClaw's security model — secrets should never sit in the -// sandbox; the L7 egress proxy materializes them only on outbound requests. +// 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 @@ -43,8 +43,8 @@ // 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 (provider not wired) the patch falls through to the original in-process mint, -// so the channel keeps working on the legacy SA-file path — inert until the B-side lands. +// 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 @@ -88,18 +88,22 @@ // 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). Falls back to the raw value for non-OpenShell deployments. Built as - // a single line (no template-literal escaping) for a clean source rewrite. + // 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() { var canonical = "openshell:resolve:env:" + ENV_VAR; return ( - 'try { var __nemoGcRaw = (typeof process !== "undefined" && process.env) ' + + '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; } } catch (_e) {} /* ' + + '" : __nemoGcRaw; } throw new Error("nemoclaw googlechat: ' + + ENV_VAR + + ' is not set; the gateway-minted outbound bearer is unavailable"); /* ' + CALL_MARKER + " */" ); @@ -116,7 +120,7 @@ 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 - // leave outbound auth on the in-process SA path. + // leaving outbound auth unpatched. throw new Error( "OpenClaw Google Chat getGoogleChatAccessToken definition shape not recognized in " + filename + @@ -192,12 +196,15 @@ "[channels] [googlechat] outbound-auth patch active " + "(gateway-minted bearer via " + ENV_VAR + - " when wired; falls back to in-process mint otherwise)\n", + ")\n", ); } catch (e) { if (isGooglechatOutboundAuthPatchError(e)) { - // Shape drift: surface loudly but do not crash gateway startup — the - // channel degrades to the legacy in-process SA mint. + // 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: " + String(e && e.message) + "\n", ); From 32dee76a25973ffdf687d4275a5cd901a34ebefa Mon Sep 17 00:00:00 2001 From: Hung Le Date: Thu, 2 Jul 2026 11:14:51 +0530 Subject: [PATCH 11/50] feat(messaging): make the Google Chat start-gate marker an obvious sentinel --- .../messaging/channels/googlechat/manifest.ts | 16 +++++++++------- src/lib/messaging/channels/manifests.test.ts | 12 ++++++------ 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/src/lib/messaging/channels/googlechat/manifest.ts b/src/lib/messaging/channels/googlechat/manifest.ts index 9cf55f02f3..ab4220f7fb 100644 --- a/src/lib/messaging/channels/googlechat/manifest.ts +++ b/src/lib/messaging/channels/googlechat/manifest.ts @@ -115,14 +115,16 @@ export const googlechatManifest = { path: "channels.googlechat", value: { enabled: true, - // Configured-marker ONLY — the file is never delivered (no secretFiles) - // and never read. OpenClaw's channel-start gate requires a serviceAccount* - // (isConfigured: credentialSource !== "none") to start the webhook, but the - // actual token is gateway-minted and proxy-injected, and the + // 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 is read — so the SA key never enters the sandbox. (Clean fix is - // upstream: a non-SA "configured"/accessToken credential source in @openclaw/googlechat.) - serviceAccountFile: "/sandbox/.openclaw/secrets/googlechat-service-account.json", + // 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}}", diff --git a/src/lib/messaging/channels/manifests.test.ts b/src/lib/messaging/channels/manifests.test.ts index 73c5c86c81..4b50b2f8ef 100644 --- a/src/lib/messaging/channels/manifests.test.ts +++ b/src/lib/messaging/channels/manifests.test.ts @@ -911,13 +911,13 @@ describe("built-in channel manifests", () => { const render = renderJson(googlechatManifest); expect(render).toContain('"path":"channels.googlechat"'); expect(render).toContain('"path":"plugins.entries.googlechat"'); - // serviceAccountFile is a start-gate marker only (OpenClaw requires a - // serviceAccount* to start the channel); the file is never delivered (no - // secretFiles) and never read (the outbound-auth preload short-circuits the - // token producer), so the SA key never enters the sandbox. No inline JSON and - // no outbound SA placeholder. + // serviceAccountFile is a synthetic start-gate sentinel (OpenClaw requires a + // serviceAccount* to start the channel, but accepts any non-empty string and does + // not read it at start); the file is never delivered and never read (the + // outbound-auth preload short-circuits the token producer), so the SA key never + // enters the sandbox. The path is deliberately non-existent, not a real location. expect(render).toContain( - '"serviceAccountFile":"/sandbox/.openclaw/secrets/googlechat-service-account.json"', + '"serviceAccountFile":"/nonexistent/googlechat-gateway-minted-no-service-account-file"', ); expect(render).not.toContain("openshell:resolve:env:GOOGLECHAT_SERVICE_ACCOUNT"); expect(render).not.toContain("credential.googlechatServiceAccount"); From d8f0a11b34fb10821cc1dd735f08fa417f92ac94 Mon Sep 17 00:00:00 2001 From: Hung Le Date: Thu, 2 Jul 2026 11:29:35 +0530 Subject: [PATCH 12/50] feat(messaging): explain the broad www.googleapis.com cert egress in the Google Chat preset --- nemoclaw-blueprint/policies/presets/googlechat.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/nemoclaw-blueprint/policies/presets/googlechat.yaml b/nemoclaw-blueprint/policies/presets/googlechat.yaml index fc8a419568..8bd5ea1dba 100644 --- a/nemoclaw-blueprint/policies/presets/googlechat.yaml +++ b/nemoclaw-blueprint/policies/presets/googlechat.yaml @@ -31,7 +31,9 @@ network_policies: - allow: { method: PATCH, path: "/v1/spaces/**" } # Delete a message: DELETE /v1/spaces/{space}/messages/{id}. - allow: { method: DELETE, path: "/v1/spaces/**" } - # Public certificates used to verify inbound Chat / add-on JWT signatures. + # Inbound JWT verification certs (read-only, public). GET /** not pinned: + # google-auth-library selects the cert URL/format by version, so pinning risks + # breaking inbound verification on a bundle bump. GET-only, no credential rewrite. - host: www.googleapis.com port: 443 protocol: rest From 05a22093ec3b6d32cab7c0236b2b122c8c9e3590 Mon Sep 17 00:00:00 2001 From: Hung Le Date: Thu, 2 Jul 2026 12:26:53 +0530 Subject: [PATCH 13/50] feat(messaging): keep Google Chat hook files out of the eager side-effect-import graph --- .../channels/googlechat/hooks/tunnel-runtime.ts | 11 ++++++++--- src/lib/messaging/channels/manifests.test.ts | 4 ++++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/lib/messaging/channels/googlechat/hooks/tunnel-runtime.ts b/src/lib/messaging/channels/googlechat/hooks/tunnel-runtime.ts index 16925f90c0..a421707011 100644 --- a/src/lib/messaging/channels/googlechat/hooks/tunnel-runtime.ts +++ b/src/lib/messaging/channels/googlechat/hooks/tunnel-runtime.ts @@ -1,9 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { execSync } from "node:child_process"; import { DASHBOARD_PORT } from "../../../../core/ports"; -import { prompt } from "../../../../credentials/store"; import { getTunnelUrl as getServiceTunnelUrl, readCloudflaredState, @@ -16,11 +14,14 @@ import type { GooglechatTunnelAudienceGateHookOptions } from "./tunnel-audience- // Side-effectful defaults for the tunnel/audience gate, kept out of the hook // file itself. The gate composes these with the same service helpers // `nemoclaw tunnel start/status/stop` use, so it targets the same tunnel. +// 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. export function createDefaultGooglechatTunnelGateOptions(): GooglechatTunnelAudienceGateHookOptions { const dashboardPort = DASHBOARD_PORT; return { 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 { @@ -35,6 +36,10 @@ export function createDefaultGooglechatTunnelGateOptions(): GooglechatTunnelAudi stopCloudflared(); }, getTunnelUrl: () => getServiceTunnelUrl(resolveServicePidDir(), dashboardPort), - prompt: (question: string) => prompt(question), + prompt: (question: string) => { + const { prompt } = + require("../../../../credentials/store") as typeof import("../../../../credentials/store"); + return prompt(question); + }, }; } diff --git a/src/lib/messaging/channels/manifests.test.ts b/src/lib/messaging/channels/manifests.test.ts index 4b50b2f8ef..c5c59a20eb 100644 --- a/src/lib/messaging/channels/manifests.test.ts +++ b/src/lib/messaging/channels/manifests.test.ts @@ -267,6 +267,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", ]; From 9c2e59db13b0d26718997c9c5b5cc97c7d591d6e Mon Sep 17 00:00:00 2001 From: Hung Le Date: Thu, 2 Jul 2026 13:27:41 +0530 Subject: [PATCH 14/50] feat(messaging): unit-test the Google Chat runtime preloads and bridge provider --- .../runtime/googlechat-outbound-auth.test.ts | 114 ++++++++ .../runtime/googlechat-outbound-auth.ts | 10 + .../googlechat-trusted-proxy-fetch.test.ts | 103 ++++++++ .../runtime/googlechat-trusted-proxy-fetch.ts | 9 + .../googlechat-bridge-provider.test.ts | 243 ++++++++++++++++++ 5 files changed, 479 insertions(+) create mode 100644 src/lib/messaging/channels/googlechat/runtime/googlechat-outbound-auth.test.ts create mode 100644 src/lib/messaging/channels/googlechat/runtime/googlechat-trusted-proxy-fetch.test.ts create mode 100644 src/lib/onboard/googlechat-bridge-provider.test.ts 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..5586fadab4 --- /dev/null +++ b/src/lib/messaging/channels/googlechat/runtime/googlechat-outbound-auth.test.ts @@ -0,0 +1,114 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { outboundAuthPatchInternals } from "./googlechat-outbound-auth"; + +// The self-installing preload publishes its pure helpers here on require (above). +const { patchSource, buildShortCircuit, isPatchError } = outboundAuthPatchInternals as { + patchSource: (source: string, filename: string) => string; + buildShortCircuit: () => string; + isPatchError: (reason: unknown) => 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("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"; + let saved: string | undefined; + + beforeEach(() => { + saved = process.env[ENV]; + }); + afterEach(() => { + if (saved === undefined) delete process.env[ENV]; + else process.env[ENV] = saved; + }); + + // 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", () => { + process.env[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", () => { + process.env[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", () => { + delete process.env[ENV]; + 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 index a6e14a1ba4..acf464174c 100644 --- a/src/lib/messaging/channels/googlechat/runtime/googlechat-outbound-auth.ts +++ b/src/lib/messaging/channels/googlechat/runtime/googlechat-outbound-auth.ts @@ -55,6 +55,12 @@ // 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. +export const outboundAuthPatchInternals = {}; + (function () { "use strict"; @@ -190,6 +196,10 @@ } } + outboundAuthPatchInternals.patchSource = patchGooglechatOutboundAuthSource; + outboundAuthPatchInternals.buildShortCircuit = buildBearerShortCircuitSource; + outboundAuthPatchInternals.isPatchError = isGooglechatOutboundAuthPatchError; + try { installGooglechatOutboundAuthPatch(); process.stderr.write( 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..8b78750645 --- /dev/null +++ b/src/lib/messaging/channels/googlechat/runtime/googlechat-trusted-proxy-fetch.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 } from "vitest"; +import { trustedProxyFetchPatchInternals } from "./googlechat-trusted-proxy-fetch"; + +const { patchSource, isPatchError } = trustedProxyFetchPatchInternals as { + patchSource: (source: string, filename: string) => string; + isPatchError: (reason: unknown) => boolean; +}; + +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,", + " });", + "}", +].join("\n"); + +describe("googlechat trusted-proxy-fetch patch", () => { + 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); + }); +}); 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 index 43bcbfc649..4c98f186e2 100644 --- a/src/lib/messaging/channels/googlechat/runtime/googlechat-trusted-proxy-fetch.ts +++ b/src/lib/messaging/channels/googlechat/runtime/googlechat-trusted-proxy-fetch.ts @@ -52,6 +52,12 @@ // module loader hooks). It targets the SAME dist chunk that // googlechat-outbound-auth already patches, so the loader-hook coverage is proven. +// 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. +export const trustedProxyFetchPatchInternals = {}; + (function () { "use strict"; @@ -208,6 +214,9 @@ } } + trustedProxyFetchPatchInternals.patchSource = patchTrustedProxyFetchSource; + trustedProxyFetchPatchInternals.isPatchError = isTrustedProxyFetchPatchError; + try { installTrustedProxyFetchPatch(); process.stderr.write( diff --git a/src/lib/onboard/googlechat-bridge-provider.test.ts b/src/lib/onboard/googlechat-bridge-provider.test.ts new file mode 100644 index 0000000000..a11da96894 --- /dev/null +++ b/src/lib/onboard/googlechat-bridge-provider.test.ts @@ -0,0 +1,243 @@ +// 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 { + configureGooglechatBridgeRefresh, + ensureGooglechatBridgeProfile, + GOOGLECHAT_BRIDGE_CREDENTIAL_KEY, + GOOGLECHAT_BRIDGE_PENDING_VALUE, + GOOGLECHAT_BRIDGE_PROFILE_ID, + GOOGLECHAT_SERVICE_ACCOUNT_ENV, + maybeGooglechatBridgeTokenDef, + resolveGooglechatServiceAccount, +} from "./googlechat-bridge-provider"; + +const SA_JSON = JSON.stringify({ + client_email: "bot@p.iam.gserviceaccount.com", + private_key: "-----BEGIN PRIVATE KEY-----\nMIIabc\n-----END PRIVATE KEY-----\n", +}); +const normalizeCredentialValue = (v: unknown) => String(v ?? "").trim(); +const redact = (s: string) => s; +const noLog = vi.fn(); + +const BRIDGE_DEF = { + name: "sbx-googlechat-bridge", + providerType: GOOGLECHAT_BRIDGE_PROFILE_ID, + token: GOOGLECHAT_BRIDGE_PENDING_VALUE, +}; + +function tokenDefInput( + overrides: Partial[0]> = {}, +) { + return { + sandboxName: "sbx", + getCredential: () => null, + enabledChannels: ["googlechat"], + disabledChannelNames: new Set(), + ...overrides, + }; +} + +describe("resolveGooglechatServiceAccount", () => { + it("prefers the credential store over the injected env", () => { + const value = resolveGooglechatServiceAccount({ + getCredential: (k) => (k === GOOGLECHAT_SERVICE_ACCOUNT_ENV ? "from-store" : null), + env: { [GOOGLECHAT_SERVICE_ACCOUNT_ENV]: "from-env" }, + normalizeCredentialValue, + }); + expect(value).toBe("from-store"); + }); + + it("falls back to the injected env when the store is empty", () => { + const value = resolveGooglechatServiceAccount({ + getCredential: () => null, + env: { [GOOGLECHAT_SERVICE_ACCOUNT_ENV]: " from-env " }, + normalizeCredentialValue, + }); + expect(value).toBe("from-env"); + }); + + it("returns null when neither store nor env has the value", () => { + expect( + resolveGooglechatServiceAccount({ + getCredential: () => null, + env: {}, + normalizeCredentialValue, + }), + ).toBeNull(); + }); +}); + +describe("maybeGooglechatBridgeTokenDef", () => { + it("returns null when Google Chat is disabled", () => { + expect( + maybeGooglechatBridgeTokenDef( + tokenDefInput({ + getCredential: () => SA_JSON, + disabledChannelNames: new Set(["googlechat"]), + }), + ), + ).toBeNull(); + }); + + it("returns null when Google Chat is not in the enabled channels", () => { + expect( + maybeGooglechatBridgeTokenDef( + tokenDefInput({ getCredential: () => SA_JSON, enabledChannels: ["slack"] }), + ), + ).toBeNull(); + }); + + it("returns null when no service account is available", () => { + expect(maybeGooglechatBridgeTokenDef(tokenDefInput())).toBeNull(); + }); + + it("emits the bridge token def when the service account is in the store", () => { + const def = maybeGooglechatBridgeTokenDef(tokenDefInput({ getCredential: () => SA_JSON })); + expect(def).toEqual({ + name: "sbx-googlechat-bridge", + envKey: GOOGLECHAT_BRIDGE_CREDENTIAL_KEY, + token: GOOGLECHAT_BRIDGE_PENDING_VALUE, + providerType: GOOGLECHAT_BRIDGE_PROFILE_ID, + }); + }); + + it("emits the bridge token def from an env-only service account (resolution parity)", () => { + const def = maybeGooglechatBridgeTokenDef( + tokenDefInput({ + getCredential: () => null, + env: { [GOOGLECHAT_SERVICE_ACCOUNT_ENV]: SA_JSON }, + normalizeCredentialValue, + }), + ); + expect(def?.providerType).toBe(GOOGLECHAT_BRIDGE_PROFILE_ID); + expect(def?.envKey).toBe(GOOGLECHAT_BRIDGE_CREDENTIAL_KEY); + }); +}); + +describe("configureGooglechatBridgeRefresh", () => { + it("is a no-op success when there is no bridge token def", () => { + const runOpenshell = vi.fn(); + const result = configureGooglechatBridgeRefresh([], { + runOpenshell, + redact, + getCredential: () => SA_JSON, + log: noLog, + }); + expect(result).toEqual({ ok: true }); + expect(runOpenshell).not.toHaveBeenCalled(); + }); + + it("fails closed when the service account is unavailable", () => { + const result = configureGooglechatBridgeRefresh([BRIDGE_DEF], { + runOpenshell: vi.fn(), + redact, + getCredential: () => null, + log: noLog, + }); + expect(result.ok).toBe(false); + }); + + it("fails closed when the service account JSON cannot be parsed", () => { + const result = configureGooglechatBridgeRefresh([BRIDGE_DEF], { + runOpenshell: vi.fn(), + redact, + getCredential: () => "not json", + log: noLog, + }); + expect(result.ok).toBe(false); + }); + + it("fails closed when client_email or private_key is missing", () => { + const result = configureGooglechatBridgeRefresh([BRIDGE_DEF], { + runOpenshell: vi.fn(), + redact, + getCredential: () => JSON.stringify({ client_email: "x@y" }), + log: noLog, + }); + expect(result.ok).toBe(false); + }); + + it("configures refresh and returns ok when runOpenshell succeeds", () => { + const runOpenshell = vi.fn((_args: string[], _opts: unknown) => ({ status: 0 })); + const result = configureGooglechatBridgeRefresh([BRIDGE_DEF], { + runOpenshell, + redact, + getCredential: () => SA_JSON, + log: noLog, + }); + 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(GOOGLECHAT_BRIDGE_CREDENTIAL_KEY); + expect(args).toContain("google-service-account-jwt"); + expect(args).toContain("client_email=bot@p.iam.gserviceaccount.com"); + expect(args).toContain("private_key"); + expect(args).toContain("sbx-googlechat-bridge"); + }); + + it("fails closed when runOpenshell exits nonzero", () => { + const runOpenshell = vi.fn(() => ({ status: 1, stderr: "gateway rejected the material" })); + const result = configureGooglechatBridgeRefresh([BRIDGE_DEF], { + runOpenshell, + redact, + getCredential: () => SA_JSON, + log: noLog, + }); + expect(result.ok).toBe(false); + expect(result.reason).toBeTruthy(); + }); + + it("resolves the service account from the injected env too (parity)", () => { + const runOpenshell = vi.fn(() => ({ status: 0 })); + const result = configureGooglechatBridgeRefresh([BRIDGE_DEF], { + runOpenshell, + redact, + getCredential: () => null, + env: { [GOOGLECHAT_SERVICE_ACCOUNT_ENV]: SA_JSON }, + normalizeCredentialValue, + log: noLog, + }); + expect(result).toEqual({ ok: true }); + expect(runOpenshell).toHaveBeenCalledTimes(1); + }); +}); + +describe("ensureGooglechatBridgeProfile", () => { + const baseDeps = () => ({ + root: "/repo", + redact, + log: noLog, + exit: vi.fn(() => undefined as never), + }); + + it("does nothing when there is no bridge token def", () => { + const runOpenshell = vi.fn(); + ensureGooglechatBridgeProfile([], { ...baseDeps(), runOpenshell }); + expect(runOpenshell).not.toHaveBeenCalled(); + }); + + it("imports the profile and does not exit on success", () => { + const runOpenshell = vi.fn(() => ({ status: 0 })); + const exit = vi.fn(() => undefined as never); + ensureGooglechatBridgeProfile([BRIDGE_DEF], { ...baseDeps(), runOpenshell, exit }); + expect(runOpenshell).toHaveBeenCalledTimes(1); + 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); + ensureGooglechatBridgeProfile([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); + ensureGooglechatBridgeProfile([BRIDGE_DEF], { ...baseDeps(), runOpenshell, exit }); + expect(exit).toHaveBeenCalled(); + }); +}); From 174c8536bc10cc4a64bbf9640d535d77f524ac58 Mon Sep 17 00:00:00 2001 From: Hung Le Date: Thu, 2 Jul 2026 14:36:18 +0530 Subject: [PATCH 15/50] feat(messaging): register the Google Chat rendered-config parser --- .../googlechat/rendered-config-parser.ts | 46 +++++++++++++++++++ .../channels/rendered-config-parser.ts | 3 ++ 2 files changed, 49 insertions(+) create mode 100644 src/lib/messaging/channels/googlechat/rendered-config-parser.ts 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..30d6bfe4a5 --- /dev/null +++ b/src/lib/messaging/channels/googlechat/rendered-config-parser.ts @@ -0,0 +1,46 @@ +// 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("webhookPath", "openclaw.json", [ + "channels", + "googlechat", + "webhookPath", + ]), + structuredConfigKey("allowFrom", "openclaw.json", [ + "channels", + "googlechat", + "dm", + "allowFrom", + ]), + ]; + }, + + getValue(key, source) { + return getStructuredConfigValue(source, key.path); + }, +}; 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": From 360970cd86c4348bf6449fe1373571b7fefb31df Mon Sep 17 00:00:00 2001 From: Hung Le Date: Thu, 2 Jul 2026 14:53:40 +0530 Subject: [PATCH 16/50] feat(messaging): satisfy the private-key + no-if guardrails in Google Chat tests --- .../runtime/googlechat-outbound-auth.test.ts | 15 +++++---------- .../onboard/googlechat-bridge-provider.test.ts | 2 +- 2 files changed, 6 insertions(+), 11 deletions(-) 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 index 5586fadab4..1b215d1cf7 100644 --- a/src/lib/messaging/channels/googlechat/runtime/googlechat-outbound-auth.test.ts +++ b/src/lib/messaging/channels/googlechat/runtime/googlechat-outbound-auth.test.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +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). @@ -68,14 +68,9 @@ describe("googlechat outbound-auth patch", () => { describe("injected short-circuit runtime behavior", () => { const ENV = "GOOGLE_CHAT_ACCESS_TOKEN"; - let saved: string | undefined; - beforeEach(() => { - saved = process.env[ENV]; - }); afterEach(() => { - if (saved === undefined) delete process.env[ENV]; - else process.env[ENV] = saved; + vi.unstubAllEnvs(); }); // Build a callable from the patched producer whose original body returns a @@ -89,17 +84,17 @@ describe("googlechat outbound-auth patch", () => { } it("returns the revision-less canonical placeholder when a stamped placeholder is set", () => { - process.env[ENV] = "openshell:resolve:env:v7_GOOGLE_CHAT_ACCESS_TOKEN"; + 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", () => { - process.env[ENV] = "ya29.raw-access-token"; + 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", () => { - delete process.env[ENV]; + vi.stubEnv(ENV, undefined); const producer = buildProducer(); expect(producer).toThrow(/GOOGLE_CHAT_ACCESS_TOKEN is not set/); }); diff --git a/src/lib/onboard/googlechat-bridge-provider.test.ts b/src/lib/onboard/googlechat-bridge-provider.test.ts index a11da96894..4591b92ef9 100644 --- a/src/lib/onboard/googlechat-bridge-provider.test.ts +++ b/src/lib/onboard/googlechat-bridge-provider.test.ts @@ -15,7 +15,7 @@ import { const SA_JSON = JSON.stringify({ client_email: "bot@p.iam.gserviceaccount.com", - private_key: "-----BEGIN PRIVATE KEY-----\nMIIabc\n-----END PRIVATE KEY-----\n", + private_key: "fake-test-private-key-material", }); const normalizeCredentialValue = (v: unknown) => String(v ?? "").trim(); const redact = (s: string) => s; From e33b925c603438eb97e62f537c82d5f243cfdcb1 Mon Sep 17 00:00:00 2001 From: Hung Le Date: Thu, 2 Jul 2026 22:54:24 +0530 Subject: [PATCH 17/50] feat(messaging): disable gateway config hot-reload for Google Chat --- .../messaging/channels/googlechat/manifest.ts | 23 +++++++++++++++++++ src/lib/messaging/channels/manifests.test.ts | 5 ++++ 2 files changed, 28 insertions(+) diff --git a/src/lib/messaging/channels/googlechat/manifest.ts b/src/lib/messaging/channels/googlechat/manifest.ts index ab4220f7fb..1ef02d6a04 100644 --- a/src/lib/messaging/channels/googlechat/manifest.ts +++ b/src/lib/messaging/channels/googlechat/manifest.ts @@ -151,6 +151,29 @@ export const googlechatManifest = { }, }, }, + { + // Turn OFF OpenClaw's config hot-reload for this sandbox. + // Safe: the sandbox's openclaw.json is fixed at build time (sealed 0600 + + // integrity hash), so nothing should reload it while running. + // Needed: ~60s after boot, OpenClaw rewrites its OWN config (it adds default + // provider-plugin entries). If hot-reload is ON, OpenClaw reacts to that + // self-write by reloading plugins, which rebuilds its HTTP route table and + // drops the Google Chat inbound webhook route — so incoming messages 404 and + // the bot goes silent ~60s after every start. + // "off" tells OpenClaw to ignore config-file changes (see @openclaw + // config-reload.ts). NemoClaw still restarts the gateway itself when it needs + // to (rebuild / `nemoclaw gateway restart`). + id: "googlechat-openclaw-gateway-reload-off", + kind: "json-fragment", + agent: "openclaw", + target: "openclaw.json", + fragment: { + path: "gateway.reload", + value: { + mode: "off", + }, + }, + }, ], runtime: { openclaw: { diff --git a/src/lib/messaging/channels/manifests.test.ts b/src/lib/messaging/channels/manifests.test.ts index d01f259e71..735d1ec742 100644 --- a/src/lib/messaging/channels/manifests.test.ts +++ b/src/lib/messaging/channels/manifests.test.ts @@ -965,6 +965,11 @@ describe("built-in channel manifests", () => { expect(render).toContain("googlechatConfig.appPrincipal"); // OpenClaw-only: no Hermes env-lines / platform render. expect(render).not.toContain("~/.hermes"); + // Disable the gateway's reactive config hot-reload: OpenClaw's ~60s post-boot + // provider-plugin auto-enable self-write would otherwise reload plugins, swap the + // HTTP route registry, and drop the Google Chat inbound webhook route (404 → silent). + expect(render).toContain('"path":"gateway.reload"'); + expect(render).toContain('"mode":"off"'); expect(findHook(googlechatManifest, "googlechat-tunnel-audience-gate")).toMatchObject({ phase: "enroll", From 5e656cdd0270807f9555e1c2b9c4f8ed5448791e Mon Sep 17 00:00:00 2001 From: Hung Le Date: Thu, 2 Jul 2026 23:54:26 +0530 Subject: [PATCH 18/50] feat(messaging): always skip Google Chat in non-interactive mode --- .../hooks/tunnel-audience-gate.test.ts | 19 ++++++++++-- .../googlechat/hooks/tunnel-audience-gate.ts | 30 ++++++++++++------- 2 files changed, 35 insertions(+), 14 deletions(-) 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 index 1d978d413f..d4714060e8 100644 --- a/src/lib/messaging/channels/googlechat/hooks/tunnel-audience-gate.test.ts +++ b/src/lib/messaging/channels/googlechat/hooks/tunnel-audience-gate.test.ts @@ -72,9 +72,22 @@ describe("googlechat tunnel/audience gate hook", () => { expect(startTunnel).not.toHaveBeenCalled(); }); - it("skips (throws) in non-interactive mode without an explicit audience", async () => { - const hook = createGooglechatTunnelAudienceGateHook(baseOptions()); - await expect(hook(gateContext({}, false))).rejects.toThrow(/non-interactive/); + 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 mode/); + // 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 mode/); + // 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 () => { diff --git a/src/lib/messaging/channels/googlechat/hooks/tunnel-audience-gate.ts b/src/lib/messaging/channels/googlechat/hooks/tunnel-audience-gate.ts index 59ea0d8704..c0d191ad72 100644 --- a/src/lib/messaging/channels/googlechat/hooks/tunnel-audience-gate.ts +++ b/src/lib/messaging/channels/googlechat/hooks/tunnel-audience-gate.ts @@ -71,6 +71,22 @@ export function createGooglechatTunnelAudienceGateHook( 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("Google Chat enrollment requires interactive mode."); + } + const audienceType = readString(context.inputs?.audienceType) || "app-url"; const webhookPath = normalizeWebhookPath( readString(context.inputs?.webhookPath) || readString(env.GOOGLECHAT_WEBHOOK_PATH), @@ -86,14 +102,6 @@ export function createGooglechatTunnelAudienceGateHook( // other audienceType (e.g. project-number) is entered via the config prompt. if (audienceType !== "app-url") return {}; - // Non-interactive (CI/E2E): never spawn a tunnel; require an explicit audience. - if (context.isInteractive === false) { - log(" Skipped googlechat (set GOOGLECHAT_AUDIENCE to use Google Chat non-interactively)"); - throw new Error( - "Google Chat app-url audience requires GOOGLECHAT_AUDIENCE in non-interactive mode.", - ); - } - const readTunnelState = requireOption(options.readTunnelState, "readTunnelState"); const getTunnelUrl = requireOption(options.getTunnelUrl, "getTunnelUrl"); const stopTunnel = requireOption(options.stopTunnel, "stopTunnel"); @@ -129,9 +137,9 @@ export function createGooglechatTunnelAudienceGateHook( const audience = `${url.replace(/\/+$/, "")}${webhookPath}`; printEndpointInstructions(log, audience); - // Non-interactive app-url already returned/threw above, so this prompt path - // is only reached interactively. Mirrors promptYesNoOrDefault: default No, - // y/yes wins. + // 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]: ", From f301d9ef349383ca70c983a892d3b5ff4c4f25a2 Mon Sep 17 00:00:00 2001 From: Hung Le Date: Fri, 3 Jul 2026 01:04:14 +0530 Subject: [PATCH 19/50] feat(messaging): auto-seed Google Chat appPrincipal discovery sentinel --- .../messaging/channels/googlechat/manifest.ts | 20 ++++++++++++++++--- .../googlechat/template-resolver.test.ts | 8 +++++--- .../channels/googlechat/template-resolver.ts | 15 +++++++++++++- 3 files changed, 36 insertions(+), 7 deletions(-) diff --git a/src/lib/messaging/channels/googlechat/manifest.ts b/src/lib/messaging/channels/googlechat/manifest.ts index 1ef02d6a04..3e0cb7506c 100644 --- a/src/lib/messaging/channels/googlechat/manifest.ts +++ b/src/lib/messaging/channels/googlechat/manifest.ts @@ -15,9 +15,23 @@ export const googlechatManifest = { displayName: "Google Chat", description: "Google Chat (Chat API) bot messaging", enrollmentNotes: [ - "Google Workspace accounts need no appPrincipal.", - "Personal/standalone Google accounts are served as Workspace add-ons and also need channels.googlechat.appPrincipal — the add-on's numeric OAuth client ID (~21 digits, not an email). Paste it at the appPrincipal prompt if you know it.", - "To capture appPrincipal: once the bot is live, send ONE direct message to it, then read the gateway log for `unexpected add-on principal: ` (set a placeholder appPrincipal first, or the log reports `missing add-on principal binding` with no number). Set GOOGLECHAT_APP_PRINCIPAL= (or paste it) and rebuild to persist.", + "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). Leave it blank at the prompt: a discovery placeholder is seeded", + " so the first message reveals the real value. Once the bot 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 cloudflared tunnel exposes the whole dashboard port publicly; open the Control UI from http://127.0.0.1:18789 (localhost), not the public URL.", ], supportedAgents: ["openclaw"], diff --git a/src/lib/messaging/channels/googlechat/template-resolver.test.ts b/src/lib/messaging/channels/googlechat/template-resolver.test.ts index 83dce2b99c..a8c0eb7d9f 100644 --- a/src/lib/messaging/channels/googlechat/template-resolver.test.ts +++ b/src/lib/messaging/channels/googlechat/template-resolver.test.ts @@ -25,7 +25,7 @@ describe("Google Chat template resolver", () => { ).toBe("/googlechat"); }); - it("passes through configured values and drops audience/appPrincipal when unset", () => { + 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"), @@ -45,14 +45,16 @@ describe("Google Chat template resolver", () => { resolveGooglechatTemplateReference("googlechatConfig.webhookPath", { inputs: set })?.value, ).toBe("/gchat"); - // Unset → undefined so the render engine drops the key entirely. + // 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, - ).toBeUndefined(); + ).toBe("000000000000000000000"); }); it("normalizes the DM allowlist into nested dm.policy / dm.allowFrom", () => { diff --git a/src/lib/messaging/channels/googlechat/template-resolver.ts b/src/lib/messaging/channels/googlechat/template-resolver.ts index 32dde2f520..990a2438b1 100644 --- a/src/lib/messaging/channels/googlechat/template-resolver.ts +++ b/src/lib/messaging/channels/googlechat/template-resolver.ts @@ -13,6 +13,18 @@ import { const DEFAULT_AUDIENCE_TYPE = "app-url"; const DEFAULT_WEBHOOK_PATH = "/googlechat"; +// 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, @@ -29,7 +41,8 @@ export const resolveGooglechatTemplateReference: BuiltInRenderTemplateResolver = ); case "googlechatConfig.appPrincipal": return resolvedRenderTemplateReference( - nonEmptyString(stateValue(context, "googlechatConfig.appPrincipal")), + nonEmptyString(stateValue(context, "googlechatConfig.appPrincipal")) ?? + APP_PRINCIPAL_DISCOVERY_SENTINEL, ); case "googlechatConfig.webhookPath": return resolvedRenderTemplateReference( From f95165e6fbf3bc0da20fd6e5ffdf0eb8049a2879 Mon Sep 17 00:00:00 2001 From: Hung Le Date: Fri, 3 Jul 2026 01:23:08 +0530 Subject: [PATCH 20/50] feat(messaging): clarify Google Chat appPrincipal capture note --- src/lib/messaging/channels/googlechat/manifest.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/lib/messaging/channels/googlechat/manifest.ts b/src/lib/messaging/channels/googlechat/manifest.ts index 3e0cb7506c..b2d4dd4f4f 100644 --- a/src/lib/messaging/channels/googlechat/manifest.ts +++ b/src/lib/messaging/channels/googlechat/manifest.ts @@ -21,8 +21,13 @@ export const googlechatManifest = { " 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). Leave it blank at the prompt: a discovery placeholder is seeded", - " so the first message reveals the real value. Once the bot is live:", + " 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).", From b3dc2031edbaaf0dd3fa0e27b21713774bb16b73 Mon Sep 17 00:00:00 2001 From: Hung Le Date: Fri, 3 Jul 2026 14:40:45 +0530 Subject: [PATCH 21/50] feat(messaging): co-locate Google Chat policy and provider profile --- .../channels/googlechat/policy/openclaw.yaml | 0 .../googlechat/provider-profile/openclaw.yaml | 0 src/lib/messaging/channels/policy.test.ts | 1 + src/lib/onboard/googlechat-bridge-provider.ts | 18 +++++++++++++++--- 4 files changed, 16 insertions(+), 3 deletions(-) rename nemoclaw-blueprint/policies/presets/googlechat.yaml => src/lib/messaging/channels/googlechat/policy/openclaw.yaml (100%) rename nemoclaw-blueprint/provider-profiles/google-chat-bridge.yaml => src/lib/messaging/channels/googlechat/provider-profile/openclaw.yaml (100%) diff --git a/nemoclaw-blueprint/policies/presets/googlechat.yaml b/src/lib/messaging/channels/googlechat/policy/openclaw.yaml similarity index 100% rename from nemoclaw-blueprint/policies/presets/googlechat.yaml rename to src/lib/messaging/channels/googlechat/policy/openclaw.yaml diff --git a/nemoclaw-blueprint/provider-profiles/google-chat-bridge.yaml b/src/lib/messaging/channels/googlechat/provider-profile/openclaw.yaml similarity index 100% rename from nemoclaw-blueprint/provider-profiles/google-chat-bridge.yaml rename to src/lib/messaging/channels/googlechat/provider-profile/openclaw.yaml diff --git a/src/lib/messaging/channels/policy.test.ts b/src/lib/messaging/channels/policy.test.ts index eda57a434d..3242cf1273 100644 --- a/src/lib/messaging/channels/policy.test.ts +++ b/src/lib/messaging/channels/policy.test.ts @@ -63,6 +63,7 @@ describe("messaging channel policy presets", () => { const presets = listMessagingChannelPolicyPresets(); expect(presets.map((preset) => preset.name).sort()).toEqual([ "discord", + "googlechat", "slack", "teams", "telegram", diff --git a/src/lib/onboard/googlechat-bridge-provider.ts b/src/lib/onboard/googlechat-bridge-provider.ts index c18157fc48..35cf889664 100644 --- a/src/lib/onboard/googlechat-bridge-provider.ts +++ b/src/lib/onboard/googlechat-bridge-provider.ts @@ -5,8 +5,9 @@ import path from "node:path"; import { compactText } from "../core/url-utils"; -// Profile id registered with OpenShell (nemoclaw-blueprint/provider-profiles/ -// google-chat-bridge.yaml) and passed as `provider create --type`. +// Profile id registered with OpenShell (the profile YAML is co-located with the +// channel at src/lib/messaging/channels/googlechat/provider-profile/openclaw.yaml, +// mirroring the per-channel policy layout) and passed as `provider create --type`. export const GOOGLECHAT_BRIDGE_PROFILE_ID = "google-chat-bridge"; // Injectable credential key the gateway mints + the L7 proxy injects as @@ -78,7 +79,18 @@ function bufferOrStringToText(value: string | Buffer | null | undefined): string } export function googlechatBridgeProfilePath(root: string): string { - return path.join(root, "nemoclaw-blueprint", "provider-profiles", "google-chat-bridge.yaml"); + // Co-located with the channel (mirrors /policy/.yaml), read + // ROOT-relative from the source tree the same way channel policy presets are. + return path.join( + root, + "src", + "lib", + "messaging", + "channels", + "googlechat", + "provider-profile", + "openclaw.yaml", + ); } /** From ff984f458baac8caaa456e2612d1ea214e2cc039 Mon Sep 17 00:00:00 2001 From: Hung Le Date: Mon, 6 Jul 2026 08:46:19 +0530 Subject: [PATCH 22/50] feat(messaging): drive bridge provider setup generically from channel profiles --- .../googlechat-bridge-provider.test.ts | 243 ---------- src/lib/onboard/googlechat-bridge-provider.ts | 279 ------------ .../onboard/messaging-bridge-provider.test.ts | 258 +++++++++++ src/lib/onboard/messaging-bridge-provider.ts | 429 ++++++++++++++++++ src/lib/onboard/messaging-prep.ts | 34 +- src/lib/onboard/providers.ts | 51 ++- 6 files changed, 742 insertions(+), 552 deletions(-) delete mode 100644 src/lib/onboard/googlechat-bridge-provider.test.ts delete mode 100644 src/lib/onboard/googlechat-bridge-provider.ts create mode 100644 src/lib/onboard/messaging-bridge-provider.test.ts create mode 100644 src/lib/onboard/messaging-bridge-provider.ts diff --git a/src/lib/onboard/googlechat-bridge-provider.test.ts b/src/lib/onboard/googlechat-bridge-provider.test.ts deleted file mode 100644 index 4591b92ef9..0000000000 --- a/src/lib/onboard/googlechat-bridge-provider.test.ts +++ /dev/null @@ -1,243 +0,0 @@ -// 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 { - configureGooglechatBridgeRefresh, - ensureGooglechatBridgeProfile, - GOOGLECHAT_BRIDGE_CREDENTIAL_KEY, - GOOGLECHAT_BRIDGE_PENDING_VALUE, - GOOGLECHAT_BRIDGE_PROFILE_ID, - GOOGLECHAT_SERVICE_ACCOUNT_ENV, - maybeGooglechatBridgeTokenDef, - resolveGooglechatServiceAccount, -} from "./googlechat-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(); - -const BRIDGE_DEF = { - name: "sbx-googlechat-bridge", - providerType: GOOGLECHAT_BRIDGE_PROFILE_ID, - token: GOOGLECHAT_BRIDGE_PENDING_VALUE, -}; - -function tokenDefInput( - overrides: Partial[0]> = {}, -) { - return { - sandboxName: "sbx", - getCredential: () => null, - enabledChannels: ["googlechat"], - disabledChannelNames: new Set(), - ...overrides, - }; -} - -describe("resolveGooglechatServiceAccount", () => { - it("prefers the credential store over the injected env", () => { - const value = resolveGooglechatServiceAccount({ - getCredential: (k) => (k === GOOGLECHAT_SERVICE_ACCOUNT_ENV ? "from-store" : null), - env: { [GOOGLECHAT_SERVICE_ACCOUNT_ENV]: "from-env" }, - normalizeCredentialValue, - }); - expect(value).toBe("from-store"); - }); - - it("falls back to the injected env when the store is empty", () => { - const value = resolveGooglechatServiceAccount({ - getCredential: () => null, - env: { [GOOGLECHAT_SERVICE_ACCOUNT_ENV]: " from-env " }, - normalizeCredentialValue, - }); - expect(value).toBe("from-env"); - }); - - it("returns null when neither store nor env has the value", () => { - expect( - resolveGooglechatServiceAccount({ - getCredential: () => null, - env: {}, - normalizeCredentialValue, - }), - ).toBeNull(); - }); -}); - -describe("maybeGooglechatBridgeTokenDef", () => { - it("returns null when Google Chat is disabled", () => { - expect( - maybeGooglechatBridgeTokenDef( - tokenDefInput({ - getCredential: () => SA_JSON, - disabledChannelNames: new Set(["googlechat"]), - }), - ), - ).toBeNull(); - }); - - it("returns null when Google Chat is not in the enabled channels", () => { - expect( - maybeGooglechatBridgeTokenDef( - tokenDefInput({ getCredential: () => SA_JSON, enabledChannels: ["slack"] }), - ), - ).toBeNull(); - }); - - it("returns null when no service account is available", () => { - expect(maybeGooglechatBridgeTokenDef(tokenDefInput())).toBeNull(); - }); - - it("emits the bridge token def when the service account is in the store", () => { - const def = maybeGooglechatBridgeTokenDef(tokenDefInput({ getCredential: () => SA_JSON })); - expect(def).toEqual({ - name: "sbx-googlechat-bridge", - envKey: GOOGLECHAT_BRIDGE_CREDENTIAL_KEY, - token: GOOGLECHAT_BRIDGE_PENDING_VALUE, - providerType: GOOGLECHAT_BRIDGE_PROFILE_ID, - }); - }); - - it("emits the bridge token def from an env-only service account (resolution parity)", () => { - const def = maybeGooglechatBridgeTokenDef( - tokenDefInput({ - getCredential: () => null, - env: { [GOOGLECHAT_SERVICE_ACCOUNT_ENV]: SA_JSON }, - normalizeCredentialValue, - }), - ); - expect(def?.providerType).toBe(GOOGLECHAT_BRIDGE_PROFILE_ID); - expect(def?.envKey).toBe(GOOGLECHAT_BRIDGE_CREDENTIAL_KEY); - }); -}); - -describe("configureGooglechatBridgeRefresh", () => { - it("is a no-op success when there is no bridge token def", () => { - const runOpenshell = vi.fn(); - const result = configureGooglechatBridgeRefresh([], { - runOpenshell, - redact, - getCredential: () => SA_JSON, - log: noLog, - }); - expect(result).toEqual({ ok: true }); - expect(runOpenshell).not.toHaveBeenCalled(); - }); - - it("fails closed when the service account is unavailable", () => { - const result = configureGooglechatBridgeRefresh([BRIDGE_DEF], { - runOpenshell: vi.fn(), - redact, - getCredential: () => null, - log: noLog, - }); - expect(result.ok).toBe(false); - }); - - it("fails closed when the service account JSON cannot be parsed", () => { - const result = configureGooglechatBridgeRefresh([BRIDGE_DEF], { - runOpenshell: vi.fn(), - redact, - getCredential: () => "not json", - log: noLog, - }); - expect(result.ok).toBe(false); - }); - - it("fails closed when client_email or private_key is missing", () => { - const result = configureGooglechatBridgeRefresh([BRIDGE_DEF], { - runOpenshell: vi.fn(), - redact, - getCredential: () => JSON.stringify({ client_email: "x@y" }), - log: noLog, - }); - expect(result.ok).toBe(false); - }); - - it("configures refresh and returns ok when runOpenshell succeeds", () => { - const runOpenshell = vi.fn((_args: string[], _opts: unknown) => ({ status: 0 })); - const result = configureGooglechatBridgeRefresh([BRIDGE_DEF], { - runOpenshell, - redact, - getCredential: () => SA_JSON, - log: noLog, - }); - 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(GOOGLECHAT_BRIDGE_CREDENTIAL_KEY); - expect(args).toContain("google-service-account-jwt"); - expect(args).toContain("client_email=bot@p.iam.gserviceaccount.com"); - expect(args).toContain("private_key"); - expect(args).toContain("sbx-googlechat-bridge"); - }); - - it("fails closed when runOpenshell exits nonzero", () => { - const runOpenshell = vi.fn(() => ({ status: 1, stderr: "gateway rejected the material" })); - const result = configureGooglechatBridgeRefresh([BRIDGE_DEF], { - runOpenshell, - redact, - getCredential: () => SA_JSON, - log: noLog, - }); - expect(result.ok).toBe(false); - expect(result.reason).toBeTruthy(); - }); - - it("resolves the service account from the injected env too (parity)", () => { - const runOpenshell = vi.fn(() => ({ status: 0 })); - const result = configureGooglechatBridgeRefresh([BRIDGE_DEF], { - runOpenshell, - redact, - getCredential: () => null, - env: { [GOOGLECHAT_SERVICE_ACCOUNT_ENV]: SA_JSON }, - normalizeCredentialValue, - log: noLog, - }); - expect(result).toEqual({ ok: true }); - expect(runOpenshell).toHaveBeenCalledTimes(1); - }); -}); - -describe("ensureGooglechatBridgeProfile", () => { - const baseDeps = () => ({ - root: "/repo", - redact, - log: noLog, - exit: vi.fn(() => undefined as never), - }); - - it("does nothing when there is no bridge token def", () => { - const runOpenshell = vi.fn(); - ensureGooglechatBridgeProfile([], { ...baseDeps(), runOpenshell }); - expect(runOpenshell).not.toHaveBeenCalled(); - }); - - it("imports the profile and does not exit on success", () => { - const runOpenshell = vi.fn(() => ({ status: 0 })); - const exit = vi.fn(() => undefined as never); - ensureGooglechatBridgeProfile([BRIDGE_DEF], { ...baseDeps(), runOpenshell, exit }); - expect(runOpenshell).toHaveBeenCalledTimes(1); - 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); - ensureGooglechatBridgeProfile([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); - ensureGooglechatBridgeProfile([BRIDGE_DEF], { ...baseDeps(), runOpenshell, exit }); - expect(exit).toHaveBeenCalled(); - }); -}); diff --git a/src/lib/onboard/googlechat-bridge-provider.ts b/src/lib/onboard/googlechat-bridge-provider.ts deleted file mode 100644 index 35cf889664..0000000000 --- a/src/lib/onboard/googlechat-bridge-provider.ts +++ /dev/null @@ -1,279 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import path from "node:path"; - -import { compactText } from "../core/url-utils"; - -// Profile id registered with OpenShell (the profile YAML is co-located with the -// channel at src/lib/messaging/channels/googlechat/provider-profile/openclaw.yaml, -// mirroring the per-channel policy layout) and passed as `provider create --type`. -export const GOOGLECHAT_BRIDGE_PROFILE_ID = "google-chat-bridge"; - -// Injectable credential key the gateway mints + the L7 proxy injects as -// `Authorization: Bearer` on chat.googleapis.com. MUST match the env var the -// googlechat-outbound-auth runtime preload reads. -export const GOOGLECHAT_BRIDGE_CREDENTIAL_KEY = "GOOGLE_CHAT_ACCESS_TOKEN"; - -// Where the pasted service-account JSON is stored (the tokenPaste enroll hook -// saves it under this env key). Used only as gateway-side refresh MATERIAL — -// it is never delivered into the sandbox. -export const GOOGLECHAT_SERVICE_ACCOUNT_ENV = "GOOGLECHAT_SERVICE_ACCOUNT"; - -// Scope the bot token is minted for (Google Chat bot scope). -export const GOOGLECHAT_BRIDGE_SCOPE = "https://www.googleapis.com/auth/chat.bot"; - -// Sentinel credential value used at `provider create`. 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 GOOGLECHAT_BRIDGE_PENDING_VALUE = "openshell-managed-pending-mint"; - -const GOOGLECHAT_CHANNEL = "googlechat"; - -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 }; - -export type GooglechatBridgeProfileDeps = { - root: string; - runOpenshell: RunOpenshell; - redact: (input: string) => string; - log?: (message?: string) => void; - exit?: (code?: number) => never; -}; - -export type GooglechatBridgeRefreshDeps = { - runOpenshell: RunOpenshell; - redact: (input: string) => string; - getCredential: (envKey: string) => string | null; - env?: NodeJS.ProcessEnv | Record; - normalizeCredentialValue?: (value: unknown) => string; - log?: (message?: string) => void; -}; - -// Result of gateway-refresh configuration. `ok:false` when a bridge token def is -// present (Google Chat active) but minting could not be configured, so the caller -// fails onboarding instead of leaving the channel silently unable to reply. -export type GooglechatBridgeRefreshResult = { ok: boolean; reason?: string }; - -// Credentials the service-account resolver consults, in the same order the rest -// of onboarding uses (credential store first, then the injected env map). -type ServiceAccountResolveDeps = { - getCredential: (envKey: string) => string | null; - env?: NodeJS.ProcessEnv | Record; - normalizeCredentialValue?: (value: unknown) => 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 ""; -} - -export function googlechatBridgeProfilePath(root: string): string { - // Co-located with the channel (mirrors /policy/.yaml), read - // ROOT-relative from the source tree the same way channel policy presets are. - return path.join( - root, - "src", - "lib", - "messaging", - "channels", - "googlechat", - "provider-profile", - "openclaw.yaml", - ); -} - -/** - * Resolve the Google Chat service-account JSON with the same order the rest of - * onboarding uses (mirrors the Brave key resolution in messaging-prep): the - * credential store first, then the injected env map. Using `getCredential` alone - * misses setups where the value arrives through the passed-in env (e.g. - * non-interactive runs), which would enable the channel with no bridge provider. - */ -export function resolveGooglechatServiceAccount(deps: ServiceAccountResolveDeps): string | null { - const fromCredential = deps.getCredential(GOOGLECHAT_SERVICE_ACCOUNT_ENV); - if (fromCredential) return fromCredential; - if (deps.env && deps.normalizeCredentialValue) { - const fromEnv = deps.normalizeCredentialValue(deps.env[GOOGLECHAT_SERVICE_ACCOUNT_ENV]); - if (fromEnv) return fromEnv; - } - return null; -} - -/** - * Build the messaging token definition for the Google Chat outbound-auth bridge - * provider, or null when it does not apply. - * - * Unlike a normal channel credential the value is NOT pasted — it is minted - * gateway-side — so the token is a non-empty sentinel (overwritten by the first - * refresh) and the real service-account material is supplied separately via - * {@link configureGooglechatBridgeRefresh}. Emitted only when the Google Chat - * service account was captured and the channel is enabled (mirrors how the Brave - * provider is pushed in messaging-prep). - */ -export function maybeGooglechatBridgeTokenDef(input: { - sandboxName: string; - getCredential: (envKey: string) => string | null; - env?: NodeJS.ProcessEnv | Record; - normalizeCredentialValue?: (value: unknown) => string; - enabledChannels: readonly string[] | null; - disabledChannelNames: ReadonlySet; -}): { name: string; envKey: string; token: string; providerType: string } | null { - if (input.disabledChannelNames.has(GOOGLECHAT_CHANNEL)) return null; - if (input.enabledChannels != null && !input.enabledChannels.includes(GOOGLECHAT_CHANNEL)) { - return null; - } - const serviceAccount = resolveGooglechatServiceAccount(input); - if (!serviceAccount) return null; - return { - name: `${input.sandboxName}-googlechat-bridge`, - envKey: GOOGLECHAT_BRIDGE_CREDENTIAL_KEY, - token: GOOGLECHAT_BRIDGE_PENDING_VALUE, - providerType: GOOGLECHAT_BRIDGE_PROFILE_ID, - }; -} - -/** - * Register the Google Chat bridge provider profile with OpenShell so providers - * created with `--type google-chat-bridge` drive the L7 proxy's outbound bearer - * injection. Skipped unless a bridge-typed token definition is present. - * Idempotent: tolerates OpenShell reporting the custom profile already exists. - */ -export function ensureGooglechatBridgeProfile( - tokenDefs: readonly TokenDefShape[], - deps: GooglechatBridgeProfileDeps, -): void { - const needs = tokenDefs.some( - ({ providerType, token }) => providerType === GOOGLECHAT_BRIDGE_PROFILE_ID && Boolean(token), - ); - if (!needs) return; - - const errorLog = deps.log ?? console.error; - const exit = deps.exit ?? ((code?: number) => process.exit(code)); - - const result = deps.runOpenshell( - ["provider", "profile", "import", "--file", googlechatBridgeProfilePath(deps.root)], - { ignoreError: true, stdio: ["ignore", "pipe", "pipe"] }, - ); - if (result.status === 0) return; - - const rawDiagnostic = `${bufferOrStringToText(result.stderr)} ${bufferOrStringToText(result.stdout)}`; - if (/already exists/i.test(rawDiagnostic)) return; - - const diagnostic = compactText(deps.redact(rawDiagnostic)); - errorLog("\n ✗ Failed to register the Google Chat 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); -} - -/** - * Configure gateway-side credential refresh for the Google Chat bridge provider: - * the gateway mints (and rotates) the bot access token from the service-account - * key via the google_service_account_jwt strategy. Must run AFTER the provider - * is created. The service-account private key is passed as refresh material and - * stays gateway-side — it is never written into the sandbox. - * - * Fail-closed: when a bridge token def is present (Google Chat active) and - * minting cannot be configured, returns { ok: false } so the caller aborts - * onboarding rather than leaving the channel able to receive but not reply. - * Returns { ok: true } as a no-op when no bridge token def is present. Inbound - * webhook verification is unaffected. The private key is never logged. - */ -export function configureGooglechatBridgeRefresh( - tokenDefs: readonly TokenDefShape[], - deps: GooglechatBridgeRefreshDeps, -): GooglechatBridgeRefreshResult { - const bridge = tokenDefs.find( - ({ providerType, token }) => providerType === GOOGLECHAT_BRIDGE_PROFILE_ID && Boolean(token), - ); - if (!bridge) return { ok: true }; - - const warn = deps.log ?? console.error; - const serviceAccount = resolveGooglechatServiceAccount(deps); - if (!serviceAccount) { - warn( - "\n ✗ Google Chat bridge: service account JSON unavailable; cannot configure gateway token minting.", - ); - return { ok: false, reason: "service account JSON unavailable" }; - } - - let clientEmail: unknown; - let privateKey: unknown; - try { - const parsed = JSON.parse(serviceAccount) as Record; - clientEmail = parsed.client_email; - privateKey = parsed.private_key; - } catch { - warn( - "\n ✗ Google Chat bridge: service account JSON could not be parsed; cannot configure gateway token minting.", - ); - return { ok: false, reason: "service account JSON could not be parsed" }; - } - if ( - typeof clientEmail !== "string" || - !clientEmail || - typeof privateKey !== "string" || - !privateKey - ) { - warn( - "\n ✗ Google Chat bridge: service account JSON missing client_email/private_key; cannot configure gateway token minting.", - ); - return { ok: false, reason: "service account JSON missing client_email/private_key" }; - } - - // SECURITY (host-local, tracked upstream): OpenShell `provider refresh configure` - // ingests refresh material only via `--material KEY=VALUE` argv — it has no stdin, - // file, or env-ref transport for secret material today (openshell-cli - // parse_key_value_pairs stores values verbatim; the JWT strategy reads private_key - // from the material map). So the SA private key transits this argv. Accepted risk: - // it never enters the sandbox (the key-out-of-sandbox boundary holds), and the - // exposure is transient (this one configure call) and host-local (ps //proc// - // cmdline on the trusted host that already holds the key to mint tokens). Tracked - // upstream to add a non-argv transport (--secret-material-file/stdin); switch to it - // when released — runOpenshell already supports env/stdin here. - const result = deps.runOpenshell( - [ - "provider", - "refresh", - "configure", - "--credential-key", - GOOGLECHAT_BRIDGE_CREDENTIAL_KEY, - "--strategy", - "google-service-account-jwt", - "--material", - `client_email=${clientEmail}`, - "--material", - `private_key=${privateKey}`, - "--material", - `scope=${GOOGLECHAT_BRIDGE_SCOPE}`, - "--secret-material-key", - "private_key", - bridge.name, - ], - { ignoreError: true, stdio: ["ignore", "pipe", "pipe"] }, - ); - if (result.status === 0) return { ok: true }; - - // Redact before logging — never echo the private key material. - const diagnostic = compactText( - deps.redact(`${bufferOrStringToText(result.stderr)} ${bufferOrStringToText(result.stdout)}`), - ); - warn(`\n ✗ Google Chat bridge: failed to configure gateway token minting for '${bridge.name}'.`); - if (diagnostic) warn(` ${diagnostic.slice(0, 500)}`); - warn(" Outbound Google Chat replies will not authenticate until this is resolved."); - return { - ok: false, - reason: diagnostic || `provider refresh configure exited with status ${result.status}`, - }; -} 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..34152e0da7 --- /dev/null +++ b/src/lib/onboard/messaging-bridge-provider.test.ts @@ -0,0 +1,258 @@ +// 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 { + 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("configures refresh and returns ok when runOpenshell succeeds", () => { + const runOpenshell = vi.fn((_args: string[], _opts: unknown) => ({ 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("private_key"); + expect(args).toContain("sbx-googlechat-bridge"); + }); + + 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); + }); +}); diff --git a/src/lib/onboard/messaging-bridge-provider.ts b/src/lib/onboard/messaging-bridge-provider.ts new file mode 100644 index 0000000000..a8fa1f14d2 --- /dev/null +++ b/src/lib/onboard/messaging-bridge-provider.ts @@ -0,0 +1,429 @@ +// 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` (passed as --secret-material-key). */ + 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)); +} + +/** + * 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: `${input.sandboxName}-${profile.channelId}-bridge`, + envKey: profile.credentialKey, + token: MESSAGING_BRIDGE_PENDING_VALUE, + providerType: profile.profileId, + }); + } + return defs; +} + +/** + * 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 }; + } + + // SECURITY (host-local, tracked upstream): OpenShell `provider refresh configure` + // ingests refresh material only via `--material KEY=VALUE` argv — it has no stdin, + // file, or env-ref transport for secret material today. So secret material transits + // this argv. Accepted risk: it never enters the sandbox (the key-out-of-sandbox + // boundary holds), and the exposure is transient (this one configure call) and + // host-local (ps //proc//cmdline on the trusted host that already holds the key + // to mint tokens). Tracked upstream to add a non-argv transport + // (--secret-material-file/stdin); switch to it when released. + const materialArgs = built.material.flatMap(({ key, value }) => [ + "--material", + `${key}=${value}`, + ]); + const secretKeyArgs = built.secretKeys.flatMap((key) => ["--secret-material-key", key]); + const result = deps.runOpenshell( + [ + "provider", + "refresh", + "configure", + "--credential-key", + profile.credentialKey, + "--strategy", + profile.strategy, + ...materialArgs, + ...secretKeyArgs, + bridge.name, + ], + { 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.ts b/src/lib/onboard/messaging-prep.ts index 9bb7412e4a..8f6a12bb3f 100644 --- a/src/lib/onboard/messaging-prep.ts +++ b/src/lib/onboard/messaging-prep.ts @@ -6,7 +6,7 @@ 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 * as googlechatBridge from "./googlechat-bridge-provider"; +import { collectMessagingBridgeTokenDefs } from "./messaging-bridge-provider"; export type NamedMessagingChannel = { name: string } & ChannelDef; @@ -105,22 +105,22 @@ export function prepareCreateSandboxMessaging( }); } - // Google Chat outbound-auth bridge: when the service account was captured and - // the channel is enabled, register a refresh-minted provider so the gateway - // mints the bot token (key stays gateway-side) and the L7 proxy injects it on - // chat.googleapis.com. The credential value is a sentinel (minted by refresh, - // configured post-create in onboard's upsertMessagingProviders wrapper). - const googlechatBridgeTokenDef = googlechatBridge.maybeGooglechatBridgeTokenDef({ - sandboxName: input.sandboxName, - getCredential: input.getCredential, - env: input.env, - normalizeCredentialValue: input.normalizeCredentialValue, - enabledChannels: input.enabledChannels, - disabledChannelNames, - }); - if (googlechatBridgeTokenDef) { - messagingTokenDefs.push(googlechatBridgeTokenDef); - } + // 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, diff --git a/src/lib/onboard/providers.ts b/src/lib/onboard/providers.ts index c4c9f7a625..b55a699968 100644 --- a/src/lib/onboard/providers.ts +++ b/src/lib/onboard/providers.ts @@ -443,11 +443,34 @@ function upsertProvider(name, type, credentialEnv, baseUrl, env, _runOpenshell, * @returns {string[]} Provider names that were upserted. */ function upsertMessagingProviders(tokenDefs, _runOpenshell, options = {}) { - const googlechatBridgeProvider = require("./googlechat-bridge-provider"); - // Register the Google Chat bridge provider profile before creating providers - // (the bridge is created with --type google-chat-bridge). Self-gates when no - // googlechat bridge token def is present. - googlechatBridgeProvider.ensureGooglechatBridgeProfile(tokenDefs, { + // Provider creation order. Every token def is created uniformly in the loop + // (bearers as --type generic, bridges as --type ). Minted-token + // bridges (e.g. Google Chat) need two extra steps bracketing the loop, ordered + // around `provider create`: + // + // ensureMessagingBridgeProfiles <- BEFORE the loop + // provider profile import (profile must exist before create) + // | + // +----v-------------------------------------------------+ + // | for (tokenDef of tokenDefs) <- THE LOOP | + // | upsertProvider(name, providerType || "generic") | every provider + // | . slack -> --type generic | here, incl. + // | . googlechat -> --type google-chat-bridge | the bridge + // +----+-------------------------------------------------+ + // | + // configureMessagingBridgeRefreshes <- AFTER the loop + // provider refresh configure (provider must exist first) + // + // Bridges create with a sentinel token; refresh then wires minting, which + // overwrites it with the real token. + const messagingBridgeProvider = require("./messaging-bridge-provider"); + // Register any messaging bridge provider profiles before creating providers + // (a bridge is created with --type ). Generic: self-gates when no + // bridge token def is present. A channel counts as a bridge channel by the + // PRESENCE of a co-located provider-profile file + // (channels//provider-profile/.yaml) — not a flag inside it; + // the YAML's fields are read only after that file is found. + messagingBridgeProvider.ensureMessagingBridgeProfiles(tokenDefs, { root: ROOT, runOpenshell: _runOpenshell, redact, @@ -478,27 +501,29 @@ function upsertMessagingProviders(tokenDefs, _runOpenshell, options = {}) { if (failures.length > 0) { throw new Error(failures.join("; ")); } - // Gateway-side token minting for the Google Chat bridge is configured AFTER - // the provider exists (best-effort; self-gates without a bridge token def). - // The service-account private key is passed as refresh material and stays + // Gateway-side token minting for messaging bridge providers is configured AFTER + // the providers exist (best-effort; self-gates without a bridge token def). The + // secret material is passed as gateway-side refresh material and stays // gateway-side — never written into the sandbox. - const refreshResult = googlechatBridgeProvider.configureGooglechatBridgeRefresh(tokenDefs, { + const refreshResult = messagingBridgeProvider.configureMessagingBridgeRefreshes(tokenDefs, { runOpenshell: _runOpenshell, redact, getCredential, env: process.env, normalizeCredentialValue, }); - // Fail-closed: an active Google Chat channel whose gateway token minting was - // not configured can receive webhooks but cannot authenticate outbound replies. + // 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 Google Chat gateway token minting."); + throw new Error("Failed to configure gateway token minting for a messaging bridge."); } - console.error("\n ✗ Google Chat gateway token minting was not configured; aborting."); + console.error( + "\n ✗ Gateway token minting for a messaging bridge was not configured; aborting.", + ); process.exit(1); } return upserted; From ac8a8947ac5328c4efab4d32d6aba122ec950593 Mon Sep 17 00:00:00 2001 From: Hung Le Date: Mon, 6 Jul 2026 09:49:16 +0530 Subject: [PATCH 23/50] feat(messaging): drop the Google Chat webhookPath config input --- src/lib/messaging-channel-config.test.ts | 1 - .../hooks/tunnel-audience-gate.test.ts | 20 ------------------- .../googlechat/hooks/tunnel-audience-gate.ts | 13 +----------- .../messaging/channels/googlechat/manifest.ts | 12 ++--------- .../googlechat/rendered-config-parser.ts | 5 ----- .../googlechat/template-resolver.test.ts | 9 +-------- .../channels/googlechat/template-resolver.ts | 5 ----- src/lib/messaging/channels/manifests.test.ts | 4 +--- src/lib/messaging/channels/metadata.test.ts | 1 - 9 files changed, 5 insertions(+), 65 deletions(-) diff --git a/src/lib/messaging-channel-config.test.ts b/src/lib/messaging-channel-config.test.ts index beb4064519..80ae224370 100644 --- a/src/lib/messaging-channel-config.test.ts +++ b/src/lib/messaging-channel-config.test.ts @@ -34,7 +34,6 @@ describe("messaging channel config", () => { "GOOGLECHAT_AUDIENCE_TYPE", "GOOGLECHAT_AUDIENCE", "GOOGLECHAT_APP_PRINCIPAL", - "GOOGLECHAT_WEBHOOK_PATH", "GOOGLECHAT_ALLOWED_USERS", ]); }); 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 index d4714060e8..067ea607e2 100644 --- a/src/lib/messaging/channels/googlechat/hooks/tunnel-audience-gate.test.ts +++ b/src/lib/messaging/channels/googlechat/hooks/tunnel-audience-gate.test.ts @@ -126,26 +126,6 @@ describe("googlechat tunnel/audience gate hook", () => { expect(stopTunnel).not.toHaveBeenCalled(); }); - it("honors a custom webhook path when deriving the audience", async () => { - let running = false; - const hook = createGooglechatTunnelAudienceGateHook( - baseOptions({ - readTunnelState: () => ({ running }), - startTunnel: async () => { - running = true; - }, - getTunnelUrl: () => "https://abc.trycloudflare.com", - prompt: async () => "y", - }), - ); - - const result = await hook(gateContext({ webhookPath: "/gchat" })); - - expect(result).toEqual({ - outputs: { audience: { kind: "config", value: "https://abc.trycloudflare.com/gchat" } }, - }); - }); - it("stops a self-started tunnel when the operator declines", async () => { let running = false; const stopTunnel = vi.fn(); diff --git a/src/lib/messaging/channels/googlechat/hooks/tunnel-audience-gate.ts b/src/lib/messaging/channels/googlechat/hooks/tunnel-audience-gate.ts index c0d191ad72..0bd16c99ee 100644 --- a/src/lib/messaging/channels/googlechat/hooks/tunnel-audience-gate.ts +++ b/src/lib/messaging/channels/googlechat/hooks/tunnel-audience-gate.ts @@ -33,14 +33,6 @@ function readString(value: MessagingSerializableValue | undefined): string { return typeof value === "string" ? value.trim() : ""; } -function normalizeWebhookPath(raw: string): string { - const trimmed = raw.trim(); - if (!trimmed || /\s/.test(trimmed)) return DEFAULT_WEBHOOK_PATH; - const withLeadingSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`; - const withoutTrailingSlash = withLeadingSlash.replace(/\/+$/, ""); - return withoutTrailingSlash || DEFAULT_WEBHOOK_PATH; -} - function isAffirmative(value: string): boolean { const normalized = value.trim().toLowerCase(); return normalized === "y" || normalized === "yes"; @@ -88,9 +80,6 @@ export function createGooglechatTunnelAudienceGateHook( } const audienceType = readString(context.inputs?.audienceType) || "app-url"; - const webhookPath = normalizeWebhookPath( - readString(context.inputs?.webhookPath) || readString(env.GOOGLECHAT_WEBHOOK_PATH), - ); // 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. @@ -134,7 +123,7 @@ export function createGooglechatTunnelAudienceGateHook( throw new Error("No public tunnel URL is available for the Google Chat webhook."); } - const audience = `${url.replace(/\/+$/, "")}${webhookPath}`; + const audience = `${url.replace(/\/+$/, "")}${DEFAULT_WEBHOOK_PATH}`; printEndpointInstructions(log, audience); // Non-interactive mode already threw at the top of the hook, so this prompt diff --git a/src/lib/messaging/channels/googlechat/manifest.ts b/src/lib/messaging/channels/googlechat/manifest.ts index b2d4dd4f4f..4fc5cc50ce 100644 --- a/src/lib/messaging/channels/googlechat/manifest.ts +++ b/src/lib/messaging/channels/googlechat/manifest.ts @@ -90,14 +90,6 @@ export const googlechatManifest = { emptyValueMessage: "Workspace accounts do not need it; personal accounts must set it later", }, }, - { - id: "webhookPath", - kind: "config", - required: false, - envKey: "GOOGLECHAT_WEBHOOK_PATH", - statePath: "googlechatConfig.webhookPath", - defaultValue: "/googlechat", - }, { id: "allowFrom", kind: "config", @@ -147,7 +139,7 @@ export const googlechatManifest = { audienceType: "{{googlechatConfig.audienceType}}", audience: "{{googlechatConfig.audience}}", appPrincipal: "{{googlechatConfig.appPrincipal}}", - webhookPath: "{{googlechatConfig.webhookPath}}", + webhookPath: "/googlechat", healthMonitor: { enabled: false, }, @@ -266,7 +258,7 @@ export const googlechatManifest = { id: "googlechat-tunnel-audience-gate", phase: "enroll", handler: "googlechat.tunnelAudienceGate", - inputs: ["audienceType", "audience", "webhookPath"], + inputs: ["audienceType", "audience"], outputs: [ { id: "audience", diff --git a/src/lib/messaging/channels/googlechat/rendered-config-parser.ts b/src/lib/messaging/channels/googlechat/rendered-config-parser.ts index 30d6bfe4a5..e78018ed31 100644 --- a/src/lib/messaging/channels/googlechat/rendered-config-parser.ts +++ b/src/lib/messaging/channels/googlechat/rendered-config-parser.ts @@ -26,11 +26,6 @@ export const googlechatRenderedConfigParser: RenderedChannelConfigParser = { "googlechat", "appPrincipal", ]), - structuredConfigKey("webhookPath", "openclaw.json", [ - "channels", - "googlechat", - "webhookPath", - ]), structuredConfigKey("allowFrom", "openclaw.json", [ "channels", "googlechat", diff --git a/src/lib/messaging/channels/googlechat/template-resolver.test.ts b/src/lib/messaging/channels/googlechat/template-resolver.test.ts index a8c0eb7d9f..85cb3d5330 100644 --- a/src/lib/messaging/channels/googlechat/template-resolver.test.ts +++ b/src/lib/messaging/channels/googlechat/template-resolver.test.ts @@ -15,14 +15,11 @@ function configInput( } describe("Google Chat template resolver", () => { - it("defaults audienceType and webhookPath when unset", () => { + it("defaults audienceType when unset", () => { const inputs: SandboxMessagingInputReference[] = []; expect( resolveGooglechatTemplateReference("googlechatConfig.audienceType", { inputs })?.value, ).toBe("app-url"); - expect( - resolveGooglechatTemplateReference("googlechatConfig.webhookPath", { inputs })?.value, - ).toBe("/googlechat"); }); it("passes through configured values; drops audience but seeds the appPrincipal sentinel when unset", () => { @@ -30,7 +27,6 @@ describe("Google Chat template resolver", () => { configInput("audience", "googlechatConfig.audience", "https://x.example/googlechat"), configInput("appPrincipal", "googlechatConfig.appPrincipal", "103987852733692332624"), configInput("audienceType", "googlechatConfig.audienceType", "project-number"), - configInput("webhookPath", "googlechatConfig.webhookPath", "/gchat"), ]; expect( resolveGooglechatTemplateReference("googlechatConfig.audience", { inputs: set })?.value, @@ -41,9 +37,6 @@ describe("Google Chat template resolver", () => { expect( resolveGooglechatTemplateReference("googlechatConfig.audienceType", { inputs: set })?.value, ).toBe("project-number"); - expect( - resolveGooglechatTemplateReference("googlechatConfig.webhookPath", { inputs: set })?.value, - ).toBe("/gchat"); // Unset audience → undefined so the render engine drops the key entirely. // Unset appPrincipal → the all-zeros discovery sentinel (so the first DM diff --git a/src/lib/messaging/channels/googlechat/template-resolver.ts b/src/lib/messaging/channels/googlechat/template-resolver.ts index 990a2438b1..5d39ae69e1 100644 --- a/src/lib/messaging/channels/googlechat/template-resolver.ts +++ b/src/lib/messaging/channels/googlechat/template-resolver.ts @@ -11,7 +11,6 @@ import { } from "../template-resolver-utils"; const DEFAULT_AUDIENCE_TYPE = "app-url"; -const DEFAULT_WEBHOOK_PATH = "/googlechat"; // 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) @@ -44,10 +43,6 @@ export const resolveGooglechatTemplateReference: BuiltInRenderTemplateResolver = nonEmptyString(stateValue(context, "googlechatConfig.appPrincipal")) ?? APP_PRINCIPAL_DISCOVERY_SENTINEL, ); - case "googlechatConfig.webhookPath": - return resolvedRenderTemplateReference( - nonEmptyString(stateValue(context, "googlechatConfig.webhookPath")) ?? DEFAULT_WEBHOOK_PATH, - ); default: break; } diff --git a/src/lib/messaging/channels/manifests.test.ts b/src/lib/messaging/channels/manifests.test.ts index 735d1ec742..c30e803d3b 100644 --- a/src/lib/messaging/channels/manifests.test.ts +++ b/src/lib/messaging/channels/manifests.test.ts @@ -917,7 +917,6 @@ describe("built-in channel manifests", () => { const audienceType = findInput(googlechatManifest, "audienceType"); const audience = findInput(googlechatManifest, "audience"); const appPrincipal = findInput(googlechatManifest, "appPrincipal"); - const webhookPath = findInput(googlechatManifest, "webhookPath"); const allowFrom = findInput(googlechatManifest, "allowFrom"); expect(googlechatManifest.supportedAgents).toEqual(["openclaw"]); @@ -937,7 +936,6 @@ describe("built-in channel manifests", () => { required: false, envKey: "GOOGLECHAT_APP_PRINCIPAL", }); - expect(webhookPath).toMatchObject({ kind: "config", defaultValue: "/googlechat" }); expect(allowFrom).toMatchObject({ kind: "config", statePath: "allowedIds.googlechat" }); // Outbound auth is gateway-minted (google-service-account-jwt bridge provider) @@ -974,7 +972,7 @@ describe("built-in channel manifests", () => { expect(findHook(googlechatManifest, "googlechat-tunnel-audience-gate")).toMatchObject({ phase: "enroll", handler: GOOGLECHAT_TUNNEL_AUDIENCE_GATE_HOOK_ID, - inputs: ["audienceType", "audience", "webhookPath"], + inputs: ["audienceType", "audience"], outputs: [{ id: "audience", kind: "config" }], onFailure: "skip-channel", }); diff --git a/src/lib/messaging/channels/metadata.test.ts b/src/lib/messaging/channels/metadata.test.ts index bd84dde7fc..c038abd60e 100644 --- a/src/lib/messaging/channels/metadata.test.ts +++ b/src/lib/messaging/channels/metadata.test.ts @@ -91,7 +91,6 @@ describe("built-in messaging channel metadata", () => { "GOOGLECHAT_AUDIENCE_TYPE", "GOOGLECHAT_AUDIENCE", "GOOGLECHAT_APP_PRINCIPAL", - "GOOGLECHAT_WEBHOOK_PATH", "GOOGLECHAT_ALLOWED_USERS", ]); expect(getMessagingConfigEnvAliases()).toEqual({ From 8660bf2be970951c1ad8ef85257ed4b0fe2d38cc Mon Sep 17 00:00:00 2001 From: Hung Le Date: Mon, 6 Jul 2026 10:50:56 +0530 Subject: [PATCH 24/50] feat(messaging): ratchet test-file-size budget after main merge --- ci/test-file-size-budget.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ci/test-file-size-budget.json b/ci/test-file-size-budget.json index 4f1ab2fae1..e9a868f02a 100644 --- a/ci/test-file-size-budget.json +++ b/ci/test-file-size-budget.json @@ -10,8 +10,8 @@ "test/install-preflight.test.ts": 3934, "test/nemoclaw-start.test.ts": 4827, "test/onboard-messaging.test.ts": 2062, - "test/onboard-selection.test.ts": 6867, - "test/onboard.test.ts": 4774, - "test/policies.test.ts": 2488 + "test/onboard-selection.test.ts": 6146, + "test/onboard.test.ts": 4057, + "test/policies.test.ts": 2331 } } From 12612760c7892668009896316415ae3506dcedf4 Mon Sep 17 00:00:00 2001 From: Hung Le Date: Mon, 6 Jul 2026 13:36:24 +0530 Subject: [PATCH 25/50] feat(messaging): pin Google Chat plugin archive integrity after main merge --- ci/reviewed-npm-lifecycle-allowlist.json | 1 + .../messaging/applier/build/messaging-build-applier.mts | 2 ++ src/lib/messaging/channels/googlechat/manifest.ts | 7 +++++++ src/lib/messaging/channels/manifests.test.ts | 7 +++++++ src/lib/messaging/channels/metadata.test.ts | 6 ++++++ src/lib/state/openclaw-managed-extensions.test.ts | 1 + src/lib/tunnel/services.ts | 2 +- test/messaging-build-applier-integrity.test.ts | 2 ++ test/openclaw-lifecycle-policy.test.ts | 1 + 9 files changed, 28 insertions(+), 1 deletion(-) 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/src/lib/messaging/applier/build/messaging-build-applier.mts b/src/lib/messaging/applier/build/messaging-build-applier.mts index 67afb5e1b8..6df16141cc 100755 --- a/src/lib/messaging/applier/build/messaging-build-applier.mts +++ b/src/lib/messaging/applier/build/messaging-build-applier.mts @@ -16,6 +16,7 @@ import { homedir, tmpdir } from "node:os"; import { dirname, isAbsolute, join, resolve, sep } from "node:path"; import { pathToFileURL } from "node:url"; 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"; @@ -149,6 +150,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/googlechat/manifest.ts b/src/lib/messaging/channels/googlechat/manifest.ts index 4fc5cc50ce..9624ab1da0 100644 --- a/src/lib/messaging/channels/googlechat/manifest.ts +++ b/src/lib/messaging/channels/googlechat/manifest.ts @@ -250,6 +250,13 @@ export const googlechatManifest = { 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, }, ], diff --git a/src/lib/messaging/channels/manifests.test.ts b/src/lib/messaging/channels/manifests.test.ts index 081204dba2..bbd36acd35 100644 --- a/src/lib/messaging/channels/manifests.test.ts +++ b/src/lib/messaging/channels/manifests.test.ts @@ -1028,6 +1028,13 @@ describe("built-in channel manifests", () => { 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, }); }); diff --git a/src/lib/messaging/channels/metadata.test.ts b/src/lib/messaging/channels/metadata.test.ts index 99f3794cf4..08f3ad108c 100644 --- a/src/lib/messaging/channels/metadata.test.ts +++ b/src/lib/messaging/channels/metadata.test.ts @@ -141,6 +141,7 @@ describe("built-in messaging channel metadata", () => { "slack", "whatsapp", "msteams", + "googlechat", ]); expect( Object.fromEntries( @@ -229,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/state/openclaw-managed-extensions.test.ts b/src/lib/state/openclaw-managed-extensions.test.ts index 629fbd354d..99ed56f148 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/services.ts b/src/lib/tunnel/services.ts index c629791eef..1e0d78f1df 100644 --- a/src/lib/tunnel/services.ts +++ b/src/lib/tunnel/services.ts @@ -653,7 +653,7 @@ export function stopCloudflared(opts: ServiceOptions = {}): void { 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 * registry default-sandbox fallback (registration is skipped rather than diff --git a/test/messaging-build-applier-integrity.test.ts b/test/messaging-build-applier-integrity.test.ts index 7c3885c3a7..a74d6c66de 100644 --- a/test/messaging-build-applier-integrity.test.ts +++ b/test/messaging-build-applier-integrity.test.ts @@ -123,6 +123,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/openclaw-lifecycle-policy.test.ts b/test/openclaw-lifecycle-policy.test.ts index a0296f4f82..850f10d4e8 100644 --- a/test/openclaw-lifecycle-policy.test.ts +++ b/test/openclaw-lifecycle-policy.test.ts @@ -107,6 +107,7 @@ describe("reviewed npm lifecycle policy", () => { "@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", From 44b6ff5a74383f6cea2b28d768c7ed5d9556b2ad Mon Sep 17 00:00:00 2001 From: Hung Le Date: Mon, 6 Jul 2026 13:37:06 +0530 Subject: [PATCH 26/50] feat(messaging): lazy-load tunnel services in the Google Chat enroll hook --- .../googlechat/hooks/tunnel-runtime.ts | 37 ++++++++++++------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/src/lib/messaging/channels/googlechat/hooks/tunnel-runtime.ts b/src/lib/messaging/channels/googlechat/hooks/tunnel-runtime.ts index a421707011..5f8d822665 100644 --- a/src/lib/messaging/channels/googlechat/hooks/tunnel-runtime.ts +++ b/src/lib/messaging/channels/googlechat/hooks/tunnel-runtime.ts @@ -2,20 +2,15 @@ // SPDX-License-Identifier: Apache-2.0 import { DASHBOARD_PORT } from "../../../../core/ports"; -import { - getTunnelUrl as getServiceTunnelUrl, - readCloudflaredState, - resolveServicePidDir, - startAll, - stopCloudflared, -} from "../../../../tunnel/services"; import type { GooglechatTunnelAudienceGateHookOptions } from "./tunnel-audience-gate"; // Side-effectful defaults for the tunnel/audience gate, kept out of the hook // file itself. The gate composes these with the same service helpers // `nemoclaw tunnel start/status/stop` use, so it targets the same tunnel. -// 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. +// 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(): GooglechatTunnelAudienceGateHookOptions { const dashboardPort = DASHBOARD_PORT; return { @@ -28,14 +23,28 @@ export function createDefaultGooglechatTunnelGateOptions(): GooglechatTunnelAudi return false; } }, - readTunnelState: () => ({ - running: readCloudflaredState(resolveServicePidDir()).kind === "running", - }), - startTunnel: () => startAll(), + readTunnelState: () => { + const { readCloudflaredState, resolveServicePidDir } = + require("../../../../tunnel/services") as typeof import("../../../../tunnel/services"); + return { + running: readCloudflaredState(resolveServicePidDir()).kind === "running", + }; + }, + startTunnel: () => { + const { startAll } = + require("../../../../tunnel/services") as typeof import("../../../../tunnel/services"); + return startAll(); + }, stopTunnel: () => { + const { stopCloudflared } = + require("../../../../tunnel/services") as typeof import("../../../../tunnel/services"); stopCloudflared(); }, - getTunnelUrl: () => getServiceTunnelUrl(resolveServicePidDir(), dashboardPort), + getTunnelUrl: () => { + const { getTunnelUrl: getServiceTunnelUrl, resolveServicePidDir } = + require("../../../../tunnel/services") as typeof import("../../../../tunnel/services"); + return getServiceTunnelUrl(resolveServicePidDir(), dashboardPort); + }, prompt: (question: string) => { const { prompt } = require("../../../../credentials/store") as typeof import("../../../../credentials/store"); From 6176bd21a715eac17ea2a7e771135bdbda9256f3 Mon Sep 17 00:00:00 2001 From: Hung Le Date: Mon, 6 Jul 2026 15:16:50 +0530 Subject: [PATCH 27/50] feat(messaging): match Google Chat runtime patches to the extension install path --- .../runtime/googlechat-outbound-auth.test.ts | 28 +++++++++++++++---- .../runtime/googlechat-outbound-auth.ts | 11 +++++++- .../googlechat-trusted-proxy-fetch.test.ts | 21 +++++++++++++- .../runtime/googlechat-trusted-proxy-fetch.ts | 11 +++++++- 4 files changed, 63 insertions(+), 8 deletions(-) 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 index 1b215d1cf7..5dfc170515 100644 --- a/src/lib/messaging/channels/googlechat/runtime/googlechat-outbound-auth.test.ts +++ b/src/lib/messaging/channels/googlechat/runtime/googlechat-outbound-auth.test.ts @@ -5,11 +5,13 @@ 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 } = outboundAuthPatchInternals as { - patchSource: (source: string, filename: string) => string; - buildShortCircuit: () => string; - isPatchError: (reason: unknown) => boolean; -}; +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"; @@ -22,6 +24,22 @@ const PLUGIN_SRC = "}"; 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); diff --git a/src/lib/messaging/channels/googlechat/runtime/googlechat-outbound-auth.ts b/src/lib/messaging/channels/googlechat/runtime/googlechat-outbound-auth.ts index acf464174c..b6d1b5a0b2 100644 --- a/src/lib/messaging/channels/googlechat/runtime/googlechat-outbound-auth.ts +++ b/src/lib/messaging/channels/googlechat/runtime/googlechat-outbound-auth.ts @@ -82,7 +82,15 @@ export const outboundAuthPatchInternals = {}; function isOpenClawGooglechatFile(filename) { var normalized = String(filename || "").replace(/\\/g, "/"); - return normalized.indexOf("/@openclaw/googlechat/") !== -1 && normalized.endsWith(".js"); + 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 @@ -199,6 +207,7 @@ export const outboundAuthPatchInternals = {}; outboundAuthPatchInternals.patchSource = patchGooglechatOutboundAuthSource; outboundAuthPatchInternals.buildShortCircuit = buildBearerShortCircuitSource; outboundAuthPatchInternals.isPatchError = isGooglechatOutboundAuthPatchError; + outboundAuthPatchInternals.isOpenClawGooglechatFile = isOpenClawGooglechatFile; try { installGooglechatOutboundAuthPatch(); 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 index 8b78750645..81607d83b6 100644 --- 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 @@ -4,9 +4,10 @@ import { describe, expect, it } from "vitest"; import { trustedProxyFetchPatchInternals } from "./googlechat-trusted-proxy-fetch"; -const { patchSource, isPatchError } = trustedProxyFetchPatchInternals as { +const { patchSource, isPatchError, isOpenClawGooglechatFile } = trustedProxyFetchPatchInternals as { patchSource: (source: string, filename: string) => string; isPatchError: (reason: unknown) => boolean; + isOpenClawGooglechatFile: (filename: string) => boolean; }; const FILE = "/x/node_modules/@openclaw/googlechat/dist/api-XXXX.js"; @@ -37,6 +38,24 @@ const BUNDLE = [ ].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); 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 index 4c98f186e2..56c8927191 100644 --- a/src/lib/messaging/channels/googlechat/runtime/googlechat-trusted-proxy-fetch.ts +++ b/src/lib/messaging/channels/googlechat/runtime/googlechat-trusted-proxy-fetch.ts @@ -78,7 +78,15 @@ export const trustedProxyFetchPatchInternals = {}; function isOpenClawGooglechatFile(filename) { var normalized = String(filename || "").replace(/\\/g, "/"); - return normalized.indexOf("/@openclaw/googlechat/") !== -1 && normalized.endsWith(".js"); + 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 @@ -216,6 +224,7 @@ export const trustedProxyFetchPatchInternals = {}; trustedProxyFetchPatchInternals.patchSource = patchTrustedProxyFetchSource; trustedProxyFetchPatchInternals.isPatchError = isTrustedProxyFetchPatchError; + trustedProxyFetchPatchInternals.isOpenClawGooglechatFile = isOpenClawGooglechatFile; try { installTrustedProxyFetchPatch(); From facd28ad3fcc14f2af9a354da04ee52daa0ff5ea Mon Sep 17 00:00:00 2001 From: Hung Le Date: Mon, 6 Jul 2026 20:23:50 +0530 Subject: [PATCH 28/50] feat(messaging): fold bridge lifecycle notes into the ascii diagram --- src/lib/onboard/providers.ts | 38 ++++++++++++++---------------------- 1 file changed, 15 insertions(+), 23 deletions(-) diff --git a/src/lib/onboard/providers.ts b/src/lib/onboard/providers.ts index 708de376d4..d7af5aca0a 100644 --- a/src/lib/onboard/providers.ts +++ b/src/lib/onboard/providers.ts @@ -445,33 +445,26 @@ function upsertProvider(name, type, credentialEnv, baseUrl, env, _runOpenshell, * @returns {string[]} Provider names that were upserted. */ function upsertMessagingProviders(tokenDefs, _runOpenshell, options = {}) { - // Provider creation order. Every token def is created uniformly in the loop - // (bearers as --type generic, bridges as --type ). Minted-token - // bridges (e.g. Google Chat) need two extra steps bracketing the loop, ordered - // around `provider create`: + // Provider creation order. Bridges (e.g. Google Chat) need two steps bracketing + // the uniform create loop, ordered around `provider create`: // - // ensureMessagingBridgeProfiles <- BEFORE the loop - // provider profile import (profile must exist before 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") | every provider - // | . slack -> --type generic | here, incl. - // | . googlechat -> --type google-chat-bridge | the bridge + // | upsertProvider(name, providerType || "generic") | bridge created + // | . slack -> --type generic | with a sentinel + // | . googlechat -> --type google-chat-bridge | token // +----+-------------------------------------------------+ // | - // configureMessagingBridgeRefreshes <- AFTER the loop - // provider refresh configure (provider must exist first) + // configureMessagingBridgeRefreshes <- AFTER loop: refresh mints the real + // provider refresh configure token, overwriting the sentinel // - // Bridges create with a sentinel token; refresh then wires minting, which - // overwrites it with the real token. + // 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"); - // Register any messaging bridge provider profiles before creating providers - // (a bridge is created with --type ). Generic: self-gates when no - // bridge token def is present. A channel counts as a bridge channel by the - // PRESENCE of a co-located provider-profile file - // (channels//provider-profile/.yaml) — not a flag inside it; - // the YAML's fields are read only after that file is found. messagingBridgeProvider.ensureMessagingBridgeProfiles(tokenDefs, { root: ROOT, runOpenshell: _runOpenshell, @@ -503,10 +496,9 @@ function upsertMessagingProviders(tokenDefs, _runOpenshell, options = {}) { if (failures.length > 0) { throw new Error(failures.join("; ")); } - // Gateway-side token minting for messaging bridge providers is configured AFTER - // the providers exist (best-effort; self-gates without a bridge token def). The - // secret material is passed as gateway-side refresh material and stays - // gateway-side — never written into the sandbox. + // 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, From 9ab6550dbe58fc5b4c8d0927af588fb9a8145cf3 Mon Sep 17 00:00:00 2001 From: Hung Le Date: Mon, 6 Jul 2026 23:37:25 +0530 Subject: [PATCH 29/50] feat(messaging): correct bridge-provider lifecycle on channels add/remove --- src/lib/actions/sandbox/policy-channel.ts | 34 ++++++++++++++----- .../onboard/messaging-bridge-provider.test.ts | 24 +++++++++++++ src/lib/onboard/messaging-bridge-provider.ts | 28 ++++++++++++++- 3 files changed, 77 insertions(+), 9 deletions(-) diff --git a/src/lib/actions/sandbox/policy-channel.ts b/src/lib/actions/sandbox/policy-channel.ts index 6c7c4e10b5..ddd020cc30 100644 --- a/src/lib/actions/sandbox/policy-channel.ts +++ b/src/lib/actions/sandbox/policy-channel.ts @@ -48,6 +48,7 @@ import { type PolicyRemoveOptions, parsePolicyAddOptions, } from "../../domain/policy-channel"; +import { bridgeProviderNamesForChannel } from "../../onboard/messaging-bridge-provider"; import { getMessagingToken } from "../../onboard/messaging-token"; import { shellQuote } from "../../runner"; import { @@ -549,7 +550,7 @@ async function applyChannelAddToGatewayAndRegistry( sandboxName: string, channelName: string, acquired: Record, -): Promise { +): Promise { const tokenDefs = Object.entries(acquired).map(([envKey, token]) => ({ name: bridgeProviderName(sandboxName, channelName, envKey), envKey, @@ -569,7 +570,9 @@ async function applyChannelAddToGatewayAndRegistry( // upsertMessagingProviders handles create-or-update and process.exits on // failure, so reaching the next line means every entry is registered. onboardProviders.upsertMessagingProviders(tokenDefs, runOpenshell); + return true; } + return false; } // Remove a channel's bridge providers from the gateway and drop it from the @@ -584,7 +587,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) { @@ -610,8 +624,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"], @@ -647,8 +660,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, @@ -1050,8 +1062,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, { diff --git a/src/lib/onboard/messaging-bridge-provider.test.ts b/src/lib/onboard/messaging-bridge-provider.test.ts index 34152e0da7..b450216137 100644 --- a/src/lib/onboard/messaging-bridge-provider.test.ts +++ b/src/lib/onboard/messaging-bridge-provider.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest"; import { + bridgeProviderNamesForChannel, collectMessagingBridgeTokenDefs, configureMessagingBridgeRefreshes, ensureMessagingBridgeProfiles, @@ -256,3 +257,26 @@ describe("listMessagingBridgeProfiles (real registry + co-located YAML)", () => 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"]); + }); +}); diff --git a/src/lib/onboard/messaging-bridge-provider.ts b/src/lib/onboard/messaging-bridge-provider.ts index a8fa1f14d2..63371085b2 100644 --- a/src/lib/onboard/messaging-bridge-provider.ts +++ b/src/lib/onboard/messaging-bridge-provider.ts @@ -240,6 +240,11 @@ function bridgeProfilesForTokenDefs( 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 @@ -259,7 +264,7 @@ export function collectMessagingBridgeTokenDefs( const secret = resolveBridgeSecret(profile.sourceSecretEnv, input); if (!secret) continue; defs.push({ - name: `${input.sandboxName}-${profile.channelId}-bridge`, + name: bridgeProviderNameFor(input.sandboxName, profile.channelId), envKey: profile.credentialKey, token: MESSAGING_BRIDGE_PENDING_VALUE, providerType: profile.profileId, @@ -268,6 +273,27 @@ export function collectMessagingBridgeTokenDefs( 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)), + ), + ]; +} + /** * Register each active bridge provider profile with OpenShell before providers * are created (they are created with `--type `). Idempotent: tolerates From 73ffcc71910cc2d9737f218bc99b76a1eef33d2d Mon Sep 17 00:00:00 2001 From: Hung Le Date: Tue, 7 Jul 2026 00:16:12 +0530 Subject: [PATCH 30/50] feat(messaging): justify the broad www.googleapis.com cert egress --- src/lib/messaging/channels/googlechat/policy/openclaw.yaml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/lib/messaging/channels/googlechat/policy/openclaw.yaml b/src/lib/messaging/channels/googlechat/policy/openclaw.yaml index 8bd5ea1dba..e4d08d2c68 100644 --- a/src/lib/messaging/channels/googlechat/policy/openclaw.yaml +++ b/src/lib/messaging/channels/googlechat/policy/openclaw.yaml @@ -31,9 +31,10 @@ network_policies: - 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 (read-only, public). GET /** not pinned: - # google-auth-library selects the cert URL/format by version, so pinning risks - # breaking inbound verification on a bundle bump. GET-only, no credential rewrite. + # 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 From 10cabf06e697ff8b92327b2a0424e99a4c99d8ea Mon Sep 17 00:00:00 2001 From: Hung Le Date: Tue, 7 Jul 2026 00:21:26 +0530 Subject: [PATCH 31/50] feat(messaging): explain the intentional cloudflared presence-probe catch --- src/lib/messaging/channels/googlechat/hooks/tunnel-runtime.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/lib/messaging/channels/googlechat/hooks/tunnel-runtime.ts b/src/lib/messaging/channels/googlechat/hooks/tunnel-runtime.ts index 5f8d822665..971a6b72a3 100644 --- a/src/lib/messaging/channels/googlechat/hooks/tunnel-runtime.ts +++ b/src/lib/messaging/channels/googlechat/hooks/tunnel-runtime.ts @@ -20,6 +20,9 @@ export function createDefaultGooglechatTunnelGateOptions(): GooglechatTunnelAudi 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; } }, From f2dbcfd9fc31c85eeb2261f9ec038bf40c4a6575 Mon Sep 17 00:00:00 2001 From: Hung Le Date: Tue, 7 Jul 2026 01:30:30 +0530 Subject: [PATCH 32/50] feat(messaging): reword the non-interactive Google Chat skip message --- .../channels/googlechat/hooks/tunnel-audience-gate.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/lib/messaging/channels/googlechat/hooks/tunnel-audience-gate.ts b/src/lib/messaging/channels/googlechat/hooks/tunnel-audience-gate.ts index 0bd16c99ee..3ee36269d4 100644 --- a/src/lib/messaging/channels/googlechat/hooks/tunnel-audience-gate.ts +++ b/src/lib/messaging/channels/googlechat/hooks/tunnel-audience-gate.ts @@ -76,7 +76,9 @@ export function createGooglechatTunnelAudienceGateHook( log( " Skipped googlechat (interactive setup required: Google Cloud Console endpoint URL + appPrincipal)", ); - throw new Error("Google Chat enrollment requires interactive mode."); + 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"; From 859eea5a10f879aad5dfc9c04a360bfbcfb0b740 Mon Sep 17 00:00:00 2001 From: Hung Le Date: Tue, 7 Jul 2026 01:35:22 +0530 Subject: [PATCH 33/50] feat(messaging): note the Google Chat preload rewrite brittleness + bounds --- .../googlechat/runtime/googlechat-trusted-proxy-fetch.ts | 7 +++++++ 1 file changed, 7 insertions(+) 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 index 56c8927191..f2fa51859a 100644 --- a/src/lib/messaging/channels/googlechat/runtime/googlechat-trusted-proxy-fetch.ts +++ b/src/lib/messaging/channels/googlechat/runtime/googlechat-trusted-proxy-fetch.ts @@ -51,6 +51,13 @@ // (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 loader-hook coverage is proven. +// +// ── Brittleness ────────────────────────────────────────────────────────────── +// This keys on anchors in the plugin's dist bundle, so it is version-sensitive. +// Bounded because the plugin is integrity-version-pinned (the bundle is frozen +// per build): anchors are covered by unit tests, drift can only appear on a +// deliberate version bump, and this fails loud (named patch error) rather than +// silently mispatching — never a silent security downgrade. // 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 From 8047eebc580b294022d3e0c8345eda3a0f0a2592 Mon Sep 17 00:00:00 2001 From: Hung Le Date: Tue, 7 Jul 2026 01:44:01 +0530 Subject: [PATCH 34/50] feat(messaging): tie DEFAULT_WEBHOOK_PATH to the manifest webhookPath contract --- .../messaging/channels/googlechat/hooks/tunnel-audience-gate.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/lib/messaging/channels/googlechat/hooks/tunnel-audience-gate.ts b/src/lib/messaging/channels/googlechat/hooks/tunnel-audience-gate.ts index 3ee36269d4..23e4cf00ce 100644 --- a/src/lib/messaging/channels/googlechat/hooks/tunnel-audience-gate.ts +++ b/src/lib/messaging/channels/googlechat/hooks/tunnel-audience-gate.ts @@ -10,6 +10,8 @@ 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. */ From fe26f5f1ee7a017beffddedb271a8af35af0a7e9 Mon Sep 17 00:00:00 2001 From: Hung Le Date: Tue, 7 Jul 2026 02:22:08 +0530 Subject: [PATCH 35/50] feat(messaging): add Workaround Analysis blocks to the Google Chat workarounds --- .../messaging/channels/googlechat/manifest.ts | 24 ++++++++++--------- .../runtime/googlechat-outbound-auth.ts | 14 +++++++++++ .../runtime/googlechat-trusted-proxy-fetch.ts | 21 ++++++++++------ 3 files changed, 41 insertions(+), 18 deletions(-) diff --git a/src/lib/messaging/channels/googlechat/manifest.ts b/src/lib/messaging/channels/googlechat/manifest.ts index 9624ab1da0..f4edf36a30 100644 --- a/src/lib/messaging/channels/googlechat/manifest.ts +++ b/src/lib/messaging/channels/googlechat/manifest.ts @@ -163,17 +163,19 @@ export const googlechatManifest = { }, }, { - // Turn OFF OpenClaw's config hot-reload for this sandbox. - // Safe: the sandbox's openclaw.json is fixed at build time (sealed 0600 + - // integrity hash), so nothing should reload it while running. - // Needed: ~60s after boot, OpenClaw rewrites its OWN config (it adds default - // provider-plugin entries). If hot-reload is ON, OpenClaw reacts to that - // self-write by reloading plugins, which rebuilds its HTTP route table and - // drops the Google Chat inbound webhook route — so incoming messages 404 and - // the bot goes silent ~60s after every start. - // "off" tells OpenClaw to ignore config-file changes (see @openclaw - // config-reload.ts). NemoClaw still restarts the gateway itself when it needs - // to (rebuild / `nemoclaw gateway restart`). + // ── 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", diff --git a/src/lib/messaging/channels/googlechat/runtime/googlechat-outbound-auth.ts b/src/lib/messaging/channels/googlechat/runtime/googlechat-outbound-auth.ts index b6d1b5a0b2..4ee343b181 100644 --- a/src/lib/messaging/channels/googlechat/runtime/googlechat-outbound-auth.ts +++ b/src/lib/messaging/channels/googlechat/runtime/googlechat-outbound-auth.ts @@ -7,6 +7,20 @@ // 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 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 index f2fa51859a..1f709a5b29 100644 --- a/src/lib/messaging/channels/googlechat/runtime/googlechat-trusted-proxy-fetch.ts +++ b/src/lib/messaging/channels/googlechat/runtime/googlechat-trusted-proxy-fetch.ts @@ -8,6 +8,20 @@ // 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 @@ -51,13 +65,6 @@ // (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 loader-hook coverage is proven. -// -// ── Brittleness ────────────────────────────────────────────────────────────── -// This keys on anchors in the plugin's dist bundle, so it is version-sensitive. -// Bounded because the plugin is integrity-version-pinned (the bundle is frozen -// per build): anchors are covered by unit tests, drift can only appear on a -// deliberate version bump, and this fails loud (named patch error) rather than -// silently mispatching — never a silent security downgrade. // 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 From 7d761f3ca4ed4a18cf18391ea59d101102c38e98 Mon Sep 17 00:00:00 2001 From: Hung Le Date: Tue, 7 Jul 2026 03:16:11 +0530 Subject: [PATCH 36/50] feat(messaging): reconcile policy-channel imports after the main merge --- src/lib/actions/sandbox/policy-channel.ts | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/src/lib/actions/sandbox/policy-channel.ts b/src/lib/actions/sandbox/policy-channel.ts index f2243bddc7..1b0c3720d9 100644 --- a/src/lib/actions/sandbox/policy-channel.ts +++ b/src/lib/actions/sandbox/policy-channel.ts @@ -35,22 +35,11 @@ import { import { findChannelConflicts } from "../../messaging/applier/conflict-detection/registry"; import { hydrateMessagingChannelConfig } from "../../messaging-channel-config"; import { filterSetupPolicyPresetsForAgent } from "../../onboard/agent-policy-presets"; +import { bridgeProviderNamesForChannel } from "../../onboard/messaging-bridge-provider"; import { getStoredMessagingChannelConfig } from "../../onboard/messaging-config"; import { getMessagingToken } from "../../onboard/messaging-token"; import * as policies from "../../policy"; import { formatPolicyListPresetRow } from "../../policy/policy-list-display"; - -const onboardSession = - require("../../state/onboard-session") as typeof import("../../state/onboard-session"); - -import { runOpenshell } from "../../adapters/openshell/runtime"; -import { - type PolicyAddOptions, - type PolicyRemoveOptions, - parsePolicyAddOptions, -} from "../../domain/policy-channel"; -import { bridgeProviderNamesForChannel } from "../../onboard/messaging-bridge-provider"; -import { getMessagingToken } from "../../onboard/messaging-token"; import { shellQuote } from "../../runner"; import { type ChannelDef, @@ -574,7 +563,7 @@ async function applyChannelAddToGatewayAndRegistry( } // upsertMessagingProviders handles create-or-update and process.exits on // failure, so reaching the next line means every entry is registered. - onboardProviders.upsertMessagingProviders(tokenDefs, runOpenshell); + policyChannelDependencies.upsertMessagingProviders(tokenDefs); return true; } return false; From c5ad1c6428870399b324afbc7eb619c6f4459c6d Mon Sep 17 00:00:00 2001 From: Hung Le Date: Tue, 7 Jul 2026 03:39:56 +0530 Subject: [PATCH 37/50] feat(messaging): match the reworded non-interactive Google Chat skip message --- .../channels/googlechat/hooks/tunnel-audience-gate.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index 067ea607e2..07b31ba523 100644 --- a/src/lib/messaging/channels/googlechat/hooks/tunnel-audience-gate.test.ts +++ b/src/lib/messaging/channels/googlechat/hooks/tunnel-audience-gate.test.ts @@ -78,13 +78,13 @@ describe("googlechat tunnel/audience gate hook", () => { const hook = createGooglechatTunnelAudienceGateHook(baseOptions({ startTunnel, stopTunnel })); // No audience → skip. - await expect(hook(gateContext({}, false))).rejects.toThrow(/interactive mode/); + 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 mode/); + ).rejects.toThrow(/interactive enrollment required/); // Never touches the tunnel in non-interactive mode. expect(startTunnel).not.toHaveBeenCalled(); expect(stopTunnel).not.toHaveBeenCalled(); From ac471cf65fd64f2933fa54e5b4e5b380d49b1f82 Mon Sep 17 00:00:00 2001 From: Hung Le Date: Tue, 7 Jul 2026 12:17:31 +0530 Subject: [PATCH 38/50] feat(messaging): apply biome --- test/channels-add-preset.test.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/test/channels-add-preset.test.ts b/test/channels-add-preset.test.ts index 30985153f0..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", "googlechat"], + supportedChannelIds: [ + "telegram", + "discord", + "wechat", + "slack", + "whatsapp", + "teams", + "googlechat", + ], credentialAvailability: expect.any(Object), }); }); From 377b20f89feb6a1003a6981274c32621e74d843c Mon Sep 17 00:00:00 2001 From: Hung Le Date: Wed, 8 Jul 2026 13:32:04 +0530 Subject: [PATCH 39/50] feat(messaging): bridge lifecycle on channels add --- src/lib/actions/sandbox/policy-channel.ts | 94 ++++++-- .../onboard/messaging-bridge-provider.test.ts | 19 ++ src/lib/onboard/messaging-bridge-provider.ts | 17 ++ src/lib/onboard/messaging-prep.test.ts | 64 ++++++ src/lib/onboard/messaging-prep.ts | 23 +- test/channels-add-bridge-lifecycle.test.ts | 213 ++++++++++++++++++ 6 files changed, 413 insertions(+), 17 deletions(-) create mode 100644 test/channels-add-bridge-lifecycle.test.ts diff --git a/src/lib/actions/sandbox/policy-channel.ts b/src/lib/actions/sandbox/policy-channel.ts index 1b0c3720d9..abf69cb246 100644 --- a/src/lib/actions/sandbox/policy-channel.ts +++ b/src/lib/actions/sandbox/policy-channel.ts @@ -6,7 +6,11 @@ import path from "node:path"; import { runOpenshell } from "../../adapters/openshell/runtime"; import { type AgentDefinition, loadAgent } from "../../agent/defs"; import { CLI_DISPLAY_NAME, CLI_NAME } from "../../cli/branding"; -import { prompt as askPrompt, getCredential } from "../../credentials/store"; +import { + prompt as askPrompt, + getCredential, + normalizeCredentialValue, +} from "../../credentials/store"; import { type PolicyAddOptions, type PolicyRemoveOptions, @@ -35,8 +39,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 } from "../../onboard/messaging-bridge-provider"; +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"; @@ -545,28 +554,74 @@ async function applyChannelAddToGatewayAndRegistry( channelName: string, acquired: Record, ): Promise { - const tokenDefs = Object.entries(acquired).map(([envKey, token]) => ({ + 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); - return true; + process.exit(1); } - return false; + return true; } // Remove a channel's bridge providers from the gateway and drop it from the @@ -1109,6 +1164,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]) => ({ diff --git a/src/lib/onboard/messaging-bridge-provider.test.ts b/src/lib/onboard/messaging-bridge-provider.test.ts index b450216137..bac4aa81e5 100644 --- a/src/lib/onboard/messaging-bridge-provider.test.ts +++ b/src/lib/onboard/messaging-bridge-provider.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it, vi } from "vitest"; import { bridgeProviderNamesForChannel, + bridgeSecretEnvsForChannel, collectMessagingBridgeTokenDefs, configureMessagingBridgeRefreshes, ensureMessagingBridgeProfiles, @@ -280,3 +281,21 @@ describe("bridgeProviderNamesForChannel (PRA-8: channels remove teardown)", () = ).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 index 63371085b2..8f2dcd5c8d 100644 --- a/src/lib/onboard/messaging-bridge-provider.ts +++ b/src/lib/onboard/messaging-bridge-provider.ts @@ -294,6 +294,23 @@ export function bridgeProviderNamesForChannel( ]; } +/** + * 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 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 5345030125..1699f68e55 100644 --- a/src/lib/onboard/messaging-prep.ts +++ b/src/lib/onboard/messaging-prep.ts @@ -6,7 +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 { collectMessagingBridgeTokenDefs } from "./messaging-bridge-provider"; +import { + bridgeProviderNamesForChannel, + collectMessagingBridgeTokenDefs, +} from "./messaging-bridge-provider"; export type NamedMessagingChannel = { name: string } & ChannelDef; @@ -149,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/test/channels-add-bridge-lifecycle.test.ts b/test/channels-add-bridge-lifecycle.test.ts new file mode 100644 index 0000000000..9121e07a72 --- /dev/null +++ b/test/channels-add-bridge-lifecycle.test.ts @@ -0,0 +1,213 @@ +// 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 } 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 testHome: string; + +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); + + const entry = { name: "test-sb", agent: "openclaw" } as SandboxEntry; + vi.spyOn(registry, "getSandbox").mockImplementation(() => entry); + vi.spyOn(registry, "listSandboxes").mockImplementation(() => ({ + sandboxes: [entry], + defaultSandbox: "test-sb", + })); + vi.spyOn(registry, "updateSandbox").mockReturnValue(true); + + vi.spyOn(policies, "loadPresetForSandbox").mockReturnValue( + "network_policies:\n stub:\n egress:\n - host: example.com\n", + ); + vi.spyOn(policies, "applyPreset").mockReturnValue(true); + vi.spyOn(policies, "getAppliedPresets").mockReturnValue([]); + + vi.spyOn(store, "getCredential").mockImplementation((key) => process.env[key] || null); + vi.spyOn(store, "saveCredential").mockImplementation(() => undefined); + vi.spyOn(store, "prompt").mockResolvedValue("y"); + + const session = { + sandboxName: "test-sb", + policyPresets: [], + } as unknown as onboardSession.Session; + vi.spyOn(onboardSession, "loadSession").mockReturnValue(session); + vi.spyOn(onboardSession, "updateSession").mockImplementation(() => session); + + providerSpy = vi + .spyOn(policyChannelDependencies, "upsertMessagingProviders") + .mockImplementation(() => []); + vi.spyOn(policyChannelDependencies, "rebuildSandbox").mockImplementation(async () => 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 and refresh-configures the gateway bridge provider on the direct add path", 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 }, + ); + 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"], + ]), + ); + }); +}); From 8f80fec101524b681fb91b7a0cb24544693bf365 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Tue, 14 Jul 2026 03:05:05 -0700 Subject: [PATCH 40/50] fix(messaging): keep Google Chat private key off argv --- .../onboard/messaging-bridge-provider.test.ts | 15 +++++-- src/lib/onboard/messaging-bridge-provider.ts | 39 +++++++++++-------- 2 files changed, 35 insertions(+), 19 deletions(-) diff --git a/src/lib/onboard/messaging-bridge-provider.test.ts b/src/lib/onboard/messaging-bridge-provider.test.ts index bac4aa81e5..cbf042c9f1 100644 --- a/src/lib/onboard/messaging-bridge-provider.test.ts +++ b/src/lib/onboard/messaging-bridge-provider.test.ts @@ -151,8 +151,12 @@ describe("configureMessagingBridgeRefreshes", () => { expect(result.ok).toBe(false); }); - it("configures refresh and returns ok when runOpenshell succeeds", () => { - const runOpenshell = vi.fn((_args: string[], _opts: unknown) => ({ status: 0 })); + 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, @@ -168,8 +172,13 @@ describe("configureMessagingBridgeRefreshes", () => { 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("private_key"); + 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", () => { diff --git a/src/lib/onboard/messaging-bridge-provider.ts b/src/lib/onboard/messaging-bridge-provider.ts index 8f2dcd5c8d..add56e2d15 100644 --- a/src/lib/onboard/messaging-bridge-provider.ts +++ b/src/lib/onboard/messaging-bridge-provider.ts @@ -66,7 +66,7 @@ export interface MessagingBridgeProfile { readonly strategy: string; /** OAuth scope(s) declared in the profile's refresh block. */ readonly scopes: readonly string[]; - /** Material names the profile marks `secret: true` (passed as --secret-material-key). */ + /** 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; @@ -424,19 +424,23 @@ export function configureMessagingBridgeRefreshes( return { ok: false, reason: built.reason }; } - // SECURITY (host-local, tracked upstream): OpenShell `provider refresh configure` - // ingests refresh material only via `--material KEY=VALUE` argv — it has no stdin, - // file, or env-ref transport for secret material today. So secret material transits - // this argv. Accepted risk: it never enters the sandbox (the key-out-of-sandbox - // boundary holds), and the exposure is transient (this one configure call) and - // host-local (ps //proc//cmdline on the trusted host that already holds the key - // to mint tokens). Tracked upstream to add a non-argv transport - // (--secret-material-file/stdin); switch to it when released. - const materialArgs = built.material.flatMap(({ key, value }) => [ - "--material", - `${key}=${value}`, - ]); - const secretKeyArgs = built.secretKeys.flatMap((key) => ["--secret-material-key", key]); + // 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", @@ -447,10 +451,13 @@ export function configureMessagingBridgeRefreshes( "--strategy", profile.strategy, ...materialArgs, - ...secretKeyArgs, bridge.name, ], - { ignoreError: true, stdio: ["ignore", "pipe", "pipe"] }, + { + env: secretMaterialEnv, + ignoreError: true, + stdio: ["ignore", "pipe", "pipe"], + }, ); if (result.status === 0) continue; From a621c3c295f1f458b09a546c913e4782919613df Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Tue, 14 Jul 2026 03:05:38 -0700 Subject: [PATCH 41/50] docs(messaging): document Google Chat setup --- docs/index.yml | 3 + .../add-channels-after-onboarding.mdx | 15 +++ .../enable-channels-during-onboarding.mdx | 13 +++ .../manage-messaging-channels.mdx | 14 +++ docs/manage-sandboxes/messaging-channels.mdx | 14 ++- docs/manage-sandboxes/set-up-google-chat.mdx | 107 ++++++++++++++++++ 6 files changed, 162 insertions(+), 4 deletions(-) create mode 100644 docs/manage-sandboxes/set-up-google-chat.mdx 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..e9914997d3 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,10 @@ $$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. + + 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 +85,11 @@ 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. +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..235640440f --- /dev/null +++ b/docs/manage-sandboxes/set-up-google-chat.mdx @@ -0,0 +1,107 @@ +--- +# 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 default cloudflared tunnel exposes the entire OpenClaw dashboard port, not only the `/googlechat` path. +Open the Control UI through `http://127.0.0.1:18789`, not through the public tunnel 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 uses the same cloudflared service as `nemoclaw tunnel start`. +Install `cloudflared` on the host before enrollment when you do not already have a public endpoint. + +When no tunnel is running, NemoClaw starts one 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 a tunnel that it started 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 endpoint must forward to the OpenClaw dashboard port and preserve the `/googlechat` path. + +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 tunnel is 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. +Refer to [Manage Messaging Channels](manage-messaging-channels) for lifecycle behavior. From 4d8bc4e5b4eb18667a924e2c7eb2b07b65c54357 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Tue, 14 Jul 2026 03:39:56 -0700 Subject: [PATCH 42/50] fix(messaging): compose Google Chat runtime preloads --- .../googlechat/provider-profile/openclaw.yaml | 8 ++-- .../runtime/googlechat-outbound-auth.ts | 40 ++++++++++++----- .../googlechat-trusted-proxy-fetch.test.ts | 44 +++++++++++++++++-- .../runtime/googlechat-trusted-proxy-fetch.ts | 43 +++++++++++++----- 4 files changed, 104 insertions(+), 31 deletions(-) diff --git a/src/lib/messaging/channels/googlechat/provider-profile/openclaw.yaml b/src/lib/messaging/channels/googlechat/provider-profile/openclaw.yaml index 5a7a51fa52..5509e4d640 100644 --- a/src/lib/messaging/channels/googlechat/provider-profile/openclaw.yaml +++ b/src/lib/messaging/channels/googlechat/provider-profile/openclaw.yaml @@ -13,10 +13,10 @@ # 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 (client_email, private_key, scope) are supplied at -# onboard/rebuild time via `openshell provider refresh configure --material ...` -# (see src/lib/onboard/googlechat-bridge-provider.ts); the block below only -# declares their shape so the gateway knows which keys are required/secret. +# 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 diff --git a/src/lib/messaging/channels/googlechat/runtime/googlechat-outbound-auth.ts b/src/lib/messaging/channels/googlechat/runtime/googlechat-outbound-auth.ts index 4ee343b181..82aa7e671c 100644 --- a/src/lib/messaging/channels/googlechat/runtime/googlechat-outbound-auth.ts +++ b/src/lib/messaging/channels/googlechat/runtime/googlechat-outbound-auth.ts @@ -185,21 +185,38 @@ export const outboundAuthPatchInternals = {}; 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) { + return function nemoclawGooglechatJsLoader(mod, filename) { + if (!isOpenClawGooglechatFile(filename) || typeof mod._compile !== "function") { + return originalJsLoader.apply(this, arguments); + } + + var originalCompile = mod._compile; + mod._compile = function nemoclawGooglechatCompile(source, loadedFilename) { + var sourceText = sourceToText(source); + var patched = + sourceText === null + ? source + : patchGooglechatOutboundAuthSource(sourceText, loadedFilename || filename); + return originalCompile.call(this, patched, loadedFilename); + }; + try { + return originalJsLoader.apply(this, arguments); + } finally { + mod._compile = originalCompile; + } + }; + } + function installGooglechatOutboundAuthPatch() { var Module = require("module"); - var fs = require("fs"); var originalJsLoader = Module._extensions && Module._extensions[".js"]; if (typeof originalJsLoader === "function") { - Module._extensions[".js"] = function nemoclawGooglechatJsLoader(mod, filename) { - if (isOpenClawGooglechatFile(filename)) { - var source = fs.readFileSync(filename, "utf8"); - var patched = patchGooglechatOutboundAuthSource(source, filename); - if (patched !== source) { - return mod._compile(patched, filename); - } - } - return originalJsLoader.apply(this, arguments); - }; + Module._extensions[".js"] = createComposableJsLoader(originalJsLoader); } if (typeof Module.registerHooks === "function") { @@ -222,6 +239,7 @@ export const outboundAuthPatchInternals = {}; outboundAuthPatchInternals.buildShortCircuit = buildBearerShortCircuitSource; outboundAuthPatchInternals.isPatchError = isGooglechatOutboundAuthPatchError; outboundAuthPatchInternals.isOpenClawGooglechatFile = isOpenClawGooglechatFile; + outboundAuthPatchInternals.createComposableJsLoader = createComposableJsLoader; try { installGooglechatOutboundAuthPatch(); 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 index 81607d83b6..827ba25072 100644 --- 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 @@ -2,12 +2,24 @@ // 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"; -const { patchSource, isPatchError, isOpenClawGooglechatFile } = trustedProxyFetchPatchInternals as { - patchSource: (source: string, filename: string) => string; - isPatchError: (reason: unknown) => boolean; - isOpenClawGooglechatFile: (filename: string) => boolean; +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"; @@ -35,6 +47,9 @@ const BUNDLE = [ " init,", " });", "}", + "async function getGoogleChatAccessToken(account) {", + " return (await account.auth.getClient()).getAccessToken();", + "}", ].join("\n"); describe("googlechat trusted-proxy-fetch patch", () => { @@ -119,4 +134,25 @@ describe("googlechat trusted-proxy-fetch patch", () => { ), ).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 index 1f709a5b29..540384909c 100644 --- a/src/lib/messaging/channels/googlechat/runtime/googlechat-trusted-proxy-fetch.ts +++ b/src/lib/messaging/channels/googlechat/runtime/googlechat-trusted-proxy-fetch.ts @@ -64,7 +64,8 @@ // 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 loader-hook coverage is proven. +// 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 @@ -203,21 +204,38 @@ export const trustedProxyFetchPatchInternals = {}; 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) { + return function nemoclawGooglechatTrustedProxyJsLoader(mod, filename) { + if (!isOpenClawGooglechatFile(filename) || typeof mod._compile !== "function") { + return originalJsLoader.apply(this, arguments); + } + + var originalCompile = mod._compile; + mod._compile = function nemoclawGooglechatTrustedProxyCompile(source, loadedFilename) { + var sourceText = sourceToText(source); + var patched = + sourceText === null + ? source + : patchTrustedProxyFetchSource(sourceText, loadedFilename || filename); + return originalCompile.call(this, patched, loadedFilename); + }; + try { + return originalJsLoader.apply(this, arguments); + } finally { + mod._compile = originalCompile; + } + }; + } + function installTrustedProxyFetchPatch() { var Module = require("module"); - var fs = require("fs"); var originalJsLoader = Module._extensions && Module._extensions[".js"]; if (typeof originalJsLoader === "function") { - Module._extensions[".js"] = function nemoclawGooglechatTrustedProxyJsLoader(mod, filename) { - if (isOpenClawGooglechatFile(filename)) { - var source = fs.readFileSync(filename, "utf8"); - var patched = patchTrustedProxyFetchSource(source, filename); - if (patched !== source) { - return mod._compile(patched, filename); - } - } - return originalJsLoader.apply(this, arguments); - }; + Module._extensions[".js"] = createComposableJsLoader(originalJsLoader); } if (typeof Module.registerHooks === "function") { @@ -239,6 +257,7 @@ export const trustedProxyFetchPatchInternals = {}; trustedProxyFetchPatchInternals.patchSource = patchTrustedProxyFetchSource; trustedProxyFetchPatchInternals.isPatchError = isTrustedProxyFetchPatchError; trustedProxyFetchPatchInternals.isOpenClawGooglechatFile = isOpenClawGooglechatFile; + trustedProxyFetchPatchInternals.createComposableJsLoader = createComposableJsLoader; try { installTrustedProxyFetchPatch(); From 76e85d38bddd014d0a1b968de5ff1408b7926efb Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Tue, 14 Jul 2026 03:41:14 -0700 Subject: [PATCH 43/50] test(messaging): cover Google Chat security lifecycle --- test/channels-add-bridge-lifecycle.test.ts | 82 ++++++++++++++++++---- 1 file changed, 69 insertions(+), 13 deletions(-) diff --git a/test/channels-add-bridge-lifecycle.test.ts b/test/channels-add-bridge-lifecycle.test.ts index 9121e07a72..212b07ff35 100644 --- a/test/channels-add-bridge-lifecycle.test.ts +++ b/test/channels-add-bridge-lifecycle.test.ts @@ -11,7 +11,7 @@ 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 } from "../src/lib/actions/sandbox/policy-channel"; +import { addSandboxChannel, removeSandboxChannel } 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"; @@ -73,6 +73,9 @@ let exitSpy: MockInstance; let providerSpy: MockInstance; let runOpenshellSpy: MockInstance; let testHome: string; +let registryEntry: SandboxEntry; +let appliedPresets: string[]; +let session: onboardSession.Session; function printedText(): string { return [...logSpy.mock.calls, ...errorSpy.mock.calls] @@ -96,34 +99,49 @@ beforeEach(() => { throw new ExitError(code); }) as never); - const entry = { name: "test-sb", agent: "openclaw" } as SandboxEntry; - vi.spyOn(registry, "getSandbox").mockImplementation(() => entry); + registryEntry = { name: "test-sb", agent: "openclaw", policies: [] } as SandboxEntry; + vi.spyOn(registry, "getSandbox").mockImplementation(() => registryEntry); vi.spyOn(registry, "listSandboxes").mockImplementation(() => ({ - sandboxes: [entry], + sandboxes: [registryEntry], defaultSandbox: "test-sb", })); - vi.spyOn(registry, "updateSandbox").mockReturnValue(true); + vi.spyOn(registry, "updateSandbox").mockImplementation((_name, update) => { + registryEntry = { ...registryEntry, ...update } as SandboxEntry; + return true; + }); + appliedPresets = []; vi.spyOn(policies, "loadPresetForSandbox").mockReturnValue( "network_policies:\n stub:\n egress:\n - host: example.com\n", ); - vi.spyOn(policies, "applyPreset").mockReturnValue(true); - vi.spyOn(policies, "getAppliedPresets").mockReturnValue([]); + vi.spyOn(policies, "applyPreset").mockImplementation((_sandboxName, preset) => { + if (!appliedPresets.includes(preset)) appliedPresets.push(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"); - const session = { + session = { sandboxName: "test-sb", policyPresets: [], } as unknown as onboardSession.Session; vi.spyOn(onboardSession, "loadSession").mockReturnValue(session); - vi.spyOn(onboardSession, "updateSession").mockImplementation(() => session); + vi.spyOn(onboardSession, "updateSession").mockImplementation((update) => { + session = update(session) ?? session; + return session; + }); - providerSpy = vi - .spyOn(policyChannelDependencies, "upsertMessagingProviders") - .mockImplementation(() => []); + // 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); runOpenshellSpy = vi.spyOn(runtime, "runOpenshell").mockImplementation(() => ({ @@ -164,7 +182,7 @@ afterEach(() => { }); describe("channels add owns the bridge-provider lifecycle (#6120)", () => { - it("creates and refresh-configures the gateway bridge provider on the direct add path", async () => { + it("creates the bridge while keeping service-account material outside argv and durable state", async () => { await addSandboxChannel("test-sb", { channel: "googlechat" }); expect(providerSpy).toHaveBeenCalledWith( @@ -178,6 +196,23 @@ describe("channels add owns the bridge-provider lifecycle (#6120)", () => { ], { 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"); }); @@ -210,4 +245,25 @@ describe("channels add owns the bridge-provider lifecycle (#6120)", () => { ]), ); }); + + 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"); + }); }); From 5a54d64426f1ca482fe5a4dc3928c33dc455e595 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Tue, 14 Jul 2026 03:41:54 -0700 Subject: [PATCH 44/50] test(policy): preserve merged coverage attribution --- src/lib/policy/commands.ts | 4 +-- test/onboard-readiness.test.ts | 47 +++++++++++++++++++++++++++------- 2 files changed, 40 insertions(+), 11 deletions(-) diff --git a/src/lib/policy/commands.ts b/src/lib/policy/commands.ts index 6016139d64..825a58c853 100644 --- a/src/lib/policy/commands.ts +++ b/src/lib/policy/commands.ts @@ -3,8 +3,8 @@ // Late binding keeps tests able to replace the resolver without rewiring // command builders that are shared by policy and Shields flows. -const openshellResolveModule = - require("../adapters/openshell/resolve") as typeof import("../adapters/openshell/resolve"); +type OpenshellResolveModule = typeof import("../adapters/openshell/resolve"); +const openshellResolveModule = require("../adapters/openshell/resolve") as OpenshellResolveModule; function resolveOpenshellBinary(): string { return openshellResolveModule.resolveOpenshell() ?? "openshell"; diff --git a/test/onboard-readiness.test.ts b/test/onboard-readiness.test.ts index 1d9e4cb1f2..420e982c65 100644 --- a/test/onboard-readiness.test.ts +++ b/test/onboard-readiness.test.ts @@ -1,8 +1,16 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { describe, expect, it } from "vitest"; -import { applyPreset, buildPolicyGetCommand, buildPolicySetCommand } from "../src/lib/policy"; +import { createRequire } from "node:module"; +import { describe, expect, it, vi } from "vitest"; + +const requireForTest = createRequire(import.meta.url); +const policyCommands = requireForTest( + "../src/lib/policy/index.ts", +) as typeof import("../src/lib/policy"); +const openshellResolve = requireForTest( + "../src/lib/adapters/openshell/resolve.ts", +) as typeof import("../src/lib/adapters/openshell/resolve"); type OnboardReadinessInternals = { hasStaleGateway: (output: string | null | undefined) => boolean; @@ -117,36 +125,57 @@ describe("sandbox readiness parsing", () => { // argument parsing (e.g. "my-assistant" → "m"). describe("WSL sandbox name handling", () => { it("buildPolicySetCommand preserves hyphenated sandbox name as a separate argv element", () => { - const cmd = buildPolicySetCommand("/tmp/policy.yaml", "my-assistant"); + const cmd = policyCommands.buildPolicySetCommand("/tmp/policy.yaml", "my-assistant"); expect(cmd).toContain("my-assistant"); // The sandbox name must be a discrete argv element, not concatenated into a shell string expect(cmd[cmd.length - 1]).toBe("my-assistant"); }); it("buildPolicyGetCommand preserves hyphenated sandbox name", () => { - const cmd = buildPolicyGetCommand("my-assistant"); + const cmd = policyCommands.buildPolicyGetCommand("my-assistant"); expect(cmd).toContain("my-assistant"); }); + it("buildPolicyGetFullCommand preserves hyphenated sandbox name", () => { + const cmd = policyCommands.buildPolicyGetFullCommand("my-assistant"); + expect(cmd).toContain("my-assistant"); + expect(cmd).toContain("--full"); + }); + + it("falls back to the openshell command name when no binary path resolves", () => { + const resolveSpy = vi.spyOn(openshellResolve, "resolveOpenshell").mockReturnValue(null); + try { + expect(policyCommands.buildPolicyGetCommand("my-assistant")[0]).toBe("openshell"); + } finally { + resolveSpy.mockRestore(); + } + }); + it("buildPolicySetCommand preserves multi-hyphen names", () => { - const cmd = buildPolicySetCommand("/tmp/p.yaml", "my-dev-assistant-v2"); + const cmd = policyCommands.buildPolicySetCommand("/tmp/p.yaml", "my-dev-assistant-v2"); expect(cmd).toContain("my-dev-assistant-v2"); }); it("buildPolicySetCommand preserves single-char name", () => { // If WSL truncates "my-assistant" to "m", the single-char name should // still be passed through unchanged as an argv element - const cmd = buildPolicySetCommand("/tmp/p.yaml", "m"); + const cmd = policyCommands.buildPolicySetCommand("/tmp/p.yaml", "m"); expect(cmd).toContain("m"); }); it("applyPreset rejects truncated/invalid sandbox name", () => { // Empty name - expect(() => applyPreset("", "npm")).toThrow(/Invalid or truncated sandbox name/); + expect(() => policyCommands.applyPreset("", "npm")).toThrow( + /Invalid or truncated sandbox name/, + ); // Name with uppercase (not valid per RFC 1123) - expect(() => applyPreset("My-Assistant", "npm")).toThrow(/Invalid or truncated sandbox name/); + expect(() => policyCommands.applyPreset("My-Assistant", "npm")).toThrow( + /Invalid or truncated sandbox name/, + ); // Name starting with hyphen - expect(() => applyPreset("-broken", "npm")).toThrow(/Invalid or truncated sandbox name/); + expect(() => policyCommands.applyPreset("-broken", "npm")).toThrow( + /Invalid or truncated sandbox name/, + ); }); it("readiness check uses exact match preventing truncated name false-positive", () => { From dcb4c181042d61e30eed442e8365fd7e991f705f Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Tue, 14 Jul 2026 03:45:25 -0700 Subject: [PATCH 45/50] test(messaging): keep lifecycle setup linear --- test/channels-add-bridge-lifecycle.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/channels-add-bridge-lifecycle.test.ts b/test/channels-add-bridge-lifecycle.test.ts index 212b07ff35..6a2fafa765 100644 --- a/test/channels-add-bridge-lifecycle.test.ts +++ b/test/channels-add-bridge-lifecycle.test.ts @@ -115,7 +115,7 @@ beforeEach(() => { "network_policies:\n stub:\n egress:\n - host: example.com\n", ); vi.spyOn(policies, "applyPreset").mockImplementation((_sandboxName, preset) => { - if (!appliedPresets.includes(preset)) appliedPresets.push(preset); + appliedPresets = [...new Set([...appliedPresets, preset])]; return true; }); vi.spyOn(policies, "removePreset").mockImplementation((_sandboxName, preset) => { From 95d83f1d75b08ae81d20f8b92a1c8159b9851a61 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Tue, 14 Jul 2026 04:31:34 -0700 Subject: [PATCH 46/50] fix(googlechat): restrict public webhook tunnel Signed-off-by: Carlos Villela --- .../manage-messaging-channels.mdx | 2 + docs/manage-sandboxes/set-up-google-chat.mdx | 21 +- src/lib/actions/sandbox/destroy.ts | 24 ++ .../sandbox/policy-channel-dependencies.ts | 6 + src/lib/actions/sandbox/policy-channel.ts | 9 + .../googlechat/hooks/tunnel-audience-gate.ts | 2 +- .../googlechat/hooks/tunnel-runtime.test.ts | 103 +++++++ .../googlechat/hooks/tunnel-runtime.ts | 116 ++++--- .../messaging/channels/googlechat/manifest.ts | 8 +- src/lib/policy/commands.ts | 4 +- .../googlechat-webhook-lifecycle.test.ts | 33 ++ .../tunnel/googlechat-webhook-lifecycle.ts | 26 ++ .../tunnel/googlechat-webhook-proxy.test.ts | 117 ++++++++ src/lib/tunnel/googlechat-webhook-proxy.ts | 284 ++++++++++++++++++ src/lib/tunnel/services.ts | 6 +- test/channels-add-bridge-lifecycle.test.ts | 5 + test/destroy-cleanup-sandbox-services.test.ts | 14 +- test/onboard-readiness.test.ts | 48 +-- 18 files changed, 742 insertions(+), 86 deletions(-) create mode 100644 src/lib/messaging/channels/googlechat/hooks/tunnel-runtime.test.ts create mode 100644 src/lib/tunnel/googlechat-webhook-lifecycle.test.ts create mode 100644 src/lib/tunnel/googlechat-webhook-lifecycle.ts create mode 100644 src/lib/tunnel/googlechat-webhook-proxy.test.ts create mode 100644 src/lib/tunnel/googlechat-webhook-proxy.ts diff --git a/docs/manage-sandboxes/manage-messaging-channels.mdx b/docs/manage-sandboxes/manage-messaging-channels.mdx index e9914997d3..a8522959d9 100644 --- a/docs/manage-sandboxes/manage-messaging-channels.mdx +++ b/docs/manage-sandboxes/manage-messaging-channels.mdx @@ -43,6 +43,7 @@ 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. @@ -87,6 +88,7 @@ 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. diff --git a/docs/manage-sandboxes/set-up-google-chat.mdx b/docs/manage-sandboxes/set-up-google-chat.mdx index 235640440f..b1232e8318 100644 --- a/docs/manage-sandboxes/set-up-google-chat.mdx +++ b/docs/manage-sandboxes/set-up-google-chat.mdx @@ -12,9 +12,9 @@ content: 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 default cloudflared tunnel exposes the entire OpenClaw dashboard port, not only the `/googlechat` path. -Open the Control UI through `http://127.0.0.1:18789`, not through the public tunnel URL. + +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 @@ -31,15 +31,17 @@ Do not copy it into the sandbox or its OpenClaw configuration. ## Prepare the Public Webhook -The default `app-url` audience flow uses the same cloudflared service as `nemoclaw tunnel start`. +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 no tunnel is running, NemoClaw starts one and prints the exact HTTPS endpoint ending in `/googlechat`. +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 a tunnel that it started if you do not confirm the endpoint or if enrollment fails before confirmation. +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 endpoint must forward to the OpenClaw dashboard port and preserve the `/googlechat` path. +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. @@ -101,7 +103,10 @@ The re-add flow prompts for the service-account JSON again when it is not alread ## 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 tunnel is running, and the Google Chat API configuration contains the exact same URL. +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 1039d1dca3..2ba5490da0 100644 --- a/src/lib/actions/sandbox/policy-channel.ts +++ b/src/lib/actions/sandbox/policy-channel.ts @@ -1467,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/channels/googlechat/hooks/tunnel-audience-gate.ts b/src/lib/messaging/channels/googlechat/hooks/tunnel-audience-gate.ts index 23e4cf00ce..39f5125f5d 100644 --- a/src/lib/messaging/channels/googlechat/hooks/tunnel-audience-gate.ts +++ b/src/lib/messaging/channels/googlechat/hooks/tunnel-audience-gate.ts @@ -109,7 +109,7 @@ export function createGooglechatTunnelAudienceGateHook( "cloudflared is not installed; cannot expose a public Google Chat webhook.", ); } - log(" Google Chat needs a public HTTPS URL. Starting a tunnel (nemoclaw tunnel start)…"); + log(" Google Chat needs a public HTTPS URL. Starting a dedicated webhook tunnel…"); const startTunnel = requireOption(options.startTunnel, "startTunnel"); await startTunnel(); if (!readTunnelState().running) { 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 index 971a6b72a3..28f09d3aea 100644 --- a/src/lib/messaging/channels/googlechat/hooks/tunnel-runtime.ts +++ b/src/lib/messaging/channels/googlechat/hooks/tunnel-runtime.ts @@ -2,56 +2,104 @@ // 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. The gate composes these with the same service helpers -// `nemoclaw tunnel start/status/stop` use, so it targets the same tunnel. +// 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(): GooglechatTunnelAudienceGateHookOptions { - const dashboardPort = DASHBOARD_PORT; +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: () => { - 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; - } - }, + 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, resolveServicePidDir } = - require("../../../../tunnel/services") as typeof import("../../../../tunnel/services"); + const { readCloudflaredState } = loadServices(); + const { readGooglechatWebhookProxyState } = loadWebhookProxy(); + const pidDir = resolveGooglechatPidDir(); return { - running: readCloudflaredState(resolveServicePidDir()).kind === "running", + running: + readCloudflaredState(pidDir).kind === "running" && + readGooglechatWebhookProxyState(pidDir).running, }; }, - startTunnel: () => { - const { startAll } = - require("../../../../tunnel/services") as typeof import("../../../../tunnel/services"); - return startAll(); + 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 } = - require("../../../../tunnel/services") as typeof import("../../../../tunnel/services"); - stopCloudflared(); + const { stopCloudflared } = loadServices(); + const { stopGooglechatWebhookProxy } = loadWebhookProxy(); + const pidDir = resolveGooglechatPidDir(); + stopCloudflared({ pidDir }); + stopGooglechatWebhookProxy(pidDir); }, getTunnelUrl: () => { - const { getTunnelUrl: getServiceTunnelUrl, resolveServicePidDir } = - require("../../../../tunnel/services") as typeof import("../../../../tunnel/services"); - return getServiceTunnelUrl(resolveServicePidDir(), dashboardPort); - }, - prompt: (question: string) => { - const { prompt } = - require("../../../../credentials/store") as typeof import("../../../../credentials/store"); - return prompt(question); + 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 index f4edf36a30..03d9e06c0c 100644 --- a/src/lib/messaging/channels/googlechat/manifest.ts +++ b/src/lib/messaging/channels/googlechat/manifest.ts @@ -6,9 +6,9 @@ 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` — the same port `nemoclaw tunnel start` already -// exposes. So there is no `hostForward`; the tunnel/audience enroll hook derives -// the public webhook URL from the existing cloudflared tunnel instead. +// 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", @@ -37,7 +37,7 @@ export const googlechatManifest = { " GOOGLECHAT_APP_PRINCIPAL= nemoclaw channels add googlechat", " nemoclaw rebuild --yes", "──────────────────────────────────────────────────────────────", - "The cloudflared tunnel exposes the whole dashboard port publicly; open the Control UI from http://127.0.0.1:18789 (localhost), not the public URL.", + "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: { diff --git a/src/lib/policy/commands.ts b/src/lib/policy/commands.ts index 825a58c853..6016139d64 100644 --- a/src/lib/policy/commands.ts +++ b/src/lib/policy/commands.ts @@ -3,8 +3,8 @@ // Late binding keeps tests able to replace the resolver without rewiring // command builders that are shared by policy and Shields flows. -type OpenshellResolveModule = typeof import("../adapters/openshell/resolve"); -const openshellResolveModule = require("../adapters/openshell/resolve") as OpenshellResolveModule; +const openshellResolveModule = + require("../adapters/openshell/resolve") as typeof import("../adapters/openshell/resolve"); function resolveOpenshellBinary(): string { return openshellResolveModule.resolveOpenshell() ?? "openshell"; 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..f6bd36ac46 --- /dev/null +++ b/src/lib/tunnel/googlechat-webhook-proxy.test.ts @@ -0,0 +1,117 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { createServer, type Server } from "node:http"; +import { mkdtempSync, rmSync, statSync } from "node:fs"; +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(); + if (!address || typeof address === "string") throw new Error("Expected a TCP server address"); + return address.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 64d78a8eb1..cbeec0ff08 100644 --- a/src/lib/tunnel/services.ts +++ b/src/lib/tunnel/services.ts @@ -502,9 +502,9 @@ export function stopAll(opts: ServiceOptions = {}): void { /** * Resolve the PID directory for host-side services without starting or stopping - * anything. Lets callers (e.g. the Google Chat tunnel/audience enroll gate) read - * cloudflared state and the tunnel URL via the same resolution `start`/`stop`/ - * `status` use, so they all target the same tunnel. + * 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); diff --git a/test/channels-add-bridge-lifecycle.test.ts b/test/channels-add-bridge-lifecycle.test.ts index 6a2fafa765..eb831abe39 100644 --- a/test/channels-add-bridge-lifecycle.test.ts +++ b/test/channels-add-bridge-lifecycle.test.ts @@ -72,6 +72,7 @@ let logSpy: MockInstance; let exitSpy: MockInstance; let providerSpy: MockInstance; let runOpenshellSpy: MockInstance; +let stopGooglechatWebhookTunnelSpy: MockInstance; let testHome: string; let registryEntry: SandboxEntry; let appliedPresets: string[]; @@ -143,6 +144,9 @@ beforeEach(() => { // 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, @@ -265,5 +269,6 @@ describe("channels add owns the bridge-provider lifecycle (#6120)", () => { ); expect(appliedPresets).not.toContain("googlechat"); expect(session.policyPresets).not.toContain("googlechat"); + expect(stopGooglechatWebhookTunnelSpy).toHaveBeenCalledWith("test-sb"); }); }); 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/onboard-readiness.test.ts b/test/onboard-readiness.test.ts index 420e982c65..c57b37b6f0 100644 --- a/test/onboard-readiness.test.ts +++ b/test/onboard-readiness.test.ts @@ -1,16 +1,13 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { createRequire } from "node:module"; -import { describe, expect, it, vi } from "vitest"; - -const requireForTest = createRequire(import.meta.url); -const policyCommands = requireForTest( - "../src/lib/policy/index.ts", -) as typeof import("../src/lib/policy"); -const openshellResolve = requireForTest( - "../src/lib/adapters/openshell/resolve.ts", -) as typeof import("../src/lib/adapters/openshell/resolve"); +import { describe, expect, it } from "vitest"; +import { + applyPreset, + buildPolicyGetCommand, + buildPolicyGetFullCommand, + buildPolicySetCommand, +} from "../src/lib/policy"; type OnboardReadinessInternals = { hasStaleGateway: (output: string | null | undefined) => boolean; @@ -125,57 +122,42 @@ describe("sandbox readiness parsing", () => { // argument parsing (e.g. "my-assistant" → "m"). describe("WSL sandbox name handling", () => { it("buildPolicySetCommand preserves hyphenated sandbox name as a separate argv element", () => { - const cmd = policyCommands.buildPolicySetCommand("/tmp/policy.yaml", "my-assistant"); + const cmd = buildPolicySetCommand("/tmp/policy.yaml", "my-assistant"); expect(cmd).toContain("my-assistant"); // The sandbox name must be a discrete argv element, not concatenated into a shell string expect(cmd[cmd.length - 1]).toBe("my-assistant"); }); it("buildPolicyGetCommand preserves hyphenated sandbox name", () => { - const cmd = policyCommands.buildPolicyGetCommand("my-assistant"); + const cmd = buildPolicyGetCommand("my-assistant"); expect(cmd).toContain("my-assistant"); }); it("buildPolicyGetFullCommand preserves hyphenated sandbox name", () => { - const cmd = policyCommands.buildPolicyGetFullCommand("my-assistant"); + const cmd = buildPolicyGetFullCommand("my-assistant"); expect(cmd).toContain("my-assistant"); expect(cmd).toContain("--full"); }); - it("falls back to the openshell command name when no binary path resolves", () => { - const resolveSpy = vi.spyOn(openshellResolve, "resolveOpenshell").mockReturnValue(null); - try { - expect(policyCommands.buildPolicyGetCommand("my-assistant")[0]).toBe("openshell"); - } finally { - resolveSpy.mockRestore(); - } - }); - it("buildPolicySetCommand preserves multi-hyphen names", () => { - const cmd = policyCommands.buildPolicySetCommand("/tmp/p.yaml", "my-dev-assistant-v2"); + const cmd = buildPolicySetCommand("/tmp/p.yaml", "my-dev-assistant-v2"); expect(cmd).toContain("my-dev-assistant-v2"); }); it("buildPolicySetCommand preserves single-char name", () => { // If WSL truncates "my-assistant" to "m", the single-char name should // still be passed through unchanged as an argv element - const cmd = policyCommands.buildPolicySetCommand("/tmp/p.yaml", "m"); + const cmd = buildPolicySetCommand("/tmp/p.yaml", "m"); expect(cmd).toContain("m"); }); it("applyPreset rejects truncated/invalid sandbox name", () => { // Empty name - expect(() => policyCommands.applyPreset("", "npm")).toThrow( - /Invalid or truncated sandbox name/, - ); + expect(() => applyPreset("", "npm")).toThrow(/Invalid or truncated sandbox name/); // Name with uppercase (not valid per RFC 1123) - expect(() => policyCommands.applyPreset("My-Assistant", "npm")).toThrow( - /Invalid or truncated sandbox name/, - ); + expect(() => applyPreset("My-Assistant", "npm")).toThrow(/Invalid or truncated sandbox name/); // Name starting with hyphen - expect(() => policyCommands.applyPreset("-broken", "npm")).toThrow( - /Invalid or truncated sandbox name/, - ); + expect(() => applyPreset("-broken", "npm")).toThrow(/Invalid or truncated sandbox name/); }); it("readiness check uses exact match preventing truncated name false-positive", () => { From 4e832e18bb127bd2afc967f48371fb7fbf355f30 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Tue, 14 Jul 2026 04:33:38 -0700 Subject: [PATCH 47/50] test(googlechat): keep proxy setup linear Signed-off-by: Carlos Villela --- src/lib/tunnel/googlechat-webhook-proxy.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/lib/tunnel/googlechat-webhook-proxy.test.ts b/src/lib/tunnel/googlechat-webhook-proxy.test.ts index f6bd36ac46..c9eb8b6982 100644 --- a/src/lib/tunnel/googlechat-webhook-proxy.test.ts +++ b/src/lib/tunnel/googlechat-webhook-proxy.test.ts @@ -1,8 +1,9 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { createServer, type Server } from "node:http"; 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"; @@ -33,8 +34,9 @@ async function listen(server: Server): Promise { server.listen(0, "127.0.0.1", () => resolve()); }); const address = server.address(); - if (!address || typeof address === "string") throw new Error("Expected a TCP server address"); - return address.port; + expect(address).not.toBeNull(); + expect(typeof address).not.toBe("string"); + return (address as AddressInfo).port; } describe("Google Chat webhook route proxy", () => { From 3949836f39f6983d0bfad4d552c6c45cff5f816a Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Tue, 14 Jul 2026 05:08:32 -0700 Subject: [PATCH 48/50] test(googlechat): cover stop start lifecycle Signed-off-by: Carlos Villela --- test/channels-add-bridge-lifecycle.test.ts | 49 +++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/test/channels-add-bridge-lifecycle.test.ts b/test/channels-add-bridge-lifecycle.test.ts index eb831abe39..caf0ac198f 100644 --- a/test/channels-add-bridge-lifecycle.test.ts +++ b/test/channels-add-bridge-lifecycle.test.ts @@ -11,7 +11,12 @@ 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 } from "../src/lib/actions/sandbox/policy-channel"; +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"; @@ -110,6 +115,9 @@ beforeEach(() => { registryEntry = { ...registryEntry, ...update } as SandboxEntry; return true; }); + vi.spyOn(registry, "getDisabledChannels").mockImplementation(() => [ + ...(registryEntry.messaging?.plan.disabledChannels ?? []), + ]); appliedPresets = []; vi.spyOn(policies, "loadPresetForSandbox").mockReturnValue( @@ -271,4 +279,43 @@ describe("channels add owns the bridge-provider lifecycle (#6120)", () => { 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(); + }); }); From abebc59effe4f15e8f7dca80c34222076e1100bd Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Tue, 14 Jul 2026 05:20:40 -0700 Subject: [PATCH 49/50] refactor(googlechat): type runtime patches Signed-off-by: Carlos Villela --- .../runtime/googlechat-outbound-auth.ts | 84 +++++++++++++---- .../runtime/googlechat-trusted-proxy-fetch.ts | 91 ++++++++++++++----- 2 files changed, 133 insertions(+), 42 deletions(-) diff --git a/src/lib/messaging/channels/googlechat/runtime/googlechat-outbound-auth.ts b/src/lib/messaging/channels/googlechat/runtime/googlechat-outbound-auth.ts index 82aa7e671c..c0bd2a552a 100644 --- a/src/lib/messaging/channels/googlechat/runtime/googlechat-outbound-auth.ts +++ b/src/lib/messaging/channels/googlechat/runtime/googlechat-outbound-auth.ts @@ -1,4 +1,3 @@ -// @ts-nocheck // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 // @@ -73,7 +72,29 @@ // 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. -export const outboundAuthPatchInternals = {}; +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"; @@ -87,14 +108,19 @@ export const outboundAuthPatchInternals = {}; var CALL_MARKER = "nemoclaw: googlechat outbound bearer via gateway-minted credential"; var DEF_SIGNATURE = "function getGoogleChatAccessToken"; - if (process.__nemoclawGooglechatOutboundAuthInstalled) return; + var processWithPatchMarker = process as NodeJS.Process & { + __nemoclawGooglechatOutboundAuthInstalled?: boolean; + }; + if (processWithPatchMarker.__nemoclawGooglechatOutboundAuthInstalled) return; try { - Object.defineProperty(process, "__nemoclawGooglechatOutboundAuthInstalled", { value: true }); + Object.defineProperty(processWithPatchMarker, "__nemoclawGooglechatOutboundAuthInstalled", { + value: true, + }); } catch (_e) { - process.__nemoclawGooglechatOutboundAuthInstalled = true; + processWithPatchMarker.__nemoclawGooglechatOutboundAuthInstalled = true; } - function isOpenClawGooglechatFile(filename) { + 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 @@ -120,7 +146,7 @@ export const outboundAuthPatchInternals = {}; // 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() { + function buildBearerShortCircuitSource(): string { var canonical = "openshell:resolve:env:" + ENV_VAR; return ( 'var __nemoGcRaw = (typeof process !== "undefined" && process.env) ' + @@ -137,7 +163,7 @@ export const outboundAuthPatchInternals = {}; ); } - function patchGooglechatOutboundAuthSource(source, filename) { + 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; @@ -158,15 +184,22 @@ export const outboundAuthPatchInternals = {}; return source.replace(anchor, "$1\n " + buildBearerShortCircuitSource()); } - function isGooglechatOutboundAuthPatchError(reason) { - var msg = String((reason && reason.message) || reason || ""); + 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) { + function fileNameFromModuleUrl(urlValue: unknown): string { if (typeof urlValue !== "string" || !urlValue.startsWith("file:")) return ""; try { return require("url").fileURLToPath(urlValue); @@ -175,7 +208,7 @@ export const outboundAuthPatchInternals = {}; } } - function sourceToText(source) { + function sourceToText(source: unknown): string | null { if (typeof source === "string") return source; if (typeof Buffer !== "undefined") { if (Buffer.isBuffer(source)) return source.toString("utf8"); @@ -189,14 +222,23 @@ export const outboundAuthPatchInternals = {}; // 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) { - return function nemoclawGooglechatJsLoader(mod, filename) { + 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.apply(this, arguments); + return originalJsLoader.call(this, mod, filename, ...args); } var originalCompile = mod._compile; - mod._compile = function nemoclawGooglechatCompile(source, loadedFilename) { + mod._compile = function nemoclawGooglechatCompile( + this: unknown, + source: unknown, + loadedFilename?: string, + ) { var sourceText = sourceToText(source); var patched = sourceText === null @@ -205,7 +247,7 @@ export const outboundAuthPatchInternals = {}; return originalCompile.call(this, patched, loadedFilename); }; try { - return originalJsLoader.apply(this, arguments); + return originalJsLoader.call(this, mod, filename, ...args); } finally { mod._compile = originalCompile; } @@ -221,7 +263,11 @@ export const outboundAuthPatchInternals = {}; if (typeof Module.registerHooks === "function") { Module.registerHooks({ - load: function nemoclawGooglechatLoadHook(urlValue, context, nextLoad) { + load: function nemoclawGooglechatLoadHook( + urlValue: string, + context: unknown, + nextLoad: NextModuleLoad, + ) { var result = nextLoad(urlValue, context); var filename = fileNameFromModuleUrl(urlValue); if (!isOpenClawGooglechatFile(filename)) return result; @@ -257,7 +303,7 @@ export const outboundAuthPatchInternals = {}; // degrading silently. Boot/health-time fail-closed on drift is a tracked // follow-up. process.stderr.write( - "[channels] [googlechat] outbound-auth patch NOT applied: " + String(e && e.message) + "\n", + "[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.ts b/src/lib/messaging/channels/googlechat/runtime/googlechat-trusted-proxy-fetch.ts index 540384909c..419d1c90bf 100644 --- a/src/lib/messaging/channels/googlechat/runtime/googlechat-trusted-proxy-fetch.ts +++ b/src/lib/messaging/channels/googlechat/runtime/googlechat-trusted-proxy-fetch.ts @@ -1,4 +1,3 @@ -// @ts-nocheck // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 // @@ -71,7 +70,28 @@ // 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. -export const trustedProxyFetchPatchInternals = {}; +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"; @@ -82,16 +102,23 @@ export const trustedProxyFetchPatchInternals = {}; // this constant is defined right beside it, so its presence identifies the bundle. var BUNDLE_MARKER = "GOOGLE_AUTH_AUDIT_CONTEXT"; - if (process.__nemoclawGooglechatTrustedProxyFetchInstalled) return; + var processWithPatchMarker = process as NodeJS.Process & { + __nemoclawGooglechatTrustedProxyFetchInstalled?: boolean; + }; + if (processWithPatchMarker.__nemoclawGooglechatTrustedProxyFetchInstalled) return; try { - Object.defineProperty(process, "__nemoclawGooglechatTrustedProxyFetchInstalled", { - value: true, - }); + Object.defineProperty( + processWithPatchMarker, + "__nemoclawGooglechatTrustedProxyFetchInstalled", + { + value: true, + }, + ); } catch (_e) { - process.__nemoclawGooglechatTrustedProxyFetchInstalled = true; + processWithPatchMarker.__nemoclawGooglechatTrustedProxyFetchInstalled = true; } - function isOpenClawGooglechatFile(filename) { + 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 @@ -119,13 +146,13 @@ export const trustedProxyFetchPatchInternals = {}; // uniquely identifies this call (the google-auth call opens with auditContext). var ANCHOR_C = /fetchWithSsrFGuard\(\{(\s*)url\s*,/; - function patchTrustedProxyFetchSource(source, filename) { + 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 = []; + 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"); @@ -177,15 +204,22 @@ export const trustedProxyFetchPatchInternals = {}; return patched; } - function isTrustedProxyFetchPatchError(reason) { - var msg = String((reason && reason.message) || reason || ""); + 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) { + function fileNameFromModuleUrl(urlValue: unknown): string { if (typeof urlValue !== "string" || !urlValue.startsWith("file:")) return ""; try { return require("url").fileURLToPath(urlValue); @@ -194,7 +228,7 @@ export const trustedProxyFetchPatchInternals = {}; } } - function sourceToText(source) { + function sourceToText(source: unknown): string | null { if (typeof source === "string") return source; if (typeof Buffer !== "undefined") { if (Buffer.isBuffer(source)) return source.toString("utf8"); @@ -208,14 +242,23 @@ export const trustedProxyFetchPatchInternals = {}; // 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) { - return function nemoclawGooglechatTrustedProxyJsLoader(mod, filename) { + 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.apply(this, arguments); + return originalJsLoader.call(this, mod, filename, ...args); } var originalCompile = mod._compile; - mod._compile = function nemoclawGooglechatTrustedProxyCompile(source, loadedFilename) { + mod._compile = function nemoclawGooglechatTrustedProxyCompile( + this: unknown, + source: unknown, + loadedFilename?: string, + ) { var sourceText = sourceToText(source); var patched = sourceText === null @@ -224,7 +267,7 @@ export const trustedProxyFetchPatchInternals = {}; return originalCompile.call(this, patched, loadedFilename); }; try { - return originalJsLoader.apply(this, arguments); + return originalJsLoader.call(this, mod, filename, ...args); } finally { mod._compile = originalCompile; } @@ -240,7 +283,11 @@ export const trustedProxyFetchPatchInternals = {}; if (typeof Module.registerHooks === "function") { Module.registerHooks({ - load: function nemoclawGooglechatTrustedProxyLoadHook(urlValue, context, nextLoad) { + load: function nemoclawGooglechatTrustedProxyLoadHook( + urlValue: string, + context: unknown, + nextLoad: NextModuleLoad, + ) { var result = nextLoad(urlValue, context); var filename = fileNameFromModuleUrl(urlValue); if (!isOpenClawGooglechatFile(filename)) return result; @@ -268,9 +315,7 @@ export const trustedProxyFetchPatchInternals = {}; } catch (e) { if (isTrustedProxyFetchPatchError(e)) { process.stderr.write( - "[channels] [googlechat] trusted-proxy-fetch patch NOT applied: " + - String(e && e.message) + - "\n", + "[channels] [googlechat] trusted-proxy-fetch patch NOT applied: " + errorMessage(e) + "\n", ); } // Any other failure: never break gateway boot. From 439331dac6306486707a07c4f9cb44f9f8000e19 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Tue, 14 Jul 2026 06:08:07 -0700 Subject: [PATCH 50/50] test(e2e): keep channel lifecycle visible Signed-off-by: Carlos Villela --- test/e2e/live/channels-stop-start-helpers.ts | 4 ++ test/e2e/live/channels-stop-start-progress.ts | 51 ++++++++++++++++ .../channels-stop-start-progress.test.ts | 60 +++++++++++++++++++ 3 files changed, 115 insertions(+) create mode 100644 test/e2e/live/channels-stop-start-progress.ts create mode 100644 test/e2e/support/channels-stop-start-progress.test.ts 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); + }); +});