@corenel/sidecar 0.1.0 → 0.1.1

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/dist/cli.js +319 -11
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import { createRequire as __cr } from 'node:module'; const require = __cr(import.meta.url);
2
3
 
3
4
  // src/cli.ts
4
5
  import { resolve as resolve2 } from "node:path";
@@ -76,16 +77,19 @@ var SidecarServer = class {
76
77
  }
77
78
  onHello(m) {
78
79
  if (m.protocol !== SIDECAR_PROTOCOL) {
80
+ this.opts.log?.(`denied: unsupported protocol ${m.protocol}`);
79
81
  this.ch.send({ t: "denied", reason: `protocol ${m.protocol} unsupported` });
80
82
  return;
81
83
  }
82
84
  if (!safeEqual(m.token ?? "", this.opts.token)) {
85
+ this.opts.log?.("denied: invalid token");
83
86
  this.ch.send({ t: "denied", reason: "invalid token" });
84
87
  return;
85
88
  }
86
89
  this.authed = true;
90
+ this.opts.log?.(`paired \u2014 ${this.tools.size} ${this.tools.size === 1 ? "tool" : "tools"} advertised`);
87
91
  const tools = [...this.tools.values()].map(({ run: _run, ...decl }) => decl);
88
- this.ch.send({ t: "welcome", tools, features: ["http-proxy"] });
92
+ this.ch.send({ t: "welcome", tools, features: ["http-proxy"], ...this.opts.info ? { info: this.opts.info } : {} });
89
93
  }
