test(qa): cover gateway exec approvals (#118872)

This commit is contained in:
Vincent Koc
2026-08-04 05:23:28 +08:00
committed by GitHub
parent 99c5ea79ab
commit 16635fa9c7
2 changed files with 223 additions and 0 deletions
@@ -0,0 +1,27 @@
title: Gateway exec approvals
scenario:
id: gateway-exec-approvals
surface: gateway
category: gateway.approvals-and-remote-execution
coverage:
primary:
- gateway.exec-approvals
objective: Verify Gateway exec approval policy snapshots and pending approval decisions through authenticated WebSocket clients.
successCriteria:
- The Gateway returns a redacted persisted approval policy snapshot and its authoritative hash.
- A hash-guarded replacement succeeds while a stale replacement is rejected without changing stored policy.
- A two-phase exec approval request is accepted and visible through list and lookup RPCs.
- A distinct authenticated reviewer resolves a concurrent waitDecision request.
- The resolved request leaves the pending approval inventory.
docsRefs:
- docs/gateway/protocol.md
- docs/help/testing.md
codeRefs:
- src/gateway/server-methods/exec-approvals.ts
- src/gateway/server-methods/exec-approval.ts
- test/e2e/qa-lab/runtime/gateway-exec-approvals.e2e.test.ts
execution:
kind: vitest
path: test/e2e/qa-lab/runtime/gateway-exec-approvals.e2e.test.ts
summary: Run a real Gateway with authenticated requester and reviewer WebSocket clients across snapshot mutation and approval decision APIs.
@@ -0,0 +1,196 @@
// Gateway exec approvals QA proves policy snapshots and approval decisions over real WebSockets.
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import type { ExecApprovalsSnapshot } from "../../../../packages/gateway-protocol/src/index.js";
import { ADMIN_SCOPE, APPROVALS_SCOPE } from "../../../../src/gateway/method-scopes.js";
import {
connectGatewayClient,
disconnectGatewayClient,
} from "../../../../src/gateway/test-helpers.e2e.js";
import {
getFreePort,
installGatewayTestHooks,
startGatewayServer,
} from "../../../../src/gateway/test-helpers.js";
import { loadOrCreateDeviceIdentity } from "../../../../src/infra/device-identity.js";
import { readExecApprovalsSnapshot } from "../../../../src/infra/exec-approvals.js";
type Cleanup = () => Promise<void> | void;
type ExecApprovalListEntry = {
id: string;
request: {
command?: string;
};
};
installGatewayTestHooks({ scope: "suite" });
describe("gateway exec approvals QA", () => {
const cleanup: Cleanup[] = [];
afterEach(async () => {
for (const step of cleanup.splice(0).toReversed()) {
await step();
}
});
it("protects policy snapshots and resolves a pending request from another reviewer", async () => {
const port = await getFreePort();
const token = "gateway-exec-approvals-qa-token";
const stateDir = process.env.OPENCLAW_STATE_DIR;
if (!stateDir) {
throw new Error("OPENCLAW_STATE_DIR is required for gateway QA fixtures");
}
const server = await startGatewayServer(port, {
bind: "loopback",
auth: { mode: "token", token },
controlUiEnabled: false,
sidecarStartup: "defer",
});
cleanup.push(() => server.close());
const requesterIdentity = loadOrCreateDeviceIdentity({
path: path.join(stateDir, "test-device-identities", "exec-requester.sqlite"),
});
const reviewerIdentity = loadOrCreateDeviceIdentity({
path: path.join(stateDir, "test-device-identities", "exec-reviewer.sqlite"),
});
expect(reviewerIdentity.deviceId).not.toBe(requesterIdentity.deviceId);
const url = `ws://127.0.0.1:${port}`;
const requester = await connectGatewayClient({
url,
token,
clientDisplayName: "exec approval requester",
deviceIdentity: requesterIdentity,
scopes: [APPROVALS_SCOPE],
timeoutMs: 60_000,
});
cleanup.push(() => disconnectGatewayClient(requester));
const reviewer = await connectGatewayClient({
url,
token,
clientDisplayName: "exec approval reviewer",
deviceIdentity: reviewerIdentity,
scopes: [ADMIN_SCOPE],
timeoutMs: 60_000,
});
cleanup.push(() => disconnectGatewayClient(reviewer));
const initial = await reviewer.request<ExecApprovalsSnapshot>("exec.approvals.get", {});
const storedInitial = readExecApprovalsSnapshot();
expect(storedInitial.file.socket?.token).toMatch(/^[A-Za-z0-9_-]{32}$/);
expect(initial).toMatchObject({
exists: true,
hash: storedInitial.hash,
file: {
version: 1,
socket: { path: storedInitial.file.socket?.path },
},
});
expect(initial.file.socket).not.toHaveProperty("token");
const replacement = {
version: 1 as const,
defaults: {
security: "allowlist",
ask: "always",
askFallback: "deny",
autoAllowSkills: false,
},
agents: {
main: {
allowlist: [{ pattern: "printf gateway-qa" }],
},
},
};
const updated = await reviewer.request<ExecApprovalsSnapshot>("exec.approvals.set", {
baseHash: initial.hash,
file: replacement,
});
expect(updated.hash).not.toBe(initial.hash);
expect(updated.file).toMatchObject({
...replacement,
socket: { path: storedInitial.file.socket?.path },
});
expect(updated.file.socket).not.toHaveProperty("token");
expect(readExecApprovalsSnapshot().file.socket?.token).toBe(storedInitial.file.socket?.token);
await expect(
reviewer.request("exec.approvals.set", {
baseHash: initial.hash,
file: {
...replacement,
defaults: { ...replacement.defaults, security: "deny" },
},
}),
).rejects.toThrow("exec approvals changed since last load");
await expect(
reviewer.request<ExecApprovalsSnapshot>("exec.approvals.get", {}),
).resolves.toEqual(updated);
const approvalId = "gateway-exec-approvals-qa";
await expect(
requester.request("exec.approval.request", {
id: approvalId,
command: "printf gateway-qa",
commandArgv: ["printf", "gateway-qa"],
cwd: "/tmp",
host: "gateway",
ask: "always",
twoPhase: true,
requireDeliveryRoute: false,
timeoutMs: 60_000,
}),
).resolves.toMatchObject({ id: approvalId, status: "accepted" });
const pending = await requester.request<ExecApprovalListEntry[]>("exec.approval.list", {});
expect(pending).toContainEqual(
expect.objectContaining({
id: approvalId,
request: expect.objectContaining({ command: "printf gateway-qa" }),
}),
);
await expect(requester.request("exec.approval.get", { id: approvalId })).resolves.toMatchObject(
{
id: approvalId,
commandText: "printf gateway-qa",
host: "gateway",
nodeId: null,
agentId: null,
},
);
let waitSettled = false;
const waitDecision = requester
.request<{
id: string;
decision: string;
}>("exec.approval.waitDecision", { id: approvalId }, { timeoutMs: 10_000 })
.finally(() => {
waitSettled = true;
});
await new Promise((resolve) => setTimeout(resolve, 25));
expect(waitSettled).toBe(false);
await expect(
reviewer.request("exec.approval.resolve", {
id: approvalId,
decision: "allow-once",
}),
).resolves.toEqual({ ok: true });
await expect(waitDecision).resolves.toMatchObject({
id: approvalId,
decision: "allow-once",
});
const remaining = await requester.request<ExecApprovalListEntry[]>("exec.approval.list", {});
expect(remaining.map((entry) => entry.id)).not.toContain(approvalId);
await expect(requester.request("exec.approval.get", { id: approvalId })).rejects.toThrow(
"unknown or expired approval id",
);
}, 120_000);
});