@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 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 with
8
- a token the app reads once.
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 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).
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; keep the token secret. |
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 token, then command a manual run of the
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 both the pairing token **and** an
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`.