@hypercli.com/sdk 0.9.1
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 +629 -0
- package/dist/acp-driver.d.ts +72 -0
- package/dist/acp-driver.d.ts.map +1 -0
- package/dist/acp-driver.js +147 -0
- package/dist/acp-driver.js.map +1 -0
- package/dist/acp-pool.d.ts +70 -0
- package/dist/acp-pool.d.ts.map +1 -0
- package/dist/acp-pool.js +181 -0
- package/dist/acp-pool.js.map +1 -0
- package/dist/acp.d.ts +179 -0
- package/dist/acp.d.ts.map +1 -0
- package/dist/acp.js +487 -0
- package/dist/acp.js.map +1 -0
- package/dist/agent-persona.d.ts +68 -0
- package/dist/agent-persona.d.ts.map +1 -0
- package/dist/agent-persona.js +78 -0
- package/dist/agent-persona.js.map +1 -0
- package/dist/agent-slots.d.ts +18 -0
- package/dist/agent-slots.d.ts.map +1 -0
- package/dist/agent-slots.js +24 -0
- package/dist/agent-slots.js.map +1 -0
- package/dist/agent-urls.d.ts +47 -0
- package/dist/agent-urls.d.ts.map +1 -0
- package/dist/agent-urls.js +178 -0
- package/dist/agent-urls.js.map +1 -0
- package/dist/agent.d.ts +482 -0
- package/dist/agent.d.ts.map +1 -0
- package/dist/agent.js +926 -0
- package/dist/agent.js.map +1 -0
- package/dist/agents.d.ts +1452 -0
- package/dist/agents.d.ts.map +1 -0
- package/dist/agents.js +4022 -0
- package/dist/agents.js.map +1 -0
- package/dist/billing.d.ts +72 -0
- package/dist/billing.d.ts.map +1 -0
- package/dist/billing.js +93 -0
- package/dist/billing.js.map +1 -0
- package/dist/browser-jobs.d.ts +24 -0
- package/dist/browser-jobs.d.ts.map +1 -0
- package/dist/browser-jobs.js +209 -0
- package/dist/browser-jobs.js.map +1 -0
- package/dist/browser.d.ts +41 -0
- package/dist/browser.d.ts.map +1 -0
- package/dist/browser.js +57 -0
- package/dist/browser.js.map +1 -0
- package/dist/buzz-activity.d.ts +169 -0
- package/dist/buzz-activity.d.ts.map +1 -0
- package/dist/buzz-activity.js +844 -0
- package/dist/buzz-activity.js.map +1 -0
- package/dist/channels.d.ts +99 -0
- package/dist/channels.d.ts.map +1 -0
- package/dist/channels.js +27 -0
- package/dist/channels.js.map +1 -0
- package/dist/chat-integrations.d.ts +44 -0
- package/dist/chat-integrations.d.ts.map +1 -0
- package/dist/chat-integrations.js +138 -0
- package/dist/chat-integrations.js.map +1 -0
- package/dist/client.d.ts +88 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +144 -0
- package/dist/client.js.map +1 -0
- package/dist/config.d.ts +44 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +192 -0
- package/dist/config.js.map +1 -0
- package/dist/connectors.d.ts +80 -0
- package/dist/connectors.d.ts.map +1 -0
- package/dist/connectors.js +2 -0
- package/dist/connectors.js.map +1 -0
- package/dist/errors.d.ts +16 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +26 -0
- package/dist/errors.js.map +1 -0
- package/dist/files.d.ts +60 -0
- package/dist/files.d.ts.map +1 -0
- package/dist/files.js +128 -0
- package/dist/files.js.map +1 -0
- package/dist/http.d.ts +56 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +362 -0
- package/dist/http.js.map +1 -0
- package/dist/index.d.ts +49 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +64 -0
- package/dist/index.js.map +1 -0
- package/dist/instances.d.ts +94 -0
- package/dist/instances.d.ts.map +1 -0
- package/dist/instances.js +197 -0
- package/dist/instances.js.map +1 -0
- package/dist/integrations.d.ts +111 -0
- package/dist/integrations.d.ts.map +1 -0
- package/dist/integrations.js +206 -0
- package/dist/integrations.js.map +1 -0
- package/dist/job/base.d.ts +86 -0
- package/dist/job/base.d.ts.map +1 -0
- package/dist/job/base.js +206 -0
- package/dist/job/base.js.map +1 -0
- package/dist/job/comfyui.d.ts +259 -0
- package/dist/job/comfyui.d.ts.map +1 -0
- package/dist/job/comfyui.js +1563 -0
- package/dist/job/comfyui.js.map +1 -0
- package/dist/job/gradio.d.ts +48 -0
- package/dist/job/gradio.d.ts.map +1 -0
- package/dist/job/gradio.js +90 -0
- package/dist/job/gradio.js.map +1 -0
- package/dist/job-codecs.d.ts +66 -0
- package/dist/job-codecs.d.ts.map +1 -0
- package/dist/job-codecs.js +132 -0
- package/dist/job-codecs.js.map +1 -0
- package/dist/jobs.d.ts +148 -0
- package/dist/jobs.d.ts.map +1 -0
- package/dist/jobs.js +480 -0
- package/dist/jobs.js.map +1 -0
- package/dist/keys.d.ts +99 -0
- package/dist/keys.d.ts.map +1 -0
- package/dist/keys.js +145 -0
- package/dist/keys.js.map +1 -0
- package/dist/logs.d.ts +54 -0
- package/dist/logs.d.ts.map +1 -0
- package/dist/logs.js +256 -0
- package/dist/logs.js.map +1 -0
- package/dist/managed-context.d.ts +20 -0
- package/dist/managed-context.d.ts.map +1 -0
- package/dist/managed-context.js +95 -0
- package/dist/managed-context.js.map +1 -0
- package/dist/mcp.d.ts +7 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +8 -0
- package/dist/mcp.js.map +1 -0
- package/dist/memory.d.ts +87 -0
- package/dist/memory.d.ts.map +1 -0
- package/dist/memory.js +79 -0
- package/dist/memory.js.map +1 -0
- package/dist/models.d.ts +15 -0
- package/dist/models.d.ts.map +1 -0
- package/dist/models.js +19 -0
- package/dist/models.js.map +1 -0
- package/dist/openclaw-control-ui-origin.d.ts +23 -0
- package/dist/openclaw-control-ui-origin.d.ts.map +1 -0
- package/dist/openclaw-control-ui-origin.js +53 -0
- package/dist/openclaw-control-ui-origin.js.map +1 -0
- package/dist/renders.d.ts +187 -0
- package/dist/renders.d.ts.map +1 -0
- package/dist/renders.js +360 -0
- package/dist/renders.js.map +1 -0
- package/dist/routines.d.ts +59 -0
- package/dist/routines.d.ts.map +1 -0
- package/dist/routines.js +135 -0
- package/dist/routines.js.map +1 -0
- package/dist/runners.d.ts +61 -0
- package/dist/runners.d.ts.map +1 -0
- package/dist/runners.js +127 -0
- package/dist/runners.js.map +1 -0
- package/dist/sessions.d.ts +280 -0
- package/dist/sessions.d.ts.map +1 -0
- package/dist/sessions.js +292 -0
- package/dist/sessions.js.map +1 -0
- package/dist/skills.d.ts +170 -0
- package/dist/skills.d.ts.map +1 -0
- package/dist/skills.js +2 -0
- package/dist/skills.js.map +1 -0
- package/dist/user.d.ts +78 -0
- package/dist/user.d.ts.map +1 -0
- package/dist/user.js +143 -0
- package/dist/user.js.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +3 -0
- package/dist/version.js.map +1 -0
- package/dist/voice-session.d.ts +66 -0
- package/dist/voice-session.d.ts.map +1 -0
- package/dist/voice-session.js +366 -0
- package/dist/voice-session.js.map +1 -0
- package/dist/voice-transcription-session.d.ts +64 -0
- package/dist/voice-transcription-session.d.ts.map +1 -0
- package/dist/voice-transcription-session.js +228 -0
- package/dist/voice-transcription-session.js.map +1 -0
- package/dist/voice.d.ts +62 -0
- package/dist/voice.d.ts.map +1 -0
- package/dist/voice.js +134 -0
- package/dist/voice.js.map +1 -0
- package/dist/workspace-tomd.d.ts +61 -0
- package/dist/workspace-tomd.d.ts.map +1 -0
- package/dist/workspace-tomd.js +177 -0
- package/dist/workspace-tomd.js.map +1 -0
- package/dist/workspaces.d.ts +255 -0
- package/dist/workspaces.d.ts.map +1 -0
- package/dist/workspaces.js +653 -0
- package/dist/workspaces.js.map +1 -0
- package/package.json +115 -0
- package/runtime/config-require.browser.js +4 -0
- package/runtime/config-require.d.ts +2 -0
- package/runtime/config-require.node.js +8 -0
|
@@ -0,0 +1,844 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Buzz-activity transport: subscribe to a Buzz-backed agent's observer
|
|
3
|
+
* telemetry directly from its Nostr relay.
|
|
4
|
+
*
|
|
5
|
+
* Buzz-backed HyperCLI deployments run `hyper-acp` with Buzz credentials, which publishes observer
|
|
6
|
+
* frames as kind-24200 events signed by the agent key, with the content
|
|
7
|
+
* NIP-44-v2-encrypted to the owner pubkey. The SDK subscribes to the relay
|
|
8
|
+
* over a plain WebSocket and decrypts locally; no backend round-trips are
|
|
9
|
+
* involved beyond revealing the agent key.
|
|
10
|
+
*
|
|
11
|
+
* NIP-44 conversation keys are symmetric:
|
|
12
|
+
* `getConversationKey(ownerSecret, agentPub) === getConversationKey(agentSecret, ownerPub)`,
|
|
13
|
+
* so decryption works with either the owner's nsec or the agent's
|
|
14
|
+
* `BUZZ_PRIVATE_KEY` secret.
|
|
15
|
+
*/
|
|
16
|
+
import { secp256k1 } from '@noble/curves/secp256k1.js';
|
|
17
|
+
import { extract as hkdfExtract, expand as hkdfExpand } from '@noble/hashes/hkdf.js';
|
|
18
|
+
import { hmac } from '@noble/hashes/hmac.js';
|
|
19
|
+
import { sha256 } from '@noble/hashes/sha2.js';
|
|
20
|
+
import { bytesToHex, concatBytes, hexToBytes, utf8ToBytes } from '@noble/hashes/utils.js';
|
|
21
|
+
import { chacha20 } from '@noble/ciphers/chacha.js';
|
|
22
|
+
import { equalBytes } from '@noble/ciphers/utils.js';
|
|
23
|
+
import { base64, bech32 } from '@scure/base';
|
|
24
|
+
import { preferredWebSocket } from './agents.js';
|
|
25
|
+
export const BUZZ_OBSERVER_EVENT_KIND = 24200;
|
|
26
|
+
const NIP44_MIN_PAYLOAD_LEN = 99;
|
|
27
|
+
const NIP44_MAX_PAYLOAD_LEN = 87_472;
|
|
28
|
+
const NIP44_EXTENDED_PREFIX_THRESHOLD = 0x1_00_00;
|
|
29
|
+
const DEDUP_CAP = 4_096;
|
|
30
|
+
const RECONNECT_DELAYS_MS = [1_000, 2_000, 4_000];
|
|
31
|
+
function isPlainRecord(value) {
|
|
32
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
33
|
+
}
|
|
34
|
+
function isHex64(value) {
|
|
35
|
+
return /^[0-9a-f]{64}$/.test(value);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Decode an agent/owner secret given as an nsec bech32 string, a 64-char hex
|
|
39
|
+
* string, or a raw 32-byte array. Throws on anything else.
|
|
40
|
+
*/
|
|
41
|
+
export function decodeBuzzSecret(nsecOrHex) {
|
|
42
|
+
let bytes;
|
|
43
|
+
if (nsecOrHex instanceof Uint8Array) {
|
|
44
|
+
bytes = nsecOrHex;
|
|
45
|
+
}
|
|
46
|
+
else if (typeof nsecOrHex === 'string') {
|
|
47
|
+
const trimmed = nsecOrHex.trim();
|
|
48
|
+
if (isHex64(trimmed)) {
|
|
49
|
+
bytes = hexToBytes(trimmed);
|
|
50
|
+
}
|
|
51
|
+
else if (trimmed.startsWith('nsec1')) {
|
|
52
|
+
let decoded;
|
|
53
|
+
try {
|
|
54
|
+
decoded = bech32.decode(trimmed, 1_000);
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
throw new Error(`invalid nsec bech32: ${error.message}`);
|
|
58
|
+
}
|
|
59
|
+
if (decoded.prefix !== 'nsec') {
|
|
60
|
+
throw new Error(`expected an nsec bech32 string, got prefix "${decoded.prefix}"`);
|
|
61
|
+
}
|
|
62
|
+
bytes = new Uint8Array(bech32.fromWords(decoded.words));
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
throw new Error('secret key must be an nsec bech32 string or 64-char hex');
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
throw new Error('secret key must be an nsec/hex string or raw bytes');
|
|
70
|
+
}
|
|
71
|
+
if (bytes.length !== 32 || !secp256k1.utils.isValidPrivateKey(bytes)) {
|
|
72
|
+
throw new Error('secret key must be a valid 32-byte secp256k1 scalar');
|
|
73
|
+
}
|
|
74
|
+
return bytes;
|
|
75
|
+
}
|
|
76
|
+
/** Derive the x-only hex pubkey for a 32-byte secret key. */
|
|
77
|
+
export function buzzPublicKeyHex(secretKey) {
|
|
78
|
+
return bytesToHex(secp256k1.getPublicKey(secretKey, true).subarray(1, 33));
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* NIP-44 v2 conversation key: HKDF-extract(salt "nip44-v2", ECDH shared x).
|
|
82
|
+
* Symmetric across the (owner, agent) pair.
|
|
83
|
+
*/
|
|
84
|
+
export function buzzConversationKey(secretKey, peerPubHex) {
|
|
85
|
+
if (!isHex64(peerPubHex)) {
|
|
86
|
+
throw new Error('peer pubkey must be 64-char hex');
|
|
87
|
+
}
|
|
88
|
+
const sharedX = secp256k1.getSharedSecret(secretKey, hexToBytes(`02${peerPubHex}`), true).subarray(1, 33);
|
|
89
|
+
return hkdfExtract(sha256, sharedX, utf8ToBytes('nip44-v2'));
|
|
90
|
+
}
|
|
91
|
+
function nip44MessageKeys(conversationKey, nonce) {
|
|
92
|
+
const keys = hkdfExpand(sha256, conversationKey, nonce, 76);
|
|
93
|
+
return {
|
|
94
|
+
chachaKey: keys.subarray(0, 32),
|
|
95
|
+
chachaNonce: keys.subarray(32, 44),
|
|
96
|
+
hmacKey: keys.subarray(44, 76),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
function calcPaddedLen(len) {
|
|
100
|
+
if (!Number.isSafeInteger(len) || len < 1)
|
|
101
|
+
throw new Error('expected positive integer');
|
|
102
|
+
if (len <= 32)
|
|
103
|
+
return 32;
|
|
104
|
+
const nextPower = 2 ** (Math.floor(Math.log2(len - 1)) + 1);
|
|
105
|
+
const chunk = nextPower <= 256 ? 32 : nextPower / 8;
|
|
106
|
+
return chunk * (Math.floor((len - 1) / chunk) + 1);
|
|
107
|
+
}
|
|
108
|
+
function unpadNip44(padded) {
|
|
109
|
+
if (padded.length < 2)
|
|
110
|
+
throw new Error('invalid padding');
|
|
111
|
+
const view = new DataView(padded.buffer, padded.byteOffset, padded.byteLength);
|
|
112
|
+
const firstTwo = view.getUint16(0);
|
|
113
|
+
let unpaddedLen;
|
|
114
|
+
let prefixLen;
|
|
115
|
+
if (firstTwo === 0) {
|
|
116
|
+
unpaddedLen = view.getUint32(2);
|
|
117
|
+
if (unpaddedLen < NIP44_EXTENDED_PREFIX_THRESHOLD)
|
|
118
|
+
throw new Error('invalid padding');
|
|
119
|
+
prefixLen = 6;
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
unpaddedLen = firstTwo;
|
|
123
|
+
prefixLen = 2;
|
|
124
|
+
}
|
|
125
|
+
const unpadded = padded.subarray(prefixLen, prefixLen + unpaddedLen);
|
|
126
|
+
if (unpadded.length !== unpaddedLen
|
|
127
|
+
|| padded.length !== prefixLen + calcPaddedLen(unpaddedLen)) {
|
|
128
|
+
throw new Error('invalid padding');
|
|
129
|
+
}
|
|
130
|
+
return unpadded;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Decrypt a NIP-44 v2 payload: base64 of [version=2][nonce32][ciphertext][mac32].
|
|
134
|
+
* Rejects non-v2 versions, bad MACs, bad lengths, and malformed padding.
|
|
135
|
+
*/
|
|
136
|
+
export function decryptBuzzPayload(conversationKey, payload) {
|
|
137
|
+
if (typeof payload !== 'string')
|
|
138
|
+
throw new Error('payload must be a string');
|
|
139
|
+
let data;
|
|
140
|
+
try {
|
|
141
|
+
data = base64.decode(payload);
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
throw new Error(`invalid base64 payload: ${error.message}`);
|
|
145
|
+
}
|
|
146
|
+
if (data.length < NIP44_MIN_PAYLOAD_LEN || data.length > NIP44_MAX_PAYLOAD_LEN) {
|
|
147
|
+
throw new Error(`invalid payload length: ${data.length}`);
|
|
148
|
+
}
|
|
149
|
+
if (data[0] !== 2) {
|
|
150
|
+
throw new Error(`unsupported payload version: ${data[0]}`);
|
|
151
|
+
}
|
|
152
|
+
const nonce = data.subarray(1, 33);
|
|
153
|
+
const ciphertext = data.subarray(33, data.length - 32);
|
|
154
|
+
const mac = data.subarray(data.length - 32);
|
|
155
|
+
const { chachaKey, chachaNonce, hmacKey } = nip44MessageKeys(conversationKey, nonce);
|
|
156
|
+
const expectedMac = hmac(sha256, hmacKey, concatBytes(nonce, ciphertext));
|
|
157
|
+
if (!equalBytes(expectedMac, mac)) {
|
|
158
|
+
throw new Error('invalid MAC');
|
|
159
|
+
}
|
|
160
|
+
const padded = chacha20(chachaKey, chachaNonce, ciphertext);
|
|
161
|
+
return new TextDecoder().decode(unpadNip44(padded));
|
|
162
|
+
}
|
|
163
|
+
function readEnvString(env, key) {
|
|
164
|
+
const value = env[key];
|
|
165
|
+
return typeof value === 'string' && value.length > 0 ? value : null;
|
|
166
|
+
}
|
|
167
|
+
function ownerFromAuthTag(authTag) {
|
|
168
|
+
let parsed;
|
|
169
|
+
try {
|
|
170
|
+
parsed = JSON.parse(authTag);
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
if (!Array.isArray(parsed) || parsed.length < 2 || parsed[0] !== 'auth')
|
|
176
|
+
return null;
|
|
177
|
+
const candidate = parsed[1];
|
|
178
|
+
return typeof candidate === 'string' && isHex64(candidate) ? candidate : null;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Resolve the Buzz relay URL and owner pubkey from a launch-config env map.
|
|
182
|
+
* Owner comes from `BUZZ_AUTH_TAG` (JSON `["auth", owner_pubkey_hex, ...]`,
|
|
183
|
+
* element [1]) with `BUZZ_ACP_AGENT_OWNER` (bare 64-hex) as fallback.
|
|
184
|
+
* Throws a clear "not Buzz-backed" error when no relay URL is present.
|
|
185
|
+
*/
|
|
186
|
+
export function resolveBuzzOwnerFromEnv(env) {
|
|
187
|
+
const relayUrl = readEnvString(env, 'BUZZ_RELAY_URL');
|
|
188
|
+
if (!relayUrl) {
|
|
189
|
+
throw new Error('Agent is not Buzz-backed: launchConfig env has no BUZZ_RELAY_URL; ' +
|
|
190
|
+
'observer activity is only published by Buzz deployments');
|
|
191
|
+
}
|
|
192
|
+
const authTag = readEnvString(env, 'BUZZ_AUTH_TAG');
|
|
193
|
+
const fromTag = authTag ? ownerFromAuthTag(authTag) : null;
|
|
194
|
+
const fallback = readEnvString(env, 'BUZZ_ACP_AGENT_OWNER');
|
|
195
|
+
const ownerPubHex = fromTag ?? (fallback && isHex64(fallback) ? fallback : null);
|
|
196
|
+
if (!ownerPubHex) {
|
|
197
|
+
throw new Error('Buzz owner pubkey not found: BUZZ_AUTH_TAG is missing/malformed and ' +
|
|
198
|
+
'BUZZ_ACP_AGENT_OWNER is not a 64-char hex pubkey');
|
|
199
|
+
}
|
|
200
|
+
return { relayUrl, ownerPubHex };
|
|
201
|
+
}
|
|
202
|
+
function launchConfigEnv(launchConfig) {
|
|
203
|
+
if (!isPlainRecord(launchConfig))
|
|
204
|
+
return {};
|
|
205
|
+
const env = launchConfig.env;
|
|
206
|
+
return isPlainRecord(env) ? env : {};
|
|
207
|
+
}
|
|
208
|
+
function optionalString(value) {
|
|
209
|
+
return typeof value === 'string' && value.length > 0 ? value : null;
|
|
210
|
+
}
|
|
211
|
+
function toObserverFrame(value) {
|
|
212
|
+
if (!isPlainRecord(value))
|
|
213
|
+
return null;
|
|
214
|
+
const seq = value.seq;
|
|
215
|
+
const timestamp = value.timestamp;
|
|
216
|
+
const kind = value.kind;
|
|
217
|
+
if (typeof seq !== 'number' || !Number.isFinite(seq))
|
|
218
|
+
return null;
|
|
219
|
+
if (typeof timestamp !== 'string' || timestamp.length === 0)
|
|
220
|
+
return null;
|
|
221
|
+
if (typeof kind !== 'string' || kind.length === 0)
|
|
222
|
+
return null;
|
|
223
|
+
const agentIndex = value.agentIndex ?? value.agent_index;
|
|
224
|
+
const frame = {
|
|
225
|
+
seq,
|
|
226
|
+
timestamp,
|
|
227
|
+
kind,
|
|
228
|
+
agentIndex: typeof agentIndex === 'number' && Number.isInteger(agentIndex) ? agentIndex : null,
|
|
229
|
+
channelId: optionalString(value.channelId ?? value.channel_id),
|
|
230
|
+
sessionId: optionalString(value.sessionId ?? value.session_id),
|
|
231
|
+
turnId: optionalString(value.turnId ?? value.turn_id),
|
|
232
|
+
payload: value.payload,
|
|
233
|
+
};
|
|
234
|
+
const startedAt = optionalString(value.startedAt ?? value.started_at);
|
|
235
|
+
if (startedAt !== null)
|
|
236
|
+
frame.startedAt = startedAt;
|
|
237
|
+
return frame;
|
|
238
|
+
}
|
|
239
|
+
function hasTag(tags, name, value) {
|
|
240
|
+
if (!Array.isArray(tags))
|
|
241
|
+
return false;
|
|
242
|
+
return tags.some((tag) => Array.isArray(tag) && tag[0] === name && tag[1] === value);
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Shared deduper for both transports: drop frames already seen, keyed on
|
|
246
|
+
* (timestamp, seq). Bounded memory — the oldest key is evicted past
|
|
247
|
+
* DEDUP_CAP. Replay/live overlap happens on every (re)connect, so this is
|
|
248
|
+
* load-bearing, not defensive.
|
|
249
|
+
*/
|
|
250
|
+
function createBuzzActivityDeduper() {
|
|
251
|
+
const seen = new Map();
|
|
252
|
+
return (frame) => {
|
|
253
|
+
const dedupKey = `${frame.timestamp.length}:${frame.timestamp}:${frame.seq}`;
|
|
254
|
+
if (seen.has(dedupKey))
|
|
255
|
+
return false;
|
|
256
|
+
seen.set(dedupKey, true);
|
|
257
|
+
if (seen.size > DEDUP_CAP) {
|
|
258
|
+
const oldest = seen.keys().next().value;
|
|
259
|
+
if (oldest !== undefined)
|
|
260
|
+
seen.delete(oldest);
|
|
261
|
+
}
|
|
262
|
+
return true;
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Shared frame tolerance: unwrap `batch` envelopes, drop (but never fail on)
|
|
267
|
+
* unparseable frames. Used by the relay transport after decryption and by the
|
|
268
|
+
* route transport after JSON.parse — the payloads are the same shape.
|
|
269
|
+
*/
|
|
270
|
+
function emitObserverPayload(parsed, deliver, countDrop) {
|
|
271
|
+
const frame = toObserverFrame(parsed);
|
|
272
|
+
const payload = frame?.payload;
|
|
273
|
+
if (frame?.kind === 'batch' && isPlainRecord(payload) && Array.isArray(payload.events)) {
|
|
274
|
+
// Batch envelope: unwrap each inner event. A malformed batch (no events
|
|
275
|
+
// array) falls through and is emitted as-is.
|
|
276
|
+
for (const inner of payload.events) {
|
|
277
|
+
const innerFrame = toObserverFrame(inner);
|
|
278
|
+
if (!innerFrame) {
|
|
279
|
+
countDrop();
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
deliver(innerFrame);
|
|
283
|
+
}
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
if (!frame) {
|
|
287
|
+
countDrop();
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
deliver(frame);
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Subscribe to a Buzz-backed agent's observer telemetry stream.
|
|
294
|
+
*
|
|
295
|
+
* Resolves the agent, reads `BUZZ_RELAY_URL`/owner pubkey from its
|
|
296
|
+
* launch-config env, reveals the agent `BUZZ_PRIVATE_KEY`, then REQ-subscribes
|
|
297
|
+
* the relay for kind-24200 frames and decrypts them locally. Reconnects with
|
|
298
|
+
* bounded backoff (1s/2s/4s) resuming from the last accepted frame; gives up
|
|
299
|
+
* via `onClose`.
|
|
300
|
+
*/
|
|
301
|
+
export async function subscribeBuzzActivity(deployments, agentIdOrName, handlers) {
|
|
302
|
+
const agent = await deployments.get(agentIdOrName);
|
|
303
|
+
const env = launchConfigEnv(agent.launchConfig);
|
|
304
|
+
let relayUrl;
|
|
305
|
+
let ownerPubHex;
|
|
306
|
+
try {
|
|
307
|
+
({ relayUrl, ownerPubHex } = resolveBuzzOwnerFromEnv(env));
|
|
308
|
+
}
|
|
309
|
+
catch (error) {
|
|
310
|
+
// Agents launched after BUZZ_AUTH_TAG moved from the launch-config env
|
|
311
|
+
// into the redacted secrets projection no longer carry the attestation
|
|
312
|
+
// here. The "not Buzz-backed" failure (no relay URL) is terminal; only
|
|
313
|
+
// the missing-owner case can be recovered by revealing the secret.
|
|
314
|
+
if (error instanceof Error && error.message.startsWith('Agent is not Buzz-backed')) {
|
|
315
|
+
throw error;
|
|
316
|
+
}
|
|
317
|
+
relayUrl = readEnvString(env, 'BUZZ_RELAY_URL') ?? '';
|
|
318
|
+
if (!relayUrl)
|
|
319
|
+
throw error;
|
|
320
|
+
const tagResponse = await deployments
|
|
321
|
+
.secret(agentIdOrName, 'BUZZ_AUTH_TAG')
|
|
322
|
+
.catch(() => null);
|
|
323
|
+
const tag = tagResponse
|
|
324
|
+
? typeof tagResponse.value === 'string'
|
|
325
|
+
? tagResponse.value
|
|
326
|
+
: new TextDecoder().decode(tagResponse.value)
|
|
327
|
+
: null;
|
|
328
|
+
ownerPubHex = tag ? ownerFromAuthTag(tag) : null;
|
|
329
|
+
if (!ownerPubHex)
|
|
330
|
+
throw error;
|
|
331
|
+
}
|
|
332
|
+
// The agent secret is revealed in both modes: even when the caller supplies
|
|
333
|
+
// an owner key for decryption, the agent pubkey is required for the REQ
|
|
334
|
+
// `authors` filter and for per-event sender validation.
|
|
335
|
+
const agentSecretResponse = await deployments.secret(agentIdOrName, 'BUZZ_PRIVATE_KEY');
|
|
336
|
+
const agentSecret = decodeBuzzSecret(agentSecretResponse.value);
|
|
337
|
+
const agentPubHex = buzzPublicKeyHex(agentSecret);
|
|
338
|
+
const conversationKey = handlers.ownerSecretKey !== undefined
|
|
339
|
+
? buzzConversationKey(decodeBuzzSecret(handlers.ownerSecretKey), agentPubHex)
|
|
340
|
+
: buzzConversationKey(agentSecret, ownerPubHex);
|
|
341
|
+
const sinceSeconds = handlers.sinceSeconds ?? 86_400;
|
|
342
|
+
const limit = handlers.limit ?? 500;
|
|
343
|
+
let closed = false;
|
|
344
|
+
let ws = null;
|
|
345
|
+
let reconnectTimer = null;
|
|
346
|
+
let failures = 0;
|
|
347
|
+
let historyEndFired = false;
|
|
348
|
+
let lastFrameSince = null;
|
|
349
|
+
let connectionCounter = 0;
|
|
350
|
+
const isNewFrame = createBuzzActivityDeduper();
|
|
351
|
+
const onAbort = () => close();
|
|
352
|
+
function close() {
|
|
353
|
+
if (closed)
|
|
354
|
+
return;
|
|
355
|
+
closed = true;
|
|
356
|
+
handlers.signal?.removeEventListener('abort', onAbort);
|
|
357
|
+
if (reconnectTimer !== null) {
|
|
358
|
+
clearTimeout(reconnectTimer);
|
|
359
|
+
reconnectTimer = null;
|
|
360
|
+
}
|
|
361
|
+
const current = ws;
|
|
362
|
+
ws = null;
|
|
363
|
+
if (current) {
|
|
364
|
+
current.onopen = null;
|
|
365
|
+
current.onmessage = null;
|
|
366
|
+
current.onerror = null;
|
|
367
|
+
current.onclose = null;
|
|
368
|
+
try {
|
|
369
|
+
current.close();
|
|
370
|
+
}
|
|
371
|
+
catch {
|
|
372
|
+
// A socket already closed by the peer needs no local close.
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
function deliver(frame) {
|
|
377
|
+
if (!isNewFrame(frame))
|
|
378
|
+
return;
|
|
379
|
+
const parsed = Date.parse(frame.timestamp);
|
|
380
|
+
if (!Number.isNaN(parsed)) {
|
|
381
|
+
const seconds = Math.floor(parsed / 1_000);
|
|
382
|
+
if (lastFrameSince === null || seconds > lastFrameSince)
|
|
383
|
+
lastFrameSince = seconds;
|
|
384
|
+
}
|
|
385
|
+
handlers.onFrame(frame);
|
|
386
|
+
}
|
|
387
|
+
function emitDecrypted(plaintext) {
|
|
388
|
+
let parsed;
|
|
389
|
+
try {
|
|
390
|
+
parsed = JSON.parse(plaintext);
|
|
391
|
+
}
|
|
392
|
+
catch {
|
|
393
|
+
// Unparseable plaintext drops silently; it never kills the subscription.
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
emitObserverPayload(parsed, deliver, () => {
|
|
397
|
+
// Unparseable observer frames drop silently, matching the route
|
|
398
|
+
// transport; malformed frames never kill the subscription.
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
function handleEvent(envelope, subId) {
|
|
402
|
+
if (envelope[1] !== subId)
|
|
403
|
+
return;
|
|
404
|
+
const event = envelope[2];
|
|
405
|
+
if (!isPlainRecord(event))
|
|
406
|
+
return;
|
|
407
|
+
if (event.kind !== BUZZ_OBSERVER_EVENT_KIND)
|
|
408
|
+
return;
|
|
409
|
+
if (event.pubkey !== agentPubHex)
|
|
410
|
+
return;
|
|
411
|
+
if (typeof event.content !== 'string')
|
|
412
|
+
return;
|
|
413
|
+
if (!hasTag(event.tags, 'agent', agentPubHex))
|
|
414
|
+
return;
|
|
415
|
+
if (!hasTag(event.tags, 'frame', 'telemetry'))
|
|
416
|
+
return;
|
|
417
|
+
let plaintext;
|
|
418
|
+
try {
|
|
419
|
+
plaintext = decryptBuzzPayload(conversationKey, event.content);
|
|
420
|
+
}
|
|
421
|
+
catch {
|
|
422
|
+
// Decryption failures skip the frame; they never kill the subscription.
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
emitDecrypted(plaintext);
|
|
426
|
+
}
|
|
427
|
+
function connect() {
|
|
428
|
+
if (closed)
|
|
429
|
+
return;
|
|
430
|
+
connectionCounter += 1;
|
|
431
|
+
const subId = `buzz-activity-${connectionCounter}`;
|
|
432
|
+
const WebSocketImpl = preferredWebSocket();
|
|
433
|
+
const socket = new WebSocketImpl(relayUrl);
|
|
434
|
+
ws = socket;
|
|
435
|
+
socket.onopen = () => {
|
|
436
|
+
const since = lastFrameSince ?? Math.floor(Date.now() / 1_000) - sinceSeconds;
|
|
437
|
+
const filter = {
|
|
438
|
+
kinds: [BUZZ_OBSERVER_EVENT_KIND],
|
|
439
|
+
authors: [agentPubHex],
|
|
440
|
+
'#p': [ownerPubHex],
|
|
441
|
+
since,
|
|
442
|
+
limit,
|
|
443
|
+
};
|
|
444
|
+
socket.send(JSON.stringify(['REQ', subId, filter]));
|
|
445
|
+
};
|
|
446
|
+
socket.onmessage = (message) => {
|
|
447
|
+
const raw = typeof message.data === 'string'
|
|
448
|
+
? message.data
|
|
449
|
+
: message.data instanceof Uint8Array
|
|
450
|
+
? new TextDecoder().decode(message.data)
|
|
451
|
+
: '';
|
|
452
|
+
if (!raw)
|
|
453
|
+
return;
|
|
454
|
+
let envelope;
|
|
455
|
+
try {
|
|
456
|
+
envelope = JSON.parse(raw);
|
|
457
|
+
}
|
|
458
|
+
catch {
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
if (!Array.isArray(envelope))
|
|
462
|
+
return;
|
|
463
|
+
switch (envelope[0]) {
|
|
464
|
+
case 'EVENT':
|
|
465
|
+
handleEvent(envelope, subId);
|
|
466
|
+
return;
|
|
467
|
+
case 'EOSE':
|
|
468
|
+
// A relay that served history is healthy; reset the backoff budget.
|
|
469
|
+
failures = 0;
|
|
470
|
+
if (!historyEndFired) {
|
|
471
|
+
historyEndFired = true;
|
|
472
|
+
handlers.onHistoryEnd?.();
|
|
473
|
+
}
|
|
474
|
+
return;
|
|
475
|
+
case 'NOTICE':
|
|
476
|
+
handlers.onError?.(new Error(`Buzz relay notice: ${String(envelope[1] ?? '')}`));
|
|
477
|
+
return;
|
|
478
|
+
default:
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
};
|
|
482
|
+
socket.onerror = () => {
|
|
483
|
+
handlers.onError?.(new Error('Buzz relay WebSocket error'));
|
|
484
|
+
};
|
|
485
|
+
socket.onclose = (event) => {
|
|
486
|
+
if (closed)
|
|
487
|
+
return;
|
|
488
|
+
ws = null;
|
|
489
|
+
const code = event?.code ?? 1006;
|
|
490
|
+
const reason = event?.reason ?? '';
|
|
491
|
+
if (failures >= RECONNECT_DELAYS_MS.length) {
|
|
492
|
+
close();
|
|
493
|
+
handlers.onClose?.({ code, reason });
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
const delay = RECONNECT_DELAYS_MS[failures];
|
|
497
|
+
failures += 1;
|
|
498
|
+
reconnectTimer = setTimeout(() => {
|
|
499
|
+
reconnectTimer = null;
|
|
500
|
+
connect();
|
|
501
|
+
}, delay);
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
if (handlers.signal?.aborted) {
|
|
505
|
+
return { close };
|
|
506
|
+
}
|
|
507
|
+
handlers.signal?.addEventListener('abort', onAbort, { once: true });
|
|
508
|
+
connect();
|
|
509
|
+
return { close };
|
|
510
|
+
}
|
|
511
|
+
// ── Legacy route transport: former hyper-acp read-only activity stream. ──
|
|
512
|
+
// ── app-level token auth ──
|
|
513
|
+
/**
|
|
514
|
+
* Pinned route name for the legacy hyper-acp introspection listener. Current
|
|
515
|
+
* hosted Buzz launches use raw outbound ACP over `HYPER_ACP_WS_URL` and do not
|
|
516
|
+
* provision this route.
|
|
517
|
+
*/
|
|
518
|
+
export const HYPER_ACP_ROUTE_NAME = 'hyper-acp';
|
|
519
|
+
/** Thrown when the agent's launch config has no `hyper-acp` edge route. */
|
|
520
|
+
export class BuzzActivityRouteUnavailableError extends Error {
|
|
521
|
+
constructor(message) {
|
|
522
|
+
super(message);
|
|
523
|
+
this.name = 'BuzzActivityRouteUnavailableError';
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* Soft-signal error emitted via `onError` when the in-pod stream skipped a
|
|
528
|
+
* lagging client ahead. Transient by definition — the subscription is alive.
|
|
529
|
+
*/
|
|
530
|
+
export class BuzzActivityGapError extends Error {
|
|
531
|
+
dropped;
|
|
532
|
+
constructor(dropped) {
|
|
533
|
+
super(`buzz activity stream skipped ${dropped} events after client lag`);
|
|
534
|
+
this.name = 'BuzzActivityGapError';
|
|
535
|
+
this.dropped = dropped;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
/**
|
|
539
|
+
* Legacy deployment secret for the retired read-only route. Current hosted
|
|
540
|
+
* Buzz launches do not create this secret; callers should prefer the relay
|
|
541
|
+
* activity subscription or the raw outbound ACP launch contract.
|
|
542
|
+
*/
|
|
543
|
+
const BUZZ_ACTIVITY_WS_TOKEN_SECRET = 'HYPER_ACP_WS_TOKEN';
|
|
544
|
+
/**
|
|
545
|
+
* Resolve the `hyper-acp` edge URL from an already-loaded Agent model.
|
|
546
|
+
* Host derivation mirrors the platform contract: prefix defaults to the route
|
|
547
|
+
* name on both sides, so `prefix` is `${prefix}-${agent.hostname}` and a bare
|
|
548
|
+
* prefix is the root host itself. Returns null when the route is absent or
|
|
549
|
+
* the agent hostname is not ready — callers use that to select the relay
|
|
550
|
+
* transport instead.
|
|
551
|
+
*/
|
|
552
|
+
export function resolveBuzzActivityRouteTarget(agent) {
|
|
553
|
+
const route = agent.routes?.[HYPER_ACP_ROUTE_NAME];
|
|
554
|
+
if (!route || typeof route.port !== 'number')
|
|
555
|
+
return null;
|
|
556
|
+
const hostname = String(agent.hostname ?? '').trim().replace(/\.$/, '');
|
|
557
|
+
if (!hostname)
|
|
558
|
+
return null;
|
|
559
|
+
const prefix = String(route.prefix ?? HYPER_ACP_ROUTE_NAME).trim();
|
|
560
|
+
const host = prefix ? `${prefix}-${hostname}` : hostname;
|
|
561
|
+
return { wsUrl: `wss://${host}/`, port: route.port };
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* Open the route WebSocket. Uniform in every runtime: the app token travels
|
|
565
|
+
* in-band as the client's first text frame after the upgrade, so neither
|
|
566
|
+
* browser cookies nor ws-package request headers are involved. (The bare
|
|
567
|
+
* `ws`-constructor URL also sidesteps Node-undici's WebSocket quirks.)
|
|
568
|
+
*/
|
|
569
|
+
function openBuzzActivityRouteSocket(wsUrl) {
|
|
570
|
+
const WebSocketImpl = preferredWebSocket();
|
|
571
|
+
return new WebSocketImpl(wsUrl);
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* Subscribe to a legacy Buzz-backed agent's observer stream through the
|
|
575
|
+
* agent's `hyper-acp` WS route. Current hosted Buzz launches do not provision
|
|
576
|
+
* this observer route; the active path uses raw outbound ACP over
|
|
577
|
+
* `HYPER_ACP_WS_URL`.
|
|
578
|
+
*
|
|
579
|
+
* Auth is app-level and entirely in-band: the route is `auth: false` at the
|
|
580
|
+
* edge, so no platform JWT, cookie priming, or header tricks are involved.
|
|
581
|
+
* The token is the deployment-provisioned `HYPER_ACP_WS_TOKEN`; each
|
|
582
|
+
* connect attempt reveals the secret through the OpenClaw-style per-secret
|
|
583
|
+
* endpoint — fresh per attempt, with a launch-epoch freshness guard
|
|
584
|
+
* mirroring the OpenClaw gateway flow. After the WebSocket upgrade the client's FIRST text
|
|
585
|
+
* frame is exactly `{"type":"auth","token":...}`; the server answers exactly
|
|
586
|
+
* one `{"type":"auth_ok"}` text frame and then replays. A close with code
|
|
587
|
+
* 4401 before `auth_ok` means the token was rejected: terminal for the
|
|
588
|
+
* subscription — the retry loop is skipped and `onClose` fires immediately
|
|
589
|
+
* with that code/reason (consumers treat 4401 as no-reconnect).
|
|
590
|
+
*
|
|
591
|
+
* Token-fetch failures on the FIRST attempt (empty value, stale launch
|
|
592
|
+
* epoch, or a `secret()` APIError 401/403/404) reject this promise as-is —
|
|
593
|
+
* they are never converted to {@link BuzzActivityRouteUnavailableError}. On
|
|
594
|
+
* a mid-life reconnect they surface via `onError` and end the subscription
|
|
595
|
+
* with a terminal `onClose`.
|
|
596
|
+
*
|
|
597
|
+
* A recognizable protocol message (`replay_end`, `replay_gap`, or an
|
|
598
|
+
* observer-shaped frame) arriving BEFORE `auth_ok` is a protocol violation
|
|
599
|
+
* — the signature of a skewed deployment (backend secret present, pod
|
|
600
|
+
* missing the token). It surfaces exactly one `onError` per subscription
|
|
601
|
+
* and never touches the retry budget, history-end, or dedup state (so a
|
|
602
|
+
* wedged stream cannot look healthy); the socket stays open so a healed pod
|
|
603
|
+
* recovers on its own.
|
|
604
|
+
*
|
|
605
|
+
* Reconnects with bounded backoff (1s/2s/4s). The server's session log
|
|
606
|
+
* re-plays from disk on reconnect; overlap dedup on (timestamp, seq) makes
|
|
607
|
+
* duplicates a no-op. A served `replay_end` also resets the retry budget,
|
|
608
|
+
* the route analogue of the relay transport's EOSE reset. A
|
|
609
|
+
* `{"type":"replay_gap"}` frame surfaces as a soft
|
|
610
|
+
* {@link BuzzActivityGapError}; the stream stays alive.
|
|
611
|
+
*/
|
|
612
|
+
export async function subscribeBuzzActivityRoute(deployments, agentIdOrName, handlers) {
|
|
613
|
+
const agent = await deployments.get(agentIdOrName);
|
|
614
|
+
const target = resolveBuzzActivityRouteTarget(agent);
|
|
615
|
+
if (!target) {
|
|
616
|
+
throw new BuzzActivityRouteUnavailableError(`agent has no ${HYPER_ACP_ROUTE_NAME} route`);
|
|
617
|
+
}
|
|
618
|
+
// Hoisted function declarations below lose control-flow narrowing, so bind
|
|
619
|
+
// the post-guard values to fresh consts.
|
|
620
|
+
const targetWsUrl = target.wsUrl;
|
|
621
|
+
const agentId = String(agent.id ?? agentIdOrName);
|
|
622
|
+
// Launch-epoch freshness guard for the ws token: a 0/missing epoch on the
|
|
623
|
+
// agent side means "unknown", which disables the guard rather than failing
|
|
624
|
+
// it.
|
|
625
|
+
const agentLaunchEpoch = Number(agent.launchEpoch ?? 0);
|
|
626
|
+
let closed = false;
|
|
627
|
+
let ws = null;
|
|
628
|
+
let reconnectTimer = null;
|
|
629
|
+
let failures = 0;
|
|
630
|
+
let historyEndFired = false;
|
|
631
|
+
// One-shot latch for the pre-auth protocol-violation notice (per
|
|
632
|
+
// subscription, so reconnects don't re-report the wedge).
|
|
633
|
+
let preAuthProtocolNoticeFired = false;
|
|
634
|
+
let connectionCounter = 0;
|
|
635
|
+
const isNewFrame = createBuzzActivityDeduper();
|
|
636
|
+
const onAbort = () => close();
|
|
637
|
+
function close() {
|
|
638
|
+
if (closed)
|
|
639
|
+
return;
|
|
640
|
+
closed = true;
|
|
641
|
+
handlers.signal?.removeEventListener('abort', onAbort);
|
|
642
|
+
if (reconnectTimer !== null) {
|
|
643
|
+
clearTimeout(reconnectTimer);
|
|
644
|
+
reconnectTimer = null;
|
|
645
|
+
}
|
|
646
|
+
const current = ws;
|
|
647
|
+
ws = null;
|
|
648
|
+
if (current) {
|
|
649
|
+
current.onopen = null;
|
|
650
|
+
current.onmessage = null;
|
|
651
|
+
current.onerror = null;
|
|
652
|
+
current.onclose = null;
|
|
653
|
+
try {
|
|
654
|
+
current.close();
|
|
655
|
+
}
|
|
656
|
+
catch {
|
|
657
|
+
// A socket already closed by the peer needs no local close.
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
function deliver(frame) {
|
|
662
|
+
if (!isNewFrame(frame))
|
|
663
|
+
return;
|
|
664
|
+
handlers.onFrame(frame);
|
|
665
|
+
}
|
|
666
|
+
function handleMessage(data, handshake) {
|
|
667
|
+
const raw = typeof data === 'string'
|
|
668
|
+
? data
|
|
669
|
+
: data instanceof Uint8Array
|
|
670
|
+
? new TextDecoder().decode(data)
|
|
671
|
+
: '';
|
|
672
|
+
if (!raw)
|
|
673
|
+
return;
|
|
674
|
+
let parsed;
|
|
675
|
+
try {
|
|
676
|
+
parsed = JSON.parse(raw);
|
|
677
|
+
}
|
|
678
|
+
catch {
|
|
679
|
+
// Malformed wire frames drop silently; they never kill the subscription.
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
if (isPlainRecord(parsed) && parsed.type === 'auth_ok') {
|
|
683
|
+
// First-frame auth accepted; replay follows. Nothing to surface.
|
|
684
|
+
handshake.markAuthed();
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
if (!handshake.complete()) {
|
|
688
|
+
// Before auth_ok a healthy server sends NOTHING. A recognizable
|
|
689
|
+
// protocol message here (replay_end/replay_gap envelope or an
|
|
690
|
+
// observer-shaped frame) is a protocol violation — the signature of a
|
|
691
|
+
// skewed deployment (backend secret present, pod missing the token).
|
|
692
|
+
// Surface exactly one error per subscription; never reset the retry
|
|
693
|
+
// budget, never fire onHistoryEnd, never feed the deduper from this
|
|
694
|
+
// state (so an identical frame arriving post-handshake still
|
|
695
|
+
// delivers, and a wedged stream cannot look healthy via budget
|
|
696
|
+
// resets). The socket stays open: if the pod heals, the stream
|
|
697
|
+
// recovers on its own. Unrecognizable pre-auth noise (non-JSON,
|
|
698
|
+
// unknown JSON) drops silently as before. This is dedup/state
|
|
699
|
+
// hygiene, not client auth enforcement, which the server owns.
|
|
700
|
+
const recognizable = isPlainRecord(parsed) && (parsed.type === 'replay_end'
|
|
701
|
+
|| parsed.type === 'replay_gap'
|
|
702
|
+
|| (typeof parsed.seq === 'number' && typeof parsed.timestamp === 'string'));
|
|
703
|
+
if (recognizable && !preAuthProtocolNoticeFired) {
|
|
704
|
+
preAuthProtocolNoticeFired = true;
|
|
705
|
+
handlers.onError?.(new Error('hyper-acp endpoint spoke before completing the auth handshake'));
|
|
706
|
+
}
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
if (isPlainRecord(parsed)) {
|
|
710
|
+
if (parsed.type === 'replay_end') {
|
|
711
|
+
// A route that served history is healthy; reset the backoff budget
|
|
712
|
+
// (route analogue of the relay transport's EOSE reset).
|
|
713
|
+
failures = 0;
|
|
714
|
+
if (!historyEndFired) {
|
|
715
|
+
historyEndFired = true;
|
|
716
|
+
handlers.onHistoryEnd?.();
|
|
717
|
+
}
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
if (parsed.type === 'replay_gap') {
|
|
721
|
+
const dropped = typeof parsed.dropped === 'number' ? parsed.dropped : 0;
|
|
722
|
+
handlers.onError?.(new BuzzActivityGapError(dropped));
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
emitObserverPayload(parsed, deliver, () => {
|
|
727
|
+
// Unparseable observer frames drop silently, matching the relay
|
|
728
|
+
// transport; the counter was removed with the old auth flow.
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
async function fetchWsToken() {
|
|
732
|
+
// Fresh reveal per attempt, no caching: a rotated token is honored
|
|
733
|
+
// without resubscribing. `secret()` APIError (401/403/404) propagates
|
|
734
|
+
// untouched.
|
|
735
|
+
const secretData = await deployments.secret(agentId, BUZZ_ACTIVITY_WS_TOKEN_SECRET);
|
|
736
|
+
const token = String(secretData?.value ?? '').trim();
|
|
737
|
+
if (!token)
|
|
738
|
+
throw new Error('activity ws token secret is empty');
|
|
739
|
+
if (agentLaunchEpoch > 0
|
|
740
|
+
&& Number(secretData?.launch_epoch ?? 0) < agentLaunchEpoch) {
|
|
741
|
+
throw new Error('activity ws token belongs to an older launch epoch');
|
|
742
|
+
}
|
|
743
|
+
return token;
|
|
744
|
+
}
|
|
745
|
+
async function connect() {
|
|
746
|
+
if (closed)
|
|
747
|
+
return;
|
|
748
|
+
connectionCounter += 1;
|
|
749
|
+
const connection = connectionCounter;
|
|
750
|
+
let token;
|
|
751
|
+
try {
|
|
752
|
+
token = await fetchWsToken();
|
|
753
|
+
}
|
|
754
|
+
catch (error) {
|
|
755
|
+
if (closed || connection !== connectionCounter)
|
|
756
|
+
return;
|
|
757
|
+
const surfaced = error instanceof Error
|
|
758
|
+
? error
|
|
759
|
+
: new Error('activity ws token request failed');
|
|
760
|
+
if (connection === 1) {
|
|
761
|
+
// Pre-first-connect: the subscribe promise rejects.
|
|
762
|
+
throw surfaced;
|
|
763
|
+
}
|
|
764
|
+
// Mid-life reconnect: surface, then terminate without retrying.
|
|
765
|
+
handlers.onError?.(surfaced);
|
|
766
|
+
close();
|
|
767
|
+
handlers.onClose?.({ code: 1006, reason: surfaced.message });
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
if (closed || connection !== connectionCounter)
|
|
771
|
+
return;
|
|
772
|
+
const socket = openBuzzActivityRouteSocket(targetWsUrl);
|
|
773
|
+
ws = socket;
|
|
774
|
+
// Per-connection handshake completion; a 4401 close is terminal only
|
|
775
|
+
// before `auth_ok` (after it, 4401 cannot legitimately arrive).
|
|
776
|
+
let authed = false;
|
|
777
|
+
socket.onopen = () => {
|
|
778
|
+
if (closed || connection !== connectionCounter)
|
|
779
|
+
return;
|
|
780
|
+
// The first text frame must be the auth frame, within the server's
|
|
781
|
+
// 3s window; the shape is pinned by the wire contract.
|
|
782
|
+
socket.send(JSON.stringify({ type: 'auth', token }));
|
|
783
|
+
};
|
|
784
|
+
socket.onmessage = (message) => {
|
|
785
|
+
if (closed || connection !== connectionCounter)
|
|
786
|
+
return;
|
|
787
|
+
handleMessage(message.data, {
|
|
788
|
+
complete: () => authed,
|
|
789
|
+
markAuthed: () => {
|
|
790
|
+
authed = true;
|
|
791
|
+
},
|
|
792
|
+
});
|
|
793
|
+
};
|
|
794
|
+
socket.onerror = () => {
|
|
795
|
+
if (closed || connection !== connectionCounter)
|
|
796
|
+
return;
|
|
797
|
+
handlers.onError?.(new Error('Buzz activity route WebSocket error'));
|
|
798
|
+
};
|
|
799
|
+
socket.onclose = (event) => {
|
|
800
|
+
if (closed || connection !== connectionCounter)
|
|
801
|
+
return;
|
|
802
|
+
ws = null;
|
|
803
|
+
const code = event?.code ?? 1006;
|
|
804
|
+
const reason = event?.reason ?? '';
|
|
805
|
+
if (code === 4401 && !authed) {
|
|
806
|
+
// The server rejected the token. Terminal: skip the retry loop and
|
|
807
|
+
// report immediately.
|
|
808
|
+
close();
|
|
809
|
+
handlers.onClose?.({ code, reason });
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
fail(code, reason);
|
|
813
|
+
};
|
|
814
|
+
function fail(code, reason = '') {
|
|
815
|
+
if (failures >= RECONNECT_DELAYS_MS.length) {
|
|
816
|
+
close();
|
|
817
|
+
handlers.onClose?.({ code: code ?? 1006, reason });
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
const delay = RECONNECT_DELAYS_MS[failures];
|
|
821
|
+
failures += 1;
|
|
822
|
+
reconnectTimer = setTimeout(() => {
|
|
823
|
+
reconnectTimer = null;
|
|
824
|
+
void connect();
|
|
825
|
+
}, delay);
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
if (handlers.signal?.aborted) {
|
|
829
|
+
return { close };
|
|
830
|
+
}
|
|
831
|
+
handlers.signal?.addEventListener('abort', onAbort, { once: true });
|
|
832
|
+
try {
|
|
833
|
+
// Await the first attempt's token fetch so empty/404/stale-epoch secrets
|
|
834
|
+
// reject the subscribe promise; everything after the token resolves into
|
|
835
|
+
// the socket retry loop.
|
|
836
|
+
await connect();
|
|
837
|
+
}
|
|
838
|
+
catch (error) {
|
|
839
|
+
close();
|
|
840
|
+
throw error;
|
|
841
|
+
}
|
|
842
|
+
return { close };
|
|
843
|
+
}
|
|
844
|
+
//# sourceMappingURL=buzz-activity.js.map
|