helmpilot 0.4.2

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/outbound.ts ADDED
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Helmpilot Channel — Outbound Adapter
3
+ *
4
+ * Sends messages from OpenClaw to the Helmpilot desktop client via the relay tunnel.
5
+ *
6
+ * Message format (OpenClaw event frame, sent as raw WS string through relay):
7
+ * { "type": "event", "event": "helmpilot.outbound", "payload": { kind, text|mediaUrl, messageId, ... } }
8
+ *
9
+ * The Helmpilot client's handleMessage → handleEvent → EventDispatcher routes these
10
+ * to registered 'helmpilot.outbound' event handlers.
11
+ */
12
+
13
+ import { randomUUID } from 'node:crypto';
14
+ import { getRelay } from './relay-registry.js';
15
+
16
+ /** Maximum text length per outbound message (chars). Exceeding text will be chunked. */
17
+ const TEXT_CHUNK_LIMIT = 4000;
18
+ const CHANNEL_ID = 'helmpilot';
19
+
20
+ /**
21
+ * Build the outbound adapter object for ChannelPlugin.outbound.
22
+ *
23
+ * deliveryMode: "direct" — the adapter runs in the gateway process (where the relay lives).
24
+ */
25
+ export function createOutboundAdapter() {
26
+ return {
27
+ deliveryMode: 'direct' as const,
28
+ textChunkLimit: TEXT_CHUNK_LIMIT,
29
+
30
+ async sendText(ctx: {
31
+ to: string;
32
+ text: string;
33
+ replyToId?: string | null;
34
+ accountId?: string | null;
35
+ identity?: { name?: string; avatar?: string; emoji?: string };
36
+ }) {
37
+ const relay = getRelay(ctx.accountId);
38
+ if (!relay || relay.getState() !== 'connected') {
39
+ return { channel: CHANNEL_ID, messageId: '', error: 'Relay not connected' };
40
+ }
41
+
42
+ const messageId = `msg_${randomUUID()}`;
43
+ const msg = JSON.stringify({
44
+ type: 'event',
45
+ event: 'helmpilot.outbound',
46
+ payload: {
47
+ kind: 'text',
48
+ text: ctx.text,
49
+ messageId,
50
+ replyToId: ctx.replyToId ?? undefined,
51
+ identity: ctx.identity ?? undefined,
52
+ },
53
+ });
54
+
55
+ const sent = relay.sendRaw(msg);
56
+ if (!sent) {
57
+ return { channel: CHANNEL_ID, messageId: '', error: 'Failed to send via relay' };
58
+ }
59
+
60
+ return { channel: CHANNEL_ID, messageId };
61
+ },
62
+
63
+ async sendMedia(ctx: {
64
+ to: string;
65
+ text?: string;
66
+ mediaUrl?: string;
67
+ replyToId?: string | null;
68
+ accountId?: string | null;
69
+ identity?: { name?: string; avatar?: string; emoji?: string };
70
+ }) {
71
+ const relay = getRelay(ctx.accountId);
72
+ if (!relay || relay.getState() !== 'connected') {
73
+ return { channel: CHANNEL_ID, messageId: '', error: 'Relay not connected' };
74
+ }
75
+
76
+ const messageId = `msg_${randomUUID()}`;
77
+ const msg = JSON.stringify({
78
+ type: 'event',
79
+ event: 'helmpilot.outbound',
80
+ payload: {
81
+ kind: 'media',
82
+ mediaUrl: ctx.mediaUrl,
83
+ text: ctx.text ?? undefined,
84
+ messageId,
85
+ replyToId: ctx.replyToId ?? undefined,
86
+ identity: ctx.identity ?? undefined,
87
+ },
88
+ });
89
+
90
+ const sent = relay.sendRaw(msg);
91
+ if (!sent) {
92
+ return { channel: CHANNEL_ID, messageId: '', error: 'Failed to send via relay' };
93
+ }
94
+
95
+ return { channel: CHANNEL_ID, messageId };
96
+ },
97
+ };
98
+ }
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "helmpilot",
3
+ "version": "0.4.2",
4
+ "description": "Helmpilot desktop channel plugin for OpenClaw — interactive tools and gateway RPC bridge",
5
+ "type": "module",
6
+ "main": "index.ts",
7
+ "license": "MIT",
8
+ "keywords": [
9
+ "openclaw",
10
+ "plugin",
11
+ "helmpilot",
12
+ "channel",
13
+ "desktop"
14
+ ],
15
+ "openclaw": {
16
+ "extensions": [
17
+ "./index.ts"
18
+ ],
19
+ "setupEntry": "./setup-entry.ts",
20
+ "channel": {
21
+ "id": "helmpilot",
22
+ "label": "Helmpilot Desktop",
23
+ "docsPath": "/docs/channels/helmpilot",
24
+ "blurb": "Connect to the Helmpilot desktop AI assistant client"
25
+ },
26
+ "compat": {
27
+ "pluginApi": ">=2026.3.24",
28
+ "minGatewayVersion": "2026.3.24"
29
+ }
30
+ },
31
+ "dependencies": {
32
+ "ws": ">=8.0.0",
33
+ "@types/ws": ">=8.0.0"
34
+ },
35
+ "devDependencies": {
36
+ "@types/ws": "^8.18.1"
37
+ }
38
+ }
package/preload.cjs ADDED
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Plugin preload entry (CJS format).
3
+ *
4
+ * The openclaw framework loads plugins via require(), so this needs a .cjs suffix
5
+ * to work correctly in a "type": "module" package.
6
+ *
7
+ * Before requiring the actual plugin code (which depends on openclaw/plugin-sdk),
8
+ * we synchronously ensure the node_modules/openclaw symlink exists.
9
+ */
10
+ "use strict";
11
+
12
+ const { ensurePluginSdkSymlink } = require("./scripts/link-sdk-core.cjs");
13
+
14
+ // 1) Synchronously create symlink
15
+ ensurePluginSdkSymlink(__dirname, "[helmpilot-preload]");
16
+
17
+ // 2) Node 22 natively supports CJS require() loading ESM modules
18
+ // Synchronously load plugin entry so the framework can find register/activate
19
+ const _pluginModule = require("./index.ts");
20
+
21
+ // 3) Flatten default export: framework checks register/activate at top level
22
+ // ESM's export default becomes { default: plugin, ... } after require()
23
+ const _default = _pluginModule.default;
24
+ const merged = Object.assign({}, _pluginModule);
25
+ if (_default && typeof _default === "object") {
26
+ for (const key of Object.keys(_default)) {
27
+ if (!(key in merged)) {
28
+ merged[key] = _default[key];
29
+ }
30
+ }
31
+ }
32
+
33
+ module.exports = merged;
@@ -0,0 +1,380 @@
1
+ /**
2
+ * Relay outbound client for helmpilot-channel plugin.
3
+ *
4
+ * Connects to a Helmpilot Relay Service as the "gateway" side.
5
+ * The relay is a transparent tunnel — raw WS messages are forwarded
6
+ * between the device (Helmpilot Desktop) and the gateway plugin.
7
+ *
8
+ * Supports two auth modes:
9
+ * 1. Ed25519 challenge-response (preferred) — signs relay nonce with keypair
10
+ * 2. Legacy key auth — channelKey in URL query parameter
11
+ *
12
+ * Relay-originated messages (lifecycle events) are identified by `__relay: true`
13
+ * and handled internally. Everything else is forwarded via onRawMessage callback.
14
+ *
15
+ * In tunnel mode:
16
+ * Helmpilot Desktop ──WS──→ Relay ←──WS── this client (gateway plugin)
17
+ * ↕ bridge
18
+ * Local Gateway WS
19
+ */
20
+
21
+ import { WebSocket } from 'ws';
22
+ import { generateKeyPairSync, sign, randomBytes } from 'node:crypto';
23
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
24
+ import { join, dirname } from 'node:path';
25
+ import { homedir } from 'node:os';
26
+
27
+ // ── Types ──
28
+
29
+ /** Relay-originated lifecycle event (identified by __relay: true) */
30
+ interface RelayLifecycleMessage {
31
+ __relay: true;
32
+ type: 'lifecycle';
33
+ event: string;
34
+ timestamp: number;
35
+ }
36
+
37
+ /** Ed25519 keypair for relay authentication */
38
+ interface RelayKeyPair {
39
+ publicKey: string; // base64url, 32 bytes raw
40
+ privateKeyDer: string; // base64url, PKCS8 DER encoded
41
+ }
42
+
43
+ interface RelayClientConfig {
44
+ relayUrl: string;
45
+ channelId: string;
46
+ channelKey: string;
47
+ /** If true, use Ed25519 challenge-response instead of channelKey in URL */
48
+ useEd25519?: boolean;
49
+ }
50
+
51
+ interface RelayClientCallbacks {
52
+ /** Called when a raw application message arrives from the device, to be forwarded to local gateway */
53
+ onRawMessage: (data: string) => void;
54
+ /** Called when the relay sends a lifecycle event (device_connected, device_disconnected, etc.) */
55
+ onLifecycleEvent: (event: string) => void;
56
+ /** Called when the Relay connection state changes */
57
+ onStateChange: (state: RelayConnectionState) => void;
58
+ }
59
+
60
+ export type RelayConnectionState = 'disconnected' | 'connecting' | 'connected' | 'authenticating' | 'error';
61
+
62
+ const RECONNECT_BASE_MS = 1000;
63
+ const RECONNECT_MAX_MS = 30_000;
64
+ const PING_INTERVAL_MS = 25_000;
65
+
66
+ /** Ed25519 SPKI DER header (12 bytes) — stripped to get raw 32-byte public key */
67
+ const ED25519_SPKI_HEADER_LEN = 12;
68
+
69
+ /**
70
+ * Get or generate an Ed25519 keypair for the gateway plugin.
71
+ * Persists in ~/.openclaw/helmpilot-gateway-keypair.json
72
+ */
73
+ function getOrCreateKeyPair(): RelayKeyPair {
74
+ const dir = join(homedir(), '.openclaw');
75
+ const file = join(dir, 'helmpilot-gateway-keypair.json');
76
+
77
+ if (existsSync(file)) {
78
+ try {
79
+ const data = JSON.parse(readFileSync(file, 'utf-8')) as RelayKeyPair;
80
+ if (data.publicKey && data.privateKeyDer) return data;
81
+ } catch { /* regenerate */ }
82
+ }
83
+
84
+ const { publicKey: pubKeyObject, privateKey: privKeyObject } = generateKeyPairSync('ed25519');
85
+
86
+ // Export raw 32-byte public key as base64url
87
+ const spkiDer = pubKeyObject.export({ type: 'spki', format: 'der' });
88
+ const rawPubKey = spkiDer.subarray(ED25519_SPKI_HEADER_LEN);
89
+ const publicKey = rawPubKey.toString('base64url');
90
+
91
+ // Export private key as PKCS8 DER (for signing)
92
+ const pkcs8Der = privKeyObject.export({ type: 'pkcs8', format: 'der' });
93
+ const privateKeyDer = pkcs8Der.toString('base64url');
94
+
95
+ const kp: RelayKeyPair = { publicKey, privateKeyDer };
96
+ mkdirSync(dir, { recursive: true });
97
+ writeFileSync(file, JSON.stringify(kp, null, 2), { mode: 0o600 });
98
+ console.log('[helmpilot-relay-client] Generated new Ed25519 gateway keypair');
99
+ return kp;
100
+ }
101
+
102
+ /**
103
+ * Manages the outbound WebSocket connection to the Relay service.
104
+ * Auto-reconnects on disconnect with exponential backoff.
105
+ * Operates as a transparent tunnel — forwards raw messages without parsing.
106
+ */
107
+ export class RelayClient {
108
+ private ws: WebSocket | null = null;
109
+ private state: RelayConnectionState = 'disconnected';
110
+ private reconnectAttempt = 0;
111
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
112
+ private pingTimer: ReturnType<typeof setInterval> | null = null;
113
+ private stopped = false;
114
+ private keyPair: RelayKeyPair | null = null;
115
+
116
+ constructor(
117
+ private config: RelayClientConfig,
118
+ private callbacks: RelayClientCallbacks,
119
+ ) {
120
+ if (config.useEd25519) {
121
+ this.keyPair = getOrCreateKeyPair();
122
+ }
123
+ }
124
+
125
+ /** Get the Ed25519 public key (for registration with Relay) */
126
+ getPublicKey(): string | null {
127
+ return this.keyPair?.publicKey ?? null;
128
+ }
129
+
130
+ /**
131
+ * Register the gateway Ed25519 public key with the Relay via HTTP.
132
+ * Must be called before start() for Ed25519 auth to work.
133
+ * On failure, falls back to legacy auth silently.
134
+ */
135
+ async registerPublicKey(): Promise<boolean> {
136
+ if (!this.keyPair) return false;
137
+
138
+ try {
139
+ const url = new URL(this.config.relayUrl);
140
+ // Convert ws/wss to http/https for REST API calls
141
+ const httpProtocol = url.protocol === 'wss:' ? 'https:' : 'http:';
142
+ const base = `${httpProtocol}//${url.host}`;
143
+ const res = await fetch(`${base}/api/channels/${this.config.channelId}/register-key`, {
144
+ method: 'POST',
145
+ headers: { 'Content-Type': 'application/json' },
146
+ body: JSON.stringify({
147
+ side: 'gateway',
148
+ publicKey: this.keyPair.publicKey,
149
+ channelKey: this.config.channelKey,
150
+ }),
151
+ });
152
+ if (res.ok) {
153
+ console.log('[helmpilot-relay-client] Gateway Ed25519 public key registered');
154
+ return true;
155
+ }
156
+ console.warn(`[helmpilot-relay-client] Failed to register gateway public key: ${res.status}`);
157
+ // Fall back to legacy auth
158
+ this.config = { ...this.config, useEd25519: false };
159
+ this.keyPair = null;
160
+ return false;
161
+ } catch (err) {
162
+ console.warn('[helmpilot-relay-client] Failed to register gateway public key:', err);
163
+ this.config = { ...this.config, useEd25519: false };
164
+ this.keyPair = null;
165
+ return false;
166
+ }
167
+ }
168
+
169
+ /** Start the Relay connection (with auto-reconnect) */
170
+ start(): void {
171
+ this.stopped = false;
172
+ this.connect();
173
+ }
174
+
175
+ /** Stop the Relay connection and cancel reconnect */
176
+ stop(): void {
177
+ this.stopped = true;
178
+ this.clearTimers();
179
+ if (this.ws) {
180
+ try { this.ws.close(1000, 'Plugin stopping'); } catch {}
181
+ this.ws = null;
182
+ }
183
+ this.setState('disconnected');
184
+ }
185
+
186
+ /** Forward a raw message to the device through the Relay (transparent tunnel) */
187
+ sendRaw(data: string): boolean {
188
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return false;
189
+ try {
190
+ this.ws.send(data);
191
+ return true;
192
+ } catch {
193
+ return false;
194
+ }
195
+ }
196
+
197
+ /** Get current connection state */
198
+ getState(): RelayConnectionState {
199
+ return this.state;
200
+ }
201
+
202
+ // ── Internal ──
203
+
204
+ private connect(): void {
205
+ if (this.stopped) return;
206
+
207
+ this.setState('connecting');
208
+
209
+ // Build WS URL
210
+ const url = new URL(this.config.relayUrl);
211
+ if (!url.pathname.endsWith('/ws/gateway')) {
212
+ url.pathname = url.pathname.replace(/\/$/, '') + '/ws/gateway';
213
+ }
214
+ url.searchParams.set('channel', this.config.channelId);
215
+
216
+ // Only include key in URL for legacy auth (not Ed25519)
217
+ if (!this.config.useEd25519) {
218
+ url.searchParams.set('key', this.config.channelKey);
219
+ }
220
+
221
+ try {
222
+ this.ws = new WebSocket(url.toString());
223
+ } catch (err) {
224
+ console.error('[helmpilot-relay-client] Failed to create WebSocket:', err);
225
+ this.setState('error');
226
+ this.scheduleReconnect();
227
+ return;
228
+ }
229
+
230
+ this.ws.on('open', () => {
231
+ if (this.config.useEd25519) {
232
+ console.log('[helmpilot-relay-client] Connected to Relay, awaiting auth challenge');
233
+ this.setState('authenticating');
234
+ } else {
235
+ console.log('[helmpilot-relay-client] Connected to Relay (legacy auth)');
236
+ this.setState('connected');
237
+ this.reconnectAttempt = 0;
238
+ this.startPing();
239
+ }
240
+ });
241
+
242
+ this.ws.on('message', (data) => {
243
+ const raw = typeof data === 'string' ? data : data.toString();
244
+
245
+ // Check if this is a relay-originated message
246
+ if (raw.startsWith('{"__relay":true')) {
247
+ this.handleRelayMessage(raw);
248
+ return;
249
+ }
250
+
251
+ // Everything else is a forwarded application message (OpenClaw WS protocol)
252
+ this.callbacks.onRawMessage(raw);
253
+ });
254
+
255
+ this.ws.on('close', (code, reason) => {
256
+ console.log(`[helmpilot-relay-client] Disconnected: ${code} ${reason}`);
257
+ this.ws = null;
258
+ this.stopPing();
259
+ if (!this.stopped) {
260
+ this.setState('disconnected');
261
+ this.scheduleReconnect();
262
+ }
263
+ });
264
+
265
+ this.ws.on('error', (err) => {
266
+ console.error('[helmpilot-relay-client] WebSocket error:', err);
267
+ // 'close' event will follow
268
+ });
269
+ }
270
+
271
+ /** Handle relay-originated messages (__relay: true) — auth, lifecycle, flush markers, keepalive responses */
272
+ private handleRelayMessage(raw: string): void {
273
+ let msg: { __relay: true; type: string; event?: string; nonce?: string; reason?: string; count?: number; oldestTimestamp?: number };
274
+ try {
275
+ msg = JSON.parse(raw);
276
+ } catch {
277
+ return;
278
+ }
279
+
280
+ if (!msg.__relay) return;
281
+
282
+ switch (msg.type) {
283
+ case 'auth_challenge':
284
+ this.handleAuthChallenge(msg.nonce!);
285
+ break;
286
+ case 'auth_ok':
287
+ console.log('[helmpilot-relay-client] Ed25519 auth succeeded');
288
+ this.setState('connected');
289
+ this.reconnectAttempt = 0;
290
+ this.startPing();
291
+ break;
292
+ case 'auth_failed':
293
+ console.error(`[helmpilot-relay-client] Ed25519 auth failed: ${msg.reason}`);
294
+ this.setState('error');
295
+ break;
296
+ case 'flush_start':
297
+ console.log(`[helmpilot-relay-client] Flushing ${msg.count} buffered messages (oldest: ${msg.oldestTimestamp ? new Date(msg.oldestTimestamp).toISOString() : '?'})`);
298
+ break;
299
+ case 'flush_end':
300
+ console.log('[helmpilot-relay-client] Flush complete');
301
+ break;
302
+ case 'lifecycle':
303
+ if (typeof msg.event === 'string') {
304
+ this.callbacks.onLifecycleEvent(msg.event);
305
+ }
306
+ break;
307
+ }
308
+ }
309
+
310
+ /** Sign and respond to Ed25519 auth challenge from Relay */
311
+ private handleAuthChallenge(nonce: string): void {
312
+ if (!this.keyPair || !this.ws || this.ws.readyState !== WebSocket.OPEN) return;
313
+
314
+ try {
315
+ const { createPrivateKey } = require('node:crypto') as typeof import('node:crypto');
316
+
317
+ const message = `relay-auth|${this.config.channelId}|${nonce}`;
318
+ const messageBuffer = Buffer.from(message, 'utf-8');
319
+
320
+ // Reconstruct private key from PKCS8 DER
321
+ const pkcs8Der = Buffer.from(this.keyPair.privateKeyDer, 'base64url');
322
+ const privateKey = createPrivateKey({ key: pkcs8Der, format: 'der', type: 'pkcs8' });
323
+
324
+ const signature = sign(null, messageBuffer, privateKey);
325
+
326
+ this.ws.send(JSON.stringify({
327
+ __relay: true,
328
+ type: 'auth_response',
329
+ publicKey: this.keyPair.publicKey,
330
+ signature: signature.toString('base64url'),
331
+ }));
332
+ } catch (err) {
333
+ console.error('[helmpilot-relay-client] Failed to sign auth challenge:', err);
334
+ this.setState('error');
335
+ }
336
+ }
337
+
338
+ private scheduleReconnect(): void {
339
+ if (this.stopped) return;
340
+ const delay = Math.min(RECONNECT_BASE_MS * 2 ** this.reconnectAttempt, RECONNECT_MAX_MS);
341
+ this.reconnectAttempt++;
342
+ console.log(`[helmpilot-relay-client] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempt})`);
343
+ this.reconnectTimer = setTimeout(() => this.connect(), delay);
344
+ }
345
+
346
+ private startPing(): void {
347
+ this.stopPing();
348
+ // Send relay-level keepalive (not WS-level ping) so the relay server
349
+ // can update lastPing via its onMessage handler. The relay recognises
350
+ // messages starting with '{"__relay":true' and will NOT forward them.
351
+ const keepalive = JSON.stringify({ __relay: true, type: 'ping' });
352
+ this.pingTimer = setInterval(() => {
353
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
354
+ this.ws.send(keepalive);
355
+ }
356
+ }, PING_INTERVAL_MS);
357
+ }
358
+
359
+ private stopPing(): void {
360
+ if (this.pingTimer) {
361
+ clearInterval(this.pingTimer);
362
+ this.pingTimer = null;
363
+ }
364
+ }
365
+
366
+ private clearTimers(): void {
367
+ this.stopPing();
368
+ if (this.reconnectTimer) {
369
+ clearTimeout(this.reconnectTimer);
370
+ this.reconnectTimer = null;
371
+ }
372
+ }
373
+
374
+ private setState(state: RelayConnectionState): void {
375
+ if (this.state !== state) {
376
+ this.state = state;
377
+ this.callbacks.onStateChange(state);
378
+ }
379
+ }
380
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Module-level relay client registry.
3
+ *
4
+ * Allows the outbound adapter to access the RelayClient instance
5
+ * created by gateway.startAccount(). Each account gets one relay client.
6
+ */
7
+
8
+ import type { RelayClient } from './relay-client.js';
9
+
10
+ const accountRelays = new Map<string, RelayClient>();
11
+
12
+ /** Register a relay client for an account (called from gateway.startAccount) */
13
+ export function registerRelay(accountId: string, client: RelayClient): void {
14
+ accountRelays.set(accountId, client);
15
+ }
16
+
17
+ /** Unregister a relay client (called on account stop/abort) */
18
+ export function unregisterRelay(accountId: string): void {
19
+ accountRelays.delete(accountId);
20
+ }
21
+
22
+ /** Get the relay client for an account. Requires explicit accountId — no fallback to prevent cross-account leaks. */
23
+ export function getRelay(accountId?: string | null): RelayClient | null {
24
+ if (!accountId) return null;
25
+ return accountRelays.get(accountId) ?? null;
26
+ }
27
+
28
+ /** Get the first available relay client (only for single-account fallback during bootstrap). */
29
+ export function getFirstRelay(): RelayClient | null {
30
+ const first = accountRelays.values().next();
31
+ return first.done ? null : first.value;
32
+ }