Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 27 additions & 7 deletions internal/controlplane/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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()
Expand Down
73 changes: 73 additions & 0 deletions internal/controlplane/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
7 changes: 6 additions & 1 deletion internal/node/node.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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(),
Expand Down
4 changes: 4 additions & 0 deletions internal/router/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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(),
Expand Down
51 changes: 37 additions & 14 deletions sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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
Expand Down Expand Up @@ -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 <dir>` moves a member between the two
(`internal/node/statedir.go`). `TestNativeSDKExamples` resumes each SDK's
Expand All @@ -198,6 +202,11 @@ messages in `api/sam.proto`. Bodies are capped at 1 MiB on both sides.

- `<ts>` 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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -268,7 +281,10 @@ bytes), with a 64 KiB cap on the first frame.
the same authorizer, then forwards `<upstream>` 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://<name>`, only.
of it for their own agent, `a2a://<name>`, 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`)

Expand Down Expand Up @@ -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://<name>`,
Expand All @@ -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.
Expand Down Expand Up @@ -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.

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions sdk/js/README.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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

Expand Down
Loading
Loading