mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
b56bad33f2
commit
7a38f140a2
@@ -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" });
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user