Skip to content

Browser members: Noise beside TLS, CORS on the mesh surface, the JS SDK in a page - #516

Merged
aojea merged 9 commits into
google:mainfrom
aojea:feat/noise-and-cors
Sep 26, 2026
Merged

aojea merged 9 commits into
google:mainfrom
aojea:feat/noise-and-cors

Conversation

@aojea

@aojea aojea commented Sep 26, 2026

Copy link
Copy Markdown
Collaborator

A member of the mesh can now be a browser page. Seven commits, each standing on its own:

  1. router, node: accept Noise after TLS. libp2p.Security(noise.ID, noise.New) after TLS on sam-router and sam-node. TLS first: Go peers and the Node/Python SDKs land on it. Noise is what a browser can speak; both bind the connection to the peer ID. TestNoiseOnlyPeer joins a Noise-only host through the router and reaches a node's service over the relay; removing Noise from the node makes the relayed dial fail. No new go.mod entry (flynn/noise was already indirect). Docs (networking concept, SDK design, host comments) no longer say TLS only.
  2. controlplane: answer the mesh protocol's endpoints for any origin. /info, /keys, /enroll, /enroll/status, /register, /refresh, /policies answer a preflight and set Access-Control-Allow-Origin: *. They authenticate by what the request carries (token in the body, biscuit as a bearer), never by a cookie, so a page on another origin can send nothing a program could not already. /admin/*, /user/* (cookie-authenticated) and /routers/lease get no such header; TestMeshSurfaceCORS pins both sides.
  3. sdk/js: identity and encodings without node:crypto and Buffer. Ed25519 from @noble/curves (what libp2p uses in a browser), RFC 8032 verification as Go and node:crypto apply it; hex/base64/base64url from uint8arrays, named by alphabet and padding to match each Go decoder. Both were already installed through libp2p and are now declared. Cross-checked against node:crypto in both directions.
  4. sdk/js: speak /libp2p-http on the stream itself. A small HTTP/1.1 codec (http1.ts: Content-Length, chunked, until-close, heads capped at 64 KiB) replaces Node's http over a Duplex for session.fetch()/fetchOverStream and for the handler/url ingress; a streaming Response body goes out chunk by chunk, so SSE works. The Node listener (Express) path keeps Node's parser in libp2p-http-node.ts and shares admission (admitIngress), so authorization is written once. Codec unit tests feed it byte by byte; the ingress tests add a fetch handler with a streaming body and Node's own client reading the chunked response.
  5. sdk/js: one SDK for Node and the browser. Four platform pairs under src/platform with .browser.ts twins mapped by package.json's browser field: transports (TCP+WS with TLS then Noise / WS with Noise via @chainsafe/libp2p-noise), state (owner-only files / IndexedDB), the biscuit WASM loader, the ingress. scripts/bundle-browser.mjs (esbuild, devDependency) bundles for a page and fails if the browser graph reaches a node: module.
  6. sdk: a browser page as a member of a sam-one mesh, tested. sdk/js/examples/browser is the Echo agent in a page (A2A SDK's JsonRpcTransportHandler behind a fetch handler, the verified peer as the A2A user, IndexedDB state so a reload resumes the same peer without a token). tests/ui/browser-sdk.spec.js drives it in Chromium against bin/sam-one directly and behind a TLS-terminating edge reached by name (the sam-one --tunnel topology, router advertised as wss): the page enrolls cross-origin, joins over ws/wss + Noise (verified: enc: "/noise", wss://localhost:<edge>), a Node member calls the page's agent through the relay, the page calls a Node agent. run.sh builds the SDK, examples and the page; make ui-test runs it. Three fixes the page surfaced: the Node and Python SDKs must also accept Noise beside TLS (else a relayed connection from a browser member has no security protocol in common); a browser's fetch must not be stored as a method; js-libp2p in a browser denies loopback and plain ws:// addresses by default, which is what sam-one on the same machine advertises, so the host's gater judges peers, not address shapes.
  7. docs: the JS SDK in a browser. "In a browser" section in the Native SDKs guide; the browser non-goal is gone from sdk/README.md and the guide; the npm page points at the guide.

Decisions: CORS * on the mesh surface only; a hand-written codec rather than a dependency; Node keeps node:http for listener/url; TLS first and Noise accepted everywhere; FIPS parked until a concrete customer need.

Validation: make build, go vet ./..., hack/lint.sh (0 issues), go test ./... (incl. TestNoiseOnlyPeer, TestMeshSurfaceCORS, TestNativeSDKsMesh, TestNativeSDKA2A, TestNativeSDKExamples, TestSDKCanaryScript, TestStandaloneSDKAgents*), npm test (77), npm run build/examples, npm ci from the lockfile, pytest (82), hack/verify-sdk-generated.sh, hack/verify-secrets.sh, make ui-test (20 passed, both browser topologies included).

A browser cannot run libp2p TLS: its TLS stack presents no client
certificate and hides the server's, and the libp2p handshake needs both.
Noise, a handshake over X25519 and ChaCha20-Poly1305 with the identity
key signing the static key, it can run in JavaScript. Routers and nodes
now offer TLS first and accept Noise; a peer that has TLS lands on it as
before, and the connections a browser makes, to the router and relayed
through it to a node, are Noise. Both bind the connection to the peer ID.

TestNoiseOnlyPeer is what a browser is on the wire: a Go peer with Noise
alone joins through the router, is relayed to a sam-node, completes the
node's auth handshake and reaches its MCP service; with Noise removed
from the node the relayed dial fails. The docs no longer say TLS is the
only security protocol. FIPS is not claimed: a build for it would offer
TLS alone, and nothing asks for one.
A member running in a browser enrolls, refreshes and reads keys, /info
and policy from a page whose origin is not the control plane's, and the
browser asks the control plane first whether that page may. The mesh
protocol's endpoints (/info, /keys, /enroll, /enroll/status, /register,
/refresh, /policies) now answer the preflight and mark their responses
for any origin. They authenticate by what the request carries, a token
in the body or a biscuit as a bearer, never by a cookie, so a page on
another origin can send nothing a program could not send already. The
operator plane (/admin, /user), which is cookie-authenticated, and the
router lease get no such header.
The core of the SDK, identity, enrollment, credential and refresh, no
longer reaches for node:crypto and Buffer. Ed25519 comes from
@noble/curves, the arithmetic libp2p itself uses in a browser, with
RFC 8032 verification as Go crypto/ed25519 and node:crypto apply it.
Hex, standard base64 and base64url come from uint8arrays, named by
alphabet and padding so each matches the Go decoder that reads it.
Both packages were already installed through libp2p; they are now
declared because the SDK imports them directly. A signature made here
verifies with node:crypto and the other way round, and the SDK
integration tests still enroll, refresh and sync policy against the
Go control plane.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces browser support for the JS SDK, enabling agents to run directly in browser pages. Key changes include adding CORS support to the control plane, enabling Noise security transport on nodes and routers, refactoring state storage to use IndexedDB in browsers, and implementing a runtime-neutral HTTP/1.1 codec over libp2p streams to replace Node-specific modules. Feedback on the changes highlights a potential runtime TypeError in browser environments when invoking custom fetch implementations due to incorrect this binding, as well as a security vulnerability in the custom HTTP/1.1 chunk size parser that could permit HTTP Request Smuggling due to lax validation.

