npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

openclaw-webchannel

v0.7.0

Published

Self-hosted web chat channel plugin for OpenClaw: E2E-encrypted browser chat over a NATS relay.

Readme

openclaw-webchannel

The OpenClaw web chat channel plugin — embed a chat widget on a web page and talk to an OpenClaw agent (Claude) from the browser.

The authoritative status lives in ../../docs/STATUS.md. AC completion reports and seed files elsewhere describe component-level work, not end-to-end functionality. Where they conflict with STATUS.md, STATUS.md is correct.

Transport

This package ships the NATS E2E plugin entry. Both browser and agent dial out to a shared NATS relay; the agent exposes no browser-facing inbound port and the relay sees encrypted envelopes only.

Protocol v2 places simultaneous hard bounds on all not-yet-running user work: 32 messages / 1 MiB charged bytes per opaque account+peer session and 1,024 messages / 32 MiB process-wide across accounts. Existing admitted work is preserved; only the newest overflow is rejected with a durable id-correlated inbound_rejected{reason:"overloaded"} result. Client and plugin v2 must be upgraded together. /stop holds killed pre-run entries through suppression + ACK delivery, and every ACK/rejection path is split at 64 ids, 64 KiB sealed wire size, and the NATS server's effective max_payload.

As of e384198, a real headless-Chromium message HAS travelled browser → NATS → this plugin → inbound.run → (echo model) → back. Earlier the NATS entry assumed APIs that don't exist (api.http.post, a webchannel-nats id, keepAlive) — fixed there. Register admission is now fully over NATS: a register-hop account subscribes its own webchannel.{tenant}.{accountId}.*.register subject and the browser drives challenge/register/ unregister via NATS request/reply on …{peerId}.register (the old inbound HTTP register routes are deleted — the agent makes ONLY outbound connections). The browser client is wired to register over this hop (registerWithPop), and the live harnesses exercise it end-to-end. One caveat remains: the deterministic echo model stands in for a live LLM (by design); a real browser/Playwright variant against a hosted SaaS issuer is follow-up #13. See STATUS.md.

Status

  • NATS entry (index-nats.ts) — production default, cut over live on the real gateway (:18789): enrolled via the SaaS device flow against a persistent local trust chain (nats-server + reference issuer), credentials cached in the exact (tenant, accountId) namespace under ~/.openclaw-webchannel-v2/ so restarts reconnect with no re-approval. See ../../e2e/local/README.md to reproduce browser↔agent locally.
  • Defer to ../../docs/STATUS.md for the current authoritative state.

Enrollment & credentials (NATS mode)

src/enrollment-client.ts implements plugin-side onboarding over the RFC 8628 device flow — ingress-free (outbound HTTPS only, no listening sockets, no secret pasting).

CLI flag mapping (channels add)

OpenClaw's channels add parses a fixed generic flag set, and a non-bundled plugin cannot register its own commander flags. So on the non-interactive onboarding command the identity rides mapped generic flags — note that the tenant id goes on --url, not --base-url:

openclaw channels add --channel webchannel \
  --account <accountId>     # the on-wire identity
  --base-url <saas-url>     # → saasBaseUrl (the SaaS issuer URL)
  --url     <tenant-uuid>   # → tenant   (yes, the TENANT id — flag name is a host-CLI limitation)

--url reading "Channel setup URL" in --help is the host CLI's generic text; there is no plugin-registered --tenant/--saas-base-url. afterAccountConfigWritten echoes the resolved accountId/tenant/saasBaseUrl before enrolling so a mis-mapping is visible immediately. The interactive channels add wizard prompts for tenant by name and avoids the flag entirely. (The legacy WEBCHANNEL_TENANT env is not an onboarding alternative — it is honored only at gateway-run time when no webchannel config exists, and is deprecated once config is present.)

import { EnrollmentClient } from "./src/enrollment-client.js";

const client = new EnrollmentClient({
  saasBaseUrl:  "https://saas.example",
  saasEnrollUrl: "https://saas.example/api/enroll",
  saasPollUrl:   "https://saas.example/api/poll",
  tenant: "tenant-123",
  accountId: "account-a",
});

