feat(gateway): add node canvas capability refresh flow

This commit is contained in:
Ayaan Zaidi
2026-02-27 11:47:06 +05:30
committed by Ayaan Zaidi
parent 0896bb09b0
commit 54eaf17327
8 changed files with 137 additions and 11 deletions

View File

@@ -19,7 +19,12 @@ export const CLI_DEFAULT_OPERATOR_SCOPES: OperatorScope[] = [
PAIRING_SCOPE,
];
const NODE_ROLE_METHODS = new Set(["node.invoke.result", "node.event", "skills.bins"]);
const NODE_ROLE_METHODS = new Set([
"node.invoke.result",
"node.event",
"node.canvas.capability.refresh",
"skills.bins",
]);
const METHOD_SCOPE_GROUPS: Record<OperatorScope, readonly string[]> = {
[APPROVALS_SCOPE]: [

View File

@@ -77,6 +77,7 @@ const BASE_METHODS = [
"node.invoke",
"node.invoke.result",
"node.event",
"node.canvas.capability.refresh",
"cron.list",
"cron.status",
"cron.add",

View File

@@ -0,0 +1,63 @@
import { describe, expect, it, vi } from "vitest";
import { ErrorCodes } from "../protocol/index.js";
import { nodeHandlers } from "./nodes.js";
describe("node.canvas.capability.refresh", () => {
it("rotates the caller canvas capability and returns a fresh scoped URL", async () => {
const respond = vi.fn();
const client = {
connect: { role: "node", client: { id: "node-1" } },
canvasHostUrl: "http://127.0.0.1:18789",
canvasCapability: "old-token",
canvasCapabilityExpiresAtMs: Date.now() - 1,
};
await nodeHandlers["node.canvas.capability.refresh"]({
req: { type: "req", id: "req-1", method: "node.canvas.capability.refresh" },
params: {},
respond,
context: {} as never,
client: client as never,
isWebchatConnect: () => false,
});
const call = respond.mock.calls[0] as
| [
boolean,
{
canvasCapability?: string;
canvasHostUrl?: string;
canvasCapabilityExpiresAtMs?: number;
},
]
| undefined;
expect(call?.[0]).toBe(true);
const payload = call?.[1] ?? {};
expect(typeof payload.canvasCapability).toBe("string");
expect(payload.canvasCapability).not.toBe("old-token");
expect(payload.canvasHostUrl).toContain("/__openclaw__/cap/");
expect(typeof payload.canvasCapabilityExpiresAtMs).toBe("number");
expect(payload.canvasCapabilityExpiresAtMs).toBeGreaterThan(Date.now());
expect(client.canvasCapability).toBe(payload.canvasCapability);
expect(client.canvasCapabilityExpiresAtMs).toBe(payload.canvasCapabilityExpiresAtMs);
});
it("returns unavailable when the caller session has no base canvas URL", async () => {
const respond = vi.fn();
await nodeHandlers["node.canvas.capability.refresh"]({
req: { type: "req", id: "req-2", method: "node.canvas.capability.refresh" },
params: {},
respond,
context: {} as never,
client: { connect: { role: "node", client: { id: "node-1" } } } as never,
isWebchatConnect: () => false,
});
const call = respond.mock.calls[0] as
| [boolean, unknown, { code?: number; message?: string }]
| undefined;
expect(call?.[0]).toBe(false);
expect(call?.[2]?.code).toBe(ErrorCodes.UNAVAILABLE);
});
});

View File

@@ -59,20 +59,22 @@ export function respondUnavailableOnNodeInvokeError<T extends { ok: boolean; err
if (res.ok) {
return true;
}
const message =
res.error && typeof res.error === "object" && "message" in res.error
? (res.error as { message?: unknown }).message
const nodeError =
res.error && typeof res.error === "object"
? (res.error as { code?: unknown; message?: unknown })
: null;
const nodeCode = typeof nodeError?.code === "string" ? nodeError.code.trim() : "";
const nodeMessage =
typeof nodeError?.message === "string" && nodeError.message.trim().length > 0
? nodeError.message.trim()
: "node invoke failed";
const message = nodeCode ? `${nodeCode}: ${nodeMessage}` : nodeMessage;
respond(
false,
undefined,
errorShape(
ErrorCodes.UNAVAILABLE,
typeof message === "string" ? message : "node invoke failed",
{
details: { nodeError: res.error ?? null },
},
),
errorShape(ErrorCodes.UNAVAILABLE, message, {
details: { nodeError: res.error ?? null },
}),
);
return false;
}

View File

@@ -14,6 +14,11 @@ import {
sendApnsAlert,
sendApnsBackgroundWake,
} from "../../infra/push-apns.js";
import {
buildCanvasScopedHostUrl,
CANVAS_CAPABILITY_TTL_MS,
mintCanvasCapabilityToken,
} from "../canvas-capability.js";
import { isNodeCommandAllowed, resolveNodeCommandAllowlist } from "../node-command-policy.js";
import { sanitizeNodeInvokeParamsForForwarding } from "../node-invoke-sanitize.js";
import {
@@ -558,6 +563,51 @@ export const nodeHandlers: GatewayRequestHandlers = {
);
});
},
"node.canvas.capability.refresh": async ({ params, respond, client }) => {
if (!validateNodeListParams(params)) {
respondInvalidParams({
respond,
method: "node.canvas.capability.refresh",
validator: validateNodeListParams,
});
return;
}
const baseCanvasHostUrl = client?.canvasHostUrl?.trim() ?? "";
if (!baseCanvasHostUrl) {
respond(
false,
undefined,
errorShape(ErrorCodes.UNAVAILABLE, "canvas host unavailable for this node session"),
);
return;
}
const canvasCapability = mintCanvasCapabilityToken();
const canvasCapabilityExpiresAtMs = Date.now() + CANVAS_CAPABILITY_TTL_MS;
const scopedCanvasHostUrl = buildCanvasScopedHostUrl(baseCanvasHostUrl, canvasCapability);
if (!scopedCanvasHostUrl) {
respond(
false,
undefined,
errorShape(ErrorCodes.UNAVAILABLE, "failed to mint scoped canvas host URL"),
);
return;
}
if (client) {
client.canvasCapability = canvasCapability;
client.canvasCapabilityExpiresAtMs = canvasCapabilityExpiresAtMs;
}
respond(
true,
{
canvasCapability,
canvasCapabilityExpiresAtMs,
canvasHostUrl: scopedCanvasHostUrl,
},
undefined,
);
},
"node.invoke": async ({ params, respond, context, client, req }) => {
if (!validateNodeInvokeParams(params)) {
respondInvalidParams({

View File

@@ -18,6 +18,9 @@ export type GatewayClient = {
connect: ConnectParams;
connId?: string;
clientIp?: string;
canvasHostUrl?: string;
canvasCapability?: string;
canvasCapabilityExpiresAtMs?: number;
};
export type RespondFn = (

View File

@@ -1016,6 +1016,7 @@ export function attachGatewayWsMessageHandler(params: {
connId,
presenceKey,
clientIp: reportedClientIp,
canvasHostUrl,
canvasCapability,
canvasCapabilityExpiresAtMs,
};

View File

@@ -7,6 +7,7 @@ export type GatewayWsClient = {
connId: string;
presenceKey?: string;
clientIp?: string;
canvasHostUrl?: string;
canvasCapability?: string;
canvasCapabilityExpiresAtMs?: number;
};