diff --git a/src/agents/tools/gateway.test.ts b/src/agents/tools/gateway.test.ts index cd9abe4a2733..ec10300f8914 100644 --- a/src/agents/tools/gateway.test.ts +++ b/src/agents/tools/gateway.test.ts @@ -319,6 +319,107 @@ describe("gateway tool defaults", () => { expect(call.deviceIdentity).toEqual(mocks.deviceIdentity); }); + it.each([ + { + name: "approved flag", + params: { approved: true, runId: "approval-inline" }, + }, + { + name: "allow-once decision", + params: { approvalDecision: "allow-once", runId: "approval-async" }, + }, + { + name: "allow-always decision", + params: { approvalDecision: "allow-always", runId: "approval-always" }, + }, + ])("binds persisted device identity to node system.run with $name", async ({ params }) => { + mocks.callGateway.mockResolvedValueOnce({ ok: true }); + + await callGatewayTool( + "node.invoke", + {}, + { nodeId: "node-1", command: "system.run", params, idempotencyKey: "invoke-1" }, + { scopes: ["operator.write", "operator.approvals"] }, + ); + + const call = capturedGatewayCall(); + expect(call.deviceIdentity).toEqual(mocks.deviceIdentity); + expect(call).not.toHaveProperty("approvalRuntimeToken"); + }); + + it.each([ + { + name: "unapproved system.run", + command: "system.run", + params: { approved: false }, + }, + { + name: "system.run.prepare", + command: "system.run.prepare", + params: { approved: true, approvalDecision: "allow-once" }, + }, + { + name: "unrelated node command", + command: "system.info", + params: { approved: true, approvalDecision: "allow-always" }, + }, + ])("keeps ordinary node.invoke device-less for $name", async ({ command, params }) => { + mocks.callGateway.mockResolvedValueOnce({ ok: true }); + + await callGatewayTool( + "node.invoke", + {}, + { + nodeId: "node-1", + command, + params, + idempotencyKey: "invoke-1", + }, + ); + + const call = capturedGatewayCall(); + expect(call).not.toHaveProperty("deviceIdentity"); + expect(call).not.toHaveProperty("approvalRuntimeToken"); + }); + + it("fails approved node system.run closed without a persisted identity", async () => { + mocks.persistedDeviceIdentity = null; + + await expect( + callGatewayTool( + "node.invoke", + {}, + { + nodeId: "node-1", + command: "system.run", + params: { approved: true, runId: "approval-id" }, + idempotencyKey: "invoke-1", + }, + { scopes: ["operator.write", "operator.approvals"] }, + ), + ).rejects.toThrow("approved node gateway calls require a stable device identity"); + expect(mocks.callGateway).not.toHaveBeenCalled(); + }); + + it("reuses an existing replay identity without trying to create one", async () => { + mocks.deviceIdentityError = new Error("must not create identity during replay"); + mocks.callGateway.mockResolvedValueOnce({ ok: true }); + + await callGatewayTool( + "node.invoke", + {}, + { + nodeId: "node-1", + command: "system.run", + params: { approved: true, runId: "approval-id" }, + idempotencyKey: "invoke-1", + }, + { scopes: ["operator.write", "operator.approvals"] }, + ); + + expect(capturedGatewayCall().deviceIdentity).toEqual(mocks.deviceIdentity); + }); + it("does not mark direct cron helper calls with agent runtime identity", async () => { mocks.callGateway.mockResolvedValueOnce({ id: "job-1" }); diff --git a/src/agents/tools/gateway.ts b/src/agents/tools/gateway.ts index 95285f142de4..f3782796180f 100644 --- a/src/agents/tools/gateway.ts +++ b/src/agents/tools/gateway.ts @@ -3,6 +3,7 @@ * * Resolves gateway URL/token overrides, local credentials, and least-privilege operator scopes. */ +import { asNullableRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, @@ -240,29 +241,55 @@ function resolveApprovalRuntimeTokenForGatewayTool(params: { return getOperatorApprovalRuntimeToken(); } +function isApprovalReplayNodeSystemRun(method: string, callParams: unknown): boolean { + const invoke = method === "node.invoke" ? asNullableRecord(callParams) : null; + const run = invoke?.command === "system.run" ? asNullableRecord(invoke.params) : null; + const decision = normalizeOptionalString(run?.approvalDecision); + return run?.approved === true || decision === "allow-once" || decision === "allow-always"; +} + function resolveApprovalRequesterDeviceIdentityForGatewayTool(params: { method: string; + callParams: unknown; opts: GatewayCallOptions; target: GatewayOverrideTarget; }): DeviceIdentity | undefined { - if (!APPROVAL_RUNTIME_METHODS.has(params.method)) { + const isApprovalRuntimeMethod = APPROVAL_RUNTIME_METHODS.has(params.method); + const isNodeApprovalReplay = isApprovalReplayNodeSystemRun(params.method, params.callParams); + if (!isApprovalRuntimeMethod && !isNodeApprovalReplay) { return undefined; } - if (trimToUndefined(params.opts.gatewayUrl) !== undefined) { + if (isApprovalRuntimeMethod && trimToUndefined(params.opts.gatewayUrl) !== undefined) { return undefined; } try { + if (isNodeApprovalReplay) { + // Replay must reuse the identity present when the approval was registered. + // Creating one here could turn a device-less record into a different identity. + const identity = loadDeviceIdentityIfPresent(); + if (!identity) { + throw new Error("device identity is not persisted"); + } + return identity; + } const identity = loadOrCreateDeviceIdentity(); - // Approval request/wait calls may cross backend processes. Bind them to the - // persisted device id so a process-local approval token mismatch cannot hide - // the pending record from the matching wait call. - // Reject loadOrCreate's unpersisted fallback so another process can see the same id. + // Approval registration and wait can use separate gateway connections. + // Reject loadOrCreate's unpersisted fallback so both sides bind the same id. const persistedIdentity = loadDeviceIdentityIfPresent(); if (persistedIdentity?.deviceId !== identity.deviceId) { throw new Error("device identity is not persisted"); } return identity; } catch (error) { + if (isNodeApprovalReplay) { + throw new Error( + [ + "approved node gateway calls require a stable device identity.", + "Fix the OpenClaw state directory permissions and retry the approval.", + ].join(" "), + { cause: error }, + ); + } if (params.target === "local") { return undefined; } @@ -347,6 +374,7 @@ export async function callGatewayTool>( }); const deviceIdentity = resolveApprovalRequesterDeviceIdentityForGatewayTool({ method, + callParams: params, opts, target: gateway.target, });