reley 0.1.1 → 0.1.4
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 +44 -2082
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,449 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
var
|
|
3
|
-
|
|
4
|
-
var
|
|
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("Not logged in. Run `reley login` first, or use `reley --online` to connect to the remote server.");
|
|
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>
|
|
2
|
+
var Ct=Object.defineProperty;var Et=(t,e)=>()=>(t&&(e=t(t=0)),e);var _t=(t,e)=>{for(var o in e)Ct(t,o,{get:e[o],enumerable:!0})};var st={};_t(st,{clearConfig:()=>Lt,getConfigDir:()=>Dt,getConfigPath:()=>$e,isConfigured:()=>je,loadConfig:()=>nt,saveConfig:()=>Be});import{existsSync as tt,readFileSync as It,writeFileSync as ot,mkdirSync as Kt}from"node:fs";import{join as rt}from"node:path";import{homedir as Ut}from"node:os";function Dt(){return Me}function $e(){return le}function nt(){try{if(!tt(le))return null;let t=It(le,"utf-8");return JSON.parse(t)}catch{return null}}function Be(t){Kt(Me,{recursive:!0}),ot(le,JSON.stringify(t,null,2)+`
|
|
3
|
+
`,"utf-8")}function Lt(){try{tt(le)&&ot(le,`{}
|
|
4
|
+
`,"utf-8")}catch{}}function je(){let t=nt();return!!(t?.reley_url&&t?.device_token)}var Me,le,we=Et(()=>{"use strict";Me=rt(Ut(),".reley"),le=rt(Me,"config.json")});import{Command as ao}from"commander";import{createServer as Ht}from"node:http";import{createServer as at}from"node:net";import{fork as Mt}from"node:child_process";import{existsSync as Ge,readFileSync as ct,writeFileSync as Je,unlinkSync as de}from"node:fs";import{fileURLToPath as $t}from"node:url";import{dirname as Bt,join as ze}from"node:path";import{homedir as jt}from"node:os";import Fe from"node:crypto";import R,{WebSocketServer as Ft}from"ws";import{spawn as lt}from"node-pty";import{createRequire as Tt}from"node:module";var Pt=Tt(import.meta.url),Rt=Pt("libsodium-wrappers-sumo"),Oe=Rt;var Ve=!1;async function f(){return Ve||(await Oe.ready,Ve=!0),Oe}async function Ne(){let e=(await f()).crypto_sign_keypair();return{publicKey:e.publicKey,secretKey:e.privateKey,keyType:"ed25519"}}async function re(){let e=(await f()).crypto_kx_keypair();return{publicKey:e.publicKey,secretKey:e.privateKey,keyType:"x25519"}}async function Ae(t=32){return(await f()).randombytes_buf(t)}async function J(t,e){return(await f()).crypto_scalarmult(t,e)}var ve=32;async function Ot(t,e){let o=await f(),r=t.length>0?t:new Uint8Array(ve);return o.crypto_auth_hmacsha256(e,r)}async function Nt(t,e,o){let r=await f(),n=Math.ceil(o/ve),s=new Uint8Array(n*ve),i=new Uint8Array(0);for(let c=1;c<=n;c++){let a=new Uint8Array(i.length+e.length+1);a.set(i,0),a.set(e,i.length),a[i.length+e.length]=c,i=new Uint8Array(r.crypto_auth_hmacsha256(a,t)),s.set(i,(c-1)*ve)}return s.slice(0,o)}async function W(t,e=new Uint8Array(0),o="reley-v1"){let r=new TextEncoder().encode(o),n=await Ot(e,t),s=await Nt(n,r,64);return{sendKey:s.slice(0,32),recvKey:s.slice(32,64)}}async function be(t,e="reley-chain"){let o=await f(),r=new TextEncoder().encode(e+"-msg"),n=new TextEncoder().encode(e+"-chain"),s=o.crypto_auth_hmacsha256(r,t),i=o.crypto_auth_hmacsha256(n,t);return{messageKey:s,nextChainKey:i}}var qe=12;var me=32;async function Ie(t,e,o=new Uint8Array(0)){let r=await f();if(e.length!==me)throw new Error(`Key must be ${me} bytes, got ${e.length}`);let n=r.randombytes_buf(qe),s=r.crypto_aead_xchacha20poly1305_ietf_encrypt(t,o.length>0?o:null,null,Xe(n,r),e);return{nonce:n,ciphertext:s}}async function Ke(t,e,o,r=new Uint8Array(0)){let n=await f();if(o.length!==me)throw new Error(`Key must be ${me} bytes, got ${o.length}`);try{return n.crypto_aead_xchacha20poly1305_ietf_decrypt(null,t,r.length>0?r:null,Xe(e,n),o)}catch{throw new Error("Decryption failed: invalid ciphertext or key")}}function Xe(t,e){let o=new Uint8Array(e.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES);return o.set(t,0),o}var Qe=50;function Y(t,e){return{sendChainKey:t,recvChainKey:e,sendCounter:0,recvCounter:0,maxRecvCounter:-1}}async function V(t,e){let{messageKey:o,nextChainKey:r}=await be(t.sendChainKey),n=t.sendCounter,s=Ze(1,n),{nonce:i,ciphertext:c}=await Ie(e,o,s),a={...t,sendChainKey:r,sendCounter:n+1};return{ciphertext:c,nonce:i,counter:n,state:a}}async function se(t,e,o,r){if(r<=t.maxRecvCounter)throw new Error(`Replay attack detected: counter ${r} <= ${t.maxRecvCounter}`);let n=t.recvChainKey,s=t.recvCounter,i;for(;s<=r;){let m=await be(n);s===r&&(i=m.messageKey),n=m.nextChainKey,s++}if(!i)throw new Error("Failed to derive message key");let c=Ze(1,r),a=await Ke(e,o,i,c),u={...t,recvChainKey:n,recvCounter:s,maxRecvCounter:r};return{plaintext:a,state:u}}function Ue(t){return t.sendCounter>0&&t.sendCounter%Qe===0}function Ze(t,e){let o=new Uint8Array(6);return o[0]=t,o[1]=1,o[2]=e>>>24&255,o[3]=e>>>16&255,o[4]=e>>>8&255,o[5]=e&255,o}var De="CB1";async function Le(t){let e=await f(),o=new TextEncoder().encode(t.releyUrl),r=new TextEncoder().encode(t.jwt),n=5+o.length+32+32+2+r.length,s=new Uint8Array(n),i=0;return s[i++]=De.charCodeAt(0),s[i++]=De.charCodeAt(1),s[i++]=De.charCodeAt(2),s[i++]=o.length>>>8&255,s[i++]=o.length&255,s.set(o,i),i+=o.length,s.set(t.publicKey,i),i+=32,s.set(t.oneTimeCode,i),i+=32,s[i++]=r.length>>>8&255,s[i++]=r.length&255,s.set(r,i),e.to_base64(s,e.base64_variants.URLSAFE_NO_PADDING)}async function He(t){return(await f()).crypto_generichash(32,t,null)}var q={VERSION:1,TYPE:1,COUNTER:4,NONCE:12,TAG:16,HEADER:18},K={TERMINAL_DATA:1,TERMINAL_RESIZE:2,TERMINAL_INPUT:3,HOOK_EVENT:16,HOOK_RESPONSE:17,SESSION_PING:32,SESSION_PONG:33,SESSION_CLOSE:34,KEY_ROTATION:48,KEY_EXCHANGE:49},ie={PAIRING_EXPIRY:5*60*1e3,JWT_EXPIRY:24*60*60*1e3,PING_INTERVAL:30*1e3,PONG_TIMEOUT:10*1e3,WS_RECONNECT_BASE:1e3,WS_RECONNECT_MAX:30*1e3,HOOK_RESPONSE_TIMEOUT:5*60*1e3};var et={terminal_data:K.TERMINAL_DATA,terminal_resize:K.TERMINAL_RESIZE,terminal_input:K.TERMINAL_INPUT,hook_event:K.HOOK_EVENT,hook_response:K.HOOK_RESPONSE,ping:K.SESSION_PING,pong:K.SESSION_PONG,session_close:K.SESSION_CLOSE,key_rotation:K.KEY_ROTATION,key_exchange:K.KEY_EXCHANGE},At=new Map;for(let[t,e]of Object.entries(et))At.set(e,t);function X(t){let e=et[t];if(e===void 0)throw new Error(`Unknown message type: ${t}`);return e}function Q(t){let e=q.HEADER+t.ciphertext.length,o=new Uint8Array(e),r=0;return o[r++]=t.version,o[r++]=t.type,o[r++]=t.counter>>>24&255,o[r++]=t.counter>>>16&255,o[r++]=t.counter>>>8&255,o[r++]=t.counter&255,o.set(t.nonce,r),r+=q.NONCE,o.set(t.ciphertext,r),o}function ae(t){if(t.length<q.HEADER+q.TAG)throw new Error(`Envelope too short: ${t.length} bytes`);let e=0,o=t[e++];if(o!==1)throw new Error(`Unsupported protocol version: ${o}`);let r=t[e++],n=t[e]<<24|t[e+1]<<16|t[e+2]<<8|t[e+3];e+=4;let s=t.slice(e,e+q.NONCE);e+=q.NONCE;let i=t.slice(e);return{version:o,type:r,counter:n,nonce:s,ciphertext:i}}function Z(t){return new TextEncoder().encode(JSON.stringify(t))}function ce(t){return JSON.parse(new TextDecoder().decode(t))}var it=Bt($t(import.meta.url)),We=!0;function E(t,e){if(!We)return;console.error(`${{RELEY:"\x1B[34m",PTY:"\x1B[32m",WEB:"\x1B[35m",CRYPTO:"\x1B[33m",HOOKS:"\x1B[36m"}[t]??""}[${t}]\x1B[0m ${e}`)}function zt(){let t=[ze(it,"../../../reley/dist/server.js"),ze(it,"../../../../apps/reley/dist/server.js")];for(let e of t)if(Ge(e))return e;throw new Error("Not logged in. Run `reley login` first, or use `reley` to connect to the remote server.")}async function dt(t){try{return(await fetch(`${t}/health`)).ok}catch{return!1}}async function Gt(t){for(let e=0;e<50;e++){if(await dt(t))return;await new Promise(o=>setTimeout(o,200))}throw new Error("Reley server failed to start")}function Jt(){return`<!DOCTYPE html>
|
|
447
5
|
<html lang="ko"><head>
|
|
448
6
|
<meta charset="utf-8">
|
|
449
7
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
@@ -998,14 +556,7 @@ function getTerminalHtml() {
|
|
|
998
556
|
return d.innerHTML;
|
|
999
557
|
}
|
|
1000
558
|
</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
|
|
559
|
+
</body></html>`}function Wt(){return ze(jt(),".claude","settings.json")}function pt(t){let e=`/tmp/reley-hook-${process.pid}`;return Je(e,`#!/usr/bin/env node
|
|
1009
560
|
const net = require('net');
|
|
1010
561
|
|
|
1011
562
|
// Session isolation: only run for the Reley session that set this env var.
|
|
@@ -1039,1476 +590,42 @@ process.stdin.on('end', () => {
|
|
|
1039
590
|
function tryParse(s) {
|
|
1040
591
|
try { return JSON.parse(s); } catch { return { raw: s }; }
|
|
1041
592
|
}
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
}
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
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(`
|
|
593
|
+
`,{mode:493}),e}function ut(t){let e=Wt(),o={},r={path:e,hadHooks:!1,originalHooks:void 0};try{Ge(e)&&(o=JSON.parse(ct(e,"utf-8")))}catch{o={}}o.hooks&&(r.hadHooks=!0,r.originalHooks=JSON.parse(JSON.stringify(o.hooks))),o.hooks||(o.hooks={});let n=i=>`${t} ${i}`,s={PreToolUse:[{matcher:"Bash",type:"pre-tool-use"},{matcher:"Write",type:"pre-tool-use"},{matcher:"Edit",type:"pre-tool-use"}],Stop:[{matcher:"",type:"stop"}],Notification:[{matcher:"",type:"notification"}]};for(let[i,c]of Object.entries(s)){o.hooks[i]||(o.hooks[i]=[]);for(let a of c){let u=n(a.type);if(!o.hooks[i].some(p=>Array.isArray(p.hooks)&&p.hooks.some(v=>v.command===u))){let p={matcher:a.matcher,hooks:[{type:"command",command:u}]};o.hooks[i].push(p)}}}return Je(e,JSON.stringify(o,null,2),"utf-8"),r}function ht(t,e){try{let o=t.path;if(!Ge(o))return;let r=JSON.parse(ct(o,"utf-8"));if(!r.hooks)return;for(let n of Object.keys(r.hooks))Array.isArray(r.hooks[n])&&(r.hooks[n]=r.hooks[n].filter(s=>!Array.isArray(s.hooks)||!s.hooks.some(i=>i.command?.includes(e))),r.hooks[n].length===0&&delete r.hooks[n]);Object.keys(r.hooks).length===0&&delete r.hooks,Je(o,JSON.stringify(r,null,2),"utf-8")}catch{}}async function mt(t,e){if(e.online)return Yt(t,e);let o=parseInt(e.webPort,10),r=parseInt(e.releyPort,10),n=`http://localhost:${r}`,s=`ws://localhost:${r}/ws`,i=t.length>0?t[0]:process.env.SHELL||"/bin/zsh",c=t.length>1?t.slice(1):[];console.error(`
|
|
594
|
+
\x1B[1m Reley\x1B[0m`),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
|
|
595
|
+
`);let a=null;if(await dt(n))E("RELEY",`Already running on :${r}`);else{E("RELEY",`Starting on :${r}...`);let l;try{l=zt()}catch{console.error(" \x1B[33mLocal server not found.\x1B[0m"),console.error(` Run \x1B[1mreley login\x1B[0m to connect to the remote server.
|
|
596
|
+
`),process.exit(1)}a=Mt(l,[],{env:{...process.env,PORT:String(r),LOG_LEVEL:"warn"},stdio:"pipe"}),await Gt(n),E("RELEY","Ready")}let u=await f(),m=await re(),p=await re(),v=u.randombytes_buf(32),y=Fe.createHash("sha256").update(Buffer.from(v)).digest(),k=await fetch(`${n}/api/v1/pair/initiate`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({publicKey:u.to_base64(m.publicKey,u.base64_variants.URLSAFE_NO_PADDING),otcHash:Buffer.from(y).toString("base64"),deviceId:Fe.randomUUID()})}),{roomId:C,token:j}=await k.json(),x=await fetch(`${n}/api/v1/pair/complete`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({roomId:C,otc:Buffer.from(v).toString("base64"),publicKey:u.to_base64(p.publicKey,u.base64_variants.URLSAFE_NO_PADDING),deviceId:Fe.randomUUID()})}),{token:S}=await x.json();E("CRYPTO","E2E ready");let B=await J(m.secretKey,p.publicKey),O=await J(p.secretKey,m.publicKey),U=await W(B),te=await W(O),ue=Y(U.sendKey,U.recvKey),D=Y(te.recvKey,te.sendKey),oe=new R(s,{headers:{Authorization:`Bearer ${j}`}});await new Promise((l,d)=>{oe.on("open",l),oe.on("error",d)});let ne=new R(s,{headers:{Authorization:`Bearer ${S}`}});await new Promise((l,d)=>{ne.on("open",l),ne.on("error",d)});let he=new Ft({noServer:!0}),T=new Set,z=process.stdout.columns||80,N=process.stdout.rows||24,L=`/tmp/reley-hooks-${process.pid}.sock`;try{de(L)}catch{}let Te=at(l=>{let d="";l.on("data",w=>{d+=w.toString();let g=d.indexOf(`
|
|
597
|
+
`);if(g===-1)return;let P=d.substring(0,g);d=d.substring(g+1);let I;try{I=JSON.parse(P)}catch{l.end(JSON.stringify({action:"approve"}));return}let M=`hook-${Date.now()}-${Math.random().toString(36).slice(2)}`,Re=JSON.stringify({type:"hook_event",hookType:I.hookType,data:I.data,requestId:M});for(let Ye of T)Ye.readyState===R.OPEN&&Ye.send(Re);l.end(JSON.stringify({action:"approve"}))})});await new Promise(l=>{Te.listen(L,()=>l())}),E("HOOKS",`Socket at ${L}`);let H=null,G=null;(i==="claude"||i.endsWith("/claude"))&&(H=pt(L),G=ut(H),E("HOOKS","Claude hooks installed")),ne.on("message",async l=>{if(Buffer.isBuffer(l))try{let d=ae(new Uint8Array(l)),w=await se(D,d.ciphertext,d.nonce,d.counter);D=w.state;let g=ce(w.plaintext);if(g.type==="terminal_data"){let P=Buffer.from(g.data,"base64").toString(),I=JSON.stringify({type:"output",data:P});for(let M of T)M.readyState===R.OPEN&&M.send(I)}}catch{}}),he.on("connection",l=>{T.add(l),l.send(JSON.stringify({type:"sync_size",cols:z,rows:N})),l.on("message",async d=>{try{let w=JSON.parse(d.toString());w.type==="input"&&h.write(w.data)}catch{}}),l.on("close",()=>{T.delete(l)})});let A=Ht((l,d)=>{d.writeHead(200,{"Content-Type":"text/html; charset=utf-8"}),d.end(Jt())});A.on("upgrade",(l,d,w)=>{he.handleUpgrade(l,d,w,g=>{he.emit("connection",g,l)})}),await new Promise(l=>{A.listen(o,()=>l())}),E("WEB",`http://localhost:${o}`),console.error(`
|
|
598
|
+
\x1B[1m\x1B[36m\u2192 http://localhost:${o}\x1B[0m \uC6F9\uC5D0\uC11C\uB3C4 \uD655\uC778/\uC785\uB825 \uAC00\uB2A5
|
|
599
|
+
`),We=!1;let fe={...process.env};L&&(fe.RELEY_HOOK_SOCK=L);let h=lt(i,c,{name:"xterm-256color",cols:z,rows:N,cwd:process.cwd(),env:fe});h.onData(async l=>{process.stdout.write(l);try{let d={type:"terminal_data",data:Buffer.from(l).toString("base64")},w=Z(d),g=await V(ue,w);ue=g.state;let P=Q({version:1,type:X("terminal_data"),counter:g.counter,nonce:g.nonce,ciphertext:g.ciphertext});oe.readyState===R.OPEN&&oe.send(Buffer.from(P))}catch{}}),process.stdin.isTTY&&process.stdin.setRawMode(!0),process.stdin.resume(),process.stdin.on("data",l=>{h.write(l.toString())}),process.stdout.on("resize",()=>{z=process.stdout.columns||80,N=process.stdout.rows||24,h.resize(z,N);let l=JSON.stringify({type:"sync_size",cols:z,rows:N});for(let d of T)d.readyState===R.OPEN&&d.send(l)});let b=()=>{Te.close();try{de(L)}catch{}if(H)try{de(H)}catch{}G&&H&&ht(G,H),oe.close(),ne.close(),A.close(),a&&a.kill()};h.onExit(()=>{b(),process.exit(0)}),process.on("SIGINT",()=>h.kill()),process.on("SIGTERM",()=>h.kill())}async function Yt(t,e){let{loadConfig:o}=await Promise.resolve().then(()=>(we(),st)),r=o();(!r?.device_token||!r?.reley_url)&&(console.error("\x1B[31mError:\x1B[0m Not logged in. Run `reley login` first."),process.exit(1));let n=t.length>0?t[0]:process.env.SHELL||"/bin/zsh",s=t.length>1?t.slice(1):[];console.error(`
|
|
600
|
+
\x1B[1m Reley\x1B[0m \x1B[36m(online)\x1B[0m`),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
|
|
601
|
+
`),console.error(` \x1B[2mUser: ${r.user_email}\x1B[0m`),console.error(` \x1B[2mServer: ${r.reley_url}\x1B[0m
|
|
602
|
+
`);let i=await f(),c=await re(),a=i.to_base64(c.publicKey,i.base64_variants.URLSAFE_NO_PADDING);E("RELEY","Creating session...");let u=t.length>0?t.join(" "):"Terminal session",m=await fetch(`${r.reley_url}/api/v1/sessions`,{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${r.device_token}`},body:JSON.stringify({name:u,publicKey:a})});if(!m.ok){let h=await m.json().catch(()=>({}));h.code==="SUBSCRIPTION_REQUIRED"&&(console.error(" \x1B[33mSubscription required.\x1B[0m"),console.error(` Subscribe at \x1B[1m\x1B[4mhttps://reley.sh/dashboard\x1B[0m
|
|
603
|
+
`),process.exit(1)),h.code==="SUBSCRIPTION_INACTIVE"&&(console.error(` \x1B[33mSubscription ${h.status||"inactive"}.\x1B[0m`),console.error(` Update your subscription at \x1B[1m\x1B[4mhttps://reley.sh/dashboard\x1B[0m
|
|
604
|
+
`),process.exit(1)),m.status===401&&(console.error(` \x1B[31mSession expired.\x1B[0m Run \x1B[1mreley login\x1B[0m to re-authenticate.
|
|
605
|
+
`),process.exit(1)),console.error(` \x1B[31mFailed to create session:\x1B[0m ${h.error||m.statusText}
|
|
606
|
+
`),process.exit(1)}let{roomId:p,cliToken:v,sessionId:y}=await m.json();E("RELEY",`Session: ${y}`),E("RELEY",`Room: ${p}`);let k=r.reley_url.startsWith("https")?"wss":"ws",C=r.reley_url.replace(/^https?:\/\//,""),j=`${k}://${C}/ws`,x=new R(j,{headers:{Authorization:`Bearer ${v}`}});await new Promise((h,b)=>{x.on("open",h),x.on("error",b)}),E("RELEY","WebSocket connected");let S=null,B=!1,O=[],U=Promise.resolve();function te(h){let b=U.then(h,()=>h());return U=b.then(()=>{},()=>{}),b}let ue=100*1024,D=Buffer.alloc(0);function oe(h){D=Buffer.concat([D,h]),D.length>ue&&(D=D.slice(D.length-ue))}async function ne(h){let b=i.from_base64(h,i.base64_variants.URLSAFE_NO_PADDING),l=await J(c.secretKey,b),d=await W(l);S=Y(d.sendKey,d.recvKey);let w=Vt(i,c.publicKey,b);console.error(` \x1B[33m\u{1F510} E2E Fingerprint: ${w}\x1B[0m`),i.memzero(l),x.readyState===R.OPEN&&x.send(JSON.stringify({type:"key_exchange",publicKey:a,role:"cli"})),await te(async()=>{if(S&&D.length>0){let g={type:"terminal_data",data:D.toString("base64")},P=Z(g),I=await V(S,P);S=I.state;let M=Q({version:1,type:X("terminal_data"),counter:I.counter,nonce:I.nonce,ciphertext:I.ciphertext});x.readyState===R.OPEN&&x.send(Buffer.from(M))}}),B=!0,O.length=0}function he(h){if(!S||!B){O.push(h);return}te(async()=>{if(!(!S||x.readyState!==R.OPEN))try{let b={type:"terminal_data",data:h.toString("base64")},l=Z(b),d=await V(S,l);S=d.state;let w=Q({version:1,type:X("terminal_data"),counter:d.counter,nonce:d.nonce,ciphertext:d.ciphertext});x.send(Buffer.from(w))}catch{}})}let T=`/tmp/reley-hooks-${process.pid}.sock`;try{de(T)}catch{}let z=at(h=>{let b="";h.on("data",l=>{b+=l.toString();let d=b.indexOf(`
|
|
607
|
+
`);if(d===-1)return;let w=b.substring(0,d);b=b.substring(d+1);let g;try{g=JSON.parse(w)}catch{h.end(JSON.stringify({action:"approve"}));return}B&&S&&x.readyState===R.OPEN?te(async()=>{if(!S||x.readyState!==R.OPEN)return;let P={type:"hook_event",hookType:g.hookType==="pre-tool-use"?"pre_tool_use":g.hookType,sessionId:y,payload:g.data??{kind:g.hookType,raw:!0}},I=Z(P),M=await V(S,I);S=M.state;let Re=Q({version:1,type:X("hook_event"),counter:M.counter,nonce:M.nonce,ciphertext:M.ciphertext});x.send(Buffer.from(Re))}):x.readyState===R.OPEN&&x.send(JSON.stringify({type:"hook_event",hookType:g.hookType,data:g.data})),h.end(JSON.stringify({action:"approve"}))})});await new Promise(h=>{z.listen(T,()=>h())}),E("HOOKS",`Socket at ${T}`);let N=null,L=null;(n==="claude"||n.endsWith("/claude"))&&(N=pt(T),L=ut(N),E("HOOKS","Claude hooks installed"));let H=process.stdout.columns||80,G=process.stdout.rows||24;console.error(`
|
|
1564
608
|
\x1B[1m\x1B[36m\u2192 View in web dashboard\x1B[0m
|
|
1565
|
-
`);
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
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
|
|
609
|
+
`),We=!1;let Pe={...process.env};T&&(Pe.RELEY_HOOK_SOCK=T);let A=lt(n,s,{name:"xterm-256color",cols:H,rows:G,cwd:process.cwd(),env:Pe});A.onData(h=>{process.stdout.write(h);let b=Buffer.from(h);oe(b),he(b)}),x.on("message",async(h,b)=>{if(!b){try{let l=JSON.parse(h.toString());l.type==="key_exchange"&&l.publicKey&&l.role==="viewer"?(await ne(l.publicKey),E("CRYPTO","E2E established with viewer")):l.type==="peer_joined"&&(B=!1,S=null)}catch{}return}!B||!S||te(async()=>{if(S)try{let l=ae(new Uint8Array(h)),d=await se(S,l.ciphertext,l.nonce,l.counter);S=d.state;let w=ce(d.plaintext);if(w.type==="terminal_input"){let g=Buffer.from(w.data,"base64").toString();A.write(g)}else if(w.type==="terminal_resize"){let{cols:g,rows:P}=w;g&&P&&(H=g,G=P,A.resize(g,P))}}catch{}})}),process.stdin.isTTY&&process.stdin.setRawMode(!0),process.stdin.resume(),process.stdin.on("data",h=>{A.write(h.toString())}),process.stdout.on("resize",()=>{H=process.stdout.columns||80,G=process.stdout.rows||24,A.resize(H,G)});let fe=()=>{z.close();try{de(T)}catch{}if(N)try{de(N)}catch{}L&&N&&ht(L,N),x.close(),fetch(`${r.reley_url}/api/v1/sessions/${y}`,{method:"PATCH",headers:{"Content-Type":"application/json",Authorization:`Bearer ${r.device_token}`},body:JSON.stringify({status:"closed"})}).catch(()=>{})};A.onExit(()=>{fe(),process.exit(0)}),process.on("SIGINT",()=>A.kill()),process.on("SIGTERM",()=>A.kill())}function Vt(t,e,o){let r=[e,o].sort((c,a)=>{for(let u=0;u<c.length;u++)if(c[u]!==a[u])return c[u]-a[u];return 0}),n=new Uint8Array(r[0].length+r[1].length);n.set(r[0],0),n.set(r[1],r[0].length);let s=t.crypto_generichash(16,n,null);return t.to_hex(s).toUpperCase().match(/.{4}/g).join("-")}import*as xe from"node:fs/promises";import*as yt from"node:path";import*as gt from"node:crypto";import qt from"qrcode-terminal";async function ft(t){let{releyUrl:e,configDir:o}=t,r=await f();console.log("Generating identity key pair...");let n=await Ne();console.log("Generating ephemeral key pair...");let s=await re();console.log("Generating one-time pairing code...");let i=await Ae(32),c=await He(i),a=gt.randomUUID();console.log("Initiating pairing with reley...");let u={publicKey:r.to_base64(n.publicKey,r.base64_variants.URLSAFE_NO_PADDING),otcHash:r.to_base64(c,r.base64_variants.URLSAFE_NO_PADDING),deviceId:a},m=await fetch(`${e}/api/v1/pair/initiate`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(u)});if(!m.ok){let O=await m.text();throw new Error(`Failed to initiate pairing: ${m.status} ${O}`)}let{sessionId:p,jwt:v}=await m.json();console.log(`Pairing session created: ${p}`);let y=await Le({releyUrl:e,publicKey:s.publicKey,oneTimeCode:i,jwt:v});console.log(`
|
|
610
|
+
Scan this QR code with the Reley mobile app:
|
|
611
|
+
`),await new Promise(O=>{qt.generate(y,{small:!0},U=>{console.log(U),O()})}),console.log(`
|
|
612
|
+
Waiting for mobile device to complete pairing...`);let k=2e3,C=5*60*1e3,j=Date.now(),x;for(;Date.now()-j<C;){await Xt(k);let O=await fetch(`${e}/api/v1/pair/status/${p}`,{headers:{Authorization:`Bearer ${v}`}});if(!O.ok){console.error(`Poll error: ${O.status}`);continue}let U=await O.json();if(U.status==="completed"){x=U.peerPublicKey;break}if(U.status==="expired")throw new Error("Pairing session expired. Please try again.");process.stdout.write(".")}if(!x)throw new Error("Pairing timed out after 5 minutes.");console.log(`
|
|
613
|
+
|
|
614
|
+
Pairing successful!`);let S={deviceId:a,sessionId:p,releyUrl:e,jwt:v,identityPublicKey:r.to_base64(n.publicKey,r.base64_variants.URLSAFE_NO_PADDING),identitySecretKey:r.to_base64(n.secretKey,r.base64_variants.URLSAFE_NO_PADDING),ephemeralPublicKey:r.to_base64(s.publicKey,r.base64_variants.URLSAFE_NO_PADDING),ephemeralSecretKey:r.to_base64(s.secretKey,r.base64_variants.URLSAFE_NO_PADDING),peerPublicKey:x,pairedAt:new Date().toISOString()};await xe.mkdir(o,{recursive:!0});let B=yt.join(o,"session.json");await xe.writeFile(B,JSON.stringify(S,null,2),"utf-8"),console.log(`Session saved to ${B}`),console.log(`Device ID: ${a}`),console.log(`Session ID: ${p}`)}function Xt(t){return new Promise(e=>setTimeout(e,t))}import*as vt from"node:fs/promises";import*as bt from"node:path";import Qt from"node-pty";var Se=class{process=null;dataCallbacks=[];exitCallbacks=[];spawn(e,o,r={}){let{cols:n=80,rows:s=24,cwd:i,env:c}=r;return this.process=Qt.spawn(e,o,{name:"xterm-256color",cols:n,rows:s,cwd:i||process.cwd(),env:c||process.env}),this.process.onData(a=>{for(let u of this.dataCallbacks)u(a)}),this.process.onExit(({exitCode:a,signal:u})=>{for(let m of this.exitCallbacks)m(a,u);this.process=null}),this.process}onData(e){this.dataCallbacks.push(e)}onExit(e){this.exitCallbacks.push(e)}write(e){if(!this.process)throw new Error("PTY process not running");this.process.write(e)}resize(e,o){this.process&&this.process.resize(e,o)}kill(e){if(this.process){try{this.process.kill(e)}catch{}this.process=null}}get pid(){return this.process?.pid}get isRunning(){return this.process!==null}shutdown(){this.kill(),this.dataCallbacks=[],this.exitCallbacks=[]}};import{EventEmitter as Zt}from"node:events";import ke from"ws";var Ce=class extends Zt{ws=null;url="";token="";releyHttpUrl="";reconnectAttempts=0;reconnectTimer=null;pingTimer=null;pongTimer=null;tokenRefreshTimer=null;shouldReconnect=!0;messageCallbacks=[];connectCallbacks=[];disconnectCallbacks=[];async connect(e,o){return this.url=e,this.token=o,this.shouldReconnect=!0,this.releyHttpUrl=e.replace(/^ws:/,"http:").replace(/^wss:/,"https:").replace(/\/ws\??.*$/,""),this.scheduleTokenRefresh(),this.doConnect()}doConnect(){return new Promise((e,o)=>{let r=new ke(this.url,{headers:{Authorization:`Bearer ${this.token}`}});r.binaryType="arraybuffer",r.on("open",()=>{this.ws=r,this.reconnectAttempts=0,this.startPingInterval();for(let n of this.connectCallbacks)n();this.emit("connect"),e(r)}),r.on("message",n=>{let s;if(n instanceof ArrayBuffer)s=new Uint8Array(n);else if(Buffer.isBuffer(n))s=new Uint8Array(n);else if(Array.isArray(n))s=new Uint8Array(Buffer.concat(n));else return;for(let i of this.messageCallbacks)i(s);this.emit("message",s)}),r.on("pong",()=>{this.clearPongTimeout()}),r.on("close",(n,s)=>{let i=s.toString("utf-8");this.ws=null,this.stopPingInterval();for(let c of this.disconnectCallbacks)c(n,i);this.emit("disconnect",n,i),this.shouldReconnect&&this.scheduleReconnect()}),r.on("error",n=>{this.emit("error",n),!this.ws&&this.reconnectAttempts===0&&o(n)})})}onMessage(e){this.messageCallbacks.push(e)}onConnect(e){this.connectCallbacks.push(e)}onDisconnect(e){this.disconnectCallbacks.push(e)}send(e){if(!this.ws||this.ws.readyState!==ke.OPEN)throw new Error("WebSocket is not connected");this.ws.send(e)}updateToken(e){this.token=e}close(){this.shouldReconnect=!1,this.reconnectTimer&&(clearTimeout(this.reconnectTimer),this.reconnectTimer=null),this.tokenRefreshTimer&&(clearTimeout(this.tokenRefreshTimer),this.tokenRefreshTimer=null),this.stopPingInterval(),this.ws&&(this.ws.close(1e3,"Client closing"),this.ws=null)}get isConnected(){return this.ws!==null&&this.ws.readyState===ke.OPEN}scheduleReconnect(){let e=ie.WS_RECONNECT_BASE,o=ie.WS_RECONNECT_MAX,r=Math.min(e*Math.pow(2,this.reconnectAttempts),o),n=Math.random()*r*.25,s=r+n;this.reconnectAttempts++,this.reconnectTimer=setTimeout(async()=>{try{await this.doConnect()}catch{}},s)}startPingInterval(){this.stopPingInterval(),this.pingTimer=setInterval(()=>{this.ws&&this.ws.readyState===ke.OPEN&&(this.ws.ping(),this.startPongTimeout())},ie.PING_INTERVAL)}stopPingInterval(){this.pingTimer&&(clearInterval(this.pingTimer),this.pingTimer=null),this.clearPongTimeout()}startPongTimeout(){this.clearPongTimeout(),this.pongTimer=setTimeout(()=>{this.ws&&this.ws.terminate()},ie.PONG_TIMEOUT)}clearPongTimeout(){this.pongTimer&&(clearTimeout(this.pongTimer),this.pongTimer=null)}scheduleTokenRefresh(){this.tokenRefreshTimer&&clearTimeout(this.tokenRefreshTimer);let e=23*60*60*1e3;this.tokenRefreshTimer=setTimeout(async()=>{await this.refreshToken()},e),this.tokenRefreshTimer.unref()}async refreshToken(){try{let e=await fetch(`${this.releyHttpUrl}/api/v1/auth/refresh`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({token:this.token})});if(e.ok){let o=await e.json();this.token=o.token,this.scheduleTokenRefresh()}}catch{this.tokenRefreshTimer=setTimeout(async()=>{await this.refreshToken()},5*60*1e3),this.tokenRefreshTimer?.unref()}}};import*as pe from"node:fs/promises";import*as ye from"node:path";var Ee=class{ratchetState=null;isInitialized=!1;async initFromConfig(e){let o=await f(),r=ye.join(e,"ratchet-state.json");try{let v=await pe.readFile(r,"utf-8"),y=JSON.parse(v);this.ratchetState={sendChainKey:o.from_base64(y.sendChainKey,o.base64_variants.URLSAFE_NO_PADDING),recvChainKey:o.from_base64(y.recvChainKey,o.base64_variants.URLSAFE_NO_PADDING),sendCounter:y.sendCounter,recvCounter:y.recvCounter,maxRecvCounter:y.maxRecvCounter},this.isInitialized=!0;return}catch{}let n=ye.join(e,"session.json"),s=await pe.readFile(n,"utf-8"),i=JSON.parse(s),c=o.from_base64(i.ephemeralSecretKey,o.base64_variants.URLSAFE_NO_PADDING),a=o.from_base64(i.peerPublicKey,o.base64_variants.URLSAFE_NO_PADDING),u=await J(c,a),{sendKey:m,recvKey:p}=await W(u);this.ratchetState=Y(m,p),this.isInitialized=!0}async initFromKeys(e,o){let r=await J(e,o),{sendKey:n,recvKey:s}=await W(r);this.ratchetState=Y(n,s),this.isInitialized=!0}async encryptMessage(e){if(!this.ratchetState)throw new Error("Crypto session not initialized");let o=Z(e),r=X(e.type),{ciphertext:n,nonce:s,counter:i,state:c}=await V(this.ratchetState,o);this.ratchetState=c;let a={version:1,type:r,counter:i,nonce:s,ciphertext:n};return Ue(this.ratchetState)&&await this.performKeyRotation(),Q(a)}async decryptMessage(e){if(!this.ratchetState)throw new Error("Crypto session not initialized");let o=ae(e),{plaintext:r,state:n}=await se(this.ratchetState,o.ciphertext,o.nonce,o.counter);return this.ratchetState=n,ce(r)}async saveState(e){if(!this.ratchetState)return;let o=await f(),r={sendChainKey:o.to_base64(this.ratchetState.sendChainKey,o.base64_variants.URLSAFE_NO_PADDING),recvChainKey:o.to_base64(this.ratchetState.recvChainKey,o.base64_variants.URLSAFE_NO_PADDING),sendCounter:this.ratchetState.sendCounter,recvCounter:this.ratchetState.recvCounter,maxRecvCounter:this.ratchetState.maxRecvCounter},n=ye.join(e,"ratchet-state.json");await pe.writeFile(n,JSON.stringify(r,null,2),"utf-8")}async loadState(e){let o=await f(),r=ye.join(e,"ratchet-state.json");try{let n=await pe.readFile(r,"utf-8"),s=JSON.parse(n);return this.ratchetState={sendChainKey:o.from_base64(s.sendChainKey,o.base64_variants.URLSAFE_NO_PADDING),recvChainKey:o.from_base64(s.recvChainKey,o.base64_variants.URLSAFE_NO_PADDING),sendCounter:s.sendCounter,recvCounter:s.recvCounter,maxRecvCounter:s.maxRecvCounter},this.isInitialized=!0,!0}catch{return!1}}getRatchetInfo(){return{sendCounter:this.ratchetState?.sendCounter??0,recvCounter:this.ratchetState?.recvCounter??0,initialized:this.isInitialized}}async performKeyRotation(){}};async function wt(t,e){let{configDir:o}=e,r=bt.join(o,"session.json"),n;try{let y=await vt.readFile(r,"utf-8");n=JSON.parse(y)}catch{console.error('No active session found. Run "reley pair" first.'),process.exit(1)}let s=new Ee;await s.initFromConfig(o);let i=t[0],c=t.slice(1);console.log(`Wrapping command: ${t.join(" ")}`);let a=new Se,u=a.spawn(i,c,{cols:process.stdout.columns||80,rows:process.stdout.rows||24,cwd:process.cwd(),env:process.env}),m=n.releyUrl.replace(/^http/,"ws"),p=new Ce;await p.connect(`${m}/api/v1/ws/${n.sessionId}`,n.jwt),console.log("Connected to reley. Forwarding terminal output..."),a.onData(async y=>{process.stdout.write(y);let k={type:"terminal_data",data:Buffer.from(y,"utf-8").toString("base64")};try{let C=await s.encryptMessage(k);p.send(C)}catch(C){console.error("Encryption error:",C)}}),process.stdin.isTTY&&process.stdin.setRawMode(!0),process.stdin.resume(),process.stdin.on("data",y=>{a.write(y.toString("utf-8"))}),process.stdout.on("resize",()=>{let y=process.stdout.columns||80,k=process.stdout.rows||24;a.resize(y,k)}),p.onMessage(async y=>{try{let k=await s.decryptMessage(y);switch(k.type){case"terminal_input":{let C=k,j=Buffer.from(C.data,"base64").toString("utf-8");a.write(j);break}case"terminal_resize":{let C=k;a.resize(C.cols,C.rows);break}case"session_close":{console.log(`
|
|
615
|
+
Remote session closed: ${k.reason}`),v();break}default:break}}catch(k){console.error("Decryption/handling error:",k)}}),a.onExit(async(y,k)=>{console.log(`
|
|
616
|
+
Process exited with code ${y}${k?` (signal ${k})`:""}`);let C={type:"session_close",reason:`Process exited with code ${y}`};try{let j=await s.encryptMessage(C);p.send(j)}catch{}await s.saveState(o),v()}),p.onDisconnect(()=>{console.log(`
|
|
617
|
+
Disconnected from reley. Attempting reconnection...`)}),p.onConnect(()=>{console.log("Reconnected to reley.")});function v(){a.kill(),p.close(),process.stdin.isTTY&&process.stdin.setRawMode(!1),process.stdin.pause(),process.exit(0)}process.on("SIGINT",v),process.on("SIGTERM",v)}import*as ge from"node:fs/promises";import*as _e from"node:path";async function xt(t){let{configDir:e}=t;console.log("Reley Status"),console.log(`=================
|
|
618
|
+
`);let o=_e.join(e,"session.json"),r=null;try{let c=await ge.readFile(o,"utf-8");r=JSON.parse(c)}catch{}if(!r)console.log("Pairing: Not paired"),console.log(`
|
|
619
|
+
Run "reley pair" to pair with a mobile device.
|
|
620
|
+
`);else{if(console.log("Pairing: Paired"),console.log(`Device ID: ${r.deviceId}`),console.log(`Session ID: ${r.sessionId}`),console.log(`Reley URL: ${r.releyUrl}`),console.log(`Paired at: ${r.pairedAt||"Unknown"}`),r.peerPublicKey){let c=r.peerPublicKey.substring(0,16)+"...";console.log(`Peer key: ${c}`)}console.log(`Identity: ${r.identityPublicKey.substring(0,16)}...`)}let n=r?.releyUrl||t.releyUrl;console.log(`
|
|
621
|
+
Reley Health (${n}):`);try{let c=await fetch(`${n}/api/v1/health`,{signal:AbortSignal.timeout(5e3)});if(c.ok){let a=await c.json();console.log(` Status: ${a.status}`),a.version&&console.log(` Version: ${a.version}`),a.uptime!==void 0&&console.log(` Uptime: ${Math.round(a.uptime)}s`)}else console.log(` Status: Error (HTTP ${c.status})`)}catch(c){let a=c instanceof Error?c.message:String(c);console.log(` Status: Unreachable (${a})`)}let s=_e.join(e,"ratchet-state.json");try{await ge.access(s),console.log(`
|
|
622
|
+
Crypto: Ratchet state saved`)}catch{console.log(`
|
|
623
|
+
Crypto: No ratchet state (new session)`)}let i=_e.join(e,"hooks.sock");try{await ge.access(i),console.log("Hooks: Socket exists")}catch{console.log("Hooks: Not active")}console.log("")}import*as ee from"node:fs/promises";import*as $ from"node:path";import{fileURLToPath as eo}from"node:url";async function St(t){let{configDir:e}=t;console.log(`Installing Claude Code hooks for Reley...
|
|
624
|
+
`);let o=eo(import.meta.url),r=$.dirname(o),n=$.resolve(r,"..","hooks","hooks.json"),s;try{let y=await ee.readFile(n,"utf-8");s=JSON.parse(y)}catch{s={hooks:{Stop:[{matcher:"",command:"reley-hook stop"}],Notification:[{matcher:"",command:"reley-hook notification"}],PreToolUse:[{matcher:"Bash",command:"reley-hook pre-tool-use"},{matcher:"Write",command:"reley-hook pre-tool-use"},{matcher:"Edit",command:"reley-hook pre-tool-use"}]}}}let i=$.join(process.env.HOME||"~",".claude"),c=$.join(i,"hooks");await ee.mkdir(c,{recursive:!0});let a=$.join(c,"reley-hooks.json");await ee.writeFile(a,JSON.stringify(s,null,2),"utf-8");let u=$.join(e,"hooks.sock"),m=to(u),p=$.join(e,"bin");await ee.mkdir(p,{recursive:!0});let v=$.join(p,"reley-hook");await ee.writeFile(v,m,{mode:493}),console.log("Installed files:"),console.log(` Hooks config: ${a}`),console.log(` Hook script: ${v}`),console.log(` Socket path: ${u}`),console.log(""),console.log("Hook configuration:"),console.log(" Stop: Sends stop events to mobile"),console.log(" Notification: Forwards notifications to mobile"),console.log(" PreToolUse: Requires mobile approval for Bash, Write, Edit"),console.log(""),console.log("To activate, add to your PATH:"),console.log(` export PATH="${p}:$PATH"`),console.log(""),console.log("Or create a symlink:"),console.log(` ln -sf ${v} /usr/local/bin/reley-hook`),console.log("")}function to(t){return`#!/usr/bin/env node
|
|
2508
625
|
import * as net from 'node:net';
|
|
2509
626
|
import * as process from 'node:process';
|
|
2510
627
|
|
|
2511
|
-
const SOCKET_PATH = ${JSON.stringify(
|
|
628
|
+
const SOCKET_PATH = ${JSON.stringify(t)};
|
|
2512
629
|
const HOOK_TYPE = process.argv[2]; // stop, notification, pre-tool-use
|
|
2513
630
|
const TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
|
2514
631
|
|
|
@@ -2567,76 +684,10 @@ main().catch((err) => {
|
|
|
2567
684
|
// Default to approve on error
|
|
2568
685
|
console.log(JSON.stringify({ action: 'approve' }));
|
|
2569
686
|
});
|
|
2570
|
-
|
|
2571
|
-
}
|
|
2572
|
-
|
|
2573
|
-
|
|
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>
|
|
687
|
+
`}we();import{createServer as oo}from"node:http";import{URL as ro}from"node:url";import{hostname as no}from"node:os";var so="https://api.reley.sh";async function kt(t){let e=t.releyUrl||process.env.RELEY_URL||so,o=process.env.SUPABASE_URL,r=process.env.SUPABASE_ANON_KEY;if(!o||!r)try{let m=await fetch(`${e}/api/v1/auth/config`);if(!m.ok)throw new Error(`HTTP ${m.status}`);let p=await m.json();o=p.supabaseUrl,r=p.supabaseAnonKey}catch{console.error("\x1B[31mError:\x1B[0m Could not connect to reley server at "+e),console.error("Check that the server is running and the URL is correct."),process.exit(1)}console.log(`\x1B[36m[Reley]\x1B[0m Starting Google login...
|
|
688
|
+
`);let{token:n,email:s}=await io(o,r);console.log(`\x1B[32m\u2713\x1B[0m Logged in as \x1B[1m${s}\x1B[0m
|
|
689
|
+
`),console.log("\x1B[36m[Reley]\x1B[0m Registering device...");let i=no()||"Unknown Device",c=await fetch(`${e}/api/v1/devices/register`,{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${n}`},body:JSON.stringify({name:i})});if(!c.ok){let m=await c.json().catch(()=>({}));console.error(`\x1B[31mError:\x1B[0m Failed to register device: ${m.error||c.statusText}`),process.exit(1)}let{deviceId:a,deviceToken:u}=await c.json();Be({reley_url:e,device_token:u,device_id:a,user_email:s}),console.log(`\x1B[32m\u2713\x1B[0m Device registered: \x1B[1m${i}\x1B[0m`),console.log(`\x1B[32m\u2713\x1B[0m Config saved to ${$e()}
|
|
690
|
+
`),console.log("You can now run \x1B[1mreley\x1B[0m to start a session.")}async function io(t,e){return new Promise((o,r)=>{let s="http://localhost:54321/auth/callback",i=oo((c,a)=>{let u=new ro(c.url,"http://localhost:54321");if(u.pathname==="/auth/callback"){a.writeHead(200,{"Content-Type":"text/html"}),a.end(`<!DOCTYPE html>
|
|
2640
691
|
<html><body>
|
|
2641
692
|
<script>
|
|
2642
693
|
// Supabase puts tokens in the hash fragment
|
|
@@ -2657,96 +708,7 @@ async function startOAuthFlow(supabaseUrl, supabaseAnonKey) {
|
|
|
2657
708
|
}
|
|
2658
709
|
</script>
|
|
2659
710
|
<h2 style="font-family:system-ui;text-align:center;margin-top:100px">Processing login...</h2>
|
|
2660
|
-
</body></html>`);
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
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
|
-
init_config();
|
|
2722
|
-
var program = new Command();
|
|
2723
|
-
program.name("reley").description("Bridge your terminal to mobile/web with E2E encryption").version("0.1.0");
|
|
2724
|
-
program.option("--reley-url <url>", "Reley server URL", "http://localhost:3100").option("--config-dir <path>", "Configuration directory", `${process.env.HOME}/.reley`);
|
|
2725
|
-
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) => {
|
|
2726
|
-
const online = opts.online ?? isConfigured();
|
|
2727
|
-
await runCommand(commandArgs, { webPort: opts.webPort, releyPort: opts.releyPort, online });
|
|
2728
|
-
});
|
|
2729
|
-
program.command("login").description("Login with Google and register this device").option("--reley-url <url>", "Reley server URL").action(async (opts) => {
|
|
2730
|
-
const parentOpts = program.opts();
|
|
2731
|
-
await loginCommand({ releyUrl: opts.releyUrl || parentOpts.releyUrl });
|
|
2732
|
-
});
|
|
2733
|
-
program.command("pair").description("Pair this device with a mobile client").action(async () => {
|
|
2734
|
-
const opts = program.opts();
|
|
2735
|
-
await pairCommand({ releyUrl: opts.releyUrl, configDir: opts.configDir });
|
|
2736
|
-
});
|
|
2737
|
-
program.command("wrap <command...>").description("Wrap a command with existing session (requires prior pairing)").action(async (commandArgs) => {
|
|
2738
|
-
const opts = program.opts();
|
|
2739
|
-
await wrapCommand(commandArgs, { releyUrl: opts.releyUrl, configDir: opts.configDir });
|
|
2740
|
-
});
|
|
2741
|
-
program.command("status").description("Show pairing and connection status").action(async () => {
|
|
2742
|
-
const opts = program.opts();
|
|
2743
|
-
await statusCommand({ releyUrl: opts.releyUrl, configDir: opts.configDir });
|
|
2744
|
-
});
|
|
2745
|
-
program.command("install-hooks").description("Install Claude Code hooks for Reley integration").action(async () => {
|
|
2746
|
-
const opts = program.opts();
|
|
2747
|
-
await installHooksCommand({ releyUrl: opts.releyUrl, configDir: opts.configDir });
|
|
2748
|
-
});
|
|
2749
|
-
program.parseAsync(process.argv).catch((err) => {
|
|
2750
|
-
console.error("Fatal error:", err);
|
|
2751
|
-
process.exit(1);
|
|
2752
|
-
});
|
|
711
|
+
</body></html>`);return}if(u.pathname==="/auth/token"&&c.method==="POST"){let m="";c.on("data",p=>{m+=p.toString()}),c.on("end",async()=>{a.writeHead(200,{"Content-Type":"application/json"}),a.end(JSON.stringify({ok:!0}));try{let{access_token:p}=JSON.parse(m),v=await fetch(`${t}/auth/v1/user`,{headers:{Authorization:`Bearer ${p}`,apikey:e}});if(!v.ok){r(new Error("Failed to verify token"));return}let y=await v.json();i.close(),o({token:p,email:y.email})}catch(p){r(p)}});return}a.writeHead(404),a.end("Not found")});i.listen(54321,()=>{let c=`${t}/auth/v1/authorize?`+new URLSearchParams({provider:"google",redirect_to:s}).toString();console.log("\x1B[36m[Reley]\x1B[0m Opening browser for Google login..."),console.log(`\x1B[2m${c}\x1B[0m
|
|
712
|
+
`);let a=process.platform==="darwin"?"open":process.platform==="win32"?"start":"xdg-open";import("node:child_process").then(({spawn:u})=>{u(a,[c],{stdio:"ignore",detached:!0}).unref()}),setTimeout(()=>{i.close(),r(new Error("Login timed out (5 minutes)"))},5*60*1e3)}),i.on("error",c=>{r(new Error(`Failed to start callback server: ${c.message}`))})})}we();var _=new ao;_.name("reley").description("Bridge your terminal to mobile/web with E2E encryption").version("0.1.4");_.option("--reley-url <url>","Reley server URL","https://api.reley.sh").option("--config-dir <path>","Configuration directory",`${process.env.HOME}/.reley`);_.command("run [command...]",{isDefault:!0}).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(t,e)=>{let o=e.online??je();await mt(t,{webPort:e.webPort,releyPort:e.releyPort,online:o})});_.command("login").description("Login with Google and register this device").option("--reley-url <url>","Reley server URL").action(async t=>{let e=_.opts();await kt({releyUrl:t.releyUrl||e.releyUrl})});_.command("pair").description("Pair this device with a mobile client").action(async()=>{let t=_.opts();await ft({releyUrl:t.releyUrl,configDir:t.configDir})});_.command("wrap <command...>").description("Wrap a command with existing session (requires prior pairing)").action(async t=>{let e=_.opts();await wt(t,{releyUrl:e.releyUrl,configDir:e.configDir})});_.command("status").description("Show pairing and connection status").action(async()=>{let t=_.opts();await xt({releyUrl:t.releyUrl,configDir:t.configDir})});_.command("install-hooks").description("Install Claude Code hooks for Reley integration").action(async()=>{let t=_.opts();await St({releyUrl:t.releyUrl,configDir:t.configDir})});_.parseAsync(process.argv).catch(t=>{let e=t instanceof Error?t.message:String(t);console.error(`
|
|
713
|
+
\x1B[31m\x1B[1mError:\x1B[0m ${e}
|
|
714
|
+
`),process.exit(1)});
|