esque-bridge 0.3.0 → 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.
- package/index.js +149 -13
- 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
|
-
|
|
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
|
+
};
|
|
293
347
|
|
|
294
|
-
|
|
295
|
-
|
|
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
|
+
};
|
|
362
|
+
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
328
|
-
|
|
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
|
-
|
|
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
|
});
|
|
@@ -465,7 +546,10 @@ async function startPreview(cmd, port) {
|
|
|
465
546
|
return t.url;
|
|
466
547
|
}
|
|
467
548
|
|
|
468
|
-
|
|
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;
|
|
469
553
|
|
|
470
554
|
// If the agent's reply declares a preview, start it (bridge-owned) and
|
|
471
555
|
// swap the marker line for the public URL the phone can open.
|
|
@@ -508,6 +592,10 @@ function gcJobs() {
|
|
|
508
592
|
if (now - j.createdAt > RESULT_TTL_MS) jobs.delete(id);
|
|
509
593
|
}
|
|
510
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?.();
|
|
511
599
|
|
|
512
600
|
async function executeHandler(req, res) {
|
|
513
601
|
const body = req.body || {};
|
|
@@ -610,12 +698,60 @@ app.use((_req, res) => res.status(405).json({ error: 'POST only' }));
|
|
|
610
698
|
|
|
611
699
|
// --- Boot -----------------------------------------------------------------
|
|
612
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
|
+
|
|
613
721
|
async function main() {
|
|
614
722
|
if (!fs.existsSync(WORKDIR) || !fs.statSync(WORKDIR).isDirectory()) {
|
|
615
723
|
console.error(`workdir does not exist: ${WORKDIR}`);
|
|
616
724
|
process.exit(1);
|
|
617
725
|
}
|
|
618
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
|
+
|
|
619
755
|
await new Promise((resolve) => app.listen(PORT, resolve));
|
|
620
756
|
|
|
621
757
|
let tunnel;
|
|
@@ -645,7 +781,7 @@ async function main() {
|
|
|
645
781
|
console.log(` Workdir ${WORKDIR}`);
|
|
646
782
|
console.log(` Binary ${AGENT_BIN ?? '(custom)'}`);
|
|
647
783
|
console.log(
|
|
648
|
-
` Pair secret ${PAIRING_SECRET.slice(0, 8)}… (
|
|
784
|
+
` Pair secret ${PAIRING_SECRET.slice(0, 8)}… (persists across restarts; --rotate to reset)`,
|
|
649
785
|
);
|
|
650
786
|
console.log('');
|
|
651
787
|
console.log(' Press Ctrl-C to stop.');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "esque-bridge",
|
|
3
|
-
"version": "0.
|
|
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"
|