From 405883a12ca14ac41f398a2ba9e4b6b04c19ddd7 Mon Sep 17 00:00:00 2001 From: Ho Lim Date: Sun, 16 Aug 2026 02:33:20 -0700 Subject: [PATCH 1/5] fix(messaging): activate Google Chat pairing approval Signed-off-by: Ho Lim --- .../exec-googlechat-pairing-restart.test.ts | 192 ++++++++++++++++++ src/lib/actions/sandbox/exec.ts | 29 ++- 2 files changed, 220 insertions(+), 1 deletion(-) create mode 100644 src/lib/actions/sandbox/exec-googlechat-pairing-restart.test.ts diff --git a/src/lib/actions/sandbox/exec-googlechat-pairing-restart.test.ts b/src/lib/actions/sandbox/exec-googlechat-pairing-restart.test.ts new file mode 100644 index 00000000000..c88b78145f9 --- /dev/null +++ b/src/lib/actions/sandbox/exec-googlechat-pairing-restart.test.ts @@ -0,0 +1,192 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + execSandbox, + isGoogleChatPairingApproval, + type ExecSandboxDeps, + type SandboxExecCleanupDeps, +} from "./exec"; + +const CLEANUP_SKIPPED: SandboxExecCleanupDeps = { + getSandbox: () => null, + inspectMutableConfigPerms: () => { + throw new Error("cleanup should be skipped for an unregistered sandbox"); + }, + repairMutableConfigPerms: () => { + throw new Error("cleanup should be skipped for an unregistered sandbox"); + }, +}; + +function depsFor(status: number, restartGateway = vi.fn(() => ({ ok: true }))): ExecSandboxDeps { + return { + resolveBinary: () => "openshell", + selectGateway: () => ({ outcome: "unregistered", gatewayName: null }), + run: () => ({ status }), + cleanupDeps: CLEANUP_SKIPPED, + restartGateway, + policyHint: { + now: () => 1_000, + probeLogs: () => "", + enableAudit: () => {}, + sleep: async () => {}, + attempts: 1, + writeStderr: () => {}, + }, + }; +} + +async function runAndCaptureExit( + command: readonly string[], + deps: ExecSandboxDeps, +): Promise { + let exitCode = Number.NaN; + vi.spyOn(process, "exit").mockImplementation(((code?: number) => { + exitCode = code ?? 0; + throw new Error("__exec_exit__"); + }) as never); + + await execSandbox("alpha", command, {}, deps).catch((error: unknown) => { + expect(error).toEqual(new Error("__exec_exit__")); + }); + return exitCode; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("Google Chat pairing approval gateway activation (#8553)", () => { + it("recognizes only a direct Google Chat pairing approval with a code", () => { + expect( + isGoogleChatPairingApproval(["openclaw", "pairing", "approve", "googlechat", "ABCD1234"]), + ).toBe(true); + expect( + isGoogleChatPairingApproval([ + "openclaw", + "pairing", + "approve", + "googlechat", + "ABCD1234", + "--json", + ]), + ).toBe(true); + expect( + isGoogleChatPairingApproval(["openclaw", "pairing", "approve", "telegram", "ABCD1234"]), + ).toBe(false); + expect( + isGoogleChatPairingApproval(["sh", "-lc", "openclaw pairing approve googlechat ABCD1234"]), + ).toBe(false); + expect( + isGoogleChatPairingApproval(["openclaw", "pairing", "approve", "googlechat", "--help"]), + ).toBe(false); + }); + + it("restarts the managed gateway after the exact approval succeeds", async () => { + const restartGateway = vi.fn(() => ({ ok: true })); + const exitCode = await runAndCaptureExit( + ["openclaw", "pairing", "approve", "googlechat", "ABCD1234"], + depsFor(0, restartGateway), + ); + + expect(restartGateway).toHaveBeenCalledOnce(); + expect(restartGateway).toHaveBeenCalledWith("alpha"); + expect(exitCode).toBe(0); + }); + + it("restarts only after the mutable OpenClaw config contract is verified", async () => { + const order: string[] = []; + const restartGateway = vi.fn(() => { + order.push("restart"); + return { ok: true }; + }); + const deps = depsFor(0, restartGateway); + deps.run = () => { + order.push("command"); + return { status: 0 }; + }; + deps.cleanupDeps = { + getSandbox: () => ({ agent: "openclaw" }), + inspectMutableConfigPerms: () => { + order.push("cleanup"); + return { + applies: true, + ok: true, + dirMode: "2770", + dirOwner: "sandbox:sandbox", + fileMode: "660", + fileOwner: "sandbox:sandbox", + configDir: "/sandbox/.openclaw", + configFile: "openclaw.json", + issues: [], + }; + }, + repairMutableConfigPerms: () => { + throw new Error("healthy config should not need repair"); + }, + }; + + const exitCode = await runAndCaptureExit( + ["openclaw", "pairing", "approve", "googlechat", "ABCD1234"], + deps, + ); + + expect(order).toEqual(["command", "cleanup", "restart"]); + expect(exitCode).toBe(0); + }); + + it("does not restart when post-command config cleanup fails", async () => { + const restartGateway = vi.fn(() => ({ ok: true })); + const deps = depsFor(0, restartGateway); + deps.cleanupDeps = { + getSandbox: () => { + throw new Error("invalid registry JSON"); + }, + inspectMutableConfigPerms: CLEANUP_SKIPPED.inspectMutableConfigPerms, + repairMutableConfigPerms: CLEANUP_SKIPPED.repairMutableConfigPerms, + }; + + const exitCode = await runAndCaptureExit( + ["openclaw", "pairing", "approve", "googlechat", "ABCD1234"], + deps, + ); + + expect(restartGateway).not.toHaveBeenCalled(); + expect(exitCode).toBe(1); + }); + + it("fails the public command when activation restart fails", async () => { + const restartGateway = vi.fn(() => ({ ok: false })); + const exitCode = await runAndCaptureExit( + ["openclaw", "pairing", "approve", "googlechat", "ABCD1234"], + depsFor(0, restartGateway), + ); + + expect(restartGateway).toHaveBeenCalledOnce(); + expect(exitCode).toBe(1); + }); + + it("does not restart after a failed approval", async () => { + const restartGateway = vi.fn(() => ({ ok: true })); + const exitCode = await runAndCaptureExit( + ["openclaw", "pairing", "approve", "googlechat", "BADCODE"], + depsFor(17, restartGateway), + ); + + expect(restartGateway).not.toHaveBeenCalled(); + expect(exitCode).toBe(17); + }); + + it("leaves unrelated successful exec commands unchanged", async () => { + const restartGateway = vi.fn(() => ({ ok: true })); + const exitCode = await runAndCaptureExit( + ["openclaw", "pairing", "approve", "telegram", "ABCD1234"], + depsFor(0, restartGateway), + ); + + expect(restartGateway).not.toHaveBeenCalled(); + expect(exitCode).toBe(0); + }); +}); diff --git a/src/lib/actions/sandbox/exec.ts b/src/lib/actions/sandbox/exec.ts index ec386bcf8ef..b0db667931f 100644 --- a/src/lib/actions/sandbox/exec.ts +++ b/src/lib/actions/sandbox/exec.ts @@ -27,6 +27,8 @@ export type SandboxExecOptions = { stdin?: boolean; }; +export type SandboxExecGatewayRestart = (sandboxName: string) => { ok: boolean }; + type SpawnLikeResult = { status: number | null; signal?: NodeJS.Signals | null; @@ -364,10 +366,30 @@ export type ExecSandboxDeps = { run?: SandboxExecRunner; policyHint?: ExecPolicyHintDeps; cleanupDeps?: SandboxExecCleanupDeps; + /** Activate config written by a successful direct Google Chat pairing approval. */ + restartGateway?: SandboxExecGatewayRestart; /** Select the sandbox's owning gateway before the exec talks to OpenShell. */ selectGateway?: (sandboxName: string) => GatewaySelectResult; }; +export function isGoogleChatPairingApproval(command: readonly string[]): boolean { + return ( + command.length >= 5 && + command[0] === "openclaw" && + command[1] === "pairing" && + command[2] === "approve" && + command[3] === "googlechat" && + Boolean(command[4]) && + !command[4]!.startsWith("-") + ); +} + +function defaultRestartGateway(sandboxName: string): { ok: boolean } { + const { defaultInferenceGatewayRestart } = + require("../inference-set-gateway-restart") as typeof import("../inference-set-gateway-restart"); + return defaultInferenceGatewayRestart(sandboxName); +} + export async function execSandbox( sandboxName: string, command: readonly string[], @@ -437,5 +459,10 @@ export async function execSandbox( console.error(cleanupFailureMessage(completion.commandCode, completion.cleanupError)); } await emitPolicyDenialHint(completion); - process.exit(completion.code); + let exitCode = completion.code; + if (exitCode === 0 && isGoogleChatPairingApproval(command)) { + const restart = (deps.restartGateway ?? defaultRestartGateway)(sandboxName); + if (!restart.ok) exitCode = 1; + } + process.exit(exitCode); } From bb35c485127f28d82bfe68143920cac8e9bf40ea Mon Sep 17 00:00:00 2001 From: Ho Lim Date: Sun, 16 Aug 2026 03:10:40 -0700 Subject: [PATCH 2/5] fix(messaging): harden Google Chat approval activation Signed-off-by: Ho Lim --- docs/manage-sandboxes/set-up-google-chat.mdx | 11 +++ docs/reference/commands.mdx | 9 ++ .../exec-googlechat-pairing-restart.test.ts | 87 +++++++++++++++++++ src/lib/actions/sandbox/exec.ts | 51 ++++++++++- 4 files changed, 155 insertions(+), 3 deletions(-) diff --git a/docs/manage-sandboxes/set-up-google-chat.mdx b/docs/manage-sandboxes/set-up-google-chat.mdx index b66a0587f95..8d7b9602e9b 100644 --- a/docs/manage-sandboxes/set-up-google-chat.mdx +++ b/docs/manage-sandboxes/set-up-google-chat.mdx @@ -172,6 +172,17 @@ 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 an unknown sender receives a pairing code, approve it through the registered OpenClaw sandbox: + +```bash +nemoclaw my-assistant exec -- openclaw pairing approve googlechat +``` + +After OpenClaw commits the sender to its owner allowlist, NemoClaw verifies the mutable config permissions and automatically restarts the managed gateway. +An exit status of `0` means activation completed, so the sender's next message should receive a reply without another manual restart. +If permission cleanup or gateway restart fails after the approval commits, `exec` exits with status `1`, reports that the approval was not rolled back, and prints the recovery command. +Correct any reported permission problem, then run `nemoclaw my-assistant gateway restart` before testing the next message; do not submit the pairing code again unless OpenClaw reports that it was not accepted. + 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. Refer to [Manage Messaging Channels](manage-messaging-channels) to stop, start, or remove Google Chat after setup. diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index aa56e91fc1f..c9d502bef01 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -1321,6 +1321,11 @@ When cleanup succeeds, `exec` returns the remote command's exit code. If cleanup cannot inspect, restore, or verify that contract, it fails closed and prints `OpenClaw permission cleanup failed (...)` to `stderr`. In that case, `exec` returns the cleanup failure instead of the remote command's status. +For a registered OpenClaw sandbox, a successful direct `openclaw pairing approve googlechat ` command also restarts the managed gateway after cleanup so the new sender allowlist applies to the next message. +If cleanup or restart fails after the approval commits, `exec` exits with status `1` and reports that the approval was not rolled back. +Correct any reported cleanup problem, then run `$$nemoclaw gateway restart` before testing the next message. +NemoClaw does not apply this automatic restart to unregistered or non-OpenClaw sandboxes. + @@ -2148,6 +2153,10 @@ After the remote command exits, NemoClaw verifies and, when needed, restores the When cleanup succeeds, `exec` preserves the remote command's exit code. When cleanup fails closed, `exec` returns the cleanup failure and reports both statuses on `stderr`. +A successful direct `openclaw pairing approve googlechat ` command in a registered OpenClaw sandbox restarts the managed gateway after cleanup, so the new sender allowlist applies to the next message. +If the approval commits but cleanup or restart fails, `exec` exits with status `1`, reports that the approval was not rolled back, and directs you to run `$$nemoclaw gateway restart` after correcting any cleanup problem. +Unregistered and non-OpenClaw sandboxes do not receive this automatic restart. + diff --git a/src/lib/actions/sandbox/exec-googlechat-pairing-restart.test.ts b/src/lib/actions/sandbox/exec-googlechat-pairing-restart.test.ts index c88b78145f9..02eb112a1db 100644 --- a/src/lib/actions/sandbox/exec-googlechat-pairing-restart.test.ts +++ b/src/lib/actions/sandbox/exec-googlechat-pairing-restart.test.ts @@ -27,6 +27,7 @@ function depsFor(status: number, restartGateway = vi.fn(() => ({ ok: true }))): run: () => ({ status }), cleanupDeps: CLEANUP_SKIPPED, restartGateway, + resolveSandboxAgent: () => "openclaw", policyHint: { now: () => 1_000, probeLogs: () => "", @@ -139,6 +140,7 @@ describe("Google Chat pairing approval gateway activation (#8553)", () => { it("does not restart when post-command config cleanup fails", async () => { const restartGateway = vi.fn(() => ({ ok: true })); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); const deps = depsFor(0, restartGateway); deps.cleanupDeps = { getSandbox: () => { @@ -155,10 +157,38 @@ describe("Google Chat pairing approval gateway activation (#8553)", () => { expect(restartGateway).not.toHaveBeenCalled(); expect(exitCode).toBe(1); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining("pairing approval committed for 'alpha'"), + ); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining("nemoclaw alpha gateway restart"), + ); }); it("fails the public command when activation restart fails", async () => { const restartGateway = vi.fn(() => ({ ok: false })); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const exitCode = await runAndCaptureExit( + ["openclaw", "pairing", "approve", "googlechat", "ABCD1234"], + depsFor(0, restartGateway), + ); + + expect(restartGateway).toHaveBeenCalledOnce(); + expect(exitCode).toBe(1); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining("pairing approval committed for 'alpha'"), + ); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining("nemoclaw alpha gateway restart"), + ); + }); + + it("reports a controlled partial commit when the activation restart throws", async () => { + const restartGateway = vi.fn(() => { + throw new Error("supervisor transport unavailable"); + }); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const exitCode = await runAndCaptureExit( ["openclaw", "pairing", "approve", "googlechat", "ABCD1234"], depsFor(0, restartGateway), @@ -166,6 +196,12 @@ describe("Google Chat pairing approval gateway activation (#8553)", () => { expect(restartGateway).toHaveBeenCalledOnce(); expect(exitCode).toBe(1); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining("approval was not rolled back"), + ); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining("nemoclaw alpha gateway restart"), + ); }); it("does not restart after a failed approval", async () => { @@ -189,4 +225,55 @@ describe("Google Chat pairing approval gateway activation (#8553)", () => { expect(restartGateway).not.toHaveBeenCalled(); expect(exitCode).toBe(0); }); + + it.each(["hermes", "custom-agent"])( + "does not restart a recorded non-OpenClaw %s sandbox", + async (agent) => { + const restartGateway = vi.fn(() => ({ ok: true })); + const deps = depsFor(0, restartGateway); + deps.resolveSandboxAgent = () => agent; + + const exitCode = await runAndCaptureExit( + ["openclaw", "pairing", "approve", "googlechat", "ABCD1234"], + deps, + ); + + expect(restartGateway).not.toHaveBeenCalled(); + expect(exitCode).toBe(0); + }, + ); + + it("does not restart an unregistered sandbox", async () => { + const restartGateway = vi.fn(() => ({ ok: true })); + const deps = depsFor(0, restartGateway); + deps.resolveSandboxAgent = () => null; + + const exitCode = await runAndCaptureExit( + ["openclaw", "pairing", "approve", "googlechat", "ABCD1234"], + deps, + ); + + expect(restartGateway).not.toHaveBeenCalled(); + expect(exitCode).toBe(0); + }); + + it("fails closed when the recorded sandbox identity cannot be read", async () => { + const restartGateway = vi.fn(() => ({ ok: true })); + const deps = depsFor(0, restartGateway); + deps.resolveSandboxAgent = () => { + throw new Error("registry unavailable"); + }; + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + const exitCode = await runAndCaptureExit( + ["openclaw", "pairing", "approve", "googlechat", "ABCD1234"], + deps, + ); + + expect(restartGateway).not.toHaveBeenCalled(); + expect(exitCode).toBe(1); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining("pairing approval committed for 'alpha'"), + ); + }); }); diff --git a/src/lib/actions/sandbox/exec.ts b/src/lib/actions/sandbox/exec.ts index b0db667931f..bc69af9c85d 100644 --- a/src/lib/actions/sandbox/exec.ts +++ b/src/lib/actions/sandbox/exec.ts @@ -29,6 +29,8 @@ export type SandboxExecOptions = { export type SandboxExecGatewayRestart = (sandboxName: string) => { ok: boolean }; +export type SandboxExecAgentResolver = (sandboxName: string) => string | null; + type SpawnLikeResult = { status: number | null; signal?: NodeJS.Signals | null; @@ -368,6 +370,8 @@ export type ExecSandboxDeps = { cleanupDeps?: SandboxExecCleanupDeps; /** Activate config written by a successful direct Google Chat pairing approval. */ restartGateway?: SandboxExecGatewayRestart; + /** Resolve the sandbox's recorded agent before applying agent-specific post-exec effects. */ + resolveSandboxAgent?: SandboxExecAgentResolver; /** Select the sandbox's owning gateway before the exec talks to OpenShell. */ selectGateway?: (sandboxName: string) => GatewaySelectResult; }; @@ -390,6 +394,24 @@ function defaultRestartGateway(sandboxName: string): { ok: boolean } { return defaultInferenceGatewayRestart(sandboxName); } +function defaultResolveSandboxAgent(sandboxName: string): string | null { + const entry = ( + require("../../state/registry") as typeof import("../../state/registry") + ).getSandbox(sandboxName); + if (!entry) return null; + return entry.agent ?? "openclaw"; +} + +function googleChatPairingActivationFailureMessage( + cliName: string, + sandboxName: string, +): string { + return ( + ` Google Chat pairing approval committed for '${sandboxName}', but managed gateway activation failed. ` + + `The approval was not rolled back. Run '${cliName} ${sandboxName} gateway restart' before testing the next message.` + ); +} + export async function execSandbox( sandboxName: string, command: readonly string[], @@ -460,9 +482,32 @@ export async function execSandbox( } await emitPolicyDenialHint(completion); let exitCode = completion.code; - if (exitCode === 0 && isGoogleChatPairingApproval(command)) { - const restart = (deps.restartGateway ?? defaultRestartGateway)(sandboxName); - if (!restart.ok) exitCode = 1; + const googleChatApprovalCommitted = + completion.commandCode === 0 && isGoogleChatPairingApproval(command); + if (googleChatApprovalCommitted && completion.cleanupError) { + console.error(googleChatPairingActivationFailureMessage(CLI_NAME, sandboxName)); + } + if (exitCode === 0 && googleChatApprovalCommitted) { + let recordedAgent: string | null; + try { + recordedAgent = (deps.resolveSandboxAgent ?? defaultResolveSandboxAgent)(sandboxName); + } catch { + console.error(googleChatPairingActivationFailureMessage(CLI_NAME, sandboxName)); + process.exit(1); + } + if (recordedAgent === "openclaw") { + let restartSucceeded = false; + try { + restartSucceeded = (deps.restartGateway ?? defaultRestartGateway)(sandboxName).ok; + } catch { + // The approval already committed inside OpenClaw. Convert restart + // exceptions into the same explicit partial-commit recovery contract. + } + if (!restartSucceeded) { + console.error(googleChatPairingActivationFailureMessage(CLI_NAME, sandboxName)); + exitCode = 1; + } + } } process.exit(exitCode); } From 96c209e3ea680b1f4c9ca99047e133e951369b2b Mon Sep 17 00:00:00 2001 From: Ho Lim Date: Sun, 16 Aug 2026 03:23:55 -0700 Subject: [PATCH 3/5] fix(messaging): require Google Chat gateway ownership Signed-off-by: Ho Lim --- .../exec-googlechat-pairing-restart.test.ts | 34 ++++++++++++++++--- src/lib/actions/sandbox/exec.ts | 6 ++-- 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/src/lib/actions/sandbox/exec-googlechat-pairing-restart.test.ts b/src/lib/actions/sandbox/exec-googlechat-pairing-restart.test.ts index 02eb112a1db..d70ce077f65 100644 --- a/src/lib/actions/sandbox/exec-googlechat-pairing-restart.test.ts +++ b/src/lib/actions/sandbox/exec-googlechat-pairing-restart.test.ts @@ -23,7 +23,7 @@ const CLEANUP_SKIPPED: SandboxExecCleanupDeps = { function depsFor(status: number, restartGateway = vi.fn(() => ({ ok: true }))): ExecSandboxDeps { return { resolveBinary: () => "openshell", - selectGateway: () => ({ outcome: "unregistered", gatewayName: null }), + selectGateway: () => ({ outcome: "selected", gatewayName: "nemoclaw-alpha" }), run: () => ({ status }), cleanupDeps: CLEANUP_SKIPPED, restartGateway, @@ -196,9 +196,7 @@ describe("Google Chat pairing approval gateway activation (#8553)", () => { expect(restartGateway).toHaveBeenCalledOnce(); expect(exitCode).toBe(1); - expect(errorSpy).toHaveBeenCalledWith( - expect.stringContaining("approval was not rolled back"), - ); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("approval was not rolled back")); expect(errorSpy).toHaveBeenCalledWith( expect.stringContaining("nemoclaw alpha gateway restart"), ); @@ -257,6 +255,34 @@ describe("Google Chat pairing approval gateway activation (#8553)", () => { expect(exitCode).toBe(0); }); + it("does not activate or claim managed recovery without an owning gateway", async () => { + const restartGateway = vi.fn(() => ({ ok: true })); + const deps = depsFor(0, restartGateway); + deps.selectGateway = () => ({ outcome: "unregistered", gatewayName: null }); + deps.cleanupDeps = { + getSandbox: () => { + throw new Error("invalid registry JSON"); + }, + inspectMutableConfigPerms: CLEANUP_SKIPPED.inspectMutableConfigPerms, + repairMutableConfigPerms: CLEANUP_SKIPPED.repairMutableConfigPerms, + }; + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + const exitCode = await runAndCaptureExit( + ["openclaw", "pairing", "approve", "googlechat", "ABCD1234"], + deps, + ); + + expect(restartGateway).not.toHaveBeenCalled(); + expect(exitCode).toBe(1); + expect(errorSpy).not.toHaveBeenCalledWith( + expect.stringContaining("managed gateway activation failed"), + ); + expect(errorSpy).not.toHaveBeenCalledWith( + expect.stringContaining("nemoclaw alpha gateway restart"), + ); + }); + it("fails closed when the recorded sandbox identity cannot be read", async () => { const restartGateway = vi.fn(() => ({ ok: true })); const deps = depsFor(0, restartGateway); diff --git a/src/lib/actions/sandbox/exec.ts b/src/lib/actions/sandbox/exec.ts index bc69af9c85d..de0f9a5f197 100644 --- a/src/lib/actions/sandbox/exec.ts +++ b/src/lib/actions/sandbox/exec.ts @@ -484,10 +484,12 @@ export async function execSandbox( let exitCode = completion.code; const googleChatApprovalCommitted = completion.commandCode === 0 && isGoogleChatPairingApproval(command); - if (googleChatApprovalCommitted && completion.cleanupError) { + const managedGoogleChatApproval = + googleChatApprovalCommitted && gatewaySelection.outcome === "selected"; + if (managedGoogleChatApproval && completion.cleanupError) { console.error(googleChatPairingActivationFailureMessage(CLI_NAME, sandboxName)); } - if (exitCode === 0 && googleChatApprovalCommitted) { + if (exitCode === 0 && managedGoogleChatApproval) { let recordedAgent: string | null; try { recordedAgent = (deps.resolveSandboxAgent ?? defaultResolveSandboxAgent)(sandboxName); From 43e68ef3a85ccb8705a1e740497ba60c7ad3cfef Mon Sep 17 00:00:00 2001 From: Ho Lim Date: Sun, 16 Aug 2026 03:33:11 -0700 Subject: [PATCH 4/5] fix(messaging): report unmanaged pairing partial commit Signed-off-by: Ho Lim --- .../exec-googlechat-pairing-restart.test.ts | 1 + src/lib/actions/sandbox/exec.ts | 15 +++++++++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/lib/actions/sandbox/exec-googlechat-pairing-restart.test.ts b/src/lib/actions/sandbox/exec-googlechat-pairing-restart.test.ts index d70ce077f65..71bf65bd409 100644 --- a/src/lib/actions/sandbox/exec-googlechat-pairing-restart.test.ts +++ b/src/lib/actions/sandbox/exec-googlechat-pairing-restart.test.ts @@ -275,6 +275,7 @@ describe("Google Chat pairing approval gateway activation (#8553)", () => { expect(restartGateway).not.toHaveBeenCalled(); expect(exitCode).toBe(1); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("approval was not rolled back")); expect(errorSpy).not.toHaveBeenCalledWith( expect.stringContaining("managed gateway activation failed"), ); diff --git a/src/lib/actions/sandbox/exec.ts b/src/lib/actions/sandbox/exec.ts index de0f9a5f197..5eddbcfd2ec 100644 --- a/src/lib/actions/sandbox/exec.ts +++ b/src/lib/actions/sandbox/exec.ts @@ -412,6 +412,13 @@ function googleChatPairingActivationFailureMessage( ); } +function googleChatPairingUnmanagedCleanupFailureMessage(sandboxName: string): string { + return ( + ` Google Chat pairing approval committed for '${sandboxName}', but post-command cleanup failed. ` + + "The approval was not rolled back. No owning managed gateway is registered, so NemoClaw did not attempt gateway activation." + ); +} + export async function execSandbox( sandboxName: string, command: readonly string[], @@ -486,8 +493,12 @@ export async function execSandbox( completion.commandCode === 0 && isGoogleChatPairingApproval(command); const managedGoogleChatApproval = googleChatApprovalCommitted && gatewaySelection.outcome === "selected"; - if (managedGoogleChatApproval && completion.cleanupError) { - console.error(googleChatPairingActivationFailureMessage(CLI_NAME, sandboxName)); + if (googleChatApprovalCommitted && completion.cleanupError) { + console.error( + managedGoogleChatApproval + ? googleChatPairingActivationFailureMessage(CLI_NAME, sandboxName) + : googleChatPairingUnmanagedCleanupFailureMessage(sandboxName), + ); } if (exitCode === 0 && managedGoogleChatApproval) { let recordedAgent: string | null; From 62a2e0cb87a63b71546d8c1e893ff73a9cb69340 Mon Sep 17 00:00:00 2001 From: Ho Lim Date: Sun, 16 Aug 2026 03:42:59 -0700 Subject: [PATCH 5/5] test(messaging): prove Google Chat approval activation Signed-off-by: Ho Lim --- docs/manage-sandboxes/set-up-google-chat.mdx | 10 +- docs/reference/commands.mdx | 13 +- .../exec-googlechat-pairing-restart.test.ts | 138 ++++++++++++++++++ 3 files changed, 151 insertions(+), 10 deletions(-) diff --git a/docs/manage-sandboxes/set-up-google-chat.mdx b/docs/manage-sandboxes/set-up-google-chat.mdx index 8d7b9602e9b..46634d35180 100644 --- a/docs/manage-sandboxes/set-up-google-chat.mdx +++ b/docs/manage-sandboxes/set-up-google-chat.mdx @@ -172,16 +172,18 @@ 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 an unknown sender receives a pairing code, approve it through the registered OpenClaw sandbox: +If an unknown sender receives a pairing code, approve it through the registered OpenClaw sandbox that has a selected owning managed gateway: ```bash nemoclaw my-assistant exec -- openclaw pairing approve googlechat ``` -After OpenClaw commits the sender to its owner allowlist, NemoClaw verifies the mutable config permissions and automatically restarts the managed gateway. +After OpenClaw commits the sender to its owner allowlist, NemoClaw verifies the mutable config permissions and automatically restarts that selected gateway. An exit status of `0` means activation completed, so the sender's next message should receive a reply without another manual restart. -If permission cleanup or gateway restart fails after the approval commits, `exec` exits with status `1`, reports that the approval was not rolled back, and prints the recovery command. -Correct any reported permission problem, then run `nemoclaw my-assistant gateway restart` before testing the next message; do not submit the pairing code again unless OpenClaw reports that it was not accepted. +If permission cleanup or gateway restart fails after the approval commits, `exec` exits with status `1` and reports that the approval was not rolled back. +For a selected owning gateway, it prints the managed recovery command; correct any reported permission problem, then run `nemoclaw my-assistant gateway restart` before testing the next message. +If no owning managed gateway is registered, NemoClaw does not attempt activation or print that command; register and select the sandbox's owning gateway before recovery. +Do not submit the pairing code again unless OpenClaw reports that it was not accepted. 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. diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index c9d502bef01..0525a6ab461 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -1321,10 +1321,10 @@ When cleanup succeeds, `exec` returns the remote command's exit code. If cleanup cannot inspect, restore, or verify that contract, it fails closed and prints `OpenClaw permission cleanup failed (...)` to `stderr`. In that case, `exec` returns the cleanup failure instead of the remote command's status. -For a registered OpenClaw sandbox, a successful direct `openclaw pairing approve googlechat ` command also restarts the managed gateway after cleanup so the new sender allowlist applies to the next message. +For a registered OpenClaw sandbox with a selected owning managed gateway, a successful direct `openclaw pairing approve googlechat ` command also restarts that gateway after cleanup so the new sender allowlist applies to the next message. If cleanup or restart fails after the approval commits, `exec` exits with status `1` and reports that the approval was not rolled back. -Correct any reported cleanup problem, then run `$$nemoclaw gateway restart` before testing the next message. -NemoClaw does not apply this automatic restart to unregistered or non-OpenClaw sandboxes. +When an owning gateway was selected, correct any reported cleanup problem, then run `$$nemoclaw gateway restart` before testing the next message. +Without an owning managed gateway, NemoClaw does not attempt activation or print a managed restart command; unregistered and non-OpenClaw sandboxes also do not receive the automatic restart. @@ -2153,9 +2153,10 @@ After the remote command exits, NemoClaw verifies and, when needed, restores the When cleanup succeeds, `exec` preserves the remote command's exit code. When cleanup fails closed, `exec` returns the cleanup failure and reports both statuses on `stderr`. -A successful direct `openclaw pairing approve googlechat ` command in a registered OpenClaw sandbox restarts the managed gateway after cleanup, so the new sender allowlist applies to the next message. -If the approval commits but cleanup or restart fails, `exec` exits with status `1`, reports that the approval was not rolled back, and directs you to run `$$nemoclaw gateway restart` after correcting any cleanup problem. -Unregistered and non-OpenClaw sandboxes do not receive this automatic restart. +A successful direct `openclaw pairing approve googlechat ` command in a registered OpenClaw sandbox with a selected owning managed gateway restarts that gateway after cleanup, so the new sender allowlist applies to the next message. +If the approval commits but cleanup or restart fails, `exec` exits with status `1` and reports that the approval was not rolled back. +When an owning gateway was selected, it directs you to run `$$nemoclaw gateway restart` after correcting any cleanup problem. +Without an owning managed gateway, NemoClaw does not attempt activation or print a managed restart command; unregistered and non-OpenClaw sandboxes do not receive the automatic restart. diff --git a/src/lib/actions/sandbox/exec-googlechat-pairing-restart.test.ts b/src/lib/actions/sandbox/exec-googlechat-pairing-restart.test.ts index 71bf65bd409..90336bb9839 100644 --- a/src/lib/actions/sandbox/exec-googlechat-pairing-restart.test.ts +++ b/src/lib/actions/sandbox/exec-googlechat-pairing-restart.test.ts @@ -1,6 +1,10 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { @@ -9,6 +13,7 @@ import { type ExecSandboxDeps, type SandboxExecCleanupDeps, } from "./exec"; +import { restartSandboxGatewayWithDeps } from "./gateway-restart"; const CLEANUP_SKIPPED: SandboxExecCleanupDeps = { getSandbox: () => null, @@ -56,6 +61,7 @@ async function runAndCaptureExit( } afterEach(() => { + vi.unstubAllEnvs(); vi.restoreAllMocks(); }); @@ -138,6 +144,138 @@ describe("Google Chat pairing approval gateway activation (#8553)", () => { expect(exitCode).toBe(0); }); + it("authorizes the approved sender after a sandbox-process approval and managed restart", async () => { + const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-googlechat-pairing-")); + const openshellPath = path.join(fixtureRoot, "openshell"); + const configPath = path.join(fixtureRoot, "openclaw.json"); + const runtimePath = path.join(fixtureRoot, "gateway-runtime.json"); + const supervisorLog = path.join(fixtureRoot, "supervisor.log"); + const sender = "googlechat:users/123456789"; + + fs.writeFileSync(configPath, JSON.stringify({ commands: { ownerAllowFrom: [] } })); + fs.writeFileSync(runtimePath, JSON.stringify({ ownerAllowFrom: [] })); + fs.writeFileSync( + openshellPath, + [ + `#!${process.execPath}`, + 'const fs = require("node:fs");', + "const args = process.argv.slice(2);", + 'const commandIndex = args.indexOf("openclaw");', + "const command = commandIndex === -1 ? [] : args.slice(commandIndex);", + 'if (args[0] !== "sandbox" || args[1] !== "exec" || command.join(" ") !== "openclaw pairing approve googlechat ABCD1234") process.exit(64);', + 'const config = JSON.parse(fs.readFileSync(process.env.NEMOCLAW_TEST_GOOGLECHAT_CONFIG, "utf8"));', + "config.commands.ownerAllowFrom = [process.env.NEMOCLAW_TEST_GOOGLECHAT_SENDER];", + "fs.writeFileSync(process.env.NEMOCLAW_TEST_GOOGLECHAT_CONFIG, JSON.stringify(config));", + 'process.stdout.write("Approved googlechat sender users/123456789\\n");', + "process.exit(0);", + "", + ].join("\n"), + { mode: 0o700 }, + ); + + vi.stubEnv("NEMOCLAW_TEST_GOOGLECHAT_CONFIG", configPath); + vi.stubEnv("NEMOCLAW_TEST_GOOGLECHAT_RUNTIME", runtimePath); + vi.stubEnv("NEMOCLAW_TEST_GOOGLECHAT_SENDER", sender); + + try { + const exitCode = await runAndCaptureExit( + ["openclaw", "pairing", "approve", "googlechat", "ABCD1234"], + { + resolveBinary: () => openshellPath, + selectGateway: () => ({ outcome: "selected", gatewayName: "nemoclaw-alpha" }), + cleanupDeps: { + getSandbox: () => ({ agent: "openclaw" }), + inspectMutableConfigPerms: () => ({ + applies: true, + ok: true, + dirMode: "2770", + dirOwner: "sandbox:sandbox", + fileMode: "660", + fileOwner: "sandbox:sandbox", + configDir: "/sandbox/.openclaw", + configFile: "openclaw.json", + issues: [], + }), + repairMutableConfigPerms: () => { + throw new Error("healthy config should not need repair"); + }, + }, + resolveSandboxAgent: () => "openclaw", + restartGateway: (sandboxName) => + restartSandboxGatewayWithDeps(sandboxName, { + quiet: true, + deps: { + getSessionAgent: () => null, + getSandbox: () => ({ name: sandboxName, agent: "openclaw" }), + resolveSandboxDashboardPort: () => 18789, + requestGatewaySupervisorAction: (_name, action) => { + const result = spawnSync( + process.execPath, + [ + "-e", + [ + 'const fs = require("node:fs");', + 'const config = JSON.parse(fs.readFileSync(process.env.NEMOCLAW_TEST_GOOGLECHAT_CONFIG, "utf8"));', + "fs.writeFileSync(process.env.NEMOCLAW_TEST_GOOGLECHAT_RUNTIME, JSON.stringify({ ownerAllowFrom: config.commands.ownerAllowFrom }));", + 'fs.appendFileSync(process.env.NEMOCLAW_TEST_GOOGLECHAT_SUPERVISOR_LOG, process.argv[1] + "\\n");', + 'process.stdout.write("GATEWAY_PID=4242\\n");', + ].join("\n"), + action, + ], + { + encoding: "utf8", + env: { + ...process.env, + NEMOCLAW_TEST_GOOGLECHAT_SUPERVISOR_LOG: supervisorLog, + }, + }, + ); + return { + status: result.status ?? 1, + stdout: result.stdout, + stderr: result.stderr, + }; + }, + executeSandboxExecCommand: () => null, + waitForRecoveredSandboxGateway: () => true, + ensureSandboxPortForward: () => true, + ensureHermesDashboardPortForwardIfEnabled: () => null, + recoverMessagingHostForward: () => null, + recoverDeclaredAgentForwardPorts: () => null, + printGatewayWedgeDiagnostics: () => false, + inspectHermesMcpReconciliationRefusal: () => null, + }, + }), + policyHint: { + now: () => 1_000, + probeLogs: () => "", + enableAudit: () => {}, + sleep: async () => {}, + attempts: 1, + writeStderr: () => {}, + }, + }, + ); + + const nextDm = spawnSync( + process.execPath, + [ + "-e", + 'const fs = require("node:fs"); const runtime = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); process.exit(runtime.ownerAllowFrom.includes(process.argv[2]) ? 0 : 1);', + runtimePath, + sender, + ], + { encoding: "utf8" }, + ); + + expect(exitCode).toBe(0); + expect(fs.readFileSync(supervisorLog, "utf8")).toBe("restart\n"); + expect(nextDm.status, nextDm.stderr).toBe(0); + } finally { + fs.rmSync(fixtureRoot, { recursive: true, force: true }); + } + }); + it("does not restart when post-command config cleanup fails", async () => { const restartGateway = vi.fn(() => ({ ok: true })); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});