mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
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:
@@ -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.
|
||||
|
||||
@@ -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", {}, {});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user