@contextableai/clawg-ui 0.1.1 → 0.2.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/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.1 (2026-02-05)
4
+
5
+ ### Fixed
6
+ - Return HTTP 429 `rate_limit` error when max pending pairing requests (3) is reached, instead of returning an empty pairing code
7
+
8
+ ## 0.2.0 (2026-02-04)
9
+
10
+ ### Added
11
+ - **Device pairing authentication** - Secure per-device access control
12
+ - HMAC-signed device tokens (no master token exposure)
13
+ - Pairing approval workflow (`openclaw pairing approve clawg-ui <code>`)
14
+ - New CLI command: `openclaw clawg-ui devices` - List approved devices
15
+
16
+ ### Changed
17
+ - **Breaking:** Direct bearer token authentication using `OPENCLAW_GATEWAY_TOKEN` is now deprecated and no longer supported. All clients must use device pairing.
18
+
19
+ ### Security
20
+ - Device tokens are HMAC-signed and do not expose the gateway's master secret
21
+ - Pending pairing requests expire after 1 hour (max 3 per channel)
22
+ - Each device requires explicit approval by the gateway owner
23
+
3
24
  ## 0.1.1 (2026-02-03)
4
25
 
5
26
  ### Changed
package/README.md CHANGED
@@ -22,7 +22,7 @@ Then restart the gateway. The plugin auto-registers the `/v1/clawg-ui` endpoint
22
22
 
23
23
  The plugin registers as an OpenClaw channel and adds an HTTP route at `/v1/clawg-ui`. When an AG-UI client POSTs a `RunAgentInput` payload, the plugin:
24
24
 
