@agentmesh-p2p/sdk 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +182 -2
  2. package/dist/auth.d.ts +66 -0
  3. package/dist/auth.js +164 -0
  4. package/dist/authorizer.d.ts +41 -0
  5. package/dist/authorizer.js +201 -0
  6. package/dist/base58.d.ts +2 -0
  7. package/dist/base58.js +57 -0
  8. package/dist/biscuit.d.ts +75 -0
  9. package/dist/biscuit.js +250 -0
  10. package/dist/bytes.d.ts +10 -0
  11. package/dist/bytes.js +51 -0
  12. package/dist/challenges.d.ts +16 -0
  13. package/dist/challenges.js +55 -0
  14. package/dist/conformance-join.d.ts +1 -0
  15. package/dist/conformance-join.js +257 -0
  16. package/dist/conformance.d.ts +1 -0
  17. package/dist/conformance.js +60 -0
  18. package/dist/controlplane.d.ts +123 -0
  19. package/dist/controlplane.js +368 -0
  20. package/dist/credential.d.ts +66 -0
  21. package/dist/credential.js +124 -0
  22. package/dist/discovery.d.ts +50 -0
  23. package/dist/discovery.js +150 -0
  24. package/dist/gen/agentmesh_pb.d.ts +2740 -0
  25. package/dist/gen/agentmesh_pb.js +591 -0
  26. package/dist/gen/datalog.d.ts +39 -0
  27. package/dist/gen/datalog.js +84 -0
  28. package/dist/host.d.ts +47 -0
  29. package/dist/host.js +109 -0
  30. package/dist/http1.d.ts +68 -0
  31. package/dist/http1.js +371 -0
  32. package/dist/identity.d.ts +31 -0
  33. package/dist/identity.js +125 -0
  34. package/dist/index.d.ts +18 -0
  35. package/dist/index.js +30 -0
  36. package/dist/libp2p-http-node.d.ts +17 -0
  37. package/dist/libp2p-http-node.js +116 -0
  38. package/dist/libp2p-http.d.ts +165 -0
  39. package/dist/libp2p-http.js +581 -0
  40. package/dist/mcp.d.ts +67 -0
  41. package/dist/mcp.js +157 -0
  42. package/dist/mesh.d.ts +151 -0
  43. package/dist/mesh.js +370 -0
  44. package/dist/platform/ingress.browser.d.ts +1 -0
  45. package/dist/platform/ingress.browser.js +16 -0
  46. package/dist/platform/ingress.d.ts +1 -0
  47. package/dist/platform/ingress.js +15 -0
  48. package/dist/platform/state.browser.d.ts +5 -0
  49. package/dist/platform/state.browser.js +47 -0
  50. package/dist/platform/state.d.ts +5 -0
  51. package/dist/platform/state.js +44 -0
  52. package/dist/platform/transports.browser.d.ts +3 -0
  53. package/dist/platform/transports.browser.js +27 -0
  54. package/dist/platform/transports.d.ts +3 -0
  55. package/dist/platform/transports.js +27 -0
  56. package/dist/platform/types.d.ts +7 -0
  57. package/dist/platform/types.js +14 -0
  58. package/dist/platform/wasm.browser.d.ts +2 -0
  59. package/dist/platform/wasm.browser.js +16 -0
  60. package/dist/platform/wasm.d.ts +2 -0
  61. package/dist/platform/wasm.js +56 -0
  62. package/dist/session.d.ts +316 -0
  63. package/dist/session.js +1269 -0
  64. package/dist/sync.d.ts +41 -0
  65. package/dist/sync.js +111 -0
  66. package/dist/tar.d.ts +25 -0
  67. package/dist/tar.js +406 -0
  68. package/package.json +74 -4
