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/README.md +307 -0
- package/__tests__/config-adapter.test.ts +249 -0
- package/__tests__/outbound.test.ts +147 -0
- package/__tests__/setup-adapter.test.ts +138 -0
- package/adapters.ts +242 -0
- package/bridge.ts +220 -0
- package/channel-plugin.ts +295 -0
- package/index.ts +298 -0
- package/openclaw.plugin.json +67 -0
- package/outbound.ts +98 -0
- package/package.json +38 -0
- package/preload.cjs +33 -0
- package/relay-client.ts +380 -0
- package/relay-registry.ts +32 -0
- package/scripts/link-sdk-core.cjs +124 -0
- package/setup-entry.ts +193 -0
- package/tools.ts +121 -0
- package/types.ts +93 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { createSetupAdapter } from '../adapters.js';
|
|
3
|
+
|
|
4
|
+
describe('createSetupAdapter', () => {
|
|
5
|
+
const setup = createSetupAdapter();
|
|
6
|
+
const emptyCfg = {};
|
|
7
|
+
|
|
8
|
+
describe('applyAccountConfig', () => {
|
|
9
|
+
it('maps --url to relayUrl', () => {
|
|
10
|
+
const result = setup.applyAccountConfig({
|
|
11
|
+
cfg: emptyCfg,
|
|
12
|
+
accountId: 'default',
|
|
13
|
+
input: { url: 'wss://relay.example.com' },
|
|
14
|
+
});
|
|
15
|
+
expect((result as any).channels.helmpilot.relayUrl).toBe('wss://relay.example.com');
|
|
16
|
+
expect((result as any).channels.helmpilot.enabled).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('maps --token to channelKey', () => {
|
|
20
|
+
const result = setup.applyAccountConfig({
|
|
21
|
+
cfg: emptyCfg,
|
|
22
|
+
accountId: 'default',
|
|
23
|
+
input: { token: 'ck_abc123' },
|
|
24
|
+
});
|
|
25
|
+
expect((result as any).channels.helmpilot.channelKey).toBe('ck_abc123');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('maps --code to channelId', () => {
|
|
29
|
+
const result = setup.applyAccountConfig({
|
|
30
|
+
cfg: emptyCfg,
|
|
31
|
+
accountId: 'default',
|
|
32
|
+
input: { code: 'ch_abc123' },
|
|
33
|
+
});
|
|
34
|
+
expect((result as any).channels.helmpilot.channelId).toBe('ch_abc123');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('maps all three fields at once', () => {
|
|
38
|
+
const result = setup.applyAccountConfig({
|
|
39
|
+
cfg: emptyCfg,
|
|
40
|
+
accountId: 'default',
|
|
41
|
+
input: { url: 'wss://relay.example.com', code: 'ch_abc', token: 'ck_xyz' },
|
|
42
|
+
});
|
|
43
|
+
const hp = (result as any).channels.helmpilot;
|
|
44
|
+
expect(hp.relayUrl).toBe('wss://relay.example.com');
|
|
45
|
+
expect(hp.channelId).toBe('ch_abc');
|
|
46
|
+
expect(hp.channelKey).toBe('ck_xyz');
|
|
47
|
+
expect(hp.enabled).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('preserves existing config', () => {
|
|
51
|
+
const cfg = { channels: { helmpilot: { relayUrl: 'wss://old.example.com' } } };
|
|
52
|
+
const result = setup.applyAccountConfig({
|
|
53
|
+
cfg,
|
|
54
|
+
accountId: 'default',
|
|
55
|
+
input: { token: 'ck_new' },
|
|
56
|
+
});
|
|
57
|
+
const hp = (result as any).channels.helmpilot;
|
|
58
|
+
expect(hp.relayUrl).toBe('wss://old.example.com');
|
|
59
|
+
expect(hp.channelKey).toBe('ck_new');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('does not mutate original config', () => {
|
|
63
|
+
const cfg = { channels: { helmpilot: { relayUrl: 'wss://orig.com' } } };
|
|
64
|
+
setup.applyAccountConfig({ cfg, accountId: 'default', input: { token: 'ck_x' } });
|
|
65
|
+
expect((cfg as any).channels.helmpilot.channelKey).toBeUndefined();
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('validateInput', () => {
|
|
70
|
+
it('rejects empty input', () => {
|
|
71
|
+
expect(setup.validateInput({ cfg: emptyCfg, accountId: 'default', input: {} })).toBeTruthy();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('rejects invalid URL protocol', () => {
|
|
75
|
+
const err = setup.validateInput({
|
|
76
|
+
cfg: emptyCfg,
|
|
77
|
+
accountId: 'default',
|
|
78
|
+
input: { url: 'ftp://relay.example.com' },
|
|
79
|
+
});
|
|
80
|
+
expect(err).toContain('protocol');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('rejects malformed URL', () => {
|
|
84
|
+
const err = setup.validateInput({
|
|
85
|
+
cfg: emptyCfg,
|
|
86
|
+
accountId: 'default',
|
|
87
|
+
input: { url: 'not-a-url' },
|
|
88
|
+
});
|
|
89
|
+
expect(err).toContain('Invalid');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('rejects token without ck_ prefix', () => {
|
|
93
|
+
const err = setup.validateInput({
|
|
94
|
+
cfg: emptyCfg,
|
|
95
|
+
accountId: 'default',
|
|
96
|
+
input: { token: 'bad_token' },
|
|
97
|
+
});
|
|
98
|
+
expect(err).toContain('ck_');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('rejects code without ch_ prefix', () => {
|
|
102
|
+
const err = setup.validateInput({
|
|
103
|
+
cfg: emptyCfg,
|
|
104
|
+
accountId: 'default',
|
|
105
|
+
input: { code: 'bad_channel' },
|
|
106
|
+
});
|
|
107
|
+
expect(err).toContain('ch_');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('accepts valid url only', () => {
|
|
111
|
+
expect(
|
|
112
|
+
setup.validateInput({ cfg: emptyCfg, accountId: 'default', input: { url: 'wss://r.com' } }),
|
|
113
|
+
).toBeNull();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('accepts valid code only', () => {
|
|
117
|
+
expect(
|
|
118
|
+
setup.validateInput({ cfg: emptyCfg, accountId: 'default', input: { code: 'ch_abc' } }),
|
|
119
|
+
).toBeNull();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('accepts valid token only', () => {
|
|
123
|
+
expect(
|
|
124
|
+
setup.validateInput({ cfg: emptyCfg, accountId: 'default', input: { token: 'ck_abc' } }),
|
|
125
|
+
).toBeNull();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('accepts all three valid inputs', () => {
|
|
129
|
+
expect(
|
|
130
|
+
setup.validateInput({
|
|
131
|
+
cfg: emptyCfg,
|
|
132
|
+
accountId: 'default',
|
|
133
|
+
input: { url: 'wss://r.com', code: 'ch_abc', token: 'ck_xyz' },
|
|
134
|
+
}),
|
|
135
|
+
).toBeNull();
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
});
|
package/adapters.ts
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helmpilot Channel — Adapters
|
|
3
|
+
*
|
|
4
|
+
* Implements: Messaging, Setup, Status, Pairing, Security adapters
|
|
5
|
+
* for the full ChannelPlugin interface.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { RelayConnectionState } from './relay-client.js';
|
|
9
|
+
import { getRelay } from './relay-registry.js';
|
|
10
|
+
|
|
11
|
+
// ── Constants ──
|
|
12
|
+
|
|
13
|
+
const CHANNEL_ID = 'helmpilot';
|
|
14
|
+
const DEFAULT_ACCOUNT_ID = 'default';
|
|
15
|
+
const HELMPILOT_CONFIG_SECTION = 'channels.helmpilot';
|
|
16
|
+
|
|
17
|
+
// ── Messaging Adapter ──
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Helmpilot targets are simple: a single device per channel.
|
|
21
|
+
* Target format: "helmpilot:default" or just "default".
|
|
22
|
+
*/
|
|
23
|
+
export function createMessagingAdapter() {
|
|
24
|
+
return {
|
|
25
|
+
normalizeTarget(raw: string): string | undefined {
|
|
26
|
+
if (!raw) return undefined;
|
|
27
|
+
// Strip channel prefix if present
|
|
28
|
+
const stripped = raw.startsWith('helmpilot:') ? raw.slice(10) : raw;
|
|
29
|
+
return stripped || undefined;
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
targetResolver: {
|
|
33
|
+
looksLikeId(raw: string): boolean {
|
|
34
|
+
// Helmpilot targets are simple identifiers (no complex format)
|
|
35
|
+
return /^[a-zA-Z0-9_-]+$/.test(raw);
|
|
36
|
+
},
|
|
37
|
+
hint: 'Device ID (default: "default")',
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ── Setup Adapter ──
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* CLI configuration wizard for helmpilot-channel.
|
|
46
|
+
*
|
|
47
|
+
* Supports input fields:
|
|
48
|
+
* - url: Relay URL (e.g. wss://relay.example.com)
|
|
49
|
+
* - token: Channel key (ck_xxx...)
|
|
50
|
+
*/
|
|
51
|
+
export function createSetupAdapter() {
|
|
52
|
+
return {
|
|
53
|
+
applyAccountConfig(params: {
|
|
54
|
+
cfg: Record<string, unknown>;
|
|
55
|
+
accountId: string;
|
|
56
|
+
input: { url?: string; token?: string; code?: string };
|
|
57
|
+
}): Record<string, unknown> {
|
|
58
|
+
const cfg = structuredClone(params.cfg);
|
|
59
|
+
const channels = (cfg.channels ?? {}) as Record<string, unknown>;
|
|
60
|
+
const hp = (channels.helmpilot ?? {}) as Record<string, unknown>;
|
|
61
|
+
|
|
62
|
+
if (params.input.url) {
|
|
63
|
+
hp.relayUrl = params.input.url;
|
|
64
|
+
}
|
|
65
|
+
if (params.input.code) {
|
|
66
|
+
hp.channelId = params.input.code;
|
|
67
|
+
}
|
|
68
|
+
if (params.input.token) {
|
|
69
|
+
hp.channelKey = params.input.token;
|
|
70
|
+
}
|
|
71
|
+
hp.enabled = true;
|
|
72
|
+
|
|
73
|
+
channels.helmpilot = hp;
|
|
74
|
+
cfg.channels = channels;
|
|
75
|
+
return cfg;
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
validateInput(params: {
|
|
79
|
+
cfg: Record<string, unknown>;
|
|
80
|
+
accountId: string;
|
|
81
|
+
input: { url?: string; token?: string; code?: string };
|
|
82
|
+
}): string | null {
|
|
83
|
+
const { url, token, code } = params.input;
|
|
84
|
+
|
|
85
|
+
if (url) {
|
|
86
|
+
try {
|
|
87
|
+
const parsed = new URL(url);
|
|
88
|
+
if (!['ws:', 'wss:', 'http:', 'https:'].includes(parsed.protocol)) {
|
|
89
|
+
return 'Relay URL must use ws://, wss://, http://, or https:// protocol';
|
|
90
|
+
}
|
|
91
|
+
} catch {
|
|
92
|
+
return 'Invalid Relay URL format';
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (code && !code.startsWith('ch_')) {
|
|
97
|
+
return 'Channel ID must start with "ch_"';
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (token && !token.startsWith('ck_')) {
|
|
101
|
+
return 'Channel key must start with "ck_"';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Check for required fields (existing config values fill gaps)
|
|
105
|
+
const channels = (params.cfg?.channels ?? {}) as Record<string, unknown>;
|
|
106
|
+
const existing = (channels?.helmpilot ?? {}) as Record<string, unknown>;
|
|
107
|
+
const hasUrl = url || existing.relayUrl;
|
|
108
|
+
const hasCode = code || existing.channelId;
|
|
109
|
+
const hasToken = token || typeof existing.channelKey === 'string';
|
|
110
|
+
|
|
111
|
+
if (!hasUrl && !hasCode && !hasToken) {
|
|
112
|
+
return 'Helmpilot requires --url (Relay URL), --code (channel ID), and --token (channel key).\n'
|
|
113
|
+
+ 'Example: openclaw channels add --channel helmpilot --url ws://localhost:4800 --code ch_xxx --token ck_xxx';
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return null;
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── Status Adapter ──
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Connection health and account status reporting.
|
|
125
|
+
*/
|
|
126
|
+
export function createStatusAdapter() {
|
|
127
|
+
return {
|
|
128
|
+
defaultRuntime: {
|
|
129
|
+
accountId: DEFAULT_ACCOUNT_ID,
|
|
130
|
+
running: false,
|
|
131
|
+
connected: false,
|
|
132
|
+
lastConnectedAt: null,
|
|
133
|
+
lastError: null,
|
|
134
|
+
lastInboundAt: null,
|
|
135
|
+
lastOutboundAt: null,
|
|
136
|
+
},
|
|
137
|
+
|
|
138
|
+
buildChannelSummary(params: {
|
|
139
|
+
snapshot: { configured?: boolean; running?: boolean; connected?: boolean; lastError?: string | null };
|
|
140
|
+
}) {
|
|
141
|
+
return {
|
|
142
|
+
configured: params.snapshot.configured ?? false,
|
|
143
|
+
running: params.snapshot.running ?? false,
|
|
144
|
+
connected: params.snapshot.connected ?? false,
|
|
145
|
+
lastError: params.snapshot.lastError ?? null,
|
|
146
|
+
};
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
buildAccountSnapshot(params: {
|
|
150
|
+
account: { accountId: string; config: { enabled?: boolean; relayUrl?: string; channelKey?: string } };
|
|
151
|
+
cfg: Record<string, unknown>;
|
|
152
|
+
runtime?: {
|
|
153
|
+
running?: boolean;
|
|
154
|
+
connected?: boolean;
|
|
155
|
+
lastConnectedAt?: number | null;
|
|
156
|
+
lastError?: string | null;
|
|
157
|
+
lastInboundAt?: number | null;
|
|
158
|
+
lastOutboundAt?: number | null;
|
|
159
|
+
};
|
|
160
|
+
}) {
|
|
161
|
+
const { account, runtime } = params;
|
|
162
|
+
const relay = getRelay(account.accountId);
|
|
163
|
+
const relayState = relay?.getState() ?? 'disconnected';
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
accountId: account.accountId,
|
|
167
|
+
enabled: account.config.enabled !== false,
|
|
168
|
+
configured: Boolean(account.config.relayUrl && account.config.channelKey),
|
|
169
|
+
mode: account.config.relayUrl ? 'relay' : 'local',
|
|
170
|
+
running: runtime?.running ?? false,
|
|
171
|
+
connected: relayState === 'connected',
|
|
172
|
+
lastConnectedAt: runtime?.lastConnectedAt ?? null,
|
|
173
|
+
lastError: runtime?.lastError ?? null,
|
|
174
|
+
lastInboundAt: runtime?.lastInboundAt ?? null,
|
|
175
|
+
lastOutboundAt: runtime?.lastOutboundAt ?? null,
|
|
176
|
+
};
|
|
177
|
+
},
|
|
178
|
+
|
|
179
|
+
probeAccount(params: {
|
|
180
|
+
account: { accountId: string; config: { relayUrl?: string; channelKey?: string } };
|
|
181
|
+
timeoutMs: number;
|
|
182
|
+
}) {
|
|
183
|
+
const relay = getRelay(params.account.accountId);
|
|
184
|
+
return Promise.resolve({
|
|
185
|
+
relayConnected: relay?.getState() === 'connected',
|
|
186
|
+
relayUrl: params.account.config.relayUrl ?? null,
|
|
187
|
+
hasChannelKey: Boolean(params.account.config.channelKey),
|
|
188
|
+
});
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ── Pairing Adapter ──
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Device pairing adapter.
|
|
197
|
+
*
|
|
198
|
+
* Helmpilot uses device keys (dk_xxx) for authentication.
|
|
199
|
+
* The pairing flow is manual: user creates a channel on the relay,
|
|
200
|
+
* then enters the device key in the Helmpilot client.
|
|
201
|
+
*/
|
|
202
|
+
export function createPairingAdapter() {
|
|
203
|
+
return {
|
|
204
|
+
idLabel: 'Device Key',
|
|
205
|
+
|
|
206
|
+
normalizeAllowEntry(entry: string): string {
|
|
207
|
+
// Device keys start with dk_; normalize by trimming whitespace
|
|
208
|
+
return entry.trim();
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ── Security Adapter ──
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* DM policy enforcement for Helmpilot connections.
|
|
217
|
+
*
|
|
218
|
+
* Since Helmpilot is a 1:1 desktop client (not a group platform), the security
|
|
219
|
+
* model is simpler: if the device has a valid key, it's authorized.
|
|
220
|
+
*/
|
|
221
|
+
export function createSecurityAdapter() {
|
|
222
|
+
return {
|
|
223
|
+
resolveDmPolicy(ctx: {
|
|
224
|
+
account: { accountId: string; config: { enabled?: boolean; relayUrl?: string; channelKey?: string } };
|
|
225
|
+
cfg: Record<string, unknown>;
|
|
226
|
+
}) {
|
|
227
|
+
const { config } = ctx.account;
|
|
228
|
+
|
|
229
|
+
// Not configured or disabled → no policy (deny)
|
|
230
|
+
if (!config.relayUrl || !config.channelKey || config.enabled === false) {
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Configured and enabled → allow (device auth is handled at relay level)
|
|
235
|
+
return {
|
|
236
|
+
policy: 'open',
|
|
237
|
+
allowFromPath: `${HELMPILOT_CONFIG_SECTION}.allowFrom`,
|
|
238
|
+
approveHint: 'Helmpilot desktop devices authenticate via relay channel keys',
|
|
239
|
+
};
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
}
|
package/bridge.ts
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LocalBridge — manages the WebSocket connection between the Relay tunnel
|
|
3
|
+
* and the local OpenClaw Gateway instance.
|
|
4
|
+
*
|
|
5
|
+
* The bridge is a transparent tunnel: it forwards raw WS frames between
|
|
6
|
+
* the RelayClient (remote device) and the local gateway, without parsing
|
|
7
|
+
* or interpreting the OpenClaw protocol messages.
|
|
8
|
+
*
|
|
9
|
+
* For the initial `connect` RPC, the bridge re-signs the device identity
|
|
10
|
+
* with the gateway token (which the remote device doesn't know), so the
|
|
11
|
+
* gateway's token auth AND device signature verification both pass.
|
|
12
|
+
*
|
|
13
|
+
* Includes WS-level ping/pong heartbeat to detect dead connections.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import crypto from 'node:crypto';
|
|
17
|
+
import { WebSocket as WsWebSocket } from 'ws';
|
|
18
|
+
|
|
19
|
+
/** Ping interval — shorter than relay's 25s to detect failures early */
|
|
20
|
+
const PING_INTERVAL_MS = 20_000;
|
|
21
|
+
/** If no pong received within this duration, close the connection */
|
|
22
|
+
const PONG_TIMEOUT_MS = 10_000;
|
|
23
|
+
|
|
24
|
+
/** Encode a Buffer to base64url (no padding) */
|
|
25
|
+
function base64UrlEncode(buf: Buffer): string {
|
|
26
|
+
return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** SPKI DER prefix for Ed25519 public keys */
|
|
30
|
+
const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Ephemeral Ed25519 identity for authenticating the bridge connection
|
|
34
|
+
* with the local gateway. Generated once per bridge instance.
|
|
35
|
+
*/
|
|
36
|
+
class BridgeIdentity {
|
|
37
|
+
readonly deviceId: string;
|
|
38
|
+
readonly publicKeyBase64Url: string;
|
|
39
|
+
private readonly privateKey: crypto.KeyObject;
|
|
40
|
+
|
|
41
|
+
constructor() {
|
|
42
|
+
const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
|
|
43
|
+
this.privateKey = privateKey;
|
|
44
|
+
const spki = publicKey.export({ type: 'spki', format: 'der' }) as Buffer;
|
|
45
|
+
const raw = spki.subarray(ED25519_SPKI_PREFIX.length);
|
|
46
|
+
this.publicKeyBase64Url = base64UrlEncode(raw);
|
|
47
|
+
this.deviceId = crypto.createHash('sha256').update(raw).digest('hex');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
sign(payload: string): string {
|
|
51
|
+
const sig = crypto.sign(null, Buffer.from(payload, 'utf8'), this.privateKey);
|
|
52
|
+
return base64UrlEncode(sig);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface BridgeSender {
|
|
57
|
+
/** Send a raw message to the remote device via relay */
|
|
58
|
+
sendRaw(data: string): void;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export class LocalBridge {
|
|
62
|
+
private ws: WsWebSocket | null = null;
|
|
63
|
+
private pendingMessages: string[] = [];
|
|
64
|
+
private readonly localGwUrl: string;
|
|
65
|
+
private readonly gatewayToken: string | undefined;
|
|
66
|
+
private readonly identity: BridgeIdentity;
|
|
67
|
+
private pingTimer: ReturnType<typeof setInterval> | null = null;
|
|
68
|
+
private pongTimer: ReturnType<typeof setTimeout> | null = null;
|
|
69
|
+
|
|
70
|
+
constructor(localGwUrl: string, gatewayToken?: string) {
|
|
71
|
+
this.localGwUrl = localGwUrl;
|
|
72
|
+
this.gatewayToken = gatewayToken;
|
|
73
|
+
this.identity = new BridgeIdentity();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Open a new WS connection to the local gateway, bridged to the relay sender */
|
|
77
|
+
open(sender: BridgeSender): void {
|
|
78
|
+
this.close();
|
|
79
|
+
this.pendingMessages = [];
|
|
80
|
+
|
|
81
|
+
const ws = new WsWebSocket(this.localGwUrl);
|
|
82
|
+
|
|
83
|
+
ws.on('open', () => {
|
|
84
|
+
console.log('[helmpilot-channel] Local gateway bridge opened');
|
|
85
|
+
for (const msg of this.pendingMessages) {
|
|
86
|
+
ws.send(msg);
|
|
87
|
+
}
|
|
88
|
+
this.pendingMessages = [];
|
|
89
|
+
this.startHeartbeat(ws);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
ws.on('message', (data: Buffer | ArrayBuffer | Buffer[]) => {
|
|
93
|
+
const raw = typeof data === 'string' ? data : data.toString();
|
|
94
|
+
sender.sendRaw(raw);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
ws.on('pong', () => {
|
|
98
|
+
// Pong received — clear the timeout
|
|
99
|
+
if (this.pongTimer) {
|
|
100
|
+
clearTimeout(this.pongTimer);
|
|
101
|
+
this.pongTimer = null;
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
ws.on('close', (code: number) => {
|
|
106
|
+
console.log(`[helmpilot-channel] Local gateway bridge closed: ${code}`);
|
|
107
|
+
this.stopHeartbeat();
|
|
108
|
+
this.ws = null;
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
ws.on('error', (err: Error) => {
|
|
112
|
+
console.error('[helmpilot-channel] Local gateway bridge error:', err.message);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
this.ws = ws;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Forward a raw message from relay to the local gateway */
|
|
119
|
+
forward(data: string): void {
|
|
120
|
+
const msg = this.injectGatewayAuth(data);
|
|
121
|
+
if (this.ws && this.ws.readyState === 1 /* OPEN */) {
|
|
122
|
+
this.ws.send(msg);
|
|
123
|
+
} else {
|
|
124
|
+
// WS not yet open or not created — queue for flush when bridge opens
|
|
125
|
+
this.pendingMessages.push(msg);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* If the message is a `connect` RPC, inject the local gateway auth token
|
|
131
|
+
* and re-sign the device identity using the bridge's own Ed25519 keypair.
|
|
132
|
+
*
|
|
133
|
+
* The remote device signed the challenge with an empty token (it doesn't
|
|
134
|
+
* know the gateway token). The gateway verifies the signature using
|
|
135
|
+
* `auth.token` — so we must re-sign with the real token for it to match.
|
|
136
|
+
*/
|
|
137
|
+
private injectGatewayAuth(data: string): string {
|
|
138
|
+
try {
|
|
139
|
+
const frame = JSON.parse(data);
|
|
140
|
+
if (frame.method === 'connect' && frame.params) {
|
|
141
|
+
const token = this.gatewayToken || process.env.OPENCLAW_GATEWAY_TOKEN;
|
|
142
|
+
if (!token) return data;
|
|
143
|
+
|
|
144
|
+
const nonce = frame.params.device?.nonce;
|
|
145
|
+
if (!nonce) return data;
|
|
146
|
+
|
|
147
|
+
const signedAt = Date.now();
|
|
148
|
+
const scopes = Array.isArray(frame.params.scopes)
|
|
149
|
+
? frame.params.scopes.join(',')
|
|
150
|
+
: '';
|
|
151
|
+
|
|
152
|
+
// Normalize platform/deviceFamily to lowercase (matches gateway's normalizeDeviceMetadataForAuth)
|
|
153
|
+
const platform = (frame.params.client?.platform || '').toLowerCase();
|
|
154
|
+
const deviceFamily = (frame.params.client?.deviceFamily || '').toLowerCase();
|
|
155
|
+
|
|
156
|
+
const payload = [
|
|
157
|
+
'v3',
|
|
158
|
+
this.identity.deviceId,
|
|
159
|
+
frame.params.client?.id || 'cli',
|
|
160
|
+
frame.params.client?.mode || 'ui',
|
|
161
|
+
frame.params.role || 'operator',
|
|
162
|
+
scopes,
|
|
163
|
+
String(signedAt),
|
|
164
|
+
token,
|
|
165
|
+
nonce,
|
|
166
|
+
platform,
|
|
167
|
+
deviceFamily,
|
|
168
|
+
].join('|');
|
|
169
|
+
|
|
170
|
+
frame.params.auth = { ...(frame.params.auth || {}), token };
|
|
171
|
+
frame.params.device = {
|
|
172
|
+
id: this.identity.deviceId,
|
|
173
|
+
publicKey: this.identity.publicKeyBase64Url,
|
|
174
|
+
signature: this.identity.sign(payload),
|
|
175
|
+
signedAt,
|
|
176
|
+
nonce,
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
return JSON.stringify(frame);
|
|
180
|
+
}
|
|
181
|
+
} catch {
|
|
182
|
+
// Not JSON or parse error — forward as-is
|
|
183
|
+
}
|
|
184
|
+
return data;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Close the bridge connection and stop heartbeat */
|
|
188
|
+
close(): void {
|
|
189
|
+
this.stopHeartbeat();
|
|
190
|
+
if (this.ws) {
|
|
191
|
+
try { this.ws.close(1000, 'Bridge closed'); } catch {}
|
|
192
|
+
this.ws = null;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Start WS-level ping/pong heartbeat */
|
|
197
|
+
private startHeartbeat(ws: WsWebSocket): void {
|
|
198
|
+
this.stopHeartbeat();
|
|
199
|
+
this.pingTimer = setInterval(() => {
|
|
200
|
+
if (ws.readyState !== 1 /* OPEN */) return;
|
|
201
|
+
ws.ping();
|
|
202
|
+
this.pongTimer = setTimeout(() => {
|
|
203
|
+
console.warn('[helmpilot-channel] Local bridge: pong timeout, closing connection');
|
|
204
|
+
try { ws.terminate(); } catch {}
|
|
205
|
+
}, PONG_TIMEOUT_MS);
|
|
206
|
+
}, PING_INTERVAL_MS);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Stop heartbeat timers */
|
|
210
|
+
private stopHeartbeat(): void {
|
|
211
|
+
if (this.pingTimer) {
|
|
212
|
+
clearInterval(this.pingTimer);
|
|
213
|
+
this.pingTimer = null;
|
|
214
|
+
}
|
|
215
|
+
if (this.pongTimer) {
|
|
216
|
+
clearTimeout(this.pongTimer);
|
|
217
|
+
this.pongTimer = null;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|