The OpenClaw Gateway's agent method has strict parameter validation that rejects unknown properties. The paperclip property was being sent at the root level of agentParams, causing validation failures with error: "invalid agent params: at root: unexpected property 'paperclip'" The paperclip metadata is already included in the message field via wakeText, so removing the separate paperclip property resolves the validation error while preserving the necessary information. Fixes #606 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1434 lines
47 KiB
TypeScript
1434 lines
47 KiB
TypeScript
import type {
|
|
AdapterExecutionContext,
|
|
AdapterExecutionResult,
|
|
AdapterRuntimeServiceReport,
|
|
} from "@paperclipai/adapter-utils";
|
|
import { asNumber, asString, buildPaperclipEnv, parseObject } from "@paperclipai/adapter-utils/server-utils";
|
|
import crypto, { randomUUID } from "node:crypto";
|
|
import { WebSocket } from "ws";
|
|
|
|
type SessionKeyStrategy = "fixed" | "issue" | "run";
|
|
|
|
type WakePayload = {
|
|
runId: string;
|
|
agentId: string;
|
|
companyId: string;
|
|
taskId: string | null;
|
|
issueId: string | null;
|
|
wakeReason: string | null;
|
|
wakeCommentId: string | null;
|
|
approvalId: string | null;
|
|
approvalStatus: string | null;
|
|
issueIds: string[];
|
|
};
|
|
|
|
type GatewayDeviceIdentity = {
|
|
deviceId: string;
|
|
publicKeyRawBase64Url: string;
|
|
privateKeyPem: string;
|
|
source: "configured" | "ephemeral";
|
|
};
|
|
|
|
type GatewayRequestFrame = {
|
|
type: "req";
|
|
id: string;
|
|
method: string;
|
|
params?: unknown;
|
|
};
|
|
|
|
type GatewayResponseFrame = {
|
|
type: "res";
|
|
id: string;
|
|
ok: boolean;
|
|
payload?: unknown;
|
|
error?: {
|
|
code?: unknown;
|
|
message?: unknown;
|
|
};
|
|
};
|
|
|
|
type GatewayEventFrame = {
|
|
type: "event";
|
|
event: string;
|
|
payload?: unknown;
|
|
seq?: number;
|
|
};
|
|
|
|
type PendingRequest = {
|
|
resolve: (value: unknown) => void;
|
|
reject: (err: Error) => void;
|
|
expectFinal: boolean;
|
|
timer: ReturnType<typeof setTimeout> | null;
|
|
};
|
|
|
|
type GatewayResponseError = Error & {
|
|
gatewayCode?: string;
|
|
gatewayDetails?: Record<string, unknown>;
|
|
};
|
|
|
|
type GatewayClientOptions = {
|
|
url: string;
|
|
headers: Record<string, string>;
|
|
onEvent: (frame: GatewayEventFrame) => Promise<void> | void;
|
|
onLog: AdapterExecutionContext["onLog"];
|
|
};
|
|
|
|
type GatewayClientRequestOptions = {
|
|
timeoutMs: number;
|
|
expectFinal?: boolean;
|
|
};
|
|
|
|
const PROTOCOL_VERSION = 3;
|
|
const DEFAULT_SCOPES = ["operator.admin"];
|
|
const DEFAULT_CLIENT_ID = "gateway-client";
|
|
const DEFAULT_CLIENT_MODE = "backend";
|
|
const DEFAULT_CLIENT_VERSION = "paperclip";
|
|
const DEFAULT_ROLE = "operator";
|
|
|
|
const SENSITIVE_LOG_KEY_PATTERN =
|
|
/(^|[_-])(auth|authorization|token|secret|password|api[_-]?key|private[_-]?key)([_-]|$)|^x-openclaw-(auth|token)$/i;
|
|
|
|
const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
|
|
|
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
function nonEmpty(value: unknown): string | null {
|
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
|
}
|
|
|
|
function parseOptionalPositiveInteger(value: unknown): number | null {
|
|
if (typeof value === "number" && Number.isFinite(value)) {
|
|
return Math.max(1, Math.floor(value));
|
|
}
|
|
if (typeof value === "string" && value.trim().length > 0) {
|
|
const parsed = Number.parseInt(value.trim(), 10);
|
|
if (Number.isFinite(parsed)) return Math.max(1, Math.floor(parsed));
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function parseBoolean(value: unknown, fallback = false): boolean {
|
|
if (typeof value === "boolean") return value;
|
|
if (typeof value === "string") {
|
|
const normalized = value.trim().toLowerCase();
|
|
if (normalized === "true" || normalized === "1") return true;
|
|
if (normalized === "false" || normalized === "0") return false;
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
function normalizeSessionKeyStrategy(value: unknown): SessionKeyStrategy {
|
|
const normalized = asString(value, "issue").trim().toLowerCase();
|
|
if (normalized === "fixed" || normalized === "run") return normalized;
|
|
return "issue";
|
|
}
|
|
|
|
function resolveSessionKey(input: {
|
|
strategy: SessionKeyStrategy;
|
|
configuredSessionKey: string | null;
|
|
runId: string;
|
|
issueId: string | null;
|
|
}): string {
|
|
const fallback = input.configuredSessionKey ?? "paperclip";
|
|
if (input.strategy === "run") return `paperclip:run:${input.runId}`;
|
|
if (input.strategy === "issue" && input.issueId) return `paperclip:issue:${input.issueId}`;
|
|
return fallback;
|
|
}
|
|
|
|
function isLoopbackHost(hostname: string): boolean {
|
|
const value = hostname.trim().toLowerCase();
|
|
return value === "localhost" || value === "127.0.0.1" || value === "::1";
|
|
}
|
|
|
|
function toStringRecord(value: unknown): Record<string, string> {
|
|
const parsed = parseObject(value);
|
|
const out: Record<string, string> = {};
|
|
for (const [key, entry] of Object.entries(parsed)) {
|
|
if (typeof entry === "string") out[key] = entry;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function toStringArray(value: unknown): string[] {
|
|
if (Array.isArray(value)) {
|
|
return value
|
|
.filter((entry): entry is string => typeof entry === "string")
|
|
.map((entry) => entry.trim())
|
|
.filter(Boolean);
|
|
}
|
|
if (typeof value === "string") {
|
|
return value
|
|
.split(",")
|
|
.map((entry) => entry.trim())
|
|
.filter(Boolean);
|
|
}
|
|
return [];
|
|
}
|
|
|
|
function normalizeScopes(value: unknown): string[] {
|
|
const parsed = toStringArray(value);
|
|
return parsed.length > 0 ? parsed : [...DEFAULT_SCOPES];
|
|
}
|
|
|
|
function uniqueScopes(scopes: string[]): string[] {
|
|
return Array.from(new Set(scopes.map((scope) => scope.trim()).filter(Boolean)));
|
|
}
|
|
|
|
function headerMapGetIgnoreCase(headers: Record<string, string>, key: string): string | null {
|
|
const match = Object.entries(headers).find(([entryKey]) => entryKey.toLowerCase() === key.toLowerCase());
|
|
return match ? match[1] : null;
|
|
}
|
|
|
|
function headerMapHasIgnoreCase(headers: Record<string, string>, key: string): boolean {
|
|
return Object.keys(headers).some((entryKey) => entryKey.toLowerCase() === key.toLowerCase());
|
|
}
|
|
|
|
function getGatewayErrorDetails(err: unknown): Record<string, unknown> | null {
|
|
if (!err || typeof err !== "object") return null;
|
|
const candidate = (err as GatewayResponseError).gatewayDetails;
|
|
return asRecord(candidate);
|
|
}
|
|
|
|
function extractPairingRequestId(err: unknown): string | null {
|
|
const details = getGatewayErrorDetails(err);
|
|
const fromDetails = nonEmpty(details?.requestId);
|
|
if (fromDetails) return fromDetails;
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
const match = message.match(/requestId\s*[:=]\s*([A-Za-z0-9_-]+)/i);
|
|
return match?.[1] ?? null;
|
|
}
|
|
|
|
function toAuthorizationHeaderValue(rawToken: string): string {
|
|
const trimmed = rawToken.trim();
|
|
if (!trimmed) return trimmed;
|
|
return /^bearer\s+/i.test(trimmed) ? trimmed : `Bearer ${trimmed}`;
|
|
}
|
|
|
|
function tokenFromAuthHeader(rawHeader: string | null): string | null {
|
|
if (!rawHeader) return null;
|
|
const trimmed = rawHeader.trim();
|
|
if (!trimmed) return null;
|
|
const match = trimmed.match(/^bearer\s+(.+)$/i);
|
|
return match ? nonEmpty(match[1]) : trimmed;
|
|
}
|
|
|
|
function resolveAuthToken(config: Record<string, unknown>, headers: Record<string, string>): string | null {
|
|
const explicit = nonEmpty(config.authToken) ?? nonEmpty(config.token);
|
|
if (explicit) return explicit;
|
|
|
|
const tokenHeader = headerMapGetIgnoreCase(headers, "x-openclaw-token");
|
|
if (nonEmpty(tokenHeader)) return nonEmpty(tokenHeader);
|
|
|
|
const authHeader =
|
|
headerMapGetIgnoreCase(headers, "x-openclaw-auth") ??
|
|
headerMapGetIgnoreCase(headers, "authorization");
|
|
return tokenFromAuthHeader(authHeader);
|
|
}
|
|
|
|
function isSensitiveLogKey(key: string): boolean {
|
|
return SENSITIVE_LOG_KEY_PATTERN.test(key.trim());
|
|
}
|
|
|
|
function sha256Prefix(value: string): string {
|
|
return crypto.createHash("sha256").update(value).digest("hex").slice(0, 12);
|
|
}
|
|
|
|
function redactSecretForLog(value: string): string {
|
|
return `[redacted len=${value.length} sha256=${sha256Prefix(value)}]`;
|
|
}
|
|
|
|
function truncateForLog(value: string, maxChars = 320): string {
|
|
if (value.length <= maxChars) return value;
|
|
return `${value.slice(0, maxChars)}... [truncated ${value.length - maxChars} chars]`;
|
|
}
|
|
|
|
function redactForLog(value: unknown, keyPath: string[] = [], depth = 0): unknown {
|
|
const currentKey = keyPath[keyPath.length - 1] ?? "";
|
|
if (typeof value === "string") {
|
|
if (isSensitiveLogKey(currentKey)) return redactSecretForLog(value);
|
|
return truncateForLog(value);
|
|
}
|
|
if (typeof value === "number" || typeof value === "boolean" || value == null) {
|
|
return value;
|
|
}
|
|
if (Array.isArray(value)) {
|
|
if (depth >= 6) return "[array-truncated]";
|
|
const out = value.slice(0, 20).map((entry, index) => redactForLog(entry, [...keyPath, `${index}`], depth + 1));
|
|
if (value.length > 20) out.push(`[+${value.length - 20} more items]`);
|
|
return out;
|
|
}
|
|
if (typeof value === "object") {
|
|
if (depth >= 6) return "[object-truncated]";
|
|
const entries = Object.entries(value as Record<string, unknown>);
|
|
const out: Record<string, unknown> = {};
|
|
for (const [key, entry] of entries.slice(0, 80)) {
|
|
out[key] = redactForLog(entry, [...keyPath, key], depth + 1);
|
|
}
|
|
if (entries.length > 80) {
|
|
out.__truncated__ = `+${entries.length - 80} keys`;
|
|
}
|
|
return out;
|
|
}
|
|
return String(value);
|
|
}
|
|
|
|
function stringifyForLog(value: unknown, maxChars: number): string {
|
|
const text = JSON.stringify(value);
|
|
if (text.length <= maxChars) return text;
|
|
return `${text.slice(0, maxChars)}... [truncated ${text.length - maxChars} chars]`;
|
|
}
|
|
|
|
function buildWakePayload(ctx: AdapterExecutionContext): WakePayload {
|
|
const { runId, agent, context } = ctx;
|
|
return {
|
|
runId,
|
|
agentId: agent.id,
|
|
companyId: agent.companyId,
|
|
taskId: nonEmpty(context.taskId) ?? nonEmpty(context.issueId),
|
|
issueId: nonEmpty(context.issueId),
|
|
wakeReason: nonEmpty(context.wakeReason),
|
|
wakeCommentId: nonEmpty(context.wakeCommentId) ?? nonEmpty(context.commentId),
|
|
approvalId: nonEmpty(context.approvalId),
|
|
approvalStatus: nonEmpty(context.approvalStatus),
|
|
issueIds: Array.isArray(context.issueIds)
|
|
? context.issueIds.filter(
|
|
(value): value is string => typeof value === "string" && value.trim().length > 0,
|
|
)
|
|
: [],
|
|
};
|
|
}
|
|
|
|
function resolvePaperclipApiUrlOverride(value: unknown): string | null {
|
|
const raw = nonEmpty(value);
|
|
if (!raw) return null;
|
|
try {
|
|
const parsed = new URL(raw);
|
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
|
|
return parsed.toString();
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function buildPaperclipEnvForWake(ctx: AdapterExecutionContext, wakePayload: WakePayload): Record<string, string> {
|
|
const paperclipApiUrlOverride = resolvePaperclipApiUrlOverride(ctx.config.paperclipApiUrl);
|
|
const paperclipEnv: Record<string, string> = {
|
|
...buildPaperclipEnv(ctx.agent),
|
|
PAPERCLIP_RUN_ID: ctx.runId,
|
|
};
|
|
|
|
if (paperclipApiUrlOverride) {
|
|
paperclipEnv.PAPERCLIP_API_URL = paperclipApiUrlOverride;
|
|
}
|
|
if (wakePayload.taskId) paperclipEnv.PAPERCLIP_TASK_ID = wakePayload.taskId;
|
|
if (wakePayload.wakeReason) paperclipEnv.PAPERCLIP_WAKE_REASON = wakePayload.wakeReason;
|
|
if (wakePayload.wakeCommentId) paperclipEnv.PAPERCLIP_WAKE_COMMENT_ID = wakePayload.wakeCommentId;
|
|
if (wakePayload.approvalId) paperclipEnv.PAPERCLIP_APPROVAL_ID = wakePayload.approvalId;
|
|
if (wakePayload.approvalStatus) paperclipEnv.PAPERCLIP_APPROVAL_STATUS = wakePayload.approvalStatus;
|
|
if (wakePayload.issueIds.length > 0) {
|
|
paperclipEnv.PAPERCLIP_LINKED_ISSUE_IDS = wakePayload.issueIds.join(",");
|
|
}
|
|
|
|
return paperclipEnv;
|
|
}
|
|
|
|
function buildWakeText(payload: WakePayload, paperclipEnv: Record<string, string>): string {
|
|
const claimedApiKeyPath = "~/.openclaw/workspace/paperclip-claimed-api-key.json";
|
|
const orderedKeys = [
|
|
"PAPERCLIP_RUN_ID",
|
|
"PAPERCLIP_AGENT_ID",
|
|
"PAPERCLIP_COMPANY_ID",
|
|
"PAPERCLIP_API_URL",
|
|
"PAPERCLIP_TASK_ID",
|
|
"PAPERCLIP_WAKE_REASON",
|
|
"PAPERCLIP_WAKE_COMMENT_ID",
|
|
"PAPERCLIP_APPROVAL_ID",
|
|
"PAPERCLIP_APPROVAL_STATUS",
|
|
"PAPERCLIP_LINKED_ISSUE_IDS",
|
|
];
|
|
|
|
const envLines: string[] = [];
|
|
for (const key of orderedKeys) {
|
|
const value = paperclipEnv[key];
|
|
if (!value) continue;
|
|
envLines.push(`${key}=${value}`);
|
|
}
|
|
|
|
const issueIdHint = payload.taskId ?? payload.issueId ?? "";
|
|
const apiBaseHint = paperclipEnv.PAPERCLIP_API_URL ?? "<set PAPERCLIP_API_URL>";
|
|
|
|
const lines = [
|
|
"Paperclip wake event for a cloud adapter.",
|
|
"",
|
|
"Run this procedure now. Do not guess undocumented endpoints and do not ask for additional heartbeat docs.",
|
|
"",
|
|
"Set these values in your run context:",
|
|
...envLines,
|
|
`PAPERCLIP_API_KEY=<token from ${claimedApiKeyPath}>`,
|
|
"",
|
|
`Load PAPERCLIP_API_KEY from ${claimedApiKeyPath} (the token you saved after claim-api-key).`,
|
|
"",
|
|
`api_base=${apiBaseHint}`,
|
|
`task_id=${payload.taskId ?? ""}`,
|
|
`issue_id=${payload.issueId ?? ""}`,
|
|
`wake_reason=${payload.wakeReason ?? ""}`,
|
|
`wake_comment_id=${payload.wakeCommentId ?? ""}`,
|
|
`approval_id=${payload.approvalId ?? ""}`,
|
|
`approval_status=${payload.approvalStatus ?? ""}`,
|
|
`linked_issue_ids=${payload.issueIds.join(",")}`,
|
|
"",
|
|
"HTTP rules:",
|
|
"- Use Authorization: Bearer $PAPERCLIP_API_KEY on every API call.",
|
|
"- Use X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID on every mutating API call.",
|
|
"- Use only /api endpoints listed below.",
|
|
"- Do NOT call guessed endpoints like /api/cloud-adapter/*, /api/cloud-adapters/*, /api/adapters/cloud/*, or /api/heartbeat.",
|
|
"",
|
|
"Workflow:",
|
|
"1) GET /api/agents/me",
|
|
`2) Determine issueId: PAPERCLIP_TASK_ID if present, otherwise issue_id (${issueIdHint}).`,
|
|
"3) If issueId exists:",
|
|
" - POST /api/issues/{issueId}/checkout with {\"agentId\":\"$PAPERCLIP_AGENT_ID\",\"expectedStatuses\":[\"todo\",\"backlog\",\"blocked\"]}",
|
|
" - GET /api/issues/{issueId}",
|
|
" - GET /api/issues/{issueId}/comments",
|
|
" - Execute the issue instructions exactly.",
|
|
" - If instructions require a comment, POST /api/issues/{issueId}/comments with {\"body\":\"...\"}.",
|
|
" - PATCH /api/issues/{issueId} with {\"status\":\"done\",\"comment\":\"what changed and why\"}.",
|
|
"4) If issueId does not exist:",
|
|
" - GET /api/companies/$PAPERCLIP_COMPANY_ID/issues?assigneeAgentId=$PAPERCLIP_AGENT_ID&status=todo,in_progress,blocked",
|
|
" - Pick in_progress first, then todo, then blocked, then execute step 3.",
|
|
"",
|
|
"Useful endpoints for issue work:",
|
|
"- POST /api/issues/{issueId}/comments",
|
|
"- PATCH /api/issues/{issueId}",
|
|
"- POST /api/companies/{companyId}/issues (when asked to create a new issue)",
|
|
"",
|
|
"Complete the workflow in this run.",
|
|
];
|
|
return lines.join("\n");
|
|
}
|
|
|
|
function appendWakeText(baseText: string, wakeText: string): string {
|
|
const trimmedBase = baseText.trim();
|
|
return trimmedBase.length > 0 ? `${trimmedBase}\n\n${wakeText}` : wakeText;
|
|
}
|
|
|
|
function buildStandardPaperclipPayload(
|
|
ctx: AdapterExecutionContext,
|
|
wakePayload: WakePayload,
|
|
paperclipEnv: Record<string, string>,
|
|
payloadTemplate: Record<string, unknown>,
|
|
): Record<string, unknown> {
|
|
const templatePaperclip = parseObject(payloadTemplate.paperclip);
|
|
const workspace = asRecord(ctx.context.paperclipWorkspace);
|
|
const workspaces = Array.isArray(ctx.context.paperclipWorkspaces)
|
|
? ctx.context.paperclipWorkspaces.filter((entry): entry is Record<string, unknown> => Boolean(asRecord(entry)))
|
|
: [];
|
|
const configuredWorkspaceRuntime = parseObject(ctx.config.workspaceRuntime);
|
|
const runtimeServiceIntents = Array.isArray(ctx.context.paperclipRuntimeServiceIntents)
|
|
? ctx.context.paperclipRuntimeServiceIntents.filter(
|
|
(entry): entry is Record<string, unknown> => Boolean(asRecord(entry)),
|
|
)
|
|
: [];
|
|
|
|
const standardPaperclip: Record<string, unknown> = {
|
|
runId: ctx.runId,
|
|
companyId: ctx.agent.companyId,
|
|
agentId: ctx.agent.id,
|
|
agentName: ctx.agent.name,
|
|
taskId: wakePayload.taskId,
|
|
issueId: wakePayload.issueId,
|
|
issueIds: wakePayload.issueIds,
|
|
wakeReason: wakePayload.wakeReason,
|
|
wakeCommentId: wakePayload.wakeCommentId,
|
|
approvalId: wakePayload.approvalId,
|
|
approvalStatus: wakePayload.approvalStatus,
|
|
apiUrl: paperclipEnv.PAPERCLIP_API_URL ?? null,
|
|
};
|
|
|
|
if (workspace) {
|
|
standardPaperclip.workspace = workspace;
|
|
}
|
|
if (workspaces.length > 0) {
|
|
standardPaperclip.workspaces = workspaces;
|
|
}
|
|
if (runtimeServiceIntents.length > 0 || Object.keys(configuredWorkspaceRuntime).length > 0) {
|
|
standardPaperclip.workspaceRuntime = {
|
|
...configuredWorkspaceRuntime,
|
|
...(runtimeServiceIntents.length > 0 ? { services: runtimeServiceIntents } : {}),
|
|
};
|
|
}
|
|
|
|
return {
|
|
...templatePaperclip,
|
|
...standardPaperclip,
|
|
};
|
|
}
|
|
|
|
function normalizeUrl(input: string): URL | null {
|
|
try {
|
|
return new URL(input);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function rawDataToString(data: unknown): string {
|
|
if (typeof data === "string") return data;
|
|
if (Buffer.isBuffer(data)) return data.toString("utf8");
|
|
if (data instanceof ArrayBuffer) return Buffer.from(data).toString("utf8");
|
|
if (Array.isArray(data)) {
|
|
return Buffer.concat(
|
|
data.map((entry) => (Buffer.isBuffer(entry) ? entry : Buffer.from(String(entry), "utf8"))),
|
|
).toString("utf8");
|
|
}
|
|
return String(data ?? "");
|
|
}
|
|
|
|
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
|
|
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return promise;
|
|
return new Promise<T>((resolve, reject) => {
|
|
const timer = setTimeout(() => reject(new Error(message)), timeoutMs);
|
|
promise
|
|
.then((value) => {
|
|
clearTimeout(timer);
|
|
resolve(value);
|
|
})
|
|
.catch((err) => {
|
|
clearTimeout(timer);
|
|
reject(err);
|
|
});
|
|
});
|
|
}
|
|
|
|
function derivePublicKeyRaw(publicKeyPem: string): Buffer {
|
|
const key = crypto.createPublicKey(publicKeyPem);
|
|
const spki = key.export({ type: "spki", format: "der" }) as Buffer;
|
|
if (
|
|
spki.length === ED25519_SPKI_PREFIX.length + 32 &&
|
|
spki.subarray(0, ED25519_SPKI_PREFIX.length).equals(ED25519_SPKI_PREFIX)
|
|
) {
|
|
return spki.subarray(ED25519_SPKI_PREFIX.length);
|
|
}
|
|
return spki;
|
|
}
|
|
|
|
function base64UrlEncode(buf: Buffer): string {
|
|
return buf.toString("base64").replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/g, "");
|
|
}
|
|
|
|
function signDevicePayload(privateKeyPem: string, payload: string): string {
|
|
const key = crypto.createPrivateKey(privateKeyPem);
|
|
const sig = crypto.sign(null, Buffer.from(payload, "utf8"), key);
|
|
return base64UrlEncode(sig);
|
|
}
|
|
|
|
function buildDeviceAuthPayloadV3(params: {
|
|
deviceId: string;
|
|
clientId: string;
|
|
clientMode: string;
|
|
role: string;
|
|
scopes: string[];
|
|
signedAtMs: number;
|
|
token?: string | null;
|
|
nonce: string;
|
|
platform?: string | null;
|
|
deviceFamily?: string | null;
|
|
}): string {
|
|
const scopes = params.scopes.join(",");
|
|
const token = params.token ?? "";
|
|
const platform = params.platform?.trim() ?? "";
|
|
const deviceFamily = params.deviceFamily?.trim() ?? "";
|
|
return [
|
|
"v3",
|
|
params.deviceId,
|
|
params.clientId,
|
|
params.clientMode,
|
|
params.role,
|
|
scopes,
|
|
String(params.signedAtMs),
|
|
token,
|
|
params.nonce,
|
|
platform,
|
|
deviceFamily,
|
|
].join("|");
|
|
}
|
|
|
|
function resolveDeviceIdentity(config: Record<string, unknown>): GatewayDeviceIdentity {
|
|
const configuredPrivateKey = nonEmpty(config.devicePrivateKeyPem);
|
|
if (configuredPrivateKey) {
|
|
const privateKey = crypto.createPrivateKey(configuredPrivateKey);
|
|
const publicKey = crypto.createPublicKey(privateKey);
|
|
const publicKeyPem = publicKey.export({ type: "spki", format: "pem" }).toString();
|
|
const raw = derivePublicKeyRaw(publicKeyPem);
|
|
return {
|
|
deviceId: crypto.createHash("sha256").update(raw).digest("hex"),
|
|
publicKeyRawBase64Url: base64UrlEncode(raw),
|
|
privateKeyPem: configuredPrivateKey,
|
|
source: "configured",
|
|
};
|
|
}
|
|
|
|
const generated = crypto.generateKeyPairSync("ed25519");
|
|
const publicKeyPem = generated.publicKey.export({ type: "spki", format: "pem" }).toString();
|
|
const privateKeyPem = generated.privateKey.export({ type: "pkcs8", format: "pem" }).toString();
|
|
const raw = derivePublicKeyRaw(publicKeyPem);
|
|
return {
|
|
deviceId: crypto.createHash("sha256").update(raw).digest("hex"),
|
|
publicKeyRawBase64Url: base64UrlEncode(raw),
|
|
privateKeyPem,
|
|
source: "ephemeral",
|
|
};
|
|
}
|
|
|
|
function isResponseFrame(value: unknown): value is GatewayResponseFrame {
|
|
const record = asRecord(value);
|
|
return Boolean(record && record.type === "res" && typeof record.id === "string" && typeof record.ok === "boolean");
|
|
}
|
|
|
|
function isEventFrame(value: unknown): value is GatewayEventFrame {
|
|
const record = asRecord(value);
|
|
return Boolean(record && record.type === "event" && typeof record.event === "string");
|
|
}
|
|
|
|
class GatewayWsClient {
|
|
private ws: WebSocket | null = null;
|
|
private pending = new Map<string, PendingRequest>();
|
|
private challengePromise: Promise<string>;
|
|
private resolveChallenge!: (nonce: string) => void;
|
|
private rejectChallenge!: (err: Error) => void;
|
|
|
|
constructor(private readonly opts: GatewayClientOptions) {
|
|
this.challengePromise = new Promise<string>((resolve, reject) => {
|
|
this.resolveChallenge = resolve;
|
|
this.rejectChallenge = reject;
|
|
});
|
|
}
|
|
|
|
async connect(
|
|
buildConnectParams: (nonce: string) => Record<string, unknown>,
|
|
timeoutMs: number,
|
|
): Promise<Record<string, unknown> | null> {
|
|
this.ws = new WebSocket(this.opts.url, {
|
|
headers: this.opts.headers,
|
|
maxPayload: 25 * 1024 * 1024,
|
|
});
|
|
|
|
const ws = this.ws;
|
|
|
|
ws.on("message", (data) => {
|
|
this.handleMessage(rawDataToString(data));
|
|
});
|
|
|
|
ws.on("close", (code, reason) => {
|
|
const reasonText = rawDataToString(reason);
|
|
const err = new Error(`gateway closed (${code}): ${reasonText}`);
|
|
this.failPending(err);
|
|
this.rejectChallenge(err);
|
|
});
|
|
|
|
ws.on("error", (err) => {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
void this.opts.onLog("stderr", `[openclaw-gateway] websocket error: ${message}\n`);
|
|
});
|
|
|
|
await withTimeout(
|
|
new Promise<void>((resolve, reject) => {
|
|
const onOpen = () => {
|
|
cleanup();
|
|
resolve();
|
|
};
|
|
const onError = (err: Error) => {
|
|
cleanup();
|
|
reject(err);
|
|
};
|
|
const onClose = (code: number, reason: Buffer) => {
|
|
cleanup();
|
|
reject(new Error(`gateway closed before open (${code}): ${rawDataToString(reason)}`));
|
|
};
|
|
const cleanup = () => {
|
|
ws.off("open", onOpen);
|
|
ws.off("error", onError);
|
|
ws.off("close", onClose);
|
|
};
|
|
ws.once("open", onOpen);
|
|
ws.once("error", onError);
|
|
ws.once("close", onClose);
|
|
}),
|
|
timeoutMs,
|
|
"gateway websocket open timeout",
|
|
);
|
|
|
|
const nonce = await withTimeout(this.challengePromise, timeoutMs, "gateway connect challenge timeout");
|
|
const signedConnectParams = buildConnectParams(nonce);
|
|
|
|
const hello = await this.request<Record<string, unknown> | null>("connect", signedConnectParams, {
|
|
timeoutMs,
|
|
});
|
|
|
|
return hello;
|
|
}
|
|
|
|
async request<T>(
|
|
method: string,
|
|
params: unknown,
|
|
opts: GatewayClientRequestOptions,
|
|
): Promise<T> {
|
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
throw new Error("gateway not connected");
|
|
}
|
|
|
|
const id = randomUUID();
|
|
const frame: GatewayRequestFrame = {
|
|
type: "req",
|
|
id,
|
|
method,
|
|
params,
|
|
};
|
|
|
|
const payload = JSON.stringify(frame);
|
|
const requestPromise = new Promise<T>((resolve, reject) => {
|
|
const timer =
|
|
opts.timeoutMs > 0
|
|
? setTimeout(() => {
|
|
this.pending.delete(id);
|
|
reject(new Error(`gateway request timeout (${method})`));
|
|
}, opts.timeoutMs)
|
|
: null;
|
|
|
|
this.pending.set(id, {
|
|
resolve: (value) => resolve(value as T),
|
|
reject,
|
|
expectFinal: opts.expectFinal === true,
|
|
timer,
|
|
});
|
|
});
|
|
|
|
this.ws.send(payload);
|
|
return requestPromise;
|
|
}
|
|
|
|
close() {
|
|
if (!this.ws) return;
|
|
this.ws.close(1000, "paperclip-complete");
|
|
this.ws = null;
|
|
}
|
|
|
|
private failPending(err: Error) {
|
|
for (const [, pending] of this.pending) {
|
|
if (pending.timer) clearTimeout(pending.timer);
|
|
pending.reject(err);
|
|
}
|
|
this.pending.clear();
|
|
}
|
|
|
|
private handleMessage(raw: string) {
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(raw);
|
|
} catch {
|
|
return;
|
|
}
|
|
|
|
if (isEventFrame(parsed)) {
|
|
if (parsed.event === "connect.challenge") {
|
|
const payload = asRecord(parsed.payload);
|
|
const nonce = nonEmpty(payload?.nonce);
|
|
if (nonce) {
|
|
this.resolveChallenge(nonce);
|
|
return;
|
|
}
|
|
}
|
|
void Promise.resolve(this.opts.onEvent(parsed)).catch(() => {
|
|
// Ignore event callback failures and keep stream active.
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (!isResponseFrame(parsed)) return;
|
|
|
|
const pending = this.pending.get(parsed.id);
|
|
if (!pending) return;
|
|
|
|
const payload = asRecord(parsed.payload);
|
|
const status = nonEmpty(payload?.status)?.toLowerCase();
|
|
if (pending.expectFinal && status === "accepted") {
|
|
return;
|
|
}
|
|
|
|
if (pending.timer) clearTimeout(pending.timer);
|
|
this.pending.delete(parsed.id);
|
|
|
|
if (parsed.ok) {
|
|
pending.resolve(parsed.payload ?? null);
|
|
return;
|
|
}
|
|
|
|
const errorRecord = asRecord(parsed.error);
|
|
const message =
|
|
nonEmpty(errorRecord?.message) ??
|
|
nonEmpty(errorRecord?.code) ??
|
|
"gateway request failed";
|
|
const err = new Error(message) as GatewayResponseError;
|
|
const code = nonEmpty(errorRecord?.code);
|
|
const details = asRecord(errorRecord?.details);
|
|
if (code) err.gatewayCode = code;
|
|
if (details) err.gatewayDetails = details;
|
|
pending.reject(err);
|
|
}
|
|
}
|
|
|
|
async function autoApproveDevicePairing(params: {
|
|
url: string;
|
|
headers: Record<string, string>;
|
|
connectTimeoutMs: number;
|
|
clientId: string;
|
|
clientMode: string;
|
|
clientVersion: string;
|
|
role: string;
|
|
scopes: string[];
|
|
authToken: string | null;
|
|
password: string | null;
|
|
requestId: string | null;
|
|
deviceId: string | null;
|
|
onLog: AdapterExecutionContext["onLog"];
|
|
}): Promise<{ ok: true; requestId: string } | { ok: false; reason: string }> {
|
|
if (!params.authToken && !params.password) {
|
|
return { ok: false, reason: "shared auth token/password is missing" };
|
|
}
|
|
|
|
const approvalScopes = uniqueScopes([...params.scopes, "operator.pairing"]);
|
|
const client = new GatewayWsClient({
|
|
url: params.url,
|
|
headers: params.headers,
|
|
onEvent: () => {},
|
|
onLog: params.onLog,
|
|
});
|
|
|
|
try {
|
|
await params.onLog(
|
|
"stdout",
|
|
"[openclaw-gateway] pairing required; attempting automatic pairing approval via gateway methods\n",
|
|
);
|
|
|
|
await client.connect(
|
|
() => ({
|
|
minProtocol: PROTOCOL_VERSION,
|
|
maxProtocol: PROTOCOL_VERSION,
|
|
client: {
|
|
id: params.clientId,
|
|
version: params.clientVersion,
|
|
platform: process.platform,
|
|
mode: params.clientMode,
|
|
},
|
|
role: params.role,
|
|
scopes: approvalScopes,
|
|
auth: {
|
|
...(params.authToken ? { token: params.authToken } : {}),
|
|
...(params.password ? { password: params.password } : {}),
|
|
},
|
|
}),
|
|
params.connectTimeoutMs,
|
|
);
|
|
|
|
let requestId = params.requestId;
|
|
if (!requestId) {
|
|
const listPayload = await client.request<Record<string, unknown>>("device.pair.list", {}, {
|
|
timeoutMs: params.connectTimeoutMs,
|
|
});
|
|
const pending = Array.isArray(listPayload.pending) ? listPayload.pending : [];
|
|
const pendingRecords = pending
|
|
.map((entry) => asRecord(entry))
|
|
.filter((entry): entry is Record<string, unknown> => Boolean(entry));
|
|
const matching =
|
|
(params.deviceId
|
|
? pendingRecords.find((entry) => nonEmpty(entry.deviceId) === params.deviceId)
|
|
: null) ?? pendingRecords[pendingRecords.length - 1];
|
|
requestId = nonEmpty(matching?.requestId);
|
|
}
|
|
|
|
if (!requestId) {
|
|
return { ok: false, reason: "no pending device pairing request found" };
|
|
}
|
|
|
|
await client.request(
|
|
"device.pair.approve",
|
|
{ requestId },
|
|
{
|
|
timeoutMs: params.connectTimeoutMs,
|
|
},
|
|
);
|
|
|
|
return { ok: true, requestId };
|
|
} catch (err) {
|
|
return { ok: false, reason: err instanceof Error ? err.message : String(err) };
|
|
} finally {
|
|
client.close();
|
|
}
|
|
}
|
|
|
|
function parseUsage(value: unknown): AdapterExecutionResult["usage"] | undefined {
|
|
const record = asRecord(value);
|
|
if (!record) return undefined;
|
|
|
|
const inputTokens = asNumber(record.inputTokens ?? record.input, 0);
|
|
const outputTokens = asNumber(record.outputTokens ?? record.output, 0);
|
|
const cachedInputTokens = asNumber(
|
|
record.cachedInputTokens ?? record.cached_input_tokens ?? record.cacheRead ?? record.cache_read,
|
|
0,
|
|
);
|
|
|
|
if (inputTokens <= 0 && outputTokens <= 0 && cachedInputTokens <= 0) {
|
|
return undefined;
|
|
}
|
|
|
|
return {
|
|
inputTokens,
|
|
outputTokens,
|
|
...(cachedInputTokens > 0 ? { cachedInputTokens } : {}),
|
|
};
|
|
}
|
|
|
|
function extractRuntimeServicesFromMeta(meta: Record<string, unknown> | null): AdapterRuntimeServiceReport[] {
|
|
if (!meta) return [];
|
|
const reports: AdapterRuntimeServiceReport[] = [];
|
|
|
|
const runtimeServices = Array.isArray(meta.runtimeServices)
|
|
? meta.runtimeServices.filter((entry): entry is Record<string, unknown> => Boolean(asRecord(entry)))
|
|
: [];
|
|
for (const entry of runtimeServices) {
|
|
const serviceName = nonEmpty(entry.serviceName) ?? nonEmpty(entry.name);
|
|
if (!serviceName) continue;
|
|
const rawStatus = nonEmpty(entry.status)?.toLowerCase();
|
|
const status =
|
|
rawStatus === "starting" || rawStatus === "running" || rawStatus === "stopped" || rawStatus === "failed"
|
|
? rawStatus
|
|
: "running";
|
|
const rawLifecycle = nonEmpty(entry.lifecycle)?.toLowerCase();
|
|
const lifecycle = rawLifecycle === "shared" ? "shared" : "ephemeral";
|
|
const rawScopeType = nonEmpty(entry.scopeType)?.toLowerCase();
|
|
const scopeType =
|
|
rawScopeType === "project_workspace" ||
|
|
rawScopeType === "execution_workspace" ||
|
|
rawScopeType === "agent"
|
|
? rawScopeType
|
|
: "run";
|
|
const rawHealth = nonEmpty(entry.healthStatus)?.toLowerCase();
|
|
const healthStatus =
|
|
rawHealth === "healthy" || rawHealth === "unhealthy" || rawHealth === "unknown"
|
|
? rawHealth
|
|
: status === "running"
|
|
? "healthy"
|
|
: "unknown";
|
|
|
|
reports.push({
|
|
id: nonEmpty(entry.id),
|
|
projectId: nonEmpty(entry.projectId),
|
|
projectWorkspaceId: nonEmpty(entry.projectWorkspaceId),
|
|
issueId: nonEmpty(entry.issueId),
|
|
scopeType,
|
|
scopeId: nonEmpty(entry.scopeId),
|
|
serviceName,
|
|
status,
|
|
lifecycle,
|
|
reuseKey: nonEmpty(entry.reuseKey),
|
|
command: nonEmpty(entry.command),
|
|
cwd: nonEmpty(entry.cwd),
|
|
port: parseOptionalPositiveInteger(entry.port),
|
|
url: nonEmpty(entry.url),
|
|
providerRef: nonEmpty(entry.providerRef) ?? nonEmpty(entry.previewId),
|
|
ownerAgentId: nonEmpty(entry.ownerAgentId),
|
|
stopPolicy: asRecord(entry.stopPolicy),
|
|
healthStatus,
|
|
});
|
|
}
|
|
|
|
const previewUrl = nonEmpty(meta.previewUrl);
|
|
if (previewUrl) {
|
|
reports.push({
|
|
serviceName: "preview",
|
|
status: "running",
|
|
lifecycle: "ephemeral",
|
|
scopeType: "run",
|
|
url: previewUrl,
|
|
providerRef: nonEmpty(meta.previewId) ?? previewUrl,
|
|
healthStatus: "healthy",
|
|
});
|
|
}
|
|
|
|
const previewUrls = Array.isArray(meta.previewUrls)
|
|
? meta.previewUrls.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0)
|
|
: [];
|
|
previewUrls.forEach((url, index) => {
|
|
reports.push({
|
|
serviceName: index === 0 ? "preview" : `preview-${index + 1}`,
|
|
status: "running",
|
|
lifecycle: "ephemeral",
|
|
scopeType: "run",
|
|
url,
|
|
providerRef: `${url}#${index}`,
|
|
healthStatus: "healthy",
|
|
});
|
|
});
|
|
|
|
return reports;
|
|
}
|
|
|
|
function extractResultText(value: unknown): string | null {
|
|
const record = asRecord(value);
|
|
if (!record) return null;
|
|
|
|
const payloads = Array.isArray(record.payloads) ? record.payloads : [];
|
|
const texts = payloads
|
|
.map((entry) => {
|
|
const payload = asRecord(entry);
|
|
return nonEmpty(payload?.text);
|
|
})
|
|
.filter((entry): entry is string => Boolean(entry));
|
|
|
|
if (texts.length > 0) return texts.join("\n\n");
|
|
return nonEmpty(record.text) ?? nonEmpty(record.summary) ?? null;
|
|
}
|
|
|
|
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
|
|
const urlValue = asString(ctx.config.url, "").trim();
|
|
if (!urlValue) {
|
|
return {
|
|
exitCode: 1,
|
|
signal: null,
|
|
timedOut: false,
|
|
errorMessage: "OpenClaw gateway adapter missing url",
|
|
errorCode: "openclaw_gateway_url_missing",
|
|
};
|
|
}
|
|
|
|
const parsedUrl = normalizeUrl(urlValue);
|
|
if (!parsedUrl) {
|
|
return {
|
|
exitCode: 1,
|
|
signal: null,
|
|
timedOut: false,
|
|
errorMessage: `Invalid gateway URL: ${urlValue}`,
|
|
errorCode: "openclaw_gateway_url_invalid",
|
|
};
|
|
}
|
|
|
|
if (parsedUrl.protocol !== "ws:" && parsedUrl.protocol !== "wss:") {
|
|
return {
|
|
exitCode: 1,
|
|
signal: null,
|
|
timedOut: false,
|
|
errorMessage: `Unsupported gateway URL protocol: ${parsedUrl.protocol}`,
|
|
errorCode: "openclaw_gateway_url_protocol",
|
|
};
|
|
}
|
|
|
|
const timeoutSec = Math.max(0, Math.floor(asNumber(ctx.config.timeoutSec, 120)));
|
|
const timeoutMs = timeoutSec > 0 ? timeoutSec * 1000 : 0;
|
|
const connectTimeoutMs = timeoutMs > 0 ? Math.min(timeoutMs, 15_000) : 10_000;
|
|
const waitTimeoutMs = parseOptionalPositiveInteger(ctx.config.waitTimeoutMs) ?? (timeoutMs > 0 ? timeoutMs : 30_000);
|
|
|
|
const payloadTemplate = parseObject(ctx.config.payloadTemplate);
|
|
const transportHint = nonEmpty(ctx.config.streamTransport) ?? nonEmpty(ctx.config.transport);
|
|
|
|
const headers = toStringRecord(ctx.config.headers);
|
|
const authToken = resolveAuthToken(parseObject(ctx.config), headers);
|
|
const password = nonEmpty(ctx.config.password);
|
|
const deviceToken = nonEmpty(ctx.config.deviceToken);
|
|
|
|
if (authToken && !headerMapHasIgnoreCase(headers, "authorization")) {
|
|
headers.authorization = toAuthorizationHeaderValue(authToken);
|
|
}
|
|
|
|
const clientId = nonEmpty(ctx.config.clientId) ?? DEFAULT_CLIENT_ID;
|
|
const clientMode = nonEmpty(ctx.config.clientMode) ?? DEFAULT_CLIENT_MODE;
|
|
const clientVersion = nonEmpty(ctx.config.clientVersion) ?? DEFAULT_CLIENT_VERSION;
|
|
const role = nonEmpty(ctx.config.role) ?? DEFAULT_ROLE;
|
|
const scopes = normalizeScopes(ctx.config.scopes);
|
|
const deviceFamily = nonEmpty(ctx.config.deviceFamily);
|
|
const disableDeviceAuth = parseBoolean(ctx.config.disableDeviceAuth, false);
|
|
|
|
const wakePayload = buildWakePayload(ctx);
|
|
const paperclipEnv = buildPaperclipEnvForWake(ctx, wakePayload);
|
|
const wakeText = buildWakeText(wakePayload, paperclipEnv);
|
|
|
|
const sessionKeyStrategy = normalizeSessionKeyStrategy(ctx.config.sessionKeyStrategy);
|
|
const configuredSessionKey = nonEmpty(ctx.config.sessionKey);
|
|
const sessionKey = resolveSessionKey({
|
|
strategy: sessionKeyStrategy,
|
|
configuredSessionKey,
|
|
runId: ctx.runId,
|
|
issueId: wakePayload.issueId,
|
|
});
|
|
|
|
const templateMessage = nonEmpty(payloadTemplate.message) ?? nonEmpty(payloadTemplate.text);
|
|
const message = templateMessage ? appendWakeText(templateMessage, wakeText) : wakeText;
|
|
const paperclipPayload = buildStandardPaperclipPayload(ctx, wakePayload, paperclipEnv, payloadTemplate);
|
|
|
|
const agentParams: Record<string, unknown> = {
|
|
...payloadTemplate,
|
|
message,
|
|
sessionKey,
|
|
idempotencyKey: ctx.runId,
|
|
};
|
|
delete agentParams.text;
|
|
|
|
const configuredAgentId = nonEmpty(ctx.config.agentId);
|
|
if (configuredAgentId && !nonEmpty(agentParams.agentId)) {
|
|
agentParams.agentId = configuredAgentId;
|
|
}
|
|
|
|
if (typeof agentParams.timeout !== "number") {
|
|
agentParams.timeout = waitTimeoutMs;
|
|
}
|
|
|
|
if (ctx.onMeta) {
|
|
await ctx.onMeta({
|
|
adapterType: "openclaw_gateway",
|
|
command: "gateway",
|
|
commandArgs: ["ws", parsedUrl.toString(), "agent"],
|
|
context: ctx.context,
|
|
});
|
|
}
|
|
|
|
const outboundHeaderKeys = Object.keys(headers).sort();
|
|
await ctx.onLog(
|
|
"stdout",
|
|
`[openclaw-gateway] outbound headers (redacted): ${stringifyForLog(redactForLog(headers), 4_000)}\n`,
|
|
);
|
|
await ctx.onLog(
|
|
"stdout",
|
|
`[openclaw-gateway] outbound payload (redacted): ${stringifyForLog(redactForLog(agentParams), 12_000)}\n`,
|
|
);
|
|
await ctx.onLog("stdout", `[openclaw-gateway] outbound header keys: ${outboundHeaderKeys.join(", ")}\n`);
|
|
if (transportHint) {
|
|
await ctx.onLog(
|
|
"stdout",
|
|
`[openclaw-gateway] ignoring streamTransport=${transportHint}; gateway adapter always uses websocket protocol\n`,
|
|
);
|
|
}
|
|
if (parsedUrl.protocol === "ws:" && !isLoopbackHost(parsedUrl.hostname)) {
|
|
await ctx.onLog(
|
|
"stdout",
|
|
"[openclaw-gateway] warning: using plaintext ws:// to a non-loopback host; prefer wss:// for remote endpoints\n",
|
|
);
|
|
}
|
|
|
|
const autoPairOnFirstConnect = parseBoolean(ctx.config.autoPairOnFirstConnect, true);
|
|
let autoPairAttempted = false;
|
|
let latestResultPayload: unknown = null;
|
|
|
|
while (true) {
|
|
const trackedRunIds = new Set<string>([ctx.runId]);
|
|
const assistantChunks: string[] = [];
|
|
let lifecycleError: string | null = null;
|
|
let deviceIdentity: GatewayDeviceIdentity | null = null;
|
|
|
|
const onEvent = async (frame: GatewayEventFrame) => {
|
|
if (frame.event !== "agent") {
|
|
if (frame.event === "shutdown") {
|
|
await ctx.onLog(
|
|
"stdout",
|
|
`[openclaw-gateway] gateway shutdown notice: ${stringifyForLog(frame.payload ?? {}, 2_000)}\n`,
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const payload = asRecord(frame.payload);
|
|
if (!payload) return;
|
|
|
|
const runId = nonEmpty(payload.runId);
|
|
if (!runId || !trackedRunIds.has(runId)) return;
|
|
|
|
const stream = nonEmpty(payload.stream) ?? "unknown";
|
|
const data = asRecord(payload.data) ?? {};
|
|
await ctx.onLog(
|
|
"stdout",
|
|
`[openclaw-gateway:event] run=${runId} stream=${stream} data=${stringifyForLog(data, 8_000)}\n`,
|
|
);
|
|
|
|
if (stream === "assistant") {
|
|
const delta = nonEmpty(data.delta);
|
|
const text = nonEmpty(data.text);
|
|
if (delta) {
|
|
assistantChunks.push(delta);
|
|
} else if (text) {
|
|
assistantChunks.push(text);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (stream === "error") {
|
|
lifecycleError = nonEmpty(data.error) ?? nonEmpty(data.message) ?? lifecycleError;
|
|
return;
|
|
}
|
|
|
|
if (stream === "lifecycle") {
|
|
const phase = nonEmpty(data.phase)?.toLowerCase();
|
|
if (phase === "error" || phase === "failed" || phase === "cancelled") {
|
|
lifecycleError = nonEmpty(data.error) ?? nonEmpty(data.message) ?? lifecycleError;
|
|
}
|
|
}
|
|
};
|
|
|
|
const client = new GatewayWsClient({
|
|
url: parsedUrl.toString(),
|
|
headers,
|
|
onEvent,
|
|
onLog: ctx.onLog,
|
|
});
|
|
|
|
try {
|
|
deviceIdentity = disableDeviceAuth ? null : resolveDeviceIdentity(parseObject(ctx.config));
|
|
if (deviceIdentity) {
|
|
await ctx.onLog(
|
|
"stdout",
|
|
`[openclaw-gateway] device auth enabled keySource=${deviceIdentity.source} deviceId=${deviceIdentity.deviceId}\n`,
|
|
);
|
|
} else {
|
|
await ctx.onLog("stdout", "[openclaw-gateway] device auth disabled\n");
|
|
}
|
|
|
|
await ctx.onLog("stdout", `[openclaw-gateway] connecting to ${parsedUrl.toString()}\n`);
|
|
|
|
const hello = await client.connect((nonce) => {
|
|
const signedAtMs = Date.now();
|
|
const connectParams: Record<string, unknown> = {
|
|
minProtocol: PROTOCOL_VERSION,
|
|
maxProtocol: PROTOCOL_VERSION,
|
|
client: {
|
|
id: clientId,
|
|
version: clientVersion,
|
|
platform: process.platform,
|
|
...(deviceFamily ? { deviceFamily } : {}),
|
|
mode: clientMode,
|
|
},
|
|
role,
|
|
scopes,
|
|
auth:
|
|
authToken || password || deviceToken
|
|
? {
|
|
...(authToken ? { token: authToken } : {}),
|
|
...(deviceToken ? { deviceToken } : {}),
|
|
...(password ? { password } : {}),
|
|
}
|
|
: undefined,
|
|
};
|
|
|
|
if (deviceIdentity) {
|
|
const payload = buildDeviceAuthPayloadV3({
|
|
deviceId: deviceIdentity.deviceId,
|
|
clientId,
|
|
clientMode,
|
|
role,
|
|
scopes,
|
|
signedAtMs,
|
|
token: authToken,
|
|
nonce,
|
|
platform: process.platform,
|
|
deviceFamily,
|
|
});
|
|
connectParams.device = {
|
|
id: deviceIdentity.deviceId,
|
|
publicKey: deviceIdentity.publicKeyRawBase64Url,
|
|
signature: signDevicePayload(deviceIdentity.privateKeyPem, payload),
|
|
signedAt: signedAtMs,
|
|
nonce,
|
|
};
|
|
}
|
|
return connectParams;
|
|
}, connectTimeoutMs);
|
|
|
|
await ctx.onLog(
|
|
"stdout",
|
|
`[openclaw-gateway] connected protocol=${asNumber(asRecord(hello)?.protocol, PROTOCOL_VERSION)}\n`,
|
|
);
|
|
|
|
const acceptedPayload = await client.request<Record<string, unknown>>("agent", agentParams, {
|
|
timeoutMs: connectTimeoutMs,
|
|
});
|
|
|
|
latestResultPayload = acceptedPayload;
|
|
|
|
const acceptedStatus = nonEmpty(acceptedPayload?.status)?.toLowerCase() ?? "";
|
|
const acceptedRunId = nonEmpty(acceptedPayload?.runId) ?? ctx.runId;
|
|
trackedRunIds.add(acceptedRunId);
|
|
|
|
await ctx.onLog(
|
|
"stdout",
|
|
`[openclaw-gateway] agent accepted runId=${acceptedRunId} status=${acceptedStatus || "unknown"}\n`,
|
|
);
|
|
|
|
if (acceptedStatus === "error") {
|
|
const errorMessage =
|
|
nonEmpty(acceptedPayload?.summary) ?? lifecycleError ?? "OpenClaw gateway agent request failed";
|
|
return {
|
|
exitCode: 1,
|
|
signal: null,
|
|
timedOut: false,
|
|
errorMessage,
|
|
errorCode: "openclaw_gateway_agent_error",
|
|
resultJson: acceptedPayload,
|
|
};
|
|
}
|
|
|
|
if (acceptedStatus !== "ok") {
|
|
const waitPayload = await client.request<Record<string, unknown>>(
|
|
"agent.wait",
|
|
{ runId: acceptedRunId, timeoutMs: waitTimeoutMs },
|
|
{ timeoutMs: waitTimeoutMs + connectTimeoutMs },
|
|
);
|
|
|
|
latestResultPayload = waitPayload;
|
|
|
|
const waitStatus = nonEmpty(waitPayload?.status)?.toLowerCase() ?? "";
|
|
if (waitStatus === "timeout") {
|
|
return {
|
|
exitCode: 1,
|
|
signal: null,
|
|
timedOut: true,
|
|
errorMessage: `OpenClaw gateway run timed out after ${waitTimeoutMs}ms`,
|
|
errorCode: "openclaw_gateway_wait_timeout",
|
|
resultJson: waitPayload,
|
|
};
|
|
}
|
|
|
|
if (waitStatus === "error") {
|
|
return {
|
|
exitCode: 1,
|
|
signal: null,
|
|
timedOut: false,
|
|
errorMessage:
|
|
nonEmpty(waitPayload?.error) ??
|
|
lifecycleError ??
|
|
"OpenClaw gateway run failed",
|
|
errorCode: "openclaw_gateway_wait_error",
|
|
resultJson: waitPayload,
|
|
};
|
|
}
|
|
|
|
if (waitStatus && waitStatus !== "ok") {
|
|
return {
|
|
exitCode: 1,
|
|
signal: null,
|
|
timedOut: false,
|
|
errorMessage: `Unexpected OpenClaw gateway agent.wait status: ${waitStatus}`,
|
|
errorCode: "openclaw_gateway_wait_status_unexpected",
|
|
resultJson: waitPayload,
|
|
};
|
|
}
|
|
}
|
|
|
|
const summaryFromEvents = assistantChunks.join("").trim();
|
|
const summaryFromPayload =
|
|
extractResultText(asRecord(acceptedPayload?.result)) ??
|
|
extractResultText(acceptedPayload) ??
|
|
extractResultText(asRecord(latestResultPayload)) ??
|
|
null;
|
|
const summary = summaryFromEvents || summaryFromPayload || null;
|
|
|
|
const acceptedResult = asRecord(acceptedPayload?.result);
|
|
const latestPayload = asRecord(latestResultPayload);
|
|
const latestResult = asRecord(latestPayload?.result);
|
|
const acceptedMeta = asRecord(acceptedResult?.meta) ?? asRecord(acceptedPayload?.meta);
|
|
const latestMeta = asRecord(latestResult?.meta) ?? asRecord(latestPayload?.meta);
|
|
const mergedMeta = {
|
|
...(acceptedMeta ?? {}),
|
|
...(latestMeta ?? {}),
|
|
};
|
|
const agentMeta =
|
|
asRecord(mergedMeta.agentMeta) ??
|
|
asRecord(acceptedMeta?.agentMeta) ??
|
|
asRecord(latestMeta?.agentMeta);
|
|
const usage = parseUsage(agentMeta?.usage ?? mergedMeta.usage);
|
|
const runtimeServices = extractRuntimeServicesFromMeta(agentMeta ?? mergedMeta);
|
|
const provider = nonEmpty(agentMeta?.provider) ?? nonEmpty(mergedMeta.provider) ?? "openclaw";
|
|
const model = nonEmpty(agentMeta?.model) ?? nonEmpty(mergedMeta.model) ?? null;
|
|
const costUsd = asNumber(agentMeta?.costUsd ?? mergedMeta.costUsd, 0);
|
|
|
|
await ctx.onLog(
|
|
"stdout",
|
|
`[openclaw-gateway] run completed runId=${Array.from(trackedRunIds).join(",")} status=ok\n`,
|
|
);
|
|
|
|
return {
|
|
exitCode: 0,
|
|
signal: null,
|
|
timedOut: false,
|
|
provider,
|
|
...(model ? { model } : {}),
|
|
...(usage ? { usage } : {}),
|
|
...(costUsd > 0 ? { costUsd } : {}),
|
|
resultJson: asRecord(latestResultPayload),
|
|
...(runtimeServices.length > 0 ? { runtimeServices } : {}),
|
|
...(summary ? { summary } : {}),
|
|
};
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
const lower = message.toLowerCase();
|
|
const timedOut = lower.includes("timeout");
|
|
const pairingRequired = lower.includes("pairing required");
|
|
|
|
if (
|
|
pairingRequired &&
|
|
!disableDeviceAuth &&
|
|
autoPairOnFirstConnect &&
|
|
!autoPairAttempted &&
|
|
(authToken || password)
|
|
) {
|
|
autoPairAttempted = true;
|
|
const pairResult = await autoApproveDevicePairing({
|
|
url: parsedUrl.toString(),
|
|
headers,
|
|
connectTimeoutMs,
|
|
clientId,
|
|
clientMode,
|
|
clientVersion,
|
|
role,
|
|
scopes,
|
|
authToken,
|
|
password,
|
|
requestId: extractPairingRequestId(err),
|
|
deviceId: deviceIdentity?.deviceId ?? null,
|
|
onLog: ctx.onLog,
|
|
});
|
|
if (pairResult.ok) {
|
|
await ctx.onLog(
|
|
"stdout",
|
|
`[openclaw-gateway] auto-approved pairing request ${pairResult.requestId}; retrying\n`,
|
|
);
|
|
continue;
|
|
}
|
|
await ctx.onLog(
|
|
"stderr",
|
|
`[openclaw-gateway] auto-pairing failed: ${pairResult.reason}\n`,
|
|
);
|
|
}
|
|
|
|
const detailedMessage = pairingRequired
|
|
? `${message}. Approve the pending device in OpenClaw (for example: openclaw devices approve --latest --url <gateway-ws-url> --token <gateway-token>) and retry. Ensure this agent has a persisted adapterConfig.devicePrivateKeyPem so approvals are reused.`
|
|
: message;
|
|
|
|
await ctx.onLog("stderr", `[openclaw-gateway] request failed: ${detailedMessage}\n`);
|
|
|
|
return {
|
|
exitCode: 1,
|
|
signal: null,
|
|
timedOut,
|
|
errorMessage: detailedMessage,
|
|
errorCode: timedOut
|
|
? "openclaw_gateway_timeout"
|
|
: pairingRequired
|
|
? "openclaw_gateway_pairing_required"
|
|
: "openclaw_gateway_request_failed",
|
|
resultJson: asRecord(latestResultPayload),
|
|
};
|
|
} finally {
|
|
client.close();
|
|
}
|
|
}
|
|
}
|