@corenel/sidecar 0.1.3 → 0.1.5

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 (3) hide show
  1. package/README.md +2 -2
  2. package/dist/cli.js +154 -24
  3. package/package.json +48 -41
package/README.md CHANGED
@@ -25,7 +25,7 @@ match the site you're connecting from (the app fills it in for you).
25
25
  | `--allow-origin <url>` | _(none)_ | Browser origin allowed to connect. Required for a web page. Repeatable. |
26
26
  | `--port <n>` | `4858` | Port to listen on. |
27
27
  | `--root <dir>` | cwd | Root the file tools are confined to. |
28
- | `--allow-shell` | off | Enable the `shell_exec` tool (RCE on your machine — opt-in). |
28
+ | `--allow-shell` | off | Enable the `sidecar_shell_exec` tool (RCE on your machine — opt-in). |
29
29
  | `--host <addr>` | `127.0.0.1` | Bind address. Non-loopback is reachable off-box; keep the token secret. |
30
30
  | `--tls-cert <f>` `--tls-key <f>` | _(none)_ | Serve `wss://` (needed for a remote sidecar from an https page). |
31
31
 
@@ -35,6 +35,6 @@ match the site you're connecting from (the app fills it in for you).
35
35
  allowed Origin.
36
36
  - The HTTP proxy (for local LLMs) only reaches **loopback** hosts — it can't be used as
37
37
  an SSRF pivot to internal services.
38
- - `shell_exec` is off unless you pass `--allow-shell`.
38
+ - `sidecar_shell_exec` is off unless you pass `--allow-shell`.
39
39
 
40
40
  Licensed under Elastic-2.0.
package/dist/cli.js CHANGED
@@ -10,6 +10,13 @@ import { spawn as spawn4 } from "node:child_process";
10
10
  import { WebSocketServer } from "ws";
11
11
  import { createServer as createHttpsServer } from "node:https";
12
12
 
13
+ // ../protocol/stateDir.ts
14
+ var DEFAULT_STATE_DIR = ".corenel";
15
+ var dirName = DEFAULT_STATE_DIR;
16
+ function stateDirName() {
17
+ return dirName;
18
+ }
19
+
13
20
  // ../protocol/sidecar.ts
14
21
  var SIDECAR_PROTOCOL = 1;