25
- 1. Authenticates the request using the gateway bearer token
25
+ 1. Authenticates the request using device pairing (see [Authentication](#authentication))
26
26
  2. Parses the AG-UI messages into an OpenClaw inbound context
27
27
  3. Routes to the appropriate agent via the gateway's standard routing
28
28
  4. Dispatches the message through the reply pipeline (same path as Telegram, Teams, etc.)
@@ -31,9 +31,9 @@ The plugin registers as an OpenClaw channel and adds an HTTP route at `/v1/clawg
31
31
  ```
32
32
  AG-UI Client OpenClaw Gateway
33
33
  | |
34
- | POST /v1/agui (RunAgentInput) |
34
+ | POST /v1/clawg-ui (RunAgentInput) |
35
35
  |------------------------------------->|
36
- | | Auth (bearer token)
36
+ | | Auth (device token)
37
37
  | | Route to agent
38
38
  | | Dispatch inbound message
39
39
  | |
@@ -60,15 +60,16 @@ AG-UI Client OpenClaw Gateway
60
60
  ### Prerequisites
61
61
 
62
62
  - OpenClaw gateway running (`openclaw gateway run`)
63
- - A gateway auth token configured (`OPENCLAW_GATEWAY_TOKEN` env var or `gateway.auth.token` in config)
63
+ - A paired device token (see [Authentication](#authentication))
64
64
 
65
65
  ### curl
66
66
 
67
67
  ```bash
68
+ # Using your device token (obtained through pairing)
68
69
  curl -N -X POST http://localhost:18789/v1/clawg-ui \
69
70
  -H "Content-Type: application/json" \
70
71
  -H "Accept: text/event-stream" \
71
- -H "Authorization: Bearer $OPENCLAW_GATEWAY_TOKEN" \
72
+ -H "Authorization: Bearer $CLAWG_UI_DEVICE_TOKEN" \
72
73
  -d '{
73
74
  "threadId": "thread-1",
74
75
  "runId": "run-1",
@@ -83,10 +84,13 @@ curl -N -X POST http://localhost:18789/v1/clawg-ui \
83
84
  ```typescript
84
85
  import { HttpAgent } from "@ag-ui/client";
85
86
 
87
+ // Device token obtained through the pairing flow
88
+ const deviceToken = process.env.CLAWG_UI_DEVICE_TOKEN;
89
+
86
90
  const agent = new HttpAgent({
87
91
  url: "http://localhost:18789/v1/clawg-ui",
88
92
  headers: {
89
- Authorization: `Bearer ${process.env.OPENCLAW_GATEWAY_TOKEN}`,
93
+ Authorization: `Bearer ${deviceToken}`,
90
94
  },
91
95
  });
92
96
 
@@ -108,12 +112,15 @@ for await (const event of stream) {
108
112
  ```tsx
109
113
  import { CopilotKit } from "@copilotkit/react-core";
110
114
 
115
+ // Device token obtained through the pairing flow
116
+ const deviceToken = process.env.CLAWG_UI_DEVICE_TOKEN;
117
+
111
118
  function App() {
112
119
  return (
113
120
  <CopilotKit
114
121
  runtimeUrl="http://localhost:18789/v1/clawg-ui"
115
122
  headers={{
116
- Authorization: `Bearer ${process.env.OPENCLAW_GATEWAY_TOKEN}`,
123
+ Authorization: `Bearer ${deviceToken}`,
117
124
  }}
118
125
  >
119
126
  {/* your app */}
@@ -162,24 +169,130 @@ The response is an SSE stream. Each event is a `data:` line containing a JSON ob
162
169
 
163
170
  ## Authentication
164
171
 
165
- The endpoint uses the same bearer token as the OpenClaw gateway. Set it via:
172
+ clawg-ui uses **device pairing** to authenticate clients. This provides secure, per-device access control without exposing the gateway's master token.
173
+
174
+ ### Device Pairing Flow
175
+
176
+ ```
177
+ ┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
178
+ │ Gateway Owner │ │ OpenClaw Server │ │ AG-UI Client │
179
+ └────────┬────────┘ └────────┬─────────┘ └────────┬────────┘
180
+ │ │ │
181
+ │ │ 1. POST (no auth) │
182
+ │ │<────────────────────────│
183
+ │ │ │
184
+ │ │ 2. Return device token │
185
+ │ │ + pairing code │
186
+ │ │────────────────────────>│
187
+ │ │ 403 pairing_pending │
188
+ │ │ { pairingCode, token }
189
+ │ │ │
190
+ │ 3. Share pairing code (out of band) │
191
+ │<─────────────────────────────────────────────────│
192
+ │ │ │
193
+ 4. Approve device │ │
194
+ │ openclaw pairing approve clawg-ui ABCD1234 │
195
+ │───────────────────────>│ │
196
+ │ │ │
197
+ │ │ 5. POST with device token
198
+ │ │<────────────────────────│
199
+ │ │ Authorization: Bearer <token>
200
+ │ │ │
201
+ │ │ 6. Success - SSE stream│
202
+ │ │────────────────────────>│
203
+ │ │ │
204
+ ```
205
+
206
+ ### Step-by-Step Setup
207
+
208
+ #### 1. Client initiates pairing
209
+
210
+ The client sends a POST request without any authorization header:
211
+
212
+ ```bash
213
+ curl -X POST http://localhost:18789/v1/clawg-ui \
214
+ -H "Content-Type: application/json" \
215
+ -d '{}'
216
+ ```
217
+
218
+ Response (403):
219
+
220
+ ```json
221
+ {
222
+ "error": {
223
+ "type": "pairing_pending",
224
+ "message": "Device pending approval",
225
+ "pairing": {
226
+ "pairingCode": "ABCD1234",
227
+ "token": "MmRlOTA0ODIt...b71d",
228
+ "instructions": "Save this token for use as a Bearer token and ask the owner to approve: openclaw pairing approve clawg-ui ABCD1234"
229
+ }
230
+ }
231
+ }
232
+ ```
233
+
234
+ The client must save the `token` for future requests.
166
235
 
167
- - Environment variable: `OPENCLAW_GATEWAY_TOKEN`
168
- - Config file: `gateway.auth.token`
236
+ #### 2. Approve the device (gateway owner)
169
237
 
170
- Pass it in the `Authorization` header:
238
+ The client shares the `pairingCode` with the gateway owner, who approves it:
171
239
 
240
+ ```bash
241
+ # List pending pairing requests
242
+ openclaw pairing list clawg-ui
243
+
244
+ # Approve the device
245
+ openclaw pairing approve clawg-ui ABCD1234
172
246
  ```
173
- Authorization: Bearer <token>
247
+
248
+ #### 3. Client uses Bearer token
249
+
250
+ Once approved, the client uses their Bearer token for all requests:
251
+
252
+ ```bash
253
+ curl -N -X POST http://localhost:18789/v1/clawg-ui \
254
+ -H "Authorization: Bearer MmRlOTA0ODIt...b71d" \
255
+ -H "Content-Type: application/json" \
256
+ -d '{"messages":[{"role":"user","content":"Hello"}]}'
174
257
  ```
175
258
 
259
+ ### CLI Commands
260
+
261
+ | Command | Description |
262
+ |---------|-------------|
263
+ | `openclaw clawg-ui devices` | List approved devices |
264
+ | `openclaw pairing list clawg-ui` | List pending pairing requests awaiting approval |
265
+ | `openclaw pairing approve clawg-ui <code>` | Approve a device by its pairing code |
266
+
267
+ ### Error Responses
268
+
269
+ | Status | Type | Meaning |
270
+ |--------|------|---------|
271
+ | 401 | `unauthorized` | Invalid device token |
272
+ | 403 | `pairing_pending` | No auth (initiates pairing) or valid token but device not yet approved |
273
+
274
+ ### Deprecated: Direct Bearer Token
275
+
276
+ > **Deprecated:** Previous versions (0.1.x) allowed using the gateway's master token (`OPENCLAW_GATEWAY_TOKEN`) directly. This approach is **no longer supported**. All clients must now use device pairing.
277
+ >
278
+ > Old (deprecated):
279
+ > ```
280
+ > Authorization: Bearer $OPENCLAW_GATEWAY_TOKEN
281
+ > ```
282
+ >
283
+ > New (required):
284
+ > ```
285
+ > POST without Authorization header # Initiates pairing
286
+ > Authorization: Bearer <device-token> # After approval
287
+ > ```
288
+
176
289
  ## Agent routing
177
290
 
178
291
  The plugin uses OpenClaw's standard agent routing. By default, messages route to the `main` agent. To target a specific agent, set the `X-OpenClaw-Agent-Id` header:
179
292
 
180
293
  ```bash
181
294
  curl -N -X POST http://localhost:18789/v1/clawg-ui \
182
- -H "Authorization: Bearer $OPENCLAW_GATEWAY_TOKEN" \
295
+ -H "Authorization: Bearer $CLAWG_UI_DEVICE_TOKEN" \
183
296
  -H "X-OpenClaw-Agent-Id: my-agent" \
184
297
  -d '{"messages":[{"role":"user","content":"Hello"}]}'
185
298
  ```
@@ -188,11 +301,12 @@ curl -N -X POST http://localhost:18789/v1/clawg-ui \
188
301
 
189
302
  Non-streaming errors return JSON:
190
303
 
191
- | Status | Meaning |
192
- |---|---|
193
- | 400 | Invalid request (missing messages, bad JSON) |
194
- | 401 | Unauthorized (missing or invalid token) |
195
- | 405 | Method not allowed (only POST accepted) |
304
+ | Status | Type | Meaning |
305
+ |---|---|---|
306
+ | 400 | `invalid_request_error` | Invalid request (missing messages, bad JSON) |
307
+ | 401 | `unauthorized` | Invalid device token |
308
+ | 403 | `pairing_pending` | No auth header (initiates pairing) or valid token but device not yet approved |
309
+ | 405 | — | Method not allowed (only POST accepted) |
196
310
 
197
311
  Streaming errors emit a `RUN_ERROR` event and close the connection.
198
312
 
package/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
+ import type { Command } from "commander";
2
3
  import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
3
4
  import { randomUUID } from "node:crypto";
4
5
  import { EventType } from "@ag-ui/core";
@@ -82,6 +83,31 @@ const plugin = {
82
83
  });
83
84
  }
84
85
  });
86
+
87
+ // CLI commands for device management
88
+ api.registerCli(
89
+ ({ program }: { program: Command }) => {
90
+ const clawgUi = program
91
+ .command("clawg-ui")
92
+ .description("CLAWG-UI (AG-UI) channel commands");
93
+
94
+ clawgUi
95
+ .command("devices")
96
+ .description("List approved devices")
97
+ .action(async () => {
98
+ const devices = await api.runtime.channel.pairing.readAllowFromStore("clawg-ui");
99
+ if (devices.length === 0) {
100
+ console.log("No approved devices.");
101
+ return;
102
+ }
103
+ console.log("Approved devices:");
104
+ for (const deviceId of devices) {
105
+ console.log(` ${deviceId}`);
106
+ }
107
+ });
108
+ },
109
+ { commands: ["clawg-ui"] },
110
+ );
85
111
  },
86
112
  };
87
113
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contextableai/clawg-ui",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "description": "AG-UI protocol channel plugin for OpenClaw — connect CopilotKit and AG-UI clients to your OpenClaw gateway",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/channel.ts CHANGED
@@ -30,4 +30,8 @@ export const aguiChannelPlugin: ChannelPlugin<ResolvedAguiAccount> = {
30
30
  }),
31
31
  defaultAccountId: () => "default",
32
32
  },
33
+ pairing: {
34
+ idLabel: "clawgUiDeviceId",
35
+ normalizeAllowEntry: (entry: string) => entry.replace(/^clawg-ui:/i, "").toLowerCase(),
36
+ },
33
37
  };
@@ -1,5 +1,5 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
- import { randomUUID } from "node:crypto";
2
+ import { randomUUID, createHmac, timingSafeEqual } from "node:crypto";
3
3
  import { EventType } from "@ag-ui/core";
4
4
  import type { RunAgentInput, Message } from "@ag-ui/core";
5
5
  import { EventEncoder } from "@ag-ui/encoder";
@@ -13,6 +13,7 @@ import {
13
13
  clearClientToolCalled,
14
14
  clearClientToolNames,
15
15
  } from "./tool-store.js";
16
+ import { aguiChannelPlugin } from "./channel.js";
16
17
 
17
18
  // ---------------------------------------------------------------------------
18
19
  // Lightweight HTTP helpers (no internal imports needed)
@@ -32,7 +33,7 @@ function sendMethodNotAllowed(res: ServerResponse) {
32
33
  }
33
34
 
34
35
  function sendUnauthorized(res: ServerResponse) {
35
- sendJson(res, 401, { error: { message: "Unauthorized", type: "unauthorized" } });
36
+ sendJson(res, 401, { error: { message: "Authentication required", type: "unauthorized" } });
36
37
  }
37
38
 
38
39
  function readJsonBody(req: IncomingMessage, maxBytes: number): Promise<unknown> {
@@ -67,6 +68,51 @@ function getBearerToken(req: IncomingMessage): string | undefined {
67
68
  return raw.slice(7).trim() || undefined;
68
69
  }
69
70
 
71
+ // ---------------------------------------------------------------------------
72
+ // HMAC-signed device token utilities
73
+ // ---------------------------------------------------------------------------
74
+
75
+ function createDeviceToken(secret: string, deviceId: string): string {
76
+ const encodedId = Buffer.from(deviceId).toString("base64url");
77
+ const signature = createHmac("sha256", secret).update(deviceId).digest("hex").slice(0, 32);
78
+ return `${encodedId}.${signature}`;
79
+ }
80
+
81
+ function verifyDeviceToken(token: string, secret: string): string | null {
82
+ const dotIndex = token.indexOf(".");
83
+ if (dotIndex <= 0 || dotIndex >= token.length - 1) {
84
+ return null;
85
+ }
86
+
87
+ const encodedId = token.slice(0, dotIndex);
88
+ const providedSig = token.slice(dotIndex + 1);
89
+
90
+ try {
91
+ const deviceId = Buffer.from(encodedId, "base64url").toString("utf-8");
92
+
93
+ // Validate it looks like a UUID
94
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(deviceId)) {
95
+ return null;
96
+ }
97
+
98
+ const expectedSig = createHmac("sha256", secret).update(deviceId).digest("hex").slice(0, 32);
99
+
100
+ // Constant-time comparison
101
+ if (providedSig.length !== expectedSig.length) {
102
+ return null;
103
+ }
104
+ const providedBuf = Buffer.from(providedSig);
105
+ const expectedBuf = Buffer.from(expectedSig);
106
+ if (!timingSafeEqual(providedBuf, expectedBuf)) {
107
+ return null;
108
+ }
109
+
110
+ return deviceId;
111
+ } catch {
112
+ return null;
113
+ }
114
+ }
115
+
70
116
  // ---------------------------------------------------------------------------
71
117
  // Extract text from AG-UI messages
72
118
  // ---------------------------------------------------------------------------
@@ -134,35 +180,19 @@ function buildBodyFromMessages(messages: Message[]): {
134
180
  }
135
181
 
136
182
  // ---------------------------------------------------------------------------
137
- // Token-based auth check against gateway config
183
+ // Gateway secret resolution
138
184
  // ---------------------------------------------------------------------------
139
185
 
140
- function authenticateRequest(
141
- req: IncomingMessage,
142
- api: OpenClawPluginApi,
143
- ): boolean {
144
- const token = getBearerToken(req);
145
- if (!token) {
146
- return false;
147
- }
148
- // Read the configured gateway token from config
186
+ function getGatewaySecret(api: OpenClawPluginApi): string | null {
149
187
  const gatewayAuth = api.config.gateway?.auth;
150
- const configuredToken =
188
+ const secret =
151
189
  (gatewayAuth as Record<string, unknown> | undefined)?.token ??
152
190
  process.env.OPENCLAW_GATEWAY_TOKEN ??
153
191
  process.env.CLAWDBOT_GATEWAY_TOKEN;
154
- if (typeof configuredToken !== "string" || !configuredToken) {
155
- return false;
192
+ if (typeof secret === "string" && secret) {
193
+ return secret;
156
194
  }
157
- // Constant-time comparison
158
- if (token.length !== configuredToken.length) {
159
- return false;
160
- }
161
- let mismatch = 0;
162
- for (let i = 0; i < token.length; i++) {
163
- mismatch |= token.charCodeAt(i) ^ configuredToken.charCodeAt(i);
164
- }
165
- return mismatch === 0;
195
+ return null;
166
196
  }
167
197
 
168
198
  // ---------------------------------------------------------------------------
@@ -182,11 +212,95 @@ export function createAguiHttpHandler(api: OpenClawPluginApi) {
182
212
  return;
183
213
  }
184
214
 
185
- // Auth
186
- if (!authenticateRequest(req, api)) {
215
+ // Get gateway secret for HMAC operations
216
+ const gatewaySecret = getGatewaySecret(api);
217
+ if (!gatewaySecret) {
218
+ sendJson(res, 500, {
219
+ error: { message: "Gateway not configured", type: "server_error" },
220
+ });
221
+ return;
222
+ }
223
+
224
+ // ---------------------------------------------------------------------------
225
+ // Authentication: No auth (pairing initiation) or Device token
226
+ // ---------------------------------------------------------------------------
227
+ let deviceId: string;
228
+
229
+ const bearerToken = getBearerToken(req);
230
+
231
+ if (!bearerToken) {
232
+ // No auth header: initiate pairing
233
+ // Generate new device ID
234
+ deviceId = randomUUID();
235
+
236
+ // Add to pending via OpenClaw pairing API - returns a pairing code for approval
237
+ const { code: pairingCode } = await runtime.channel.pairing.upsertPairingRequest({
238
+ channel: "clawg-ui",
239
+ id: deviceId,
240
+ pairingAdapter: aguiChannelPlugin.pairing,
241
+ });
242
+
243
+ // Rate limit reached - max pending requests exceeded
244
+ if (!pairingCode) {
245
+ sendJson(res, 429, {
246
+ error: {
247
+ type: "rate_limit",
248
+ message: "Too many pending pairing requests. Please wait for existing requests to expire (10 minutes) or ask the owner to approve/reject them.",
249
+ },
250
+ });
251
+ return;
252
+ }
253
+
254
+ // Generate signed device token
255
+ const deviceToken = createDeviceToken(gatewaySecret, deviceId);
256
+
257
+ // Return pairing pending response with device token and pairing code
258
+ sendJson(res, 403, {
259
+ error: {
260
+ type: "pairing_pending",
261
+ message: "Device pending approval",
262
+ pairing: {
263
+ pairingCode,
264
+ token: deviceToken,
265
+ instructions: `Save this token for use as a Bearer token and ask the owner to approve: openclaw pairing approve clawg-ui ${pairingCode}`,
266
+ },
267
+ },
268
+ });
269
+ return;
270
+ }
271
+
272
+ // Device token flow: verify HMAC signature, extract device ID
273
+ const extractedDeviceId = verifyDeviceToken(bearerToken, gatewaySecret);
274
+ if (!extractedDeviceId) {
187
275
  sendUnauthorized(res);
188
276
  return;
189
277
  }
278
+ deviceId = extractedDeviceId;
279
+
280
+ // ---------------------------------------------------------------------------
281
+ // Pairing check: verify device is approved
282
+ // ---------------------------------------------------------------------------
283
+ const storeAllowFrom = await runtime.channel.pairing
284
+ .readAllowFromStore("clawg-ui")
285
+ .catch(() => []);
286
+ const normalizedAllowFrom = storeAllowFrom.map((e) =>
287
+ e.replace(/^clawg-ui:/i, "").toLowerCase(),
288
+ );
289
+ const allowed = normalizedAllowFrom.includes(deviceId.toLowerCase());
290
+
291
+ if (!allowed) {
292
+ sendJson(res, 403, {
293
+ error: {
294
+ type: "pairing_pending",
295
+ message: "Device pending approval. Ask the owner to approve using the pairing code from your initial pairing response.",
296
+ },
297
+ });
298
+ return;
299
+ }
300
+
301
+ // ---------------------------------------------------------------------------
302
+ // Device approved - proceed with request
303
+ // ---------------------------------------------------------------------------
190
304
 
191
305
  // Parse body
192
306
  let body: unknown;
@@ -257,12 +371,12 @@ export function createAguiHttpHandler(api: OpenClawPluginApi) {
257
371
  const messageId = `msg-${randomUUID()}`;
258
372
  let messageStarted = false;
259
373
 
260
- const writeEvent = (event: Record<string, unknown>) => {
374
+ const writeEvent = (event: { type: EventType } & Record<string, unknown>) => {
261
375
  if (closed) {
262
376
  return;
263
377
  }
264
378
  try {
265
- res.write(encoder.encode(event));
379
+ res.write(encoder.encode(event as Parameters<typeof encoder.encode>[0]));
266
380
  } catch {
267
381
  // Client may have disconnected
268
382
  closed = true;
@@ -316,13 +430,13 @@ export function createAguiHttpHandler(api: OpenClawPluginApi) {
316
430
  Body: envelopedBody,
317
431
  RawBody: messageBody,
318
432
  CommandBody: messageBody,
319
- From: `clawg-ui:${threadId}`,
433
+ From: `clawg-ui:${deviceId}`,
320
434
  To: "clawg-ui",
321
435
  SessionKey: sessionKey,
322
436
  ChatType: "direct",
323
437
  ConversationLabel: "AG-UI",
324
438
  SenderName: "AG-UI Client",
325
- SenderId: `clawg-ui-${threadId}`,
439
+ SenderId: deviceId,
326
440
  Provider: "clawg-ui" as const,
327
441
  Surface: "clawg-ui" as const,
328
442
  MessageSid: runId,