@corenel/sidecar 0.1.1 → 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 +91 -35
  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";
@@ -317,42 +318,47 @@ function withinRoot(root, abs) {
317
318
  const rel = relative(root, abs);
318
319
  return rel === "" || !rel.startsWith("..") && !isAbsolute(rel) && rel.split(sep)[0] !== "..";
319
320
  }
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}`);
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}`);
323
328
  return abs;
324
329
  }
325
- async function assertRealWithinRoot(root, abs) {
326
- const realRoot = await fs.realpath(root);
330
+ async function assertRealWithinRoot(roots, abs) {
331
+ const realRoots = await Promise.all(roots.map((r) => fs.realpath(r)));
327
332
  let probe = abs;
328
333
  for (; ; ) {
329
334
  try {
330
335
  const real = await fs.realpath(probe);
331
- 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}`);
332
337
  return;
333
338
  } catch (e) {
334
339
  if (e instanceof Error && e.message.includes("via symlink")) throw e;
335
340
  const parent = dirname(probe);
336
- 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}`);
337
342
  probe = parent;
338
343
  }
339
344
  }
340
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)" };
341
347
  var MAX_READ_BYTES = 16 * 1024 * 1024;
342
- function nodeFsTools(root) {
348
+ function nodeFsTools(roots) {
343
349
  return [
344
350
  {
345
351
  name: "fs_read_file",
346
- 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.",
347
353
  parameters: {
348
354
  type: "object",
349
- 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 },
350
356
  required: ["path"]
351
357
  },
352
358
  namespace: "fs",
353
359
  async run(args) {
354
- const abs = confine(root, String(args.path ?? ""));
355
- await assertRealWithinRoot(root, abs);
360
+ const abs = confine(roots, args.root, String(args.path ?? ""));
361
+ await assertRealWithinRoot(roots, abs);
356
362
  const st = await fs.stat(abs);
357
363
  if (st.size > MAX_READ_BYTES) {
358
364
  throw new Error(`file too large to read (${st.size} bytes; max ${MAX_READ_BYTES})`);
@@ -362,36 +368,56 @@ function nodeFsTools(root) {
362
368
  },
363
369
  {
364
370
  name: "fs_write_file",
365
- 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.",
366
372
  parameters: {
367
373
  type: "object",
368
374
  properties: {
369
- path: { type: "string", description: "path relative to the sidecar root" },
370
- content: { type: "string" }
375
+ path: { type: "string", description: "path relative to the chosen root" },
376
+ content: { type: "string" },
377
+ root: ROOT_PARAM
371
378
  },
372
379
  required: ["path", "content"]
373
380
  },
374
381
  namespace: "fs",
375
382
  mutates: true,
376
383
  async run(args) {
377
- const abs = confine(root, String(args.path ?? ""));
384
+ const abs = confine(roots, args.root, String(args.path ?? ""));
378
385
  await fs.mkdir(dirname(abs), { recursive: true });
379
- await assertRealWithinRoot(root, abs);
386
+ await assertRealWithinRoot(roots, abs);
380
387
  await fs.writeFile(abs, String(args.content ?? ""), "utf8");
381
388
  return `wrote ${args.path}`;
382
389
  }
383
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
+ },
384
410
  {
385
411
  name: "fs_list_dir",
386
- 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.",
387
413
  parameters: {
388
414
  type: "object",
389
- 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 }
390
416
  },
391
417
  namespace: "fs",
392
418
  async run(args) {
393
- const abs = confine(root, String(args.path ?? "."));
394
- await assertRealWithinRoot(root, abs);
419
+ const abs = confine(roots, args.root, String(args.path ?? "."));
420
+ await assertRealWithinRoot(roots, abs);
395
421
  const entries = await fs.readdir(abs, { withFileTypes: true });
396
422
  return entries.map((e) => e.isDirectory() ? `${e.name}/` : e.name).join("\n");
397
423
  }
@@ -401,15 +427,16 @@ function nodeFsTools(root) {
401
427
  var DEFAULT_TIMEOUT_MS = 6e4;
402
428
  var MAX_TIMEOUT_MS = 3e5;
403
429
  var MAX_OUTPUT = 1e5;
404
- function nodeShellTool(root) {
430
+ function nodeShellTool(roots) {
405
431
  return {
406
432
  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.",
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.",
408
434
  parameters: {
409
435
  type: "object",
410
436
  properties: {
411
437
  command: { type: "string", description: "the shell command line to run" },
412
- 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,
413
440
  timeout_ms: { type: "number", description: `max run time in ms (default ${DEFAULT_TIMEOUT_MS}, max ${MAX_TIMEOUT_MS})` }
414
441
  },
415
442
  required: ["command"]
@@ -420,8 +447,8 @@ function nodeShellTool(root) {
420
447
  async run(args) {
421
448
  const command = String(args.command ?? "").trim();
422
449
  if (!command) return "Error: no command given";
423
- const cwd = confine(root, String(args.cwd ?? "."));
424
- await assertRealWithinRoot(root, cwd);
450
+ const cwd = confine(roots, args.root, String(args.cwd ?? "."));
451
+ await assertRealWithinRoot(roots, cwd);
425
452
  const timeout = Math.min(Math.max(Number(args.timeout_ms) || DEFAULT_TIMEOUT_MS, 1), MAX_TIMEOUT_MS);
426
453
  return runShell(command, cwd, timeout);
427
454
  }
@@ -675,7 +702,7 @@ function extractTunnelUrl(s) {
675
702
  }
676
703
  var START_TIMEOUT_MS2 = 25e3;
677
704
  function tunnelArgs(port, host = "localhost") {
678
- return ["tunnel", "--no-autoupdate", "--url", `http://${host}:${port}`];
705
+ return ["tunnel", "--no-autoupdate", "--protocol", "http2", "--url", `http://${host}:${port}`];
679
706
  }
680
707
  var tail2 = (s, n = 6) => s.trim().split("\n").slice(-n).join("\n");
681
708
  function cloudflaredBins() {
@@ -780,12 +807,12 @@ registerTunnel(zrokProvider);
780
807
 
781
808
  // src/cli.ts
782
809
  function parseArgs(argv) {
783
- const out = { port: 4858, host: "127.0.0.1", root: process.cwd(), allowOrigins: [], allowShell: false, allowLan: 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" };
784
811
  for (let i = 0; i < argv.length; i++) {
785
812
  const a = argv[i];
786
813
  if (a === "--port") out.port = Number(argv[++i]);
787
814
  else if (a === "--host") out.host = argv[++i];
788
- else if (a === "--root") out.root = resolve2(argv[++i]);
815
+ else if (a === "--root") out.roots.push(resolve2(argv[++i]));
789
816
  else if (a === "--allow-origin") out.allowOrigins.push(argv[++i]);
790
817
  else if (a === "--allow-shell") out.allowShell = true;
791
818
  else if (a === "--allow-lan") out.allowLan = true;
@@ -794,13 +821,35 @@ function parseArgs(argv) {
794
821
  else if (a === "--self-signed") out.selfSigned = true;
795
822
  else if (a === "--tunnel") out.tunnel = argv[++i];
796
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];
797
826
  }
798
827
  return out;
799
828
  }
800
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
+ }
801
839
  var DEFAULT_ALLOWED_ORIGINS = ["https://prompd.app", "https://www.prompd.app", "https://prmd.ai", "https://www.prmd.ai"];
802
840
  async function main() {
803
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
+ }
804
853
  const token = await loadOrCreateToken();
805
854
  if (args.tlsCert && !args.tlsKey || !args.tlsCert && args.tlsKey) {
806
855
  throw new Error("TLS needs both --tls-cert and --tls-key");
@@ -831,12 +880,12 @@ async function main() {
831
880
  port: args.port,
832
881
  host: args.host,
833
882
  token,
834
- tools: [...nodeFsTools(args.root), ...args.allowShell ? [nodeShellTool(args.root)] : []],
883
+ tools: [...nodeFsTools(args.roots), ...args.allowShell ? [nodeShellTool(args.roots)] : []],
835
884
  allowedOrigins,
836
885
  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 },
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 },
840
889
  log: slog,
841
890
  allowLan: args.allowLan
842
891
  });
@@ -855,7 +904,7 @@ async function main() {
855
904
  process.stdout.write(
856
905
  [
857
906
  `corenel sidecar listening on ${scheme}://${handle.host}:${handle.port}`,
858
- ` 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)`,
859
908
  ` tools: fs_read/write/list${args.allowShell ? " + shell_exec" : " (shell off; pass --allow-shell to enable)"}`,
860
909
  ` token: ${token} (also at ${tokenPath()})`,
861
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(" ")}`,
@@ -866,6 +915,13 @@ async function main() {
866
915
  ""
867
916
  ].filter((l) => l !== null).join("\n")
868
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
+ }
869
925
  const shutdown = () => {
870
926
  const closeServer = () => handle.close().then(() => process.exit(0));
871
927
  if (tunnel) void tunnel.stop().catch(() => {
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.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
+ }