mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-04 12:20:28 +00:00
feat(gateway): add node canvas capability refresh flow
This commit is contained in:
@@ -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]: [
|
||||
|
||||
@@ -77,6 +77,7 @@ const BASE_METHODS = [
|
||||
"node.invoke",
|
||||
"node.invoke.result",
|
||||
"node.event",
|
||||
"node.canvas.capability.refresh",
|
||||
"cron.list",
|
||||
"cron.status",
|
||||
"cron.add",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -18,6 +18,9 @@ export type GatewayClient = {
|
||||
connect: ConnectParams;
|
||||
connId?: string;
|
||||
clientIp?: string;
|
||||
canvasHostUrl?: string;
|
||||
canvasCapability?: string;
|
||||
canvasCapabilityExpiresAtMs?: number;
|
||||
};
|
||||
|
||||
export type RespondFn = (
|
||||
|
||||
@@ -1016,6 +1016,7 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
connId,
|
||||
presenceKey,
|
||||
clientIp: reportedClientIp,
|
||||
canvasHostUrl,
|
||||
canvasCapability,
|
||||
canvasCapabilityExpiresAtMs,
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ export type GatewayWsClient = {
|
||||
connId: string;
|
||||
presenceKey?: string;
|
||||
clientIp?: string;
|
||||
canvasHostUrl?: string;
|
||||
canvasCapability?: string;
|
||||
canvasCapabilityExpiresAtMs?: number;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user