@contextableai/clawg-ui 0.2.9 → 0.3.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.
@@ -1,627 +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
- pairing_code: pairingCode,
249
- bearer_token: deviceToken,
250
- error: {
251
- type: "pairing_pending",
252
- message: "Device pending approval",
253
- pairing: {
254
- pairingCode,
255
- token: deviceToken,
256
- instructions: `Save this token for use as a Bearer token and ask the owner to approve: openclaw pairing approve clawg-ui ${pairingCode}`,
257
- },
258
- },
259
- });
260
- return;
261
- }
262
-
263
- // Device token flow: verify HMAC signature, extract device ID
264
- const extractedDeviceId = verifyDeviceToken(bearerToken, gatewaySecret);
265
- if (!extractedDeviceId) {
266
- sendUnauthorized(res);
267
- return;
268
- }
269
- deviceId = extractedDeviceId;
270
-
271
- // ---------------------------------------------------------------------------
272
- // Pairing check: verify device is approved
273
- // ---------------------------------------------------------------------------
274
- const storeAllowFrom = await runtime.channel.pairing
275
- .readAllowFromStore({ channel: "clawg-ui", accountId: "default" })
276
- .catch(() => []);
277
- const normalizedAllowFrom = storeAllowFrom.map((e) =>
278
- e.replace(/^clawg-ui:/i, "").toLowerCase(),
279
- );
280
- const allowed = normalizedAllowFrom.includes(deviceId.toLowerCase());
281
-
282
- if (!allowed) {
283
- sendJson(res, 403, {
284
- error: {
285
- type: "pairing_pending",
286
- message: "Device pending approval. Ask the owner to approve using the pairing code from your initial pairing response.",
287
- },
288
- });
289
- return;
290
- }
291
-
292
- // ---------------------------------------------------------------------------
293
- // Device approved - proceed with request
294
- // ---------------------------------------------------------------------------
295
-
296
- // Parse body
297
- let body: unknown;
298
- try {
299
- body = await readJsonBody(req, 1024 * 1024);
300
- } catch (err) {
301
- sendJson(res, 400, {
302
- error: { message: String(err), type: "invalid_request_error" },
303
- });
304
- return;
305
- }
306
-
307
- const input = body as RunAgentInput;
308
- const threadId = input.threadId || `clawg-ui-${randomUUID()}`;
309
- const runId = input.runId || `clawg-ui-run-${randomUUID()}`;
310
-
311
- // Validate messages
312
- const messages: Message[] = Array.isArray(input.messages)
313
- ? input.messages
314
- : [];
315
-
316
- const hasUserMessage = messages.some((m) => m.role === "user");
317
- const hasToolMessage = messages.some((m) => m.role === "tool");
318
- if (!hasUserMessage && !hasToolMessage) {
319
- console.log(
320
- `[clawg-ui] 400: no user/tool message, roles=[${messages.map((m) => m.role).join(",")}], messageCount=${messages.length}`,
321
- );
322
- sendJson(res, 400, {
323
- error: {
324
- message: "At least one user or tool message is required in `messages`.",
325
- type: "invalid_request_error",
326
- },
327
- });
328
- return;
329
- }
330
-
331
- // Build body from messages
332
- const { body: messageBody } = buildBodyFromMessages(messages);
333
- if (!messageBody.trim()) {
334
- console.log(
335
- `[clawg-ui] 400: empty extracted body, roles=[${messages.map((m) => m.role).join(",")}], contents=[${messages.map((m) => JSON.stringify(m.content)).join(",")}]`,
336
- );
337
- sendJson(res, 400, {
338
- error: {
339
- message: "Could not extract a prompt from `messages`.",
340
- type: "invalid_request_error",
341
- },
342
- });
343
- return;
344
- }
345
-
346
- // Resolve agent route
347
- const cfg = runtime.config.loadConfig();
348
- const route = runtime.channel.routing.resolveAgentRoute({
349
- cfg,
350
- channel: "clawg-ui",
351
- peer: { kind: "dm", id: `clawg-ui-${threadId}` },
352
- });
353
-
354
- // Set up SSE via EventEncoder
355
- const accept =
356
- typeof req.headers.accept === "string"
357
- ? req.headers.accept
358
- : "text/event-stream";
359
- const encoder = new EventEncoder({ accept });
360
- res.statusCode = 200;
361
- res.setHeader("Content-Type", encoder.getContentType());
362
- res.setHeader("Cache-Control", "no-cache");
363
- res.setHeader("Connection", "keep-alive");
364
- res.setHeader("X-Accel-Buffering", "no");
365
- res.flushHeaders?.();
366
-
367
- let closed = false;
368
- let currentMessageId = `msg-${randomUUID()}`;
369
- let messageStarted = false;
370
- let currentRunId = runId;
371
-
372
- const writeEvent = (event: { type: EventType } & Record<string, unknown>) => {
373
- if (closed) {
374
- return;
375
- }
376
- try {
377
- res.write(encoder.encode(event as Parameters<typeof encoder.encode>[0]));
378
- } catch {
379
- // Client may have disconnected
380
- closed = true;
381
- }
382
- };
383
-
384
- // If a tool call was emitted in the current run, finish that run and start
385
- // a fresh one for text messages. This keeps tool events and text events in
386
- // separate runs per the AG-UI protocol.
387
- const splitRunIfToolFired = () => {
388
- if (!wasToolFiredInRun(sessionKey)) {
389
- return;
390
- }
391
- // Close any open text message before ending the run
392
- if (messageStarted) {
393
- writeEvent({
394
- type: EventType.TEXT_MESSAGE_END,
395
- messageId: currentMessageId,
396
- runId: currentRunId,
397
- });
398
- messageStarted = false;
399
- }
400
- // End the tool run
401
- writeEvent({
402
- type: EventType.RUN_FINISHED,
403
- threadId,
404
- runId: currentRunId,
405
- });
406
- // Start a new run for text messages
407
- currentRunId = `clawg-ui-run-${randomUUID()}`;
408
- currentMessageId = `msg-${randomUUID()}`;
409
- messageStarted = false;
410
- clearToolFiredInRun(sessionKey);
411
- writeEvent({
412
- type: EventType.RUN_STARTED,
413
- threadId,
414
- runId: currentRunId,
415
- });
416
- };
417
-
418
- // Handle client disconnect
419
- req.on("close", () => {
420
- closed = true;
421
- });
422
-
423
- // Emit RUN_STARTED
424
- writeEvent({
425
- type: EventType.RUN_STARTED,
426
- threadId,
427
- runId,
428
- });
429
-
430
- // Build inbound context using the plugin runtime (same pattern as msteams)
431
- const sessionKey = route.sessionKey;
432
-
433
- // Stash client-provided tools so the plugin tool factory can pick them up
434
- if (Array.isArray(input.tools) && input.tools.length > 0) {
435
- stashTools(sessionKey, input.tools);
436
- markClientToolNames(
437
- sessionKey,
438
- input.tools.map((t: { name: string }) => t.name),
439
- );
440
- }
441
-
442
- // Register SSE writer so before/after_tool_call hooks can emit AG-UI events
443
- setWriter(sessionKey, writeEvent, currentMessageId);
444
- const storePath = runtime.channel.session.resolveStorePath(cfg.session?.store, {
445
- agentId: route.agentId,
446
- });
447
- const envelopeOptions = runtime.channel.reply.resolveEnvelopeFormatOptions(cfg);
448
- const previousTimestamp = runtime.channel.session.readSessionUpdatedAt({
449
- storePath,
450
- sessionKey,
451
- });
452
- const envelopedBody = runtime.channel.reply.formatAgentEnvelope({
453
- channel: "AG-UI",
454
- from: "User",
455
- timestamp: new Date(),
456
- previousTimestamp,
457
- envelope: envelopeOptions,
458
- body: messageBody,
459
- });
460
-
461
- const ctxPayload = runtime.channel.reply.finalizeInboundContext({
462
- Body: envelopedBody,
463
- RawBody: messageBody,
464
- CommandBody: messageBody,
465
- From: `clawg-ui:${deviceId}`,
466
- To: "clawg-ui",
467
- SessionKey: sessionKey,
468
- ChatType: "direct",
469
- ConversationLabel: "AG-UI",
470
- SenderName: "AG-UI Client",
471
- SenderId: deviceId,
472
- Provider: "clawg-ui" as const,
473
- Surface: "clawg-ui" as const,
474
- MessageSid: runId,
475
- Timestamp: Date.now(),
476
- WasMentioned: true,
477
- CommandAuthorized: true,
478
- OriginatingChannel: "clawg-ui" as const,
479
- });
480
-
481
- // Record inbound session
482
- await runtime.channel.session.recordInboundSession({
483
- storePath,
484
- sessionKey,
485
- ctx: ctxPayload,
486
- onRecordError: () => {},
487
- });
488
-
489
- // Create reply dispatcher — translates reply payloads into AG-UI SSE events
490
- const abortController = new AbortController();
491
- req.on("close", () => {
492
- abortController.abort();
493
- });
494
-
495
- const dispatcher = {
496
- sendToolResult: (_payload: { text?: string }) => {
497
- // Tool call events are emitted by before/after_tool_call hooks
498
- return !closed;
499
- },
500
- sendBlockReply: (payload: { text?: string }) => {
501
- if (closed || wasClientToolCalled(sessionKey)) {
502
- return false;
503
- }
504
- const text = payload.text?.trim();
505
- if (!text) {
506
- return false;
507
- }
508
-
509
- splitRunIfToolFired();
510
-
511
- if (!messageStarted) {
512
- messageStarted = true;
513
- writeEvent({
514
- type: EventType.TEXT_MESSAGE_START,
515
- messageId: currentMessageId,
516
- runId: currentRunId,
517
- role: "assistant",
518
- });
519
- }
520
-
521
- // Join chunks with \n\n (breakPreference: paragraph uses double-newline joiner)
522
- writeEvent({
523
- type: EventType.TEXT_MESSAGE_CONTENT,
524
- messageId: currentMessageId,
525
- runId: currentRunId,
526
- delta: text + "\n\n",
527
- });
528
- return true;
529
- },
530
- sendFinalReply: (payload: { text?: string }) => {
531
- if (closed) {
532
- return false;
533
- }
534
- const text = wasClientToolCalled(sessionKey) ? "" : payload.text?.trim();
535
-
536
- if (text) {
537
- splitRunIfToolFired();
538
-
539
- if (!messageStarted) {
540
- messageStarted = true;
541
- writeEvent({
542
- type: EventType.TEXT_MESSAGE_START,
543
- messageId: currentMessageId,
544
- runId: currentRunId,
545
- role: "assistant",
546
- });
547
- }
548
- // Join chunks with \n\n (breakPreference: paragraph uses double-newline joiner)
549
- writeEvent({
550
- type: EventType.TEXT_MESSAGE_CONTENT,
551
- messageId: currentMessageId,
552
- runId: currentRunId,
553
- delta: text + "\n\n",
554
- });
555
- }
556
- // End the message and run
557
- if (messageStarted) {
558
- writeEvent({
559
- type: EventType.TEXT_MESSAGE_END,
560
- messageId: currentMessageId,
561
- runId: currentRunId,
562
- });
563
- }
564
- writeEvent({
565
- type: EventType.RUN_FINISHED,
566
- threadId,
567
- runId: currentRunId,
568
- });
569
- closed = true;
570
- res.end();
571
- return true;
572
- },
573
- waitForIdle: () => Promise.resolve(),
574
- getQueuedCounts: () => ({ tool: 0, block: 0, final: 0 }),
575
- };
576
-
577
- // Dispatch the inbound message — this triggers the agent run
578
- try {
579
- await runtime.channel.reply.dispatchReplyFromConfig({
580
- ctx: ctxPayload,
581
- cfg,
582
- dispatcher,
583
- replyOptions: {
584
- runId,
585
- abortSignal: abortController.signal,
586
- disableBlockStreaming: false,
587
- onAgentRunStart: () => {},
588
- onToolResult: () => {
589
- // Tool call events are emitted by before/after_tool_call hooks
590
- },
591
- },
592
- });
593
-
594
- // If the dispatcher's final reply didn't close the stream, close it now
595
- if (!closed) {
596
- if (messageStarted) {
597
- writeEvent({
598
- type: EventType.TEXT_MESSAGE_END,
599
- messageId: currentMessageId,
600
- runId: currentRunId,
601
- });
602
- }
603
- writeEvent({
604
- type: EventType.RUN_FINISHED,
605
- threadId,
606
- runId: currentRunId,
607
- });
608
- closed = true;
609
- res.end();
610
- }
611
- } catch (err) {
612
- if (!closed) {
613
- writeEvent({
614
- type: EventType.RUN_ERROR,
615
- message: String(err),
616
- });
617
- closed = true;
618
- res.end();
619
- }
620
- } finally {
621
- clearWriter(sessionKey);
622
- clearClientToolCalled(sessionKey);
623
- clearClientToolNames(sessionKey);
624
- clearToolFiredInRun(sessionKey);
625
- }
626
- };
627
- }