@clawcrony/claw-crony 1.0.1
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 +21 -0
- package/README.md +82 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +720 -0
- package/dist/index.js.map +1 -0
- package/dist/src/agent-card.d.ts +4 -0
- package/dist/src/agent-card.d.ts.map +1 -0
- package/dist/src/agent-card.js +61 -0
- package/dist/src/agent-card.js.map +1 -0
- package/dist/src/audit.d.ts +36 -0
- package/dist/src/audit.d.ts.map +1 -0
- package/dist/src/audit.js +88 -0
- package/dist/src/audit.js.map +1 -0
- package/dist/src/client.d.ts +53 -0
- package/dist/src/client.d.ts.map +1 -0
- package/dist/src/client.js +322 -0
- package/dist/src/client.js.map +1 -0
- package/dist/src/executor.d.ts +34 -0
- package/dist/src/executor.d.ts.map +1 -0
- package/dist/src/executor.js +994 -0
- package/dist/src/executor.js.map +1 -0
- package/dist/src/file-security.d.ts +63 -0
- package/dist/src/file-security.d.ts.map +1 -0
- package/dist/src/file-security.js +350 -0
- package/dist/src/file-security.js.map +1 -0
- package/dist/src/hub-match.d.ts +73 -0
- package/dist/src/hub-match.d.ts.map +1 -0
- package/dist/src/hub-match.js +120 -0
- package/dist/src/hub-match.js.map +1 -0
- package/dist/src/hub-registration.d.ts +24 -0
- package/dist/src/hub-registration.d.ts.map +1 -0
- package/dist/src/hub-registration.js +242 -0
- package/dist/src/hub-registration.js.map +1 -0
- package/dist/src/internal/envelope.d.ts +33 -0
- package/dist/src/internal/envelope.d.ts.map +1 -0
- package/dist/src/internal/envelope.js +152 -0
- package/dist/src/internal/envelope.js.map +1 -0
- package/dist/src/internal/idempotency.d.ts +48 -0
- package/dist/src/internal/idempotency.d.ts.map +1 -0
- package/dist/src/internal/idempotency.js +82 -0
- package/dist/src/internal/idempotency.js.map +1 -0
- package/dist/src/internal/metrics.d.ts +38 -0
- package/dist/src/internal/metrics.d.ts.map +1 -0
- package/dist/src/internal/metrics.js +83 -0
- package/dist/src/internal/metrics.js.map +1 -0
- package/dist/src/internal/outbox.d.ts +49 -0
- package/dist/src/internal/outbox.d.ts.map +1 -0
- package/dist/src/internal/outbox.js +149 -0
- package/dist/src/internal/outbox.js.map +1 -0
- package/dist/src/internal/routing.d.ts +28 -0
- package/dist/src/internal/routing.d.ts.map +1 -0
- package/dist/src/internal/routing.js +57 -0
- package/dist/src/internal/routing.js.map +1 -0
- package/dist/src/internal/security.d.ts +53 -0
- package/dist/src/internal/security.d.ts.map +1 -0
- package/dist/src/internal/security.js +122 -0
- package/dist/src/internal/security.js.map +1 -0
- package/dist/src/internal/transport.d.ts +49 -0
- package/dist/src/internal/transport.d.ts.map +1 -0
- package/dist/src/internal/transport.js +207 -0
- package/dist/src/internal/transport.js.map +1 -0
- package/dist/src/internal/types-internal.d.ts +95 -0
- package/dist/src/internal/types-internal.d.ts.map +1 -0
- package/dist/src/internal/types-internal.js +9 -0
- package/dist/src/internal/types-internal.js.map +1 -0
- package/dist/src/peer-health.d.ts +47 -0
- package/dist/src/peer-health.d.ts.map +1 -0
- package/dist/src/peer-health.js +169 -0
- package/dist/src/peer-health.js.map +1 -0
- package/dist/src/peer-retry.d.ts +16 -0
- package/dist/src/peer-retry.d.ts.map +1 -0
- package/dist/src/peer-retry.js +75 -0
- package/dist/src/peer-retry.js.map +1 -0
- package/dist/src/queueing-executor.d.ts +23 -0
- package/dist/src/queueing-executor.d.ts.map +1 -0
- package/dist/src/queueing-executor.js +179 -0
- package/dist/src/queueing-executor.js.map +1 -0
- package/dist/src/routing-rules.d.ts +53 -0
- package/dist/src/routing-rules.d.ts.map +1 -0
- package/dist/src/routing-rules.js +130 -0
- package/dist/src/routing-rules.js.map +1 -0
- package/dist/src/task-cleanup.d.ts +21 -0
- package/dist/src/task-cleanup.d.ts.map +1 -0
- package/dist/src/task-cleanup.js +77 -0
- package/dist/src/task-cleanup.js.map +1 -0
- package/dist/src/task-store.d.ts +16 -0
- package/dist/src/task-store.d.ts.map +1 -0
- package/dist/src/task-store.js +80 -0
- package/dist/src/task-store.js.map +1 -0
- package/dist/src/telemetry.d.ts +88 -0
- package/dist/src/telemetry.d.ts.map +1 -0
- package/dist/src/telemetry.js +235 -0
- package/dist/src/telemetry.js.map +1 -0
- package/dist/src/transport-fallback.d.ts +29 -0
- package/dist/src/transport-fallback.d.ts.map +1 -0
- package/dist/src/transport-fallback.js +81 -0
- package/dist/src/transport-fallback.js.map +1 -0
- package/dist/src/types.d.ts +160 -0
- package/dist/src/types.d.ts.map +1 -0
- package/dist/src/types.js +7 -0
- package/dist/src/types.js.map +1 -0
- package/openclaw.plugin.json +272 -0
- package/package.json +56 -0
- package/skill/SKILL.md +230 -0
- package/skill/references/tools-md-template.md +57 -0
- package/skill/scripts/a2a-send.mjs +357 -0
|
@@ -0,0 +1,994 @@
|
|
|
1
|
+
import { v4 as uuidv4 } from "uuid";
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
import { validateMimeType, validateUriSchemeAndIp, checkFileSize, decodedBase64Size, sanitizeUriForLog, } from "./file-security.js";
|
|
4
|
+
const DEFAULT_AGENT_RESPONSE_TIMEOUT_MS = 300_000;
|
|
5
|
+
const GATEWAY_CONNECT_TIMEOUT_MS = 10_000;
|
|
6
|
+
const GATEWAY_REQUEST_TIMEOUT_MS = 10_000;
|
|
7
|
+
const HOOKS_WAKE_TIMEOUT_MS = 5_000;
|
|
8
|
+
const TASK_CONTEXT_CACHE_LIMIT = 10_000;
|
|
9
|
+
/**
|
|
10
|
+
* Interval for SSE heartbeat events during agent dispatch.
|
|
11
|
+
* Keeps the SSE connection alive and signals clients the task is still working.
|
|
12
|
+
*/
|
|
13
|
+
const STREAMING_HEARTBEAT_INTERVAL_MS = 15_000;
|
|
14
|
+
/**
|
|
15
|
+
* Maximum number of messages retained in task history.
|
|
16
|
+
* Prevents unbounded growth in long-running conversations.
|
|
17
|
+
* The SDK's tasks/get historyLength param can further trim on read.
|
|
18
|
+
*/
|
|
19
|
+
const MAX_HISTORY_MESSAGES = 200;
|
|
20
|
+
function pickAgentId(requestContext, fallbackAgentId) {
|
|
21
|
+
const msg = requestContext.userMessage;
|
|
22
|
+
const explicit = msg && typeof msg.agentId === "string" ? msg.agentId : "";
|
|
23
|
+
return explicit || fallbackAgentId;
|
|
24
|
+
}
|
|
25
|
+
function asObject(value) {
|
|
26
|
+
if (!value || typeof value !== "object") {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
function asString(value) {
|
|
32
|
+
if (typeof value !== "string") {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
const trimmed = value.trim();
|
|
36
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
37
|
+
}
|
|
38
|
+
function asFiniteNumber(value) {
|
|
39
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Format an A2A DataPart as human-readable text for the OpenClaw agent.
|
|
46
|
+
*
|
|
47
|
+
* DataPart carries structured JSON data (kind: "data"). Since the Gateway RPC
|
|
48
|
+
* only accepts plain text, we serialize the data with a mimeType hint so the
|
|
49
|
+
* agent can interpret it.
|
|
50
|
+
*/
|
|
51
|
+
function formatDataPartAsText(obj) {
|
|
52
|
+
const data = asObject(obj.data);
|
|
53
|
+
if (!data) {
|
|
54
|
+
// Fallback: stringify the entire obj.data even if it's a primitive/array
|
|
55
|
+
if (obj.data !== undefined && obj.data !== null) {
|
|
56
|
+
const raw = JSON.stringify(obj.data);
|
|
57
|
+
const mimeType = asString(obj.mimeType) || "application/json";
|
|
58
|
+
return `[Data (${mimeType}): ${raw.slice(0, 2000)}]`;
|
|
59
|
+
}
|
|
60
|
+
return "";
|
|
61
|
+
}
|
|
62
|
+
const mimeType = asString(obj.mimeType) || "application/json";
|
|
63
|
+
const raw = JSON.stringify(data);
|
|
64
|
+
// Truncate very large payloads to prevent overwhelming the agent context
|
|
65
|
+
const preview = raw.length > 2000 ? raw.slice(0, 2000) + "…" : raw;
|
|
66
|
+
return `[Data (${mimeType}): ${preview}]`;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Format an A2A FilePart as human-readable text for the OpenClaw agent.
|
|
70
|
+
*
|
|
71
|
+
* The Gateway RPC `agent` method only accepts a `message: string` parameter,
|
|
72
|
+
* so file parts must be serialized into text. URI-based files include the URL
|
|
73
|
+
* so the agent can reference or fetch them; inline base64 files include a size
|
|
74
|
+
* hint since the raw bytes cannot be forwarded through the text channel.
|
|
75
|
+
*/
|
|
76
|
+
function formatFilePartAsText(obj) {
|
|
77
|
+
const file = asObject(obj.file);
|
|
78
|
+
if (!file) {
|
|
79
|
+
return "";
|
|
80
|
+
}
|
|
81
|
+
// Sanitize name/mimeType: strip control chars and newlines to prevent
|
|
82
|
+
// format injection when embedded in the agent message text.
|
|
83
|
+
const rawName = asString(file.name) || "file";
|
|
84
|
+
const rawMimeType = asString(file.mimeType) || "application/octet-stream";
|
|
85
|
+
const name = rawName.replace(/[\r\n\t\x00-\x1f]/g, "").slice(0, 200);
|
|
86
|
+
const mimeType = rawMimeType.replace(/[\r\n\t\x00-\x1f]/g, "").slice(0, 100);
|
|
87
|
+
// URI-based file
|
|
88
|
+
const uri = asString(file.uri);
|
|
89
|
+
if (uri) {
|
|
90
|
+
return `[Attached: ${name} (${mimeType}) \u2192 ${uri}]`;
|
|
91
|
+
}
|
|
92
|
+
// Base64-encoded inline file
|
|
93
|
+
const bytes = asString(file.bytes);
|
|
94
|
+
if (bytes) {
|
|
95
|
+
const sizeKB = Math.ceil(decodedBase64Size(bytes) / 1024);
|
|
96
|
+
return `[Attached: ${name} (${mimeType}), inline ${sizeKB}KB]`;
|
|
97
|
+
}
|
|
98
|
+
return `[Attached: ${name} (${mimeType})]`;
|
|
99
|
+
}
|
|
100
|
+
function extractTextFragments(value) {
|
|
101
|
+
if (typeof value === "string") {
|
|
102
|
+
const trimmed = value.trim();
|
|
103
|
+
return trimmed ? [trimmed] : [];
|
|
104
|
+
}
|
|
105
|
+
if (Array.isArray(value)) {
|
|
106
|
+
return value.flatMap((entry) => extractTextFragments(entry));
|
|
107
|
+
}
|
|
108
|
+
const obj = asObject(value);
|
|
109
|
+
if (!obj) {
|
|
110
|
+
return [];
|
|
111
|
+
}
|
|
112
|
+
if (obj.kind === "text" && typeof obj.text === "string") {
|
|
113
|
+
const trimmed = obj.text.trim();
|
|
114
|
+
return trimmed ? [trimmed] : [];
|
|
115
|
+
}
|
|
116
|
+
// Handle A2A FilePart: format as human-readable text for the agent
|
|
117
|
+
if (obj.kind === "file") {
|
|
118
|
+
const description = formatFilePartAsText(obj);
|
|
119
|
+
return description ? [description] : [];
|
|
120
|
+
}
|
|
121
|
+
// Handle A2A DataPart: serialize structured data as human-readable text
|
|
122
|
+
if (obj.kind === "data") {
|
|
123
|
+
const description = formatDataPartAsText(obj);
|
|
124
|
+
return description ? [description] : [];
|
|
125
|
+
}
|
|
126
|
+
const parts = Array.isArray(obj.parts) ? obj.parts : [];
|
|
127
|
+
if (parts.length > 0) {
|
|
128
|
+
return parts.flatMap((part) => extractTextFragments(part));
|
|
129
|
+
}
|
|
130
|
+
const content = Array.isArray(obj.content) ? obj.content : [];
|
|
131
|
+
if (content.length > 0) {
|
|
132
|
+
return content.flatMap((entry) => extractTextFragments(entry));
|
|
133
|
+
}
|
|
134
|
+
if (typeof obj.text === "string") {
|
|
135
|
+
const trimmed = obj.text.trim();
|
|
136
|
+
return trimmed ? [trimmed] : [];
|
|
137
|
+
}
|
|
138
|
+
return [];
|
|
139
|
+
}
|
|
140
|
+
function extractInboundMessageText(message) {
|
|
141
|
+
const fragments = extractTextFragments(message);
|
|
142
|
+
if (fragments.length > 0) {
|
|
143
|
+
return fragments.join("\n");
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
return JSON.stringify(message);
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return "A2A inbound message";
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
function extractAgentPayloadText(payload) {
|
|
153
|
+
const fragments = extractTextFragments(payload);
|
|
154
|
+
if (fragments.length === 0) {
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
return fragments.join("\n").trim() || undefined;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Extract media URLs from a single agent payload entry.
|
|
161
|
+
*
|
|
162
|
+
* OpenClaw Gateway agent payloads carry media via `mediaUrl` (single) and/or
|
|
163
|
+
* `mediaUrls` (array). Both are extracted and de-duplicated.
|
|
164
|
+
*/
|
|
165
|
+
function extractMediaUrlsFromPayload(payload) {
|
|
166
|
+
const obj = asObject(payload);
|
|
167
|
+
if (!obj) {
|
|
168
|
+
return [];
|
|
169
|
+
}
|
|
170
|
+
const urls = [];
|
|
171
|
+
const single = asString(obj.mediaUrl);
|
|
172
|
+
if (single) {
|
|
173
|
+
urls.push(single);
|
|
174
|
+
}
|
|
175
|
+
if (Array.isArray(obj.mediaUrls)) {
|
|
176
|
+
for (const entry of obj.mediaUrls) {
|
|
177
|
+
const url = asString(entry);
|
|
178
|
+
if (url && !urls.includes(url)) {
|
|
179
|
+
urls.push(url);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return urls;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Extract structured response (text + media URLs) from the Gateway agent
|
|
187
|
+
* final payload. Returns undefined when no usable content is found.
|
|
188
|
+
*/
|
|
189
|
+
function extractAgentResponse(payload) {
|
|
190
|
+
const body = asObject(payload);
|
|
191
|
+
if (!body) {
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
|
194
|
+
const result = asObject(body.result);
|
|
195
|
+
const payloads = Array.isArray(result?.payloads) ? result.payloads : [];
|
|
196
|
+
const texts = payloads
|
|
197
|
+
.map((entry) => extractAgentPayloadText(entry))
|
|
198
|
+
.filter((entry) => Boolean(entry && entry.trim()));
|
|
199
|
+
const mediaUrls = [];
|
|
200
|
+
for (const entry of payloads) {
|
|
201
|
+
for (const url of extractMediaUrlsFromPayload(entry)) {
|
|
202
|
+
if (!mediaUrls.includes(url)) {
|
|
203
|
+
mediaUrls.push(url);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (texts.length > 0 || mediaUrls.length > 0) {
|
|
208
|
+
return {
|
|
209
|
+
text: texts.join("\n\n"),
|
|
210
|
+
mediaUrls,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
return undefined;
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Build A2A Part array from an AgentResponse. Produces TextPart for text
|
|
217
|
+
* content and FilePart entries for each media URL.
|
|
218
|
+
*/
|
|
219
|
+
function buildResponseParts(response) {
|
|
220
|
+
const parts = [];
|
|
221
|
+
if (response.text) {
|
|
222
|
+
parts.push({ kind: "text", text: response.text });
|
|
223
|
+
}
|
|
224
|
+
for (const url of response.mediaUrls) {
|
|
225
|
+
parts.push({
|
|
226
|
+
kind: "file",
|
|
227
|
+
file: { uri: url },
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
// Ensure at least one part exists (A2A requires non-empty parts array)
|
|
231
|
+
if (parts.length === 0) {
|
|
232
|
+
parts.push({ kind: "text", text: "" });
|
|
233
|
+
}
|
|
234
|
+
return parts;
|
|
235
|
+
}
|
|
236
|
+
function extractLatestAssistantReply(historyPayload) {
|
|
237
|
+
const body = asObject(historyPayload);
|
|
238
|
+
if (!body) {
|
|
239
|
+
return undefined;
|
|
240
|
+
}
|
|
241
|
+
const messages = Array.isArray(body.messages) ? body.messages : [];
|
|
242
|
+
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
243
|
+
const entry = asObject(messages[i]);
|
|
244
|
+
if (!entry || entry.role !== "assistant") {
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
const text = extractAgentPayloadText(entry);
|
|
248
|
+
if (text) {
|
|
249
|
+
return text;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return undefined;
|
|
253
|
+
}
|
|
254
|
+
function withTimeout(promise, timeoutMs, timeoutMessage) {
|
|
255
|
+
return new Promise((resolve, reject) => {
|
|
256
|
+
const timer = setTimeout(() => {
|
|
257
|
+
reject(new Error(timeoutMessage));
|
|
258
|
+
}, timeoutMs);
|
|
259
|
+
promise.then((value) => {
|
|
260
|
+
clearTimeout(timer);
|
|
261
|
+
resolve(value);
|
|
262
|
+
}, (error) => {
|
|
263
|
+
clearTimeout(timer);
|
|
264
|
+
reject(error);
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Lazily generated Ed25519 device identity for this plugin instance.
|
|
270
|
+
* Persisted in-memory for the lifetime of the process so all connections
|
|
271
|
+
* from this gateway share the same device id and auto-pair only once.
|
|
272
|
+
*/
|
|
273
|
+
let cachedDeviceIdentity = null;
|
|
274
|
+
function getOrCreateDeviceIdentity() {
|
|
275
|
+
if (cachedDeviceIdentity)
|
|
276
|
+
return cachedDeviceIdentity;
|
|
277
|
+
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
|
|
278
|
+
const publicKeyRaw = publicKey.export({ type: "spki", format: "der" });
|
|
279
|
+
// Ed25519 SPKI DER has a 12-byte header; the raw 32-byte key starts at offset 12
|
|
280
|
+
const rawBytes = publicKeyRaw.subarray(12);
|
|
281
|
+
const publicKeyB64Url = rawBytes.toString("base64url");
|
|
282
|
+
const deviceId = crypto
|
|
283
|
+
.createHash("sha256")
|
|
284
|
+
.update(rawBytes)
|
|
285
|
+
.digest("hex");
|
|
286
|
+
cachedDeviceIdentity = { publicKey: publicKeyB64Url, privateKey, deviceId };
|
|
287
|
+
return cachedDeviceIdentity;
|
|
288
|
+
}
|
|
289
|
+
class GatewayRpcConnection {
|
|
290
|
+
wsUrl;
|
|
291
|
+
gatewayToken;
|
|
292
|
+
gatewayPassword;
|
|
293
|
+
pending;
|
|
294
|
+
socket;
|
|
295
|
+
messageListener;
|
|
296
|
+
closeListener;
|
|
297
|
+
connectChallengeTimer;
|
|
298
|
+
connectChallengeResolver;
|
|
299
|
+
connectChallengeRejecter;
|
|
300
|
+
challengeNonce;
|
|
301
|
+
constructor(config) {
|
|
302
|
+
this.wsUrl = config.wsUrl;
|
|
303
|
+
this.gatewayToken = config.gatewayToken;
|
|
304
|
+
this.gatewayPassword = config.gatewayPassword;
|
|
305
|
+
this.pending = new Map();
|
|
306
|
+
this.socket = null;
|
|
307
|
+
this.messageListener = null;
|
|
308
|
+
this.closeListener = null;
|
|
309
|
+
this.connectChallengeTimer = null;
|
|
310
|
+
this.connectChallengeResolver = null;
|
|
311
|
+
this.connectChallengeRejecter = null;
|
|
312
|
+
this.challengeNonce = "";
|
|
313
|
+
}
|
|
314
|
+
async connect() {
|
|
315
|
+
const ctor = globalThis.WebSocket;
|
|
316
|
+
if (!ctor) {
|
|
317
|
+
throw new Error("WebSocket runtime is unavailable");
|
|
318
|
+
}
|
|
319
|
+
const socket = new ctor(this.wsUrl);
|
|
320
|
+
this.socket = socket;
|
|
321
|
+
this.messageListener = (event) => {
|
|
322
|
+
this.handleMessage(event);
|
|
323
|
+
};
|
|
324
|
+
this.closeListener = () => {
|
|
325
|
+
const error = new Error("gateway connection closed");
|
|
326
|
+
this.rejectConnectChallenge(error);
|
|
327
|
+
this.rejectAllPending(error);
|
|
328
|
+
};
|
|
329
|
+
socket.addEventListener("message", this.messageListener);
|
|
330
|
+
socket.addEventListener("close", this.closeListener);
|
|
331
|
+
const challengePromise = this.awaitConnectChallenge();
|
|
332
|
+
try {
|
|
333
|
+
await new Promise((resolve, reject) => {
|
|
334
|
+
let settled = false;
|
|
335
|
+
const cleanup = () => {
|
|
336
|
+
socket.removeEventListener("open", onOpen);
|
|
337
|
+
socket.removeEventListener("error", onError);
|
|
338
|
+
socket.removeEventListener("close", onClose);
|
|
339
|
+
};
|
|
340
|
+
const settle = (error) => {
|
|
341
|
+
if (settled) {
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
settled = true;
|
|
345
|
+
clearTimeout(timer);
|
|
346
|
+
cleanup();
|
|
347
|
+
if (error) {
|
|
348
|
+
this.rejectConnectChallenge(error);
|
|
349
|
+
reject(error);
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
resolve();
|
|
353
|
+
};
|
|
354
|
+
const onOpen = () => {
|
|
355
|
+
settle();
|
|
356
|
+
};
|
|
357
|
+
const onError = () => {
|
|
358
|
+
settle(new Error("failed to open gateway websocket"));
|
|
359
|
+
};
|
|
360
|
+
const onClose = () => {
|
|
361
|
+
settle(new Error("gateway websocket closed during connect"));
|
|
362
|
+
};
|
|
363
|
+
const timer = setTimeout(() => {
|
|
364
|
+
settle(new Error("gateway websocket connect timed out"));
|
|
365
|
+
}, GATEWAY_CONNECT_TIMEOUT_MS);
|
|
366
|
+
socket.addEventListener("open", onOpen);
|
|
367
|
+
socket.addEventListener("error", onError);
|
|
368
|
+
socket.addEventListener("close", onClose);
|
|
369
|
+
});
|
|
370
|
+
// OpenClaw Gateway uses a challenge event before accepting connect.
|
|
371
|
+
await challengePromise;
|
|
372
|
+
}
|
|
373
|
+
catch (error) {
|
|
374
|
+
await challengePromise.catch(() => { });
|
|
375
|
+
throw error;
|
|
376
|
+
}
|
|
377
|
+
try {
|
|
378
|
+
await this.request("connect", this.buildConnectParams(true), GATEWAY_CONNECT_TIMEOUT_MS, false);
|
|
379
|
+
}
|
|
380
|
+
catch (connectError) {
|
|
381
|
+
const msg = connectError instanceof Error ? connectError.message : String(connectError);
|
|
382
|
+
// On older gateways (≤2026.3.11), sending a device identity may trigger
|
|
383
|
+
// "pairing required" if silent auto-pairing is not supported.
|
|
384
|
+
// Retry without device identity — the gateway token alone is sufficient
|
|
385
|
+
// on those versions because they preserve scopes for shared-auth connections.
|
|
386
|
+
if (msg.includes("pairing required") || msg.includes("device identity required")) {
|
|
387
|
+
this.challengeNonce = "";
|
|
388
|
+
await this.reconnect();
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
throw connectError;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Reconnect without device identity (fallback for gateways that reject unpaired devices).
|
|
396
|
+
*/
|
|
397
|
+
async reconnect() {
|
|
398
|
+
this.close();
|
|
399
|
+
const ctor = globalThis.WebSocket;
|
|
400
|
+
if (!ctor)
|
|
401
|
+
throw new Error("WebSocket runtime is unavailable");
|
|
402
|
+
const socket = new ctor(this.wsUrl);
|
|
403
|
+
this.socket = socket;
|
|
404
|
+
this.messageListener = (event) => { this.handleMessage(event); };
|
|
405
|
+
this.closeListener = () => {
|
|
406
|
+
const error = new Error("gateway connection closed");
|
|
407
|
+
this.rejectConnectChallenge(error);
|
|
408
|
+
this.rejectAllPending(error);
|
|
409
|
+
};
|
|
410
|
+
socket.addEventListener("message", this.messageListener);
|
|
411
|
+
socket.addEventListener("close", this.closeListener);
|
|
412
|
+
const challengePromise = this.awaitConnectChallenge();
|
|
413
|
+
await new Promise((resolve, reject) => {
|
|
414
|
+
let settled = false;
|
|
415
|
+
const settle = (error) => {
|
|
416
|
+
if (settled)
|
|
417
|
+
return;
|
|
418
|
+
settled = true;
|
|
419
|
+
clearTimeout(timer);
|
|
420
|
+
socket.removeEventListener("open", onOpen);
|
|
421
|
+
socket.removeEventListener("error", onError);
|
|
422
|
+
socket.removeEventListener("close", onClose);
|
|
423
|
+
if (error) {
|
|
424
|
+
this.rejectConnectChallenge(error);
|
|
425
|
+
reject(error);
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
resolve();
|
|
429
|
+
};
|
|
430
|
+
const onOpen = () => settle();
|
|
431
|
+
const onError = () => settle(new Error("failed to open gateway websocket"));
|
|
432
|
+
const onClose = () => settle(new Error("gateway websocket closed during connect"));
|
|
433
|
+
const timer = setTimeout(() => settle(new Error("gateway websocket connect timed out")), GATEWAY_CONNECT_TIMEOUT_MS);
|
|
434
|
+
socket.addEventListener("open", onOpen);
|
|
435
|
+
socket.addEventListener("error", onError);
|
|
436
|
+
socket.addEventListener("close", onClose);
|
|
437
|
+
});
|
|
438
|
+
await challengePromise;
|
|
439
|
+
await this.request("connect", this.buildConnectParams(false), GATEWAY_CONNECT_TIMEOUT_MS, false);
|
|
440
|
+
}
|
|
441
|
+
async request(method, params, timeoutMs, expectFinal) {
|
|
442
|
+
const socket = this.socket;
|
|
443
|
+
if (!socket || socket.readyState !== 1) {
|
|
444
|
+
throw new Error("gateway websocket is not connected");
|
|
445
|
+
}
|
|
446
|
+
const id = uuidv4();
|
|
447
|
+
const frame = {
|
|
448
|
+
type: "req",
|
|
449
|
+
id,
|
|
450
|
+
method,
|
|
451
|
+
params,
|
|
452
|
+
};
|
|
453
|
+
return await new Promise((resolve, reject) => {
|
|
454
|
+
const timer = setTimeout(() => {
|
|
455
|
+
this.pending.delete(id);
|
|
456
|
+
reject(new Error(`gateway request timed out: ${method}`));
|
|
457
|
+
}, timeoutMs);
|
|
458
|
+
this.pending.set(id, {
|
|
459
|
+
method,
|
|
460
|
+
expectFinal,
|
|
461
|
+
timer,
|
|
462
|
+
resolve,
|
|
463
|
+
reject,
|
|
464
|
+
});
|
|
465
|
+
try {
|
|
466
|
+
socket.send(JSON.stringify(frame));
|
|
467
|
+
}
|
|
468
|
+
catch (error) {
|
|
469
|
+
clearTimeout(timer);
|
|
470
|
+
this.pending.delete(id);
|
|
471
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
472
|
+
}
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
close() {
|
|
476
|
+
const socket = this.socket;
|
|
477
|
+
this.socket = null;
|
|
478
|
+
if (socket) {
|
|
479
|
+
if (this.messageListener) {
|
|
480
|
+
socket.removeEventListener("message", this.messageListener);
|
|
481
|
+
}
|
|
482
|
+
if (this.closeListener) {
|
|
483
|
+
socket.removeEventListener("close", this.closeListener);
|
|
484
|
+
}
|
|
485
|
+
this.messageListener = null;
|
|
486
|
+
this.closeListener = null;
|
|
487
|
+
socket.close();
|
|
488
|
+
}
|
|
489
|
+
const error = new Error("gateway websocket connection closed");
|
|
490
|
+
this.rejectConnectChallenge(error);
|
|
491
|
+
this.rejectAllPending(error);
|
|
492
|
+
}
|
|
493
|
+
awaitConnectChallenge() {
|
|
494
|
+
if (this.connectChallengeRejecter) {
|
|
495
|
+
this.connectChallengeRejecter(new Error("gateway connect challenge wait superseded"));
|
|
496
|
+
}
|
|
497
|
+
this.clearConnectChallengeWait();
|
|
498
|
+
return new Promise((resolve, reject) => {
|
|
499
|
+
this.connectChallengeResolver = (nonce) => {
|
|
500
|
+
this.challengeNonce = nonce;
|
|
501
|
+
this.clearConnectChallengeWait();
|
|
502
|
+
resolve();
|
|
503
|
+
};
|
|
504
|
+
this.connectChallengeRejecter = (error) => {
|
|
505
|
+
this.clearConnectChallengeWait();
|
|
506
|
+
reject(error);
|
|
507
|
+
};
|
|
508
|
+
// OpenClaw Gateway typically emits a connect.challenge event shortly after the socket opens.
|
|
509
|
+
// Use a bounded timeout so we fail fast (and can fall back) if the gateway isn't reachable.
|
|
510
|
+
const timeoutMs = 2_000;
|
|
511
|
+
this.connectChallengeTimer = setTimeout(() => {
|
|
512
|
+
this.connectChallengeRejecter?.(new Error("gateway connect challenge timed out"));
|
|
513
|
+
}, timeoutMs);
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
clearConnectChallengeWait() {
|
|
517
|
+
if (this.connectChallengeTimer) {
|
|
518
|
+
clearTimeout(this.connectChallengeTimer);
|
|
519
|
+
}
|
|
520
|
+
this.connectChallengeTimer = null;
|
|
521
|
+
this.connectChallengeResolver = null;
|
|
522
|
+
this.connectChallengeRejecter = null;
|
|
523
|
+
}
|
|
524
|
+
rejectConnectChallenge(error) {
|
|
525
|
+
if (this.connectChallengeRejecter) {
|
|
526
|
+
this.connectChallengeRejecter(error);
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
this.clearConnectChallengeWait();
|
|
530
|
+
}
|
|
531
|
+
buildConnectParams(includeDevice = true) {
|
|
532
|
+
const auth = {};
|
|
533
|
+
if (this.gatewayToken) {
|
|
534
|
+
auth.token = this.gatewayToken;
|
|
535
|
+
}
|
|
536
|
+
if (this.gatewayPassword) {
|
|
537
|
+
auth.password = this.gatewayPassword;
|
|
538
|
+
}
|
|
539
|
+
const role = "operator";
|
|
540
|
+
const scopes = ["operator.admin", "operator.read", "operator.write", "operator.approvals", "operator.pairing"];
|
|
541
|
+
const params = {
|
|
542
|
+
minProtocol: 3,
|
|
543
|
+
maxProtocol: 3,
|
|
544
|
+
client: {
|
|
545
|
+
id: "cli",
|
|
546
|
+
version: "a2a-gateway-plugin",
|
|
547
|
+
platform: process.platform,
|
|
548
|
+
mode: "cli",
|
|
549
|
+
instanceId: uuidv4(),
|
|
550
|
+
},
|
|
551
|
+
role,
|
|
552
|
+
scopes,
|
|
553
|
+
};
|
|
554
|
+
if (Object.keys(auth).length > 0) {
|
|
555
|
+
params.auth = auth;
|
|
556
|
+
}
|
|
557
|
+
// Build device identity so the gateway preserves requested scopes.
|
|
558
|
+
// Without this, OpenClaw ≥2026.3.13 clears scopes for connections
|
|
559
|
+
// that lack a device identity, causing "missing scope: operator.write".
|
|
560
|
+
// See: https://github.com/win4r/openclaw-a2a-gateway/issues/29
|
|
561
|
+
const nonce = this.challengeNonce;
|
|
562
|
+
if (includeDevice && nonce) {
|
|
563
|
+
const identity = getOrCreateDeviceIdentity();
|
|
564
|
+
const signedAtMs = Date.now();
|
|
565
|
+
// Build the v3 signature payload — must match buildDeviceAuthPayloadV3 in OpenClaw core
|
|
566
|
+
const payloadParts = [
|
|
567
|
+
"v3",
|
|
568
|
+
identity.deviceId,
|
|
569
|
+
"cli", // clientId
|
|
570
|
+
"cli", // clientMode
|
|
571
|
+
role,
|
|
572
|
+
scopes.join(","),
|
|
573
|
+
String(signedAtMs),
|
|
574
|
+
auth.token || "", // token from shared auth
|
|
575
|
+
nonce,
|
|
576
|
+
process.platform, // platform
|
|
577
|
+
"", // deviceFamily (empty)
|
|
578
|
+
];
|
|
579
|
+
const payload = payloadParts.join("|");
|
|
580
|
+
const signature = crypto.sign(null, Buffer.from(payload), identity.privateKey);
|
|
581
|
+
const signatureB64Url = signature.toString("base64url");
|
|
582
|
+
params.device = {
|
|
583
|
+
id: identity.deviceId,
|
|
584
|
+
publicKey: identity.publicKey,
|
|
585
|
+
signedAt: signedAtMs,
|
|
586
|
+
nonce,
|
|
587
|
+
signature: signatureB64Url,
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
return params;
|
|
591
|
+
}
|
|
592
|
+
handleMessage(event) {
|
|
593
|
+
const raw = typeof event.data === "string" ? event.data : "";
|
|
594
|
+
if (!raw) {
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
let parsed;
|
|
598
|
+
try {
|
|
599
|
+
parsed = JSON.parse(raw);
|
|
600
|
+
}
|
|
601
|
+
catch {
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
const frame = asObject(parsed);
|
|
605
|
+
if (!frame) {
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
if (frame.type === "event") {
|
|
609
|
+
if (frame.event === "connect.challenge") {
|
|
610
|
+
const payload = asObject(frame.payload);
|
|
611
|
+
const nonce = asString(payload?.nonce)?.trim() || "";
|
|
612
|
+
if (nonce && this.connectChallengeResolver) {
|
|
613
|
+
this.connectChallengeResolver(nonce);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
if (frame.type !== "res") {
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
const id = asString(frame.id);
|
|
622
|
+
if (!id) {
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
const pending = this.pending.get(id);
|
|
626
|
+
if (!pending) {
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
if (pending.expectFinal) {
|
|
630
|
+
const payload = asObject(frame.payload);
|
|
631
|
+
if (payload?.status === "accepted") {
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
this.pending.delete(id);
|
|
636
|
+
clearTimeout(pending.timer);
|
|
637
|
+
if (frame.ok === true) {
|
|
638
|
+
pending.resolve(frame.payload);
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
const errorBody = asObject(frame.error);
|
|
642
|
+
const message = asString(errorBody?.message) || `gateway method failed: ${pending.method}`;
|
|
643
|
+
pending.reject(new Error(message));
|
|
644
|
+
}
|
|
645
|
+
rejectAllPending(error) {
|
|
646
|
+
for (const [, pending] of this.pending) {
|
|
647
|
+
clearTimeout(pending.timer);
|
|
648
|
+
pending.reject(error);
|
|
649
|
+
}
|
|
650
|
+
this.pending.clear();
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
/**
|
|
654
|
+
* Bridges A2A inbound messages to OpenClaw agent dispatch.
|
|
655
|
+
*
|
|
656
|
+
* - Dispatches to an OpenClaw agent via Gateway RPC (agent server method)
|
|
657
|
+
* - On success: publishes a complete Task with "completed" state and artifacts
|
|
658
|
+
* - On dispatch failure: keeps legacy fallback text and attempts `/hooks/wake`
|
|
659
|
+
*/
|
|
660
|
+
export class OpenClawAgentExecutor {
|
|
661
|
+
api;
|
|
662
|
+
defaultAgentId;
|
|
663
|
+
agentResponseTimeoutMs;
|
|
664
|
+
security;
|
|
665
|
+
taskContextByTaskId;
|
|
666
|
+
constructor(api, config) {
|
|
667
|
+
this.api = api;
|
|
668
|
+
this.defaultAgentId = config.routing.defaultAgentId;
|
|
669
|
+
this.security = config.security;
|
|
670
|
+
const configured = config.timeouts?.agentResponseTimeoutMs;
|
|
671
|
+
this.agentResponseTimeoutMs =
|
|
672
|
+
typeof configured === "number" && Number.isFinite(configured) && configured >= 1000
|
|
673
|
+
? configured
|
|
674
|
+
: DEFAULT_AGENT_RESPONSE_TIMEOUT_MS;
|
|
675
|
+
this.taskContextByTaskId = new Map();
|
|
676
|
+
}
|
|
677
|
+
async execute(requestContext, eventBus) {
|
|
678
|
+
const agentId = pickAgentId(requestContext, this.defaultAgentId);
|
|
679
|
+
const taskId = requestContext.taskId;
|
|
680
|
+
const contextId = requestContext.contextId;
|
|
681
|
+
this.rememberTaskContext(taskId, contextId);
|
|
682
|
+
// Carry forward conversation history from previous rounds (if any).
|
|
683
|
+
// The SDK's ResultManager replaces currentTask with { ...taskEvent }, so
|
|
684
|
+
// omitting history would wipe out prior messages.
|
|
685
|
+
//
|
|
686
|
+
// IMPORTANT: The SDK's _createRequestContext already appends the current
|
|
687
|
+
// user message to task.history before calling execute(). The ResultManager
|
|
688
|
+
// then checks messageId to avoid double-adding. We rely on this dedup —
|
|
689
|
+
// do NOT manually strip the current message from existingHistory.
|
|
690
|
+
const rawHistory = requestContext.task?.history ?? [];
|
|
691
|
+
const existingHistory = rawHistory.length > MAX_HISTORY_MESSAGES
|
|
692
|
+
? rawHistory.slice(-MAX_HISTORY_MESSAGES)
|
|
693
|
+
: rawHistory;
|
|
694
|
+
// Publish initial "working" state so the task is trackable during async dispatch
|
|
695
|
+
const workingTask = {
|
|
696
|
+
kind: "task",
|
|
697
|
+
id: taskId,
|
|
698
|
+
contextId,
|
|
699
|
+
status: {
|
|
700
|
+
state: "working",
|
|
701
|
+
timestamp: new Date().toISOString(),
|
|
702
|
+
},
|
|
703
|
+
history: existingHistory,
|
|
704
|
+
};
|
|
705
|
+
eventBus.publish(workingTask);
|
|
706
|
+
// Validate inbound FileParts before dispatching to the agent
|
|
707
|
+
const fileValidationError = this.validateInboundFileParts(requestContext.userMessage);
|
|
708
|
+
if (fileValidationError) {
|
|
709
|
+
this.api.logger.warn(`a2a-gateway: inbound file validation failed: ${fileValidationError}`);
|
|
710
|
+
const rejectedMessage = {
|
|
711
|
+
kind: "message",
|
|
712
|
+
messageId: uuidv4(),
|
|
713
|
+
role: "agent",
|
|
714
|
+
parts: [{ kind: "text", text: `File validation failed: ${fileValidationError}` }],
|
|
715
|
+
contextId,
|
|
716
|
+
};
|
|
717
|
+
const rejectedTask = {
|
|
718
|
+
kind: "task",
|
|
719
|
+
id: taskId,
|
|
720
|
+
contextId,
|
|
721
|
+
status: {
|
|
722
|
+
state: "failed",
|
|
723
|
+
message: rejectedMessage,
|
|
724
|
+
timestamp: new Date().toISOString(),
|
|
725
|
+
},
|
|
726
|
+
history: existingHistory,
|
|
727
|
+
};
|
|
728
|
+
eventBus.publish(rejectedTask);
|
|
729
|
+
eventBus.finished();
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
let agentResponse;
|
|
733
|
+
// Emit periodic heartbeat events while the agent is working.
|
|
734
|
+
// This keeps SSE connections alive and signals that the task is still in progress.
|
|
735
|
+
const heartbeat = setInterval(() => {
|
|
736
|
+
const heartbeatTask = {
|
|
737
|
+
kind: "task",
|
|
738
|
+
id: taskId,
|
|
739
|
+
contextId,
|
|
740
|
+
status: {
|
|
741
|
+
state: "working",
|
|
742
|
+
timestamp: new Date().toISOString(),
|
|
743
|
+
},
|
|
744
|
+
history: existingHistory,
|
|
745
|
+
};
|
|
746
|
+
eventBus.publish(heartbeatTask);
|
|
747
|
+
}, STREAMING_HEARTBEAT_INTERVAL_MS);
|
|
748
|
+
try {
|
|
749
|
+
agentResponse = await this.dispatchViaGatewayRpc(agentId, requestContext.userMessage, contextId);
|
|
750
|
+
}
|
|
751
|
+
catch (err) {
|
|
752
|
+
clearInterval(heartbeat);
|
|
753
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
754
|
+
const truncatedError = errorMessage.length > 500 ? errorMessage.slice(0, 500) + "..." : errorMessage;
|
|
755
|
+
this.api.logger.error(`a2a-gateway: agent dispatch failed: ${truncatedError}`);
|
|
756
|
+
await this.tryHooksWakeFallback(agentId, taskId, contextId, requestContext.userMessage);
|
|
757
|
+
// Return failed task status so the caller knows dispatch did not succeed.
|
|
758
|
+
const failedMessage = {
|
|
759
|
+
kind: "message",
|
|
760
|
+
messageId: uuidv4(),
|
|
761
|
+
role: "agent",
|
|
762
|
+
parts: [{ kind: "text", text: `Agent dispatch failed: ${errorMessage}` }],
|
|
763
|
+
contextId,
|
|
764
|
+
};
|
|
765
|
+
const failedTask = {
|
|
766
|
+
kind: "task",
|
|
767
|
+
id: taskId,
|
|
768
|
+
contextId,
|
|
769
|
+
status: {
|
|
770
|
+
state: "failed",
|
|
771
|
+
message: failedMessage,
|
|
772
|
+
timestamp: new Date().toISOString(),
|
|
773
|
+
},
|
|
774
|
+
history: existingHistory,
|
|
775
|
+
};
|
|
776
|
+
eventBus.publish(failedTask);
|
|
777
|
+
eventBus.finished();
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
clearInterval(heartbeat);
|
|
781
|
+
// Publish completed Task with artifact (text + optional file parts)
|
|
782
|
+
const responseParts = buildResponseParts(agentResponse);
|
|
783
|
+
const responseMessage = {
|
|
784
|
+
kind: "message",
|
|
785
|
+
messageId: uuidv4(),
|
|
786
|
+
role: "agent",
|
|
787
|
+
parts: responseParts,
|
|
788
|
+
contextId,
|
|
789
|
+
};
|
|
790
|
+
const completedTask = {
|
|
791
|
+
kind: "task",
|
|
792
|
+
id: taskId,
|
|
793
|
+
contextId,
|
|
794
|
+
status: {
|
|
795
|
+
state: "completed",
|
|
796
|
+
message: responseMessage,
|
|
797
|
+
timestamp: new Date().toISOString(),
|
|
798
|
+
},
|
|
799
|
+
history: existingHistory,
|
|
800
|
+
artifacts: [
|
|
801
|
+
{
|
|
802
|
+
artifactId: uuidv4(),
|
|
803
|
+
parts: responseParts,
|
|
804
|
+
},
|
|
805
|
+
],
|
|
806
|
+
};
|
|
807
|
+
eventBus.publish(completedTask);
|
|
808
|
+
eventBus.finished();
|
|
809
|
+
}
|
|
810
|
+
// cancelTask intentionally omits history: it only receives taskId (no
|
|
811
|
+
// RequestContext), so loading history would require a TaskStore reference
|
|
812
|
+
// that the executor doesn't hold. Cancellation is a terminal state where
|
|
813
|
+
// consumers care about status, not conversation history.
|
|
814
|
+
async cancelTask(taskId, eventBus) {
|
|
815
|
+
const contextId = this.taskContextByTaskId.get(taskId);
|
|
816
|
+
if (!contextId) {
|
|
817
|
+
this.api.logger.warn(`a2a-gateway: cancelTask missing contextId for task ${taskId}; skipping cancel publish`);
|
|
818
|
+
eventBus.finished();
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
const canceledTask = {
|
|
822
|
+
kind: "task",
|
|
823
|
+
id: taskId,
|
|
824
|
+
contextId,
|
|
825
|
+
status: {
|
|
826
|
+
state: "canceled",
|
|
827
|
+
timestamp: new Date().toISOString(),
|
|
828
|
+
},
|
|
829
|
+
};
|
|
830
|
+
eventBus.publish(canceledTask);
|
|
831
|
+
this.taskContextByTaskId.delete(taskId);
|
|
832
|
+
eventBus.finished();
|
|
833
|
+
}
|
|
834
|
+
rememberTaskContext(taskId, contextId) {
|
|
835
|
+
if (this.taskContextByTaskId.has(taskId)) {
|
|
836
|
+
this.taskContextByTaskId.delete(taskId);
|
|
837
|
+
}
|
|
838
|
+
this.taskContextByTaskId.set(taskId, contextId);
|
|
839
|
+
if (this.taskContextByTaskId.size > TASK_CONTEXT_CACHE_LIMIT) {
|
|
840
|
+
const oldestTaskId = this.taskContextByTaskId.keys().next().value;
|
|
841
|
+
if (oldestTaskId) {
|
|
842
|
+
this.taskContextByTaskId.delete(oldestTaskId);
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
async dispatchViaGatewayRpc(agentId, userMessage, contextId) {
|
|
847
|
+
const messageText = extractInboundMessageText(userMessage);
|
|
848
|
+
const gatewayConfig = this.resolveGatewayRuntimeConfig();
|
|
849
|
+
const gateway = new GatewayRpcConnection(gatewayConfig);
|
|
850
|
+
await gateway.connect();
|
|
851
|
+
try {
|
|
852
|
+
// Derive a deterministic session key from A2A contextId for:
|
|
853
|
+
// 1. Session reuse across messages in the same A2A context (conversation continuity)
|
|
854
|
+
// 2. Isolation between different A2A contexts (no cross-contamination)
|
|
855
|
+
// The gateway `agent` RPC auto-creates the session if it doesn't exist.
|
|
856
|
+
const sessionKey = `agent:${agentId}:a2a:${contextId}`;
|
|
857
|
+
const runId = uuidv4();
|
|
858
|
+
const agentParams = {
|
|
859
|
+
agentId,
|
|
860
|
+
message: messageText,
|
|
861
|
+
deliver: false,
|
|
862
|
+
idempotencyKey: runId,
|
|
863
|
+
sessionKey,
|
|
864
|
+
};
|
|
865
|
+
const finalPayload = await gateway.request("agent", agentParams, this.agentResponseTimeoutMs, true);
|
|
866
|
+
const finalBody = asObject(finalPayload);
|
|
867
|
+
const status = asString(finalBody?.status);
|
|
868
|
+
if (status && status !== "ok") {
|
|
869
|
+
const summary = asString(finalBody?.summary) || "Agent run did not complete";
|
|
870
|
+
throw new Error(summary);
|
|
871
|
+
}
|
|
872
|
+
const agentResponse = extractAgentResponse(finalPayload);
|
|
873
|
+
if (agentResponse) {
|
|
874
|
+
return agentResponse;
|
|
875
|
+
}
|
|
876
|
+
// sessionKey is always available (deterministic from contextId),
|
|
877
|
+
// so we can always try to retrieve the latest assistant reply from history.
|
|
878
|
+
const historyPayload = await gateway.request("chat.history", { sessionKey, limit: 50 }, GATEWAY_REQUEST_TIMEOUT_MS, false);
|
|
879
|
+
const historyText = extractLatestAssistantReply(historyPayload);
|
|
880
|
+
if (historyText) {
|
|
881
|
+
return { text: historyText, mediaUrls: [] };
|
|
882
|
+
}
|
|
883
|
+
throw new Error("No assistant response text returned by gateway");
|
|
884
|
+
}
|
|
885
|
+
finally {
|
|
886
|
+
gateway.close();
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
resolveGatewayRuntimeConfig() {
|
|
890
|
+
const config = asObject(this.api.config) || {};
|
|
891
|
+
const gateway = asObject(config.gateway) || {};
|
|
892
|
+
const gatewayAuth = asObject(gateway.auth) || {};
|
|
893
|
+
const hooks = asObject(config.hooks) || {};
|
|
894
|
+
const gatewayTls = asObject(gateway.tls) || {};
|
|
895
|
+
const port = asFiniteNumber(gateway.port) || 18_789;
|
|
896
|
+
const tlsEnabled = gatewayTls.enabled === true;
|
|
897
|
+
const scheme = tlsEnabled ? "wss" : "ws";
|
|
898
|
+
return {
|
|
899
|
+
port,
|
|
900
|
+
wsUrl: `${scheme}://localhost:${port}`,
|
|
901
|
+
hooksWakeUrl: `http://localhost:${port}/hooks/wake`,
|
|
902
|
+
gatewayToken: asString(gatewayAuth.token) || "",
|
|
903
|
+
gatewayPassword: asString(gatewayAuth.password) || "",
|
|
904
|
+
hooksToken: asString(hooks.token) || "",
|
|
905
|
+
};
|
|
906
|
+
}
|
|
907
|
+
/**
|
|
908
|
+
* Validate inbound FileParts for scheme/IP safety, MIME, and size.
|
|
909
|
+
*
|
|
910
|
+
* Inbound validation is lighter than outbound (a2a_send_file):
|
|
911
|
+
* - Scheme + IP-literal check only (no DNS resolution — we don't fetch the URL)
|
|
912
|
+
* - MIME whitelist
|
|
913
|
+
* - Inline size limit
|
|
914
|
+
*/
|
|
915
|
+
validateInboundFileParts(userMessage) {
|
|
916
|
+
const parts = this.extractFileParts(userMessage);
|
|
917
|
+
if (parts.length === 0)
|
|
918
|
+
return null;
|
|
919
|
+
for (const part of parts) {
|
|
920
|
+
const file = asObject(part.file);
|
|
921
|
+
if (!file)
|
|
922
|
+
continue;
|
|
923
|
+
const uri = asString(file.uri);
|
|
924
|
+
const mimeType = asString(file.mimeType);
|
|
925
|
+
const bytes = asString(file.bytes);
|
|
926
|
+
// URI-based file: scheme + IP literal check + MIME
|
|
927
|
+
if (uri) {
|
|
928
|
+
const schemeCheck = validateUriSchemeAndIp(uri);
|
|
929
|
+
if (schemeCheck) {
|
|
930
|
+
return `URI blocked: ${sanitizeUriForLog(uri)} — ${schemeCheck}`;
|
|
931
|
+
}
|
|
932
|
+
if (mimeType && !validateMimeType(mimeType, this.security.allowedMimeTypes)) {
|
|
933
|
+
return `MIME type rejected: "${mimeType}"`;
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
// Inline base64 file: size + MIME check
|
|
937
|
+
if (bytes) {
|
|
938
|
+
const decodedSize = decodedBase64Size(bytes);
|
|
939
|
+
const sizeCheck = checkFileSize(decodedSize, this.security.maxInlineFileSizeBytes);
|
|
940
|
+
if (!sizeCheck.ok) {
|
|
941
|
+
return `Inline file too large: ${sizeCheck.reason}`;
|
|
942
|
+
}
|
|
943
|
+
if (mimeType && !validateMimeType(mimeType, this.security.allowedMimeTypes)) {
|
|
944
|
+
return `MIME type rejected: "${mimeType}"`;
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
return null;
|
|
949
|
+
}
|
|
950
|
+
extractFileParts(value) {
|
|
951
|
+
const results = [];
|
|
952
|
+
if (!value || typeof value !== "object")
|
|
953
|
+
return results;
|
|
954
|
+
const obj = value;
|
|
955
|
+
if (obj.kind === "file" && obj.file) {
|
|
956
|
+
results.push(obj);
|
|
957
|
+
return results;
|
|
958
|
+
}
|
|
959
|
+
const parts = Array.isArray(obj.parts) ? obj.parts : [];
|
|
960
|
+
for (const p of parts) {
|
|
961
|
+
if (p && typeof p === "object") {
|
|
962
|
+
const part = p;
|
|
963
|
+
if (part.kind === "file" && part.file) {
|
|
964
|
+
results.push(part);
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
return results;
|
|
969
|
+
}
|
|
970
|
+
async tryHooksWakeFallback(agentId, taskId, contextId, userMessage) {
|
|
971
|
+
const config = this.resolveGatewayRuntimeConfig();
|
|
972
|
+
if (!config.hooksToken) {
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
const text = extractInboundMessageText(userMessage);
|
|
976
|
+
const wakeText = `[A2A_INBOUND] agentId=${agentId} taskId=${taskId} contextId=${contextId} message=${text}`;
|
|
977
|
+
try {
|
|
978
|
+
await fetch(config.hooksWakeUrl, {
|
|
979
|
+
method: "POST",
|
|
980
|
+
headers: {
|
|
981
|
+
authorization: `Bearer ${config.hooksToken}`,
|
|
982
|
+
"content-type": "application/json",
|
|
983
|
+
},
|
|
984
|
+
body: JSON.stringify({ text: wakeText }),
|
|
985
|
+
signal: AbortSignal.timeout(HOOKS_WAKE_TIMEOUT_MS),
|
|
986
|
+
});
|
|
987
|
+
}
|
|
988
|
+
catch (error) {
|
|
989
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
990
|
+
this.api.logger.warn(`a2a-gateway: hooks/wake fallback failed (${message})`);
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
//# sourceMappingURL=executor.js.map
|