From daef50408dcaeb94abb9bdcc237ebe220cedd3b6 Mon Sep 17 00:00:00 2001 From: Antonio Ojea Date: Sat, 26 Sep 2026 12:59:17 +0000 Subject: [PATCH 1/9] router, node: accept Noise after TLS 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. --- internal/node/node.go | 7 +- internal/router/router.go | 4 + sdk/README.md | 14 +-- sdk/js/src/host.ts | 9 +- sdk/python/src/agent_mesh/host.py | 5 +- site/content/docs/concepts/networking.md | 8 +- site/content/docs/guides/native-sdks.md | 6 +- tests/integration/noise_test.go | 103 +++++++++++++++++++++++ 8 files changed, 136 insertions(+), 20 deletions(-) create mode 100644 tests/integration/noise_test.go diff --git a/internal/node/node.go b/internal/node/node.go index 07b77312..7ef2d962 100644 --- a/internal/node/node.go +++ b/internal/node/node.go @@ -61,6 +61,7 @@ import ( "github.com/libp2p/go-libp2p/p2p/host/autorelay" "github.com/libp2p/go-libp2p/p2p/net/connmgr" "github.com/libp2p/go-libp2p/p2p/net/swarm" + "github.com/libp2p/go-libp2p/p2p/security/noise" libp2ptls "github.com/libp2p/go-libp2p/p2p/security/tls" "github.com/libp2p/go-msgio" "github.com/multiformats/go-multiaddr" @@ -430,11 +431,15 @@ func (n *SamNode) Start(ctx context.Context) error { return fmt.Errorf("failed to create connection manager: %w", err) } - // Layer 1: Establish FIPS-compliant Transports & NAT Services + // Layer 1: Transports & NAT Services. TLS first: Go peers and the + // Node/Python SDKs land on it. Noise is what a browser can speak, and a + // relayed connection from one is upgraded here, end to end; both bind + // the connection to the peer ID. opts := []libp2p.Option{ libp2p.Identity(n.config.PrivKey), libp2p.DefaultTransports, libp2p.Security(libp2ptls.ID, libp2ptls.New), + libp2p.Security(noise.ID, noise.New), libp2p.ConnectionGater(gater), libp2p.ListenAddrStrings(n.config.ListenAddrs...), libp2p.EnableNATService(), diff --git a/internal/router/router.go b/internal/router/router.go index 5f0e3f44..d2232849 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -51,6 +51,7 @@ import ( rcmgr "github.com/libp2p/go-libp2p/p2p/host/resource-manager" "github.com/libp2p/go-libp2p/p2p/net/connmgr" "github.com/libp2p/go-libp2p/p2p/protocol/circuitv2/relay" + "github.com/libp2p/go-libp2p/p2p/security/noise" libp2ptls "github.com/libp2p/go-libp2p/p2p/security/tls" libp2pquic "github.com/libp2p/go-libp2p/p2p/transport/quic" "github.com/libp2p/go-libp2p/p2p/transport/tcp" @@ -353,7 +354,10 @@ func (r *Router) Start() error { libp2p.Identity(r.privKey), r.transportOptions(), libp2p.ListenAddrStrings(r.config.ListenAddrs...), + // 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. libp2p.Security(libp2ptls.ID, libp2ptls.New), + libp2p.Security(noise.ID, noise.New), libp2p.ConnectionManager(cm), libp2p.EnableAutoNATv2(), libp2p.EnableNATService(), diff --git a/sdk/README.md b/sdk/README.md index 1475825d..a4207a5a 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -226,9 +226,10 @@ messages in `api/sam.proto`. Bodies are capped at 1 MiB on both sides. ### libp2p host (`internal/node/node.go`) - Transports: `libp2p.DefaultTransports` (TCP, QUIC, WebSocket). -- Security: **TLS only** (`libp2p.Security(libp2ptls.ID, libp2ptls.New)`). - There is no Noise on `sam-node` or `sam-router`. js-libp2p has TLS; - py-libp2p gained it in +- Security: TLS first, Noise accepted (`libp2p.Security` twice, in that + order, on `sam-node` and `sam-router`). Both bind the connection to the + peer ID. The Node and Python SDKs speak TLS and land on it; Noise is what + a browser can speak. js-libp2p has both; py-libp2p gained TLS in [libp2p/py-libp2p#831](https://github.com/libp2p/py-libp2p/pull/831) and has passed the libp2p transport interoperability suite against the other implementations since @@ -591,10 +592,9 @@ same commit as the Go components they talk to. service, a DHT record or a catalog entry. That is `sam-node`'s job; see [Agents, not services](#agents-not-services). - A browser build. The JS SDK dials routers over WebSocket, which a browser - can do, but it still runs on Node.js only: the routers and nodes accept - libp2p TLS alone, which a browser cannot speak (it would need Noise on the - Go side), and the SDK reads its state and speaks HTTP/1.1 on streams with - Node's `fs` and `http`. + can do, and routers and nodes accept Noise, which a browser can speak, but + it still runs on Node.js only: it speaks libp2p TLS, and reads its state + and speaks HTTP/1.1 on streams with Node's `fs` and `http`. - Any SDK-only wire protocol. If an SDK needs something the Go node does not speak, the Go node learns it first. diff --git a/sdk/js/src/host.ts b/sdk/js/src/host.ts index cc1ddc73..39eb6d7b 100644 --- a/sdk/js/src/host.ts +++ b/sdk/js/src/host.ts @@ -13,10 +13,11 @@ // limitations under the License. // The libp2p host a member joins the mesh with, configured the way -// sam-node's is (internal/node/node.go): TLS is the only security -// protocol, yamux the muxer, circuit relay v2 for reachability through -// the routers. TCP and WebSocket are the transports: the testnets' routers -// listen on TCP, sam-one's single port is a WebSocket listener. +// sam-node's is (internal/node/node.go): TLS for the security protocol, +// which every peer offers first, yamux the muxer, circuit relay v2 for +// reachability through the routers. TCP and WebSocket are the transports: +// the testnets' routers listen on TCP, sam-one's single port is a WebSocket +// listener. import { yamux } from "@chainsafe/libp2p-yamux"; import { circuitRelayTransport } from "@libp2p/circuit-relay-v2"; diff --git a/sdk/python/src/agent_mesh/host.py b/sdk/python/src/agent_mesh/host.py index 9bb0504b..c91b7e79 100644 --- a/sdk/python/src/agent_mesh/host.py +++ b/sdk/python/src/agent_mesh/host.py @@ -13,8 +13,9 @@ # limitations under the License. """The libp2p host a member joins the mesh with, configured the way sam-node's -is (internal/node/node.go): TLS is the only security protocol and yamux the -muxer. py-libp2p is trio-based, so everything here is trio async.""" +is (internal/node/node.go): TLS for the security protocol, which every peer +offers first, and yamux the muxer. py-libp2p is trio-based, so everything here +is trio async.""" from __future__ import annotations diff --git a/site/content/docs/concepts/networking.md b/site/content/docs/concepts/networking.md index 9a8097b5..dda1d662 100644 --- a/site/content/docs/concepts/networking.md +++ b/site/content/docs/concepts/networking.md @@ -89,9 +89,11 @@ sends any request data. ## What travels where -Every hop between two nodes is encrypted by libp2p's TLS 1.3 secure channel. -TLS is the only security transport enabled, so that FIPS-validated -cryptography can be used end to end. A relay forwards ciphertext and learns +Every hop between two nodes is encrypted by a libp2p secure channel that +binds the connection to the peer's identity: TLS 1.3, which Go peers and the +Node and Python SDKs use, or Noise, which a browser can speak. Routers and +nodes offer TLS first and accept Noise; two peers that both have TLS land on +it. A relay forwards ciphertext and learns only that the two peers are talking. The control plane sees enrollments, refreshes, router leases, policy fetches and catalog reports. It never sees a request. diff --git a/site/content/docs/guides/native-sdks.md b/site/content/docs/guides/native-sdks.md index 2b9f12f0..c7e6c71e 100644 --- a/site/content/docs/guides/native-sdks.md +++ b/site/content/docs/guides/native-sdks.md @@ -926,9 +926,9 @@ or a pattern) and the members it may reach; see - They do not serve the discovery table. A member is a client of it; the routers hold the records. - They do not run in a browser. Node.js and CPython only. The JS SDK - dials routers over WebSocket, which a browser can do, but routers and - nodes accept libp2p TLS alone, which a browser cannot speak, and the SDK - keeps its state and speaks HTTP on streams with Node's own modules. + dials routers over WebSocket, which a browser can do, and routers and + nodes accept Noise, which a browser can speak, but the SDK speaks libp2p + TLS and keeps its state and speaks HTTP on streams with Node's own modules. The wire contract and the interoperability facts the tests pin are in [`sdk/README.md`](https://github.com/google/sam/blob/main/sdk/README.md). diff --git a/tests/integration/noise_test.go b/tests/integration/noise_test.go new file mode 100644 index 00000000..18330b6e --- /dev/null +++ b/tests/integration/noise_test.go @@ -0,0 +1,103 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package integration_test + +import ( + "context" + "crypto/ed25519" + "strings" + "testing" + "time" + + "github.com/google/sam/api" + "github.com/google/sam/internal/identity" + "github.com/libp2p/go-libp2p" + "github.com/libp2p/go-libp2p/core/host" + "github.com/libp2p/go-libp2p/core/network" + "github.com/libp2p/go-libp2p/core/peer" + "github.com/libp2p/go-libp2p/p2p/security/noise" + "github.com/multiformats/go-multiaddr" +) + +// TestNoiseOnlyPeer pins what a browser member is on the wire: a peer that +// speaks Noise and no libp2p TLS. It authenticates with the router over +// Noise, is relayed to a sam-node, and the relayed connection, upgraded end +// to end between the two of them, is Noise as well, so the node's auth +// handshake and a service call go through. A peer that speaks TLS still +// lands on TLS: it is offered first. +func TestNoiseOnlyPeer(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + mesh := startSDKMesh(t) + + h, err := libp2p.New( + libp2p.NoListenAddrs, + libp2p.Security(noise.ID, noise.New), + libp2p.EnableRelay(), + ) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = h.Close() }) + biscuit := goHostBiscuit(t, mesh.cpPriv, h.ID()) + + router, err := peer.AddrInfoFromString(mesh.routerAddr) + if err != nil { + t.Fatal(err) + } + if err := h.Connect(ctx, *router); err != nil { + t.Fatalf("noise-only peer could not connect to the router: %v", err) + } + if got := securityOf(h, router.ID); got != noise.ID { + t.Fatalf("connection to the router is %q, want %q", got, noise.ID) + } + routerBiscuit := authHandshake(t, ctx, h, router.ID, biscuit) + if err := identity.VerifyBiscuitRole(routerBiscuit, mesh.cpPriv.Public().(ed25519.PublicKey), api.RoleRouter, 5*time.Second); err != nil { + t.Fatalf("router credential lacks the router role: %v", err) + } + + nodeID := mesh.samNode.peerID + relayed := multiaddr.StringCast(mesh.routerAddr + "/p2p-circuit/p2p/" + nodeID.String()) + // A relayed connection reports no security protocol in its ConnState; + // that this peer, which has Noise alone, gets one at all is the check. + if err := h.Connect(network.WithAllowLimitedConn(ctx, "test"), peer.AddrInfo{ID: nodeID, Addrs: []multiaddr.Multiaddr{relayed}}); err != nil { + t.Fatalf("noise-only peer could not reach the node through the router: %v", err) + } + nodeBiscuit := authHandshake(t, ctx, h, nodeID, biscuit) + if err := identity.VerifyBiscuitRole(nodeBiscuit, mesh.cpPriv.Public().(ed25519.PublicKey), api.RoleNode, 5*time.Second); err != nil { + t.Fatalf("node credential lacks the node role: %v", err) + } + // The MCP service answers a bare GET with its own 400 (the streamable + // HTTP transport wants an SSE Accept); an answer from the service is what + // shows the request crossed the noise connection into the node. + if status, body := libp2pHTTPGet(t, ctx, h, nodeID, biscuit, "/mcp/calc"); status != 200 && (status != 400 || !strings.Contains(body, "text/event-stream")) { + t.Fatalf("service call over the noise connection: %d %s", status, body) + } + + // The TLS peer of the other tests is unchanged: TLS is offered first. + tlsPeer := newAdmittedGoPeer(t, ctx, mesh.cpPriv, mesh.routerAddr) + if got := securityOf(tlsPeer, router.ID); !strings.HasPrefix(got, "/tls/") { + t.Fatalf("TLS peer's connection to the router is %q, want /tls/...", got) + } +} + +// securityOf is the security protocol of h's connection to p. +func securityOf(h host.Host, p peer.ID) string { + conns := h.Network().ConnsToPeer(p) + if len(conns) == 0 { + return "" + } + return string(conns[0].ConnState().Security) +} From 384a29486e13e545dcf65538710403f042cb12b8 Mon Sep 17 00:00:00 2001 From: Antonio Ojea Date: Sat, 26 Sep 2026 13:02:27 +0000 Subject: [PATCH 2/9] controlplane: answer the mesh protocol's endpoints for any origin 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. --- internal/controlplane/server.go | 34 ++++++++++--- internal/controlplane/server_test.go | 73 ++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 7 deletions(-) diff --git a/internal/controlplane/server.go b/internal/controlplane/server.go index bbda0e60..661c8921 100644 --- a/internal/controlplane/server.go +++ b/internal/controlplane/server.go @@ -230,14 +230,14 @@ func (s *Server) RegisterRoutes(mux *http.ServeMux) { handle := func(pattern string, h http.HandlerFunc) { mux.Handle(pattern, observeRoute(pattern, h)) } - handle("/info", s.HandleInfo) - handle("/register", noStore(s.HandleRegister)) - handle("/keys", s.HandleKeys) + handle("/info", meshSurface(s.HandleInfo)) + handle("/register", meshSurface(noStore(s.HandleRegister))) + handle("/keys", meshSurface(s.HandleKeys)) handle("/routers/lease", s.HandleRouterLease) - handle("/policies", s.HandlePolicies) - handle("/enroll", noStore(s.HandleEnroll)) - handle("/enroll/status", noStore(s.HandleEnrollStatus)) - handle("/refresh", noStore(s.HandleRefresh)) + handle("/policies", meshSurface(s.HandlePolicies)) + handle("/enroll", meshSurface(noStore(s.HandleEnroll))) + handle("/enroll/status", meshSurface(noStore(s.HandleEnrollStatus))) + handle("/refresh", meshSurface(noStore(s.HandleRefresh))) handle("/nodes/catalog", s.HandleNodeCatalog) handle("/admin/bootstrap-tokens", noStore(s.HandleAdminBootstrapTokens)) handle("/admin/bootstrap-tokens/", noStore(s.HandleAdminBootstrapTokenAction)) @@ -261,6 +261,26 @@ func noStore(h http.HandlerFunc) http.HandlerFunc { } } +// meshSurface lets a member running in a browser call an endpoint of the +// mesh protocol from any origin. These endpoints authenticate by what the +// request carries, a bootstrap token or an OIDC 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) is cookie-authenticated and gets no such header. +func meshSurface(h http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + if r.Method == http.MethodOptions { + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", strings.Join([]string{"Authorization", "Content-Type", api.HeaderChallengeTimestamp, api.HeaderChallengeSignature}, ", ")) + w.Header().Set("Access-Control-Max-Age", "600") + w.WriteHeader(http.StatusNoContent) + return + } + h(w, r) + } +} + func (s *Server) discoverProviders() error { s.providersMu.Lock() defer s.providersMu.Unlock() diff --git a/internal/controlplane/server_test.go b/internal/controlplane/server_test.go index 8c4633ae..959ea277 100644 --- a/internal/controlplane/server_test.go +++ b/internal/controlplane/server_test.go @@ -3018,6 +3018,79 @@ func TestInitRegisterRoutesEmbedded(t *testing.T) { } } +// TestMeshSurfaceCORS pins what a member in a browser needs from the control +// plane and what it must not get: the mesh protocol's endpoints answer a +// preflight and mark every response for any origin, the operator plane +// does neither. +func TestMeshSurfaceCORS(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "cp-cors.db") + store, err := storage.NewSQLStore("sqlite", dbPath) + if err != nil { + t.Fatalf("failed to create store: %v", err) + } + defer func() { _ = store.Close() }() + srv, err := NewServer(Options{DriverName: "sqlite", DataSourceName: dbPath, AllowedAudiences: []string{"sam-mesh-audience"}, AdminToken: "admin-token"}, store) + if err != nil { + t.Fatalf("failed to create server: %v", err) + } + if err := srv.Init(); err != nil { + t.Fatal(err) + } + defer func() { _ = srv.Close() }() + mux := http.NewServeMux() + srv.RegisterRoutes(mux) + ts := httptest.NewServer(mux) + defer ts.Close() + client := &http.Client{Timeout: 5 * time.Second} + + for _, path := range []string{"/info", "/keys", "/enroll", "/enroll/status", "/register", "/refresh", "/policies"} { + req, _ := http.NewRequest(http.MethodOptions, ts.URL+path, nil) + req.Header.Set("Origin", "https://agent.example") + req.Header.Set("Access-Control-Request-Method", "POST") + req.Header.Set("Access-Control-Request-Headers", "content-type, "+api.HeaderChallengeTimestamp) + resp, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + _ = resp.Body.Close() + if resp.StatusCode != http.StatusNoContent { + t.Errorf("OPTIONS %s = %s, want 204", path, resp.Status) + } + if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "*" { + t.Errorf("OPTIONS %s Access-Control-Allow-Origin = %q, want *", path, got) + } + for _, h := range []string{"Authorization", "Content-Type", api.HeaderChallengeTimestamp, api.HeaderChallengeSignature} { + if !strings.Contains(resp.Header.Get("Access-Control-Allow-Headers"), h) { + t.Errorf("OPTIONS %s does not allow header %s: %q", path, h, resp.Header.Get("Access-Control-Allow-Headers")) + } + } + } + resp, err := client.Get(ts.URL + "/info") + if err != nil { + t.Fatal(err) + } + _ = resp.Body.Close() + if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "*" { + t.Errorf("GET /info Access-Control-Allow-Origin = %q, want *", got) + } + + for _, path := range []string{"/admin/status", "/user/status", "/routers/lease"} { + req, _ := http.NewRequest(http.MethodOptions, ts.URL+path, nil) + req.Header.Set("Origin", "https://agent.example") + resp, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + _ = resp.Body.Close() + if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" { + t.Errorf("OPTIONS %s Access-Control-Allow-Origin = %q, want none", path, got) + } + if resp.StatusCode == http.StatusNoContent { + t.Errorf("OPTIONS %s answered a preflight", path) + } + } +} + // TestAdminBootstrapTokensList pins the admin listing surface used by // `sam-one token list`: created tokens show up, and the list is admin-gated. func TestAdminBootstrapTokensList(t *testing.T) { From 28fc673264eddcb610c36bc6d8d663f7a6c481f0 Mon Sep 17 00:00:00 2001 From: Antonio Ojea Date: Sat, 26 Sep 2026 13:26:17 +0000 Subject: [PATCH 3/9] sdk/js: identity and encodings without node:crypto and Buffer 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. --- sdk/js/package-lock.json | 2 ++ sdk/js/package.json | 2 ++ sdk/js/src/bytes.ts | 57 +++++++++++++++++++++++++++++++++++ sdk/js/src/controlplane.ts | 13 ++++---- sdk/js/src/credential.ts | 9 +++--- sdk/js/src/identity.ts | 61 +++++++++++++------------------------- sdk/js/src/mesh.ts | 7 +++-- 7 files changed, 96 insertions(+), 55 deletions(-) create mode 100644 sdk/js/src/bytes.ts diff --git a/sdk/js/package-lock.json b/sdk/js/package-lock.json index 73101ea1..dcc383c4 100644 --- a/sdk/js/package-lock.json +++ b/sdk/js/package-lock.json @@ -26,8 +26,10 @@ "@libp2p/websockets": "^10.1.21", "@modelcontextprotocol/sdk": "^1.30.1", "@multiformats/multiaddr": "^13.0.3", + "@noble/curves": "^2.0.1", "libp2p": "^3.3.11", "multiformats": "^14.0.5", + "uint8arrays": "^6.1.1", "zod": "^4.6.5" }, "devDependencies": { diff --git a/sdk/js/package.json b/sdk/js/package.json index 544d401e..a735ce3b 100644 --- a/sdk/js/package.json +++ b/sdk/js/package.json @@ -51,8 +51,10 @@ "@libp2p/websockets": "^10.1.21", "@modelcontextprotocol/sdk": "^1.30.1", "@multiformats/multiaddr": "^13.0.3", + "@noble/curves": "^2.0.1", "libp2p": "^3.3.11", "multiformats": "^14.0.5", + "uint8arrays": "^6.1.1", "zod": "^4.6.5" }, "devDependencies": { diff --git a/sdk/js/src/bytes.ts b/sdk/js/src/bytes.ts new file mode 100644 index 00000000..ef8f0bea --- /dev/null +++ b/sdk/js/src/bytes.ts @@ -0,0 +1,57 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Text encodings of bytes as the Go side reads them, for any JavaScript +// runtime. The names say which alphabet and whether the text is padded: +// a biscuit travels as padded standard base64 (Go base64.StdEncoding), a +// challenge signature as unpadded base64url (Go base64.RawURLEncoding). + +import { fromString } from "uint8arrays/from-string"; +import { toString } from "uint8arrays/to-string"; + +export function toHex(bytes: Uint8Array): string { + return toString(bytes, "hex"); +} + +export function fromHex(text: string): Uint8Array { + return fromString(text, "hex"); +} + +/** Padded standard base64 (RFC 4648 section 4), what Go base64.StdEncoding decodes. */ +export function toBase64(bytes: Uint8Array): string { + return toString(bytes, "base64pad"); +} + +export function fromBase64(text: string): Uint8Array { + return fromString(text, "base64pad"); +} + +/** Unpadded base64url (RFC 4648 section 5), what Go base64.RawURLEncoding decodes. */ +export function toBase64Url(bytes: Uint8Array): string { + return toString(bytes, "base64url"); +} + +export function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { + return a.length === b.length && a.every((v, i) => v === b[i]); +} + +export function concatBytes(...parts: Uint8Array[]): Uint8Array { + const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0)); + let offset = 0; + for (const p of parts) { + out.set(p, offset); + offset += p.length; + } + return out; +} diff --git a/sdk/js/src/controlplane.ts b/sdk/js/src/controlplane.ts index ee77c80e..9149d692 100644 --- a/sdk/js/src/controlplane.ts +++ b/sdk/js/src/controlplane.ts @@ -34,6 +34,7 @@ import { type KeysResponse, } from "./gen/sam_pb.ts"; import type { Identity } from "./identity.ts"; +import { bytesEqual, toBase64, toBase64Url } from "./bytes.ts"; import { verifyEd25519 } from "./identity.ts"; export const PROTOBUF_CONTENT_TYPE = "application/x-protobuf"; @@ -154,10 +155,6 @@ export function validateControlPlaneURL(rawUrl: string, allowInsecure = false): throw new Error(`control plane URL ${JSON.stringify(rawUrl)} must use http:// or https://`); } -function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { - return a.length === b.length && a.every((v, i) => v === b[i]); -} - /** * Returns the key set if it is fresh and at least one listed key is already * trusted and its signature verifies. Mirrors api.VerifyKeysResponse. @@ -280,7 +277,7 @@ export class ControlPlaneClient { const sig = identity.sign(enrollStatusChallenge(identity.peerId, ts)); const body = await this.#request("GET", `/enroll/status?peer_id=${encodeURIComponent(identity.peerId)}`, undefined, { [HEADER_CHALLENGE_TIMESTAMP]: String(ts), - [HEADER_CHALLENGE_SIGNATURE]: Buffer.from(sig).toString("base64url"), + [HEADER_CHALLENGE_SIGNATURE]: toBase64Url(sig), }); return fromBinary(BootstrapEnrollResponseSchema, body); } @@ -322,7 +319,7 @@ export class ControlPlaneClient { peerId: identity.peerId, }); const body = await this.#request("POST", "/refresh", toBinary(TokenRefreshRequestSchema, req), { - Authorization: `Bearer ${Buffer.from(params.biscuit).toString("base64")}`, + Authorization: `Bearer ${toBase64(params.biscuit)}`, }); const resp = fromBinary(TokenRefreshResponseSchema, body); if (resp.errorMessage) { @@ -341,7 +338,7 @@ export class ControlPlaneClient { */ async policyRules(biscuit: Uint8Array): Promise { const body = await this.#request("GET", "/policies", undefined, { - Authorization: `Bearer ${Buffer.from(biscuit).toString("base64")}`, + Authorization: `Bearer ${toBase64(biscuit)}`, }); const resp = fromBinary(PolicyConfigGetResponseSchema, body); if (resp.$unknown !== undefined && resp.$unknown.length > 0) { @@ -360,7 +357,7 @@ export class ControlPlaneClient { signal: controller.signal, }; if (body !== undefined) { - init.body = Buffer.from(body); + init.body = new Uint8Array(body); init.headers = { ...init.headers, "Content-Type": PROTOBUF_CONTENT_TYPE }; } const resp = await this.#fetch(new URL(path, this.url), init); diff --git a/sdk/js/src/credential.ts b/sdk/js/src/credential.ts index e4490170..8f979b60 100644 --- a/sdk/js/src/credential.ts +++ b/sdk/js/src/credential.ts @@ -14,6 +14,7 @@ import { create, fromBinary, fromJson, toBinary, toJson } from "@bufbuild/protobuf"; import { timestampDate, timestampFromDate, type Timestamp } from "@bufbuild/protobuf/wkt"; +import { toHex } from "./bytes.ts"; import { AuthFrameSchema, AuthResponseSchema, MemberCredentialSchema, type AuthResponse, type OIDCSession } from "./gen/sam_pb.ts"; /** What a member holds after enrolling: its biscuit and what it trusts. */ @@ -44,8 +45,8 @@ export interface MeshCredential { /** Whether a key trusted now was unknown when the credential was issued. */ export function credentialPredatesRotation(c: MeshCredential): boolean { - const issued = new Set(c.issuedUnderKeys.map((k) => Buffer.from(k).toString("hex"))); - return c.controlPlaneKeys.some((k) => !issued.has(Buffer.from(k).toString("hex"))); + const issued = new Set(c.issuedUnderKeys.map((k) => toHex(k))); + return c.controlPlaneKeys.some((k) => !issued.has(toHex(k))); } /** Seconds of validity left on the biscuit; negative once expired. */ @@ -78,7 +79,7 @@ export function credentialToJSON(c: MeshCredential): string { biscuit: c.biscuit, expireTime: timestampFromDate(new Date(c.expiration * 1000)), trustedKeys: c.controlPlaneKeys.map((k) => { - const receiveTime = c.extra?.receiveTime.get(Buffer.from(k).toString("hex")); + const receiveTime = c.extra?.receiveTime.get(toHex(k)); return receiveTime !== undefined ? { publicKey: k, receiveTime } : { publicKey: k }; }), issuedUnderKeys: c.issuedUnderKeys, @@ -97,7 +98,7 @@ export function credentialFromJSON(text: string): MeshCredential { const receiveTime = new Map(); for (const k of message.trustedKeys) { if (k.receiveTime !== undefined) { - receiveTime.set(Buffer.from(k.publicKey).toString("hex"), k.receiveTime); + receiveTime.set(toHex(k.publicKey), k.receiveTime); } } return { diff --git a/sdk/js/src/identity.ts b/sdk/js/src/identity.ts index 1744d8a2..c5439a00 100644 --- a/sdk/js/src/identity.ts +++ b/sdk/js/src/identity.ts @@ -14,13 +14,17 @@ // A mesh identity is an ed25519 key pair. Its peer ID is the one libp2p // derives, so the same key works in the SDK, in sam-node and on the wire. +// The arithmetic is @noble/curves, the implementation libp2p itself uses, +// so an identity is built and used the same way in Node and in a browser. -import { createPrivateKey, createPublicKey, sign, verify, type KeyObject } from "node:crypto"; +import { ed25519 } from "@noble/curves/ed25519.js"; import { peerIdFromString } from "@libp2p/peer-id"; import { encodeBase58 } from "./base58.ts"; +import { bytesEqual, concatBytes as concat } from "./bytes.ts"; const PUBLIC_KEY_SIZE = 32; const SEED_SIZE = 32; +const SIGNATURE_SIZE = 64; // libp2p crypto.proto PublicKey{Type: Ed25519 (1), Data: <32 bytes>}. const LIBP2P_PUBLIC_KEY_PREFIX = Uint8Array.of(0x08, 0x01, 0x12, 0x20); @@ -29,29 +33,21 @@ const LIBP2P_PRIVATE_KEY_PREFIX = Uint8Array.of(0x08, 0x01, 0x12, 0x40); // multihash: identity function (0x00), digest length 36. const IDENTITY_MULTIHASH_PREFIX = Uint8Array.of(0x00, 0x24); -// PKCS#8 wrapper for a raw ed25519 seed (RFC 8410): what node:crypto imports. -const PKCS8_ED25519_PREFIX = Uint8Array.of( - 0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20, -); -// SubjectPublicKeyInfo wrapper for a raw ed25519 public key (RFC 8410). -const SPKI_ED25519_PREFIX = Uint8Array.of(0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00); - -function concat(...parts: Uint8Array[]): Uint8Array { - const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0)); - let offset = 0; - for (const p of parts) { - out.set(p, offset); - offset += p.length; - } - return out; -} - function startsWith(bytes: Uint8Array, prefix: Uint8Array): boolean { return prefix.every((b, i) => bytes[i] === b); } -function publicKeyObject(publicKeyRaw: Uint8Array): KeyObject { - return createPublicKey({ key: Buffer.from(concat(SPKI_ED25519_PREFIX, publicKeyRaw)), format: "der", type: "spki" }); +// RFC 8032 verification, as Go crypto/ed25519 and node:crypto do it; the +// looser ZIP 215 rules noble defaults to accept signatures those reject. +function verifyRaw(publicKeyRaw: Uint8Array, data: Uint8Array, signature: Uint8Array): boolean { + if (publicKeyRaw.length !== PUBLIC_KEY_SIZE || signature.length !== SIGNATURE_SIZE) { + return false; + } + try { + return ed25519.verify(signature, data, publicKeyRaw, { zip215: false }); + } catch { + return false; + } } /** The libp2p protobuf encoding of an ed25519 public key. */ @@ -83,8 +79,6 @@ export function canonicalPeerId(text: string): string { } export class Identity { - readonly #privateKey: KeyObject; - readonly #publicKey: KeyObject; readonly #seed: Uint8Array; /** Raw 32-byte ed25519 public key. */ readonly publicKeyRaw: Uint8Array; @@ -95,17 +89,7 @@ export class Identity { throw new Error(`ed25519 seed must be ${SEED_SIZE} bytes, got ${seed.length}`); } this.#seed = new Uint8Array(seed); - this.#privateKey = createPrivateKey({ - key: Buffer.from(concat(PKCS8_ED25519_PREFIX, this.#seed)), - format: "der", - type: "pkcs8", - }); - this.#publicKey = createPublicKey(this.#privateKey); - const spki = new Uint8Array(this.#publicKey.export({ format: "der", type: "spki" })); - if (spki.length !== SPKI_ED25519_PREFIX.length + PUBLIC_KEY_SIZE || !startsWith(spki, SPKI_ED25519_PREFIX)) { - throw new Error("unexpected ed25519 public key encoding"); - } - this.publicKeyRaw = spki.slice(SPKI_ED25519_PREFIX.length); + this.publicKeyRaw = ed25519.getPublicKey(this.#seed); this.peerId = peerIdFromPublicKey(this.publicKeyRaw); } @@ -127,7 +111,7 @@ export class Identity { const seed = bytes.subarray(LIBP2P_PRIVATE_KEY_PREFIX.length, LIBP2P_PRIVATE_KEY_PREFIX.length + SEED_SIZE); const pub = bytes.subarray(LIBP2P_PRIVATE_KEY_PREFIX.length + SEED_SIZE); const id = new Identity(seed); - if (!pub.every((b, i) => id.publicKeyRaw[i] === b)) { + if (!bytesEqual(pub, id.publicKeyRaw)) { throw new Error("libp2p private key: public half does not match the seed"); } return id; @@ -144,18 +128,15 @@ export class Identity { } sign(data: Uint8Array): Uint8Array { - return new Uint8Array(sign(null, data, this.#privateKey)); + return ed25519.sign(data, this.#seed); } verify(data: Uint8Array, signature: Uint8Array): boolean { - return verify(null, data, this.#publicKey, signature); + return verifyRaw(this.publicKeyRaw, data, signature); } } /** Verifies an ed25519 signature with a raw 32-byte public key. */ export function verifyEd25519(publicKeyRaw: Uint8Array, data: Uint8Array, signature: Uint8Array): boolean { - if (publicKeyRaw.length !== PUBLIC_KEY_SIZE) { - return false; - } - return verify(null, data, publicKeyObject(publicKeyRaw), signature); + return verifyRaw(publicKeyRaw, data, signature); } diff --git a/sdk/js/src/mesh.ts b/sdk/js/src/mesh.ts index d5192207..f08be813 100644 --- a/sdk/js/src/mesh.ts +++ b/sdk/js/src/mesh.ts @@ -14,6 +14,7 @@ import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; import { join } from "node:path"; +import { toHex } from "./bytes.ts"; import { ControlPlaneClient, ROLE_NODE, type Enrollment } from "./controlplane.ts"; import { credentialFromJSON, credentialPredatesRotation, credentialTimeToLiveSeconds, credentialToJSON, encodeAuthFrame, type MeshCredential } from "./credential.ts"; import { Identity } from "./identity.ts"; @@ -213,8 +214,8 @@ export class AgentMesh { * credentials the new key signs verify before the next pull confirms it. */ addTrustedKey(key: Uint8Array): boolean { - const hex = Buffer.from(key).toString("hex"); - if (this.#credential.controlPlaneKeys.some((k) => Buffer.from(k).toString("hex") === hex)) { + const hex = toHex(key); + if (this.#credential.controlPlaneKeys.some((k) => toHex(k) === hex)) { return false; } this.#credential = { ...this.#credential, controlPlaneKeys: [...this.#credential.controlPlaneKeys, key] }; @@ -307,7 +308,7 @@ function newClient(options: AgentMeshOptions): ControlPlaneClient { function sameKeySet(a: Uint8Array[], b: Uint8Array[]): boolean { const hex = (keys: Uint8Array[]) => keys - .map((k) => Buffer.from(k).toString("hex")) + .map((k) => toHex(k)) .sort() .join(","); return hex(a) === hex(b); From 07f20080e3ffa3b9c65edc74573d0e1ad9b7de9d Mon Sep 17 00:00:00 2001 From: Antonio Ojea Date: Sat, 26 Sep 2026 13:37:50 +0000 Subject: [PATCH 4/9] sdk/js: speak /libp2p-http on the stream itself 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. --- sdk/js/src/bytes.ts | 2 +- sdk/js/src/http1.test.ts | 160 ++++++++++++ sdk/js/src/http1.ts | 423 ++++++++++++++++++++++++++++++++ sdk/js/src/index.ts | 3 +- sdk/js/src/libp2p-http-node.ts | 143 +++++++++++ sdk/js/src/libp2p-http.test.ts | 69 +++++- sdk/js/src/libp2p-http.ts | 428 ++++++++++++++++----------------- sdk/js/src/session.ts | 4 +- 8 files changed, 1012 insertions(+), 220 deletions(-) create mode 100644 sdk/js/src/http1.test.ts create mode 100644 sdk/js/src/http1.ts create mode 100644 sdk/js/src/libp2p-http-node.ts diff --git a/sdk/js/src/bytes.ts b/sdk/js/src/bytes.ts index ef8f0bea..f33245f2 100644 --- a/sdk/js/src/bytes.ts +++ b/sdk/js/src/bytes.ts @@ -46,7 +46,7 @@ export function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { return a.length === b.length && a.every((v, i) => v === b[i]); } -export function concatBytes(...parts: Uint8Array[]): Uint8Array { +export function concatBytes(...parts: Uint8Array[]): Uint8Array { const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0)); let offset = 0; for (const p of parts) { diff --git a/sdk/js/src/http1.test.ts b/sdk/js/src/http1.test.ts new file mode 100644 index 00000000..68ee6bbb --- /dev/null +++ b/sdk/js/src/http1.test.ts @@ -0,0 +1,160 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// The HTTP/1.1 codec on its own: what the ingress and the client agree on +// with go-libp2p-http, fed byte by byte so every split point is exercised. + +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + ByteReader, + HTTPParseError, + LAST_CHUNK, + MAX_HEAD_BYTES, + bodyStream, + encodeChunk, + encodeRequestHead, + encodeResponseHead, + readBody, + readRequestHead, + readResponseHead, + requestBodyFraming, + responseBodyFraming, + type ByteSource, +} from "./http1.ts"; + +const enc = new TextEncoder(); +const dec = new TextDecoder(); + +/** A source that hands out the text in pieces of the given size. */ +function source(text: string | Uint8Array, pieceSize = 1): ByteSource { + const bytes = typeof text === "string" ? enc.encode(text) : text; + let offset = 0; + return { + async read() { + if (offset >= bytes.length) { + return null; + } + const next = bytes.subarray(offset, offset + pieceSize); + offset += pieceSize; + return next; + }, + }; +} + +test("a request head parses however the bytes are split", async () => { + const wire = "POST /a2a/agent/tasks?x=1 HTTP/1.1\r\nHost: 12D3KooW\r\nX-Sam-Biscuit: abc=\r\nContent-Length: 2\r\n\r\n{}"; + for (const size of [1, 3, 7, 1000]) { + const reader = new ByteReader(source(wire, size)); + const head = await readRequestHead(reader); + assert.ok(head); + assert.equal(head.method, "POST"); + assert.equal(head.target, "/a2a/agent/tasks?x=1"); + assert.equal(head.headers.get("host"), "12D3KooW"); + assert.equal(head.headers.get("x-sam-biscuit"), "abc="); + assert.deepEqual(requestBodyFraming(head), { kind: "length", length: 2 }); + assert.equal(dec.decode(await readBody(reader, requestBodyFraming(head), 1024)), "{}"); + } + // Nothing at all is a closed stream, not an error. + assert.equal(await readRequestHead(new ByteReader(source(""))), null); +}); + +test("a request without a length has no body; malformed heads are refused", async () => { + const head = await readRequestHead(new ByteReader(source("GET /a2a/agent HTTP/1.1\r\nHost: x\r\n\r\n"))); + assert.deepEqual(requestBodyFraming(head!), { kind: "none" }); + for (const bad of ["GET /x\r\n\r\n", "GET /x HTTP/2\r\n\r\n", "GET /x HTTP/1.1\r\nbad header\r\n\r\n", "GET /x HTTP/1.1\r\n bad: fold\r\n\r\n", "GET /x HTTP/1.1\r\nHost: x"]) { + await assert.rejects(readRequestHead(new ByteReader(source(bad, 64))), HTTPParseError, bad); + } + const huge = "GET /x HTTP/1.1\r\n" + "X-Pad: " + "a".repeat(MAX_HEAD_BYTES) + "\r\n\r\n"; + await assert.rejects(readRequestHead(new ByteReader(source(huge, 4096))), HTTPParseError); + await assert.rejects(readRequestHead(new ByteReader(source("GET /x HTTP/1.1\r\nContent-Length: 1, 2\r\n\r\n"))).then((h) => requestBodyFraming(h!)), HTTPParseError); + await assert.rejects(readRequestHead(new ByteReader(source("GET /x HTTP/1.1\r\nTransfer-Encoding: gzip, chunked\r\n\r\n"))).then((h) => requestBodyFraming(h!)), HTTPParseError); +}); + +test("a chunked body is reassembled, extensions and trailers skipped, and streamed piece by piece", async () => { + const wire = "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nTransfer-Encoding: chunked\r\n\r\n" + "5;ext=1\r\nhello\r\n" + "6\r\n world\r\n" + "0\r\nX-Trailer: t\r\n\r\n"; + for (const size of [1, 2, 5, 64]) { + const reader = new ByteReader(source(wire, size)); + const head = await readResponseHead(reader); + assert.equal(head.status, 200); + assert.equal(head.statusText, "OK"); + const framing = responseBodyFraming("GET", head); + assert.deepEqual(framing, { kind: "chunked" }); + assert.equal(dec.decode(await readBody(reader, framing, 1024)), "hello world"); + } + let finished = false; + const reader = new ByteReader(source(wire, 1000)); + const head = await readResponseHead(reader); + const pieces: string[] = []; + const stream = bodyStream(reader, responseBodyFraming("GET", head), () => (finished = true)); + for await (const piece of stream) { + pieces.push(dec.decode(piece)); + } + assert.deepEqual(pieces, ["hello", " world"]); + assert.ok(finished); + // Each malformed size line is followed by a body that would otherwise + // parse, so only the size check can be what refuses it. + for (const bad of ["zz\r\nhello\r\n0\r\n\r\n", "5g\r\nhello\r\n0\r\n\r\n", "5 g\r\nhello\r\n0\r\n\r\n", "-5\r\nhello\r\n0\r\n\r\n", "\r\nhello\r\n0\r\n\r\n", "123456789\r\n0\r\n\r\n"]) { + await assert.rejects(readBody(new ByteReader(source(bad)), { kind: "chunked" }, 1024), HTTPParseError, bad); + } + for (const bad of ["5\r\nhello\r\n", "5\r\nhelloXX", "5\r\nhel"]) { + await assert.rejects(readBody(new ByteReader(source(bad)), { kind: "chunked" }, 1024), HTTPParseError, bad); + } + // Whitespace around the extension separator is allowed (RFC 9112 section 7.1.1). + assert.equal(dec.decode(await readBody(new ByteReader(source("5 ; ext=1\r\nhello\r\n0\r\n\r\n")), { kind: "chunked" }, 1024)), "hello"); +}); + +test("a response without framing runs to the end of the stream; some have no body at all", async () => { + const reader = new ByteReader(source("HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\nuntil the end", 3)); + const head = await readResponseHead(reader); + const framing = responseBodyFraming("GET", head); + assert.deepEqual(framing, { kind: "until-close" }); + assert.equal(dec.decode(await readBody(reader, framing, 1024)), "until the end"); + for (const [method, status] of [["HEAD", 200], ["GET", 204], ["GET", 304], ["GET", 101]] as const) { + const h = await readResponseHead(new ByteReader(source(`HTTP/1.1 ${status} X\r\nContent-Length: 10\r\n\r\n`))); + assert.deepEqual(responseBodyFraming(method, h), { kind: "none" }, `${method} ${status}`); + } + // A status line with no reason phrase is still a status line. + assert.equal((await readResponseHead(new ByteReader(source("HTTP/1.1 204\r\n\r\n")))).status, 204); + await assert.rejects(readResponseHead(new ByteReader(source("HTTP/1.1 twenty\r\n\r\n"))), HTTPParseError); + await assert.rejects(readResponseHead(new ByteReader(source(""))), HTTPParseError); +}); + +test("a line past the limit is refused however the bytes arrive", async () => { + assert.equal(await new ByteReader(source("abcde\r\nrest", 1000)).readLine(5), "abcde"); + assert.equal(await new ByteReader(source("abcde\r\nrest", 1)).readLine(5), "abcde"); + // In one piece, so the line is complete before the limit is compared. + await assert.rejects(new ByteReader(source("abcdef\r\nrest", 1000)).readLine(5), HTTPParseError); + await assert.rejects(new ByteReader(source("abcdef\r\nrest", 1)).readLine(5), HTTPParseError); + await assert.rejects(new ByteReader(source("a".repeat(100), 1)).readLine(5), HTTPParseError); +}); + +test("bodies past the limit are refused, up front when the length says so", async () => { + await assert.rejects(readBody(new ByteReader(source("x".repeat(20))), { kind: "length", length: 20 }, 10), HTTPParseError); + await assert.rejects(readBody(new ByteReader(source("x".repeat(20))), { kind: "until-close" }, 10), HTTPParseError); + await assert.rejects(readBody(new ByteReader(source("x".repeat(5))), { kind: "length", length: 20 }, 100), HTTPParseError); +}); + +test("heads and chunks are written as the other side reads them", async () => { + const headers = new Headers({ Host: "peer", "X-Sam-Biscuit": "abc=", "Content-Length": "0" }); + assert.equal(dec.decode(encodeRequestHead("GET", "/a2a/agent/card", headers)), "GET /a2a/agent/card HTTP/1.1\r\ncontent-length: 0\r\nhost: peer\r\nx-sam-biscuit: abc=\r\n\r\n"); + assert.equal(dec.decode(encodeResponseHead(404, "Not Found", new Headers({ "Content-Type": "text/plain" }))), "HTTP/1.1 404 Not Found\r\ncontent-type: text/plain\r\n\r\n"); + assert.equal(dec.decode(encodeResponseHead(200, "", new Headers())), "HTTP/1.1 200 \r\n\r\n"); + // Headers itself refuses a line break in a value; a stray one in the reason phrase is flattened. + assert.throws(() => new Headers({ "X-Bad": "a\r\nInjected: 1" }), TypeError); + assert.equal(dec.decode(encodeResponseHead(200, "OK\r\nInjected: 1", new Headers())), "HTTP/1.1 200 OK Injected: 1\r\n\r\n"); + const body = new Uint8Array([...encodeChunk(enc.encode("hello")), ...encodeChunk(enc.encode(" world")), ...LAST_CHUNK]); + assert.equal(dec.decode(body), "5\r\nhello\r\n6\r\n world\r\n0\r\n\r\n"); + assert.equal(dec.decode(await readBody(new ByteReader(source(body, 4)), { kind: "chunked" }, 1024)), "hello world"); +}); diff --git a/sdk/js/src/http1.ts b/sdk/js/src/http1.ts new file mode 100644 index 00000000..a0519be0 --- /dev/null +++ b/sdk/js/src/http1.ts @@ -0,0 +1,423 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// HTTP/1.1 on a libp2p stream, as go-libp2p-http speaks it: one request and +// one response per stream, the body framed by Content-Length or chunked +// (RFC 9112 section 6), a response without either running to the end of the +// stream. This is the whole of what a member needs to call a service or +// answer as one, so it is written here rather than borrowed from Node and +// runs wherever the SDK does, a browser included. + +import type { Stream } from "@libp2p/interface"; +import { concatBytes } from "./bytes.ts"; + +/** Longest request or response head (start line and headers) accepted. */ +export const MAX_HEAD_BYTES = 64 * 1024; +const MAX_CHUNK_LINE_BYTES = 1024; + +const CR = 0x0d; +const LF = 0x0a; +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +/** Where bytes come from: a chunk, or null when no more will arrive. */ +export interface ByteSource { + read(): Promise; +} + +/** Reads a libp2p stream, holding the remote back while nobody is reading. */ +export function streamSource(stream: Stream): ByteSource { + const queue: Uint8Array[] = []; + let ended = false; + let waiter: (() => void) | undefined; + const wake = () => { + waiter?.(); + waiter = undefined; + }; + stream.addEventListener("message", (evt) => { + queue.push(evt.data.subarray()); + stream.pause(); + wake(); + }); + const end = () => { + ended = true; + wake(); + }; + // 'end' fires once the read buffer has drained after the remote closed its + // writable end; 'remoteCloseWrite' can fire while data is still buffered. + stream.addEventListener("end", end); + stream.addEventListener("close", end); + if (stream.readableEnded) { + ended = true; + } + return { + async read() { + for (;;) { + const next = queue.shift(); + if (next !== undefined) { + if (queue.length === 0 && !ended) { + stream.resume(); + } + return next; + } + if (ended) { + return null; + } + stream.resume(); + await new Promise((resolve) => { + waiter = resolve; + }); + } + }, + }; +} + +/** Sends to a libp2p stream, waiting for the remote when the send buffer is full. */ +export async function sendAll(stream: Stream, data: Uint8Array): Promise { + if (data.length === 0) { + return; + } + if (!stream.send(data)) { + await stream.onDrain(); + } +} + +export class HTTPParseError extends Error { + constructor(message: string) { + super(message); + this.name = "HTTPParseError"; + } +} + +/** Bytes from a source with lookahead: lines and exact counts. */ +export class ByteReader { + #source: ByteSource; + #buf: Uint8Array = new Uint8Array(0); + #eof = false; + + constructor(source: ByteSource) { + this.#source = source; + } + + async #fill(): Promise { + if (this.#eof) { + return false; + } + const next = await this.#source.read(); + if (next === null) { + this.#eof = true; + return false; + } + this.#buf = this.#buf.length === 0 ? next : concatBytes(this.#buf, next); + return true; + } + + /** One CRLF-terminated line without its terminator, at most limit bytes; null at end of input before any byte. */ + async readLine(limit: number): Promise { + for (;;) { + const lf = this.#buf.indexOf(LF); + if (lf !== -1) { + const end = lf > 0 && this.#buf[lf - 1] === CR ? lf - 1 : lf; + if (end > limit) { + throw new HTTPParseError(`line exceeds ${limit} bytes`); + } + const line = decoder.decode(this.#buf.subarray(0, end)); + this.#buf = this.#buf.subarray(lf + 1); + return line; + } + // Room for the CRLF of a line exactly at the limit. + if (this.#buf.length > limit + 2) { + throw new HTTPParseError(`line exceeds ${limit} bytes`); + } + if (!(await this.#fill())) { + if (this.#buf.length === 0) { + return null; + } + throw new HTTPParseError("unexpected end of input in a line"); + } + } + } + + /** Exactly n bytes, or throws at end of input. */ + async readExactly(n: number): Promise { + while (this.#buf.length < n) { + if (!(await this.#fill())) { + throw new HTTPParseError(`unexpected end of input after ${this.#buf.length} of ${n} bytes`); + } + } + const out = this.#buf.slice(0, n); + this.#buf = this.#buf.subarray(n); + return out; + } + + /** Whatever is available next, at most n bytes; null at end of input. */ + async readSome(n: number): Promise { + if (this.#buf.length === 0 && !(await this.#fill())) { + return null; + } + const take = Math.min(n, this.#buf.length); + const out = this.#buf.slice(0, take); + this.#buf = this.#buf.subarray(take); + return out; + } +} + +export interface RequestHead { + method: string; + target: string; + headers: Headers; +} + +export interface ResponseHead { + status: number; + statusText: string; + headers: Headers; +} + +const TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; + +async function readHeaders(reader: ByteReader, budget: number): Promise { + const headers = new Headers(); + for (;;) { + const line = await reader.readLine(budget); + if (line === null) { + throw new HTTPParseError("unexpected end of input in headers"); + } + if (line === "") { + return headers; + } + budget -= line.length + 2; + if (budget < 0) { + throw new HTTPParseError(`head exceeds ${MAX_HEAD_BYTES} bytes`); + } + const colon = line.indexOf(":"); + if (colon <= 0 || line[0] === " " || line[0] === "\t") { + throw new HTTPParseError(`malformed header line ${JSON.stringify(line)}`); + } + const name = line.slice(0, colon); + if (!TOKEN.test(name)) { + throw new HTTPParseError(`malformed header name ${JSON.stringify(name)}`); + } + headers.append(name, line.slice(colon + 1).trim()); + } +} + +/** The request line and headers, as a server reads them. */ +export async function readRequestHead(reader: ByteReader): Promise { + const line = await reader.readLine(MAX_HEAD_BYTES); + if (line === null) { + return null; + } + const parts = line.split(" "); + if (parts.length !== 3 || !TOKEN.test(parts[0] as string) || parts[1] === "" || !/^HTTP\/1\.[01]$/.test(parts[2] as string)) { + throw new HTTPParseError(`malformed request line ${JSON.stringify(line)}`); + } + const headers = await readHeaders(reader, MAX_HEAD_BYTES - line.length - 2); + return { method: parts[0] as string, target: parts[1] as string, headers }; +} + +/** The status line and headers, as a client reads them. */ +export async function readResponseHead(reader: ByteReader): Promise { + const line = await reader.readLine(MAX_HEAD_BYTES); + if (line === null) { + throw new HTTPParseError("no response"); + } + const m = /^HTTP\/1\.[01] (\d{3})(?: (.*))?$/.exec(line); + if (m === null) { + throw new HTTPParseError(`malformed status line ${JSON.stringify(line)}`); + } + const headers = await readHeaders(reader, MAX_HEAD_BYTES - line.length - 2); + return { status: Number(m[1]), statusText: m[2] ?? "", headers }; +} + +/** How the bytes after a head are delimited. */ +export type BodyFraming = { kind: "none" } | { kind: "length"; length: number } | { kind: "chunked" } | { kind: "until-close" }; + +function framingOf(headers: Headers, whenUnframed: BodyFraming): BodyFraming { + const te = headers.get("transfer-encoding"); + if (te !== null) { + const codings = te.split(",").map((c) => c.trim().toLowerCase()); + if (codings.at(-1) !== "chunked" || codings.length !== 1) { + throw new HTTPParseError(`unsupported transfer-encoding ${JSON.stringify(te)}`); + } + return { kind: "chunked" }; + } + const cl = headers.get("content-length"); + if (cl !== null) { + const values = new Set(cl.split(",").map((v) => v.trim())); + if (values.size !== 1 || !/^\d{1,15}$/.test(cl.split(",")[0]?.trim() ?? "")) { + throw new HTTPParseError(`malformed content-length ${JSON.stringify(cl)}`); + } + const length = Number(values.values().next().value); + return length === 0 ? { kind: "none" } : { kind: "length", length }; + } + return whenUnframed; +} + +/** A request body without Content-Length or Transfer-Encoding is empty (RFC 9112 section 6.3). */ +export function requestBodyFraming(head: RequestHead): BodyFraming { + return framingOf(head.headers, { kind: "none" }); +} + +/** A response body ends where the sender says, or with the stream. */ +export function responseBodyFraming(method: string, head: ResponseHead): BodyFraming { + if (method === "HEAD" || head.status === 204 || head.status === 304 || (head.status >= 100 && head.status < 200)) { + return { kind: "none" }; + } + return framingOf(head.headers, { kind: "until-close" }); +} + +async function* chunkedBody(reader: ByteReader): AsyncGenerator { + for (;;) { + const line = await reader.readLine(MAX_CHUNK_LINE_BYTES); + if (line === null) { + throw new HTTPParseError("unexpected end of input in chunked body"); + } + // The whole size token must be hex, else "5g" would read as 5 and the + // two ends would disagree on where the chunk ends. Eight digits bound + // the size to 4 GiB, well within what parseInt represents exactly. + const sizeText = (line.split(";")[0] as string).trim(); + if (!/^[0-9A-Fa-f]{1,8}$/.test(sizeText)) { + throw new HTTPParseError(`malformed chunk size ${JSON.stringify(line)}`); + } + const size = parseInt(sizeText, 16); + if (size === 0) { + // Trailer fields, then the empty line that ends the message. + for (let t = await reader.readLine(MAX_HEAD_BYTES); t !== ""; t = await reader.readLine(MAX_HEAD_BYTES)) { + if (t === null) { + throw new HTTPParseError("unexpected end of input in trailers"); + } + } + return; + } + let remaining = size; + while (remaining > 0) { + const part = await reader.readSome(remaining); + if (part === null) { + throw new HTTPParseError("unexpected end of input in a chunk"); + } + remaining -= part.length; + yield part; + } + if ((await reader.readLine(2)) !== "") { + throw new HTTPParseError("chunk data not followed by CRLF"); + } + } +} + +/** The body bytes, as the framing delimits them, in the pieces they arrive. */ +export async function* bodyChunks(reader: ByteReader, framing: BodyFraming): AsyncGenerator { + switch (framing.kind) { + case "none": + return; + case "length": { + let remaining = framing.length; + while (remaining > 0) { + const part = await reader.readSome(remaining); + if (part === null) { + throw new HTTPParseError(`body ended ${remaining} bytes short of content-length`); + } + remaining -= part.length; + yield part; + } + return; + } + case "chunked": + yield* chunkedBody(reader); + return; + case "until-close": + for (let part = await reader.readSome(64 * 1024); part !== null; part = await reader.readSome(64 * 1024)) { + yield part; + } + return; + } +} + +/** A whole body, refused past the limit. */ +export async function readBody(reader: ByteReader, framing: BodyFraming, limit: number): Promise> { + if (framing.kind === "length" && framing.length > limit) { + throw new HTTPParseError(`body of ${framing.length} bytes exceeds ${limit}`); + } + const parts: Uint8Array[] = []; + let size = 0; + for await (const part of bodyChunks(reader, framing)) { + size += part.length; + if (size > limit) { + throw new HTTPParseError(`body exceeds ${limit} bytes`); + } + parts.push(part); + } + return concatBytes(...parts); +} + +/** The body as a web stream; onDone runs once it is drained, failed or cancelled. */ +export function bodyStream(reader: ByteReader, framing: BodyFraming, onDone: (err?: Error) => void): ReadableStream { + const chunks = bodyChunks(reader, framing); + let done = false; + const finish = (err?: Error) => { + if (!done) { + done = true; + onDone(err); + } + }; + return new ReadableStream({ + async pull(controller) { + try { + const next = await chunks.next(); + if (next.done) { + controller.close(); + finish(); + } else { + controller.enqueue(next.value); + } + } catch (err) { + const e = err instanceof Error ? err : new Error(String(err)); + controller.error(e); + finish(e); + } + }, + cancel(reason) { + finish(reason instanceof Error ? reason : new Error(String(reason))); + }, + }); +} + +function headerLines(headers: Headers): string { + let out = ""; + headers.forEach((value, name) => { + if (/[\r\n]/.test(value)) { + throw new HTTPParseError(`header ${name} contains a line break`); + } + out += `${name}: ${value}\r\n`; + }); + return out; +} + +/** A request head on the wire. */ +export function encodeRequestHead(method: string, target: string, headers: Headers): Uint8Array { + return encoder.encode(`${method} ${target} HTTP/1.1\r\n${headerLines(headers)}\r\n`); +} + +/** A response head on the wire. */ +export function encodeResponseHead(status: number, statusText: string, headers: Headers): Uint8Array { + return encoder.encode(`HTTP/1.1 ${status} ${statusText.replace(/[\r\n]/g, " ")}\r\n${headerLines(headers)}\r\n`); +} + +/** One chunk of a chunked body. */ +export function encodeChunk(data: Uint8Array): Uint8Array { + return concatBytes(encoder.encode(`${data.length.toString(16)}\r\n`), data, encoder.encode("\r\n")); +} + +/** The end of a chunked body. */ +export const LAST_CHUNK = encoder.encode("0\r\n\r\n"); diff --git a/sdk/js/src/index.ts b/sdk/js/src/index.ts index 91bbaa8a..5648df3b 100644 --- a/sdk/js/src/index.ts +++ b/sdk/js/src/index.ts @@ -51,13 +51,13 @@ export { HTTP_PROTOCOL, MESH_PATH_PREFIX, a2aEndpoint, + admitIngress, fetchOverStream, httpIngressHandler, httpRequestOverStream, meshHTTPTarget, meshURL, splitMeshURL, - streamToNodeDuplex, type A2AEndpoint, type A2AEndpointSpec, type HTTPHandler, @@ -67,5 +67,6 @@ export { type NodeRequestListener, type ProviderOptions, } from "./libp2p-http.ts"; +export { nodeIngressHandler, streamToNodeDuplex } from "./libp2p-http-node.ts"; export { BASELINE_DATALOG } from "./gen/datalog.ts"; export { BanSet, EVENT_FRESHNESS_MS, GOSSIP_EVENTS_TOPIC, verifyMeshEvent, type VerifiedMeshEvent } from "./sync.ts"; diff --git a/sdk/js/src/libp2p-http-node.ts b/sdk/js/src/libp2p-http-node.ts new file mode 100644 index 00000000..3b4bbebc --- /dev/null +++ b/sdk/js/src/libp2p-http-node.ts @@ -0,0 +1,143 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// The Node side of the /libp2p-http ingress: an A2A endpoint answered by a +// Node request listener (an Express app with the A2A SDK's handlers mounted) +// is served by Node's own HTTP server over the libp2p stream, so the +// listener sees a real IncomingMessage and ServerResponse. Admission is the +// same as for a handler or url target; only who parses the request differs. + +import type { Connection, Stream, StreamHandler } from "@libp2p/interface"; +import http from "node:http"; +import { Duplex } from "node:stream"; +import { AUTH_HANDSHAKE_TIMEOUT_MS } from "./auth.ts"; +import { + HEADER_PEER_ID, + HEADER_SAM_AGENT, + HEADER_SAM_BISCUIT, + HEADER_SAM_NO_TRAILING_SLASH, + admitIngress, + httpIngressHandler, + type A2AEndpoint, + type NodeRequestListener, + type ProviderOptions, +} from "./libp2p-http.ts"; + +interface StreamSocket extends Duplex { + remotePeer: string; +} + +/** + * Bridges a libp2p stream to a Node Duplex so Node's own HTTP parser and + * client can run over it. + */ +export function streamToNodeDuplex(stream: Stream, remotePeer: string): StreamSocket { + const duplex = new Duplex({ + read() { + stream.resume(); + }, + write(chunk: Uint8Array, _encoding, callback) { + if (stream.send(chunk)) { + callback(); + } else { + stream.addEventListener("drain", () => callback(), { once: true }); + } + }, + final(callback) { + stream.close().then( + () => callback(), + (err: Error) => callback(err), + ); + }, + destroy(err, callback) { + if (err !== null) { + stream.abort(err); + } else { + void stream.close().catch(() => {}); + } + callback(err); + }, + }) as StreamSocket; + duplex.remotePeer = remotePeer; + stream.addEventListener("message", (evt) => { + if (!duplex.push(Buffer.from(evt.data.subarray()))) { + stream.pause(); + } + }); + const end = () => { + if (!duplex.readableEnded) { + duplex.push(null); + } + }; + stream.addEventListener("remoteCloseWrite", end); + stream.addEventListener("close", end); + return duplex; +} + +function nodeHeaders(req: http.IncomingMessage): Headers { + const headers = new Headers(); + for (const [name, value] of Object.entries(req.headers)) { + for (const v of Array.isArray(value) ? value : value === undefined ? [] : [value]) { + headers.append(name, v); + } + } + return headers; +} + +async function serveListener(req: http.IncomingMessage, res: http.ServerResponse, endpoint: A2AEndpoint, listener: NodeRequestListener, options: ProviderOptions): Promise { + const remotePeer = (req.socket as unknown as StreamSocket).remotePeer; + const admission = await admitIngress({ target: req.url ?? "/", headers: nodeHeaders(req), remotePeer }, endpoint, options); + if ("status" in admission) { + res.writeHead(admission.status, { "content-type": "text/plain; charset=utf-8" }); + res.end(admission.text + "\n"); + return; + } + // The listener sees the request as a backend behind sam-node would: the + // path relative to the service, the verified caller, never the biscuit. + // X-Peer-Id is set, not added, so an inbound value cannot pose as the + // verified peer. + req.url = admission.path; + delete req.headers[HEADER_SAM_BISCUIT]; + delete req.headers[HEADER_SAM_AGENT]; + req.headers[HEADER_PEER_ID] = remotePeer; + if (admission.noTrailingSlash) { + req.headers[HEADER_SAM_NO_TRAILING_SLASH] = "true"; + } else { + delete req.headers[HEADER_SAM_NO_TRAILING_SLASH]; + } + listener(req, res); +} + +/** + * Server side of /libp2p-http on Node: a listener target is served by Node's + * HTTP server on the stream, any other by the runtime-neutral ingress. + */ +export function nodeIngressHandler(endpoint: A2AEndpoint, options: ProviderOptions): StreamHandler { + if (!("listener" in endpoint.target)) { + return httpIngressHandler(endpoint, options); + } + const listener = endpoint.target.listener; + const server = http.createServer({ keepAlive: false }, (req, res) => { + void serveListener(req, res, endpoint, listener, options).catch((err: unknown) => { + if (!res.headersSent) { + res.writeHead(500, { "content-type": "text/plain" }); + } + res.end(`ingress error: ${err instanceof Error ? err.message : String(err)}\n`); + }); + }); + server.headersTimeout = AUTH_HANDSHAKE_TIMEOUT_MS; + return (stream: Stream, connection: Connection) => { + server.emit("connection", streamToNodeDuplex(stream, connection.remotePeer.toString())); + }; +} diff --git a/sdk/js/src/libp2p-http.test.ts b/sdk/js/src/libp2p-http.test.ts index 3e11af9f..83350c15 100644 --- a/sdk/js/src/libp2p-http.test.ts +++ b/sdk/js/src/libp2p-http.test.ts @@ -37,9 +37,9 @@ import { meshHTTPTarget, meshURL, splitMeshURL, - streamToNodeDuplex, type ProviderOptions, } from "./libp2p-http.ts"; +import { nodeIngressHandler, streamToNodeDuplex } from "./libp2p-http-node.ts"; type Wasm = Awaited>; @@ -48,6 +48,7 @@ let cpKeyPair: InstanceType; let cpKey: Uint8Array; let agent: Libp2p; let listenerAgent: Libp2p; +let handlerAgent: Libp2p; let caller: Libp2p; let callerBiscuit: Uint8Array; let guestBiscuit: Uint8Array; @@ -148,10 +149,36 @@ before(async () => { }; await listenerAgent.handle( HTTP_PROTOCOL, - httpIngressHandler(a2aEndpoint({ name: "worker", listener }), providerOptions(mint(listenerAgent.peerId.toString(), ROLE_NODE))), + nodeIngressHandler(a2aEndpoint({ name: "worker", listener }), providerOptions(mint(listenerAgent.peerId.toString(), ROLE_NODE))), { runOnLimitedConnection: true }, ); + // An agent answering with a fetch handler, the shape a browser member uses: + // echoes what it was given and, on /stream, writes three SSE events one at + // a time into a streaming body. + handlerAgent = await newHost(); + const handler = async (request: Request): Promise => { + const url = new URL(request.url); + if (url.pathname === "/stream") { + const encoder = new TextEncoder(); + let i = 0; + const body = new ReadableStream({ + async pull(controller) { + await new Promise((r) => setTimeout(r, 10)); + controller.enqueue(encoder.encode(`data: ${JSON.stringify({ event: i })}\n\n`)); + if (++i === 3) { + controller.close(); + } + }, + }); + return new Response(body, { headers: { "content-type": "text/event-stream" } }); + } + return Response.json({ path: url.pathname + url.search, method: request.method, peer: request.headers.get("x-peer-id"), biscuit: request.headers.get("x-sam-biscuit"), echo: await request.text() }); + }; + await handlerAgent.handle(HTTP_PROTOCOL, httpIngressHandler(a2aEndpoint({ handler }), providerOptions(mint(handlerAgent.peerId.toString(), ROLE_NODE))), { + runOnLimitedConnection: true, + }); + caller = await newHost(); callerBiscuit = mint(caller.peerId.toString(), ROLE_NODE); guestBiscuit = mint(caller.peerId.toString(), "sam:role:guest"); @@ -161,6 +188,7 @@ after(async () => { await caller.stop(); await agent.stop(); await listenerAgent.stop(); + await handlerAgent.stop(); await new Promise((resolve) => backend.close(() => resolve())); }); @@ -209,6 +237,43 @@ test("a fetch over the stream delivers an SSE body event by event", async () => ); }); +test("a fetch handler sees the caller and the path, and its streaming body goes out as it is written", async () => { + const conn = await dial(handlerAgent); + const res = await httpRequestOverStream(conn, callerBiscuit, "a2a://agent", "/tasks?x=1", { method: "POST", headers: { "x-sam-biscuit": "spoof", "content-type": "text/plain" }, body: "hello" }); + assert.equal(res.status, 200); + assert.deepEqual(JSON.parse(res.text()), { path: "/tasks?x=1", method: "POST", peer: caller.peerId.toString(), biscuit: null, echo: "hello" }); + + const response = await fetchOverStream(conn, callerBiscuit, new Request(meshURL(handlerAgent.peerId.toString(), "a2a://agent", "/stream"))); + assert.equal(response.status, 200); + assert.equal(response.headers.get("content-type"), "text/event-stream"); + const chunks: string[] = []; + const reader = (response.body as ReadableStream).getReader(); + for (let next = await reader.read(); !next.done; next = await reader.read()) { + chunks.push(new TextDecoder().decode(next.value)); + } + assert.ok(chunks.length >= 3, `want at least three chunks, got ${JSON.stringify(chunks)}`); + assert.deepEqual(chunks.join("").split("\n").filter((l) => l.startsWith("data:")), ['data: {"event":0}', 'data: {"event":1}', 'data: {"event":2}']); + + // Node's own client reads the chunked response the ingress writes. + const stream = await conn.newStream(HTTP_PROTOCOL, { runOnLimitedConnection: true }); + const socket = streamToNodeDuplex(stream, handlerAgent.peerId.toString()); + const viaNode = await new Promise<{ status: number; body: string }>((resolve, reject) => { + const req = http.request( + { method: "GET", path: "/a2a/agent/card", headers: { host: handlerAgent.peerId.toString(), "x-sam-biscuit": Buffer.from(callerBiscuit).toString("base64") }, createConnection: () => socket }, + (r) => { + let body = ""; + r.on("data", (c: Buffer) => (body += c.toString())); + r.on("end", () => resolve({ status: r.statusCode ?? 0, body })); + }, + ); + req.on("error", reject); + req.end(); + }); + socket.destroy(); + assert.equal(viaNode.status, 200); + assert.equal(JSON.parse(viaNode.body).path, "/card"); +}); + test("a Node request listener sees the path relative to the agent and the verified caller", async () => { const conn = await dial(listenerAgent); const res = await httpRequestOverStream(conn, callerBiscuit, "a2a://worker", "/tasks/1?q=1", { method: "POST", headers: { "x-sam-biscuit": "spoof" }, body: "{}" }); diff --git a/sdk/js/src/libp2p-http.ts b/sdk/js/src/libp2p-http.ts index 0b4871f9..f7276c7e 100644 --- a/sdk/js/src/libp2p-http.ts +++ b/sdk/js/src/libp2p-http.ts @@ -15,16 +15,33 @@ // The /libp2p-http protocol (go-libp2p-http): the client that calls inference // and A2A services on the mesh, and the ingress that accepts A2A requests for // this member's own agent, gated by the authorizer the way sam-node gates its -// ingress (StartIngressServer in internal/node). Node's own HTTP server and -// client run over the libp2p stream, so bodies stream in both directions and -// an A2A message/stream (SSE) works. +// ingress (StartIngressServer in internal/node). Requests and responses are +// framed by the codec in http1.ts on the libp2p stream itself, so bodies +// stream in both directions, an A2A message/stream (SSE) works, and none of +// it needs Node: a member in a browser calls and answers the same way. A +// Node request listener (an Express app) is served by libp2p-http-node.ts. import type { Connection, Stream, StreamHandler } from "@libp2p/interface"; -import http from "node:http"; -import { Duplex, Readable } from "node:stream"; import { AUTH_HANDSHAKE_TIMEOUT_MS } from "./auth.ts"; import { AuthorizationError, authorizeCaller, type ProviderAuthorizerOptions } from "./authorizer.ts"; import type { VerifiedBiscuit } from "./biscuit.ts"; +import { fromBase64, toBase64 } from "./bytes.ts"; +import { + ByteReader, + HTTPParseError, + LAST_CHUNK, + bodyStream, + encodeChunk, + encodeRequestHead, + encodeResponseHead, + readBody, + readRequestHead, + readResponseHead, + requestBodyFraming, + responseBodyFraming, + sendAll, + streamSource, +} from "./http1.ts"; import { canonicalPeerId } from "./identity.ts"; /** go-libp2p-http's protocol: plain HTTP/1.1 on a stream, one request per stream. */ @@ -50,14 +67,21 @@ export const DEFAULT_A2A_NAME = "agent"; */ export const MESH_PATH_PREFIX = "/sam/"; -const MAX_INGRESS_BODY_BYTES = 8 * 1024 * 1024; +/** Largest request body the ingress reads whole for a handler or url target. */ +export const MAX_INGRESS_BODY_BYTES = 8 * 1024 * 1024; const REQUEST_TIMEOUT_MS = 60_000; /** A fetch-style handler in this process. */ export type HTTPHandler = (request: Request, caller: VerifiedBiscuit) => Promise | Response; -/** A Node request listener in this process, such as an Express app with the A2A SDK's handlers mounted. */ -export type NodeRequestListener = (req: http.IncomingMessage, res: http.ServerResponse) => void; +/** + * A request listener of the runtime's own HTTP server, such as an Express + * app with the A2A SDK's handlers mounted. On Node it is + * (req: http.IncomingMessage, res: http.ServerResponse) => void, served by + * libp2p-http-node.ts; a browser has no such server and answers 501. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type NodeRequestListener = (req: any, res: any) => void; /** * This member's agent as other members reach it: `a2a://`, answered by @@ -97,239 +121,210 @@ export interface ProviderOptions extends ProviderAuthorizerOptions { onAuthorized?(peerId: string, verified: VerifiedBiscuit, targetService: string): void; } -interface StreamSocket extends Duplex { - remotePeer: string; +function hasDotSegment(path: string): boolean { + return path.split("/").some((seg) => seg === "." || seg === ".."); } -/** - * Bridges a libp2p stream to a Node Duplex so Node's own HTTP parser and - * client can run over it. - */ -export function streamToNodeDuplex(stream: Stream, remotePeer: string): StreamSocket { - const duplex = new Duplex({ - read() { - stream.resume(); - }, - write(chunk: Uint8Array, _encoding, callback) { - if (stream.send(chunk)) { - callback(); - } else { - stream.addEventListener("drain", () => callback(), { once: true }); - } - }, - final(callback) { - stream.close().then( - () => callback(), - (err: Error) => callback(err), - ); - }, - destroy(err, callback) { - if (err !== null) { - stream.abort(err); - } else { - void stream.close().catch(() => {}); - } - callback(err); - }, - }) as StreamSocket; - duplex.remotePeer = remotePeer; - stream.addEventListener("message", (evt) => { - if (!duplex.push(Buffer.from(evt.data.subarray()))) { - stream.pause(); - } - }); - const end = () => { - if (!duplex.readableEnded) { - duplex.push(null); - } - }; - stream.addEventListener("remoteCloseWrite", end); - stream.addEventListener("close", end); - return duplex; +/** What the ingress looks at before anything reaches the agent. */ +export interface IngressRequest { + /** The request target as it came off the wire, path and query. */ + target: string; + headers: Headers; + remotePeer: string; } -/** Reads a whole body, bounded. */ -async function readBody(readable: Readable, limit: number): Promise { - const chunks: Buffer[] = []; - let size = 0; - for await (const chunk of readable) { - const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array); - size += buf.length; - if (size > limit) { - throw new Error(`body exceeds ${limit} bytes`); - } - chunks.push(buf); - } - return Buffer.concat(chunks); +/** A refusal, with the text the caller gets. */ +export interface IngressRefusal { + status: number; + text: string; } -function hasDotSegment(path: string): boolean { - return path.split("/").some((seg) => seg === "." || seg === ".."); +/** An authorized request for the endpoint, with what the agent may see. */ +export interface IngressAdmission { + verified: VerifiedBiscuit; + /** The path relative to the service, query included. */ + path: string; + noTrailingSlash: boolean; } /** - * Server side of /libp2p-http, as sam-node's StartIngressServer: the path is - * //[/], the caller's biscuit is X-Sam-Biscuit, and the - * request is authorized for :// before anything is forwarded. Only - * the agent's own endpoint is answered; anything else is 404 after - * authorization, so an unauthorized caller learns nothing about it. + * Server-side admission of /libp2p-http, as sam-node's StartIngressServer: + * the path is //[/], the caller's biscuit is + * X-Sam-Biscuit, and the request is authorized for :// before + * anything is forwarded. Only the agent's own endpoint is answered; anything + * else is 404 after authorization, so an unauthorized caller learns nothing + * about it. */ -export function httpIngressHandler(endpoint: A2AEndpoint, options: ProviderOptions): StreamHandler { - const server = http.createServer({ keepAlive: false }, (req, res) => { - void handleIngress(req, res, endpoint, options).catch((err: unknown) => { - if (!res.headersSent) { - res.writeHead(500, { "content-type": "text/plain" }); - } - res.end(`ingress error: ${err instanceof Error ? err.message : String(err)}\n`); - }); - }); - server.headersTimeout = AUTH_HANDSHAKE_TIMEOUT_MS; - return (stream: Stream, connection: Connection) => { - server.emit("connection", streamToNodeDuplex(stream, connection.remotePeer.toString())); - }; -} - -async function handleIngress(req: http.IncomingMessage, res: http.ServerResponse, endpoint: A2AEndpoint, options: ProviderOptions): Promise { - const remotePeer = (req.socket as unknown as StreamSocket).remotePeer; - const reply = (status: number, text: string) => { - res.writeHead(status, { "content-type": "text/plain; charset=utf-8" }); - res.end(text + "\n"); - }; - - const rawURL = req.url ?? "/"; +export async function admitIngress(req: IngressRequest, endpoint: A2AEndpoint, options: ProviderOptions): Promise { // Policy is decided on the // prefix; a dot segment in what // follows could resolve to a sibling path on the backend. Checked on the // raw path, before URL parsing normalizes it away. - if (hasDotSegment(rawURL.split("?")[0] as string)) { - reply(400, "Invalid path"); - return; + if (hasDotSegment(req.target.split("?")[0] as string)) { + return { status: 400, text: "Invalid path" }; } - const url = new URL(rawURL, "http://mesh.invalid"); + const url = new URL(req.target, "http://mesh.invalid"); const parts = url.pathname.replace(/^\//, "").split("/"); if (parts.length < 2 || parts[0] === "" || parts[1] === "") { - reply(400, "Invalid path"); - return; + return { status: 400, text: "Invalid path" }; } const [serviceType, serviceName, ...rest] = parts as [string, string, ...string[]]; if (serviceType !== "inference" && serviceType !== "a2a" && serviceType !== "mcp") { - reply(400, "Invalid service type"); - return; + return { status: 400, text: "Invalid service type" }; } const upstreamPath = rest.join("/"); - const biscuitB64 = req.headers[HEADER_SAM_BISCUIT]; - if (typeof biscuitB64 !== "string" || biscuitB64 === "") { - reply(401, "Missing X-Sam-Biscuit header"); - return; + const biscuitB64 = req.headers.get(HEADER_SAM_BISCUIT); + if (biscuitB64 === null || biscuitB64 === "") { + return { status: 401, text: "Missing X-Sam-Biscuit header" }; } let biscuit: Uint8Array; try { - biscuit = new Uint8Array(Buffer.from(biscuitB64, "base64")); + biscuit = fromBase64(biscuitB64); if (biscuit.length === 0) { throw new Error("empty"); } } catch { - reply(400, "Invalid X-Sam-Biscuit encoding"); - return; + return { status: 400, text: "Invalid X-Sam-Biscuit encoding" }; } const targetService = `${serviceType}://${serviceName}`; - if (options.isBanned?.(remotePeer) === true) { - reply(403, "Authorization failed"); - return; + if (options.isBanned?.(req.remotePeer) === true) { + return { status: 403, text: "Authorization failed" }; } - const agentHeader = req.headers[HEADER_SAM_AGENT]; let verified: VerifiedBiscuit; try { verified = await authorizeCaller( - { biscuit, peerId: remotePeer, targetService, protocol: HTTP_PROTOCOL, agent: typeof agentHeader === "string" ? agentHeader : "" }, + { biscuit, peerId: req.remotePeer, targetService, protocol: HTTP_PROTOCOL, agent: req.headers.get(HEADER_SAM_AGENT) ?? "" }, options, ); } catch (err) { if (err instanceof AuthorizationError) { - reply(403, "Authorization failed"); - return; + return { status: 403, text: "Authorization failed" }; } throw err; } - options.onAuthorized?.(remotePeer, verified, targetService); + options.onAuthorized?.(req.remotePeer, verified, targetService); // Under the type the policy was evaluated on. if (targetService !== endpoint.service) { - reply(404, "Service not found"); - return; + return { status: 404, text: "Service not found" }; } + return { verified, path: "/" + upstreamPath + url.search, noTrailingSlash: upstreamPath === "" && rest.length === 0 }; +} - const path = "/" + upstreamPath + url.search; - const noTrailingSlash = upstreamPath === "" && rest.length === 0; - - if ("listener" in endpoint.target) { - // The listener sees the request as a backend behind sam-node would: the - // path relative to the service, the verified caller, never the biscuit. - // X-Peer-Id is set, not added, so an inbound value cannot pose as the - // verified peer. - req.url = path; - delete req.headers[HEADER_SAM_BISCUIT]; - delete req.headers[HEADER_SAM_AGENT]; - req.headers[HEADER_PEER_ID] = remotePeer; - if (noTrailingSlash) { - req.headers[HEADER_SAM_NO_TRAILING_SLASH] = "true"; - } else { - delete req.headers[HEADER_SAM_NO_TRAILING_SLASH]; - } - endpoint.target.listener(req, res); - return; - } +/** Headers of the mesh datapath and of the hop itself, not passed on to the agent. */ +const HOP_HEADERS = new Set([HEADER_SAM_BISCUIT, HEADER_SAM_AGENT, HEADER_SAM_NO_TRAILING_SLASH, HEADER_PEER_ID, "host", "connection", "transfer-encoding", "content-length", "keep-alive"]); +/** + * The headers the agent sees: the request's own, less the datapath's, with + * X-Peer-Id set (not added) to the verified caller so an inbound value + * cannot pose as the peer. + */ +export function agentHeaders(inbound: Headers, remotePeer: string, noTrailingSlash: boolean): Headers { const headers = new Headers(); - for (const [k, v] of Object.entries(req.headers)) { - if (v === undefined || k === HEADER_SAM_BISCUIT || k === HEADER_SAM_AGENT || k === HEADER_SAM_NO_TRAILING_SLASH || k === HEADER_PEER_ID || k === "host" || k === "connection" || k === "transfer-encoding" || k === "content-length") { - continue; - } - for (const value of Array.isArray(v) ? v : [v]) { - headers.append(k, value); + inbound.forEach((value, name) => { + if (!HOP_HEADERS.has(name)) { + headers.append(name, value); } - } + }); headers.set(HEADER_PEER_ID, remotePeer); if (noTrailingSlash) { headers.set(HEADER_SAM_NO_TRAILING_SLASH, "true"); } + return headers; +} - const method = req.method ?? "GET"; - const body: Uint8Array | undefined = - method === "GET" || method === "HEAD" ? undefined : new Uint8Array(await readBody(req, MAX_INGRESS_BODY_BYTES)); - const init: RequestInit = { method, headers }; - if (body !== undefined) { - init.body = body; - } +/** A plain-text refusal, as sam-node's ingress writes one. */ +export function refusalResponse(refusal: IngressRefusal): Response { + return new Response(refusal.text + "\n", { status: refusal.status, headers: { "content-type": "text/plain; charset=utf-8" } }); +} - let response: Response; - if ("url" in endpoint.target) { - const base = endpoint.target.url.replace(/\/$/, ""); - response = await fetch(base + path, { ...init, redirect: "manual" }); - } else { - response = await endpoint.target.handler(new Request("http://" + endpoint.name + path, init), verified); +async function writeResponse(stream: Stream, response: Response, headOnly: boolean): Promise { + const headers = new Headers(); + response.headers.forEach((value, name) => { + if (name !== "content-length" && name !== "transfer-encoding" && name !== "connection") { + headers.set(name, value); + } + }); + headers.set("connection", "close"); + const body = headOnly ? null : response.body; + if (body === null) { + headers.set("content-length", "0"); + await sendAll(stream, encodeResponseHead(response.status, response.statusText, headers)); + return; + } + // The length is not known up front, so each piece goes as a chunk as soon + // as the agent writes it; an SSE event reaches the caller before the next. + headers.set("transfer-encoding", "chunked"); + await sendAll(stream, encodeResponseHead(response.status, response.statusText, headers)); + const reader = body.getReader(); + try { + for (let next = await reader.read(); !next.done; next = await reader.read()) { + if (next.value.length > 0) { + await sendAll(stream, encodeChunk(next.value)); + } + } + } finally { + reader.releaseLock(); } + await sendAll(stream, LAST_CHUNK); +} - const outHeaders: Record = {}; - response.headers.forEach((value, key) => { - if (key === "content-length" || key === "transfer-encoding" || key === "connection") { +async function serveIngress(stream: Stream, remotePeer: string, endpoint: A2AEndpoint, options: ProviderOptions): Promise { + const reader = new ByteReader(streamSource(stream)); + const headTimer = setTimeout(() => stream.abort(new Error(`no request head from ${remotePeer} within ${AUTH_HANDSHAKE_TIMEOUT_MS}ms`)), AUTH_HANDSHAKE_TIMEOUT_MS); + let response: Response; + let headOnly = false; + try { + let head; + try { + head = await readRequestHead(reader); + } finally { + clearTimeout(headTimer); + } + if (head === null) { return; } - outHeaders[key] = value; - }); - res.writeHead(response.status, outHeaders); - if (response.body === null) { - res.end(); - return; + headOnly = head.method === "HEAD"; + const admission = await admitIngress({ target: head.target, headers: head.headers, remotePeer }, endpoint, options); + if ("status" in admission) { + response = refusalResponse(admission); + } else if ("listener" in endpoint.target) { + response = refusalResponse({ status: 501, text: "A request listener is not served in this runtime" }); + } else { + const headers = agentHeaders(head.headers, remotePeer, admission.noTrailingSlash); + const init: RequestInit = { method: head.method, headers }; + if (head.method !== "GET" && head.method !== "HEAD") { + init.body = await readBody(reader, requestBodyFraming(head), MAX_INGRESS_BODY_BYTES); + } + if ("url" in endpoint.target) { + const base = endpoint.target.url.replace(/\/$/, ""); + response = await fetch(base + admission.path, { ...init, redirect: "manual" }); + } else { + response = await endpoint.target.handler(new Request("http://" + endpoint.name + admission.path, init), admission.verified); + } + } + } catch (err) { + if (err instanceof HTTPParseError) { + response = refusalResponse({ status: 400, text: `Bad request: ${err.message}` }); + } else { + response = refusalResponse({ status: 500, text: `ingress error: ${err instanceof Error ? err.message : String(err)}` }); + } } - await new Promise((resolve, reject) => { - Readable.fromWeb(response.body as import("node:stream/web").ReadableStream) - .on("error", reject) - .pipe(res) - .on("finish", resolve) - .on("error", reject); - }); + await writeResponse(stream, response, headOnly); +} + +/** + * Server side of /libp2p-http for an endpoint answered by a handler in this + * process or an A2A server at a URL. One request per stream; the stream is + * closed once the response has been written. + */ +export function httpIngressHandler(endpoint: A2AEndpoint, options: ProviderOptions): StreamHandler { + return (stream: Stream, connection: Connection) => { + void serveIngress(stream, connection.remotePeer.toString(), endpoint, options) + .then(() => stream.close()) + .catch((err: unknown) => stream.abort(err instanceof Error ? err : new Error(String(err)))); + }; } /** The request target for a service on a peer: ///. */ @@ -393,50 +388,55 @@ export async function fetchOverStream(conn: Connection, biscuit: Uint8Array, req ctl.abort(new DOMException(`no response headers from ${peerId} within ${REQUEST_TIMEOUT_MS}ms`, "TimeoutError")); } }, REQUEST_TIMEOUT_MS); - timer.unref?.(); + (timer as { unref?: () => void }).unref?.(); } const signal = ctl.signal; const stream = await conn.newStream(HTTP_PROTOCOL, { signal, runOnLimitedConnection: true }); - const socket = streamToNodeDuplex(stream, peerId); - const headers: Record = {}; - request.headers.forEach((value, key) => { - if (key !== "host" && key !== "content-length" && key !== HEADER_SAM_BISCUIT && key !== HEADER_PEER_ID) { - headers[key] = value; + const asError = (reason: unknown) => (reason instanceof Error ? reason : new Error(String(reason))); + const abortStream = () => stream.abort(asError(signal.reason)); + signal.addEventListener("abort", abortStream, { once: true }); + + const headers = new Headers(); + request.headers.forEach((value, name) => { + if (name !== "host" && name !== "content-length" && name !== "transfer-encoding" && name !== HEADER_SAM_BISCUIT && name !== HEADER_PEER_ID) { + headers.set(name, value); } }); - headers.host = peerId; - headers[HEADER_SAM_BISCUIT] = Buffer.from(biscuit).toString("base64"); + headers.set("host", peerId); + headers.set(HEADER_SAM_BISCUIT, toBase64(biscuit)); if (options.agent) { - headers[HEADER_SAM_AGENT] = options.agent; + headers.set(HEADER_SAM_AGENT, options.agent); } - const body = request.body === null ? undefined : Buffer.from(await request.arrayBuffer()); - headers["content-length"] = String(body?.length ?? 0); - - return new Promise((resolve, reject) => { - const req = http.request({ method: request.method, path: target, headers, createConnection: () => socket, signal }, (res) => { - headersIn = true; - const responseHeaders = new Headers(); - for (const [k, v] of Object.entries(res.headers)) { - if (typeof v === "string") { - responseHeaders.set(k, v); - } else if (Array.isArray(v)) { - responseHeaders.set(k, v.join(", ")); - } + const body = request.body === null ? new Uint8Array(0) : new Uint8Array(await request.arrayBuffer()); + headers.set("content-length", String(body.length)); + + try { + await sendAll(stream, encodeRequestHead(request.method, target, headers)); + await sendAll(stream, body); + const reader = new ByteReader(streamSource(stream)); + const head = await readResponseHead(reader); + headersIn = true; + signal.throwIfAborted(); + const framing = responseBodyFraming(request.method, head); + const done = (err?: Error) => { + signal.removeEventListener("abort", abortStream); + if (err !== undefined) { + stream.abort(err); + } else { + void stream.close().catch(() => {}); } - res.on("close", () => socket.destroy()); - const status = res.statusCode ?? 0; - const bodyStream = Readable.toWeb(res) as ReadableStream; - resolve(new Response(status === 204 || status === 304 || request.method === "HEAD" ? null : bodyStream, { status, statusText: res.statusMessage ?? "", headers: responseHeaders })); - }); - req.on("error", (err) => { - socket.destroy(); - reject(err); - }); - if (body !== undefined) { - req.write(body); + }; + let responseBody: ReadableStream | null = null; + if (framing.kind === "none") { + done(); + } else { + responseBody = bodyStream(reader, framing, done); } - req.end(); - }); + return new Response(responseBody, { status: head.status, statusText: head.statusText, headers: head.headers }); + } catch (err) { + stream.abort(asError(err)); + throw signal.aborted ? signal.reason : err; + } } export interface HTTPRequestOptions { diff --git a/sdk/js/src/session.ts b/sdk/js/src/session.ts index 49db9b9a..bb09a881 100644 --- a/sdk/js/src/session.ts +++ b/sdk/js/src/session.ts @@ -29,7 +29,6 @@ import { HTTP_PROTOCOL, a2aEndpoint, fetchOverStream, - httpIngressHandler, httpRequestOverStream, meshURL, splitMeshURL, @@ -39,6 +38,7 @@ import { type HTTPResponse, type ProviderOptions, } from "./libp2p-http.ts"; +import { nodeIngressHandler } from "./libp2p-http-node.ts"; import { BanSet, GOSSIP_EVENTS_TOPIC, MeshEvent_Type, verifyMeshEvent } from "./sync.ts"; export interface JoinOptions extends MeshHostOptions { @@ -491,7 +491,7 @@ export class MeshSession { isBanned: (peerId) => this.banned.has(peerId), onAuthorized: (peerId, verified) => this.authenticatedPeers.set(peerId, verified.expiration), }; - await this.node.handle(HTTP_PROTOCOL, httpIngressHandler(endpoint, providerOptions), HTTP_HANDLER_OPTIONS); + await this.node.handle(HTTP_PROTOCOL, nodeIngressHandler(endpoint, providerOptions), HTTP_HANDLER_OPTIONS); this.#policyTimer = setInterval(() => void this.syncPolicy().catch(() => {}), this.#policySyncMs); this.#policyTimer.unref?.(); this.endpoint = endpoint; From 03deb15d8f981a179f673ec667797d70db3f5cf0 Mon Sep 17 00:00:00 2001 From: Antonio Ojea Date: Sat, 26 Sep 2026 13:46:15 +0000 Subject: [PATCH 5/9] sdk/js: one SDK for Node and the browser 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/package-lock.json | 549 ++++++++++++++++++++++ sdk/js/package.json | 13 +- sdk/js/scripts/bundle-browser.mjs | 97 ++++ sdk/js/src/biscuit.ts | 39 +- sdk/js/src/host.ts | 17 +- sdk/js/src/index.ts | 3 +- sdk/js/src/mesh.ts | 81 ++-- sdk/js/src/platform/ingress.browser.ts | 18 + sdk/js/src/platform/ingress.ts | 17 + sdk/js/src/platform/state.browser.ts | 58 +++ sdk/js/src/platform/state.ts | 48 ++ sdk/js/src/platform/transports.browser.ts | 32 ++ sdk/js/src/platform/transports.ts | 30 ++ sdk/js/src/platform/types.ts | 25 + sdk/js/src/platform/wasm.browser.ts | 25 + sdk/js/src/platform/wasm.ts | 52 ++ sdk/js/src/session.ts | 4 +- 17 files changed, 1007 insertions(+), 101 deletions(-) create mode 100644 sdk/js/scripts/bundle-browser.mjs create mode 100644 sdk/js/src/platform/ingress.browser.ts create mode 100644 sdk/js/src/platform/ingress.ts create mode 100644 sdk/js/src/platform/state.browser.ts create mode 100644 sdk/js/src/platform/state.ts create mode 100644 sdk/js/src/platform/transports.browser.ts create mode 100644 sdk/js/src/platform/transports.ts create mode 100644 sdk/js/src/platform/types.ts create mode 100644 sdk/js/src/platform/wasm.browser.ts create mode 100644 sdk/js/src/platform/wasm.ts diff --git a/sdk/js/package-lock.json b/sdk/js/package-lock.json index dcc383c4..9376cbb5 100644 --- a/sdk/js/package-lock.json +++ b/sdk/js/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@biscuit-auth/biscuit-wasm": "^0.6.0", "@bufbuild/protobuf": "^2.15.0", + "@chainsafe/libp2p-noise": "^17.0.0", "@chainsafe/libp2p-yamux": "^8.0.1", "@libp2p/circuit-relay-v2": "^4.2.13", "@libp2p/crypto": "^5.1.23", @@ -37,6 +38,7 @@ "@bufbuild/protoc-gen-es": "^2.15.0", "@types/express": "^5.0.6", "@types/node": "^26.6.1", + "esbuild": "^0.28.2", "express": "^5.2.1", "typescript": "^7.0.2" }, @@ -138,12 +140,74 @@ "node": ">=14.17" } }, + "node_modules/@chainsafe/as-chacha20poly1305": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@chainsafe/as-chacha20poly1305/-/as-chacha20poly1305-0.1.0.tgz", + "integrity": "sha512-BpNcL8/lji/GM3+vZ/bgRWqJ1q5kwvTFmGPk7pxm/QQZDbaMI98waOHjEymTjq2JmdD/INdNBFOVSyJofXg7ew==" + }, + "node_modules/@chainsafe/as-sha256": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@chainsafe/as-sha256/-/as-sha256-1.2.5.tgz", + "integrity": "sha512-8LzmWxBC/2O5BgbP+aRom/FaDJMrM04VZMlVIivYTc6yNQKYrONmIv27m3q/1VBgxrfrNNqmibitb2hE2tQzIw==" + }, "node_modules/@chainsafe/is-ip": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@chainsafe/is-ip/-/is-ip-2.1.0.tgz", "integrity": "sha512-KIjt+6IfysQ4GCv66xihEitBjvhU/bixbbbFxdJ1sqCp4uJ0wuZiYBPhksZoy4lfaF0k9cwNzY5upEW/VWdw3w==", "license": "MIT" }, + "node_modules/@chainsafe/libp2p-noise": { + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/@chainsafe/libp2p-noise/-/libp2p-noise-17.0.0.tgz", + "integrity": "sha512-vwrmY2Y+L1xYhIDiEpl61KHxwrLCZoXzTpwhyk34u+3+6zCAZPL3GxH3i2cs+u5IYNoyLptORdH17RKFXy7upA==", + "dependencies": { + "@chainsafe/as-chacha20poly1305": "^0.1.0", + "@chainsafe/as-sha256": "^1.2.0", + "@libp2p/crypto": "^5.1.9", + "@libp2p/interface": "^3.0.0", + "@libp2p/peer-id": "^6.0.0", + "@libp2p/utils": "^7.0.0", + "@noble/ciphers": "^2.0.1", + "@noble/curves": "^2.0.1", + "@noble/hashes": "^2.0.1", + "protons-runtime": "^5.6.0", + "uint8arraylist": "^2.4.8", + "uint8arrays": "^5.1.0", + "wherearewe": "^2.0.1" + } + }, + "node_modules/@chainsafe/libp2p-noise/node_modules/multiformats": { + "version": "13.4.2", + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-13.4.2.tgz", + "integrity": "sha512-eh6eHCrRi1+POZ3dA+Dq1C6jhP1GNtr9CRINMb67OKzqW9I5DUuZM/3jLPlzhgpGeiNUlEGEbkCYChXMCc/8DQ==" + }, + "node_modules/@chainsafe/libp2p-noise/node_modules/protons-runtime": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/protons-runtime/-/protons-runtime-5.6.0.tgz", + "integrity": "sha512-/Kde+sB9DsMFrddJT/UZWe6XqvL7SL5dbag/DBCElFKhkwDj7XKt53S+mzLyaDP5OqS0wXjV5SA572uWDaT0Hg==", + "dependencies": { + "uint8-varint": "^2.0.2", + "uint8arraylist": "^2.4.3", + "uint8arrays": "^5.0.1" + } + }, + "node_modules/@chainsafe/libp2p-noise/node_modules/uint8-varint": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/uint8-varint/-/uint8-varint-2.0.5.tgz", + "integrity": "sha512-jeFLbL/x30wBRnWjKE1qVBXeumG46r7XmYkpis955lTQ+blccGKFrOsSMHlxePwYB1pI7L8YPHz1t4jLxEs3nA==", + "dependencies": { + "uint8arraylist": "^2.0.0", + "uint8arrays": "^5.0.0" + } + }, + "node_modules/@chainsafe/libp2p-noise/node_modules/uint8arrays": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-5.1.1.tgz", + "integrity": "sha512-9muQwa4wZG4dKi9gMAIBtnk2Pw87SRpvWTH6lOGm19V2Uqxr4uomUf2PGqPnWc+qs06sN8owUU4jfcoWOcfwVQ==", + "dependencies": { + "multiformats": "^13.0.0" + } + }, "node_modules/@chainsafe/libp2p-yamux": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/@chainsafe/libp2p-yamux/-/libp2p-yamux-8.0.1.tgz", @@ -178,6 +242,422 @@ "node": ">=6" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@hono/node-server": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.1.tgz", @@ -769,6 +1249,17 @@ "@multiformats/multiaddr": "^13.0.0" } }, + "node_modules/@noble/ciphers": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.4.0.tgz", + "integrity": "sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@noble/curves": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.4.0.tgz", @@ -1857,6 +2348,47 @@ "node": ">= 0.4" } }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -2198,6 +2730,11 @@ "node": ">= 0.10" } }, + "node_modules/is-electron": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-electron/-/is-electron-2.2.2.tgz", + "integrity": "sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg==" + }, "node_modules/is-loopback-addr": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-loopback-addr/-/is-loopback-addr-2.1.0.tgz", @@ -3313,6 +3850,18 @@ "tslib": "^2.8.1" } }, + "node_modules/wherearewe": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/wherearewe/-/wherearewe-2.0.1.tgz", + "integrity": "sha512-XUguZbDxCA2wBn2LoFtcEhXL6AXo+hVjGonwhSTTTU9SzbWG8Xu3onNIpzf9j/mYUcJQ0f+m37SzG77G851uFw==", + "dependencies": { + "is-electron": "^2.2.0" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=7.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/sdk/js/package.json b/sdk/js/package.json index a735ce3b..ea85f34f 100644 --- a/sdk/js/package.json +++ b/sdk/js/package.json @@ -1,7 +1,7 @@ { "name": "@sam-mesh/sdk", "version": "0.1.0", - "description": "Native SDK for joining a SAM agent mesh from inside a Node.js agent process", + "description": "Native SDK for joining a SAM agent mesh from a Node.js agent process or a browser page", "license": "Apache-2.0", "repository": { "type": "git", @@ -18,6 +18,12 @@ "default": "./dist/index.js" } }, + "browser": { + "./dist/platform/ingress.js": "./dist/platform/ingress.browser.js", + "./dist/platform/state.js": "./dist/platform/state.browser.js", + "./dist/platform/transports.js": "./dist/platform/transports.browser.js", + "./dist/platform/wasm.js": "./dist/platform/wasm.browser.js" + }, "files": [ "dist", "README.md" @@ -31,11 +37,13 @@ "examples": "tsc -p tsconfig.examples.json", "typecheck": "tsc -p tsconfig.json --noEmit", "pretest": "tsc -p tsconfig.test.json", - "test": "node --test 'build/**/*.test.js'" + "test": "node --test 'build/**/*.test.js'", + "bundle:browser": "node scripts/bundle-browser.mjs" }, "dependencies": { "@biscuit-auth/biscuit-wasm": "^0.6.0", "@bufbuild/protobuf": "^2.15.0", + "@chainsafe/libp2p-noise": "^17.0.0", "@chainsafe/libp2p-yamux": "^8.0.1", "@libp2p/circuit-relay-v2": "^4.2.13", "@libp2p/crypto": "^5.1.23", @@ -62,6 +70,7 @@ "@bufbuild/protoc-gen-es": "^2.15.0", "@types/express": "^5.0.6", "@types/node": "^26.6.1", + "esbuild": "^0.28.2", "express": "^5.2.1", "typescript": "^7.0.2" } diff --git a/sdk/js/scripts/bundle-browser.mjs b/sdk/js/scripts/bundle-browser.mjs new file mode 100644 index 00000000..77891fdb --- /dev/null +++ b/sdk/js/scripts/bundle-browser.mjs @@ -0,0 +1,97 @@ +#!/usr/bin/env node +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Bundles an ES module for a browser page with esbuild, the way a page's own +// bundler would: package.json "browser" fields swap the Node platform files +// for their browser twins, and a .wasm import (biscuit-wasm is built for +// bundlers and imports its module that way) becomes a module that fetches +// the file beside the bundle and instantiates it. Bundling dist/index.js +// this way also proves that nothing in the browser graph reaches for Node. +// +// node scripts/bundle-browser.mjs [entry] [outdir] +// +// Defaults: dist/index.js into build/browser. + +import * as esbuild from "esbuild"; +import { readFile } from "node:fs/promises"; +import { basename, dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const [entry = join(here, "..", "dist", "index.js"), outdir = join(here, "..", "build", "browser")] = process.argv.slice(2); + +/** import * as wasm from "./x.wasm" as WebAssembly ESM integration would resolve it. */ +const wasmModules = { + name: "wasm-esm", + setup(build) { + build.onResolve({ filter: /\.wasm$/ }, (args) => ({ path: join(args.resolveDir, args.path), namespace: "wasm-esm" })); + build.onLoad({ filter: /.*/, namespace: "wasm-esm" }, async (args) => { + const bytes = await readFile(args.path); + const module = new WebAssembly.Module(bytes); + const importModules = [...new Set(WebAssembly.Module.imports(module).map((imp) => imp.module))]; + const exports = WebAssembly.Module.exports(module).map((exp) => exp.name); + const lines = []; + const imports = []; + importModules.forEach((mod, i) => { + if (mod.startsWith("./") || mod.startsWith("../")) { + lines.push(`import * as m${i} from ${JSON.stringify(mod)};`); + imports.push(`${JSON.stringify(mod)}: m${i}`); + } else { + // wasm-bindgen's own placeholder module: intrinsics the bindings provide by name. + imports.push(`${JSON.stringify(mod)}: { performance_now: () => performance.now() }`); + } + }); + lines.push(`const url = new URL(${JSON.stringify(basename(args.path))}, import.meta.url);`); + lines.push(`const { instance } = await WebAssembly.instantiateStreaming(fetch(url), { ${imports.join(", ")} });`); + for (const name of exports) { + lines.push(`export const ${name} = instance.exports[${JSON.stringify(name)}];`); + } + return { contents: lines.join("\n"), loader: "js", resolveDir: dirname(args.path), watchFiles: [args.path] }; + }); + // The .wasm file itself goes beside the bundle under its own name. + build.onResolve({ filter: /\.wasm$/, namespace: "wasm-esm" }, (args) => ({ path: args.path, namespace: "wasm-file" })); + }, +}; + +const result = await esbuild.build({ + entryPoints: [entry], + bundle: true, + format: "esm", + platform: "browser", + target: "es2022", + outdir, + entryNames: "[name]", + assetNames: "[name]", + sourcemap: true, + plugins: [wasmModules], + metafile: true, + logLevel: "warning", +}); + +// The bytes the stub fetches: copy every .wasm the graph touched beside the bundle. +const { copyFile, mkdir } = await import("node:fs/promises"); +await mkdir(outdir, { recursive: true }); +for (const input of Object.keys(result.metafile.inputs)) { + if (input.startsWith("wasm-esm:")) { + const file = input.slice("wasm-esm:".length); + await copyFile(file, join(outdir, basename(file))); + } +} +const nodeBuiltins = Object.keys(result.metafile.inputs).filter((p) => p.startsWith("node:")); +if (nodeBuiltins.length > 0) { + console.error(`browser bundle reaches Node: ${nodeBuiltins.join(", ")}`); + process.exit(1); +} +console.log(`bundled ${entry} into ${outdir}`); diff --git a/sdk/js/src/biscuit.ts b/sdk/js/src/biscuit.ts index 2ef5bd0f..9e2f6d32 100644 --- a/sdk/js/src/biscuit.ts +++ b/sdk/js/src/biscuit.ts @@ -16,46 +16,13 @@ // signed by a trusted control plane key, authority block only, unexpired, // and bound to the peer at the other end of the connection. -import { readFile } from "node:fs/promises"; -import { fileURLToPath } from "node:url"; - -// biscuit-wasm is built for bundlers and imports its .wasm as a module, -// which Node only does behind a flag. Instantiating it by hand avoids the -// flag; the module's import list says what it needs. -type BiscuitWasm = typeof import("@biscuit-auth/biscuit-wasm"); +import { loadBiscuitWasm, type BiscuitWasm } from "./platform/wasm.ts"; let loading: Promise | undefined; +/** The biscuit-wasm module, loaded once, the way the runtime loads it. */ export function loadBiscuit(): Promise { - loading ??= (async () => { - const dir = new URL("./", import.meta.resolve("@biscuit-auth/biscuit-wasm")); - const bindings = (await import(new URL("biscuit_bg.js", dir).href)) as BiscuitWasm & { - __wbg_set_wasm(exports: WebAssembly.Exports): void; - }; - const module = await WebAssembly.compile(await readFile(fileURLToPath(new URL("biscuit_bg.wasm", dir)))); - const imports: WebAssembly.Imports = {}; - for (const imp of WebAssembly.Module.imports(module)) { - const ns = (imports[imp.module] ??= {}) as Record; - if (imp.module === "./biscuit_bg.js") { - ns[imp.name] = (bindings as unknown as Record)[imp.name] as WebAssembly.ImportValue; - } else if (imp.name === "performance_now") { - ns[imp.name] = () => performance.now(); - } else { - throw new Error(`biscuit-wasm needs an unknown import ${imp.module}:${imp.name}`); - } - } - const instance = await WebAssembly.instantiate(module, imports); - bindings.__wbg_set_wasm(instance.exports); - // The module's start hook logs a greeting to the console. - const log = console.log; - console.log = () => {}; - try { - (instance.exports as { __wbindgen_start(): void }).__wbindgen_start(); - } finally { - console.log = log; - } - return bindings; - })(); + loading ??= loadBiscuitWasm(); return loading; } diff --git a/sdk/js/src/host.ts b/sdk/js/src/host.ts index 39eb6d7b..93f09b8c 100644 --- a/sdk/js/src/host.ts +++ b/sdk/js/src/host.ts @@ -13,11 +13,10 @@ // limitations under the License. // The libp2p host a member joins the mesh with, configured the way -// sam-node's is (internal/node/node.go): TLS for the security protocol, -// which every peer offers first, yamux the muxer, circuit relay v2 for -// reachability through the routers. TCP and WebSocket are the transports: -// the testnets' routers listen on TCP, sam-one's single port is a WebSocket -// listener. +// sam-node's is (internal/node/node.go): yamux the muxer, circuit relay v2 +// for reachability through the routers. Transports and the security +// protocol come from the runtime (platform/transports.ts): on Node TCP and +// WebSocket with TLS, in a browser WebSocket with Noise. import { yamux } from "@chainsafe/libp2p-yamux"; import { circuitRelayTransport } from "@libp2p/circuit-relay-v2"; @@ -27,13 +26,11 @@ import { identify } from "@libp2p/identify"; import type { Libp2p, PeerId } from "@libp2p/interface"; import { kadDHT, passthroughMapper } from "@libp2p/kad-dht"; import { ping } from "@libp2p/ping"; -import { tcp } from "@libp2p/tcp"; -import { tls } from "@libp2p/tls"; -import { webSockets } from "@libp2p/websockets"; import type { Multiaddr } from "@multiformats/multiaddr"; import { createLibp2p, type Libp2pOptions } from "libp2p"; import { DHT_PROTOCOL } from "./discovery.ts"; import type { Identity } from "./identity.ts"; +import { connectionEncrypters, transports } from "./platform/transports.ts"; export interface MeshHostOptions { /** @@ -62,8 +59,8 @@ export async function createMeshHost(identity: Identity, options: MeshHostOption privateKey: privateKeyFromProtobuf(identity.toLibp2pPrivateKey()), addresses: { listen: options.listenAddrs ?? [] }, ...(options.dns !== undefined ? { dns: options.dns } : {}), - transports: [tcp(), webSockets(), circuitRelayTransport()], - connectionEncrypters: [tls()], + transports: [...transports(), circuitRelayTransport()], + connectionEncrypters: connectionEncrypters(), streamMuxers: [yamux()], connectionGater: { denyDialPeer: denyBanned, diff --git a/sdk/js/src/index.ts b/sdk/js/src/index.ts index 5648df3b..47d2b496 100644 --- a/sdk/js/src/index.ts +++ b/sdk/js/src/index.ts @@ -67,6 +67,7 @@ export { type NodeRequestListener, type ProviderOptions, } from "./libp2p-http.ts"; -export { nodeIngressHandler, streamToNodeDuplex } from "./libp2p-http-node.ts"; +export { ingressHandler } from "./platform/ingress.ts"; +export type { StateStore } from "./platform/types.ts"; export { BASELINE_DATALOG } from "./gen/datalog.ts"; export { BanSet, EVENT_FRESHNESS_MS, GOSSIP_EVENTS_TOPIC, verifyMeshEvent, type VerifiedMeshEvent } from "./sync.ts"; diff --git a/sdk/js/src/mesh.ts b/sdk/js/src/mesh.ts index f08be813..5634f574 100644 --- a/sdk/js/src/mesh.ts +++ b/sdk/js/src/mesh.ts @@ -12,12 +12,12 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; -import { join } from "node:path"; import { toHex } from "./bytes.ts"; import { ControlPlaneClient, ROLE_NODE, type Enrollment } from "./controlplane.ts"; import { credentialFromJSON, credentialPredatesRotation, credentialTimeToLiveSeconds, credentialToJSON, encodeAuthFrame, type MeshCredential } from "./credential.ts"; import { Identity } from "./identity.ts"; +import { openState, readTextFile } from "./platform/state.ts"; +import type { StateStore } from "./platform/types.ts"; import { joinMesh, type JoinOptions, type MeshSession } from "./session.ts"; const IDENTITY_FILE = "identity.key"; @@ -31,8 +31,9 @@ export interface AgentMeshOptions { /** Accept plaintext http:// to a non-loopback control plane. Off by default. */ allowInsecure?: boolean; /** - * Directory that keeps the identity key and the credential across - * restarts. Without it the identity lives only in this process. + * Where the identity key and the credential are kept across restarts: a + * directory on Node, an IndexedDB database of that name in a browser. + * Without it the identity lives only in this process. */ stateDir?: string | undefined; /** Use this identity instead of the persisted or a freshly generated one. */ @@ -48,7 +49,7 @@ export interface AgentMeshOptions { export interface EnrollOptions extends AgentMeshOptions { /** A bootstrap token value, when the caller already holds it in memory. */ bootstrapToken?: string | undefined; - /** Path of a file holding the bootstrap token. Preferred over a value. */ + /** Path of a file holding the bootstrap token. Preferred over a value on Node; a browser has no files. */ bootstrapTokenPath?: string | undefined; /** An OIDC ID token, for meshes that enroll identities interactively. */ jwt?: string | undefined; @@ -87,13 +88,13 @@ export class AgentMesh { readonly identity: Identity; readonly controlPlane: ControlPlaneClient; #credential: MeshCredential; - readonly #stateDir: string | undefined; + readonly #state: StateStore | undefined; - private constructor(identity: Identity, controlPlane: ControlPlaneClient, credential: MeshCredential, stateDir: string | undefined) { + private constructor(identity: Identity, controlPlane: ControlPlaneClient, credential: MeshCredential, state: StateStore | undefined) { this.identity = identity; this.controlPlane = controlPlane; this.#credential = credential; - this.#stateDir = stateDir; + this.#state = state; } get peerId(): string { @@ -115,13 +116,14 @@ export class AgentMesh { * the state directory to enroll afresh, for instance with other labels. */ static async enroll(options: EnrollOptions): Promise { - const saved = await loadIdentity(options.stateDir); + const state = options.stateDir !== undefined ? openState(options.stateDir) : undefined; + const saved = await loadIdentity(state); const identity = options.identity ?? saved ?? Identity.generate(); const controlPlane = newClient(options); - if (options.stateDir !== undefined && saved !== undefined && saved.peerId === identity.peerId) { - const credential = await loadCredential(options.stateDir); + if (state !== undefined && saved !== undefined && saved.peerId === identity.peerId) { + const credential = await loadCredential(state); if (credential !== undefined && sameBaseUrl(credential.controlPlaneUrl, controlPlane.url) && credentialTimeToLiveSeconds(credential) > REUSE_MIN_TTL_SECONDS) { - return new AgentMesh(identity, controlPlane, credential, options.stateDir); + return new AgentMesh(identity, controlPlane, credential, state); } } const given = [options.bootstrapToken, options.bootstrapTokenPath, options.jwt, options.jwtPath].filter((v) => v !== undefined).length; @@ -133,10 +135,10 @@ export class AgentMesh { let enrollment: Enrollment; if (options.jwt !== undefined || options.jwtPath !== undefined) { - const jwt = options.jwtPath !== undefined ? (await readFile(options.jwtPath, "utf8")).trim() : (options.jwt as string); + const jwt = options.jwtPath !== undefined ? (await readTextFile(options.jwtPath)).trim() : (options.jwt as string); enrollment = await controlPlane.register({ identity, jwt, role, ...labelsOf(options) }); } else { - const bootstrapToken = options.bootstrapTokenPath !== undefined ? (await readFile(options.bootstrapTokenPath, "utf8")).trim() : (options.bootstrapToken as string); + const bootstrapToken = options.bootstrapTokenPath !== undefined ? (await readTextFile(options.bootstrapTokenPath)).trim() : (options.bootstrapToken as string); enrollment = await controlPlane.enrollBootstrap({ identity, bootstrapToken, @@ -168,7 +170,7 @@ export class AgentMesh { issuedUnderKeys: controlPlaneKeys, routerAddresses: enrollment.routerAddresses, }, - options.stateDir, + state, ); await mesh.save(); return mesh; @@ -180,15 +182,16 @@ export class AgentMesh { * plane URL comes from the saved credential. */ static async load(options: Omit & { stateDir: string }): Promise { - const identity = options.identity ?? (await loadIdentity(options.stateDir)); + const state = openState(options.stateDir); + const identity = options.identity ?? (await loadIdentity(state)); if (!identity) { throw new Error(`no identity in ${options.stateDir}; enroll first`); } - const credential = await loadCredential(options.stateDir); + const credential = await loadCredential(state); if (credential === undefined) { throw new Error(`no credential in ${options.stateDir}; enroll first`); } - return new AgentMesh(identity, newClient({ ...options, controlPlaneUrl: credential.controlPlaneUrl }), credential, options.stateDir); + return new AgentMesh(identity, newClient({ ...options, controlPlaneUrl: credential.controlPlaneUrl }), credential, state); } /** @@ -286,14 +289,13 @@ export class AgentMesh { return joinMesh(this, options); } - /** Writes identity and credential to the state directory, if one is configured. */ + /** Writes identity and credential to the state store, if one is configured. */ async save(): Promise { - if (this.#stateDir === undefined) { + if (this.#state === undefined) { return; } - await mkdir(this.#stateDir, { recursive: true, mode: 0o700 }); - await writeAtomic(join(this.#stateDir, IDENTITY_FILE), this.identity.toLibp2pPrivateKey(), 0o600); - await writeAtomic(join(this.#stateDir, CREDENTIAL_FILE), credentialToJSON(this.#credential), 0o600); + await this.#state.write(IDENTITY_FILE, this.identity.toLibp2pPrivateKey()); + await this.#state.write(CREDENTIAL_FILE, new TextEncoder().encode(credentialToJSON(this.#credential))); } } @@ -318,29 +320,14 @@ function labelsOf(options: AgentMeshOptions): { labels?: Record return options.labels !== undefined ? { labels: options.labels } : {}; } -async function loadIdentity(stateDir: string | undefined): Promise { - if (stateDir === undefined) { - return undefined; - } - try { - return Identity.fromLibp2pPrivateKey(new Uint8Array(await readFile(join(stateDir, IDENTITY_FILE)))); - } catch (err) { - if ((err as NodeJS.ErrnoException).code === "ENOENT") { - return undefined; - } - throw err; - } +async function loadIdentity(state: StateStore | undefined): Promise { + const bytes = await state?.read(IDENTITY_FILE); + return bytes === undefined ? undefined : Identity.fromLibp2pPrivateKey(bytes); } -async function loadCredential(stateDir: string): Promise { - try { - return credentialFromJSON(await readFile(join(stateDir, CREDENTIAL_FILE), "utf8")); - } catch (err) { - if ((err as NodeJS.ErrnoException).code === "ENOENT") { - return undefined; - } - throw err; - } +async function loadCredential(state: StateStore): Promise { + const bytes = await state.read(CREDENTIAL_FILE); + return bytes === undefined ? undefined : credentialFromJSON(new TextDecoder().decode(bytes)); } /** The form every implementation persists: scheme, host, port, path, no trailing slash. */ @@ -351,9 +338,3 @@ function baseUrl(url: URL | string): string { function sameBaseUrl(a: URL | string, b: URL | string): boolean { return baseUrl(a) === baseUrl(b); } - -async function writeAtomic(path: string, data: Uint8Array | string, mode: number): Promise { - const tmp = `${path}.tmp`; - await writeFile(tmp, data, { mode }); - await rename(tmp, path); -} diff --git a/sdk/js/src/platform/ingress.browser.ts b/sdk/js/src/platform/ingress.browser.ts new file mode 100644 index 00000000..992c3125 --- /dev/null +++ b/sdk/js/src/platform/ingress.browser.ts @@ -0,0 +1,18 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// The /libp2p-http ingress in a browser: a fetch handler or a URL; a Node +// request listener is answered 501, there is no Node HTTP server to run it. + +export { httpIngressHandler as ingressHandler } from "../libp2p-http.ts"; diff --git a/sdk/js/src/platform/ingress.ts b/sdk/js/src/platform/ingress.ts new file mode 100644 index 00000000..9fbd806f --- /dev/null +++ b/sdk/js/src/platform/ingress.ts @@ -0,0 +1,17 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// The /libp2p-http ingress on Node serves a Node request listener too. + +export { nodeIngressHandler as ingressHandler } from "../libp2p-http-node.ts"; diff --git a/sdk/js/src/platform/state.browser.ts b/sdk/js/src/platform/state.browser.ts new file mode 100644 index 00000000..06913964 --- /dev/null +++ b/sdk/js/src/platform/state.browser.ts @@ -0,0 +1,58 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// State in a browser: an IndexedDB database per state location, one record +// per name, kept by the browser for the page's origin. A put replaces the +// record in one transaction, so a reader sees the old or the new value. + +import type { StateStore } from "./types.ts"; + +const STORE = "state"; + +function request(req: IDBRequest): Promise { + return new Promise((resolve, reject) => { + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error ?? new Error("IndexedDB request failed")); + }); +} + +function openDatabase(name: string): Promise { + const req = indexedDB.open(`sam-mesh:${name}`, 1); + req.onupgradeneeded = () => { + req.result.createObjectStore(STORE); + }; + return request(req); +} + +/** The store for a state location: a database named after it. */ +export function openState(location: string): StateStore { + let db: Promise | undefined; + const open = () => (db ??= openDatabase(location)); + return { + async read(name) { + const tx = (await open()).transaction(STORE, "readonly"); + const value = await request(tx.objectStore(STORE).get(name)); + return value instanceof Uint8Array ? value : undefined; + }, + async write(name, data) { + const tx = (await open()).transaction(STORE, "readwrite"); + await request(tx.objectStore(STORE).put(new Uint8Array(data), name)); + }, + }; +} + +/** A browser has no file system a page may read; give the token itself. */ +export async function readTextFile(path: string): Promise { + throw new Error(`cannot read ${path}: a browser has no files to read a token from; pass the value instead`); +} diff --git a/sdk/js/src/platform/state.ts b/sdk/js/src/platform/state.ts new file mode 100644 index 00000000..49df0c48 --- /dev/null +++ b/sdk/js/src/platform/state.ts @@ -0,0 +1,48 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// State on Node: a directory, one file per name, owner-only, written to a +// temporary name and renamed into place. Where a token file is read from. + +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import type { StateStore } from "./types.ts"; + +/** The store for a state location: a directory, created on first write. */ +export function openState(location: string): StateStore { + return { + async read(name) { + try { + return new Uint8Array(await readFile(join(location, name))); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + return undefined; + } + throw err; + } + }, + async write(name, data) { + await mkdir(location, { recursive: true, mode: 0o700 }); + const path = join(location, name); + const tmp = `${path}.tmp`; + await writeFile(tmp, data, { mode: 0o600 }); + await rename(tmp, path); + }, + }; +} + +/** A text file, for a token an operator or a platform placed on disk. */ +export async function readTextFile(path: string): Promise { + return readFile(path, "utf8"); +} diff --git a/sdk/js/src/platform/transports.browser.ts b/sdk/js/src/platform/transports.browser.ts new file mode 100644 index 00000000..e020de13 --- /dev/null +++ b/sdk/js/src/platform/transports.browser.ts @@ -0,0 +1,32 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// How a member in a browser reaches the mesh: WebSocket, the one transport a +// page can open to a router (sam-one's single port, or a router behind a +// TLS-terminating proxy as wss), secured with Noise. A browser cannot run +// libp2p's TLS, which needs a self-signed certificate over a raw socket; +// routers and nodes accept Noise beside TLS, and both bind the connection to +// the peer ID. + +import { noise } from "@chainsafe/libp2p-noise"; +import { webSockets } from "@libp2p/websockets"; +import type { Libp2pOptions } from "libp2p"; + +export function transports(): NonNullable { + return [webSockets()]; +} + +export function connectionEncrypters(): NonNullable { + return [noise()]; +} diff --git a/sdk/js/src/platform/transports.ts b/sdk/js/src/platform/transports.ts new file mode 100644 index 00000000..7ed3e374 --- /dev/null +++ b/sdk/js/src/platform/transports.ts @@ -0,0 +1,30 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// How a member on Node reaches the mesh: TCP and WebSocket, the transports +// the routers listen on, secured with TLS 1.3, which every peer offers +// first (internal/node/node.go). + +import { tcp } from "@libp2p/tcp"; +import { tls } from "@libp2p/tls"; +import { webSockets } from "@libp2p/websockets"; +import type { Libp2pOptions } from "libp2p"; + +export function transports(): NonNullable { + return [tcp(), webSockets()]; +} + +export function connectionEncrypters(): NonNullable { + return [tls()]; +} diff --git a/sdk/js/src/platform/types.ts b/sdk/js/src/platform/types.ts new file mode 100644 index 00000000..4118e0b7 --- /dev/null +++ b/sdk/js/src/platform/types.ts @@ -0,0 +1,25 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// What differs between Node and a browser, as the rest of the SDK sees it. +// Each concern has a Node module and a .browser.ts twin with the same +// exports; package.json's "browser" field points a bundler at the twin. + +/** Where a member keeps its identity and credential between runs. */ +export interface StateStore { + /** The bytes under a name, or undefined when nothing was written yet. */ + read(name: string): Promise; + /** Writes the bytes under a name so that a reader sees the old or the new value, never a mix. */ + write(name: string, data: Uint8Array): Promise; +} diff --git a/sdk/js/src/platform/wasm.browser.ts b/sdk/js/src/platform/wasm.browser.ts new file mode 100644 index 00000000..68aaf04e --- /dev/null +++ b/sdk/js/src/platform/wasm.browser.ts @@ -0,0 +1,25 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// biscuit-wasm is built for bundlers: its entry imports the .wasm as an ES +// module, which the bundler of the page resolves (webpack's +// asyncWebAssembly, vite-plugin-wasm, or the esbuild plugin in +// scripts/bundle-browser.mjs). The module is loaded once the page first +// needs it. + +export type BiscuitWasm = typeof import("@biscuit-auth/biscuit-wasm"); + +export async function loadBiscuitWasm(): Promise { + return import("@biscuit-auth/biscuit-wasm"); +} diff --git a/sdk/js/src/platform/wasm.ts b/sdk/js/src/platform/wasm.ts new file mode 100644 index 00000000..35479ed4 --- /dev/null +++ b/sdk/js/src/platform/wasm.ts @@ -0,0 +1,52 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// biscuit-wasm is built for bundlers and imports its .wasm as a module, +// which Node only does behind a flag. Instantiating it by hand avoids the +// flag; the module's import list says what it needs. + +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; + +export type BiscuitWasm = typeof import("@biscuit-auth/biscuit-wasm"); + +export async function loadBiscuitWasm(): Promise { + const dir = new URL("./", import.meta.resolve("@biscuit-auth/biscuit-wasm")); + const bindings = (await import(new URL("biscuit_bg.js", dir).href)) as BiscuitWasm & { + __wbg_set_wasm(exports: WebAssembly.Exports): void; + }; + const module = await WebAssembly.compile(await readFile(fileURLToPath(new URL("biscuit_bg.wasm", dir)))); + const imports: WebAssembly.Imports = {}; + for (const imp of WebAssembly.Module.imports(module)) { + const ns = (imports[imp.module] ??= {}) as Record; + if (imp.module === "./biscuit_bg.js") { + ns[imp.name] = (bindings as unknown as Record)[imp.name] as WebAssembly.ImportValue; + } else if (imp.name === "performance_now") { + ns[imp.name] = () => performance.now(); + } else { + throw new Error(`biscuit-wasm needs an unknown import ${imp.module}:${imp.name}`); + } + } + const instance = await WebAssembly.instantiate(module, imports); + bindings.__wbg_set_wasm(instance.exports); + // The module's start hook logs a greeting to the console. + const log = console.log; + console.log = () => {}; + try { + (instance.exports as { __wbindgen_start(): void }).__wbindgen_start(); + } finally { + console.log = log; + } + return bindings; +} diff --git a/sdk/js/src/session.ts b/sdk/js/src/session.ts index bb09a881..50f57249 100644 --- a/sdk/js/src/session.ts +++ b/sdk/js/src/session.ts @@ -38,7 +38,7 @@ import { type HTTPResponse, type ProviderOptions, } from "./libp2p-http.ts"; -import { nodeIngressHandler } from "./libp2p-http-node.ts"; +import { ingressHandler } from "./platform/ingress.ts"; import { BanSet, GOSSIP_EVENTS_TOPIC, MeshEvent_Type, verifyMeshEvent } from "./sync.ts"; export interface JoinOptions extends MeshHostOptions { @@ -491,7 +491,7 @@ export class MeshSession { isBanned: (peerId) => this.banned.has(peerId), onAuthorized: (peerId, verified) => this.authenticatedPeers.set(peerId, verified.expiration), }; - await this.node.handle(HTTP_PROTOCOL, nodeIngressHandler(endpoint, providerOptions), HTTP_HANDLER_OPTIONS); + await this.node.handle(HTTP_PROTOCOL, ingressHandler(endpoint, providerOptions), HTTP_HANDLER_OPTIONS); this.#policyTimer = setInterval(() => void this.syncPolicy().catch(() => {}), this.#policySyncMs); this.#policyTimer.unref?.(); this.endpoint = endpoint; From ba771ad8f2f0a28a9e48920cb4d557e863aa91d9 Mon Sep 17 00:00:00 2001 From: Antonio Ojea Date: Sat, 26 Sep 2026 14:13:40 +0000 Subject: [PATCH 6/9] sdk: a browser page as a member of a sam-one mesh, tested 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. --- sdk/js/examples/browser/app.js | 176 ++++++++++++++++++++ sdk/js/examples/browser/index.html | 53 ++++++ sdk/js/scripts/bundle-browser.mjs | 8 +- sdk/js/src/controlplane.test.ts | 12 ++ sdk/js/src/controlplane.ts | 4 +- sdk/js/src/host.ts | 7 + sdk/js/src/platform/transports.ts | 8 +- sdk/python/src/agent_mesh/host.py | 13 +- tests/ui/browser-sdk.spec.js | 99 ++++++++++++ tests/ui/lib/sam-one.js | 249 +++++++++++++++++++++++++++++ tests/ui/run.sh | 8 + 11 files changed, 629 insertions(+), 8 deletions(-) create mode 100644 sdk/js/examples/browser/app.js create mode 100644 sdk/js/examples/browser/index.html create mode 100644 tests/ui/browser-sdk.spec.js create mode 100644 tests/ui/lib/sam-one.js diff --git a/sdk/js/examples/browser/app.js b/sdk/js/examples/browser/app.js new file mode 100644 index 00000000..75276b73 --- /dev/null +++ b/sdk/js/examples/browser/app.js @@ -0,0 +1,176 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// A member of the mesh in a browser page: the same Echo agent as +// a2a-agent.ts, answering with a fetch handler instead of an Express app, +// and the same caller as a2a-call.ts. Bundle it for the page with +// +// npm run build && node scripts/bundle-browser.mjs examples/browser/app.js +// +// and serve together with index.html. The page's identity and +// credential are kept in IndexedDB, so a reload resumes the same peer +// without a token. + +import { A2A_PROTOCOL_VERSION, AGENT_CARD_PATH, Message, Role } from "@a2a-js/sdk"; +import { ClientFactory, DefaultAgentCardResolver, JsonRpcTransportFactory } from "@a2a-js/sdk/client"; +import { AgentEvent, DefaultRequestHandler, InMemoryTaskStore, JsonRpcTransportHandler, ServerCallContext } from "@a2a-js/sdk/server"; +import { AgentMesh, MeshSession } from "@sam-mesh/sdk"; + +const $ = (id) => document.getElementById(id); +const params = new URLSearchParams(location.search); +$("control-plane-url").value = params.get("controlPlaneUrl") ?? location.origin; +$("bootstrap-token").value = params.get("bootstrapToken") ?? ""; +$("target-peer").value = params.get("peerId") ?? ""; + +let session; + +function log(line) { + $("log").textContent += line + "\n"; +} + +/** The Echo agent: answers every message with what it said and who sent it. */ +class EchoExecutor { + async execute(context, eventBus) { + const said = context.userMessage.parts.map((p) => (p.content?.$case === "text" ? p.content.value : "")).join(""); + const caller = context.context.user?.userName ?? "someone"; + log(`message from ${caller}: ${said}`); + eventBus.publish( + AgentEvent.message({ + messageId: crypto.randomUUID(), + contextId: context.contextId, + taskId: "", + role: Role.ROLE_AGENT, + parts: [{ content: { $case: "text", value: `${caller} said: ${said}` }, filename: "", mediaType: "text/plain", metadata: undefined }], + metadata: undefined, + extensions: [], + referenceTaskIds: [], + }), + ); + eventBus.finished(); + } + + async cancelTask() {} +} + +function agentCard(url) { + return { + name: "Echo agent (browser)", + description: "Answers every message with what it said and who sent it.", + version: "1.0.0", + supportedInterfaces: [{ url, protocolBinding: "JSONRPC", tenant: "", protocolVersion: A2A_PROTOCOL_VERSION }], + provider: undefined, + documentationUrl: "", + capabilities: { streaming: true, pushNotifications: false, extendedAgentCard: false, extensions: [] }, + securitySchemes: {}, + securityRequirements: [], + defaultInputModes: ["text/plain"], + defaultOutputModes: ["text/plain"], + skills: [{ id: "echo", name: "Echo", description: "Repeats the message.", tags: ["echo"], examples: ["hello"], inputModes: [], outputModes: [], securityRequirements: [] }], + signatures: [], + iconUrl: "", + }; +} + +/** + * The handler behind a2a://agent. The SDK has already authorized the caller; + * `caller` is its verified biscuit, so the A2A user is the peer ID it is + * bound to, never anything the request said about itself. + */ +function a2aHandler(card) { + const transport = new JsonRpcTransportHandler(new DefaultRequestHandler(card, new InMemoryTaskStore(), new EchoExecutor())); + return async (request, caller) => { + const path = new URL(request.url).pathname; + if (request.method === "GET" && path === `/${AGENT_CARD_PATH}`) { + return Response.json(card); + } + if (request.method !== "POST") { + return new Response("not found\n", { status: 404 }); + } + const context = new ServerCallContext({ user: { isAuthenticated: true, userName: caller.peerId }, requestedVersion: request.headers.get("a2a-version") ?? undefined }); + const result = await transport.handle(await request.text(), context); + if (Symbol.asyncIterator in result) { + // message/stream: one SSE event per JSON-RPC response, as it is produced. + const encoder = new TextEncoder(); + const body = new ReadableStream({ + async pull(controller) { + const next = await result.next(); + if (next.done) { + controller.close(); + } else { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(next.value)}\n\n`)); + } + }, + }); + return new Response(body, { headers: { "content-type": "text/event-stream" } }); + } + return Response.json(result); + }; +} + +async function join(controlPlaneUrl, bootstrapToken) { + $("status").textContent = "enrolling"; + const mesh = await AgentMesh.enroll({ + controlPlaneUrl, + // Empty resumes the enrollment saved in IndexedDB under this name. + ...(bootstrapToken !== "" ? { bootstrapToken } : {}), + stateDir: "browser-agent", + // A plaintext http:// control plane is otherwise accepted only on loopback. + allowInsecure: params.get("allowInsecure") === "true", + }); + $("status").textContent = "joining"; + session = await mesh.join(); + const url = MeshSession.meshURL(session.peerId, "a2a://agent"); + await session.acceptA2A({ handler: a2aHandler(agentCard(url)) }); + $("peer-id").textContent = session.peerId; + $("status").textContent = `on the mesh, accepting a2a://agent`; + window.addEventListener("pagehide", () => void session.close()); + return session.peerId; +} + +async function call(peerId, text) { + const fetchImpl = session.fetch(); + const factory = new ClientFactory({ + transports: [new JsonRpcTransportFactory({ fetchImpl })], + cardResolver: new DefaultAgentCardResolver({ fetchImpl }), + }); + const client = await factory.createFromUrl(MeshSession.meshURL(peerId, "a2a://agent") + "/"); + const card = await client.getAgentCard(); + const answer = await client.sendMessage({ + tenant: "", + message: Message.fromJSON({ messageId: crypto.randomUUID(), role: Role[Role.ROLE_USER], parts: [{ text }] }), + configuration: undefined, + metadata: undefined, + }); + const parts = "parts" in answer ? answer.parts : (answer.status?.message?.parts ?? []); + const reply = parts.map((p) => (p.content?.$case === "text" ? p.content.value : "")).join(""); + $("answer").textContent = `${card.name}: ${reply}`; + return reply; +} + +$("join-form").addEventListener("submit", (event) => { + event.preventDefault(); + join($("control-plane-url").value, $("bootstrap-token").value).catch((err) => { + $("status").textContent = `failed: ${err.message}`; + }); +}); + +$("call-form").addEventListener("submit", (event) => { + event.preventDefault(); + call($("target-peer").value, $("message").value).catch((err) => { + $("answer").textContent = `failed: ${err.message}`; + }); +}); + +// For scripts and tests driving the page. +window.sam = { join, call, get session() { return session; } }; diff --git a/sdk/js/examples/browser/index.html b/sdk/js/examples/browser/index.html new file mode 100644 index 00000000..1d60ca41 --- /dev/null +++ b/sdk/js/examples/browser/index.html @@ -0,0 +1,53 @@ + + + + + + SAM browser agent + + + +

SAM browser agent

+

+ This page is a member of the mesh: it enrolls with the control plane, joins through a router over WebSocket and Noise, + answers A2A requests as a2a://agent and calls other agents by peer ID. +

+
+ + + +
+

Status: not joined

+

Peer ID:

+
+ + + +
+

Answer:

+

Requests received

+

+    
+  
+
diff --git a/sdk/js/scripts/bundle-browser.mjs b/sdk/js/scripts/bundle-browser.mjs
index 77891fdb..1d95020c 100644
--- a/sdk/js/scripts/bundle-browser.mjs
+++ b/sdk/js/scripts/bundle-browser.mjs
@@ -81,7 +81,7 @@ const result = await esbuild.build({
 });
 
 // The bytes the stub fetches: copy every .wasm the graph touched beside the bundle.
-const { copyFile, mkdir } = await import("node:fs/promises");
+const { copyFile, mkdir, readdir } = await import("node:fs/promises");
 await mkdir(outdir, { recursive: true });
 for (const input of Object.keys(result.metafile.inputs)) {
   if (input.startsWith("wasm-esm:")) {
@@ -89,6 +89,12 @@ for (const input of Object.keys(result.metafile.inputs)) {
     await copyFile(file, join(outdir, basename(file)));
   }
 }
+// A page's static files beside the entry (index.html) go with it.
+for (const name of await readdir(dirname(entry))) {
+  if (name.endsWith(".html") || name.endsWith(".css")) {
+    await copyFile(join(dirname(entry), name), join(outdir, name));
+  }
+}
 const nodeBuiltins = Object.keys(result.metafile.inputs).filter((p) => p.startsWith("node:"));
 if (nodeBuiltins.length > 0) {
   console.error(`browser bundle reaches Node: ${nodeBuiltins.join(", ")}`);
diff --git a/sdk/js/src/controlplane.test.ts b/sdk/js/src/controlplane.test.ts
index a368ae55..06408150 100644
--- a/sdk/js/src/controlplane.test.ts
+++ b/sdk/js/src/controlplane.test.ts
@@ -257,3 +257,15 @@ test("keys() fetches and verifies against the enrollment key", async () => {
   assert.deepEqual(await client.keys([cpKey.publicKeyRaw]), [cpKey.publicKeyRaw]);
   await assert.rejects(client.keys([Identity.generate().publicKeyRaw]), /not signed by any trusted/);
 });
+
+test("an injected fetch is called unbound, as a browser's window.fetch requires", async () => {
+  const inner = fakeFetch({ "GET /keys": () => proto(toBinary(KeysResponseSchema, signedKeys([cpKey]))) });
+  let receiver: unknown = "unset";
+  const strict = function (this: unknown, input: Parameters[0], init?: RequestInit) {
+    receiver = this;
+    return inner(input, init);
+  } as typeof fetch;
+  const client = new ControlPlaneClient({ url: "http://127.0.0.1:1", fetch: strict });
+  await client.keys([cpKey.publicKeyRaw]);
+  assert.equal(receiver, undefined);
+});
diff --git a/sdk/js/src/controlplane.ts b/sdk/js/src/controlplane.ts
index 9149d692..cb711a83 100644
--- a/sdk/js/src/controlplane.ts
+++ b/sdk/js/src/controlplane.ts
@@ -226,7 +226,9 @@ export class ControlPlaneClient {
 
   constructor(options: ControlPlaneClientOptions) {
     this.url = validateControlPlaneURL(options.url, options.allowInsecure ?? false);
-    this.#fetch = options.fetch ?? globalThis.fetch;
+    // Unbound: a browser's fetch refuses to run as a method of anything else.
+    const f = options.fetch ?? globalThis.fetch;
+    this.#fetch = (input, init) => f(input, init);
     this.#timeoutMs = options.timeoutMs ?? 30_000;
   }
 
diff --git a/sdk/js/src/host.ts b/sdk/js/src/host.ts
index 93f09b8c..e3c21de5 100644
--- a/sdk/js/src/host.ts
+++ b/sdk/js/src/host.ts
@@ -66,6 +66,13 @@ export async function createMeshHost(identity: Identity, options: MeshHostOption
       denyDialPeer: denyBanned,
       denyInboundEncryptedConnection: denyBanned,
       denyInboundRelayedConnection: (_relay, remotePeer) => denyBanned(remotePeer),
+      // The addresses dialled are the routers' as the control plane
+      // published them, and every connection is authenticated by peer ID
+      // whatever the address, so no address is refused for its shape. In a
+      // browser js-libp2p would otherwise skip loopback and plain ws://
+      // addresses, which is what sam-one on the same machine advertises; a
+      // page on https cannot open ws:// anyway, the browser sees to that.
+      denyDialMultiaddr: () => false,
     },
     services: {
       identify: identify(),
diff --git a/sdk/js/src/platform/transports.ts b/sdk/js/src/platform/transports.ts
index 7ed3e374..f37910df 100644
--- a/sdk/js/src/platform/transports.ts
+++ b/sdk/js/src/platform/transports.ts
@@ -13,9 +13,11 @@
 // limitations under the License.
 
 // How a member on Node reaches the mesh: TCP and WebSocket, the transports
-// the routers listen on, secured with TLS 1.3, which every peer offers
-// first (internal/node/node.go).
+// the routers listen on. TLS 1.3 first, as every Go peer and the Python SDK
+// offer it (internal/node/node.go); Noise accepted, so a member in a
+// browser, which speaks Noise alone, is reached end to end through a relay.
 
+import { noise } from "@chainsafe/libp2p-noise";
 import { tcp } from "@libp2p/tcp";
 import { tls } from "@libp2p/tls";
 import { webSockets } from "@libp2p/websockets";
@@ -26,5 +28,5 @@ export function transports(): NonNullable {
 }
 
 export function connectionEncrypters(): NonNullable {
-  return [tls()];
+  return [tls(), noise()];
 }
diff --git a/sdk/python/src/agent_mesh/host.py b/sdk/python/src/agent_mesh/host.py
index c91b7e79..3d04b651 100644
--- a/sdk/python/src/agent_mesh/host.py
+++ b/sdk/python/src/agent_mesh/host.py
@@ -14,8 +14,8 @@
 
 """The libp2p host a member joins the mesh with, configured the way sam-node's
 is (internal/node/node.go): TLS for the security protocol, which every peer
-offers first, and yamux the muxer. py-libp2p is trio-based, so everything here
-is trio async."""
+offers first, Noise accepted beside it, and yamux the muxer. py-libp2p is
+trio-based, so everything here is trio async."""
 
 from __future__ import annotations
 
@@ -34,10 +34,13 @@
 from libp2p import new_host
 from libp2p.abc import IHost, INetStream
 from libp2p.crypto.ed25519 import create_new_key_pair
+from libp2p.crypto.x25519 import create_new_key_pair as create_new_x25519_key_pair
 from libp2p.custom_types import TProtocol
 from libp2p.network.config import ConnectionConfig
 from libp2p.peer.id import ID
 from libp2p.peer.peerinfo import PeerInfo, info_from_p2p_addr
+from libp2p.security.noise.transport import PROTOCOL_ID as NOISE_PROTOCOL_ID
+from libp2p.security.noise.transport import Transport as NoiseTransport
 from libp2p.security.tls.transport import PROTOCOL_ID as TLS_PROTOCOL_ID
 from libp2p.security.tls.transport import IdentityConfig, TLSTransport
 from libp2p.stream_muxer.exceptions import MuxedStreamError
@@ -164,9 +167,13 @@ def create_mesh_host(identity: Identity, listen_addrs: Sequence[str] = ()) -> tu
     # but does not complete it, and go-libp2p then refuses the mux upgrade.
     # Without it the muxer is negotiated with multistream-select as before.
     tls = TLSTransport(key_pair, identity_config=IdentityConfig(cert_template=_certificate_template()))
+    # TLS first, as every Go peer and the JS SDK offer it; Noise accepted, so
+    # a member in a browser, which speaks Noise alone, is reached end to end
+    # through a relay. Both bind the connection to the peer ID.
+    noise = NoiseTransport(key_pair, noise_privkey=create_new_x25519_key_pair().private_key)
     host = new_host(
         key_pair=key_pair,
-        sec_opt={TLS_PROTOCOL_ID: tls},
+        sec_opt={TLS_PROTOCOL_ID: tls, NOISE_PROTOCOL_ID: noise},
         muxer_opt={TProtocol(YAMUX_PROTOCOL_ID): Yamux},
         enable_websocket=True,
         # py-libp2p 0.7 dials wss with certificate verification off unless
diff --git a/tests/ui/browser-sdk.spec.js b/tests/ui/browser-sdk.spec.js
new file mode 100644
index 00000000..94809917
--- /dev/null
+++ b/tests/ui/browser-sdk.spec.js
@@ -0,0 +1,99 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// The JS SDK in a browser page (sdk/js/examples/browser) as a member of a
+// sam-one mesh: the page enrolls with the join token from an origin of its
+// own, joins the router over WebSocket and Noise, answers A2A requests from
+// a Node member, calls a Node agent, and after a reload resumes the saved
+// enrollment without a token. Once against sam-one directly and once behind
+// a TLS-terminating edge reached by name, the topology `sam-one --tunnel`
+// leaves the page in, where the router is advertised as wss.
+
+const { test, expect } = require('@playwright/test');
+const stack = require('./lib/sam-one');
+
+test.use({ ignoreHTTPSErrors: true });
+test.describe.configure({ mode: 'serial' });
+test.setTimeout(120_000);
+
+const topologies = [
+  { name: 'direct', edge: false },
+  { name: 'behind a TLS edge', edge: true },
+];
+
+for (const topology of topologies) {
+  test(`a browser page is a member of a sam-one mesh (${topology.name})`, async ({ page }) => {
+    const why = stack.missing();
+    test.skip(why !== '', why);
+
+    const stops = [];
+    try {
+      let samOne;
+      let caFile;
+      if (topology.edge) {
+        const cert = stack.selfSignedCert('localhost');
+        test.skip(cert === null, 'openssl is not installed');
+        caFile = cert.certFile;
+        // The edge's port must be known before sam-one starts, since sam-one
+        // advertises it; the edge then proxies to wherever sam-one bound.
+        const edgePort = await stack.freePort();
+        samOne = await stack.startSamOne({ externalUrl: `https://localhost:${edgePort}` });
+        stops.push(() => samOne.stop());
+        const edge = await stack.startTLSEdge({ host: 'localhost', origin: samOne.localUrl.replace('http://', ''), cert: cert.cert, key: cert.key, port: edgePort });
+        stops.push(() => edge.stop());
+      } else {
+        samOne = await stack.startSamOne();
+        stops.push(() => samOne.stop());
+      }
+      const site = await stack.serveStatic(stack.PAGE_DIR);
+      stops.push(() => site.stop());
+
+      // Enroll and join from the page, on an origin of its own.
+      const query = new URLSearchParams({ controlPlaneUrl: samOne.publicUrl, bootstrapToken: samOne.joinToken, allowInsecure: 'true' });
+      await page.goto(`${site.url}/?${query}`);
+      await page.getByRole('button', { name: 'Join' }).click();
+      await expect(page.locator('#status')).toContainText('accepting a2a://agent', { timeout: 30_000 });
+      const browserPeer = (await page.locator('#peer-id').textContent()).trim();
+      expect(browserPeer).toMatch(/^12D3Koo/);
+
+      // A Node member calls the page's agent through the router.
+      const nodeEnv = stack.exampleEnv({ publicUrl: samOne.publicUrl, joinToken: samOne.joinToken, caFile }, 'caller');
+      const callOut = await stack.runExample('a2a-call', nodeEnv, [browserPeer, 'hello from node']);
+      const nodePeer = /on the mesh as (\S+)/.exec(callOut)?.[1];
+      expect(nodePeer, callOut).toBeTruthy();
+      expect(callOut).toContain('agent: Echo agent (browser)');
+      expect(callOut).toContain(`${nodePeer} said: hello from node`);
+      await expect(page.locator('#log')).toContainText(`message from ${nodePeer}: hello from node`);
+
+      // The page calls a Node agent.
+      const agent = await stack.startExampleAgent('a2a-agent', stack.exampleEnv({ publicUrl: samOne.publicUrl, joinToken: samOne.joinToken, caFile }, 'agent'));
+      stops.push(() => agent.stop());
+      await page.locator('#target-peer').fill(agent.peerId);
+      await page.locator('#message').fill('hello from a browser');
+      await page.getByRole('button', { name: 'Call' }).click();
+      await expect(page.locator('#answer')).toContainText(`Echo agent: ${browserPeer} said: hello from a browser`, { timeout: 30_000 });
+
+      // A reload resumes the saved enrollment: same peer, no token.
+      const resume = new URLSearchParams({ controlPlaneUrl: samOne.publicUrl, allowInsecure: 'true' });
+      await page.goto(`${site.url}/?${resume}`);
+      await page.getByRole('button', { name: 'Join' }).click();
+      await expect(page.locator('#status')).toContainText('accepting a2a://agent', { timeout: 30_000 });
+      expect((await page.locator('#peer-id').textContent()).trim()).toBe(browserPeer);
+    } finally {
+      for (const stop of stops.reverse()) {
+        stop();
+      }
+    }
+  });
+}
diff --git a/tests/ui/lib/sam-one.js b/tests/ui/lib/sam-one.js
new file mode 100644
index 00000000..e906730a
--- /dev/null
+++ b/tests/ui/lib/sam-one.js
@@ -0,0 +1,249 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// The pieces the browser SDK test stands up around Chromium: sam-one from
+// bin/, a TLS-terminating edge in front of it that routes by name the way
+// `sam-one --tunnel` puts one there, a static server for the page, and the
+// Node example programs from sdk/js as the other members.
+
+const { spawn, spawnSync } = require('node:child_process');
+const fs = require('node:fs');
+const http = require('node:http');
+const https = require('node:https');
+const net = require('node:net');
+const os = require('node:os');
+const path = require('node:path');
+const tls = require('node:tls');
+
+const REPO_ROOT = path.resolve(__dirname, '..', '..', '..');
+const SDK_JS = path.join(REPO_ROOT, 'sdk', 'js');
+const PAGE_DIR = path.join(SDK_JS, 'build', 'browser-example');
+
+/** Why the test cannot run here, or '' when everything is in place. */
+function missing() {
+  if (!fs.existsSync(path.join(REPO_ROOT, 'bin', 'sam-one'))) {
+    return 'bin/sam-one is not built (make build)';
+  }
+  if (!fs.existsSync(path.join(PAGE_DIR, 'index.html')) || !fs.existsSync(path.join(SDK_JS, 'build', 'examples', 'a2a-call.js'))) {
+    return 'the sdk/js browser example is not built (tests/ui/run.sh builds it)';
+  }
+  return '';
+}
+
+function freePort() {
+  return new Promise((resolve, reject) => {
+    const srv = net.createServer();
+    srv.listen(0, '127.0.0.1', () => {
+      const { port } = srv.address();
+      srv.close(() => resolve(port));
+    });
+    srv.on('error', reject);
+  });
+}
+
+async function waitFor(url, what, tries = 100) {
+  for (let i = 0; i < tries; i++) {
+    try {
+      const res = await fetch(url);
+      if (res.ok) {
+        return;
+      }
+    } catch {
+      // not up yet
+    }
+    await new Promise((r) => setTimeout(r, 100));
+  }
+  throw new Error(`timed out waiting for ${what} at ${url}`);
+}
+
+/** Runs bin/sam-one on a free loopback port; externalUrl is what it advertises. */
+async function startSamOne({ externalUrl } = {}) {
+  const port = await freePort();
+  const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sam-one-ui-'));
+  const args = ['--bind-address', '127.0.0.1', '--port', String(port), '--data-dir', dataDir, '--log-level', 'warn'];
+  if (externalUrl) {
+    args.push('--external-url', externalUrl);
+  }
+  const proc = spawn(path.join(REPO_ROOT, 'bin', 'sam-one'), args, { stdio: ['ignore', 'pipe', 'pipe'] });
+  let output = '';
+  proc.stdout.on('data', (d) => (output += d));
+  proc.stderr.on('data', (d) => (output += d));
+  const localUrl = `http://127.0.0.1:${port}`;
+  try {
+    await waitFor(`${localUrl}/readyz`, 'sam-one');
+  } catch (err) {
+    proc.kill();
+    throw new Error(`${err.message}\n${output}`);
+  }
+  return {
+    localUrl,
+    publicUrl: externalUrl ?? localUrl,
+    joinToken: fs.readFileSync(path.join(dataDir, 'join-token'), 'utf8').trim(),
+    get output() {
+      return output;
+    },
+    stop() {
+      proc.kill();
+      fs.rmSync(dataDir, { recursive: true, force: true });
+    },
+  };
+}
+
+/** A self-signed certificate for host, from openssl; null when openssl is not installed. */
+function selfSignedCert(host) {
+  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sam-edge-'));
+  const cert = path.join(dir, 'cert.pem');
+  const key = path.join(dir, 'key.pem');
+  const r = spawnSync('openssl', ['req', '-x509', '-newkey', 'ec', '-pkeyopt', 'ec_paramgen_curve:prime256v1', '-nodes', '-keyout', key, '-out', cert, '-days', '1', '-subj', `/CN=${host}`, '-addext', `subjectAltName=DNS:${host}`], { stdio: 'pipe' });
+  if (r.error || r.status !== 0) {
+    fs.rmSync(dir, { recursive: true, force: true });
+    return null;
+  }
+  return { certFile: cert, cert: fs.readFileSync(cert), key: fs.readFileSync(key), dir };
+}
+
+function hostOf(req) {
+  const h = req.headers.host ?? '';
+  return h.includes(':') ? h.slice(0, h.lastIndexOf(':')) : h;
+}
+
+/**
+ * Terminates TLS for host and proxies to origin, HTTP and WebSocket
+ * upgrades alike. A handshake for another server name fails and a request
+ * for another host gets 403, as an edge that routes by name treats a client
+ * that reached it by IP.
+ */
+async function startTLSEdge({ host, origin, cert, key, port = 0 }) {
+  const [originHost, originPort] = origin.split(':');
+  const context = tls.createSecureContext({ cert, key });
+  const server = https.createServer(
+    {
+      cert,
+      key,
+      SNICallback: (name, cb) => (name.toLowerCase() === host ? cb(null, context) : cb(new Error(`edge: unknown server name ${name}`))),
+    },
+    (req, res) => {
+      if (hostOf(req).toLowerCase() !== host) {
+        res.writeHead(403, { 'content-type': 'text/plain' });
+        res.end(`403 Forbidden (edge: unknown host ${req.headers.host})\n`);
+        return;
+      }
+      const upstream = http.request({ host: originHost, port: Number(originPort), method: req.method, path: req.url, headers: { ...req.headers, host: origin } }, (ur) => {
+        res.writeHead(ur.statusCode, ur.headers);
+        ur.pipe(res);
+      });
+      upstream.on('error', () => {
+        res.writeHead(502);
+        res.end();
+      });
+      req.pipe(upstream);
+    },
+  );
+  server.on('upgrade', (req, socket, head) => {
+    if (hostOf(req).toLowerCase() !== host) {
+      socket.end('HTTP/1.1 403 Forbidden\r\ncontent-length: 0\r\n\r\n');
+      return;
+    }
+    const upstream = net.connect(Number(originPort), originHost, () => {
+      const lines = [`${req.method} ${req.url} HTTP/1.1`];
+      for (let i = 0; i < req.rawHeaders.length; i += 2) {
+        const name = req.rawHeaders[i];
+        lines.push(`${name}: ${name.toLowerCase() === 'host' ? origin : req.rawHeaders[i + 1]}`);
+      }
+      upstream.write(lines.join('\r\n') + '\r\n\r\n');
+      if (head.length > 0) {
+        upstream.write(head);
+      }
+      socket.pipe(upstream).pipe(socket);
+    });
+    upstream.on('error', () => socket.destroy());
+    socket.on('error', () => upstream.destroy());
+  });
+  await new Promise((resolve) => server.listen(port, '127.0.0.1', resolve));
+  return { url: `https://${host}:${server.address().port}`, port: server.address().port, stop: () => server.close() };
+}
+
+const TYPES = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.map': 'application/json', '.wasm': 'application/wasm' };
+
+/** Serves a directory of static files on a free loopback port. */
+async function serveStatic(dir) {
+  const server = http.createServer((req, res) => {
+    const rel = decodeURIComponent(new URL(req.url, 'http://x').pathname).replace(/^\/+/, '') || 'index.html';
+    const file = path.join(dir, rel);
+    if (!file.startsWith(dir) || !fs.existsSync(file) || fs.statSync(file).isDirectory()) {
+      res.writeHead(404);
+      res.end();
+      return;
+    }
+    res.writeHead(200, { 'content-type': TYPES[path.extname(file)] ?? 'application/octet-stream' });
+    fs.createReadStream(file).pipe(res);
+  });
+  await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
+  return { url: `http://127.0.0.1:${server.address().port}`, stop: () => server.close() };
+}
+
+/** Runs one of sdk/js's built examples to completion and returns its output. */
+function runExample(name, env, args = []) {
+  return new Promise((resolve, reject) => {
+    const proc = spawn('node', [path.join(SDK_JS, 'build', 'examples', `${name}.js`), ...args], { cwd: REPO_ROOT, env: { ...process.env, ...env }, stdio: ['ignore', 'pipe', 'pipe'] });
+    let out = '';
+    proc.stdout.on('data', (d) => (out += d));
+    proc.stderr.on('data', (d) => (out += d));
+    const timer = setTimeout(() => proc.kill(), 30_000);
+    proc.on('close', (code) => {
+      clearTimeout(timer);
+      code === 0 ? resolve(out) : reject(new Error(`${name} exited with ${code}:\n${out}`));
+    });
+  });
+}
+
+/** Starts one of sdk/js's built example agents and waits for the line that names its peer. */
+function startExampleAgent(name, env, ready = /accepting a2a:\/\/agent as (\S+)/) {
+  return new Promise((resolve, reject) => {
+    const proc = spawn('node', [path.join(SDK_JS, 'build', 'examples', `${name}.js`)], { cwd: REPO_ROOT, env: { ...process.env, ...env }, stdio: ['ignore', 'pipe', 'pipe'] });
+    let out = '';
+    const timer = setTimeout(() => {
+      proc.kill();
+      reject(new Error(`${name} did not come up:\n${out}`));
+    }, 30_000);
+    const onData = (d) => {
+      out += d;
+      const m = ready.exec(out);
+      if (m) {
+        clearTimeout(timer);
+        resolve({ peerId: m[1], stop: () => proc.kill(), get output() { return out; } });
+      }
+    };
+    proc.stdout.on('data', onData);
+    proc.stderr.on('data', onData);
+    proc.on('close', () => {
+      clearTimeout(timer);
+      reject(new Error(`${name} exited:\n${out}`));
+    });
+  });
+}
+
+/** The environment the Node examples take to join sam-one at publicUrl. */
+function exampleEnv({ publicUrl, joinToken, caFile }, stateName) {
+  const dir = fs.mkdtempSync(path.join(os.tmpdir(), `sam-${stateName}-`));
+  fs.writeFileSync(path.join(dir, 'join-token'), joinToken + '\n', { mode: 0o600 });
+  return {
+    SAM_CONTROL_PLANE_URL: publicUrl,
+    SAM_BOOTSTRAP_TOKEN_PATH: path.join(dir, 'join-token'),
+    SAM_STATE_DIR: path.join(dir, 'state'),
+    ...(caFile ? { NODE_EXTRA_CA_CERTS: caFile } : {}),
+  };
+}
+
+module.exports = { PAGE_DIR, missing, freePort, startSamOne, selfSignedCert, startTLSEdge, serveStatic, runExample, startExampleAgent, exampleEnv };
diff --git a/tests/ui/run.sh b/tests/ui/run.sh
index a5461e09..6b64471c 100755
--- a/tests/ui/run.sh
+++ b/tests/ui/run.sh
@@ -32,6 +32,14 @@ export SAM_CONSOLE_URL="${STACK_CONSOLE_URL}"
 
 stack_start
 
+# The browser SDK test (browser-sdk.spec.js) runs sdk/js in a page against
+# bin/sam-one; the page and the Node examples it talks to are built here.
+cd "${REPO_ROOT}/sdk/js"
+npm ci --no-audit --no-fund
+npm run build
+npm run examples
+node scripts/bundle-browser.mjs examples/browser/app.js build/browser-example
+
 cd "${REPO_ROOT}/tests/ui"
 npm ci --no-audit --no-fund
 # --with-deps needs root to install OS libraries; only CI runners allow that.

From 74881e230b6abd62f5ee0b1ab290c921f12a3937 Mon Sep 17 00:00:00 2001
From: Antonio Ojea 
Date: Sat, 26 Sep 2026 14:16:47 +0000
Subject: [PATCH 7/9] docs: the JS SDK in a browser

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.
---
 sdk/README.md                           | 47 +++++++++++++++++-------
 sdk/js/README.md                        |  8 +++--
 site/content/docs/guides/native-sdks.md | 48 ++++++++++++++++++++++---
 3 files changed, 85 insertions(+), 18 deletions(-)

diff --git a/sdk/README.md b/sdk/README.md
index a4207a5a..b54c8dae 100644
--- a/sdk/README.md
+++ b/sdk/README.md
@@ -38,9 +38,9 @@ Milestones 1 to 5 are implemented and tested in both languages:
 | Enrollment with an OIDC token (`POST /register`) | yes | yes |
 | Credential refresh (`POST /refresh`), also in the background while joined | yes | yes |
 | Signed key-set sync (`GET /keys`) | yes | yes |
-| Persisted state (identity and credential, owner-only files) | yes | yes |
+| Persisted state (identity and credential, owner-only files; IndexedDB in a browser) | yes | yes |
 | Biscuit verification of a peer's credential (signature, expiry, peer binding, roles, labels) | yes | yes |
-| libp2p host as `sam-node` configures it (TCP and WebSocket, TLS, yamux) | yes | yes |
+| libp2p host as `sam-node` configures it (TCP and WebSocket, TLS first and Noise, yamux) | yes | yes |
 | `/sam/auth/1.0.0`, both sides; join = handshake with a router and check its role | yes | yes |
 | Circuit relay v2 reservation on the router; dial and accept through it | yes | yes |
 | Service discovery in the mesh DHT (`/sam/kad/1.0.0`) | yes | yes |
@@ -53,6 +53,7 @@ Milestones 1 to 5 are implemented and tested in both languages:
 | Control plane pull on `sam-node`'s interval: `/keys` verified against the trusted set, credential refresh after a rotation, `/info` bans and router addresses | yes | yes |
 | Gossip events from the control plane (`/sam/mesh/events/v1`, StrictSign): ban enforced at once, key rotation adopted, policy update pulls | yes | yes |
 | Banned peers refused: connections dropped and denied, handshakes and requests refused, dials refused | yes | yes |
+| Runs in a browser page: WebSocket and Noise to the router, state in IndexedDB, the agent answered by a fetch handler; `sdk/js/examples/browser` against `sam-one`, tested in Chromium | yes | — |
 | Published to a registry from the release workflow | npm `@sam-mesh/sdk` | PyPI `sam-mesh` |
 
 A member built this way is on the mesh and uses it in both directions: it
@@ -173,6 +174,9 @@ names (`control_plane_url`, `biscuit`, `expire_time` as RFC 3339,
 `trusted_keys[].public_key`, `issued_under_keys`, `router_addresses`,
 `oidc_session`), written with mode `0600` in a directory of mode `0700`.
 An unknown field is an error. `control_plane_url` has no trailing slash.
+In a browser the JS SDK keeps the same two records in an IndexedDB
+database named after the state location, kept by the browser for the
+page's origin.
 `sam-node` keeps the same message in `agent.db`, and `sam-node state
 export|import ` moves a member between the two
 (`internal/node/statedir.go`). `TestNativeSDKExamples` resumes each SDK's
@@ -198,6 +202,11 @@ messages in `api/sam.proto`. Bodies are capped at 1 MiB on both sides.
 
 - `` is the request's `challenge_unix_ms`, unix milliseconds, and must
   be within 5 minutes of the control plane's clock (`challengeMaxAge`).
+- The endpoints above answer a CORS preflight and mark their responses for
+  any origin (`Access-Control-Allow-Origin: *`), so a page on another origin
+  can call them. They authenticate by what the request carries, a token in
+  the body or a biscuit as a bearer, never by a cookie. The operator plane
+  (`/admin/*`, `/user/*`) and `/routers/lease` do not.
   Challenges are defined in `api/network.go`. It is the one instant on the
   wire that is an `int64`: it is the number in the signed text. Every other
   instant (`expire_time`, `sign_time`, `event_time`, `announce_time`) is a
@@ -228,8 +237,11 @@ messages in `api/sam.proto`. Bodies are capped at 1 MiB on both sides.
 - Transports: `libp2p.DefaultTransports` (TCP, QUIC, WebSocket).
 - Security: TLS first, Noise accepted (`libp2p.Security` twice, in that
   order, on `sam-node` and `sam-router`). Both bind the connection to the
-  peer ID. The Node and Python SDKs speak TLS and land on it; Noise is what
-  a browser can speak. js-libp2p has both; py-libp2p gained TLS in
+  peer ID. The Node and Python SDKs offer the same two in the same order and
+  land on TLS with a Go peer and with each other; in a browser the JS SDK
+  offers Noise alone, since a page cannot run libp2p's TLS, and a Node or
+  Python member reached through a relay meets it on Noise. js-libp2p has
+  both; py-libp2p gained TLS in
   [libp2p/py-libp2p#831](https://github.com/libp2p/py-libp2p/pull/831) and
   has passed the libp2p transport interoperability suite against the other
   implementations since
@@ -269,7 +281,10 @@ bytes), with a 64 KiB cap on the first frame.
   the same authorizer, then forwards `` to the service with those
   two headers stripped and `X-Peer-Id` set to the verified caller. The SDKs
   are clients of this for `inference://` and `a2a://` services, and servers
-  of it for their own agent, `a2a://`, only.
+  of it for their own agent, `a2a://`, only. Bodies are framed by
+  `Content-Length` or chunked transfer coding; a response with neither runs
+  to the end of the stream. The JS SDK frames these itself
+  (`http1.ts`), so the same code runs in a browser.
 
 ### Discovery (`internal/node/service.go`)
 
@@ -341,7 +356,10 @@ service, no DHT record, no catalog entry. The reasons:
   protocols times a registry of services.
 - **The browser.** A browser cannot listen. An agent in a browser is
   reachable through a router's relay and nothing else, which is what
-  `accept_a2a` is.
+  `accept_a2a` is. The JS SDK runs in a page: it reaches the router over
+  WebSocket (`wss` when the router sits behind a TLS-terminating edge, as
+  `sam-one --tunnel` puts it), secures the connection with Noise, keeps its
+  state in IndexedDB and answers its agent with a fetch handler.
 
 What an SDK agent is on the wire: a peer with a relay reservation on a
 router, answering `/sam/auth/1.0.0` and `/libp2p-http` for `a2a://`,
@@ -354,7 +372,8 @@ need no special case.
 Two agents that both wrote nothing down still meet: A learns B's peer ID
 (an invite, an agent card, a coordinator), dials it through a router, both
 present their credentials, and A opens the A2A conversation on that
-connection; libp2p's TLS is end to end, so the router carries ciphertext.
+connection; libp2p's secure channel is end to end, so the router carries
+ciphertext.
 An agent that must be *found* by name runs behind a `sam-node`. Two agents
 that both can only call out (two browsers) meet at a third agent behind a
 `sam-node` that both call.
@@ -591,10 +610,6 @@ same commit as the Go components they talk to.
 - Publishing services from an SDK: an MCP server, a named inference or A2A
   service, a DHT record or a catalog entry. That is `sam-node`'s job; see
   [Agents, not services](#agents-not-services).
-- A browser build. The JS SDK dials routers over WebSocket, which a browser
-  can do, and routers and nodes accept Noise, which a browser can speak, but
-  it still runs on Node.js only: it speaks libp2p TLS, and reads its state
-  and speaks HTTP/1.1 on streams with Node's `fs` and `http`.
 - Any SDK-only wire protocol. If an SDK needs something the Go node does not
   speak, the Go node learns it first.
 
@@ -616,6 +631,11 @@ sdk/python/.venv/bin/pytest sdk/python/tests
 # Both against a real control plane, router and sam-node, and the example
 # programs the docs embed against the same
 go test ./tests/integration -run TestNativeSDK -v
+
+# The JS SDK in a browser page against sam-one, in Chromium (Playwright);
+# also run by `make ui-test`
+cd sdk/js && node scripts/bundle-browser.mjs examples/browser/app.js build/browser-example
+cd tests/ui && npm ci && npx playwright install chromium && npx playwright test browser-sdk
 ```
 
 `make sdk-test` runs all of the above. The integration tests skip an SDK
@@ -669,10 +689,13 @@ one follows is named so a change on one side can be carried to the others.
 | `mcp.ts` | `mcp_client.py` | MCP over `/sam/mcp/1.0.0`, the client side of `internal/node/gate.go` |
 | `authorizer.ts` | `authorizer.py` | the provider authorizer, as `internal/node.(*SamNode).Authorize`, over the generated baseline and `datalog_rules` |
 | `libp2p-http.ts` | `libp2p_http.py` | `/libp2p-http` client (streaming) and the A2A ingress for the agent, as go-libp2p-http and `StartIngressServer`; mesh URLs |
+| `http1.ts` | — | HTTP/1.1 heads and bodies on a libp2p stream, for `libp2p-http.ts` (Python uses `h11` in `libp2p_http.py`) |
+| `libp2p-http-node.ts` | — | the ingress for a Node request listener (an Express app), Node's own HTTP server over the stream |
+| `platform/*.ts`, `platform/*.browser.ts` | — | what differs between Node and a browser: transports and security (TCP+WebSocket with TLS then Noise; WebSocket with Noise), state (files; IndexedDB), the biscuit WASM loader, the ingress. `package.json`'s `browser` field maps each to its twin; `scripts/bundle-browser.mjs` bundles for a page and fails if the browser graph reaches a `node:` module |
 | — | `httpx_transport.py` | `MeshTransport`, the mesh as an `httpx.AsyncBaseTransport` (JS has `session.fetch()` instead) |
 | `sync.ts` | `sync.py` | ban set and mesh event verification, as `reconcileBannedPeers` and `verifyEvent` |
 | `conformance.ts`, `conformance-join.ts` | `conformance.py`, `conformance_join.py` | the runners the integration tests drive |
-| `../examples/` | `../../examples/` | the programs the docs embed and `TestNativeSDKExamples` runs |
+| `../examples/` | `../../examples/` | the programs the docs embed and `TestNativeSDKExamples` runs; `../examples/browser/` is the page `tests/ui/browser-sdk.spec.js` drives |
 | `gen/` | `_proto/`, `_gen/` | generated by `hack/gen-sdk-proto.sh` from `api/sam.proto`, `sdk/python/proto/circuit.proto` and `api/datalog.go` |
 
 Unit tests sit beside the code (`*.test.ts`, `tests/test_*.py`) and build a
diff --git a/sdk/js/README.md b/sdk/js/README.md
index 424ae3ce..1624f4d2 100644
--- a/sdk/js/README.md
+++ b/sdk/js/README.md
@@ -1,7 +1,8 @@
 # @sam-mesh/sdk
 
 Native JavaScript SDK for joining a SAM agent mesh from inside the agent
-process. It replaces the `sam-node` sidecar for agents written for Node.js:
+process. It replaces the `sam-node` sidecar for agents written for Node.js
+or running in a browser page:
 the agent enrolls with the control plane, joins the mesh through a router,
 finds services and calls them, answers A2A requests for the agent itself,
 and follows the control plane's keys, bans and policy while it runs. It
@@ -18,7 +19,10 @@ Source: [github.com/google/sam/tree/main/sdk/js](https://github.com/google/sam/t
 npm install @sam-mesh/sdk @modelcontextprotocol/sdk zod
 ```
 
-Requires Node.js 22.18 or later.
+Requires Node.js 22.18 or later. In a browser, bundle it with the page
+(the package's `browser` field selects the browser files); the guide's
+[In a browser](https://sam-mesh.dev/docs/guides/native-sdks/#in-a-browser)
+section has the details and an example page.
 
 ## Use
 
diff --git a/site/content/docs/guides/native-sdks.md b/site/content/docs/guides/native-sdks.md
index c7e6c71e..9f5fcecf 100644
--- a/site/content/docs/guides/native-sdks.md
+++ b/site/content/docs/guides/native-sdks.md
@@ -110,6 +110,9 @@ pip install sam-mesh               # Python 3.11 or later
 The Python package is imported as `agent_mesh`. It runs on trio, because
 py-libp2p does; under asyncio, use it through `anyio` with the trio backend.
 
+The JS package also runs in a browser page; see
+[In a browser](#in-a-browser) below.
+
 ## 3. Be an agent
 
 This program joins the mesh and answers A2A requests for `a2a://agent`
@@ -916,6 +919,45 @@ or a pattern) and the members it may reach; see
 - **Reading the policy.** `session.policyRules` (`session.policy_rules`)
   holds the Datalog the session enforces, for logging or tests.
 
+## In a browser
+
+The JS SDK is the same package in a page. `AgentMesh.enroll`, `join`,
+`acceptA2A`, `fetch()` and the rest work as above; what differs is how
+the page reaches the mesh and where it keeps its state:
+
+- The page connects to a router over WebSocket and secures the connection
+  with Noise. Routers and nodes offer TLS first and accept Noise, so a
+  Node, Python or Go member reached through a relay meets the page on
+  Noise, end to end. `sam-one` listens on WebSocket by default; behind
+  `--tunnel` or any TLS-terminating proxy it advertises the router as
+  `wss`, which a page served over `https` needs.
+- The control plane answers the page's requests from any origin. Enrollment
+  and refresh authenticate by the token or biscuit the request carries, so a
+  page on another origin sends nothing a program could not send already.
+- `stateDir` names an IndexedDB database instead of a directory; a reload
+  resumes the saved enrollment without a token. `bootstrapTokenPath` and
+  `jwtPath` are refused, a browser has no files: pass `bootstrapToken` or
+  `jwt`.
+- `acceptA2A` takes a `handler` (a function from `Request` to `Response`) or
+  a `url`; a Node `listener` has no HTTP server to run on in a page.
+
+The example `sdk/js/examples/browser` is the Echo agent above in a page,
+answering with the A2A SDK's `JsonRpcTransportHandler` behind a handler.
+Bundle it with the page's own bundler, or with the script the repository
+uses:
+
+```bash
+cd sdk/js && npm run build
+node scripts/bundle-browser.mjs examples/browser/app.js build/browser-example
+```
+
+and serve `build/browser-example`. The `browser` field of `package.json`
+tells a bundler which files to swap for the browser; `@biscuit-auth/biscuit-wasm`
+imports its `.wasm` as a module, which webpack (`asyncWebAssembly`), Vite
+(`vite-plugin-wasm`) and the script above resolve. `tests/ui/browser-sdk.spec.js`
+runs the page in Chromium against `sam-one`, once directly and once behind
+a TLS-terminating edge, with Node members calling it and being called.
+
 ## What the SDKs do not do
 
 - They do not publish services. An MCP server, a model or an agent that
@@ -925,10 +967,8 @@ or a pattern) and the members it may reach; see
   policy enforcement of `sam-box` runs beside a `sam-node`.
 - They do not serve the discovery table. A member is a client of it; the
   routers hold the records.
-- They do not run in a browser. Node.js and CPython only. The JS SDK
-  dials routers over WebSocket, which a browser can do, and routers and
-  nodes accept Noise, which a browser can speak, but the SDK speaks libp2p
-  TLS and keeps its state and speaks HTTP on streams with Node's own modules.
+- The Python SDK does not run in a browser; the JS SDK does, see
+  [In a browser](#in-a-browser).
 
 The wire contract and the interoperability facts the tests pin are in
 [`sdk/README.md`](https://github.com/google/sam/blob/main/sdk/README.md).

From 0b852509ccfec1e94511dd854dd67dd2588ed292 Mon Sep 17 00:00:00 2001
From: Antonio Ojea 
Date: Sat, 26 Sep 2026 14:53:16 +0000
Subject: [PATCH 8/9] sdk: a dot segment spelled %2e is a dot segment

The A2A ingress in both SDKs refuses a request whose path has a "." or
".." segment, since policy is decided on the // 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.
---
 sdk/js/src/libp2p-http.test.ts           | 14 ++++++++++++++
 sdk/js/src/libp2p-http.ts                |  7 ++++++-
 sdk/python/src/agent_mesh/libp2p_http.py |  8 +++++++-
 sdk/python/tests/test_libp2p_http.py     |  3 +++
 4 files changed, 30 insertions(+), 2 deletions(-)

diff --git a/sdk/js/src/libp2p-http.test.ts b/sdk/js/src/libp2p-http.test.ts
index 83350c15..aec23b19 100644
--- a/sdk/js/src/libp2p-http.test.ts
+++ b/sdk/js/src/libp2p-http.test.ts
@@ -31,6 +31,7 @@ import { ROLE_NODE } from "./controlplane.ts";
 import {
   HTTP_PROTOCOL,
   a2aEndpoint,
+  admitIngress,
   fetchOverStream,
   httpIngressHandler,
   httpRequestOverStream,
@@ -318,6 +319,19 @@ test("the ingress rejects a request without a biscuit and a dotted path", async
   assert.equal(status, 400);
 });
 
+test("a dot segment is refused however it is spelled", async () => {
+  const endpoint = a2aEndpoint({ handler: () => new Response() });
+  // Nothing in options is consulted before the path check.
+  const options = providerOptions(new Uint8Array());
+  for (const target of ["/a2a/agent/../x", "/a2a/agent/./x", "/a2a/agent/%2e%2e/x", "/a2a/agent/%2E%2E/x", "/a2a/agent/.%2e/x", "/a2a/agent/%2e/x", "/a2a/%2e%2e/other/x?q=1"]) {
+    assert.deepEqual(await admitIngress({ target, headers: new Headers(), remotePeer: "peer" }, endpoint, options), { status: 400, text: "Invalid path" }, target);
+  }
+  // Not dot segments: the request reaches the next check, the missing biscuit.
+  for (const target of ["/a2a/agent/%2e%2ex/x", "/a2a/agent/..x/x", "/a2a/agent/x?p=../y"]) {
+    assert.deepEqual(await admitIngress({ target, headers: new Headers(), remotePeer: "peer" }, endpoint, options), { status: 401, text: "Missing X-Sam-Biscuit header" }, target);
+  }
+});
+
 test("mesh URLs name a peer and a service", () => {
   const peer = "12D3KooWJ2Yhy3CKwVPDw7AnkxN5HbZbb65xDoRif54hdXXUzh1U";
   assert.deepEqual(splitMeshURL(new URL(`http://mesh/sam/${peer}/a2a/agent`)), { peerId: peer, target: "/a2a/agent" });
diff --git a/sdk/js/src/libp2p-http.ts b/sdk/js/src/libp2p-http.ts
index f7276c7e..ab1672bd 100644
--- a/sdk/js/src/libp2p-http.ts
+++ b/sdk/js/src/libp2p-http.ts
@@ -121,8 +121,13 @@ export interface ProviderOptions extends ProviderAuthorizerOptions {
   onAuthorized?(peerId: string, verified: VerifiedBiscuit, targetService: string): void;
 }
 
+// A URL parser reads %2e as a dot too (WHATWG URL, path state), so the check
+// sees what the parser and the backend will see.
 function hasDotSegment(path: string): boolean {
-  return path.split("/").some((seg) => seg === "." || seg === "..");
+  return path.split("/").some((seg) => {
+    const s = seg.replace(/%2e/gi, ".");
+    return s === "." || s === "..";
+  });
 }
 
 /** What the ingress looks at before anything reaches the agent. */
diff --git a/sdk/python/src/agent_mesh/libp2p_http.py b/sdk/python/src/agent_mesh/libp2p_http.py
index f773bb92..f470fa0d 100644
--- a/sdk/python/src/agent_mesh/libp2p_http.py
+++ b/sdk/python/src/agent_mesh/libp2p_http.py
@@ -22,6 +22,7 @@
 import base64
 import json
 import logging
+import re
 from dataclasses import dataclass, field
 from typing import AsyncIterator, Awaitable, Callable, Mapping, Optional, Sequence, Union
 
@@ -114,8 +115,13 @@ class ProviderOptions:
     on_authorized: Optional[Callable[[str, VerifiedBiscuit, str], None]] = None
 
 
+_DOT_ENCODED = re.compile("%2e", re.IGNORECASE)
+
+
 def _has_dot_segment(path: str) -> bool:
-    return any(seg in (".", "..") for seg in path.split("/"))
+    # A URL parser reads %2e as a dot too (WHATWG URL, path state), so the
+    # check sees what the backend will see.
+    return any(_DOT_ENCODED.sub(".", seg) in (".", "..") for seg in path.split("/"))
 
 
 async def _read_http_request(stream: INetStream, conn: h11.Connection, limit: int) -> tuple[h11.Request, bytes]:
diff --git a/sdk/python/tests/test_libp2p_http.py b/sdk/python/tests/test_libp2p_http.py
index e0274334..a7a6adff 100644
--- a/sdk/python/tests/test_libp2p_http.py
+++ b/sdk/python/tests/test_libp2p_http.py
@@ -211,6 +211,9 @@ async def main():
                 assert (await http_request_over_stream(caller, pid, caller_biscuit, "a2a://other", "/card")).status == 404
                 assert (await http_request_over_stream(caller, pid, b"", "a2a://agent", "/card")).status == 401
                 assert (await http_request_over_stream(caller, pid, caller_biscuit, "a2a://agent", "/../other/x")).status == 400
+                # A URL parser reads %2e as a dot too; the spelling does not get past the check.
+                assert (await http_request_over_stream(caller, pid, caller_biscuit, "a2a://agent", "/%2e%2e/other/x")).status == 400
+                assert (await http_request_over_stream(caller, pid, caller_biscuit, "a2a://agent", "/.%2E/other/x")).status == 400
             nursery.cancel_scope.cancel()
 
     async def with_timeout():

From be9f8b21425c19744cae951558f9211c9c49fb27 Mon Sep 17 00:00:00 2001
From: Antonio Ojea 
Date: Sat, 26 Sep 2026 15:32:33 +0000
Subject: [PATCH 9/9] sdk/js: HEAD keeps the declared length, a cancelled body
 closes its reader

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.
---
 sdk/js/src/authorizer.test.ts  |  7 +++++--
 sdk/js/src/authorizer.ts       | 10 +++++++++-
 sdk/js/src/http1.test.ts       | 12 ++++++++++++
 sdk/js/src/http1.ts            |  4 +++-
 sdk/js/src/libp2p-http.test.ts | 18 ++++++++++++++++++
 sdk/js/src/libp2p-http.ts      | 15 +++++++++++++--
 6 files changed, 60 insertions(+), 6 deletions(-)

diff --git a/sdk/js/src/authorizer.test.ts b/sdk/js/src/authorizer.test.ts
index 039b7891..b528e1c4 100644
--- a/sdk/js/src/authorizer.test.ts
+++ b/sdk/js/src/authorizer.test.ts
@@ -93,8 +93,11 @@ test("the empty target is the protocol in the system namespace", async () => {
   await assert.rejects(authorizeCaller(request(token, "mcp://calc"), options([])), AuthorizationError);
 });
 
-test("a service the role was not granted is denied", async () => {
-  await assert.rejects(authorizeCaller(request(nodeToken(CALLER), "mcp://other"), options(NODE_ROLE_GRANTS)), AuthorizationError);
+test("a service the role was not granted is denied, and the refusal names the check", async () => {
+  await assert.rejects(
+    authorizeCaller(request(nodeToken(CALLER), "mcp://other"), options(NODE_ROLE_GRANTS)),
+    (err: unknown) => err instanceof AuthorizationError && /FailedLogic/.test(err.message) && !/\[object Object\]/.test(err.message),
+  );
 });
 
 test("a role with no grants at all is denied", async () => {
diff --git a/sdk/js/src/authorizer.ts b/sdk/js/src/authorizer.ts
index d5bf4a36..efe843f3 100644
--- a/sdk/js/src/authorizer.ts
+++ b/sdk/js/src/authorizer.ts
@@ -52,8 +52,16 @@ export class AuthorizationError extends Error {
   }
 }
 
+// biscuit-wasm throws plain objects ({ FailedLogic: ... }, { RunLimit: ... }).
 function describe(err: unknown): string {
-  return err instanceof Error ? err.message : String(err);
+  if (err instanceof Error) {
+    return err.message;
+  }
+  try {
+    return JSON.stringify(err);
+  } catch {
+    return String(err);
+  }
 }
 
 function timeFact(now: Date): string {
diff --git a/sdk/js/src/http1.test.ts b/sdk/js/src/http1.test.ts
index 68ee6bbb..d0374eae 100644
--- a/sdk/js/src/http1.test.ts
+++ b/sdk/js/src/http1.test.ts
@@ -103,6 +103,18 @@ test("a chunked body is reassembled, extensions and trailers skipped, and stream
   }
   assert.deepEqual(pieces, ["hello", " world"]);
   assert.ok(finished);
+  // Cancelling the stream reports the reason once and closes the generator,
+  // so nothing is read after the caller has gone.
+  const slow = new ByteReader(source("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n6\r\n world\r\n0\r\n\r\n", 3));
+  await readResponseHead(slow);
+  const reasons: (Error | undefined)[] = [];
+  const cancelled = bodyStream(slow, { kind: "chunked" }, (err) => reasons.push(err));
+  const r = cancelled.getReader();
+  assert.ok("hello".startsWith(dec.decode((await r.read()).value)));
+  await r.cancel(new Error("gone"));
+  assert.equal(reasons.length, 1);
+  assert.equal(reasons[0]?.message, "gone");
+  assert.deepEqual(await r.read(), { done: true, value: undefined });
   // Each malformed size line is followed by a body that would otherwise
   // parse, so only the size check can be what refuses it.
   for (const bad of ["zz\r\nhello\r\n0\r\n\r\n", "5g\r\nhello\r\n0\r\n\r\n", "5 g\r\nhello\r\n0\r\n\r\n", "-5\r\nhello\r\n0\r\n\r\n", "\r\nhello\r\n0\r\n\r\n", "123456789\r\n0\r\n\r\n"]) {
diff --git a/sdk/js/src/http1.ts b/sdk/js/src/http1.ts
index a0519be0..07811e50 100644
--- a/sdk/js/src/http1.ts
+++ b/sdk/js/src/http1.ts
@@ -387,8 +387,10 @@ export function bodyStream(reader: ByteReader, framing: BodyFraming, onDone: (er
         finish(e);
       }
     },
-    cancel(reason) {
+    async cancel(reason) {
+      // The stream is torn down first, so a read the generator is blocked on ends.
       finish(reason instanceof Error ? reason : new Error(String(reason)));
+      await chunks.return(undefined).catch(() => {});
     },
   });
 }
diff --git a/sdk/js/src/libp2p-http.test.ts b/sdk/js/src/libp2p-http.test.ts
index aec23b19..0bc05183 100644
--- a/sdk/js/src/libp2p-http.test.ts
+++ b/sdk/js/src/libp2p-http.test.ts
@@ -174,6 +174,10 @@ before(async () => {
       });
       return new Response(body, { headers: { "content-type": "text/event-stream" } });
     }
+    if (url.pathname === "/sized") {
+      // An agent that declares its length, as a static file server would.
+      return new Response(request.method === "HEAD" ? null : "x".repeat(42), { headers: { "content-type": "text/plain", "content-length": "42" } });
+    }
     return Response.json({ path: url.pathname + url.search, method: request.method, peer: request.headers.get("x-peer-id"), biscuit: request.headers.get("x-sam-biscuit"), echo: await request.text() });
   };
   await handlerAgent.handle(HTTP_PROTOCOL, httpIngressHandler(a2aEndpoint({ handler }), providerOptions(mint(handlerAgent.peerId.toString(), ROLE_NODE))), {
@@ -244,6 +248,20 @@ test("a fetch handler sees the caller and the path, and its streaming body goes
   assert.equal(res.status, 200);
   assert.deepEqual(JSON.parse(res.text()), { path: "/tasks?x=1", method: "POST", peer: caller.peerId.toString(), biscuit: null, echo: "hello" });
 
+  // HEAD: the head a GET would get, the declared length kept, no body sent.
+  const sized = await httpRequestOverStream(conn, callerBiscuit, "a2a://agent", "/sized");
+  assert.equal(sized.text(), "x".repeat(42));
+  const head = await httpRequestOverStream(conn, callerBiscuit, "a2a://agent", "/sized", { method: "HEAD" });
+  assert.equal(head.status, 200);
+  assert.equal(head.headers["content-length"], "42");
+  assert.equal(head.headers["content-type"], "text/plain");
+  assert.equal(head.body.length, 0);
+  // A handler that built a body for HEAD anyway: no length is invented.
+  const headJson = await httpRequestOverStream(conn, callerBiscuit, "a2a://agent", "/card", { method: "HEAD" });
+  assert.equal(headJson.status, 200);
+  assert.equal(headJson.headers["content-length"], undefined);
+  assert.equal(headJson.body.length, 0);
+
   const response = await fetchOverStream(conn, callerBiscuit, new Request(meshURL(handlerAgent.peerId.toString(), "a2a://agent", "/stream")));
   assert.equal(response.status, 200);
   assert.equal(response.headers.get("content-type"), "text/event-stream");
diff --git a/sdk/js/src/libp2p-http.ts b/sdk/js/src/libp2p-http.ts
index ab1672bd..8b544641 100644
--- a/sdk/js/src/libp2p-http.ts
+++ b/sdk/js/src/libp2p-http.ts
@@ -252,7 +252,18 @@ async function writeResponse(stream: Stream, response: Response, headOnly: boole
     }
   });
   headers.set("connection", "close");
-  const body = headOnly ? null : response.body;
+  if (headOnly) {
+    // The same head a GET would get (RFC 9110 section 9.3.2): the length the
+    // agent declared, if any; the body it may have built is not sent.
+    const declared = response.headers.get("content-length");
+    if (declared !== null) {
+      headers.set("content-length", declared);
+    }
+    void response.body?.cancel().catch(() => {});
+    await sendAll(stream, encodeResponseHead(response.status, response.statusText, headers));
+    return;
+  }
+  const body = response.body;
   if (body === null) {
     headers.set("content-length", "0");
     await sendAll(stream, encodeResponseHead(response.status, response.statusText, headers));
@@ -440,7 +451,7 @@ export async function fetchOverStream(conn: Connection, biscuit: Uint8Array, req
     return new Response(responseBody, { status: head.status, statusText: head.statusText, headers: head.headers });
   } catch (err) {
     stream.abort(asError(err));
-    throw signal.aborted ? signal.reason : err;
+    throw signal.aborted ? (signal.reason ?? err) : err;
   }
 }