@corenel/sidecar 0.1.0
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.
- package/README.md +40 -0
- package/dist/cli.js +574 -0
- package/package.json +41 -0
package/README.md
ADDED
|
@@ -0,0 +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.
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { resolve as resolve2 } from "node:path";
|
|
5
|
+
import { readFileSync } from "node:fs";
|
|
6
|
+
|
|
7
|
+
// src/server.ts
|
|
8
|
+
import { WebSocketServer } from "ws";
|
|
9
|
+
import { createServer as createHttpsServer } from "node:https";
|
|
10
|
+
|
|
11
|
+
// ../protocol/sidecar.ts
|
|
12
|
+
var SIDECAR_PROTOCOL = 1;
|
|
13
|
+
function isLoopbackHost(host) {
|
|
14
|
+
const h = host.toLowerCase().replace(/^\[/, "").replace(/\]$/, "");
|
|
15
|
+
return h === "localhost" || h.endsWith(".localhost") || h === "::1" || /^127\./.test(h);
|
|
16
|
+
}
|
|
17
|
+
function errMessage(e) {
|
|
18
|
+
return e instanceof Error ? e.message : String(e);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ../harness/sidecar/server.ts
|
|
22
|
+
function bytesToB64(bytes) {
|
|
23
|
+
let bin = "";
|
|
24
|
+
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
|
|
25
|
+
return btoa(bin);
|
|
26
|
+
}
|
|
27
|
+
function safeEqual(a, b) {
|
|
28
|
+
if (a.length !== b.length) return false;
|
|
29
|
+
let diff = 0;
|
|
30
|
+
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
31
|
+
return diff === 0;
|
|
32
|
+
}
|
|
33
|
+
var SidecarServer = class {
|
|
34
|
+
ch;
|
|
35
|
+
opts;
|
|
36
|
+
tools = /* @__PURE__ */ new Map();
|
|
37
|
+
authed = false;
|
|
38
|
+
hostSeq = 0;
|
|
39
|
+
hostPending = /* @__PURE__ */ new Map();
|
|
40
|
+
constructor(opts) {
|
|
41
|
+
this.opts = opts;
|
|
42
|
+
this.ch = opts.channel;
|
|
43
|
+
for (const t of opts.tools) this.tools.set(t.name, t);
|
|
44
|
+
this.ch.onMessage((m) => this.onMessage(m));
|
|
45
|
+
}
|
|
46
|
+
onMessage(m) {
|
|
47
|
+
switch (m.t) {
|
|
48
|
+
case "hello":
|
|
49
|
+
this.onHello(m);
|
|
50
|
+
return;
|
|
51
|
+
case "invoke":
|
|
52
|
+
if (!this.authed) {
|
|
53
|
+
this.ch.send({ t: "invoke-result", id: m.id, ok: false, error: "not authenticated" });
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
void this.onInvoke(m);
|
|
57
|
+
return;
|
|
58
|
+
case "host-call-result": {
|
|
59
|
+
const p = this.hostPending.get(m.id);
|
|
60
|
+
if (!p) return;
|
|
61
|
+
this.hostPending.delete(m.id);
|
|
62
|
+
if (m.ok) p.resolve(m.result);
|
|
63
|
+
else p.reject(new Error(m.error || "host call failed"));
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
case "http-req":
|
|
67
|
+
if (!this.authed) {
|
|
68
|
+
this.ch.send({ t: "http-err", id: m.id, error: "not authenticated" });
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
void this.onHttpReq(m);
|
|
72
|
+
return;
|
|
73
|
+
default:
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
onHello(m) {
|
|
78
|
+
if (m.protocol !== SIDECAR_PROTOCOL) {
|
|
79
|
+
this.ch.send({ t: "denied", reason: `protocol ${m.protocol} unsupported` });
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (!safeEqual(m.token ?? "", this.opts.token)) {
|
|
83
|
+
this.ch.send({ t: "denied", reason: "invalid token" });
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
this.authed = true;
|
|
87
|
+
const tools = [...this.tools.values()].map(({ run: _run, ...decl }) => decl);
|
|
88
|
+
this.ch.send({ t: "welcome", tools, features: ["http-proxy"] });
|
|
89
|
+
}
|
|
90
|
+
/** Proxy a host HTTP request server-side. Restricted to LOOPBACK targets — the
|
|
91
|
+
* sidecar must never be usable as an SSRF pivot to internal/metadata hosts. The
|
|
92
|
+
* response streams back (http-res -> http-chunk* -> http-end). */
|
|
93
|
+
async onHttpReq(m) {
|
|
94
|
+
try {
|
|
95
|
+
let u;
|
|
96
|
+
try {
|
|
97
|
+
u = new URL(m.url);
|
|
98
|
+
} catch {
|
|
99
|
+
throw new Error(`invalid URL: ${m.url}`);
|
|
100
|
+
}
|
|
101
|
+
if (u.protocol !== "http:" && u.protocol !== "https:") throw new Error("http(s) URLs only");
|
|
102
|
+
if (!isLoopbackHost(u.hostname)) throw new Error(`the sidecar proxy only reaches loopback hosts, not "${u.hostname}"`);
|
|
103
|
+
const res = await fetch(m.url, { method: m.method, headers: m.headers, body: m.body });
|
|
104
|
+
const headers = {};
|
|
105
|
+
res.headers.forEach((v, k) => {
|
|
106
|
+
headers[k] = v;
|
|
107
|
+
});
|
|
108
|
+
this.ch.send({ t: "http-res", id: m.id, status: res.status, statusText: res.statusText, headers });
|
|
109
|
+
if (res.body) {
|
|
110
|
+
const reader = res.body.getReader();
|
|
111
|
+
for (; ; ) {
|
|
112
|
+
const { done, value } = await reader.read();
|
|
113
|
+
if (done) break;
|
|
114
|
+
if (value && value.length) this.ch.send({ t: "http-chunk", id: m.id, b64: bytesToB64(value) });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
this.ch.send({ t: "http-end", id: m.id });
|
|
118
|
+
} catch (e) {
|
|
119
|
+
this.ch.send({ t: "http-err", id: m.id, error: errMessage(e) });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
async onInvoke(m) {
|
|
123
|
+
const tool = this.tools.get(m.name);
|
|
124
|
+
if (!tool) {
|
|
125
|
+
this.ch.send({ t: "invoke-result", id: m.id, ok: false, error: `unknown tool ${m.name}` });
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
try {
|
|
129
|
+
const result = await tool.run(m.args, this.makeContext());
|
|
130
|
+
this.ch.send({ t: "invoke-result", id: m.id, ok: true, result });
|
|
131
|
+
} catch (e) {
|
|
132
|
+
this.ch.send({ t: "invoke-result", id: m.id, ok: false, error: errMessage(e) });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/** A ToolContext whose capabilities proxy back to the host (persist-nothing).
|
|
136
|
+
* The stores, the host's file workspace, and the user prompts (confirm/ask) all
|
|
137
|
+
* round-trip over the channel. confirm/ask default to allow/dismiss when the
|
|
138
|
+
* host wired no handler — the host already gated the tool call before dispatch,
|
|
139
|
+
* so an unwired secondary prompt must not deadlock the tool. */
|
|
140
|
+
makeContext() {
|
|
141
|
+
return {
|
|
142
|
+
signal: new AbortController().signal,
|
|
143
|
+
memory: this.memory,
|
|
144
|
+
kv: this.kv,
|
|
145
|
+
search: this.search,
|
|
146
|
+
sessions: this.sessions,
|
|
147
|
+
files: this.files,
|
|
148
|
+
confirm: this.confirm,
|
|
149
|
+
ask: this.ask
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
hostCall(provider, method, args) {
|
|
153
|
+
const id = ++this.hostSeq;
|
|
154
|
+
return new Promise((resolve3, reject) => {
|
|
155
|
+
this.hostPending.set(id, { resolve: resolve3, reject });
|
|
156
|
+
this.ch.send({ t: "host-call", id, provider, method, args });
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
memory = {
|
|
160
|
+
save: (text, tags) => this.hostCall("memory", "save", [text, tags]),
|
|
161
|
+
recall: (query, limit) => this.hostCall("memory", "recall", [query, limit]),
|
|
162
|
+
list: (limit) => this.hostCall("memory", "list", [limit]),
|
|
163
|
+
forget: (id) => this.hostCall("memory", "forget", [id])
|
|
164
|
+
};
|
|
165
|
+
kv = {
|
|
166
|
+
get: (key) => this.hostCall("kv", "get", [key]),
|
|
167
|
+
set: (key, value) => this.hostCall("kv", "set", [key, value]),
|
|
168
|
+
delete: (key) => this.hostCall("kv", "delete", [key]),
|
|
169
|
+
keys: (prefix) => this.hostCall("kv", "keys", [prefix])
|
|
170
|
+
};
|
|
171
|
+
search = {
|
|
172
|
+
searchPackages: (query) => this.hostCall("search", "searchPackages", [query])
|
|
173
|
+
};
|
|
174
|
+
sessions = {
|
|
175
|
+
create: (title, surface) => this.hostCall("sessions", "create", [title, surface]),
|
|
176
|
+
get: (id) => this.hostCall("sessions", "get", [id]),
|
|
177
|
+
list: (surface) => this.hostCall("sessions", "list", [surface]),
|
|
178
|
+
save: (session) => this.hostCall("sessions", "save", [session]),
|
|
179
|
+
rename: (id, title) => this.hostCall("sessions", "rename", [id, title]),
|
|
180
|
+
delete: (id) => this.hostCall("sessions", "delete", [id])
|
|
181
|
+
};
|
|
182
|
+
/** The host's file workspace, proxied. Metadata is a default — the methods are
|
|
183
|
+
* what a sidecar tool uses; onChange is a no-op (the host UI owns refresh). */
|
|
184
|
+
files = {
|
|
185
|
+
kind: "directory",
|
|
186
|
+
label: "host workspace",
|
|
187
|
+
isLocalDisk: false,
|
|
188
|
+
writable: true,
|
|
189
|
+
list: (dir) => this.hostCall("files", "list", [dir]),
|
|
190
|
+
read: (path) => this.hostCall("files", "read", [path]),
|
|
191
|
+
stat: (path) => this.hostCall("files", "stat", [path]),
|
|
192
|
+
write: (path, content) => this.hostCall("files", "write", [path, content]),
|
|
193
|
+
create: (path, content) => this.hostCall("files", "create", [path, content]),
|
|
194
|
+
remove: (path) => this.hostCall("files", "remove", [path]),
|
|
195
|
+
rename: (from, to) => this.hostCall("files", "rename", [from, to]),
|
|
196
|
+
search: (query) => this.hostCall("files", "search", [query]),
|
|
197
|
+
onChange: () => () => {
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
confirm = (req) => this.hostCall("confirm", "confirm", [req]).then((v) => v, () => true);
|
|
201
|
+
ask = (questions) => this.hostCall("ask", "ask", [questions]).then((v) => v, () => null);
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
// src/wsChannel.ts
|
|
205
|
+
function wsChannel(ws) {
|
|
206
|
+
let handler = null;
|
|
207
|
+
ws.on("message", (data) => {
|
|
208
|
+
if (!handler) return;
|
|
209
|
+
try {
|
|
210
|
+
const text = typeof data === "string" ? data : String(data);
|
|
211
|
+
handler(JSON.parse(text));
|
|
212
|
+
} catch {
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
return {
|
|
216
|
+
send(msg) {
|
|
217
|
+
if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(msg));
|
|
218
|
+
},
|
|
219
|
+
onMessage(h) {
|
|
220
|
+
handler = h;
|
|
221
|
+
},
|
|
222
|
+
close() {
|
|
223
|
+
try {
|
|
224
|
+
ws.close();
|
|
225
|
+
} catch {
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// src/server.ts
|
|
232
|
+
function startSidecar(opts) {
|
|
233
|
+
const host = opts.host ?? "127.0.0.1";
|
|
234
|
+
const allowed = new Set(opts.allowedOrigins ?? []);
|
|
235
|
+
const verifyClient = ({ origin }) => {
|
|
236
|
+
if (!origin) return true;
|
|
237
|
+
return allowed.has(origin);
|
|
238
|
+
};
|
|
239
|
+
const onConnection = (ws, _req) => {
|
|
240
|
+
new SidecarServer({ channel: wsChannel(ws), token: opts.token, tools: opts.tools });
|
|
241
|
+
};
|
|
242
|
+
if (opts.tls) {
|
|
243
|
+
const httpsServer = createHttpsServer({ cert: opts.tls.cert, key: opts.tls.key });
|
|
244
|
+
const wss2 = new WebSocketServer({ server: httpsServer, verifyClient });
|
|
245
|
+
wss2.on("connection", onConnection);
|
|
246
|
+
return new Promise((resolveHandle, reject) => {
|
|
247
|
+
httpsServer.on("error", reject);
|
|
248
|
+
httpsServer.listen(opts.port, host, () => {
|
|
249
|
+
const addr = httpsServer.address();
|
|
250
|
+
const boundPort = typeof addr === "object" && addr ? addr.port : opts.port;
|
|
251
|
+
opts.onListening?.({ host, port: boundPort, secure: true });
|
|
252
|
+
resolveHandle({
|
|
253
|
+
host,
|
|
254
|
+
port: boundPort,
|
|
255
|
+
secure: true,
|
|
256
|
+
close: () => new Promise((res) => wss2.close(() => httpsServer.close(() => res())))
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
const wss = new WebSocketServer({ host, port: opts.port, verifyClient });
|
|
262
|
+
wss.on("connection", onConnection);
|
|
263
|
+
return new Promise((resolveHandle, reject) => {
|
|
264
|
+
wss.on("error", reject);
|
|
265
|
+
wss.on("listening", () => {
|
|
266
|
+
const addr = wss.address();
|
|
267
|
+
const boundPort = typeof addr === "object" && addr ? addr.port : opts.port;
|
|
268
|
+
opts.onListening?.({ host, port: boundPort, secure: false });
|
|
269
|
+
resolveHandle({
|
|
270
|
+
host,
|
|
271
|
+
port: boundPort,
|
|
272
|
+
secure: false,
|
|
273
|
+
close: () => new Promise((res) => wss.close(() => res()))
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// src/tools.ts
|
|
280
|
+
import { promises as fs } from "node:fs";
|
|
281
|
+
import { spawn } from "node:child_process";
|
|
282
|
+
import { resolve, relative, isAbsolute, sep, dirname } from "node:path";
|
|
283
|
+
function withinRoot(root, abs) {
|
|
284
|
+
const rel = relative(root, abs);
|
|
285
|
+
return rel === "" || !rel.startsWith("..") && !isAbsolute(rel) && rel.split(sep)[0] !== "..";
|
|
286
|
+
}
|
|
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}`);
|
|
290
|
+
return abs;
|
|
291
|
+
}
|
|
292
|
+
async function assertRealWithinRoot(root, abs) {
|
|
293
|
+
const realRoot = await fs.realpath(root);
|
|
294
|
+
let probe = abs;
|
|
295
|
+
for (; ; ) {
|
|
296
|
+
try {
|
|
297
|
+
const real = await fs.realpath(probe);
|
|
298
|
+
if (!withinRoot(realRoot, real)) throw new Error(`path escapes the sidecar root via symlink: ${abs}`);
|
|
299
|
+
return;
|
|
300
|
+
} catch (e) {
|
|
301
|
+
if (e instanceof Error && e.message.includes("via symlink")) throw e;
|
|
302
|
+
const parent = dirname(probe);
|
|
303
|
+
if (parent === probe) throw new Error(`cannot resolve path under the sidecar root: ${abs}`);
|
|
304
|
+
probe = parent;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
var MAX_READ_BYTES = 16 * 1024 * 1024;
|
|
309
|
+
function nodeFsTools(root) {
|
|
310
|
+
return [
|
|
311
|
+
{
|
|
312
|
+
name: "fs_read_file",
|
|
313
|
+
description: "Read a UTF-8 text file from the sidecar machine, relative to its root.",
|
|
314
|
+
parameters: {
|
|
315
|
+
type: "object",
|
|
316
|
+
properties: { path: { type: "string", description: "path relative to the sidecar root" } },
|
|
317
|
+
required: ["path"]
|
|
318
|
+
},
|
|
319
|
+
namespace: "fs",
|
|
320
|
+
async run(args) {
|
|
321
|
+
const abs = confine(root, String(args.path ?? ""));
|
|
322
|
+
await assertRealWithinRoot(root, abs);
|
|
323
|
+
const st = await fs.stat(abs);
|
|
324
|
+
if (st.size > MAX_READ_BYTES) {
|
|
325
|
+
throw new Error(`file too large to read (${st.size} bytes; max ${MAX_READ_BYTES})`);
|
|
326
|
+
}
|
|
327
|
+
return await fs.readFile(abs, "utf8");
|
|
328
|
+
}
|
|
329
|
+
},
|
|
330
|
+
{
|
|
331
|
+
name: "fs_write_file",
|
|
332
|
+
description: "Write a UTF-8 text file on the sidecar machine, relative to its root.",
|
|
333
|
+
parameters: {
|
|
334
|
+
type: "object",
|
|
335
|
+
properties: {
|
|
336
|
+
path: { type: "string", description: "path relative to the sidecar root" },
|
|
337
|
+
content: { type: "string" }
|
|
338
|
+
},
|
|
339
|
+
required: ["path", "content"]
|
|
340
|
+
},
|
|
341
|
+
namespace: "fs",
|
|
342
|
+
mutates: true,
|
|
343
|
+
async run(args) {
|
|
344
|
+
const abs = confine(root, String(args.path ?? ""));
|
|
345
|
+
await fs.mkdir(dirname(abs), { recursive: true });
|
|
346
|
+
await assertRealWithinRoot(root, abs);
|
|
347
|
+
await fs.writeFile(abs, String(args.content ?? ""), "utf8");
|
|
348
|
+
return `wrote ${args.path}`;
|
|
349
|
+
}
|
|
350
|
+
},
|
|
351
|
+
{
|
|
352
|
+
name: "fs_list_dir",
|
|
353
|
+
description: "List the entries of a directory on the sidecar machine, relative to its root.",
|
|
354
|
+
parameters: {
|
|
355
|
+
type: "object",
|
|
356
|
+
properties: { path: { type: "string", description: 'directory relative to the sidecar root (default ".")' } }
|
|
357
|
+
},
|
|
358
|
+
namespace: "fs",
|
|
359
|
+
async run(args) {
|
|
360
|
+
const abs = confine(root, String(args.path ?? "."));
|
|
361
|
+
await assertRealWithinRoot(root, abs);
|
|
362
|
+
const entries = await fs.readdir(abs, { withFileTypes: true });
|
|
363
|
+
return entries.map((e) => e.isDirectory() ? `${e.name}/` : e.name).join("\n");
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
];
|
|
367
|
+
}
|
|
368
|
+
var DEFAULT_TIMEOUT_MS = 6e4;
|
|
369
|
+
var MAX_TIMEOUT_MS = 3e5;
|
|
370
|
+
var MAX_OUTPUT = 1e5;
|
|
371
|
+
function nodeShellTool(root) {
|
|
372
|
+
return {
|
|
373
|
+
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.",
|
|
375
|
+
parameters: {
|
|
376
|
+
type: "object",
|
|
377
|
+
properties: {
|
|
378
|
+
command: { type: "string", description: "the shell command line to run" },
|
|
379
|
+
cwd: { type: "string", description: 'working directory relative to the sidecar root (default ".")' },
|
|
380
|
+
timeout_ms: { type: "number", description: `max run time in ms (default ${DEFAULT_TIMEOUT_MS}, max ${MAX_TIMEOUT_MS})` }
|
|
381
|
+
},
|
|
382
|
+
required: ["command"]
|
|
383
|
+
},
|
|
384
|
+
namespace: "shell",
|
|
385
|
+
mutates: true,
|
|
386
|
+
permission: "ask",
|
|
387
|
+
async run(args) {
|
|
388
|
+
const command = String(args.command ?? "").trim();
|
|
389
|
+
if (!command) return "Error: no command given";
|
|
390
|
+
const cwd = confine(root, String(args.cwd ?? "."));
|
|
391
|
+
await assertRealWithinRoot(root, cwd);
|
|
392
|
+
const timeout = Math.min(Math.max(Number(args.timeout_ms) || DEFAULT_TIMEOUT_MS, 1), MAX_TIMEOUT_MS);
|
|
393
|
+
return runShell(command, cwd, timeout);
|
|
394
|
+
}
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
function runShell(command, cwd, timeout) {
|
|
398
|
+
return new Promise((resolveOut) => {
|
|
399
|
+
const isWin = process.platform === "win32";
|
|
400
|
+
const [shell, shellArgs] = isWin ? ["cmd.exe", ["/d", "/s", "/c", command]] : ["/bin/sh", ["-c", command]];
|
|
401
|
+
const child = spawn(shell, shellArgs, { cwd, windowsHide: true });
|
|
402
|
+
let out = "";
|
|
403
|
+
let truncated = false;
|
|
404
|
+
let timedOut = false;
|
|
405
|
+
const append = (chunk) => {
|
|
406
|
+
if (truncated) return;
|
|
407
|
+
out += chunk.toString("utf8");
|
|
408
|
+
if (out.length > MAX_OUTPUT) {
|
|
409
|
+
out = out.slice(0, MAX_OUTPUT);
|
|
410
|
+
truncated = true;
|
|
411
|
+
}
|
|
412
|
+
};
|
|
413
|
+
child.stdout.on("data", append);
|
|
414
|
+
child.stderr.on("data", append);
|
|
415
|
+
const timer = setTimeout(() => {
|
|
416
|
+
timedOut = true;
|
|
417
|
+
child.kill("SIGKILL");
|
|
418
|
+
}, timeout);
|
|
419
|
+
child.on("error", (e) => {
|
|
420
|
+
clearTimeout(timer);
|
|
421
|
+
resolveOut(`Error: failed to start command: ${e instanceof Error ? e.message : String(e)}`);
|
|
422
|
+
});
|
|
423
|
+
child.on("close", (code, signal) => {
|
|
424
|
+
clearTimeout(timer);
|
|
425
|
+
const status = timedOut ? `timed out after ${timeout}ms (killed)` : `exit ${code ?? "null"}${signal ? ` (signal ${signal})` : ""}`;
|
|
426
|
+
const body = out.trim() || "(no output)";
|
|
427
|
+
resolveOut(`${status}
|
|
428
|
+
${truncated ? "[output truncated]\n" : ""}${body}`);
|
|
429
|
+
});
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// src/token.ts
|
|
434
|
+
import { promises as fs2 } from "node:fs";
|
|
435
|
+
import { randomBytes } from "node:crypto";
|
|
436
|
+
import { homedir } from "node:os";
|
|
437
|
+
import { join } from "node:path";
|
|
438
|
+
function corenelDir() {
|
|
439
|
+
return join(homedir(), ".corenel");
|
|
440
|
+
}
|
|
441
|
+
function tokenPath() {
|
|
442
|
+
return join(corenelDir(), "daemon.token");
|
|
443
|
+
}
|
|
444
|
+
async function loadOrCreateToken() {
|
|
445
|
+
const path = tokenPath();
|
|
446
|
+
try {
|
|
447
|
+
const existing = (await fs2.readFile(path, "utf8")).trim();
|
|
448
|
+
if (existing) return existing;
|
|
449
|
+
} catch {
|
|
450
|
+
}
|
|
451
|
+
await fs2.mkdir(corenelDir(), { recursive: true });
|
|
452
|
+
const token = randomBytes(32).toString("hex");
|
|
453
|
+
await fs2.writeFile(path, token + "\n", { mode: 384 });
|
|
454
|
+
try {
|
|
455
|
+
await fs2.chmod(path, 384);
|
|
456
|
+
} catch {
|
|
457
|
+
}
|
|
458
|
+
return token;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// src/cert.ts
|
|
462
|
+
import { promises as fs3 } from "node:fs";
|
|
463
|
+
import { networkInterfaces } from "node:os";
|
|
464
|
+
import { join as join2 } from "node:path";
|
|
465
|
+
import selfsigned from "selfsigned";
|
|
466
|
+
function lanIPv4s() {
|
|
467
|
+
const out = [];
|
|
468
|
+
for (const ifaces of Object.values(networkInterfaces())) {
|
|
469
|
+
for (const ni of ifaces ?? []) {
|
|
470
|
+
if (ni.family === "IPv4" && !ni.internal) out.push(ni.address);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
return [...new Set(out)];
|
|
474
|
+
}
|
|
475
|
+
var certPath = () => join2(corenelDir(), "sidecar.crt");
|
|
476
|
+
var keyPath = () => join2(corenelDir(), "sidecar.key");
|
|
477
|
+
var sansPath = () => join2(corenelDir(), "sidecar.sans.json");
|
|
478
|
+
function isIp(s) {
|
|
479
|
+
return /^\d{1,3}(?:\.\d{1,3}){3}$/.test(s) || s.includes(":");
|
|
480
|
+
}
|
|
481
|
+
async function loadOrCreateSelfSigned(extraHosts = []) {
|
|
482
|
+
const sans = [...new Set(["localhost", "127.0.0.1", "::1", ...lanIPv4s(), ...extraHosts].filter(Boolean))];
|
|
483
|
+
try {
|
|
484
|
+
const have = JSON.parse(await fs3.readFile(sansPath(), "utf8"));
|
|
485
|
+
if (Array.isArray(have) && sans.every((s) => have.includes(s))) {
|
|
486
|
+
const [cert, key] = await Promise.all([fs3.readFile(certPath(), "utf8"), fs3.readFile(keyPath(), "utf8")]);
|
|
487
|
+
return { cert, key, sans: have };
|
|
488
|
+
}
|
|
489
|
+
} catch {
|
|
490
|
+
}
|
|
491
|
+
const altNames = sans.map((h) => isIp(h) ? { type: 7, ip: h } : { type: 2, value: h });
|
|
492
|
+
const pems = selfsigned.generate([{ name: "commonName", value: sans[0] }], {
|
|
493
|
+
days: 3650,
|
|
494
|
+
keySize: 2048,
|
|
495
|
+
algorithm: "sha256",
|
|
496
|
+
extensions: [{ name: "subjectAltName", altNames }]
|
|
497
|
+
});
|
|
498
|
+
await fs3.mkdir(corenelDir(), { recursive: true });
|
|
499
|
+
await fs3.writeFile(certPath(), pems.cert, { mode: 384 });
|
|
500
|
+
await fs3.writeFile(keyPath(), pems.private, { mode: 384 });
|
|
501
|
+
await fs3.writeFile(sansPath(), JSON.stringify(sans));
|
|
502
|
+
return { cert: pems.cert, key: pems.private, sans };
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// src/cli.ts
|
|
506
|
+
function parseArgs(argv) {
|
|
507
|
+
const out = { port: 4858, host: "127.0.0.1", root: process.cwd(), allowOrigins: [], allowShell: false, selfSigned: false };
|
|
508
|
+
for (let i = 0; i < argv.length; i++) {
|
|
509
|
+
const a = argv[i];
|
|
510
|
+
if (a === "--port") out.port = Number(argv[++i]);
|
|
511
|
+
else if (a === "--host") out.host = argv[++i];
|
|
512
|
+
else if (a === "--root") out.root = resolve2(argv[++i]);
|
|
513
|
+
else if (a === "--allow-origin") out.allowOrigins.push(argv[++i]);
|
|
514
|
+
else if (a === "--allow-shell") out.allowShell = true;
|
|
515
|
+
else if (a === "--tls-cert") out.tlsCert = resolve2(argv[++i]);
|
|
516
|
+
else if (a === "--tls-key") out.tlsKey = resolve2(argv[++i]);
|
|
517
|
+
else if (a === "--self-signed") out.selfSigned = true;
|
|
518
|
+
}
|
|
519
|
+
return out;
|
|
520
|
+
}
|
|
521
|
+
var isIp2 = (s) => /^\d{1,3}(?:\.\d{1,3}){3}$/.test(s);
|
|
522
|
+
var DEFAULT_ALLOWED_ORIGINS = ["https://prompd.app", "https://www.prompd.app", "https://prmd.ai", "https://www.prmd.ai"];
|
|
523
|
+
async function main() {
|
|
524
|
+
const args = parseArgs(process.argv.slice(2));
|
|
525
|
+
const token = await loadOrCreateToken();
|
|
526
|
+
if (args.tlsCert && !args.tlsKey || !args.tlsCert && args.tlsKey) {
|
|
527
|
+
throw new Error("TLS needs both --tls-cert and --tls-key");
|
|
528
|
+
}
|
|
529
|
+
let tls = args.tlsCert && args.tlsKey ? { cert: readFileSync(args.tlsCert, "utf8"), key: readFileSync(args.tlsKey, "utf8") } : void 0;
|
|
530
|
+
let selfSigned = false;
|
|
531
|
+
if (!tls && args.selfSigned) {
|
|
532
|
+
const extra = isIp2(args.host) && args.host !== "127.0.0.1" ? [args.host] : [];
|
|
533
|
+
const ss = await loadOrCreateSelfSigned(extra);
|
|
534
|
+
tls = { cert: ss.cert, key: ss.key };
|
|
535
|
+
selfSigned = true;
|
|
536
|
+
}
|
|
537
|
+
const allowedOrigins = [.../* @__PURE__ */ new Set([...DEFAULT_ALLOWED_ORIGINS, ...args.allowOrigins])];
|
|
538
|
+
const handle = await startSidecar({
|
|
539
|
+
port: args.port,
|
|
540
|
+
host: args.host,
|
|
541
|
+
token,
|
|
542
|
+
tools: [...nodeFsTools(args.root), ...args.allowShell ? [nodeShellTool(args.root)] : []],
|
|
543
|
+
allowedOrigins,
|
|
544
|
+
tls
|
|
545
|
+
});
|
|
546
|
+
const scheme = handle.secure ? "wss" : "ws";
|
|
547
|
+
const exposed = handle.host !== "127.0.0.1" && handle.host !== "localhost";
|
|
548
|
+
const connectHosts = handle.host === "0.0.0.0" ? lanIPv4s().length ? lanIPv4s() : ["127.0.0.1"] : [handle.host];
|
|
549
|
+
const connectUrls = connectHosts.map((h) => `${scheme}://${h}:${handle.port}`);
|
|
550
|
+
process.stdout.write(
|
|
551
|
+
[
|
|
552
|
+
`corenel sidecar listening on ${scheme}://${handle.host}:${handle.port}`,
|
|
553
|
+
` root: ${args.root}`,
|
|
554
|
+
` tools: fs_read/write/list${args.allowShell ? " + shell_exec" : " (shell off; pass --allow-shell to enable)"}`,
|
|
555
|
+
` 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)"}`,
|
|
558
|
+
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
|
+
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
|
+
!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
|
+
""
|
|
562
|
+
].filter((l) => l !== null).join("\n")
|
|
563
|
+
);
|
|
564
|
+
const shutdown = () => {
|
|
565
|
+
void handle.close().then(() => process.exit(0));
|
|
566
|
+
};
|
|
567
|
+
process.on("SIGINT", shutdown);
|
|
568
|
+
process.on("SIGTERM", shutdown);
|
|
569
|
+
}
|
|
570
|
+
main().catch((e) => {
|
|
571
|
+
process.stderr.write(`corenel-sidecar: ${e instanceof Error ? e.message : String(e)}
|
|
572
|
+
`);
|
|
573
|
+
process.exit(1);
|
|
574
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +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
|
+
}
|