@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.
- package/README.md +182 -2
- package/dist/auth.d.ts +66 -0
- package/dist/auth.js +164 -0
- package/dist/authorizer.d.ts +41 -0
- package/dist/authorizer.js +201 -0
- package/dist/base58.d.ts +2 -0
- package/dist/base58.js +57 -0
- package/dist/biscuit.d.ts +75 -0
- package/dist/biscuit.js +250 -0
- package/dist/bytes.d.ts +10 -0
- package/dist/bytes.js +51 -0
- package/dist/challenges.d.ts +16 -0
- package/dist/challenges.js +55 -0
- package/dist/conformance-join.d.ts +1 -0
- package/dist/conformance-join.js +257 -0
- package/dist/conformance.d.ts +1 -0
- package/dist/conformance.js +60 -0
- package/dist/controlplane.d.ts +123 -0
- package/dist/controlplane.js +368 -0
- package/dist/credential.d.ts +66 -0
- package/dist/credential.js +124 -0
- package/dist/discovery.d.ts +50 -0
- package/dist/discovery.js +150 -0
- package/dist/gen/agentmesh_pb.d.ts +2740 -0
- package/dist/gen/agentmesh_pb.js +591 -0
- package/dist/gen/datalog.d.ts +39 -0
- package/dist/gen/datalog.js +84 -0
- package/dist/host.d.ts +47 -0
- package/dist/host.js +109 -0
- package/dist/http1.d.ts +68 -0
- package/dist/http1.js +371 -0
- package/dist/identity.d.ts +31 -0
- package/dist/identity.js +125 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +30 -0
- package/dist/libp2p-http-node.d.ts +17 -0
- package/dist/libp2p-http-node.js +116 -0
- package/dist/libp2p-http.d.ts +165 -0
- package/dist/libp2p-http.js +581 -0
- package/dist/mcp.d.ts +67 -0
- package/dist/mcp.js +157 -0
- package/dist/mesh.d.ts +151 -0
- package/dist/mesh.js +370 -0
- package/dist/platform/ingress.browser.d.ts +1 -0
- package/dist/platform/ingress.browser.js +16 -0
- package/dist/platform/ingress.d.ts +1 -0
- package/dist/platform/ingress.js +15 -0
- package/dist/platform/state.browser.d.ts +5 -0
- package/dist/platform/state.browser.js +47 -0
- package/dist/platform/state.d.ts +5 -0
- package/dist/platform/state.js +44 -0
- package/dist/platform/transports.browser.d.ts +3 -0
- package/dist/platform/transports.browser.js +27 -0
- package/dist/platform/transports.d.ts +3 -0
- package/dist/platform/transports.js +27 -0
- package/dist/platform/types.d.ts +7 -0
- package/dist/platform/types.js +14 -0
- package/dist/platform/wasm.browser.d.ts +2 -0
- package/dist/platform/wasm.browser.js +16 -0
- package/dist/platform/wasm.d.ts +2 -0
- package/dist/platform/wasm.js +56 -0
- package/dist/session.d.ts +316 -0
- package/dist/session.js +1269 -0
- package/dist/sync.d.ts +41 -0
- package/dist/sync.js +111 -0
- package/dist/tar.d.ts +25 -0
- package/dist/tar.js +406 -0
- package/package.json +74 -4
|
@@ -0,0 +1,368 @@
|
|
|
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
|
+
// Client for the control plane's mesh-protocol surface: protobuf over HTTP
|
|
15
|
+
// (api/agentmesh.proto). The operator plane (/admin/*, JSON) is out of scope.
|
|
16
|
+
import { create, fromBinary, toBinary } from "@bufbuild/protobuf";
|
|
17
|
+
import { timestampMs } from "@bufbuild/protobuf/wkt";
|
|
18
|
+
import { enrollChallenge, enrollStatusChallenge, policiesChallenge, refreshChallenge, registerChallenge } from "./challenges.js";
|
|
19
|
+
import { BootstrapEnrollRequestSchema, BootstrapEnrollResponseSchema, ControlPlaneInfoResponseSchema, EnrollRequestSchema, EnrollResponseSchema, EnrollmentStatus, KeysResponseSchema, PolicyConfigGetResponseSchema, TokenRefreshRequestSchema, TokenRefreshResponseSchema, } from "./gen/agentmesh_pb.js";
|
|
20
|
+
import { bytesEqual, toBase64, toBase64Url } from "./bytes.js";
|
|
21
|
+
import { verifyEd25519 } from "./identity.js";
|
|
22
|
+
export const PROTOBUF_CONTENT_TYPE = "application/x-protobuf";
|
|
23
|
+
export const HEADER_CHALLENGE_TIMESTAMP = "X-Mesh-Challenge-Ts";
|
|
24
|
+
export const HEADER_CHALLENGE_SIGNATURE = "X-Mesh-Challenge-Sig";
|
|
25
|
+
export const STALE_CHALLENGE_TIMESTAMP_MESSAGE = "stale or invalid challenge timestamp";
|
|
26
|
+
/** The role a plain mesh member enrolls with (api.RoleNode). */
|
|
27
|
+
export const ROLE_NODE = "mesh:role:node";
|
|
28
|
+
/** How far a signed /keys response's timestamp may drift from our clock. */
|
|
29
|
+
export const KEYS_RESPONSE_FRESHNESS_MS = 5 * 60 * 1000;
|
|
30
|
+
const ED25519_PUBLIC_KEY_SIZE = 32;
|
|
31
|
+
const MAX_RESPONSE_BYTES = 1024 * 1024;
|
|
32
|
+
/** A non-2xx answer from the control plane. */
|
|
33
|
+
export class ControlPlaneError extends Error {
|
|
34
|
+
status;
|
|
35
|
+
body;
|
|
36
|
+
constructor(path, status, body) {
|
|
37
|
+
super(`control plane ${path}: HTTP ${status}${body ? `: ${body.trim()}` : ""}`);
|
|
38
|
+
this.name = "ControlPlaneError";
|
|
39
|
+
this.status = status;
|
|
40
|
+
this.body = body;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** The control plane answered, and the answer is a refusal. */
|
|
44
|
+
export class EnrollmentRejectedError extends Error {
|
|
45
|
+
constructor(message) {
|
|
46
|
+
super(message);
|
|
47
|
+
this.name = "EnrollmentRejectedError";
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The /keys answer is signed by no key this member trusts: every key it
|
|
52
|
+
* holds has left the control plane's set, so its credential cannot be
|
|
53
|
+
* verified or refreshed either.
|
|
54
|
+
*/
|
|
55
|
+
export class KeysNotTrustedError extends Error {
|
|
56
|
+
constructor() {
|
|
57
|
+
super("keys response is not signed by any trusted control plane key");
|
|
58
|
+
this.name = "KeysNotTrustedError";
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/** Plaintext http:// to a host that is not loopback; see api.ValidateControlPlaneTransport. */
|
|
62
|
+
export class InsecureControlPlaneURLError extends Error {
|
|
63
|
+
constructor(url) {
|
|
64
|
+
super(`plaintext http:// control plane URL to a non-loopback host: ${url} (use https://, or set allowInsecure for a network you trust)`);
|
|
65
|
+
this.name = "InsecureControlPlaneURLError";
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function isLoopbackHost(host) {
|
|
69
|
+
const h = host.replace(/^\[|\]$/g, "");
|
|
70
|
+
return h.toLowerCase() === "localhost" || h === "127.0.0.1" || h === "::1" || /^127\.\d+\.\d+\.\d+$/.test(h);
|
|
71
|
+
}
|
|
72
|
+
/** Mirrors api.ValidateControlPlaneTransport: https, or http to loopback only. */
|
|
73
|
+
export function validateControlPlaneURL(rawUrl, allowInsecure = false) {
|
|
74
|
+
let u;
|
|
75
|
+
try {
|
|
76
|
+
u = new URL(rawUrl);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
throw new Error(`invalid control plane URL ${JSON.stringify(rawUrl)}`);
|
|
80
|
+
}
|
|
81
|
+
if (u.protocol === "https:") {
|
|
82
|
+
return u;
|
|
83
|
+
}
|
|
84
|
+
if (u.protocol === "http:") {
|
|
85
|
+
if (allowInsecure || isLoopbackHost(u.hostname)) {
|
|
86
|
+
return u;
|
|
87
|
+
}
|
|
88
|
+
throw new InsecureControlPlaneURLError(rawUrl);
|
|
89
|
+
}
|
|
90
|
+
throw new Error(`control plane URL ${JSON.stringify(rawUrl)} must use http:// or https://`);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Returns the key set if it is fresh and at least one listed key is already
|
|
94
|
+
* trusted and its signature verifies. Mirrors api.VerifyKeysResponse.
|
|
95
|
+
*/
|
|
96
|
+
export function verifyKeysResponse(resp, trusted, nowMs = Date.now()) {
|
|
97
|
+
if (trusted.length === 0) {
|
|
98
|
+
throw new Error("no trusted control plane key to verify /keys against");
|
|
99
|
+
}
|
|
100
|
+
if (resp.signatures.length !== resp.publicKeys.length) {
|
|
101
|
+
throw new Error(`keys response carries ${resp.signatures.length} signatures for ${resp.publicKeys.length} keys`);
|
|
102
|
+
}
|
|
103
|
+
if (resp.signTime === undefined) {
|
|
104
|
+
throw new Error("keys response carries no sign_time");
|
|
105
|
+
}
|
|
106
|
+
const issued = timestampMs(resp.signTime);
|
|
107
|
+
if (Math.abs(nowMs - issued) > KEYS_RESPONSE_FRESHNESS_MS) {
|
|
108
|
+
throw new Error(`keys response sign_time ${new Date(issued).toISOString()} is outside the freshness window`);
|
|
109
|
+
}
|
|
110
|
+
// Each signature covers the set and the signing time, deterministically
|
|
111
|
+
// encoded with the signatures cleared.
|
|
112
|
+
const payload = toBinary(KeysResponseSchema, create(KeysResponseSchema, { publicKeys: resp.publicKeys, signTime: resp.signTime }));
|
|
113
|
+
const keys = [];
|
|
114
|
+
let verified = false;
|
|
115
|
+
resp.publicKeys.forEach((pub, i) => {
|
|
116
|
+
if (pub.length !== ED25519_PUBLIC_KEY_SIZE) {
|
|
117
|
+
throw new Error(`keys response key ${i} has invalid size ${pub.length} (expected ${ED25519_PUBLIC_KEY_SIZE})`);
|
|
118
|
+
}
|
|
119
|
+
keys.push(pub);
|
|
120
|
+
if (verified) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const sig = resp.signatures[i];
|
|
124
|
+
if (sig && trusted.some((t) => bytesEqual(t, pub)) && verifyEd25519(pub, payload, sig)) {
|
|
125
|
+
verified = true;
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
if (!verified) {
|
|
129
|
+
throw new KeysNotTrustedError();
|
|
130
|
+
}
|
|
131
|
+
return keys;
|
|
132
|
+
}
|
|
133
|
+
function abortError(signal) {
|
|
134
|
+
return signal.reason instanceof Error ? signal.reason : new Error("aborted");
|
|
135
|
+
}
|
|
136
|
+
function sleep(ms, signal) {
|
|
137
|
+
return new Promise((resolve, reject) => {
|
|
138
|
+
if (signal?.aborted) {
|
|
139
|
+
reject(abortError(signal));
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const timer = setTimeout(() => {
|
|
143
|
+
signal?.removeEventListener("abort", onAbort);
|
|
144
|
+
resolve();
|
|
145
|
+
}, ms);
|
|
146
|
+
const onAbort = () => {
|
|
147
|
+
clearTimeout(timer);
|
|
148
|
+
reject(abortError(signal));
|
|
149
|
+
};
|
|
150
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
export class ControlPlaneClient {
|
|
154
|
+
url;
|
|
155
|
+
#fetch;
|
|
156
|
+
#timeoutMs;
|
|
157
|
+
#now;
|
|
158
|
+
constructor(options) {
|
|
159
|
+
this.url = validateControlPlaneURL(options.url, options.allowInsecure ?? false);
|
|
160
|
+
// Unbound: a browser's fetch refuses to run as a method of anything else.
|
|
161
|
+
const f = options.fetch ?? globalThis.fetch;
|
|
162
|
+
this.#fetch = (input, init) => f(input, init);
|
|
163
|
+
this.#timeoutMs = options.timeoutMs ?? 30_000;
|
|
164
|
+
this.#now = options.now ?? (() => Date.now());
|
|
165
|
+
}
|
|
166
|
+
/** GET /info: OIDC settings, router addresses and the ban list. Unauthenticated. */
|
|
167
|
+
async info() {
|
|
168
|
+
const body = await this.#request("GET", "/info");
|
|
169
|
+
return fromBinary(ControlPlaneInfoResponseSchema, body);
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* GET /keys: every signing key the control plane currently trusts,
|
|
173
|
+
* verified against a key the caller already trusts (the enrollment key).
|
|
174
|
+
*/
|
|
175
|
+
async keys(trusted) {
|
|
176
|
+
const body = await this.#request("GET", "/keys");
|
|
177
|
+
return verifyKeysResponse(fromBinary(KeysResponseSchema, body), trusted, this.#now());
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* POST /enroll with a bootstrap token, then GET /enroll/status until an
|
|
181
|
+
* operator approves the request if the mesh is not on auto-approve.
|
|
182
|
+
*/
|
|
183
|
+
async enrollBootstrap(params) {
|
|
184
|
+
const { identity, signal } = params;
|
|
185
|
+
const role = params.role ?? ROLE_NODE;
|
|
186
|
+
const raw = await this.#requestWithChallenge("POST", "/enroll", (ts) => {
|
|
187
|
+
const req = create(BootstrapEnrollRequestSchema, {
|
|
188
|
+
bootstrapToken: params.bootstrapToken,
|
|
189
|
+
peerId: identity.peerId,
|
|
190
|
+
publicKey: identity.libp2pPublicKey,
|
|
191
|
+
requestedRole: role,
|
|
192
|
+
labels: params.labels ?? {},
|
|
193
|
+
challengeUnixMs: BigInt(ts),
|
|
194
|
+
challengeSignature: identity.sign(enrollChallenge(identity.peerId, ts)),
|
|
195
|
+
});
|
|
196
|
+
return { body: toBinary(BootstrapEnrollRequestSchema, req) };
|
|
197
|
+
});
|
|
198
|
+
let resp = fromBinary(BootstrapEnrollResponseSchema, raw);
|
|
199
|
+
while (resp.status === EnrollmentStatus.PENDING) {
|
|
200
|
+
const waitMs = params.pollIntervalMs ?? Math.max(1, resp.pollIntervalSeconds) * 1000;
|
|
201
|
+
await sleep(waitMs, signal);
|
|
202
|
+
resp = await this.#enrollStatus(identity);
|
|
203
|
+
}
|
|
204
|
+
return enrollmentFromBootstrapResponse(resp);
|
|
205
|
+
}
|
|
206
|
+
async #enrollStatus(identity) {
|
|
207
|
+
const body = await this.#requestWithChallenge("GET", `/enroll/status?peer_id=${encodeURIComponent(identity.peerId)}`, (ts) => {
|
|
208
|
+
const sig = identity.sign(enrollStatusChallenge(identity.peerId, ts));
|
|
209
|
+
return {
|
|
210
|
+
headers: {
|
|
211
|
+
[HEADER_CHALLENGE_TIMESTAMP]: String(ts),
|
|
212
|
+
[HEADER_CHALLENGE_SIGNATURE]: toBase64Url(sig),
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
});
|
|
216
|
+
return fromBinary(BootstrapEnrollResponseSchema, body);
|
|
217
|
+
}
|
|
218
|
+
/** POST /register with an OIDC ID token. */
|
|
219
|
+
async register(params) {
|
|
220
|
+
const { identity } = params;
|
|
221
|
+
const raw = await this.#requestWithChallenge("POST", "/register", (ts) => {
|
|
222
|
+
const req = create(EnrollRequestSchema, {
|
|
223
|
+
jwt: params.jwt,
|
|
224
|
+
peerId: identity.peerId,
|
|
225
|
+
publicKey: identity.libp2pPublicKey,
|
|
226
|
+
requestedRole: params.role ?? ROLE_NODE,
|
|
227
|
+
labels: params.labels ?? {},
|
|
228
|
+
challengeUnixMs: BigInt(ts),
|
|
229
|
+
challengeSignature: identity.sign(registerChallenge(identity.peerId, ts)),
|
|
230
|
+
});
|
|
231
|
+
return { body: toBinary(EnrollRequestSchema, req) };
|
|
232
|
+
});
|
|
233
|
+
const resp = fromBinary(EnrollResponseSchema, raw);
|
|
234
|
+
if (resp.errorMessage) {
|
|
235
|
+
throw new EnrollmentRejectedError(`enrollment failed: ${resp.errorMessage}`);
|
|
236
|
+
}
|
|
237
|
+
return checkedEnrollment({
|
|
238
|
+
biscuit: resp.biscuitToken,
|
|
239
|
+
controlPlanePublicKey: resp.controlPlanePublicKey,
|
|
240
|
+
routerAddresses: resp.routerAddresses,
|
|
241
|
+
}, resp.expireTime);
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* POST /refresh: trades the biscuit for a fresh one. The old one is
|
|
245
|
+
* spent by this call; callers must persist the result before using it.
|
|
246
|
+
*/
|
|
247
|
+
async refresh(params) {
|
|
248
|
+
const { identity } = params;
|
|
249
|
+
const authHeader = `Bearer ${toBase64(params.biscuit)}`;
|
|
250
|
+
const body = await this.#requestWithChallenge("POST", "/refresh", (ts) => {
|
|
251
|
+
const req = create(TokenRefreshRequestSchema, {
|
|
252
|
+
challengeUnixMs: BigInt(ts),
|
|
253
|
+
challengeSignature: identity.sign(refreshChallenge(identity.peerId, ts)),
|
|
254
|
+
peerId: identity.peerId,
|
|
255
|
+
...(params.jwt ? { jwt: params.jwt } : {}),
|
|
256
|
+
});
|
|
257
|
+
return {
|
|
258
|
+
body: toBinary(TokenRefreshRequestSchema, req),
|
|
259
|
+
headers: { Authorization: authHeader },
|
|
260
|
+
};
|
|
261
|
+
});
|
|
262
|
+
const resp = fromBinary(TokenRefreshResponseSchema, body);
|
|
263
|
+
if (resp.errorMessage) {
|
|
264
|
+
throw new EnrollmentRejectedError(`refresh failed: ${resp.errorMessage}`);
|
|
265
|
+
}
|
|
266
|
+
if (resp.biscuitToken.length === 0) {
|
|
267
|
+
throw new Error("refresh returned an empty biscuit");
|
|
268
|
+
}
|
|
269
|
+
return { biscuit: resp.biscuitToken, expiration: expirationSeconds(resp.expireTime, "refresh") };
|
|
270
|
+
}
|
|
271
|
+
async policyRules(identityOrParams, maybeBiscuit) {
|
|
272
|
+
const identity = "identity" in identityOrParams ? identityOrParams.identity : identityOrParams;
|
|
273
|
+
const biscuit = "identity" in identityOrParams ? identityOrParams.biscuit : maybeBiscuit;
|
|
274
|
+
const authHeader = `Bearer ${toBase64(biscuit)}`;
|
|
275
|
+
const body = await this.#requestWithChallenge("GET", "/policies", (ts) => {
|
|
276
|
+
const sig = identity.sign(policiesChallenge(identity.peerId, ts));
|
|
277
|
+
return {
|
|
278
|
+
headers: {
|
|
279
|
+
Authorization: authHeader,
|
|
280
|
+
[HEADER_CHALLENGE_TIMESTAMP]: String(ts),
|
|
281
|
+
[HEADER_CHALLENGE_SIGNATURE]: toBase64Url(sig),
|
|
282
|
+
},
|
|
283
|
+
};
|
|
284
|
+
});
|
|
285
|
+
const resp = fromBinary(PolicyConfigGetResponseSchema, body);
|
|
286
|
+
if (resp.$unknown !== undefined && resp.$unknown.length > 0) {
|
|
287
|
+
throw new Error("control plane predates datalog_rules in its policy response; upgrade the control plane");
|
|
288
|
+
}
|
|
289
|
+
return resp.datalogRules;
|
|
290
|
+
}
|
|
291
|
+
async #requestWithChallenge(method, path, build) {
|
|
292
|
+
const first = build(this.#now());
|
|
293
|
+
const outcome = await this.#fetchOnce(method, path, first.body, first.headers);
|
|
294
|
+
if (outcome.ok) {
|
|
295
|
+
return outcome.buf;
|
|
296
|
+
}
|
|
297
|
+
const text = new TextDecoder().decode(outcome.buf);
|
|
298
|
+
if (outcome.status === 401 && text.includes(STALE_CHALLENGE_TIMESTAMP_MESSAGE) && outcome.dateHeader) {
|
|
299
|
+
const serverMs = Date.parse(outcome.dateHeader);
|
|
300
|
+
if (Number.isFinite(serverMs) && serverMs > 0) {
|
|
301
|
+
const retry = build(serverMs);
|
|
302
|
+
return this.#request(method, path, retry.body, retry.headers);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
throw new ControlPlaneError(path, outcome.status, text);
|
|
306
|
+
}
|
|
307
|
+
async #fetchOnce(method, path, body, headers = {}) {
|
|
308
|
+
const controller = new AbortController();
|
|
309
|
+
const timer = setTimeout(() => controller.abort(new Error(`control plane ${path}: timed out after ${this.#timeoutMs}ms`)), this.#timeoutMs);
|
|
310
|
+
try {
|
|
311
|
+
const init = {
|
|
312
|
+
method,
|
|
313
|
+
headers: { Accept: PROTOBUF_CONTENT_TYPE, ...headers },
|
|
314
|
+
signal: controller.signal,
|
|
315
|
+
};
|
|
316
|
+
if (body !== undefined) {
|
|
317
|
+
init.body = new Uint8Array(body);
|
|
318
|
+
init.headers = { ...init.headers, "Content-Type": PROTOBUF_CONTENT_TYPE };
|
|
319
|
+
}
|
|
320
|
+
const resp = await this.#fetch(new URL(path, this.url), init);
|
|
321
|
+
const buf = new Uint8Array(await resp.arrayBuffer());
|
|
322
|
+
if (buf.length > MAX_RESPONSE_BYTES) {
|
|
323
|
+
throw new Error(`control plane ${path}: response of ${buf.length} bytes exceeds the ${MAX_RESPONSE_BYTES} byte limit`);
|
|
324
|
+
}
|
|
325
|
+
return { ok: resp.ok, status: resp.status, buf, dateHeader: resp.headers.get("date") };
|
|
326
|
+
}
|
|
327
|
+
finally {
|
|
328
|
+
clearTimeout(timer);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
async #request(method, path, body, headers = {}) {
|
|
332
|
+
const outcome = await this.#fetchOnce(method, path, body, headers);
|
|
333
|
+
if (!outcome.ok) {
|
|
334
|
+
throw new ControlPlaneError(path, outcome.status, new TextDecoder().decode(outcome.buf));
|
|
335
|
+
}
|
|
336
|
+
return outcome.buf;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
function enrollmentFromBootstrapResponse(resp) {
|
|
340
|
+
switch (resp.status) {
|
|
341
|
+
case EnrollmentStatus.APPROVED:
|
|
342
|
+
return checkedEnrollment({
|
|
343
|
+
biscuit: resp.biscuitToken,
|
|
344
|
+
controlPlanePublicKey: resp.controlPlanePublicKey,
|
|
345
|
+
routerAddresses: resp.routerAddresses,
|
|
346
|
+
}, resp.expireTime);
|
|
347
|
+
case EnrollmentStatus.REJECTED:
|
|
348
|
+
throw new EnrollmentRejectedError(`enrollment rejected: ${resp.errorMessage || "no reason given"}`);
|
|
349
|
+
default:
|
|
350
|
+
throw new Error(`unexpected enrollment status ${resp.status}: ${resp.errorMessage}`);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
function checkedEnrollment(e, expireTime) {
|
|
354
|
+
if (e.biscuit.length === 0) {
|
|
355
|
+
throw new Error("received empty biscuit token");
|
|
356
|
+
}
|
|
357
|
+
if (e.controlPlanePublicKey.length !== ED25519_PUBLIC_KEY_SIZE) {
|
|
358
|
+
throw new Error(`received invalid control plane public key size: ${e.controlPlanePublicKey.length} bytes (expected ${ED25519_PUBLIC_KEY_SIZE})`);
|
|
359
|
+
}
|
|
360
|
+
return { ...e, expiration: expirationSeconds(expireTime, "enrollment") };
|
|
361
|
+
}
|
|
362
|
+
/** A biscuit of unknown lifetime cannot be kept fresh; the control plane must say when it expires. */
|
|
363
|
+
function expirationSeconds(expireTime, what) {
|
|
364
|
+
if (expireTime === undefined) {
|
|
365
|
+
throw new Error(`${what} response carries no expire_time`);
|
|
366
|
+
}
|
|
367
|
+
return Math.floor(timestampMs(expireTime) / 1000);
|
|
368
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { type MessageInitShape } from "@bufbuild/protobuf";
|
|
2
|
+
import { type Timestamp } from "@bufbuild/protobuf/wkt";
|
|
3
|
+
import { TaskAuthorizationRuleSchema, type AuthFrame, type AuthResponse, type OIDCSession } from "./gen/agentmesh_pb.ts";
|
|
4
|
+
/** What a member holds after enrolling: its biscuit and what it trusts. */
|
|
5
|
+
export interface MeshCredential {
|
|
6
|
+
controlPlaneUrl: string;
|
|
7
|
+
/** The biscuit the control plane minted for this identity. */
|
|
8
|
+
biscuit: Uint8Array;
|
|
9
|
+
/** Unix seconds at which the biscuit expires. */
|
|
10
|
+
expiration: number;
|
|
11
|
+
/** Every control plane signing key currently trusted (rotation keeps several valid). */
|
|
12
|
+
controlPlaneKeys: Uint8Array[];
|
|
13
|
+
/**
|
|
14
|
+
* The trusted set when the biscuit was issued. A key trusted now that was
|
|
15
|
+
* not in this set means a rotation happened since: the biscuit is signed
|
|
16
|
+
* by a retiring key and must be refreshed before that key leaves its
|
|
17
|
+
* grace period (agentmesh-node's identityPredatesRotation).
|
|
18
|
+
*/
|
|
19
|
+
issuedUnderKeys: Uint8Array[];
|
|
20
|
+
/** Router multiaddrs, `/p2p/<peer id>` suffixed, as handed out at enrollment. */
|
|
21
|
+
routerAddresses: string[];
|
|
22
|
+
/**
|
|
23
|
+
* What other implementations persist in the same file and this SDK does
|
|
24
|
+
* not use: key receipt times and agentmesh-node's OIDC session. Carried through
|
|
25
|
+
* so a state directory survives a round trip untouched.
|
|
26
|
+
*/
|
|
27
|
+
extra?: {
|
|
28
|
+
receiveTime: Map<string, Timestamp>;
|
|
29
|
+
oidcSession?: OIDCSession;
|
|
30
|
+
};
|
|
31
|
+
/** Returns a new credential whose biscuit is attenuated in memory with rule. */
|
|
32
|
+
attenuate?: (rule: MessageInitShape<typeof TaskAuthorizationRuleSchema>) => Promise<MeshCredential>;
|
|
33
|
+
/** Returns a new credential whose biscuit is sealed against further attenuation. */
|
|
34
|
+
seal?: () => Promise<MeshCredential>;
|
|
35
|
+
}
|
|
36
|
+
/** Attaches .attenuate() and .seal() convenience methods to a MeshCredential object. */
|
|
37
|
+
export declare function withCredentialMethods(c: MeshCredential): MeshCredential;
|
|
38
|
+
/**
|
|
39
|
+
* Returns a new MeshCredential with a tar_block appended offline in memory.
|
|
40
|
+
* If the rule sets expire_time earlier than the credential's expiration,
|
|
41
|
+
* the returned credential's expiration is narrowed to match.
|
|
42
|
+
*/
|
|
43
|
+
export declare function attenuateCredential(c: MeshCredential, ruleInput: MessageInitShape<typeof TaskAuthorizationRuleSchema>): Promise<MeshCredential>;
|
|
44
|
+
/** Returns a new MeshCredential whose biscuit is sealed so no further blocks can be appended. */
|
|
45
|
+
export declare function sealCredential(c: MeshCredential): Promise<MeshCredential>;
|
|
46
|
+
/** Whether a key trusted now was unknown when the credential was issued. */
|
|
47
|
+
export declare function credentialPredatesRotation(c: MeshCredential): boolean;
|
|
48
|
+
/** Seconds of validity left on the biscuit; negative once expired. */
|
|
49
|
+
export declare function credentialTimeToLiveSeconds(c: MeshCredential, nowMs?: number): number;
|
|
50
|
+
/**
|
|
51
|
+
* The first frame on every mesh stream (/mesh/auth/1.0.0, /mesh/mcp/1.0.0):
|
|
52
|
+
* the caller's biscuit and the service it wants.
|
|
53
|
+
* Framing (varint length prefix) is the transport's job.
|
|
54
|
+
*/
|
|
55
|
+
export declare function encodeAuthFrame(biscuit: Uint8Array, targetService?: string): Uint8Array;
|
|
56
|
+
/** The peer's answer to an AuthResponse, carrying its own biscuit on success. */
|
|
57
|
+
/** Reads back an AuthFrame this member built (encodeAuthFrame). */
|
|
58
|
+
export declare function decodeAuthFrame(bytes: Uint8Array): AuthFrame;
|
|
59
|
+
export declare function decodeAuthResponse(bytes: Uint8Array): AuthResponse;
|
|
60
|
+
/**
|
|
61
|
+
* credential.json is the api.MemberCredential message as protojson with
|
|
62
|
+
* proto field names, the layout `agentmesh-node state export` writes and every
|
|
63
|
+
* SDK reads. An unknown field is an error, as on every Agent Mesh surface.
|
|
64
|
+
*/
|
|
65
|
+
export declare function credentialToJSON(c: MeshCredential): string;
|
|
66
|
+
export declare function credentialFromJSON(text: string): MeshCredential;
|
|
@@ -0,0 +1,124 @@
|
|
|
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 { create, fromBinary, fromJson, toBinary, toJson } from "@bufbuild/protobuf";
|
|
15
|
+
import { timestampDate, timestampFromDate } from "@bufbuild/protobuf/wkt";
|
|
16
|
+
import { attenuateBiscuit, sealBiscuit } from "./biscuit.js";
|
|
17
|
+
import { toHex } from "./bytes.js";
|
|
18
|
+
import { AuthFrameSchema, AuthResponseSchema, MemberCredentialSchema, TaskAuthorizationRuleSchema, } from "./gen/agentmesh_pb.js";
|
|
19
|
+
/** Attaches .attenuate() and .seal() convenience methods to a MeshCredential object. */
|
|
20
|
+
export function withCredentialMethods(c) {
|
|
21
|
+
const out = {
|
|
22
|
+
...c,
|
|
23
|
+
attenuate: (rule) => attenuateCredential(out, rule),
|
|
24
|
+
seal: () => sealCredential(out),
|
|
25
|
+
};
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Returns a new MeshCredential with a tar_block appended offline in memory.
|
|
30
|
+
* If the rule sets expire_time earlier than the credential's expiration,
|
|
31
|
+
* the returned credential's expiration is narrowed to match.
|
|
32
|
+
*/
|
|
33
|
+
export async function attenuateCredential(c, ruleInput) {
|
|
34
|
+
const rule = create(TaskAuthorizationRuleSchema, ruleInput);
|
|
35
|
+
const biscuit = await attenuateBiscuit(c.biscuit, rule, c.controlPlaneKeys);
|
|
36
|
+
let expiration = c.expiration;
|
|
37
|
+
if (rule.expireTime !== undefined) {
|
|
38
|
+
const ruleExp = Math.floor(timestampDate(rule.expireTime).getTime() / 1000);
|
|
39
|
+
if (ruleExp < expiration) {
|
|
40
|
+
expiration = ruleExp;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return withCredentialMethods({
|
|
44
|
+
...c,
|
|
45
|
+
biscuit,
|
|
46
|
+
expiration,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
/** Returns a new MeshCredential whose biscuit is sealed so no further blocks can be appended. */
|
|
50
|
+
export async function sealCredential(c) {
|
|
51
|
+
const biscuit = await sealBiscuit(c.biscuit, c.controlPlaneKeys);
|
|
52
|
+
return withCredentialMethods({
|
|
53
|
+
...c,
|
|
54
|
+
biscuit,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
/** Whether a key trusted now was unknown when the credential was issued. */
|
|
58
|
+
export function credentialPredatesRotation(c) {
|
|
59
|
+
const issued = new Set(c.issuedUnderKeys.map((k) => toHex(k)));
|
|
60
|
+
return c.controlPlaneKeys.some((k) => !issued.has(toHex(k)));
|
|
61
|
+
}
|
|
62
|
+
/** Seconds of validity left on the biscuit; negative once expired. */
|
|
63
|
+
export function credentialTimeToLiveSeconds(c, nowMs = Date.now()) {
|
|
64
|
+
return c.expiration - Math.floor(nowMs / 1000);
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The first frame on every mesh stream (/mesh/auth/1.0.0, /mesh/mcp/1.0.0):
|
|
68
|
+
* the caller's biscuit and the service it wants.
|
|
69
|
+
* Framing (varint length prefix) is the transport's job.
|
|
70
|
+
*/
|
|
71
|
+
export function encodeAuthFrame(biscuit, targetService = "") {
|
|
72
|
+
return toBinary(AuthFrameSchema, create(AuthFrameSchema, { biscuit, targetService }));
|
|
73
|
+
}
|
|
74
|
+
/** The peer's answer to an AuthResponse, carrying its own biscuit on success. */
|
|
75
|
+
/** Reads back an AuthFrame this member built (encodeAuthFrame). */
|
|
76
|
+
export function decodeAuthFrame(bytes) {
|
|
77
|
+
return fromBinary(AuthFrameSchema, bytes);
|
|
78
|
+
}
|
|
79
|
+
export function decodeAuthResponse(bytes) {
|
|
80
|
+
return fromBinary(AuthResponseSchema, bytes);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* credential.json is the api.MemberCredential message as protojson with
|
|
84
|
+
* proto field names, the layout `agentmesh-node state export` writes and every
|
|
85
|
+
* SDK reads. An unknown field is an error, as on every Agent Mesh surface.
|
|
86
|
+
*/
|
|
87
|
+
export function credentialToJSON(c) {
|
|
88
|
+
const message = create(MemberCredentialSchema, {
|
|
89
|
+
controlPlaneUrl: c.controlPlaneUrl,
|
|
90
|
+
biscuit: c.biscuit,
|
|
91
|
+
expireTime: timestampFromDate(new Date(c.expiration * 1000)),
|
|
92
|
+
trustedKeys: c.controlPlaneKeys.map((k) => {
|
|
93
|
+
const receiveTime = c.extra?.receiveTime.get(toHex(k));
|
|
94
|
+
return receiveTime !== undefined ? { publicKey: k, receiveTime } : { publicKey: k };
|
|
95
|
+
}),
|
|
96
|
+
issuedUnderKeys: c.issuedUnderKeys,
|
|
97
|
+
routerAddresses: c.routerAddresses,
|
|
98
|
+
...(c.extra?.oidcSession !== undefined ? { oidcSession: c.extra.oidcSession } : {}),
|
|
99
|
+
});
|
|
100
|
+
return JSON.stringify(toJson(MemberCredentialSchema, message, { useProtoFieldName: true }), null, 2) + "\n";
|
|
101
|
+
}
|
|
102
|
+
export function credentialFromJSON(text) {
|
|
103
|
+
const message = fromJson(MemberCredentialSchema, JSON.parse(text));
|
|
104
|
+
if (message.controlPlaneUrl === "" || message.biscuit.length === 0 || message.expireTime === undefined) {
|
|
105
|
+
throw new Error("malformed credential file: control_plane_url, biscuit and expire_time are required");
|
|
106
|
+
}
|
|
107
|
+
const controlPlaneKeys = message.trustedKeys.map((k) => k.publicKey);
|
|
108
|
+
const receiveTime = new Map();
|
|
109
|
+
for (const k of message.trustedKeys) {
|
|
110
|
+
if (k.receiveTime !== undefined) {
|
|
111
|
+
receiveTime.set(toHex(k.publicKey), k.receiveTime);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return withCredentialMethods({
|
|
115
|
+
controlPlaneUrl: message.controlPlaneUrl,
|
|
116
|
+
biscuit: message.biscuit,
|
|
117
|
+
expiration: Math.floor(timestampDate(message.expireTime).getTime() / 1000),
|
|
118
|
+
controlPlaneKeys,
|
|
119
|
+
// A file that recorded no issuance set: the keys trusted then are the best answer.
|
|
120
|
+
issuedUnderKeys: message.issuedUnderKeys.length > 0 ? message.issuedUnderKeys : controlPlaneKeys,
|
|
121
|
+
routerAddresses: message.routerAddresses,
|
|
122
|
+
extra: { receiveTime, ...(message.oidcSession !== undefined ? { oidcSession: message.oidcSession } : {}) },
|
|
123
|
+
});
|
|
124
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { Connection } from "@libp2p/interface";
|
|
2
|
+
import { type DirectoryRequest, type DirectoryResponse, type PeerRecord } from "./gen/agentmesh_pb.ts";
|
|
3
|
+
/** api.DirectoryProtocolID. */
|
|
4
|
+
export declare const DIRECTORY_PROTOCOL = "/mesh/directory/1.0.0";
|
|
5
|
+
export declare const DEFAULT_LIMIT = 20;
|
|
6
|
+
export type ServiceType = "mcp" | "inference" | "a2a" | "egress";
|
|
7
|
+
export declare function isServiceType(value: unknown): value is ServiceType;
|
|
8
|
+
/** The directory key of a service type, or of one named service of that type (api.ServiceKey). */
|
|
9
|
+
export declare function serviceKey(type: ServiceType, name?: string): Promise<Uint8Array>;
|
|
10
|
+
/** The directory key a member announces where it is reached under (api.PeerKey). */
|
|
11
|
+
export declare function peerKey(peerId: string): Promise<Uint8Array>;
|
|
12
|
+
/** Splits "mcp://calculator" into its type and name; "" is the node's own catalog. */
|
|
13
|
+
export declare function parseServiceTarget(target: string): {
|
|
14
|
+
type: ServiceType | "";
|
|
15
|
+
name: string;
|
|
16
|
+
};
|
|
17
|
+
/** No held router answered a directory request. */
|
|
18
|
+
export declare class DirectoryError extends Error {
|
|
19
|
+
constructor(message: string);
|
|
20
|
+
}
|
|
21
|
+
/** A router connection a request goes over. */
|
|
22
|
+
export interface DirectoryRouter {
|
|
23
|
+
peerId: string;
|
|
24
|
+
connection(): Connection | undefined;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Sends req to the held routers in turn until one answers. A router that
|
|
28
|
+
* is restarting costs the request a timeout, not an answer. A lookup one
|
|
29
|
+
* router answers with nothing is put to the next as well: a router cut
|
|
30
|
+
* off from the routers closest to the key answers from what it can reach.
|
|
31
|
+
*/
|
|
32
|
+
export declare function request(routers: DirectoryRouter[], req: DirectoryRequest, signal?: AbortSignal): Promise<DirectoryResponse>;
|
|
33
|
+
/**
|
|
34
|
+
* Announces this member as a provider of key, reached at addrs, to a held
|
|
35
|
+
* router; the router keeps the record and places it on the routers closest
|
|
36
|
+
* to the key.
|
|
37
|
+
*/
|
|
38
|
+
export declare function provide(routers: DirectoryRouter[], key: Uint8Array, addrs: string[], signal?: AbortSignal): Promise<void>;
|
|
39
|
+
/**
|
|
40
|
+
* Removes this member's record under key from the held router and from the
|
|
41
|
+
* routers it was placed on.
|
|
42
|
+
*/
|
|
43
|
+
export declare function withdraw(routers: DirectoryRouter[], key: Uint8Array, signal?: AbortSignal): Promise<void>;
|
|
44
|
+
/** Who provides key, up to limit, with their addresses; empty when nobody does. */
|
|
45
|
+
export declare function findProviders(routers: DirectoryRouter[], key: Uint8Array, limit?: number, signal?: AbortSignal): Promise<PeerRecord[]>;
|
|
46
|
+
/**
|
|
47
|
+
* Where a peer is reached: its addresses, each ending in its peer ID,
|
|
48
|
+
* relayed ones included. Undefined when nobody knows the peer.
|
|
49
|
+
*/
|
|
50
|
+
export declare function findPeer(routers: DirectoryRouter[], peerId: string, signal?: AbortSignal): Promise<PeerRecord | undefined>;
|