90
94
  /** Proxy a host HTTP request server-side. Restricted to LOOPBACK targets — the
91
95
  * sidecar must never be usable as an SSRF pivot to internal/metadata hosts. The
@@ -122,13 +126,16 @@ var SidecarServer = class {
122
126
  async onInvoke(m) {
123
127
  const tool = this.tools.get(m.name);
124
128
  if (!tool) {
129
+ this.opts.log?.(`invoke ${m.name}: unknown tool`);
125
130
  this.ch.send({ t: "invoke-result", id: m.id, ok: false, error: `unknown tool ${m.name}` });
126
131
  return;
127
132
  }
133
+ this.opts.log?.(`invoke ${m.name}`);
128
134
  try {
129
135
  const result = await tool.run(m.args, this.makeContext());
130
136
  this.ch.send({ t: "invoke-result", id: m.id, ok: true, result });
131
137
  } catch (e) {
138
+ this.opts.log?.(`invoke ${m.name} failed: ${errMessage(e)}`);
132
139
  this.ch.send({ t: "invoke-result", id: m.id, ok: false, error: errMessage(e) });
133
140
  }
134
141
  }
@@ -228,16 +235,42 @@ function wsChannel(ws) {
228
235
  };
229
236
  }
230
237
 
238
+ // src/lanOrigin.ts
239
+ function isPrivateLanOrigin(origin) {
240
+ let host;
241
+ try {
242
+ host = new URL(origin).hostname;
243
+ } catch {
244
+ return false;
245
+ }
246
+ const h = host.replace(/^\[/, "").replace(/\]$/, "").toLowerCase();
247
+ if (h === "localhost" || h.endsWith(".localhost")) return true;
248
+ if (h.includes(":")) return h === "::1" || /^f[cd]/.test(h) || /^fe[89ab]/.test(h);
249
+ const m = h.match(/^(\d{1,3})\.(\d{1,3})\.\d{1,3}\.\d{1,3}$/);
250
+ if (!m) return false;
251
+ const a = Number(m[1]);
252
+ const b = Number(m[2]);
253
+ return a === 127 || a === 10 || a === 192 && b === 168 || a === 172 && b >= 16 && b <= 31 || a === 169 && b === 254;
254
+ }
255
+
231
256
  // src/server.ts
232
257
  function startSidecar(opts) {
233
258
  const host = opts.host ?? "127.0.0.1";
234
259
  const allowed = new Set(opts.allowedOrigins ?? []);
235
- const verifyClient = ({ origin }) => {
236
- if (!origin) return true;
237
- return allowed.has(origin);
260
+ const verifyClient = (info) => {
261
+ const origin = info.origin;
262
+ const ip = info.req.socket?.remoteAddress || "?";
263
+ const lan = !!origin && !!opts.allowLan && isPrivateLanOrigin(origin);
264
+ const ok = !origin || allowed.has(origin) || lan;
265
+ opts.log?.(ok ? `upgrade allowed \u2014 origin ${origin || "(native client)"}${lan ? " (LAN)" : ""} (${ip})` : `upgrade REFUSED \u2014 origin ${origin} not in allowlist [${[...allowed].join(", ") || "(none)"}]${opts.allowLan ? "" : " (pass --allow-lan to accept any LAN origin)"} (${ip})`);
266
+ return ok;
238
267
  };
239
- const onConnection = (ws, _req) => {
240
- new SidecarServer({ channel: wsChannel(ws), token: opts.token, tools: opts.tools });
268
+ const onConnection = (ws, req) => {
269
+ const origin = req.headers.origin || "(native client)";
270
+ const ip = req.socket.remoteAddress || "?";
271
+ opts.log?.(`client connected \u2014 ${origin} (${ip})`);
272
+ ws.on("close", () => opts.log?.(`client disconnected \u2014 ${origin}`));
273
+ new SidecarServer({ channel: wsChannel(ws), token: opts.token, tools: opts.tools, info: opts.info, log: opts.log });
241
274
  };
242
275
  if (opts.tls) {
243
276
  const httpsServer = createHttpsServer({ cert: opts.tls.cert, key: opts.tls.key });
@@ -502,9 +535,252 @@ async function loadOrCreateSelfSigned(extraHosts = []) {
502
535
  return { cert: pems.cert, key: pems.private, sans };
503
536
  }
504
537
 
538
+ // src/tunnel/registry.ts
539
+ var providers = /* @__PURE__ */ new Map();
540
+ function registerTunnel(p) {
541
+ providers.set(p.id, p);
542
+ }
543
+ function getTunnel(id) {
544
+ const direct = providers.get(id);
545
+ if (direct) return direct;
546
+ for (const p of providers.values()) if (p.aliases?.includes(id)) return p;
547
+ return void 0;
548
+ }
549
+ function tunnelIds() {
550
+ return [...providers.keys()];
551
+ }
552
+
553
+ // src/tunnel/zrok.ts
554
+ import { spawn as spawn2 } from "node:child_process";
555
+ function extractShareUrl(s) {
556
+ const full = s.match(/https?:\/\/[A-Za-z0-9.-]+(?::\d+)?(?:\/[^\s"'\\]*)?/);
557
+ if (full) return full[0];
558
+ const ep = s.match(/endpoints:(?:\\[nrt]|\s)*([A-Za-z0-9][A-Za-z0-9.-]*\.[A-Za-z]{2,}(?:\/[^\s"'\\]*)?)/);
559
+ return ep ? `https://${ep[1]}` : null;
560
+ }
561
+ var START_TIMEOUT_MS = 25e3;
562
+ function shareArgs(port, name) {
563
+ const a = ["share", "public", "--headless", "--backend-mode", "proxy"];
564
+ if (name) a.push("-n", `public:${name}`);
565
+ a.push(`http://localhost:${port}`);
566
+ return a;
567
+ }
568
+ var tail = (s, n = 6) => s.trim().split("\n").slice(-n).join("\n");
569
+ function zrokBins() {
570
+ const env = process.env.ZROK_BIN?.trim();
571
+ return env ? [env] : ["zrok", "zrok2"];
572
+ }
573
+ function stopChild(child) {
574
+ return new Promise((res) => {
575
+ if (child.exitCode !== null || child.signalCode) {
576
+ res();
577
+ return;
578
+ }
579
+ const hard = setTimeout(() => {
580
+ try {
581
+ child.kill("SIGKILL");
582
+ } catch {
583
+ }
584
+ res();
585
+ }, 2e3);
586
+ child.once("exit", () => {
587
+ clearTimeout(hard);
588
+ res();
589
+ });
590
+ try {
591
+ child.kill("SIGTERM");
592
+ } catch {
593
+ clearTimeout(hard);
594
+ res();
595
+ }
596
+ });
597
+ }
598
+ function reserveName(bin, name) {
599
+ return new Promise((res, rej) => {
600
+ const child = spawn2(bin, ["create", "name", name], { stdio: ["ignore", "ignore", "ignore"] });
601
+ let settled = false;
602
+ const done = (fn) => {
603
+ if (settled) return;
604
+ settled = true;
605
+ clearTimeout(t);
606
+ fn();
607
+ };
608
+ child.on("error", (e) => done(() => e?.code === "ENOENT" ? rej(e) : res()));
609
+ child.on("exit", () => done(() => res()));
610
+ const t = setTimeout(() => done(() => {
611
+ try {
612
+ child.kill();
613
+ } catch {
614
+ }
615
+ res();
616
+ }), 15e3);
617
+ });
618
+ }
619
+ async function startWith(bin, port, name) {
620
+ if (name) await reserveName(bin, name);
621
+ return new Promise((resolveHandle, reject) => {
622
+ const child = spawn2(bin, shareArgs(port, name), { stdio: ["ignore", "pipe", "pipe"] });
623
+ let out = "";
624
+ let settled = false;
625
+ const finish = (fn) => {
626
+ if (settled) return;
627
+ settled = true;
628
+ clearTimeout(timer);
629
+ fn();
630
+ };
631
+ const onData = (b) => {
632
+ out += b.toString();
633
+ const url = extractShareUrl(out);
634
+ if (url) finish(() => resolveHandle({ url, stop: () => stopChild(child) }));
635
+ };
636
+ child.stdout?.on("data", onData);
637
+ child.stderr?.on("data", onData);
638
+ child.on("error", (e) => finish(() => reject(e)));
639
+ child.on("exit", (code) => finish(() => reject(new Error(
640
+ `${bin} exited (code ${code}) before a share URL appeared \u2014 is the environment enabled? Run \`${bin} enable <token>\` once.
641
+ ${tail(out)}`
642
+ ))));
643
+ const timer = setTimeout(() => finish(() => {
644
+ void stopChild(child);
645
+ reject(new Error(`${bin} produced no share URL within ${START_TIMEOUT_MS / 1e3}s.
646
+ ${tail(out)}`));
647
+ }), START_TIMEOUT_MS);
648
+ });
649
+ }
650
+ var zrokProvider = {
651
+ id: "zrok",
652
+ description: "zrok public share (needs the zrok CLI [`zrok`/`zrok2`] installed + `enable <token>` run once \u2014 https://zrok.io)",
653
+ start({ port, name }) {
654
+ const bins = zrokBins();
655
+ const tryAt = (i) => {
656
+ if (i >= bins.length) {
657
+ return Promise.reject(new Error(
658
+ `zrok not found (tried: ${bins.join(", ")}). Install the zrok CLI (https://docs.zrok.io) and run its \`enable <token>\` once, or set ZROK_BIN to the binary name/path.`
659
+ ));
660
+ }
661
+ return startWith(bins[i], port, name).catch((e) => {
662
+ if (e?.code === "ENOENT") return tryAt(i + 1);
663
+ throw e;
664
+ });
665
+ };
666
+ return tryAt(0);
667
+ }
668
+ };
669
+
670
+ // src/tunnel/cloudflared.ts
671
+ import { spawn as spawn3 } from "node:child_process";
672
+ function extractTunnelUrl(s) {
673
+ const m = s.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/i);
674
+ return m ? m[0] : null;
675
+ }
676
+ var START_TIMEOUT_MS2 = 25e3;
677
+ function tunnelArgs(port, host = "localhost") {
678
+ return ["tunnel", "--no-autoupdate", "--url", `http://${host}:${port}`];
679
+ }
680
+ var tail2 = (s, n = 6) => s.trim().split("\n").slice(-n).join("\n");
681
+ function cloudflaredBins() {
682
+ const env = process.env.CLOUDFLARED_BIN?.trim();
683
+ return env ? [env] : ["cloudflared"];
684
+ }
685
+ function stopChild2(child) {
686
+ return new Promise((res) => {
687
+ if (child.exitCode !== null || child.signalCode) {
688
+ res();
689
+ return;
690
+ }
691
+ const hard = setTimeout(() => {
692
+ try {
693
+ child.kill("SIGKILL");
694
+ } catch {
695
+ }
696
+ res();
697
+ }, 2e3);
698
+ child.once("exit", () => {
699
+ clearTimeout(hard);
700
+ res();
701
+ });
702
+ try {
703
+ child.kill("SIGTERM");
704
+ } catch {
705
+ clearTimeout(hard);
706
+ res();
707
+ }
708
+ });
709
+ }
710
+ function startWith2(bin, port, host, log) {
711
+ return new Promise((resolveHandle, reject) => {
712
+ const child = spawn3(bin, tunnelArgs(port, host), { stdio: ["ignore", "pipe", "pipe"] });
713
+ let out = "";
714
+ let settled = false;
715
+ let tailBuf = "";
716
+ const finish = (fn) => {
717
+ if (settled) return;
718
+ settled = true;
719
+ clearTimeout(timer);
720
+ fn();
721
+ };
722
+ const onData = (b) => {
723
+ const text = b.toString();
724
+ if (!settled) {
725
+ out += text;
726
+ const url = extractTunnelUrl(out);
727
+ if (url) finish(() => resolveHandle({ url, stop: () => stopChild2(child) }));
728
+ return;
729
+ }
730
+ tailBuf += text;
731
+ let nl;
732
+ while ((nl = tailBuf.indexOf("\n")) >= 0) {
733
+ const line = tailBuf.slice(0, nl).replace(/\r$/, "").trim();
734
+ tailBuf = tailBuf.slice(nl + 1);
735
+ if (line) log?.(`cloudflared: ${line}`);
736
+ }
737
+ };
738
+ child.stdout?.on("data", onData);
739
+ child.stderr?.on("data", onData);
740
+ child.on("error", (e) => finish(() => reject(e)));
741
+ child.on("exit", (code) => {
742
+ if (settled) {
743
+ log?.(`cloudflared exited (code ${code}) \u2014 the tunnel is down`);
744
+ return;
745
+ }
746
+ finish(() => reject(new Error(`${bin} exited (code ${code}) before a tunnel URL appeared.
747
+ ${tail2(out)}`)));
748
+ });
749
+ const timer = setTimeout(() => finish(() => {
750
+ void stopChild2(child);
751
+ reject(new Error(`${bin} produced no tunnel URL within ${START_TIMEOUT_MS2 / 1e3}s.
752
+ ${tail2(out)}`));
753
+ }), START_TIMEOUT_MS2);
754
+ });
755
+ }
756
+ var cloudflaredProvider = {
757
+ id: "cloudflared",
758
+ aliases: ["cloudflare"],
759
+ description: "Cloudflare quick tunnel (needs the `cloudflared` CLI installed \u2014 https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/ \u2014 no account, no interstitial; URL is ephemeral)",
760
+ start({ port, host, log }) {
761
+ const bins = cloudflaredBins();
762
+ const tryAt = (i) => {
763
+ if (i >= bins.length) {
764
+ return Promise.reject(new Error(
765
+ `cloudflared not found (tried: ${bins.join(", ")}). Install the cloudflared CLI (https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/), or set CLOUDFLARED_BIN to the binary name/path.`
766
+ ));
767
+ }
768
+ return startWith2(bins[i], port, host || "localhost", log).catch((e) => {
769
+ if (e?.code === "ENOENT") return tryAt(i + 1);
770
+ throw e;
771
+ });
772
+ };
773
+ return tryAt(0);
774
+ }
775
+ };
776
+
777
+ // src/tunnel/index.ts
778
+ registerTunnel(cloudflaredProvider);
779
+ registerTunnel(zrokProvider);
780
+
505
781
  // src/cli.ts
506
782
  function parseArgs(argv) {
507
- const out = { port: 4858, host: "127.0.0.1", root: process.cwd(), allowOrigins: [], allowShell: false, selfSigned: false };
783
+ const out = { port: 4858, host: "127.0.0.1", root: process.cwd(), allowOrigins: [], allowShell: false, allowLan: false, selfSigned: false };
508
784
  for (let i = 0; i < argv.length; i++) {
509
785
  const a = argv[i];
510
786
  if (a === "--port") out.port = Number(argv[++i]);
@@ -512,9 +788,12 @@ function parseArgs(argv) {
512
788
  else if (a === "--root") out.root = resolve2(argv[++i]);
513
789
  else if (a === "--allow-origin") out.allowOrigins.push(argv[++i]);
514
790
  else if (a === "--allow-shell") out.allowShell = true;
791
+ else if (a === "--allow-lan") out.allowLan = true;
515
792
  else if (a === "--tls-cert") out.tlsCert = resolve2(argv[++i]);
516
793
  else if (a === "--tls-key") out.tlsKey = resolve2(argv[++i]);
517
794
  else if (a === "--self-signed") out.selfSigned = true;
795
+ else if (a === "--tunnel") out.tunnel = argv[++i];
796
+ else if (a === "--tunnel-name") out.tunnelName = argv[++i];
518
797
  }
519
798
  return out;
520
799
  }
@@ -534,15 +813,41 @@ async function main() {
534
813
  tls = { cert: ss.cert, key: ss.key };
535
814
  selfSigned = true;
536
815
  }
816
+ const tunnelProvider = args.tunnel ? getTunnel(args.tunnel) : void 0;
817
+ if (args.tunnel && !tunnelProvider) {
818
+ throw new Error(`unknown --tunnel "${args.tunnel}". Available: ${tunnelIds().join(", ") || "(none)"}`);
819
+ }
820
+ if (tunnelProvider) {
821
+ args.host = "127.0.0.1";
822
+ tls = void 0;
823
+ selfSigned = false;
824
+ }
537
825
  const allowedOrigins = [.../* @__PURE__ */ new Set([...DEFAULT_ALLOWED_ORIGINS, ...args.allowOrigins])];
826
+ const slog = (line) => {
827
+ process.stdout.write(` [${(/* @__PURE__ */ new Date()).toTimeString().slice(0, 8)}] ${line}
828
+ `);
829
+ };
538
830
  const handle = await startSidecar({
539
831
  port: args.port,
540
832
  host: args.host,
541
833
  token,
542
834
  tools: [...nodeFsTools(args.root), ...args.allowShell ? [nodeShellTool(args.root)] : []],
543
835
  allowedOrigins,
544
- tls
836
+ tls,
837
+ // Advertised in the handshake so the host can show the working directory when
838
+ // browsing this sidecar as a file source.
839
+ info: { root: args.root, platform: process.platform },
840
+ log: slog,
841
+ allowLan: args.allowLan
545
842
  });
843
+ let tunnel = null;
844
+ let tunnelWss = null;
845
+ if (tunnelProvider) {
846
+ process.stdout.write(`starting ${tunnelProvider.id} tunnel${args.tunnelName ? ` (reserved name "${args.tunnelName}")` : ""}\u2026
847
+ `);
848
+ tunnel = await tunnelProvider.start({ port: handle.port, host: handle.host, name: args.tunnelName, log: slog });
849
+ tunnelWss = tunnel.url.replace(/^http/, "ws");
850
+ }
546
851
  const scheme = handle.secure ? "wss" : "ws";
547
852
  const exposed = handle.host !== "127.0.0.1" && handle.host !== "localhost";
548
853
  const connectHosts = handle.host === "0.0.0.0" ? lanIPv4s().length ? lanIPv4s() : ["127.0.0.1"] : [handle.host];
@@ -553,8 +858,8 @@ async function main() {
553
858
  ` root: ${args.root}`,
554
859
  ` tools: fs_read/write/list${args.allowShell ? " + shell_exec" : " (shell off; pass --allow-shell to enable)"}`,
555
860
  ` token: ${token} (also at ${tokenPath()})`,
556
- ` connect at: ${connectUrls.join(" ")}`,
557
- ` origins: ${allowedOrigins.join(", ")}${args.allowOrigins.length ? "" : " (canonical defaults; pass --allow-origin to add dev/self-hosted)"}`,
861
+ tunnelWss ? ` connect at: ${tunnelWss} (public ${args.tunnel} tunnel${args.tunnelName ? ", stable" : ""} \u2014 pair from any device with this URL + the token)` : ` connect at: ${connectUrls.join(" ")}`,
862
+ ` origins: ${allowedOrigins.join(", ")}${args.allowLan ? " + any private-LAN origin (--allow-lan)" : args.allowOrigins.length ? "" : " (canonical defaults; pass --allow-origin to add dev/self-hosted)"}`,
558
863
  selfSigned ? ` cert: self-signed (in ~/.corenel) \u2014 trust it once: open ${connectUrls.map((u) => u.replace(/^wss/, "https")).join(" or ")} in this browser and accept the warning, then pair.` : null,
559
864
  exposed ? ` WARNING: bound to ${handle.host} (not loopback) \u2014 reachable off-box. The pairing token + Origin allowlist are the only protection; keep the token secret.` : null,
560
865
  !handle.secure && exposed ? ` NOTE: a browser on an https page cannot connect to ws:// \u2014 serve wss:// with --self-signed (or --tls-cert/--tls-key) for LAN pairing.` : null,
@@ -562,7 +867,10 @@ async function main() {
562
867
  ].filter((l) => l !== null).join("\n")
563
868
  );
564
869
  const shutdown = () => {
565
- void handle.close().then(() => process.exit(0));
870
+ const closeServer = () => handle.close().then(() => process.exit(0));
871
+ if (tunnel) void tunnel.stop().catch(() => {
872
+ }).then(closeServer);
873
+ else void closeServer();
566
874
  };
567
875
  process.on("SIGINT", shutdown);
568
876
  process.on("SIGTERM", shutdown);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@corenel/sidecar",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "type": "module",
5
5
  "license": "Elastic-2.0",
6
6
  "description": "Prompd sidecar — a local capability server. Run it next to Prompd (prompd.app) so the browser app can reach your machine: local LLMs (Ollama/LM Studio/vLLM) without CORS, your filesystem, and (opt-in) shell.",