Comment thread sdk/js/src/controlplane.ts Outdated
Comment thread sdk/js/src/http1.ts Outdated
@aojea
aojea force-pushed the feat/noise-and-cors branch from 3777685 to b23d29e Compare September 26, 2026 14:35
@aojea

aojea commented Sep 26, 2026

Copy link
Copy Markdown
Collaborator Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces support for running the JS SDK in a browser page by adding a CORS handler to the control plane, enabling the Noise security protocol across Go, JS, and Python hosts, and implementing a runtime-neutral HTTP/1.1 codec. It also splits platform-specific concerns such as state storage (using IndexedDB in browsers) and transports. Feedback on these changes highlights a potential Denial of Service (DoS) vulnerability in readLine due to a bypassable line length limit check, a path traversal vulnerability in hasDotSegment from unchecked URL-encoded dot segments, and TypeScript compilation issues caused by using Uint8Array as a generic type in bytes.ts and http1.ts.

Comment thread sdk/js/src/http1.ts
Comment thread sdk/js/src/libp2p-http.ts
Comment thread sdk/js/src/bytes.ts
Comment thread sdk/js/src/http1.ts
The client that calls a service on a peer and the ingress that answers
for this member's agent framed their HTTP/1.1 with Node's http module
over a Duplex bridge. They now use a codec of their own on the libp2p
stream (http1.ts): Content-Length and chunked bodies in and out, a
response without either read to the end of the stream, heads bounded
at 64 KiB, the body of a streaming Response written as chunks as the
agent produces them so an SSE event reaches the caller before the next.
Nothing in that path needs Node any more; a member in a browser calls
and answers the same way.

