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.mdto reproduce browser↔agent locally. - Defer to
../../docs/STATUS.mdfor 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
storageRootmust 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-levelcredentialPathAPI 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:
storageRootis not a live v2-to-v2 migration switch. Stop the gateway before moving the complete opaque tuple directory with its0700/0600permissions, or re-enroll and let browsers re-register. - Permissions: written with mode
0o600(owner read/write only) - Shape (
PluginCredentials):identityKey { publicKey, privateKey }(base64url X25519), optionalenrollment { creds, peerId, jwksUrl, bootstrapUrl, natsUrl, issuer }, onecredentialIdentity(CredentialBindingIdentityV2) block, plustenant,accountId,saasEnrollUrl, andsaasPollUrl. 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}.registersubject) wraps K (src/late-join-decryptor.ts— X25519 ECDH + HKDF-SHA256webchannel-key-wrap-v1+ ChaCha20-Poly1305) to the device key attested in that request's verified JWTcnfclaim 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 JWTcnf, 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.cnfclaim verification (src/jwt.ts): after the bootstrap JWT's signature is verified,verifyJwtextracts the RFC 7800cnf.jwkconfirmation claim. The claim must bekty: "OKP",crv: "X25519", with a 32-bytex; ad(private) field, wrong length, or any malformedcnfcauses the whole JWT to be rejected (fail-closed). The validated key surfaces asJwtIdentity.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-wiredhandshake-verifier.ts) in Phase 6 W7.- Session scoping caveat: a register account serves many users, so openclaw's
session.dmScopeMUST 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
anonymousauth strategy throws at plugin load (makeAnonymousVerifiernever returns a verifier) — connections must usejwt.
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.
- 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. - Find and stop abnormal issuer/audience/account routing or
subchurn. If the store is below the limit, restart it and monitor; do not delete entries. - If real new-user capacity is needed, enroll a new account with
openclaw channels add --channel webchannel --account <new-account>. - Store an immutable
webchannelAccountIdin 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. - Make the old and new JWT audiences distinct scalar strings, each matching
its account. Never reuse an audience or mint a multi-
audtoken spanning both accounts. Assert the stored account ID is a string at the bootstrap mint call and mint only that scalar audience. - Before traffic, run
openclaw doctorand require noshared-audienceorverifier-unbuildablefinding. Privately decode one sample bootstrap per cohort—without logging it—and verifyaudis 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", andauth.strategy:"anonymous"are rejected at account resolution (assertNoRemovedConfiginsrc/account-config.ts), with a message pointing atopenclaw 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
accountIdis the on-wire/admission identity (the deployment). Which OpenClaw agent actually answers is decoupled and selected viaopenclaw agents bind --bind webchannel:<accountId> --agent <agent>(telegram-like) — not a per-account configagentId. Inbound routing callsresolveAgentRoute({ accountId, … }), which honours those bindings.
Develop / test
npm install
npm run typecheck # tsc --noEmit
npm test # vitest runNote: 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).
