Skip to content

Commit 405883a

Browse files
committed
fix(messaging): activate Google Chat pairing approval
Signed-off-by: Ho Lim <subhoya@gmail.com>
1 parent 9b3b95d commit 405883a

2 files changed

Lines changed: 220 additions & 1 deletion

File tree

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { afterEach, describe, expect, it, vi } from "vitest";
5+
6+
import {
7+
execSandbox,
8+
isGoogleChatPairingApproval,
9+
type ExecSandboxDeps,
10+
type SandboxExecCleanupDeps,
11+
} from "./exec";
12+
13+
const CLEANUP_SKIPPED: SandboxExecCleanupDeps = {
14+
getSandbox: () => null,
15+
inspectMutableConfigPerms: () => {
16+
throw new Error("cleanup should be skipped for an unregistered sandbox");
17+
},
18+
repairMutableConfigPerms: () => {
19+
throw new Error("cleanup should be skipped for an unregistered sandbox");
20+
},
21+
};
22+
23+
function depsFor(status: number, restartGateway = vi.fn(() => ({ ok: true }))): ExecSandboxDeps {
24+
return {
25+
resolveBinary: () => "openshell",
26+
selectGateway: () => ({ outcome: "unregistered", gatewayName: null }),
27+
run: () => ({ status }),
28+
cleanupDeps: CLEANUP_SKIPPED,
29+
restartGateway,
30+
policyHint: {
31+
now: () => 1_000,
32+
probeLogs: () => "",
33+
enableAudit: () => {},
34+
sleep: async () => {},
35+
attempts: 1,
36+
writeStderr: () => {},
37+
},
38+
};
39+
}
40+
41+
async function runAndCaptureExit(
42+
command: readonly string[],
43+
deps: ExecSandboxDeps,
44+
): Promise<number> {
45+
let exitCode = Number.NaN;
46+
vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
47+
exitCode = code ?? 0;
48+
throw new Error("__exec_exit__");
49+
}) as never);
50+
51+
await execSandbox("alpha", command, {}, deps).catch((error: unknown) => {
52+
expect(error).toEqual(new Error("__exec_exit__"));
53+
});
54+
return exitCode;
55+
}
56+
57+
afterEach(() => {
58+
vi.restoreAllMocks();
59+
});
60+
61+
describe("Google Chat pairing approval gateway activation (#8553)", () => {
62+
it("recognizes only a direct Google Chat pairing approval with a code", () => {
63+
expect(
64+
isGoogleChatPairingApproval(["openclaw", "pairing", "approve", "googlechat", "ABCD1234"]),
65+
).toBe(true);
66+
expect(
67+
isGoogleChatPairingApproval([
68+
"openclaw",
69+
"pairing",
70+
"approve",
71+
"googlechat",
72+
"ABCD1234",
73+
"--json",
74+
]),
75+
).toBe(true);
76+
expect(
77+
isGoogleChatPairingApproval(["openclaw", "pairing", "approve", "telegram", "ABCD1234"]),
78+
).toBe(false);
79+
expect(
80+
isGoogleChatPairingApproval(["sh", "-lc", "openclaw pairing approve googlechat ABCD1234"]),
81+
).toBe(false);
82+
expect(
83+
isGoogleChatPairingApproval(["openclaw", "pairing", "approve", "googlechat", "--help"]),
84+
).toBe(false);
85+
});
86+
87+
it("restarts the managed gateway after the exact approval succeeds", async () => {
88+
const restartGateway = vi.fn(() => ({ ok: true }));
89+
const exitCode = await runAndCaptureExit(
90+
["openclaw", "pairing", "approve", "googlechat", "ABCD1234"],
91+
depsFor(0, restartGateway),
92+
);
93+
94+
expect(restartGateway).toHaveBeenCalledOnce();
95+
expect(restartGateway).toHaveBeenCalledWith("alpha");
96+
expect(exitCode).toBe(0);
97+
});
98+
99+
it("restarts only after the mutable OpenClaw config contract is verified", async () => {
100+
const order: string[] = [];
101+
const restartGateway = vi.fn(() => {
102+
order.push("restart");
103+
return { ok: true };
104+
});
105+
const deps = depsFor(0, restartGateway);
106+
deps.run = () => {
107+
order.push("command");
108+
return { status: 0 };
109+
};
110+
deps.cleanupDeps = {
111+
getSandbox: () => ({ agent: "openclaw" }),
112+
inspectMutableConfigPerms: () => {
113+
order.push("cleanup");
114+
return {
115+
applies: true,
116+
ok: true,
117+
dirMode: "2770",
118+
dirOwner: "sandbox:sandbox",
119+
fileMode: "660",
120+
fileOwner: "sandbox:sandbox",
121+
configDir: "/sandbox/.openclaw",
122+
configFile: "openclaw.json",
123+
issues: [],
124+
};
125+
},
126+
repairMutableConfigPerms: () => {
127+
throw new Error("healthy config should not need repair");
128+
},
129+
};
130+
131+
const exitCode = await runAndCaptureExit(
132+
["openclaw", "pairing", "approve", "googlechat", "ABCD1234"],
133+
deps,
134+
);
135+
136+
expect(order).toEqual(["command", "cleanup", "restart"]);
137+
expect(exitCode).toBe(0);
138+
});
139+
140+
it("does not restart when post-command config cleanup fails", async () => {
141+
const restartGateway = vi.fn(() => ({ ok: true }));
142+
const deps = depsFor(0, restartGateway);
143+
deps.cleanupDeps = {
144+
getSandbox: () => {
145+
throw new Error("invalid registry JSON");
146+
},
147+
inspectMutableConfigPerms: CLEANUP_SKIPPED.inspectMutableConfigPerms,
148+
repairMutableConfigPerms: CLEANUP_SKIPPED.repairMutableConfigPerms,
149+
};
150+
151+
const exitCode = await runAndCaptureExit(
152+
["openclaw", "pairing", "approve", "googlechat", "ABCD1234"],
153+
deps,
154+
);
155+
156+
expect(restartGateway).not.toHaveBeenCalled();
157+
expect(exitCode).toBe(1);
158+
});
159+
160+
it("fails the public command when activation restart fails", async () => {
161+
const restartGateway = vi.fn(() => ({ ok: false }));
162+
const exitCode = await runAndCaptureExit(
163+
["openclaw", "pairing", "approve", "googlechat", "ABCD1234"],
164+
depsFor(0, restartGateway),
165+
);
166+
167+
expect(restartGateway).toHaveBeenCalledOnce();
168+
expect(exitCode).toBe(1);
169+
});
170+
171+
it("does not restart after a failed approval", async () => {
172+
const restartGateway = vi.fn(() => ({ ok: true }));
173+
const exitCode = await runAndCaptureExit(
174+
["openclaw", "pairing", "approve", "googlechat", "BADCODE"],
175+
depsFor(17, restartGateway),
176+
);
177+
178+
expect(restartGateway).not.toHaveBeenCalled();
179+
expect(exitCode).toBe(17);
180+
});
181+
182+
it("leaves unrelated successful exec commands unchanged", async () => {
183+
const restartGateway = vi.fn(() => ({ ok: true }));
184+
const exitCode = await runAndCaptureExit(
185+
["openclaw", "pairing", "approve", "telegram", "ABCD1234"],
186+
depsFor(0, restartGateway),
187+
);
188+
189+
expect(restartGateway).not.toHaveBeenCalled();
190+
expect(exitCode).toBe(0);
191+
});
192+
});