package/dist/mcp.js ADDED
@@ -0,0 +1,157 @@
1
+ // Copyright 2026 Google LLC
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+ import { lpStream } from "@libp2p/utils";
15
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
16
+ import { JSONRPCMessageSchema } from "@modelcontextprotocol/sdk/types.js";
17
+ import { AUTH_HANDSHAKE_TIMEOUT_MS, AuthRejectedError, MAX_AUTH_FRAME_BYTES, MCP_PROTOCOL } from "./auth.js";
18
+ import { BiscuitVerificationError, requirePublish, requireRole, verifyPeerBiscuit } from "./biscuit.js";
19
+ import { ROLE_NODE } from "./controlplane.js";
20
+ import { decodeAuthFrame, decodeAuthResponse } from "./credential.js";
21
+ /** go-msgio's default message cap, which agentmesh-node's StreamTransport uses. */
22
+ export const MAX_MCP_MESSAGE_BYTES = 8 * 1024 * 1024;
23
+ /** The MCP client the SDK identifies itself as. */
24
+ export const MCP_CLIENT_INFO = { name: "agent-mesh-sdk", version: "0.1.0" };
25
+ /**
26
+ * A Transport for @modelcontextprotocol/sdk over a libp2p stream that has
27
+ * already passed the auth handshake.
28
+ */
29
+ export class StreamTransport {
30
+ onclose;
31
+ onerror;
32
+ onmessage;
33
+ #lp;
34
+ #stream;
35
+ #closed = false;
36
+ constructor(stream) {
37
+ this.#stream = stream;
38
+ this.#lp = lpStream(stream, { maxDataLength: MAX_MCP_MESSAGE_BYTES });
39
+ }
40
+ async start() {
41
+ void this.#readLoop();
42
+ }
43
+ async #readLoop() {
44
+ const decoder = new TextDecoder();
45
+ try {
46
+ while (!this.#closed) {
47
+ const frame = await this.#lp.read();
48
+ const message = JSONRPCMessageSchema.parse(JSON.parse(decoder.decode(frame.subarray())));
49
+ this.onmessage?.(message);
50
+ }
51
+ }
52
+ catch (err) {
53
+ if (!this.#closed) {
54
+ this.onerror?.(err instanceof Error ? err : new Error(String(err)));
55
+ }
56
+ }
57
+ finally {
58
+ if (!this.#closed) {
59
+ this.#closed = true;
60
+ this.onclose?.();
61
+ }
62
+ }
63
+ }
64
+ async send(message) {
65
+ await this.#lp.write(new TextEncoder().encode(JSON.stringify(message)));
66
+ }
67
+ async close() {
68
+ if (this.#closed) {
69
+ return;
70
+ }
71
+ this.#closed = true;
72
+ await this.#stream.close().catch(() => this.#stream.abort(new Error("mcp stream close failed")));
73
+ this.onclose?.();
74
+ }
75
+ }
76
+ /**
77
+ * The provider's credential lacks a label the caller requires or a label of
78
+ * the session's egress floor, as checkPeerLabels refuses.
79
+ */
80
+ export class LabelsNotSatisfiedError extends Error {
81
+ constructor(peerId, required, what) {
82
+ super(`peer ${peerId} ${what}: ${required.join(", ")}`);
83
+ this.name = "LabelsNotSatisfiedError";
84
+ }
85
+ }
86
+ function requireEveryPair(provider, required, what) {
87
+ if (!required) {
88
+ return;
89
+ }
90
+ const pairs = Object.entries(required);
91
+ if (pairs.every(([k, v]) => provider.labels[k] === v)) {
92
+ return;
93
+ }
94
+ throw new LabelsNotSatisfiedError(provider.peerId, pairs.map(([k, v]) => `${k}=${v}`), what);
95
+ }
96
+ /**
97
+ * A requirement is satisfied only when the provider attests every pair, as
98
+ * agentmesh-node's api.LabelCheck (`check if label(k1, v1), label(k2, v2)`), the
99
+ * same rule as the egress floor. A map holds one value per key, so listing
100
+ * several pairs narrows the acceptable providers. Empty is no requirement.
101
+ */
102
+ export function requireLabels(provider, required) {
103
+ requireEveryPair(provider, required, "does not attest every required label");
104
+ }
105
+ /**
106
+ * The session's egress floor, agentmesh-node's egress.require_labels: the same
107
+ * rule as requireLabels, refused with a message that names the floor. Empty
108
+ * is no floor.
109
+ */
110
+ export function requireEgressLabels(provider, required) {
111
+ requireEveryPair(provider, required, "does not attest the egress floor");
112
+ }
113
+ /**
114
+ * Opens /mesh/mcp/1.0.0 to a connected provider for targetService ("" is the
115
+ * provider's own catalog), verifies the provider, and returns a connected
116
+ * MCP client. frame is this member's AuthFrame for that service; egressRequireLabels
117
+ * is the session's, not the caller's (requireEgressLabels).
118
+ */
119
+ export async function openMCPSession(conn, frame, trustedKeys, options = {}, egressRequireLabels) {
120
+ const signal = options.signal ?? AbortSignal.timeout(AUTH_HANDSHAKE_TIMEOUT_MS);
121
+ const stream = await conn.newStream(MCP_PROTOCOL, { signal, runOnLimitedConnection: true });
122
+ let provider;
123
+ try {
124
+ // The handshake frames are small; the MCP messages after them are not.
125
+ const auth = lpStream(stream, { maxDataLength: MAX_AUTH_FRAME_BYTES });
126
+ await auth.write(frame, { signal });
127
+ const resp = decodeAuthResponse((await auth.read({ signal })).subarray());
128
+ if (!resp.success) {
129
+ throw new AuthRejectedError(conn.remotePeer.toString(), resp.error || "no reason given");
130
+ }
131
+ provider = await verifyPeerBiscuit(resp.biscuit, conn.remotePeer.toString(), trustedKeys);
132
+ // Only nodes host services; a router's or an admin's credential is a member, not a provider.
133
+ requireRole(provider, ROLE_NODE);
134
+ // And only a node the policy lets serve the named service is one.
135
+ await requirePublish(provider, decodeAuthFrame(frame).targetService, options.policyRules);
136
+ requireLabels(provider, options.requiredLabels);
137
+ requireEgressLabels(provider, egressRequireLabels);
138
+ }
139
+ catch (err) {
140
+ await stream.close().catch(() => stream.abort(err instanceof Error ? err : new Error(String(err))));
141
+ if (err instanceof BiscuitVerificationError) {
142
+ throw new AuthRejectedError(conn.remotePeer.toString(), `provider credential rejected: ${err.message}`);
143
+ }
144
+ throw err;
145
+ }
146
+ const transport = new StreamTransport(stream);
147
+ const client = new Client(MCP_CLIENT_INFO);
148
+ await client.connect(transport, { signal });
149
+ return {
150
+ client,
151
+ provider,
152
+ close: async () => {
153
+ await client.close();
154
+ await transport.close();
155
+ },
156
+ };
157
+ }
package/dist/mesh.d.ts ADDED
@@ -0,0 +1,151 @@
1
+ import type { MessageInitShape } from "@bufbuild/protobuf";
2
+ import { ControlPlaneClient } from "./controlplane.ts";
3
+ import { type MeshCredential } from "./credential.ts";
4
+ import type { RouterInfo, TaskAuthorizationRuleSchema } from "./gen/agentmesh_pb.ts";
5
+ import { Identity } from "./identity.ts";
6
+ import { type JoinOptions, type MeshSession } from "./session.ts";
7
+ /**
8
+ * The saved credential was issued under a signing key the control plane no
9
+ * longer serves, so nothing can verify or refresh it; the member must enroll
10
+ * again with a token.
11
+ */
12
+ export declare class CredentialRetiredError extends Error {
13
+ constructor(stateDir: string);
14
+ }
15
+ export interface AgentMeshOptions {
16
+ /** Base URL of the control plane, e.g. https://mesh.example.com. */
17
+ controlPlaneUrl: string;
18
+ /** Accept plaintext http:// to a non-loopback control plane. Off by default. */
19
+ allowInsecure?: boolean;
20
+ /**
21
+ * Where the identity key and the credential are kept across restarts: a
22
+ * directory on Node, an IndexedDB database of that name in a browser.
23
+ * Without it the identity lives only in this process.
24
+ */
25
+ stateDir?: string | undefined;
26
+ /** Use this identity instead of the persisted or a freshly generated one. */
27
+ identity?: Identity;
28
+ /** Role to enroll as. Defaults to ROLE_NODE. */
29
+ role?: string;
30
+ /** Operator-declared labels, e.g. { region: "eu" }. */
31
+ labels?: Record<string, string>;
32
+ /** Injection point for tests. */
33
+ fetch?: typeof fetch;
34
+ }
35
+ /** Callback returning a fresh platform or OIDC JWT for enrollment and refresh. */
36
+ export type JwtSource = () => string | Promise<string>;
37
+ export interface EnrollOptions extends AgentMeshOptions {
38
+ /** A bootstrap token value, when the caller already holds it in memory. */
39
+ bootstrapToken?: string | undefined;
40
+ /** Path of a file holding the bootstrap token. Preferred over a value on Node; a browser has no files. */
41
+ bootstrapTokenPath?: string | undefined;
42
+ /**
43
+ * An OIDC ID token value, or a callback returning a fresh OIDC / platform
44
+ * workload identity token at enrollment and on every refresh.
45
+ */
46
+ jwt?: string | JwtSource | undefined;
47
+ /**
48
+ * Path of a file holding an OIDC ID token or a platform's workload identity
49
+ * token, such as a Kubernetes projected service account token. Preferred
50
+ * over a value; the file is read at enrollment and on every refresh.
51
+ */
52
+ jwtPath?: string | undefined;
53
+ /** Bounds the wait for an operator to approve a pending enrollment. */
54
+ signal?: AbortSignal;
55
+ /** Overrides the control plane's suggested poll interval while pending. */
56
+ pollIntervalMs?: number;
57
+ }
58
+ /** What one pull from the control plane changed. */
59
+ export interface ControlPlaneSync {
60
+ /** The trusted key set differs from before the pull. */
61
+ keysChanged: boolean;
62
+ /** The credential was re-issued because a rotation had happened since. */
63
+ refreshed: boolean;
64
+ /** The control plane's ban set, when /info answered. */
65
+ bannedPeerIds: string[] | undefined;
66
+ /** Every router with a live lease reported by /info, when present. */
67
+ routers?: RouterInfo[] | undefined;
68
+ /** When /info was asked; a ban recorded later cannot be in the answer. */
69
+ fetchedAt: Date;
70
+ /** One entry per part that failed; empty when everything landed. */
71
+ errors: string[];
72
+ }
73
+ /**
74
+ * A member of the mesh: an identity, the credential the control plane minted
75
+ * for it, and the client that keeps that credential fresh. join() puts it on
76
+ * the mesh over libp2p.
77
+ */
78
+ export declare class AgentMesh {
79
+ #private;
80
+ readonly identity: Identity;
81
+ readonly controlPlane: ControlPlaneClient;
82
+ private constructor();
83
+ get peerId(): string;
84
+ get credential(): MeshCredential;
85
+ /** The router catalog last returned by /info; empty when the control plane only listed router_addresses. */
86
+ get routers(): RouterInfo[];
87
+ /** Returns a new MeshCredential with a tar_block appended offline in memory. */
88
+ attenuate(rule: MessageInitShape<typeof TaskAuthorizationRuleSchema>): Promise<MeshCredential>;
89
+ /** Returns a new MeshCredential with its biscuit sealed against further attenuation. */
90
+ seal(): Promise<MeshCredential>;
91
+ /**
92
+ * Enrolls with the control plane and returns a member holding a credential.
93
+ *
94
+ * When stateDir already holds an unexpired credential from this control
95
+ * plane for the saved identity, that member is returned and no token is
96
+ * needed, so a program can call enroll on every start and read the token
97
+ * from its environment only on the first. A credential is resumed only
98
+ * while the control plane still serves a key the member trusts: one issued
99
+ * under a key that retired while the member was off cannot be verified or
100
+ * refreshed, so the member enrolls again with the token given, or throws
101
+ * CredentialRetiredError without one. Otherwise exactly one of
102
+ * bootstrapToken, bootstrapTokenPath, jwt or jwtPath must be given. Delete
103
+ * the state directory to enroll afresh, for instance with other labels.
104
+ */
105
+ static enroll(options: EnrollOptions): Promise<AgentMesh>;
106
+ /**
107
+ * Resumes a member from a state directory written by an earlier enroll(),
108
+ * for a process that must never hold an enrollment token. The control
109
+ * plane URL comes from the saved credential.
110
+ */
111
+ static load(options: Omit<AgentMeshOptions, "controlPlaneUrl"> & {
112
+ stateDir: string;
113
+ jwt?: JwtSource | undefined;
114
+ jwtPath?: string | undefined;
115
+ }): Promise<AgentMesh>;
116
+ /**
117
+ * Trades the current biscuit for a fresh one and persists it. The control
118
+ * plane redeems only the last biscuit it issued, so a lost refresh result
119
+ * means re-enrolling; persisting before returning keeps that rare.
120
+ * Concurrent calls wait for each other.
121
+ */
122
+ refresh(): Promise<MeshCredential>;
123
+ /**
124
+ * Adopts a signing key announced by a KEY_ROTATION event, so peers whose
125
+ * credentials the new key signs verify before the next pull confirms it.
126
+ */
127
+ addTrustedKey(key: Uint8Array): boolean;
128
+ /**
129
+ * The member's pull from the control plane, as agentmesh-node's SyncControlPlane:
130
+ * the signing keys (verified against the set already trusted, so whoever
131
+ * answers the URL cannot become the trust root), a credential refresh when
132
+ * a rotation happened since it was issued, and /info for the router
133
+ * addresses and the ban set. Each part is attempted even when another
134
+ * fails; the errors are reported together. Gossip events only bring this
135
+ * forward; they are never the only way state arrives.
136
+ */
137
+ syncControlPlane(): Promise<ControlPlaneSync>;
138
+ /**
139
+ * The frame that opens every stream to a peer: this member's biscuit plus
140
+ * the service it wants (e.g. "mcp://calculator").
141
+ */
142
+ authFrame(targetService?: string): Uint8Array;
143
+ /**
144
+ * Joins the mesh: connects to the routers in the credential, passes the
145
+ * auth handshake with them, reserves a relay slot and keeps the credential
146
+ * fresh for as long as the session is open.
147
+ */
148
+ join(options?: JoinOptions): Promise<MeshSession>;
149
+ /** Writes identity and credential to the state store, if one is configured. */
150
+ save(): Promise<void>;
151
+ }
package/dist/mesh.js ADDED
@@ -0,0 +1,370 @@
1
+ // Copyright 2026 Google LLC
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+ import { toHex } from "./bytes.js";
15
+ import { ControlPlaneClient, KeysNotTrustedError, ROLE_NODE } from "./controlplane.js";
16
+ import { attenuateCredential, credentialFromJSON, credentialPredatesRotation, credentialTimeToLiveSeconds, credentialToJSON, encodeAuthFrame, sealCredential, withCredentialMethods, } from "./credential.js";
17
+ import { Identity } from "./identity.js";
18
+ import { openState, readTextFile } from "./platform/state.js";
19
+ import { joinMesh } from "./session.js";
20
+ const IDENTITY_FILE = "identity.key";
21
+ const CREDENTIAL_FILE = "credential.json";
22
+ /** A saved credential with less validity left than this is not worth resuming; enroll again instead. */
23
+ const REUSE_MIN_TTL_SECONDS = 5 * 60;
24
+ /**
25
+ * The saved credential was issued under a signing key the control plane no
26
+ * longer serves, so nothing can verify or refresh it; the member must enroll
27
+ * again with a token.
28
+ */
29
+ export class CredentialRetiredError extends Error {
30
+ constructor(stateDir) {
31
+ super(`the credential in ${stateDir} was issued under a key the control plane no longer serves ` +
32
+ "(the member was off for longer than the key grace period); enroll again with a token");
33
+ this.name = "CredentialRetiredError";
34
+ }
35
+ }
36
+ /**
37
+ * The saved credential with the control plane's current keys adopted, or
38
+ * undefined when the control plane vouches for none of the keys it trusts. A
39
+ * control plane that cannot be reached, or answers in a way that is not a
40
+ * verdict on the keys, leaves the credential as it is: the pull before join
41
+ * tries again.
42
+ */
43
+ async function resumableCredential(controlPlane, credential) {
44
+ let keys;
45
+ try {
46
+ keys = await controlPlane.keys(credential.controlPlaneKeys);
47
+ }
48
+ catch (err) {
49
+ return err instanceof KeysNotTrustedError ? undefined : credential;
50
+ }
51
+ if (keys.length === 0 || sameKeySet(keys, credential.controlPlaneKeys)) {
52
+ return credential;
53
+ }
54
+ return { ...credential, controlPlaneKeys: keys };
55
+ }
56
+ /**
57
+ * A member of the mesh: an identity, the credential the control plane minted
58
+ * for it, and the client that keeps that credential fresh. join() puts it on
59
+ * the mesh over libp2p.
60
+ */
61
+ export class AgentMesh {
62
+ identity;
63
+ controlPlane;
64
+ #credential;
65
+ #routers = [];
66
+ #state;
67
+ #jwtSource;
68
+ // Refreshes run one after another, as agentmesh-node's refreshMu: the control
69
+ // plane redeems only the last biscuit it issued, so two in flight would
70
+ // leave the loser holding a spent one.
71
+ #refreshChain = Promise.resolve();
72
+ constructor(identity, controlPlane, credential, state, jwtSource) {
73
+ this.identity = identity;
74
+ this.controlPlane = controlPlane;
75
+ this.#credential = withCredentialMethods(credential);
76
+ this.#state = state;
77
+ this.#jwtSource = jwtSource;
78
+ }
79
+ get peerId() {
80
+ return this.identity.peerId;
81
+ }
82
+ get credential() {
83
+ return this.#credential;
84
+ }
85
+ /** The router catalog last returned by /info; empty when the control plane only listed router_addresses. */
86
+ get routers() {
87
+ return this.#routers;
88
+ }
89
+ /** Returns a new MeshCredential with a tar_block appended offline in memory. */
90
+ attenuate(rule) {
91
+ return attenuateCredential(this.#credential, rule);
92
+ }
93
+ /** Returns a new MeshCredential with its biscuit sealed against further attenuation. */
94
+ seal() {
95
+ return sealCredential(this.#credential);
96
+ }
97
+ /**
98
+ * Enrolls with the control plane and returns a member holding a credential.
99
+ *
100
+ * When stateDir already holds an unexpired credential from this control
101
+ * plane for the saved identity, that member is returned and no token is
102
+ * needed, so a program can call enroll on every start and read the token
103
+ * from its environment only on the first. A credential is resumed only
104
+ * while the control plane still serves a key the member trusts: one issued
105
+ * under a key that retired while the member was off cannot be verified or
106
+ * refreshed, so the member enrolls again with the token given, or throws
107
+ * CredentialRetiredError without one. Otherwise exactly one of
108
+ * bootstrapToken, bootstrapTokenPath, jwt or jwtPath must be given. Delete
109
+ * the state directory to enroll afresh, for instance with other labels.
110
+ */
111
+ static async enroll(options) {
112
+ const state = options.stateDir !== undefined ? openState(options.stateDir) : undefined;
113
+ const saved = await loadIdentity(state);
114
+ const identity = options.identity ?? saved ?? Identity.generate();
115
+ const controlPlane = newClient(options);
116
+ const jwtSource = resolveJwtSource(options);
117
+ const given = [options.bootstrapToken, options.bootstrapTokenPath, options.jwt, options.jwtPath].filter((v) => v !== undefined).length;
118
+ if (state !== undefined && saved !== undefined && saved.peerId === identity.peerId) {
119
+ const credential = await loadCredential(state);
120
+ if (credential !== undefined && sameBaseUrl(credential.controlPlaneUrl, controlPlane.url) && credentialTimeToLiveSeconds(credential) > REUSE_MIN_TTL_SECONDS) {
121
+ const resumed = await resumableCredential(controlPlane, credential);
122
+ if (resumed !== undefined) {
123
+ const mesh = new AgentMesh(identity, controlPlane, resumed, state, jwtSource);
124
+ if (resumed !== credential) {
125
+ await mesh.save();
126
+ }
127
+ return mesh;
128
+ }
129
+ if (given === 0) {
130
+ throw new CredentialRetiredError(options.stateDir);
131
+ }
132
+ }
133
+ }
134
+ if (given !== 1) {
135
+ const where = options.stateDir !== undefined ? ` (no credential to resume in ${options.stateDir})` : "";
136
+ throw new Error(`exactly one of bootstrapToken, bootstrapTokenPath, jwt or jwtPath is required${where}`);
137
+ }
138
+ const role = options.role ?? ROLE_NODE;
139
+ let enrollment;
140
+ if (options.jwt !== undefined || options.jwtPath !== undefined) {
141
+ const jwt = jwtSource !== undefined ? await jwtSource() : options.jwt;
142
+ enrollment = await controlPlane.register({ identity, jwt, role, ...labelsOf(options) });
143
+ }
144
+ else {
145
+ const bootstrapToken = options.bootstrapTokenPath !== undefined ? (await readTextFile(options.bootstrapTokenPath)).trim() : options.bootstrapToken;
146
+ enrollment = await controlPlane.enrollBootstrap({
147
+ identity,
148
+ bootstrapToken,
149
+ role,
150
+ ...labelsOf(options),
151
+ ...(options.pollIntervalMs !== undefined ? { pollIntervalMs: options.pollIntervalMs } : {}),
152
+ ...(options.signal !== undefined ? { signal: options.signal } : {}),
153
+ });
154
+ }
155
+ // Widen trust from the one key the enrollment carries to every key the
156
+ // control plane currently signs with, so peers holding credentials from
157
+ // a retiring key still verify. Best effort, as in agentmesh-node.
158
+ let controlPlaneKeys = [enrollment.controlPlanePublicKey];
159
+ try {
160
+ controlPlaneKeys = await controlPlane.keys(controlPlaneKeys);
161
+ }
162
+ catch {
163
+ // The enrollment key alone still works until the next sync.
164
+ }
165
+ const mesh = new AgentMesh(identity, controlPlane, {
166
+ controlPlaneUrl: baseUrl(controlPlane.url),
167
+ biscuit: enrollment.biscuit,
168
+ expiration: enrollment.expiration,
169
+ controlPlaneKeys,
170
+ issuedUnderKeys: controlPlaneKeys,
171
+ routerAddresses: enrollment.routerAddresses,
172
+ }, state, jwtSource);
173
+ await mesh.save();
174
+ return mesh;
175
+ }
176
+ /**
177
+ * Resumes a member from a state directory written by an earlier enroll(),
178
+ * for a process that must never hold an enrollment token. The control
179
+ * plane URL comes from the saved credential.
180
+ */
181
+ static async load(options) {
182
+ const state = openState(options.stateDir);
183
+ const identity = options.identity ?? (await loadIdentity(state));
184
+ if (!identity) {
185
+ throw new Error(`no identity in ${options.stateDir}; enroll first`);
186
+ }
187
+ const credential = await loadCredential(state);
188
+ if (credential === undefined) {
189
+ throw new Error(`no credential in ${options.stateDir}; enroll first`);
190
+ }
191
+ return new AgentMesh(identity, newClient({ ...options, controlPlaneUrl: credential.controlPlaneUrl }), credential, state, resolveJwtSource(options));
192
+ }
193
+ /**
194
+ * Trades the current biscuit for a fresh one and persists it. The control
195
+ * plane redeems only the last biscuit it issued, so a lost refresh result
196
+ * means re-enrolling; persisting before returning keeps that rare.
197
+ * Concurrent calls wait for each other.
198
+ */
199
+ async refresh() {
200
+ const run = this.#refreshChain.then(() => this.#refreshOnce(), () => this.#refreshOnce());
201
+ this.#refreshChain = run.then(() => undefined, () => undefined);
202
+ return run;
203
+ }
204
+ async #refreshOnce() {
205
+ let jwt;
206
+ if (this.#jwtSource !== undefined) {
207
+ try {
208
+ const token = await this.#jwtSource();
209
+ if (token !== "") {
210
+ jwt = token;
211
+ }
212
+ }
213
+ catch {
214
+ // Best effort: fall back to session-only refresh if the token source is temporarily unavailable.
215
+ }
216
+ }
217
+ const result = await this.controlPlane.refresh({
218
+ identity: this.identity,
219
+ biscuit: this.#credential.biscuit,
220
+ ...(jwt !== undefined ? { jwt } : {}),
221
+ });
222
+ let controlPlaneKeys = this.#credential.controlPlaneKeys;
223
+ try {
224
+ controlPlaneKeys = await this.controlPlane.keys(controlPlaneKeys);
225
+ }
226
+ catch {
227
+ // Keep the previous set; a failed /keys sync must not cost the new biscuit.
228
+ }
229
+ this.#credential = withCredentialMethods({
230
+ ...this.#credential,
231
+ biscuit: result.biscuit,
232
+ expiration: result.expiration,
233
+ controlPlaneKeys,
234
+ issuedUnderKeys: controlPlaneKeys,
235
+ });
236
+ await this.save();
237
+ return this.#credential;
238
+ }
239
+ /**
240
+ * Adopts a signing key announced by a KEY_ROTATION event, so peers whose
241
+ * credentials the new key signs verify before the next pull confirms it.
242
+ */
243
+ addTrustedKey(key) {
244
+ const hex = toHex(key);
245
+ if (this.#credential.controlPlaneKeys.some((k) => toHex(k) === hex)) {
246
+ return false;
247
+ }
248
+ this.#credential = withCredentialMethods({ ...this.#credential, controlPlaneKeys: [...this.#credential.controlPlaneKeys, key] });
249
+ return true;
250
+ }
251
+ /**
252
+ * The member's pull from the control plane, as agentmesh-node's SyncControlPlane:
253
+ * the signing keys (verified against the set already trusted, so whoever
254
+ * answers the URL cannot become the trust root), a credential refresh when
255
+ * a rotation happened since it was issued, and /info for the router
256
+ * addresses and the ban set. Each part is attempted even when another
257
+ * fails; the errors are reported together. Gossip events only bring this
258
+ * forward; they are never the only way state arrives.
259
+ */
260
+ async syncControlPlane() {
261
+ const errors = [];
262
+ let keysChanged = false;
263
+ let refreshed = false;
264
+ try {
265
+ const keys = await this.controlPlane.keys(this.#credential.controlPlaneKeys);
266
+ if (keys.length === 0) {
267
+ throw new Error("/keys returned no keys");
268
+ }
269
+ keysChanged = !sameKeySet(keys, this.#credential.controlPlaneKeys);
270
+ this.#credential = withCredentialMethods({ ...this.#credential, controlPlaneKeys: keys });
271
+ if (credentialPredatesRotation(this.#credential)) {
272
+ await this.refresh();
273
+ refreshed = true;
274
+ }
275
+ }
276
+ catch (err) {
277
+ errors.push(`keys: ${err instanceof Error ? err.message : String(err)}`);
278
+ }
279
+ let bannedPeerIds;
280
+ let routers;
281
+ // Taken before the request: a ban recorded after this instant cannot be
282
+ // in the answer, so its absence must not be read as an unban.
283
+ const fetchedAt = new Date();
284
+ try {
285
+ const info = await this.controlPlane.info();
286
+ routers = info.routers;
287
+ this.#routers = info.routers;
288
+ const discovered = info.routerAddresses.length > 0
289
+ ? info.routerAddresses
290
+ : info.routers.flatMap((r) => r.addresses).filter((a) => a.length > 0);
291
+ if (discovered.length > 0) {
292
+ this.#credential = withCredentialMethods({ ...this.#credential, routerAddresses: discovered });
293
+ }
294
+ bannedPeerIds = info.bannedPeerIds;
295
+ }
296
+ catch (err) {
297
+ errors.push(`info: ${err instanceof Error ? err.message : String(err)}`);
298
+ }
299
+ if (keysChanged || bannedPeerIds !== undefined) {
300
+ await this.save();
301
+ }
302
+ return { keysChanged, refreshed, bannedPeerIds, routers, fetchedAt, errors };
303
+ }
304
+ /**
305
+ * The frame that opens every stream to a peer: this member's biscuit plus
306
+ * the service it wants (e.g. "mcp://calculator").
307
+ */
308
+ authFrame(targetService = "") {
309
+ return encodeAuthFrame(this.#credential.biscuit, targetService);
310
+ }
311
+ /**
312
+ * Joins the mesh: connects to the routers in the credential, passes the
313
+ * auth handshake with them, reserves a relay slot and keeps the credential
314
+ * fresh for as long as the session is open.
315
+ */
316
+ join(options) {
317
+ return joinMesh(this, options);
318
+ }
319
+ /** Writes identity and credential to the state store, if one is configured. */
320
+ async save() {
321
+ if (this.#state === undefined) {
322
+ return;
323
+ }
324
+ await this.#state.write(IDENTITY_FILE, this.identity.toLibp2pPrivateKey());
325
+ await this.#state.write(CREDENTIAL_FILE, new TextEncoder().encode(credentialToJSON(this.#credential)));
326
+ }
327
+ }
328
+ function newClient(options) {
329
+ return new ControlPlaneClient({
330
+ url: options.controlPlaneUrl,
331
+ allowInsecure: options.allowInsecure ?? false,
332
+ ...(options.fetch !== undefined ? { fetch: options.fetch } : {}),
333
+ });
334
+ }
335
+ function sameKeySet(a, b) {
336
+ const hex = (keys) => keys
337
+ .map((k) => toHex(k))
338
+ .sort()
339
+ .join(",");
340
+ return hex(a) === hex(b);
341
+ }
342
+ function labelsOf(options) {
343
+ return options.labels !== undefined ? { labels: options.labels } : {};
344
+ }
345
+ async function loadIdentity(state) {
346
+ const bytes = await state?.read(IDENTITY_FILE);
347
+ return bytes === undefined ? undefined : Identity.fromLibp2pPrivateKey(bytes);
348
+ }
349
+ async function loadCredential(state) {
350
+ const bytes = await state.read(CREDENTIAL_FILE);
351
+ return bytes === undefined ? undefined : credentialFromJSON(new TextDecoder().decode(bytes));
352
+ }
353
+ /** The form every implementation persists: scheme, host, port, path, no trailing slash. */
354
+ function baseUrl(url) {
355
+ return String(url).replace(/\/+$/, "");
356
+ }
357
+ function sameBaseUrl(a, b) {
358
+ return baseUrl(a) === baseUrl(b);
359
+ }
360
+ function resolveJwtSource(options) {
361
+ if (options.jwtPath !== undefined) {
362
+ const path = options.jwtPath;
363
+ return async () => (await readTextFile(path)).trim();
364
+ }
365
+ if (typeof options.jwt === "function") {
366
+ const fn = options.jwt;
367
+ return async () => (await fn()).trim();
368
+ }
369
+ return undefined;
370
+ }
@@ -0,0 +1 @@
1
+ export { httpIngressHandler as ingressHandler } from "../libp2p-http.ts";