@corenel/sidecar 0.9.0 → 0.11.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.
Files changed (3) hide show
  1. package/README.md +270 -0
  2. package/dist/cli.js +12645 -2656
  3. package/package.json +7 -7
package/README.md CHANGED
@@ -28,6 +28,7 @@ match the site you're connecting from (the app fills it in for you).
28
28
  | `--allow-shell` | off | Enable the `sidecar_shell_exec` tool (RCE on your machine — opt-in). |
29
29
  | `--host <addr>` | `127.0.0.1` | Bind address. Non-loopback is reachable off-box; keep the token secret. |
30
30
  | `--tls-cert <f>` `--tls-key <f>` | _(none)_ | Serve `wss://` (needed for a remote sidecar from an https page). |
31
+ | `--env <name>` | _(none)_ | Layer `daemon.<name>.json` over `daemon.json` (same state dir), under flags. See [Config environments](#config-environments). |
31
32
 
32
33
  ## Daemon (preview)
33
34
 
@@ -151,6 +152,275 @@ Entitlement for daemon runs is enforced at the gateway, not locally — a 401/40
151
152
  the run means the login token from step 1 doesn't carry daemon access, not a bug in
152
153
  the sidecar itself.
153
154
 
155
+ ## Config environments
156
+
157
+ `daemon.json` (in the state dir) is the machine's own settings, written by
158
+ `corenel setup`/`config`/`roots`. `--env <name>` (or `CORENEL_ENV=<name>`)
159
+ layers a second file, `daemon.<name>.json`, on top of it, in the same state
160
+ dir: `defaults < daemon.json < daemon.<name>.json < flags`. Every key present
161
+ in the named file replaces the base value outright (an overlay `roots` array
162
+ replaces the base array, never merges into it); a flag still wins over both.
163
+
164
+ The first use is pointing a local checkout at a local API instead of the
165
+ production one, without hand-editing `daemon.json` back and forth:
166
+
167
+ ```bash
168
+ echo '{ "identityIssuer": "http://localhost:3010" }' > ~/.corenel/daemon.local.json
169
+ corenel --env local
170
+ ```
171
+
172
+ The name is restricted to `^[a-z0-9-]{1,32}$` (it becomes part of a filename) —
173
+ anything else is refused rather than resolved into a path. Requesting a named
174
+ environment that has no `daemon.<name>.json`, or one that is corrupt or
175
+ carries a credential, fails the command rather than silently falling back to
176
+ the base file: unlike a missing `daemon.json`, a named environment was asked
177
+ for by name. `corenel doctor` and `corenel config get`/`list` (and `corenel
178
+ roots list`/`corenel mcp list`) show the active environment and, per value,
179
+ whether it came from the base file, the overlay, or a flag.
180
+
181
+ **Writes.** An EXPLICIT `--env <name>` on `corenel config set|unset|add|remove`
182
+ redirects that one write to `daemon.<name>.json` instead of `daemon.json` —
183
+ this is how you create an environment file in the first place; a missing
184
+ overlay is created (`{ "version": 1 }` plus the change) rather than refused.
185
+ `CORENEL_ENV` alone does NOT redirect a write — a shell profile exporting it
186
+ must not silently turn every `config set` into an overlay edit — so a write
187
+ with only the env var set still lands on `daemon.json`, with one stderr note
188
+ saying so. Every OTHER write on this plane (`corenel roots add`, `corenel mcp
189
+ add`, `corenel setup`, and a folder added from the app) always lands on
190
+ `daemon.json`, never an overlay, regardless of `--env`/`CORENEL_ENV`.
191
+
192
+ ```bash
193
+ corenel config set --env local identityIssuer http://localhost:3010
194
+ corenel config add --env local allowOrigins https://127.0.0.1:5174
195
+ corenel --env local
196
+ ```
197
+
198
+ **Caveat:** if the active overlay defines its own `roots`, a root added at
199
+ runtime (`corenel roots add`, or a folder added from the app) still saves to
200
+ `daemon.json` as always, but the overlay's `roots` replaces the base array
201
+ wholesale on the next start under that same environment — so the just-added
202
+ root is shadowed until it is removed from `daemon.<name>.json`, or added
203
+ there directly. The add command's own output says so when this applies.
204
+
205
+ **Discovery.** `corenel config envs` lists every `daemon.*.json` file in the
206
+ state dir (never `daemon.json` itself — that is the base file, not an
207
+ environment): for each one whose name passes the same rule, whether it parses
208
+ and the keys it sets; a file whose name does not pass the rule is listed
209
+ separately as "ignored (invalid name)" rather than silently skipped. Marks
210
+ whichever one `--env`/`CORENEL_ENV` currently selects. Prints one note, and
211
+ nothing else, when none exist.
212
+
213
+ **In the app.** A connected daemon's active environment shows up next to its
214
+ name in the connection UI, e.g. `laptop (env: local)` — the welcome
215
+ handshake's `info.env`, present only when one is active.
216
+
217
+ ## Web push
218
+
219
+ On by default — no flag needed to advertise it. When a permission ask's
220
+ owner is not attached but has a subscribed device, the daemon pushes a
221
+ notification and holds the ask for a bounded wait (`pushWaitMs`, default
222
+ 90s) before falling through to the run's unattended policy, giving the
223
+ owner a chance to open the app and answer from wherever they are.
224
+
225
+ Pushes are sent only for runs started from a connected browser or device —
226
+ those asks carry the owning connection's `clientId`, which is what a
227
+ subscription is filed under. Scheduled and trigger-fired crew runs are
228
+ started by the daemon itself, have no owning connection, and **do not push
229
+ yet**; their asks go to whoever is attached, or straight to the unattended
230
+ policy.
231
+
232
+ Every link in that chain — VAPID signing, the subscribe handshake, the
233
+ persisted store, the push-and-wait timing, the service worker's
234
+ `push`/`notificationclick` handlers — is built and unit-tested. Real
235
+ delivery to a phone with the tab closed has not yet been observed; see
236
+ `docs/competitive-landscape.md`'s "Push, precisely" note for the test
237
+ list and what that gap means.
238
+
239
+ - **`--no-push`** turns the whole feature off (`daemon.json`: `push: false`).
240
+ With no subscriptions, push already does nothing — this flag matters for
241
+ the PHI/self-host story, where even the metadata below must not leave the
242
+ network.
243
+ - **Egress.** The push body is end-to-end encrypted (RFC 8291/8188) — Apple
244
+ or Google, whoever runs the browser's push service, cannot read it. But
245
+ they DO learn that a notification was sent, when, and to which
246
+ subscription. That is a real egress for a self-hosted or PHI deployment
247
+ and is why `--no-push` exists.
248
+ A daemon with no outbound internet cannot deliver, and the one
249
+ difference from a daemon without this feature is time: the send fails
250
+ silently (never throws into the run), but an owner with a subscription on
251
+ file still makes the ask wait (`pushWaitMs` under a deny/allow policy; a
252
+ `park` policy waits its own bound) before the run applies its unattended
253
+ policy — later than it otherwise would, never a hang.
254
+ A push failure of any kind — a throw, a timeout (each send is bounded at
255
+ 10s), a refusal — never fails the run; the ask reaches the same outcome
256
+ it would have reached without push, only later by that bounded wait.
257
+ - **Endpoints are restricted to known push services.** The daemon POSTs to
258
+ a subscription's endpoint, so an open-ended URL would let anyone holding a
259
+ pairing token make it send requests to any host it can reach. A
260
+ subscription is refused unless its endpoint is `https://` on the default
261
+ port, carries no credentials, and its host is — or is a subdomain of —
262
+ `fcm.googleapis.com` (Chrome/Chromium), `push.services.mozilla.com`
263
+ (Firefox), `push.apple.com` (Safari) or `notify.windows.com` (Edge/
264
+ Windows). The list is `WEB_PUSH_SERVICE_HOSTS` in `@corenel/protocol`;
265
+ stored subscriptions outside it are dropped (and logged) on load.
266
+ - **Where things live**, both under `<stateDir>/sidecar/`:
267
+ - `vapid.json` — the daemon's own VAPID keypair, generated on first use,
268
+ mode `0600`, written atomically. Deliberately NOT `auth.json`: signing
269
+ out deletes `auth.json`, and every browser subscription is bound to
270
+ this public key. **Deleting `vapid.json` orphans every subscribed
271
+ device** — each one is bound to the key that no longer exists, and
272
+ there is no re-linking; they have to click "Notify me on this device"
273
+ again once the daemon mints a new key. A new key is minted only when the
274
+ file does not exist. A file that is not a valid keypair is moved aside
275
+ to `vapid.json.corrupt-<timestamp>` with a loud log before a new key is
276
+ minted; any other read failure (permissions, a locked file) disables
277
+ push for that start, with a log line, rather than minting over a key
278
+ that may be fine.
279
+ - `push-subscriptions.json` — subscriptions, deduped by endpoint, capped
280
+ at 10 per client and 500 total, pruned automatically on a `404`/`410`
281
+ from the push service (the browser dropped the subscription) and after
282
+ 5 consecutive `401`/`403`s (the device subscribed to a key this daemon
283
+ no longer signs with). A corrupt file is moved aside
284
+ (`push-subscriptions.json.corrupt-<timestamp>`) and logged; an
285
+ unreadable one disables push for that start.
286
+ - A subscription is bound to one daemon's key; subscribing to a second
287
+ daemon replaces it — pairing with a new daemon and clicking "Notify me on
288
+ this device" moves the browser's one push registration to that daemon.
289
+ - The owner's OTHER devices only count as reachable when they share a
290
+ **verified** Clerk user id, never a claimed account alone — see
291
+ `docs/team-agents.md` §5.2. That id comes from the attendance registry,
292
+ which refuses to answer for a `clientId` seen verified as two different
293
+ users (a shared browser); such an owner reaches only its own
294
+ subscriptions. A browser re-sends its existing subscription on every
295
+ pairing, so the id it is filed under follows an account switch.
296
+
297
+ ## Identity
298
+
299
+ On by default. The daemon's issuer is **corenel-api**, not Clerk — the browser
300
+ never gives the daemon its Clerk session token at all (spec amendment
301
+ 2026-09-25b). Pairing runs challenge → API assertion → assert:
302
+
303
+ 1. On `hello.wantsIdentity`, the daemon sends `identity-challenge` naming its
304
+ own `sidecarId` and a fresh, single-use nonce.
305
+ 2. The browser exchanges its Clerk token for a short-lived RS256 assertion by
306
+ calling corenel-api's `POST /api/v1/pairing-assertions` — the Clerk token
307
+ goes there and nowhere else. The assertion's claims are `aud` (this
308
+ daemon's `sidecarId`) and `nonce` (the one just issued), which is what
309
+ closes replay: a captured assertion names one daemon and one nonce that
310
+ daemon has already consumed, so it verifies nowhere else and never again
311
+ **[V]** `src/prompd/sidecar/connect.identity.test.ts` ("answers the daemon
312
+ challenge by fetching the Clerk token, POSTing {aud, nonce, tkh} with it,
313
+ and sending the returned assertion -- never the Clerk token itself"), which
314
+ also asserts that the Clerk token never appears in any message this
315
+ connection ever sends, `hello` included **[V]** same test, and
316
+ `packages/harness/sidecar/host.identity.test.ts` ("omits wantsIdentity from
317
+ hello, and never sends identityToken, when not configured") confirms
318
+ `hello.identityToken` is gone from the wire.
319
+ 3. The browser sends `identity-assert` with that assertion. The daemon
320
+ verifies it against corenel-api's own OIDC discovery/JWKS, checking `aud`,
321
+ `nonce`, `azp`, `cnf.tkh` (below) and the existing signature/`iss`/`exp`
322
+ checks **[V]** `packages/sidecar/src/identity/oidcVerifier.test.ts`
323
+ (`'rejects: %s'` cases `'foreign app (azp)'`, `'missing azp'`, `'wrong
324
+ aud'`, `'missing aud'`, `'aud array without the audience'`, `'wrong
325
+ nonce'`, `'missing nonce'`, `'wrong cnf.tkh'`, `'missing cnf'`,
326
+ `'non-object cnf'`).
327
+
328
+ **Relay binding (`cnf.tkh`).** `aud` and the nonce stop a captured assertion
329
+ being replayed, but not a LIVE relay: a malicious daemon M that holds this
330
+ daemon's (B's) pairing token connects to B, forwards B's own challenge to a
331
+ victim browser paired with M, and relays the victim's fresh assertion back.
332
+ So step 2 also sends `tkh`, the unpadded base64url SHA-256 of the pairing
333
+ token the browser presented on ITS connection; corenel-api signs it in as
334
+ `cnf: { tkh }`, and the daemon requires it to equal the hash of the token
335
+ presented on the connection the assertion arrives on. The victim's browser
336
+ hashed M's token, not B's, so the relayed assertion pairs unverified
337
+ (`pairing not bound to this connection`) **[V]**
338
+ `packages/sidecar/src/identity/pairingRelay.e2e.test.ts` (two real SidecarServers, the real
339
+ verifier, a test-key API minting exactly what corenel-api mints: the relay
340
+ pairs UNVERIFIED and a direct pairing still verifies; removing the `cnf`
341
+ comparison turns it red), `oidcVerifier.test.ts` ("binds to the
342
+ pairing token"), `packages/harness/sidecar/identityHandshake.test.ts` ("the
343
+ verifier is handed the hash of the pairing token THIS connection presented,
344
+ not another"). The API only ever sees the hash, never the pairing token.
345
+ **[D] Residual, not stopped:** an attacker who hands the victim B's pairing
346
+ token under the attacker's OWN URL (so the victim's browser really presents
347
+ B's token, to M, which proxies it to B) gets an assertion bound to B's token.
348
+ The protocol cannot tell the browser that the daemon at a URL is not the one
349
+ that printed the token; pair a token only with the daemon that printed it.
350
+
351
+ The pairing token (`hello.token`) is checked FIRST; the challenge is never
352
+ sent before it passes **[V]** `packages/harness/sidecar/identityHandshake.test.ts`
353
+ ("never sends a challenge for a bad pairing token"). A missing, late, or
354
+ invalid assertion — or corenel-api being unreachable, or the signing key being
355
+ absent (corenel-api then answers 503 on discovery/assertion) — always pairs
356
+ the connection **unverified**, never refuses it **[V]**
357
+ `identityHandshake.test.ts` ("no assertion arrives within 3000ms: pairs
358
+ unverified, reason \"timed out\"", "a LATE identity-assert, arriving after the
359
+ 3000ms bound already answered \"timed out\", is ignored", "a throwing
360
+ verifier pairs unverified with reason \"error\""). The whole identity leg is
361
+ bounded at 3 000 ms from the challenge.
362
+
363
+ The verified Clerk user id (never the claimed `accounts` text, which stays
364
+ display-only) is the only thing that widens routing to a second device or
365
+ widens push delivery to a device that never subscribed itself — see
366
+ `docs/team-agents.md` §5.2 (at the repository root).
367
+
368
+ - **Default issuer:** `https://api.corenel.ai` **[V]** `packages/sidecar/src/args.test.ts`
369
+ ("identity verification is on by default against the API issuer"). Point at
370
+ a local corenel-api with `--identity-issuer <url>`, or the `daemon.json` key
371
+ `identityIssuer`.
372
+ - **https rule, with a loopback exception:** the issuer must be `https://`,
373
+ except a loopback host (`localhost`, `127.0.0.1`, `[::1]`), which may use
374
+ `http://` — for local development against a corenel-api with no TLS cert to
375
+ offer **[V]** `args.test.ts` ("rejects http:// on a non-loopback host",
376
+ "accepts %s (loopback issuer, for local development)" for
377
+ `http://localhost:4000`, `http://127.0.0.1:4000`, `http://[::1]:4000`); a
378
+ loopback-lookalike host (`http://localhost.evil.com`, etc.) is still
379
+ rejected **[V]** `args.test.ts` ("rejects the loopback lookalike %s").
380
+ - **`--no-identity`** turns verification off entirely (`daemon.json`:
381
+ `identity: false`); every connection then pairs unverified, exactly as
382
+ before this feature existed.
383
+ - **Keys over https:** the discovery document's `jwks_uri` must be `https://`
384
+ too — except under a loopback `http://` issuer, where an `http://`
385
+ `jwks_uri` on the issuer's own origin (same host and port) is accepted, so
386
+ a local corenel-api works end to end **[V]** `oidcVerifier.test.ts`
387
+ ("loopback http issuer" describe block: same-origin http verifies; another
388
+ host, another loopback host or another port does not; an https issuer with
389
+ an http `jwks_uri` still fails).
390
+ - **Egress:** one OIDC discovery fetch (`<issuer>/.well-known/openid-configuration`)
391
+ plus one JWKS fetch per hour to the configured issuer, cached together; an
392
+ unknown `kid` triggers at most one extra refetch per 60 seconds.
393
+ - The Clerk token never reaches the daemon at all, and the assertion itself is
394
+ never logged and never stored — only the resulting user id is kept, in
395
+ memory, for the life of the connection.
396
+ - **[D]** The browser's worst case for the whole exchange is roughly 6 s (up to
397
+ 3 s to fetch a fresh Clerk token, plus up to 3 s for the POST to
398
+ corenel-api) against the daemon's fixed 3 s bound from the challenge — a
399
+ slow answer pairs unverified, not refused.
400
+ - **[D]** corenel-api only stamps `azp` for request origins in its own CORS
401
+ allowlist. A page served from an origin missing there pairs unverified even
402
+ with a valid Clerk session; local https dev against a local corenel-api
403
+ needs that instance's `FRONTEND_URL` set to the dev origin.
404
+ - **[D]** Both web apps call `https://api.corenel.ai/api/v1/pairing-assertions`
405
+ directly (`VITE_PAIRING_ASSERTION_URL` in each app's `vite.config.ts`),
406
+ never through a front end's own `/api` proxy: the route's pre-auth limiter is
407
+ per client IP, and behind a proxy every user of that front end would share
408
+ one address and one bucket.
409
+ - The runbook for provisioning and rotating the signing key
410
+ (`corenel-pairing-signing-key`) lives in corenel-api's own
411
+ `docs/pairing-signing-key.md`, not in this repo.
412
+
413
+ **`corenel doctor` Identity section.** Computed from the EFFECTIVE config
414
+ (flags/file/overlay already layered), never a second guess: `identity:
415
+ disabled` when `--no-identity`/`identity: false`; otherwise the issuer URL and
416
+ whether it passes the https/loopback rule, a real discovery fetch (5 s
417
+ timeout, the SAME code the verifier uses), whether the document's `issuer`
418
+ matches, whether `jwks_uri` is accepted under the same https/loopback rule,
419
+ how many usable RS256 keys it lists (zero is a failure), the trusted origins
420
+ (canonical defaults + `--allow-origin`), and a warning when `--allow-lan` is
421
+ on that a LAN-allowed page can connect but can never verify identity unless
422
+ its origin is also in `allowOrigins`. Included in `--json`.
423
+
154
424
  ## Security
155
425
 
156
426
  - Loopback by default; a browser connection requires both the pairing token **and** an