const enrollment = await client.enroll();   // first boot: device flow; restart: load creds
const identityKey = client.getIdentityKey(); // X25519 KeyPair
const creds       = client.getNatsCredentials();
const peerId      = client.getPeerId();

src/enrolled-nats-connection.ts wraps enrollment + NATS connect in one call (createEnrolledNatsConnection(...)), returning { transport, identityKey, enrollment, tenant }.

Flow: first boot generates an X25519 identity key, POSTs to /enroll, surfaces the user_code + verification URI, polls /poll (RFC 8628 minimum 5s interval) until the operator approves, then receives and persists NATS user credentials. On restart it loads the stored credentials and skips enrollment only when their complete v2 identity matches the effective tenant, account, SaaS base, delivered issuer/relay, and local public key.

Credential storage:

  • Default location: ~/.openclaw-webchannel-v2/<v2_namespace>/credentials.json, where <v2_namespace> is the fixed path-safe id derived from the exact, case-sensitive (tenant, accountId).
  • Overrides: account config storageRoot must be an absolute filesystem path and changes the common base for both secret stores. Relative roots are rejected rather than resolved against the process working directory. The low-level credentialPath API remains one absolute exact credential file; relative overrides are rejected rather than resolved against the process working directory. When both are supplied, it does not relocate conversation keys. The offline rotation entry accepts the same exact value as --credential-path; use that option only when the deployment configured the low-level override. Its value is never printed.
  • Root changes: storageRoot is not a live v2-to-v2 migration switch. Stop the gateway before moving the complete opaque tuple directory with its 0700/0600 permissions, or re-enroll and let browsers re-register.
  • Permissions: written with mode 0o600 (owner read/write only)
  • Shape (PluginCredentials): identityKey { publicKey, privateKey } (base64url X25519), optional enrollment { creds, peerId, jwksUrl, bootstrapUrl, natsUrl, issuer }, one credentialIdentity (CredentialBindingIdentityV2) block, plus tenant, accountId, saasEnrollUrl, and saasPollUrl. The private key is generated locally and never transmitted. Legacy/unbound or mismatched files are never reused or overwritten automatically; stop the gateway, archive the exact file, complete any required SaaS active-key replacement, and then re-run account enrollment.

v1 → v2 cutover: stop every old plugin process that can access the same home before starting this version. On first access, a legacy bare-account directory is adopted only when its credential document independently proves the exact tenant/account labels and satisfies the complete credential-binding readiness checks. The complete legacy directory is atomically moved under ~/.openclaw-webchannel/.legacy-v1-backups/, both v2 destinations are written without overwrite and read-verified, and only then is migration marked complete. Keep that backup.

A legacy conversation-keys.json with no fully proven credential owner is renamed to an .ambiguous-v2-* archive and never adopted. That tuple starts with an empty v2 key store, so browsers must re-register and receive a fresh K. A claim conflict, failed archive, destination mismatch, incomplete credential binding, or incomplete migration fails closed; do not run old and new binaries concurrently to work around it. The offline rotation entry is deliberately more conservative: it preserves ownership-ambiguous legacy K and refuses the preview, so omitting or mistyping a configured exact --credential-path cannot silently turn the intended rotation target into an empty v2 store.

E2E security model (admission + key establishment)

