@taptapai/taptapai-openclaw 0.0.2 → 0.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taptapai/taptapai-openclaw",
3
- "version": "0.0.2",
3
+ "version": "0.1.1",
4
4
  "description": "TapTapAI gateway proxy plugin",
5
5
  "type": "module",
6
6
  "openclaw": {
package/src/backendWs.ts CHANGED
@@ -30,6 +30,10 @@ export type BackendWsState = {
30
30
  reconnectAttempt: number;
31
31
  watchdogTimer: ReturnType<typeof setInterval> | null;
32
32
  pingTimer: ReturnType<typeof setInterval> | null;
33
+ /** Tracks consecutive connect errors for log throttling. */
34
+ _consecutiveConnectErrors: number;
35
+ /** Timestamp of last logged connect error. */
36
+ _lastConnectErrorLog: number;
33
37
  };
34
38
 
35
39
  export function connectBackendWs(params: {
@@ -92,6 +96,7 @@ export function connectBackendWs(params: {
92
96
  try {
93
97
  logger.info?.("[taptapai] WS open, sending handshake...");
94
98
  state.reconnectAttempt = 0;
99
+ state._consecutiveConnectErrors = 0;
95
100
  ws.send(
96
101
  JSON.stringify({
97
102
  type: "handshake",
@@ -151,11 +156,25 @@ export function connectBackendWs(params: {
151
156
  ws.addEventListener("error", (event: any) => {
152
157
  if (state.ws !== ws) return;
153
158
  const msg = event?.message || event?.error?.message || String(event);
154
- logger.error?.(`[taptapai] WS error: ${msg}`);
155
- try { ws.close(); } catch {}
159
+
160
+ // Throttle identical connection errors to avoid log flood during startup.
161
+ state._consecutiveConnectErrors++;
162
+ const now = Date.now();
163
+ const sinceLast = now - state._lastConnectErrorLog;
164
+ if (state._consecutiveConnectErrors === 1 || sinceLast >= 5000) {
165
+ const suffix = state._consecutiveConnectErrors > 1
166
+ ? ` (repeated ${state._consecutiveConnectErrors} times)`
167
+ : "";
168
+ logger.warn?.(`[taptapai] WS error: ${msg}${suffix}`);
169
+ state._lastConnectErrorLog = now;
170
+ state._consecutiveConnectErrors = 0;
171
+ }
172
+
173
+ // Null out state.ws BEFORE closing to prevent synchronous close handler re-entry.
156
174
  state.ws = null;
157
175
  state.connected = false;
158
176
  state.connecting = false;
177
+ try { ws.close(); } catch {}
159
178
  scheduleReconnect({ ...params, url: normalizedUrl, token: normalizedToken });
160
179
  });
161
180
 
@@ -199,7 +218,7 @@ export function scheduleReconnect(params: {
199
218
  if (state.connecting) return;
200
219
 
201
220
  state.reconnectAttempt++;
202
- const delay = Math.min(1000 * Math.pow(2, state.reconnectAttempt - 1), MAX_RECONNECT_DELAY_MS);
221
+ const delay = Math.min(Math.max(2000, 1000 * Math.pow(2, state.reconnectAttempt - 1)), MAX_RECONNECT_DELAY_MS);
203
222
  logger.info?.(`[taptapai] Reconnecting in ${delay}ms (attempt ${state.reconnectAttempt})...`);
204
223
  state.reconnectTimer = setTimeout(() => {
205
224
  state.reconnectTimer = null;
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Device authentication for the local OpenClaw gateway.
3
+ *
4
+ * The gateway strips all scopes from WebSocket connections that do not include
5
+ * a valid device identity. Without scopes, methods like `chat.send` (which
6
+ * requires `operator.write`) are rejected.
7
+ *
8
+ * This module reads the device keypair that the `openclaw` CLI created during
9
+ * its initial pairing (`~/.openclaw/identity/device.json`) and builds the
10
+ * cryptographic `device` object that must be included in the WS `connect`
11
+ * frame.
12
+ */
13
+
14
+ import * as crypto from "crypto";
15
+ import * as fs from "fs";
16
+ import * as path from "path";
17
+ import * as os from "os";
18
+ import type { LoggerLike } from "./types";
19
+
20
+ // ── Types ──
21
+
22
+ export interface DeviceIdentity {
23
+ deviceId: string;
24
+ publicKeyPem: string;
25
+ privateKeyPem: string;
26
+ }
27
+
28
+ export interface DeviceConnectField {
29
+ id: string;
30
+ publicKey: string; // base64url raw 32-byte Ed25519 public key
31
+ signature: string; // base64url Ed25519 signature of the auth payload
32
+ signedAt: number; // ms since epoch
33
+ }
34
+
35
+ // ── Helpers ──
36
+
37
+ const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
38
+
39
+ /** Convert standard base64 to base64url (no padding). */
40
+ function toBase64Url(buf: Buffer): string {
41
+ return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
42
+ }
43
+
44
+ /** Extract the raw 32-byte public key from a PEM-encoded Ed25519 public key. */
45
+ function derivePublicKeyRaw(publicKeyPem: string): Buffer {
46
+ const keyObj = crypto.createPublicKey(publicKeyPem);
47
+ const spkiDer = keyObj.export({ type: "spki", format: "der" });
48
+ // Ed25519 SPKI = 12-byte prefix + 32-byte raw key
49
+ return spkiDer.subarray(ED25519_SPKI_PREFIX.length);
50
+ }
51
+
52
+ /** Build the same payload string that the gateway expects. */
53
+ function buildDeviceAuthPayload(params: {
54
+ deviceId: string;
55
+ clientId: string;
56
+ clientMode: string;
57
+ role: string;
58
+ scopes: string[];
59
+ signedAtMs: number;
60
+ token: string | null;
61
+ }): string {
62
+ // v1 format (no nonce): version|deviceId|clientId|clientMode|role|scopes|signedAtMs|token
63
+ return [
64
+ "v1",
65
+ params.deviceId,
66
+ params.clientId,
67
+ params.clientMode,
68
+ params.role,
69
+ params.scopes.join(","),
70
+ String(params.signedAtMs),
71
+ params.token ?? "",
72
+ ].join("|");
73
+ }
74
+
75
+ /** Sign a payload with an Ed25519 private key and return base64url signature. */
76
+ function signPayload(privateKeyPem: string, payload: string): string {
77
+ const key = crypto.createPrivateKey(privateKeyPem);
78
+ const sig = crypto.sign(null, Buffer.from(payload, "utf8"), key);
79
+ return toBase64Url(sig);
80
+ }
81
+
82
+ // ── Public API ──
83
+
84
+ const DEFAULT_IDENTITY_PATH = path.join(os.homedir(), ".openclaw", "identity", "device.json");
85
+
86
+ /**
87
+ * Load the device identity from disk.
88
+ * Returns `null` if the file does not exist or is malformed.
89
+ */
90
+ export function loadDeviceIdentity(logger: LoggerLike, filePath?: string): DeviceIdentity | null {
91
+ const p = filePath ?? DEFAULT_IDENTITY_PATH;
92
+ try {
93
+ if (!fs.existsSync(p)) {
94
+ logger.info?.(`[taptapai] No device identity at ${p} — device auth disabled`);
95
+ return null;
96
+ }
97
+ const raw = JSON.parse(fs.readFileSync(p, "utf8"));
98
+ if (raw?.version === 1 && raw.deviceId && raw.publicKeyPem && raw.privateKeyPem) {
99
+ logger.info?.(`[taptapai] Loaded device identity: ${raw.deviceId.substring(0, 12)}...`);
100
+ return {
101
+ deviceId: raw.deviceId,
102
+ publicKeyPem: raw.publicKeyPem,
103
+ privateKeyPem: raw.privateKeyPem,
104
+ };
105
+ }
106
+ logger.warn?.(`[taptapai] Device identity at ${p} has unexpected format`);
107
+ return null;
108
+ } catch (e: any) {
109
+ logger.warn?.(`[taptapai] Failed to load device identity: ${e?.message}`);
110
+ return null;
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Build the `device` field for the gateway WS `connect` frame.
116
+ *
117
+ * @param identity The device identity (from `loadDeviceIdentity`).
118
+ * @param clientId The client ID used in the connect frame (e.g. "gateway-client").
119
+ * @param clientMode The client mode (e.g. "backend").
120
+ * @param role The role (e.g. "operator").
121
+ * @param scopes The scopes to request (e.g. ["operator.admin", "operator.write", "operator.read"]).
122
+ * @param authToken The gateway auth token sent in `auth.token`.
123
+ */
124
+ export function buildDeviceConnect(
125
+ identity: DeviceIdentity,
126
+ clientId: string,
127
+ clientMode: string,
128
+ role: string,
129
+ scopes: string[],
130
+ authToken: string,
131
+ ): DeviceConnectField {
132
+ const signedAtMs = Date.now();
133
+ const payload = buildDeviceAuthPayload({
134
+ deviceId: identity.deviceId,
135
+ clientId,
136
+ clientMode,
137
+ role,
138
+ scopes,
139
+ signedAtMs,
140
+ token: authToken,
141
+ });
142
+ const signature = signPayload(identity.privateKeyPem, payload);
143
+ const publicKey = toBase64Url(derivePublicKeyRaw(identity.publicKeyPem));
144
+
145
+ return {
146
+ id: identity.deviceId,
147
+ publicKey,
148
+ signature,
149
+ signedAt: signedAtMs,
150
+ };
151
+ }
package/src/gatewayWs.ts CHANGED
@@ -4,6 +4,7 @@ import { readFileSync } from "fs";
4
4
  import { fileURLToPath } from "url";
5
5
  import { dirname, join } from "path";
6
6
  import type { LoggerLike } from "./types";
7
+ import { loadDeviceIdentity, buildDeviceConnect, type DeviceIdentity } from "./deviceAuth";
7
8
 
8
9
  let _pluginVersion = "";
9
10
  function getPluginVersion(): string {
@@ -42,6 +43,12 @@ export type GatewayWsState = {
42
43
  rpcId: number;
43
44
  pendingRequests: Map<string, PendingRequest>;
44
45
  pendingChat: Map<string, PendingChat>;
46
+ /** Tracks consecutive connect errors for log throttling. */
47
+ _consecutiveConnectErrors: number;
48
+ /** Timestamp of last logged connect error. */
49
+ _lastConnectErrorLog: number;
50
+ /** Cached device identity for device-auth (loaded once). */
51
+ _deviceIdentity?: DeviceIdentity | null;
45
52
  };
46
53
 
47
54
  export function getGatewayToken(runtime: any, pluginConfig: any, readOpenClawConfig: () => any): string {
@@ -111,6 +118,7 @@ export function connectGatewayWs(params: {
111
118
  try { clearTimeout(connectTimeout); } catch {}
112
119
  logger.info?.("[taptapai] Gateway WS open, waiting for connect.challenge...");
113
120
  state.reconnectAttempt = 0;
121
+ state._consecutiveConnectErrors = 0;
114
122
  });
115
123
 
116
124
  ws.addEventListener("message", (event: MessageEvent) => {
@@ -171,12 +179,25 @@ export function connectGatewayWs(params: {
171
179
  if (state.ws !== ws) return;
172
180
  const anyEvent: any = event as any;
173
181
  const msg = anyEvent?.message || anyEvent?.error?.message || String(event);
174
- logger.error?.(`[taptapai] Gateway WS error: ${msg}`);
182
+
183
+ // Throttle identical connection errors to avoid log flood during startup.
184
+ state._consecutiveConnectErrors++;
185
+ const now = Date.now();
186
+ const sinceLast = now - state._lastConnectErrorLog;
187
+ if (state._consecutiveConnectErrors === 1 || sinceLast >= 5000) {
188
+ const suffix = state._consecutiveConnectErrors > 1
189
+ ? ` (repeated ${state._consecutiveConnectErrors} times)`
190
+ : "";
191
+ logger.warn?.(`[taptapai] Gateway WS error: ${msg}${suffix}`);
192
+ state._lastConnectErrorLog = now;
193
+ state._consecutiveConnectErrors = 0;
194
+ }
175
195
 
176
196
  try { clearTimeout(connectTimeout); } catch {}
177
- try { ws.close(); } catch {}
197
+ // Null out state.ws BEFORE closing to prevent synchronous close handler re-entry.
178
198
  state.connected = false;
179
199
  state.ws = null;
200
+ try { ws.close(); } catch {}
180
201
 
181
202
  // Some implementations emit error without close; make sure we fail any waiters.
182
203
  for (const [id, pending] of state.pendingRequests) {
@@ -209,6 +230,29 @@ function sendGatewayConnect(params: {
209
230
  }
210
231
 
211
232
  const id = `gw-connect-${++state.rpcId}`;
233
+
234
+ // Load device identity once (cached on the state object).
235
+ // The device keypair was created by the `openclaw` CLI during initial pairing.
236
+ // Including it in the connect frame prevents the gateway from stripping scopes.
237
+ if (state._deviceIdentity === undefined) {
238
+ state._deviceIdentity = loadDeviceIdentity(logger);
239
+ }
240
+
241
+ const clientId = "gateway-client";
242
+ const clientMode = "backend";
243
+ const role = "operator";
244
+ const scopes = ["operator.admin", "operator.write", "operator.read"];
245
+
246
+ const device = state._deviceIdentity
247
+ ? buildDeviceConnect(state._deviceIdentity, clientId, clientMode, role, scopes, token)
248
+ : undefined;
249
+
250
+ if (device) {
251
+ logger.info?.(`[taptapai] Including device auth in connect (device=${device.id.substring(0, 12)}...)`);
252
+ } else {
253
+ logger.warn?.(`[taptapai] No device identity — scopes will be stripped by gateway`);
254
+ }
255
+
212
256
  const frame = {
213
257
  type: "req",
214
258
  id,
@@ -217,15 +261,16 @@ function sendGatewayConnect(params: {
217
261
  minProtocol: GATEWAY_PROTOCOL_VERSION,
218
262
  maxProtocol: GATEWAY_PROTOCOL_VERSION,
219
263
  client: {
220
- id: "gateway-client",
264
+ id: clientId,
221
265
  displayName: "TapTapAI Plugin",
222
266
  version: getPluginVersion(),
223
267
  platform: process.platform || "linux",
224
- mode: "backend",
268
+ mode: clientMode,
225
269
  },
226
270
  auth: { token },
227
- role: "operator",
228
- scopes: ["operator.admin"],
271
+ role,
272
+ scopes,
273
+ device,
229
274
  },
230
275
  };
231
276
 
@@ -265,7 +310,7 @@ function scheduleGatewayReconnect(params: {
265
310
  if (state.reconnectTimer) return;
266
311
 
267
312
  state.reconnectAttempt++;
268
- const delay = Math.min(1000 * Math.pow(2, state.reconnectAttempt - 1), MAX_RECONNECT_DELAY_MS);
313
+ const delay = Math.min(Math.max(2000, 1000 * Math.pow(2, state.reconnectAttempt - 1)), MAX_RECONNECT_DELAY_MS);
269
314
  logger.info?.(`[taptapai] Gateway WS reconnecting in ${delay}ms (attempt ${state.reconnectAttempt})...`);
270
315
  state.reconnectTimer = setTimeout(() => {
271
316
  state.reconnectTimer = null;
package/src/plugin.ts CHANGED
@@ -45,6 +45,8 @@ function createInitialState(): PluginState {
45
45
  reconnectAttempt: 0,
46
46
  watchdogTimer: null,
47
47
  pingTimer: null,
48
+ _consecutiveConnectErrors: 0,
49
+ _lastConnectErrorLog: 0,
48
50
  },
49
51
  gateway: {
50
52
  ws: null,
@@ -55,6 +57,8 @@ function createInitialState(): PluginState {
55
57
  rpcId: 0,
56
58
  pendingRequests: new Map(),
57
59
  pendingChat: new Map(),
60
+ _consecutiveConnectErrors: 0,
61
+ _lastConnectErrorLog: 0,
58
62
  },
59
63
  shutdownHandlersInstalled: false,
60
64
  globalErrorGuardsInstalled: false,
@@ -283,15 +287,21 @@ export function createTapTapAiPlugin() {
283
287
  state.aborted = false;
284
288
 
285
289
  logger.info?.("[taptapai] Starting local gateway WS client for agent dispatch...");
286
- connectGatewayWs({
287
- state: state.gateway,
288
- logger,
289
- aborted,
290
- enabled: () => state.bridgeLock.held,
291
- readOpenClawConfig: () => readOpenClawConfig(logger),
292
- runtime,
293
- pluginConfig,
294
- });
290
+ // Delay initial connection to let the gateway WS server finish starting.
291
+ // Inside the gateway process the server may not be listening yet.
292
+ const gwConnectDelay = isRunningInGatewayProcess() ? 3000 : 500;
293
+ setTimeout(() => {
294
+ if (aborted()) return;
295
+ connectGatewayWs({
296
+ state: state.gateway,
297
+ logger,
298
+ aborted,
299
+ enabled: () => state.bridgeLock.held,
300
+ readOpenClawConfig: () => readOpenClawConfig(logger),
301
+ runtime,
302
+ pluginConfig,
303
+ });
304
+ }, gwConnectDelay);
295
305
 
296
306
  if (backendWsUrl) {
297
307
  logger.info?.(`[taptapai] Starting backend WS connection to ${backendWsUrl}`);
@@ -81,15 +81,16 @@ export async function handleBackendRequest(deps: RequestHandlerDeps, req: RpcReq
81
81
  send("ack");
82
82
 
83
83
  try {
84
+ // Prefer WS chat.send through the local gateway — this requires
85
+ // device auth (operator.write scope). Falls back to HTTP
86
+ // /v1/chat/completions if the WS is not connected.
87
+ let responseText: string;
84
88
  if (gatewayState.connected) {
85
89
  logger.info?.(`[taptapai] Dispatching via gateway WS (chat.send) [ack sent in ${Date.now() - t0}ms]`);
86
- const responseText = await dispatchViaGateway({ state: gatewayState, logger, text: String(text), session: String(session) });
87
- const t1 = Date.now();
88
- logger.info?.(`[taptapai] ⏱️ agent.send DONE: total=${t1 - t0}ms | Gateway WS response: "${responseText.substring(0, 80)}..."`);
89
- send("stream", { text: responseText }, { event: "done" });
90
+ responseText = await dispatchViaGateway({ state: gatewayState, logger, text: String(text), session: String(session) });
90
91
  } else {
91
- logger.info?.("[taptapai] Gateway WS not connected — falling back to HTTP");
92
- const responseText = await callGatewayHttp({
92
+ logger.info?.(`[taptapai] Gateway WS not connected — falling back to HTTP (chat/completions) [ack sent in ${Date.now() - t0}ms]`);
93
+ responseText = await callGatewayHttp({
93
94
  text: String(text),
94
95
  session: String(session),
95
96
  runtime,
@@ -97,13 +98,13 @@ export async function handleBackendRequest(deps: RequestHandlerDeps, req: RpcReq
97
98
  readOpenClawConfig: () => readOpenClawConfig(logger),
98
99
  logger,
99
100
  });
100
- const t1 = Date.now();
101
- logger.info?.(`[taptapai] ⏱️ agent.send HTTP fallback DONE: total=${t1 - t0}ms`);
102
- send("stream", { text: responseText }, { event: "done" });
103
101
  }
102
+ const t1 = Date.now();
103
+ logger.info?.(`[taptapai] ⏱️ agent.send DONE: total=${t1 - t0}ms | Gateway HTTP response: "${responseText.substring(0, 80)}..."`);
104
+ send("stream", { text: responseText }, { event: "done" });
104
105
  } catch (e: any) {
105
106
  logger.error?.(`[taptapai] agent.send error after ${Date.now() - t0}ms: ${e?.message || e}`);
106
- send("stream", { text: `Error: ${e?.message || e}` }, { event: "done" });
107
+ send("error", undefined, { message: `${e?.message || e}`, code: 502 });
107
108
  }
108
109
 
109
110
  break;