Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions docs/manage-sandboxes/set-up-google-chat.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <code>
```

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.
Expand Down
9 changes: 9 additions & 0 deletions docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <code>` 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 <name> gateway restart` before testing the next message.
NemoClaw does not apply this automatic restart to unregistered or non-OpenClaw sandboxes.

</AgentOnly>
<AgentOnly variant="hermes">

Expand Down Expand Up @@ -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 <code>` 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 <name> gateway restart` after correcting any cleanup problem.
Unregistered and non-OpenClaw sandboxes do not receive this automatic restart.

</AgentOnly>
<AgentOnly variant="hermes">

Expand Down
279 changes: 279 additions & 0 deletions src/lib/actions/sandbox/exec-googlechat-pairing-restart.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,279 @@
// 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,
resolveSandboxAgent: () => "openclaw",
policyHint: {
now: () => 1_000,
probeLogs: () => "",
enableAudit: () => {},
sleep: async () => {},
attempts: 1,
writeStderr: () => {},
},
};
}

async function runAndCaptureExit(
command: readonly string[],
deps: ExecSandboxDeps,
): Promise<number> {
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 errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
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);
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),
);

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 () => {
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);
});

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'"),
);
});
});
Loading
Loading