esque-bridge 0.2.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/index.js +267 -14
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -54,6 +54,8 @@ OPTIONS
54
54
  --bin Override the agent's default binary
55
55
  --timeout Max ms per prompt before SIGTERM (default: 300000 = 5 min)
56
56
  --subdomain Request a stable localtunnel subdomain (optional)
57
+ --rotate Generate a fresh pair secret (invalidates existing pairings)
58
+ --yes, -y Skip the "edit files in <dir>?" confirmation prompt
57
59
 
58
60
  PREREQS
59
61
  Claude: npm install -g @anthropic-ai/claude-code && claude /login
@@ -79,8 +81,35 @@ const AGENT_TYPE = String(argv.agent || process.env.ESQUE_AGENT || 'claude').toL
79
81
  const CUSTOM_CMD = argv.cmd || process.env.ESQUE_CMD || null;
80
82
  const BIN_OVERRIDE = argv.bin || null;
81
83
  const LT_SUBDOMAIN = argv.subdomain || process.env.LT_SUBDOMAIN || undefined;
82
- const PAIRING_SECRET = crypto.randomBytes(16).toString('hex');
83
84
  const SESSIONS_FILE = path.join(os.homedir(), '.esque-bridge-sessions.json');
85
+ const SECRET_FILE = path.join(os.homedir(), '.esque-bridge-secret');
86
+
87
+ // Pairing secret. Persisted across restarts so a routine bridge restart
88
+ // doesn't silently invalidate the phone's pairing (the #1 real-world
89
+ // breakage). Pass `--rotate` to force a fresh secret (e.g. if it leaked).
90
+ // Stored 0600 in the user's home dir; a malformed/empty file is regenerated.
91
+ function resolvePairingSecret() {
92
+ const gen = () => crypto.randomBytes(16).toString('hex');
93
+ if (!argv.rotate) {
94
+ try {
95
+ const existing = fs.readFileSync(SECRET_FILE, 'utf8').trim();
96
+ if (/^[a-f0-9]{32}$/.test(existing)) return existing;
97
+ } catch {
98
+ /* missing/unreadable — fall through and create one */
99
+ }
100
+ }
101
+ const secret = gen();
102
+ try {
103
+ fs.writeFileSync(SECRET_FILE, secret, { mode: 0o600 });
104
+ } catch (err) {
105
+ console.warn(
106
+ `[esque-bridge] could not persist pair secret to ${SECRET_FILE} ` +
107
+ `(${err.message}); it will rotate on next restart.`,
108
+ );
109
+ }
110
+ return secret;
111
+ }
112
+ const PAIRING_SECRET = resolvePairingSecret();
84
113
 
