@yawlabs/caddy-mcp 2.5.10 → 2.6.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/README.md CHANGED
@@ -104,6 +104,21 @@ that socket: over a unix path caddy-mcp is usually talking to Caddy's own
104
104
  socket, where the token does nothing — and, before Caddy 2.11.3, is logged in
105
105
  clear.
106
106
 
107
+ **Runtime (oam or Node):**
108
+
109
+ The `caddy-mcp` command prefers [oam](https://oamjs.org) when it finds a usable
110
+ one and otherwise runs on Node. It never serves on an oam older than 0.18.0.
111
+
112
+ | Environment variable | Default | Description |
113
+ |---|---|---|
114
+ | `CADDY_MCP_RUNTIME` | `auto` | `auto`: the newest oam at 0.18.0 or newer, else Node. `oam`: oam or exit with an error. `node`: always Node -- the escape hatch when a problem only shows up under oam. |
115
+ | `CADDY_MCP_SANDBOX` | (unset) | `1` runs the server under oam's `--permission` sandbox: network limited to the `CADDY_ADMIN_URL` host and port, filesystem limited to `CADDY_MCP_SNAPSHOT_DIR`, no child processes. Needs oam; under `auto` a machine without one runs unsandboxed, so pair it with `CADDY_MCP_RUNTIME=oam` when the sandbox has to hold. A unix-socket `CADDY_ADMIN_URL` is not reachable under the sandbox yet. |
116
+ | `OAM_BIN` | (unset) | Path to the oam binary to use. Otherwise the launcher checks `OAM_INSTALL_DIR`, the installed locations (`~/.oam/bin`, and `%LOCALAPPDATA%\oam\bin` on Windows) and `PATH`, and takes the newest. |
117
+
118
+ An outdated oam is fixed with `oam self-update`. Under Yaw MCP, which runs servers
119
+ on oam by default, `yaw-mcp set <namespace> runtime=node` switches this server to
120
+ Node.
121
+
107
122
  **Alternate MCP clients:**
108
123
 
109
124
  | Client | Config file |
package/bin/caddy-mcp.mjs CHANGED
@@ -88,18 +88,24 @@
88
88
  *
89
89
  * The admin API endpoint is DERIVED from CADDY_ADMIN_URL (default
90
90
  * http://localhost:2019 -- byte-identical to DEFAULT_URL in src/api.ts, see
91
- * sandboxFlags). For a TCP endpoint the grant is the HOST, deliberately WITHOUT
92
- * a port: oam checks `fetch` against the bare hostname and sockets against
93
- * "host:port", and grants are prefix-matched, so pinning the port denies every
94
- * fetch -- and fetch is the transport api.ts uses for everything but a unix
95
- * socket. Granting the host therefore also admits its other ports; that is the
96
- * cost of the check having no port to match against.
91
+ * sandboxFlags). For a TCP endpoint the grant is "host:port" -- the port taken
92
+ * from the URL, or the scheme's default (80/443) when it names none. Since oam
93
+ * 0.18.0, the floor, a port-scoped `--allow-net` entry admits `fetch` to that
94
+ * port exactly as it admits a socket, and the resource fetch presents is
95
+ * "host:port" (an IPv6 literal bracketed, "[::1]:2019"). Up to 0.17.1 a
96
+ * port-scoped entry admitted no HTTP request at all, which is why this grant
97
+ * used to be the bare host -- and the bare host admits every port on it.
97
98
  *
98
99
  * A unix-socket CADDY_ADMIN_URL gets NO net grant, which DENIES the category
99
100
  * outright -- it is not an oversight that it looks narrower than the TCP case.
100
- * oam ships no unix socket transport, so the socket dial cannot work under the
101
- * sandbox regardless; the alternative was a bare `--allow-net`, which grants
102
- * every host on the network. See sandboxFlags for the mechanism.
101
+ * oam 0.18.0 added net over pipes (Unix domain sockets, Windows named pipes)
102
+ * with `http.request({ socketPath })` riding on them, and under --permission a
103
+ * socket would need `--allow-net=<absolute path>` plus fs read and write grants
104
+ * naming it. That has not been measured for this server: oam's own changelog
105
+ * calls the Unix half untested off Windows, and this server has not been
106
+ * run that way on Linux/macOS. Until it is, the socket stays
107
+ * denied; the alternative was a bare `--allow-net`, which grants every host on
108
+ * the network. See sandboxFlags for the mechanism.
103
109
  *
