@clawchatsai/connector 0.0.12 → 0.0.14

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.
@@ -20,6 +20,7 @@ export interface PluginConfig {
20
20
  }
21
21
  export interface BridgeConfig {
22
22
  gatewayToken: string;
23
+ dataDir: string;
23
24
  }
24
25
  export declare class GatewayBridge extends EventEmitter {
25
26
  private readonly gatewayUrl;
@@ -9,6 +9,9 @@
9
9
  * 3. Reconnect with exponential backoff on disconnect.
10
10
  */
11
11
  import { EventEmitter } from 'node:events';
12
+ import * as crypto from 'node:crypto';
13
+ import * as fs from 'node:fs';
14
+ import * as path from 'node:path';
12
15
  import { WebSocket } from 'ws';
13
16
  import { PLUGIN_VERSION } from './index.js';
14
17
  // Reconnect backoff constants (milliseconds)
@@ -19,6 +22,62 @@ const CLIENT_ID = 'gateway-client';
19
22
  const CLIENT_MODE = 'backend';
20
23
  const ROLE = 'operator';
21
24
  const SCOPES = ['operator.read', 'operator.write', 'operator.admin'];
25
+ // ---------------------------------------------------------------------------
26
+ // Device Identity — ed25519 key generation + connect payload signing
27
+ // Required by OpenClaw ≥2.15 to prevent scope clearing on connect.
28
+ // ---------------------------------------------------------------------------
29
+ const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
30
+ function _derivePublicKeyRaw(publicKeyPem) {
31
+ const spki = crypto.createPublicKey(publicKeyPem).export({ type: 'spki', format: 'der' });
32
+ if (spki.length === ED25519_SPKI_PREFIX.length + 32 &&
33
+ spki.subarray(0, ED25519_SPKI_PREFIX.length).equals(ED25519_SPKI_PREFIX)) {
34
+ return spki.subarray(ED25519_SPKI_PREFIX.length);
35
+ }
36
+ return spki;
37
+ }
38
+ function _fingerprintPublicKey(publicKeyPem) {
39
+ return crypto.createHash('sha256').update(_derivePublicKeyRaw(publicKeyPem)).digest('hex');
40
+ }
41
+ function _base64UrlEncode(buf) {
42
+ return buf.toString('base64').replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/g, '');
43
+ }
44
+ function _loadOrCreateDeviceIdentity(identityPath) {
45
+ try {
46
+ if (fs.existsSync(identityPath)) {
47
+ const parsed = JSON.parse(fs.readFileSync(identityPath, 'utf8'));
48
+ if (parsed?.version === 1 && parsed.deviceId && parsed.publicKeyPem && parsed.privateKeyPem) {
49
+ return parsed;
50
+ }
51
+ }
52
+ }
53
+ catch { /* regenerate */ }
54
+ const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
55
+ const publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' }).toString();
56
+ const privateKeyPem = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString();
57
+ const identity = {
58
+ version: 1,
59
+ deviceId: _fingerprintPublicKey(publicKeyPem),
60
+ publicKeyPem,
61
+ privateKeyPem,
62
+ createdAtMs: Date.now(),
63
+ };
64
+ fs.mkdirSync(path.dirname(identityPath), { recursive: true });
65
+ fs.writeFileSync(identityPath, JSON.stringify(identity, null, 2) + '\n', { mode: 0o600 });
66
+ return identity;
67
+ }
68
+ function _buildDeviceAuth(identity, params) {
69
+ const signedAt = Date.now();
70
+ const payload = [
71
+ 'v2', identity.deviceId, params.clientId, params.clientMode,
72
+ params.role, params.scopes.join(','), String(signedAt),
73
+ params.token || '', params.nonce,
74
+ ].join('|');
75
+ const privateKey = crypto.createPrivateKey(identity.privateKeyPem);
76
+ const signature = _base64UrlEncode(crypto.sign(null, Buffer.from(payload, 'utf8'), privateKey));
77
+ const publicKeyB64Url = _base64UrlEncode(_derivePublicKeyRaw(identity.publicKeyPem));
78
+ return { id: identity.deviceId, publicKey: publicKeyB64Url, signature, signedAt, nonce: params.nonce };
79
+ }
80
+ // ---------------------------------------------------------------------------
22
81
  export class GatewayBridge extends EventEmitter {
23
82
  gatewayUrl;
24
83
  config;
@@ -121,9 +180,11 @@ export class GatewayBridge extends EventEmitter {
121
180
  }
122
181
  const type = msg['type'];
123
182
  const event = msg['event'];
124
- // connect.challenge → send token-only connect request (loopback skips nonce)
183
+ // connect.challenge → send signed connect request with device identity
125
184
  if (type === 'event' && event === 'connect.challenge') {
126
- this._sendConnect();
185
+ const payload = msg['payload'];
186
+ const nonce = payload?.['nonce'] ?? '';
187
+ this._sendConnect(nonce);
127
188
  return;
128
189
  }
129
190
  // hello-ok → handshake complete
@@ -138,7 +199,17 @@ export class GatewayBridge extends EventEmitter {
138
199
  }
139
200
  }
140
201
  }