P0-2 made the authenticated register hop the sole admission path; key establishment happens there:

  • Register admission (the only path) — register-delivered conversation key. The agent OWNS a stable per-peerId key K (src/conversation-key-store.ts, persisted at ~/.openclaw-webchannel-v2/<v2_namespace>/conversation-keys.json, 0600). The register handler (a NATS request/reply on the account's …{peerId}.register subject) wraps K (src/late-join-decryptor.ts — X25519 ECDH + HKDF-SHA256 webchannel-key-wrap-v1 + ChaCha20-Poly1305) to the device key attested in that request's verified JWT cnf claim and returns it in the register reply. There is no unauthenticated key-exchange subject on this path — the register handler DOES subscribe and answer .register (that IS the admission path), but K is never negotiated on the wire; it travels only inside the authenticated register reply — so an active relay cannot substitute keys: K only ever travels wrapped to a JWT-attested device key, and the wrap target comes from the SaaS-signed JWT cnf, not from anything the transport controls. So even though register now rides NATS (visible to the relay), the relay/observer sees only ciphertext + a wrapped key it cannot open, and cannot coax K to be wrapped to a key it holds. This resolves review finding C2 structurally for register deployments. One user's devices all receive the SAME K, so multi-device decryption works and a second device no longer overwrites the first one's key.
    • cnf claim verification (src/jwt.ts): after the bootstrap JWT's signature is verified, verifyJwt extracts the RFC 7800 cnf.jwk confirmation claim. The claim must be kty: "OKP", crv: "X25519", with a 32-byte x; a d (private) field, wrong length, or any malformed cnf causes the whole JWT to be rejected (fail-closed). The validated key surfaces as JwtIdentity.devicePublicKey; the register handler REQUIRES it (401 without) and wraps per-request. There is deliberately no cross-request pinned-key store — the old peerId-keyed pin store collided two devices of one user and was removed (with the never-wired handshake-verifier.ts) in Phase 6 W7.
    • Session scoping caveat: a register account serves many users, so openclaw's session.dmScope MUST be "per-channel-peer" (or the per-account variant). The default "main" collapses every peer into ONE agent session, and the register history snapshot then delivers the shared transcript to every user (re-sealed to each requester's own K — encryption cannot prevent a scoping leak). The plugin warns loudly at startup when it detects this (crossUserHistoryWarning).
  • No anonymous admission: the anonymous auth strategy throws at plugin load (makeAnonymousVerifier never returns a verifier) — connections must use jwt.

Threat model (register path): relay substitutes a key → impossible, K is wrapped to the JWT-attested cnf key and never negotiated on the wire; attacker skips bootstrap → no admission (register requires a verified JWT + PoP); forged cnf → JWT signature verification fails; tampered wrapped key → Poly1305 reject, client fails closed (terminal error, no registration downgrade). Review finding C2 (active-relay MITM) is CLOSED on this path — the residual is only relay TRUST for availability/metadata, not confidentiality/integrity. Out of scope: SaaS key compromise / revocation (deferred to re-enrollment); K rotation (deferred — fixed key first); real-time allowlist authz is a core-delegated stub.

Conversation-key capacity and recovery

Each OpenClaw Gateway process persists conversation keys per exact tenant/account tuple, not on the SaaS server. The fixed 10,000-entry guard applies to distinct peerId values (verified JWT sub claims), not browser devices. It is an abuse/misrouting safety boundary, not a normal scaling knob or public setting. At 90% the plugin emits a one-time warning for that account. At the limit, existing peers continue to receive their original key and history, while only previously unseen peers receive terminal capacity_exceeded code 507.

Do not delete entries or the whole conversation-keys.json, and do not raise the cap: either action can break encrypted-history continuity. There is no automatic key-retention workflow. A capacity warning or rejection should first be treated as a routing incident: inspect issuer, scalar audience, account mapping, and unexpected sub churn.

The one exception is a confirmed containment. If a conversation key K leaked, replace it with the installed plugin's offline rotation entry — with every gateway replica stopped — and follow docs/CREDENTIAL_CONTAINMENT_RUNBOOK.md for the supported openclaw plugins inspect webchannel --json invocation and the order of operations. Resolve and run it as the stopped gateway's same OS service identity/HOME, mount/container namespace, and OpenClaw profile or explicit state/config selection; compare the dry-run tuple directory with the deployment before apply. Rotation supports one local tuple store or one authoritative store shared by every replica; independent per-replica volumes are not supported and must not be rotated separately. Deleting conversation-keys.json is still never the way to rotate: it destroys every peer's key at once and leaves no audit trail, which is exactly what the offline entry replaces. Account-wide confirmation digests bind the exact tenant, account, and peer set. On a shared store, every existing rotation lock or atomic-write temp artifact is treated as potentially remote/live and left untouched; consult the command's explicit apply outcome before any retry. An exact --storage-root can bridge another invocation HOME only for already-v2 state; legacy discovery still requires the gateway's original service context.

