@contextableai/clawg-ui 0.2.8 → 0.3.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.
@@ -1,619 +0,0 @@
1
- import type { IncomingMessage, ServerResponse } from "node:http";
2
- import { randomUUID, createHmac, timingSafeEqual } from "node:crypto";
3
- import { EventType } from "@ag-ui/core";
4
- import type { RunAgentInput, Message } from "@ag-ui/core";
5
- import { EventEncoder } from "@ag-ui/encoder";
6
- import type { OpenClawPluginApi, PluginRuntime } from "openclaw/plugin-sdk";
7
- import {
8
- stashTools,
9
- setWriter,
10
- clearWriter,
11
- markClientToolNames,
12
- wasClientToolCalled,
13
- clearClientToolCalled,
14
- clearClientToolNames,
15
- wasToolFiredInRun,
16
- clearToolFiredInRun,
17
- } from "./tool-store.js";
18
- import { aguiChannelPlugin } from "./channel.js";
19
- import { resolveGatewaySecret } from "./gateway-secret.js";
20
-
21
- // ---------------------------------------------------------------------------
22
- // Lightweight HTTP helpers (no internal imports needed)
23
- // ---------------------------------------------------------------------------
24
-
25
- function sendJson(res: ServerResponse, status: number, body: unknown) {
26
- res.statusCode = status;
27
- res.setHeader("Content-Type", "application/json; charset=utf-8");
28
- res.end(JSON.stringify(body));
29
- }
30
-
31
- function sendMethodNotAllowed(res: ServerResponse) {
32
- res.setHeader("Allow", "POST");
33
- res.statusCode = 405;
34
- res.setHeader("Content-Type", "text/plain; charset=utf-8");
35
- res.end("Method Not Allowed");
36
- }
37
-
38
- function sendUnauthorized(res: ServerResponse) {
39
- sendJson(res, 401, { error: { message: "Authentication required", type: "unauthorized" } });
40
- }
41
-
42
- function readJsonBody(req: IncomingMessage, maxBytes: number): Promise<unknown> {
43
- return new Promise((resolve, reject) => {
44
- const chunks: Buffer[] = [];
45
- let size = 0;
46
- req.on("data", (chunk: Buffer) => {
47
- size += chunk.length;
48
- if (size > maxBytes) {
49
- reject(new Error("Request body too large"));
50
- req.destroy();
51
- return;
52
- }
53
- chunks.push(chunk);
54
- });
55
- req.on("end", () => {
56
- try {
57
- resolve(JSON.parse(Buffer.concat(chunks).toString("utf-8")));
58
- } catch {
59
- reject(new Error("Invalid JSON body"));
60
- }
61
- });
62
- req.on("error", reject);
63
- });
64
- }
65
-
66
- function getBearerToken(req: IncomingMessage): string | undefined {
67
- const raw = req.headers.authorization?.trim() ?? "";
68
- if (!raw.toLowerCase().startsWith("bearer ")) {
69
- return undefined;
70
- }
71
- return raw.slice(7).trim() || undefined;
72
- }
73
-
74
- // ---------------------------------------------------------------------------
75
- // HMAC-signed device token utilities
76
- // ---------------------------------------------------------------------------
77
-
78
- function createDeviceToken(secret: string, deviceId: string): string {
79
- const encodedId = Buffer.from(deviceId).toString("base64url");
80
- const signature = createHmac("sha256", secret).update(deviceId).digest("hex").slice(0, 32);
81
- return `${encodedId}.${signature}`;
82
- }
83
-
84
- function verifyDeviceToken(token: string, secret: string): string | null {
85
- const dotIndex = token.indexOf(".");
86
- if (dotIndex <= 0 || dotIndex >= token.length - 1) {
87
- return null;
88
- }
89
-
90
- const encodedId = token.slice(0, dotIndex);
91
- const providedSig = token.slice(dotIndex + 1);
92
-
93
- try {
94
- const deviceId = Buffer.from(encodedId, "base64url").toString("utf-8");
95
-
96
- // Validate it looks like a UUID
97
- if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(deviceId)) {
98
- return null;
99
- }
100
-
101
- const expectedSig = createHmac("sha256", secret).update(deviceId).digest("hex").slice(0, 32);
102
-
103
- // Constant-time comparison
104
- if (providedSig.length !== expectedSig.length) {
105
- return null;
106
- }
107
- const providedBuf = Buffer.from(providedSig);
108
- const expectedBuf = Buffer.from(expectedSig);
109
- if (!timingSafeEqual(providedBuf, expectedBuf)) {
110
- return null;
111
- }
112
-
113
- return deviceId;
114
- } catch {
115
- return null;
116
- }
117
- }
118
-
119
- // ---------------------------------------------------------------------------
120
- // Extract text from AG-UI messages
121
- // ---------------------------------------------------------------------------
122
-
123
- function extractTextContent(msg: Message): string {
124
- if (typeof msg.content === "string") {
125
- return msg.content;
126
- }
127
- return "";
128
- }
129
-
130
- // ---------------------------------------------------------------------------
131
- // Build MsgContext-compatible body from AG-UI messages
132
- // ---------------------------------------------------------------------------
133
-
134
- function buildBodyFromMessages(messages: Message[]): {
135
- body: string;
136
- systemPrompt?: string;
137
- } {
138
- const systemParts: string[] = [];
139
- const parts: string[] = [];
140
- let lastUserBody = "";
141
- let lastToolBody = "";
142
-
143
- for (const msg of messages) {
144
- const role = msg.role?.trim() ?? "";
145
- const content = extractTextContent(msg).trim();
146
- // Allow messages with no content (e.g., assistant with only toolCalls)
147
- if (!role) {
148
- continue;
149
- }
150
- if (role === "system") {
151
- if (content) systemParts.push(content);
152
- continue;
153
- }
154
- if (role === "user") {
155
- lastUserBody = content;
156
- if (content) parts.push(`User: ${content}`);
157
- } else if (role === "assistant") {
158
- if (content) parts.push(`Assistant: ${content}`);
159
- } else if (role === "tool") {
160
- lastToolBody = content;
161
- if (content) parts.push(`Tool result: ${content}`);
162
- }
163
- }
164
-
165
- // If there's only a single user message, use it directly (no envelope needed)
166
- // If there's only a tool result (resuming after client tool), use it directly
167
- const userMessages = messages.filter((m) => m.role === "user");
168
- const toolMessages = messages.filter((m) => m.role === "tool");
169
- let body: string;
170
- if (userMessages.length === 1 && parts.length === 1) {
171
- body = lastUserBody;
172
- } else if (userMessages.length === 0 && toolMessages.length > 0 && parts.length === toolMessages.length) {
173
- // Tool-result-only submission: format as tool result for agent context
174
- body = `Tool result: ${lastToolBody}`;
175
- } else {
176
- body = parts.join("\n");
177
- }
178
-
179
- return {
180
- body,
181
- systemPrompt: systemParts.length > 0 ? systemParts.join("\n\n") : undefined,
182
- };
183
- }
184
-
185
- // ---------------------------------------------------------------------------
186
- // HTTP handler factory
187
- // ---------------------------------------------------------------------------
188
-
189
- export function createAguiHttpHandler(api: OpenClawPluginApi) {
190
- const runtime: PluginRuntime = api.runtime;
191
-
192
- // Resolve once at init so the per-request handler never touches env vars.
193
- const gatewaySecret = resolveGatewaySecret(api);
194
-
195
- return async function handleAguiRequest(
196
- req: IncomingMessage,
197
- res: ServerResponse,
198
- ): Promise<void> {
199
- // POST-only
200
- if (req.method !== "POST") {
201
- sendMethodNotAllowed(res);
202
- return;
203
- }
204
-
205
- // Verify gateway secret was resolved at startup
206
- if (!gatewaySecret) {
207
- sendJson(res, 500, {
208
- error: { message: "Gateway not configured", type: "server_error" },
209
- });
210
- return;
211
- }
212
-
213
- // ---------------------------------------------------------------------------
214
- // Authentication: No auth (pairing initiation) or Device token
215
- // ---------------------------------------------------------------------------
216
- let deviceId: string;
217
-
218
- const bearerToken = getBearerToken(req);
219
-
220
- if (!bearerToken) {
221
- // No auth header: initiate pairing
222
- // Generate new device ID
223
- deviceId = randomUUID();
224
-
225
- // Add to pending via OpenClaw pairing API - returns a pairing code for approval
226
- const { code: pairingCode } = await runtime.channel.pairing.upsertPairingRequest({
227
- channel: "clawg-ui",
228
- id: deviceId,
229
- pairingAdapter: aguiChannelPlugin.pairing,
230
- });
231
-
232
- // Rate limit reached - max pending requests exceeded
233
- if (!pairingCode) {
234
- sendJson(res, 429, {
235
- error: {
236
- type: "rate_limit",
237
- message: "Too many pending pairing requests. Please wait for existing requests to expire (10 minutes) or ask the owner to approve/reject them.",
238
- },
239
- });
240
- return;
241
- }
242
-
243
- // Generate signed device token
244
- const deviceToken = createDeviceToken(gatewaySecret, deviceId);
245
-
246
- // Return pairing pending response with device token and pairing code
247
- sendJson(res, 403, {
248
- error: {
249
- type: "pairing_pending",
250
- message: "Device pending approval",
251
- pairing: {
252
- pairingCode,
253
- token: deviceToken,
254
- instructions: `Save this token for use as a Bearer token and ask the owner to approve: openclaw pairing approve clawg-ui ${pairingCode}`,
255
- },
256
- },
257
- });
258
- return;
259
- }
260
-
261
- // Device token flow: verify HMAC signature, extract device ID
262
- const extractedDeviceId = verifyDeviceToken(bearerToken, gatewaySecret);
263
- if (!extractedDeviceId) {
264
- sendUnauthorized(res);
265
- return;
266
- }
267
- deviceId = extractedDeviceId;
268
-
269
- // ---------------------------------------------------------------------------
270
- // Pairing check: verify device is approved
271
- // ---------------------------------------------------------------------------
272
- const storeAllowFrom = await runtime.channel.pairing
273
- .readAllowFromStore("clawg-ui")
274
- .catch(() => []);
275
- const normalizedAllowFrom = storeAllowFrom.map((e) =>
276
- e.replace(/^clawg-ui:/i, "").toLowerCase(),
277
- );
278
- const allowed = normalizedAllowFrom.includes(deviceId.toLowerCase());
279
-
280
- if (!allowed) {
281
- sendJson(res, 403, {
282
- error: {
283
- type: "pairing_pending",
284
- message: "Device pending approval. Ask the owner to approve using the pairing code from your initial pairing response.",
285
- },
286
- });
287
- return;
288
- }
289
-
290
- // ---------------------------------------------------------------------------
291
- // Device approved - proceed with request
292
- // ---------------------------------------------------------------------------
293
-
294
- // Parse body
295
- let body: unknown;
296
- try {
297
- body = await readJsonBody(req, 1024 * 1024);
298
- } catch (err) {
299
- sendJson(res, 400, {
300
- error: { message: String(err), type: "invalid_request_error" },
301
- });
302
- return;
303
- }
304
-
305
- const input = body as RunAgentInput;
306
- const threadId = input.threadId || `clawg-ui-${randomUUID()}`;
307
- const runId = input.runId || `clawg-ui-run-${randomUUID()}`;
308
-
309
- // Validate messages
310
- const messages: Message[] = Array.isArray(input.messages)
311
- ? input.messages
312
- : [];
313
-
314
- const hasUserMessage = messages.some((m) => m.role === "user");
315
- const hasToolMessage = messages.some((m) => m.role === "tool");
316
- if (!hasUserMessage && !hasToolMessage) {
317
- sendJson(res, 400, {
318
- error: {
319
- message: "At least one user or tool message is required in `messages`.",
320
- type: "invalid_request_error",
321
- },
322
- });
323
- return;
324
- }
325
-
326
- // Build body from messages
327
- const { body: messageBody } = buildBodyFromMessages(messages);
328
- if (!messageBody.trim()) {
329
- sendJson(res, 400, {
330
- error: {
331
- message: "Could not extract a prompt from `messages`.",
332
- type: "invalid_request_error",
333
- },
334
- });
335
- return;
336
- }
337
-
338
- // Resolve agent route
339
- const cfg = runtime.config.loadConfig();
340
- const route = runtime.channel.routing.resolveAgentRoute({
341
- cfg,
342
- channel: "clawg-ui",
343
- peer: { kind: "dm", id: `clawg-ui-${threadId}` },
344
- });
345
-
346
- // Set up SSE via EventEncoder
347
- const accept =
348
- typeof req.headers.accept === "string"
349
- ? req.headers.accept
350
- : "text/event-stream";
351
- const encoder = new EventEncoder({ accept });
352
- res.statusCode = 200;
353
- res.setHeader("Content-Type", encoder.getContentType());
354
- res.setHeader("Cache-Control", "no-cache");
355
- res.setHeader("Connection", "keep-alive");
356
- res.setHeader("X-Accel-Buffering", "no");
357
- res.flushHeaders?.();
358
-
359
- let closed = false;
360
- let currentMessageId = `msg-${randomUUID()}`;
361
- let messageStarted = false;
362
- let currentRunId = runId;
363
-
364
- const writeEvent = (event: { type: EventType } & Record<string, unknown>) => {
365
- if (closed) {
366
- return;
367
- }
368
- try {
369
- res.write(encoder.encode(event as Parameters<typeof encoder.encode>[0]));
370
- } catch {
371
- // Client may have disconnected
372
- closed = true;
373
- }
374
- };
375
-
376
- // If a tool call was emitted in the current run, finish that run and start
377
- // a fresh one for text messages. This keeps tool events and text events in
378
- // separate runs per the AG-UI protocol.
379
- const splitRunIfToolFired = () => {
380
- if (!wasToolFiredInRun(sessionKey)) {
381
- return;
382
- }
383
- // Close any open text message before ending the run
384
- if (messageStarted) {
385
- writeEvent({
386
- type: EventType.TEXT_MESSAGE_END,
387
- messageId: currentMessageId,
388
- runId: currentRunId,
389
- });
390
- messageStarted = false;
391
- }
392
- // End the tool run
393
- writeEvent({
394
- type: EventType.RUN_FINISHED,
395
- threadId,
396
- runId: currentRunId,
397
- });
398
- // Start a new run for text messages
399
- currentRunId = `clawg-ui-run-${randomUUID()}`;
400
- currentMessageId = `msg-${randomUUID()}`;
401
- messageStarted = false;
402
- clearToolFiredInRun(sessionKey);
403
- writeEvent({
404
- type: EventType.RUN_STARTED,
405
- threadId,
406
- runId: currentRunId,
407
- });
408
- };
409
-
410
- // Handle client disconnect
411
- req.on("close", () => {
412
- closed = true;
413
- });
414
-
415
- // Emit RUN_STARTED
416
- writeEvent({
417
- type: EventType.RUN_STARTED,
418
- threadId,
419
- runId,
420
- });
421
-
422
- // Build inbound context using the plugin runtime (same pattern as msteams)
423
- const sessionKey = route.sessionKey;
424
-
425
- // Stash client-provided tools so the plugin tool factory can pick them up
426
- if (Array.isArray(input.tools) && input.tools.length > 0) {
427
- stashTools(sessionKey, input.tools);
428
- markClientToolNames(
429
- sessionKey,
430
- input.tools.map((t: { name: string }) => t.name),
431
- );
432
- }
433
-
434
- // Register SSE writer so before/after_tool_call hooks can emit AG-UI events
435
- setWriter(sessionKey, writeEvent, currentMessageId);
436
- const storePath = runtime.channel.session.resolveStorePath(cfg.session?.store, {
437
- agentId: route.agentId,
438
- });
439
- const envelopeOptions = runtime.channel.reply.resolveEnvelopeFormatOptions(cfg);
440
- const previousTimestamp = runtime.channel.session.readSessionUpdatedAt({
441
- storePath,
442
- sessionKey,
443
- });
444
- const envelopedBody = runtime.channel.reply.formatAgentEnvelope({
445
- channel: "AG-UI",
446
- from: "User",
447
- timestamp: new Date(),
448
- previousTimestamp,
449
- envelope: envelopeOptions,
450
- body: messageBody,
451
- });
452
-
453
- const ctxPayload = runtime.channel.reply.finalizeInboundContext({
454
- Body: envelopedBody,
455
- RawBody: messageBody,
456
- CommandBody: messageBody,
457
- From: `clawg-ui:${deviceId}`,
458
- To: "clawg-ui",
459
- SessionKey: sessionKey,
460
- ChatType: "direct",
461
- ConversationLabel: "AG-UI",
462
- SenderName: "AG-UI Client",
463
- SenderId: deviceId,
464
- Provider: "clawg-ui" as const,
465
- Surface: "clawg-ui" as const,
466
- MessageSid: runId,
467
- Timestamp: Date.now(),
468
- WasMentioned: true,
469
- CommandAuthorized: true,
470
- OriginatingChannel: "clawg-ui" as const,
471
- });
472
-
473
- // Record inbound session
474
- await runtime.channel.session.recordInboundSession({
475
- storePath,
476
- sessionKey,
477
- ctx: ctxPayload,
478
- onRecordError: () => {},
479
- });
480
-
481
- // Create reply dispatcher — translates reply payloads into AG-UI SSE events
482
- const abortController = new AbortController();
483
- req.on("close", () => {
484
- abortController.abort();
485
- });
486
-
487
- const dispatcher = {
488
- sendToolResult: (_payload: { text?: string }) => {
489
- // Tool call events are emitted by before/after_tool_call hooks
490
- return !closed;
491
- },
492
- sendBlockReply: (payload: { text?: string }) => {
493
- if (closed || wasClientToolCalled(sessionKey)) {
494
- return false;
495
- }
496
- const text = payload.text?.trim();
497
- if (!text) {
498
- return false;
499
- }
500
-
501
- splitRunIfToolFired();
502
-
503
- if (!messageStarted) {
504
- messageStarted = true;
505
- writeEvent({
506
- type: EventType.TEXT_MESSAGE_START,
507
- messageId: currentMessageId,
508
- runId: currentRunId,
509
- role: "assistant",
510
- });
511
- }
512
-
513
- // Join chunks with \n\n (breakPreference: paragraph uses double-newline joiner)
514
- writeEvent({
515
- type: EventType.TEXT_MESSAGE_CONTENT,
516
- messageId: currentMessageId,
517
- runId: currentRunId,
518
- delta: text + "\n\n",
519
- });
520
- return true;
521
- },
522
- sendFinalReply: (payload: { text?: string }) => {
523
- if (closed) {
524
- return false;
525
- }
526
- const text = wasClientToolCalled(sessionKey) ? "" : payload.text?.trim();
527
-
528
- if (text) {
529
- splitRunIfToolFired();
530
-
531
- if (!messageStarted) {
532
- messageStarted = true;
533
- writeEvent({
534
- type: EventType.TEXT_MESSAGE_START,
535
- messageId: currentMessageId,
536
- runId: currentRunId,
537
- role: "assistant",
538
- });
539
- }
540
- // Join chunks with \n\n (breakPreference: paragraph uses double-newline joiner)
541
- writeEvent({
542
- type: EventType.TEXT_MESSAGE_CONTENT,
543
- messageId: currentMessageId,
544
- runId: currentRunId,
545
- delta: text + "\n\n",
546
- });
547
- }
548
- // End the message and run
549
- if (messageStarted) {
550
- writeEvent({
551
- type: EventType.TEXT_MESSAGE_END,
552
- messageId: currentMessageId,
553
- runId: currentRunId,
554
- });
555
- }
556
- writeEvent({
557
- type: EventType.RUN_FINISHED,
558
- threadId,
559
- runId: currentRunId,
560
- });
561
- closed = true;
562
- res.end();
563
- return true;
564
- },
565
- waitForIdle: () => Promise.resolve(),
566
- getQueuedCounts: () => ({ tool: 0, block: 0, final: 0 }),
567
- };
568
-
569
- // Dispatch the inbound message — this triggers the agent run
570
- try {
571
- await runtime.channel.reply.dispatchReplyFromConfig({
572
- ctx: ctxPayload,
573
- cfg,
574
- dispatcher,
575
- replyOptions: {
576
- runId,
577
- abortSignal: abortController.signal,
578
- disableBlockStreaming: false,
579
- onAgentRunStart: () => {},
580
- onToolResult: () => {
581
- // Tool call events are emitted by before/after_tool_call hooks
582
- },
583
- },
584
- });
585
-
586
- // If the dispatcher's final reply didn't close the stream, close it now
587
- if (!closed) {
588
- if (messageStarted) {
589
- writeEvent({
590
- type: EventType.TEXT_MESSAGE_END,
591
- messageId: currentMessageId,
592
- runId: currentRunId,
593
- });
594
- }
595
- writeEvent({
596
- type: EventType.RUN_FINISHED,
597
- threadId,
598
- runId: currentRunId,
599
- });
600
- closed = true;
601
- res.end();
602
- }
603
- } catch (err) {
604
- if (!closed) {
605
- writeEvent({
606
- type: EventType.RUN_ERROR,
607
- message: String(err),
608
- });
609
- closed = true;
610
- res.end();
611
- }
612
- } finally {
613
- clearWriter(sessionKey);
614
- clearClientToolCalled(sessionKey);
615
- clearClientToolNames(sessionKey);
616
- clearToolFiredInRun(sessionKey);
617
- }
618
- };
619
- }