Skip to content

Commit 448b7c7

Browse files
authored
Stabilize Google Meet chrome-node launch config (#96908)
1 parent 6830aa3 commit 448b7c7

7 files changed

Lines changed: 389 additions & 5 deletions

File tree

extensions/google-meet/index.test.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -855,7 +855,7 @@ describe("google-meet plugin", () => {
855855
});
856856

857857
it("registers the node-host command used by chrome-node transport", () => {
858-
const { nodeHostCommands } = setup();
858+
const { nodeHostCommands, nodeInvokePolicies } = setup();
859859

860860
const command = nodeHostCommands.find(
861861
(entry): entry is Record<string, unknown> =>
@@ -865,7 +865,13 @@ describe("google-meet plugin", () => {
865865
throw new Error("expected googlemeet.chrome node host command");
866866
}
867867
expect(command.cap).toBe("google-meet");
868+
expect(command.dangerous).toBe(true);
868869
expect(typeof command.handle).toBe("function");
870+
expect(nodeInvokePolicies).toHaveLength(1);
871+
expect(nodeInvokePolicies[0]).toMatchObject({
872+
commands: ["googlemeet.chrome"],
873+
dangerous: true,
874+
});
869875
});
870876

871877
it("keeps the agent tool visible on non-macOS hosts but blocks local Chrome talk-back joins", async () => {
@@ -2239,6 +2245,9 @@ describe("google-meet plugin", () => {
22392245
try {
22402246
const { methods, runCommandWithTimeout } = setup({
22412247
defaultMode: "transcribe",
2248+
chrome: {
2249+
browserProfile: "meet-devtools",
2250+
},
22422251
});
22432252
const callGatewayFromCli = mockLocalMeetBrowserRequest({
22442253
inCall: true,
@@ -3428,7 +3437,12 @@ describe("google-meet plugin", () => {
34283437
},
34293438
);
34303439
chromeTransportTesting.setDepsForTest({ callGatewayFromCli });
3431-
const { tools, nodesInvoke } = setup({ defaultTransport: "chrome" });
3440+
const { tools, nodesInvoke } = setup({
3441+
defaultTransport: "chrome",
3442+
chrome: {
3443+
browserProfile: "meet-devtools",
3444+
},
3445+
});
34323446
const tool = tools[0] as {
34333447
execute: (
34343448
id: string,
@@ -3458,6 +3472,7 @@ describe("google-meet plugin", () => {
34583472
expect(focusCall[0]).toBe("browser.request");
34593473
expect(requireRecord(focusCall[2], "focus request").method).toBe("POST");
34603474
expect(requireRecord(focusCall[2], "focus request").path).toBe("/tabs/focus");
3475+
expect(requireRecord(focusCall[2], "focus request").query).toBeUndefined();
34613476
expect(focusCall[3]).toEqual({ progress: false });
34623477
expect(nodesInvoke).not.toHaveBeenCalled();
34633478
});

extensions/google-meet/index.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ import {
3535
fetchGoogleMeetSpace,
3636
} from "./src/meet.js";
3737
import { handleGoogleMeetNodeHostCommand } from "./src/node-host.js";
38+
import {
39+
createGoogleMeetChromeNodeInvokePolicy,
40+
GOOGLE_MEET_CHROME_NODE_COMMAND,
41+
} from "./src/node-invoke-policy.js";
3842
import { GoogleMeetRuntime } from "./src/runtime.js";
3943
import { isGoogleMeetBrowserManualActionError } from "./src/transports/chrome-create.js";
4044

@@ -1196,10 +1200,12 @@ export default definePluginEntry({
11961200
);
11971201

11981202
api.registerNodeHostCommand({
1199-
command: "googlemeet.chrome",
1203+
command: GOOGLE_MEET_CHROME_NODE_COMMAND,
12001204
cap: "google-meet",
1205+
dangerous: true,
12011206
handle: handleGoogleMeetNodeHostCommand,
12021207
});
1208+
api.registerNodeInvokePolicy(createGoogleMeetChromeNodeInvokePolicy(config));
12031209

12041210
api.registerCli(
12051211
async ({ program }) => {

extensions/google-meet/node-host.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,41 @@ describe("google-meet node host bridge sessions", () => {
9191
}
9292
});
9393

94+
it("passes the Meet URL before Chrome profile args when launching a profiled browser", async () => {
95+
const originalPlatform = process.platform;
96+
children.length = 0;
97+
vi.mocked(spawnSync).mockClear();
98+
99+
Object.defineProperty(process, "platform", { configurable: true, value: "darwin" });
100+
try {
101+
const start = JSON.parse(
102+
await handleGoogleMeetNodeHostCommand(
103+
JSON.stringify({
104+
action: "start",
105+
url: "https://meet.google.com/xyz-abcd-uvw",
106+
mode: "transcribe",
107+
browserProfile: "Profile 2",
108+
}),
109+
),
110+
);
111+
112+
expect(start.launched).toBe(true);
113+
expect(spawnSync).toHaveBeenCalledWith(
114+
"open",
115+
[
116+
"-a",
117+
"Google Chrome",
118+
"https://meet.google.com/xyz-abcd-uvw",
119+
"--args",
120+
"--profile-directory=Profile 2",
121+
],
122+
expect.objectContaining({ encoding: "utf8" }),
123+
);
124+
} finally {
125+
Object.defineProperty(process, "platform", { configurable: true, value: originalPlatform });
126+
}
127+
});
128+
94129
it("clears output playback without closing the active bridge when the old output exits", async () => {
95130
const originalPlatform = process.platform;
96131
children.length = 0;

extensions/google-meet/src/node-host.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -332,12 +332,11 @@ function startChrome(params: Record<string, unknown>) {
332332
}
333333

334334
if (params.launch !== false) {
335-
const argv = ["open", "-a", "Google Chrome"];
335+
const argv = ["open", "-a", "Google Chrome", url];
336336
const browserProfile = readString(params.browserProfile);
337337
if (browserProfile) {
338338
argv.push("--args", `--profile-directory=${browserProfile}`);
339339
}
340-
argv.push(url);
341340
const result = runCommandWithTimeout(argv, timeoutMs);
342341
if (result.code !== 0) {
343342
if (bridgeId) {
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
// Google Meet node.invoke policy tests cover caller-controlled command sanitization.
2+
import type { OpenClawPluginNodeInvokePolicyContext } from "openclaw/plugin-sdk/plugin-entry";
3+
import { describe, expect, it, vi } from "vitest";
4+
import { resolveGoogleMeetConfig } from "./config.js";
5+
import {
6+
createGoogleMeetChromeNodeInvokePolicy,
7+
GOOGLE_MEET_CHROME_NODE_COMMAND,
8+
} from "./node-invoke-policy.js";
9+
10+
function createContext(params: unknown, pluginConfig: Record<string, unknown> = {}) {
11+
const invokeNode = vi.fn<OpenClawPluginNodeInvokePolicyContext["invokeNode"]>(async () => ({
12+
ok: true,
13+
payload: { ok: true },
14+
}));
15+
const ctx: OpenClawPluginNodeInvokePolicyContext = {
16+
nodeId: "node-1",
17+
command: GOOGLE_MEET_CHROME_NODE_COMMAND,
18+
params,
19+
config: {} as never,
20+
pluginConfig,
21+
invokeNode,
22+
};
23+
return { ctx, invokeNode };
24+
}
25+
26+
describe("Google Meet node invoke policy", () => {
27+
it("rewrites start executable fields from trusted config", async () => {
28+
const policy = createGoogleMeetChromeNodeInvokePolicy(
29+
resolveGoogleMeetConfig({
30+
chrome: {
31+
launch: false,
32+
browserProfile: "Trusted Profile",
33+
joinTimeoutMs: 45_000,
34+
audioInputCommand: ["trusted-capture", "--raw"],
35+
audioOutputCommand: ["trusted-play", "--raw"],
36+
},
37+
}),
38+
);
39+
const { ctx, invokeNode } = createContext({
40+
action: "start",
41+
url: "https://meet.google.com/abc-defg-hij",
42+
mode: "bidi",
43+
launch: true,
44+
browserProfile: "Attacker Profile",
45+
joinTimeoutMs: 1,
46+
audioBridgeCommand: ["node", "-e", "process.exit(99)"],
47+
audioBridgeHealthCommand: ["node", "-e", "process.exit(98)"],
48+
audioInputCommand: ["malicious-capture"],
49+
audioOutputCommand: ["malicious-play"],
50+
});
51+
52+
await expect(policy.handle(ctx)).resolves.toEqual({ ok: true, payload: { ok: true } });
53+
54+
expect(invokeNode).toHaveBeenCalledTimes(1);
55+
expect(invokeNode).toHaveBeenCalledWith({
56+
params: {
57+
action: "start",
58+
url: "https://meet.google.com/abc-defg-hij",
59+
mode: "bidi",
60+
launch: false,
61+
browserProfile: "Trusted Profile",
62+
joinTimeoutMs: 45_000,
63+
audioInputCommand: ["trusted-capture", "--raw"],
64+
audioOutputCommand: ["trusted-play", "--raw"],
65+
},
66+
});
67+
});
68+
69+
it("uses trusted configured external bridge commands for start", async () => {
70+
const policy = createGoogleMeetChromeNodeInvokePolicy(
71+
resolveGoogleMeetConfig({
72+
chrome: {
73+
audioBridgeHealthCommand: ["trusted-bridge", "status"],
74+
audioBridgeCommand: ["trusted-bridge", "start"],
75+
},
76+
}),
77+
);
78+
const { ctx, invokeNode } = createContext({
79+
action: "start",
80+
url: "https://meet.google.com/abc-defg-hij",
81+
mode: "bidi",
82+
audioBridgeHealthCommand: ["node", "-e", "process.exit(98)"],
83+
audioBridgeCommand: ["node", "-e", "process.exit(99)"],
84+
});
85+
86+
await policy.handle(ctx);
87+
88+
const call = invokeNode.mock.calls[0]?.[0];
89+
expect(call?.params).toMatchObject({
90+
action: "start",
91+
audioBridgeHealthCommand: ["trusted-bridge", "status"],
92+
audioBridgeCommand: ["trusted-bridge", "start"],
93+
});
94+
});
95+
96+
it("rejects direct start for non-Meet URLs before node dispatch", async () => {
97+
const policy = createGoogleMeetChromeNodeInvokePolicy(resolveGoogleMeetConfig({}));
98+
const { ctx, invokeNode } = createContext({
99+
action: "start",
100+
url: "https://example.com/private",
101+
mode: "bidi",
102+
});
103+
104+
await expect(policy.handle(ctx)).resolves.toMatchObject({
105+
ok: false,
106+
code: "GOOGLE_MEET_NODE_POLICY_DENIED",
107+
message: "url must be an explicit https://meet.google.com/... URL",
108+
});
109+
expect(invokeNode).not.toHaveBeenCalled();
110+
});
111+
112+
it("keeps direct setup diagnostics but strips extra fields", async () => {
113+
const policy = createGoogleMeetChromeNodeInvokePolicy(resolveGoogleMeetConfig({}));
114+
const { ctx, invokeNode } = createContext({
115+
action: "setup",
116+
audioBridgeCommand: ["node", "-e", "process.exit(99)"],
117+
});
118+
119+
await policy.handle(ctx);
120+
121+
expect(invokeNode).toHaveBeenCalledWith({ params: { action: "setup" } });
122+
});
123+
124+
it("rejects unsupported googlemeet.chrome actions before node dispatch", async () => {
125+
const policy = createGoogleMeetChromeNodeInvokePolicy(resolveGoogleMeetConfig({}));
126+
const { ctx, invokeNode } = createContext({ action: "exec", command: ["id"] });
127+
128+
await expect(policy.handle(ctx)).resolves.toMatchObject({
129+
ok: false,
130+
code: "GOOGLE_MEET_NODE_POLICY_DENIED",
131+
});
132+
expect(invokeNode).not.toHaveBeenCalled();
133+
});
134+
});

0 commit comments

Comments
 (0)