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) {
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..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
@@ -226,9 +235,13 @@ 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 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
@@ -268,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`)
@@ -340,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://`,
@@ -353,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.
@@ -590,11 +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, 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`.
- 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/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/package-lock.json b/sdk/js/package-lock.json
index 73101ea1..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",
@@ -26,8 +27,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": {
@@ -35,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"
},
@@ -136,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",
@@ -176,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",
@@ -767,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",
@@ -1855,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",
@@ -2196,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",
@@ -3311,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 544d401e..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",
@@ -51,8 +59,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": {
@@ -60,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..1d95020c
--- /dev/null
+++ b/sdk/js/scripts/bundle-browser.mjs
@@ -0,0 +1,103 @@
+#!/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, 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:")) {
+ const file = input.slice("wasm-esm:".length);
+ 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(", ")}`);
+ process.exit(1);
+}
+console.log(`bundled ${entry} into ${outdir}`);
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/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/bytes.ts b/sdk/js/src/bytes.ts
new file mode 100644
index 00000000..f33245f2
--- /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.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 ee77c80e..cb711a83 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.
@@ -229,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;
}
@@ -280,7 +279,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 +321,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 +340,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 +359,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/host.ts b/sdk/js/src/host.ts
index cc1ddc73..e3c21de5 100644
--- a/sdk/js/src/host.ts
+++ b/sdk/js/src/host.ts
@@ -13,10 +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 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): 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";
@@ -26,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 {
/**
@@ -61,13 +59,20 @@ 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,
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/http1.test.ts b/sdk/js/src/http1.test.ts
new file mode 100644
index 00000000..d0374eae
--- /dev/null
+++ b/sdk/js/src/http1.test.ts
@@ -0,0 +1,172 @@
+// 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);
+ // 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"]) {
+ 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..07811e50
--- /dev/null
+++ b/sdk/js/src/http1.ts
@@ -0,0 +1,425 @@
+// 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);
+ }
+ },
+ 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(() => {});
+ },
+ });
+}
+
+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/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/index.ts b/sdk/js/src/index.ts
index 91bbaa8a..47d2b496 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,7 @@ export {
type NodeRequestListener,
type ProviderOptions,
} from "./libp2p-http.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/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..0bc05183 100644
--- a/sdk/js/src/libp2p-http.test.ts
+++ b/sdk/js/src/libp2p-http.test.ts
@@ -31,15 +31,16 @@ import { ROLE_NODE } from "./controlplane.ts";
import {
HTTP_PROTOCOL,
a2aEndpoint,
+ admitIngress,
fetchOverStream,
httpIngressHandler,
httpRequestOverStream,
meshHTTPTarget,
meshURL,
splitMeshURL,
- streamToNodeDuplex,
type ProviderOptions,
} from "./libp2p-http.ts";
+import { nodeIngressHandler, streamToNodeDuplex } from "./libp2p-http-node.ts";
type Wasm = Awaited>;
@@ -48,6 +49,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 +150,40 @@ 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" } });
+ }
+ 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))), {
+ runOnLimitedConnection: true,
+ });
+
caller = await newHost();
callerBiscuit = mint(caller.peerId.toString(), ROLE_NODE);
guestBiscuit = mint(caller.peerId.toString(), "sam:role:guest");
@@ -161,6 +193,7 @@ after(async () => {
await caller.stop();
await agent.stop();
await listenerAgent.stop();
+ await handlerAgent.stop();
await new Promise((resolve) => backend.close(() => resolve()));
});
@@ -209,6 +242,57 @@ 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" });
+
+ // 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");
+ 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: "{}" });
@@ -253,6 +337,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 0b4871f9..8b544641 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,226 @@ export interface ProviderOptions extends ProviderAuthorizerOptions {
onAuthorized?(peerId: string, verified: VerifiedBiscuit, targetService: string): void;
}
-interface StreamSocket extends Duplex {
- remotePeer: string;
+// 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) => {
+ const s = seg.replace(/%2e/gi, ".");
+ return s === "." || s === "..";
+ });
}
-/**
- * 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;
+ inbound.forEach((value, name) => {
+ if (!HOP_HEADERS.has(name)) {
+ headers.append(name, value);
}
- for (const value of Array.isArray(v) ? v : [v]) {
- headers.append(k, 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");
+ 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));
+ 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 +404,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) : err;
+ }
}
export interface HTTPRequestOptions {
diff --git a/sdk/js/src/mesh.ts b/sdk/js/src/mesh.ts
index d5192207..5634f574 100644
--- a/sdk/js/src/mesh.ts
+++ b/sdk/js/src/mesh.ts
@@ -12,11 +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";
@@ -30,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. */
@@ -47,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;
@@ -86,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 {
@@ -114,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;
@@ -132,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,
@@ -167,7 +170,7 @@ export class AgentMesh {
issuedUnderKeys: controlPlaneKeys,
routerAddresses: enrollment.routerAddresses,
},
- options.stateDir,
+ state,
);
await mesh.save();
return mesh;
@@ -179,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);
}
/**
@@ -213,8 +217,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] };
@@ -285,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)));
}
}
@@ -307,7 +310,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);
@@ -317,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. */
@@ -350,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..f37910df
--- /dev/null
+++ b/sdk/js/src/platform/transports.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 on Node reaches the mesh: TCP and WebSocket, the transports
+// 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";
+import type { Libp2pOptions } from "libp2p";
+
+export function transports(): NonNullable {
+ return [tcp(), webSockets()];
+}
+
+export function connectionEncrypters(): NonNullable {
+ return [tls(), noise()];
+}
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 49db9b9a..50f57249 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 { 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, httpIngressHandler(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;
diff --git a/sdk/python/src/agent_mesh/host.py b/sdk/python/src/agent_mesh/host.py
index 9bb0504b..3d04b651 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, Noise accepted beside it, and yamux the muxer. py-libp2p is
+trio-based, so everything here is trio async."""
from __future__ import annotations
@@ -33,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
@@ -163,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/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():
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..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, 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.
+- 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).
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)
+}
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.