Skip to content

Commit f312d6c

Browse files
vincentkocsteipete
authored andcommitted
fix(qa): preserve gateway cli auth in no-fork rpc path
1 parent e7538b4 commit f312d6c

4 files changed

Lines changed: 116 additions & 159 deletions

File tree

extensions/qa-lab/src/gateway-child.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,7 @@ export async function startQaGatewayChild(params: {
270270
rpcClient = await startQaGatewayRpcClient({
271271
wsUrl,
272272
token: gatewayToken,
273+
env,
273274
logs,
274275
});
275276
} catch (error) {
Lines changed: 46 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -1,126 +1,77 @@
11
import { beforeEach, describe, expect, it, vi } from "vitest";
22

3-
const gatewayClientMock = vi.hoisted(() => {
4-
const request = vi.fn(async (_method?: string, _params?: unknown, _opts?: unknown) => ({
5-
ok: true,
6-
}));
7-
const stopAndWait = vi.fn(async () => {});
8-
const stop = vi.fn();
9-
const constructorCalls: Array<Record<string, unknown>> = [];
10-
let startMode: "hello" | "connect-error" = "hello";
11-
12-
class MockGatewayClient {
13-
private readonly options: Record<string, unknown>;
14-
15-
constructor(options: Record<string, unknown>) {
16-
this.options = options;
17-
constructorCalls.push(options);
18-
}
19-
20-
start() {
21-
queueMicrotask(() => {
22-
if (startMode === "connect-error") {
23-
const onConnectError = this.options.onConnectError;
24-
if (typeof onConnectError === "function") {
25-
onConnectError(new Error("connect boom"));
26-
}
27-
return;
28-
}
29-
const onHelloOk = this.options.onHelloOk;
30-
if (typeof onHelloOk === "function") {
31-
onHelloOk({});
32-
}
33-
});
34-
}
35-
36-
async request(method: string, params?: unknown, opts?: unknown) {
37-
return await request(method, params, opts);
38-
}
39-
40-
async stopAndWait() {
41-
await stopAndWait();
42-
}
43-
44-
stop() {
45-
stop();
46-
}
47-
}
48-
3+
const gatewayRpcMock = vi.hoisted(() => {
4+
const callGatewayFromCli = vi.fn(async () => ({ ok: true }));
495
return {
50-
MockGatewayClient,
51-
request,
52-
stopAndWait,
53-
stop,
54-
constructorCalls,
6+
callGatewayFromCli,
557
reset() {
56-
request.mockReset().mockResolvedValue({ ok: true });
57-
stopAndWait.mockReset().mockResolvedValue(undefined);
58-
stop.mockReset();
59-
constructorCalls.splice(0, constructorCalls.length);
60-
startMode = "hello";
61-
},
62-
setStartMode(mode: "hello" | "connect-error") {
63-
startMode = mode;
8+
callGatewayFromCli.mockReset().mockResolvedValue({ ok: true });
649
},
6510
};
6611
});
6712

6813
vi.mock("./runtime-api.js", () => ({
69-
GatewayClient: gatewayClientMock.MockGatewayClient,
14+
callGatewayFromCli: gatewayRpcMock.callGatewayFromCli,
7015
}));
7116

7217
import { startQaGatewayRpcClient } from "./gateway-rpc-client.js";
7318

7419
describe("startQaGatewayRpcClient", () => {
7520
beforeEach(() => {
76-
gatewayClientMock.reset();
21+
gatewayRpcMock.reset();
7722
});
7823

79-
it("starts a gateway client without device identity and forwards requests", async () => {
24+
it("calls the in-process gateway cli helper with the qa runtime env", async () => {
25+
const originalHome = process.env.OPENCLAW_HOME;
26+
delete process.env.OPENCLAW_HOME;
27+
delete process.env.OPENCLAW_QA_TEST_ONLY;
28+
29+
gatewayRpcMock.callGatewayFromCli.mockImplementationOnce(async () => {
30+
expect(process.env.OPENCLAW_HOME).toBe("/tmp/openclaw-home");
31+
expect(process.env.OPENCLAW_QA_TEST_ONLY).toBe("1");
32+
return { ok: true };
33+
});
34+
8035
const client = await startQaGatewayRpcClient({
8136
wsUrl: "ws://127.0.0.1:18789",
8237
token: "qa-token",
38+
env: {
39+
OPENCLAW_HOME: "/tmp/openclaw-home",
40+
OPENCLAW_QA_TEST_ONLY: "1",
41+
} as NodeJS.ProcessEnv,
8342
logs: () => "qa logs",
8443
});
8544

86-
expect(gatewayClientMock.constructorCalls[0]).toEqual(
87-
expect.objectContaining({
88-
url: "ws://127.0.0.1:18789",
89-
token: "qa-token",
90-
deviceIdentity: null,
91-
scopes: [
92-
"operator.admin",
93-
"operator.read",
94-
"operator.write",
95-
"operator.approvals",
96-
"operator.pairing",
97-
"operator.talk.secrets",
98-
],
99-
}),
100-
);
101-
10245
await expect(
10346
client.request("agent.run", { prompt: "hi" }, { expectFinal: true, timeoutMs: 45_000 }),
10447
).resolves.toEqual({ ok: true });
10548

106-
expect(gatewayClientMock.request).toHaveBeenCalledWith(
49+
expect(gatewayRpcMock.callGatewayFromCli).toHaveBeenCalledWith(
10750
"agent.run",
51+
{
52+
url: "ws://127.0.0.1:18789",
53+
token: "qa-token",
54+
timeout: "45000",
55+
expectFinal: true,
56+
json: true,
57+
},
10858
{ prompt: "hi" },
10959
{
11060
expectFinal: true,
111-
timeoutMs: 45_000,
61+
progress: false,
11262
},
11363
);
11464

115-
await client.stop();
116-
expect(gatewayClientMock.stopAndWait).toHaveBeenCalledTimes(1);
65+
expect(process.env.OPENCLAW_HOME).toBe(originalHome);
66+
expect(process.env.OPENCLAW_QA_TEST_ONLY).toBeUndefined();
11767
});
11868

11969
it("wraps request failures with gateway logs", async () => {
120-
gatewayClientMock.request.mockRejectedValueOnce(new Error("gateway not connected"));
70+
gatewayRpcMock.callGatewayFromCli.mockRejectedValueOnce(new Error("gateway not connected"));
12171
const client = await startQaGatewayRpcClient({
12272
wsUrl: "ws://127.0.0.1:18789",
12373
token: "qa-token",
74+
env: { OPENCLAW_HOME: "/tmp/openclaw-home" } as NodeJS.ProcessEnv,
12475
logs: () => "qa logs",
12576
});
12677

@@ -129,15 +80,18 @@ describe("startQaGatewayRpcClient", () => {
12980
);
13081
});
13182

132-
it("wraps connect failures with gateway logs", async () => {
133-
gatewayClientMock.setStartMode("connect-error");
83+
it("rejects new requests after stop", async () => {
84+
const client = await startQaGatewayRpcClient({
85+
wsUrl: "ws://127.0.0.1:18789",
86+
token: "qa-token",
87+
env: { OPENCLAW_HOME: "/tmp/openclaw-home" } as NodeJS.ProcessEnv,
88+
logs: () => "qa logs",
89+
});
90+
91+
await client.stop();
13492

135-
await expect(
136-
startQaGatewayRpcClient({
137-
wsUrl: "ws://127.0.0.1:18789",
138-
token: "qa-token",
139-
logs: () => "qa logs",
140-
}),
141-
).rejects.toThrow("connect boom\nGateway logs:\nqa logs");
93+
await expect(client.request("health")).rejects.toThrow(
94+
"gateway rpc client already stopped\nGateway logs:\nqa logs",
95+
);
14296
});
14397
});
Lines changed: 68 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,11 @@
11
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
2-
import { GatewayClient } from "./runtime-api.js";
2+
import { callGatewayFromCli } from "./runtime-api.js";
33

44
type QaGatewayRpcRequestOptions = {
55
expectFinal?: boolean;
66
timeoutMs?: number;
77
};
88

9-
const QA_GATEWAY_RPC_SCOPES = [
10-
"operator.admin",
11-
"operator.read",
12-
"operator.write",
13-
"operator.approvals",
14-
"operator.pairing",
15-
"operator.talk.secrets",
16-
] as const;
17-
189
export type QaGatewayRpcClient = {
1910
request(method: string, rpcParams?: unknown, opts?: QaGatewayRpcRequestOptions): Promise<unknown>;
2011
stop(): Promise<void>;
@@ -25,77 +16,88 @@ function formatQaGatewayRpcError(error: unknown, logs: () => string) {
2516
return new Error(`${details}\nGateway logs:\n${logs()}`);
2617
}
2718

28-
export async function startQaGatewayRpcClient(params: {
29-
wsUrl: string;
30-
token: string;
31-
logs: () => string;
32-
}): Promise<QaGatewayRpcClient> {
33-
let readySettled = false;
34-
let stopping = false;
35-
let resolveReady!: () => void;
36-
let rejectReady!: (err: unknown) => void;
19+
let qaGatewayRpcQueue = Promise.resolve();
3720

38-
const ready = new Promise<void>((resolve, reject) => {
39-
resolveReady = resolve;
40-
rejectReady = reject;
41-
});
21+
async function withScopedProcessEnv<T>(env: NodeJS.ProcessEnv, task: () => Promise<T>): Promise<T> {
22+
const original = new Map<string, string | undefined>();
23+
const keys = new Set([...Object.keys(process.env), ...Object.keys(env)]);
4224

43-
const settleReady = (error?: unknown) => {
44-
if (readySettled) {
45-
return;
46-
}
47-
readySettled = true;
48-
if (error) {
49-
rejectReady(error);
50-
return;
25+
for (const key of keys) {
26+
original.set(key, process.env[key]);
27+
const nextValue = env[key];
28+
if (nextValue === undefined) {
29+
delete process.env[key];
30+
continue;
5131
}
52-
resolveReady();
53-
};
54-
55-
const wrapError = (error: unknown) => formatQaGatewayRpcError(error, params.logs);
32+
process.env[key] = nextValue;
33+
}
5634

57-
const client = new GatewayClient({
58-
url: params.wsUrl,
59-
token: params.token,
60-
deviceIdentity: null,
61-
// Mirror the old gateway CLI caller scopes so the faster path stays behavior-identical.
62-
scopes: [...QA_GATEWAY_RPC_SCOPES],
63-
onHelloOk: () => {
64-
settleReady();
65-
},
66-
onConnectError: (error) => {
67-
settleReady(wrapError(error));
68-
},
69-
onClose: (code, reason) => {
70-
if (stopping) {
71-
return;
35+
try {
36+
return await task();
37+
} finally {
38+
for (const key of keys) {
39+
const previousValue = original.get(key);
40+
if (previousValue === undefined) {
41+
delete process.env[key];
42+
continue;
7243
}
73-
const reasonText = reason.trim() || "no close reason";
74-
settleReady(wrapError(new Error(`gateway closed (${code}): ${reasonText}`)));
75-
},
76-
});
44+
process.env[key] = previousValue;
45+
}
46+
}
47+
}
7748

78-
client.start();
79-
await ready;
49+
async function runQueuedQaGatewayRpc<T>(task: () => Promise<T>): Promise<T> {
50+
const run = qaGatewayRpcQueue.then(task, task);
51+
qaGatewayRpcQueue = run.then(
52+
() => undefined,
53+
() => undefined,
54+
);
55+
return await run;
56+
}
57+
58+
export async function startQaGatewayRpcClient(params: {
59+
wsUrl: string;
60+
token: string;
61+
env: NodeJS.ProcessEnv;
62+
logs: () => string;
63+
}): Promise<QaGatewayRpcClient> {
64+
const wrapError = (error: unknown) => formatQaGatewayRpcError(error, params.logs);
65+
let stopped = false;
8066

8167
return {
8268
async request(method, rpcParams, opts) {
69+
if (stopped) {
70+
throw wrapError(new Error("gateway rpc client already stopped"));
71+
}
8372
try {
84-
return await client.request(method, rpcParams, {
85-
expectFinal: opts?.expectFinal,
86-
timeoutMs: opts?.timeoutMs ?? 20_000,
87-
});
73+
return await runQueuedQaGatewayRpc(
74+
async () =>
75+
await withScopedProcessEnv(
76+
params.env,
77+
async () =>
78+
await callGatewayFromCli(
79+
method,
80+
{
81+
url: params.wsUrl,
82+
token: params.token,
83+
timeout: String(opts?.timeoutMs ?? 20_000),
84+
expectFinal: opts?.expectFinal,
85+
json: true,
86+
},
87+
rpcParams ?? {},
88+
{
89+
expectFinal: opts?.expectFinal,
90+
progress: false,
91+
},
92+
),
93+
),
94+
);
8895
} catch (error) {
8996
throw wrapError(error);
9097
}
9198
},
9299
async stop() {
93-
stopping = true;
94-
try {
95-
await client.stopAndWait();
96-
} catch {
97-
client.stop();
98-
}
100+
stopped = true;
99101
},
100102
};
101103
}

extensions/qa-lab/src/runtime-api.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
export type { Command } from "commander";
22
export type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk/core";
33
export { definePluginEntry } from "openclaw/plugin-sdk/core";
4-
export { GatewayClient } from "openclaw/plugin-sdk/gateway-runtime";
4+
export { callGatewayFromCli } from "openclaw/plugin-sdk/browser-node-runtime";
55
export {
66
buildQaTarget,
77
createQaBusThread,

0 commit comments

Comments
 (0)