85
114
  function parseArgs(args) {
86
115
  const out = {};
@@ -267,15 +296,22 @@ function runAgent(prompt, esqueSessionId) {
267
296
  usesStdin = !argv.some((a) => a.includes(prompt));
268
297
  }
269
298
 
299
+ // detached → its own process group so we can kill the WHOLE tree (the
300
+ // agent can spawn its own subprocesses) on timeout instead of orphaning
301
+ // zombies.
270
302
  const child = spawn(bin, argv, {
271
303
  cwd: WORKDIR,
272
304
  env: process.env,
273
305
  stdio: ['pipe', 'pipe', 'pipe'],
306
+ detached: true,
274
307
  });
275
308
 
309
+ const MAX_BUF = 16 * 1024 * 1024; // hard cap so a runaway agent can't OOM the bridge
276
310
  let stdout = '';
277
311
  let stderr = '';
312
+ let truncated = false;
278
313
  let settled = false;
314
+ let killEscalation = null;
279
315
  const settle = (fn) => (val) => {
280
316
  if (settled) return;
281
317
  settled = true;
@@ -284,17 +320,50 @@ function runAgent(prompt, esqueSessionId) {
284
320
  const resolveOnce = settle(resolve);
285
321
  const rejectOnce = settle(reject);
286
322
 
323
+ const killTree = (signal) => {
324
+ try {
325
+ process.kill(-child.pid, signal);
326
+ } catch {
327
+ try {
328
+ child.kill(signal);
329
+ } catch {
330
+ /* already gone */
331
+ }
332
+ }
333
+ };
334
+
287
335
  const killTimer = setTimeout(() => {
288
- child.kill('SIGTERM');
336
+ killTree('SIGTERM');
337
+ // Escalate to SIGKILL if the agent ignores SIGTERM.
338
+ killEscalation = setTimeout(() => killTree('SIGKILL'), 5000);
289
339
  rejectOnce(
290
340
  new Error(`${adapter.label} timed out after ${Math.round(TIMEOUT_MS / 1000)}s`),
291
341
  );
292
342
  }, TIMEOUT_MS);
343
+ const clearTimers = () => {
344
+ clearTimeout(killTimer);
345
+ if (killEscalation) clearTimeout(killEscalation);
346
+ };
347
+
348
+ const appendCapped = (buf, d) => {
349
+ if (truncated) return buf;
350
+ const next = buf + d;
351
+ if (next.length > MAX_BUF) {
352
+ truncated = true;
353
+ clearTimers();
354
+ killTree('SIGTERM');
355
+ rejectOnce(
356
+ new Error(`${adapter.label} produced too much output (>16MB) and was stopped.`),
357
+ );
358
+ return next.slice(0, MAX_BUF);
359
+ }
360
+ return next;
361
+ };
293
362
 
294
- child.stdout.on('data', (d) => (stdout += d));
295
- child.stderr.on('data', (d) => (stderr += d));
363
+ child.stdout.on('data', (d) => (stdout = appendCapped(stdout, d)));
364
+ child.stderr.on('data', (d) => (stderr = appendCapped(stderr, d)));
296
365
  child.on('error', (err) => {
297
- clearTimeout(killTimer);
366
+ clearTimers();
298
367
  if (err.code === 'ENOENT') {
299
368
  rejectOnce(
300
369
  new Error(`'${bin}' not found in PATH. Install: ${adapter.install}`),
@@ -304,7 +373,8 @@ function runAgent(prompt, esqueSessionId) {
304
373
  rejectOnce(err);
305
374
  });
306
375
  child.on('close', (code) => {
307
- clearTimeout(killTimer);
376
+ clearTimers();
377
+ if (truncated) return;
308
378
  if (code !== 0) {
309
379
  rejectOnce(
310
380
  new Error(
@@ -324,10 +394,15 @@ function runAgent(prompt, esqueSessionId) {
324
394
  }
325
395
  });
326
396
 
327
- if (usesStdin) {
328
- child.stdin.write(prompt);
397
+ // A child that dies at startup makes the stdin write throw EPIPE — an
398
+ // uncaught error that would otherwise crash the whole bridge.
399
+ child.stdin.on('error', () => {});
400
+ try {
401
+ if (usesStdin) child.stdin.write(prompt);
402
+ child.stdin.end();
403
+ } catch {
404
+ /* child already gone — handled by the 'error'/'close' listeners */
329
405
  }
330
- child.stdin.end();
331
406
  });
332
407
  }
333
408
 
@@ -353,10 +428,16 @@ app.get('/', (_req, res) => {
353
428
  });
354
429
  });
355
430
 
356
- // Connection-test probe — no auth.
431
+ // Connection-test probe — no auth required to confirm reachability, but if
432
+ // the phone includes the pair secret we validate it and report back via
433
+ // `paired` so the app can verify the secret BEFORE showing a green
434
+ // "Paired" state. `paired` is true (match), false (wrong/stale secret), or
435
+ // null (no secret sent — older app builds; we can't say either way).
357
436
  app.post('/', (req, res, next) => {
358
437
  if (req.body && req.body._probe === true) {
359
- return res.json({ ok: true, service: 'esque-bridge', agent: AGENT_TYPE });
438
+ const provided = req.header('x-esque-pair') || req.body?.pairSecret;
439
+ const paired = provided == null ? null : provided === PAIRING_SECRET;
440
+ return res.json({ ok: true, service: 'esque-bridge', agent: AGENT_TYPE, paired });
360
441
  }
361
442
  return next();
362
443
  });
@@ -373,6 +454,125 @@ function requireAuth(req, res, next) {
373
454
  next();
374
455
  }
375
456
 
457
+ // ── Live preview (bridge-owned) ───────────────────────────────────────
458
+ // The agent declares a preview with a marker line in its reply:
459
+ // ESQUE_PREVIEW: <dev server start command> @ <port>
460
+ // e.g. ESQUE_PREVIEW: npm run dev @ 3000
461
+ //
462
+ // The BRIDGE runs that dev server and tunnels it — NOT the one-shot
463
+ // `claude --print`, which exits the instant its turn ends and would
464
+ // orphan/kill anything it started (that's why the old "agent starts its
465
+ // own tunnel" approach left the preview URL dead by the time you tapped
466
+ // it). cloudflared is preferred over localtunnel because its URL has no
467
+ // "click to continue" interstitial that would block the in-app WebView.
468
+ const net = require('net');
469
+ const { execSync } = require('child_process');
470
+
471
+ let preview = null; // { port, proc, kind, url, tunnel, tunnelProc }
472
+
473
+ function killPreview() {
474
+ if (!preview) return;
475
+ try { preview.proc && preview.proc.kill('SIGTERM'); } catch {}
476
+ try { preview.tunnel && preview.tunnel.close(); } catch {}
477
+ try { preview.tunnelProc && preview.tunnelProc.kill('SIGTERM'); } catch {}
478
+ preview = null;
479
+ }
480
+
481
+ function waitForPort(port, timeoutMs) {
482
+ const start = Date.now();
483
+ return new Promise((resolve) => {
484
+ const tick = () => {
485
+ const s = net.connect(port, '127.0.0.1');
486
+ s.on('connect', () => { s.destroy(); resolve(true); });
487
+ s.on('error', () => {
488
+ s.destroy();
489
+ if (Date.now() - start > timeoutMs) resolve(false);
490
+ else setTimeout(tick, 600);
491
+ });
492
+ };
493
+ tick();
494
+ });
495
+ }
496
+
497
+ function hasCloudflared() {
498
+ try { execSync('which cloudflared', { stdio: 'ignore' }); return true; }
499
+ catch { return false; }
500
+ }
501
+
502
+ // Open a public tunnel to a local port. Prefers cloudflared (clean URL,
503
+ // no interstitial), falls back to localtunnel. Resolves the tunnel handle.
504
+ function openPreviewTunnel(port) {
505
+ return new Promise(async (resolve) => {
506
+ if (hasCloudflared()) {
507
+ const proc = spawn('cloudflared', ['tunnel', '--url', `http://localhost:${port}`], {
508
+ stdio: ['ignore', 'pipe', 'pipe'],
509
+ });
510
+ let done = false;
511
+ const scan = (d) => {
512
+ const m = String(d).match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/i);
513
+ if (m && !done) { done = true; resolve({ url: m[0], kind: 'cloudflared', tunnelProc: proc }); }
514
+ };
515
+ proc.stdout.on('data', scan);
516
+ proc.stderr.on('data', scan);
517
+ setTimeout(() => { if (!done) { done = true; try { proc.kill(); } catch {} resolve(null); } }, 25000);
518
+ } else {
519
+ try {
520
+ const tunnel = await localtunnel({ port });
521
+ resolve({ url: tunnel.url, kind: 'localtunnel', tunnel });
522
+ } catch { resolve(null); }
523
+ }
524
+ });
525
+ }
526
+
527
+ async function startPreview(cmd, port) {
528
+ killPreview();
529
+ console.log(`[preview] starting: ${cmd} (port ${port})`);
530
+ const proc = spawn('sh', ['-c', cmd], { cwd: WORKDIR, env: process.env, stdio: ['ignore', 'pipe', 'pipe'] });
531
+ proc.stdout.on('data', (d) => process.stdout.write(`[preview] ${d}`));
532
+ proc.stderr.on('data', (d) => process.stdout.write(`[preview] ${d}`));
533
+ proc.on('exit', (code) => console.log(`[preview] dev server exited (${code})`));
534
+ preview = { port, proc, kind: null, url: null, tunnel: null, tunnelProc: null };
535
+
536
+ const up = await waitForPort(port, 60000);
537
+ if (!up) { console.log('[preview] dev server never opened the port'); return null; }
538
+
539
+ const t = await openPreviewTunnel(port);
540
+ if (!t) { console.log('[preview] could not open a public tunnel'); return null; }
541
+ preview.url = t.url;
542
+ preview.kind = t.kind;
543
+ preview.tunnel = t.tunnel || null;
544
+ preview.tunnelProc = t.tunnelProc || null;
545
+ console.log(`[preview] live at ${t.url} (${t.kind})`);
546
+ return t.url;
547
+ }
548
+
549
+ // Tolerate leading markdown punctuation / code-fence backticks the agent
550
+ // may wrap the marker in (e.g. `ESQUE_PREVIEW: npm run dev @ 3000`), and an
551
+ // optional trailing backtick — otherwise the whole preview feature no-ops.
552
+ const PREVIEW_RE = /^[ \t>*`-]*ESQUE_PREVIEW:\s*(.+?)\s*@\s*(\d+)\s*`?[ \t]*$/im;
553
+
554
+ // If the agent's reply declares a preview, start it (bridge-owned) and
555
+ // swap the marker line for the public URL the phone can open.
556
+ async function applyPreview(text) {
557
+ if (!text) return text;
558
+ const m = text.match(PREVIEW_RE);
559
+ if (!m) return text;
560
+ const cmd = m[1].trim();
561
+ const port = Number(m[2]);
562
+ let url = null;
563
+ try { url = await startPreview(cmd, port); } catch (e) { console.error('[preview] error:', e.message); }
564
+ const replacement = url
565
+ ? `🔗 Live preview: ${url}`
566
+ : `(Couldn't auto-start the preview — run \`${cmd}\` in ${WORKDIR} yourself.)`;
567
+ return text.replace(PREVIEW_RE, replacement);
568
+ }
569
+
570
+ async function runAgentWithPreview(prompt, sessionId) {
571
+ const result = await runAgent(prompt, sessionId);
572
+ if (result && result.text) result.text = await applyPreview(result.text);
573
+ return result;
574
+ }
575
+
376
576
  // ── Async job store ───────────────────────────────────────────────────
377
577
  // A prompt can run for minutes (e.g. a full project scaffold). Holding one
378
578
  // HTTP connection open that long is fragile over tunnels and times the
@@ -392,6 +592,10 @@ function gcJobs() {
392
592
  if (now - j.createdAt > RESULT_TTL_MS) jobs.delete(id);
393
593
  }
394
594
  }
595
+ // Evict expired jobs even on an idle bridge (the POST path GCs too, but an
596
+ // idle bridge would otherwise hold finished jobs forever). unref so it
597
+ // never keeps the process alive on its own.
598
+ setInterval(gcJobs, 60_000).unref?.();
395
599
 
396
600
  async function executeHandler(req, res) {
397
601
  const body = req.body || {};
@@ -417,7 +621,7 @@ async function executeHandler(req, res) {
417
621
  const jobId = newJobId();
418
622
  jobs.set(jobId, { status: 'working', text: '', createdAt: Date.now() });
419
623
  res.json({ jobId, status: 'working' });
420
- runAgent(prompt, esqueSessionId)
624
+ runAgentWithPreview(prompt, esqueSessionId)
421
625
  .then((result) => {
422
626
  jobs.set(jobId, {
423
627
  status: result.isError ? 'blocked' : 'finished',
@@ -463,7 +667,7 @@ async function executeHandler(req, res) {
463
667
  };
464
668
 
465
669
  try {
466
- const result = await runAgent(prompt, esqueSessionId);
670
+ const result = await runAgentWithPreview(prompt, esqueSessionId);
467
671
  finish({
468
672
  text: result.text,
469
673
  status: result.isError ? 'blocked' : 'finished',
@@ -494,12 +698,60 @@ app.use((_req, res) => res.status(405).json({ error: 'POST only' }));
494
698
 
495
699
  // --- Boot -----------------------------------------------------------------
496
700
 
701
+ // One-line y/N confirmation read from the controlling terminal. Resolves
702
+ // true only on an explicit yes.
703
+ function confirmWorkdir(dir) {
704
+ return new Promise((resolve) => {
705
+ const rl = require('readline').createInterface({
706
+ input: process.stdin,
707
+ output: process.stdout,
708
+ });
709
+ rl.question(
710
+ `\n ⚠ Esque will let the AI agent READ and EDIT files in:\n ${dir}\n` +
711
+ ` (Claude runs with --dangerously-skip-permissions, so it won't ask per-file.)\n` +
712
+ ` Continue? [y/N] `,
713
+ (answer) => {
714
+ rl.close();
715
+ resolve(/^y(es)?$/i.test(String(answer).trim()));
716
+ },
717
+ );
718
+ });
719
+ }
720
+
497
721
  async function main() {
498
722
  if (!fs.existsSync(WORKDIR) || !fs.statSync(WORKDIR).isDirectory()) {
499
723
  console.error(`workdir does not exist: ${WORKDIR}`);
500
724
  process.exit(1);
501
725
  }
502
726
 
727
+ // Safety gate: confirm the working directory before exposing it to the
728
+ // agent. Only prompts on an interactive terminal — piped/CI starts (and
729
+ // `--yes`) skip it. This is the guard against accidentally running against
730
+ // $HOME or an unintended repo via `npx esque-bridge` in the wrong place.
731
+ if (process.stdin.isTTY && !argv.yes && !argv.y) {
732
+ const ok = await confirmWorkdir(WORKDIR);
733
+ if (!ok) {
734
+ console.log(
735
+ '\n Aborted. cd into your project first, or pass --workdir <path>.\n',
736
+ );
737
+ process.exit(0);
738
+ }
739
+ }
740
+
741
+ // Claude Code refuses to run with --dangerously-skip-permissions as root,
742
+ // which would make every prompt fail on a healthy-looking pairing. Catch it
743
+ // up front with a plain-language fix instead.
744
+ if (
745
+ AGENT_TYPE === 'claude' &&
746
+ typeof process.getuid === 'function' &&
747
+ process.getuid() === 0
748
+ ) {
749
+ console.error('\n ✗ Esque can\'t drive Claude as the root user.');
750
+ console.error(' Re-run WITHOUT sudo, as your normal user:');
751
+ console.error(' npx esque-bridge\n');
752
+ process.exit(1);
753
+ }
754
+
503
755
  await new Promise((resolve) => app.listen(PORT, resolve));
504
756
 
505
757
  let tunnel;
@@ -529,7 +781,7 @@ async function main() {
529
781
  console.log(` Workdir ${WORKDIR}`);
530
782
  console.log(` Binary ${AGENT_BIN ?? '(custom)'}`);
531
783
  console.log(
532
- ` Pair secret ${PAIRING_SECRET.slice(0, 8)}… (rotates on restart don't share)`,
784
+ ` Pair secret ${PAIRING_SECRET.slice(0, 8)}… (persists across restarts; --rotate to reset)`,
533
785
  );
534
786
  console.log('');
535
787
  console.log(' Press Ctrl-C to stop.');
@@ -537,6 +789,7 @@ async function main() {
537
789
 
538
790
  const shutdown = (signal) => {
539
791
  console.log(`\n Received ${signal} — closing tunnel…`);
792
+ killPreview();
540
793
  try {
541
794
  tunnel.close();
542
795
  } catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "esque-bridge",
3
- "version": "0.2.2",
3
+ "version": "0.4.0",
4
4
  "description": "Desktop-side receiver for the Esque Agent mobile app. Pairs your phone with a local coding-agent CLI (Claude Code, Aider, or any custom command) via a tunnel + QR code, so prompts run through your subscription instead of per-token API billing.",
5
5
  "bin": {
6
6
  "esque-bridge": "index.js"