@viewportai/daemon 0.1.0 → 0.2.0

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.
Files changed (136) hide show
  1. package/dist/cli/commands.d.ts +1 -0
  2. package/dist/cli/commands.d.ts.map +1 -1
  3. package/dist/cli/commands.js +1 -0
  4. package/dist/cli/commands.js.map +1 -1
  5. package/dist/cli/daemon-lifecycle.d.ts +3 -0
  6. package/dist/cli/daemon-lifecycle.d.ts.map +1 -1
  7. package/dist/cli/daemon-lifecycle.js +11 -1
  8. package/dist/cli/daemon-lifecycle.js.map +1 -1
  9. package/dist/cli/daemon-settings.d.ts.map +1 -1
  10. package/dist/cli/daemon-settings.js +115 -3
  11. package/dist/cli/daemon-settings.js.map +1 -1
  12. package/dist/cli/lifecycle-commands.d.ts.map +1 -1
  13. package/dist/cli/lifecycle-commands.js +2 -0
  14. package/dist/cli/lifecycle-commands.js.map +1 -1
  15. package/dist/cli/remote-commands.d.ts +3 -0
  16. package/dist/cli/remote-commands.d.ts.map +1 -0
  17. package/dist/cli/remote-commands.js +236 -0
  18. package/dist/cli/remote-commands.js.map +1 -0
  19. package/dist/cli/setup-command.d.ts.map +1 -1
  20. package/dist/cli/setup-command.js +4 -1
  21. package/dist/cli/setup-command.js.map +1 -1
  22. package/dist/cli/supervisor-protocol.d.ts +12 -0
  23. package/dist/cli/supervisor-protocol.d.ts.map +1 -1
  24. package/dist/cli/supervisor.d.ts.map +1 -1
  25. package/dist/cli/supervisor.js +30 -0
  26. package/dist/cli/supervisor.js.map +1 -1
  27. package/dist/core/config-schema.d.ts +16 -0
  28. package/dist/core/config-schema.d.ts.map +1 -1
  29. package/dist/core/config-schema.js +12 -0
  30. package/dist/core/config-schema.js.map +1 -1
  31. package/dist/core/config.d.ts +23 -0
  32. package/dist/core/config.d.ts.map +1 -1
  33. package/dist/core/config.js +46 -3
  34. package/dist/core/config.js.map +1 -1
  35. package/dist/core/session-state-file.d.ts.map +1 -1
  36. package/dist/core/session-state-file.js +3 -1
  37. package/dist/core/session-state-file.js.map +1 -1
  38. package/dist/core/types.d.ts +7 -0
  39. package/dist/core/types.d.ts.map +1 -1
  40. package/dist/hooks/installers/claude.js +4 -1
  41. package/dist/hooks/installers/claude.js.map +1 -1
  42. package/dist/hooks/router.d.ts.map +1 -1
  43. package/dist/hooks/router.js +11 -0
  44. package/dist/hooks/router.js.map +1 -1
  45. package/dist/hooks/supervision.d.ts +2 -0
  46. package/dist/hooks/supervision.d.ts.map +1 -1
  47. package/dist/hooks/supervision.js +12 -0
  48. package/dist/hooks/supervision.js.map +1 -1
  49. package/dist/index.js +5 -1
  50. package/dist/index.js.map +1 -1
  51. package/dist/plugins/loader.d.ts.map +1 -1
  52. package/dist/plugins/loader.js +14 -0
  53. package/dist/plugins/loader.js.map +1 -1
  54. package/dist/relay/bridge-backoff.d.ts +3 -0
  55. package/dist/relay/bridge-backoff.d.ts.map +1 -0
  56. package/dist/relay/bridge-backoff.js +10 -0
  57. package/dist/relay/bridge-backoff.js.map +1 -0
  58. package/dist/relay/bridge-constants.d.ts +12 -0
  59. package/dist/relay/bridge-constants.d.ts.map +1 -0
  60. package/dist/relay/bridge-constants.js +12 -0
  61. package/dist/relay/bridge-constants.js.map +1 -0
  62. package/dist/relay/bridge-crypto.d.ts +18 -0
  63. package/dist/relay/bridge-crypto.d.ts.map +1 -0
  64. package/dist/relay/bridge-crypto.js +63 -0
  65. package/dist/relay/bridge-crypto.js.map +1 -0
  66. package/dist/relay/bridge-errors.d.ts +6 -0
  67. package/dist/relay/bridge-errors.d.ts.map +1 -0
  68. package/dist/relay/bridge-errors.js +9 -0
  69. package/dist/relay/bridge-errors.js.map +1 -0
  70. package/dist/relay/bridge-jwt.d.ts +18 -0
  71. package/dist/relay/bridge-jwt.d.ts.map +1 -0
  72. package/dist/relay/bridge-jwt.js +130 -0
  73. package/dist/relay/bridge-jwt.js.map +1 -0
  74. package/dist/relay/bridge-key-exchange.d.ts +49 -0
  75. package/dist/relay/bridge-key-exchange.d.ts.map +1 -0
  76. package/dist/relay/bridge-key-exchange.js +234 -0
  77. package/dist/relay/bridge-key-exchange.js.map +1 -0
  78. package/dist/relay/bridge-network.d.ts +12 -0
  79. package/dist/relay/bridge-network.d.ts.map +1 -0
  80. package/dist/relay/bridge-network.js +90 -0
  81. package/dist/relay/bridge-network.js.map +1 -0
  82. package/dist/relay/bridge-noise-v3.d.ts +74 -0
  83. package/dist/relay/bridge-noise-v3.d.ts.map +1 -0
  84. package/dist/relay/bridge-noise-v3.js +403 -0
  85. package/dist/relay/bridge-noise-v3.js.map +1 -0
  86. package/dist/relay/daemon-relay-bridge.d.ts +93 -0
  87. package/dist/relay/daemon-relay-bridge.d.ts.map +1 -0
  88. package/dist/relay/daemon-relay-bridge.js +1005 -0
  89. package/dist/relay/daemon-relay-bridge.js.map +1 -0
  90. package/dist/server/auth.d.ts.map +1 -1
  91. package/dist/server/auth.js +9 -7
  92. package/dist/server/auth.js.map +1 -1
  93. package/dist/server/http-server.d.ts +6 -0
  94. package/dist/server/http-server.d.ts.map +1 -1
  95. package/dist/server/http-server.js +102 -15
  96. package/dist/server/http-server.js.map +1 -1
  97. package/dist/server/pairing-offers.d.ts +2 -1
  98. package/dist/server/pairing-offers.d.ts.map +1 -1
  99. package/dist/server/pairing-offers.js +438 -204
  100. package/dist/server/pairing-offers.js.map +1 -1
  101. package/dist/server/ring-buffer.d.ts +48 -7
  102. package/dist/server/ring-buffer.d.ts.map +1 -1
  103. package/dist/server/ring-buffer.js +387 -14
  104. package/dist/server/ring-buffer.js.map +1 -1
  105. package/dist/server/security.d.ts.map +1 -1
  106. package/dist/server/security.js +5 -1
  107. package/dist/server/security.js.map +1 -1
  108. package/dist/server/ws-command-handlers.d.ts.map +1 -1
  109. package/dist/server/ws-command-handlers.js +18 -6
  110. package/dist/server/ws-command-handlers.js.map +1 -1
  111. package/dist/server/ws-daemon-event-bridge.d.ts.map +1 -1
  112. package/dist/server/ws-daemon-event-bridge.js +14 -2
  113. package/dist/server/ws-daemon-event-bridge.js.map +1 -1
  114. package/dist/server/ws-server.d.ts.map +1 -1
  115. package/dist/server/ws-server.js +26 -3
  116. package/dist/server/ws-server.js.map +1 -1
  117. package/dist/startup-relay-security.d.ts +3 -0
  118. package/dist/startup-relay-security.d.ts.map +1 -0
  119. package/dist/startup-relay-security.js +61 -0
  120. package/dist/startup-relay-security.js.map +1 -0
  121. package/dist/startup-session-persistence.d.ts +7 -0
  122. package/dist/startup-session-persistence.d.ts.map +1 -0
  123. package/dist/startup-session-persistence.js +72 -0
  124. package/dist/startup-session-persistence.js.map +1 -0
  125. package/dist/startup.d.ts.map +1 -1
  126. package/dist/startup.js +115 -65
  127. package/dist/startup.js.map +1 -1
  128. package/dist/tracking/git-tracker.d.ts +4 -0
  129. package/dist/tracking/git-tracker.d.ts.map +1 -1
  130. package/dist/tracking/git-tracker.js +80 -15
  131. package/dist/tracking/git-tracker.js.map +1 -1
  132. package/docs/configuration.md +63 -5
  133. package/docs/relay-noise-conformance-vectors.json +41 -0
  134. package/docs/relay-noise-v3-conformance-vectors.json +50 -0
  135. package/docs/security.md +3 -2
  136. package/package.json +1 -1
