Fix approval runtime gateway calls (#83433)

* fix approval runtime gateway calls

* docs: credit approval runtime fix contributors

* docs: include maintainer changelog credit
This commit is contained in:
Jesse Merhi
2026-05-18 15:10:15 +10:00
committed by GitHub
parent e3d5518838
commit 198f20fd20
5 changed files with 76 additions and 0 deletions
+1
View File
@@ -30,6 +30,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Agents/exec approvals: forward approval-runtime credentials on agent-owned Gateway approval calls so approved async commands complete through the existing runtime path instead of stalling on unauthenticated follow-up calls. Thanks @IWhatsskill, @Patrick-Erichsen, and @jesse-merhi.
- Gateway/skills: preflight remote macOS skill-bin refreshes with a WebSocket connectivity check so stale node sessions skip quickly instead of logging slow `system.which` timeout warnings.
- GitHub Copilot: drop unsafe native Responses reasoning replay items with non-replayable IDs before dispatch, preventing affected Copilot sessions from failing with `invalid_request_body`. Fixes #83220. Thanks @galiniliev.
- Agents/Codex: fail closed when an explicitly requested Codex harness is not registered instead of silently trying configured model fallbacks. Fixes #83349. Thanks @r2-vibes.
+36
View File
@@ -246,6 +246,42 @@ describe("gateway tool defaults", () => {
expect(call.scopes).toEqual(["operator.admin"]);
});
it("marks local approval request calls as approval runtime calls", async () => {
mocks.callGateway.mockResolvedValueOnce({ id: "approval-id" });
await callGatewayTool("exec.approval.request", {}, { command: "printf hi" });
const call = capturedGatewayCall();
expect(call.method).toBe("exec.approval.request");
expect(call.scopes).toEqual(["operator.approvals"]);
expect(call.approvalRuntimeToken).toEqual(expect.any(String));
});
it("marks local approval wait calls as approval runtime calls", async () => {
mocks.callGateway.mockResolvedValueOnce({ decision: "allow-once" });
await callGatewayTool("exec.approval.waitDecision", {}, { id: "approval-id" });
const call = capturedGatewayCall();
expect(call.method).toBe("exec.approval.waitDecision");
expect(call.scopes).toEqual(["operator.approvals"]);
expect(call.approvalRuntimeToken).toEqual(expect.any(String));
});
it("does not send the local approval runtime token to gatewayUrl overrides", async () => {
mocks.callGateway.mockResolvedValueOnce({ decision: "allow-once" });
await callGatewayTool(
"exec.approval.waitDecision",
{ gatewayUrl: "ws://127.0.0.1:18789", gatewayToken: "t" },
{ id: "approval-id" },
);
const call = capturedGatewayCall();
expect(call.url).toBe("ws://127.0.0.1:18789");
expect(call).not.toHaveProperty("approvalRuntimeToken");
});
it("default-denies unknown methods by sending no scopes", async () => {
mocks.callGateway.mockResolvedValueOnce({ ok: true });
await callGatewayTool("nonexistent.method", {}, {});
+23
View File
@@ -6,6 +6,7 @@ import {
resolveLeastPrivilegeOperatorScopesForMethod,
type OperatorScope,
} from "../../gateway/method-scopes.js";
import { getOperatorApprovalRuntimeToken } from "../../gateway/operator-approval-runtime-token.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../gateway/protocol/client-info.js";
import { formatErrorMessage } from "../../infra/errors.js";
import {
@@ -145,6 +146,26 @@ export function resolveGatewayOptions(opts?: GatewayCallOptions) {
return { url: validatedOverride?.url, token, timeoutMs };
}
const APPROVAL_RUNTIME_METHODS = new Set<string>([
"exec.approval.request",
"exec.approval.waitDecision",
"plugin.approval.request",
"plugin.approval.waitDecision",
]);
function resolveApprovalRuntimeTokenForGatewayTool(params: {
method: string;
opts: GatewayCallOptions;
}): string | undefined {
if (!APPROVAL_RUNTIME_METHODS.has(params.method)) {
return undefined;
}
if (trimToUndefined(params.opts.gatewayUrl) !== undefined) {
return undefined;
}
return getOperatorApprovalRuntimeToken();
}
export async function callGatewayTool<T = Record<string, unknown>>(
method: string,
opts: GatewayCallOptions,
@@ -155,6 +176,7 @@ export async function callGatewayTool<T = Record<string, unknown>>(
const scopes = Array.isArray(extra?.scopes)
? extra.scopes
: resolveLeastPrivilegeOperatorScopesForMethod(method, params);
const approvalRuntimeToken = resolveApprovalRuntimeTokenForGatewayTool({ method, opts });
return await callGateway<T>({
url: gateway.url,
token: gateway.token,
@@ -165,6 +187,7 @@ export async function callGatewayTool<T = Record<string, unknown>>(
clientName: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
clientDisplayName: "agent",
mode: GATEWAY_CLIENT_MODES.BACKEND,
...(approvalRuntimeToken ? { approvalRuntimeToken } : {}),
scopes,
});
}
+14
View File
@@ -51,6 +51,7 @@ let lastClientOptions: {
clientName?: string;
clientDisplayName?: string;
mode?: string;
approvalRuntimeToken?: string;
scopes?: string[];
deviceIdentity?: unknown;
onHelloOk?: (hello: { features?: { methods?: string[] } }) => void | Promise<void>;
@@ -87,6 +88,7 @@ vi.mock("./client.js", () => ({
clientName?: string;
clientDisplayName?: string;
mode?: string;
approvalRuntimeToken?: string;
scopes?: string[];
onHelloOk?: (hello: { features?: { methods?: string[] } }) => void | Promise<void>;
onClose?: (code: number, reason: string) => void;
@@ -628,6 +630,18 @@ describe("callGateway url resolution", () => {
expect(lastClientOptions?.clientDisplayName).toBe("gateway:sessions.delete");
});
it("passes approval runtime tokens to backend gateway clients", async () => {
setLocalLoopbackGatewayConfig();
await callGateway({
method: "exec.approval.waitDecision",
scopes: ["operator.approvals"],
approvalRuntimeToken: "runtime-token",
});
expect(lastClientOptions?.approvalRuntimeToken).toBe("runtime-token");
});
it("does not synthesize display names for CLI calls", async () => {
setLocalLoopbackGatewayConfig();
+2
View File
@@ -60,6 +60,7 @@ type CallGatewayBaseOptions = {
clientVersion?: string;
platform?: string;
mode?: GatewayClientMode;
approvalRuntimeToken?: string;
deviceIdentity?: DeviceIdentity | null;
instanceId?: string;
minProtocol?: number;
@@ -698,6 +699,7 @@ async function executeGatewayRequestWithScopes<T>(params: {
clientVersion: opts.clientVersion ?? VERSION,
platform: opts.platform,
mode: opts.mode ?? GATEWAY_CLIENT_MODES.CLI,
...(opts.approvalRuntimeToken ? { approvalRuntimeToken: opts.approvalRuntimeToken } : {}),
role: "operator",
scopes,
deviceIdentity: