@corenel/sidecar 0.1.0 → 0.1.2

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 +40 -40
  2. package/dist/cli.js +405 -41
  3. package/package.json +41 -41
package/README.md CHANGED
@@ -1,40 +1,40 @@
1
- # @corenel/sidecar
2
-
3
- A small local capability server for [Prompd](https://prompd.app). Run it next to the
4
- web app and the browser can reach your machine — most usefully, your **local LLMs**
5
- (Ollama, LM Studio, vLLM) **without CORS**, plus your filesystem and (opt-in) shell.
6
-
7
- It runs only while the command is open, binds to loopback by default, and pairs with
8
- a token the app reads once.
9
-
10
- ## Use
11
-
12
- In Prompd, open **Connect a sidecar** and follow the steps, or run it directly:
13
-
14
- ```bash
15
- npx @corenel/sidecar --allow-origin https://prompd.app
16
- ```
17
-
18
- It prints a pairing token; paste that into the app. The `--allow-origin` flag must
19
- match the site you're connecting from (the app fills it in for you).
20
-
21
- ## Options
22
-
23
- | Flag | Default | What |
24
- |------|---------|------|
25
- | `--allow-origin <url>` | _(none)_ | Browser origin allowed to connect. Required for a web page. Repeatable. |
26
- | `--port <n>` | `4858` | Port to listen on. |
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). |
29
- | `--host <addr>` | `127.0.0.1` | Bind address. Non-loopback is reachable off-box; keep the token secret. |
30
- | `--tls-cert <f>` `--tls-key <f>` | _(none)_ | Serve `wss://` (needed for a remote sidecar from an https page). |
31
-
32
- ## Security
33
-
34
- - Loopback by default; a browser connection requires both the pairing token **and** an
35
- allowed Origin.
36
- - The HTTP proxy (for local LLMs) only reaches **loopback** hosts — it can't be used as
37
- an SSRF pivot to internal services.
38
- - `shell_exec` is off unless you pass `--allow-shell`.
39
-
40
- Licensed under Elastic-2.0.
1
+ # @corenel/sidecar
2
+
3
+ A small local capability server for [Prompd](https://prompd.app). Run it next to the
4
+ web app and the browser can reach your machine — most usefully, your **local LLMs**
5
+ (Ollama, LM Studio, vLLM) **without CORS**, plus your filesystem and (opt-in) shell.
6
+
7
+ It runs only while the command is open, binds to loopback by default, and pairs with
8
+ a token the app reads once.
9
+
10
+ ## Use
11
+
12
+ In Prompd, open **Connect a sidecar** and follow the steps, or run it directly:
13
+
14
+ ```bash
15
+ npx @corenel/sidecar --allow-origin https://prompd.app
16
+ ```
17
+
18
+ It prints a pairing token; paste that into the app. The `--allow-origin` flag must
19
+ match the site you're connecting from (the app fills it in for you).
20
+
21
+ ## Options
22
+
23
+ | Flag | Default | What |
24
+ |------|---------|------|
25
+ | `--allow-origin <url>` | _(none)_ | Browser origin allowed to connect. Required for a web page. Repeatable. |
26
+ | `--port <n>` | `4858` | Port to listen on. |
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). |
29
+ | `--host <addr>` | `127.0.0.1` | Bind address. Non-loopback is reachable off-box; keep the token secret. |
30
+ | `--tls-cert <f>` `--tls-key <f>` | _(none)_ | Serve `wss://` (needed for a remote sidecar from an https page). |
31
+
32
+ ## Security
33
+
34
+ - Loopback by default; a browser connection requires both the pairing token **and** an
35
+ allowed Origin.
36
+ - The HTTP proxy (for local LLMs) only reaches **loopback** hosts — it can't be used as
37
+ an SSRF pivot to internal services.
38
+ - `shell_exec` is off unless you pass `--allow-shell`.
39
+
40
+ Licensed under Elastic-2.0.
package/dist/cli.js CHANGED
@@ -1,8 +1,10 @@
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";
5
- import { readFileSync } from "node:fs";
6
+ import { readFileSync, statSync } from "node:fs";
7
+ import { spawn as spawn4 } from "node:child_process";
6
8
 
7
9
  // src/server.ts
8
10
  import { WebSocketServer } from "ws";
@@ -76,16 +78,19 @@ var SidecarServer = class {
76
78
  }