104
110
  * Child-process stays denied: this server drives Caddy entirely over its admin
105
111
  * HTTP API and never shells out to the `caddy` binary (the only execFileSync
@@ -184,7 +190,9 @@ function pathKey(p) {
184
190
  * Both installed forms are checked on Windows: the installer defaults to
185
191
  * %LOCALAPPDATA%\oam\bin there, but oam's docs name ~/.oam/bin first and
186
192
  * OAM_INSTALL_DIR can pick either, so checking one silently misses a real
187
- * install.
193
+ * install. OAM_INSTALL_DIR itself, when set, is checked FIRST: it is the
194
+ * installer's target directory (oam docs/cli-reference.md), so an oam installed
195
+ * there and never put on PATH would otherwise be invisible.
188
196
  *
189
197
  * PATH is resolved manually rather than by spawning `which`/`where`, which
190
198
  * would cost a subprocess on every launch just to decide whether to spawn.
@@ -209,6 +217,7 @@ function discoverOamPaths() {
209
217
  if (isWin) {
210
218
  installed.unshift(join(process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local"), "oam", "bin", exe));
211
219
  }
220
+ if (process.env.OAM_INSTALL_DIR) installed.unshift(join(process.env.OAM_INSTALL_DIR, exe));
212
221
  const onPath = (process.env.PATH ?? "")
213
222
  .split(delimiter)
214
223
  .filter(Boolean)
@@ -317,7 +326,8 @@ function runtimePlan({ mode, hostOam, sandbox }) {
317
326
  * not after it. `oam run --permission file.js` is rejected outright, which is a
318
327
  * good failure but only because it is loud -- ordering here is load-bearing.
319
328
  *
320
- * Net grants prefix-match `host` for fetch and `host:port` for sockets.
329
+ * Net grants (oam 0.18.0): an entry without a port admits every port on that
330
+ * host; "host:port" is exact, for sockets and HTTP requests alike.
321
331
  * A denied environment variable is ABSENT from process.env rather than throwing,
322
332
  * so the env list below is derived from what the bundle actually reads; trimming
323
333
  * it produces silent misbehaviour, not a clear denial.
@@ -355,10 +365,14 @@ function sandboxFlags() {
355
365
  // guards against, reached by a different route.
356
366
  //
357
367
  // Omitting the flag DENIES the category (oam reads an absent --allow-net as
358
- // false, a bare one as "*"), and denial costs nothing here: oam has no unix
359
- // socket transport at all, so api.ts's node:http `socketPath` dial cannot work
360
- // under oam whether the grant is open or closed. Re-verified against oam 0.18.0 --
361
- // bare grant lets an unrelated host through, omitted grant denies it.
368
+ // false, a bare one as "*"). Re-verified against oam 0.18.0 -- bare grant lets
369
+ // an unrelated host through, omitted grant denies it. What denial costs is
370
+ // NOT yet measured: oam 0.18.0 added Unix domain sockets, and api.ts's
371
+ // node:http `socketPath` dial rides on them, so a granted socket may now work
372
+ // there (it would need `--allow-net=<absolute path>` plus --allow-fs-read and
373
+ // --allow-fs-write naming the socket file). Upstream calls the Unix half
374
+ // untested off Windows, so the socket stays denied until it is run on
375
+ // Linux/macOS; an operator who needs it can run unsandboxed meanwhile.
362
376
  //
363
377
  // This mirrors getMalformedUnixUrl's predicate in src/api.ts, NOT the stricter
364
378
  // getUnixSocketPath -- deliberately, and the difference is the whole point.
@@ -383,14 +397,13 @@ function sandboxFlags() {
383
397
  if (!isUnixDsn) {
384
398
  try {
385
399
  const u = new URL(dsn);
386
- // HOST ONLY, no port, deliberately. Grants are prefix-matched against the
387
- // resource string, and the resource `fetch` presents is the bare hostname
388
- // ("localhost") while sockets present "host:port". "localhost" does not
389
- // start with "localhost:2019", so pinning the port denies every fetch --
390
- // and fetch is how api.ts talks to a TCP admin endpoint. Granting the host
391
- // alone also admits the other ports on that host; that is the cost of the
392
- // check having no port to match against, not an oversight here.
393
- if (u.hostname) netFlag = `--allow-net=${u.hostname}`;
400
+ // HOST:PORT. Since oam 0.18.0 the resource `fetch` presents is "host:port"
401
+ // and a port-scoped entry admits it, so the grant names exactly the
402
+ // endpoint api.ts dials. `u.port` is "" when the URL names the scheme's
403
+ // default port, so fill that in: 443 for https, 80 otherwise (api.ts only
404
+ // dials http and https). `u.hostname` keeps an IPv6 literal's brackets,
405
+ // which is the form oam matches ("[::1]:2019"; "::1:2019" is refused).
406
+ if (u.hostname) netFlag = `--allow-net=${u.hostname}:${u.port || (u.protocol === "https:" ? "443" : "80")}`;
394
407
  } catch {
395
408
  // Genuinely unparseable CADDY_ADMIN_URL (not the unix forms -- those are
396
409
  // handled above): leave the grant open. The server will fail on its own
@@ -422,20 +435,23 @@ function sandboxFlags() {
422
435
  // quietly stop surviving a restart -- the thing the operator turned the
423
436
  // variable on to get.
424
437
  //
425
- // TWO spellings, because grants are matched as plain string PREFIXES against
426
- // whatever path each call passes: snapshots.ts hands the raw variable to
427
- // readdirSync/mkdirSync but builds per-file paths with path.join, which
428
- // normalizes ("./snaps" -> "snaps"). The raw form alone then misses the files;
429
- // the normalized form alone misses the directory listing.
438
+ // ONE spelling is enough. Since oam 0.18.0 a relative --allow-fs-* entry is
439
+ // resolved against the cwd at startup, and each path a call passes is resolved
440
+ // against the cwd of the moment before it is matched, so "./snaps", "snaps"
441
+ // and "snaps/c.json" all land inside the same grant (snapshots.ts hands the
442
+ // raw variable to readdirSync/mkdirSync and builds per-file paths with
443
+ // path.join). Matching is path CONTAINMENT, not a string prefix: a grant for
444
+ // "/var/snap" does not admit "/var/snapshots-elsewhere" (measured on 0.18.0).
445
+ // On Windows the comparison follows node's path resolution with the `\\?\`
446
+ // prefix and is case-SENSITIVE, so spell the directory the way the server
447
+ // will.
430
448
  //
431
- // Two consequences worth naming rather than discovering: a prefix also admits
432
- // a sibling path that merely starts with the same string ("/var/snap" grants
433
- // "/var/snapshots-elsewhere"), and oam splits the list on commas with no
434
- // escape, so a directory whose path contains a comma cannot be granted here.
449
+ // One consequence worth naming rather than discovering: oam splits the list
450
+ // on commas with no escape, so a directory whose path contains a comma cannot
451
+ // be granted here.
435
452
  const snapshotDir = process.env.CADDY_MCP_SNAPSHOT_DIR?.trim();
436
453
  if (snapshotDir) {
437
- const forms = [...new Set([snapshotDir, join(snapshotDir, ".")])].join(",");
438
- flags.push(`--allow-fs-read=${forms}`, `--allow-fs-write=${forms}`);
454
+ flags.push(`--allow-fs-read=${snapshotDir}`, `--allow-fs-write=${snapshotDir}`);
439
455
  }
440
456
 
441
457
  return flags;
@@ -510,20 +526,29 @@ function unusableReason(path, version, label = path) {
510
526
 
511
527
  /**
512
528
  * Choose the oam to spawn: a usable OAM_BIN, else the newest usable discovered
513
- * binary. Returns the choice (or null) plus stderr notes: `overrideNote` about
514
- * an unusable OAM_BIN, and `skipped` describing what was found and rejected
515
- * when nothing was usable.
529
+ * binary. Returns the choice (or null) plus what stderr needs:
530
+ * overrideNote why OAM_BIN was passed over, or null
531
+ * skipped why each discovered binary was passed over, when none was chosen
532
+ * passedOver the `version` of every existing binary rejected (OAM_BIN
533
+ * included), so a hard failure can name the right remedy
534
+ * overrideMissing OAM_BIN was set to a path that does not exist
516
535
  */
517
536
  function chooseOam() {
518
537
  const override = process.env.OAM_BIN;
519
538
  let overrideNote = null;
539
+ let overrideMissing = false;
540
+ const passedOver = [];
520
541
  if (override) {
521
542
  if (!existsSync(override)) {
522
543
  overrideNote = `OAM_BIN=${override} does not exist`;
544
+ overrideMissing = true;
523
545
  } else {
524
546
  const version = oamVersion(override);
525
- if (atLeast(version, OAM_MIN)) return { chosen: { path: override, version }, overrideNote, skipped: [] };
547
+ if (atLeast(version, OAM_MIN)) {
548
+ return { chosen: { path: override, version }, overrideNote, skipped: [], passedOver, overrideMissing };
549
+ }
526
550
  overrideNote = unusableReason(override, version, `OAM_BIN=${override}`);
551
+ passedOver.push(version);
527
552
  }
528
553
  }
529
554
  const overrideKey = override ? pathKey(override) : null;
@@ -532,7 +557,66 @@ function chooseOam() {
532
557
  .map((path) => ({ path, version: oamVersion(path) }));
533
558
  const chosen = pickNewest(candidates);
534
559
  const skipped = chosen ? [] : candidates.map((c) => unusableReason(c.path, c.version));
535
- return { chosen, overrideNote, skipped };
560
+ if (!chosen) passedOver.push(...candidates.map((c) => c.version));
561
+ return { chosen, overrideNote, skipped, passedOver, overrideMissing };
562
+ }
563
+
564
+ /**
565
+ * What would fix "no usable oam", one line per cause that was actually seen.
566
+ *
567
+ * An outdated oam is fixed by `oam self-update`; an unrunnable one is not (it
568
+ * never ran, so updating it in place cannot help); and only when NO oam was
569
+ * found is installing one the answer. Even that line is replaced where there is
570
+ * nothing to install: oam publishes darwin arm64/x64, windows arm64/x64 and
571
+ * linux x64, so on any other Linux arch (a Pi, an arm64 cloud instance, WSL on
572
+ * an ARM Windows host) "install oam" is an impossible remedy.
573
+ */
574
+ function remedyFor({ passedOver, overrideMissing, shim }) {
575
+ const lines = [];
576
+ if (passedOver.some((v) => v !== null)) {
577
+ lines.push(`Run \`oam self-update\` to get oam ${OAM_MIN.join(".")} or newer.\n`);
578
+ }
579
+ if (passedOver.some((v) => v === null)) {
580
+ lines.push("Check that it is an executable oam binary for this platform.\n");
581
+ }
582
+ if (overrideMissing) lines.push("Point OAM_BIN at an existing oam binary, or unset it.\n");
583
+ if (lines.length === 0 && !shim) {
584
+ lines.push(
585
+ process.platform === "linux" && process.arch !== "x64"
586
+ ? `oam publishes no build for linux-${process.arch}, so there is nothing to install here; set OAM_BIN=/path/to/oam if you built one yourself.\n`
587
+ : "Install oam from https://oamjs.org, or set OAM_BIN=/path/to/oam.\n",
588
+ );
589
+ }
590
+ lines.push("Or use CADDY_MCP_RUNTIME=node to run on Node.\n");
591
+ return lines.join("");
592
+ }
593
+
594
+ /**
595
+ * The env a child is spawned with: process.env as-is on Node, and on an oam host
596
+ * a copy whose NODE_OPTIONS has every `--permission` / `--allow-*` token removed.
597
+ *
598
+ * An oam host can carry those in NODE_OPTIONS -- since 0.18.0 oam reads them
599
+ * from there, and appends its own execArgv's permission flags to every child's
600
+ * NODE_OPTIONS -- and handing them on breaks both children this launcher
601
+ * spawns. Node refuses an oam-only flag outright: `NODE_OPTIONS=--allow-net node`
602
+ * exits 9 with "--allow-net is not allowed in NODE_OPTIONS" (Node 22), so the
603
+ * Node handoff would die before the server loads. A fresh oam would instead
604
+ * inherit the HOST's grants rather than the ones sandboxFlags derived for it.
605
+ * The child's sandbox, if any, is decided by its own argv and nothing else.
606
+ *
607
+ * Tokens are split on whitespace, as Node splits NODE_OPTIONS; a value-taking
608
+ * permission flag is always written `--allow-x=value`, one token. An emptied
609
+ * NODE_OPTIONS is removed rather than left as "".
610
+ */
611
+ function childEnv() {
612
+ if (process.versions.oam === undefined) return process.env;
613
+ const raw = process.env.NODE_OPTIONS;
614
+ if (!raw) return process.env;
615
+ const kept = raw.split(/\s+/).filter((token) => token && token !== "--permission" && !token.startsWith("--allow-"));
616
+ const env = { ...process.env };
617
+ if (kept.length > 0) env.NODE_OPTIONS = kept.join(" ");
618
+ else delete env.NODE_OPTIONS;
619
+ return env;
536
620
  }
537
621
 
538
622
  /** Run the server in THIS process. The zero-overhead fallback. */
@@ -581,7 +665,7 @@ async function launchChild(cmd, args, onLaunchFailed) {
581
665
  // server's shutdown path. Piping preserves both as well: bytes are copied
582
666
  // unchanged, and stdin's end propagates to the child.
583
667
  stdio: piped ? ["pipe", "pipe", "pipe"] : "inherit",
584
- env: process.env,
668
+ env: childEnv(),
585
669
  windowsHide: true,
586
670
  });
587
671
  } catch (err) {
@@ -752,7 +836,7 @@ if (plan === "in-process") {
752
836
  const belowFloor = !atLeast(parseVersion(hostOam), OAM_MIN);
753
837
  await handOffToNode(belowFloor ? `this process is oam ${hostOam}, older than ${OAM_MIN.join(".")}` : "");
754
838
  } else {
755
- const { chosen, overrideNote, skipped } = chooseOam();
839
+ const { chosen, overrideNote, skipped, passedOver, overrideMissing } = chooseOam();
756
840
 
757
841
  if (chosen) {
758
842
  if (overrideNote)
@@ -789,7 +873,7 @@ if (plan === "in-process") {
789
873
  await errSync(
790
874
  `caddy-mcp: CADDY_MCP_RUNTIME=oam but no usable oam (${OAM_MIN.join(".")} or newer) was found.\n` +
791
875
  notes.map((note) => ` ${note}\n`).join("") +
792
- "Install or update from https://oamjs.org, set OAM_BIN=/path/to/oam, or use CADDY_MCP_RUNTIME=node.\n",
876
+ remedyFor({ passedOver, overrideMissing, shim }),
793
877
  );
794
878
  process.exit(1);
795
879
  }
package/dist/index.js CHANGED
@@ -304,7 +304,12 @@ function originOf(origin) {
304
304
  }
305
305
  }
306
306
  function settleAdminRestart(origin) {
307
- if (!origin || !dispatcherHooked || (liveSockets.get(origin) ?? 0) === 0) return Promise.resolve();
307
+ if (!origin) return Promise.resolve();
308
+ if (!dispatcherHooked) {
309
+ if (process.versions.oam === void 0) return Promise.resolve();
310
+ return new Promise((resolve) => setTimeout(resolve, ADMIN_RESTART_SETTLE_MS));
311
+ }
312
+ if ((liveSockets.get(origin) ?? 0) === 0) return Promise.resolve();
308
313
  return new Promise((resolve) => {
309
314
  const timer = setTimeout(done, ADMIN_RESTART_SETTLE_MS);
310
315
  function check() {
package/dist/server.js CHANGED
@@ -302,7 +302,12 @@ function originOf(origin) {
302
302
  }
303
303
  }
304
304
  function settleAdminRestart(origin) {
305
- if (!origin || !dispatcherHooked || (liveSockets.get(origin) ?? 0) === 0) return Promise.resolve();
305
+ if (!origin) return Promise.resolve();
306
+ if (!dispatcherHooked) {
307
+ if (process.versions.oam === void 0) return Promise.resolve();
308
+ return new Promise((resolve) => setTimeout(resolve, ADMIN_RESTART_SETTLE_MS));
309
+ }
310
+ if ((liveSockets.get(origin) ?? 0) === 0) return Promise.resolve();
306
311
  return new Promise((resolve) => {
307
312
  const timer = setTimeout(done, ADMIN_RESTART_SETTLE_MS);
308
313
  function check() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/caddy-mcp",
3
- "version": "2.5.10",
3
+ "version": "2.6.0",
4
4
  "mcpName": "io.github.YawLabs/caddy-mcp",
5
5
  "description": "Caddy MCP server for Claude Code, Cursor, and any MCP client: admin API, config, routes, reverse proxy, TLS, PKI, metrics",
6
6
  "license": "MIT",
@@ -32,6 +32,7 @@
32
32
  "lint:fix": "node scripts/lint.mjs check --write src/ bin/ scripts/",
33
33
  "typecheck": "node scripts/typecheck.mjs",
34
34
  "typecheck:tsc": "tsc --noEmit",
35
+ "check:oam-floor": "node scripts/check-oam-floor.mjs",
35
36
  "test:ci": "npm run build && npm test",
36
37
  "prepublishOnly": "npm run build",
37
38
  "prepare": "git config core.hooksPath .githooks 2>/dev/null || true",