141
- _sendConnect() {
202
+ _sendConnect(nonce) {
203
+ const identityPath = path.join(this.config.dataDir, 'device-identity.json');
204
+ const identity = _loadOrCreateDeviceIdentity(identityPath);
205
+ const device = _buildDeviceAuth(identity, {
206
+ clientId: CLIENT_ID,
207
+ clientMode: CLIENT_MODE,
208
+ role: ROLE,
209
+ scopes: SCOPES,
210
+ token: this.config.gatewayToken,
211
+ nonce,
212
+ });
142
213
  this.send(JSON.stringify({
143
214
  type: 'req',
144
215
  id: 'gw-connect-1',
@@ -154,6 +225,7 @@ export class GatewayBridge extends EventEmitter {
154
225
  },
155
226
  role: ROLE,
156
227
  scopes: SCOPES,
228
+ device,
157
229
  auth: { token: this.config.gatewayToken },
158
230
  caps: ['tool-events'],
159
231
  },
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
- * @shellchat/tunnel — OpenClaw plugin entry point
2
+ * @clawchatsai/connector — OpenClaw plugin entry point
3
3
  *
4
- * Registers ShellChat as a gateway plugin, providing:
4
+ * Registers ClawChats as a gateway plugin, providing:
5
5
  * - Local HTTP API bridge via createApp()
6
6
  * - WebRTC DataChannel for browser connections
7
7
  * - Signaling client for NAT traversal
@@ -10,7 +10,7 @@
10
10
  * Spec: specs/multitenant-p2p.md sections 6.1-6.2
11
11
  */
12
12
  export declare const PLUGIN_ID = "connector";
13
- export declare const PLUGIN_VERSION = "0.0.12";
13
+ export declare const PLUGIN_VERSION = "0.0.13";
14
14
  interface PluginServiceContext {
15
15
  stateDir: string;
16
16
  logger: {
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
- * @shellchat/tunnel — OpenClaw plugin entry point
2
+ * @clawchatsai/connector — OpenClaw plugin entry point
3
3
  *
4
- * Registers ShellChat as a gateway plugin, providing:
4
+ * Registers ClawChats as a gateway plugin, providing:
5
5
  * - Local HTTP API bridge via createApp()
6
6
  * - WebRTC DataChannel for browser connections
7
7
  * - Signaling client for NAT traversal
@@ -19,7 +19,7 @@ import { checkForUpdates, performUpdate } from './updater.js';
19
19
  // Inline from shared/api-version.ts to avoid rootDir conflict
20
20
  const CURRENT_API_VERSION = 1;
21
21
  export const PLUGIN_ID = 'connector';
22
- export const PLUGIN_VERSION = '0.0.12';
22
+ export const PLUGIN_VERSION = '0.0.13';
23
23
  /** Max DataChannel message size (~256KB, leave room for envelope) */
24
24
  const MAX_DC_MESSAGE_SIZE = 256 * 1024;
25
25
  /** Active DataChannel connections: connectionId → send function */
@@ -32,7 +32,7 @@ let _stopRequested = false;
32
32
  // ---------------------------------------------------------------------------
33
33
  // Config helpers
34
34
  // ---------------------------------------------------------------------------
35
- const CONFIG_DIR = path.join(process.env.HOME || '/root', '.openclaw', 'shellchats');
35
+ const CONFIG_DIR = path.join(process.env.HOME || '/root', '.openclaw', 'clawchats');
36
36
  const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
37
37
  const RUNTIME_FILE = path.join(CONFIG_DIR, 'runtime.json');
38
38
  function loadConfig() {
@@ -73,11 +73,11 @@ async function ensureNativeModules(ctx) {
73
73
  ctx.logger.error('Try running manually: cd ~/.openclaw/extensions/connector && npm rebuild better-sqlite3');
74
74
  }
75
75
  }
76
- async function startShellChat(ctx, api) {
76
+ async function startClawChats(ctx, api) {
77
77
  _stopRequested = false;
78
78
  let config = loadConfig();
79
79
  if (!config) {
80
- ctx.logger.info('ShellChats not configured. Waiting for setup...');
80
+ ctx.logger.info('ClawChats not configured. Waiting for setup...');
81
81
  fs.mkdirSync(CONFIG_DIR, { recursive: true });
82
82
  while (!config && !_stopRequested) {
83
83
  await new Promise(r => setTimeout(r, 2000));
@@ -85,7 +85,7 @@ async function startShellChat(ctx, api) {
85
85
  }
86
86
  if (_stopRequested || !config)
87
87
  return;
88
- ctx.logger.info('Setup detected — connecting to ShellChats...');
88
+ ctx.logger.info('Setup detected — connecting to ClawChats...');
89
89
  }
90
90
  // 1. Check for updates
91
91
  const update = await checkForUpdates();
@@ -95,7 +95,7 @@ async function startShellChat(ctx, api) {
95
95
  try {
96
96
  await performUpdate();
97
97
  ctx.logger.info(`Updated to ${update.latest}. Requesting graceful restart...`);
98
- api.runtime.requestRestart?.('shellchats update');
98
+ api.runtime.requestRestart?.('clawchats update');
99
99
  return; // will restart with new version
100
100
  }
101
101
  catch (e) {
@@ -108,14 +108,14 @@ async function startShellChat(ctx, api) {
108
108
  const gwAuth = gwCfg?.['gateway']?.['auth'];
109
109
  const gatewayToken = gwAuth?.['token'] || config.gatewayToken || '';
110
110
  if (!gatewayToken) {
111
- ctx.logger.error('No gateway token available. Re-run: openclaw shellchats setup <token>');
111
+ ctx.logger.error('No gateway token available. Re-run: openclaw clawchats setup <token>');
112
112
  return;
113
113
  }
114
114
  // 3. Ensure native modules are built (OpenClaw installs with --ignore-scripts)
115
115
  await ensureNativeModules(ctx);
116
116
  // 4. Import server.js and create app instance with plugin paths
117
- const dataDir = path.join(ctx.stateDir, 'shellchats', 'data');
118
- const uploadsDir = path.join(ctx.stateDir, 'shellchats', 'uploads');
117
+ const dataDir = path.join(ctx.stateDir, 'clawchats', 'data');
118
+ const uploadsDir = path.join(ctx.stateDir, 'clawchats', 'uploads');
119
119
  // Dynamic import of server.js (plain JS, no type declarations)
120
120
  // @ts-expect-error — server.js is plain JS with no .d.ts
121
121
  const serverModule = await import('../server.js');
@@ -228,11 +228,11 @@ async function startShellChat(ctx, api) {
228
228
  }, null, 2), { mode: 0o600 });
229
229
  ctx.logger.info(`Health endpoint on 127.0.0.1:${addr.port}`);
230
230
  });
231
- ctx.logger.info('ShellChat service started');
231
+ ctx.logger.info('ClawChats service started');
232
232
  }
233
- async function stopShellChat(ctx) {
233
+ async function stopClawChats(ctx) {
234
234
  _stopRequested = true;
235
- ctx.logger.info('ShellChat service stopping...');
235
+ ctx.logger.info('ClawChats service stopping...');
236
236
  // 0. Tear down health endpoint
237
237
  if (healthServer) {
238
238
  healthServer.close();
@@ -259,7 +259,7 @@ async function stopShellChat(ctx) {
259
259
  // 4. Close SQLite databases
260
260
  app?.shutdown();
261
261
  app = null;
262
- ctx.logger.info('ShellChat service stopped');
262
+ ctx.logger.info('ClawChats service stopped');
263
263
  }
264
264
  // ---------------------------------------------------------------------------
265
265
  // DataChannel message handler (spec section 6.4)
@@ -386,7 +386,7 @@ function broadcastToClients(msg) {
386
386
  // ---------------------------------------------------------------------------
387
387
  function formatStatus() {
388
388
  const lines = [];
389
- lines.push(`ShellChat Plugin v${PLUGIN_VERSION}`);
389
+ lines.push(`ClawChats Plugin v${PLUGIN_VERSION}`);
390
390
  lines.push(`Gateway: ${app?.gatewayClient?.connected ? 'connected' : 'disconnected'}`);
391
391
  lines.push(`Signaling: ${signaling?.isConnected ? 'connected' : 'disconnected'}`);
392
392
  lines.push(`Clients: ${connectedClients.size}`);
@@ -410,7 +410,7 @@ async function handleSetup(token) {
410
410
  console.error('Setup token has expired. Generate a new one from clawchats.ai.');
411
411
  return;
412
412
  }
413
- console.log('Setting up ShellChats...');
413
+ console.log('Setting up ClawChats...');
414
414
  console.log(` Server: ${tokenData.serverUrl}`);
415
415
  // Generate API key for signaling server auth
416
416
  const { randomBytes } = await import('node:crypto');
@@ -467,7 +467,7 @@ async function handleSetup(token) {
467
467
  const uploadsDir = path.join(CONFIG_DIR, 'uploads');
468
468
  fs.mkdirSync(dataDir, { recursive: true });
469
469
  fs.mkdirSync(uploadsDir, { recursive: true });
470
- console.log(' ShellChats is ready!');
470
+ console.log(' ClawChats is ready!');
471
471
  console.log(' Open clawchats.ai in your browser to start chatting.');
472
472
  ws.close();
473
473
  resolve();
@@ -497,7 +497,7 @@ async function handleStatus() {
497
497
  runtime = JSON.parse(fs.readFileSync(RUNTIME_FILE, 'utf8'));
498
498
  }
499
499
  catch {
500
- console.log('ShellChats: offline (service not running)');
500
+ console.log('ClawChats: offline (service not running)');
501
501
  return;
502
502
  }
503
503
  // Verify PID is alive
@@ -505,7 +505,7 @@ async function handleStatus() {
505
505
  process.kill(runtime.pid, 0);
506
506
  }
507
507
  catch {
508
- console.log('ShellChats: offline (stale runtime file)');
508
+ console.log('ClawChats: offline (stale runtime file)');
509
509
  try {
510
510
  fs.unlinkSync(RUNTIME_FILE);
511
511
  }
@@ -524,20 +524,20 @@ async function handleStatus() {
524
524
  req.setTimeout(3000, () => { req.destroy(); reject(new Error('timeout')); });
525
525
  });
526
526
  const status = JSON.parse(body);
527
- console.log(`ShellChats Plugin v${status.version}`);
527
+ console.log(`ClawChats Plugin v${status.version}`);
528
528
  console.log(`Uptime: ${Math.floor(status.uptime)}s`);
529
529
  console.log(`Gateway: ${status.gateway.connected ? 'connected' : 'disconnected'}`);
530
530
  console.log(`Signaling: ${status.signaling.connected ? 'connected' : 'disconnected'}`);
531
531
  console.log(`Clients: ${status.clients.active}`);
532
532
  }
533
533
  catch {
534
- console.log('ShellChats: offline (could not reach service)');
534
+ console.log('ClawChats: offline (could not reach service)');
535
535
  }
536
536
  }
537
537
  async function handleReset() {
538
538
  try {
539
539
  fs.rmSync(CONFIG_DIR, { recursive: true, force: true });
540
- console.log('ShellChats data removed. Plugin disconnected.');
540
+ console.log('ClawChats data removed. Plugin disconnected.');
541
541
  }
542
542
  catch (e) {
543
543
  console.error(`Reset failed: ${e.message}`);
@@ -554,7 +554,7 @@ async function handleImport(sourcePath) {
554
554
  console.error(`Source must be a directory: ${resolvedSource}`);
555
555
  return;
556
556
  }
557
- // Destination: ~/.openclaw/shellchats/data/
557
+ // Destination: ~/.openclaw/clawchats/data/
558
558
  const destDataDir = path.join(CONFIG_DIR, 'data');
559
559
  fs.mkdirSync(destDataDir, { recursive: true });
560
560
  // Import .db files
@@ -586,7 +586,7 @@ async function handleImport(sourcePath) {
586
586
  }
587
587
  }
588
588
  // Also try to migrate config.json from the parent directory
589
- // e.g. if source is ~/.openclaw/shellchat/data/, config is at ~/.openclaw/shellchat/config.json
589
+ // e.g. if source is ~/.openclaw/clawchats/data/, config is at ~/.openclaw/clawchats/config.json
590
590
  const parentConfigPath = path.join(path.dirname(resolvedSource), 'config.json');
591
591
  if (fs.existsSync(parentConfigPath)) {
592
592
  try {
@@ -605,26 +605,26 @@ async function handleImport(sourcePath) {
605
605
  // ---------------------------------------------------------------------------
606
606
  const plugin = {
607
607
  id: PLUGIN_ID,
608
- name: 'ShellChats',
609
- description: 'Connects your gateway to ShellChats via WebRTC P2P',
608
+ name: 'ClawChats',
609
+ description: 'Connects your gateway to ClawChats via WebRTC P2P',
610
610
  register(api) {
611
611
  // Background service: signaling + gateway bridge + future WebRTC
612
612
  api.registerService({
613
613
  id: 'connector-service',
614
- start: (ctx) => startShellChat(ctx, api),
615
- stop: (ctx) => stopShellChat(ctx),
614
+ start: (ctx) => startClawChats(ctx, api),
615
+ stop: (ctx) => stopClawChats(ctx),
616
616
  });
617
617
  // CLI commands
618
618
  api.registerCli((ctx) => {
619
- const cmd = ctx.program.command('shellchats');
619
+ const cmd = ctx.program.command('clawchats');
620
620
  cmd.command('setup <token>')
621
- .description('Set up ShellChats with a setup token')
621
+ .description('Set up ClawChats with a setup token')
622
622
  .action((token) => handleSetup(String(token)));
623
623
  cmd.command('status')
624
- .description('Show ShellChats connection status')
624
+ .description('Show ClawChats connection status')
625
625
  .action(() => handleStatus());
626
626
  cmd.command('reset')
627
- .description('Disconnect and remove all ShellChats data')
627
+ .description('Disconnect and remove all ClawChats data')
628
628
  .action(() => handleReset());
629
629
  cmd.command('import <path>')
630
630
  .description('Import databases and config from a folder (e.g. migrate from old data directory)')
@@ -632,8 +632,8 @@ const plugin = {
632
632
  });
633
633
  // Slash command for status from any channel
634
634
  api.registerCommand({
635
- name: 'shellchats',
636
- description: 'Show ShellChats tunnel status',
635
+ name: 'clawchats',
636
+ description: 'Show ClawChats tunnel status',
637
637
  handler: () => ({ text: formatStatus() }),
638
638
  });
639
639
  },
package/dist/migrate.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Schema migration runner for the ShellChat plugin SQLite database.
2
+ * Schema migration runner for the ClawChats plugin SQLite database.
3
3
  *
4
4
  * - Tracks schema version in a `_schema_version` table
5
5
  * - Runs migrations sequentially from current version to target
package/dist/migrate.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Schema migration runner for the ShellChat plugin SQLite database.
2
+ * Schema migration runner for the ClawChats plugin SQLite database.
3
3
  *
4
4
  * - Tracks schema version in a `_schema_version` table
5
5
  * - Runs migrations sequentially from current version to target
package/dist/shim.js CHANGED
@@ -46,7 +46,7 @@ class FakeReq extends Readable {
46
46
  }
47
47
  else if (parsed && parsed['_multipart']) {
48
48
  // Multipart form data: { _multipart: true, fields: { key: string | { filename, contentType, data } } }
49
- const boundary = '----ShellChatBoundary' + Date.now();
49
+ const boundary = '----ClawChatsBoundary' + Date.now();
50
50
  this.headers['content-type'] = `multipart/form-data; boundary=${boundary}`;
51
51
  const fields = parsed['fields'] || {};
52
52
  const parts = [];
@@ -1,5 +1,5 @@
1
1
  /**
2
- * SignalingClient — persistent WSS connection to the ShellChat signaling server.
2
+ * SignalingClient — persistent WSS connection to the ClawChats signaling server.
3
3
  *
4
4
  * Responsibilities:
5
5
  * - Authenticate with the signaling server on connect (gateway-auth)
@@ -1,5 +1,5 @@
1
1
  /**
2
- * SignalingClient — persistent WSS connection to the ShellChat signaling server.
2
+ * SignalingClient — persistent WSS connection to the ClawChats signaling server.
3
3
  *
4
4
  * Responsibilities:
5
5
  * - Authenticate with the signaling server on connect (gateway-auth)
@@ -109,7 +109,7 @@ export declare class WebRTCPeerManager extends EventEmitter {
109
109
  handleIceCandidate(connectionId: string, candidate: unknown): void;
110
110
  /**
111
111
  * Close all active peer connections and DataChannels.
112
- * Called during plugin shutdown (stopShellChat).
112
+ * Called during plugin shutdown (stopClawChats).
113
113
  */
114
114
  closeAll(): void;
115
115
  /**
@@ -194,7 +194,7 @@ export class WebRTCPeerManager extends EventEmitter {
194
194
  }
195
195
  /**
196
196
  * Close all active peer connections and DataChannels.
197
- * Called during plugin shutdown (stopShellChat).
197
+ * Called during plugin shutdown (stopClawChats).
198
198
  */
199
199
  closeAll() {
200
200
  console.log(`[WebRTCPeerManager] Closing all connections (${this.peerConnections.size} peers, ${this.activeChannels.size} channels)`);
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "connector",
3
- "name": "ShellChats",
4
- "description": "Connects your OpenClaw gateway to ShellChats via WebRTC P2P",
3
+ "name": "ClawChats",
4
+ "description": "Connects your OpenClaw gateway to ClawChats via WebRTC P2P",
5
5
  "kind": "integration",
6
6
  "configSchema": {
7
7
  "type": "object",
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@clawchatsai/connector",
3
- "version": "0.0.12",
3
+ "version": "0.0.14",
4
4
  "type": "module",
5
- "description": "ShellChat OpenClaw plugin — P2P tunnel + local API bridge",
5
+ "description": "ClawChats OpenClaw plugin — P2P tunnel + local API bridge",
6
6
  "main": "dist/index.js",
7
7
  "types": "dist/index.d.ts",
8
8
  "files": [
package/server.js CHANGED
@@ -1,4 +1,4 @@
1
- // ShellChat Backend Server
1
+ // ClawChats Backend Server
2
2
  // Single-file Node.js HTTP server with SQLite storage
3
3
  // See specs/backend-session-architecture.md for full spec
4
4
 
@@ -16,9 +16,55 @@ import { WebSocket as WS, WebSocketServer } from 'ws';
16
16
  const __filename = fileURLToPath(import.meta.url);
17
17
  const __dirname = path.dirname(__filename);
18
18
 
19
+ // ─── Device Identity (ed25519 signing for OpenClaw ≥2.15 scope preservation) ─
20
+
21
+ const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
22
+
23
+ function _derivePublicKeyRaw(publicKeyPem) {
24
+ const spki = crypto.createPublicKey(publicKeyPem).export({ type: 'spki', format: 'der' });
25
+ if (spki.length === ED25519_SPKI_PREFIX.length + 32 &&
26
+ spki.subarray(0, ED25519_SPKI_PREFIX.length).equals(ED25519_SPKI_PREFIX)) {
27
+ return spki.subarray(ED25519_SPKI_PREFIX.length);
28
+ }
29
+ return spki;
30
+ }
31
+
32
+ function _fingerprintPublicKey(publicKeyPem) {
33
+ return crypto.createHash('sha256').update(_derivePublicKeyRaw(publicKeyPem)).digest('hex');
34
+ }
35
+
36
+ function _base64UrlEncode(buf) {
37
+ return buf.toString('base64').replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/g, '');
38
+ }
39
+
40
+ function _loadOrCreateDeviceIdentity(identityPath) {
41
+ try {
42
+ if (fs.existsSync(identityPath)) {
43
+ const parsed = JSON.parse(fs.readFileSync(identityPath, 'utf8'));
44
+ if (parsed?.version === 1 && parsed.deviceId && parsed.publicKeyPem && parsed.privateKeyPem) return parsed;
45
+ }
46
+ } catch { /* regenerate */ }
47
+ const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
48
+ const publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' }).toString();
49
+ const privateKeyPem = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString();
50
+ const identity = { version: 1, deviceId: _fingerprintPublicKey(publicKeyPem), publicKeyPem, privateKeyPem, createdAtMs: Date.now() };
51
+ fs.mkdirSync(path.dirname(identityPath), { recursive: true });
52
+ fs.writeFileSync(identityPath, JSON.stringify(identity, null, 2) + '\n', { mode: 0o600 });
53
+ return identity;
54
+ }
55
+
56
+ function _buildDeviceAuth(identity, { clientId, clientMode, role, scopes, token, nonce }) {
57
+ const signedAt = Date.now();
58
+ const payload = ['v2', identity.deviceId, clientId, clientMode, role, scopes.join(','), String(signedAt), token || '', nonce].join('|');
59
+ const privateKey = crypto.createPrivateKey(identity.privateKeyPem);
60
+ const signature = _base64UrlEncode(crypto.sign(null, Buffer.from(payload, 'utf8'), privateKey));
61
+ const publicKeyB64Url = _base64UrlEncode(_derivePublicKeyRaw(identity.publicKeyPem));
62
+ return { id: identity.deviceId, publicKey: publicKeyB64Url, signature, signedAt, nonce };
63
+ }
64
+
19
65
  // ─── Configuration ──────────────────────────────────────────────────────────
20
66
 
21
- const PORT = parseInt(process.env.SHELLCHAT_PORT || '3001', 10);
67
+ const PORT = parseInt(process.env.CLAWCHATS_PORT || process.env.SHELLCHAT_PORT || '3001', 10);
22
68
  const DATA_DIR = path.join(__dirname, 'data');
23
69
  const UPLOADS_DIR = path.join(__dirname, 'uploads');
24
70
  const WORKSPACES_FILE = path.join(DATA_DIR, 'workspaces.json');
@@ -128,9 +174,9 @@ function parseConfigField(field) {
128
174
  }
129
175
 
130
176
  // Load auth token from config.js or env var
131
- let AUTH_TOKEN = process.env.SHELLCHAT_AUTH_TOKEN || parseConfigField('authToken') || '';
177
+ let AUTH_TOKEN = process.env.CLAWCHATS_AUTH_TOKEN || process.env.SHELLCHAT_AUTH_TOKEN || parseConfigField('authToken') || '';
132
178
  if (!AUTH_TOKEN) {
133
- console.error('WARNING: No auth token configured. Set SHELLCHAT_AUTH_TOKEN or create config.js');
179
+ console.error('WARNING: No auth token configured. Set CLAWCHATS_AUTH_TOKEN or create config.js');
134
180
  }
135
181
 
136
182
  // Load gateway WebSocket URL
@@ -837,7 +883,7 @@ async function handleMarkMessagesRead(req, res, params) {
837
883
  // Broadcast unread-update to ALL browser clients (so other tabs/devices sync)
838
884
  const workspace = getWorkspaces().active;
839
885
  gatewayClient.broadcastToBrowsers(JSON.stringify({
840
- type: 'shellchat',
886
+ type: 'clawchats',
841
887
  event: 'unread-update',
842
888
  workspace,
843
889
  threadId,
@@ -2233,6 +2279,13 @@ class GatewayClient {
2233
2279
  // Handle connect.challenge (handshake)
2234
2280
  if (msg.type === 'event' && msg.event === 'connect.challenge') {
2235
2281
  console.log('Received connect.challenge, sending auth...');
2282
+ const nonce = msg.payload?.nonce || '';
2283
+ const identityPath = path.join(DATA_DIR, 'device-identity.json');
2284
+ const identity = _loadOrCreateDeviceIdentity(identityPath);
2285
+ const device = _buildDeviceAuth(identity, {
2286
+ clientId: 'gateway-client', clientMode: 'backend', role: 'operator',
2287
+ scopes: ['operator.read', 'operator.write', 'operator.admin'], token: AUTH_TOKEN, nonce
2288
+ });
2236
2289
  this.ws.send(JSON.stringify({
2237
2290
  type: 'req',
2238
2291
  id: 'gw-connect-1',
@@ -2243,6 +2296,7 @@ class GatewayClient {
2243
2296
  client: { id: 'gateway-client', version: '0.1.0', platform: 'node', mode: 'backend' },
2244
2297
  role: 'operator',
2245
2298
  scopes: ['operator.read', 'operator.write', 'operator.admin'],
2299
+ device,
2246
2300
  auth: { token: AUTH_TOKEN },
2247
2301
  caps: ['tool-events']
2248
2302
  }
@@ -2315,7 +2369,7 @@ class GatewayClient {
2315
2369
 
2316
2370
  saveAssistantMessage(sessionKey, message, seq) {
2317
2371
  const parsed = parseSessionKey(sessionKey);
2318
- if (!parsed) return; // Non-ShellChat session key, silently ignore
2372
+ if (!parsed) return; // Non-ClawChats session key, silently ignore
2319
2373
 
2320
2374
  // Guard: verify workspace still exists
2321
2375
  const ws = getWorkspaces();
@@ -2370,7 +2424,7 @@ class GatewayClient {
2370
2424
 
2371
2425
  // Broadcast message-saved for active thread reload
2372
2426
  this.broadcastToBrowsers(JSON.stringify({
2373
- type: 'shellchat',
2427
+ type: 'clawchats',
2374
2428
  event: 'message-saved',
2375
2429
  threadId: parsed.threadId,
2376
2430
  workspace: parsed.workspace,
@@ -2384,7 +2438,7 @@ class GatewayClient {
2384
2438
  // Always broadcast unread-update — browser sends read receipts to clear
2385
2439
  const workspaceUnreadTotal = db.prepare('SELECT COALESCE(SUM(unread_count), 0) as total FROM threads').get().total;
2386
2440
  this.broadcastToBrowsers(JSON.stringify({
2387
- type: 'shellchat',
2441
+ type: 'clawchats',
2388
2442
  event: 'unread-update',
2389
2443
  workspace: parsed.workspace,
2390
2444
  threadId: parsed.threadId,
@@ -2562,7 +2616,7 @@ class GatewayClient {
2562
2616
  if (!parsed) return;
2563
2617
 
2564
2618
  this.broadcastToBrowsers(JSON.stringify({
2565
- type: 'shellchat',
2619
+ type: 'clawchats',
2566
2620
  event: 'agent-activity',
2567
2621
  workspace: parsed.workspace,
2568
2622
  threadId: parsed.threadId,
@@ -2707,7 +2761,7 @@ class GatewayClient {
2707
2761
 
2708
2762
  // Notify browsers to re-render this message with activity data
2709
2763
  this.broadcastToBrowsers(JSON.stringify({
2710
- type: 'shellchat',
2764
+ type: 'clawchats',
2711
2765
  event: 'activity-saved',
2712
2766
  workspace: parsed.workspace,
2713
2767
  threadId: parsed.threadId,
@@ -2790,7 +2844,7 @@ class GatewayClient {
2790
2844
 
2791
2845
  broadcastGatewayStatus(connected) {
2792
2846
  const msg = JSON.stringify({
2793
- type: 'shellchat',
2847
+ type: 'clawchats',
2794
2848
  event: 'gateway-status',
2795
2849
  connected
2796
2850
  });
@@ -2819,7 +2873,7 @@ class GatewayClient {
2819
2873
  // Send current gateway status
2820
2874
  if (ws.readyState === WS.OPEN) {
2821
2875
  ws.send(JSON.stringify({
2822
- type: 'shellchat',
2876
+ type: 'clawchats',
2823
2877
  event: 'gateway-status',
2824
2878
  connected: this.connected
2825
2879
  }));
@@ -2833,7 +2887,7 @@ class GatewayClient {
2833
2887
  }
2834
2888
  if (streams.length > 0) {
2835
2889
  ws.send(JSON.stringify({
2836
- type: 'shellchat',
2890
+ type: 'clawchats',
2837
2891
  event: 'stream-sync',
2838
2892
  streams
2839
2893
  }));
@@ -2870,7 +2924,7 @@ class GatewayClient {
2870
2924
  // Broadcast unread-update clear to ALL browser clients
2871
2925
  const workspaceUnreadTotal = db.prepare('SELECT COALESCE(SUM(unread_count), 0) as total FROM threads').get().total;
2872
2926
  this.broadcastToBrowsers(JSON.stringify({
2873
- type: 'shellchat',
2927
+ type: 'clawchats',
2874
2928
  event: 'unread-update',
2875
2929
  workspace,
2876
2930
  threadId,
@@ -2898,7 +2952,7 @@ function syncThreadUnreadCount(db, threadId) {
2898
2952
  function parseSessionKey(sessionKey) {
2899
2953
  if (!sessionKey) return null;
2900
2954
  const match = sessionKey.match(/^agent:main:([^:]+):chat:([^:]+)$/);
2901
- if (!match) return null; // Non-ShellChat keys — silently ignore
2955
+ if (!match) return null; // Non-ClawChats keys — silently ignore
2902
2956
  return { workspace: match[1], threadId: match[2] };
2903
2957
  }
2904
2958
 
@@ -2919,7 +2973,7 @@ const gatewayClient = new GatewayClient();
2919
2973
 
2920
2974
  // ─── createApp Factory ───────────────────────────────────────────────────────
2921
2975
  // Returns an isolated instance of the app state + handlers.
2922
- // Used by the plugin (signaling/index.js) to embed ShellChat logic without
2976
+ // Used by the plugin (signaling/index.js) to embed ClawChats logic without
2923
2977
  // spinning up a standalone HTTP server.
2924
2978
 
2925
2979
  export function createApp(config = {}) {
@@ -2932,7 +2986,7 @@ export function createApp(config = {}) {
2932
2986
 
2933
2987
  let _AUTH_TOKEN = config.authToken !== undefined
2934
2988
  ? config.authToken
2935
- : (process.env.SHELLCHAT_AUTH_TOKEN || parseConfigField('authToken') || '');
2989
+ : (process.env.CLAWCHATS_AUTH_TOKEN || process.env.SHELLCHAT_AUTH_TOKEN || parseConfigField('authToken') || '');
2936
2990
 
2937
2991
  // Separate token for gateway WS auth (falls back to _AUTH_TOKEN for direct mode)
2938
2992
  const _GATEWAY_TOKEN = config.gatewayToken !== undefined
@@ -3178,7 +3232,7 @@ export function createApp(config = {}) {
3178
3232
  const remaining = syncThreadUnreadCount(db, threadId);
3179
3233
  const workspace = _getWorkspaces().active;
3180
3234
  _gatewayClient.broadcastToBrowsers(JSON.stringify({
3181
- type: 'shellchat', event: 'unread-update', workspace, threadId,
3235
+ type: 'clawchats', event: 'unread-update', workspace, threadId,
3182
3236
  action: 'read', messageIds, unreadCount: remaining, timestamp: Date.now()
3183
3237
  }));
3184
3238
  send(res, 200, { unread_count: remaining });
@@ -3567,7 +3621,14 @@ export function createApp(config = {}) {
3567
3621
  try { msg = JSON.parse(data); } catch { console.error('Invalid JSON from gateway:', data); return; }
3568
3622
  if (msg.type === 'event' && msg.event === 'connect.challenge') {
3569
3623
  console.log('Received connect.challenge, sending auth...');
3570
- this.ws.send(JSON.stringify({ type: 'req', id: 'gw-connect-1', method: 'connect', params: { minProtocol: 3, maxProtocol: 3, client: { id: 'gateway-client', version: '0.1.0', platform: 'node', mode: 'backend' }, role: 'operator', scopes: ['operator.read', 'operator.write', 'operator.admin'], auth: { token: _GATEWAY_TOKEN }, caps: ['tool-events'] } }));
3624
+ const _nonce = msg.payload?.nonce || '';
3625
+ const _identityPath = path.join(_DATA_DIR, 'device-identity.json');
3626
+ const _identity = _loadOrCreateDeviceIdentity(_identityPath);
3627
+ const _device = _buildDeviceAuth(_identity, {
3628
+ clientId: 'gateway-client', clientMode: 'backend', role: 'operator',
3629
+ scopes: ['operator.read', 'operator.write', 'operator.admin'], token: _GATEWAY_TOKEN, nonce: _nonce
3630
+ });
3631
+ this.ws.send(JSON.stringify({ type: 'req', id: 'gw-connect-1', method: 'connect', params: { minProtocol: 3, maxProtocol: 3, client: { id: 'gateway-client', version: '0.1.0', platform: 'node', mode: 'backend' }, role: 'operator', scopes: ['operator.read', 'operator.write', 'operator.admin'], device: _device, auth: { token: _GATEWAY_TOKEN }, caps: ['tool-events'] } }));
3571
3632
  return;
3572
3633
  }
3573
3634
  if (msg.type === 'res' && msg.payload?.type === 'hello-ok') { console.log('Gateway handshake complete'); this.connected = true; this.broadcastGatewayStatus(true); }
@@ -3618,9 +3679,9 @@ export function createApp(config = {}) {
3618
3679
  const threadInfo = db.prepare('SELECT title FROM threads WHERE id = ?').get(parsed.threadId);
3619
3680
  const unreadCount = db.prepare('SELECT COUNT(*) as c FROM unread_messages WHERE thread_id = ?').get(parsed.threadId).c;
3620
3681
  const preview = content.length > 120 ? content.substring(0, 120) + '...' : content;
3621
- this.broadcastToBrowsers(JSON.stringify({ type: 'shellchat', event: 'message-saved', threadId: parsed.threadId, workspace: parsed.workspace, messageId, timestamp: now, title: threadInfo?.title || 'Chat', preview, unreadCount }));
3682
+ this.broadcastToBrowsers(JSON.stringify({ type: 'clawchats', event: 'message-saved', threadId: parsed.threadId, workspace: parsed.workspace, messageId, timestamp: now, title: threadInfo?.title || 'Chat', preview, unreadCount }));
3622
3683
  const workspaceUnreadTotal = db.prepare('SELECT COALESCE(SUM(unread_count), 0) as total FROM threads').get().total;
3623
- this.broadcastToBrowsers(JSON.stringify({ type: 'shellchat', event: 'unread-update', workspace: parsed.workspace, threadId: parsed.threadId, messageId, action: 'new', unreadCount, workspaceUnreadTotal, title: threadInfo?.title || 'Chat', preview, timestamp: now }));
3684
+ this.broadcastToBrowsers(JSON.stringify({ type: 'clawchats', event: 'unread-update', workspace: parsed.workspace, threadId: parsed.threadId, messageId, action: 'new', unreadCount, workspaceUnreadTotal, title: threadInfo?.title || 'Chat', preview, timestamp: now }));
3624
3685
  console.log(`Saved assistant message to ${parsed.workspace}/${parsed.threadId} (seq: ${seq})`);
3625
3686
  } catch (e) { console.error(`Failed to save assistant message:`, e.message); }
3626
3687
  }
@@ -3698,7 +3759,7 @@ export function createApp(config = {}) {
3698
3759
  broadcastActivityUpdate(runId, log) {
3699
3760
  const parsed = log.sessionKey ? parseSessionKey(log.sessionKey) : null;
3700
3761
  if (!parsed) return;
3701
- this.broadcastToBrowsers(JSON.stringify({ type: 'shellchat', event: 'agent-activity', workspace: parsed.workspace, threadId: parsed.threadId, runId, steps: log.steps, summary: this.generateActivitySummary(log.steps) }));
3762
+ this.broadcastToBrowsers(JSON.stringify({ type: 'clawchats', event: 'agent-activity', workspace: parsed.workspace, threadId: parsed.threadId, runId, steps: log.steps, summary: this.generateActivitySummary(log.steps) }));
3702
3763
  }
3703
3764
 
3704
3765
  finalizeActivityLog(runId, log) {
@@ -3763,7 +3824,7 @@ export function createApp(config = {}) {
3763
3824
  metadata.activitySummary = this.generateActivitySummary(log.steps);
3764
3825
  db.prepare('UPDATE messages SET metadata = ? WHERE id = ?').run(JSON.stringify(metadata), msg.id);
3765
3826
  console.log(`[ActivityLog] Saved ${toolSteps.length} tool steps for message ${msg.id}`);
3766
- this.broadcastToBrowsers(JSON.stringify({ type: 'shellchat', event: 'activity-saved', workspace: parsed.workspace, threadId: parsed.threadId, messageId: msg.id }));
3827
+ this.broadcastToBrowsers(JSON.stringify({ type: 'clawchats', event: 'activity-saved', workspace: parsed.workspace, threadId: parsed.threadId, messageId: msg.id }));
3767
3828
  }
3768
3829
  }
3769
3830
  } catch (e) { console.error('Failed to save activity log:', e.message); }
@@ -3803,7 +3864,7 @@ export function createApp(config = {}) {
3803
3864
  }
3804
3865
 
3805
3866
  broadcastGatewayStatus(connected) {
3806
- this.broadcastToBrowsers(JSON.stringify({ type: 'shellchat', event: 'gateway-status', connected }));
3867
+ this.broadcastToBrowsers(JSON.stringify({ type: 'clawchats', event: 'gateway-status', connected }));
3807
3868
  }
3808
3869
 
3809
3870
  sendToGateway(data) {
@@ -3822,12 +3883,12 @@ export function createApp(config = {}) {
3822
3883
  addBrowserClient(ws) {
3823
3884
  this.browserClients.set(ws, { activeWorkspace: null, activeThreadId: null });
3824
3885
  if (ws.readyState === WS.OPEN) {
3825
- ws.send(JSON.stringify({ type: 'shellchat', event: 'gateway-status', connected: this.connected }));
3886
+ ws.send(JSON.stringify({ type: 'clawchats', event: 'gateway-status', connected: this.connected }));
3826
3887
  const streams = [];
3827
3888
  for (const [sessionKey, state] of this.streamState.entries()) {
3828
3889
  if (state.state === 'streaming') streams.push({ sessionKey, threadId: state.threadId, buffer: state.buffer });
3829
3890
  }
3830
- if (streams.length > 0) ws.send(JSON.stringify({ type: 'shellchat', event: 'stream-sync', streams }));
3891
+ if (streams.length > 0) ws.send(JSON.stringify({ type: 'clawchats', event: 'stream-sync', streams }));
3831
3892
  }
3832
3893
  }
3833
3894
 
@@ -3847,7 +3908,7 @@ export function createApp(config = {}) {
3847
3908
  if (deleted.changes > 0) {
3848
3909
  syncThreadUnreadCount(db, threadId);
3849
3910
  const workspaceUnreadTotal = db.prepare('SELECT COALESCE(SUM(unread_count), 0) as total FROM threads').get().total;
3850
- this.broadcastToBrowsers(JSON.stringify({ type: 'shellchat', event: 'unread-update', workspace, threadId, action: 'clear', unreadCount: 0, workspaceUnreadTotal, timestamp: Date.now() }));
3911
+ this.broadcastToBrowsers(JSON.stringify({ type: 'clawchats', event: 'unread-update', workspace, threadId, action: 'clear', unreadCount: 0, workspaceUnreadTotal, timestamp: Date.now() }));
3851
3912
  }
3852
3913
  } catch (e) { console.error('Failed to auto-clear unreads on active-thread:', e.message); }
3853
3914
  }
@@ -3963,7 +4024,7 @@ export function createApp(config = {}) {
3963
4024
  const token = msg.params?.auth?.token;
3964
4025
  if (token === _AUTH_TOKEN || !_AUTH_TOKEN) {
3965
4026
  console.log('Browser client authenticated');
3966
- ws.send(JSON.stringify({ type: 'res', id: msg.id, ok: true, payload: { type: 'hello-ok', protocol: 3, server: { version: '0.1.0', host: 'shellchat-backend' } } }));
4027
+ ws.send(JSON.stringify({ type: 'res', id: msg.id, ok: true, payload: { type: 'hello-ok', protocol: 3, server: { version: '0.1.0', host: 'clawchats-backend' } } }));
3967
4028
  } else {
3968
4029
  console.log('Browser client auth failed');
3969
4030
  ws.send(JSON.stringify({ type: 'res', id: msg.id, ok: false, error: { code: 'AUTH_FAILED', message: 'Invalid auth token' } }));
@@ -3971,12 +4032,12 @@ export function createApp(config = {}) {
3971
4032
  }
3972
4033
  return;
3973
4034
  }
3974
- if (msg.type === 'shellchat') {
4035
+ if (msg.type === 'clawchats' || msg.type === 'shellchat') { // backward compat: accept legacy 'shellchat' type
3975
4036
  if (msg.action === 'active-thread') { _gatewayClient.setActiveThread(ws, msg.workspace, msg.threadId); console.log(`Browser client set active thread: ${msg.workspace}/${msg.threadId}`); return; }
3976
- if (msg.action === 'debug-start') { const result = _debugLogger.start(msg.ts, ws); if (result.error === 'already-active') ws.send(JSON.stringify({ type: 'shellchat', event: 'debug-error', error: 'Recording already active in another tab', sessionId: result.sessionId })); else ws.send(JSON.stringify({ type: 'shellchat', event: 'debug-started', sessionId: result.sessionId })); return; }
3977
- if (msg.action === 'debug-dump') { const { sessionId, files } = _debugLogger.saveDump(msg); ws.send(JSON.stringify({ type: 'shellchat', event: 'debug-saved', sessionId, files })); return; }
4037
+ if (msg.action === 'debug-start') { const result = _debugLogger.start(msg.ts, ws); if (result.error === 'already-active') ws.send(JSON.stringify({ type: 'clawchats', event: 'debug-error', error: 'Recording already active in another tab', sessionId: result.sessionId })); else ws.send(JSON.stringify({ type: 'clawchats', event: 'debug-started', sessionId: result.sessionId })); return; }
4038
+ if (msg.action === 'debug-dump') { const { sessionId, files } = _debugLogger.saveDump(msg); ws.send(JSON.stringify({ type: 'clawchats', event: 'debug-saved', sessionId, files })); return; }
3978
4039
  }
3979
- } catch { /* Not JSON or not a ShellChat message, forward to gateway */ }
4040
+ } catch { /* Not JSON or not a ClawChats message, forward to gateway */ }
3980
4041
  _gatewayClient.sendToGateway(msgStr);
3981
4042
  });
3982
4043
 
@@ -4025,7 +4086,7 @@ if (isDirectRun) {
4025
4086
  });
4026
4087
 
4027
4088
  server.listen(PORT, () => {
4028
- console.log(`ShellChat backend listening on port ${PORT}`);
4089
+ console.log(`ClawChats backend listening on port ${PORT}`);
4029
4090
  console.log(`Active workspace: ${app.getWorkspaces().active}`);
4030
4091
  console.log(`Data dir: ${app.dataDir}`);
4031
4092