@muxoai/sdk 1.2.3 → 1.3.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/.agents/plugins/marketplace.json +22 -0
- package/.claude-plugin/marketplace.json +17 -0
- package/.codex/config.toml +5 -0
- package/ARCHITECTURE.md +111 -0
- package/README.md +21 -12
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +894 -0
- package/dist/config.d.ts +20 -0
- package/dist/config.js +89 -0
- package/dist/engine.d.ts +156 -0
- package/dist/engine.js +876 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/mcp.d.ts +45 -0
- package/dist/mcp.js +914 -0
- package/dist/nl.d.ts +34 -0
- package/dist/nl.js +577 -0
- package/dist/providers/composio-instant.d.ts +14 -0
- package/dist/providers/composio-instant.js +814 -0
- package/dist/providers/composio-sdk.d.ts +41 -0
- package/dist/providers/composio-sdk.js +572 -0
- package/dist/providers/microsoft-governance.d.ts +66 -0
- package/dist/providers/microsoft-governance.js +415 -0
- package/dist/providers/stripe-billing.d.ts +41 -0
- package/dist/providers/stripe-billing.js +372 -0
- package/dist/providers/stripe-projects.d.ts +53 -0
- package/dist/providers/stripe-projects.js +636 -0
- package/dist/providers/supermemory.d.ts +64 -0
- package/dist/providers/supermemory.js +224 -0
- package/{index.d.ts → dist/sdk.d.ts} +77 -1
- package/dist/sdk.js +270 -0
- package/dist/server.d.ts +10 -0
- package/dist/server.js +1277 -0
- package/dist/store.d.ts +216 -0
- package/dist/store.js +1449 -0
- package/dist/types.d.ts +285 -0
- package/dist/types.js +1 -0
- package/dist/ui/console-html.d.ts +2 -0
- package/dist/ui/console-html.js +2343 -0
- package/package.json +66 -6
- package/plugins/muxo/.app.json +8 -0
- package/plugins/muxo/.claude-plugin/plugin.json +16 -0
- package/plugins/muxo/.codex-plugin/plugin.json +55 -0
- package/plugins/muxo/.mcp.json +40 -0
- package/plugins/muxo/assets/logo.png +0 -0
- package/plugins/muxo/assets/logo.svg +6 -0
- package/plugins/muxo/hooks/hooks.json +27 -0
- package/plugins/muxo/hooks/session-start.sh +85 -0
- package/plugins/muxo/hooks/user-prompt-submit.sh +45 -0
- package/plugins/muxo/mcp.json +38 -0
- package/plugins/muxo/plugin.json +59 -0
- package/plugins/muxo/skills/muxo/SKILL.md +25 -0
- package/plugins/muxo/skills/muxo/references/cli.md +57 -0
- package/plugins/muxo/skills/muxo/references/mcp.md +25 -0
- package/plugins/muxo/skills/setup/SKILL.md +28 -0
- package/src/catalogues/stripe-projects-catalog.json +9832 -0
- package/vendor/microsoft-governance/README.md +363 -0
- package/vendor/microsoft-governance/dist/audit.d.ts +26 -0
- package/vendor/microsoft-governance/dist/audit.d.ts.map +1 -0
- package/vendor/microsoft-governance/dist/audit.js +107 -0
- package/vendor/microsoft-governance/dist/audit.js.map +1 -0
- package/vendor/microsoft-governance/dist/cascade-containment.d.ts +84 -0
- package/vendor/microsoft-governance/dist/cascade-containment.d.ts.map +1 -0
- package/vendor/microsoft-governance/dist/cascade-containment.js +387 -0
- package/vendor/microsoft-governance/dist/cascade-containment.js.map +1 -0
- package/vendor/microsoft-governance/dist/client.d.ts +34 -0
- package/vendor/microsoft-governance/dist/client.d.ts.map +1 -0
- package/vendor/microsoft-governance/dist/client.js +112 -0
- package/vendor/microsoft-governance/dist/client.js.map +1 -0
- package/vendor/microsoft-governance/dist/context-poisoning.d.ts +58 -0
- package/vendor/microsoft-governance/dist/context-poisoning.d.ts.map +1 -0
- package/vendor/microsoft-governance/dist/context-poisoning.js +410 -0
- package/vendor/microsoft-governance/dist/context-poisoning.js.map +1 -0
- package/vendor/microsoft-governance/dist/credential-vault.d.ts +213 -0
- package/vendor/microsoft-governance/dist/credential-vault.d.ts.map +1 -0
- package/vendor/microsoft-governance/dist/credential-vault.js +567 -0
- package/vendor/microsoft-governance/dist/credential-vault.js.map +1 -0
- package/vendor/microsoft-governance/dist/discovery.d.ts +80 -0
- package/vendor/microsoft-governance/dist/discovery.d.ts.map +1 -0
- package/vendor/microsoft-governance/dist/discovery.js +404 -0
- package/vendor/microsoft-governance/dist/discovery.js.map +1 -0
- package/vendor/microsoft-governance/dist/encryption/channel.d.ts +29 -0
- package/vendor/microsoft-governance/dist/encryption/channel.d.ts.map +1 -0
- package/vendor/microsoft-governance/dist/encryption/channel.js +78 -0
- package/vendor/microsoft-governance/dist/encryption/channel.js.map +1 -0
- package/vendor/microsoft-governance/dist/encryption/index.d.ts +9 -0
- package/vendor/microsoft-governance/dist/encryption/index.d.ts.map +1 -0
- package/vendor/microsoft-governance/dist/encryption/index.js +23 -0
- package/vendor/microsoft-governance/dist/encryption/index.js.map +1 -0
- package/vendor/microsoft-governance/dist/encryption/mesh-client.d.ts +267 -0
- package/vendor/microsoft-governance/dist/encryption/mesh-client.d.ts.map +1 -0
- package/vendor/microsoft-governance/dist/encryption/mesh-client.js +1001 -0
- package/vendor/microsoft-governance/dist/encryption/mesh-client.js.map +1 -0
- package/vendor/microsoft-governance/dist/encryption/ratchet.d.ts +38 -0
- package/vendor/microsoft-governance/dist/encryption/ratchet.d.ts.map +1 -0
- package/vendor/microsoft-governance/dist/encryption/ratchet.js +223 -0
- package/vendor/microsoft-governance/dist/encryption/ratchet.js.map +1 -0
- package/vendor/microsoft-governance/dist/encryption/registry-client.d.ts +136 -0
- package/vendor/microsoft-governance/dist/encryption/registry-client.d.ts.map +1 -0
- package/vendor/microsoft-governance/dist/encryption/registry-client.js +369 -0
- package/vendor/microsoft-governance/dist/encryption/registry-client.js.map +1 -0
- package/vendor/microsoft-governance/dist/encryption/x3dh.d.ts +80 -0
- package/vendor/microsoft-governance/dist/encryption/x3dh.d.ts.map +1 -0
- package/vendor/microsoft-governance/dist/encryption/x3dh.js +238 -0
- package/vendor/microsoft-governance/dist/encryption/x3dh.js.map +1 -0
- package/vendor/microsoft-governance/dist/framework-adapter.d.ts +63 -0
- package/vendor/microsoft-governance/dist/framework-adapter.d.ts.map +1 -0
- package/vendor/microsoft-governance/dist/framework-adapter.js +253 -0
- package/vendor/microsoft-governance/dist/framework-adapter.js.map +1 -0
- package/vendor/microsoft-governance/dist/identity.d.ts +124 -0
- package/vendor/microsoft-governance/dist/identity.d.ts.map +1 -0
- package/vendor/microsoft-governance/dist/identity.js +446 -0
- package/vendor/microsoft-governance/dist/identity.js.map +1 -0
- package/vendor/microsoft-governance/dist/index.d.ts +39 -0
- package/vendor/microsoft-governance/dist/index.d.ts.map +1 -0
- package/vendor/microsoft-governance/dist/index.js +95 -0
- package/vendor/microsoft-governance/dist/index.js.map +1 -0
- package/vendor/microsoft-governance/dist/kill-switch.d.ts +22 -0
- package/vendor/microsoft-governance/dist/kill-switch.d.ts.map +1 -0
- package/vendor/microsoft-governance/dist/kill-switch.js +60 -0
- package/vendor/microsoft-governance/dist/kill-switch.js.map +1 -0
- package/vendor/microsoft-governance/dist/lifecycle.d.ts +50 -0
- package/vendor/microsoft-governance/dist/lifecycle.d.ts.map +1 -0
- package/vendor/microsoft-governance/dist/lifecycle.js +113 -0
- package/vendor/microsoft-governance/dist/lifecycle.js.map +1 -0
- package/vendor/microsoft-governance/dist/mcp.d.ts +41 -0
- package/vendor/microsoft-governance/dist/mcp.d.ts.map +1 -0
- package/vendor/microsoft-governance/dist/mcp.js +233 -0
- package/vendor/microsoft-governance/dist/mcp.js.map +1 -0
- package/vendor/microsoft-governance/dist/metrics.d.ts +123 -0
- package/vendor/microsoft-governance/dist/metrics.d.ts.map +1 -0
- package/vendor/microsoft-governance/dist/metrics.js +289 -0
- package/vendor/microsoft-governance/dist/metrics.js.map +1 -0
- package/vendor/microsoft-governance/dist/oci-manifest.d.ts +49 -0
- package/vendor/microsoft-governance/dist/oci-manifest.d.ts.map +1 -0
- package/vendor/microsoft-governance/dist/oci-manifest.js +182 -0
- package/vendor/microsoft-governance/dist/oci-manifest.js.map +1 -0
- package/vendor/microsoft-governance/dist/policy-backends/cedar.d.ts +15 -0
- package/vendor/microsoft-governance/dist/policy-backends/cedar.d.ts.map +1 -0
- package/vendor/microsoft-governance/dist/policy-backends/cedar.js +88 -0
- package/vendor/microsoft-governance/dist/policy-backends/cedar.js.map +1 -0
- package/vendor/microsoft-governance/dist/policy-backends/opa.d.ts +17 -0
- package/vendor/microsoft-governance/dist/policy-backends/opa.d.ts.map +1 -0
- package/vendor/microsoft-governance/dist/policy-backends/opa.js +74 -0
- package/vendor/microsoft-governance/dist/policy-backends/opa.js.map +1 -0
- package/vendor/microsoft-governance/dist/policy.d.ts +77 -0
- package/vendor/microsoft-governance/dist/policy.d.ts.map +1 -0
- package/vendor/microsoft-governance/dist/policy.js +760 -0
- package/vendor/microsoft-governance/dist/policy.js.map +1 -0
- package/vendor/microsoft-governance/dist/prompt-defense.d.ts +37 -0
- package/vendor/microsoft-governance/dist/prompt-defense.d.ts.map +1 -0
- package/vendor/microsoft-governance/dist/prompt-defense.js +229 -0
- package/vendor/microsoft-governance/dist/prompt-defense.js.map +1 -0
- package/vendor/microsoft-governance/dist/protocol-facets.d.ts +87 -0
- package/vendor/microsoft-governance/dist/protocol-facets.d.ts.map +1 -0
- package/vendor/microsoft-governance/dist/protocol-facets.js +728 -0
- package/vendor/microsoft-governance/dist/protocol-facets.js.map +1 -0
- package/vendor/microsoft-governance/dist/rings.d.ts +17 -0
- package/vendor/microsoft-governance/dist/rings.d.ts.map +1 -0
- package/vendor/microsoft-governance/dist/rings.js +61 -0
- package/vendor/microsoft-governance/dist/rings.js.map +1 -0
- package/vendor/microsoft-governance/dist/sandbox.d.ts +61 -0
- package/vendor/microsoft-governance/dist/sandbox.d.ts.map +1 -0
- package/vendor/microsoft-governance/dist/sandbox.js +199 -0
- package/vendor/microsoft-governance/dist/sandbox.js.map +1 -0
- package/vendor/microsoft-governance/dist/surface-parity.d.ts +45 -0
- package/vendor/microsoft-governance/dist/surface-parity.d.ts.map +1 -0
- package/vendor/microsoft-governance/dist/surface-parity.js +216 -0
- package/vendor/microsoft-governance/dist/surface-parity.js.map +1 -0
- package/vendor/microsoft-governance/dist/trust.d.ts +28 -0
- package/vendor/microsoft-governance/dist/trust.d.ts.map +1 -0
- package/vendor/microsoft-governance/dist/trust.js +209 -0
- package/vendor/microsoft-governance/dist/trust.js.map +1 -0
- package/vendor/microsoft-governance/dist/types.d.ts +422 -0
- package/vendor/microsoft-governance/dist/types.d.ts.map +1 -0
- package/vendor/microsoft-governance/dist/types.js +29 -0
- package/vendor/microsoft-governance/dist/types.js.map +1 -0
- package/vendor/microsoft-governance/dist/verify.d.ts +135 -0
- package/vendor/microsoft-governance/dist/verify.d.ts.map +1 -0
- package/vendor/microsoft-governance/dist/verify.js +356 -0
- package/vendor/microsoft-governance/dist/verify.js.map +1 -0
- package/vendor/microsoft-governance/package.json +64 -0
- package/index.js +0 -1
|
@@ -0,0 +1,1001 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Copyright (c) Microsoft Corporation.
|
|
3
|
+
// Licensed under the MIT License.
|
|
4
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
5
|
+
exports.MeshClient = void 0;
|
|
6
|
+
/**
|
|
7
|
+
* AgentMesh transport client — WebSocket connection to relay with
|
|
8
|
+
* plaintext-peer support, KNOCK pending queue, and wsFactory hook.
|
|
9
|
+
*
|
|
10
|
+
* Spec: docs/specs/AGENTMESH-WIRE-1.0.md Sections 9, 10, 12
|
|
11
|
+
*
|
|
12
|
+
* Features added for AzureClaw compatibility:
|
|
13
|
+
* - plaintextPeers: bypass E2E encryption for legacy peers (e.g., Rust controller)
|
|
14
|
+
* - wsFactory: custom WebSocket constructor for HTTPS_PROXY CONNECT tunneling
|
|
15
|
+
* - KNOCK pending queue: handle race between KNOCK and first message
|
|
16
|
+
*/
|
|
17
|
+
const channel_1 = require("./channel");
|
|
18
|
+
const registry_client_1 = require("./registry-client");
|
|
19
|
+
/**
|
|
20
|
+
* Derive the canonical AGT-main agent DID from an Ed25519 public key.
|
|
21
|
+
*
|
|
22
|
+
* Format: `did:mesh:<sha256(public_key)[:32]>` (32 hex chars = 16 bytes
|
|
23
|
+
* of digest). Matches the server's derivation in
|
|
24
|
+
* `agent-governance-python/agent-mesh/src/agentmesh/registry/app.py`
|
|
25
|
+
* (`key_hash = hashlib.sha256(public_key).hexdigest()[:32]`).
|
|
26
|
+
*
|
|
27
|
+
* If `fallback` looks like a `did:mesh:` already (callers that compute
|
|
28
|
+
* it themselves), prefer that; otherwise compute fresh from the public
|
|
29
|
+
* key. Sync function — relies on Node's `node:crypto` or the browser's
|
|
30
|
+
* SubtleCrypto can't be used synchronously so we hand-roll SHA-256 if
|
|
31
|
+
* neither is available. In practice the SDK only runs in Node so this
|
|
32
|
+
* just falls through to `require("node:crypto")`.
|
|
33
|
+
*/
|
|
34
|
+
function computeCanonicalDid(ed25519Public, fallback) {
|
|
35
|
+
if (fallback && fallback.startsWith("did:mesh:"))
|
|
36
|
+
return fallback;
|
|
37
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
38
|
+
const nodeCrypto = require("node:crypto");
|
|
39
|
+
const hex = nodeCrypto.createHash("sha256").update(ed25519Public).digest("hex");
|
|
40
|
+
return `did:mesh:${hex.slice(0, 32)}`;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* High-level mesh client for agent-to-agent communication.
|
|
44
|
+
*
|
|
45
|
+
* Manages WebSocket connection to the relay, session establishment
|
|
46
|
+
* (KNOCK + X3DH), and message encryption/decryption. Supports
|
|
47
|
+
* plaintext peers for legacy interop.
|
|
48
|
+
*/
|
|
49
|
+
class MeshClient {
|
|
50
|
+
options;
|
|
51
|
+
sessions = new Map();
|
|
52
|
+
plaintextPeers;
|
|
53
|
+
knockPending = new Map();
|
|
54
|
+
knockAccepted = new Set();
|
|
55
|
+
messageHandlers = [];
|
|
56
|
+
knockHandlers = [];
|
|
57
|
+
errorHandlers = [];
|
|
58
|
+
disconnectHandlers = [];
|
|
59
|
+
e2eVerifiedHandlers = [];
|
|
60
|
+
/** Tracks peers whose first encrypted message we've seen — feeds onE2EVerified. */
|
|
61
|
+
e2eVerifiedSet = new Set();
|
|
62
|
+
ws = null;
|
|
63
|
+
connected = false;
|
|
64
|
+
knockTimeout;
|
|
65
|
+
autoReconnect;
|
|
66
|
+
maxReconnectAttempts;
|
|
67
|
+
reconnectBaseDelayMs;
|
|
68
|
+
reconnectMaxDelayMs;
|
|
69
|
+
reconnectAttempts = 0;
|
|
70
|
+
reconnectTimer = null;
|
|
71
|
+
clientInitiatedClose = false;
|
|
72
|
+
preKnockBufferSize;
|
|
73
|
+
preKnockBufferTtlMs;
|
|
74
|
+
maxBufferedPeers;
|
|
75
|
+
/**
|
|
76
|
+
* Per-peer buffer for encrypted message frames that arrived before the
|
|
77
|
+
* peer's KNOCK was processed. Drained when the KNOCK is later accepted.
|
|
78
|
+
* Mirrors vendored agentmesh-sdk patch #16.
|
|
79
|
+
*/
|
|
80
|
+
preKnockBuffer = new Map();
|
|
81
|
+
registry;
|
|
82
|
+
autoRegister;
|
|
83
|
+
oneTimePrekeyCount;
|
|
84
|
+
registered = false;
|
|
85
|
+
/**
|
|
86
|
+
* The agent DID used on every wire-level send (`connect.from`,
|
|
87
|
+
* `mesh_send.from`, `knock.from`, …). Starts at `options.agentDid` so
|
|
88
|
+
* back-compat is preserved when no registry is configured or when
|
|
89
|
+
* registering against a pre-2533 registry. When `registerSelf()` runs
|
|
90
|
+
* against a POP-aware registry (AGT main since 2026-05-23), the server
|
|
91
|
+
* returns the canonical `did:mesh:<sha256(public_key)[:32]>` and we
|
|
92
|
+
* adopt it here — every subsequent frame uses the canonical form so
|
|
93
|
+
* relay and registry lookups agree.
|
|
94
|
+
*/
|
|
95
|
+
activeDid;
|
|
96
|
+
/**
|
|
97
|
+
* Public read-only view of the active DID. Caller code that previously
|
|
98
|
+
* read `options.agentDid` and stored it should read this instead so
|
|
99
|
+
* post-registration DID swaps are visible.
|
|
100
|
+
*/
|
|
101
|
+
get currentDid() {
|
|
102
|
+
return this.activeDid;
|
|
103
|
+
}
|
|
104
|
+
constructor(options) {
|
|
105
|
+
this.options = options;
|
|
106
|
+
// Compute the canonical AGT main DID locally so the connect frame
|
|
107
|
+
// can include the matching `from` field even on the very first
|
|
108
|
+
// connect (registerSelf runs AFTER connect resolves; the relay's
|
|
109
|
+
// POP gate validates `from == "did:mesh:" + sha256(public_key)[:32]`
|
|
110
|
+
// on the connect frame, NOT on the eventual server-derived DID).
|
|
111
|
+
// We replace whatever caller-supplied DID was passed because the
|
|
112
|
+
// POP-aware relay/registry will reject any other format. Pre-POP
|
|
113
|
+
// deployments don't care, so this is back-compat-safe.
|
|
114
|
+
this.activeDid = computeCanonicalDid(options.keyManager.identityKeyEd, options.agentDid);
|
|
115
|
+
this.plaintextPeers = new Set(options.plaintextPeers ?? []);
|
|
116
|
+
this.knockTimeout = options.knockTimeout ?? 10_000;
|
|
117
|
+
this.autoReconnect = options.autoReconnect ?? true;
|
|
118
|
+
this.maxReconnectAttempts = options.maxReconnectAttempts ?? Number.POSITIVE_INFINITY;
|
|
119
|
+
this.reconnectBaseDelayMs = options.reconnectBaseDelayMs ?? 1000;
|
|
120
|
+
this.reconnectMaxDelayMs = options.reconnectMaxDelayMs ?? 60_000;
|
|
121
|
+
this.preKnockBufferSize = options.preKnockBufferSize ?? 5;
|
|
122
|
+
this.preKnockBufferTtlMs = options.preKnockBufferTtlMs ?? 3_000;
|
|
123
|
+
this.maxBufferedPeers = options.maxBufferedPeers ?? 100;
|
|
124
|
+
this.autoRegister = options.autoRegister ?? true;
|
|
125
|
+
this.oneTimePrekeyCount = options.oneTimePrekeyCount ?? 20;
|
|
126
|
+
if (options.registryClient) {
|
|
127
|
+
this.registry = options.registryClient;
|
|
128
|
+
}
|
|
129
|
+
else if (options.registryUrl) {
|
|
130
|
+
// Wire an Ed25519-Timestamp signer to every registry call so
|
|
131
|
+
// `PUT /v1/agents/{did}/prekeys` and the other authed endpoints
|
|
132
|
+
// (heartbeat, reputation) pass `verify_ed25519_timestamp_auth`.
|
|
133
|
+
// The signer uses the same identity key the relay POP / connect-frame
|
|
134
|
+
// signature uses, so the registry can map `authed_did == activeDid`.
|
|
135
|
+
// The caller can override by passing their own `authSigner` via
|
|
136
|
+
// `registryClientOptions`.
|
|
137
|
+
const callerOpts = options.registryClientOptions ?? {};
|
|
138
|
+
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
|
139
|
+
const self = this;
|
|
140
|
+
const authSigner = callerOpts.authSigner ?? {
|
|
141
|
+
get did() { return self.activeDid; }, // late-bind: DID can change after register
|
|
142
|
+
sign: (m) => options.keyManager.signMessage(m),
|
|
143
|
+
};
|
|
144
|
+
this.registry = new registry_client_1.RegistryClient({
|
|
145
|
+
baseUrl: options.registryUrl,
|
|
146
|
+
...callerOpts,
|
|
147
|
+
authSigner,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
this.registry = null;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Access the RegistryClient (built from registryUrl or injected via
|
|
156
|
+
* registryClient). Returns null only if MeshClient was constructed
|
|
157
|
+
* without a registry URL or client — which means discover/peer lookup
|
|
158
|
+
* features are disabled.
|
|
159
|
+
*/
|
|
160
|
+
getRegistry() {
|
|
161
|
+
return this.registry;
|
|
162
|
+
}
|
|
163
|
+
// ── Plaintext peers ─────────────────────────────────────────────
|
|
164
|
+
addPlaintextPeer(peerId) {
|
|
165
|
+
this.plaintextPeers.add(peerId);
|
|
166
|
+
}
|
|
167
|
+
removePlaintextPeer(peerId) {
|
|
168
|
+
this.plaintextPeers.delete(peerId);
|
|
169
|
+
}
|
|
170
|
+
isPlaintextPeer(peerId) {
|
|
171
|
+
return this.plaintextPeers.has(peerId);
|
|
172
|
+
}
|
|
173
|
+
// ── Connection ──────────────────────────────────────────────────
|
|
174
|
+
async connect() {
|
|
175
|
+
if (this.connected)
|
|
176
|
+
return;
|
|
177
|
+
const wsUrl = this.options.relayUrl.replace(/^http/, "ws") + "/ws";
|
|
178
|
+
const wsFactory = this.options.wsFactory ?? ((url) => new WebSocket(url));
|
|
179
|
+
this.ws = wsFactory(wsUrl);
|
|
180
|
+
await new Promise((resolve, reject) => {
|
|
181
|
+
this.ws.onopen = () => {
|
|
182
|
+
this.connected = true;
|
|
183
|
+
// POP-enabled connect frame (AGT main since 2026-05-23 PR #2632).
|
|
184
|
+
// Pre-POP relays ignore the extra fields — back-compat preserved.
|
|
185
|
+
// The relay verifies:
|
|
186
|
+
// - DID equals `did:mesh:` + sha256(public_key)[:32]
|
|
187
|
+
// - Ed25519 signature over the timestamp string (NOT pub||ts)
|
|
188
|
+
// -- distinct from registry POP which signs pub||ts
|
|
189
|
+
//
|
|
190
|
+
// Encoding gotcha: the relay decodes with stdlib `base64.b64decode`
|
|
191
|
+
// (standard base64 with +/=), while the registry decodes with
|
|
192
|
+
// `urlsafe_b64decode` (base64url with -_). Mismatch in upstream
|
|
193
|
+
// server code; we have to match each endpoint's expectation
|
|
194
|
+
// separately. Emitting standard base64 here so the relay's
|
|
195
|
+
// `base64.b64decode(pub_b64)` and signature decode succeed.
|
|
196
|
+
const pubB64 = Buffer.from(this.options.keyManager.identityKeyEd).toString("base64");
|
|
197
|
+
const tsStr = new Date().toISOString();
|
|
198
|
+
const sig = this.options.keyManager.signMessage(new TextEncoder().encode(tsStr));
|
|
199
|
+
const sigB64 = Buffer.from(sig).toString("base64");
|
|
200
|
+
this.sendFrame({
|
|
201
|
+
v: 1,
|
|
202
|
+
type: "connect",
|
|
203
|
+
from: this.activeDid,
|
|
204
|
+
public_key: pubB64,
|
|
205
|
+
timestamp: tsStr,
|
|
206
|
+
signature: sigB64,
|
|
207
|
+
});
|
|
208
|
+
// Request any messages queued while offline (inbox replay)
|
|
209
|
+
this.sendFrame({
|
|
210
|
+
v: 1,
|
|
211
|
+
type: "fetch_pending",
|
|
212
|
+
from: this.activeDid,
|
|
213
|
+
});
|
|
214
|
+
resolve();
|
|
215
|
+
};
|
|
216
|
+
this.ws.onerror = (e) => {
|
|
217
|
+
// Surface to AzureClaw-style observers BEFORE rejecting connect.
|
|
218
|
+
// Once connect resolves, subsequent ws errors flow through this same path.
|
|
219
|
+
const errEvent = e;
|
|
220
|
+
const detail = errEvent?.message ?? errEvent?.type ?? "ws-error";
|
|
221
|
+
for (const h of this.errorHandlers) {
|
|
222
|
+
try {
|
|
223
|
+
h("ws", this.activeDid, detail);
|
|
224
|
+
}
|
|
225
|
+
catch { /* swallow handler errors */ }
|
|
226
|
+
}
|
|
227
|
+
if (!this.connected)
|
|
228
|
+
reject(new Error(`WebSocket error: ${e}`));
|
|
229
|
+
};
|
|
230
|
+
this.ws.onmessage = (event) => {
|
|
231
|
+
// Guard against malformed frames (upstream #1998) — combines with
|
|
232
|
+
// our additive event-hook handler.
|
|
233
|
+
let frame;
|
|
234
|
+
try {
|
|
235
|
+
frame = JSON.parse(String(event.data));
|
|
236
|
+
}
|
|
237
|
+
catch (err) {
|
|
238
|
+
console.warn(`MeshClient: dropping malformed frame (JSON parse): ${err}`);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
this.handleFrame(frame).catch((err) => {
|
|
242
|
+
console.warn(`MeshClient: handler error for frame type=${String(frame.type)}: ${err}`);
|
|
243
|
+
});
|
|
244
|
+
};
|
|
245
|
+
this.ws.onclose = (event) => {
|
|
246
|
+
const wasConnected = this.connected;
|
|
247
|
+
this.connected = false;
|
|
248
|
+
if (wasConnected) {
|
|
249
|
+
// Distinguish client-initiated disconnect (1000 Normal Closure) from server / network drops.
|
|
250
|
+
const code = event?.code;
|
|
251
|
+
const isClientInitiated = code === 1000 || this.clientInitiatedClose;
|
|
252
|
+
const reason = isClientInitiated ? "client" : "server";
|
|
253
|
+
for (const h of this.disconnectHandlers) {
|
|
254
|
+
try {
|
|
255
|
+
h(reason, code);
|
|
256
|
+
}
|
|
257
|
+
catch { /* swallow handler errors */ }
|
|
258
|
+
}
|
|
259
|
+
// Auto-reconnect on non-client closures (network drops, relay restart).
|
|
260
|
+
// Mirrors vendored agentmesh-sdk patch #9: never give up by default,
|
|
261
|
+
// exponential backoff capped at 60s. Caller can opt out via
|
|
262
|
+
// autoReconnect: false in MeshClientOptions.
|
|
263
|
+
if (!isClientInitiated && this.autoReconnect) {
|
|
264
|
+
this.scheduleReconnect();
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
this.clientInitiatedClose = false;
|
|
268
|
+
};
|
|
269
|
+
});
|
|
270
|
+
// Successful connect — reset reconnect counter.
|
|
271
|
+
this.reconnectAttempts = 0;
|
|
272
|
+
// Auto-register the agent in the registry on first successful connect.
|
|
273
|
+
// Idempotent: a re-connect after a relay restart skips this. The
|
|
274
|
+
// registry POST is idempotent on its end too (409 = already present).
|
|
275
|
+
if (this.autoRegister && this.registry && !this.registered) {
|
|
276
|
+
await this.registerSelf();
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Publish this agent in the registry and upload an X3DH pre-key bundle.
|
|
281
|
+
*
|
|
282
|
+
* - Generates a signed pre-key and `oneTimePrekeyCount` one-time
|
|
283
|
+
* pre-keys via `keyManager.generateSignedPreKey()` /
|
|
284
|
+
* `generateOneTimePreKeys()`.
|
|
285
|
+
* - Capabilities are `[displayName?, ...options.capabilities]` so peers
|
|
286
|
+
* can find this agent via `registry.discover(displayName)`.
|
|
287
|
+
* - Throws RegistryError on transport / 4xx (other than 409) / 5xx
|
|
288
|
+
* failure. Callers that want best-effort registration should catch.
|
|
289
|
+
*
|
|
290
|
+
* Safe to call directly when `autoRegister: false` was used.
|
|
291
|
+
*/
|
|
292
|
+
async registerSelf() {
|
|
293
|
+
if (!this.registry) {
|
|
294
|
+
throw new Error("MeshClient.registerSelf: no registry configured");
|
|
295
|
+
}
|
|
296
|
+
const km = this.options.keyManager;
|
|
297
|
+
// identityKey is the long-term X25519 public key derived from the
|
|
298
|
+
// Ed25519 signing key in the X3DHKeyManager constructor.
|
|
299
|
+
// identityKeyEd is the Ed25519 public key — peers MUST receive it
|
|
300
|
+
// to verify the signed pre-key signature (see x3dh.ts verifyBundle).
|
|
301
|
+
const identityKey = km.identityKey.publicKey;
|
|
302
|
+
const identityKeyEd = km.identityKeyEd;
|
|
303
|
+
const signedPreKey = km.generateSignedPreKey();
|
|
304
|
+
const oneTimePreKeys = km.generateOneTimePreKeys(this.oneTimePrekeyCount);
|
|
305
|
+
// Capabilities: include displayName so name-based discover() works.
|
|
306
|
+
const caps = [];
|
|
307
|
+
const dn = this.options.displayName;
|
|
308
|
+
if (dn)
|
|
309
|
+
caps.push(dn);
|
|
310
|
+
for (const c of this.options.capabilities ?? []) {
|
|
311
|
+
if (c && !caps.includes(c))
|
|
312
|
+
caps.push(c);
|
|
313
|
+
}
|
|
314
|
+
const metadata = { ...(this.options.registrationMetadata ?? {}) };
|
|
315
|
+
if (dn && metadata.display_name === undefined)
|
|
316
|
+
metadata.display_name = dn;
|
|
317
|
+
// POP-aware registration (AGT registry built from main since 2026-05-23,
|
|
318
|
+
// PR #2533). The registry verifies Ed25519(public_key_b64 || timestamp)
|
|
319
|
+
// against the supplied `public_key` (which MUST be the Ed25519 key,
|
|
320
|
+
// not the X25519 key — the server uses pynacl `VerifyKey` on it).
|
|
321
|
+
// Server then derives the canonical DID as
|
|
322
|
+
// `did:mesh:<sha256(public_key)[:32]>` and returns it. We adopt that
|
|
323
|
+
// DID as the active DID so subsequent registry lookups + relay
|
|
324
|
+
// `connect.from` agree with the server's view.
|
|
325
|
+
const popSigner = { sign: (m) => km.signMessage(m) };
|
|
326
|
+
const registerResult = await this.registry.register(this.activeDid, // ignored by POP-aware registries; kept for legacy back-compat
|
|
327
|
+
identityKeyEd, // POP path uses the Ed25519 public; legacy path tolerates
|
|
328
|
+
// either key since it only forwards to peers verbatim.
|
|
329
|
+
caps, metadata, popSigner);
|
|
330
|
+
// Adopt the canonical server DID. Legacy registries return the
|
|
331
|
+
// caller-supplied DID, so this is a no-op when registering against an
|
|
332
|
+
// older deployment.
|
|
333
|
+
if (registerResult.did && registerResult.did !== this.activeDid) {
|
|
334
|
+
this.activeDid = registerResult.did;
|
|
335
|
+
}
|
|
336
|
+
// Prekey upload must use the canonical DID (the registry indexes
|
|
337
|
+
// prekeys by the DID it derived, not the one the SDK started with).
|
|
338
|
+
await this.registry.uploadPrekeys(this.activeDid, identityKey, identityKeyEd, signedPreKey, oneTimePreKeys);
|
|
339
|
+
this.registered = true;
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Discover peers advertising a given capability. Returns [] if no
|
|
343
|
+
* registry is configured.
|
|
344
|
+
*/
|
|
345
|
+
async discover(capability, limit = 50) {
|
|
346
|
+
if (!this.registry)
|
|
347
|
+
return [];
|
|
348
|
+
return this.registry.discover(capability, limit);
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Convenience: fetch a peer's pre-key bundle from the registry, then
|
|
352
|
+
* call `establishSession`. Throws if no registry is configured or no
|
|
353
|
+
* bundle is published for `peerId`.
|
|
354
|
+
*/
|
|
355
|
+
async establishSessionWithPeer(peerId) {
|
|
356
|
+
const existing = this.sessions.get(peerId);
|
|
357
|
+
if (existing)
|
|
358
|
+
return existing;
|
|
359
|
+
if (this.isPlaintextPeer(peerId)) {
|
|
360
|
+
return this.establishSession(peerId, {});
|
|
361
|
+
}
|
|
362
|
+
if (!this.registry) {
|
|
363
|
+
throw new Error("MeshClient.establishSessionWithPeer: no registry configured");
|
|
364
|
+
}
|
|
365
|
+
const bundle = await this.registry.fetchPrekeys(peerId);
|
|
366
|
+
if (!bundle) {
|
|
367
|
+
throw new Error(`MeshClient.establishSessionWithPeer: no prekey bundle for ${peerId}`);
|
|
368
|
+
}
|
|
369
|
+
return this.establishSession(peerId, bundle);
|
|
370
|
+
}
|
|
371
|
+
scheduleReconnect() {
|
|
372
|
+
if (this.reconnectTimer)
|
|
373
|
+
return; // already scheduled
|
|
374
|
+
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
|
375
|
+
for (const h of this.errorHandlers) {
|
|
376
|
+
try {
|
|
377
|
+
h("ws", this.activeDid, `auto-reconnect gave up after ${this.reconnectAttempts} attempts`);
|
|
378
|
+
}
|
|
379
|
+
catch { /* swallow */ }
|
|
380
|
+
}
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
const exp = Math.min(this.reconnectBaseDelayMs * 2 ** this.reconnectAttempts, this.reconnectMaxDelayMs);
|
|
384
|
+
// Light jitter (±20%) to avoid thundering-herd reconnects across many sandboxes.
|
|
385
|
+
const jitter = exp * (0.8 + Math.random() * 0.4);
|
|
386
|
+
this.reconnectAttempts++;
|
|
387
|
+
this.reconnectTimer = setTimeout(() => {
|
|
388
|
+
this.reconnectTimer = null;
|
|
389
|
+
void this.reconnect().catch((err) => {
|
|
390
|
+
for (const h of this.errorHandlers) {
|
|
391
|
+
try {
|
|
392
|
+
h("ws", this.activeDid, `reconnect failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
393
|
+
}
|
|
394
|
+
catch { /* swallow */ }
|
|
395
|
+
}
|
|
396
|
+
// Schedule next attempt — onclose may not fire if connect() rejected before ws.onopen.
|
|
397
|
+
this.scheduleReconnect();
|
|
398
|
+
});
|
|
399
|
+
}, Math.round(jitter));
|
|
400
|
+
}
|
|
401
|
+
async disconnect() {
|
|
402
|
+
// Cancel any pending reconnect — caller asked to stay disconnected.
|
|
403
|
+
if (this.reconnectTimer) {
|
|
404
|
+
clearTimeout(this.reconnectTimer);
|
|
405
|
+
this.reconnectTimer = null;
|
|
406
|
+
}
|
|
407
|
+
this.reconnectAttempts = 0;
|
|
408
|
+
// Drop any buffered pre-KNOCK frames + their eviction timers.
|
|
409
|
+
for (const peer of [...this.preKnockBuffer.keys()]) {
|
|
410
|
+
this.dropPreKnockBuffer(peer);
|
|
411
|
+
}
|
|
412
|
+
if (!this.connected || !this.ws)
|
|
413
|
+
return;
|
|
414
|
+
this.clientInitiatedClose = true;
|
|
415
|
+
this.sendFrame({ v: 1, type: "disconnect", from: this.activeDid });
|
|
416
|
+
this.ws.close(1000, "client disconnect");
|
|
417
|
+
this.connected = false;
|
|
418
|
+
this.ws = null;
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Disconnect and reconnect to the relay.
|
|
422
|
+
*
|
|
423
|
+
* Resets the WebSocket connection and sends a fresh connect +
|
|
424
|
+
* fetch_pending sequence, triggering inbox replay for any messages
|
|
425
|
+
* queued while offline.
|
|
426
|
+
*/
|
|
427
|
+
async reconnect() {
|
|
428
|
+
this.connected = false;
|
|
429
|
+
if (this.ws) {
|
|
430
|
+
try {
|
|
431
|
+
this.ws.close();
|
|
432
|
+
}
|
|
433
|
+
catch {
|
|
434
|
+
/* ignore close errors */
|
|
435
|
+
}
|
|
436
|
+
this.ws = null;
|
|
437
|
+
}
|
|
438
|
+
await this.connect();
|
|
439
|
+
}
|
|
440
|
+
get isConnected() {
|
|
441
|
+
return this.connected && this.ws !== null;
|
|
442
|
+
}
|
|
443
|
+
// ── Sending ─────────────────────────────────────────────────────
|
|
444
|
+
async send(peerId, payload) {
|
|
445
|
+
if (!this.isConnected)
|
|
446
|
+
throw new Error("Not connected to relay");
|
|
447
|
+
const messageId = crypto.randomUUID();
|
|
448
|
+
if (this.isPlaintextPeer(peerId)) {
|
|
449
|
+
// Legacy plaintext path — no encryption
|
|
450
|
+
this.sendFrame({
|
|
451
|
+
v: 1,
|
|
452
|
+
type: "message",
|
|
453
|
+
from: this.activeDid,
|
|
454
|
+
to: peerId,
|
|
455
|
+
id: messageId,
|
|
456
|
+
ts: new Date().toISOString(),
|
|
457
|
+
ciphertext: btoa(JSON.stringify(payload)),
|
|
458
|
+
plaintext: true,
|
|
459
|
+
});
|
|
460
|
+
this.incrementSessionCount(peerId, true);
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
// Encrypted path
|
|
464
|
+
let session = this.sessions.get(peerId);
|
|
465
|
+
if (!session || !session.channel) {
|
|
466
|
+
throw new Error(`No encrypted session with ${peerId}. Call establishSession() first.`);
|
|
467
|
+
}
|
|
468
|
+
const encrypted = session.channel.send(new TextEncoder().encode(JSON.stringify(payload)));
|
|
469
|
+
this.sendFrame({
|
|
470
|
+
v: 1,
|
|
471
|
+
type: "message",
|
|
472
|
+
from: this.activeDid,
|
|
473
|
+
to: peerId,
|
|
474
|
+
id: messageId,
|
|
475
|
+
ts: new Date().toISOString(),
|
|
476
|
+
header: {
|
|
477
|
+
dh: this.uint8ToBase64(encrypted.header.dhPublicKey),
|
|
478
|
+
pn: encrypted.header.previousChainLength,
|
|
479
|
+
n: encrypted.header.messageNumber,
|
|
480
|
+
},
|
|
481
|
+
ciphertext: this.uint8ToBase64(encrypted.ciphertext),
|
|
482
|
+
});
|
|
483
|
+
session.messageCount++;
|
|
484
|
+
}
|
|
485
|
+
// ── Session establishment ───────────────────────────────────────
|
|
486
|
+
async establishSession(peerId, peerBundle) {
|
|
487
|
+
// Check for existing session
|
|
488
|
+
const existing = this.sessions.get(peerId);
|
|
489
|
+
if (existing)
|
|
490
|
+
return existing;
|
|
491
|
+
if (this.isPlaintextPeer(peerId)) {
|
|
492
|
+
const session = {
|
|
493
|
+
peerId,
|
|
494
|
+
channel: null,
|
|
495
|
+
isPlaintext: true,
|
|
496
|
+
createdAt: new Date(),
|
|
497
|
+
messageCount: 0,
|
|
498
|
+
};
|
|
499
|
+
this.sessions.set(peerId, session);
|
|
500
|
+
return session;
|
|
501
|
+
}
|
|
502
|
+
// Send KNOCK
|
|
503
|
+
const knockId = crypto.randomUUID();
|
|
504
|
+
// X3DH + SecureChannel — compute establishment FIRST so we can embed it
|
|
505
|
+
// in the KNOCK frame. This lets the receiver auto-bootstrap the responder
|
|
506
|
+
// session before any ciphertext arrives, eliminating the
|
|
507
|
+
// "No encrypted session" race that vendored agentmesh-sdk patch #4b
|
|
508
|
+
// worked around. Backwards-compatible: receivers that don't understand
|
|
509
|
+
// the `establishment` field fall back to manual acceptSession() calls.
|
|
510
|
+
const [channel, establishment] = channel_1.SecureChannel.createSender(this.options.keyManager, peerBundle, new TextEncoder().encode(`${this.activeDid}|${peerId}`));
|
|
511
|
+
this.sendFrame({
|
|
512
|
+
v: 1,
|
|
513
|
+
type: "knock",
|
|
514
|
+
from: this.activeDid,
|
|
515
|
+
to: peerId,
|
|
516
|
+
id: knockId,
|
|
517
|
+
ts: new Date().toISOString(),
|
|
518
|
+
intent: { action: "establish_session" },
|
|
519
|
+
establishment: this.serializeEstablishment(establishment),
|
|
520
|
+
});
|
|
521
|
+
const session = {
|
|
522
|
+
peerId,
|
|
523
|
+
channel,
|
|
524
|
+
isPlaintext: false,
|
|
525
|
+
createdAt: new Date(),
|
|
526
|
+
messageCount: 0,
|
|
527
|
+
};
|
|
528
|
+
this.sessions.set(peerId, session);
|
|
529
|
+
return session;
|
|
530
|
+
}
|
|
531
|
+
acceptSession(peerId, establishment) {
|
|
532
|
+
const channel = channel_1.SecureChannel.createReceiver(this.options.keyManager, establishment, new TextEncoder().encode(`${peerId}|${this.activeDid}`));
|
|
533
|
+
const session = {
|
|
534
|
+
peerId,
|
|
535
|
+
channel,
|
|
536
|
+
isPlaintext: false,
|
|
537
|
+
createdAt: new Date(),
|
|
538
|
+
messageCount: 0,
|
|
539
|
+
};
|
|
540
|
+
this.sessions.set(peerId, session);
|
|
541
|
+
this.knockAccepted.add(peerId);
|
|
542
|
+
return session;
|
|
543
|
+
}
|
|
544
|
+
getSession(peerId) {
|
|
545
|
+
return this.sessions.get(peerId);
|
|
546
|
+
}
|
|
547
|
+
closeSession(peerId) {
|
|
548
|
+
const session = this.sessions.get(peerId);
|
|
549
|
+
if (!session)
|
|
550
|
+
return false;
|
|
551
|
+
if (session.channel)
|
|
552
|
+
session.channel.close();
|
|
553
|
+
this.sessions.delete(peerId);
|
|
554
|
+
this.knockAccepted.delete(peerId);
|
|
555
|
+
return true;
|
|
556
|
+
}
|
|
557
|
+
// ── Handlers ────────────────────────────────────────────────────
|
|
558
|
+
onMessage(handler) {
|
|
559
|
+
this.messageHandlers.push(handler);
|
|
560
|
+
}
|
|
561
|
+
onKnock(handler) {
|
|
562
|
+
this.knockHandlers.push(handler);
|
|
563
|
+
}
|
|
564
|
+
/**
|
|
565
|
+
* Register a callback for transport-level errors.
|
|
566
|
+
*
|
|
567
|
+
* Fires for: WebSocket errors (handshake, mid-stream), decrypt failures,
|
|
568
|
+
* KNOCK protocol errors, frame validation, and `session_desync` events
|
|
569
|
+
* (recoverable ratchet drift — caller should re-establishSession to that
|
|
570
|
+
* peer; differs from "decrypt" which means no session existed at all).
|
|
571
|
+
* Multiple handlers may be registered; each is invoked in registration
|
|
572
|
+
* order. Handler exceptions are swallowed so one buggy observer cannot
|
|
573
|
+
* break the others.
|
|
574
|
+
*/
|
|
575
|
+
onError(handler) {
|
|
576
|
+
this.errorHandlers.push(handler);
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* Register a callback for transport disconnect events.
|
|
580
|
+
*
|
|
581
|
+
* `reason` is `"client"` for caller-initiated `disconnect()`, `"server"`
|
|
582
|
+
* for relay-side closes (network drop, relay restart), and `"ws-error"`
|
|
583
|
+
* when an error event fires on an already-connected socket.
|
|
584
|
+
*/
|
|
585
|
+
onDisconnect(handler) {
|
|
586
|
+
this.disconnectHandlers.push(handler);
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* Register a callback that fires the first time we successfully decrypt
|
|
590
|
+
* a message from a given peer (i.e. the X3DH+Double-Ratchet session
|
|
591
|
+
* with that peer is fully end-to-end verified).
|
|
592
|
+
*
|
|
593
|
+
* `isFirstPeer` is `true` only for the very first verified peer in the
|
|
594
|
+
* client's lifetime; subsequent peers fire with `false`. This lets
|
|
595
|
+
* orchestrators print "mesh online" once and "+ peer X verified" for
|
|
596
|
+
* the rest.
|
|
597
|
+
*/
|
|
598
|
+
onE2EVerified(handler) {
|
|
599
|
+
this.e2eVerifiedHandlers.push(handler);
|
|
600
|
+
}
|
|
601
|
+
// ── Heartbeat ───────────────────────────────────────────────────
|
|
602
|
+
sendHeartbeat() {
|
|
603
|
+
if (!this.isConnected)
|
|
604
|
+
return;
|
|
605
|
+
this.sendFrame({
|
|
606
|
+
v: 1,
|
|
607
|
+
type: "heartbeat",
|
|
608
|
+
from: this.activeDid,
|
|
609
|
+
ts: new Date().toISOString(),
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
// ── Frame handling ──────────────────────────────────────────────
|
|
613
|
+
async handleFrame(frame) {
|
|
614
|
+
const type = frame.type;
|
|
615
|
+
const from = frame.from;
|
|
616
|
+
if (type === "message") {
|
|
617
|
+
await this.handleMessage(frame);
|
|
618
|
+
}
|
|
619
|
+
else if (type === "knock") {
|
|
620
|
+
await this.handleKnock(frame);
|
|
621
|
+
}
|
|
622
|
+
else if (type === "knock_accept") {
|
|
623
|
+
this.handleKnockAccept(frame);
|
|
624
|
+
}
|
|
625
|
+
else if (type === "knock_reject") {
|
|
626
|
+
this.handleKnockReject(frame);
|
|
627
|
+
}
|
|
628
|
+
else if (type === "ack") {
|
|
629
|
+
// ACK processed — nothing to do
|
|
630
|
+
}
|
|
631
|
+
else if (type === "pending_messages") {
|
|
632
|
+
await this.handlePendingMessages(frame);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
async handleMessage(frame) {
|
|
636
|
+
const from = frame.from;
|
|
637
|
+
// Check KNOCK pending queue — wait for resolution if KNOCK is in-flight
|
|
638
|
+
if (!this.knockAccepted.has(from) && !this.isPlaintextPeer(from)) {
|
|
639
|
+
const pending = this.knockPending.get(from);
|
|
640
|
+
if (pending) {
|
|
641
|
+
// Wait for KNOCK resolution
|
|
642
|
+
const accepted = await new Promise((resolve) => {
|
|
643
|
+
const originalResolve = pending.resolve;
|
|
644
|
+
pending.resolve = (val) => {
|
|
645
|
+
originalResolve(val);
|
|
646
|
+
resolve(val);
|
|
647
|
+
};
|
|
648
|
+
});
|
|
649
|
+
if (!accepted)
|
|
650
|
+
return; // KNOCK rejected — drop message
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
let payload;
|
|
654
|
+
let isPlaintext = false;
|
|
655
|
+
if (frame.plaintext || this.isPlaintextPeer(from)) {
|
|
656
|
+
// Legacy plaintext
|
|
657
|
+
payload = JSON.parse(atob(frame.ciphertext));
|
|
658
|
+
isPlaintext = true;
|
|
659
|
+
}
|
|
660
|
+
else {
|
|
661
|
+
// Encrypted
|
|
662
|
+
const session = this.sessions.get(from);
|
|
663
|
+
if (!session?.channel) {
|
|
664
|
+
// Gap-G4 (vendored agentmesh-sdk patch #16): pre-KNOCK message buffer.
|
|
665
|
+
// The relay does not guarantee inter-frame ordering — an encrypted
|
|
666
|
+
// message can land before its KNOCK is processed. Drop-on-floor was
|
|
667
|
+
// the upstream behaviour; instead, buffer the raw frame here and
|
|
668
|
+
// drain it from handleKnock() once the KNOCK is accepted. Capped at
|
|
669
|
+
// preKnockBufferSize entries per peer with preKnockBufferTtlMs TTL.
|
|
670
|
+
if (this.preKnockBufferSize > 0 && !this.isPlaintextPeer(from)) {
|
|
671
|
+
this.bufferPreKnockFrame(from, frame);
|
|
672
|
+
}
|
|
673
|
+
else {
|
|
674
|
+
for (const h of this.errorHandlers) {
|
|
675
|
+
try {
|
|
676
|
+
h("decrypt", from, "no session for encrypted message — dropping");
|
|
677
|
+
}
|
|
678
|
+
catch { /* swallow */ }
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
const header = frame.header;
|
|
684
|
+
const encrypted = {
|
|
685
|
+
header: {
|
|
686
|
+
dhPublicKey: this.base64ToUint8(header.dh),
|
|
687
|
+
previousChainLength: header.pn,
|
|
688
|
+
messageNumber: header.n,
|
|
689
|
+
},
|
|
690
|
+
ciphertext: this.base64ToUint8(frame.ciphertext),
|
|
691
|
+
};
|
|
692
|
+
let plaintext;
|
|
693
|
+
try {
|
|
694
|
+
plaintext = session.channel.receive(encrypted);
|
|
695
|
+
}
|
|
696
|
+
catch (err) {
|
|
697
|
+
// Gap-G3 (vendored agentmesh-sdk patch #13): on decrypt failure inside
|
|
698
|
+
// an existing session, the ratchet is desynchronised — every
|
|
699
|
+
// subsequent inbound frame will fail the same way. Tear down the
|
|
700
|
+
// broken session so the next establishSession() to this peer runs a
|
|
701
|
+
// fresh X3DH + KNOCK round, and surface a dedicated "session_desync"
|
|
702
|
+
// error kind so callers can distinguish recoverable ratchet drift
|
|
703
|
+
// (re-establish & retry) from genuine tampering (drop & alert).
|
|
704
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
705
|
+
this.closeSession(from);
|
|
706
|
+
this.knockAccepted.delete(from);
|
|
707
|
+
for (const h of this.errorHandlers) {
|
|
708
|
+
try {
|
|
709
|
+
h("session_desync", from, detail);
|
|
710
|
+
}
|
|
711
|
+
catch { /* swallow */ }
|
|
712
|
+
}
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
payload = JSON.parse(new TextDecoder().decode(plaintext));
|
|
716
|
+
session.messageCount++;
|
|
717
|
+
// First successfully-decrypted message from this peer means E2E
|
|
718
|
+
// is end-to-end verified (KNOCK + X3DH + Double Ratchet all worked).
|
|
719
|
+
// Surface to observers exactly once per peer per process lifetime.
|
|
720
|
+
if (!this.e2eVerifiedSet.has(from)) {
|
|
721
|
+
this.e2eVerifiedSet.add(from);
|
|
722
|
+
const isFirstPeer = this.e2eVerifiedSet.size === 1;
|
|
723
|
+
for (const h of this.e2eVerifiedHandlers) {
|
|
724
|
+
try {
|
|
725
|
+
h(from, isFirstPeer);
|
|
726
|
+
}
|
|
727
|
+
catch { /* swallow handler errors */ }
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
// Send ACK
|
|
732
|
+
this.sendFrame({ v: 1, type: "ack", id: frame.id });
|
|
733
|
+
// Notify handlers
|
|
734
|
+
for (const handler of this.messageHandlers) {
|
|
735
|
+
try {
|
|
736
|
+
handler(from, payload, isPlaintext);
|
|
737
|
+
}
|
|
738
|
+
catch { /* swallow handler errors */ }
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
/**
|
|
742
|
+
* Handle a batch of pending messages replayed by the relay on reconnect.
|
|
743
|
+
*
|
|
744
|
+
* The relay may respond to a fetch_pending frame with a
|
|
745
|
+
* pending_messages frame containing an array of queued messages.
|
|
746
|
+
* Each message is dispatched through the standard handleMessage path.
|
|
747
|
+
*/
|
|
748
|
+
async handlePendingMessages(frame) {
|
|
749
|
+
const messages = frame.messages;
|
|
750
|
+
if (!messages || !Array.isArray(messages))
|
|
751
|
+
return;
|
|
752
|
+
for (const msg of messages) {
|
|
753
|
+
await this.handleMessage(msg);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
async handleKnock(frame) {
|
|
757
|
+
const from = frame.from;
|
|
758
|
+
const intent = frame.intent;
|
|
759
|
+
// Register pending entry so concurrent handleMessage calls wait for
|
|
760
|
+
// the verdict. handleMessage may wrap `resolve` to add its own waiter;
|
|
761
|
+
// we look up the (possibly-wrapped) function when we resolve below.
|
|
762
|
+
//
|
|
763
|
+
// The timer is the abort path: if no verdict arrives within
|
|
764
|
+
// knockTimeout, resolve waiters to false and clear the entry. A
|
|
765
|
+
// `timedOut` flag prevents a slow handler from sending knock_accept
|
|
766
|
+
// after the timer has already told waiters the KNOCK was rejected.
|
|
767
|
+
let timedOut = false;
|
|
768
|
+
const timer = setTimeout(() => {
|
|
769
|
+
timedOut = true;
|
|
770
|
+
const entry = this.knockPending.get(from);
|
|
771
|
+
if (entry) {
|
|
772
|
+
this.knockPending.delete(from);
|
|
773
|
+
entry.resolve(false);
|
|
774
|
+
}
|
|
775
|
+
}, this.knockTimeout);
|
|
776
|
+
this.knockPending.set(from, { resolve: () => { }, timer });
|
|
777
|
+
// Evaluate via registered handlers. Use try/finally so the timer is
|
|
778
|
+
// cleared and the pending entry resolved even if a handler throws —
|
|
779
|
+
// otherwise the entry would leak and a re-KNOCK from the same peer
|
|
780
|
+
// would race against stale state.
|
|
781
|
+
let accepted = true;
|
|
782
|
+
try {
|
|
783
|
+
for (const handler of this.knockHandlers) {
|
|
784
|
+
if (!(await handler(from, intent))) {
|
|
785
|
+
accepted = false;
|
|
786
|
+
break;
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
finally {
|
|
791
|
+
clearTimeout(timer);
|
|
792
|
+
if (!timedOut) {
|
|
793
|
+
const entry = this.knockPending.get(from);
|
|
794
|
+
if (entry) {
|
|
795
|
+
this.knockPending.delete(from);
|
|
796
|
+
entry.resolve(accepted);
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
// If the timer beat the handler eval, waiters were already told
|
|
801
|
+
// "rejected"; honor that decision when responding to the relay.
|
|
802
|
+
if (timedOut)
|
|
803
|
+
accepted = false;
|
|
804
|
+
if (accepted) {
|
|
805
|
+
// Auto-bootstrap responder session if establishment data was embedded
|
|
806
|
+
// in the knock. Mirrors vendored agentmesh-sdk patch #4b — receiver no
|
|
807
|
+
// longer needs to call acceptSession() manually before the first
|
|
808
|
+
// encrypted message arrives.
|
|
809
|
+
const est = frame.establishment;
|
|
810
|
+
if (est && !this.sessions.has(from)) {
|
|
811
|
+
try {
|
|
812
|
+
const establishment = this.deserializeEstablishment(est);
|
|
813
|
+
this.acceptSession(from, establishment);
|
|
814
|
+
}
|
|
815
|
+
catch (err) {
|
|
816
|
+
for (const h of this.errorHandlers) {
|
|
817
|
+
try {
|
|
818
|
+
h("knock", from, `failed to bootstrap responder from KNOCK: ${err instanceof Error ? err.message : String(err)}`);
|
|
819
|
+
}
|
|
820
|
+
catch { /* swallow */ }
|
|
821
|
+
}
|
|
822
|
+
accepted = false;
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
// Always record acceptance so the encrypted-message gate stops
|
|
826
|
+
// waiting/buffering. (`acceptSession` also sets this, but keep the
|
|
827
|
+
// legacy-peer path covered too.)
|
|
828
|
+
if (accepted)
|
|
829
|
+
this.knockAccepted.add(from);
|
|
830
|
+
}
|
|
831
|
+
if (accepted) {
|
|
832
|
+
this.sendFrame({
|
|
833
|
+
v: 1,
|
|
834
|
+
type: "knock_accept",
|
|
835
|
+
from: this.activeDid,
|
|
836
|
+
to: from,
|
|
837
|
+
id: crypto.randomUUID(),
|
|
838
|
+
knock_id: frame.id,
|
|
839
|
+
ts: new Date().toISOString(),
|
|
840
|
+
});
|
|
841
|
+
// Gap-G4: drain any encrypted frames that arrived for this peer
|
|
842
|
+
// before its KNOCK was processed. The session is now established
|
|
843
|
+
// (via auto-bootstrap above or a prior acceptSession call), so the
|
|
844
|
+
// buffered frames can be replayed through the normal handleMessage
|
|
845
|
+
// path and decrypted against the fresh session.
|
|
846
|
+
await this.drainPreKnockBuffer(from);
|
|
847
|
+
}
|
|
848
|
+
else {
|
|
849
|
+
this.sendFrame({
|
|
850
|
+
v: 1,
|
|
851
|
+
type: "knock_reject",
|
|
852
|
+
from: this.activeDid,
|
|
853
|
+
to: from,
|
|
854
|
+
id: crypto.randomUUID(),
|
|
855
|
+
knock_id: frame.id,
|
|
856
|
+
reason: "policy_denied",
|
|
857
|
+
ts: new Date().toISOString(),
|
|
858
|
+
});
|
|
859
|
+
// Drop any buffered frames — KNOCK was rejected so we cannot decrypt
|
|
860
|
+
// them anyway. Avoids unbounded buffer growth for hostile peers.
|
|
861
|
+
this.dropPreKnockBuffer(from);
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
handleKnockAccept(frame) {
|
|
865
|
+
const from = frame.from;
|
|
866
|
+
this.knockAccepted.add(from);
|
|
867
|
+
}
|
|
868
|
+
handleKnockReject(frame) {
|
|
869
|
+
const from = frame.from;
|
|
870
|
+
this.closeSession(from);
|
|
871
|
+
}
|
|
872
|
+
// ── Pre-KNOCK buffer (Gap-G4 / vendored patch #16) ──────────────
|
|
873
|
+
bufferPreKnockFrame(from, frame) {
|
|
874
|
+
let entries = this.preKnockBuffer.get(from);
|
|
875
|
+
if (!entries) {
|
|
876
|
+
// Enforce global peer cap before adding a new peer's buffer.
|
|
877
|
+
if (this.preKnockBuffer.size >= this.maxBufferedPeers) {
|
|
878
|
+
const oldestPeer = this.preKnockBuffer.keys().next().value;
|
|
879
|
+
this.dropPreKnockBuffer(oldestPeer);
|
|
880
|
+
for (const h of this.errorHandlers) {
|
|
881
|
+
try {
|
|
882
|
+
h("frame", oldestPeer, `pre-knock buffer evicted: global peer cap (${this.maxBufferedPeers}) reached`);
|
|
883
|
+
}
|
|
884
|
+
catch { /* swallow */ }
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
entries = [];
|
|
888
|
+
this.preKnockBuffer.set(from, entries);
|
|
889
|
+
}
|
|
890
|
+
// Cap: drop oldest when full to keep newest message (sender most likely
|
|
891
|
+
// to retransmit nothing — newer frames carry the most recent ratchet
|
|
892
|
+
// state and have the best chance of being decryptable).
|
|
893
|
+
if (entries.length >= this.preKnockBufferSize) {
|
|
894
|
+
const evicted = entries.shift();
|
|
895
|
+
if (evicted)
|
|
896
|
+
clearTimeout(evicted.timer);
|
|
897
|
+
}
|
|
898
|
+
const timer = setTimeout(() => {
|
|
899
|
+
const list = this.preKnockBuffer.get(from);
|
|
900
|
+
if (!list)
|
|
901
|
+
return;
|
|
902
|
+
const idx = list.findIndex((e) => e.frame === frame);
|
|
903
|
+
if (idx >= 0)
|
|
904
|
+
list.splice(idx, 1);
|
|
905
|
+
if (list.length === 0)
|
|
906
|
+
this.preKnockBuffer.delete(from);
|
|
907
|
+
}, this.preKnockBufferTtlMs);
|
|
908
|
+
entries.push({ frame, timer });
|
|
909
|
+
}
|
|
910
|
+
async drainPreKnockBuffer(from) {
|
|
911
|
+
const entries = this.preKnockBuffer.get(from);
|
|
912
|
+
if (!entries || entries.length === 0)
|
|
913
|
+
return;
|
|
914
|
+
this.preKnockBuffer.delete(from);
|
|
915
|
+
for (const entry of entries) {
|
|
916
|
+
clearTimeout(entry.timer);
|
|
917
|
+
try {
|
|
918
|
+
await this.handleMessage(entry.frame);
|
|
919
|
+
}
|
|
920
|
+
catch (err) {
|
|
921
|
+
for (const h of this.errorHandlers) {
|
|
922
|
+
try {
|
|
923
|
+
h("decrypt", from, `pre-knock drain failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
924
|
+
}
|
|
925
|
+
catch { /* swallow */ }
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
dropPreKnockBuffer(from) {
|
|
931
|
+
const entries = this.preKnockBuffer.get(from);
|
|
932
|
+
if (!entries)
|
|
933
|
+
return;
|
|
934
|
+
for (const entry of entries)
|
|
935
|
+
clearTimeout(entry.timer);
|
|
936
|
+
this.preKnockBuffer.delete(from);
|
|
937
|
+
}
|
|
938
|
+
// ── Establishment (de)serialization ─────────────────────────────
|
|
939
|
+
serializeEstablishment(est) {
|
|
940
|
+
const out = {
|
|
941
|
+
ik: this.uint8ToBase64(est.initiatorIdentityKey),
|
|
942
|
+
ek: this.uint8ToBase64(est.ephemeralPublicKey),
|
|
943
|
+
};
|
|
944
|
+
if (typeof est.usedOneTimeKeyId === "number")
|
|
945
|
+
out.otk = est.usedOneTimeKeyId;
|
|
946
|
+
return out;
|
|
947
|
+
}
|
|
948
|
+
deserializeEstablishment(obj) {
|
|
949
|
+
const ik = obj.ik;
|
|
950
|
+
const ek = obj.ek;
|
|
951
|
+
if (typeof ik !== "string" || typeof ek !== "string") {
|
|
952
|
+
throw new Error("malformed establishment: missing ik/ek");
|
|
953
|
+
}
|
|
954
|
+
const result = {
|
|
955
|
+
initiatorIdentityKey: this.base64ToUint8(ik),
|
|
956
|
+
ephemeralPublicKey: this.base64ToUint8(ek),
|
|
957
|
+
};
|
|
958
|
+
if (typeof obj.otk === "number")
|
|
959
|
+
result.usedOneTimeKeyId = obj.otk;
|
|
960
|
+
return result;
|
|
961
|
+
}
|
|
962
|
+
// ── Utilities ───────────────────────────────────────────────────
|
|
963
|
+
sendFrame(frame) {
|
|
964
|
+
if (this.ws && this.connected) {
|
|
965
|
+
this.ws.send(JSON.stringify(frame));
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
incrementSessionCount(peerId, isPlaintext) {
|
|
969
|
+
let session = this.sessions.get(peerId);
|
|
970
|
+
if (!session) {
|
|
971
|
+
session = { peerId, channel: null, isPlaintext, createdAt: new Date(), messageCount: 0 };
|
|
972
|
+
this.sessions.set(peerId, session);
|
|
973
|
+
}
|
|
974
|
+
session.messageCount++;
|
|
975
|
+
}
|
|
976
|
+
uint8ToBase64(data) {
|
|
977
|
+
// Use Buffer in Node.js to avoid stack overflow on large payloads
|
|
978
|
+
if (typeof Buffer !== "undefined") {
|
|
979
|
+
return Buffer.from(data).toString("base64");
|
|
980
|
+
}
|
|
981
|
+
// Browser fallback — loop-based
|
|
982
|
+
let binary = "";
|
|
983
|
+
for (let i = 0; i < data.length; i++) {
|
|
984
|
+
binary += String.fromCharCode(data[i]);
|
|
985
|
+
}
|
|
986
|
+
return btoa(binary);
|
|
987
|
+
}
|
|
988
|
+
base64ToUint8(b64) {
|
|
989
|
+
if (typeof Buffer !== "undefined") {
|
|
990
|
+
return new Uint8Array(Buffer.from(b64, "base64"));
|
|
991
|
+
}
|
|
992
|
+
const binary = atob(b64);
|
|
993
|
+
const bytes = new Uint8Array(binary.length);
|
|
994
|
+
for (let i = 0; i < binary.length; i++) {
|
|
995
|
+
bytes[i] = binary.charCodeAt(i);
|
|
996
|
+
}
|
|
997
|
+
return bytes;
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
exports.MeshClient = MeshClient;
|
|
1001
|
+
//# sourceMappingURL=mesh-client.js.map
|