cli-setu 0.2.6 → 0.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # cli-setu
2
2
 
3
- Bridge your terminal to your phone. Run Claude Code, Codex, or any plain shell on your laptop, and approve prompts, type, and watch the live output from the [CLIसेतु](https://clisetu.com) app on your phone.
3
+ Bridge your terminal to your phone. Run Claude Code, Codex, or any plain shell on your laptop, and approve prompts, type, and watch the live output from the [cli-setu](https://clisetu.com) app on your phone.
4
4
 
5
5
  ## Install
6
6
 
@@ -16,7 +16,7 @@ cli-setu --cli claude # same, but runs Claude Code inside the bridged session
16
16
  cli-setu --cli codex # or Codex
17
17
  ```
18
18
 
19
- The first time you run it, `cli-setu` prints a QR code (and an 8-digit fallback code) - scan it with the CLIसेतु app to pair this computer and start watching the session live.
19
+ The first time you run it, `cli-setu` prints a QR code (and an 8-digit fallback code) - scan it with the cli-setu app to pair this computer and start watching the session live.
20
20
 
21
21
  Once paired, running `cli-setu` in any folder just works - no QR code needed again, unless this is a device you haven't paired before.
22
22
 
@@ -33,7 +33,7 @@ cli-setu --help # show all commands
33
33
 
34
34
  ## How it works
35
35
 
36
- `cli-setu` spawns your real terminal (or Claude Code/Codex) inside a PTY, same as any terminal emulator. Every byte it produces streams live to the CLIसेतु app over a secure relay; anything you type on the phone writes back into that same PTY, exactly like a local keystroke. Permission prompts (file edits, shell commands, etc.) are detected and relayed as structured approve/reject cards instead of raw text, when the adapter for your CLI supports it.
36
+ `cli-setu` spawns your real terminal (or Claude Code/Codex) inside a PTY, same as any terminal emulator. Every byte it produces streams live to the cli-setu app over a secure relay; anything you type on the phone writes back into that same PTY, exactly like a local keystroke. Permission prompts (file edits, shell commands, etc.) are detected and relayed as structured approve/reject cards instead of raw text, when the adapter for your CLI supports it.
37
37
 
38
38
  ## License
39
39
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cli-setu",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
4
4
  "description": "Bridge your terminal to your phone — run and approve Claude Code, Codex CLI and other AI coding agents from anywhere.",
5
5
  "type": "commonjs",
6
6
  "bin": {
package/src/config.js CHANGED
@@ -44,6 +44,17 @@ function loadOrCreateConfig() {
44
44
  return { deviceName: defaultDeviceName(), deviceToken: null };
45
45
  }
46
46
 
47
+ // Section 1 E2E: this device's own X25519 keypair, generated once (first
48
+ // `cli-setu pair`) and reused for the lifetime of this pairing - same
49
+ // lifetime as deviceToken itself, so a re-pair naturally mints a fresh
50
+ // keypair too, same as it mints a fresh token. Private key stored
51
+ // base64-encoded in the same config.json deviceToken already lives in -
52
+ // no new file, no new permissions model; this machine's own user-profile
53
+ // access already protects deviceToken the same way.
54
+ function hasKeypair(config) {
55
+ return !!(config.devicePrivateKey && config.devicePublicKey);
56
+ }
57
+
47
58
  module.exports = {
48
59
  CONFIG_DIR,
49
60
  CONFIG_FILE,
@@ -51,4 +62,5 @@ module.exports = {
51
62
  readConfig,
52
63
  writeConfig,
53
64
  loadOrCreateConfig,
65
+ hasKeypair,
54
66
  };
package/src/crypto.js ADDED
@@ -0,0 +1,104 @@
1
+ // Section 1 E2E: real end-to-end encryption for terminal.input/output,
2
+ // so the backend relay can forward packets without being able to read
3
+ // them (previously it could - see this file's own commit message / the
4
+ // privacy policy fix this exists to make true instead of false).
5
+ //
6
+ // Design: X25519 ECDH key exchange, one keypair per laptop, generated
7
+ // once at pair time and reused for the life of that pairing (same
8
+ // lifetime as the device token itself - a re-pair generates a fresh
9
+ // keypair, same as it mints a fresh token). The shared secret is
10
+ // derived once per phone public key via HKDF-SHA256 and cached; AES-
11
+ // 256-GCM encrypts each terminal payload with a fresh random IV.
12
+ //
13
+ // The backend only ever sees: this device's PUBLIC key (at pairing) and
14
+ // ciphertext (at relay time) - never the private key, never the derived
15
+ // secret, never plaintext. That's what makes this actually end-to-end,
16
+ // not just "encrypted somewhere."
17
+ const crypto = require('crypto');
18
+
19
+ // Node's 'x25519' curve via the generic Diffie-Hellman API - available
20
+ // since Node 13, well within this project's >=18 engines requirement.
21
+ function generateKeypair() {
22
+ const { publicKey, privateKey } = crypto.generateKeyPairSync('x25519');
23
+ return { publicKey, privateKey };
24
+ }
25
+
26
+ // Node's KeyObject export format for a raw X25519 public key is a DER-
27
+ // wrapped SPKI blob, not the raw 32 bytes - stripping to raw bytes here
28
+ // so the wire format matches what @noble/curves on the app side produces
29
+ // and expects directly (no ASN.1 parsing needed on that end).
30
+ const RAW_PUBLIC_KEY_LEN = 32;
31
+
32
+ function publicKeyToRaw(publicKey) {
33
+ const der = publicKey.export({ type: 'spki', format: 'der' });
34
+ return der.subarray(der.length - RAW_PUBLIC_KEY_LEN);
35
+ }
36
+
37
+ function publicKeyFromRaw(rawBytes) {
38
+ // SPKI header for X25519 is fixed (12 bytes) - prepending it lets
39
+ // Node's crypto module import a raw 32-byte public key the same way
40
+ // it exports one above.
41
+ const SPKI_PREFIX = Buffer.from('302a300506032b656e032100', 'hex');
42
+ const der = Buffer.concat([SPKI_PREFIX, rawBytes]);
43
+ return crypto.createPublicKey({ key: der, format: 'der', type: 'spki' });
44
+ }
45
+
46
+ function publicKeyToBase64(publicKey) {
47
+ return publicKeyToRaw(publicKey).toString('base64');
48
+ }
49
+
50
+ // Private key storage shape: Node's native PKCS8 DER export, base64-
51
+ // encoded - config.js stores it this way in config.json alongside
52
+ // deviceToken (same file, same OS-level file permissions already
53
+ // protecting that token, so no new secret-storage mechanism needed).
54
+ function privateKeyToBase64(privateKey) {
55
+ return privateKey.export({ type: 'pkcs8', format: 'der' }).toString('base64');
56
+ }
57
+
58
+ function privateKeyFromBase64(base64) {
59
+ return crypto.createPrivateKey({ key: Buffer.from(base64, 'base64'), format: 'der', type: 'pkcs8' });
60
+ }
61
+
62
+ function deriveSharedSecret(privateKey, peerPublicKeyBase64) {
63
+ const peerPublicKey = publicKeyFromRaw(Buffer.from(peerPublicKeyBase64, 'base64'));
64
+ const rawSecret = crypto.diffieHellman({ privateKey, publicKey: peerPublicKey });
65
+ // HKDF-SHA256, no salt (both sides derive deterministically from the
66
+ // same ECDH output - a salt would need its own exchange for no real
67
+ // benefit here), fixed info string scoping this key to this exact
68
+ // purpose so it can never collide with a key derived elsewhere for a
69
+ // different purpose from the same raw ECDH secret.
70
+ return Buffer.from(crypto.hkdfSync('sha256', rawSecret, Buffer.alloc(0), 'cli-setu-terminal-v1', 32));
71
+ }
72
+
73
+ // AES-256-GCM: a fresh random 12-byte IV per message (GCM's own
74
+ // requirement - reusing an IV with the same key breaks its security
75
+ // guarantees entirely), the 16-byte auth tag appended, both prepended/
76
+ // appended to the ciphertext so the wire format is one self-contained
77
+ // base64 string: IV (12) || ciphertext || authTag (16).
78
+ function encrypt(sharedSecret, plaintext) {
79
+ const iv = crypto.randomBytes(12);
80
+ const cipher = crypto.createCipheriv('aes-256-gcm', sharedSecret, iv);
81
+ const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
82
+ const authTag = cipher.getAuthTag();
83
+ return Buffer.concat([iv, ciphertext, authTag]).toString('base64');
84
+ }
85
+
86
+ function decrypt(sharedSecret, payloadBase64) {
87
+ const buf = Buffer.from(payloadBase64, 'base64');
88
+ const iv = buf.subarray(0, 12);
89
+ const authTag = buf.subarray(buf.length - 16);
90
+ const ciphertext = buf.subarray(12, buf.length - 16);
91
+ const decipher = crypto.createDecipheriv('aes-256-gcm', sharedSecret, iv);
92
+ decipher.setAuthTag(authTag);
93
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
94
+ }
95
+
96
+ module.exports = {
97
+ generateKeypair,
98
+ publicKeyToBase64,
99
+ privateKeyToBase64,
100
+ privateKeyFromBase64,
101
+ deriveSharedSecret,
102
+ encrypt,
103
+ decrypt,
104
+ };
package/src/pair.js CHANGED
@@ -5,11 +5,25 @@
5
5
  // of a hand-typed argv, and the result is saved to ~/.cli-setu/config.json
6
6
  // instead of a per-device-name file in the install folder.
7
7
  const qrcode = require('qrcode-terminal');
8
- const { loadOrCreateConfig, writeConfig } = require('./config');
8
+ const { loadOrCreateConfig, writeConfig, hasKeypair } = require('./config');
9
+ const { generateKeypair, publicKeyToBase64, privateKeyToBase64 } = require('./crypto');
9
10
 
10
11
  async function pair(authServer, authServerPublic) {
11
12
  const config = loadOrCreateConfig();
12
13
 
14
+ // Section 1 E2E: generate this device's own keypair up front, before
15
+ // either pairing path below, so both qr-start and pair/start can send
16
+ // the SAME public key no matter which path the user actually takes
17
+ // (scan vs typed code - both start by calling this function). A
18
+ // re-pair (config.deviceToken was cleared just below, or this is a
19
+ // genuinely fresh config) always gets a FRESH keypair - never reuses
20
+ // a stale one left over from a previous, now-dead pairing.
21
+ if (!hasKeypair(config)) {
22
+ const { privateKey, publicKey } = generateKeypair();
23
+ config.devicePrivateKey = privateKeyToBase64(privateKey);
24
+ config.devicePublicKey = publicKeyToBase64(publicKey);
25
+ }
26
+
13
27
  if (config.deviceToken) {
14
28
  // Real bug found by testing: unpairing from the phone deletes the
15
29
  // device server-side, but the laptop's local config.json still has
@@ -55,7 +69,7 @@ async function pair(authServer, authServerPublic) {
55
69
  const startRes = await fetch(`${authServer}/device/pair/qr-start`, {
56
70
  method: 'POST',
57
71
  headers: { 'Content-Type': 'application/json' },
58
- body: JSON.stringify({ deviceName: config.deviceName, sessionId: 'pairing' }),
72
+ body: JSON.stringify({ deviceName: config.deviceName, sessionId: 'pairing', devicePublicKey: config.devicePublicKey }),
59
73
  });
60
74
  const { qrToken } = await startRes.json();
61
75
 
@@ -69,7 +83,7 @@ async function pair(authServer, authServerPublic) {
69
83
  const codeRes = await fetch(`${authServer}/device/pair/start`, {
70
84
  method: 'POST',
71
85
  headers: { 'Content-Type': 'application/json' },
72
- body: JSON.stringify({ deviceName: config.deviceName }),
86
+ body: JSON.stringify({ deviceName: config.deviceName, devicePublicKey: config.devicePublicKey }),
73
87
  });
74
88
  const { code: typedCode, pairId: typedPairId } = await codeRes.json();
75
89
  console.log(`[cli-setu] Or type this code in the app's Devices tab: ${typedCode}`);
@@ -84,6 +98,10 @@ async function pair(authServer, authServerPublic) {
84
98
  if (status.status === 'CONFIRMED') {
85
99
  confirmed = true;
86
100
  config.deviceToken = status.deviceToken;
101
+ // Section 1 E2E: the phone's public key, so this laptop can derive
102
+ // the shared secret for terminal.input/output - see crypto.js's
103
+ // deriveSharedSecret and supervisor.js's own use of it.
104
+ if (status.phonePublicKey) config.phonePublicKey = status.phonePublicKey;
87
105
  writeConfig(config);
88
106
  console.log(`[cli-setu] ✓ Paired via QR and saved to ~/.cli-setu/config.json`);
89
107
  break;
@@ -100,6 +118,7 @@ async function pair(authServer, authServerPublic) {
100
118
  if (typedStatus.status === 'CONFIRMED') {
101
119
  confirmed = true;
102
120
  config.deviceToken = typedStatus.deviceToken;
121
+ if (typedStatus.phonePublicKey) config.phonePublicKey = typedStatus.phonePublicKey;
103
122
  writeConfig(config);
104
123
  console.log(`[cli-setu] ✓ Paired via typed code and saved to ~/.cli-setu/config.json`);
105
124
  break;
package/src/supervisor.js CHANGED
@@ -42,6 +42,7 @@ const execAsync = promisify(exec);
42
42
  // data, not part of the command syntax itself.
43
43
  const execFileAsync = promisify(execFile);
44
44
  const { getAdapter } = require('./cli-adapters');
45
+ const setuCrypto = require('./crypto');
45
46
 
46
47
  // Real bug found by testing: this script's own console.log/console.error
47
48
  // calls write to the SAME process.stdout the PTY's raw output is echoed
@@ -166,6 +167,25 @@ let ws = null;
166
167
  let azureReady = false;
167
168
  const pendingPrompts = new Map();
168
169
 
170
+ // Section 1 E2E: derived once at startup, same lifetime as this process.
171
+ // Null whenever either half is missing - a device paired before this
172
+ // feature existed has devicePrivateKey/phonePublicKey absent from its
173
+ // config.json, and terminal.input/output/snapshot below deliberately
174
+ // fall back to sending PLAINTEXT for exactly that case (see this
175
+ // project's own decision: no forced re-pair, upgrade happens naturally
176
+ // on the next real re-pair). Never silently "half encrypt" - either a
177
+ // real secret is available and used for every payload this session, or
178
+ // none is and nothing here pretends otherwise.
179
+ let sharedSecret = null;
180
+ if (config.devicePrivateKey && config.phonePublicKey) {
181
+ try {
182
+ const privateKey = setuCrypto.privateKeyFromBase64(config.devicePrivateKey);
183
+ sharedSecret = setuCrypto.deriveSharedSecret(privateKey, config.phonePublicKey);
184
+ } catch (err) {
185
+ console.error('[cli-setu] Could not derive E2E encryption key (falling back to unencrypted relay):', err.message);
186
+ }
187
+ }
188
+
169
189
  // Phase 6, Section 7: a rolling raw-byte scrollback so a phone that opens
170
190
  // terminal.html AFTER the session has been running for a while gets caught
171
191
  // up instantly via session.snapshot, instead of an empty screen. Capped so
@@ -255,6 +275,12 @@ async function connectToAzure() {
255
275
  }
256
276
  const conn = await res.json();
257
277
  deviceId = conn.deviceId;
278
+ // See this function's own call site (main()'s former focusTimer
279
+ // comment) for why this starts HERE and not unconditionally at
280
+ // startup - deviceId must be real before pollFocus's heartbeat is
281
+ // worth sending at all. Guarded so a reconnect (connectToAzure called
282
+ // again after a dropped WebSocket) never stacks a second timer.
283
+ if (!focusTimer) focusTimer = setInterval(pollFocus, 2000);
258
284
  log(`[supervisor] Connecting to Azure as device:${deviceId} (owner: ${conn.userId})...`);
259
285
  // Diagnostic found by testing: a session not appearing in the phone's
260
286
  // Terminal tab had no visible cause from the laptop's own side - this
@@ -283,7 +309,28 @@ async function connectToAzure() {
283
309
  // Section 11f Step 2: typed input from the phone, written to the PTY
284
310
  // exactly like a local keystroke - this is the "arbitrary remote
285
311
  // input" half of the DoD, not just approve/reject.
286
- ptyProcess.write(msg.data);
312
+ //
313
+ // Section 1 E2E: msg.data is ciphertext whenever sharedSecret exists
314
+ // (the phone encrypts before sending - see TerminalScreen.js's own
315
+ // sendInput). msg.encrypted distinguishes the two wire shapes so a
316
+ // pre-upgrade phone build (no E2E yet) and a pre-upgrade laptop
317
+ // build talking to each other don't try to decrypt plaintext or
318
+ // vice versa - see this file's own sharedSecret comment for why
319
+ // that fallback has to be all-or-nothing, never silent guessing.
320
+ let data = msg.data;
321
+ if (msg.encrypted) {
322
+ if (!sharedSecret) {
323
+ log('[supervisor] Dropped an encrypted terminal.input - this device has no shared secret to decrypt it (re-pair to fix).');
324
+ return;
325
+ }
326
+ try {
327
+ data = setuCrypto.decrypt(sharedSecret, msg.data);
328
+ } catch (err) {
329
+ log('[supervisor] Failed to decrypt terminal.input, dropping:', err.message);
330
+ return;
331
+ }
332
+ }
333
+ ptyProcess.write(data);
287
334
  }
288
335
  if (msg.type === 'terminal.resize' && msg.sessionId === SESSION_ID) {
289
336
  // Real bug found by testing: this used to call ptyProcess.resize()
@@ -371,14 +418,17 @@ async function sendSnapshot() {
371
418
  // pre-TUI history too, not just whatever the current alt-screen
372
419
  // buffer still happens to show.
373
420
  const history = await preservedHistoryText();
374
- const screen = history ? `${history}\n${scrollback}` : scrollback;
421
+ const plainScreen = history ? `${history}\n${scrollback}` : scrollback;
422
+ // Section 1 E2E: see terminal.input's own comment on why this is
423
+ // all-or-nothing, never a silent per-message guess.
424
+ const screen = sharedSecret ? setuCrypto.encrypt(sharedSecret, plainScreen) : plainScreen;
375
425
  await fetch(`${AUTH_SERVER}/relay/terminal-snapshot`, {
376
426
  method: 'POST',
377
427
  headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${deviceToken}` },
378
428
  // cols/rows: the real PTY's actual geometry, so a phone attaching
379
429
  // can lock its terminal to match instead of guessing its own size -
380
430
  // see auth-server.js's matching comment for why this matters.
381
- body: JSON.stringify({ sessionId: SESSION_ID, screen, cols: PTY_COLS, rows: PTY_ROWS }),
431
+ body: JSON.stringify({ sessionId: SESSION_ID, screen, encrypted: !!sharedSecret, cols: PTY_COLS, rows: PTY_ROWS }),
382
432
  });
383
433
  } catch (err) {
384
434
  log('[supervisor] Failed to send snapshot:', err.message);
@@ -555,10 +605,13 @@ function flushOutputBuffer() {
555
605
  // fetch() itself should resolve quickly regardless of Azure's own cost.
556
606
  sendInFlight = sendInFlight.then(async () => {
557
607
  try {
608
+ // Section 1 E2E: see terminal.input's own comment on why this is
609
+ // all-or-nothing, never a silent per-message guess.
610
+ const data = sharedSecret ? setuCrypto.encrypt(sharedSecret, batch) : batch;
558
611
  await fetch(`${AUTH_SERVER}/relay/terminal-output`, {
559
612
  method: 'POST',
560
613
  headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${deviceToken}` },
561
- body: JSON.stringify({ sessionId: SESSION_ID, seq: ++outputSeq, data: batch }),
614
+ body: JSON.stringify({ sessionId: SESSION_ID, seq: ++outputSeq, data, encrypted: !!sharedSecret }),
562
615
  });
563
616
  } catch (err) {
564
617
  // A dropped terminal.output batch isn't fatal the way a dropped
@@ -1249,15 +1302,20 @@ async function main() {
1249
1302
  // ptyProcess.onExit's cleanup still has a valid, harmless no-op target.
1250
1303
 
1251
1304
  pollTimer = setInterval(pollForRequests, POLL_INTERVAL_MS);
1252
- // Real gap found by testing: closing the shell directly (no graceful
1253
- // Ctrl+C) leaves no explicit "I'm done" signal for the Backend at all
1254
- // (see this function's onExit handler) - the ONLY way a dead session
1255
- // ever stops showing live on the phone is this heartbeat simply going
1256
- // quiet long enough for auth-server.js's own LIVE_SESSION_STALE_MS to
1257
- // notice. 2000ms (was 3000ms) keeps a comfortable multiple of margin
1258
- // under that window (now 7000ms) even if a poll or two is delayed,
1259
- // while detecting a real disconnect meaningfully faster than before.
1260
- focusTimer = setInterval(pollFocus, 2000);
1305
+ // Real bug found by testing (PM report: "device shows offline after
1306
+ // connecting"): focusTimer used to start HERE, unconditionally, before
1307
+ // connectToAzure() (called at the bottom of this function, unawaited)
1308
+ // had any chance to resolve its /device/connect call and set the real
1309
+ // deviceId below. pollFocus's own deviceId-aware URL building silently
1310
+ // omits the heartbeat whenever deviceId is still null - harmless for a
1311
+ // couple of ticks on a normal fast connect, but genuinely persistent
1312
+ // (heartbeat omitted for the ENTIRE outage) whenever /device/connect is
1313
+ // stuck in its own 3s retry loop (network hiccup, backend restart) -
1314
+ // every tick silently did nothing, with no visible error, for as long
1315
+ // as that retry loop ran. Moved to start right where deviceId itself
1316
+ // gets set, inside connectToAzure() (see its own comment there) -
1317
+ // "Offline" on the phone now correctly means "genuinely not
1318
+ // connected," never "connected, but the heartbeat hasn't started yet."
1261
1319
  // CLI-agnostic - runs for every adapter (Claude Code, Codex, generic),
1262
1320
  // since battery is a property of the LAPTOP, not of whatever CLI it
1263
1321
  // happens to be running.