15
22
  function isLoopbackHost(host) {
@@ -39,6 +46,10 @@ var SidecarServer = class {
39
46
  authed = false;
40
47
  hostSeq = 0;
41
48
  hostPending = /* @__PURE__ */ new Map();
49
+ /** The connected host's dot-dir name, from `hello.stateDir` (informational —
50
+ * doesn't change any sidecar behavior yet; reserved for future workspace-state
51
+ * features that need to know the host's dir name). */
52
+ hostStateDir = ".corenel";
42
53
  constructor(opts) {
43
54
  this.opts = opts;
44
55
  this.ch = opts.channel;
@@ -88,7 +99,8 @@ var SidecarServer = class {
88
99
  return;
89
100
  }
90
101
  this.authed = true;
91
- this.opts.log?.(`paired \u2014 ${this.tools.size} ${this.tools.size === 1 ? "tool" : "tools"} advertised`);
102
+ this.hostStateDir = m.stateDir ?? ".corenel";
103
+ this.opts.log?.(`paired \u2014 ${this.tools.size} ${this.tools.size === 1 ? "tool" : "tools"} advertised (host dir: ${this.hostStateDir})`);
92
104
  const tools = [...this.tools.values()].map(({ run: _run, ...decl }) => decl);
93
105
  this.ch.send({ t: "welcome", tools, features: ["http-proxy"], ...this.opts.info ? { info: this.opts.info } : {} });
94
106
  }
@@ -349,7 +361,7 @@ var MAX_READ_BYTES = 16 * 1024 * 1024;
349
361
  function nodeFsTools(roots) {
350
362
  return [
351
363
  {
352
- name: "fs_read_file",
364
+ name: "sidecar_fs_read_file",
353
365
  description: "Read a UTF-8 text file from the sidecar machine, relative to a pre-approved root.",
354
366
  parameters: {
355
367
  type: "object",
@@ -368,7 +380,7 @@ function nodeFsTools(roots) {
368
380
  }
369
381
  },
370
382
  {
371
- name: "fs_write_file",
383
+ name: "sidecar_fs_write_file",
372
384
  description: "Write a UTF-8 text file on the sidecar machine, relative to a pre-approved root.",
373
385
  parameters: {
374
386
  type: "object",
@@ -390,7 +402,7 @@ function nodeFsTools(roots) {
390
402
  }
391
403
  },
392
404
  {
393
- name: "fs_read_bytes",
405
+ name: "sidecar_fs_read_bytes",
394
406
  description: "Read a file from the sidecar machine as base64 (for previewing media/binary the text read would mangle), relative to a pre-approved root.",
395
407
  parameters: {
396
408
  type: "object",
@@ -409,7 +421,7 @@ function nodeFsTools(roots) {
409
421
  }
410
422
  },
411
423
  {
412
- name: "fs_list_dir",
424
+ name: "sidecar_fs_list_dir",
413
425
  description: "List the entries of a directory on the sidecar machine, relative to a pre-approved root.",
414
426
  parameters: {
415
427
  type: "object",
@@ -430,7 +442,7 @@ var MAX_TIMEOUT_MS = 3e5;
430
442
  var MAX_OUTPUT = 1e5;
431
443
  function nodeShellTool(roots) {
432
444
  return {
433
- name: "shell_exec",
445
+ name: "sidecar_shell_exec",
434
446
  description: "Run a shell command on the sidecar machine (commands a browser cannot spawn). Runs in a pre-approved root by default; returns the exit status plus combined stdout/stderr.",
435
447
  parameters: {
436
448
  type: "object",
@@ -497,7 +509,7 @@ import { randomBytes } from "node:crypto";
497
509
  import { homedir } from "node:os";
498
510
  import { join } from "node:path";
499
511
  function corenelDir() {
500
- return join(homedir(), ".corenel");
512
+ return join(homedir(), stateDirName());
501
513
  }
502
514
  function tokenPath() {
503
515
  return join(corenelDir(), "daemon.token");
@@ -701,10 +713,20 @@ function extractTunnelUrl(s) {
701
713
  const m = s.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/i);
702
714
  return m ? m[0] : null;
703
715
  }
716
+ function isRegisteredLine(s) {
717
+ return /Registered tunnel connection/.test(s);
718
+ }
719
+ function isTunnelGoneLine(s) {
720
+ return /Unauthorized: Tunnel not found/i.test(s);
721
+ }
722
+ var GONE_LINE_LIMIT = 3;
704
723
  var START_TIMEOUT_MS2 = 25e3;
705
724
  function tunnelArgs(port, host = "localhost") {
706
725
  return ["tunnel", "--no-autoupdate", "--protocol", "http2", "--url", `http://${host}:${port}`];
707
726
  }
727
+ function namedTunnelArgs(name, port, host = "localhost") {
728
+ return ["tunnel", "--no-autoupdate", "--protocol", "http2", "run", "--url", `http://${host}:${port}`, name];
729
+ }
708
730
  var tail2 = (s, n = 6) => s.trim().split("\n").slice(-n).join("\n");
709
731
  function cloudflaredBins() {
710
732
  const env = process.env.CLOUDFLARED_BIN?.trim();
@@ -735,24 +757,34 @@ function stopChild2(child) {
735
757
  }
736
758
  });
737
759
  }
738
- function startWith2(bin, port, host, log) {
760
+ function startWith2(bin, mode, log, onDown) {
739
761
  return new Promise((resolveHandle, reject) => {
740
- const child = spawn3(bin, tunnelArgs(port, host), { stdio: ["ignore", "pipe", "pipe"] });
762
+ const child = spawn3(bin, mode.argv, { stdio: ["ignore", "pipe", "pipe"] });
741
763
  let out = "";
742
764
  let settled = false;
743
765
  let tailBuf = "";
766
+ let goneLines = 0;
767
+ let downFired = false;
744
768
  const finish = (fn) => {
745
769
  if (settled) return;
746
770
  settled = true;
747
771
  clearTimeout(timer);
748
772
  fn();
749
773
  };
774
+ const fireDown = (reason) => {
775
+ if (downFired) return;
776
+ downFired = true;
777
+ onDown?.(reason);
778
+ };
750
779
  const onData = (b) => {
751
780
  const text = b.toString();
752
781
  if (!settled) {
753
782
  out += text;
754
- const url = extractTunnelUrl(out);
755
- if (url) finish(() => resolveHandle({ url, stop: () => stopChild2(child) }));
783
+ const url = mode.ready(out);
784
+ if (url) finish(() => resolveHandle({ url, stop: () => {
785
+ downFired = true;
786
+ return stopChild2(child);
787
+ } }));
756
788
  return;
757
789
  }
758
790
  tailBuf += text;
@@ -760,7 +792,20 @@ function startWith2(bin, port, host, log) {
760
792
  while ((nl = tailBuf.indexOf("\n")) >= 0) {
761
793
  const line = tailBuf.slice(0, nl).replace(/\r$/, "").trim();
762
794
  tailBuf = tailBuf.slice(nl + 1);
763
- if (line) log?.(`cloudflared: ${line}`);
795
+ if (!line) continue;
796
+ log?.(`cloudflared: ${line}`);
797
+ if (isRegisteredLine(line)) {
798
+ goneLines = 0;
799
+ continue;
800
+ }
801
+ if (isTunnelGoneLine(line)) {
802
+ goneLines++;
803
+ if (goneLines >= GONE_LINE_LIMIT && !downFired) {
804
+ log?.(`cloudflared: tunnel deregistered by the edge (${goneLines}x "Tunnel not found") \u2014 it can never re-register; replacing it`);
805
+ void stopChild2(child);
806
+ fireDown("quick tunnel deregistered by the edge");
807
+ }
808
+ }
764
809
  }
765
810
  };
766
811
  child.stdout?.on("data", onData);
@@ -769,14 +814,15 @@ function startWith2(bin, port, host, log) {
769
814
  child.on("exit", (code) => {
770
815
  if (settled) {
771
816
  log?.(`cloudflared exited (code ${code}) \u2014 the tunnel is down`);
817
+ fireDown(`cloudflared exited (code ${code})`);
772
818
  return;
773
819
  }
774
- finish(() => reject(new Error(`${bin} exited (code ${code}) before a tunnel URL appeared.
820
+ finish(() => reject(new Error(`${bin} exited (code ${code}) before the tunnel came up.
775
821
  ${tail2(out)}`)));
776
822
  });
777
823
  const timer = setTimeout(() => finish(() => {
778
824
  void stopChild2(child);
779
- reject(new Error(`${bin} produced no tunnel URL within ${START_TIMEOUT_MS2 / 1e3}s.
825
+ reject(new Error(`${bin} did not come up within ${START_TIMEOUT_MS2 / 1e3}s.
780
826
  ${tail2(out)}`));
781
827
  }), START_TIMEOUT_MS2);
782
828
  });
@@ -784,8 +830,21 @@ ${tail2(out)}`));
784
830
  var cloudflaredProvider = {
785
831
  id: "cloudflared",
786
832
  aliases: ["cloudflare"],
787
- 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)",
788
- start({ port, host, log }) {
833
+ description: "Cloudflare tunnel (needs the `cloudflared` CLI installed \u2014 https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/). Default: anonymous quick tunnel (no account, ephemeral URL). With --tunnel-name + --tunnel-hostname: your own NAMED tunnel (stable URL, auto-reconnects).",
834
+ start({ port, host, name, hostname, log, onDown }) {
835
+ const origin = host || "localhost";
836
+ let mode;
837
+ if (name) {
838
+ if (!hostname) {
839
+ return Promise.reject(new Error(
840
+ `a named cloudflared tunnel needs --tunnel-hostname <public-host> \u2014 the DNS route you created with \`cloudflared tunnel route dns ${name} <public-host>\` (one-time setup: \`cloudflared tunnel login\` then \`cloudflared tunnel create ${name}\`).`
841
+ ));
842
+ }
843
+ const url = `https://${hostname.replace(/^https?:\/\//, "")}`;
844
+ mode = { argv: namedTunnelArgs(name, port, origin), ready: (s) => isRegisteredLine(s) ? url : null };
845
+ } else {
846
+ mode = { argv: tunnelArgs(port, origin), ready: extractTunnelUrl };
847
+ }
789
848
  const bins = cloudflaredBins();
790
849
  const tryAt = (i) => {
791
850
  if (i >= bins.length) {
@@ -793,7 +852,7 @@ var cloudflaredProvider = {
793
852
  `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.`
794
853
  ));
795
854
  }
796
- return startWith2(bins[i], port, host || "localhost", log).catch((e) => {
855
+ return startWith2(bins[i], mode, log, onDown).catch((e) => {
797
856
  if (e?.code === "ENOENT") return tryAt(i + 1);
798
857
  throw e;
799
858
  });
@@ -806,6 +865,66 @@ var cloudflaredProvider = {
806
865
  registerTunnel(cloudflaredProvider);
807
866
  registerTunnel(zrokProvider);
808
867
 
868
+ // src/tunnel/supervisor.ts
869
+ var DEFAULT_BACKOFF = [2e3, 5e3, 1e4, 3e4];
870
+ async function superviseTunnel(opts) {
871
+ const backoff = opts.backoffMs ?? DEFAULT_BACKOFF;
872
+ const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
873
+ const log = opts.start.log;
874
+ let stopped = false;
875
+ let restarting = false;
876
+ let current = null;
877
+ let incarnation = 0;
878
+ const startOnce = () => {
879
+ const mine = ++incarnation;
880
+ return opts.provider.start({
881
+ ...opts.start,
882
+ onDown: (reason) => {
883
+ if (mine === incarnation) void restart(reason);
884
+ }
885
+ });
886
+ };
887
+ async function restart(reason) {
888
+ if (stopped || restarting) return;
889
+ restarting = true;
890
+ current = null;
891
+ log?.(`tunnel down (${reason}) \u2014 restarting`);
892
+ try {
893
+ for (let attempt = 0; !stopped; attempt++) {
894
+ await sleep(backoff[Math.min(attempt, backoff.length - 1)]);
895
+ if (stopped) return;
896
+ try {
897
+ const h = await startOnce();
898
+ if (stopped) {
899
+ void h.stop().catch(() => {
900
+ });
901
+ return;
902
+ }
903
+ current = h;
904
+ opts.onUp(h.url, true);
905
+ return;
906
+ } catch (e) {
907
+ log?.(`tunnel restart failed (${e instanceof Error ? e.message : String(e)}) \u2014 retrying`);
908
+ }
909
+ }
910
+ } finally {
911
+ restarting = false;
912
+ }
913
+ }
914
+ current = await startOnce();
915
+ opts.onUp(current.url, false);
916
+ return {
917
+ stop: async () => {
918
+ stopped = true;
919
+ incarnation++;
920
+ const c = current;
921
+ current = null;
922
+ if (c) await c.stop().catch(() => {
923
+ });
924
+ }
925
+ };
926
+ }
927
+
809
928
  // src/cli.ts
810
929
  function parseArgs(argv) {
811
930
  const out = { port: 4858, host: "127.0.0.1", roots: [], allowOrigins: [], allowShell: false, allowLan: false, selfSigned: false, open: false, appUrl: "https://prompd.app" };
@@ -822,9 +941,10 @@ function parseArgs(argv) {
822
941
  else if (a === "--self-signed") out.selfSigned = true;
823
942
  else if (a === "--tunnel") out.tunnel = argv[++i];
824
943
  else if (a === "--tunnel-name") out.tunnelName = argv[++i];
944
+ else if (a === "--tunnel-hostname") out.tunnelHostname = argv[++i];
825
945
  else if (a === "--open") out.open = true;
826
946
  else if (a === "--app-url") out.appUrl = argv[++i];
827
- else if (a === "--sidecar" || a === "start") {
947
+ else if (a === "--sidecar" || a === "start" || a === "--") {
828
948
  } else throw new Error(`unknown argument: ${a}
829
949
  valid flags: ${KNOWN_FLAGS.join(" ")}`);
830
950
  }
@@ -842,6 +962,7 @@ var KNOWN_FLAGS = [
842
962
  "--self-signed",
843
963
  "--tunnel",
844
964
  "--tunnel-name",
965
+ "--tunnel-hostname",
845
966
  "--open",
846
967
  "--app-url"
847
968
  ];
@@ -908,13 +1029,22 @@ async function main() {
908
1029
  log: slog,
909
1030
  allowLan: args.allowLan
910
1031
  });
911
- let tunnel = null;
1032
+ let tunnelSup = null;
912
1033
  let tunnelWss = null;
913
1034
  if (tunnelProvider) {
914
- process.stdout.write(`starting ${tunnelProvider.id} tunnel${args.tunnelName ? ` (reserved name "${args.tunnelName}")` : ""}\u2026
1035
+ process.stdout.write(`starting ${tunnelProvider.id} tunnel${args.tunnelName ? ` (name "${args.tunnelName}")` : ""}\u2026
915
1036
  `);
916
- tunnel = await tunnelProvider.start({ port: handle.port, host: handle.host, name: args.tunnelName, log: slog });
917
- tunnelWss = tunnel.url.replace(/^http/, "ws");
1037
+ tunnelSup = await superviseTunnel({
1038
+ provider: tunnelProvider,
1039
+ start: { port: handle.port, host: handle.host, name: args.tunnelName, hostname: args.tunnelHostname, log: slog },
1040
+ onUp: (url, restarted) => {
1041
+ const wss = url.replace(/^http/, "ws");
1042
+ const changed = tunnelWss !== null && wss !== tunnelWss;
1043
+ tunnelWss = wss;
1044
+ if (!restarted) return;
1045
+ slog(changed ? `tunnel restored at a NEW URL: ${wss} \u2014 re-pair the app with this URL (the token is unchanged)` : "tunnel restored \u2014 same URL, existing pairings reconnect automatically");
1046
+ }
1047
+ });
918
1048
  }
919
1049
  const scheme = handle.secure ? "wss" : "ws";
920
1050
  const exposed = handle.host !== "127.0.0.1" && handle.host !== "localhost";
@@ -924,7 +1054,7 @@ async function main() {
924
1054
  [
925
1055
  `corenel sidecar listening on ${scheme}://${handle.host}:${handle.port}`,
926
1056
  args.roots.length === 1 ? ` root: ${args.roots[0]}` : ` roots: ${args.roots.join("\n ")} (${args.roots.length} approved; pick the active one in the app)`,
927
- ` tools: fs_read/write/list${args.allowShell ? " + shell_exec" : " (shell off; pass --allow-shell to enable)"}`,
1057
+ ` tools: sidecar_fs_read/write/list${args.allowShell ? " + sidecar_shell_exec" : " (shell off; pass --allow-shell to enable)"}`,
928
1058
  ` token: ${token} (also at ${tokenPath()})`,
929
1059
  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(" ")}`,
930
1060
  ` origins: ${allowedOrigins.join(", ")}${args.allowLan ? " + any private-LAN origin (--allow-lan)" : args.allowOrigins.length ? "" : " (canonical defaults; pass --allow-origin to add dev/self-hosted)"}`,
@@ -943,7 +1073,7 @@ async function main() {
943
1073
  }
944
1074
  const shutdown = () => {
945
1075
  const closeServer = () => handle.close().then(() => process.exit(0));
946
- if (tunnel) void tunnel.stop().catch(() => {
1076
+ if (tunnelSup) void tunnelSup.stop().catch(() => {
947
1077
  }).then(closeServer);
948
1078
  else void closeServer();
949
1079
  };
package/package.json CHANGED
@@ -1,41 +1,48 @@
1
- {
2
- "name": "@corenel/sidecar",
3
- "version": "0.1.3",
4
- "type": "module",
5
- "license": "Elastic-2.0",
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.",
7
- "keywords": ["prompd", "corenel", "sidecar", "ollama", "local-llm", "cors"],
8
- "bin": {
9
- "corenel-sidecar": "dist/cli.js"
10
- },
11
- "files": [
12
- "dist",
13
- "README.md"
14
- ],
15
- "engines": {
16
- "node": ">=18"
17
- },
18
- "publishConfig": {
19
- "access": "public"
20
- },
21
- "scripts": {
22
- "build": "node build.mjs",
23
- "prepublishOnly": "node build.mjs",
24
- "start": "tsx src/cli.ts",
25
- "typecheck": "tsc --noEmit",
26
- "verify": "tsx src/verify.ts"
27
- },
28
- "dependencies": {
29
- "selfsigned": "^2.4.1",
30
- "ws": "^8.18.0"
31
- },
32
- "devDependencies": {
33
- "@corenel/protocol": "workspace:*",
34
- "@corenel/harness": "workspace:*",
35
- "@types/node": "^24.13.1",
36
- "@types/ws": "^8.5.13",
37
- "esbuild": "^0.21.5",
38
- "tsx": "^4.19.2",
39
- "typescript": "^5.9.3"
40
- }
41
- }
1
+ {
2
+ "name": "@corenel/sidecar",
3
+ "version": "0.1.5",
4
+ "type": "module",
5
+ "license": "Elastic-2.0",
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.",
7
+ "keywords": [
8
+ "prompd",
9
+ "corenel",
10
+ "sidecar",
11
+ "ollama",
12
+ "local-llm",
13
+ "cors"
14
+ ],
15
+ "bin": {
16
+ "corenel-sidecar": "dist/cli.js"
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "README.md"
21
+ ],
22
+ "engines": {
23
+ "node": ">=18"
24
+ },
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "scripts": {
29
+ "build": "node build.mjs",
30
+ "prepublishOnly": "node build.mjs",
31
+ "start": "tsx src/cli.ts",
32
+ "typecheck": "tsc --noEmit",
33
+ "verify": "tsx src/verify.ts"
34
+ },
35
+ "dependencies": {
36
+ "selfsigned": "^2.4.1",
37
+ "ws": "^8.18.0"
38
+ },
39
+ "devDependencies": {
40
+ "@corenel/protocol": "workspace:*",
41
+ "@corenel/harness": "workspace:*",
42
+ "@types/node": "^24.13.1",
43
+ "@types/ws": "^8.5.13",
44
+ "esbuild": "^0.21.5",
45
+ "tsx": "^4.19.2",
46
+ "typescript": "^5.9.3"
47
+ }
48
+ }