@debugg-ai/debugg-ai-mcp 3.9.3 → 4.0.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.
@@ -0,0 +1,611 @@
1
+ /**
2
+ * Caddy proxy service — services/caddy/caddyProxy.ts
3
+ *
4
+ * One CaddyProxyManager instance per session key (§2.1/§2.2 of
5
+ * docs/local-tunnel-multiplexer-architecture-2026-07-31.md). Spawns a local
6
+ * `caddy` process holding EXACTLY ONE dynamic reverse-proxy upstream, bound
7
+ * to loopback only, repointed via Caddy's local (unauthenticated) admin API
8
+ * immediately before each tool dispatch that needs a different local port.
9
+ *
10
+ * This is the structural fix for the Feb 2026 path-prefix-routing bug
11
+ * (beads lb8/p6y/brl/vp9): there is no `/p/{port}` prefix, no path
12
+ * rewriting at all, so root-absolute requests (`/api/...`, `/_next/...`)
13
+ * never collide with a second port "in the way."
14
+ *
15
+ * See the architecture doc §2.2 for the full design rationale — this file
16
+ * is a direct implementation of it, not a reinterpretation.
17
+ */
18
+ // NOTE: child_process/http are imported WITHOUT the 'node:' prefix
19
+ // deliberately — jest's unstable_mockModule (ESM mocking) cannot reliably
20
+ // intercept 'node:'-prefixed builtin specifiers in this repo's jest/ts-jest
21
+ // setup, but it can intercept the bare form (matches utils/gitContext.ts's
22
+ // existing 'child_process' mock convention). Mocked in
23
+ // __tests__/services/caddyProxy.test.ts.
24
+ import { spawn } from 'child_process';
25
+ import * as http from 'http';
26
+ import { createServer } from 'node:net';
27
+ import * as fs from 'node:fs';
28
+ import * as os from 'node:os';
29
+ import * as path from 'node:path';
30
+ import { fileURLToPath } from 'node:url';
31
+ import { randomBytes } from 'node:crypto';
32
+ import { Logger } from '../../utils/logger.js';
33
+ const logger = new Logger({ module: 'caddyProxy' });
34
+ // ── Error classes (house style: named subclasses of Error, see
35
+ // services/tunnels.ts's TunnelProvisionError for the pattern) ───────────
36
+ export class CaddyBinaryNotFoundError extends Error {
37
+ constructor(message) {
38
+ super(message);
39
+ this.name = 'CaddyBinaryNotFoundError';
40
+ }
41
+ }
42
+ export class CaddyStartupError extends Error {
43
+ constructor(message) {
44
+ super(message);
45
+ this.name = 'CaddyStartupError';
46
+ }
47
+ }
48
+ export class CaddyAdminApiError extends Error {
49
+ constructor(message) {
50
+ super(message);
51
+ this.name = 'CaddyAdminApiError';
52
+ }
53
+ }
54
+ export class CaddyPortReclaimError extends CaddyStartupError {
55
+ constructor(message) {
56
+ super(message);
57
+ this.name = 'CaddyPortReclaimError';
58
+ }
59
+ }
60
+ // ── Pure logic: HTTP/Docker upstream matrix ─────────────────────────────────
61
+ /**
62
+ * Caddy owns the full isHttpsLocal/inDocker/dockerHost resolution internally
63
+ * (architecture doc §2.2), reading `inDocker` exactly as
64
+ * tunnelManager.ts:688 does today (`process.env.DOCKER_CONTAINER === 'true'`).
65
+ * Callers pass only `{port, isHttpsLocal}` — the one fact Caddy cannot derive.
66
+ *
67
+ * The `localhost` (NOT `127.0.0.1`) asymmetry on the HTTPS/non-Docker branch
68
+ * is preserved verbatim from tunnelManager.ts:698, per explicit brief in the
69
+ * architecture doc — do not "fix" this to 127.0.0.1.
70
+ */
71
+ export function resolveDialAddress(port, isHttpsLocal, inDocker) {
72
+ const dockerHost = 'host.docker.internal';
73
+ if (isHttpsLocal)
74
+ return inDocker ? `${dockerHost}:${port}` : `localhost:${port}`; // NOT 127.0.0.1 — preserved verbatim
75
+ return inDocker ? `${dockerHost}:${port}` : `127.0.0.1:${port}`;
76
+ }
77
+ /** Whether the current process is running inside Docker, per DOCKER_CONTAINER env var. */
78
+ export function isDockerEnv() {
79
+ return process.env.DOCKER_CONTAINER === 'true';
80
+ }
81
+ // ── Bundled binary resolution ────────────────────────────────────────────────
82
+ //
83
+ // @radically-straightforward/caddy is a dependency (package.json's "caddy"
84
+ // field pins the exact version — see its postinstall) that downloads Caddy
85
+ // from the project's own GitHub releases and drops it at
86
+ // node_modules/.bin/caddy(.exe). Same pattern this repo already uses for the
87
+ // ngrok binary (the "ngrok" npm package's own postinstall). Pinned rather
88
+ // than "latest" deliberately: a real config incompatibility with a Caddy
89
+ // version (--adapter json rejected by 2.11.3) was found and fixed during
90
+ // development of this file — "latest" silently shipping a breaking change
91
+ // under us is exactly the failure mode pinning avoids.
92
+ //
93
+ // Precedence (resolveCaddyBinary): CADDY_BIN env → caddyBinOverride ctor opt
94
+ // → this bundled binary → bare 'caddy' resolved from PATH (last resort, e.g.
95
+ // a system install with no bundled binary present for some reason).
96
+ let _bundledCaddyBinaryCache; // undefined = not yet resolved
97
+ /** Same "walk up to my own package.json" pattern as config/index.ts's
98
+ * findPackageVersion() — finds this package's root regardless of whether
99
+ * it's a repo checkout, a global install, or an npx cache dir. */
100
+ function findOwnPackageRoot() {
101
+ let dir = path.dirname(fileURLToPath(import.meta.url));
102
+ while (true) {
103
+ try {
104
+ const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf-8'));
105
+ if (pkg.name === '@debugg-ai/debugg-ai-mcp')
106
+ return dir;
107
+ }
108
+ catch { /* keep walking */ }
109
+ const parent = path.dirname(dir);
110
+ if (parent === dir)
111
+ return undefined;
112
+ dir = parent;
113
+ }
114
+ }
115
+ /** Resolves (and caches) the path to the bundled Caddy binary, if present.
116
+ * Returns undefined if @radically-straightforward/caddy's postinstall never
117
+ * ran or failed (e.g. npm install --ignore-scripts) — resolveCaddyBinary()
118
+ * falls through to a bare 'caddy' PATH lookup in that case. */
119
+ export function findBundledCaddyBinary() {
120
+ if (_bundledCaddyBinaryCache !== undefined)
121
+ return _bundledCaddyBinaryCache ?? undefined;
122
+ const root = findOwnPackageRoot();
123
+ if (!root) {
124
+ _bundledCaddyBinaryCache = null;
125
+ return undefined;
126
+ }
127
+ const binName = process.platform === 'win32' ? 'caddy.exe' : 'caddy';
128
+ const binPath = path.join(root, 'node_modules', '.bin', binName);
129
+ _bundledCaddyBinaryCache = fs.existsSync(binPath) ? binPath : null;
130
+ return _bundledCaddyBinaryCache ?? undefined;
131
+ }
132
+ /** Test-only: clears the memoized bundled-binary lookup. */
133
+ export function _resetBundledCaddyBinaryCacheForTests() {
134
+ _bundledCaddyBinaryCache = undefined;
135
+ }
136
+ // ── Config file location ─────────────────────────────────────────────────────
137
+ /** ~/.debugg-ai/caddy — config files live here, one per (pid, instanceId). */
138
+ export function caddyConfigDir() {
139
+ return path.join(os.homedir(), '.debugg-ai', 'caddy');
140
+ }
141
+ const CONFIG_FILE_RE = /^config-(\d+)-[a-f0-9]+\.json$/;
142
+ function configFileName(pid, instanceId) {
143
+ return `config-${pid}-${instanceId}.json`;
144
+ }
145
+ function isPidAlive(pid) {
146
+ try {
147
+ process.kill(pid, 0);
148
+ return true;
149
+ }
150
+ catch (err) {
151
+ // EPERM means the process exists but we lack permission to signal it —
152
+ // still alive. Anything else (ESRCH etc.) means it's gone.
153
+ return err.code === 'EPERM';
154
+ }
155
+ }
156
+ /**
157
+ * Startup orphan sweep: removes config files left behind by processes that
158
+ * are no longer alive. Runs once per MCP process lifetime (module-level
159
+ * guard) — cheap, but pointless to repeat per session-key instance.
160
+ */
161
+ let orphanSweepDone = false;
162
+ export function sweepOrphanedConfigs(dir = caddyConfigDir()) {
163
+ if (orphanSweepDone)
164
+ return;
165
+ orphanSweepDone = true;
166
+ let entries;
167
+ try {
168
+ entries = fs.readdirSync(dir);
169
+ }
170
+ catch {
171
+ return; // directory doesn't exist yet — nothing to sweep
172
+ }
173
+ for (const name of entries) {
174
+ const m = CONFIG_FILE_RE.exec(name);
175
+ if (!m)
176
+ continue;
177
+ const pid = Number(m[1]);
178
+ if (pid === process.pid)
179
+ continue;
180
+ if (!isPidAlive(pid)) {
181
+ try {
182
+ fs.unlinkSync(path.join(dir, name));
183
+ logger.debug(`Swept orphaned Caddy config file: ${name}`);
184
+ }
185
+ catch (err) {
186
+ logger.debug(`Failed to remove orphaned Caddy config file ${name}: ${err}`);
187
+ }
188
+ }
189
+ }
190
+ }
191
+ /** Test-only: allow re-running the sweep within one process. */
192
+ export function _resetOrphanSweepForTests() {
193
+ orphanSweepDone = false;
194
+ }
195
+ // ── findFreePort() — bind :0 and close ──────────────────────────────────────
196
+ export function findFreePort() {
197
+ return new Promise((resolve, reject) => {
198
+ const srv = createServer();
199
+ srv.unref();
200
+ srv.once('error', reject);
201
+ srv.listen(0, '127.0.0.1', () => {
202
+ const address = srv.address();
203
+ if (address && typeof address === 'object') {
204
+ const port = address.port;
205
+ srv.close((closeErr) => {
206
+ if (closeErr)
207
+ reject(closeErr);
208
+ else
209
+ resolve(port);
210
+ });
211
+ }
212
+ else {
213
+ srv.close(() => reject(new Error('findFreePort: could not determine bound port')));
214
+ }
215
+ });
216
+ });
217
+ }
218
+ // ── Config builder ────────────────────────────────────────────────────────
219
+ /** The single addressable handler node every PATCH targets: GET/PATCH /id/dbg-handler. */
220
+ export const CADDY_HANDLER_ID = 'dbg-handler';
221
+ /** Placeholder upstream: dials a closed port so a stray request 502s cleanly
222
+ * from the first millisecond instead of exposing a raw connection-refused. */
223
+ const PLACEHOLDER_DIAL = '127.0.0.1:1';
224
+ /**
225
+ * Builds the eager-at-spawn Caddy JSON config. One code path for every call
226
+ * including the first (architecture doc §2.2): PATCH-only against a known
227
+ * @id-tagged node so config-shape drift 400s loudly instead of silently
228
+ * building new structure. Both listeners bind 127.0.0.1 only — the admin
229
+ * API has no built-in auth, so this is a hard security requirement.
230
+ */
231
+ export function buildCaddyConfig(proxyPort, adminPort) {
232
+ return {
233
+ admin: { listen: `127.0.0.1:${adminPort}` },
234
+ apps: {
235
+ http: {
236
+ servers: {
237
+ srv0: {
238
+ listen: [`127.0.0.1:${proxyPort}`],
239
+ routes: [
240
+ {
241
+ handle: [
242
+ {
243
+ '@id': CADDY_HANDLER_ID,
244
+ handler: 'reverse_proxy',
245
+ // DO NOT add header_up Host — see
246
+ // docs/local-tunnel-multiplexer-architecture-2026-07-31.md §2.2;
247
+ // this would change what every local dev server sees vs. today.
248
+ upstreams: [{ dial: PLACEHOLDER_DIAL }],
249
+ },
250
+ ],
251
+ },
252
+ ],
253
+ },
254
+ },
255
+ },
256
+ },
257
+ };
258
+ }
259
+ /**
260
+ * Builds the PATCH body for a real upstream target. One PATCH atomically
261
+ * replaces both `dial` and `transport` — never two separate requests — so
262
+ * Caddy is never briefly holding a mismatched dial/transport pair. `@id`
263
+ * MUST be resent in every PATCH body: PATCH replaces the value at that
264
+ * address, so an omitted `@id` deletes the tag and the NEXT PATCH 404s
265
+ * (only manifests on the second call — see LIFE-4 in the test suite).
266
+ */
267
+ export function buildPatchBody(target, inDocker) {
268
+ const isHttpsLocal = !!target.isHttpsLocal;
269
+ const dial = resolveDialAddress(target.port, isHttpsLocal, inDocker);
270
+ const body = {
271
+ '@id': CADDY_HANDLER_ID,
272
+ handler: 'reverse_proxy',
273
+ upstreams: [{ dial }],
274
+ };
275
+ if (isHttpsLocal) {
276
+ // Local self-signed certs — doesn't touch the public leg's real ngrok TLS.
277
+ body.transport = { protocol: 'http', tls: { insecure_skip_verify: true } };
278
+ }
279
+ return body;
280
+ }
281
+ function adminHttpRequest(adminPort, method, reqPath, body, timeoutMs = 2000) {
282
+ return new Promise((resolve, reject) => {
283
+ const req = http.request({
284
+ host: '127.0.0.1',
285
+ port: adminPort,
286
+ path: reqPath,
287
+ method,
288
+ headers: body != null
289
+ ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }
290
+ : undefined,
291
+ timeout: timeoutMs,
292
+ }, (res) => {
293
+ let data = '';
294
+ res.on('data', (chunk) => { data += chunk; });
295
+ res.on('end', () => resolve({ status: res.statusCode ?? 0, body: data }));
296
+ });
297
+ req.on('timeout', () => req.destroy(new Error(`admin API request timed out after ${timeoutMs}ms`)));
298
+ req.on('error', reject);
299
+ if (body != null)
300
+ req.write(body);
301
+ req.end();
302
+ });
303
+ }
304
+ async function probeAdminHealthy(adminPort) {
305
+ try {
306
+ const res = await adminHttpRequest(adminPort, 'GET', `/id/${CADDY_HANDLER_ID}`, undefined, 500);
307
+ return res.status === 200;
308
+ }
309
+ catch {
310
+ return false;
311
+ }
312
+ }
313
+ export class CaddyProxyManager {
314
+ instanceId;
315
+ configDir;
316
+ startTimeoutMs;
317
+ probeIntervalMs;
318
+ caddyBinOverride;
319
+ child = null;
320
+ proxyPort = null;
321
+ adminPort = null;
322
+ started = false;
323
+ startPromise = null;
324
+ configPath = null;
325
+ lastProxyPort = null;
326
+ lastAppliedTarget = null;
327
+ portChangedListeners = [];
328
+ constructor(opts = {}) {
329
+ // Short random hex suffix — PID alone collides once one process hosts
330
+ // multiple session keys (HTTP mode). Must stay pure lowercase-hex: it's
331
+ // matched by CONFIG_FILE_RE during the orphan sweep below.
332
+ this.instanceId = randomBytes(6).toString('hex');
333
+ this.configDir = opts.configDir ?? caddyConfigDir();
334
+ this.startTimeoutMs = opts.startTimeoutMs ?? 5000;
335
+ this.probeIntervalMs = opts.probeIntervalMs ?? 100;
336
+ this.caddyBinOverride = opts.caddyBinOverride;
337
+ }
338
+ // -- CaddyProxy interface -------------------------------------------------
339
+ async ensureStarted() {
340
+ if (this.started && this.child && !this.isChildDead) {
341
+ return this.currentHandle();
342
+ }
343
+ if (this.startPromise) {
344
+ await this.startPromise;
345
+ return this.currentHandle();
346
+ }
347
+ this.startPromise = this.doStart().finally(() => {
348
+ this.startPromise = null;
349
+ });
350
+ await this.startPromise;
351
+ return this.currentHandle();
352
+ }
353
+ async setUpstream(target) {
354
+ const { adminPort } = await this.ensureStarted();
355
+ const isHttpsLocal = !!target.isHttpsLocal;
356
+ if (this.lastAppliedTarget &&
357
+ this.lastAppliedTarget.port === target.port &&
358
+ this.lastAppliedTarget.isHttpsLocal === isHttpsLocal) {
359
+ return; // idempotent no-op — this is what makes same-port calls free
360
+ }
361
+ const body = buildPatchBody(target, isDockerEnv());
362
+ try {
363
+ await this.patchHandler(adminPort, body);
364
+ }
365
+ catch (err) {
366
+ if (err instanceof CaddyAdminApiError) {
367
+ // Process is alive but the admin API rejected the PATCH — no respawn,
368
+ // propagate as-is.
369
+ throw err;
370
+ }
371
+ // Network-level failure against a previously-healthy admin port —
372
+ // the process is presumed dead. Exactly one respawn-and-retry, then
373
+ // propagate uncaught — no loop (matches ngrokAgentSession.ts's
374
+ // onTerminated philosophy: lazy, bounded, next-call-triggered).
375
+ logger.warn(`Caddy admin API unreachable on port ${adminPort} — assuming process died, respawning once: ${err}`);
376
+ this.markDead();
377
+ const { adminPort: freshAdminPort } = await this.ensureStarted();
378
+ await this.patchHandler(freshAdminPort, body);
379
+ }
380
+ this.lastAppliedTarget = { port: target.port, isHttpsLocal };
381
+ }
382
+ async isHealthy() {
383
+ if (!this.started || !this.adminPort || this.isChildDead)
384
+ return false;
385
+ try {
386
+ return await probeAdminHealthy(this.adminPort);
387
+ }
388
+ catch {
389
+ return false;
390
+ }
391
+ }
392
+ async stop() {
393
+ const child = this.child;
394
+ this.started = false;
395
+ this.child = null;
396
+ this.proxyPort = null;
397
+ this.adminPort = null;
398
+ this.lastAppliedTarget = null;
399
+ if (child && !this.isChildDeadRef(child)) {
400
+ try {
401
+ child.kill();
402
+ }
403
+ catch (err) {
404
+ logger.debug(`caddy.stop(): kill() failed (already dead?): ${err}`);
405
+ }
406
+ }
407
+ if (this.configPath) {
408
+ try {
409
+ fs.unlinkSync(this.configPath);
410
+ }
411
+ catch {
412
+ // best effort — file may already be gone
413
+ }
414
+ this.configPath = null;
415
+ }
416
+ }
417
+ onPortChanged(cb) {
418
+ this.portChangedListeners.push(cb);
419
+ }
420
+ // -- internals --------------------------------------------------------------
421
+ get isChildDead() {
422
+ return this.child ? this.isChildDeadRef(this.child) : true;
423
+ }
424
+ isChildDeadRef(child) {
425
+ return child.exitCode !== null || child.signalCode !== null;
426
+ }
427
+ markDead() {
428
+ this.started = false;
429
+ this.child = null;
430
+ }
431
+ currentHandle() {
432
+ if (!this.started || this.proxyPort == null || this.adminPort == null) {
433
+ throw new CaddyStartupError('CaddyProxyManager: not started (internal invariant violation)');
434
+ }
435
+ return {
436
+ localOrigin: `http://127.0.0.1:${this.proxyPort}`,
437
+ localPort: this.proxyPort,
438
+ adminPort: this.adminPort,
439
+ };
440
+ }
441
+ resolveCaddyBinary() {
442
+ return process.env.CADDY_BIN || this.caddyBinOverride || findBundledCaddyBinary() || 'caddy';
443
+ }
444
+ writeConfig(proxyPort, adminPort) {
445
+ fs.mkdirSync(this.configDir, { recursive: true });
446
+ const configPath = path.join(this.configDir, configFileName(process.pid, this.instanceId));
447
+ const config = buildCaddyConfig(proxyPort, adminPort);
448
+ fs.writeFileSync(configPath, JSON.stringify(config));
449
+ return configPath;
450
+ }
451
+ async patchHandler(adminPort, body) {
452
+ const json = JSON.stringify(body);
453
+ const res = await adminHttpRequest(adminPort, 'PATCH', `/id/${CADDY_HANDLER_ID}`, json);
454
+ if (res.status < 200 || res.status >= 300) {
455
+ throw new CaddyAdminApiError(`PATCH /id/${CADDY_HANDLER_ID} failed: ${res.status} ${res.body}`);
456
+ }
457
+ }
458
+ /**
459
+ * Waits for the admin API's readiness probe (GET /id/dbg-handler, every
460
+ * `probeIntervalMs`, capped at `startTimeoutMs`) racing the child's own
461
+ * `exit`/`error` events — an early crash or a missing binary must fail
462
+ * fast, not wait out the full timeout.
463
+ */
464
+ waitForHealthy(adminPort, child) {
465
+ return new Promise((resolve, reject) => {
466
+ let settled = false;
467
+ // `interval` is referenced inside `finish` below before its own `const`
468
+ // declaration further down this block — safe because `finish` is only
469
+ // ever CALLED from an async event, by which point `interval` has
470
+ // already been assigned (closures resolve free variables at call
471
+ // time, not at definition time).
472
+ const finish = (fn) => {
473
+ if (settled)
474
+ return;
475
+ settled = true;
476
+ clearInterval(interval);
477
+ child.off('exit', onExit);
478
+ child.off('error', onError);
479
+ fn();
480
+ };
481
+ const onExit = (code, signal) => {
482
+ finish(() => reject(new CaddyStartupError(`caddy process exited during startup (code=${code}, signal=${signal})`)));
483
+ };
484
+ const onError = (err) => {
485
+ finish(() => {
486
+ if (err.code === 'ENOENT') {
487
+ reject(new CaddyBinaryNotFoundError(`caddy binary not found (tried "${this.resolveCaddyBinary()}"). This should have been ` +
488
+ `installed automatically by the @radically-straightforward/caddy dependency — if you ran ` +
489
+ `npm install with --ignore-scripts, or in an offline/air-gapped environment, that download ` +
490
+ `never ran. Fix by either installing caddy yourself ("brew install caddy" on macOS, ` +
491
+ `"apt install caddy" on Debian/Ubuntu, or see https://caddyserver.com/docs/install) and ` +
492
+ `pointing CADDY_BIN at it, or re-running npm install with scripts enabled.`));
493
+ }
494
+ else {
495
+ reject(new CaddyStartupError(String(err)));
496
+ }
497
+ });
498
+ };
499
+ child.once('exit', onExit);
500
+ child.once('error', onError);
501
+ const deadline = Date.now() + this.startTimeoutMs;
502
+ const check = async () => {
503
+ if (settled)
504
+ return;
505
+ const ok = await probeAdminHealthy(adminPort);
506
+ if (ok) {
507
+ finish(resolve);
508
+ return;
509
+ }
510
+ if (Date.now() >= deadline) {
511
+ finish(() => reject(new CaddyStartupError(`caddy admin API did not become healthy within ${this.startTimeoutMs}ms`)));
512
+ }
513
+ };
514
+ const interval = setInterval(check, this.probeIntervalMs);
515
+ check();
516
+ });
517
+ }
518
+ /**
519
+ * Sticky proxy port across crash-respawn: attempt 1 reuses the last
520
+ * successfully-bound proxy port (if any); attempt 2 falls back to a fresh
521
+ * one. `CaddyPortReclaimError` means BOTH attempts failed — Caddy is fully
522
+ * down. `onPortChanged` fires on the "succeeded but moved" case, which is
523
+ * the signal TunnelManager needs to evict the now-orphaned ngrok tunnel.
524
+ */
525
+ async doStart() {
526
+ sweepOrphanedConfigs(this.configDir);
527
+ const bin = this.resolveCaddyBinary();
528
+ let stickyAttemptFailed = false;
529
+ const priorProxyPort = this.lastProxyPort;
530
+ for (let attempt = 1; attempt <= 2; attempt++) {
531
+ const useSticky = attempt === 1 && priorProxyPort != null;
532
+ const proxyPort = useSticky ? priorProxyPort : await findFreePort();
533
+ const adminPort = await findFreePort();
534
+ const configPath = this.writeConfig(proxyPort, adminPort);
535
+ // NOTE: no --adapter flag. The config file is already Caddy's native
536
+ // JSON format; passing `--adapter json` errors on modern Caddy
537
+ // ("unrecognized config adapter: json") — adapters are only for
538
+ // non-native formats (e.g. Caddyfile) that need converting INTO JSON.
539
+ // Verified directly against the real caddy binary (v2.11.3) during
540
+ // implementation of this file.
541
+ const child = spawn(bin, ['run', '--config', configPath], {
542
+ stdio: ['ignore', 'pipe', 'pipe'],
543
+ });
544
+ try {
545
+ await this.waitForHealthy(adminPort, child);
546
+ this.child = child;
547
+ this.proxyPort = proxyPort;
548
+ this.adminPort = adminPort;
549
+ this.configPath = configPath;
550
+ this.started = true;
551
+ this.lastProxyPort = proxyPort;
552
+ this.lastAppliedTarget = null;
553
+ this.installExitHandler(child);
554
+ if (priorProxyPort != null && proxyPort !== priorProxyPort) {
555
+ logger.error(`Caddy proxy port changed on respawn: ${priorProxyPort} -> ${proxyPort}`);
556
+ for (const cb of this.portChangedListeners) {
557
+ try {
558
+ cb(`http://127.0.0.1:${proxyPort}`);
559
+ }
560
+ catch (cbErr) {
561
+ logger.warn(`onPortChanged listener threw: ${cbErr}`);
562
+ }
563
+ }
564
+ }
565
+ return;
566
+ }
567
+ catch (err) {
568
+ try {
569
+ child.kill();
570
+ }
571
+ catch {
572
+ // already dead
573
+ }
574
+ try {
575
+ fs.unlinkSync(configPath);
576
+ }
577
+ catch {
578
+ // best effort
579
+ }
580
+ if (err instanceof CaddyBinaryNotFoundError) {
581
+ // Retrying with a different port can never fix a missing binary —
582
+ // fail fast, don't burn the second attempt.
583
+ throw err;
584
+ }
585
+ if (useSticky)
586
+ stickyAttemptFailed = true;
587
+ if (attempt === 2) {
588
+ throw stickyAttemptFailed
589
+ ? new CaddyPortReclaimError(`Both proxy-port reclaim and fresh-port fallback failed: ${err}`)
590
+ : new CaddyStartupError(String(err));
591
+ }
592
+ }
593
+ }
594
+ }
595
+ /** Restart policy: lazy, bounded, next-call-triggered — no background
596
+ * watchdog (matches ngrokAgentSession.ts's onTerminated philosophy). This
597
+ * handler only marks state as dead; it never itself triggers a respawn. */
598
+ installExitHandler(child) {
599
+ child.once('exit', (code, signal) => {
600
+ if (this.child === child) {
601
+ logger.warn(`Caddy process exited unexpectedly (code=${code}, signal=${signal})`);
602
+ this.started = false;
603
+ this.child = null;
604
+ }
605
+ });
606
+ }
607
+ }
608
+ /** Default factory — one fresh instance per session key (never a process-wide singleton). */
609
+ export function createCaddyProxy(opts) {
610
+ return new CaddyProxyManager(opts);
611
+ }