src/lib/actions/sandbox/exec.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ export type SandboxExecOptions = {
2727
stdin?: boolean;
2828
};
2929

30+
export type SandboxExecGatewayRestart = (sandboxName: string) => { ok: boolean };
31+
3032
type SpawnLikeResult = {
3133
status: number | null;
3234
signal?: NodeJS.Signals | null;
@@ -364,10 +366,30 @@ export type ExecSandboxDeps = {
364366
run?: SandboxExecRunner;
365367
policyHint?: ExecPolicyHintDeps;
366368
cleanupDeps?: SandboxExecCleanupDeps;
369+
/** Activate config written by a successful direct Google Chat pairing approval. */
370+
restartGateway?: SandboxExecGatewayRestart;
367371
/** Select the sandbox's owning gateway before the exec talks to OpenShell. */
368372
selectGateway?: (sandboxName: string) => GatewaySelectResult;
369373
};
370374

375+
export function isGoogleChatPairingApproval(command: readonly string[]): boolean {
376+
return (
377+
command.length >= 5 &&
378+
command[0] === "openclaw" &&
379+
command[1] === "pairing" &&
380+
command[2] === "approve" &&
381+
command[3] === "googlechat" &&
382+
Boolean(command[4]) &&
383+
!command[4]!.startsWith("-")
384+
);
385+
}
386+
387+
function defaultRestartGateway(sandboxName: string): { ok: boolean } {
388+
const { defaultInferenceGatewayRestart } =
389+
require("../inference-set-gateway-restart") as typeof import("../inference-set-gateway-restart");
390+
return defaultInferenceGatewayRestart(sandboxName);
391+
}
392+
371393
export async function execSandbox(
372394
sandboxName: string,
373395
command: readonly string[],
@@ -437,5 +459,10 @@ export async function execSandbox(
437459
console.error(cleanupFailureMessage(completion.commandCode, completion.cleanupError));
438460
}
439461
await emitPolicyDenialHint(completion);
440-
process.exit(completion.code);
462+
let exitCode = completion.code;
463+
if (exitCode === 0 && isGoogleChatPairingApproval(command)) {
464+
const restart = (deps.restartGateway ?? defaultRestartGateway)(sandboxName);
465+
if (!restart.ok) exitCode = 1;
466+
}
467+
process.exit(exitCode);
441468
}

0 commit comments

Comments
 (0)