An endpoint answered by a Node request listener (an Express app with
the A2A SDK's handlers) still needs Node's own parser to build the
IncomingMessage it expects; that path moves to libp2p-http-node.ts and
shares admission (admitIngress) with the neutral one, so authorization
is written once. Unit tests feed the codec byte by byte; the ingress
tests cover a fetch handler with a streaming body and Node's own client
reading the chunked response; the SDK integration tests exercise the
Go and Python callers against it.
What differs between the two runtimes now sits in four small modules
under src/platform, each with a .browser.ts twin of the same exports,
and package.json's "browser" field points a bundler at the twins:

  transports  Node: TCP and WebSocket with TLS.
              Browser: WebSocket with Noise (@chainsafe/libp2p-noise),
              since a page cannot run libp2p's TLS; routers and nodes
              accept Noise beside TLS.
  state       Node: a directory of owner-only files, written atomically.
              Browser: an IndexedDB database named after the location.
              A token file path is refused; the page passes the value.
  wasm        Node: biscuit-wasm instantiated by hand from disk.
              Browser: the package's own entry, resolved by the bundler
              as wasm-pack's bundler target expects.
  ingress     Node: the ingress that also serves a Node request listener.
              Browser: the runtime-neutral one.

mesh.ts keeps its identity and credential through a StateStore instead
of node:fs, host.ts takes transports and encrypters from the platform,
biscuit.ts loads its module through it. Nothing else changed for Node:
the same files are read and written, the same transports dialled.

scripts/bundle-browser.mjs bundles dist/index.js for a browser with
esbuild (a devDependency), resolving the .wasm import the way a page's
bundler would, and fails when the browser graph reaches a node: module,
so the split is checked, not assumed.
sdk/js/examples/browser is the Echo agent of a2a-agent.ts in a page:
it enrolls with a join token, joins the router, answers a2a://agent
with a fetch handler around the A2A SDK's JsonRpcTransportHandler (the
verified peer as the A2A user), calls another agent by peer ID, and
keeps its identity in IndexedDB so a reload resumes the same peer
without a token.

tests/ui/browser-sdk.spec.js drives that page in Chromium against
bin/sam-one twice: directly, and behind a TLS-terminating edge reached
by name, the topology `sam-one --tunnel` leaves a page in, where the
router is advertised as wss. In both a Node member calls the page's
agent through the relay and the page calls a Node agent. The page runs
on an origin of its own, so enrollment goes through the control
plane's CORS answer, and the connection it makes is Noise over ws or
wss. run.sh builds the SDK, its examples and the page before Playwright.

Three things the page found: the Node and Python SDKs must accept Noise
beside TLS, or a relayed connection from a browser member has no
security protocol in common with them; a browser's fetch refuses to run
as a method of another object, so the control plane client wraps it;
and js-libp2p in a browser skips loopback and plain ws:// addresses by
default, which is what sam-one on the same machine advertises, so the
host's gater judges peers, not address shapes.
The SDK design and the Native SDKs guide said the SDKs run on Node.js
and CPython only and listed a browser build as a non-goal. The JS SDK
now runs in a page, so the guide gains an "In a browser" section (how
the page reaches the router, wss behind a TLS edge, CORS on the control
plane, IndexedDB state, a handler instead of a listener, how to bundle
and where the test is), the design records what is in place (Noise
beside TLS on every member, CORS on the mesh protocol's endpoints, the
JS codec and platform modules, the browser example and its test) and
the npm page points at the guide.
The A2A ingress in both SDKs refuses a request whose path has a "." or
".." segment, since policy is decided on the /<type>/<name> prefix and
a backend could resolve such a segment across it. The check read the
raw target, where "%2e%2e" is not ".."; a URL parser, and the backend
behind the agent, read it as one (WHATWG URL, path state). The check
now sees what they see: each segment with %2e (any case) read as a dot.
The Go ingress checks the decoded URL.Path and was not affected.
@aojea
aojea force-pushed the feat/noise-and-cors branch from b23d29e to 0b85250 Compare September 26, 2026 14:55
@aojea

aojea commented Sep 26, 2026

Copy link
Copy Markdown
Collaborator Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces browser support for the JS SDK, enabling browser-based agents to join the mesh over WebSocket and Noise, with state persisted in IndexedDB. To achieve this, Node-specific dependencies (like node:crypto and node:http) have been replaced with platform-neutral implementations, including a custom HTTP/1.1 codec over libp2p streams and the addition of the Noise security protocol to Go hosts and Python/JS SDKs. Feedback on the changes suggests ensuring that the bodyStream generator is properly closed upon cancellation to prevent resource leaks, preserving the original Content-Length header for HEAD responses in compliance with RFC 9110, and providing a fallback error when throwing an abort signal reason that might be undefined.

Comment thread sdk/js/src/http1.ts
Comment thread sdk/js/src/libp2p-http.ts
Comment thread sdk/js/src/libp2p-http.ts
…eader

Three things from review. A HEAD response carried Content-Length: 0
whatever the agent declared; it now carries the head a GET would get
(RFC 9110 section 9.3.2), the agent's Content-Length if it set one and
none otherwise, and a body the agent built for HEAD anyway is
cancelled, not sent. Cancelling the body stream of a response closes
the generator reading the libp2p stream, after the stream itself is
torn down so a pending read cannot block it. A fetch that fails after
its signal fired throws the signal's reason, falling back to the error
in hand when a runtime leaves the reason unset.

Also, the authorizer rendered biscuit-wasm's refusals, which are plain
objects, as "[object Object]"; a CI failure of the authorizer tests
showed nothing else. It now renders them as JSON, as biscuit.ts already
does, and the denial test pins that the failed check is named.
@aojea
aojea merged commit 3af5282 into google:main Sep 26, 2026
22 of 24 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant