@corenel/sidecar 0.1.1 → 0.1.3

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 +113 -36
  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
@@ -3,7 +3,8 @@ import { createRequire as __cr } from 'node:module'; const require = __cr(import
3
3
 
4
4
  // src/cli.ts
5
5
  import { resolve as resolve2 } from "node:path";
6
- import { readFileSync } from "node:fs";
6
+ import { readFileSync, statSync } from "node:fs";
7
+ import { spawn as spawn4 } from "node:child_process";
7
8
 
8
9
  // src/server.ts
9
10
  import { WebSocketServer } from "ws";
@@ -278,6 +279,7 @@ function startSidecar(opts) {
278
279
  wss2.on("connection", onConnection);
279
280
  return new Promise((resolveHandle, reject) => {
280
281
  httpsServer.on("error", reject);
282
+ wss2.on("error", reject);
281
283
  httpsServer.listen(opts.port, host, () => {
282
284
  const addr = httpsServer.address();
283
285
  const boundPort = typeof addr === "object" && addr ? addr.port : opts.port;
@@ -317,42 +319,47 @@ function withinRoot(root, abs) {
317
319
  const rel = relative(root, abs);
318
320
  return rel === "" || !rel.startsWith("..") && !isAbsolute(rel) && rel.split(sep)[0] !== "..";
319
321
  }
320
- function confine(root, p) {
321
- const abs = resolve(root, p);
322
- if (!withinRoot(root, abs)) throw new Error(`path escapes the sidecar root: ${p}`);
322
+ function activeRoot(roots, rootArg) {
323
+ const r = typeof rootArg === "string" ? rootArg : "";
324
+ return roots.includes(r) ? r : roots[0];
325
+ }
326
+ function confine(roots, rootArg, p) {
327
+ const abs = resolve(activeRoot(roots, rootArg), p);
328
+ if (!roots.some((root) => withinRoot(root, abs))) throw new Error(`path escapes the sidecar roots: ${p}`);
323
329
  return abs;
324
330
  }
325
- async function assertRealWithinRoot(root, abs) {
326
- const realRoot = await fs.realpath(root);
331
+ async function assertRealWithinRoot(roots, abs) {
332
+ const realRoots = await Promise.all(roots.map((r) => fs.realpath(r)));
327
333
  let probe = abs;
328
334
  for (; ; ) {
329
335
  try {
330
336
  const real = await fs.realpath(probe);
331
- if (!withinRoot(realRoot, real)) throw new Error(`path escapes the sidecar root via symlink: ${abs}`);
337
+ if (!realRoots.some((rr) => withinRoot(rr, real))) throw new Error(`path escapes the sidecar roots via symlink: ${abs}`);
332
338
  return;
333
339
  } catch (e) {
334
340
  if (e instanceof Error && e.message.includes("via symlink")) throw e;
335
341
  const parent = dirname(probe);
336
- if (parent === probe) throw new Error(`cannot resolve path under the sidecar root: ${abs}`);
342
+ if (parent === probe) throw new Error(`cannot resolve path under the sidecar roots: ${abs}`);
337
343
  probe = parent;
338
344
  }
339
345
  }
340
346
  }
347
+ var ROOT_PARAM = { type: "string", description: "which pre-approved root to resolve the path against (its absolute path; defaults to the primary root)" };
341
348
  var MAX_READ_BYTES = 16 * 1024 * 1024;
342
- function nodeFsTools(root) {
349
+ function nodeFsTools(roots) {
343
350
  return [
344
351
  {
345
352
  name: "fs_read_file",
346
- description: "Read a UTF-8 text file from the sidecar machine, relative to its root.",
353
+ description: "Read a UTF-8 text file from the sidecar machine, relative to a pre-approved root.",
347
354
  parameters: {
348
355
  type: "object",
349
- properties: { path: { type: "string", description: "path relative to the sidecar root" } },
356
+ properties: { path: { type: "string", description: "path relative to the chosen root" }, root: ROOT_PARAM },
350
357
  required: ["path"]
351
358
  },
352
359
  namespace: "fs",
353
360
  async run(args) {
354
- const abs = confine(root, String(args.path ?? ""));
355
- await assertRealWithinRoot(root, abs);
361
+ const abs = confine(roots, args.root, String(args.path ?? ""));
362
+ await assertRealWithinRoot(roots, abs);
356
363
  const st = await fs.stat(abs);
357
364
  if (st.size > MAX_READ_BYTES) {
358
365
  throw new Error(`file too large to read (${st.size} bytes; max ${MAX_READ_BYTES})`);
@@ -362,36 +369,56 @@ function nodeFsTools(root) {
362
369
  },
363
370
  {
364
371
  name: "fs_write_file",
365
- description: "Write a UTF-8 text file on the sidecar machine, relative to its root.",
372
+ description: "Write a UTF-8 text file on the sidecar machine, relative to a pre-approved root.",
366
373
  parameters: {
367
374
  type: "object",
368
375
  properties: {
369
- path: { type: "string", description: "path relative to the sidecar root" },
370
- content: { type: "string" }
376
+ path: { type: "string", description: "path relative to the chosen root" },
377
+ content: { type: "string" },
378
+ root: ROOT_PARAM
371
379
  },
372
380
  required: ["path", "content"]
373
381
  },
374
382
  namespace: "fs",
375
383
  mutates: true,
376
384
  async run(args) {
377
- const abs = confine(root, String(args.path ?? ""));
385
+ const abs = confine(roots, args.root, String(args.path ?? ""));
378
386
  await fs.mkdir(dirname(abs), { recursive: true });
379
- await assertRealWithinRoot(root, abs);
387
+ await assertRealWithinRoot(roots, abs);
380
388
  await fs.writeFile(abs, String(args.content ?? ""), "utf8");
381
389
  return `wrote ${args.path}`;
382
390
  }
383
391
  },
392
+ {
393
+ name: "fs_read_bytes",
394
+ 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
+ parameters: {
396
+ type: "object",
397
+ properties: { path: { type: "string", description: "path relative to the chosen root" }, root: ROOT_PARAM },
398
+ required: ["path"]
399
+ },
400
+ namespace: "fs",
401
+ async run(args) {
402
+ const abs = confine(roots, args.root, String(args.path ?? ""));
403
+ await assertRealWithinRoot(roots, abs);
404
+ const st = await fs.stat(abs);
405
+ if (st.size > MAX_READ_BYTES) {
406
+ throw new Error(`file too large to read (${st.size} bytes; max ${MAX_READ_BYTES})`);
407
+ }
408
+ return (await fs.readFile(abs)).toString("base64");
409
+ }
410
+ },
384
411
  {
385
412
  name: "fs_list_dir",
386
- description: "List the entries of a directory on the sidecar machine, relative to its root.",
413
+ description: "List the entries of a directory on the sidecar machine, relative to a pre-approved root.",
387
414
  parameters: {
388
415
  type: "object",
389
- properties: { path: { type: "string", description: 'directory relative to the sidecar root (default ".")' } }
416
+ properties: { path: { type: "string", description: 'directory relative to the chosen root (default ".")' }, root: ROOT_PARAM }
390
417
  },
391
418
  namespace: "fs",
392
419
  async run(args) {
393
- const abs = confine(root, String(args.path ?? "."));
394
- await assertRealWithinRoot(root, abs);
420
+ const abs = confine(roots, args.root, String(args.path ?? "."));
421
+ await assertRealWithinRoot(roots, abs);
395
422
  const entries = await fs.readdir(abs, { withFileTypes: true });
396
423
  return entries.map((e) => e.isDirectory() ? `${e.name}/` : e.name).join("\n");
397
424
  }
@@ -401,15 +428,16 @@ function nodeFsTools(root) {
401
428
  var DEFAULT_TIMEOUT_MS = 6e4;
402
429
  var MAX_TIMEOUT_MS = 3e5;
403
430
  var MAX_OUTPUT = 1e5;
404
- function nodeShellTool(root) {
431
+ function nodeShellTool(roots) {
405
432
  return {
406
433
  name: "shell_exec",
407
- 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.",
434
+ 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.",
408
435
  parameters: {
409
436
  type: "object",
410
437
  properties: {
411
438
  command: { type: "string", description: "the shell command line to run" },
412
- cwd: { type: "string", description: 'working directory relative to the sidecar root (default ".")' },
439
+ cwd: { type: "string", description: 'working directory relative to the chosen root (default ".")' },
440
+ root: ROOT_PARAM,
413
441
  timeout_ms: { type: "number", description: `max run time in ms (default ${DEFAULT_TIMEOUT_MS}, max ${MAX_TIMEOUT_MS})` }
414
442
  },
415
443
  required: ["command"]
@@ -420,8 +448,8 @@ function nodeShellTool(root) {
420
448
  async run(args) {
421
449
  const command = String(args.command ?? "").trim();
422
450
  if (!command) return "Error: no command given";
423
- const cwd = confine(root, String(args.cwd ?? "."));
424
- await assertRealWithinRoot(root, cwd);
451
+ const cwd = confine(roots, args.root, String(args.cwd ?? "."));
452
+ await assertRealWithinRoot(roots, cwd);
425
453
  const timeout = Math.min(Math.max(Number(args.timeout_ms) || DEFAULT_TIMEOUT_MS, 1), MAX_TIMEOUT_MS);
426
454
  return runShell(command, cwd, timeout);
427
455
  }
@@ -675,7 +703,7 @@ function extractTunnelUrl(s) {
675
703
  }
676
704
  var START_TIMEOUT_MS2 = 25e3;
677
705
  function tunnelArgs(port, host = "localhost") {
678
- return ["tunnel", "--no-autoupdate", "--url", `http://${host}:${port}`];
706
+ return ["tunnel", "--no-autoupdate", "--protocol", "http2", "--url", `http://${host}:${port}`];
679
707
  }
680
708
  var tail2 = (s, n = 6) => s.trim().split("\n").slice(-n).join("\n");
681
709
  function cloudflaredBins() {
@@ -780,12 +808,12 @@ registerTunnel(zrokProvider);
780
808
 
781
809
  // src/cli.ts
782
810
  function parseArgs(argv) {
783
- const out = { port: 4858, host: "127.0.0.1", root: process.cwd(), allowOrigins: [], allowShell: false, allowLan: false, selfSigned: false };
811
+ const out = { port: 4858, host: "127.0.0.1", roots: [], allowOrigins: [], allowShell: false, allowLan: false, selfSigned: false, open: false, appUrl: "https://prompd.app" };
784
812
  for (let i = 0; i < argv.length; i++) {
785
813
  const a = argv[i];
786
814
  if (a === "--port") out.port = Number(argv[++i]);
787
815
  else if (a === "--host") out.host = argv[++i];
788
- else if (a === "--root") out.root = resolve2(argv[++i]);
816
+ else if (a === "--root") out.roots.push(resolve2(argv[++i]));
789
817
  else if (a === "--allow-origin") out.allowOrigins.push(argv[++i]);
790
818
  else if (a === "--allow-shell") out.allowShell = true;
791
819
  else if (a === "--allow-lan") out.allowLan = true;
@@ -794,13 +822,53 @@ function parseArgs(argv) {
794
822
  else if (a === "--self-signed") out.selfSigned = true;
795
823
  else if (a === "--tunnel") out.tunnel = argv[++i];
796
824
  else if (a === "--tunnel-name") out.tunnelName = argv[++i];
825
+ else if (a === "--open") out.open = true;
826
+ else if (a === "--app-url") out.appUrl = argv[++i];
827
+ else if (a === "--sidecar" || a === "start") {
828
+ } else throw new Error(`unknown argument: ${a}
829
+ valid flags: ${KNOWN_FLAGS.join(" ")}`);
797
830
  }
798
831
  return out;
799
832
  }
833
+ var KNOWN_FLAGS = [
834
+ "--port",
835
+ "--host",
836
+ "--root",
837
+ "--allow-origin",
838
+ "--allow-shell",
839
+ "--allow-lan",
840
+ "--tls-cert",
841
+ "--tls-key",
842
+ "--self-signed",
843
+ "--tunnel",
844
+ "--tunnel-name",
845
+ "--open",
846
+ "--app-url"
847
+ ];
800
848
  var isIp2 = (s) => /^\d{1,3}(?:\.\d{1,3}){3}$/.test(s);
849
+ function openBrowser(url) {
850
+ const p = process.platform;
851
+ const cmd = p === "darwin" ? "open" : p === "win32" ? "cmd" : "xdg-open";
852
+ const args = p === "win32" ? ["/c", "start", "", url] : [url];
853
+ try {
854
+ spawn4(cmd, args, { stdio: "ignore", detached: true }).unref();
855
+ } catch {
856
+ }
857
+ }
801
858
  var DEFAULT_ALLOWED_ORIGINS = ["https://prompd.app", "https://www.prompd.app", "https://prmd.ai", "https://www.prmd.ai"];
802
859
  async function main() {
803
860
  const args = parseArgs(process.argv.slice(2));
861
+ if (args.roots.length === 0) args.roots = [process.cwd()];
862
+ args.roots = [...new Set(args.roots)];
863
+ for (const r of args.roots) {
864
+ let ok = false;
865
+ try {
866
+ ok = statSync(r).isDirectory();
867
+ } catch {
868
+ ok = false;
869
+ }
870
+ if (!ok) throw new Error(`--root is not a directory: ${r}`);
871
+ }
804
872
  const token = await loadOrCreateToken();
805
873
  if (args.tlsCert && !args.tlsKey || !args.tlsCert && args.tlsKey) {
806
874
  throw new Error("TLS needs both --tls-cert and --tls-key");
@@ -831,12 +899,12 @@ async function main() {
831
899
  port: args.port,
832
900
  host: args.host,
833
901
  token,
834
- tools: [...nodeFsTools(args.root), ...args.allowShell ? [nodeShellTool(args.root)] : []],
902
+ tools: [...nodeFsTools(args.roots), ...args.allowShell ? [nodeShellTool(args.roots)] : []],
835
903
  allowedOrigins,
836
904
  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 },
905
+ // Advertised in the handshake so the host can show the available working
906
+ // directories and let the user pick one (confined to the union of roots).
907
+ info: { root: args.roots[0], roots: args.roots, platform: process.platform },
840
908
  log: slog,
841
909
  allowLan: args.allowLan
842
910
  });
@@ -855,7 +923,7 @@ async function main() {
855
923
  process.stdout.write(
856
924
  [
857
925
  `corenel sidecar listening on ${scheme}://${handle.host}:${handle.port}`,
858
- ` root: ${args.root}`,
926
+ args.roots.length === 1 ? ` root: ${args.roots[0]}` : ` roots: ${args.roots.join("\n ")} (${args.roots.length} approved; pick the active one in the app)`,
859
927
  ` tools: fs_read/write/list${args.allowShell ? " + shell_exec" : " (shell off; pass --allow-shell to enable)"}`,
860
928
  ` token: ${token} (also at ${tokenPath()})`,
861
929
  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(" ")}`,
@@ -866,6 +934,13 @@ async function main() {
866
934
  ""
867
935
  ].filter((l) => l !== null).join("\n")
868
936
  );
937
+ if (args.open) {
938
+ const loopback = `${handle.secure ? "wss" : "ws"}://127.0.0.1:${handle.port}`;
939
+ const target = `${args.appUrl.replace(/\/$/, "")}/studio#sidecar=${encodeURIComponent(loopback)}&token=${encodeURIComponent(token)}`;
940
+ process.stdout.write(` opening ${args.appUrl} (auto-pairing)\u2026
941
+ `);
942
+ openBrowser(target);
943
+ }
869
944
  const shutdown = () => {
870
945
  const closeServer = () => handle.close().then(() => process.exit(0));
871
946
  if (tunnel) void tunnel.stop().catch(() => {
@@ -876,7 +951,9 @@ async function main() {
876
951
  process.on("SIGTERM", shutdown);
877
952
  }
878
953
  main().catch((e) => {
879
- process.stderr.write(`corenel-sidecar: ${e instanceof Error ? e.message : String(e)}
954
+ const code = e?.code;
955
+ const msg = code === "EADDRINUSE" ? `port already in use \u2014 another sidecar may be running. Stop it, or pass --port <n>.` : e instanceof Error ? e.message : String(e);
956
+ process.stderr.write(`corenel-sidecar: ${msg}
880
957
  `);
881
958
  process.exit(1);
882
959
  });
package/package.json CHANGED
@@ -1,41 +1,41 @@
1
- {
2
- "name": "@corenel/sidecar",
3
- "version": "0.1.1",
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.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
+ }