Account-sharding runbook

The supported expansion path is a new WebChannel account for users first assigned after a deliberate cutover. It requires application/SaaS routing work; channels add alone is not sufficient.

  1. Confirm exactly one Gateway writer uses the affected account and stop any duplicate process. Stop that writer and make a dated, owner-only backup of conversation-keys.json; never attach the file or its keys to logs/tickets.
  2. Find and stop abnormal issuer/audience/account routing or sub churn. If the store is below the limit, restart it and monitor; do not delete entries.
  3. If real new-user capacity is needed, enroll a new account with openclaw channels add --channel webchannel --account <new-account>.
  4. Store an immutable webchannelAccountId in the application database. Keep all existing users and every user assigned before cutover on the old account—even if they have never registered. Assign only users first created and assigned after cutover to the new account. Moving an old peer creates a different key/history namespace and is not a supported migration.
  5. Make the old and new JWT audiences distinct scalar strings, each matching its account. Never reuse an audience or mint a multi-aud token spanning both accounts. Assert the stored account ID is a string at the bootstrap mint call and mint only that scalar audience.
  6. Before traffic, run openclaw doctor and require no shared-audience or verifier-unbuildable finding. Privately decode one sample bootstrap per cohort—without logging it—and verify aud is the assigned scalar account; then open traffic.

Pre-cutover users assigned to a full old account can still receive 507 on their first registration. Safely reassigning that cohort requires a future membership and migration workflow; this runbook deliberately preserves them on the old account rather than risking key/history loss.

Bring-your-own NATS (static creds) — REMOVED in P0-2, returns in P0-3

Static / bring-your-own-NATS serving (and the old dev-open mode) was removed in P0-2: the authenticated register hop is now the sole admission path (see the E2E security model above). Support for static/BYO creds is planned to return in P0-3.

Until then, any removed config fails closed with a targeted migration error instead of silently degrading:

  • nats.credentials.mode:"open", the removed dev-open NATS flag, nats.admission:"auto", and auth.strategy:"anonymous" are rejected at account resolution (assertNoRemovedConfig in src/account-config.ts), with a message pointing at openclaw channels add --channel webchannel.
  • nats.credentials.mode:"static" and the matching environment overrides are rejected one phase later, at credential-source resolution (src/nats-credential-source.ts).

Enrolled (SaaS device-flow) creds remain the supported path; the connection env overrides (WEBCHANNEL_NATS_URL / _USER_JWT / _USER_SEED / _CREDS) still classify the source. Do not copy an old credentials.mode:"static" block (or a natsCredentials-only browser client) as a working recipe — it now throws at startup / requires registration.

NATS subject namespace

src/nats-channel.ts routes per-peer over tenant- and account-scoped subjects:

webchannel.{tenant}.{accountId}.{peerId}.in    # browser → agent (plugin subscribes)
webchannel.{tenant}.{accountId}.{peerId}.out   # agent → browser (plugin publishes)

Each peer (browser session) gets its own subject pair; tenant isolation is enforced by the NATS user credentials' pub/sub permissions minted during enrollment.

Handling agent vs. wire identity. The accountId is the on-wire/admission identity (the deployment). Which OpenClaw agent actually answers is decoupled and selected via openclaw agents bind --bind webchannel:<accountId> --agent <agent> (telegram-like) — not a per-account config agentId. Inbound routing calls resolveAgentRoute({ accountId, … }), which honours those bindings.

Develop / test

npm install
npm run typecheck     # tsc --noEmit
npm test              # vitest run

Note: this package currently has no build script (only test and typecheck in package.json); the plugin is loaded as TypeScript via OpenClaw's plugin loader. Packaging / publish to ClawHub is a known open question — see ../../docs/PACKAGING.md and STATUS.md.

The plugin serves no static UI and no inbound HTTP routes at all: register admission (register-hop mode) rides NATS request/reply on the account's webchannel.{tenant}.{accountId}.{peerId}.register subject, so the agent makes only outbound connections. A consumer wires the headless packages/client library into their own page (see that package's README).