fix(node): approved local commands no longer fail identity validation (#103886)

* fix(node): preserve approval replay identity

* docs(node): explain replay identity boundary

* fix(node): reuse persisted replay identity

* chore(release): leave changelog to release automation
This commit is contained in:
Peter Steinberger
2026-07-10 19:31:11 +01:00
committed by GitHub
parent b56bad33f2
commit 7a38f140a2
2 changed files with 135 additions and 6 deletions
+101
View File
@@ -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" });
+34 -6
View File
@@ -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<T = Record<string, unknown>>(
});
const deviceIdentity = resolveApprovalRequesterDeviceIdentityForGatewayTool({
method,
callParams: params,
opts,
target: gateway.target,
});