reley 0.1.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.
- package/dist/index.js +2750 -0
- package/package.json +35 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2750 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
4
|
+
var __esm = (fn, res) => function __init() {
|
|
5
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
6
|
+
};
|
|
7
|
+
var __export = (target, all) => {
|
|
8
|
+
for (var name in all)
|
|
9
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
// src/config.ts
|
|
13
|
+
var config_exports = {};
|
|
14
|
+
__export(config_exports, {
|
|
15
|
+
clearConfig: () => clearConfig,
|
|
16
|
+
getConfigDir: () => getConfigDir,
|
|
17
|
+
getConfigPath: () => getConfigPath,
|
|
18
|
+
isConfigured: () => isConfigured,
|
|
19
|
+
loadConfig: () => loadConfig,
|
|
20
|
+
saveConfig: () => saveConfig
|
|
21
|
+
});
|
|
22
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
import { homedir } from "node:os";
|
|
25
|
+
function getConfigDir() {
|
|
26
|
+
return CONFIG_DIR;
|
|
27
|
+
}
|
|
28
|
+
function getConfigPath() {
|
|
29
|
+
return CONFIG_FILE;
|
|
30
|
+
}
|
|
31
|
+
function loadConfig() {
|
|
32
|
+
try {
|
|
33
|
+
if (!existsSync(CONFIG_FILE)) return null;
|
|
34
|
+
const raw = readFileSync(CONFIG_FILE, "utf-8");
|
|
35
|
+
return JSON.parse(raw);
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function saveConfig(config) {
|
|
41
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
42
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2) + "\n", "utf-8");
|
|
43
|
+
}
|
|
44
|
+
function clearConfig() {
|
|
45
|
+
try {
|
|
46
|
+
if (existsSync(CONFIG_FILE)) {
|
|
47
|
+
writeFileSync(CONFIG_FILE, "{}\n", "utf-8");
|
|
48
|
+
}
|
|
49
|
+
} catch {
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function isConfigured() {
|
|
53
|
+
const config = loadConfig();
|
|
54
|
+
return !!(config?.reley_url && config?.device_token);
|
|
55
|
+
}
|
|
56
|
+
var CONFIG_DIR, CONFIG_FILE;
|
|
57
|
+
var init_config = __esm({
|
|
58
|
+
"src/config.ts"() {
|
|
59
|
+
"use strict";
|
|
60
|
+
CONFIG_DIR = join(homedir(), ".reley");
|
|
61
|
+
CONFIG_FILE = join(CONFIG_DIR, "config.json");
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// src/index.ts
|
|
66
|
+
import { Command } from "commander";
|
|
67
|
+
|
|
68
|
+
// src/commands/run.ts
|
|
69
|
+
import { createServer as createHttpServer } from "node:http";
|
|
70
|
+
import { createServer as createNetServer } from "node:net";
|
|
71
|
+
import { fork } from "node:child_process";
|
|
72
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2, unlinkSync } from "node:fs";
|
|
73
|
+
import { fileURLToPath } from "node:url";
|
|
74
|
+
import { dirname, join as join2 } from "node:path";
|
|
75
|
+
import { homedir as homedir2 } from "node:os";
|
|
76
|
+
import crypto from "node:crypto";
|
|
77
|
+
import WebSocket, { WebSocketServer } from "ws";
|
|
78
|
+
import { spawn } from "node-pty";
|
|
79
|
+
|
|
80
|
+
// ../../packages/crypto/dist/sodium.js
|
|
81
|
+
import { createRequire } from "node:module";
|
|
82
|
+
var require2 = createRequire(import.meta.url);
|
|
83
|
+
var sodium = require2("libsodium-wrappers-sumo");
|
|
84
|
+
var sodium_default = sodium;
|
|
85
|
+
|
|
86
|
+
// ../../packages/crypto/dist/keys.js
|
|
87
|
+
var initialized = false;
|
|
88
|
+
async function ensureSodium() {
|
|
89
|
+
if (!initialized) {
|
|
90
|
+
await sodium_default.ready;
|
|
91
|
+
initialized = true;
|
|
92
|
+
}
|
|
93
|
+
return sodium_default;
|
|
94
|
+
}
|
|
95
|
+
async function generateIdentityKeyPair() {
|
|
96
|
+
const s = await ensureSodium();
|
|
97
|
+
const kp = s.crypto_sign_keypair();
|
|
98
|
+
return {
|
|
99
|
+
publicKey: kp.publicKey,
|
|
100
|
+
secretKey: kp.privateKey,
|
|
101
|
+
keyType: "ed25519"
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
async function generateEphemeralKeyPair() {
|
|
105
|
+
const s = await ensureSodium();
|
|
106
|
+
const kp = s.crypto_kx_keypair();
|
|
107
|
+
return {
|
|
108
|
+
publicKey: kp.publicKey,
|
|
109
|
+
secretKey: kp.privateKey,
|
|
110
|
+
keyType: "x25519"
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
async function generateOneTimeCode(length = 32) {
|
|
114
|
+
const s = await ensureSodium();
|
|
115
|
+
return s.randombytes_buf(length);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ../../packages/crypto/dist/ecdh.js
|
|
119
|
+
async function computeSharedSecret(ourSecretKey, theirPublicKey) {
|
|
120
|
+
const s = await ensureSodium();
|
|
121
|
+
return s.crypto_scalarmult(ourSecretKey, theirPublicKey);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ../../packages/crypto/dist/hkdf.js
|
|
125
|
+
var HKDF_HASH_LEN = 32;
|
|
126
|
+
async function hkdfExtract(salt, ikm) {
|
|
127
|
+
const s = await ensureSodium();
|
|
128
|
+
const key = salt.length > 0 ? salt : new Uint8Array(HKDF_HASH_LEN);
|
|
129
|
+
return s.crypto_auth_hmacsha256(ikm, key);
|
|
130
|
+
}
|
|
131
|
+
async function hkdfExpand(prk, info, length) {
|
|
132
|
+
const s = await ensureSodium();
|
|
133
|
+
const n = Math.ceil(length / HKDF_HASH_LEN);
|
|
134
|
+
const okm = new Uint8Array(n * HKDF_HASH_LEN);
|
|
135
|
+
let prev = new Uint8Array(0);
|
|
136
|
+
for (let i = 1; i <= n; i++) {
|
|
137
|
+
const input = new Uint8Array(prev.length + info.length + 1);
|
|
138
|
+
input.set(prev, 0);
|
|
139
|
+
input.set(info, prev.length);
|
|
140
|
+
input[prev.length + info.length] = i;
|
|
141
|
+
prev = new Uint8Array(s.crypto_auth_hmacsha256(input, prk));
|
|
142
|
+
okm.set(prev, (i - 1) * HKDF_HASH_LEN);
|
|
143
|
+
}
|
|
144
|
+
return okm.slice(0, length);
|
|
145
|
+
}
|
|
146
|
+
async function deriveSessionKeys(sharedSecret, salt = new Uint8Array(0), info = "reley-v1") {
|
|
147
|
+
const infoBytes = new TextEncoder().encode(info);
|
|
148
|
+
const prk = await hkdfExtract(salt, sharedSecret);
|
|
149
|
+
const okm = await hkdfExpand(prk, infoBytes, 64);
|
|
150
|
+
return {
|
|
151
|
+
sendKey: okm.slice(0, 32),
|
|
152
|
+
recvKey: okm.slice(32, 64)
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
async function deriveChainKey(chainKey, info = "reley-chain") {
|
|
156
|
+
const s = await ensureSodium();
|
|
157
|
+
const msgKeyInput = new TextEncoder().encode(info + "-msg");
|
|
158
|
+
const chainKeyInput = new TextEncoder().encode(info + "-chain");
|
|
159
|
+
const messageKey = s.crypto_auth_hmacsha256(msgKeyInput, chainKey);
|
|
160
|
+
const nextChainKey = s.crypto_auth_hmacsha256(chainKeyInput, chainKey);
|
|
161
|
+
return { messageKey, nextChainKey };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ../../packages/crypto/dist/aes-gcm.js
|
|
165
|
+
var NONCE_LENGTH = 12;
|
|
166
|
+
var KEY_LENGTH = 32;
|
|
167
|
+
async function encrypt(plaintext, key, aad = new Uint8Array(0)) {
|
|
168
|
+
const s = await ensureSodium();
|
|
169
|
+
if (key.length !== KEY_LENGTH) {
|
|
170
|
+
throw new Error(`Key must be ${KEY_LENGTH} bytes, got ${key.length}`);
|
|
171
|
+
}
|
|
172
|
+
const nonce = s.randombytes_buf(NONCE_LENGTH);
|
|
173
|
+
const ciphertext = s.crypto_aead_xchacha20poly1305_ietf_encrypt(
|
|
174
|
+
plaintext,
|
|
175
|
+
aad.length > 0 ? aad : null,
|
|
176
|
+
null,
|
|
177
|
+
// nsec (unused)
|
|
178
|
+
// xchacha uses 24-byte nonce, pad our 12-byte nonce
|
|
179
|
+
padNonce(nonce, s),
|
|
180
|
+
key
|
|
181
|
+
);
|
|
182
|
+
return { nonce, ciphertext };
|
|
183
|
+
}
|
|
184
|
+
async function decrypt(ciphertext, nonce, key, aad = new Uint8Array(0)) {
|
|
185
|
+
const s = await ensureSodium();
|
|
186
|
+
if (key.length !== KEY_LENGTH) {
|
|
187
|
+
throw new Error(`Key must be ${KEY_LENGTH} bytes, got ${key.length}`);
|
|
188
|
+
}
|
|
189
|
+
try {
|
|
190
|
+
return s.crypto_aead_xchacha20poly1305_ietf_decrypt(
|
|
191
|
+
null,
|
|
192
|
+
// nsec (unused)
|
|
193
|
+
ciphertext,
|
|
194
|
+
aad.length > 0 ? aad : null,
|
|
195
|
+
padNonce(nonce, s),
|
|
196
|
+
key
|
|
197
|
+
);
|
|
198
|
+
} catch {
|
|
199
|
+
throw new Error("Decryption failed: invalid ciphertext or key");
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
function padNonce(nonce, s) {
|
|
203
|
+
const padded = new Uint8Array(s.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES);
|
|
204
|
+
padded.set(nonce, 0);
|
|
205
|
+
return padded;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ../../packages/crypto/dist/ratchet.js
|
|
209
|
+
var KEY_ROTATION_INTERVAL = 50;
|
|
210
|
+
function initRatchet(sendKey, recvKey) {
|
|
211
|
+
return {
|
|
212
|
+
sendChainKey: sendKey,
|
|
213
|
+
recvChainKey: recvKey,
|
|
214
|
+
sendCounter: 0,
|
|
215
|
+
recvCounter: 0,
|
|
216
|
+
maxRecvCounter: -1
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
async function ratchetEncrypt(state, plaintext) {
|
|
220
|
+
const { messageKey, nextChainKey } = await deriveChainKey(state.sendChainKey);
|
|
221
|
+
const counter = state.sendCounter;
|
|
222
|
+
const aad = buildAAD(1, counter);
|
|
223
|
+
const { nonce, ciphertext } = await encrypt(plaintext, messageKey, aad);
|
|
224
|
+
const newState = {
|
|
225
|
+
...state,
|
|
226
|
+
sendChainKey: nextChainKey,
|
|
227
|
+
sendCounter: counter + 1
|
|
228
|
+
};
|
|
229
|
+
return { ciphertext, nonce, counter, state: newState };
|
|
230
|
+
}
|
|
231
|
+
async function ratchetDecrypt(state, ciphertext, nonce, counter) {
|
|
232
|
+
if (counter <= state.maxRecvCounter) {
|
|
233
|
+
throw new Error(`Replay attack detected: counter ${counter} <= ${state.maxRecvCounter}`);
|
|
234
|
+
}
|
|
235
|
+
let chainKey = state.recvChainKey;
|
|
236
|
+
let currentCounter = state.recvCounter;
|
|
237
|
+
let messageKey;
|
|
238
|
+
while (currentCounter <= counter) {
|
|
239
|
+
const derived = await deriveChainKey(chainKey);
|
|
240
|
+
if (currentCounter === counter) {
|
|
241
|
+
messageKey = derived.messageKey;
|
|
242
|
+
}
|
|
243
|
+
chainKey = derived.nextChainKey;
|
|
244
|
+
currentCounter++;
|
|
245
|
+
}
|
|
246
|
+
if (!messageKey) {
|
|
247
|
+
throw new Error("Failed to derive message key");
|
|
248
|
+
}
|
|
249
|
+
const aad = buildAAD(1, counter);
|
|
250
|
+
const plaintext = await decrypt(ciphertext, nonce, messageKey, aad);
|
|
251
|
+
const newState = {
|
|
252
|
+
...state,
|
|
253
|
+
recvChainKey: chainKey,
|
|
254
|
+
recvCounter: currentCounter,
|
|
255
|
+
maxRecvCounter: counter
|
|
256
|
+
};
|
|
257
|
+
return { plaintext, state: newState };
|
|
258
|
+
}
|
|
259
|
+
function needsKeyRotation(state) {
|
|
260
|
+
return state.sendCounter > 0 && state.sendCounter % KEY_ROTATION_INTERVAL === 0;
|
|
261
|
+
}
|
|
262
|
+
function buildAAD(version, counter) {
|
|
263
|
+
const aad = new Uint8Array(6);
|
|
264
|
+
aad[0] = version;
|
|
265
|
+
aad[1] = 1;
|
|
266
|
+
aad[2] = counter >>> 24 & 255;
|
|
267
|
+
aad[3] = counter >>> 16 & 255;
|
|
268
|
+
aad[4] = counter >>> 8 & 255;
|
|
269
|
+
aad[5] = counter & 255;
|
|
270
|
+
return aad;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ../../packages/crypto/dist/qr-payload.js
|
|
274
|
+
var MAGIC = "CB1";
|
|
275
|
+
async function encodeQRPayload(payload) {
|
|
276
|
+
const s = await ensureSodium();
|
|
277
|
+
const releyBytes = new TextEncoder().encode(payload.releyUrl);
|
|
278
|
+
const jwtBytes = new TextEncoder().encode(payload.jwt);
|
|
279
|
+
const totalLen = 3 + 2 + releyBytes.length + 32 + 32 + 2 + jwtBytes.length;
|
|
280
|
+
const buf = new Uint8Array(totalLen);
|
|
281
|
+
let offset = 0;
|
|
282
|
+
buf[offset++] = MAGIC.charCodeAt(0);
|
|
283
|
+
buf[offset++] = MAGIC.charCodeAt(1);
|
|
284
|
+
buf[offset++] = MAGIC.charCodeAt(2);
|
|
285
|
+
buf[offset++] = releyBytes.length >>> 8 & 255;
|
|
286
|
+
buf[offset++] = releyBytes.length & 255;
|
|
287
|
+
buf.set(releyBytes, offset);
|
|
288
|
+
offset += releyBytes.length;
|
|
289
|
+
buf.set(payload.publicKey, offset);
|
|
290
|
+
offset += 32;
|
|
291
|
+
buf.set(payload.oneTimeCode, offset);
|
|
292
|
+
offset += 32;
|
|
293
|
+
buf[offset++] = jwtBytes.length >>> 8 & 255;
|
|
294
|
+
buf[offset++] = jwtBytes.length & 255;
|
|
295
|
+
buf.set(jwtBytes, offset);
|
|
296
|
+
return s.to_base64(buf, s.base64_variants.URLSAFE_NO_PADDING);
|
|
297
|
+
}
|
|
298
|
+
async function hashOneTimeCode(otc) {
|
|
299
|
+
const s = await ensureSodium();
|
|
300
|
+
return s.crypto_generichash(32, otc, null);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ../../packages/protocol/dist/constants.js
|
|
304
|
+
var PROTOCOL_VERSION = 1;
|
|
305
|
+
var WIRE = {
|
|
306
|
+
VERSION: 1,
|
|
307
|
+
TYPE: 1,
|
|
308
|
+
COUNTER: 4,
|
|
309
|
+
NONCE: 12,
|
|
310
|
+
TAG: 16,
|
|
311
|
+
HEADER: 1 + 1 + 4 + 12
|
|
312
|
+
// 18 bytes total header before ciphertext
|
|
313
|
+
};
|
|
314
|
+
var MessageType = {
|
|
315
|
+
TERMINAL_DATA: 1,
|
|
316
|
+
TERMINAL_RESIZE: 2,
|
|
317
|
+
TERMINAL_INPUT: 3,
|
|
318
|
+
HOOK_EVENT: 16,
|
|
319
|
+
HOOK_RESPONSE: 17,
|
|
320
|
+
SESSION_PING: 32,
|
|
321
|
+
SESSION_PONG: 33,
|
|
322
|
+
SESSION_CLOSE: 34,
|
|
323
|
+
KEY_ROTATION: 48,
|
|
324
|
+
KEY_EXCHANGE: 49
|
|
325
|
+
};
|
|
326
|
+
var TIMEOUTS = {
|
|
327
|
+
PAIRING_EXPIRY: 5 * 60 * 1e3,
|
|
328
|
+
// 5 minutes
|
|
329
|
+
JWT_EXPIRY: 24 * 60 * 60 * 1e3,
|
|
330
|
+
// 24 hours
|
|
331
|
+
PING_INTERVAL: 30 * 1e3,
|
|
332
|
+
// 30 seconds
|
|
333
|
+
PONG_TIMEOUT: 10 * 1e3,
|
|
334
|
+
// 10 seconds
|
|
335
|
+
WS_RECONNECT_BASE: 1e3,
|
|
336
|
+
// 1 second base for exponential backoff
|
|
337
|
+
WS_RECONNECT_MAX: 30 * 1e3,
|
|
338
|
+
// 30 seconds max
|
|
339
|
+
HOOK_RESPONSE_TIMEOUT: 5 * 60 * 1e3
|
|
340
|
+
// 5 minutes for user approval
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
// ../../packages/protocol/dist/envelope.js
|
|
344
|
+
var MESSAGE_TYPE_MAP = {
|
|
345
|
+
terminal_data: MessageType.TERMINAL_DATA,
|
|
346
|
+
terminal_resize: MessageType.TERMINAL_RESIZE,
|
|
347
|
+
terminal_input: MessageType.TERMINAL_INPUT,
|
|
348
|
+
hook_event: MessageType.HOOK_EVENT,
|
|
349
|
+
hook_response: MessageType.HOOK_RESPONSE,
|
|
350
|
+
ping: MessageType.SESSION_PING,
|
|
351
|
+
pong: MessageType.SESSION_PONG,
|
|
352
|
+
session_close: MessageType.SESSION_CLOSE,
|
|
353
|
+
key_rotation: MessageType.KEY_ROTATION,
|
|
354
|
+
key_exchange: MessageType.KEY_EXCHANGE
|
|
355
|
+
};
|
|
356
|
+
var REVERSE_TYPE_MAP = /* @__PURE__ */ new Map();
|
|
357
|
+
for (const [k, v] of Object.entries(MESSAGE_TYPE_MAP)) {
|
|
358
|
+
REVERSE_TYPE_MAP.set(v, k);
|
|
359
|
+
}
|
|
360
|
+
function getWireType(messageType) {
|
|
361
|
+
const t = MESSAGE_TYPE_MAP[messageType];
|
|
362
|
+
if (t === void 0) {
|
|
363
|
+
throw new Error(`Unknown message type: ${messageType}`);
|
|
364
|
+
}
|
|
365
|
+
return t;
|
|
366
|
+
}
|
|
367
|
+
function encodeEnvelope(envelope) {
|
|
368
|
+
const totalLen = WIRE.HEADER + envelope.ciphertext.length;
|
|
369
|
+
const buf = new Uint8Array(totalLen);
|
|
370
|
+
let offset = 0;
|
|
371
|
+
buf[offset++] = envelope.version;
|
|
372
|
+
buf[offset++] = envelope.type;
|
|
373
|
+
buf[offset++] = envelope.counter >>> 24 & 255;
|
|
374
|
+
buf[offset++] = envelope.counter >>> 16 & 255;
|
|
375
|
+
buf[offset++] = envelope.counter >>> 8 & 255;
|
|
376
|
+
buf[offset++] = envelope.counter & 255;
|
|
377
|
+
buf.set(envelope.nonce, offset);
|
|
378
|
+
offset += WIRE.NONCE;
|
|
379
|
+
buf.set(envelope.ciphertext, offset);
|
|
380
|
+
return buf;
|
|
381
|
+
}
|
|
382
|
+
function decodeEnvelope(data) {
|
|
383
|
+
if (data.length < WIRE.HEADER + WIRE.TAG) {
|
|
384
|
+
throw new Error(`Envelope too short: ${data.length} bytes`);
|
|
385
|
+
}
|
|
386
|
+
let offset = 0;
|
|
387
|
+
const version = data[offset++];
|
|
388
|
+
if (version !== PROTOCOL_VERSION) {
|
|
389
|
+
throw new Error(`Unsupported protocol version: ${version}`);
|
|
390
|
+
}
|
|
391
|
+
const type = data[offset++];
|
|
392
|
+
const counter = data[offset] << 24 | data[offset + 1] << 16 | data[offset + 2] << 8 | data[offset + 3];
|
|
393
|
+
offset += 4;
|
|
394
|
+
const nonce = data.slice(offset, offset + WIRE.NONCE);
|
|
395
|
+
offset += WIRE.NONCE;
|
|
396
|
+
const ciphertext = data.slice(offset);
|
|
397
|
+
return { version, type, counter, nonce, ciphertext };
|
|
398
|
+
}
|
|
399
|
+
function serializeMessage(message) {
|
|
400
|
+
return new TextEncoder().encode(JSON.stringify(message));
|
|
401
|
+
}
|
|
402
|
+
function deserializeMessage(data) {
|
|
403
|
+
return JSON.parse(new TextDecoder().decode(data));
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// src/commands/run.ts
|
|
407
|
+
var __dirname = dirname(fileURLToPath(import.meta.url));
|
|
408
|
+
var loggingEnabled = true;
|
|
409
|
+
function log(label, msg) {
|
|
410
|
+
if (!loggingEnabled) return;
|
|
411
|
+
const colors = {
|
|
412
|
+
RELEY: "\x1B[34m",
|
|
413
|
+
PTY: "\x1B[32m",
|
|
414
|
+
WEB: "\x1B[35m",
|
|
415
|
+
CRYPTO: "\x1B[33m",
|
|
416
|
+
HOOKS: "\x1B[36m"
|
|
417
|
+
};
|
|
418
|
+
console.error(`${colors[label] ?? ""}[${label}]\x1B[0m ${msg}`);
|
|
419
|
+
}
|
|
420
|
+
function findReleyServer() {
|
|
421
|
+
const candidates = [
|
|
422
|
+
join2(__dirname, "../../../reley/dist/server.js"),
|
|
423
|
+
join2(__dirname, "../../../../apps/reley/dist/server.js")
|
|
424
|
+
];
|
|
425
|
+
for (const p of candidates) {
|
|
426
|
+
if (existsSync2(p)) return p;
|
|
427
|
+
}
|
|
428
|
+
throw new Error("Reley server not found. Run `pnpm run build` first.");
|
|
429
|
+
}
|
|
430
|
+
async function isReleyRunning(url) {
|
|
431
|
+
try {
|
|
432
|
+
const res = await fetch(`${url}/health`);
|
|
433
|
+
return res.ok;
|
|
434
|
+
} catch {
|
|
435
|
+
return false;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
async function waitForReley(url) {
|
|
439
|
+
for (let i = 0; i < 50; i++) {
|
|
440
|
+
if (await isReleyRunning(url)) return;
|
|
441
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
442
|
+
}
|
|
443
|
+
throw new Error("Reley server failed to start");
|
|
444
|
+
}
|
|
445
|
+
function getTerminalHtml() {
|
|
446
|
+
return `<!DOCTYPE html>
|
|
447
|
+
<html lang="ko"><head>
|
|
448
|
+
<meta charset="utf-8">
|
|
449
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
450
|
+
<title>Reley</title>
|
|
451
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css">
|
|
452
|
+
<style>
|
|
453
|
+
:root {
|
|
454
|
+
--bg: #0E0F13;
|
|
455
|
+
--surface: rgba(255,255,255,0.06);
|
|
456
|
+
--surface-hover: rgba(255,255,255,0.10);
|
|
457
|
+
--border: rgba(255,255,255,0.08);
|
|
458
|
+
--border-hover: rgba(255,255,255,0.14);
|
|
459
|
+
--primary: #3182F6;
|
|
460
|
+
--primary-hover: #1B64DA;
|
|
461
|
+
--success: #34C759;
|
|
462
|
+
--danger: #FF3B30;
|
|
463
|
+
--warning: #FFD60A;
|
|
464
|
+
--text: #F5F5F7;
|
|
465
|
+
--text-secondary: rgba(255,255,255,0.6);
|
|
466
|
+
--text-tertiary: rgba(255,255,255,0.35);
|
|
467
|
+
--font: -apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, sans-serif;
|
|
468
|
+
--mono: 'SF Mono', Menlo, Monaco, 'Courier New', monospace;
|
|
469
|
+
--radius-card: 16px;
|
|
470
|
+
--radius-btn: 12px;
|
|
471
|
+
--spring: cubic-bezier(0.32, 0.72, 0, 1);
|
|
472
|
+
--ease: cubic-bezier(0.25, 0.1, 0.25, 1);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
476
|
+
|
|
477
|
+
body {
|
|
478
|
+
background: var(--bg);
|
|
479
|
+
color: var(--text);
|
|
480
|
+
font-family: var(--font);
|
|
481
|
+
display: flex;
|
|
482
|
+
flex-direction: column;
|
|
483
|
+
height: 100vh;
|
|
484
|
+
overflow: hidden;
|
|
485
|
+
-webkit-font-smoothing: antialiased;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/* \u2500\u2500 Header \u2500\u2500 */
|
|
489
|
+
#header {
|
|
490
|
+
padding: 12px 20px;
|
|
491
|
+
display: flex;
|
|
492
|
+
align-items: center;
|
|
493
|
+
justify-content: space-between;
|
|
494
|
+
flex-shrink: 0;
|
|
495
|
+
border-bottom: 1px solid var(--border);
|
|
496
|
+
background: rgba(14,15,19,0.8);
|
|
497
|
+
backdrop-filter: blur(20px) saturate(180%);
|
|
498
|
+
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
|
499
|
+
z-index: 10;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
.header-left {
|
|
503
|
+
display: flex;
|
|
504
|
+
align-items: center;
|
|
505
|
+
gap: 8px;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
.logo {
|
|
509
|
+
font-size: 15px;
|
|
510
|
+
font-weight: 600;
|
|
511
|
+
letter-spacing: -0.3px;
|
|
512
|
+
color: var(--text);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
.logo-icon {
|
|
516
|
+
width: 20px;
|
|
517
|
+
height: 20px;
|
|
518
|
+
background: var(--primary);
|
|
519
|
+
border-radius: 6px;
|
|
520
|
+
display: flex;
|
|
521
|
+
align-items: center;
|
|
522
|
+
justify-content: center;
|
|
523
|
+
font-size: 11px;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
.header-center {
|
|
527
|
+
position: absolute;
|
|
528
|
+
left: 50%;
|
|
529
|
+
transform: translateX(-50%);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
#status {
|
|
533
|
+
display: inline-flex;
|
|
534
|
+
align-items: center;
|
|
535
|
+
gap: 6px;
|
|
536
|
+
font-size: 12px;
|
|
537
|
+
font-weight: 500;
|
|
538
|
+
padding: 4px 12px;
|
|
539
|
+
border-radius: 100px;
|
|
540
|
+
background: var(--surface);
|
|
541
|
+
border: 1px solid var(--border);
|
|
542
|
+
color: var(--text-secondary);
|
|
543
|
+
transition: all 0.3s var(--ease);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
.status-dot {
|
|
547
|
+
width: 6px;
|
|
548
|
+
height: 6px;
|
|
549
|
+
border-radius: 50%;
|
|
550
|
+
background: var(--warning);
|
|
551
|
+
transition: background 0.3s var(--ease);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
#status.connected .status-dot { background: var(--success); }
|
|
555
|
+
#status.connected { color: var(--success); border-color: rgba(52,199,89,0.2); background: rgba(52,199,89,0.08); }
|
|
556
|
+
#status.disconnected .status-dot { background: var(--danger); }
|
|
557
|
+
#status.disconnected { color: var(--danger); border-color: rgba(255,59,48,0.2); background: rgba(255,59,48,0.08); }
|
|
558
|
+
|
|
559
|
+
.header-right {
|
|
560
|
+
display: flex;
|
|
561
|
+
align-items: center;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
.badge-e2e {
|
|
565
|
+
display: inline-flex;
|
|
566
|
+
align-items: center;
|
|
567
|
+
gap: 5px;
|
|
568
|
+
font-size: 11px;
|
|
569
|
+
font-weight: 500;
|
|
570
|
+
padding: 4px 10px;
|
|
571
|
+
border-radius: 100px;
|
|
572
|
+
background: rgba(52,199,89,0.1);
|
|
573
|
+
border: 1px solid rgba(52,199,89,0.15);
|
|
574
|
+
color: var(--success);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
.badge-e2e svg { width: 12px; height: 12px; }
|
|
578
|
+
|
|
579
|
+
/* \u2500\u2500 Terminal Window \u2500\u2500 */
|
|
580
|
+
#wrap {
|
|
581
|
+
flex: 1;
|
|
582
|
+
overflow: hidden;
|
|
583
|
+
display: flex;
|
|
584
|
+
align-items: center;
|
|
585
|
+
justify-content: center;
|
|
586
|
+
padding: 16px;
|
|
587
|
+
position: relative;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
.terminal-window {
|
|
591
|
+
border-radius: var(--radius-card);
|
|
592
|
+
overflow: hidden;
|
|
593
|
+
box-shadow: 0 8px 40px rgba(0,0,0,0.5), 0 0 0 1px var(--border);
|
|
594
|
+
background: #1A1B23;
|
|
595
|
+
position: relative;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
.terminal-titlebar {
|
|
599
|
+
height: 38px;
|
|
600
|
+
background: rgba(255,255,255,0.04);
|
|
601
|
+
border-bottom: 1px solid var(--border);
|
|
602
|
+
display: flex;
|
|
603
|
+
align-items: center;
|
|
604
|
+
padding: 0 14px;
|
|
605
|
+
gap: 8px;
|
|
606
|
+
user-select: none;
|
|
607
|
+
-webkit-user-select: none;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
.titlebar-dots {
|
|
611
|
+
display: flex;
|
|
612
|
+
gap: 7px;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
.titlebar-dot {
|
|
616
|
+
width: 11px;
|
|
617
|
+
height: 11px;
|
|
618
|
+
border-radius: 50%;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
.titlebar-dot.red { background: #FF5F57; }
|
|
622
|
+
.titlebar-dot.yellow { background: #FEBC2E; }
|
|
623
|
+
.titlebar-dot.green { background: #28C840; }
|
|
624
|
+
|
|
625
|
+
.titlebar-title {
|
|
626
|
+
flex: 1;
|
|
627
|
+
text-align: center;
|
|
628
|
+
font-size: 12px;
|
|
629
|
+
font-weight: 500;
|
|
630
|
+
color: var(--text-tertiary);
|
|
631
|
+
letter-spacing: -0.2px;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
.titlebar-spacer { width: 52px; }
|
|
635
|
+
|
|
636
|
+
#terminal {
|
|
637
|
+
transform-origin: top left;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/* \u2500\u2500 Event Cards Container \u2500\u2500 */
|
|
641
|
+
#event-cards {
|
|
642
|
+
position: fixed;
|
|
643
|
+
bottom: 20px;
|
|
644
|
+
right: 20px;
|
|
645
|
+
display: flex;
|
|
646
|
+
flex-direction: column;
|
|
647
|
+
gap: 10px;
|
|
648
|
+
z-index: 100;
|
|
649
|
+
max-width: 420px;
|
|
650
|
+
width: calc(100% - 40px);
|
|
651
|
+
pointer-events: none;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/* \u2500\u2500 Event Card \u2500\u2500 */
|
|
655
|
+
.event-card {
|
|
656
|
+
background: rgba(30, 32, 42, 0.85);
|
|
657
|
+
backdrop-filter: blur(24px) saturate(180%);
|
|
658
|
+
-webkit-backdrop-filter: blur(24px) saturate(180%);
|
|
659
|
+
border: 1px solid var(--border);
|
|
660
|
+
border-radius: var(--radius-card);
|
|
661
|
+
padding: 16px;
|
|
662
|
+
animation: slideUp 0.5s var(--spring) forwards;
|
|
663
|
+
pointer-events: auto;
|
|
664
|
+
box-shadow: 0 8px 32px rgba(0,0,0,0.3), 0 0 0 1px rgba(255,255,255,0.05);
|
|
665
|
+
transition: border-color 0.2s var(--ease);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
.event-card:hover {
|
|
669
|
+
border-color: var(--border-hover);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
.event-card.removing {
|
|
673
|
+
animation: fadeOut 0.3s var(--ease) forwards;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
@keyframes slideUp {
|
|
677
|
+
from { transform: translateY(24px); opacity: 0; }
|
|
678
|
+
to { transform: translateY(0); opacity: 1; }
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
@keyframes fadeOut {
|
|
682
|
+
to { transform: translateY(12px); opacity: 0; }
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
.card-header {
|
|
686
|
+
display: flex;
|
|
687
|
+
align-items: center;
|
|
688
|
+
gap: 10px;
|
|
689
|
+
margin-bottom: 12px;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
.card-icon {
|
|
693
|
+
width: 32px;
|
|
694
|
+
height: 32px;
|
|
695
|
+
border-radius: 10px;
|
|
696
|
+
display: flex;
|
|
697
|
+
align-items: center;
|
|
698
|
+
justify-content: center;
|
|
699
|
+
font-size: 15px;
|
|
700
|
+
flex-shrink: 0;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
.card-icon.bash { background: rgba(49,130,246,0.15); color: var(--primary); }
|
|
704
|
+
.card-icon.file { background: rgba(168,130,246,0.15); color: #A882F6; }
|
|
705
|
+
.card-icon.stop { background: rgba(52,199,89,0.15); color: var(--success); }
|
|
706
|
+
.card-icon.info { background: rgba(49,130,246,0.15); color: var(--primary); }
|
|
707
|
+
.card-icon.warning { background: rgba(255,214,10,0.15); color: var(--warning); }
|
|
708
|
+
.card-icon.error { background: rgba(255,59,48,0.15); color: var(--danger); }
|
|
709
|
+
|
|
710
|
+
.card-title {
|
|
711
|
+
font-size: 13px;
|
|
712
|
+
font-weight: 600;
|
|
713
|
+
color: var(--text);
|
|
714
|
+
letter-spacing: -0.2px;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
.card-subtitle {
|
|
718
|
+
font-size: 11px;
|
|
719
|
+
color: var(--text-tertiary);
|
|
720
|
+
margin-top: 1px;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
.card-body {
|
|
724
|
+
margin-bottom: 14px;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
.card-code {
|
|
728
|
+
background: rgba(0,0,0,0.3);
|
|
729
|
+
border: 1px solid rgba(255,255,255,0.06);
|
|
730
|
+
border-radius: 10px;
|
|
731
|
+
padding: 10px 12px;
|
|
732
|
+
font-family: var(--mono);
|
|
733
|
+
font-size: 12px;
|
|
734
|
+
line-height: 1.5;
|
|
735
|
+
color: var(--text);
|
|
736
|
+
overflow-x: auto;
|
|
737
|
+
white-space: pre-wrap;
|
|
738
|
+
word-break: break-all;
|
|
739
|
+
max-height: 120px;
|
|
740
|
+
overflow-y: auto;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
.card-filepath {
|
|
744
|
+
background: rgba(0,0,0,0.3);
|
|
745
|
+
border: 1px solid rgba(255,255,255,0.06);
|
|
746
|
+
border-radius: 10px;
|
|
747
|
+
padding: 8px 12px;
|
|
748
|
+
font-family: var(--mono);
|
|
749
|
+
font-size: 12px;
|
|
750
|
+
color: #A882F6;
|
|
751
|
+
display: flex;
|
|
752
|
+
align-items: center;
|
|
753
|
+
gap: 6px;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
.card-message {
|
|
757
|
+
font-size: 13px;
|
|
758
|
+
color: var(--text-secondary);
|
|
759
|
+
line-height: 1.5;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/* \u2500\u2500 Scrollbar \u2500\u2500 */
|
|
763
|
+
::-webkit-scrollbar { width: 6px; }
|
|
764
|
+
::-webkit-scrollbar-track { background: transparent; }
|
|
765
|
+
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); border-radius: 3px; }
|
|
766
|
+
::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.25); }
|
|
767
|
+
</style>
|
|
768
|
+
</head><body>
|
|
769
|
+
|
|
770
|
+
<!-- Header -->
|
|
771
|
+
<div id="header">
|
|
772
|
+
<div class="header-left">
|
|
773
|
+
<div class="logo-icon">
|
|
774
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2.5" stroke-linecap="round">
|
|
775
|
+
<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>
|
|
776
|
+
</svg>
|
|
777
|
+
</div>
|
|
778
|
+
<span class="logo">Reley</span>
|
|
779
|
+
</div>
|
|
780
|
+
<div class="header-center">
|
|
781
|
+
<div id="status">
|
|
782
|
+
<span class="status-dot"></span>
|
|
783
|
+
<span id="st">Connecting...</span>
|
|
784
|
+
</div>
|
|
785
|
+
</div>
|
|
786
|
+
<div class="header-right">
|
|
787
|
+
<div class="badge-e2e">
|
|
788
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
789
|
+
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
|
790
|
+
</svg>
|
|
791
|
+
E2E Encrypted
|
|
792
|
+
</div>
|
|
793
|
+
</div>
|
|
794
|
+
</div>
|
|
795
|
+
|
|
796
|
+
<!-- Terminal -->
|
|
797
|
+
<div id="wrap">
|
|
798
|
+
<div class="terminal-window">
|
|
799
|
+
<div class="terminal-titlebar">
|
|
800
|
+
<div class="titlebar-dots">
|
|
801
|
+
<div class="titlebar-dot red"></div>
|
|
802
|
+
<div class="titlebar-dot yellow"></div>
|
|
803
|
+
<div class="titlebar-dot green"></div>
|
|
804
|
+
</div>
|
|
805
|
+
<div class="titlebar-title">Reley Terminal</div>
|
|
806
|
+
<div class="titlebar-spacer"></div>
|
|
807
|
+
</div>
|
|
808
|
+
<div id="terminal"></div>
|
|
809
|
+
</div>
|
|
810
|
+
</div>
|
|
811
|
+
|
|
812
|
+
<!-- Event Cards -->
|
|
813
|
+
<div id="event-cards"></div>
|
|
814
|
+
|
|
815
|
+
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
|
|
816
|
+
<script>
|
|
817
|
+
// \u2500\u2500 Terminal Setup \u2500\u2500
|
|
818
|
+
const term = new window.Terminal({
|
|
819
|
+
theme: {
|
|
820
|
+
background: '#1A1B23',
|
|
821
|
+
foreground: '#F5F5F7',
|
|
822
|
+
cursor: '#3182F6',
|
|
823
|
+
cursorAccent: '#1A1B23',
|
|
824
|
+
selectionBackground: '#3182F644',
|
|
825
|
+
black: '#1A1B23',
|
|
826
|
+
brightBlack: '#4A4B57',
|
|
827
|
+
},
|
|
828
|
+
fontSize: 14,
|
|
829
|
+
fontFamily: "'SF Mono', Menlo, Monaco, 'Courier New', monospace",
|
|
830
|
+
cursorBlink: true,
|
|
831
|
+
scrollback: 10000,
|
|
832
|
+
cols: 80,
|
|
833
|
+
rows: 24,
|
|
834
|
+
});
|
|
835
|
+
term.open(document.getElementById('terminal'));
|
|
836
|
+
|
|
837
|
+
// \u2500\u2500 CSS Transform Scaling \u2500\u2500
|
|
838
|
+
function fitScale() {
|
|
839
|
+
requestAnimationFrame(() => {
|
|
840
|
+
const wrap = document.getElementById('wrap');
|
|
841
|
+
const win = document.querySelector('.terminal-window');
|
|
842
|
+
const scr = document.querySelector('.xterm-screen');
|
|
843
|
+
if (!wrap || !scr || !win) return;
|
|
844
|
+
const sw = scr.offsetWidth;
|
|
845
|
+
const sh = scr.offsetHeight + 38; // titlebar height
|
|
846
|
+
if (!sw || !sh) return;
|
|
847
|
+
const pad = 32;
|
|
848
|
+
const maxW = wrap.clientWidth - pad;
|
|
849
|
+
const maxH = wrap.clientHeight - pad;
|
|
850
|
+
const scale = Math.min(maxW / sw, maxH / sh, 1.5);
|
|
851
|
+
win.style.transform = 'scale(' + scale + ')';
|
|
852
|
+
win.style.transformOrigin = 'center center';
|
|
853
|
+
});
|
|
854
|
+
}
|
|
855
|
+
window.addEventListener('resize', fitScale);
|
|
856
|
+
|
|
857
|
+
// \u2500\u2500 WebSocket \u2500\u2500
|
|
858
|
+
const statusEl = document.getElementById('status');
|
|
859
|
+
const st = document.getElementById('st');
|
|
860
|
+
const ws = new WebSocket('ws://' + location.host);
|
|
861
|
+
|
|
862
|
+
ws.onopen = () => {
|
|
863
|
+
statusEl.className = 'connected';
|
|
864
|
+
st.textContent = 'Connected';
|
|
865
|
+
term.focus();
|
|
866
|
+
};
|
|
867
|
+
|
|
868
|
+
ws.onmessage = (e) => {
|
|
869
|
+
const m = JSON.parse(e.data);
|
|
870
|
+
if (m.type === 'output') {
|
|
871
|
+
term.write(m.data);
|
|
872
|
+
} else if (m.type === 'sync_size') {
|
|
873
|
+
term.resize(m.cols, m.rows);
|
|
874
|
+
setTimeout(fitScale, 50);
|
|
875
|
+
} else if (m.type === 'hook_event') {
|
|
876
|
+
renderHookEvent(m);
|
|
877
|
+
}
|
|
878
|
+
};
|
|
879
|
+
|
|
880
|
+
ws.onclose = () => {
|
|
881
|
+
statusEl.className = 'disconnected';
|
|
882
|
+
st.textContent = 'Disconnected';
|
|
883
|
+
};
|
|
884
|
+
|
|
885
|
+
term.onData((d) => ws.send(JSON.stringify({ type: 'input', data: d })));
|
|
886
|
+
setTimeout(fitScale, 200);
|
|
887
|
+
|
|
888
|
+
// \u2500\u2500 Hook Event Cards \u2500\u2500
|
|
889
|
+
const cardsContainer = document.getElementById('event-cards');
|
|
890
|
+
|
|
891
|
+
function renderHookEvent(evt) {
|
|
892
|
+
const hookType = evt.hookType;
|
|
893
|
+
const data = evt.data || {};
|
|
894
|
+
const requestId = evt.requestId;
|
|
895
|
+
|
|
896
|
+
if (hookType === 'pre-tool-use') {
|
|
897
|
+
renderPreToolUseCard(data, requestId);
|
|
898
|
+
} else if (hookType === 'stop') {
|
|
899
|
+
renderStopCard(data);
|
|
900
|
+
} else if (hookType === 'notification') {
|
|
901
|
+
renderNotificationCard(data);
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
function renderPreToolUseCard(data, requestId) {
|
|
906
|
+
const toolName = (data.tool_name || '').toLowerCase();
|
|
907
|
+
const input = data.tool_input || {};
|
|
908
|
+
|
|
909
|
+
const card = document.createElement('div');
|
|
910
|
+
card.className = 'event-card';
|
|
911
|
+
card.dataset.requestId = requestId;
|
|
912
|
+
|
|
913
|
+
let iconClass, iconSvg, title, subtitle, bodyHtml;
|
|
914
|
+
|
|
915
|
+
if (toolName === 'bash') {
|
|
916
|
+
iconClass = 'bash';
|
|
917
|
+
iconSvg = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>';
|
|
918
|
+
title = 'Bash Command';
|
|
919
|
+
subtitle = 'Terminal execution request';
|
|
920
|
+
bodyHtml = '<div class="card-code">' + escapeHtml(input.command || '') + '</div>';
|
|
921
|
+
} else if (toolName === 'write' || toolName === 'edit') {
|
|
922
|
+
iconClass = 'file';
|
|
923
|
+
iconSvg = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>';
|
|
924
|
+
title = toolName === 'write' ? 'Write File' : 'Edit File';
|
|
925
|
+
subtitle = 'File modification request';
|
|
926
|
+
const fp = input.file_path || input.filePath || '';
|
|
927
|
+
bodyHtml = '<div class="card-filepath"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>' + escapeHtml(fp) + '</div>';
|
|
928
|
+
} else {
|
|
929
|
+
iconClass = 'bash';
|
|
930
|
+
iconSvg = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>';
|
|
931
|
+
title = 'Tool: ' + escapeHtml(data.tool_name || 'Unknown');
|
|
932
|
+
subtitle = 'Tool execution request';
|
|
933
|
+
bodyHtml = '<div class="card-code">' + escapeHtml(JSON.stringify(input, null, 2)) + '</div>';
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
card.innerHTML =
|
|
937
|
+
'<div class="card-header">' +
|
|
938
|
+
'<div class="card-icon ' + iconClass + '">' + iconSvg + '</div>' +
|
|
939
|
+
'<div><div class="card-title">' + title + '</div><div class="card-subtitle">' + subtitle + '</div></div>' +
|
|
940
|
+
'</div>' +
|
|
941
|
+
'<div class="card-body">' + bodyHtml + '</div>';
|
|
942
|
+
|
|
943
|
+
cardsContainer.appendChild(card);
|
|
944
|
+
card.addEventListener('click', () => removeCard(card));
|
|
945
|
+
setTimeout(() => removeCard(card), 8000);
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
function renderStopCard(data) {
|
|
949
|
+
const card = document.createElement('div');
|
|
950
|
+
card.className = 'event-card';
|
|
951
|
+
card.innerHTML =
|
|
952
|
+
'<div class="card-header">' +
|
|
953
|
+
'<div class="card-icon stop"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg></div>' +
|
|
954
|
+
'<div><div class="card-title">Task Complete</div><div class="card-subtitle">' + escapeHtml(data.stop_reason || 'Finished') + '</div></div>' +
|
|
955
|
+
'</div>' +
|
|
956
|
+
'<div class="card-body"><div class="card-message">' + escapeHtml(data.message || 'Claude has finished the task.') + '</div></div>';
|
|
957
|
+
|
|
958
|
+
cardsContainer.appendChild(card);
|
|
959
|
+
card.addEventListener('click', () => removeCard(card));
|
|
960
|
+
setTimeout(() => removeCard(card), 5000);
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
function renderNotificationCard(data) {
|
|
964
|
+
const level = (data.level || 'info').toLowerCase();
|
|
965
|
+
let iconClass = 'info';
|
|
966
|
+
let iconSvg = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>';
|
|
967
|
+
if (level === 'warning') {
|
|
968
|
+
iconClass = 'warning';
|
|
969
|
+
iconSvg = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>';
|
|
970
|
+
} else if (level === 'error') {
|
|
971
|
+
iconClass = 'error';
|
|
972
|
+
iconSvg = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>';
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
const card = document.createElement('div');
|
|
976
|
+
card.className = 'event-card';
|
|
977
|
+
card.innerHTML =
|
|
978
|
+
'<div class="card-header">' +
|
|
979
|
+
'<div class="card-icon ' + iconClass + '">' + iconSvg + '</div>' +
|
|
980
|
+
'<div><div class="card-title">' + escapeHtml(data.title || 'Notification') + '</div></div>' +
|
|
981
|
+
'</div>' +
|
|
982
|
+
'<div class="card-body"><div class="card-message">' + escapeHtml(data.message || '') + '</div></div>';
|
|
983
|
+
|
|
984
|
+
cardsContainer.appendChild(card);
|
|
985
|
+
card.addEventListener('click', () => removeCard(card));
|
|
986
|
+
setTimeout(() => removeCard(card), 5000);
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
function removeCard(card) {
|
|
990
|
+
if (card.classList.contains('removing')) return;
|
|
991
|
+
card.classList.add('removing');
|
|
992
|
+
card.addEventListener('animationend', () => card.remove());
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
function escapeHtml(str) {
|
|
996
|
+
const d = document.createElement('div');
|
|
997
|
+
d.textContent = str;
|
|
998
|
+
return d.innerHTML;
|
|
999
|
+
}
|
|
1000
|
+
</script>
|
|
1001
|
+
</body></html>`;
|
|
1002
|
+
}
|
|
1003
|
+
function getClaudeSettingsPath() {
|
|
1004
|
+
return join2(homedir2(), ".claude", "settings.json");
|
|
1005
|
+
}
|
|
1006
|
+
function createHookScript(sockPath) {
|
|
1007
|
+
const scriptPath = `/tmp/reley-hook-${process.pid}`;
|
|
1008
|
+
const script = `#!/usr/bin/env node
|
|
1009
|
+
const net = require('net');
|
|
1010
|
+
|
|
1011
|
+
// Session isolation: only run for the Reley session that set this env var.
|
|
1012
|
+
// Other Claude sessions won't have it, so they skip immediately.
|
|
1013
|
+
const sockPath = process.env.RELEY_HOOK_SOCK;
|
|
1014
|
+
if (!sockPath) process.exit(0);
|
|
1015
|
+
|
|
1016
|
+
const hookType = process.argv[2] || 'unknown';
|
|
1017
|
+
|
|
1018
|
+
// Read stdin (Claude Code sends JSON via stdin for hooks)
|
|
1019
|
+
let input = '';
|
|
1020
|
+
process.stdin.setEncoding('utf8');
|
|
1021
|
+
process.stdin.on('data', (c) => { input += c; });
|
|
1022
|
+
process.stdin.on('end', () => {
|
|
1023
|
+
const payload = JSON.stringify({ hookType, data: tryParse(input) }) + '\\n';
|
|
1024
|
+
const client = net.createConnection(sockPath, () => {
|
|
1025
|
+
client.write(payload);
|
|
1026
|
+
});
|
|
1027
|
+
let resp = '';
|
|
1028
|
+
client.on('data', (d) => { resp += d.toString(); });
|
|
1029
|
+
client.on('end', () => {
|
|
1030
|
+
process.exit(0);
|
|
1031
|
+
});
|
|
1032
|
+
client.on('error', () => {
|
|
1033
|
+
// Socket not available, approve by default
|
|
1034
|
+
process.exit(0);
|
|
1035
|
+
});
|
|
1036
|
+
setTimeout(() => process.exit(0), 30000);
|
|
1037
|
+
});
|
|
1038
|
+
|
|
1039
|
+
function tryParse(s) {
|
|
1040
|
+
try { return JSON.parse(s); } catch { return { raw: s }; }
|
|
1041
|
+
}
|
|
1042
|
+
`;
|
|
1043
|
+
writeFileSync2(scriptPath, script, { mode: 493 });
|
|
1044
|
+
return scriptPath;
|
|
1045
|
+
}
|
|
1046
|
+
function installClaudeHooks(hookScriptPath) {
|
|
1047
|
+
const settingsPath = getClaudeSettingsPath();
|
|
1048
|
+
let settings = {};
|
|
1049
|
+
const result = {
|
|
1050
|
+
path: settingsPath,
|
|
1051
|
+
hadHooks: false,
|
|
1052
|
+
originalHooks: void 0
|
|
1053
|
+
};
|
|
1054
|
+
try {
|
|
1055
|
+
if (existsSync2(settingsPath)) {
|
|
1056
|
+
settings = JSON.parse(readFileSync2(settingsPath, "utf-8"));
|
|
1057
|
+
}
|
|
1058
|
+
} catch {
|
|
1059
|
+
settings = {};
|
|
1060
|
+
}
|
|
1061
|
+
if (settings.hooks) {
|
|
1062
|
+
result.hadHooks = true;
|
|
1063
|
+
result.originalHooks = JSON.parse(JSON.stringify(settings.hooks));
|
|
1064
|
+
}
|
|
1065
|
+
if (!settings.hooks) settings.hooks = {};
|
|
1066
|
+
const cbCommand = (type) => `${hookScriptPath} ${type}`;
|
|
1067
|
+
const hookDefs = {
|
|
1068
|
+
PreToolUse: [
|
|
1069
|
+
{ matcher: "Bash", type: "pre-tool-use" },
|
|
1070
|
+
{ matcher: "Write", type: "pre-tool-use" },
|
|
1071
|
+
{ matcher: "Edit", type: "pre-tool-use" }
|
|
1072
|
+
],
|
|
1073
|
+
Stop: [
|
|
1074
|
+
{ matcher: "", type: "stop" }
|
|
1075
|
+
],
|
|
1076
|
+
Notification: [
|
|
1077
|
+
{ matcher: "", type: "notification" }
|
|
1078
|
+
]
|
|
1079
|
+
};
|
|
1080
|
+
for (const [event, entries] of Object.entries(hookDefs)) {
|
|
1081
|
+
if (!settings.hooks[event]) settings.hooks[event] = [];
|
|
1082
|
+
for (const entry of entries) {
|
|
1083
|
+
const cmd = cbCommand(entry.type);
|
|
1084
|
+
const exists = settings.hooks[event].some(
|
|
1085
|
+
(h) => Array.isArray(h.hooks) && h.hooks.some((hk) => hk.command === cmd)
|
|
1086
|
+
);
|
|
1087
|
+
if (!exists) {
|
|
1088
|
+
const hookEntry = {
|
|
1089
|
+
matcher: entry.matcher,
|
|
1090
|
+
hooks: [{ type: "command", command: cmd }]
|
|
1091
|
+
};
|
|
1092
|
+
settings.hooks[event].push(hookEntry);
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
writeFileSync2(settingsPath, JSON.stringify(settings, null, 2), "utf-8");
|
|
1097
|
+
return result;
|
|
1098
|
+
}
|
|
1099
|
+
function uninstallClaudeHooks(original, hookScriptPath) {
|
|
1100
|
+
try {
|
|
1101
|
+
const settingsPath = original.path;
|
|
1102
|
+
if (!existsSync2(settingsPath)) return;
|
|
1103
|
+
const settings = JSON.parse(readFileSync2(settingsPath, "utf-8"));
|
|
1104
|
+
if (!settings.hooks) return;
|
|
1105
|
+
for (const event of Object.keys(settings.hooks)) {
|
|
1106
|
+
if (Array.isArray(settings.hooks[event])) {
|
|
1107
|
+
settings.hooks[event] = settings.hooks[event].filter(
|
|
1108
|
+
(h) => !Array.isArray(h.hooks) || !h.hooks.some((hk) => hk.command?.includes(hookScriptPath))
|
|
1109
|
+
);
|
|
1110
|
+
if (settings.hooks[event].length === 0) {
|
|
1111
|
+
delete settings.hooks[event];
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
if (Object.keys(settings.hooks).length === 0) {
|
|
1116
|
+
delete settings.hooks;
|
|
1117
|
+
}
|
|
1118
|
+
writeFileSync2(settingsPath, JSON.stringify(settings, null, 2), "utf-8");
|
|
1119
|
+
} catch {
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
async function runCommand(commandArgs, options) {
|
|
1123
|
+
if (options.online) {
|
|
1124
|
+
return runOnlineCommand(commandArgs, options);
|
|
1125
|
+
}
|
|
1126
|
+
const webPort = parseInt(options.webPort, 10);
|
|
1127
|
+
const releyPort = parseInt(options.releyPort, 10);
|
|
1128
|
+
const releyUrl = `http://localhost:${releyPort}`;
|
|
1129
|
+
const wsReleyUrl = `ws://localhost:${releyPort}/ws`;
|
|
1130
|
+
const command = commandArgs.length > 0 ? commandArgs[0] : process.env.SHELL || "/bin/zsh";
|
|
1131
|
+
const args = commandArgs.length > 1 ? commandArgs.slice(1) : [];
|
|
1132
|
+
console.error("\n\x1B[1m Reley\x1B[0m");
|
|
1133
|
+
console.error(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n");
|
|
1134
|
+
let releyProc = null;
|
|
1135
|
+
if (await isReleyRunning(releyUrl)) {
|
|
1136
|
+
log("RELEY", `Already running on :${releyPort}`);
|
|
1137
|
+
} else {
|
|
1138
|
+
log("RELEY", `Starting on :${releyPort}...`);
|
|
1139
|
+
const releyPath = findReleyServer();
|
|
1140
|
+
releyProc = fork(releyPath, [], {
|
|
1141
|
+
env: { ...process.env, PORT: String(releyPort), LOG_LEVEL: "warn" },
|
|
1142
|
+
stdio: "pipe"
|
|
1143
|
+
});
|
|
1144
|
+
await waitForReley(releyUrl);
|
|
1145
|
+
log("RELEY", "Ready");
|
|
1146
|
+
}
|
|
1147
|
+
const sodium2 = await ensureSodium();
|
|
1148
|
+
const cliKeys = await generateEphemeralKeyPair();
|
|
1149
|
+
const viewerKeys = await generateEphemeralKeyPair();
|
|
1150
|
+
const otc = sodium2.randombytes_buf(32);
|
|
1151
|
+
const otcHash = crypto.createHash("sha256").update(Buffer.from(otc)).digest();
|
|
1152
|
+
const initRes = await fetch(`${releyUrl}/api/v1/pair/initiate`, {
|
|
1153
|
+
method: "POST",
|
|
1154
|
+
headers: { "Content-Type": "application/json" },
|
|
1155
|
+
body: JSON.stringify({
|
|
1156
|
+
publicKey: sodium2.to_base64(cliKeys.publicKey, sodium2.base64_variants.URLSAFE_NO_PADDING),
|
|
1157
|
+
otcHash: Buffer.from(otcHash).toString("base64"),
|
|
1158
|
+
deviceId: crypto.randomUUID()
|
|
1159
|
+
})
|
|
1160
|
+
});
|
|
1161
|
+
const { roomId, token: cliToken } = await initRes.json();
|
|
1162
|
+
const compRes = await fetch(`${releyUrl}/api/v1/pair/complete`, {
|
|
1163
|
+
method: "POST",
|
|
1164
|
+
headers: { "Content-Type": "application/json" },
|
|
1165
|
+
body: JSON.stringify({
|
|
1166
|
+
roomId,
|
|
1167
|
+
otc: Buffer.from(otc).toString("base64"),
|
|
1168
|
+
publicKey: sodium2.to_base64(viewerKeys.publicKey, sodium2.base64_variants.URLSAFE_NO_PADDING),
|
|
1169
|
+
deviceId: crypto.randomUUID()
|
|
1170
|
+
})
|
|
1171
|
+
});
|
|
1172
|
+
const { token: viewerToken } = await compRes.json();
|
|
1173
|
+
log("CRYPTO", "E2E ready");
|
|
1174
|
+
const cliShared = await computeSharedSecret(cliKeys.secretKey, viewerKeys.publicKey);
|
|
1175
|
+
const viewerShared = await computeSharedSecret(viewerKeys.secretKey, cliKeys.publicKey);
|
|
1176
|
+
const cliSessionKeys = await deriveSessionKeys(cliShared);
|
|
1177
|
+
const viewerSessionKeys = await deriveSessionKeys(viewerShared);
|
|
1178
|
+
let cliRatchet = initRatchet(cliSessionKeys.sendKey, cliSessionKeys.recvKey);
|
|
1179
|
+
let viewerRatchet = initRatchet(viewerSessionKeys.recvKey, viewerSessionKeys.sendKey);
|
|
1180
|
+
const cliWs = new WebSocket(wsReleyUrl, { headers: { Authorization: `Bearer ${cliToken}` } });
|
|
1181
|
+
await new Promise((res, rej) => {
|
|
1182
|
+
cliWs.on("open", res);
|
|
1183
|
+
cliWs.on("error", rej);
|
|
1184
|
+
});
|
|
1185
|
+
const viewerWs = new WebSocket(wsReleyUrl, { headers: { Authorization: `Bearer ${viewerToken}` } });
|
|
1186
|
+
await new Promise((res, rej) => {
|
|
1187
|
+
viewerWs.on("open", res);
|
|
1188
|
+
viewerWs.on("error", rej);
|
|
1189
|
+
});
|
|
1190
|
+
const webWss = new WebSocketServer({ noServer: true });
|
|
1191
|
+
const webClients = /* @__PURE__ */ new Set();
|
|
1192
|
+
let ptyCols = process.stdout.columns || 80;
|
|
1193
|
+
let ptyRows = process.stdout.rows || 24;
|
|
1194
|
+
const hookSockPath = `/tmp/reley-hooks-${process.pid}.sock`;
|
|
1195
|
+
try {
|
|
1196
|
+
unlinkSync(hookSockPath);
|
|
1197
|
+
} catch {
|
|
1198
|
+
}
|
|
1199
|
+
const hookServer = createNetServer((socket) => {
|
|
1200
|
+
let buf = "";
|
|
1201
|
+
socket.on("data", (chunk) => {
|
|
1202
|
+
buf += chunk.toString();
|
|
1203
|
+
const nl = buf.indexOf("\n");
|
|
1204
|
+
if (nl === -1) return;
|
|
1205
|
+
const line = buf.substring(0, nl);
|
|
1206
|
+
buf = buf.substring(nl + 1);
|
|
1207
|
+
let json;
|
|
1208
|
+
try {
|
|
1209
|
+
json = JSON.parse(line);
|
|
1210
|
+
} catch {
|
|
1211
|
+
socket.end(JSON.stringify({ action: "approve" }));
|
|
1212
|
+
return;
|
|
1213
|
+
}
|
|
1214
|
+
const requestId = `hook-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
1215
|
+
const payload = JSON.stringify({
|
|
1216
|
+
type: "hook_event",
|
|
1217
|
+
hookType: json.hookType,
|
|
1218
|
+
data: json.data,
|
|
1219
|
+
requestId
|
|
1220
|
+
});
|
|
1221
|
+
for (const c of webClients) {
|
|
1222
|
+
if (c.readyState === WebSocket.OPEN) c.send(payload);
|
|
1223
|
+
}
|
|
1224
|
+
socket.end(JSON.stringify({ action: "approve" }));
|
|
1225
|
+
});
|
|
1226
|
+
});
|
|
1227
|
+
await new Promise((resolve2) => {
|
|
1228
|
+
hookServer.listen(hookSockPath, () => resolve2());
|
|
1229
|
+
});
|
|
1230
|
+
log("HOOKS", `Socket at ${hookSockPath}`);
|
|
1231
|
+
let hookScriptPath = null;
|
|
1232
|
+
let originalSettings = null;
|
|
1233
|
+
const isClaudeCommand = command === "claude" || command.endsWith("/claude");
|
|
1234
|
+
if (isClaudeCommand) {
|
|
1235
|
+
hookScriptPath = createHookScript(hookSockPath);
|
|
1236
|
+
originalSettings = installClaudeHooks(hookScriptPath);
|
|
1237
|
+
log("HOOKS", "Claude hooks installed");
|
|
1238
|
+
}
|
|
1239
|
+
viewerWs.on("message", async (raw) => {
|
|
1240
|
+
if (!Buffer.isBuffer(raw)) return;
|
|
1241
|
+
try {
|
|
1242
|
+
const env = decodeEnvelope(new Uint8Array(raw));
|
|
1243
|
+
const dec = await ratchetDecrypt(viewerRatchet, env.ciphertext, env.nonce, env.counter);
|
|
1244
|
+
viewerRatchet = dec.state;
|
|
1245
|
+
const msg = deserializeMessage(dec.plaintext);
|
|
1246
|
+
if (msg.type === "terminal_data") {
|
|
1247
|
+
const text = Buffer.from(msg.data, "base64").toString();
|
|
1248
|
+
const payload = JSON.stringify({ type: "output", data: text });
|
|
1249
|
+
for (const c of webClients) {
|
|
1250
|
+
if (c.readyState === WebSocket.OPEN) c.send(payload);
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
} catch {
|
|
1254
|
+
}
|
|
1255
|
+
});
|
|
1256
|
+
webWss.on("connection", (ws) => {
|
|
1257
|
+
webClients.add(ws);
|
|
1258
|
+
ws.send(JSON.stringify({ type: "sync_size", cols: ptyCols, rows: ptyRows }));
|
|
1259
|
+
ws.on("message", async (raw) => {
|
|
1260
|
+
try {
|
|
1261
|
+
const msg = JSON.parse(raw.toString());
|
|
1262
|
+
if (msg.type === "input") {
|
|
1263
|
+
pty2.write(msg.data);
|
|
1264
|
+
}
|
|
1265
|
+
} catch {
|
|
1266
|
+
}
|
|
1267
|
+
});
|
|
1268
|
+
ws.on("close", () => {
|
|
1269
|
+
webClients.delete(ws);
|
|
1270
|
+
});
|
|
1271
|
+
});
|
|
1272
|
+
const httpServer = createHttpServer((req, res) => {
|
|
1273
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
1274
|
+
res.end(getTerminalHtml());
|
|
1275
|
+
});
|
|
1276
|
+
httpServer.on("upgrade", (req, socket, head) => {
|
|
1277
|
+
webWss.handleUpgrade(req, socket, head, (ws) => {
|
|
1278
|
+
webWss.emit("connection", ws, req);
|
|
1279
|
+
});
|
|
1280
|
+
});
|
|
1281
|
+
await new Promise((resolve2) => {
|
|
1282
|
+
httpServer.listen(webPort, () => resolve2());
|
|
1283
|
+
});
|
|
1284
|
+
log("WEB", `http://localhost:${webPort}`);
|
|
1285
|
+
console.error(`
|
|
1286
|
+
\x1B[1m\x1B[36m\u2192 http://localhost:${webPort}\x1B[0m \uC6F9\uC5D0\uC11C\uB3C4 \uD655\uC778/\uC785\uB825 \uAC00\uB2A5
|
|
1287
|
+
`);
|
|
1288
|
+
loggingEnabled = false;
|
|
1289
|
+
const ptyEnv = {
|
|
1290
|
+
...process.env
|
|
1291
|
+
};
|
|
1292
|
+
if (hookSockPath) {
|
|
1293
|
+
ptyEnv.RELEY_HOOK_SOCK = hookSockPath;
|
|
1294
|
+
}
|
|
1295
|
+
const pty2 = spawn(command, args, {
|
|
1296
|
+
name: "xterm-256color",
|
|
1297
|
+
cols: ptyCols,
|
|
1298
|
+
rows: ptyRows,
|
|
1299
|
+
cwd: process.cwd(),
|
|
1300
|
+
env: ptyEnv
|
|
1301
|
+
});
|
|
1302
|
+
pty2.onData(async (data) => {
|
|
1303
|
+
process.stdout.write(data);
|
|
1304
|
+
try {
|
|
1305
|
+
const msg = {
|
|
1306
|
+
type: "terminal_data",
|
|
1307
|
+
data: Buffer.from(data).toString("base64")
|
|
1308
|
+
};
|
|
1309
|
+
const plain = serializeMessage(msg);
|
|
1310
|
+
const enc = await ratchetEncrypt(cliRatchet, plain);
|
|
1311
|
+
cliRatchet = enc.state;
|
|
1312
|
+
const envelope = encodeEnvelope({
|
|
1313
|
+
version: PROTOCOL_VERSION,
|
|
1314
|
+
type: getWireType("terminal_data"),
|
|
1315
|
+
counter: enc.counter,
|
|
1316
|
+
nonce: enc.nonce,
|
|
1317
|
+
ciphertext: enc.ciphertext
|
|
1318
|
+
});
|
|
1319
|
+
if (cliWs.readyState === WebSocket.OPEN) {
|
|
1320
|
+
cliWs.send(Buffer.from(envelope));
|
|
1321
|
+
}
|
|
1322
|
+
} catch {
|
|
1323
|
+
}
|
|
1324
|
+
});
|
|
1325
|
+
if (process.stdin.isTTY) {
|
|
1326
|
+
process.stdin.setRawMode(true);
|
|
1327
|
+
}
|
|
1328
|
+
process.stdin.resume();
|
|
1329
|
+
process.stdin.on("data", (data) => {
|
|
1330
|
+
pty2.write(data.toString());
|
|
1331
|
+
});
|
|
1332
|
+
process.stdout.on("resize", () => {
|
|
1333
|
+
ptyCols = process.stdout.columns || 80;
|
|
1334
|
+
ptyRows = process.stdout.rows || 24;
|
|
1335
|
+
pty2.resize(ptyCols, ptyRows);
|
|
1336
|
+
const sizeMsg = JSON.stringify({ type: "sync_size", cols: ptyCols, rows: ptyRows });
|
|
1337
|
+
for (const c of webClients) {
|
|
1338
|
+
if (c.readyState === WebSocket.OPEN) c.send(sizeMsg);
|
|
1339
|
+
}
|
|
1340
|
+
});
|
|
1341
|
+
const cleanup = () => {
|
|
1342
|
+
hookServer.close();
|
|
1343
|
+
try {
|
|
1344
|
+
unlinkSync(hookSockPath);
|
|
1345
|
+
} catch {
|
|
1346
|
+
}
|
|
1347
|
+
if (hookScriptPath) {
|
|
1348
|
+
try {
|
|
1349
|
+
unlinkSync(hookScriptPath);
|
|
1350
|
+
} catch {
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
if (originalSettings && hookScriptPath) {
|
|
1354
|
+
uninstallClaudeHooks(originalSettings, hookScriptPath);
|
|
1355
|
+
}
|
|
1356
|
+
cliWs.close();
|
|
1357
|
+
viewerWs.close();
|
|
1358
|
+
httpServer.close();
|
|
1359
|
+
if (releyProc) releyProc.kill();
|
|
1360
|
+
};
|
|
1361
|
+
pty2.onExit(() => {
|
|
1362
|
+
cleanup();
|
|
1363
|
+
process.exit(0);
|
|
1364
|
+
});
|
|
1365
|
+
process.on("SIGINT", () => pty2.kill());
|
|
1366
|
+
process.on("SIGTERM", () => pty2.kill());
|
|
1367
|
+
}
|
|
1368
|
+
async function runOnlineCommand(commandArgs, options) {
|
|
1369
|
+
const { loadConfig: loadConfig2 } = await Promise.resolve().then(() => (init_config(), config_exports));
|
|
1370
|
+
const config = loadConfig2();
|
|
1371
|
+
if (!config?.device_token || !config?.reley_url) {
|
|
1372
|
+
console.error("\x1B[31mError:\x1B[0m Not logged in. Run `reley login` first.");
|
|
1373
|
+
process.exit(1);
|
|
1374
|
+
}
|
|
1375
|
+
const command = commandArgs.length > 0 ? commandArgs[0] : process.env.SHELL || "/bin/zsh";
|
|
1376
|
+
const args = commandArgs.length > 1 ? commandArgs.slice(1) : [];
|
|
1377
|
+
console.error("\n\x1B[1m Reley\x1B[0m \x1B[36m(online)\x1B[0m");
|
|
1378
|
+
console.error(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n");
|
|
1379
|
+
console.error(` \x1B[2mUser: ${config.user_email}\x1B[0m`);
|
|
1380
|
+
console.error(` \x1B[2mServer: ${config.reley_url}\x1B[0m
|
|
1381
|
+
`);
|
|
1382
|
+
const sodium2 = await ensureSodium();
|
|
1383
|
+
const cliKeys = await generateEphemeralKeyPair();
|
|
1384
|
+
const cliPkB64 = sodium2.to_base64(cliKeys.publicKey, sodium2.base64_variants.URLSAFE_NO_PADDING);
|
|
1385
|
+
log("RELEY", "Creating session...");
|
|
1386
|
+
const sessionName = commandArgs.length > 0 ? commandArgs.join(" ") : "Terminal session";
|
|
1387
|
+
const res = await fetch(`${config.reley_url}/api/v1/sessions`, {
|
|
1388
|
+
method: "POST",
|
|
1389
|
+
headers: {
|
|
1390
|
+
"Content-Type": "application/json",
|
|
1391
|
+
Authorization: `Bearer ${config.device_token}`
|
|
1392
|
+
},
|
|
1393
|
+
body: JSON.stringify({ name: sessionName, publicKey: cliPkB64 })
|
|
1394
|
+
});
|
|
1395
|
+
if (!res.ok) {
|
|
1396
|
+
const body = await res.json().catch(() => ({}));
|
|
1397
|
+
console.error(`\x1B[31mError:\x1B[0m Failed to create session: ${body.error || res.statusText}`);
|
|
1398
|
+
process.exit(1);
|
|
1399
|
+
}
|
|
1400
|
+
const { roomId, cliToken, sessionId } = await res.json();
|
|
1401
|
+
log("RELEY", `Session: ${sessionId}`);
|
|
1402
|
+
log("RELEY", `Room: ${roomId}`);
|
|
1403
|
+
const wsProtocol = config.reley_url.startsWith("https") ? "wss" : "ws";
|
|
1404
|
+
const wsHost = config.reley_url.replace(/^https?:\/\//, "");
|
|
1405
|
+
const wsUrl = `${wsProtocol}://${wsHost}/ws`;
|
|
1406
|
+
const cliWs = new WebSocket(wsUrl, { headers: { Authorization: `Bearer ${cliToken}` } });
|
|
1407
|
+
await new Promise((resolve2, reject) => {
|
|
1408
|
+
cliWs.on("open", resolve2);
|
|
1409
|
+
cliWs.on("error", reject);
|
|
1410
|
+
});
|
|
1411
|
+
log("RELEY", "WebSocket connected");
|
|
1412
|
+
let ratchetState = null;
|
|
1413
|
+
let e2eReady = false;
|
|
1414
|
+
const pendingData = [];
|
|
1415
|
+
let ratchetQueue = Promise.resolve();
|
|
1416
|
+
function withRatchet(fn) {
|
|
1417
|
+
const p = ratchetQueue.then(fn, () => fn());
|
|
1418
|
+
ratchetQueue = p.then(() => {
|
|
1419
|
+
}, () => {
|
|
1420
|
+
});
|
|
1421
|
+
return p;
|
|
1422
|
+
}
|
|
1423
|
+
const SCROLLBACK_MAX = 100 * 1024;
|
|
1424
|
+
let scrollbackBuf = Buffer.alloc(0);
|
|
1425
|
+
function appendScrollback(data) {
|
|
1426
|
+
scrollbackBuf = Buffer.concat([scrollbackBuf, data]);
|
|
1427
|
+
if (scrollbackBuf.length > SCROLLBACK_MAX) {
|
|
1428
|
+
scrollbackBuf = scrollbackBuf.slice(scrollbackBuf.length - SCROLLBACK_MAX);
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
async function initE2E(viewerPkB64) {
|
|
1432
|
+
const viewerPk = sodium2.from_base64(viewerPkB64, sodium2.base64_variants.URLSAFE_NO_PADDING);
|
|
1433
|
+
const shared = await computeSharedSecret(cliKeys.secretKey, viewerPk);
|
|
1434
|
+
const sessionKeys = await deriveSessionKeys(shared);
|
|
1435
|
+
ratchetState = initRatchet(sessionKeys.sendKey, sessionKeys.recvKey);
|
|
1436
|
+
const fp = computeFingerprint(sodium2, cliKeys.publicKey, viewerPk);
|
|
1437
|
+
console.error(` \x1B[33m\u{1F510} E2E Fingerprint: ${fp}\x1B[0m`);
|
|
1438
|
+
sodium2.memzero(shared);
|
|
1439
|
+
if (cliWs.readyState === WebSocket.OPEN) {
|
|
1440
|
+
cliWs.send(JSON.stringify({
|
|
1441
|
+
type: "key_exchange",
|
|
1442
|
+
publicKey: cliPkB64,
|
|
1443
|
+
role: "cli"
|
|
1444
|
+
}));
|
|
1445
|
+
}
|
|
1446
|
+
await withRatchet(async () => {
|
|
1447
|
+
if (!ratchetState) return;
|
|
1448
|
+
if (scrollbackBuf.length > 0) {
|
|
1449
|
+
const msg = {
|
|
1450
|
+
type: "terminal_data",
|
|
1451
|
+
data: scrollbackBuf.toString("base64")
|
|
1452
|
+
};
|
|
1453
|
+
const plain = serializeMessage(msg);
|
|
1454
|
+
const enc = await ratchetEncrypt(ratchetState, plain);
|
|
1455
|
+
ratchetState = enc.state;
|
|
1456
|
+
const envelope = encodeEnvelope({
|
|
1457
|
+
version: PROTOCOL_VERSION,
|
|
1458
|
+
type: getWireType("terminal_data"),
|
|
1459
|
+
counter: enc.counter,
|
|
1460
|
+
nonce: enc.nonce,
|
|
1461
|
+
ciphertext: enc.ciphertext
|
|
1462
|
+
});
|
|
1463
|
+
if (cliWs.readyState === WebSocket.OPEN) {
|
|
1464
|
+
cliWs.send(Buffer.from(envelope));
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
});
|
|
1468
|
+
e2eReady = true;
|
|
1469
|
+
pendingData.length = 0;
|
|
1470
|
+
}
|
|
1471
|
+
function sendEncrypted(data) {
|
|
1472
|
+
if (!ratchetState || !e2eReady) {
|
|
1473
|
+
pendingData.push(data);
|
|
1474
|
+
return;
|
|
1475
|
+
}
|
|
1476
|
+
withRatchet(async () => {
|
|
1477
|
+
if (!ratchetState || cliWs.readyState !== WebSocket.OPEN) return;
|
|
1478
|
+
try {
|
|
1479
|
+
const msg = {
|
|
1480
|
+
type: "terminal_data",
|
|
1481
|
+
data: data.toString("base64")
|
|
1482
|
+
};
|
|
1483
|
+
const plain = serializeMessage(msg);
|
|
1484
|
+
const enc = await ratchetEncrypt(ratchetState, plain);
|
|
1485
|
+
ratchetState = enc.state;
|
|
1486
|
+
const envelope = encodeEnvelope({
|
|
1487
|
+
version: PROTOCOL_VERSION,
|
|
1488
|
+
type: getWireType("terminal_data"),
|
|
1489
|
+
counter: enc.counter,
|
|
1490
|
+
nonce: enc.nonce,
|
|
1491
|
+
ciphertext: enc.ciphertext
|
|
1492
|
+
});
|
|
1493
|
+
cliWs.send(Buffer.from(envelope));
|
|
1494
|
+
} catch {
|
|
1495
|
+
}
|
|
1496
|
+
});
|
|
1497
|
+
}
|
|
1498
|
+
const hookSockPath = `/tmp/reley-hooks-${process.pid}.sock`;
|
|
1499
|
+
try {
|
|
1500
|
+
unlinkSync(hookSockPath);
|
|
1501
|
+
} catch {
|
|
1502
|
+
}
|
|
1503
|
+
const hookServer = createNetServer((socket) => {
|
|
1504
|
+
let buf = "";
|
|
1505
|
+
socket.on("data", (chunk) => {
|
|
1506
|
+
buf += chunk.toString();
|
|
1507
|
+
const nl = buf.indexOf("\n");
|
|
1508
|
+
if (nl === -1) return;
|
|
1509
|
+
const line = buf.substring(0, nl);
|
|
1510
|
+
buf = buf.substring(nl + 1);
|
|
1511
|
+
let json;
|
|
1512
|
+
try {
|
|
1513
|
+
json = JSON.parse(line);
|
|
1514
|
+
} catch {
|
|
1515
|
+
socket.end(JSON.stringify({ action: "approve" }));
|
|
1516
|
+
return;
|
|
1517
|
+
}
|
|
1518
|
+
if (e2eReady && ratchetState && cliWs.readyState === WebSocket.OPEN) {
|
|
1519
|
+
withRatchet(async () => {
|
|
1520
|
+
if (!ratchetState || cliWs.readyState !== WebSocket.OPEN) return;
|
|
1521
|
+
const hookMsg = {
|
|
1522
|
+
type: "hook_event",
|
|
1523
|
+
hookType: json.hookType === "pre-tool-use" ? "pre_tool_use" : json.hookType,
|
|
1524
|
+
sessionId,
|
|
1525
|
+
payload: json.data ?? { kind: json.hookType, raw: true }
|
|
1526
|
+
};
|
|
1527
|
+
const plain = serializeMessage(hookMsg);
|
|
1528
|
+
const enc = await ratchetEncrypt(ratchetState, plain);
|
|
1529
|
+
ratchetState = enc.state;
|
|
1530
|
+
const envelope = encodeEnvelope({
|
|
1531
|
+
version: PROTOCOL_VERSION,
|
|
1532
|
+
type: getWireType("hook_event"),
|
|
1533
|
+
counter: enc.counter,
|
|
1534
|
+
nonce: enc.nonce,
|
|
1535
|
+
ciphertext: enc.ciphertext
|
|
1536
|
+
});
|
|
1537
|
+
cliWs.send(Buffer.from(envelope));
|
|
1538
|
+
});
|
|
1539
|
+
} else if (cliWs.readyState === WebSocket.OPEN) {
|
|
1540
|
+
cliWs.send(JSON.stringify({
|
|
1541
|
+
type: "hook_event",
|
|
1542
|
+
hookType: json.hookType,
|
|
1543
|
+
data: json.data
|
|
1544
|
+
}));
|
|
1545
|
+
}
|
|
1546
|
+
socket.end(JSON.stringify({ action: "approve" }));
|
|
1547
|
+
});
|
|
1548
|
+
});
|
|
1549
|
+
await new Promise((resolve2) => {
|
|
1550
|
+
hookServer.listen(hookSockPath, () => resolve2());
|
|
1551
|
+
});
|
|
1552
|
+
log("HOOKS", `Socket at ${hookSockPath}`);
|
|
1553
|
+
let hookScriptPath = null;
|
|
1554
|
+
let originalSettings = null;
|
|
1555
|
+
const isClaudeCommand = command === "claude" || command.endsWith("/claude");
|
|
1556
|
+
if (isClaudeCommand) {
|
|
1557
|
+
hookScriptPath = createHookScript(hookSockPath);
|
|
1558
|
+
originalSettings = installClaudeHooks(hookScriptPath);
|
|
1559
|
+
log("HOOKS", "Claude hooks installed");
|
|
1560
|
+
}
|
|
1561
|
+
let ptyCols = process.stdout.columns || 80;
|
|
1562
|
+
let ptyRows = process.stdout.rows || 24;
|
|
1563
|
+
console.error(`
|
|
1564
|
+
\x1B[1m\x1B[36m\u2192 View in web dashboard\x1B[0m
|
|
1565
|
+
`);
|
|
1566
|
+
loggingEnabled = false;
|
|
1567
|
+
const ptyEnv = {
|
|
1568
|
+
...process.env
|
|
1569
|
+
};
|
|
1570
|
+
if (hookSockPath) {
|
|
1571
|
+
ptyEnv.RELEY_HOOK_SOCK = hookSockPath;
|
|
1572
|
+
}
|
|
1573
|
+
const pty2 = spawn(command, args, {
|
|
1574
|
+
name: "xterm-256color",
|
|
1575
|
+
cols: ptyCols,
|
|
1576
|
+
rows: ptyRows,
|
|
1577
|
+
cwd: process.cwd(),
|
|
1578
|
+
env: ptyEnv
|
|
1579
|
+
});
|
|
1580
|
+
pty2.onData((data) => {
|
|
1581
|
+
process.stdout.write(data);
|
|
1582
|
+
const buf = Buffer.from(data);
|
|
1583
|
+
appendScrollback(buf);
|
|
1584
|
+
sendEncrypted(buf);
|
|
1585
|
+
});
|
|
1586
|
+
cliWs.on("message", async (raw, isBinary) => {
|
|
1587
|
+
if (!isBinary) {
|
|
1588
|
+
try {
|
|
1589
|
+
const msg = JSON.parse(raw.toString());
|
|
1590
|
+
if (msg.type === "key_exchange" && msg.publicKey && msg.role === "viewer") {
|
|
1591
|
+
await initE2E(msg.publicKey);
|
|
1592
|
+
log("CRYPTO", "E2E established with viewer");
|
|
1593
|
+
} else if (msg.type === "peer_joined") {
|
|
1594
|
+
e2eReady = false;
|
|
1595
|
+
ratchetState = null;
|
|
1596
|
+
}
|
|
1597
|
+
} catch {
|
|
1598
|
+
}
|
|
1599
|
+
return;
|
|
1600
|
+
}
|
|
1601
|
+
if (!e2eReady || !ratchetState) return;
|
|
1602
|
+
withRatchet(async () => {
|
|
1603
|
+
if (!ratchetState) return;
|
|
1604
|
+
try {
|
|
1605
|
+
const env = decodeEnvelope(new Uint8Array(raw));
|
|
1606
|
+
const dec = await ratchetDecrypt(ratchetState, env.ciphertext, env.nonce, env.counter);
|
|
1607
|
+
ratchetState = dec.state;
|
|
1608
|
+
const msg = deserializeMessage(dec.plaintext);
|
|
1609
|
+
if (msg.type === "terminal_input") {
|
|
1610
|
+
const input = Buffer.from(msg.data, "base64").toString();
|
|
1611
|
+
pty2.write(input);
|
|
1612
|
+
} else if (msg.type === "terminal_resize") {
|
|
1613
|
+
const { cols, rows } = msg;
|
|
1614
|
+
if (cols && rows) {
|
|
1615
|
+
ptyCols = cols;
|
|
1616
|
+
ptyRows = rows;
|
|
1617
|
+
pty2.resize(cols, rows);
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
} catch {
|
|
1621
|
+
}
|
|
1622
|
+
});
|
|
1623
|
+
});
|
|
1624
|
+
if (process.stdin.isTTY) {
|
|
1625
|
+
process.stdin.setRawMode(true);
|
|
1626
|
+
}
|
|
1627
|
+
process.stdin.resume();
|
|
1628
|
+
process.stdin.on("data", (data) => {
|
|
1629
|
+
pty2.write(data.toString());
|
|
1630
|
+
});
|
|
1631
|
+
process.stdout.on("resize", () => {
|
|
1632
|
+
ptyCols = process.stdout.columns || 80;
|
|
1633
|
+
ptyRows = process.stdout.rows || 24;
|
|
1634
|
+
pty2.resize(ptyCols, ptyRows);
|
|
1635
|
+
});
|
|
1636
|
+
const cleanup = () => {
|
|
1637
|
+
hookServer.close();
|
|
1638
|
+
try {
|
|
1639
|
+
unlinkSync(hookSockPath);
|
|
1640
|
+
} catch {
|
|
1641
|
+
}
|
|
1642
|
+
if (hookScriptPath) {
|
|
1643
|
+
try {
|
|
1644
|
+
unlinkSync(hookScriptPath);
|
|
1645
|
+
} catch {
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
if (originalSettings && hookScriptPath) {
|
|
1649
|
+
uninstallClaudeHooks(originalSettings, hookScriptPath);
|
|
1650
|
+
}
|
|
1651
|
+
cliWs.close();
|
|
1652
|
+
fetch(`${config.reley_url}/api/v1/sessions/${sessionId}`, {
|
|
1653
|
+
method: "PATCH",
|
|
1654
|
+
headers: {
|
|
1655
|
+
"Content-Type": "application/json",
|
|
1656
|
+
Authorization: `Bearer ${config.device_token}`
|
|
1657
|
+
},
|
|
1658
|
+
body: JSON.stringify({ status: "closed" })
|
|
1659
|
+
}).catch(() => {
|
|
1660
|
+
});
|
|
1661
|
+
};
|
|
1662
|
+
pty2.onExit(() => {
|
|
1663
|
+
cleanup();
|
|
1664
|
+
process.exit(0);
|
|
1665
|
+
});
|
|
1666
|
+
process.on("SIGINT", () => pty2.kill());
|
|
1667
|
+
process.on("SIGTERM", () => pty2.kill());
|
|
1668
|
+
}
|
|
1669
|
+
function computeFingerprint(sodium2, pk1, pk2) {
|
|
1670
|
+
const sorted = [pk1, pk2].sort((a, b) => {
|
|
1671
|
+
for (let i = 0; i < a.length; i++) {
|
|
1672
|
+
if (a[i] !== b[i]) return a[i] - b[i];
|
|
1673
|
+
}
|
|
1674
|
+
return 0;
|
|
1675
|
+
});
|
|
1676
|
+
const combined = new Uint8Array(sorted[0].length + sorted[1].length);
|
|
1677
|
+
combined.set(sorted[0], 0);
|
|
1678
|
+
combined.set(sorted[1], sorted[0].length);
|
|
1679
|
+
const hash = sodium2.crypto_generichash(16, combined, null);
|
|
1680
|
+
const hex = sodium2.to_hex(hash).toUpperCase();
|
|
1681
|
+
return hex.match(/.{4}/g).join("-");
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
// src/commands/pair.ts
|
|
1685
|
+
import * as fs from "node:fs/promises";
|
|
1686
|
+
import * as path from "node:path";
|
|
1687
|
+
import * as crypto2 from "node:crypto";
|
|
1688
|
+
import qrcode from "qrcode-terminal";
|
|
1689
|
+
async function pairCommand(options) {
|
|
1690
|
+
const { releyUrl, configDir } = options;
|
|
1691
|
+
const sodium2 = await ensureSodium();
|
|
1692
|
+
console.log("Generating identity key pair...");
|
|
1693
|
+
const identityKp = await generateIdentityKeyPair();
|
|
1694
|
+
console.log("Generating ephemeral key pair...");
|
|
1695
|
+
const ephemeralKp = await generateEphemeralKeyPair();
|
|
1696
|
+
console.log("Generating one-time pairing code...");
|
|
1697
|
+
const oneTimeCode = await generateOneTimeCode(32);
|
|
1698
|
+
const otcHash = await hashOneTimeCode(oneTimeCode);
|
|
1699
|
+
const deviceId = crypto2.randomUUID();
|
|
1700
|
+
console.log("Initiating pairing with reley...");
|
|
1701
|
+
const initiateBody = {
|
|
1702
|
+
publicKey: sodium2.to_base64(identityKp.publicKey, sodium2.base64_variants.URLSAFE_NO_PADDING),
|
|
1703
|
+
otcHash: sodium2.to_base64(otcHash, sodium2.base64_variants.URLSAFE_NO_PADDING),
|
|
1704
|
+
deviceId
|
|
1705
|
+
};
|
|
1706
|
+
const initiateRes = await fetch(`${releyUrl}/api/v1/pair/initiate`, {
|
|
1707
|
+
method: "POST",
|
|
1708
|
+
headers: { "Content-Type": "application/json" },
|
|
1709
|
+
body: JSON.stringify(initiateBody)
|
|
1710
|
+
});
|
|
1711
|
+
if (!initiateRes.ok) {
|
|
1712
|
+
const errText = await initiateRes.text();
|
|
1713
|
+
throw new Error(`Failed to initiate pairing: ${initiateRes.status} ${errText}`);
|
|
1714
|
+
}
|
|
1715
|
+
const { sessionId, jwt } = await initiateRes.json();
|
|
1716
|
+
console.log(`Pairing session created: ${sessionId}`);
|
|
1717
|
+
const qrPayloadStr = await encodeQRPayload({
|
|
1718
|
+
releyUrl,
|
|
1719
|
+
publicKey: ephemeralKp.publicKey,
|
|
1720
|
+
oneTimeCode,
|
|
1721
|
+
jwt
|
|
1722
|
+
});
|
|
1723
|
+
console.log("\nScan this QR code with the Reley mobile app:\n");
|
|
1724
|
+
await new Promise((resolve2) => {
|
|
1725
|
+
qrcode.generate(qrPayloadStr, { small: true }, (output) => {
|
|
1726
|
+
console.log(output);
|
|
1727
|
+
resolve2();
|
|
1728
|
+
});
|
|
1729
|
+
});
|
|
1730
|
+
console.log("\nWaiting for mobile device to complete pairing...");
|
|
1731
|
+
const POLL_INTERVAL_MS = 2e3;
|
|
1732
|
+
const MAX_POLL_DURATION_MS = 5 * 60 * 1e3;
|
|
1733
|
+
const startTime = Date.now();
|
|
1734
|
+
let peerPublicKey;
|
|
1735
|
+
while (Date.now() - startTime < MAX_POLL_DURATION_MS) {
|
|
1736
|
+
await sleep(POLL_INTERVAL_MS);
|
|
1737
|
+
const statusRes = await fetch(`${releyUrl}/api/v1/pair/status/${sessionId}`, {
|
|
1738
|
+
headers: { Authorization: `Bearer ${jwt}` }
|
|
1739
|
+
});
|
|
1740
|
+
if (!statusRes.ok) {
|
|
1741
|
+
console.error(`Poll error: ${statusRes.status}`);
|
|
1742
|
+
continue;
|
|
1743
|
+
}
|
|
1744
|
+
const statusData = await statusRes.json();
|
|
1745
|
+
if (statusData.status === "completed") {
|
|
1746
|
+
peerPublicKey = statusData.peerPublicKey;
|
|
1747
|
+
break;
|
|
1748
|
+
}
|
|
1749
|
+
if (statusData.status === "expired") {
|
|
1750
|
+
throw new Error("Pairing session expired. Please try again.");
|
|
1751
|
+
}
|
|
1752
|
+
process.stdout.write(".");
|
|
1753
|
+
}
|
|
1754
|
+
if (!peerPublicKey) {
|
|
1755
|
+
throw new Error("Pairing timed out after 5 minutes.");
|
|
1756
|
+
}
|
|
1757
|
+
console.log("\n\nPairing successful!");
|
|
1758
|
+
const sessionConfig = {
|
|
1759
|
+
deviceId,
|
|
1760
|
+
sessionId,
|
|
1761
|
+
releyUrl,
|
|
1762
|
+
jwt,
|
|
1763
|
+
identityPublicKey: sodium2.to_base64(identityKp.publicKey, sodium2.base64_variants.URLSAFE_NO_PADDING),
|
|
1764
|
+
identitySecretKey: sodium2.to_base64(identityKp.secretKey, sodium2.base64_variants.URLSAFE_NO_PADDING),
|
|
1765
|
+
ephemeralPublicKey: sodium2.to_base64(ephemeralKp.publicKey, sodium2.base64_variants.URLSAFE_NO_PADDING),
|
|
1766
|
+
ephemeralSecretKey: sodium2.to_base64(ephemeralKp.secretKey, sodium2.base64_variants.URLSAFE_NO_PADDING),
|
|
1767
|
+
peerPublicKey,
|
|
1768
|
+
pairedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1769
|
+
};
|
|
1770
|
+
await fs.mkdir(configDir, { recursive: true });
|
|
1771
|
+
const configPath = path.join(configDir, "session.json");
|
|
1772
|
+
await fs.writeFile(configPath, JSON.stringify(sessionConfig, null, 2), "utf-8");
|
|
1773
|
+
console.log(`Session saved to ${configPath}`);
|
|
1774
|
+
console.log(`Device ID: ${deviceId}`);
|
|
1775
|
+
console.log(`Session ID: ${sessionId}`);
|
|
1776
|
+
}
|
|
1777
|
+
function sleep(ms) {
|
|
1778
|
+
return new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
// src/commands/wrap.ts
|
|
1782
|
+
import * as fs3 from "node:fs/promises";
|
|
1783
|
+
import * as path3 from "node:path";
|
|
1784
|
+
|
|
1785
|
+
// src/daemon/pty-manager.ts
|
|
1786
|
+
import pty from "node-pty";
|
|
1787
|
+
var PtyManager = class {
|
|
1788
|
+
process = null;
|
|
1789
|
+
dataCallbacks = [];
|
|
1790
|
+
exitCallbacks = [];
|
|
1791
|
+
/**
|
|
1792
|
+
* Spawn a command in a pseudo-terminal.
|
|
1793
|
+
*/
|
|
1794
|
+
spawn(command, args, options = {}) {
|
|
1795
|
+
const { cols = 80, rows = 24, cwd, env } = options;
|
|
1796
|
+
this.process = pty.spawn(command, args, {
|
|
1797
|
+
name: "xterm-256color",
|
|
1798
|
+
cols,
|
|
1799
|
+
rows,
|
|
1800
|
+
cwd: cwd || process.cwd(),
|
|
1801
|
+
env: env || process.env
|
|
1802
|
+
});
|
|
1803
|
+
this.process.onData((data) => {
|
|
1804
|
+
for (const cb of this.dataCallbacks) {
|
|
1805
|
+
cb(data);
|
|
1806
|
+
}
|
|
1807
|
+
});
|
|
1808
|
+
this.process.onExit(({ exitCode, signal }) => {
|
|
1809
|
+
for (const cb of this.exitCallbacks) {
|
|
1810
|
+
cb(exitCode, signal);
|
|
1811
|
+
}
|
|
1812
|
+
this.process = null;
|
|
1813
|
+
});
|
|
1814
|
+
return this.process;
|
|
1815
|
+
}
|
|
1816
|
+
/**
|
|
1817
|
+
* Register a callback for PTY data output.
|
|
1818
|
+
*/
|
|
1819
|
+
onData(callback) {
|
|
1820
|
+
this.dataCallbacks.push(callback);
|
|
1821
|
+
}
|
|
1822
|
+
/**
|
|
1823
|
+
* Register a callback for PTY exit.
|
|
1824
|
+
*/
|
|
1825
|
+
onExit(callback) {
|
|
1826
|
+
this.exitCallbacks.push(callback);
|
|
1827
|
+
}
|
|
1828
|
+
/**
|
|
1829
|
+
* Write data to the PTY input.
|
|
1830
|
+
*/
|
|
1831
|
+
write(data) {
|
|
1832
|
+
if (!this.process) {
|
|
1833
|
+
throw new Error("PTY process not running");
|
|
1834
|
+
}
|
|
1835
|
+
this.process.write(data);
|
|
1836
|
+
}
|
|
1837
|
+
/**
|
|
1838
|
+
* Resize the PTY to the given dimensions.
|
|
1839
|
+
*/
|
|
1840
|
+
resize(cols, rows) {
|
|
1841
|
+
if (!this.process) {
|
|
1842
|
+
return;
|
|
1843
|
+
}
|
|
1844
|
+
this.process.resize(cols, rows);
|
|
1845
|
+
}
|
|
1846
|
+
/**
|
|
1847
|
+
* Kill the PTY process.
|
|
1848
|
+
*/
|
|
1849
|
+
kill(signal) {
|
|
1850
|
+
if (!this.process) {
|
|
1851
|
+
return;
|
|
1852
|
+
}
|
|
1853
|
+
try {
|
|
1854
|
+
this.process.kill(signal);
|
|
1855
|
+
} catch {
|
|
1856
|
+
}
|
|
1857
|
+
this.process = null;
|
|
1858
|
+
}
|
|
1859
|
+
/**
|
|
1860
|
+
* Get the PID of the PTY process.
|
|
1861
|
+
*/
|
|
1862
|
+
get pid() {
|
|
1863
|
+
return this.process?.pid;
|
|
1864
|
+
}
|
|
1865
|
+
/**
|
|
1866
|
+
* Check if the PTY process is running.
|
|
1867
|
+
*/
|
|
1868
|
+
get isRunning() {
|
|
1869
|
+
return this.process !== null;
|
|
1870
|
+
}
|
|
1871
|
+
/**
|
|
1872
|
+
* Clean shutdown: kill process and clear callbacks.
|
|
1873
|
+
*/
|
|
1874
|
+
shutdown() {
|
|
1875
|
+
this.kill();
|
|
1876
|
+
this.dataCallbacks = [];
|
|
1877
|
+
this.exitCallbacks = [];
|
|
1878
|
+
}
|
|
1879
|
+
};
|
|
1880
|
+
|
|
1881
|
+
// src/daemon/ws-client.ts
|
|
1882
|
+
import { EventEmitter } from "node:events";
|
|
1883
|
+
import WebSocket2 from "ws";
|
|
1884
|
+
var WsClient = class extends EventEmitter {
|
|
1885
|
+
ws = null;
|
|
1886
|
+
url = "";
|
|
1887
|
+
token = "";
|
|
1888
|
+
releyHttpUrl = "";
|
|
1889
|
+
reconnectAttempts = 0;
|
|
1890
|
+
reconnectTimer = null;
|
|
1891
|
+
pingTimer = null;
|
|
1892
|
+
pongTimer = null;
|
|
1893
|
+
tokenRefreshTimer = null;
|
|
1894
|
+
shouldReconnect = true;
|
|
1895
|
+
messageCallbacks = [];
|
|
1896
|
+
connectCallbacks = [];
|
|
1897
|
+
disconnectCallbacks = [];
|
|
1898
|
+
/**
|
|
1899
|
+
* Connect to a WebSocket reley server.
|
|
1900
|
+
*/
|
|
1901
|
+
async connect(url, token) {
|
|
1902
|
+
this.url = url;
|
|
1903
|
+
this.token = token;
|
|
1904
|
+
this.shouldReconnect = true;
|
|
1905
|
+
this.releyHttpUrl = url.replace(/^ws:/, "http:").replace(/^wss:/, "https:").replace(/\/ws\??.*$/, "");
|
|
1906
|
+
this.scheduleTokenRefresh();
|
|
1907
|
+
return this.doConnect();
|
|
1908
|
+
}
|
|
1909
|
+
doConnect() {
|
|
1910
|
+
return new Promise((resolve2, reject) => {
|
|
1911
|
+
const ws = new WebSocket2(this.url, {
|
|
1912
|
+
headers: {
|
|
1913
|
+
Authorization: `Bearer ${this.token}`
|
|
1914
|
+
}
|
|
1915
|
+
});
|
|
1916
|
+
ws.binaryType = "arraybuffer";
|
|
1917
|
+
ws.on("open", () => {
|
|
1918
|
+
this.ws = ws;
|
|
1919
|
+
this.reconnectAttempts = 0;
|
|
1920
|
+
this.startPingInterval();
|
|
1921
|
+
for (const cb of this.connectCallbacks) {
|
|
1922
|
+
cb();
|
|
1923
|
+
}
|
|
1924
|
+
this.emit("connect");
|
|
1925
|
+
resolve2(ws);
|
|
1926
|
+
});
|
|
1927
|
+
ws.on("message", (data) => {
|
|
1928
|
+
let bytes;
|
|
1929
|
+
if (data instanceof ArrayBuffer) {
|
|
1930
|
+
bytes = new Uint8Array(data);
|
|
1931
|
+
} else if (Buffer.isBuffer(data)) {
|
|
1932
|
+
bytes = new Uint8Array(data);
|
|
1933
|
+
} else if (Array.isArray(data)) {
|
|
1934
|
+
bytes = new Uint8Array(Buffer.concat(data));
|
|
1935
|
+
} else {
|
|
1936
|
+
return;
|
|
1937
|
+
}
|
|
1938
|
+
for (const cb of this.messageCallbacks) {
|
|
1939
|
+
cb(bytes);
|
|
1940
|
+
}
|
|
1941
|
+
this.emit("message", bytes);
|
|
1942
|
+
});
|
|
1943
|
+
ws.on("pong", () => {
|
|
1944
|
+
this.clearPongTimeout();
|
|
1945
|
+
});
|
|
1946
|
+
ws.on("close", (code, reason) => {
|
|
1947
|
+
const reasonStr = reason.toString("utf-8");
|
|
1948
|
+
this.ws = null;
|
|
1949
|
+
this.stopPingInterval();
|
|
1950
|
+
for (const cb of this.disconnectCallbacks) {
|
|
1951
|
+
cb(code, reasonStr);
|
|
1952
|
+
}
|
|
1953
|
+
this.emit("disconnect", code, reasonStr);
|
|
1954
|
+
if (this.shouldReconnect) {
|
|
1955
|
+
this.scheduleReconnect();
|
|
1956
|
+
}
|
|
1957
|
+
});
|
|
1958
|
+
ws.on("error", (err) => {
|
|
1959
|
+
this.emit("error", err);
|
|
1960
|
+
if (!this.ws && this.reconnectAttempts === 0) {
|
|
1961
|
+
reject(err);
|
|
1962
|
+
}
|
|
1963
|
+
});
|
|
1964
|
+
});
|
|
1965
|
+
}
|
|
1966
|
+
/**
|
|
1967
|
+
* Register a callback for incoming binary messages.
|
|
1968
|
+
*/
|
|
1969
|
+
onMessage(callback) {
|
|
1970
|
+
this.messageCallbacks.push(callback);
|
|
1971
|
+
}
|
|
1972
|
+
/**
|
|
1973
|
+
* Register a callback for successful connection.
|
|
1974
|
+
*/
|
|
1975
|
+
onConnect(callback) {
|
|
1976
|
+
this.connectCallbacks.push(callback);
|
|
1977
|
+
}
|
|
1978
|
+
/**
|
|
1979
|
+
* Register a callback for disconnection.
|
|
1980
|
+
*/
|
|
1981
|
+
onDisconnect(callback) {
|
|
1982
|
+
this.disconnectCallbacks.push(callback);
|
|
1983
|
+
}
|
|
1984
|
+
/**
|
|
1985
|
+
* Send binary data through the WebSocket.
|
|
1986
|
+
*/
|
|
1987
|
+
send(data) {
|
|
1988
|
+
if (!this.ws || this.ws.readyState !== WebSocket2.OPEN) {
|
|
1989
|
+
throw new Error("WebSocket is not connected");
|
|
1990
|
+
}
|
|
1991
|
+
this.ws.send(data);
|
|
1992
|
+
}
|
|
1993
|
+
/**
|
|
1994
|
+
* Close the WebSocket connection and stop reconnecting.
|
|
1995
|
+
*/
|
|
1996
|
+
/**
|
|
1997
|
+
* Update the token (called after refresh).
|
|
1998
|
+
*/
|
|
1999
|
+
updateToken(newToken) {
|
|
2000
|
+
this.token = newToken;
|
|
2001
|
+
}
|
|
2002
|
+
close() {
|
|
2003
|
+
this.shouldReconnect = false;
|
|
2004
|
+
if (this.reconnectTimer) {
|
|
2005
|
+
clearTimeout(this.reconnectTimer);
|
|
2006
|
+
this.reconnectTimer = null;
|
|
2007
|
+
}
|
|
2008
|
+
if (this.tokenRefreshTimer) {
|
|
2009
|
+
clearTimeout(this.tokenRefreshTimer);
|
|
2010
|
+
this.tokenRefreshTimer = null;
|
|
2011
|
+
}
|
|
2012
|
+
this.stopPingInterval();
|
|
2013
|
+
if (this.ws) {
|
|
2014
|
+
this.ws.close(1e3, "Client closing");
|
|
2015
|
+
this.ws = null;
|
|
2016
|
+
}
|
|
2017
|
+
}
|
|
2018
|
+
/**
|
|
2019
|
+
* Check if the WebSocket is currently connected.
|
|
2020
|
+
*/
|
|
2021
|
+
get isConnected() {
|
|
2022
|
+
return this.ws !== null && this.ws.readyState === WebSocket2.OPEN;
|
|
2023
|
+
}
|
|
2024
|
+
/**
|
|
2025
|
+
* Schedule a reconnection attempt with exponential backoff.
|
|
2026
|
+
*/
|
|
2027
|
+
scheduleReconnect() {
|
|
2028
|
+
const baseDelay = TIMEOUTS.WS_RECONNECT_BASE;
|
|
2029
|
+
const maxDelay = TIMEOUTS.WS_RECONNECT_MAX;
|
|
2030
|
+
const delay = Math.min(baseDelay * Math.pow(2, this.reconnectAttempts), maxDelay);
|
|
2031
|
+
const jitter = Math.random() * delay * 0.25;
|
|
2032
|
+
const actualDelay = delay + jitter;
|
|
2033
|
+
this.reconnectAttempts++;
|
|
2034
|
+
this.reconnectTimer = setTimeout(async () => {
|
|
2035
|
+
try {
|
|
2036
|
+
await this.doConnect();
|
|
2037
|
+
} catch {
|
|
2038
|
+
}
|
|
2039
|
+
}, actualDelay);
|
|
2040
|
+
}
|
|
2041
|
+
/**
|
|
2042
|
+
* Start sending periodic ping frames.
|
|
2043
|
+
*/
|
|
2044
|
+
startPingInterval() {
|
|
2045
|
+
this.stopPingInterval();
|
|
2046
|
+
this.pingTimer = setInterval(() => {
|
|
2047
|
+
if (this.ws && this.ws.readyState === WebSocket2.OPEN) {
|
|
2048
|
+
this.ws.ping();
|
|
2049
|
+
this.startPongTimeout();
|
|
2050
|
+
}
|
|
2051
|
+
}, TIMEOUTS.PING_INTERVAL);
|
|
2052
|
+
}
|
|
2053
|
+
/**
|
|
2054
|
+
* Stop the ping interval.
|
|
2055
|
+
*/
|
|
2056
|
+
stopPingInterval() {
|
|
2057
|
+
if (this.pingTimer) {
|
|
2058
|
+
clearInterval(this.pingTimer);
|
|
2059
|
+
this.pingTimer = null;
|
|
2060
|
+
}
|
|
2061
|
+
this.clearPongTimeout();
|
|
2062
|
+
}
|
|
2063
|
+
/**
|
|
2064
|
+
* Start a timeout waiting for pong response.
|
|
2065
|
+
*/
|
|
2066
|
+
startPongTimeout() {
|
|
2067
|
+
this.clearPongTimeout();
|
|
2068
|
+
this.pongTimer = setTimeout(() => {
|
|
2069
|
+
if (this.ws) {
|
|
2070
|
+
this.ws.terminate();
|
|
2071
|
+
}
|
|
2072
|
+
}, TIMEOUTS.PONG_TIMEOUT);
|
|
2073
|
+
}
|
|
2074
|
+
/**
|
|
2075
|
+
* Clear the pong timeout.
|
|
2076
|
+
*/
|
|
2077
|
+
clearPongTimeout() {
|
|
2078
|
+
if (this.pongTimer) {
|
|
2079
|
+
clearTimeout(this.pongTimer);
|
|
2080
|
+
this.pongTimer = null;
|
|
2081
|
+
}
|
|
2082
|
+
}
|
|
2083
|
+
/**
|
|
2084
|
+
* Schedule automatic token refresh 1 hour before expiry (i.e., every 23 hours).
|
|
2085
|
+
*/
|
|
2086
|
+
scheduleTokenRefresh() {
|
|
2087
|
+
if (this.tokenRefreshTimer) {
|
|
2088
|
+
clearTimeout(this.tokenRefreshTimer);
|
|
2089
|
+
}
|
|
2090
|
+
const refreshInterval = 23 * 60 * 60 * 1e3;
|
|
2091
|
+
this.tokenRefreshTimer = setTimeout(async () => {
|
|
2092
|
+
await this.refreshToken();
|
|
2093
|
+
}, refreshInterval);
|
|
2094
|
+
this.tokenRefreshTimer.unref();
|
|
2095
|
+
}
|
|
2096
|
+
/**
|
|
2097
|
+
* Refresh the JWT token via the reley's refresh endpoint.
|
|
2098
|
+
*/
|
|
2099
|
+
async refreshToken() {
|
|
2100
|
+
try {
|
|
2101
|
+
const response = await fetch(`${this.releyHttpUrl}/api/v1/auth/refresh`, {
|
|
2102
|
+
method: "POST",
|
|
2103
|
+
headers: { "Content-Type": "application/json" },
|
|
2104
|
+
body: JSON.stringify({ token: this.token })
|
|
2105
|
+
});
|
|
2106
|
+
if (response.ok) {
|
|
2107
|
+
const data = await response.json();
|
|
2108
|
+
this.token = data.token;
|
|
2109
|
+
this.scheduleTokenRefresh();
|
|
2110
|
+
}
|
|
2111
|
+
} catch {
|
|
2112
|
+
this.tokenRefreshTimer = setTimeout(async () => {
|
|
2113
|
+
await this.refreshToken();
|
|
2114
|
+
}, 5 * 60 * 1e3);
|
|
2115
|
+
this.tokenRefreshTimer?.unref();
|
|
2116
|
+
}
|
|
2117
|
+
}
|
|
2118
|
+
};
|
|
2119
|
+
|
|
2120
|
+
// src/daemon/crypto-session.ts
|
|
2121
|
+
import * as fs2 from "node:fs/promises";
|
|
2122
|
+
import * as path2 from "node:path";
|
|
2123
|
+
var CryptoSession = class {
|
|
2124
|
+
ratchetState = null;
|
|
2125
|
+
isInitialized = false;
|
|
2126
|
+
/**
|
|
2127
|
+
* Initialize the crypto session from saved pairing configuration.
|
|
2128
|
+
*/
|
|
2129
|
+
async initFromConfig(configDir) {
|
|
2130
|
+
const sodium2 = await ensureSodium();
|
|
2131
|
+
const ratchetPath = path2.join(configDir, "ratchet-state.json");
|
|
2132
|
+
try {
|
|
2133
|
+
const raw2 = await fs2.readFile(ratchetPath, "utf-8");
|
|
2134
|
+
const serialized = JSON.parse(raw2);
|
|
2135
|
+
this.ratchetState = {
|
|
2136
|
+
sendChainKey: sodium2.from_base64(serialized.sendChainKey, sodium2.base64_variants.URLSAFE_NO_PADDING),
|
|
2137
|
+
recvChainKey: sodium2.from_base64(serialized.recvChainKey, sodium2.base64_variants.URLSAFE_NO_PADDING),
|
|
2138
|
+
sendCounter: serialized.sendCounter,
|
|
2139
|
+
recvCounter: serialized.recvCounter,
|
|
2140
|
+
maxRecvCounter: serialized.maxRecvCounter
|
|
2141
|
+
};
|
|
2142
|
+
this.isInitialized = true;
|
|
2143
|
+
return;
|
|
2144
|
+
} catch {
|
|
2145
|
+
}
|
|
2146
|
+
const configPath = path2.join(configDir, "session.json");
|
|
2147
|
+
const raw = await fs2.readFile(configPath, "utf-8");
|
|
2148
|
+
const config = JSON.parse(raw);
|
|
2149
|
+
const ourSecretKey = sodium2.from_base64(config.ephemeralSecretKey, sodium2.base64_variants.URLSAFE_NO_PADDING);
|
|
2150
|
+
const peerPublicKey = sodium2.from_base64(config.peerPublicKey, sodium2.base64_variants.URLSAFE_NO_PADDING);
|
|
2151
|
+
const sharedSecret = await computeSharedSecret(ourSecretKey, peerPublicKey);
|
|
2152
|
+
const { sendKey, recvKey } = await deriveSessionKeys(sharedSecret);
|
|
2153
|
+
this.ratchetState = initRatchet(sendKey, recvKey);
|
|
2154
|
+
this.isInitialized = true;
|
|
2155
|
+
}
|
|
2156
|
+
/**
|
|
2157
|
+
* Initialize from raw keys (for testing or manual setup).
|
|
2158
|
+
*/
|
|
2159
|
+
async initFromKeys(ourSecretKey, peerPublicKey) {
|
|
2160
|
+
const sharedSecret = await computeSharedSecret(ourSecretKey, peerPublicKey);
|
|
2161
|
+
const { sendKey, recvKey } = await deriveSessionKeys(sharedSecret);
|
|
2162
|
+
this.ratchetState = initRatchet(sendKey, recvKey);
|
|
2163
|
+
this.isInitialized = true;
|
|
2164
|
+
}
|
|
2165
|
+
/**
|
|
2166
|
+
* Encrypt a protocol message and build a wire-format envelope.
|
|
2167
|
+
*/
|
|
2168
|
+
async encryptMessage(message) {
|
|
2169
|
+
if (!this.ratchetState) {
|
|
2170
|
+
throw new Error("Crypto session not initialized");
|
|
2171
|
+
}
|
|
2172
|
+
const plaintext = serializeMessage(message);
|
|
2173
|
+
const wireType = getWireType(message.type);
|
|
2174
|
+
const { ciphertext, nonce, counter, state } = await ratchetEncrypt(
|
|
2175
|
+
this.ratchetState,
|
|
2176
|
+
plaintext
|
|
2177
|
+
);
|
|
2178
|
+
this.ratchetState = state;
|
|
2179
|
+
const envelope = {
|
|
2180
|
+
version: PROTOCOL_VERSION,
|
|
2181
|
+
type: wireType,
|
|
2182
|
+
counter,
|
|
2183
|
+
nonce,
|
|
2184
|
+
ciphertext
|
|
2185
|
+
};
|
|
2186
|
+
if (needsKeyRotation(this.ratchetState)) {
|
|
2187
|
+
await this.performKeyRotation();
|
|
2188
|
+
}
|
|
2189
|
+
return encodeEnvelope(envelope);
|
|
2190
|
+
}
|
|
2191
|
+
/**
|
|
2192
|
+
* Decrypt a wire-format envelope and return the protocol message.
|
|
2193
|
+
*/
|
|
2194
|
+
async decryptMessage(data) {
|
|
2195
|
+
if (!this.ratchetState) {
|
|
2196
|
+
throw new Error("Crypto session not initialized");
|
|
2197
|
+
}
|
|
2198
|
+
const envelope = decodeEnvelope(data);
|
|
2199
|
+
const { plaintext, state } = await ratchetDecrypt(
|
|
2200
|
+
this.ratchetState,
|
|
2201
|
+
envelope.ciphertext,
|
|
2202
|
+
envelope.nonce,
|
|
2203
|
+
envelope.counter
|
|
2204
|
+
);
|
|
2205
|
+
this.ratchetState = state;
|
|
2206
|
+
return deserializeMessage(plaintext);
|
|
2207
|
+
}
|
|
2208
|
+
/**
|
|
2209
|
+
* Save the current ratchet state to disk.
|
|
2210
|
+
*/
|
|
2211
|
+
async saveState(configDir) {
|
|
2212
|
+
if (!this.ratchetState) {
|
|
2213
|
+
return;
|
|
2214
|
+
}
|
|
2215
|
+
const sodium2 = await ensureSodium();
|
|
2216
|
+
const serialized = {
|
|
2217
|
+
sendChainKey: sodium2.to_base64(this.ratchetState.sendChainKey, sodium2.base64_variants.URLSAFE_NO_PADDING),
|
|
2218
|
+
recvChainKey: sodium2.to_base64(this.ratchetState.recvChainKey, sodium2.base64_variants.URLSAFE_NO_PADDING),
|
|
2219
|
+
sendCounter: this.ratchetState.sendCounter,
|
|
2220
|
+
recvCounter: this.ratchetState.recvCounter,
|
|
2221
|
+
maxRecvCounter: this.ratchetState.maxRecvCounter
|
|
2222
|
+
};
|
|
2223
|
+
const ratchetPath = path2.join(configDir, "ratchet-state.json");
|
|
2224
|
+
await fs2.writeFile(ratchetPath, JSON.stringify(serialized, null, 2), "utf-8");
|
|
2225
|
+
}
|
|
2226
|
+
/**
|
|
2227
|
+
* Load ratchet state from disk.
|
|
2228
|
+
*/
|
|
2229
|
+
async loadState(configDir) {
|
|
2230
|
+
const sodium2 = await ensureSodium();
|
|
2231
|
+
const ratchetPath = path2.join(configDir, "ratchet-state.json");
|
|
2232
|
+
try {
|
|
2233
|
+
const raw = await fs2.readFile(ratchetPath, "utf-8");
|
|
2234
|
+
const serialized = JSON.parse(raw);
|
|
2235
|
+
this.ratchetState = {
|
|
2236
|
+
sendChainKey: sodium2.from_base64(serialized.sendChainKey, sodium2.base64_variants.URLSAFE_NO_PADDING),
|
|
2237
|
+
recvChainKey: sodium2.from_base64(serialized.recvChainKey, sodium2.base64_variants.URLSAFE_NO_PADDING),
|
|
2238
|
+
sendCounter: serialized.sendCounter,
|
|
2239
|
+
recvCounter: serialized.recvCounter,
|
|
2240
|
+
maxRecvCounter: serialized.maxRecvCounter
|
|
2241
|
+
};
|
|
2242
|
+
this.isInitialized = true;
|
|
2243
|
+
return true;
|
|
2244
|
+
} catch {
|
|
2245
|
+
return false;
|
|
2246
|
+
}
|
|
2247
|
+
}
|
|
2248
|
+
/**
|
|
2249
|
+
* Get current ratchet state (for diagnostics).
|
|
2250
|
+
*/
|
|
2251
|
+
getRatchetInfo() {
|
|
2252
|
+
return {
|
|
2253
|
+
sendCounter: this.ratchetState?.sendCounter ?? 0,
|
|
2254
|
+
recvCounter: this.ratchetState?.recvCounter ?? 0,
|
|
2255
|
+
initialized: this.isInitialized
|
|
2256
|
+
};
|
|
2257
|
+
}
|
|
2258
|
+
/**
|
|
2259
|
+
* Perform key rotation by generating new ephemeral keys.
|
|
2260
|
+
* This is triggered automatically when the ratchet reaches KEY_ROTATION_INTERVAL.
|
|
2261
|
+
*/
|
|
2262
|
+
async performKeyRotation() {
|
|
2263
|
+
}
|
|
2264
|
+
};
|
|
2265
|
+
|
|
2266
|
+
// src/commands/wrap.ts
|
|
2267
|
+
async function wrapCommand(commandArgs, options) {
|
|
2268
|
+
const { configDir } = options;
|
|
2269
|
+
const configPath = path3.join(configDir, "session.json");
|
|
2270
|
+
let sessionConfig;
|
|
2271
|
+
try {
|
|
2272
|
+
const raw = await fs3.readFile(configPath, "utf-8");
|
|
2273
|
+
sessionConfig = JSON.parse(raw);
|
|
2274
|
+
} catch {
|
|
2275
|
+
console.error('No active session found. Run "reley pair" first.');
|
|
2276
|
+
process.exit(1);
|
|
2277
|
+
}
|
|
2278
|
+
const cryptoSession = new CryptoSession();
|
|
2279
|
+
await cryptoSession.initFromConfig(configDir);
|
|
2280
|
+
const command = commandArgs[0];
|
|
2281
|
+
const args = commandArgs.slice(1);
|
|
2282
|
+
console.log(`Wrapping command: ${commandArgs.join(" ")}`);
|
|
2283
|
+
const ptyManager = new PtyManager();
|
|
2284
|
+
const pty2 = ptyManager.spawn(command, args, {
|
|
2285
|
+
cols: process.stdout.columns || 80,
|
|
2286
|
+
rows: process.stdout.rows || 24,
|
|
2287
|
+
cwd: process.cwd(),
|
|
2288
|
+
env: process.env
|
|
2289
|
+
});
|
|
2290
|
+
const wsUrl = sessionConfig.releyUrl.replace(/^http/, "ws");
|
|
2291
|
+
const wsClient = new WsClient();
|
|
2292
|
+
await wsClient.connect(`${wsUrl}/api/v1/ws/${sessionConfig.sessionId}`, sessionConfig.jwt);
|
|
2293
|
+
console.log("Connected to reley. Forwarding terminal output...");
|
|
2294
|
+
ptyManager.onData(async (data) => {
|
|
2295
|
+
process.stdout.write(data);
|
|
2296
|
+
const message = {
|
|
2297
|
+
type: "terminal_data",
|
|
2298
|
+
data: Buffer.from(data, "utf-8").toString("base64")
|
|
2299
|
+
};
|
|
2300
|
+
try {
|
|
2301
|
+
const encrypted = await cryptoSession.encryptMessage(message);
|
|
2302
|
+
wsClient.send(encrypted);
|
|
2303
|
+
} catch (err) {
|
|
2304
|
+
console.error("Encryption error:", err);
|
|
2305
|
+
}
|
|
2306
|
+
});
|
|
2307
|
+
if (process.stdin.isTTY) {
|
|
2308
|
+
process.stdin.setRawMode(true);
|
|
2309
|
+
}
|
|
2310
|
+
process.stdin.resume();
|
|
2311
|
+
process.stdin.on("data", (data) => {
|
|
2312
|
+
ptyManager.write(data.toString("utf-8"));
|
|
2313
|
+
});
|
|
2314
|
+
process.stdout.on("resize", () => {
|
|
2315
|
+
const cols = process.stdout.columns || 80;
|
|
2316
|
+
const rows = process.stdout.rows || 24;
|
|
2317
|
+
ptyManager.resize(cols, rows);
|
|
2318
|
+
});
|
|
2319
|
+
wsClient.onMessage(async (data) => {
|
|
2320
|
+
try {
|
|
2321
|
+
const message = await cryptoSession.decryptMessage(data);
|
|
2322
|
+
switch (message.type) {
|
|
2323
|
+
case "terminal_input": {
|
|
2324
|
+
const inputMsg = message;
|
|
2325
|
+
const inputData = Buffer.from(inputMsg.data, "base64").toString("utf-8");
|
|
2326
|
+
ptyManager.write(inputData);
|
|
2327
|
+
break;
|
|
2328
|
+
}
|
|
2329
|
+
case "terminal_resize": {
|
|
2330
|
+
const resizeMsg = message;
|
|
2331
|
+
ptyManager.resize(resizeMsg.cols, resizeMsg.rows);
|
|
2332
|
+
break;
|
|
2333
|
+
}
|
|
2334
|
+
case "session_close": {
|
|
2335
|
+
const closeMsg = message;
|
|
2336
|
+
console.log(`
|
|
2337
|
+
Remote session closed: ${closeMsg.reason}`);
|
|
2338
|
+
cleanup();
|
|
2339
|
+
break;
|
|
2340
|
+
}
|
|
2341
|
+
default:
|
|
2342
|
+
break;
|
|
2343
|
+
}
|
|
2344
|
+
} catch (err) {
|
|
2345
|
+
console.error("Decryption/handling error:", err);
|
|
2346
|
+
}
|
|
2347
|
+
});
|
|
2348
|
+
ptyManager.onExit(async (exitCode, signal) => {
|
|
2349
|
+
console.log(`
|
|
2350
|
+
Process exited with code ${exitCode}${signal ? ` (signal ${signal})` : ""}`);
|
|
2351
|
+
const closeMessage = {
|
|
2352
|
+
type: "session_close",
|
|
2353
|
+
reason: `Process exited with code ${exitCode}`
|
|
2354
|
+
};
|
|
2355
|
+
try {
|
|
2356
|
+
const encrypted = await cryptoSession.encryptMessage(closeMessage);
|
|
2357
|
+
wsClient.send(encrypted);
|
|
2358
|
+
} catch {
|
|
2359
|
+
}
|
|
2360
|
+
await cryptoSession.saveState(configDir);
|
|
2361
|
+
cleanup();
|
|
2362
|
+
});
|
|
2363
|
+
wsClient.onDisconnect(() => {
|
|
2364
|
+
console.log("\nDisconnected from reley. Attempting reconnection...");
|
|
2365
|
+
});
|
|
2366
|
+
wsClient.onConnect(() => {
|
|
2367
|
+
console.log("Reconnected to reley.");
|
|
2368
|
+
});
|
|
2369
|
+
function cleanup() {
|
|
2370
|
+
ptyManager.kill();
|
|
2371
|
+
wsClient.close();
|
|
2372
|
+
if (process.stdin.isTTY) {
|
|
2373
|
+
process.stdin.setRawMode(false);
|
|
2374
|
+
}
|
|
2375
|
+
process.stdin.pause();
|
|
2376
|
+
process.exit(0);
|
|
2377
|
+
}
|
|
2378
|
+
process.on("SIGINT", cleanup);
|
|
2379
|
+
process.on("SIGTERM", cleanup);
|
|
2380
|
+
}
|
|
2381
|
+
|
|
2382
|
+
// src/commands/status.ts
|
|
2383
|
+
import * as fs4 from "node:fs/promises";
|
|
2384
|
+
import * as path4 from "node:path";
|
|
2385
|
+
async function statusCommand(options) {
|
|
2386
|
+
const { configDir } = options;
|
|
2387
|
+
console.log("Reley Status");
|
|
2388
|
+
console.log("=================\n");
|
|
2389
|
+
const configPath = path4.join(configDir, "session.json");
|
|
2390
|
+
let sessionConfig = null;
|
|
2391
|
+
try {
|
|
2392
|
+
const raw = await fs4.readFile(configPath, "utf-8");
|
|
2393
|
+
sessionConfig = JSON.parse(raw);
|
|
2394
|
+
} catch {
|
|
2395
|
+
}
|
|
2396
|
+
if (!sessionConfig) {
|
|
2397
|
+
console.log("Pairing: Not paired");
|
|
2398
|
+
console.log('\nRun "reley pair" to pair with a mobile device.\n');
|
|
2399
|
+
} else {
|
|
2400
|
+
console.log("Pairing: Paired");
|
|
2401
|
+
console.log(`Device ID: ${sessionConfig.deviceId}`);
|
|
2402
|
+
console.log(`Session ID: ${sessionConfig.sessionId}`);
|
|
2403
|
+
console.log(`Reley URL: ${sessionConfig.releyUrl}`);
|
|
2404
|
+
console.log(`Paired at: ${sessionConfig.pairedAt || "Unknown"}`);
|
|
2405
|
+
if (sessionConfig.peerPublicKey) {
|
|
2406
|
+
const truncatedKey = sessionConfig.peerPublicKey.substring(0, 16) + "...";
|
|
2407
|
+
console.log(`Peer key: ${truncatedKey}`);
|
|
2408
|
+
}
|
|
2409
|
+
console.log(`Identity: ${sessionConfig.identityPublicKey.substring(0, 16)}...`);
|
|
2410
|
+
}
|
|
2411
|
+
const releyUrl = sessionConfig?.releyUrl || options.releyUrl;
|
|
2412
|
+
console.log(`
|
|
2413
|
+
Reley Health (${releyUrl}):`);
|
|
2414
|
+
try {
|
|
2415
|
+
const healthRes = await fetch(`${releyUrl}/api/v1/health`, {
|
|
2416
|
+
signal: AbortSignal.timeout(5e3)
|
|
2417
|
+
});
|
|
2418
|
+
if (healthRes.ok) {
|
|
2419
|
+
const healthData = await healthRes.json();
|
|
2420
|
+
console.log(` Status: ${healthData.status}`);
|
|
2421
|
+
if (healthData.version) {
|
|
2422
|
+
console.log(` Version: ${healthData.version}`);
|
|
2423
|
+
}
|
|
2424
|
+
if (healthData.uptime !== void 0) {
|
|
2425
|
+
console.log(` Uptime: ${Math.round(healthData.uptime)}s`);
|
|
2426
|
+
}
|
|
2427
|
+
} else {
|
|
2428
|
+
console.log(` Status: Error (HTTP ${healthRes.status})`);
|
|
2429
|
+
}
|
|
2430
|
+
} catch (err) {
|
|
2431
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
2432
|
+
console.log(` Status: Unreachable (${errorMessage})`);
|
|
2433
|
+
}
|
|
2434
|
+
const ratchetPath = path4.join(configDir, "ratchet-state.json");
|
|
2435
|
+
try {
|
|
2436
|
+
await fs4.access(ratchetPath);
|
|
2437
|
+
console.log("\nCrypto: Ratchet state saved");
|
|
2438
|
+
} catch {
|
|
2439
|
+
console.log("\nCrypto: No ratchet state (new session)");
|
|
2440
|
+
}
|
|
2441
|
+
const hooksSocketPath = path4.join(configDir, "hooks.sock");
|
|
2442
|
+
try {
|
|
2443
|
+
await fs4.access(hooksSocketPath);
|
|
2444
|
+
console.log("Hooks: Socket exists");
|
|
2445
|
+
} catch {
|
|
2446
|
+
console.log("Hooks: Not active");
|
|
2447
|
+
}
|
|
2448
|
+
console.log("");
|
|
2449
|
+
}
|
|
2450
|
+
|
|
2451
|
+
// src/commands/install-hooks.ts
|
|
2452
|
+
import * as fs5 from "node:fs/promises";
|
|
2453
|
+
import * as path5 from "node:path";
|
|
2454
|
+
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
2455
|
+
async function installHooksCommand(options) {
|
|
2456
|
+
const { configDir } = options;
|
|
2457
|
+
console.log("Installing Claude Code hooks for Reley...\n");
|
|
2458
|
+
const __filename = fileURLToPath2(import.meta.url);
|
|
2459
|
+
const __dirname2 = path5.dirname(__filename);
|
|
2460
|
+
const hooksTemplatePath = path5.resolve(__dirname2, "..", "hooks", "hooks.json");
|
|
2461
|
+
let hooksConfig;
|
|
2462
|
+
try {
|
|
2463
|
+
const raw = await fs5.readFile(hooksTemplatePath, "utf-8");
|
|
2464
|
+
hooksConfig = JSON.parse(raw);
|
|
2465
|
+
} catch {
|
|
2466
|
+
hooksConfig = {
|
|
2467
|
+
hooks: {
|
|
2468
|
+
Stop: [{ matcher: "", command: "reley-hook stop" }],
|
|
2469
|
+
Notification: [{ matcher: "", command: "reley-hook notification" }],
|
|
2470
|
+
PreToolUse: [
|
|
2471
|
+
{ matcher: "Bash", command: "reley-hook pre-tool-use" },
|
|
2472
|
+
{ matcher: "Write", command: "reley-hook pre-tool-use" },
|
|
2473
|
+
{ matcher: "Edit", command: "reley-hook pre-tool-use" }
|
|
2474
|
+
]
|
|
2475
|
+
}
|
|
2476
|
+
};
|
|
2477
|
+
}
|
|
2478
|
+
const claudeConfigDir = path5.join(process.env.HOME || "~", ".claude");
|
|
2479
|
+
const hooksDir = path5.join(claudeConfigDir, "hooks");
|
|
2480
|
+
await fs5.mkdir(hooksDir, { recursive: true });
|
|
2481
|
+
const hooksOutputPath = path5.join(hooksDir, "reley-hooks.json");
|
|
2482
|
+
await fs5.writeFile(hooksOutputPath, JSON.stringify(hooksConfig, null, 2), "utf-8");
|
|
2483
|
+
const socketPath = path5.join(configDir, "hooks.sock");
|
|
2484
|
+
const hookScript = createHookScript2(socketPath);
|
|
2485
|
+
const binDir = path5.join(configDir, "bin");
|
|
2486
|
+
await fs5.mkdir(binDir, { recursive: true });
|
|
2487
|
+
const hookScriptPath = path5.join(binDir, "reley-hook");
|
|
2488
|
+
await fs5.writeFile(hookScriptPath, hookScript, { mode: 493 });
|
|
2489
|
+
console.log("Installed files:");
|
|
2490
|
+
console.log(` Hooks config: ${hooksOutputPath}`);
|
|
2491
|
+
console.log(` Hook script: ${hookScriptPath}`);
|
|
2492
|
+
console.log(` Socket path: ${socketPath}`);
|
|
2493
|
+
console.log("");
|
|
2494
|
+
console.log("Hook configuration:");
|
|
2495
|
+
console.log(" Stop: Sends stop events to mobile");
|
|
2496
|
+
console.log(" Notification: Forwards notifications to mobile");
|
|
2497
|
+
console.log(" PreToolUse: Requires mobile approval for Bash, Write, Edit");
|
|
2498
|
+
console.log("");
|
|
2499
|
+
console.log("To activate, add to your PATH:");
|
|
2500
|
+
console.log(` export PATH="${binDir}:$PATH"`);
|
|
2501
|
+
console.log("");
|
|
2502
|
+
console.log("Or create a symlink:");
|
|
2503
|
+
console.log(` ln -sf ${hookScriptPath} /usr/local/bin/reley-hook`);
|
|
2504
|
+
console.log("");
|
|
2505
|
+
}
|
|
2506
|
+
function createHookScript2(socketPath) {
|
|
2507
|
+
return `#!/usr/bin/env node
|
|
2508
|
+
import * as net from 'node:net';
|
|
2509
|
+
import * as process from 'node:process';
|
|
2510
|
+
|
|
2511
|
+
const SOCKET_PATH = ${JSON.stringify(socketPath)};
|
|
2512
|
+
const HOOK_TYPE = process.argv[2]; // stop, notification, pre-tool-use
|
|
2513
|
+
const TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
|
2514
|
+
|
|
2515
|
+
async function main() {
|
|
2516
|
+
// Read hook data from stdin
|
|
2517
|
+
const chunks = [];
|
|
2518
|
+
for await (const chunk of process.stdin) {
|
|
2519
|
+
chunks.push(chunk);
|
|
2520
|
+
}
|
|
2521
|
+
const hookData = Buffer.concat(chunks).toString('utf-8');
|
|
2522
|
+
|
|
2523
|
+
// Send to daemon via Unix socket
|
|
2524
|
+
const payload = JSON.stringify({
|
|
2525
|
+
hookType: HOOK_TYPE,
|
|
2526
|
+
data: hookData ? JSON.parse(hookData) : {},
|
|
2527
|
+
timestamp: Date.now(),
|
|
2528
|
+
});
|
|
2529
|
+
|
|
2530
|
+
const response = await new Promise((resolve, reject) => {
|
|
2531
|
+
const timeout = setTimeout(() => {
|
|
2532
|
+
reject(new Error('Hook response timeout'));
|
|
2533
|
+
}, TIMEOUT_MS);
|
|
2534
|
+
|
|
2535
|
+
const client = net.createConnection(SOCKET_PATH, () => {
|
|
2536
|
+
client.write(payload + '\\n');
|
|
2537
|
+
});
|
|
2538
|
+
|
|
2539
|
+
let responseData = '';
|
|
2540
|
+
client.on('data', (data) => {
|
|
2541
|
+
responseData += data.toString();
|
|
2542
|
+
});
|
|
2543
|
+
|
|
2544
|
+
client.on('end', () => {
|
|
2545
|
+
clearTimeout(timeout);
|
|
2546
|
+
try {
|
|
2547
|
+
resolve(JSON.parse(responseData));
|
|
2548
|
+
} catch {
|
|
2549
|
+
resolve({ action: 'approve' });
|
|
2550
|
+
}
|
|
2551
|
+
});
|
|
2552
|
+
|
|
2553
|
+
client.on('error', (err) => {
|
|
2554
|
+
clearTimeout(timeout);
|
|
2555
|
+
// If daemon is not running, default to approve
|
|
2556
|
+
console.error('Reley daemon not available:', err.message);
|
|
2557
|
+
resolve({ action: 'approve' });
|
|
2558
|
+
});
|
|
2559
|
+
});
|
|
2560
|
+
|
|
2561
|
+
// Output response for Claude Code to read
|
|
2562
|
+
console.log(JSON.stringify(response));
|
|
2563
|
+
}
|
|
2564
|
+
|
|
2565
|
+
main().catch((err) => {
|
|
2566
|
+
console.error('reley-hook error:', err);
|
|
2567
|
+
// Default to approve on error
|
|
2568
|
+
console.log(JSON.stringify({ action: 'approve' }));
|
|
2569
|
+
});
|
|
2570
|
+
`;
|
|
2571
|
+
}
|
|
2572
|
+
|
|
2573
|
+
// src/commands/login.ts
|
|
2574
|
+
init_config();
|
|
2575
|
+
import { createServer } from "node:http";
|
|
2576
|
+
import { URL } from "node:url";
|
|
2577
|
+
import { hostname } from "node:os";
|
|
2578
|
+
var DEFAULT_RELEY_URL = "https://api.reley.sh";
|
|
2579
|
+
async function loginCommand(opts) {
|
|
2580
|
+
const releyUrl = opts.releyUrl || process.env.RELEY_URL || DEFAULT_RELEY_URL;
|
|
2581
|
+
let supabaseUrl = process.env.SUPABASE_URL;
|
|
2582
|
+
let supabaseAnonKey = process.env.SUPABASE_ANON_KEY;
|
|
2583
|
+
if (!supabaseUrl || !supabaseAnonKey) {
|
|
2584
|
+
try {
|
|
2585
|
+
const configRes = await fetch(`${releyUrl}/api/v1/auth/config`);
|
|
2586
|
+
if (!configRes.ok) throw new Error(`HTTP ${configRes.status}`);
|
|
2587
|
+
const config = await configRes.json();
|
|
2588
|
+
supabaseUrl = config.supabaseUrl;
|
|
2589
|
+
supabaseAnonKey = config.supabaseAnonKey;
|
|
2590
|
+
} catch {
|
|
2591
|
+
console.error(
|
|
2592
|
+
"\x1B[31mError:\x1B[0m Could not connect to reley server at " + releyUrl
|
|
2593
|
+
);
|
|
2594
|
+
console.error("Check that the server is running and the URL is correct.");
|
|
2595
|
+
process.exit(1);
|
|
2596
|
+
}
|
|
2597
|
+
}
|
|
2598
|
+
console.log("\x1B[36m[Reley]\x1B[0m Starting Google login...\n");
|
|
2599
|
+
const { token, email } = await startOAuthFlow(supabaseUrl, supabaseAnonKey);
|
|
2600
|
+
console.log(`\x1B[32m\u2713\x1B[0m Logged in as \x1B[1m${email}\x1B[0m
|
|
2601
|
+
`);
|
|
2602
|
+
console.log("\x1B[36m[Reley]\x1B[0m Registering device...");
|
|
2603
|
+
const deviceName = hostname() || "Unknown Device";
|
|
2604
|
+
const res = await fetch(`${releyUrl}/api/v1/devices/register`, {
|
|
2605
|
+
method: "POST",
|
|
2606
|
+
headers: {
|
|
2607
|
+
"Content-Type": "application/json",
|
|
2608
|
+
Authorization: `Bearer ${token}`
|
|
2609
|
+
},
|
|
2610
|
+
body: JSON.stringify({ name: deviceName })
|
|
2611
|
+
});
|
|
2612
|
+
if (!res.ok) {
|
|
2613
|
+
const body = await res.json().catch(() => ({}));
|
|
2614
|
+
console.error(
|
|
2615
|
+
`\x1B[31mError:\x1B[0m Failed to register device: ${body.error || res.statusText}`
|
|
2616
|
+
);
|
|
2617
|
+
process.exit(1);
|
|
2618
|
+
}
|
|
2619
|
+
const { deviceId, deviceToken } = await res.json();
|
|
2620
|
+
saveConfig({
|
|
2621
|
+
reley_url: releyUrl,
|
|
2622
|
+
device_token: deviceToken,
|
|
2623
|
+
device_id: deviceId,
|
|
2624
|
+
user_email: email
|
|
2625
|
+
});
|
|
2626
|
+
console.log(`\x1B[32m\u2713\x1B[0m Device registered: \x1B[1m${deviceName}\x1B[0m`);
|
|
2627
|
+
console.log(`\x1B[32m\u2713\x1B[0m Config saved to ${getConfigPath()}
|
|
2628
|
+
`);
|
|
2629
|
+
console.log("You can now run \x1B[1mreley\x1B[0m to start a session.");
|
|
2630
|
+
}
|
|
2631
|
+
async function startOAuthFlow(supabaseUrl, supabaseAnonKey) {
|
|
2632
|
+
return new Promise((resolve2, reject) => {
|
|
2633
|
+
const CALLBACK_PORT = 54321;
|
|
2634
|
+
const redirectUri = `http://localhost:${CALLBACK_PORT}/auth/callback`;
|
|
2635
|
+
const server = createServer((req, res) => {
|
|
2636
|
+
const url = new URL(req.url, `http://localhost:${CALLBACK_PORT}`);
|
|
2637
|
+
if (url.pathname === "/auth/callback") {
|
|
2638
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
2639
|
+
res.end(`<!DOCTYPE html>
|
|
2640
|
+
<html><body>
|
|
2641
|
+
<script>
|
|
2642
|
+
// Supabase puts tokens in the hash fragment
|
|
2643
|
+
const hash = window.location.hash.substring(1);
|
|
2644
|
+
const params = new URLSearchParams(hash);
|
|
2645
|
+
const accessToken = params.get('access_token');
|
|
2646
|
+
|
|
2647
|
+
if (accessToken) {
|
|
2648
|
+
fetch('/auth/token', {
|
|
2649
|
+
method: 'POST',
|
|
2650
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2651
|
+
body: JSON.stringify({ access_token: accessToken })
|
|
2652
|
+
}).then(() => {
|
|
2653
|
+
document.body.innerHTML = '<h2 style="font-family:system-ui;text-align:center;margin-top:100px">Login successful! You can close this tab.</h2>';
|
|
2654
|
+
});
|
|
2655
|
+
} else {
|
|
2656
|
+
document.body.innerHTML = '<h2 style="font-family:system-ui;text-align:center;margin-top:100px;color:red">Login failed. Please try again.</h2>';
|
|
2657
|
+
}
|
|
2658
|
+
</script>
|
|
2659
|
+
<h2 style="font-family:system-ui;text-align:center;margin-top:100px">Processing login...</h2>
|
|
2660
|
+
</body></html>`);
|
|
2661
|
+
return;
|
|
2662
|
+
}
|
|
2663
|
+
if (url.pathname === "/auth/token" && req.method === "POST") {
|
|
2664
|
+
let body = "";
|
|
2665
|
+
req.on("data", (chunk) => {
|
|
2666
|
+
body += chunk.toString();
|
|
2667
|
+
});
|
|
2668
|
+
req.on("end", async () => {
|
|
2669
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
2670
|
+
res.end(JSON.stringify({ ok: true }));
|
|
2671
|
+
try {
|
|
2672
|
+
const { access_token } = JSON.parse(body);
|
|
2673
|
+
const userRes = await fetch(`${supabaseUrl}/auth/v1/user`, {
|
|
2674
|
+
headers: {
|
|
2675
|
+
Authorization: `Bearer ${access_token}`,
|
|
2676
|
+
apikey: supabaseAnonKey
|
|
2677
|
+
}
|
|
2678
|
+
});
|
|
2679
|
+
if (!userRes.ok) {
|
|
2680
|
+
reject(new Error("Failed to verify token"));
|
|
2681
|
+
return;
|
|
2682
|
+
}
|
|
2683
|
+
const user = await userRes.json();
|
|
2684
|
+
server.close();
|
|
2685
|
+
resolve2({ token: access_token, email: user.email });
|
|
2686
|
+
} catch (err) {
|
|
2687
|
+
reject(err);
|
|
2688
|
+
}
|
|
2689
|
+
});
|
|
2690
|
+
return;
|
|
2691
|
+
}
|
|
2692
|
+
res.writeHead(404);
|
|
2693
|
+
res.end("Not found");
|
|
2694
|
+
});
|
|
2695
|
+
server.listen(CALLBACK_PORT, () => {
|
|
2696
|
+
const authUrl = `${supabaseUrl}/auth/v1/authorize?` + new URLSearchParams({
|
|
2697
|
+
provider: "google",
|
|
2698
|
+
redirect_to: redirectUri
|
|
2699
|
+
}).toString();
|
|
2700
|
+
console.log(
|
|
2701
|
+
`\x1B[36m[Reley]\x1B[0m Opening browser for Google login...`
|
|
2702
|
+
);
|
|
2703
|
+
console.log(`\x1B[2m${authUrl}\x1B[0m
|
|
2704
|
+
`);
|
|
2705
|
+
const openCmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
2706
|
+
import("node:child_process").then(({ spawn: spawnChild }) => {
|
|
2707
|
+
spawnChild(openCmd, [authUrl], { stdio: "ignore", detached: true }).unref();
|
|
2708
|
+
});
|
|
2709
|
+
setTimeout(() => {
|
|
2710
|
+
server.close();
|
|
2711
|
+
reject(new Error("Login timed out (5 minutes)"));
|
|
2712
|
+
}, 5 * 60 * 1e3);
|
|
2713
|
+
});
|
|
2714
|
+
server.on("error", (err) => {
|
|
2715
|
+
reject(new Error(`Failed to start callback server: ${err.message}`));
|
|
2716
|
+
});
|
|
2717
|
+
});
|
|
2718
|
+
}
|
|
2719
|
+
|
|
2720
|
+
// src/index.ts
|
|
2721
|
+
var program = new Command();
|
|
2722
|
+
program.name("reley").description("Bridge your terminal to mobile/web with E2E encryption").version("0.1.0");
|
|
2723
|
+
program.option("--reley-url <url>", "Reley server URL", "http://localhost:3100").option("--config-dir <path>", "Configuration directory", `${process.env.HOME}/.reley`);
|
|
2724
|
+
program.command("run [command...]", { isDefault: true }).description("Run a command with web terminal sync (default)").option("--web-port <port>", "Web UI port", "3200").option("--reley-port <port>", "Reley server port", "3100").option("--online", "Use online reley server (requires `reley login` first)").action(async (commandArgs, opts) => {
|
|
2725
|
+
await runCommand(commandArgs, { webPort: opts.webPort, releyPort: opts.releyPort, online: opts.online });
|
|
2726
|
+
});
|
|
2727
|
+
program.command("login").description("Login with Google and register this device").option("--reley-url <url>", "Reley server URL").action(async (opts) => {
|
|
2728
|
+
const parentOpts = program.opts();
|
|
2729
|
+
await loginCommand({ releyUrl: opts.releyUrl || parentOpts.releyUrl });
|
|
2730
|
+
});
|
|
2731
|
+
program.command("pair").description("Pair this device with a mobile client").action(async () => {
|
|
2732
|
+
const opts = program.opts();
|
|
2733
|
+
await pairCommand({ releyUrl: opts.releyUrl, configDir: opts.configDir });
|
|
2734
|
+
});
|
|
2735
|
+
program.command("wrap <command...>").description("Wrap a command with existing session (requires prior pairing)").action(async (commandArgs) => {
|
|
2736
|
+
const opts = program.opts();
|
|
2737
|
+
await wrapCommand(commandArgs, { releyUrl: opts.releyUrl, configDir: opts.configDir });
|
|
2738
|
+
});
|
|
2739
|
+
program.command("status").description("Show pairing and connection status").action(async () => {
|
|
2740
|
+
const opts = program.opts();
|
|
2741
|
+
await statusCommand({ releyUrl: opts.releyUrl, configDir: opts.configDir });
|
|
2742
|
+
});
|
|
2743
|
+
program.command("install-hooks").description("Install Claude Code hooks for Reley integration").action(async () => {
|
|
2744
|
+
const opts = program.opts();
|
|
2745
|
+
await installHooksCommand({ releyUrl: opts.releyUrl, configDir: opts.configDir });
|
|
2746
|
+
});
|
|
2747
|
+
program.parseAsync(process.argv).catch((err) => {
|
|
2748
|
+
console.error("Fatal error:", err);
|
|
2749
|
+
process.exit(1);
|
|
2750
|
+
});
|