@tickernelz/paperclip-pro-adapter-openclaw-gateway 2026.925.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/README.md +85 -0
- package/dist/cli/format-event.d.ts +2 -0
- package/dist/cli/format-event.d.ts.map +1 -0
- package/dist/cli/format-event.js +20 -0
- package/dist/cli/format-event.js.map +1 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +2 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +52 -0
- package/dist/index.js.map +1 -0
- package/dist/server/execute-dispatch.test.d.ts +2 -0
- package/dist/server/execute-dispatch.test.d.ts.map +1 -0
- package/dist/server/execute-dispatch.test.js +192 -0
- package/dist/server/execute-dispatch.test.js.map +1 -0
- package/dist/server/execute.d.ts +21 -0
- package/dist/server/execute.d.ts.map +1 -0
- package/dist/server/execute.js +1209 -0
- package/dist/server/execute.js.map +1 -0
- package/dist/server/execute.test.d.ts +2 -0
- package/dist/server/execute.test.d.ts.map +1 -0
- package/dist/server/execute.test.js +101 -0
- package/dist/server/execute.test.js.map +1 -0
- package/dist/server/index.d.ts +3 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +3 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/test.d.ts +3 -0
- package/dist/server/test.d.ts.map +1 -0
- package/dist/server/test.js +290 -0
- package/dist/server/test.js.map +1 -0
- package/dist/shared/stream.d.ts +5 -0
- package/dist/shared/stream.d.ts.map +1 -0
- package/dist/shared/stream.js +13 -0
- package/dist/shared/stream.js.map +1 -0
- package/dist/ui/build-config.d.ts +3 -0
- package/dist/ui/build-config.d.ts.map +1 -0
- package/dist/ui/build-config.js +85 -0
- package/dist/ui/build-config.js.map +1 -0
- package/dist/ui/build-config.test.d.ts +2 -0
- package/dist/ui/build-config.test.d.ts.map +1 -0
- package/dist/ui/build-config.test.js +49 -0
- package/dist/ui/build-config.test.js.map +1 -0
- package/dist/ui/index.d.ts +3 -0
- package/dist/ui/index.d.ts.map +1 -0
- package/dist/ui/index.js +3 -0
- package/dist/ui/index.js.map +1 -0
- package/dist/ui/parse-stdout.d.ts +3 -0
- package/dist/ui/parse-stdout.d.ts.map +1 -0
- package/dist/ui/parse-stdout.js +64 -0
- package/dist/ui/parse-stdout.js.map +1 -0
- package/package.json +59 -0
|
@@ -0,0 +1,1209 @@
|
|
|
1
|
+
import { asNumber, asString, buildPaperclipEnv, buildRuntimeToolsEnv, parseObject, readPaperclipIssueWorkModeFromContext, renderPaperclipWakePrompt, selectPaperclipTaskMarkdown, selectInitialCommunicationGuidance, joinPromptSections, stringifyPaperclipWakePayload, } from "@tickernelz/paperclip-pro-adapter-utils/server-utils";
|
|
2
|
+
import crypto, { randomUUID } from "node:crypto";
|
|
3
|
+
import { WebSocket } from "ws";
|
|
4
|
+
const PROTOCOL_VERSION = 4;
|
|
5
|
+
const DEFAULT_SCOPES = ["operator.admin"];
|
|
6
|
+
const DEFAULT_CLIENT_ID = "gateway-client";
|
|
7
|
+
const DEFAULT_CLIENT_MODE = "backend";
|
|
8
|
+
const DEFAULT_CLIENT_VERSION = "paperclip";
|
|
9
|
+
const DEFAULT_ROLE = "operator";
|
|
10
|
+
const SENSITIVE_LOG_KEY_PATTERN = /(^|[_-])(auth|authorization|token|secret|password|api[_-]?key|private[_-]?key)([_-]|$)|^x-openclaw-(auth|token)$/i;
|
|
11
|
+
const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
|
|
12
|
+
function asRecord(value) {
|
|
13
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
14
|
+
return null;
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
function nonEmpty(value) {
|
|
18
|
+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
|
19
|
+
}
|
|
20
|
+
function parseOptionalPositiveInteger(value) {
|
|
21
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
22
|
+
return Math.max(1, Math.floor(value));
|
|
23
|
+
}
|
|
24
|
+
if (typeof value === "string" && value.trim().length > 0) {
|
|
25
|
+
const parsed = Number.parseInt(value.trim(), 10);
|
|
26
|
+
if (Number.isFinite(parsed))
|
|
27
|
+
return Math.max(1, Math.floor(parsed));
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
function parseBoolean(value, fallback = false) {
|
|
32
|
+
if (typeof value === "boolean")
|
|
33
|
+
return value;
|
|
34
|
+
if (typeof value === "string") {
|
|
35
|
+
const normalized = value.trim().toLowerCase();
|
|
36
|
+
if (normalized === "true" || normalized === "1")
|
|
37
|
+
return true;
|
|
38
|
+
if (normalized === "false" || normalized === "0")
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
return fallback;
|
|
42
|
+
}
|
|
43
|
+
function normalizeSessionKeyStrategy(value) {
|
|
44
|
+
const normalized = asString(value, "issue").trim().toLowerCase();
|
|
45
|
+
if (normalized === "fixed" || normalized === "run")
|
|
46
|
+
return normalized;
|
|
47
|
+
return "issue";
|
|
48
|
+
}
|
|
49
|
+
function prefixSessionKeyForAgent(sessionKey, agentId) {
|
|
50
|
+
if (!agentId || sessionKey.startsWith("agent:"))
|
|
51
|
+
return sessionKey;
|
|
52
|
+
return `agent:${agentId}:${sessionKey}`;
|
|
53
|
+
}
|
|
54
|
+
export function resolveSessionKey(input) {
|
|
55
|
+
const fallback = input.configuredSessionKey ?? "paperclip";
|
|
56
|
+
if (input.strategy === "run") {
|
|
57
|
+
return prefixSessionKeyForAgent(`paperclip:run:${input.runId}`, input.agentId);
|
|
58
|
+
}
|
|
59
|
+
if (input.strategy === "issue" && input.issueId) {
|
|
60
|
+
return prefixSessionKeyForAgent(`paperclip:issue:${input.issueId}`, input.agentId);
|
|
61
|
+
}
|
|
62
|
+
return prefixSessionKeyForAgent(fallback, input.agentId);
|
|
63
|
+
}
|
|
64
|
+
function isLoopbackHost(hostname) {
|
|
65
|
+
const value = hostname.trim().toLowerCase();
|
|
66
|
+
return value === "localhost" || value === "127.0.0.1" || value === "::1";
|
|
67
|
+
}
|
|
68
|
+
function toStringRecord(value) {
|
|
69
|
+
const parsed = parseObject(value);
|
|
70
|
+
const out = {};
|
|
71
|
+
for (const [key, entry] of Object.entries(parsed)) {
|
|
72
|
+
if (typeof entry === "string")
|
|
73
|
+
out[key] = entry;
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
function toStringArray(value) {
|
|
78
|
+
if (Array.isArray(value)) {
|
|
79
|
+
return value
|
|
80
|
+
.filter((entry) => typeof entry === "string")
|
|
81
|
+
.map((entry) => entry.trim())
|
|
82
|
+
.filter(Boolean);
|
|
83
|
+
}
|
|
84
|
+
if (typeof value === "string") {
|
|
85
|
+
return value
|
|
86
|
+
.split(",")
|
|
87
|
+
.map((entry) => entry.trim())
|
|
88
|
+
.filter(Boolean);
|
|
89
|
+
}
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
92
|
+
function normalizeScopes(value) {
|
|
93
|
+
const parsed = toStringArray(value);
|
|
94
|
+
return parsed.length > 0 ? parsed : [...DEFAULT_SCOPES];
|
|
95
|
+
}
|
|
96
|
+
function uniqueScopes(scopes) {
|
|
97
|
+
return Array.from(new Set(scopes.map((scope) => scope.trim()).filter(Boolean)));
|
|
98
|
+
}
|
|
99
|
+
function headerMapGetIgnoreCase(headers, key) {
|
|
100
|
+
const match = Object.entries(headers).find(([entryKey]) => entryKey.toLowerCase() === key.toLowerCase());
|
|
101
|
+
return match ? match[1] : null;
|
|
102
|
+
}
|
|
103
|
+
function headerMapHasIgnoreCase(headers, key) {
|
|
104
|
+
return Object.keys(headers).some((entryKey) => entryKey.toLowerCase() === key.toLowerCase());
|
|
105
|
+
}
|
|
106
|
+
function getGatewayErrorDetails(err) {
|
|
107
|
+
if (!err || typeof err !== "object")
|
|
108
|
+
return null;
|
|
109
|
+
const candidate = err.gatewayDetails;
|
|
110
|
+
return asRecord(candidate);
|
|
111
|
+
}
|
|
112
|
+
function extractPairingRequestId(err) {
|
|
113
|
+
const details = getGatewayErrorDetails(err);
|
|
114
|
+
const fromDetails = nonEmpty(details?.requestId);
|
|
115
|
+
if (fromDetails)
|
|
116
|
+
return fromDetails;
|
|
117
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
118
|
+
const match = message.match(/requestId\s*[:=]\s*([A-Za-z0-9_-]+)/i);
|
|
119
|
+
return match?.[1] ?? null;
|
|
120
|
+
}
|
|
121
|
+
function toAuthorizationHeaderValue(rawToken) {
|
|
122
|
+
const trimmed = rawToken.trim();
|
|
123
|
+
if (!trimmed)
|
|
124
|
+
return trimmed;
|
|
125
|
+
return /^bearer\s+/i.test(trimmed) ? trimmed : `Bearer ${trimmed}`;
|
|
126
|
+
}
|
|
127
|
+
function tokenFromAuthHeader(rawHeader) {
|
|
128
|
+
if (!rawHeader)
|
|
129
|
+
return null;
|
|
130
|
+
const trimmed = rawHeader.trim();
|
|
131
|
+
if (!trimmed)
|
|
132
|
+
return null;
|
|
133
|
+
const match = trimmed.match(/^bearer\s+(.+)$/i);
|
|
134
|
+
return match ? nonEmpty(match[1]) : trimmed;
|
|
135
|
+
}
|
|
136
|
+
function resolveAuthToken(config, headers) {
|
|
137
|
+
const explicit = nonEmpty(config.authToken) ?? nonEmpty(config.token);
|
|
138
|
+
if (explicit)
|
|
139
|
+
return explicit;
|
|
140
|
+
const tokenHeader = headerMapGetIgnoreCase(headers, "x-openclaw-token");
|
|
141
|
+
if (nonEmpty(tokenHeader))
|
|
142
|
+
return nonEmpty(tokenHeader);
|
|
143
|
+
const authHeader = headerMapGetIgnoreCase(headers, "x-openclaw-auth") ??
|
|
144
|
+
headerMapGetIgnoreCase(headers, "authorization");
|
|
145
|
+
const fromHeader = tokenFromAuthHeader(authHeader);
|
|
146
|
+
if (fromHeader)
|
|
147
|
+
return fromHeader;
|
|
148
|
+
// Fallback to environment variable
|
|
149
|
+
return nonEmpty(process.env.OPENCLAW_TOKEN);
|
|
150
|
+
}
|
|
151
|
+
function isSensitiveLogKey(key) {
|
|
152
|
+
return SENSITIVE_LOG_KEY_PATTERN.test(key.trim());
|
|
153
|
+
}
|
|
154
|
+
function sha256Prefix(value) {
|
|
155
|
+
return crypto.createHash("sha256").update(value).digest("hex").slice(0, 12);
|
|
156
|
+
}
|
|
157
|
+
function redactSecretForLog(value) {
|
|
158
|
+
return `[redacted len=${value.length} sha256=${sha256Prefix(value)}]`;
|
|
159
|
+
}
|
|
160
|
+
function truncateForLog(value, maxChars = 320) {
|
|
161
|
+
if (value.length <= maxChars)
|
|
162
|
+
return value;
|
|
163
|
+
return `${value.slice(0, maxChars)}... [truncated ${value.length - maxChars} chars]`;
|
|
164
|
+
}
|
|
165
|
+
function redactForLog(value, keyPath = [], depth = 0) {
|
|
166
|
+
const currentKey = keyPath[keyPath.length - 1] ?? "";
|
|
167
|
+
if (typeof value === "string") {
|
|
168
|
+
if (isSensitiveLogKey(currentKey))
|
|
169
|
+
return redactSecretForLog(value);
|
|
170
|
+
return truncateForLog(value);
|
|
171
|
+
}
|
|
172
|
+
if (typeof value === "number" || typeof value === "boolean" || value == null) {
|
|
173
|
+
return value;
|
|
174
|
+
}
|
|
175
|
+
if (Array.isArray(value)) {
|
|
176
|
+
if (depth >= 6)
|
|
177
|
+
return "[array-truncated]";
|
|
178
|
+
const out = value.slice(0, 20).map((entry, index) => redactForLog(entry, [...keyPath, `${index}`], depth + 1));
|
|
179
|
+
if (value.length > 20)
|
|
180
|
+
out.push(`[+${value.length - 20} more items]`);
|
|
181
|
+
return out;
|
|
182
|
+
}
|
|
183
|
+
if (typeof value === "object") {
|
|
184
|
+
if (depth >= 6)
|
|
185
|
+
return "[object-truncated]";
|
|
186
|
+
const entries = Object.entries(value);
|
|
187
|
+
const out = {};
|
|
188
|
+
for (const [key, entry] of entries.slice(0, 80)) {
|
|
189
|
+
out[key] = redactForLog(entry, [...keyPath, key], depth + 1);
|
|
190
|
+
}
|
|
191
|
+
if (entries.length > 80) {
|
|
192
|
+
out.__truncated__ = `+${entries.length - 80} keys`;
|
|
193
|
+
}
|
|
194
|
+
return out;
|
|
195
|
+
}
|
|
196
|
+
return String(value);
|
|
197
|
+
}
|
|
198
|
+
function stringifyForLog(value, maxChars) {
|
|
199
|
+
const text = JSON.stringify(value);
|
|
200
|
+
if (text.length <= maxChars)
|
|
201
|
+
return text;
|
|
202
|
+
return `${text.slice(0, maxChars)}... [truncated ${text.length - maxChars} chars]`;
|
|
203
|
+
}
|
|
204
|
+
function buildWakePayload(ctx) {
|
|
205
|
+
const { runId, agent, context } = ctx;
|
|
206
|
+
return {
|
|
207
|
+
runId,
|
|
208
|
+
agentId: agent.id,
|
|
209
|
+
companyId: agent.companyId,
|
|
210
|
+
taskId: nonEmpty(context.taskId) ?? nonEmpty(context.issueId),
|
|
211
|
+
issueId: nonEmpty(context.issueId),
|
|
212
|
+
wakeReason: nonEmpty(context.wakeReason),
|
|
213
|
+
wakeCommentId: nonEmpty(context.wakeCommentId) ?? nonEmpty(context.commentId),
|
|
214
|
+
approvalId: nonEmpty(context.approvalId),
|
|
215
|
+
approvalStatus: nonEmpty(context.approvalStatus),
|
|
216
|
+
issueIds: Array.isArray(context.issueIds)
|
|
217
|
+
? context.issueIds.filter((value) => typeof value === "string" && value.trim().length > 0)
|
|
218
|
+
: [],
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
function resolvePaperclipApiUrlOverride(value) {
|
|
222
|
+
const raw = nonEmpty(value);
|
|
223
|
+
if (!raw)
|
|
224
|
+
return null;
|
|
225
|
+
try {
|
|
226
|
+
const parsed = new URL(raw);
|
|
227
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
|
|
228
|
+
return null;
|
|
229
|
+
return parsed.toString();
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
const DEFAULT_CLAIMED_API_KEY_PATH = "~/.openclaw/workspace/paperclip-claimed-api-key.json";
|
|
236
|
+
export function resolveClaimedApiKeyPath(value) {
|
|
237
|
+
return nonEmpty(value) ?? DEFAULT_CLAIMED_API_KEY_PATH;
|
|
238
|
+
}
|
|
239
|
+
function buildPaperclipEnvForWake(ctx, wakePayload) {
|
|
240
|
+
const paperclipApiUrlOverride = resolvePaperclipApiUrlOverride(ctx.config.paperclipApiUrl);
|
|
241
|
+
const paperclipEnv = {
|
|
242
|
+
...buildPaperclipEnv(ctx.agent),
|
|
243
|
+
...buildRuntimeToolsEnv(ctx.runtimeTools),
|
|
244
|
+
PAPERCLIP_RUN_ID: ctx.runId,
|
|
245
|
+
};
|
|
246
|
+
if (paperclipApiUrlOverride) {
|
|
247
|
+
paperclipEnv.PAPERCLIP_API_URL = paperclipApiUrlOverride;
|
|
248
|
+
}
|
|
249
|
+
if (wakePayload.taskId)
|
|
250
|
+
paperclipEnv.PAPERCLIP_TASK_ID = wakePayload.taskId;
|
|
251
|
+
const issueWorkMode = readPaperclipIssueWorkModeFromContext(ctx.context);
|
|
252
|
+
if (issueWorkMode)
|
|
253
|
+
paperclipEnv.PAPERCLIP_ISSUE_WORK_MODE = issueWorkMode;
|
|
254
|
+
if (wakePayload.wakeReason)
|
|
255
|
+
paperclipEnv.PAPERCLIP_WAKE_REASON = wakePayload.wakeReason;
|
|
256
|
+
if (wakePayload.wakeCommentId)
|
|
257
|
+
paperclipEnv.PAPERCLIP_WAKE_COMMENT_ID = wakePayload.wakeCommentId;
|
|
258
|
+
if (wakePayload.approvalId)
|
|
259
|
+
paperclipEnv.PAPERCLIP_APPROVAL_ID = wakePayload.approvalId;
|
|
260
|
+
if (wakePayload.approvalStatus)
|
|
261
|
+
paperclipEnv.PAPERCLIP_APPROVAL_STATUS = wakePayload.approvalStatus;
|
|
262
|
+
if (wakePayload.issueIds.length > 0) {
|
|
263
|
+
paperclipEnv.PAPERCLIP_LINKED_ISSUE_IDS = wakePayload.issueIds.join(",");
|
|
264
|
+
}
|
|
265
|
+
return paperclipEnv;
|
|
266
|
+
}
|
|
267
|
+
function buildWakeText(payload, paperclipEnv, structuredWakePrompt, claimedApiKeyPath, conversationTaskMarkdown) {
|
|
268
|
+
const orderedKeys = [
|
|
269
|
+
"PAPERCLIP_RUN_ID",
|
|
270
|
+
"PAPERCLIP_AGENT_ID",
|
|
271
|
+
"PAPERCLIP_COMPANY_ID",
|
|
272
|
+
"PAPERCLIP_API_URL",
|
|
273
|
+
"PAPERCLIP_TASK_ID",
|
|
274
|
+
"PAPERCLIP_WAKE_REASON",
|
|
275
|
+
"PAPERCLIP_WAKE_COMMENT_ID",
|
|
276
|
+
"PAPERCLIP_APPROVAL_ID",
|
|
277
|
+
"PAPERCLIP_APPROVAL_STATUS",
|
|
278
|
+
"PAPERCLIP_LINKED_ISSUE_IDS",
|
|
279
|
+
];
|
|
280
|
+
const envLines = [];
|
|
281
|
+
for (const key of orderedKeys) {
|
|
282
|
+
const value = paperclipEnv[key];
|
|
283
|
+
if (!value)
|
|
284
|
+
continue;
|
|
285
|
+
envLines.push(`${key}=${value}`);
|
|
286
|
+
}
|
|
287
|
+
const issueIdHint = payload.taskId ?? payload.issueId ?? "";
|
|
288
|
+
const apiBaseHint = paperclipEnv.PAPERCLIP_API_URL ?? "<set PAPERCLIP_API_URL>";
|
|
289
|
+
if (conversationTaskMarkdown !== undefined) {
|
|
290
|
+
return [
|
|
291
|
+
"Paperclip conversation turn for a cloud adapter.",
|
|
292
|
+
"Set these values in your run context:",
|
|
293
|
+
...envLines,
|
|
294
|
+
`Load PAPERCLIP_API_KEY from ${claimedApiKeyPath} (the token saved after claim-api-key).`,
|
|
295
|
+
"Use Authorization: Bearer $PAPERCLIP_API_KEY on every API call and X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID on every mutation.",
|
|
296
|
+
"Follow the supplied chat mode directive. Keep this conversation available for the next message.",
|
|
297
|
+
structuredWakePrompt,
|
|
298
|
+
conversationTaskMarkdown,
|
|
299
|
+
].join("\n\n");
|
|
300
|
+
}
|
|
301
|
+
const lines = [
|
|
302
|
+
"Paperclip wake event for a cloud adapter.",
|
|
303
|
+
"",
|
|
304
|
+
"Run this procedure now. Do not guess undocumented endpoints and do not ask for additional heartbeat docs.",
|
|
305
|
+
"",
|
|
306
|
+
"Set these values in your run context:",
|
|
307
|
+
...envLines,
|
|
308
|
+
`PAPERCLIP_API_KEY=<token from ${claimedApiKeyPath}>`,
|
|
309
|
+
"",
|
|
310
|
+
`Load PAPERCLIP_API_KEY from ${claimedApiKeyPath} (the token you saved after claim-api-key).`,
|
|
311
|
+
"",
|
|
312
|
+
`api_base=${apiBaseHint}`,
|
|
313
|
+
`task_id=${payload.taskId ?? ""}`,
|
|
314
|
+
`issue_id=${payload.issueId ?? ""}`,
|
|
315
|
+
`wake_reason=${payload.wakeReason ?? ""}`,
|
|
316
|
+
`wake_comment_id=${payload.wakeCommentId ?? ""}`,
|
|
317
|
+
`approval_id=${payload.approvalId ?? ""}`,
|
|
318
|
+
`approval_status=${payload.approvalStatus ?? ""}`,
|
|
319
|
+
`linked_issue_ids=${payload.issueIds.join(",")}`,
|
|
320
|
+
"",
|
|
321
|
+
"HTTP rules:",
|
|
322
|
+
"- Use Authorization: Bearer $PAPERCLIP_API_KEY on every API call.",
|
|
323
|
+
"- Use X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID on every mutating API call.",
|
|
324
|
+
"- Use only /api endpoints listed below.",
|
|
325
|
+
"- Do NOT call guessed endpoints like /api/cloud-adapter/*, /api/cloud-adapters/*, /api/adapters/cloud/*, or /api/heartbeat.",
|
|
326
|
+
"",
|
|
327
|
+
"Workflow:",
|
|
328
|
+
"1) GET /api/agents/me",
|
|
329
|
+
`2) Determine issueId: PAPERCLIP_TASK_ID if present, otherwise issue_id (${issueIdHint}).`,
|
|
330
|
+
' Replace {issueId} in every endpoint below with that determined id. Never send the literal text "{issueId}" in a URL.',
|
|
331
|
+
"3) If issueId exists:",
|
|
332
|
+
" - POST /api/issues/{issueId}/checkout with {\"agentId\":\"$PAPERCLIP_AGENT_ID\",\"expectedStatuses\":[\"todo\",\"backlog\",\"blocked\",\"in_review\"]}",
|
|
333
|
+
" - GET /api/issues/{issueId}",
|
|
334
|
+
" - GET /api/issues/{issueId}/comments",
|
|
335
|
+
" - Execute the issue instructions exactly. If the issue is actionable, take concrete action in this run; do not stop at a plan unless planning was requested.",
|
|
336
|
+
" - Leave durable progress with a clear next action. Use child issues for long or parallel delegated work instead of polling agents, sessions, or processes.",
|
|
337
|
+
" - Create child issues directly when you know what needs to be done; use POST /api/issues/{issueId}/interactions with kind suggest_tasks, ask_user_questions, or request_confirmation when the board/user must choose, answer, or confirm before you can continue.",
|
|
338
|
+
" - For plan approval, update the plan document first, then create request_confirmation targeting the latest plan revision with idempotencyKey confirmation:{issueId}:plan:{revisionId}; wait for acceptance before creating implementation subtasks.",
|
|
339
|
+
" - If blocked, PATCH /api/issues/{issueId} with {\"status\":\"blocked\",\"comment\":\"what is blocked, who owns the unblock, and the next action\"}.",
|
|
340
|
+
" - If instructions require a comment, POST /api/issues/{issueId}/comments with {\"body\":\"...\"}.",
|
|
341
|
+
" - PATCH /api/issues/{issueId} with {\"status\":\"done\",\"comment\":\"what changed and why\"}.",
|
|
342
|
+
"4) If issueId does not exist:",
|
|
343
|
+
" - GET /api/companies/$PAPERCLIP_COMPANY_ID/issues?assigneeAgentId=$PAPERCLIP_AGENT_ID&status=todo,in_progress,in_review,blocked",
|
|
344
|
+
" - Pick in_progress first, then in_review when you were woken by a comment, then todo, then blocked, then execute step 3.",
|
|
345
|
+
"",
|
|
346
|
+
"Useful endpoints for issue work:",
|
|
347
|
+
"- POST /api/issues/{issueId}/comments",
|
|
348
|
+
"- PATCH /api/issues/{issueId}",
|
|
349
|
+
"- POST /api/companies/{companyId}/issues (when asked to create a new issue)",
|
|
350
|
+
...(structuredWakePrompt
|
|
351
|
+
? [
|
|
352
|
+
"",
|
|
353
|
+
structuredWakePrompt,
|
|
354
|
+
]
|
|
355
|
+
: []),
|
|
356
|
+
"",
|
|
357
|
+
"Complete the workflow in this run.",
|
|
358
|
+
];
|
|
359
|
+
return lines.join("\n");
|
|
360
|
+
}
|
|
361
|
+
function appendWakeText(baseText, wakeText) {
|
|
362
|
+
const trimmedBase = baseText.trim();
|
|
363
|
+
return trimmedBase.length > 0 ? `${trimmedBase}\n\n${wakeText}` : wakeText;
|
|
364
|
+
}
|
|
365
|
+
function joinWakePayloadSections(structuredWakePrompt, structuredWakeJson) {
|
|
366
|
+
const sections = [
|
|
367
|
+
structuredWakePrompt.trim(),
|
|
368
|
+
"Structured wake payload JSON:",
|
|
369
|
+
"```json",
|
|
370
|
+
structuredWakeJson,
|
|
371
|
+
"```",
|
|
372
|
+
].filter((entry) => entry.trim().length > 0);
|
|
373
|
+
return sections.join("\n");
|
|
374
|
+
}
|
|
375
|
+
export function buildAgentParams(input) {
|
|
376
|
+
const agentParams = {
|
|
377
|
+
...input.payloadTemplate,
|
|
378
|
+
message: input.message,
|
|
379
|
+
sessionKey: input.sessionKey,
|
|
380
|
+
idempotencyKey: input.runId,
|
|
381
|
+
};
|
|
382
|
+
delete agentParams.text;
|
|
383
|
+
delete agentParams.paperclip;
|
|
384
|
+
if (input.configuredAgentId && !nonEmpty(agentParams.agentId)) {
|
|
385
|
+
agentParams.agentId = input.configuredAgentId;
|
|
386
|
+
}
|
|
387
|
+
if (typeof agentParams.timeout !== "number") {
|
|
388
|
+
agentParams.timeout = input.waitTimeoutMs;
|
|
389
|
+
}
|
|
390
|
+
return agentParams;
|
|
391
|
+
}
|
|
392
|
+
function normalizeUrl(input) {
|
|
393
|
+
try {
|
|
394
|
+
return new URL(input);
|
|
395
|
+
}
|
|
396
|
+
catch {
|
|
397
|
+
return null;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
function rawDataToString(data) {
|
|
401
|
+
if (typeof data === "string")
|
|
402
|
+
return data;
|
|
403
|
+
if (Buffer.isBuffer(data))
|
|
404
|
+
return data.toString("utf8");
|
|
405
|
+
if (data instanceof ArrayBuffer)
|
|
406
|
+
return Buffer.from(data).toString("utf8");
|
|
407
|
+
if (Array.isArray(data)) {
|
|
408
|
+
return Buffer.concat(data.map((entry) => (Buffer.isBuffer(entry) ? entry : Buffer.from(String(entry), "utf8")))).toString("utf8");
|
|
409
|
+
}
|
|
410
|
+
return String(data ?? "");
|
|
411
|
+
}
|
|
412
|
+
function withTimeout(promise, timeoutMs, message) {
|
|
413
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0)
|
|
414
|
+
return promise;
|
|
415
|
+
return new Promise((resolve, reject) => {
|
|
416
|
+
const timer = setTimeout(() => reject(new Error(message)), timeoutMs);
|
|
417
|
+
promise
|
|
418
|
+
.then((value) => {
|
|
419
|
+
clearTimeout(timer);
|
|
420
|
+
resolve(value);
|
|
421
|
+
})
|
|
422
|
+
.catch((err) => {
|
|
423
|
+
clearTimeout(timer);
|
|
424
|
+
reject(err);
|
|
425
|
+
});
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
function derivePublicKeyRaw(publicKeyPem) {
|
|
429
|
+
const key = crypto.createPublicKey(publicKeyPem);
|
|
430
|
+
const spki = key.export({ type: "spki", format: "der" });
|
|
431
|
+
if (spki.length === ED25519_SPKI_PREFIX.length + 32 &&
|
|
432
|
+
spki.subarray(0, ED25519_SPKI_PREFIX.length).equals(ED25519_SPKI_PREFIX)) {
|
|
433
|
+
return spki.subarray(ED25519_SPKI_PREFIX.length);
|
|
434
|
+
}
|
|
435
|
+
return spki;
|
|
436
|
+
}
|
|
437
|
+
function base64UrlEncode(buf) {
|
|
438
|
+
return buf.toString("base64").replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/g, "");
|
|
439
|
+
}
|
|
440
|
+
function signDevicePayload(privateKeyPem, payload) {
|
|
441
|
+
const key = crypto.createPrivateKey(privateKeyPem);
|
|
442
|
+
const sig = crypto.sign(null, Buffer.from(payload, "utf8"), key);
|
|
443
|
+
return base64UrlEncode(sig);
|
|
444
|
+
}
|
|
445
|
+
function buildDeviceAuthPayloadV3(params) {
|
|
446
|
+
const scopes = params.scopes.join(",");
|
|
447
|
+
const token = params.token ?? "";
|
|
448
|
+
const platform = params.platform?.trim() ?? "";
|
|
449
|
+
const deviceFamily = params.deviceFamily?.trim() ?? "";
|
|
450
|
+
return [
|
|
451
|
+
"v3",
|
|
452
|
+
params.deviceId,
|
|
453
|
+
params.clientId,
|
|
454
|
+
params.clientMode,
|
|
455
|
+
params.role,
|
|
456
|
+
scopes,
|
|
457
|
+
String(params.signedAtMs),
|
|
458
|
+
token,
|
|
459
|
+
params.nonce,
|
|
460
|
+
platform,
|
|
461
|
+
deviceFamily,
|
|
462
|
+
].join("|");
|
|
463
|
+
}
|
|
464
|
+
function resolveDeviceIdentity(config) {
|
|
465
|
+
const configuredPrivateKey = nonEmpty(config.devicePrivateKeyPem);
|
|
466
|
+
if (configuredPrivateKey) {
|
|
467
|
+
const privateKey = crypto.createPrivateKey(configuredPrivateKey);
|
|
468
|
+
const publicKey = crypto.createPublicKey(privateKey);
|
|
469
|
+
const publicKeyPem = publicKey.export({ type: "spki", format: "pem" }).toString();
|
|
470
|
+
const raw = derivePublicKeyRaw(publicKeyPem);
|
|
471
|
+
return {
|
|
472
|
+
deviceId: crypto.createHash("sha256").update(raw).digest("hex"),
|
|
473
|
+
publicKeyRawBase64Url: base64UrlEncode(raw),
|
|
474
|
+
privateKeyPem: configuredPrivateKey,
|
|
475
|
+
source: "configured",
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
const generated = crypto.generateKeyPairSync("ed25519");
|
|
479
|
+
const publicKeyPem = generated.publicKey.export({ type: "spki", format: "pem" }).toString();
|
|
480
|
+
const privateKeyPem = generated.privateKey.export({ type: "pkcs8", format: "pem" }).toString();
|
|
481
|
+
const raw = derivePublicKeyRaw(publicKeyPem);
|
|
482
|
+
return {
|
|
483
|
+
deviceId: crypto.createHash("sha256").update(raw).digest("hex"),
|
|
484
|
+
publicKeyRawBase64Url: base64UrlEncode(raw),
|
|
485
|
+
privateKeyPem,
|
|
486
|
+
source: "ephemeral",
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
function isResponseFrame(value) {
|
|
490
|
+
const record = asRecord(value);
|
|
491
|
+
return Boolean(record && record.type === "res" && typeof record.id === "string" && typeof record.ok === "boolean");
|
|
492
|
+
}
|
|
493
|
+
function isEventFrame(value) {
|
|
494
|
+
const record = asRecord(value);
|
|
495
|
+
return Boolean(record && record.type === "event" && typeof record.event === "string");
|
|
496
|
+
}
|
|
497
|
+
class GatewayWsClient {
|
|
498
|
+
opts;
|
|
499
|
+
ws = null;
|
|
500
|
+
pending = new Map();
|
|
501
|
+
challengePromise;
|
|
502
|
+
resolveChallenge;
|
|
503
|
+
rejectChallenge;
|
|
504
|
+
constructor(opts) {
|
|
505
|
+
this.opts = opts;
|
|
506
|
+
this.challengePromise = new Promise((resolve, reject) => {
|
|
507
|
+
this.resolveChallenge = resolve;
|
|
508
|
+
this.rejectChallenge = reject;
|
|
509
|
+
});
|
|
510
|
+
this.challengePromise.catch(() => { });
|
|
511
|
+
}
|
|
512
|
+
async connect(buildConnectParams, timeoutMs) {
|
|
513
|
+
this.ws = new WebSocket(this.opts.url, {
|
|
514
|
+
headers: this.opts.headers,
|
|
515
|
+
maxPayload: 25 * 1024 * 1024,
|
|
516
|
+
});
|
|
517
|
+
const ws = this.ws;
|
|
518
|
+
ws.on("message", (data) => {
|
|
519
|
+
this.handleMessage(rawDataToString(data));
|
|
520
|
+
});
|
|
521
|
+
ws.on("close", (code, reason) => {
|
|
522
|
+
const reasonText = rawDataToString(reason);
|
|
523
|
+
const err = new Error(`gateway closed (${code}): ${reasonText}`);
|
|
524
|
+
this.failPending(err);
|
|
525
|
+
this.rejectChallenge(err);
|
|
526
|
+
});
|
|
527
|
+
ws.on("error", (err) => {
|
|
528
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
529
|
+
void this.opts.onLog("stderr", `[openclaw-gateway] websocket error: ${message}\n`);
|
|
530
|
+
});
|
|
531
|
+
await withTimeout(new Promise((resolve, reject) => {
|
|
532
|
+
const onOpen = () => {
|
|
533
|
+
cleanup();
|
|
534
|
+
resolve();
|
|
535
|
+
};
|
|
536
|
+
const onError = (err) => {
|
|
537
|
+
cleanup();
|
|
538
|
+
reject(err);
|
|
539
|
+
};
|
|
540
|
+
const onClose = (code, reason) => {
|
|
541
|
+
cleanup();
|
|
542
|
+
reject(new Error(`gateway closed before open (${code}): ${rawDataToString(reason)}`));
|
|
543
|
+
};
|
|
544
|
+
const cleanup = () => {
|
|
545
|
+
ws.off("open", onOpen);
|
|
546
|
+
ws.off("error", onError);
|
|
547
|
+
ws.off("close", onClose);
|
|
548
|
+
};
|
|
549
|
+
ws.once("open", onOpen);
|
|
550
|
+
ws.once("error", onError);
|
|
551
|
+
ws.once("close", onClose);
|
|
552
|
+
}), timeoutMs, "gateway websocket open timeout");
|
|
553
|
+
const nonce = await withTimeout(this.challengePromise, timeoutMs, "gateway connect challenge timeout");
|
|
554
|
+
const signedConnectParams = buildConnectParams(nonce);
|
|
555
|
+
const hello = await this.request("connect", signedConnectParams, {
|
|
556
|
+
timeoutMs,
|
|
557
|
+
});
|
|
558
|
+
return hello;
|
|
559
|
+
}
|
|
560
|
+
async request(method, params, opts) {
|
|
561
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
562
|
+
throw new Error("gateway not connected");
|
|
563
|
+
}
|
|
564
|
+
const id = randomUUID();
|
|
565
|
+
const frame = {
|
|
566
|
+
type: "req",
|
|
567
|
+
id,
|
|
568
|
+
method,
|
|
569
|
+
params,
|
|
570
|
+
};
|
|
571
|
+
const payload = JSON.stringify(frame);
|
|
572
|
+
const requestPromise = new Promise((resolve, reject) => {
|
|
573
|
+
const timer = opts.timeoutMs > 0
|
|
574
|
+
? setTimeout(() => {
|
|
575
|
+
this.pending.delete(id);
|
|
576
|
+
reject(new Error(`gateway request timeout (${method})`));
|
|
577
|
+
}, opts.timeoutMs)
|
|
578
|
+
: null;
|
|
579
|
+
this.pending.set(id, {
|
|
580
|
+
resolve: (value) => resolve(value),
|
|
581
|
+
reject,
|
|
582
|
+
expectFinal: opts.expectFinal === true,
|
|
583
|
+
timer,
|
|
584
|
+
});
|
|
585
|
+
});
|
|
586
|
+
this.ws.send(payload);
|
|
587
|
+
return requestPromise;
|
|
588
|
+
}
|
|
589
|
+
close() {
|
|
590
|
+
if (!this.ws)
|
|
591
|
+
return;
|
|
592
|
+
this.ws.close(1000, "paperclip-complete");
|
|
593
|
+
this.ws = null;
|
|
594
|
+
}
|
|
595
|
+
failPending(err) {
|
|
596
|
+
for (const [, pending] of this.pending) {
|
|
597
|
+
if (pending.timer)
|
|
598
|
+
clearTimeout(pending.timer);
|
|
599
|
+
pending.reject(err);
|
|
600
|
+
}
|
|
601
|
+
this.pending.clear();
|
|
602
|
+
}
|
|
603
|
+
handleMessage(raw) {
|
|
604
|
+
let parsed;
|
|
605
|
+
try {
|
|
606
|
+
parsed = JSON.parse(raw);
|
|
607
|
+
}
|
|
608
|
+
catch {
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
if (isEventFrame(parsed)) {
|
|
612
|
+
if (parsed.event === "connect.challenge") {
|
|
613
|
+
const payload = asRecord(parsed.payload);
|
|
614
|
+
const nonce = nonEmpty(payload?.nonce);
|
|
615
|
+
if (nonce) {
|
|
616
|
+
this.resolveChallenge(nonce);
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
void Promise.resolve(this.opts.onEvent(parsed)).catch(() => {
|
|
621
|
+
// Ignore event callback failures and keep stream active.
|
|
622
|
+
});
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
if (!isResponseFrame(parsed))
|
|
626
|
+
return;
|
|
627
|
+
const pending = this.pending.get(parsed.id);
|
|
628
|
+
if (!pending)
|
|
629
|
+
return;
|
|
630
|
+
const payload = asRecord(parsed.payload);
|
|
631
|
+
const status = nonEmpty(payload?.status)?.toLowerCase();
|
|
632
|
+
if (pending.expectFinal && status === "accepted") {
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
if (pending.timer)
|
|
636
|
+
clearTimeout(pending.timer);
|
|
637
|
+
this.pending.delete(parsed.id);
|
|
638
|
+
if (parsed.ok) {
|
|
639
|
+
pending.resolve(parsed.payload ?? null);
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
const errorRecord = asRecord(parsed.error);
|
|
643
|
+
const message = nonEmpty(errorRecord?.message) ??
|
|
644
|
+
nonEmpty(errorRecord?.code) ??
|
|
645
|
+
"gateway request failed";
|
|
646
|
+
const err = new Error(message);
|
|
647
|
+
const code = nonEmpty(errorRecord?.code);
|
|
648
|
+
const details = asRecord(errorRecord?.details);
|
|
649
|
+
if (code)
|
|
650
|
+
err.gatewayCode = code;
|
|
651
|
+
if (details)
|
|
652
|
+
err.gatewayDetails = details;
|
|
653
|
+
pending.reject(err);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
async function autoApproveDevicePairing(params) {
|
|
657
|
+
if (!params.authToken && !params.password) {
|
|
658
|
+
return { ok: false, reason: "shared auth token/password is missing" };
|
|
659
|
+
}
|
|
660
|
+
const approvalScopes = uniqueScopes([...params.scopes, "operator.pairing"]);
|
|
661
|
+
const client = new GatewayWsClient({
|
|
662
|
+
url: params.url,
|
|
663
|
+
headers: params.headers,
|
|
664
|
+
onEvent: () => { },
|
|
665
|
+
onLog: params.onLog,
|
|
666
|
+
});
|
|
667
|
+
try {
|
|
668
|
+
await params.onLog("stdout", "[openclaw-gateway] pairing required; attempting automatic pairing approval via gateway methods\n");
|
|
669
|
+
await client.connect(() => ({
|
|
670
|
+
minProtocol: PROTOCOL_VERSION,
|
|
671
|
+
maxProtocol: PROTOCOL_VERSION,
|
|
672
|
+
client: {
|
|
673
|
+
id: params.clientId,
|
|
674
|
+
version: params.clientVersion,
|
|
675
|
+
platform: process.platform,
|
|
676
|
+
mode: params.clientMode,
|
|
677
|
+
},
|
|
678
|
+
role: params.role,
|
|
679
|
+
scopes: approvalScopes,
|
|
680
|
+
auth: {
|
|
681
|
+
...(params.authToken ? { token: params.authToken } : {}),
|
|
682
|
+
...(params.password ? { password: params.password } : {}),
|
|
683
|
+
},
|
|
684
|
+
}), params.connectTimeoutMs);
|
|
685
|
+
let requestId = params.requestId;
|
|
686
|
+
if (!requestId) {
|
|
687
|
+
const listPayload = await client.request("device.pair.list", {}, {
|
|
688
|
+
timeoutMs: params.connectTimeoutMs,
|
|
689
|
+
});
|
|
690
|
+
const pending = Array.isArray(listPayload.pending) ? listPayload.pending : [];
|
|
691
|
+
const pendingRecords = pending
|
|
692
|
+
.map((entry) => asRecord(entry))
|
|
693
|
+
.filter((entry) => Boolean(entry));
|
|
694
|
+
const matching = (params.deviceId
|
|
695
|
+
? pendingRecords.find((entry) => nonEmpty(entry.deviceId) === params.deviceId)
|
|
696
|
+
: null) ?? pendingRecords[pendingRecords.length - 1];
|
|
697
|
+
requestId = nonEmpty(matching?.requestId);
|
|
698
|
+
}
|
|
699
|
+
if (!requestId) {
|
|
700
|
+
return { ok: false, reason: "no pending device pairing request found" };
|
|
701
|
+
}
|
|
702
|
+
await client.request("device.pair.approve", { requestId }, {
|
|
703
|
+
timeoutMs: params.connectTimeoutMs,
|
|
704
|
+
});
|
|
705
|
+
return { ok: true, requestId };
|
|
706
|
+
}
|
|
707
|
+
catch (err) {
|
|
708
|
+
return { ok: false, reason: err instanceof Error ? err.message : String(err) };
|
|
709
|
+
}
|
|
710
|
+
finally {
|
|
711
|
+
client.close();
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
function parseUsage(value) {
|
|
715
|
+
const record = asRecord(value);
|
|
716
|
+
if (!record)
|
|
717
|
+
return undefined;
|
|
718
|
+
const inputTokens = asNumber(record.inputTokens ?? record.input, 0);
|
|
719
|
+
const outputTokens = asNumber(record.outputTokens ?? record.output, 0);
|
|
720
|
+
const cachedInputTokens = asNumber(record.cachedInputTokens ?? record.cached_input_tokens ?? record.cacheRead ?? record.cache_read, 0);
|
|
721
|
+
if (inputTokens <= 0 && outputTokens <= 0 && cachedInputTokens <= 0) {
|
|
722
|
+
return undefined;
|
|
723
|
+
}
|
|
724
|
+
return {
|
|
725
|
+
inputTokens,
|
|
726
|
+
outputTokens,
|
|
727
|
+
...(cachedInputTokens > 0 ? { cachedInputTokens } : {}),
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
function extractRuntimeServicesFromMeta(meta) {
|
|
731
|
+
if (!meta)
|
|
732
|
+
return [];
|
|
733
|
+
const reports = [];
|
|
734
|
+
const runtimeServices = Array.isArray(meta.runtimeServices)
|
|
735
|
+
? meta.runtimeServices.filter((entry) => Boolean(asRecord(entry)))
|
|
736
|
+
: [];
|
|
737
|
+
for (const entry of runtimeServices) {
|
|
738
|
+
const serviceName = nonEmpty(entry.serviceName) ?? nonEmpty(entry.name);
|
|
739
|
+
if (!serviceName)
|
|
740
|
+
continue;
|
|
741
|
+
const rawStatus = nonEmpty(entry.status)?.toLowerCase();
|
|
742
|
+
const status = rawStatus === "starting" || rawStatus === "running" || rawStatus === "stopped" || rawStatus === "failed"
|
|
743
|
+
? rawStatus
|
|
744
|
+
: "running";
|
|
745
|
+
const rawLifecycle = nonEmpty(entry.lifecycle)?.toLowerCase();
|
|
746
|
+
const lifecycle = rawLifecycle === "shared" ? "shared" : "ephemeral";
|
|
747
|
+
const rawScopeType = nonEmpty(entry.scopeType)?.toLowerCase();
|
|
748
|
+
const scopeType = rawScopeType === "project_workspace" ||
|
|
749
|
+
rawScopeType === "execution_workspace" ||
|
|
750
|
+
rawScopeType === "agent"
|
|
751
|
+
? rawScopeType
|
|
752
|
+
: "run";
|
|
753
|
+
const rawHealth = nonEmpty(entry.healthStatus)?.toLowerCase();
|
|
754
|
+
const healthStatus = rawHealth === "healthy" || rawHealth === "unhealthy" || rawHealth === "unknown"
|
|
755
|
+
? rawHealth
|
|
756
|
+
: status === "running"
|
|
757
|
+
? "healthy"
|
|
758
|
+
: "unknown";
|
|
759
|
+
reports.push({
|
|
760
|
+
id: nonEmpty(entry.id),
|
|
761
|
+
projectId: nonEmpty(entry.projectId),
|
|
762
|
+
projectWorkspaceId: nonEmpty(entry.projectWorkspaceId),
|
|
763
|
+
issueId: nonEmpty(entry.issueId),
|
|
764
|
+
scopeType,
|
|
765
|
+
scopeId: nonEmpty(entry.scopeId),
|
|
766
|
+
serviceName,
|
|
767
|
+
status,
|
|
768
|
+
lifecycle,
|
|
769
|
+
reuseKey: nonEmpty(entry.reuseKey),
|
|
770
|
+
command: nonEmpty(entry.command),
|
|
771
|
+
cwd: nonEmpty(entry.cwd),
|
|
772
|
+
port: parseOptionalPositiveInteger(entry.port),
|
|
773
|
+
url: nonEmpty(entry.url),
|
|
774
|
+
providerRef: nonEmpty(entry.providerRef) ?? nonEmpty(entry.previewId),
|
|
775
|
+
ownerAgentId: nonEmpty(entry.ownerAgentId),
|
|
776
|
+
stopPolicy: asRecord(entry.stopPolicy),
|
|
777
|
+
healthStatus,
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
const previewUrl = nonEmpty(meta.previewUrl);
|
|
781
|
+
if (previewUrl) {
|
|
782
|
+
reports.push({
|
|
783
|
+
serviceName: "preview",
|
|
784
|
+
status: "running",
|
|
785
|
+
lifecycle: "ephemeral",
|
|
786
|
+
scopeType: "run",
|
|
787
|
+
url: previewUrl,
|
|
788
|
+
providerRef: nonEmpty(meta.previewId) ?? previewUrl,
|
|
789
|
+
healthStatus: "healthy",
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
const previewUrls = Array.isArray(meta.previewUrls)
|
|
793
|
+
? meta.previewUrls.filter((entry) => typeof entry === "string" && entry.trim().length > 0)
|
|
794
|
+
: [];
|
|
795
|
+
previewUrls.forEach((url, index) => {
|
|
796
|
+
reports.push({
|
|
797
|
+
serviceName: index === 0 ? "preview" : `preview-${index + 1}`,
|
|
798
|
+
status: "running",
|
|
799
|
+
lifecycle: "ephemeral",
|
|
800
|
+
scopeType: "run",
|
|
801
|
+
url,
|
|
802
|
+
providerRef: `${url}#${index}`,
|
|
803
|
+
healthStatus: "healthy",
|
|
804
|
+
});
|
|
805
|
+
});
|
|
806
|
+
return reports;
|
|
807
|
+
}
|
|
808
|
+
function extractResultText(value) {
|
|
809
|
+
const record = asRecord(value);
|
|
810
|
+
if (!record)
|
|
811
|
+
return null;
|
|
812
|
+
const payloads = Array.isArray(record.payloads) ? record.payloads : [];
|
|
813
|
+
const texts = payloads
|
|
814
|
+
.map((entry) => {
|
|
815
|
+
const payload = asRecord(entry);
|
|
816
|
+
return nonEmpty(payload?.text);
|
|
817
|
+
})
|
|
818
|
+
.filter((entry) => Boolean(entry));
|
|
819
|
+
if (texts.length > 0)
|
|
820
|
+
return texts.join("\n\n");
|
|
821
|
+
return nonEmpty(record.text) ?? nonEmpty(record.summary) ?? null;
|
|
822
|
+
}
|
|
823
|
+
export async function execute(ctx) {
|
|
824
|
+
const urlValue = asString(ctx.config.url, "").trim();
|
|
825
|
+
if (!urlValue) {
|
|
826
|
+
return {
|
|
827
|
+
exitCode: 1,
|
|
828
|
+
signal: null,
|
|
829
|
+
timedOut: false,
|
|
830
|
+
errorMessage: "OpenClaw gateway adapter missing url",
|
|
831
|
+
errorCode: "openclaw_gateway_url_missing",
|
|
832
|
+
};
|
|
833
|
+
}
|
|
834
|
+
const parsedUrl = normalizeUrl(urlValue);
|
|
835
|
+
if (!parsedUrl) {
|
|
836
|
+
return {
|
|
837
|
+
exitCode: 1,
|
|
838
|
+
signal: null,
|
|
839
|
+
timedOut: false,
|
|
840
|
+
errorMessage: `Invalid gateway URL: ${urlValue}`,
|
|
841
|
+
errorCode: "openclaw_gateway_url_invalid",
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
if (parsedUrl.protocol !== "ws:" && parsedUrl.protocol !== "wss:") {
|
|
845
|
+
return {
|
|
846
|
+
exitCode: 1,
|
|
847
|
+
signal: null,
|
|
848
|
+
timedOut: false,
|
|
849
|
+
errorMessage: `Unsupported gateway URL protocol: ${parsedUrl.protocol}`,
|
|
850
|
+
errorCode: "openclaw_gateway_url_protocol",
|
|
851
|
+
};
|
|
852
|
+
}
|
|
853
|
+
const timeoutSec = Math.max(0, Math.floor(asNumber(ctx.config.timeoutSec, 120)));
|
|
854
|
+
const timeoutMs = timeoutSec > 0 ? timeoutSec * 1000 : 0;
|
|
855
|
+
const connectTimeoutMs = timeoutMs > 0 ? Math.min(timeoutMs, 15_000) : 10_000;
|
|
856
|
+
const waitTimeoutMs = parseOptionalPositiveInteger(ctx.config.waitTimeoutMs) ?? (timeoutMs > 0 ? timeoutMs : 30_000);
|
|
857
|
+
const payloadTemplate = parseObject(ctx.config.payloadTemplate);
|
|
858
|
+
const transportHint = nonEmpty(ctx.config.streamTransport) ?? nonEmpty(ctx.config.transport);
|
|
859
|
+
const headers = toStringRecord(ctx.config.headers);
|
|
860
|
+
const authToken = resolveAuthToken(parseObject(ctx.config), headers);
|
|
861
|
+
const password = nonEmpty(ctx.config.password);
|
|
862
|
+
const deviceToken = nonEmpty(ctx.config.deviceToken);
|
|
863
|
+
if (authToken && !headerMapHasIgnoreCase(headers, "authorization")) {
|
|
864
|
+
headers.authorization = toAuthorizationHeaderValue(authToken);
|
|
865
|
+
}
|
|
866
|
+
const clientId = nonEmpty(ctx.config.clientId) ?? DEFAULT_CLIENT_ID;
|
|
867
|
+
const clientMode = nonEmpty(ctx.config.clientMode) ?? DEFAULT_CLIENT_MODE;
|
|
868
|
+
const clientVersion = nonEmpty(ctx.config.clientVersion) ?? DEFAULT_CLIENT_VERSION;
|
|
869
|
+
const role = nonEmpty(ctx.config.role) ?? DEFAULT_ROLE;
|
|
870
|
+
const scopes = normalizeScopes(ctx.config.scopes);
|
|
871
|
+
const deviceFamily = nonEmpty(ctx.config.deviceFamily);
|
|
872
|
+
const disableDeviceAuth = parseBoolean(ctx.config.disableDeviceAuth, false);
|
|
873
|
+
const wakePayload = buildWakePayload(ctx);
|
|
874
|
+
const paperclipEnv = buildPaperclipEnvForWake(ctx, wakePayload);
|
|
875
|
+
// No heartbeat prompt template is sent over the gateway, so the wake prompt
|
|
876
|
+
// must carry the execution contract itself.
|
|
877
|
+
const structuredWakePrompt = renderPaperclipWakePrompt(ctx.context.paperclipWake, {
|
|
878
|
+
includeExecutionContract: true,
|
|
879
|
+
conversationMode: ctx.context.conversationMode === true,
|
|
880
|
+
});
|
|
881
|
+
const structuredWakeJson = stringifyPaperclipWakePayload(ctx.context.paperclipWake);
|
|
882
|
+
const wakeText = buildWakeText(wakePayload, paperclipEnv, structuredWakeJson
|
|
883
|
+
? joinWakePayloadSections(structuredWakePrompt, structuredWakeJson)
|
|
884
|
+
: structuredWakePrompt, resolveClaimedApiKeyPath(ctx.config.claimedApiKeyPath), ctx.context.conversationMode === true
|
|
885
|
+
? selectPaperclipTaskMarkdown(ctx.context, { resumedSession: Boolean(ctx.runtime?.sessionId), includeCommunicationGuidance: false })
|
|
886
|
+
: undefined);
|
|
887
|
+
const sessionKeyStrategy = normalizeSessionKeyStrategy(ctx.config.sessionKeyStrategy);
|
|
888
|
+
const configuredSessionKey = nonEmpty(ctx.config.sessionKey);
|
|
889
|
+
const configuredAgentId = nonEmpty(ctx.config.agentId);
|
|
890
|
+
const sessionKey = resolveSessionKey({
|
|
891
|
+
strategy: sessionKeyStrategy,
|
|
892
|
+
configuredSessionKey,
|
|
893
|
+
agentId: configuredAgentId,
|
|
894
|
+
runId: ctx.runId,
|
|
895
|
+
issueId: wakePayload.issueId,
|
|
896
|
+
});
|
|
897
|
+
const templateMessage = nonEmpty(payloadTemplate.message) ?? nonEmpty(payloadTemplate.text);
|
|
898
|
+
const message = joinPromptSections([
|
|
899
|
+
selectInitialCommunicationGuidance(ctx.context, { resumedSession: Boolean(ctx.runtime?.sessionId) }),
|
|
900
|
+
templateMessage ? appendWakeText(templateMessage, wakeText) : wakeText,
|
|
901
|
+
]);
|
|
902
|
+
const agentParams = buildAgentParams({
|
|
903
|
+
payloadTemplate,
|
|
904
|
+
message,
|
|
905
|
+
sessionKey,
|
|
906
|
+
runId: ctx.runId,
|
|
907
|
+
configuredAgentId,
|
|
908
|
+
waitTimeoutMs,
|
|
909
|
+
});
|
|
910
|
+
if (ctx.onMeta) {
|
|
911
|
+
await ctx.onMeta({
|
|
912
|
+
adapterType: "openclaw_gateway",
|
|
913
|
+
command: "gateway",
|
|
914
|
+
commandArgs: ["ws", parsedUrl.toString(), "agent"],
|
|
915
|
+
context: ctx.context,
|
|
916
|
+
});
|
|
917
|
+
}
|
|
918
|
+
const outboundHeaderKeys = Object.keys(headers).sort();
|
|
919
|
+
await ctx.onLog("stdout", `[openclaw-gateway] outbound headers (redacted): ${stringifyForLog(redactForLog(headers), 4_000)}\n`);
|
|
920
|
+
await ctx.onLog("stdout", `[openclaw-gateway] outbound payload (redacted): ${stringifyForLog(redactForLog(agentParams), 12_000)}\n`);
|
|
921
|
+
await ctx.onLog("stdout", `[openclaw-gateway] outbound header keys: ${outboundHeaderKeys.join(", ")}\n`);
|
|
922
|
+
if (transportHint) {
|
|
923
|
+
await ctx.onLog("stdout", `[openclaw-gateway] ignoring streamTransport=${transportHint}; gateway adapter always uses websocket protocol\n`);
|
|
924
|
+
}
|
|
925
|
+
if (parsedUrl.protocol === "ws:" && !isLoopbackHost(parsedUrl.hostname)) {
|
|
926
|
+
await ctx.onLog("stdout", "[openclaw-gateway] warning: using plaintext ws:// to a non-loopback host; prefer wss:// for remote endpoints\n");
|
|
927
|
+
}
|
|
928
|
+
const autoPairOnFirstConnect = parseBoolean(ctx.config.autoPairOnFirstConnect, true);
|
|
929
|
+
let autoPairAttempted = false;
|
|
930
|
+
let latestResultPayload = null;
|
|
931
|
+
let retryCount = 0;
|
|
932
|
+
let dispatchReported = false;
|
|
933
|
+
const MAX_RETRIES = 2;
|
|
934
|
+
const reportDispatch = () => {
|
|
935
|
+
if (dispatchReported)
|
|
936
|
+
return;
|
|
937
|
+
dispatchReported = true;
|
|
938
|
+
ctx.onDispatch?.();
|
|
939
|
+
};
|
|
940
|
+
while (true) {
|
|
941
|
+
const trackedRunIds = new Set([ctx.runId]);
|
|
942
|
+
const assistantChunks = [];
|
|
943
|
+
let lifecycleError = null;
|
|
944
|
+
let deviceIdentity = null;
|
|
945
|
+
const onEvent = async (frame) => {
|
|
946
|
+
if (frame.event !== "agent") {
|
|
947
|
+
if (frame.event === "shutdown") {
|
|
948
|
+
await ctx.onLog("stdout", `[openclaw-gateway] gateway shutdown notice: ${stringifyForLog(frame.payload ?? {}, 2_000)}\n`);
|
|
949
|
+
}
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
const payload = asRecord(frame.payload);
|
|
953
|
+
if (!payload)
|
|
954
|
+
return;
|
|
955
|
+
const runId = nonEmpty(payload.runId);
|
|
956
|
+
if (!runId || !trackedRunIds.has(runId))
|
|
957
|
+
return;
|
|
958
|
+
const stream = nonEmpty(payload.stream) ?? "unknown";
|
|
959
|
+
const data = asRecord(payload.data) ?? {};
|
|
960
|
+
await ctx.onLog("stdout", `[openclaw-gateway:event] run=${runId} stream=${stream} data=${stringifyForLog(data, 8_000)}\n`);
|
|
961
|
+
if (stream === "assistant") {
|
|
962
|
+
const delta = nonEmpty(data.delta);
|
|
963
|
+
const text = nonEmpty(data.text);
|
|
964
|
+
if (delta) {
|
|
965
|
+
assistantChunks.push(delta);
|
|
966
|
+
}
|
|
967
|
+
else if (text) {
|
|
968
|
+
assistantChunks.push(text);
|
|
969
|
+
}
|
|
970
|
+
return;
|
|
971
|
+
}
|
|
972
|
+
if (stream === "error") {
|
|
973
|
+
lifecycleError = nonEmpty(data.error) ?? nonEmpty(data.message) ?? lifecycleError;
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
if (stream === "lifecycle") {
|
|
977
|
+
const phase = nonEmpty(data.phase)?.toLowerCase();
|
|
978
|
+
if (phase === "error" || phase === "failed" || phase === "cancelled") {
|
|
979
|
+
lifecycleError = nonEmpty(data.error) ?? nonEmpty(data.message) ?? lifecycleError;
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
};
|
|
983
|
+
const client = new GatewayWsClient({
|
|
984
|
+
url: parsedUrl.toString(),
|
|
985
|
+
headers,
|
|
986
|
+
onEvent,
|
|
987
|
+
onLog: ctx.onLog,
|
|
988
|
+
});
|
|
989
|
+
try {
|
|
990
|
+
deviceIdentity = disableDeviceAuth ? null : resolveDeviceIdentity(parseObject(ctx.config));
|
|
991
|
+
if (deviceIdentity) {
|
|
992
|
+
await ctx.onLog("stdout", `[openclaw-gateway] device auth enabled keySource=${deviceIdentity.source} deviceId=${deviceIdentity.deviceId}\n`);
|
|
993
|
+
}
|
|
994
|
+
else {
|
|
995
|
+
await ctx.onLog("stdout", "[openclaw-gateway] device auth disabled\n");
|
|
996
|
+
}
|
|
997
|
+
await ctx.onLog("stdout", `[openclaw-gateway] connecting to ${parsedUrl.toString()}\n`);
|
|
998
|
+
const hello = await client.connect((nonce) => {
|
|
999
|
+
const signedAtMs = Date.now();
|
|
1000
|
+
const connectParams = {
|
|
1001
|
+
minProtocol: PROTOCOL_VERSION,
|
|
1002
|
+
maxProtocol: PROTOCOL_VERSION,
|
|
1003
|
+
client: {
|
|
1004
|
+
id: clientId,
|
|
1005
|
+
version: clientVersion,
|
|
1006
|
+
platform: process.platform,
|
|
1007
|
+
...(deviceFamily ? { deviceFamily } : {}),
|
|
1008
|
+
mode: clientMode,
|
|
1009
|
+
},
|
|
1010
|
+
role,
|
|
1011
|
+
scopes,
|
|
1012
|
+
auth: authToken || password || deviceToken
|
|
1013
|
+
? {
|
|
1014
|
+
...(authToken ? { token: authToken } : {}),
|
|
1015
|
+
...(deviceToken ? { deviceToken } : {}),
|
|
1016
|
+
...(password ? { password } : {}),
|
|
1017
|
+
}
|
|
1018
|
+
: undefined,
|
|
1019
|
+
};
|
|
1020
|
+
if (deviceIdentity) {
|
|
1021
|
+
const payload = buildDeviceAuthPayloadV3({
|
|
1022
|
+
deviceId: deviceIdentity.deviceId,
|
|
1023
|
+
clientId,
|
|
1024
|
+
clientMode,
|
|
1025
|
+
role,
|
|
1026
|
+
scopes,
|
|
1027
|
+
signedAtMs,
|
|
1028
|
+
token: authToken,
|
|
1029
|
+
nonce,
|
|
1030
|
+
platform: process.platform,
|
|
1031
|
+
deviceFamily,
|
|
1032
|
+
});
|
|
1033
|
+
connectParams.device = {
|
|
1034
|
+
id: deviceIdentity.deviceId,
|
|
1035
|
+
publicKey: deviceIdentity.publicKeyRawBase64Url,
|
|
1036
|
+
signature: signDevicePayload(deviceIdentity.privateKeyPem, payload),
|
|
1037
|
+
signedAt: signedAtMs,
|
|
1038
|
+
nonce,
|
|
1039
|
+
};
|
|
1040
|
+
}
|
|
1041
|
+
return connectParams;
|
|
1042
|
+
}, connectTimeoutMs);
|
|
1043
|
+
await ctx.onLog("stdout", `[openclaw-gateway] connected protocol=${asNumber(asRecord(hello)?.protocol, PROTOCOL_VERSION)}\n`);
|
|
1044
|
+
// Keep any server-side continuation lock through retryable websocket
|
|
1045
|
+
// setup and backoff. The first agent request is the remote-work boundary:
|
|
1046
|
+
// once it is sent, retrying would be unsafe because the gateway may have
|
|
1047
|
+
// accepted work even if the response is lost.
|
|
1048
|
+
reportDispatch();
|
|
1049
|
+
const acceptedPayload = await client.request("agent", agentParams, {
|
|
1050
|
+
timeoutMs: connectTimeoutMs,
|
|
1051
|
+
});
|
|
1052
|
+
latestResultPayload = acceptedPayload;
|
|
1053
|
+
const acceptedStatus = nonEmpty(acceptedPayload?.status)?.toLowerCase() ?? "";
|
|
1054
|
+
const acceptedRunId = nonEmpty(acceptedPayload?.runId) ?? ctx.runId;
|
|
1055
|
+
trackedRunIds.add(acceptedRunId);
|
|
1056
|
+
await ctx.onLog("stdout", `[openclaw-gateway] agent accepted runId=${acceptedRunId} status=${acceptedStatus || "unknown"}\n`);
|
|
1057
|
+
if (acceptedStatus === "error") {
|
|
1058
|
+
const errorMessage = nonEmpty(acceptedPayload?.summary) ?? lifecycleError ?? "OpenClaw gateway agent request failed";
|
|
1059
|
+
return {
|
|
1060
|
+
exitCode: 1,
|
|
1061
|
+
signal: null,
|
|
1062
|
+
timedOut: false,
|
|
1063
|
+
errorMessage,
|
|
1064
|
+
errorCode: "openclaw_gateway_agent_error",
|
|
1065
|
+
resultJson: acceptedPayload,
|
|
1066
|
+
};
|
|
1067
|
+
}
|
|
1068
|
+
if (acceptedStatus !== "ok") {
|
|
1069
|
+
const waitPayload = await client.request("agent.wait", { runId: acceptedRunId, timeoutMs: waitTimeoutMs }, { timeoutMs: waitTimeoutMs + connectTimeoutMs });
|
|
1070
|
+
latestResultPayload = waitPayload;
|
|
1071
|
+
const waitStatus = nonEmpty(waitPayload?.status)?.toLowerCase() ?? "";
|
|
1072
|
+
if (waitStatus === "timeout") {
|
|
1073
|
+
return {
|
|
1074
|
+
exitCode: 1,
|
|
1075
|
+
signal: null,
|
|
1076
|
+
timedOut: true,
|
|
1077
|
+
errorMessage: `OpenClaw gateway run timed out after ${waitTimeoutMs}ms`,
|
|
1078
|
+
errorCode: "openclaw_gateway_wait_timeout",
|
|
1079
|
+
resultJson: waitPayload,
|
|
1080
|
+
};
|
|
1081
|
+
}
|
|
1082
|
+
if (waitStatus === "error") {
|
|
1083
|
+
return {
|
|
1084
|
+
exitCode: 1,
|
|
1085
|
+
signal: null,
|
|
1086
|
+
timedOut: false,
|
|
1087
|
+
errorMessage: nonEmpty(waitPayload?.error) ??
|
|
1088
|
+
lifecycleError ??
|
|
1089
|
+
"OpenClaw gateway run failed",
|
|
1090
|
+
errorCode: "openclaw_gateway_wait_error",
|
|
1091
|
+
resultJson: waitPayload,
|
|
1092
|
+
};
|
|
1093
|
+
}
|
|
1094
|
+
if (waitStatus && waitStatus !== "ok") {
|
|
1095
|
+
return {
|
|
1096
|
+
exitCode: 1,
|
|
1097
|
+
signal: null,
|
|
1098
|
+
timedOut: false,
|
|
1099
|
+
errorMessage: `Unexpected OpenClaw gateway agent.wait status: ${waitStatus}`,
|
|
1100
|
+
errorCode: "openclaw_gateway_wait_status_unexpected",
|
|
1101
|
+
resultJson: waitPayload,
|
|
1102
|
+
};
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
const summaryFromEvents = assistantChunks.join("").trim();
|
|
1106
|
+
const summaryFromPayload = extractResultText(asRecord(acceptedPayload?.result)) ??
|
|
1107
|
+
extractResultText(acceptedPayload) ??
|
|
1108
|
+
extractResultText(asRecord(latestResultPayload)) ??
|
|
1109
|
+
null;
|
|
1110
|
+
const summary = summaryFromEvents || summaryFromPayload || null;
|
|
1111
|
+
const acceptedResult = asRecord(acceptedPayload?.result);
|
|
1112
|
+
const latestPayload = asRecord(latestResultPayload);
|
|
1113
|
+
const latestResult = asRecord(latestPayload?.result);
|
|
1114
|
+
const acceptedMeta = asRecord(acceptedResult?.meta) ?? asRecord(acceptedPayload?.meta);
|
|
1115
|
+
const latestMeta = asRecord(latestResult?.meta) ?? asRecord(latestPayload?.meta);
|
|
1116
|
+
const mergedMeta = {
|
|
1117
|
+
...(acceptedMeta ?? {}),
|
|
1118
|
+
...(latestMeta ?? {}),
|
|
1119
|
+
};
|
|
1120
|
+
const agentMeta = asRecord(mergedMeta.agentMeta) ??
|
|
1121
|
+
asRecord(acceptedMeta?.agentMeta) ??
|
|
1122
|
+
asRecord(latestMeta?.agentMeta);
|
|
1123
|
+
const usage = parseUsage(agentMeta?.usage ?? mergedMeta.usage);
|
|
1124
|
+
const runtimeServices = extractRuntimeServicesFromMeta(agentMeta ?? mergedMeta);
|
|
1125
|
+
const provider = nonEmpty(agentMeta?.provider) ?? nonEmpty(mergedMeta.provider) ?? "openclaw";
|
|
1126
|
+
const model = nonEmpty(agentMeta?.model) ?? nonEmpty(mergedMeta.model) ?? null;
|
|
1127
|
+
const costUsd = asNumber(agentMeta?.costUsd ?? mergedMeta.costUsd, 0);
|
|
1128
|
+
await ctx.onLog("stdout", `[openclaw-gateway] run completed runId=${Array.from(trackedRunIds).join(",")} status=ok\n`);
|
|
1129
|
+
return {
|
|
1130
|
+
exitCode: 0,
|
|
1131
|
+
signal: null,
|
|
1132
|
+
timedOut: false,
|
|
1133
|
+
provider,
|
|
1134
|
+
...(model ? { model } : {}),
|
|
1135
|
+
...(usage ? { usage } : {}),
|
|
1136
|
+
...(costUsd > 0 ? { costUsd } : {}),
|
|
1137
|
+
resultJson: asRecord(latestResultPayload),
|
|
1138
|
+
...(runtimeServices.length > 0 ? { runtimeServices } : {}),
|
|
1139
|
+
...(summary ? { summary } : {}),
|
|
1140
|
+
};
|
|
1141
|
+
}
|
|
1142
|
+
catch (err) {
|
|
1143
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1144
|
+
const lower = message.toLowerCase();
|
|
1145
|
+
const timedOut = lower.includes("timeout");
|
|
1146
|
+
const pairingRequired = lower.includes("pairing required");
|
|
1147
|
+
if (pairingRequired &&
|
|
1148
|
+
!disableDeviceAuth &&
|
|
1149
|
+
autoPairOnFirstConnect &&
|
|
1150
|
+
!autoPairAttempted &&
|
|
1151
|
+
(authToken || password)) {
|
|
1152
|
+
autoPairAttempted = true;
|
|
1153
|
+
const pairResult = await autoApproveDevicePairing({
|
|
1154
|
+
url: parsedUrl.toString(),
|
|
1155
|
+
headers,
|
|
1156
|
+
connectTimeoutMs,
|
|
1157
|
+
clientId,
|
|
1158
|
+
clientMode,
|
|
1159
|
+
clientVersion,
|
|
1160
|
+
role,
|
|
1161
|
+
scopes,
|
|
1162
|
+
authToken,
|
|
1163
|
+
password,
|
|
1164
|
+
requestId: extractPairingRequestId(err),
|
|
1165
|
+
deviceId: deviceIdentity?.deviceId ?? null,
|
|
1166
|
+
onLog: ctx.onLog,
|
|
1167
|
+
});
|
|
1168
|
+
if (pairResult.ok) {
|
|
1169
|
+
await ctx.onLog("stdout", `[openclaw-gateway] auto-approved pairing request ${pairResult.requestId}; retrying\n`);
|
|
1170
|
+
continue;
|
|
1171
|
+
}
|
|
1172
|
+
await ctx.onLog("stderr", `[openclaw-gateway] auto-pairing failed: ${pairResult.reason}\n`);
|
|
1173
|
+
}
|
|
1174
|
+
// Retry transient errors (connection refused, reset, socket hang up)
|
|
1175
|
+
const isTransient = !pairingRequired &&
|
|
1176
|
+
(lower.includes("econnrefused") ||
|
|
1177
|
+
lower.includes("econnreset") ||
|
|
1178
|
+
lower.includes("socket hang up") ||
|
|
1179
|
+
(timedOut && !lower.includes("agent.wait")));
|
|
1180
|
+
if (isTransient && !dispatchReported && retryCount < MAX_RETRIES) {
|
|
1181
|
+
retryCount++;
|
|
1182
|
+
const backoffMs = retryCount * 2000;
|
|
1183
|
+
await ctx.onLog("stdout", `[openclaw-gateway] transient error, retry ${retryCount}/${MAX_RETRIES} after ${backoffMs}ms: ${message}\n`);
|
|
1184
|
+
await new Promise((r) => setTimeout(r, backoffMs));
|
|
1185
|
+
continue;
|
|
1186
|
+
}
|
|
1187
|
+
const detailedMessage = pairingRequired
|
|
1188
|
+
? `${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.`
|
|
1189
|
+
: message;
|
|
1190
|
+
await ctx.onLog("stderr", `[openclaw-gateway] request failed: ${detailedMessage}\n`);
|
|
1191
|
+
return {
|
|
1192
|
+
exitCode: 1,
|
|
1193
|
+
signal: null,
|
|
1194
|
+
timedOut,
|
|
1195
|
+
errorMessage: detailedMessage,
|
|
1196
|
+
errorCode: timedOut
|
|
1197
|
+
? "openclaw_gateway_timeout"
|
|
1198
|
+
: pairingRequired
|
|
1199
|
+
? "openclaw_gateway_pairing_required"
|
|
1200
|
+
: "openclaw_gateway_request_failed",
|
|
1201
|
+
resultJson: asRecord(latestResultPayload),
|
|
1202
|
+
};
|
|
1203
|
+
}
|
|
1204
|
+
finally {
|
|
1205
|
+
client.close();
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
//# sourceMappingURL=execute.js.map
|