77
79
  onHello(m) {
78
80
  if (m.protocol !== SIDECAR_PROTOCOL) {
81
+ this.opts.log?.(`denied: unsupported protocol ${m.protocol}`);
79
82
  this.ch.send({ t: "denied", reason: `protocol ${m.protocol} unsupported` });
80
83
  return;
81
84
  }
82
85
  if (!safeEqual(m.token ?? "", this.opts.token)) {
86
+ this.opts.log?.("denied: invalid token");
83
87
  this.ch.send({ t: "denied", reason: "invalid token" });
84
88
  return;
85
89
  }
86
90
  this.authed = true;
91
+ this.opts.log?.(`paired \u2014 ${this.tools.size} ${this.tools.size === 1 ? "tool" : "tools"} advertised`);
87
92
  const tools = [...this.tools.values()].map(({ run: _run, ...decl }) => decl);
88
- this.ch.send({ t: "welcome", tools, features: ["http-proxy"] });
93
+ this.ch.send({ t: "welcome", tools, features: ["http-proxy"], ...this.opts.info ? { info: this.opts.info } : {} });
89
94
  }
90
95
  /** Proxy a host HTTP request server-side. Restricted to LOOPBACK targets — the
91
96
  * sidecar must never be usable as an SSRF pivot to internal/metadata hosts. The
@@ -122,13 +127,16 @@ var SidecarServer = class {
122
127
  async onInvoke(m) {
123
128
  const tool = this.tools.get(m.name);
124
129
  if (!tool) {
130
+ this.opts.log?.(`invoke ${m.name}: unknown tool`);
125
131
  this.ch.send({ t: "invoke-result", id: m.id, ok: false, error: `unknown tool ${m.name}` });
126
132
  return;
127
133
  }
134
+ this.opts.log?.(`invoke ${m.name}`);
128
135
  try {
129
136
  const result = await tool.run(m.args, this.makeContext());
130
137
  this.ch.send({ t: "invoke-result", id: m.id, ok: true, result });
131
138
  } catch (e) {
139
+ this.opts.log?.(`invoke ${m.name} failed: ${errMessage(e)}`);
132
140
  this.ch.send({ t: "invoke-result", id: m.id, ok: false, error: errMessage(e) });
133
141
  }
134
142
  }
@@ -228,16 +236,42 @@ function wsChannel(ws) {
228
236
  };
229
237
  }
230
238
 
239
+ // src/lanOrigin.ts
240
+ function isPrivateLanOrigin(origin) {
241
+ let host;
242
+ try {
243
+ host = new URL(origin).hostname;
244
+ } catch {
245
+ return false;
246
+ }
247
+ const h = host.replace(/^\[/, "").replace(/\]$/, "").toLowerCase();
248
+ if (h === "localhost" || h.endsWith(".localhost")) return true;
249
+ if (h.includes(":")) return h === "::1" || /^f[cd]/.test(h) || /^fe[89ab]/.test(h);
250
+ const m = h.match(/^(\d{1,3})\.(\d{1,3})\.\d{1,3}\.\d{1,3}$/);
251
+ if (!m) return false;
252
+ const a = Number(m[1]);
253
+ const b = Number(m[2]);
254
+ return a === 127 || a === 10 || a === 192 && b === 168 || a === 172 && b >= 16 && b <= 31 || a === 169 && b === 254;
255
+ }
256
+
231
257
  // src/server.ts
232
258
  function startSidecar(opts) {
233
259
  const host = opts.host ?? "127.0.0.1";
234
260
  const allowed = new Set(opts.allowedOrigins ?? []);
235
- const verifyClient = ({ origin }) => {
236
- if (!origin) return true;
237
- return allowed.has(origin);
261
+ const verifyClient = (info) => {
262
+ const origin = info.origin;
263
+ const ip = info.req.socket?.remoteAddress || "?";
264
+ const lan = !!origin && !!opts.allowLan && isPrivateLanOrigin(origin);
265
+ const ok = !origin || allowed.has(origin) || lan;
266
+ 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})`);
267
+ return ok;
238
268
  };
239
- const onConnection = (ws, _req) => {
240
- new SidecarServer({ channel: wsChannel(ws), token: opts.token, tools: opts.tools });
269
+ const onConnection = (ws, req) => {
270
+ const origin = req.headers.origin || "(native client)";
271
+ const ip = req.socket.remoteAddress || "?";
272
+ opts.log?.(`client connected \u2014 ${origin} (${ip})`);
273
+ ws.on("close", () => opts.log?.(`client disconnected \u2014 ${origin}`));
274
+ new SidecarServer({ channel: wsChannel(ws), token: opts.token, tools: opts.tools, info: opts.info, log: opts.log });
241
275
  };
242
276
  if (opts.tls) {
243
277
  const httpsServer = createHttpsServer({ cert: opts.tls.cert, key: opts.tls.key });
@@ -284,42 +318,47 @@ function withinRoot(root, abs) {
284
318
  const rel = relative(root, abs);
285
319
  return rel === "" || !rel.startsWith("..") && !isAbsolute(rel) && rel.split(sep)[0] !== "..";
286
320
  }
287
- function confine(root, p) {
288
- const abs = resolve(root, p);
289
- if (!withinRoot(root, abs)) throw new Error(`path escapes the sidecar root: ${p}`);
321
+ function activeRoot(roots, rootArg) {
322
+ const r = typeof rootArg === "string" ? rootArg : "";
323
+ return roots.includes(r) ? r : roots[0];
324
+ }
325
+ function confine(roots, rootArg, p) {
326
+ const abs = resolve(activeRoot(roots, rootArg), p);
327
+ if (!roots.some((root) => withinRoot(root, abs))) throw new Error(`path escapes the sidecar roots: ${p}`);
290
328
  return abs;
291
329
  }
292
- async function assertRealWithinRoot(root, abs) {
293
- const realRoot = await fs.realpath(root);
330
+ async function assertRealWithinRoot(roots, abs) {
331
+ const realRoots = await Promise.all(roots.map((r) => fs.realpath(r)));
294
332
  let probe = abs;
295
333
  for (; ; ) {
296
334
  try {
297
335
  const real = await fs.realpath(probe);
298
- if (!withinRoot(realRoot, real)) throw new Error(`path escapes the sidecar root via symlink: ${abs}`);
336
+ if (!realRoots.some((rr) => withinRoot(rr, real))) throw new Error(`path escapes the sidecar roots via symlink: ${abs}`);
299
337
  return;
300
338
  } catch (e) {
301
339
  if (e instanceof Error && e.message.includes("via symlink")) throw e;
302
340
  const parent = dirname(probe);
303
- if (parent === probe) throw new Error(`cannot resolve path under the sidecar root: ${abs}`);
341
+ if (parent === probe) throw new Error(`cannot resolve path under the sidecar roots: ${abs}`);
304
342
  probe = parent;
305
343
  }
306
344
  }
307
345
  }
346
+ var ROOT_PARAM = { type: "string", description: "which pre-approved root to resolve the path against (its absolute path; defaults to the primary root)" };
308
347
  var MAX_READ_BYTES = 16 * 1024 * 1024;
309
- function nodeFsTools(root) {
348
+ function nodeFsTools(roots) {
310
349
  return [
311
350
  {
312
351
  name: "fs_read_file",
313
- description: "Read a UTF-8 text file from the sidecar machine, relative to its root.",
352
+ description: "Read a UTF-8 text file from the sidecar machine, relative to a pre-approved root.",
314
353
  parameters: {
315
354
  type: "object",
316
- properties: { path: { type: "string", description: "path relative to the sidecar root" } },
355
+ properties: { path: { type: "string", description: "path relative to the chosen root" }, root: ROOT_PARAM },
317
356
  required: ["path"]
318
357
  },
319
358
  namespace: "fs",
320
359
  async run(args) {
321
- const abs = confine(root, String(args.path ?? ""));
322
- await assertRealWithinRoot(root, abs);
360
+ const abs = confine(roots, args.root, String(args.path ?? ""));
361
+ await assertRealWithinRoot(roots, abs);
323
362
  const st = await fs.stat(abs);
324
363
  if (st.size > MAX_READ_BYTES) {
325
364
  throw new Error(`file too large to read (${st.size} bytes; max ${MAX_READ_BYTES})`);
@@ -329,36 +368,56 @@ function nodeFsTools(root) {
329
368
  },
330
369
  {
331
370
  name: "fs_write_file",
332
- description: "Write a UTF-8 text file on the sidecar machine, relative to its root.",
371
+ description: "Write a UTF-8 text file on the sidecar machine, relative to a pre-approved root.",
333
372
  parameters: {
334
373
  type: "object",
335
374
  properties: {
336
- path: { type: "string", description: "path relative to the sidecar root" },
337
- content: { type: "string" }
375
+ path: { type: "string", description: "path relative to the chosen root" },
376
+ content: { type: "string" },
377
+ root: ROOT_PARAM
338
378
  },
339
379
  required: ["path", "content"]
340
380
  },
341
381
  namespace: "fs",
342
382
  mutates: true,
343
383
  async run(args) {
344
- const abs = confine(root, String(args.path ?? ""));
384
+ const abs = confine(roots, args.root, String(args.path ?? ""));
345
385
  await fs.mkdir(dirname(abs), { recursive: true });
346
- await assertRealWithinRoot(root, abs);
386
+ await assertRealWithinRoot(roots, abs);
347
387
  await fs.writeFile(abs, String(args.content ?? ""), "utf8");
348
388
  return `wrote ${args.path}`;
349
389
  }
350
390
  },
391
+ {
392
+ name: "fs_read_bytes",
393
+ 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.",
394
+ parameters: {
395
+ type: "object",
396
+ properties: { path: { type: "string", description: "path relative to the chosen root" }, root: ROOT_PARAM },
397
+ required: ["path"]
398
+ },
399
+ namespace: "fs",
400
+ async run(args) {
401
+ const abs = confine(roots, args.root, String(args.path ?? ""));
402
+ await assertRealWithinRoot(roots, abs);
403
+ const st = await fs.stat(abs);
404
+ if (st.size > MAX_READ_BYTES) {
405
+ throw new Error(`file too large to read (${st.size} bytes; max ${MAX_READ_BYTES})`);
406
+ }
407
+ return (await fs.readFile(abs)).toString("base64");
408
+ }
409
+ },
351
410
  {
352
411
  name: "fs_list_dir",
353
- description: "List the entries of a directory on the sidecar machine, relative to its root.",
412
+ description: "List the entries of a directory on the sidecar machine, relative to a pre-approved root.",
354
413
  parameters: {
355
414
  type: "object",
356
- properties: { path: { type: "string", description: 'directory relative to the sidecar root (default ".")' } }
415
+ properties: { path: { type: "string", description: 'directory relative to the chosen root (default ".")' }, root: ROOT_PARAM }
357
416
  },
358
417
  namespace: "fs",
359
418
  async run(args) {
360
- const abs = confine(root, String(args.path ?? "."));
361
- await assertRealWithinRoot(root, abs);
419
+ const abs = confine(roots, args.root, String(args.path ?? "."));
420
+ await assertRealWithinRoot(roots, abs);
362
421
  const entries = await fs.readdir(abs, { withFileTypes: true });
363
422
  return entries.map((e) => e.isDirectory() ? `${e.name}/` : e.name).join("\n");
364
423
  }
@@ -368,15 +427,16 @@ function nodeFsTools(root) {
368
427
  var DEFAULT_TIMEOUT_MS = 6e4;
369
428
  var MAX_TIMEOUT_MS = 3e5;
370
429
  var MAX_OUTPUT = 1e5;
371
- function nodeShellTool(root) {
430
+ function nodeShellTool(roots) {
372
431
  return {
373
432
  name: "shell_exec",
374
- description: "Run a shell command on the sidecar machine (commands a browser cannot spawn). Runs in the sidecar root by default; returns the exit status plus combined stdout/stderr.",
433
+ 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.",
375
434
  parameters: {
376
435
  type: "object",
377
436
  properties: {
378
437
  command: { type: "string", description: "the shell command line to run" },
379
- cwd: { type: "string", description: 'working directory relative to the sidecar root (default ".")' },
438
+ cwd: { type: "string", description: 'working directory relative to the chosen root (default ".")' },
439
+ root: ROOT_PARAM,
380
440
  timeout_ms: { type: "number", description: `max run time in ms (default ${DEFAULT_TIMEOUT_MS}, max ${MAX_TIMEOUT_MS})` }
381
441
  },
382
442
  required: ["command"]
@@ -387,8 +447,8 @@ function nodeShellTool(root) {
387
447
  async run(args) {
388
448
  const command = String(args.command ?? "").trim();
389
449
  if (!command) return "Error: no command given";
390
- const cwd = confine(root, String(args.cwd ?? "."));
391
- await assertRealWithinRoot(root, cwd);
450
+ const cwd = confine(roots, args.root, String(args.cwd ?? "."));
451
+ await assertRealWithinRoot(roots, cwd);
392
452
  const timeout = Math.min(Math.max(Number(args.timeout_ms) || DEFAULT_TIMEOUT_MS, 1), MAX_TIMEOUT_MS);
393
453
  return runShell(command, cwd, timeout);
394
454
  }
@@ -502,26 +562,294 @@ async function loadOrCreateSelfSigned(extraHosts = []) {
502
562
  return { cert: pems.cert, key: pems.private, sans };
503
563
  }
504
564
 
565
+ // src/tunnel/registry.ts
566
+ var providers = /* @__PURE__ */ new Map();
567
+ function registerTunnel(p) {
568
+ providers.set(p.id, p);
569
+ }
570
+ function getTunnel(id) {
571
+ const direct = providers.get(id);
572
+ if (direct) return direct;
573
+ for (const p of providers.values()) if (p.aliases?.includes(id)) return p;
574
+ return void 0;
575
+ }
576
+ function tunnelIds() {
577
+ return [...providers.keys()];
578
+ }
579
+
580
+ // src/tunnel/zrok.ts
581
+ import { spawn as spawn2 } from "node:child_process";
582
+ function extractShareUrl(s) {
583
+ const full = s.match(/https?:\/\/[A-Za-z0-9.-]+(?::\d+)?(?:\/[^\s"'\\]*)?/);
584
+ if (full) return full[0];
585
+ const ep = s.match(/endpoints:(?:\\[nrt]|\s)*([A-Za-z0-9][A-Za-z0-9.-]*\.[A-Za-z]{2,}(?:\/[^\s"'\\]*)?)/);
586
+ return ep ? `https://${ep[1]}` : null;
587
+ }
588
+ var START_TIMEOUT_MS = 25e3;
589
+ function shareArgs(port, name) {
590
+ const a = ["share", "public", "--headless", "--backend-mode", "proxy"];
591
+ if (name) a.push("-n", `public:${name}`);
592
+ a.push(`http://localhost:${port}`);
593
+ return a;
594
+ }
595
+ var tail = (s, n = 6) => s.trim().split("\n").slice(-n).join("\n");
596
+ function zrokBins() {
597
+ const env = process.env.ZROK_BIN?.trim();
598
+ return env ? [env] : ["zrok", "zrok2"];
599
+ }
600
+ function stopChild(child) {
601
+ return new Promise((res) => {
602
+ if (child.exitCode !== null || child.signalCode) {
603
+ res();
604
+ return;
605
+ }
606
+ const hard = setTimeout(() => {
607
+ try {
608
+ child.kill("SIGKILL");
609
+ } catch {
610
+ }
611
+ res();
612
+ }, 2e3);
613
+ child.once("exit", () => {
614
+ clearTimeout(hard);
615
+ res();
616
+ });
617
+ try {
618
+ child.kill("SIGTERM");
619
+ } catch {
620
+ clearTimeout(hard);
621
+ res();
622
+ }
623
+ });
624
+ }
625
+ function reserveName(bin, name) {
626
+ return new Promise((res, rej) => {
627
+ const child = spawn2(bin, ["create", "name", name], { stdio: ["ignore", "ignore", "ignore"] });
628
+ let settled = false;
629
+ const done = (fn) => {
630
+ if (settled) return;
631
+ settled = true;
632
+ clearTimeout(t);
633
+ fn();
634
+ };
635
+ child.on("error", (e) => done(() => e?.code === "ENOENT" ? rej(e) : res()));
636
+ child.on("exit", () => done(() => res()));
637
+ const t = setTimeout(() => done(() => {
638
+ try {
639
+ child.kill();
640
+ } catch {
641
+ }
642
+ res();
643
+ }), 15e3);
644
+ });
645
+ }
646
+ async function startWith(bin, port, name) {
647
+ if (name) await reserveName(bin, name);
648
+ return new Promise((resolveHandle, reject) => {
649
+ const child = spawn2(bin, shareArgs(port, name), { stdio: ["ignore", "pipe", "pipe"] });
650
+ let out = "";
651
+ let settled = false;
652
+ const finish = (fn) => {
653
+ if (settled) return;
654
+ settled = true;
655
+ clearTimeout(timer);
656
+ fn();
657
+ };
658
+ const onData = (b) => {
659
+ out += b.toString();
660
+ const url = extractShareUrl(out);
661
+ if (url) finish(() => resolveHandle({ url, stop: () => stopChild(child) }));
662
+ };
663
+ child.stdout?.on("data", onData);
664
+ child.stderr?.on("data", onData);
665
+ child.on("error", (e) => finish(() => reject(e)));
666
+ child.on("exit", (code) => finish(() => reject(new Error(
667
+ `${bin} exited (code ${code}) before a share URL appeared \u2014 is the environment enabled? Run \`${bin} enable <token>\` once.
668
+ ${tail(out)}`
669
+ ))));
670
+ const timer = setTimeout(() => finish(() => {
671
+ void stopChild(child);
672
+ reject(new Error(`${bin} produced no share URL within ${START_TIMEOUT_MS / 1e3}s.
673
+ ${tail(out)}`));
674
+ }), START_TIMEOUT_MS);
675
+ });
676
+ }
677
+ var zrokProvider = {
678
+ id: "zrok",
679
+ description: "zrok public share (needs the zrok CLI [`zrok`/`zrok2`] installed + `enable <token>` run once \u2014 https://zrok.io)",
680
+ start({ port, name }) {
681
+ const bins = zrokBins();
682
+ const tryAt = (i) => {
683
+ if (i >= bins.length) {
684
+ return Promise.reject(new Error(
685
+ `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.`
686
+ ));
687
+ }
688
+ return startWith(bins[i], port, name).catch((e) => {
689
+ if (e?.code === "ENOENT") return tryAt(i + 1);
690
+ throw e;
691
+ });
692
+ };
693
+ return tryAt(0);
694
+ }
695
+ };
696
+
697
+ // src/tunnel/cloudflared.ts
698
+ import { spawn as spawn3 } from "node:child_process";
699
+ function extractTunnelUrl(s) {
700
+ const m = s.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/i);
701
+ return m ? m[0] : null;
702
+ }
703
+ var START_TIMEOUT_MS2 = 25e3;
704
+ function tunnelArgs(port, host = "localhost") {
705
+ return ["tunnel", "--no-autoupdate", "--protocol", "http2", "--url", `http://${host}:${port}`];
706
+ }
707
+ var tail2 = (s, n = 6) => s.trim().split("\n").slice(-n).join("\n");
708
+ function cloudflaredBins() {
709
+ const env = process.env.CLOUDFLARED_BIN?.trim();
710
+ return env ? [env] : ["cloudflared"];
711
+ }
712
+ function stopChild2(child) {
713
+ return new Promise((res) => {
714
+ if (child.exitCode !== null || child.signalCode) {
715
+ res();
716
+ return;
717
+ }
718
+ const hard = setTimeout(() => {
719
+ try {
720
+ child.kill("SIGKILL");
721
+ } catch {
722
+ }
723
+ res();
724
+ }, 2e3);
725
+ child.once("exit", () => {
726
+ clearTimeout(hard);
727
+ res();
728
+ });
729
+ try {
730
+ child.kill("SIGTERM");
731
+ } catch {
732
+ clearTimeout(hard);
733
+ res();
734
+ }
735
+ });
736
+ }
737
+ function startWith2(bin, port, host, log) {
738
+ return new Promise((resolveHandle, reject) => {
739
+ const child = spawn3(bin, tunnelArgs(port, host), { stdio: ["ignore", "pipe", "pipe"] });
740
+ let out = "";
741
+ let settled = false;
742
+ let tailBuf = "";
743
+ const finish = (fn) => {
744
+ if (settled) return;
745
+ settled = true;
746
+ clearTimeout(timer);
747
+ fn();
748
+ };
749
+ const onData = (b) => {
750
+ const text = b.toString();
751
+ if (!settled) {
752
+ out += text;
753
+ const url = extractTunnelUrl(out);
754
+ if (url) finish(() => resolveHandle({ url, stop: () => stopChild2(child) }));
755
+ return;
756
+ }
757
+ tailBuf += text;
758
+ let nl;
759
+ while ((nl = tailBuf.indexOf("\n")) >= 0) {
760
+ const line = tailBuf.slice(0, nl).replace(/\r$/, "").trim();
761
+ tailBuf = tailBuf.slice(nl + 1);
762
+ if (line) log?.(`cloudflared: ${line}`);
763
+ }
764
+ };
765
+ child.stdout?.on("data", onData);
766
+ child.stderr?.on("data", onData);
767
+ child.on("error", (e) => finish(() => reject(e)));
768
+ child.on("exit", (code) => {
769
+ if (settled) {
770
+ log?.(`cloudflared exited (code ${code}) \u2014 the tunnel is down`);
771
+ return;
772
+ }
773
+ finish(() => reject(new Error(`${bin} exited (code ${code}) before a tunnel URL appeared.
774
+ ${tail2(out)}`)));
775
+ });
776
+ const timer = setTimeout(() => finish(() => {
777
+ void stopChild2(child);
778
+ reject(new Error(`${bin} produced no tunnel URL within ${START_TIMEOUT_MS2 / 1e3}s.
779
+ ${tail2(out)}`));
780
+ }), START_TIMEOUT_MS2);
781
+ });
782
+ }
783
+ var cloudflaredProvider = {
784
+ id: "cloudflared",
785
+ aliases: ["cloudflare"],
786
+ 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)",
787
+ start({ port, host, log }) {
788
+ const bins = cloudflaredBins();
789
+ const tryAt = (i) => {
790
+ if (i >= bins.length) {
791
+ return Promise.reject(new Error(
792
+ `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.`
793
+ ));
794
+ }
795
+ return startWith2(bins[i], port, host || "localhost", log).catch((e) => {
796
+ if (e?.code === "ENOENT") return tryAt(i + 1);
797
+ throw e;
798
+ });
799
+ };
800
+ return tryAt(0);
801
+ }
802
+ };
803
+
804
+ // src/tunnel/index.ts
805
+ registerTunnel(cloudflaredProvider);
806
+ registerTunnel(zrokProvider);
807
+
505
808
  // src/cli.ts
506
809
  function parseArgs(argv) {
507
- const out = { port: 4858, host: "127.0.0.1", root: process.cwd(), allowOrigins: [], allowShell: false, selfSigned: false };
810
+ const out = { port: 4858, host: "127.0.0.1", roots: [], allowOrigins: [], allowShell: false, allowLan: false, selfSigned: false, open: false, appUrl: "https://prompd.app" };
508
811
  for (let i = 0; i < argv.length; i++) {
509
812
  const a = argv[i];
510
813
  if (a === "--port") out.port = Number(argv[++i]);
511
814
  else if (a === "--host") out.host = argv[++i];
512
- else if (a === "--root") out.root = resolve2(argv[++i]);
815
+ else if (a === "--root") out.roots.push(resolve2(argv[++i]));
513
816
  else if (a === "--allow-origin") out.allowOrigins.push(argv[++i]);
514
817
  else if (a === "--allow-shell") out.allowShell = true;
818
+ else if (a === "--allow-lan") out.allowLan = true;
515
819
  else if (a === "--tls-cert") out.tlsCert = resolve2(argv[++i]);
516
820
  else if (a === "--tls-key") out.tlsKey = resolve2(argv[++i]);
517
821
  else if (a === "--self-signed") out.selfSigned = true;
822
+ else if (a === "--tunnel") out.tunnel = argv[++i];
823
+ else if (a === "--tunnel-name") out.tunnelName = argv[++i];
824
+ else if (a === "--open") out.open = true;
825
+ else if (a === "--app-url") out.appUrl = argv[++i];
518
826
  }
519
827
  return out;
520
828
  }
521
829
  var isIp2 = (s) => /^\d{1,3}(?:\.\d{1,3}){3}$/.test(s);
830
+ function openBrowser(url) {
831
+ const p = process.platform;
832
+ const cmd = p === "darwin" ? "open" : p === "win32" ? "cmd" : "xdg-open";
833
+ const args = p === "win32" ? ["/c", "start", "", url] : [url];
834
+ try {
835
+ spawn4(cmd, args, { stdio: "ignore", detached: true }).unref();
836
+ } catch {
837
+ }
838
+ }
522
839
  var DEFAULT_ALLOWED_ORIGINS = ["https://prompd.app", "https://www.prompd.app", "https://prmd.ai", "https://www.prmd.ai"];
523
840
  async function main() {
524
841
  const args = parseArgs(process.argv.slice(2));
842
+ if (args.roots.length === 0) args.roots = [process.cwd()];
843
+ args.roots = [...new Set(args.roots)];
844
+ for (const r of args.roots) {
845
+ let ok = false;
846
+ try {
847
+ ok = statSync(r).isDirectory();
848
+ } catch {
849
+ ok = false;
850
+ }
851
+ if (!ok) throw new Error(`--root is not a directory: ${r}`);
852
+ }
525
853
  const token = await loadOrCreateToken();
526
854
  if (args.tlsCert && !args.tlsKey || !args.tlsCert && args.tlsKey) {
527
855
  throw new Error("TLS needs both --tls-cert and --tls-key");
@@ -534,15 +862,41 @@ async function main() {
534
862
  tls = { cert: ss.cert, key: ss.key };
535
863
  selfSigned = true;
536
864
  }
865
+ const tunnelProvider = args.tunnel ? getTunnel(args.tunnel) : void 0;
866
+ if (args.tunnel && !tunnelProvider) {
867
+ throw new Error(`unknown --tunnel "${args.tunnel}". Available: ${tunnelIds().join(", ") || "(none)"}`);
868
+ }
869
+ if (tunnelProvider) {
870
+ args.host = "127.0.0.1";
871
+ tls = void 0;
872
+ selfSigned = false;
873
+ }
537
874
  const allowedOrigins = [.../* @__PURE__ */ new Set([...DEFAULT_ALLOWED_ORIGINS, ...args.allowOrigins])];
875
+ const slog = (line) => {
876
+ process.stdout.write(` [${(/* @__PURE__ */ new Date()).toTimeString().slice(0, 8)}] ${line}
877
+ `);
878
+ };
538
879
  const handle = await startSidecar({
539
880
  port: args.port,
540
881
  host: args.host,
541
882
  token,
542
- tools: [...nodeFsTools(args.root), ...args.allowShell ? [nodeShellTool(args.root)] : []],
883
+ tools: [...nodeFsTools(args.roots), ...args.allowShell ? [nodeShellTool(args.roots)] : []],
543
884
  allowedOrigins,
544
- tls
885
+ tls,
886
+ // Advertised in the handshake so the host can show the available working
887
+ // directories and let the user pick one (confined to the union of roots).
888
+ info: { root: args.roots[0], roots: args.roots, platform: process.platform },
889
+ log: slog,
890
+ allowLan: args.allowLan
545
891
  });
892
+ let tunnel = null;
893
+ let tunnelWss = null;
894
+ if (tunnelProvider) {
895
+ process.stdout.write(`starting ${tunnelProvider.id} tunnel${args.tunnelName ? ` (reserved name "${args.tunnelName}")` : ""}\u2026
896
+ `);
897
+ tunnel = await tunnelProvider.start({ port: handle.port, host: handle.host, name: args.tunnelName, log: slog });
898
+ tunnelWss = tunnel.url.replace(/^http/, "ws");
899
+ }
546
900
  const scheme = handle.secure ? "wss" : "ws";
547
901
  const exposed = handle.host !== "127.0.0.1" && handle.host !== "localhost";
548
902
  const connectHosts = handle.host === "0.0.0.0" ? lanIPv4s().length ? lanIPv4s() : ["127.0.0.1"] : [handle.host];
@@ -550,19 +904,29 @@ async function main() {
550
904
  process.stdout.write(
551
905
  [
552
906
  `corenel sidecar listening on ${scheme}://${handle.host}:${handle.port}`,
553
- ` root: ${args.root}`,
907
+ args.roots.length === 1 ? ` root: ${args.roots[0]}` : ` roots: ${args.roots.join("\n ")} (${args.roots.length} approved; pick the active one in the app)`,
554
908
  ` tools: fs_read/write/list${args.allowShell ? " + shell_exec" : " (shell off; pass --allow-shell to enable)"}`,
555
909
  ` 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)"}`,
910
+ 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(" ")}`,
911
+ ` 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
912
  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
913
  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
914
  !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,
561
915
  ""
562
916
  ].filter((l) => l !== null).join("\n")
563
917
  );
918
+ if (args.open) {
919
+ const loopback = `${handle.secure ? "wss" : "ws"}://127.0.0.1:${handle.port}`;
920
+ const target = `${args.appUrl.replace(/\/$/, "")}/studio#sidecar=${encodeURIComponent(loopback)}&token=${encodeURIComponent(token)}`;
921
+ process.stdout.write(` opening ${args.appUrl} (auto-pairing)\u2026
922
+ `);
923
+ openBrowser(target);
924
+ }
564
925
  const shutdown = () => {
565
- void handle.close().then(() => process.exit(0));
926
+ const closeServer = () => handle.close().then(() => process.exit(0));
927
+ if (tunnel) void tunnel.stop().catch(() => {
928
+ }).then(closeServer);
929
+ else void closeServer();
566
930
  };
567
931
  process.on("SIGINT", shutdown);
568
932
  process.on("SIGTERM", shutdown);
package/package.json CHANGED
@@ -1,41 +1,41 @@
1
- {
2
- "name": "@corenel/sidecar",
3
- "version": "0.1.0",
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.2",
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
+ }