@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.
- package/README.md +270 -0
- package/dist/cli.js +12645 -2656
- 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
|