@@ -0,0 +1,1005 @@
1
+ import WebSocket from 'ws';
2
+ import crypto from 'node:crypto';
3
+ import { computeBackoffMs, sleep } from './bridge-backoff.js';
4
+ import { CIRCUIT_BREAKER_MS, DEFAULT_MAX_PENDING_OUTBOUND, DEFAULT_MAX_PENDING_OUTBOUND_BYTES, ISSUE_FAILURE_THRESHOLD, RELAY_KEY_ROTATE_AFTER_MESSAGES, RELAY_REPLAY_WINDOW, RELAY_SESSION_IDLE_TTL_MS, } from './bridge-constants.js';
5
+ import { decryptEnvelope, encryptEnvelope, fromBase64Url, parseRelayEnvelope, toBase64Url, } from './bridge-crypto.js';
6
+ import { deriveSessionFromKeyExchange, loadOrCreateIdentity, parseRelayHandshakeProfile, parseRelayKeyExchangeInitFrame, } from './bridge-key-exchange.js';
7
+ import { deriveNoiseV3SessionFromInit, parseRelayKeyExchangeInitFrameV3, } from './bridge-noise-v3.js';
8
+ import { BridgeError } from './bridge-errors.js';
9
+ import { verifyRelayTokenClaims } from './bridge-jwt.js';
10
+ import { closeQuietly, resolveRelayTlsOptions, wsOpen } from './bridge-network.js';
11
+ import { issuePairingOffer, redeemPairingOffer, resolveRelayPairingSecret, } from '../server/pairing-offers.js';
12
+ import { loadConfig, saveConfig } from '../core/config.js';
13
+ import { logger as out } from '../core/output.js';
14
+ const MAX_JWKS_KEYS = 64;
15
+ export { CIRCUIT_BREAKER_MS } from './bridge-constants.js';
16
+ export { computeBackoffMs, decryptEnvelope, encryptEnvelope, fromBase64Url, toBase64Url };
17
+ const DEFAULT_PAIRING_CHANNEL_TTL_MS = 10 * 60_000;
18
+ const DEFAULT_PAIRING_CHANNEL_MAX_ENTRIES = 2_048;
19
+ const DEFAULT_RELAY_SESSION_MAX_ENTRIES = 4_096;
20
+ async function parseRelayIssueResponse(res) {
21
+ const json = (await res.json().catch(() => null));
22
+ if (!json) {
23
+ return {
24
+ ok: false,
25
+ reason: `relay token endpoint returned non-JSON (${res.status})`,
26
+ };
27
+ }
28
+ return json;
29
+ }
30
+ function isRelayControlFrame(value) {
31
+ if (!value || typeof value !== 'object' || Array.isArray(value))
32
+ return false;
33
+ const frame = value;
34
+ if (frame['type'] === 'relay_status')
35
+ return true;
36
+ return (frame['type'] === 'relay_key_update_required' &&
37
+ typeof frame['sessionId'] === 'string' &&
38
+ typeof frame['nextEpoch'] === 'number');
39
+ }
40
+ function parsePairingOfferRequestFrame(value) {
41
+ if (!value || typeof value !== 'object' || Array.isArray(value))
42
+ return null;
43
+ const frame = value;
44
+ if (frame['type'] !== 'relay_pairing_offer_request')
45
+ return null;
46
+ if (typeof frame['requestId'] !== 'string' || frame['requestId'].trim().length === 0) {
47
+ return null;
48
+ }
49
+ const ttlSeconds = frame['ttlSeconds'];
50
+ if (typeof ttlSeconds !== 'undefined' &&
51
+ (!Number.isInteger(ttlSeconds) || ttlSeconds < 30 || ttlSeconds > 3600)) {
52
+ return null;
53
+ }
54
+ if (typeof frame['clientChannelPublicKey'] !== 'string' ||
55
+ frame['clientChannelPublicKey'].trim().length === 0) {
56
+ return null;
57
+ }
58
+ return {
59
+ type: 'relay_pairing_offer_request',
60
+ requestId: frame['requestId'],
61
+ ttlSeconds: typeof ttlSeconds === 'number' ? ttlSeconds : undefined,
62
+ clientChannelPublicKey: frame['clientChannelPublicKey'],
63
+ };
64
+ }
65
+ function parsePairingRedeemRequestFrame(value) {
66
+ if (!value || typeof value !== 'object' || Array.isArray(value))
67
+ return null;
68
+ const frame = value;
69
+ if (frame['type'] !== 'relay_pairing_redeem_request')
70
+ return null;
71
+ if (typeof frame['requestId'] !== 'string' ||
72
+ typeof frame['offerId'] !== 'string' ||
73
+ typeof frame['encIv'] !== 'string' ||
74
+ typeof frame['encTag'] !== 'string' ||
75
+ typeof frame['encCiphertext'] !== 'string') {
76
+ return null;
77
+ }
78
+ if (frame['requestId'].trim().length === 0 ||
79
+ frame['offerId'].trim().length === 0 ||
80
+ frame['encIv'].trim().length === 0 ||
81
+ frame['encTag'].trim().length === 0 ||
82
+ frame['encCiphertext'].trim().length === 0) {
83
+ return null;
84
+ }
85
+ return {
86
+ type: 'relay_pairing_redeem_request',
87
+ requestId: frame['requestId'],
88
+ offerId: frame['offerId'],
89
+ encIv: frame['encIv'],
90
+ encTag: frame['encTag'],
91
+ encCiphertext: frame['encCiphertext'],
92
+ };
93
+ }
94
+ function derivePairingChannelKey(sharedSecret, saltLabel) {
95
+ const salt = crypto.createHash('sha256').update(saltLabel, 'utf8').digest();
96
+ const raw = crypto.hkdfSync('sha256', sharedSecret, salt, Buffer.from('viewport-relay-pairing-channel-v1', 'utf8'), 32);
97
+ return Buffer.isBuffer(raw) ? raw : Buffer.from(raw);
98
+ }
99
+ function encryptPairingPayload(key, plaintext, aadLabel) {
100
+ const iv = crypto.randomBytes(12);
101
+ const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
102
+ cipher.setAAD(Buffer.from(aadLabel, 'utf8'));
103
+ const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
104
+ const tag = cipher.getAuthTag();
105
+ return {
106
+ encIv: toBase64Url(iv),
107
+ encTag: toBase64Url(tag),
108
+ encCiphertext: toBase64Url(ciphertext),
109
+ };
110
+ }
111
+ function decryptPairingPayload(key, encrypted, aadLabel) {
112
+ const iv = fromBase64Url(encrypted.encIv);
113
+ const tag = fromBase64Url(encrypted.encTag);
114
+ const ciphertext = fromBase64Url(encrypted.encCiphertext);
115
+ const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
116
+ decipher.setAAD(Buffer.from(aadLabel, 'utf8'));
117
+ decipher.setAuthTag(tag);
118
+ const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
119
+ return plaintext.toString('utf8');
120
+ }
121
+ function profileStrength(profile) {
122
+ return profile === 'noise-ikpsk2' ? 2 : 1;
123
+ }
124
+ function isCompatibleProfile(required, requested) {
125
+ return profileStrength(requested) >= profileStrength(required);
126
+ }
127
+ export class DaemonRelayBridge {
128
+ options;
129
+ relayWs = null;
130
+ daemonWs = null;
131
+ running = false;
132
+ reconnecting = false;
133
+ reconnectAttempt = 0;
134
+ pendingOutbound = [];
135
+ pendingOutboundBytes = 0;
136
+ daemonIdentity = null;
137
+ daemonIssueToken;
138
+ requiredProfile = 'noise-ik';
139
+ relayTokenJwksUrl;
140
+ relayTokenSigningKeys;
141
+ jwksCacheExpiresAt = 0;
142
+ jwksCacheKeys = {};
143
+ relaySessions = new Map();
144
+ pairingChannelKeys = new Map();
145
+ consecutiveIssueFailures = 0;
146
+ circuitOpenUntilMs = 0;
147
+ lastErrorCode;
148
+ lastErrorMessage;
149
+ lastErrorAt;
150
+ state = 'stopped';
151
+ relayEndpoint;
152
+ keyRotateAfterMessages;
153
+ pairingChannelTtlMs;
154
+ pairingChannelMaxEntries;
155
+ relaySessionMaxEntries;
156
+ constructor(options) {
157
+ this.options = options;
158
+ this.relayEndpoint = options.relayEndpoint;
159
+ this.keyRotateAfterMessages =
160
+ typeof options.keyRotateAfterMessages === 'number' &&
161
+ Number.isInteger(options.keyRotateAfterMessages) &&
162
+ options.keyRotateAfterMessages >= 1
163
+ ? options.keyRotateAfterMessages
164
+ : RELAY_KEY_ROTATE_AFTER_MESSAGES;
165
+ this.pairingChannelTtlMs =
166
+ typeof options.pairingChannelTtlMs === 'number' &&
167
+ Number.isInteger(options.pairingChannelTtlMs) &&
168
+ options.pairingChannelTtlMs >= 1_000
169
+ ? options.pairingChannelTtlMs
170
+ : DEFAULT_PAIRING_CHANNEL_TTL_MS;
171
+ this.pairingChannelMaxEntries =
172
+ typeof options.pairingChannelMaxEntries === 'number' &&
173
+ Number.isInteger(options.pairingChannelMaxEntries) &&
174
+ options.pairingChannelMaxEntries >= 1
175
+ ? options.pairingChannelMaxEntries
176
+ : DEFAULT_PAIRING_CHANNEL_MAX_ENTRIES;
177
+ this.relaySessionMaxEntries =
178
+ typeof options.relaySessionMaxEntries === 'number' &&
179
+ Number.isInteger(options.relaySessionMaxEntries) &&
180
+ options.relaySessionMaxEntries >= 1
181
+ ? options.relaySessionMaxEntries
182
+ : DEFAULT_RELAY_SESSION_MAX_ENTRIES;
183
+ this.daemonIssueToken = options.issueToken ?? null;
184
+ this.relayTokenJwksUrl = options.relayTokenJwksUrl;
185
+ this.relayTokenSigningKeys = options.relayTokenSigningKeys ?? {};
186
+ }
187
+ getStatus() {
188
+ return {
189
+ state: this.state,
190
+ reconnectAttempt: this.reconnectAttempt,
191
+ lastErrorCode: this.lastErrorCode,
192
+ lastErrorMessage: this.lastErrorMessage,
193
+ lastErrorAt: this.lastErrorAt,
194
+ circuitOpenUntil: this.circuitOpenUntilMs > 0 ? this.circuitOpenUntilMs : undefined,
195
+ };
196
+ }
197
+ async start() {
198
+ if (this.running)
199
+ return;
200
+ this.running = true;
201
+ this.reconnectAttempt = 0;
202
+ this.state = 'connecting';
203
+ await this.connectLoop('start');
204
+ }
205
+ async stop() {
206
+ this.running = false;
207
+ this.reconnecting = false;
208
+ closeQuietly(this.relayWs);
209
+ closeQuietly(this.daemonWs);
210
+ this.relayWs = null;
211
+ this.daemonWs = null;
212
+ this.pendingOutbound.length = 0;
213
+ this.pendingOutboundBytes = 0;
214
+ this.relaySessions.clear();
215
+ this.pairingChannelKeys.clear();
216
+ this.state = 'stopped';
217
+ }
218
+ async ensureKeyMaterial() {
219
+ if (!this.daemonIdentity) {
220
+ this.daemonIdentity = await loadOrCreateIdentity(this.options.workspaceId);
221
+ }
222
+ }
223
+ async registerDaemonPublicKey() {
224
+ if (!this.daemonIdentity) {
225
+ throw new BridgeError('DAEMON_KEY_REGISTER_FAILED', 'daemon identity unavailable');
226
+ }
227
+ const url = `${this.options.relayServerUrl.replace(/\/+$/, '')}` +
228
+ `/api/poc/workspaces/${encodeURIComponent(this.options.workspaceId)}/daemon-key`;
229
+ const controller = new AbortController();
230
+ const timeout = setTimeout(() => controller.abort(), 8_000);
231
+ let res;
232
+ try {
233
+ res = await fetch(url, {
234
+ method: 'POST',
235
+ headers: { 'content-type': 'application/json' },
236
+ body: JSON.stringify({
237
+ credential: this.options.enrollToken,
238
+ issueCredential: this.daemonIssueToken ?? undefined,
239
+ daemonPublicKey: this.daemonIdentity.publicKey,
240
+ }),
241
+ signal: controller.signal,
242
+ });
243
+ }
244
+ catch (error) {
245
+ clearTimeout(timeout);
246
+ throw new BridgeError('DAEMON_KEY_REGISTER_FAILED', error instanceof Error ? error.message : String(error));
247
+ }
248
+ clearTimeout(timeout);
249
+ const parsed = (await res.json().catch(() => null));
250
+ if (!res.ok || !parsed?.ok) {
251
+ const reason = parsed?.reason ?? parsed?.error ?? `HTTP ${res.status}`;
252
+ throw new BridgeError('DAEMON_KEY_REGISTER_FAILED', `daemon key registration failed: ${reason}`);
253
+ }
254
+ if (!parsed?.daemonIssueToken || parsed.daemonIssueToken.trim().length === 0) {
255
+ if (this.daemonIssueToken && this.daemonIssueToken.trim().length > 0) {
256
+ return;
257
+ }
258
+ throw new BridgeError('DAEMON_KEY_REGISTER_FAILED', 'daemon key registration succeeded but daemon issue token was missing');
259
+ }
260
+ this.daemonIssueToken = parsed.daemonIssueToken;
261
+ await this.persistIssueToken(parsed.daemonIssueToken);
262
+ }
263
+ async persistIssueToken(issueToken) {
264
+ const normalized = issueToken.trim();
265
+ if (normalized.length === 0)
266
+ return;
267
+ try {
268
+ const config = await loadConfig();
269
+ config.daemon = config.daemon ?? {};
270
+ config.daemon.relay = config.daemon.relay ?? {};
271
+ config.daemon.relay.issueToken = normalized;
272
+ await saveConfig(config);
273
+ }
274
+ catch (error) {
275
+ out.warn(`[relay] failed to persist daemon issue token: ${error instanceof Error ? error.message : String(error)}`);
276
+ }
277
+ }
278
+ async connectLoop(reason) {
279
+ if (!this.running)
280
+ return;
281
+ if (this.reconnecting)
282
+ return;
283
+ this.reconnecting = true;
284
+ this.state = 'connecting';
285
+ try {
286
+ const now = Date.now();
287
+ if (this.circuitOpenUntilMs > now) {
288
+ const waitMs = this.circuitOpenUntilMs - now;
289
+ this.state = 'circuit_open';
290
+ this.reportStatus('CIRCUIT_OPEN', `circuit open, waiting ${waitMs}ms before retry`);
291
+ await sleep(waitMs);
292
+ }
293
+ this.reconnectAttempt += 1;
294
+ const attempt = this.reconnectAttempt;
295
+ out.log(`[relay] daemon bridge connect attempt ${attempt} (${reason})`);
296
+ await this.ensureKeyMaterial();
297
+ await this.registerDaemonPublicKey();
298
+ const issue = await this.issueRelayToken();
299
+ if (!issue.relayToken) {
300
+ throw new BridgeError('TOKEN_RESPONSE_INVALID', 'relay token response missing relayToken');
301
+ }
302
+ this.requiredProfile = issue.profile;
303
+ this.relaySessions.clear();
304
+ this.consecutiveIssueFailures = 0;
305
+ this.circuitOpenUntilMs = 0;
306
+ const daemonHeaders = {};
307
+ if (this.options.daemonAuthToken) {
308
+ daemonHeaders.authorization = `Bearer ${this.options.daemonAuthToken}`;
309
+ }
310
+ const daemonWs = new WebSocket(this.options.daemonWsUrl, {
311
+ headers: Object.keys(daemonHeaders).length > 0 ? daemonHeaders : undefined,
312
+ });
313
+ await wsOpen(daemonWs);
314
+ this.daemonWs = daemonWs;
315
+ const relayUrl = `${this.relayEndpoint}?role=workspace-daemon` +
316
+ `&workspaceId=${encodeURIComponent(this.options.workspaceId)}`;
317
+ const relayTlsOptions = resolveRelayTlsOptions(relayUrl, this.options.relayTlsVerify ?? 'auto', this.options.relayCaCertPath, this.options.relayTlsPins);
318
+ const relayWs = new WebSocket(relayUrl, {
319
+ ...relayTlsOptions,
320
+ headers: {
321
+ authorization: `Bearer ${issue.relayToken}`,
322
+ },
323
+ });
324
+ await wsOpen(relayWs);
325
+ this.relayWs = relayWs;
326
+ out.log('[relay] daemon bridge connected');
327
+ this.state = 'connected';
328
+ this.reconnectAttempt = 0;
329
+ this.installSocketHandlers(daemonWs, relayWs);
330
+ this.flushPendingOutbound();
331
+ }
332
+ catch (error) {
333
+ closeQuietly(this.relayWs);
334
+ closeQuietly(this.daemonWs);
335
+ this.relayWs = null;
336
+ this.daemonWs = null;
337
+ this.relaySessions.clear();
338
+ const bridgeError = this.normalizeError(error);
339
+ this.recordError(bridgeError.code, bridgeError.message);
340
+ out.warn(`[relay] daemon bridge connect failed [${bridgeError.code}]: ${bridgeError.message}`);
341
+ if (bridgeError.code === 'TOKEN_ISSUE_FAILED' ||
342
+ bridgeError.code === 'TOKEN_RESPONSE_INVALID' ||
343
+ bridgeError.code === 'DAEMON_KEY_REGISTER_FAILED') {
344
+ this.consecutiveIssueFailures += 1;
345
+ if (this.consecutiveIssueFailures >= ISSUE_FAILURE_THRESHOLD) {
346
+ this.circuitOpenUntilMs = Date.now() + CIRCUIT_BREAKER_MS;
347
+ this.reportStatus('CIRCUIT_OPEN', `opened after ${this.consecutiveIssueFailures} consecutive control-plane failures`);
348
+ }
349
+ }
350
+ if (this.running) {
351
+ const waitMs = computeBackoffMs(this.reconnectAttempt);
352
+ out.log(`[relay] daemon bridge reconnect in ${waitMs}ms`);
353
+ this.state = 'waiting_retry';
354
+ await sleep(waitMs);
355
+ this.reconnecting = false;
356
+ await this.connectLoop('retry');
357
+ return;
358
+ }
359
+ }
360
+ this.reconnecting = false;
361
+ }
362
+ installSocketHandlers(daemonWs, relayWs) {
363
+ daemonWs.on('message', (raw) => {
364
+ const payload = raw.toString('utf8');
365
+ if (relayWs.readyState === WebSocket.OPEN) {
366
+ this.sendToAllRelaySessions(relayWs, payload);
367
+ return;
368
+ }
369
+ const payloadBytes = Buffer.byteLength(payload);
370
+ this.pendingOutbound.push(payload);
371
+ this.pendingOutboundBytes += payloadBytes;
372
+ const maxPendingOutbound = this.options.maxPendingOutbound ?? DEFAULT_MAX_PENDING_OUTBOUND;
373
+ const maxPendingBytes = this.options.maxPendingOutboundBytes ?? DEFAULT_MAX_PENDING_OUTBOUND_BYTES;
374
+ while (this.pendingOutbound.length > maxPendingOutbound ||
375
+ this.pendingOutboundBytes > maxPendingBytes) {
376
+ const dropped = this.pendingOutbound.shift();
377
+ if (!dropped)
378
+ break;
379
+ this.pendingOutboundBytes -= Buffer.byteLength(dropped);
380
+ }
381
+ });
382
+ relayWs.on('message', async (raw) => {
383
+ const text = raw.toString('utf8');
384
+ const handledControl = await this.handleRelayControlFrame(text, relayWs, daemonWs);
385
+ if (handledControl)
386
+ return;
387
+ let envelope;
388
+ try {
389
+ envelope = parseRelayEnvelope(text);
390
+ }
391
+ catch (error) {
392
+ const msg = error instanceof Error ? error.message : String(error);
393
+ this.recordError('ENVELOPE_DECRYPT_FAILED', msg);
394
+ out.warn(`[relay] invalid relay envelope [ENVELOPE_DECRYPT_FAILED]: ${msg}`);
395
+ return;
396
+ }
397
+ const session = this.relaySessions.get(envelope.sessionId);
398
+ if (!session)
399
+ return;
400
+ if (session.profile !== envelope.profile || session.epoch !== envelope.epoch)
401
+ return;
402
+ if (!this.acceptInboundSeq(session, envelope.seq)) {
403
+ out.warn(`[relay] dropped replay/old frame for session ${session.sessionId}`);
404
+ return;
405
+ }
406
+ try {
407
+ const plaintext = decryptEnvelope(session.key, envelope);
408
+ session.lastActivityAt = Date.now();
409
+ if (daemonWs.readyState === WebSocket.OPEN) {
410
+ daemonWs.send(plaintext);
411
+ }
412
+ }
413
+ catch (error) {
414
+ const msg = error instanceof Error ? error.message : String(error);
415
+ this.recordError('ENVELOPE_DECRYPT_FAILED', msg);
416
+ out.warn(`[relay] failed to decrypt relay payload [ENVELOPE_DECRYPT_FAILED]: ${msg}`);
417
+ }
418
+ });
419
+ const reconnect = (source) => {
420
+ if (!this.running)
421
+ return;
422
+ if (this.reconnecting)
423
+ return;
424
+ this.recordError('WEBSOCKET_ERROR', `${source} disconnected`);
425
+ out.warn(`[relay] ${source} disconnected; reconnecting`);
426
+ closeQuietly(relayWs);
427
+ closeQuietly(daemonWs);
428
+ this.relayWs = null;
429
+ this.daemonWs = null;
430
+ this.relaySessions.clear();
431
+ this.state = 'waiting_retry';
432
+ void this.connectLoop(source);
433
+ };
434
+ relayWs.on('close', () => reconnect('relay'));
435
+ daemonWs.on('close', () => reconnect('daemon'));
436
+ relayWs.on('error', (err) => out.warn(`[relay] relay ws error: ${err.message}`));
437
+ daemonWs.on('error', (err) => out.warn(`[relay] daemon ws error: ${err.message}`));
438
+ }
439
+ sendToAllRelaySessions(relayWs, payload) {
440
+ this.pruneIdleSessions();
441
+ for (const session of this.relaySessions.values()) {
442
+ session.txSeq += 1;
443
+ session.lastActivityAt = Date.now();
444
+ const envelope = encryptEnvelope(session.key, payload, {
445
+ profile: session.profile,
446
+ sessionId: session.sessionId,
447
+ epoch: session.epoch,
448
+ seq: session.txSeq,
449
+ });
450
+ relayWs.send(envelope);
451
+ if (!session.keyRotationRequested && session.txSeq >= this.keyRotateAfterMessages) {
452
+ const rotateNotice = {
453
+ type: 'relay_key_update_required',
454
+ sessionId: session.sessionId,
455
+ nextEpoch: session.epoch + 1,
456
+ reason: 'message_threshold',
457
+ };
458
+ relayWs.send(JSON.stringify(rotateNotice));
459
+ session.keyRotationRequested = true;
460
+ }
461
+ }
462
+ }
463
+ acceptInboundSeq(session, seq) {
464
+ if (seq < 1)
465
+ return false;
466
+ if (session.rxSeenSeq.has(seq))
467
+ return false;
468
+ if (seq > session.rxHighestSeq + RELAY_REPLAY_WINDOW)
469
+ return false;
470
+ const minimumAllowed = Math.max(1, session.rxHighestSeq - RELAY_REPLAY_WINDOW + 1);
471
+ if (seq < minimumAllowed)
472
+ return false;
473
+ session.rxSeenSeq.add(seq);
474
+ if (seq > session.rxHighestSeq)
475
+ session.rxHighestSeq = seq;
476
+ const pruneBelow = Math.max(1, session.rxHighestSeq - RELAY_REPLAY_WINDOW + 1);
477
+ for (const seen of session.rxSeenSeq) {
478
+ if (seen < pruneBelow)
479
+ session.rxSeenSeq.delete(seen);
480
+ }
481
+ return true;
482
+ }
483
+ pruneIdleSessions() {
484
+ const now = Date.now();
485
+ for (const [sessionId, session] of this.relaySessions.entries()) {
486
+ if (now - session.lastActivityAt > RELAY_SESSION_IDLE_TTL_MS) {
487
+ this.relaySessions.delete(sessionId);
488
+ }
489
+ }
490
+ }
491
+ enforceRelaySessionCapacity() {
492
+ this.pruneIdleSessions();
493
+ while (this.relaySessions.size > this.relaySessionMaxEntries) {
494
+ const oldestSessionId = this.relaySessions.keys().next().value;
495
+ if (!oldestSessionId)
496
+ break;
497
+ this.relaySessions.delete(oldestSessionId);
498
+ out.warn(`[relay] evicted relay session ${oldestSessionId} due to relay session cap (${this.relaySessionMaxEntries})`);
499
+ }
500
+ }
501
+ async handleRelayControlFrame(text, relayWs, daemonWs) {
502
+ let parsedUnknown;
503
+ try {
504
+ parsedUnknown = JSON.parse(text);
505
+ }
506
+ catch {
507
+ return false;
508
+ }
509
+ const pairingOfferRequest = parsePairingOfferRequestFrame(parsedUnknown);
510
+ if (pairingOfferRequest) {
511
+ await this.handlePairingOfferRequest(pairingOfferRequest, relayWs);
512
+ return true;
513
+ }
514
+ const pairingRedeemRequest = parsePairingRedeemRequestFrame(parsedUnknown);
515
+ if (pairingRedeemRequest) {
516
+ await this.handlePairingRedeemRequest(pairingRedeemRequest, relayWs);
517
+ return true;
518
+ }
519
+ const keyExchangeInitV3 = parseRelayKeyExchangeInitFrameV3(parsedUnknown);
520
+ if (keyExchangeInitV3) {
521
+ await this.handleKeyExchangeInitV3(keyExchangeInitV3, relayWs);
522
+ return true;
523
+ }
524
+ const keyExchangeInit = parseRelayKeyExchangeInitFrame(parsedUnknown);
525
+ if (keyExchangeInit) {
526
+ await this.handleKeyExchangeInit(keyExchangeInit, relayWs);
527
+ return true;
528
+ }
529
+ if (!isRelayControlFrame(parsedUnknown)) {
530
+ return false;
531
+ }
532
+ const parsed = parsedUnknown;
533
+ if (parsed.type === 'relay_status') {
534
+ if (parsed.code === 'RELAY_REDIRECT' &&
535
+ typeof parsed.relayWsBaseUrl === 'string' &&
536
+ parsed.relayWsBaseUrl.trim().length > 0 &&
537
+ parsed.relayWsBaseUrl !== this.relayEndpoint) {
538
+ this.relayEndpoint = parsed.relayWsBaseUrl;
539
+ this.recordError('WEBSOCKET_ERROR', `relay redirect requested: ${parsed.relayWsBaseUrl}`);
540
+ out.log(`[relay] redirecting daemon bridge to ${parsed.relayWsBaseUrl}`);
541
+ closeQuietly(relayWs);
542
+ closeQuietly(daemonWs);
543
+ return true;
544
+ }
545
+ out.log(`[relay] status ${parsed.code ?? 'UNKNOWN'}: ${parsed.message ?? 'no additional detail'}`);
546
+ return true;
547
+ }
548
+ if (parsed.type === 'relay_key_update_required') {
549
+ const session = this.relaySessions.get(parsed.sessionId);
550
+ if (!session) {
551
+ out.warn(`[relay] key update request ignored for unknown session ${parsed.sessionId}`);
552
+ return true;
553
+ }
554
+ if (!Number.isInteger(parsed.nextEpoch) || parsed.nextEpoch !== session.epoch + 1) {
555
+ out.warn(`[relay] key update request rejected for session ${parsed.sessionId}: invalid nextEpoch=${parsed.nextEpoch} expected=${session.epoch + 1}`);
556
+ return true;
557
+ }
558
+ session.keyRotationRequested = true;
559
+ return true;
560
+ }
561
+ return true;
562
+ }
563
+ async handleKeyExchangeInit(init, relayWs) {
564
+ if (!this.daemonIdentity) {
565
+ out.warn('[relay] key exchange init ignored: daemon identity not ready');
566
+ return;
567
+ }
568
+ if (!isCompatibleProfile(this.requiredProfile, init.profile)) {
569
+ out.warn(`[relay] key exchange profile mismatch (got=${init.profile}, expected=${this.requiredProfile})`);
570
+ return;
571
+ }
572
+ let previous;
573
+ let nextEpoch = 1;
574
+ if (init.previousSessionId) {
575
+ previous = this.relaySessions.get(init.previousSessionId);
576
+ if (!previous) {
577
+ out.warn(`[relay] key exchange rejected: unknown previous session ${init.previousSessionId}`);
578
+ return;
579
+ }
580
+ if (previous.profile !== init.profile) {
581
+ out.warn(`[relay] key exchange rejected: profile mismatch for previous session ${init.previousSessionId}`);
582
+ return;
583
+ }
584
+ if (!previous.keyRotationRequested) {
585
+ out.warn(`[relay] key exchange rejected: previous session ${init.previousSessionId} has no pending key rotation`);
586
+ return;
587
+ }
588
+ nextEpoch = previous.epoch + 1;
589
+ }
590
+ let pairingSecret;
591
+ if (init.profile === 'noise-ikpsk2') {
592
+ pairingSecret = await this.resolvePolicyCPairingSecret(init.pairingPeerId);
593
+ if (!pairingSecret) {
594
+ out.warn(`[relay] key exchange rejected: missing local pairing binding for peer ${init.pairingPeerId ?? '<none>'}`);
595
+ return;
596
+ }
597
+ }
598
+ try {
599
+ const derived = deriveSessionFromKeyExchange({
600
+ init,
601
+ daemonIdentity: this.daemonIdentity,
602
+ nextEpoch,
603
+ pairingSecret,
604
+ });
605
+ if (previous && init.previousSessionId) {
606
+ this.relaySessions.delete(init.previousSessionId);
607
+ }
608
+ this.relaySessions.set(derived.session.sessionId, {
609
+ ...derived.session,
610
+ txSeq: 0,
611
+ rxHighestSeq: 0,
612
+ rxSeenSeq: new Set(),
613
+ lastActivityAt: Date.now(),
614
+ keyRotationRequested: false,
615
+ });
616
+ this.enforceRelaySessionCapacity();
617
+ if (relayWs.readyState === WebSocket.OPEN) {
618
+ relayWs.send(JSON.stringify(derived.response));
619
+ }
620
+ }
621
+ catch (error) {
622
+ const message = error instanceof Error ? error.message : String(error);
623
+ this.recordError('KEY_EXCHANGE_FAILED', message);
624
+ out.warn(`[relay] key exchange failed [KEY_EXCHANGE_FAILED]: ${message}`);
625
+ }
626
+ }
627
+ async handleKeyExchangeInitV3(init, relayWs) {
628
+ if (!this.daemonIdentity) {
629
+ out.warn('[relay] noise-v3 key exchange init ignored: daemon identity not ready');
630
+ return;
631
+ }
632
+ if (!isCompatibleProfile(this.requiredProfile, init.profile)) {
633
+ out.warn(`[relay] noise-v3 key exchange profile mismatch (got=${init.profile}, expected=${this.requiredProfile})`);
634
+ return;
635
+ }
636
+ let previous;
637
+ let nextEpoch = 1;
638
+ if (init.previousSessionId) {
639
+ previous = this.relaySessions.get(init.previousSessionId);
640
+ if (!previous) {
641
+ out.warn(`[relay] noise-v3 key exchange rejected: unknown previous session ${init.previousSessionId}`);
642
+ return;
643
+ }
644
+ if (previous.profile !== init.profile) {
645
+ out.warn(`[relay] noise-v3 key exchange rejected: profile mismatch for previous session ${init.previousSessionId}`);
646
+ return;
647
+ }
648
+ if (!previous.keyRotationRequested) {
649
+ out.warn(`[relay] noise-v3 key exchange rejected: previous session ${init.previousSessionId} has no pending key rotation`);
650
+ return;
651
+ }
652
+ nextEpoch = previous.epoch + 1;
653
+ }
654
+ let pairingSecret;
655
+ if (init.profile === 'noise-ikpsk2') {
656
+ pairingSecret = await this.resolvePolicyCPairingSecret(init.pairingPeerId);
657
+ if (!pairingSecret) {
658
+ out.warn(`[relay] noise-v3 key exchange rejected: missing local pairing binding for peer ${init.pairingPeerId ?? '<none>'}`);
659
+ return;
660
+ }
661
+ }
662
+ try {
663
+ const derived = deriveNoiseV3SessionFromInit({
664
+ init,
665
+ daemonIdentity: this.daemonIdentity,
666
+ nextEpoch,
667
+ pairingSecret,
668
+ });
669
+ if (previous && init.previousSessionId) {
670
+ this.relaySessions.delete(init.previousSessionId);
671
+ }
672
+ this.relaySessions.set(derived.session.sessionId, {
673
+ ...derived.session,
674
+ profile: derived.session.profile,
675
+ txSeq: 0,
676
+ rxHighestSeq: 0,
677
+ rxSeenSeq: new Set(),
678
+ lastActivityAt: Date.now(),
679
+ keyRotationRequested: false,
680
+ });
681
+ this.enforceRelaySessionCapacity();
682
+ if (relayWs.readyState === WebSocket.OPEN) {
683
+ relayWs.send(JSON.stringify(derived.response));
684
+ }
685
+ }
686
+ catch (error) {
687
+ const message = error instanceof Error ? error.message : String(error);
688
+ this.recordError('KEY_EXCHANGE_FAILED', message);
689
+ out.warn(`[relay] noise-v3 key exchange failed [KEY_EXCHANGE_FAILED]: ${message}`);
690
+ }
691
+ }
692
+ async handlePairingOfferRequest(frame, relayWs) {
693
+ const requestId = frame.requestId;
694
+ const reply = (payload) => {
695
+ if (relayWs.readyState === WebSocket.OPEN) {
696
+ relayWs.send(JSON.stringify(payload));
697
+ }
698
+ };
699
+ try {
700
+ this.prunePairingChannelKeys();
701
+ const clientChannelPublicKey = fromBase64Url(frame.clientChannelPublicKey);
702
+ if (clientChannelPublicKey.length !== 65) {
703
+ throw new Error('invalid clientChannelPublicKey');
704
+ }
705
+ const daemonChannel = crypto.createECDH('prime256v1');
706
+ daemonChannel.generateKeys();
707
+ const shared = daemonChannel.computeSecret(clientChannelPublicKey);
708
+ const channelKey = derivePairingChannelKey(shared, `offer:${frame.requestId}`);
709
+ const daemonUrl = new URL(this.options.daemonWsUrl);
710
+ const issued = await issuePairingOffer({
711
+ ttlSeconds: frame.ttlSeconds ?? 600,
712
+ connection: {
713
+ host: daemonUrl.hostname || '127.0.0.1',
714
+ port: daemonUrl.port ? Number(daemonUrl.port) : 7070,
715
+ listen: `relay:${this.options.workspaceId}`,
716
+ profile: 'relay',
717
+ },
718
+ });
719
+ this.pairingChannelKeys.set(issued.offerId, { key: channelKey, createdAt: Date.now() });
720
+ const encryptedOffer = encryptPairingPayload(channelKey, JSON.stringify({
721
+ offerId: issued.offerId,
722
+ createdAt: issued.createdAt,
723
+ expiresAt: issued.expiresAt,
724
+ redeemSecret: issued.redeemSecret,
725
+ trustAnchor: issued.trustAnchor,
726
+ daemonDeviceId: issued.daemonDeviceId,
727
+ daemonPublicKey: issued.daemonPublicKey,
728
+ }), `offer:${frame.requestId}`);
729
+ reply({
730
+ type: 'relay_pairing_offer_response',
731
+ requestId,
732
+ ok: true,
733
+ daemonChannelPublicKey: toBase64Url(daemonChannel.getPublicKey()),
734
+ ...encryptedOffer,
735
+ });
736
+ }
737
+ catch (error) {
738
+ const message = error instanceof Error ? error.message : String(error);
739
+ reply({
740
+ type: 'relay_pairing_offer_response',
741
+ requestId,
742
+ ok: false,
743
+ errorCode: 'PAIRING_OFFER_FAILED',
744
+ error: message,
745
+ });
746
+ }
747
+ }
748
+ async handlePairingRedeemRequest(frame, relayWs) {
749
+ const requestId = frame.requestId;
750
+ const reply = (payload) => {
751
+ if (relayWs.readyState === WebSocket.OPEN) {
752
+ relayWs.send(JSON.stringify(payload));
753
+ }
754
+ };
755
+ try {
756
+ this.prunePairingChannelKeys();
757
+ const channel = this.pairingChannelKeys.get(frame.offerId);
758
+ if (!channel) {
759
+ throw new Error('pairing channel missing or expired');
760
+ }
761
+ const decrypted = decryptPairingPayload(channel.key, {
762
+ encIv: frame.encIv,
763
+ encTag: frame.encTag,
764
+ encCiphertext: frame.encCiphertext,
765
+ }, `redeem:${frame.requestId}:${frame.offerId}`);
766
+ const parsed = JSON.parse(decrypted);
767
+ if (typeof parsed.redeemSecret !== 'string' ||
768
+ typeof parsed.trustAnchor !== 'string' ||
769
+ typeof parsed.clientPublicKey !== 'string' ||
770
+ typeof parsed.clientProof !== 'string' ||
771
+ parsed.redeemSecret.trim().length === 0 ||
772
+ parsed.trustAnchor.trim().length === 0 ||
773
+ parsed.clientPublicKey.trim().length === 0 ||
774
+ parsed.clientProof.trim().length === 0) {
775
+ throw new Error('invalid encrypted pairing payload');
776
+ }
777
+ const redeemed = await redeemPairingOffer(frame.offerId, parsed.redeemSecret, parsed.trustAnchor, parsed.clientPublicKey, parsed.clientProof);
778
+ if (!redeemed) {
779
+ reply({
780
+ type: 'relay_pairing_redeem_response',
781
+ requestId,
782
+ ok: false,
783
+ errorCode: 'PAIRING_REDEEM_FAILED',
784
+ error: 'offer not found or no longer valid',
785
+ });
786
+ return;
787
+ }
788
+ reply({
789
+ type: 'relay_pairing_redeem_response',
790
+ requestId,
791
+ ok: true,
792
+ redeemed: {
793
+ offerId: redeemed.offerId,
794
+ createdAt: redeemed.createdAt,
795
+ expiresAt: redeemed.expiresAt,
796
+ peerId: redeemed.peerId,
797
+ daemonDeviceId: redeemed.daemonDeviceId,
798
+ daemonPublicKey: redeemed.daemonPublicKey,
799
+ relayPairingPeerId: redeemed.relayPairingPeerId,
800
+ serverSignature: redeemed.serverSignature,
801
+ },
802
+ });
803
+ this.pairingChannelKeys.delete(frame.offerId);
804
+ }
805
+ catch (error) {
806
+ const message = error instanceof Error ? error.message : String(error);
807
+ reply({
808
+ type: 'relay_pairing_redeem_response',
809
+ requestId,
810
+ ok: false,
811
+ errorCode: 'PAIRING_REDEEM_FAILED',
812
+ error: message,
813
+ });
814
+ }
815
+ }
816
+ prunePairingChannelKeys(now = Date.now(), maxAgeMs = this.pairingChannelTtlMs, maxEntries = this.pairingChannelMaxEntries) {
817
+ for (const [offerId, channel] of this.pairingChannelKeys.entries()) {
818
+ if (now - channel.createdAt > maxAgeMs) {
819
+ this.pairingChannelKeys.delete(offerId);
820
+ }
821
+ }
822
+ const overflow = this.pairingChannelKeys.size - maxEntries;
823
+ if (overflow <= 0) {
824
+ return;
825
+ }
826
+ const oldest = Array.from(this.pairingChannelKeys.entries())
827
+ .sort((a, b) => a[1].createdAt - b[1].createdAt)
828
+ .slice(0, overflow);
829
+ for (const [offerId] of oldest) {
830
+ this.pairingChannelKeys.delete(offerId);
831
+ }
832
+ }
833
+ flushPendingOutbound() {
834
+ const relayWs = this.relayWs;
835
+ if (!relayWs || relayWs.readyState !== WebSocket.OPEN)
836
+ return;
837
+ while (this.pendingOutbound.length > 0 && relayWs.readyState === WebSocket.OPEN) {
838
+ const next = this.pendingOutbound.shift();
839
+ if (!next)
840
+ break;
841
+ this.pendingOutboundBytes -= Buffer.byteLength(next);
842
+ this.sendToAllRelaySessions(relayWs, next);
843
+ }
844
+ if (this.pendingOutboundBytes < 0)
845
+ this.pendingOutboundBytes = 0;
846
+ }
847
+ async resolvePolicyCPairingSecret(peerId) {
848
+ if (!peerId || peerId.trim().length === 0) {
849
+ return undefined;
850
+ }
851
+ const resolved = await resolveRelayPairingSecret(peerId);
852
+ if (!resolved || resolved.length !== 32) {
853
+ return undefined;
854
+ }
855
+ return resolved;
856
+ }
857
+ async issueRelayToken() {
858
+ const url = `${this.options.relayServerUrl.replace(/\/+$/, '')}/api/poc/relay-token`;
859
+ const controller = new AbortController();
860
+ const timeout = setTimeout(() => controller.abort(), 8_000);
861
+ let res;
862
+ if (!this.daemonIssueToken) {
863
+ throw new BridgeError('TOKEN_ISSUE_FAILED', 'missing daemon issue token');
864
+ }
865
+ try {
866
+ res = await fetch(url, {
867
+ method: 'POST',
868
+ headers: { 'content-type': 'application/json' },
869
+ body: JSON.stringify({
870
+ role: 'workspace-daemon',
871
+ workspaceId: this.options.workspaceId,
872
+ credential: this.daemonIssueToken,
873
+ }),
874
+ signal: controller.signal,
875
+ });
876
+ }
877
+ catch (error) {
878
+ clearTimeout(timeout);
879
+ throw new BridgeError('TOKEN_ISSUE_FAILED', error instanceof Error ? error.message : String(error));
880
+ }
881
+ clearTimeout(timeout);
882
+ const parsed = await parseRelayIssueResponse(res);
883
+ if (!res.ok || !parsed.ok || !parsed.relayToken) {
884
+ const reason = parsed.reason ?? parsed.error ?? `HTTP ${res.status}`;
885
+ throw new BridgeError('TOKEN_ISSUE_FAILED', `issue relay token failed: ${reason}`);
886
+ }
887
+ let tokenClaims;
888
+ let verificationKeys = await this.resolveRelayTokenVerificationKeys(false);
889
+ try {
890
+ tokenClaims = verifyRelayTokenClaims(parsed.relayToken, {
891
+ issuer: this.options.relayTokenIssuer ?? 'viewport-server-poc',
892
+ audience: this.options.relayTokenAudience ?? 'viewport-relay',
893
+ signingKeys: verificationKeys,
894
+ clockSkewSec: this.options.relayTokenClockSkewSec ?? 30,
895
+ });
896
+ }
897
+ catch (error) {
898
+ if (this.relayTokenJwksUrl &&
899
+ error instanceof BridgeError &&
900
+ error.code === 'TOKEN_RESPONSE_INVALID' &&
901
+ error.message.includes('is not trusted')) {
902
+ verificationKeys = await this.resolveRelayTokenVerificationKeys(true);
903
+ tokenClaims = verifyRelayTokenClaims(parsed.relayToken, {
904
+ issuer: this.options.relayTokenIssuer ?? 'viewport-server-poc',
905
+ audience: this.options.relayTokenAudience ?? 'viewport-relay',
906
+ signingKeys: verificationKeys,
907
+ clockSkewSec: this.options.relayTokenClockSkewSec ?? 30,
908
+ });
909
+ }
910
+ else {
911
+ throw error;
912
+ }
913
+ }
914
+ const profile = parseRelayHandshakeProfile(tokenClaims.e2eeProfile ?? 'noise-ik');
915
+ if (!profile) {
916
+ throw new BridgeError('TOKEN_RESPONSE_INVALID', 'missing/invalid e2eeProfile claim');
917
+ }
918
+ return {
919
+ relayToken: parsed.relayToken,
920
+ profile,
921
+ };
922
+ }
923
+ async resolveRelayTokenVerificationKeys(forceRefresh) {
924
+ if (!this.relayTokenJwksUrl) {
925
+ return this.relayTokenSigningKeys;
926
+ }
927
+ const now = Date.now();
928
+ if (!forceRefresh && now < this.jwksCacheExpiresAt && Object.keys(this.jwksCacheKeys).length) {
929
+ return this.jwksCacheKeys;
930
+ }
931
+ const controller = new AbortController();
932
+ const timeout = setTimeout(() => controller.abort(), 8_000);
933
+ let res;
934
+ try {
935
+ res = await fetch(this.relayTokenJwksUrl, {
936
+ method: 'GET',
937
+ headers: { accept: 'application/json' },
938
+ signal: controller.signal,
939
+ });
940
+ }
941
+ catch (error) {
942
+ clearTimeout(timeout);
943
+ throw new BridgeError('TOKEN_RESPONSE_INVALID', `failed to fetch JWKS: ${error instanceof Error ? error.message : String(error)}`);
944
+ }
945
+ clearTimeout(timeout);
946
+ if (!res.ok) {
947
+ throw new BridgeError('TOKEN_RESPONSE_INVALID', `JWKS endpoint returned HTTP ${res.status}`);
948
+ }
949
+ const parsed = (await res.json().catch(() => null));
950
+ if (!parsed || !Array.isArray(parsed.keys)) {
951
+ throw new BridgeError('TOKEN_RESPONSE_INVALID', 'JWKS response missing keys array');
952
+ }
953
+ if (parsed.keys.length > MAX_JWKS_KEYS) {
954
+ throw new BridgeError('TOKEN_RESPONSE_INVALID', `JWKS response contains too many keys (${parsed.keys.length} > ${MAX_JWKS_KEYS})`);
955
+ }
956
+ const keys = {};
957
+ for (const entry of parsed.keys) {
958
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry))
959
+ continue;
960
+ const kid = typeof entry['kid'] === 'string' ? entry['kid'].trim() : '';
961
+ const kty = typeof entry['kty'] === 'string' ? entry['kty'] : '';
962
+ const alg = typeof entry['alg'] === 'string' ? entry['alg'] : '';
963
+ const n = typeof entry['n'] === 'string' ? entry['n'] : '';
964
+ const e = typeof entry['e'] === 'string' ? entry['e'] : '';
965
+ if (!kid || kty !== 'RSA' || !n || !e)
966
+ continue;
967
+ if (alg && alg !== 'RS256')
968
+ continue;
969
+ try {
970
+ const keyObject = crypto.createPublicKey({
971
+ key: { kty: 'RSA', n, e },
972
+ format: 'jwk',
973
+ });
974
+ keys[kid] = keyObject.export({ format: 'pem', type: 'spki' }).toString();
975
+ }
976
+ catch {
977
+ continue;
978
+ }
979
+ }
980
+ if (Object.keys(keys).length === 0) {
981
+ throw new BridgeError('TOKEN_RESPONSE_INVALID', 'JWKS contained no usable signing keys');
982
+ }
983
+ this.jwksCacheKeys = keys;
984
+ this.jwksCacheExpiresAt = Date.now() + 5 * 60_000;
985
+ return keys;
986
+ }
987
+ normalizeError(error) {
988
+ if (error instanceof BridgeError) {
989
+ return error;
990
+ }
991
+ if (error instanceof Error) {
992
+ return new BridgeError('UNKNOWN', error.message);
993
+ }
994
+ return new BridgeError('UNKNOWN', String(error));
995
+ }
996
+ recordError(code, message) {
997
+ this.lastErrorCode = code;
998
+ this.lastErrorMessage = message;
999
+ this.lastErrorAt = Date.now();
1000
+ }
1001
+ reportStatus(code, message) {
1002
+ out.warn(`[relay] bridge-status [${code}]: ${message}`);
1003
+ }
1004
+ }
1005
+ //# sourceMappingURL=daemon-relay-bridge.js.map