@corenel/sidecar 0.10.0 → 0.13.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 +254 -8
- package/dist/cli.js +17019 -4027
- package/dist/helm/corenel-sidecar/.helmignore +4 -0
- package/dist/helm/corenel-sidecar/Chart.yaml +11 -0
- package/dist/helm/corenel-sidecar/templates/NOTES.txt +14 -0
- package/dist/helm/corenel-sidecar/templates/_helpers.tpl +26 -0
- package/dist/helm/corenel-sidecar/templates/deployment.yaml +147 -0
- package/dist/helm/corenel-sidecar/templates/ingress.yaml +35 -0
- package/dist/helm/corenel-sidecar/templates/pvc.yaml +36 -0
- package/dist/helm/corenel-sidecar/templates/service.yaml +17 -0
- package/dist/helm/corenel-sidecar/values.yaml +81 -0
- package/package.json +7 -7
package/README.md
CHANGED
|
@@ -4,8 +4,9 @@ A small local capability server for [Prompd](https://prompd.app). Run it next to
|
|
|
4
4
|
web app and the browser can reach your machine — most usefully, your **local LLMs**
|
|
5
5
|
(Ollama, LM Studio, vLLM) **without CORS**, plus your filesystem and (opt-in) shell.
|
|
6
6
|
|
|
7
|
-
It runs only while the command is open, binds to loopback by default, and pairs
|
|
8
|
-
a
|
|
7
|
+
It runs only while the command is open, binds to loopback by default, and pairs each
|
|
8
|
+
device once with a single-use code; after that the device proves its own key, and the
|
|
9
|
+
person using it proves who they are (see [Identity](#identity-and-who-may-connect)).
|
|
9
10
|
|
|
10
11
|
## Use
|
|
11
12
|
|
|
@@ -15,8 +16,10 @@ In Prompd, open **Connect a sidecar** and follow the steps, or run it directly:
|
|
|
15
16
|
npx @corenel/sidecar --allow-origin https://prompd.app
|
|
16
17
|
```
|
|
17
18
|
|
|
18
|
-
It prints a pairing
|
|
19
|
-
match the site you're connecting from (the app fills it in
|
|
19
|
+
It prints a pairing code and the sidecar's fingerprint; paste them into the app. The
|
|
20
|
+
`--allow-origin` flag must match the site you're connecting from (the app fills it in
|
|
21
|
+
for you). Sign in to the app first: only the account the sidecar is signed in as
|
|
22
|
+
(`corenel-sidecar login`) may connect, or with `--mode team`, members of its org.
|
|
20
23
|
|
|
21
24
|
## Options
|
|
22
25
|
|
|
@@ -26,8 +29,12 @@ match the site you're connecting from (the app fills it in for you).
|
|
|
26
29
|
| `--port <n>` | `4858` | Port to listen on. |
|
|
27
30
|
| `--root <dir>` | cwd | Root the file tools are confined to. |
|
|
28
31
|
| `--allow-shell` | off | Enable the `sidecar_shell_exec` tool (RCE on your machine — opt-in). |
|
|
29
|
-
| `--host <addr>` | `127.0.0.1` | Bind address. Non-loopback is reachable off-box;
|
|
32
|
+
| `--host <addr>` | `127.0.0.1` | Bind address. Non-loopback is reachable off-box and needs TLS (below, `--allow-lan`, a tunnel, or `--behind-tls-proxy`); the daemon refuses to serve plain `ws://` off the machine. |
|
|
33
|
+
| `--behind-tls-proxy` | off | A proxy in front terminates TLS (e.g. a container ingress): allows a non-loopback bind without a certificate of its own. |
|
|
34
|
+
| `--mode team` `--org <org id>` | solo | Team mode: members of the Clerk organization may connect (`corenel-sidecar config set org <org id>`; the ID starts with `org_`, from the Clerk dashboard). Solo: only the account the daemon is signed in as. |
|
|
30
35
|
| `--tls-cert <f>` `--tls-key <f>` | _(none)_ | Serve `wss://` (needed for a remote sidecar from an https page). |
|
|
36
|
+
| `--name <text>` | generated | The name the app shows for this sidecar (daemon.json `name`). Without it the name is generated, `<hostname> · <first root folder>`, plus ` :<port>` off the default port, so several sidecars on one machine are told apart. The banner prints it. |
|
|
37
|
+
| `--env <name>` | _(none)_ | Layer `daemon.<name>.json` over `daemon.json` (same state dir), under flags. See [Config environments](#config-environments). |
|
|
31
38
|
|
|
32
39
|
## Daemon (preview)
|
|
33
40
|
|
|
@@ -143,7 +150,7 @@ Manual smoke (manual/client-commanded trigger):
|
|
|
143
150
|
to use a state dir other than the default, or `--default-model <id>` to give
|
|
144
151
|
agents with no `settings.model` a fallback.
|
|
145
152
|
|
|
146
|
-
4. **Pair from the app** with the printed
|
|
153
|
+
4. **Pair from the app** with the printed code, then command a manual run of the
|
|
147
154
|
agent you created. Confirm the reply streams back in the app as it's generated,
|
|
148
155
|
and that a new session appears under `<state dir>/crew/<name>/sessions/<runId>/`.
|
|
149
156
|
|
|
@@ -151,10 +158,249 @@ Entitlement for daemon runs is enforced at the gateway, not locally — a 401/40
|
|
|
151
158
|
the run means the login token from step 1 doesn't carry daemon access, not a bug in
|
|
152
159
|
the sidecar itself.
|
|
153
160
|
|
|
161
|
+
## Config environments
|
|
162
|
+
|
|
163
|
+
`daemon.json` (in the state dir) is the machine's own settings, written by
|
|
164
|
+
`corenel setup`/`config`/`roots`. `--env <name>` (or `CORENEL_ENV=<name>`)
|
|
165
|
+
layers a second file, `daemon.<name>.json`, on top of it, in the same state
|
|
166
|
+
dir: `defaults < daemon.json < daemon.<name>.json < flags`. Every key present
|
|
167
|
+
in the named file replaces the base value outright (an overlay `roots` array
|
|
168
|
+
replaces the base array, never merges into it); a flag still wins over both.
|
|
169
|
+
|
|
170
|
+
The first use is pointing a local checkout at a local API instead of the
|
|
171
|
+
production one, without hand-editing `daemon.json` back and forth:
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
echo '{ "identityIssuer": "http://localhost:3010" }' > ~/.corenel/daemon.local.json
|
|
175
|
+
corenel --env local
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
The name is restricted to `^[a-z0-9-]{1,32}$` (it becomes part of a filename) —
|
|
179
|
+
anything else is refused rather than resolved into a path. Requesting a named
|
|
180
|
+
environment that has no `daemon.<name>.json`, or one that is corrupt or
|
|
181
|
+
carries a credential, fails the command rather than silently falling back to
|
|
182
|
+
the base file: unlike a missing `daemon.json`, a named environment was asked
|
|
183
|
+
for by name. `corenel doctor` and `corenel config get`/`list` (and `corenel
|
|
184
|
+
roots list`/`corenel mcp list`) show the active environment and, per value,
|
|
185
|
+
whether it came from the base file, the overlay, or a flag.
|
|
186
|
+
|
|
187
|
+
**Writes.** An EXPLICIT `--env <name>` on `corenel config set|unset|add|remove`
|
|
188
|
+
redirects that one write to `daemon.<name>.json` instead of `daemon.json` —
|
|
189
|
+
this is how you create an environment file in the first place; a missing
|
|
190
|
+
overlay is created (`{ "version": 1 }` plus the change) rather than refused.
|
|
191
|
+
`CORENEL_ENV` alone does NOT redirect a write — a shell profile exporting it
|
|
192
|
+
must not silently turn every `config set` into an overlay edit — so a write
|
|
193
|
+
with only the env var set still lands on `daemon.json`, with one stderr note
|
|
194
|
+
saying so. Every OTHER write on this plane (`corenel roots add`, `corenel mcp
|
|
195
|
+
add`, `corenel setup`, and a folder added from the app) always lands on
|
|
196
|
+
`daemon.json`, never an overlay, regardless of `--env`/`CORENEL_ENV`.
|
|
197
|
+
|
|
198
|
+
```bash
|
|
199
|
+
corenel config set --env local identityIssuer http://localhost:3010
|
|
200
|
+
corenel config add --env local allowOrigins https://127.0.0.1:5174
|
|
201
|
+
corenel --env local
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
**Caveat:** if the active overlay defines its own `roots`, a root added at
|
|
205
|
+
runtime (`corenel roots add`, or a folder added from the app) still saves to
|
|
206
|
+
`daemon.json` as always, but the overlay's `roots` replaces the base array
|
|
207
|
+
wholesale on the next start under that same environment — so the just-added
|
|
208
|
+
root is shadowed until it is removed from `daemon.<name>.json`, or added
|
|
209
|
+
there directly. The add command's own output says so when this applies.
|
|
210
|
+
|
|
211
|
+
**Discovery.** `corenel config envs` lists every `daemon.*.json` file in the
|
|
212
|
+
state dir (never `daemon.json` itself — that is the base file, not an
|
|
213
|
+
environment): for each one whose name passes the same rule, whether it parses
|
|
214
|
+
and the keys it sets; a file whose name does not pass the rule is listed
|
|
215
|
+
separately as "ignored (invalid name)" rather than silently skipped. Marks
|
|
216
|
+
whichever one `--env`/`CORENEL_ENV` currently selects. Prints one note, and
|
|
217
|
+
nothing else, when none exist.
|
|
218
|
+
|
|
219
|
+
**In the app.** A connected daemon's active environment shows up next to its
|
|
220
|
+
name in the connection UI, e.g. `laptop (env: local)` — the welcome
|
|
221
|
+
handshake's `info.env`, present only when one is active.
|
|
222
|
+
|
|
223
|
+
## Web push
|
|
224
|
+
|
|
225
|
+
On by default — no flag needed to advertise it. When a permission ask's
|
|
226
|
+
owner is not attached but has a subscribed device, the daemon pushes a
|
|
227
|
+
notification and holds the ask for a bounded wait (`pushWaitMs`, default
|
|
228
|
+
90s) before falling through to the run's unattended policy, giving the
|
|
229
|
+
owner a chance to open the app and answer from wherever they are.
|
|
230
|
+
|
|
231
|
+
Pushes are sent only for runs started from a connected browser or device —
|
|
232
|
+
those asks carry the owning connection's `clientId`, which is what a
|
|
233
|
+
subscription is filed under. Scheduled and trigger-fired crew runs are
|
|
234
|
+
started by the daemon itself, have no owning connection, and **do not push
|
|
235
|
+
yet**; their asks go to whoever is attached, or straight to the unattended
|
|
236
|
+
policy.
|
|
237
|
+
|
|
238
|
+
Every link in that chain — VAPID signing, the subscribe handshake, the
|
|
239
|
+
persisted store, the push-and-wait timing, the service worker's
|
|
240
|
+
`push`/`notificationclick` handlers — is built and unit-tested. Real
|
|
241
|
+
delivery to a phone with the tab closed has not yet been observed; see
|
|
242
|
+
`docs/competitive-landscape.md`'s "Push, precisely" note for the test
|
|
243
|
+
list and what that gap means.
|
|
244
|
+
|
|
245
|
+
- **`--no-push`** turns the whole feature off (`daemon.json`: `push: false`).
|
|
246
|
+
With no subscriptions, push already does nothing — this flag matters for
|
|
247
|
+
the PHI/self-host story, where even the metadata below must not leave the
|
|
248
|
+
network.
|
|
249
|
+
- **Egress.** The push body is end-to-end encrypted (RFC 8291/8188) — Apple
|
|
250
|
+
or Google, whoever runs the browser's push service, cannot read it. But
|
|
251
|
+
they DO learn that a notification was sent, when, and to which
|
|
252
|
+
subscription. That is a real egress for a self-hosted or PHI deployment
|
|
253
|
+
and is why `--no-push` exists.
|
|
254
|
+
A daemon with no outbound internet cannot deliver, and the one
|
|
255
|
+
difference from a daemon without this feature is time: the send fails
|
|
256
|
+
silently (never throws into the run), but an owner with a subscription on
|
|
257
|
+
file still makes the ask wait (`pushWaitMs` under a deny/allow policy; a
|
|
258
|
+
`park` policy waits its own bound) before the run applies its unattended
|
|
259
|
+
policy — later than it otherwise would, never a hang.
|
|
260
|
+
A push failure of any kind — a throw, a timeout (each send is bounded at
|
|
261
|
+
10s), a refusal — never fails the run; the ask reaches the same outcome
|
|
262
|
+
it would have reached without push, only later by that bounded wait.
|
|
263
|
+
- **Endpoints are restricted to known push services.** The daemon POSTs to
|
|
264
|
+
a subscription's endpoint, so an open-ended URL would let any paired
|
|
265
|
+
device make it send requests to any host it can reach. A
|
|
266
|
+
subscription is refused unless its endpoint is `https://` on the default
|
|
267
|
+
port, carries no credentials, and its host is — or is a subdomain of —
|
|
268
|
+
`fcm.googleapis.com` (Chrome/Chromium), `push.services.mozilla.com`
|
|
269
|
+
(Firefox), `push.apple.com` (Safari) or `notify.windows.com` (Edge/
|
|
270
|
+
Windows). The list is `WEB_PUSH_SERVICE_HOSTS` in `@corenel/protocol`;
|
|
271
|
+
stored subscriptions outside it are dropped (and logged) on load.
|
|
272
|
+
- **Where things live**, both under `<stateDir>/sidecar/`:
|
|
273
|
+
- `vapid.json` — the daemon's own VAPID keypair, generated on first use,
|
|
274
|
+
mode `0600`, written atomically. Deliberately NOT `auth.json`: signing
|
|
275
|
+
out deletes `auth.json`, and every browser subscription is bound to
|
|
276
|
+
this public key. **Deleting `vapid.json` orphans every subscribed
|
|
277
|
+
device** — each one is bound to the key that no longer exists, and
|
|
278
|
+
there is no re-linking; they have to click "Notify me on this device"
|
|
279
|
+
again once the daemon mints a new key. A new key is minted only when the
|
|
280
|
+
file does not exist. A file that is not a valid keypair is moved aside
|
|
281
|
+
to `vapid.json.corrupt-<timestamp>` with a loud log before a new key is
|
|
282
|
+
minted; any other read failure (permissions, a locked file) disables
|
|
283
|
+
push for that start, with a log line, rather than minting over a key
|
|
284
|
+
that may be fine.
|
|
285
|
+
- `push-subscriptions.json` — subscriptions, deduped by endpoint, capped
|
|
286
|
+
at 10 per client and 500 total, pruned automatically on a `404`/`410`
|
|
287
|
+
from the push service (the browser dropped the subscription) and after
|
|
288
|
+
5 consecutive `401`/`403`s (the device subscribed to a key this daemon
|
|
289
|
+
no longer signs with). A corrupt file is moved aside
|
|
290
|
+
(`push-subscriptions.json.corrupt-<timestamp>`) and logged; an
|
|
291
|
+
unreadable one disables push for that start.
|
|
292
|
+
- A subscription is bound to one daemon's key; subscribing to a second
|
|
293
|
+
daemon replaces it — pairing with a new daemon and clicking "Notify me on
|
|
294
|
+
this device" moves the browser's one push registration to that daemon.
|
|
295
|
+
- The owner's OTHER devices only count as reachable when they share a
|
|
296
|
+
**verified** Clerk user id, never a claimed account alone — see
|
|
297
|
+
`docs/team-agents.md` §5.2. That id comes from the attendance registry,
|
|
298
|
+
which refuses to answer for a `clientId` seen verified as two different
|
|
299
|
+
users (a shared browser); such an owner reaches only its own
|
|
300
|
+
subscriptions. A browser re-sends its existing subscription on every
|
|
301
|
+
pairing, so the id it is filed under follows an account switch.
|
|
302
|
+
|
|
303
|
+
## Identity and who may connect
|
|
304
|
+
|
|
305
|
+
Pairing proves two things on every connection: the DEVICE (a key the daemon
|
|
306
|
+
recorded when the device enrolled with a single-use pairing code; see the
|
|
307
|
+
device enrollment spec) and the PERSON (a verified identity). Both are
|
|
308
|
+
required; there is no switch to turn identity off. A connection that cannot
|
|
309
|
+
prove both gets the same `denied: invalid credentials` as any other bad
|
|
310
|
+
credential, and nothing is served before it is authenticated.
|
|
311
|
+
|
|
312
|
+
**Who may connect** follows the existing `--mode team` switch:
|
|
313
|
+
|
|
314
|
+
- **Solo** (the default): only the account this daemon is signed in as
|
|
315
|
+
(`corenel-sidecar login`, or `CORENEL_TOKEN`). The daemon learns that
|
|
316
|
+
account's Clerk user id at startup from `GET <endpoint>/auth/me` with its
|
|
317
|
+
own credential, re-reads it every 15 minutes and whenever its sign-in
|
|
318
|
+
changes, and FAILS CLOSED: until it knows, no one may connect, and the
|
|
319
|
+
banner's `who may connect:` line says why. An `org` set in solo mode is
|
|
320
|
+
ignored, with a log line saying so.
|
|
321
|
+
- **Team** (`--mode team`): only members of ONE Clerk organization, named by
|
|
322
|
+
its ID (`org_...`, from the Clerk dashboard; never a slug, which can be
|
|
323
|
+
renamed or taken by another org) with `corenel-sidecar config set org <org id>`
|
|
324
|
+
(daemon.json `org`, or `--org <org id>`). Team mode without a valid org ID
|
|
325
|
+
refuses to start, naming that command. Membership is checked by corenel-api
|
|
326
|
+
on every connect against Clerk's membership list for the verified user (not
|
|
327
|
+
the session's active org), cached at most 60 s; the daemon admits an
|
|
328
|
+
assertion whose `org_id` claim is its own org ID. Any role counts. A device
|
|
329
|
+
belongs to the first member who connected on it. Every open connection is re-challenged every 15 minutes
|
|
330
|
+
and closed when the same user cannot re-assert membership, so a removed
|
|
331
|
+
member is out within 15 minutes. Over the wire a member may revoke only
|
|
332
|
+
their own devices (those recorded under their verified user), and may read
|
|
333
|
+
a run's output (reports, check output, files) or watch it only for runs
|
|
334
|
+
they started;
|
|
335
|
+
the operator's local `corenel-sidecar devices revoke` keeps full power.
|
|
336
|
+
|
|
337
|
+
**The exchange.** Once the device key is proven (and, for an enrollment,
|
|
338
|
+
before the code is burned), the daemon sends `identity-challenge` naming its
|
|
339
|
+
own `sidecarId`, a fresh single-use nonce and, in team mode, its org. The
|
|
340
|
+
client obtains a one-minute RS256 assertion from corenel-api's
|
|
341
|
+
`POST /api/v1/pairing-assertions` (`{ aud, nonce, jkt, org_id? }`): a browser
|
|
342
|
+
with its Clerk session token, the `corenel` CLI with its `cnl_cli_` login
|
|
343
|
+
token, which must carry the `pair` scope (granted only when the person
|
|
344
|
+
approving `corenel login` ticks "Also let this machine connect to your
|
|
345
|
+
sidecars"; without it, log in again). That token goes to corenel-api and nowhere else; it never crosses the
|
|
346
|
+
daemon socket. The client answers with `identity-assert`, and the daemon
|
|
347
|
+
verifies the assertion locally against corenel-api's OIDC discovery and JWKS:
|
|
348
|
+
signature, `iss`, `exp`, `aud` (this daemon), `nonce` (this challenge),
|
|
349
|
+
`azp` (an allowlisted origin, or `corenel-cli` for a CLI assertion) and
|
|
350
|
+
`cnf.jkt`, which must be the thumbprint of the device key THIS connection
|
|
351
|
+
proved. That binding stops a live relay: a daemon that forwards another
|
|
352
|
+
daemon's challenge to a browser paired with it gets an assertion bound to
|
|
353
|
+
that browser's key for IT, which the other daemon refuses; and a client only
|
|
354
|
+
answers a challenge from the daemon that proved the key it pinned.
|
|
355
|
+
The whole identity leg is bounded at 10 s from the challenge; a missing,
|
|
356
|
+
late, oversized or invalid assertion is a refusal.
|
|
357
|
+
|
|
358
|
+
**What the client can say.** The refusal is uniform on the wire, but the
|
|
359
|
+
client knows when it had no identity to offer and says so: not signed in,
|
|
360
|
+
not a member of the org, or the sign-in service could not vouch in time. The
|
|
361
|
+
device is kept in those cases; only a device the daemon refused is
|
|
362
|
+
forgotten.
|
|
363
|
+
|
|
364
|
+
- **Default issuer:** `https://api.corenel.ai`. Point at a local corenel-api
|
|
365
|
+
with `--identity-issuer <url>` (daemon.json `identityIssuer`). It must be
|
|
366
|
+
`https://`, except a loopback host, which may use `http://`, and its
|
|
367
|
+
`jwks_uri` follows the same rule (same origin under a loopback issuer).
|
|
368
|
+
- **Egress:** one discovery fetch plus one JWKS fetch per hour to the issuer,
|
|
369
|
+
cached together; an unknown `kid` refetches at most once a minute. In solo
|
|
370
|
+
mode, one `GET <endpoint>/auth/me` at startup and every 15 minutes.
|
|
371
|
+
- The assertion is never logged or stored; only the verified user id is kept,
|
|
372
|
+
in memory, for the life of the connection (and recorded on the device).
|
|
373
|
+
- **[D]** corenel-api only stamps `azp` for request origins in its own CORS
|
|
374
|
+
allowlist, so a page served from an origin missing there is refused.
|
|
375
|
+
- **[D]** Both web apps call `https://api.corenel.ai/api/v1/pairing-assertions`
|
|
376
|
+
directly (`VITE_PAIRING_ASSERTION_URL`), never through a front end's own
|
|
377
|
+
`/api` proxy, whose single egress address would share one rate-limit bucket.
|
|
378
|
+
- The runbook for the signing key (`corenel-pairing-signing-key`) lives in
|
|
379
|
+
corenel-api's `docs/pairing-signing-key.md`.
|
|
380
|
+
|
|
381
|
+
**Before a connection authenticates** the daemon keeps its cost bounded: a
|
|
382
|
+
daemon-wide limiter refuses an address after 10 failed hellos in a minute
|
|
383
|
+
(keyed by IPv4 address or IPv6 /64; off with `--behind-tls-proxy`, where
|
|
384
|
+
every peer is the proxy), lets one address hold at most 4 unauthenticated
|
|
385
|
+
connections, and holds at most 32 daemon-wide (HTTP 429 and 503 at the
|
|
386
|
+
upgrade); a plain HTTP request to the port gets
|
|
387
|
+
`426`; request headers must arrive within 10 s; a frame over 64 KiB before
|
|
388
|
+
the welcome closes the socket unparsed; and a hello that does not pair within
|
|
389
|
+
20 s is closed.
|
|
390
|
+
|
|
391
|
+
**`corenel doctor` Identity section.** Computed from the EFFECTIVE config: who
|
|
392
|
+
the daemon admits (solo, or the team org; team mode without an org is a
|
|
393
|
+
failure), the issuer URL and whether it passes the https/loopback rule, a
|
|
394
|
+
real discovery fetch (the same code the verifier uses), whether the
|
|
395
|
+
document's `issuer` matches, whether `jwks_uri` is accepted, how many usable
|
|
396
|
+
RS256 keys it lists (zero is a failure), the trusted origins, and a warning
|
|
397
|
+
when `--allow-lan` is on that a LAN-allowed page is refused unless its origin
|
|
398
|
+
is also in `allowOrigins`. Included in `--json`.
|
|
399
|
+
|
|
154
400
|
## Security
|
|
155
401
|
|
|
156
|
-
- Loopback by default; a browser connection requires
|
|
157
|
-
allowed Origin.
|
|
402
|
+
- Loopback by default; a browser connection requires a recorded device key, a verified
|
|
403
|
+
identity this daemon admits, **and** an allowed Origin.
|
|
158
404
|
- The HTTP proxy (for local LLMs) only reaches **loopback** hosts — it can't be used as
|
|
159
405
|
an SSRF pivot to internal services.
|
|
160
406
|
- `sidecar_shell_exec` is off unless you pass `--allow-shell`.
|