@corenel/sidecar 0.11.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,11 @@ 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. |
31
37
  | `--env <name>` | _(none)_ | Layer `daemon.<name>.json` over `daemon.json` (same state dir), under flags. See [Config environments](#config-environments). |
32
38
 
33
39
  ## Daemon (preview)
@@ -144,7 +150,7 @@ Manual smoke (manual/client-commanded trigger):
144
150
  to use a state dir other than the default, or `--default-model <id>` to give
145
151
  agents with no `settings.model` a fallback.
146
152
 
147
- 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
148
154
  agent you created. Confirm the reply streams back in the app as it's generated,
149
155
  and that a new session appears under `<state dir>/crew/<name>/sessions/<runId>/`.
150
156
 
@@ -255,8 +261,8 @@ list and what that gap means.
255
261
  10s), a refusal — never fails the run; the ask reaches the same outcome
256
262
  it would have reached without push, only later by that bounded wait.
257
263
  - **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
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
260
266
  subscription is refused unless its endpoint is `https://` on the default
261
267
  port, carries no credentials, and its host is — or is a subdomain of —
262
268
  `fcm.googleapis.com` (Chrome/Chromium), `push.services.mozilla.com`
@@ -294,137 +300,107 @@ list and what that gap means.
294
300
  subscriptions. A browser re-sends its existing subscription on every
295
301
  pairing, so the id it is filed under follows an account switch.
296
302
 
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.
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).
400
373
  - **[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.
374
+ allowlist, so a page served from an origin missing there is refused.
404
375
  - **[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`.
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`.
423
399
 
424
400
  ## Security
425
401
 
426
- - Loopback by default; a browser connection requires both the pairing token **and** an
427
- 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.
428
404
  - The HTTP proxy (for local LLMs) only reaches **loopback** hosts — it can't be used as
429
405
  an SSRF pivot to internal services.
430
406
  - `sidecar_shell_exec` is off unless you pass `--allow-shell`.