diff --git a/CHANGELOG.md b/CHANGELOG.md index 52020e636e65..94324f971ec7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/agents/tools/gateway.test.ts b/src/agents/tools/gateway.test.ts index 5c7a66757e5a..d597350de2ac 100644 --- a/src/agents/tools/gateway.test.ts +++ b/src/agents/tools/gateway.test.ts @@ -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", {}, {}); diff --git a/src/agents/tools/gateway.ts b/src/agents/tools/gateway.ts index 38005aa4a4f4..e75458b013dc 100644 --- a/src/agents/tools/gateway.ts +++ b/src/agents/tools/gateway.ts @@ -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([ + "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>( method: string, opts: GatewayCallOptions, @@ -155,6 +176,7 @@ export async function callGatewayTool>( const scopes = Array.isArray(extra?.scopes) ? extra.scopes : resolveLeastPrivilegeOperatorScopesForMethod(method, params); + const approvalRuntimeToken = resolveApprovalRuntimeTokenForGatewayTool({ method, opts }); return await callGateway({ url: gateway.url, token: gateway.token, @@ -165,6 +187,7 @@ export async function callGatewayTool>( clientName: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT, clientDisplayName: "agent", mode: GATEWAY_CLIENT_MODES.BACKEND, + ...(approvalRuntimeToken ? { approvalRuntimeToken } : {}), scopes, }); } diff --git a/src/gateway/call.test.ts b/src/gateway/call.test.ts index b10dfd5654b9..c85459ff5b7c 100644 --- a/src/gateway/call.test.ts +++ b/src/gateway/call.test.ts @@ -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; @@ -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; 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(); diff --git a/src/gateway/call.ts b/src/gateway/call.ts index 2ca94c81d964..8b3eb6285f43 100644 --- a/src/gateway/call.ts +++ b/src/gateway/call.ts @@ -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(params: { clientVersion: opts.clientVersion ?? VERSION, platform: opts.platform, mode: opts.mode ?? GATEWAY_CLIENT_MODES.CLI, + ...(opts.approvalRuntimeToken ? { approvalRuntimeToken: opts.approvalRuntimeToken } : {}), role: "operator", scopes, deviceIdentity: