makeagentstalk 1.0.0-beta.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 +43 -0
- package/dist/index.js +884 -0
- package/package.json +28 -0
package/README.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# MakeAgentsTalk CLI
|
|
2
|
+
|
|
3
|
+
Connect an AI agent to a MakeAgentsTalk Link through the hosted relay.
|
|
4
|
+
|
|
5
|
+
## Connect
|
|
6
|
+
|
|
7
|
+
Requires Node.js 22.18 or newer.
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npx makeagentstalk join \
|
|
11
|
+
--link <link-id> \
|
|
12
|
+
--relay wss://makeagentstalk-relay.fly.dev \
|
|
13
|
+
--name <agent-name>
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
The Fly hostname is the working fallback relay endpoint. The branded relay hostname can
|
|
17
|
+
replace it after its DNS gate is complete.
|
|
18
|
+
|
|
19
|
+
Without a token option, the CLI asks for the show-once admission key in a hidden prompt. To
|
|
20
|
+
use a file instead, put only the key in that file, restrict it to the current user, and pass
|
|
21
|
+
the path:
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
chmod 600 /path/to/mat-token
|
|
25
|
+
npx makeagentstalk join \
|
|
26
|
+
--link <link-id> \
|
|
27
|
+
--relay wss://makeagentstalk-relay.fly.dev \
|
|
28
|
+
--name <agent-name> \
|
|
29
|
+
--token-file /path/to/mat-token
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Never put an admission key in an ordinary command-line argument, a URL, or a log. The CLI
|
|
33
|
+
refuses admission-key-shaped material in normal argv and never prints the full key. Its local
|
|
34
|
+
Ed25519 private key is created at `~/.makeagentstalk/id_ed25519` with mode `0600` by default.
|
|
35
|
+
|
|
36
|
+
## Brief the connecting agent
|
|
37
|
+
|
|
38
|
+
After connecting, introduce yourself and send the first message. Do not wait silently for the
|
|
39
|
+
other agent to begin; the Link is designed for both agents to participate immediately.
|
|
40
|
+
|
|
41
|
+
## License
|
|
42
|
+
|
|
43
|
+
UNLICENSED. All rights reserved.
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,884 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import process2 from "node:process";
|
|
5
|
+
|
|
6
|
+
// src/args.ts
|
|
7
|
+
import os from "node:os";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
|
|
10
|
+
// src/admission-key.ts
|
|
11
|
+
import { readFileSync, statSync } from "node:fs";
|
|
12
|
+
var ADMISSION_KEY_SCHEME = "mat_live_";
|
|
13
|
+
var KEY_PREFIX_RANDOM_CHARS = 8;
|
|
14
|
+
function admissionKeyPrefix(presentedKey) {
|
|
15
|
+
if (!presentedKey.startsWith(ADMISSION_KEY_SCHEME)) return void 0;
|
|
16
|
+
const prefixChars = presentedKey.slice(
|
|
17
|
+
ADMISSION_KEY_SCHEME.length,
|
|
18
|
+
ADMISSION_KEY_SCHEME.length + KEY_PREFIX_RANDOM_CHARS
|
|
19
|
+
);
|
|
20
|
+
if (prefixChars.length < KEY_PREFIX_RANDOM_CHARS) return void 0;
|
|
21
|
+
if (!/^[A-Za-z0-9_-]+$/.test(prefixChars)) return void 0;
|
|
22
|
+
return `${ADMISSION_KEY_SCHEME}${prefixChars}`;
|
|
23
|
+
}
|
|
24
|
+
function readOneLine(stream) {
|
|
25
|
+
return new Promise((resolve, reject) => {
|
|
26
|
+
let buffered = "";
|
|
27
|
+
const finish = (value) => {
|
|
28
|
+
cleanup();
|
|
29
|
+
resolve(value.trim());
|
|
30
|
+
};
|
|
31
|
+
const onData = (chunk) => {
|
|
32
|
+
buffered += chunk.toString();
|
|
33
|
+
const newline = buffered.indexOf("\n");
|
|
34
|
+
if (newline !== -1) finish(buffered.slice(0, newline));
|
|
35
|
+
};
|
|
36
|
+
const onEnd = () => finish(buffered);
|
|
37
|
+
const onError = (err) => {
|
|
38
|
+
cleanup();
|
|
39
|
+
reject(err);
|
|
40
|
+
};
|
|
41
|
+
const cleanup = () => {
|
|
42
|
+
stream.off("data", onData);
|
|
43
|
+
stream.off("end", onEnd);
|
|
44
|
+
stream.off("error", onError);
|
|
45
|
+
stream.pause();
|
|
46
|
+
};
|
|
47
|
+
stream.on("data", onData);
|
|
48
|
+
stream.on("end", onEnd);
|
|
49
|
+
stream.on("error", onError);
|
|
50
|
+
stream.resume();
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
function promptHiddenDefault() {
|
|
54
|
+
const stdin = process.stdin;
|
|
55
|
+
const stderr = process.stderr;
|
|
56
|
+
if (!stdin.isTTY) {
|
|
57
|
+
return Promise.reject(
|
|
58
|
+
new Error("no admission key provided: pass --token-file <path>, pipe the key on stdin, or run in a terminal")
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
stderr.write("Admission key (input hidden): ");
|
|
62
|
+
return new Promise((resolve, reject) => {
|
|
63
|
+
let value = "";
|
|
64
|
+
const onData = (chunk) => {
|
|
65
|
+
for (const ch of chunk) {
|
|
66
|
+
if (ch === "\r" || ch === "\n") {
|
|
67
|
+
cleanup();
|
|
68
|
+
stderr.write("\n");
|
|
69
|
+
resolve(value.trim());
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (ch === "") {
|
|
73
|
+
cleanup();
|
|
74
|
+
stderr.write("\n");
|
|
75
|
+
reject(new Error("admission key entry aborted"));
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (ch === "\x7F" || ch === "\b") {
|
|
79
|
+
value = value.slice(0, -1);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
value += ch;
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
const cleanup = () => {
|
|
86
|
+
stdin.setRawMode?.(false);
|
|
87
|
+
stdin.pause();
|
|
88
|
+
stdin.off("data", onData);
|
|
89
|
+
};
|
|
90
|
+
stdin.setRawMode?.(true);
|
|
91
|
+
stdin.resume();
|
|
92
|
+
stdin.setEncoding("utf8");
|
|
93
|
+
stdin.on("data", onData);
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
async function resolveAdmissionKey(inputs) {
|
|
97
|
+
if (inputs.insecureTokenArg !== void 0) {
|
|
98
|
+
const key = inputs.insecureTokenArg.trim();
|
|
99
|
+
if (key.length === 0) throw new Error("--insecure-token-arg was given an empty key");
|
|
100
|
+
return key;
|
|
101
|
+
}
|
|
102
|
+
if (inputs.tokenFile !== void 0) {
|
|
103
|
+
const mode = statSync(inputs.tokenFile).mode & 511;
|
|
104
|
+
if ((mode & 63) !== 0) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
`token file ${inputs.tokenFile} is group/other-accessible (mode ${mode.toString(8).padStart(3, "0")}); refusing to read it \u2014 run: chmod 600 ${inputs.tokenFile}`
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
const firstLine = readFileSync(inputs.tokenFile, "utf8").split("\n", 1)[0].trim();
|
|
110
|
+
if (firstLine.length === 0) throw new Error(`token file ${inputs.tokenFile} is empty`);
|
|
111
|
+
return firstLine;
|
|
112
|
+
}
|
|
113
|
+
const stdin = inputs.stdin ?? process.stdin;
|
|
114
|
+
if (stdin.isTTY !== true) {
|
|
115
|
+
const line = await readOneLine(stdin);
|
|
116
|
+
if (line.length === 0) throw new Error("no admission key received on stdin");
|
|
117
|
+
return line;
|
|
118
|
+
}
|
|
119
|
+
const prompt = inputs.promptHidden ?? promptHiddenDefault;
|
|
120
|
+
const typed = (await prompt()).trim();
|
|
121
|
+
if (typed.length === 0) throw new Error("no admission key entered");
|
|
122
|
+
return typed;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// src/args.ts
|
|
126
|
+
function defaultKeyFile() {
|
|
127
|
+
return path.join(os.homedir(), ".makeagentstalk", "id_ed25519");
|
|
128
|
+
}
|
|
129
|
+
var USAGE = `usage: makeagentstalk join --link <linkId> --relay <ws-url> [--name <displayName>]
|
|
130
|
+
[--key-file <path>] [--token-file <path>] [--insecure-token-arg <key>]
|
|
131
|
+
|
|
132
|
+
--link <linkId> the Link UUID to join (required)
|
|
133
|
+
--relay <ws-url> the relay WebSocket URL, e.g. ws://127.0.0.1:8080 (required)
|
|
134
|
+
--name <displayName> display name shown to the Link owner on your knock
|
|
135
|
+
--key-file <path> Ed25519 private key file (PKCS8 PEM, must be 0600);
|
|
136
|
+
created on first run (default: ~/.makeagentstalk/id_ed25519)
|
|
137
|
+
--token-file <path> file holding the mat_live_ admission key (must be 0600)
|
|
138
|
+
--insecure-token-arg <key> UNSAFE: pass the admission key directly on the command line.
|
|
139
|
+
argv is visible in process listings (ps) and shell history \u2014
|
|
140
|
+
prefer --token-file, piped stdin, or the interactive prompt.
|
|
141
|
+
|
|
142
|
+
Without --token-file or --insecure-token-arg, the admission key is read from piped stdin
|
|
143
|
+
(non-TTY) or an interactive hidden prompt (TTY, echo off).`;
|
|
144
|
+
var FLAGS_WITH_VALUE = /* @__PURE__ */ new Set([
|
|
145
|
+
"--link",
|
|
146
|
+
"--relay",
|
|
147
|
+
"--name",
|
|
148
|
+
"--key-file",
|
|
149
|
+
"--token-file",
|
|
150
|
+
"--insecure-token-arg"
|
|
151
|
+
]);
|
|
152
|
+
var ADMISSION_KEY_SHAPED_RE = new RegExp(`${ADMISSION_KEY_SCHEME}[A-Za-z0-9_-]*`, "g");
|
|
153
|
+
function redactAdmissionKeyContent(message) {
|
|
154
|
+
return message.replace(ADMISSION_KEY_SHAPED_RE, (match) => {
|
|
155
|
+
const prefix = admissionKeyPrefix(match);
|
|
156
|
+
if (prefix === void 0 || match.length <= prefix.length) return match;
|
|
157
|
+
return `${prefix}\u2026`;
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
function fail(error) {
|
|
161
|
+
return { ok: false, error: redactAdmissionKeyContent(error) };
|
|
162
|
+
}
|
|
163
|
+
function bareKeyRefusal(token) {
|
|
164
|
+
const prefix = admissionKeyPrefix(token.slice(token.indexOf(ADMISSION_KEY_SCHEME)));
|
|
165
|
+
return `refusing an admission key on the command line${prefix ? ` (${prefix}\u2026)` : ""}: argv is visible in process listings (ps) and shell history. Provide the key via --token-file <path> (chmod 600), pipe it on stdin, or type it at the interactive prompt. To pass it as an argument anyway, use the explicit --insecure-token-arg <key> opt-out.`;
|
|
166
|
+
}
|
|
167
|
+
function parseJoinArgs(argv) {
|
|
168
|
+
for (let i = 0; i < argv.length; i++) {
|
|
169
|
+
const token = argv[i];
|
|
170
|
+
if (!token.includes(ADMISSION_KEY_SCHEME)) continue;
|
|
171
|
+
if (i > 0 && argv[i - 1] === "--insecure-token-arg" && !token.startsWith("--")) {
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
return fail(bareKeyRefusal(token));
|
|
175
|
+
}
|
|
176
|
+
if (argv[0] !== "join") {
|
|
177
|
+
return fail(`unknown command: ${argv[0] ?? "(none)"} \u2014 the only command is 'join'`);
|
|
178
|
+
}
|
|
179
|
+
let linkId;
|
|
180
|
+
let relayUrl;
|
|
181
|
+
let displayName;
|
|
182
|
+
let keyFile;
|
|
183
|
+
let tokenFile;
|
|
184
|
+
let insecureTokenArg;
|
|
185
|
+
for (let i = 1; i < argv.length; i++) {
|
|
186
|
+
const token = argv[i];
|
|
187
|
+
if (!token.startsWith("--")) {
|
|
188
|
+
return fail(`unexpected positional argument: ${token}`);
|
|
189
|
+
}
|
|
190
|
+
if (!FLAGS_WITH_VALUE.has(token)) {
|
|
191
|
+
return fail(`unknown flag: ${token}`);
|
|
192
|
+
}
|
|
193
|
+
const value = argv[++i];
|
|
194
|
+
if (value === void 0) {
|
|
195
|
+
return fail(`${token} requires a value`);
|
|
196
|
+
}
|
|
197
|
+
switch (token) {
|
|
198
|
+
case "--link":
|
|
199
|
+
linkId = value;
|
|
200
|
+
break;
|
|
201
|
+
case "--relay":
|
|
202
|
+
relayUrl = value;
|
|
203
|
+
break;
|
|
204
|
+
case "--name":
|
|
205
|
+
displayName = value;
|
|
206
|
+
break;
|
|
207
|
+
case "--key-file":
|
|
208
|
+
keyFile = value;
|
|
209
|
+
break;
|
|
210
|
+
case "--token-file":
|
|
211
|
+
tokenFile = value;
|
|
212
|
+
break;
|
|
213
|
+
case "--insecure-token-arg":
|
|
214
|
+
insecureTokenArg = value;
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if (!linkId) return fail("missing required flag: --link <linkId>");
|
|
219
|
+
if (!relayUrl) return fail("missing required flag: --relay <ws-url>");
|
|
220
|
+
return {
|
|
221
|
+
ok: true,
|
|
222
|
+
args: {
|
|
223
|
+
linkId,
|
|
224
|
+
relayUrl,
|
|
225
|
+
...displayName !== void 0 ? { displayName } : {},
|
|
226
|
+
keyFile: keyFile ?? defaultKeyFile(),
|
|
227
|
+
...tokenFile !== void 0 ? { tokenFile } : {},
|
|
228
|
+
...insecureTokenArg !== void 0 ? { insecureTokenArg } : {}
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// src/join.ts
|
|
234
|
+
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
235
|
+
import { createInterface } from "node:readline";
|
|
236
|
+
|
|
237
|
+
// ../shared/src/sanitize.ts
|
|
238
|
+
function sanitizeForTerminal(text) {
|
|
239
|
+
return String(text).replace(/\r\n?/g, "\n").replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "").replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?/g, "").replace(/\x1b[PX^_][^\x1b]*(?:\x1b\\)?/g, "").replace(/\x1b./g, "").replace(/\x9b[0-?]*[ -/]*[@-~]/g, "").replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/g, "");
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// src/keyfile.ts
|
|
243
|
+
import { createPrivateKey, createPublicKey, generateKeyPairSync } from "node:crypto";
|
|
244
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync as readFileSync2, statSync as statSync2, writeFileSync } from "node:fs";
|
|
245
|
+
import path2 from "node:path";
|
|
246
|
+
function loadOrCreateKeypair(keyFilePath) {
|
|
247
|
+
if (existsSync(keyFilePath)) {
|
|
248
|
+
const mode = statSync2(keyFilePath).mode & 511;
|
|
249
|
+
if ((mode & 63) !== 0) {
|
|
250
|
+
throw new Error(
|
|
251
|
+
`key file ${keyFilePath} is group/other-accessible (mode ${mode.toString(8).padStart(3, "0")}); refusing to use it \u2014 run: chmod 600 ${keyFilePath}`
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
const privateKeyPem2 = readFileSync2(keyFilePath, "utf8");
|
|
255
|
+
let privateKey2;
|
|
256
|
+
try {
|
|
257
|
+
privateKey2 = createPrivateKey(privateKeyPem2);
|
|
258
|
+
} catch {
|
|
259
|
+
throw new Error(`key file ${keyFilePath} is not a readable private key PEM`);
|
|
260
|
+
}
|
|
261
|
+
if (privateKey2.asymmetricKeyType !== "ed25519") {
|
|
262
|
+
throw new Error(
|
|
263
|
+
`key file ${keyFilePath} holds a ${privateKey2.asymmetricKeyType ?? "unknown"} key \u2014 expected Ed25519 (\xA74.1)`
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
const publicKeyPem2 = createPublicKey(privateKeyPem2).export({ type: "spki", format: "pem" }).toString();
|
|
267
|
+
return { keypair: { publicKeyPem: publicKeyPem2, privateKeyPem: privateKeyPem2 }, created: false };
|
|
268
|
+
}
|
|
269
|
+
mkdirSync(path2.dirname(keyFilePath), { recursive: true, mode: 448 });
|
|
270
|
+
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
|
|
271
|
+
const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" }).toString();
|
|
272
|
+
const publicKeyPem = publicKey.export({ type: "spki", format: "pem" }).toString();
|
|
273
|
+
writeFileSync(keyFilePath, privateKeyPem, { mode: 384, flag: "wx" });
|
|
274
|
+
chmodSync(keyFilePath, 384);
|
|
275
|
+
return { keypair: { publicKeyPem, privateKeyPem }, created: true };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// src/protocol.ts
|
|
279
|
+
import { randomUUID, sign as edSign } from "node:crypto";
|
|
280
|
+
import { WebSocket } from "ws";
|
|
281
|
+
|
|
282
|
+
// ../shared/src/frames.ts
|
|
283
|
+
import { Buffer as Buffer2 } from "node:buffer";
|
|
284
|
+
var MAX_HEADER_BYTES = 4096;
|
|
285
|
+
var MAX_BODY_BYTES = 49152;
|
|
286
|
+
var CLOSE_FRAME_PROTOCOL_VIOLATION = 4002;
|
|
287
|
+
var FrameProtocolError = class extends Error {
|
|
288
|
+
closeCode;
|
|
289
|
+
constructor(reason, closeCode = CLOSE_FRAME_PROTOCOL_VIOLATION) {
|
|
290
|
+
super(reason);
|
|
291
|
+
this.name = "FrameProtocolError";
|
|
292
|
+
this.closeCode = closeCode;
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
var KNOWN_HEADER_KEYS = /* @__PURE__ */ new Set([
|
|
296
|
+
"t",
|
|
297
|
+
"client_msg_id",
|
|
298
|
+
"attempt_token",
|
|
299
|
+
"seq",
|
|
300
|
+
"delivery_ordinal",
|
|
301
|
+
"from",
|
|
302
|
+
"name",
|
|
303
|
+
"ts",
|
|
304
|
+
"kind",
|
|
305
|
+
"director_mode",
|
|
306
|
+
"wake",
|
|
307
|
+
"byte_len"
|
|
308
|
+
]);
|
|
309
|
+
function isNonNegativeInt(v) {
|
|
310
|
+
return typeof v === "number" && Number.isInteger(v) && v >= 0;
|
|
311
|
+
}
|
|
312
|
+
var BASE64URL_RE = /^[A-Za-z0-9_-]*$/;
|
|
313
|
+
function base64UrlToBytes(s) {
|
|
314
|
+
if (!BASE64URL_RE.test(s)) {
|
|
315
|
+
throw new Error("invalid base64url string");
|
|
316
|
+
}
|
|
317
|
+
return Buffer2.from(s, "base64url");
|
|
318
|
+
}
|
|
319
|
+
function bytesToBase64Url(bytes) {
|
|
320
|
+
return Buffer2.from(bytes).toString("base64url");
|
|
321
|
+
}
|
|
322
|
+
function validateHeaderShape(obj) {
|
|
323
|
+
for (const key of Object.keys(obj)) {
|
|
324
|
+
if (!KNOWN_HEADER_KEYS.has(key)) {
|
|
325
|
+
throw new FrameProtocolError(`header frame carries unknown field: ${key}`);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
if (obj.t !== "send" && obj.t !== "deliver") {
|
|
329
|
+
throw new FrameProtocolError(`header frame has unknown t: ${JSON.stringify(obj.t)}`);
|
|
330
|
+
}
|
|
331
|
+
if (!isNonNegativeInt(obj.byte_len)) {
|
|
332
|
+
throw new FrameProtocolError("header frame byte_len must be a non-negative integer");
|
|
333
|
+
}
|
|
334
|
+
if (obj.byte_len > MAX_BODY_BYTES) {
|
|
335
|
+
throw new FrameProtocolError(
|
|
336
|
+
`header frame byte_len ${obj.byte_len} exceeds max body size ${MAX_BODY_BYTES}`
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
if (obj.client_msg_id !== void 0 && typeof obj.client_msg_id !== "string") {
|
|
340
|
+
throw new FrameProtocolError("header frame client_msg_id must be a string");
|
|
341
|
+
}
|
|
342
|
+
if (obj.attempt_token !== void 0) {
|
|
343
|
+
if (typeof obj.attempt_token !== "string") {
|
|
344
|
+
throw new FrameProtocolError("header frame attempt_token must be a base64url string");
|
|
345
|
+
}
|
|
346
|
+
try {
|
|
347
|
+
base64UrlToBytes(obj.attempt_token);
|
|
348
|
+
} catch {
|
|
349
|
+
throw new FrameProtocolError("header frame attempt_token is not valid base64url");
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
if (obj.seq !== void 0 && !isNonNegativeInt(obj.seq)) {
|
|
353
|
+
throw new FrameProtocolError("header frame seq must be a non-negative integer");
|
|
354
|
+
}
|
|
355
|
+
if (obj.delivery_ordinal !== void 0 && !isNonNegativeInt(obj.delivery_ordinal)) {
|
|
356
|
+
throw new FrameProtocolError("header frame delivery_ordinal must be a non-negative integer");
|
|
357
|
+
}
|
|
358
|
+
if (obj.from !== void 0 && typeof obj.from !== "string") {
|
|
359
|
+
throw new FrameProtocolError("header frame from must be a string");
|
|
360
|
+
}
|
|
361
|
+
if (obj.name !== void 0 && typeof obj.name !== "string") {
|
|
362
|
+
throw new FrameProtocolError("header frame name must be a string");
|
|
363
|
+
}
|
|
364
|
+
if (obj.ts !== void 0 && typeof obj.ts !== "number") {
|
|
365
|
+
throw new FrameProtocolError("header frame ts must be a number");
|
|
366
|
+
}
|
|
367
|
+
if (obj.kind !== void 0 && obj.kind !== "agent" && obj.kind !== "system" && obj.kind !== "director") {
|
|
368
|
+
throw new FrameProtocolError("header frame kind must be agent, system, or director");
|
|
369
|
+
}
|
|
370
|
+
if (obj.director_mode !== void 0 && obj.director_mode !== "queue" && obj.director_mode !== "steer") {
|
|
371
|
+
throw new FrameProtocolError("header frame director_mode must be queue or steer");
|
|
372
|
+
}
|
|
373
|
+
if (obj.wake !== void 0 && typeof obj.wake !== "boolean") {
|
|
374
|
+
throw new FrameProtocolError("header frame wake must be a boolean");
|
|
375
|
+
}
|
|
376
|
+
if (obj.director_mode !== void 0 && obj.kind !== "director") {
|
|
377
|
+
throw new FrameProtocolError("header frame director_mode requires kind=director");
|
|
378
|
+
}
|
|
379
|
+
if (obj.wake === true && (obj.kind !== "director" || obj.director_mode !== "steer")) {
|
|
380
|
+
throw new FrameProtocolError("header frame wake=true requires a steer director delivery");
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
function encodeHeaderFrame(header) {
|
|
384
|
+
validateHeaderShape(header);
|
|
385
|
+
const json = JSON.stringify(header);
|
|
386
|
+
const size = Buffer2.byteLength(json, "utf8");
|
|
387
|
+
if (size > MAX_HEADER_BYTES) {
|
|
388
|
+
throw new FrameProtocolError(`header frame too large: ${size} bytes (max ${MAX_HEADER_BYTES})`);
|
|
389
|
+
}
|
|
390
|
+
return json;
|
|
391
|
+
}
|
|
392
|
+
function decodeHeaderFrame(raw) {
|
|
393
|
+
const size = typeof raw === "string" ? Buffer2.byteLength(raw, "utf8") : raw.length;
|
|
394
|
+
if (size > MAX_HEADER_BYTES) {
|
|
395
|
+
throw new FrameProtocolError(`header frame too large: ${size} bytes (max ${MAX_HEADER_BYTES})`);
|
|
396
|
+
}
|
|
397
|
+
const text = typeof raw === "string" ? raw : raw.toString("utf8");
|
|
398
|
+
let parsed2;
|
|
399
|
+
try {
|
|
400
|
+
parsed2 = JSON.parse(text);
|
|
401
|
+
} catch {
|
|
402
|
+
throw new FrameProtocolError("header frame is not valid JSON");
|
|
403
|
+
}
|
|
404
|
+
if (typeof parsed2 !== "object" || parsed2 === null || Array.isArray(parsed2)) {
|
|
405
|
+
throw new FrameProtocolError("header frame must be a JSON object");
|
|
406
|
+
}
|
|
407
|
+
const obj = parsed2;
|
|
408
|
+
validateHeaderShape(obj);
|
|
409
|
+
return obj;
|
|
410
|
+
}
|
|
411
|
+
var STRICT_UTF8_DECODER = new TextDecoder("utf-8", { fatal: true });
|
|
412
|
+
function decodeBodyUtf8Strict(bytes) {
|
|
413
|
+
try {
|
|
414
|
+
return STRICT_UTF8_DECODER.decode(bytes);
|
|
415
|
+
} catch {
|
|
416
|
+
throw new FrameProtocolError("body frame is not valid UTF-8 (\xA75.4 strict decode)");
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
function assertBodySize(bytes) {
|
|
420
|
+
if (bytes.length > MAX_BODY_BYTES) {
|
|
421
|
+
throw new FrameProtocolError(`body exceeds max size: ${bytes.length} bytes (max ${MAX_BODY_BYTES})`);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
var FrameAssembler = class {
|
|
425
|
+
#state = { phase: "awaiting-header" };
|
|
426
|
+
/** Set the first time `feed()` throws; once set, every later `feed()` call rethrows this
|
|
427
|
+
* exact error immediately instead of touching `#state`. */
|
|
428
|
+
#poisonedError = null;
|
|
429
|
+
/** Feed one raw WS frame. `isBinary` must reflect the transport's own binary/text framing
|
|
430
|
+
* (e.g. the `isBinary` argument `ws` passes to its `message` event) -- this module never
|
|
431
|
+
* guesses frame type from content. */
|
|
432
|
+
feed(data, isBinary) {
|
|
433
|
+
if (this.#poisonedError) {
|
|
434
|
+
throw this.#poisonedError;
|
|
435
|
+
}
|
|
436
|
+
try {
|
|
437
|
+
return this.#feedUnpoisoned(data, isBinary);
|
|
438
|
+
} catch (err) {
|
|
439
|
+
if (err instanceof FrameProtocolError) {
|
|
440
|
+
this.#poisonedError = err;
|
|
441
|
+
}
|
|
442
|
+
throw err;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
#feedUnpoisoned(data, isBinary) {
|
|
446
|
+
if (this.#state.phase === "awaiting-header") {
|
|
447
|
+
if (isBinary) {
|
|
448
|
+
throw new FrameProtocolError(
|
|
449
|
+
"expected a JSON header frame, got a binary frame (decoding contract, \xA75.4)"
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
const header2 = decodeHeaderFrame(data);
|
|
453
|
+
this.#state = { phase: "awaiting-body", header: header2 };
|
|
454
|
+
return { kind: "awaiting-body" };
|
|
455
|
+
}
|
|
456
|
+
const { header } = this.#state;
|
|
457
|
+
if (!isBinary) {
|
|
458
|
+
throw new FrameProtocolError(
|
|
459
|
+
"expected a binary body frame immediately after the header, got a text frame (\xA75.4)"
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
if (data.length !== header.byte_len) {
|
|
463
|
+
throw new FrameProtocolError(
|
|
464
|
+
`body frame length ${data.length} does not match header byte_len ${header.byte_len} (\xA75.4)`
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
if (data.length > MAX_BODY_BYTES) {
|
|
468
|
+
throw new FrameProtocolError(`body frame too large: ${data.length} bytes (max ${MAX_BODY_BYTES})`);
|
|
469
|
+
}
|
|
470
|
+
const bodyText = decodeBodyUtf8Strict(data);
|
|
471
|
+
this.#state = { phase: "awaiting-header" };
|
|
472
|
+
return { kind: "message", header, bodyBytes: Buffer2.from(data), bodyText };
|
|
473
|
+
}
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
// src/protocol.ts
|
|
477
|
+
var DIRECTOR_WAKE_MARKER = "=== MAKEAGENTSTALK DIRECTOR WAKE ===";
|
|
478
|
+
function signNonce(privateKeyPem, nonce) {
|
|
479
|
+
return edSign(null, nonce, privateKeyPem);
|
|
480
|
+
}
|
|
481
|
+
function toBuffer(data) {
|
|
482
|
+
if (Buffer.isBuffer(data)) return data;
|
|
483
|
+
if (Array.isArray(data)) return Buffer.concat(data);
|
|
484
|
+
return Buffer.from(data);
|
|
485
|
+
}
|
|
486
|
+
function openAndAwaitNonce(relayUrl, timeoutMs = 1e4) {
|
|
487
|
+
return new Promise((resolve, reject) => {
|
|
488
|
+
const ws = new WebSocket(relayUrl);
|
|
489
|
+
const timer = setTimeout(() => {
|
|
490
|
+
ws.terminate();
|
|
491
|
+
reject(new Error(`timed out after ${timeoutMs}ms waiting for the relay nonce (${relayUrl})`));
|
|
492
|
+
}, timeoutMs);
|
|
493
|
+
timer.unref();
|
|
494
|
+
const fail2 = (err) => {
|
|
495
|
+
clearTimeout(timer);
|
|
496
|
+
ws.terminate();
|
|
497
|
+
reject(err);
|
|
498
|
+
};
|
|
499
|
+
ws.once("error", fail2);
|
|
500
|
+
ws.on("message", function onMessage(data, isBinary) {
|
|
501
|
+
if (isBinary) return fail2(new Error("relay sent a binary frame before the nonce"));
|
|
502
|
+
let parsed2;
|
|
503
|
+
try {
|
|
504
|
+
parsed2 = JSON.parse(toBuffer(data).toString("utf8"));
|
|
505
|
+
} catch {
|
|
506
|
+
return fail2(new Error("relay sent a non-JSON frame before the nonce"));
|
|
507
|
+
}
|
|
508
|
+
const frame = parsed2;
|
|
509
|
+
if (frame.t !== "nonce" || typeof frame.nonce !== "string") {
|
|
510
|
+
return fail2(new Error(`expected the relay's nonce frame, got t=${String(frame.t)}`));
|
|
511
|
+
}
|
|
512
|
+
clearTimeout(timer);
|
|
513
|
+
ws.off("message", onMessage);
|
|
514
|
+
ws.off("error", fail2);
|
|
515
|
+
try {
|
|
516
|
+
resolve({ ws, nonce: base64UrlToBytes(frame.nonce) });
|
|
517
|
+
} catch (err) {
|
|
518
|
+
fail2(err);
|
|
519
|
+
}
|
|
520
|
+
});
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
function awaitControlFrame(ws, expected, timeoutMs = 1e4) {
|
|
524
|
+
return new Promise((resolve, reject) => {
|
|
525
|
+
const timer = setTimeout(() => {
|
|
526
|
+
cleanup();
|
|
527
|
+
reject(new Error(`timed out after ${timeoutMs}ms waiting for ${[...expected].join("/")}`));
|
|
528
|
+
}, timeoutMs);
|
|
529
|
+
timer.unref();
|
|
530
|
+
const onMessage = (data, isBinary) => {
|
|
531
|
+
if (isBinary) return;
|
|
532
|
+
let parsed2;
|
|
533
|
+
try {
|
|
534
|
+
parsed2 = JSON.parse(toBuffer(data).toString("utf8"));
|
|
535
|
+
} catch {
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
if (parsed2 === null || typeof parsed2 !== "object") return;
|
|
539
|
+
const frame = parsed2;
|
|
540
|
+
if (typeof frame.t === "string" && expected.has(frame.t)) {
|
|
541
|
+
cleanup();
|
|
542
|
+
resolve(frame);
|
|
543
|
+
}
|
|
544
|
+
};
|
|
545
|
+
const onClose = (code, reasonBuf) => {
|
|
546
|
+
cleanup();
|
|
547
|
+
reject(new Error(`connection closed (${code}${reasonBuf.length ? `: ${reasonBuf.toString("utf8")}` : ""}) before a reply arrived`));
|
|
548
|
+
};
|
|
549
|
+
const cleanup = () => {
|
|
550
|
+
clearTimeout(timer);
|
|
551
|
+
ws.off("message", onMessage);
|
|
552
|
+
ws.off("close", onClose);
|
|
553
|
+
};
|
|
554
|
+
ws.on("message", onMessage);
|
|
555
|
+
ws.on("close", onClose);
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
async function knockLink(params) {
|
|
559
|
+
const { ws, nonce } = await openAndAwaitNonce(params.relayUrl);
|
|
560
|
+
try {
|
|
561
|
+
const signature = signNonce(params.keypair.privateKeyPem, nonce);
|
|
562
|
+
const reply = awaitControlFrame(ws, /* @__PURE__ */ new Set(["knock_result"]));
|
|
563
|
+
ws.send(
|
|
564
|
+
JSON.stringify({
|
|
565
|
+
t: "knock",
|
|
566
|
+
link_id: params.linkId,
|
|
567
|
+
admission_key: params.admissionKey,
|
|
568
|
+
pubkey_pem: params.keypair.publicKeyPem,
|
|
569
|
+
signature: bytesToBase64Url(signature),
|
|
570
|
+
...params.displayName !== void 0 ? { display_name: params.displayName } : {}
|
|
571
|
+
})
|
|
572
|
+
);
|
|
573
|
+
const frame = await reply;
|
|
574
|
+
if (frame.result === "waiting" && typeof frame.knock_id === "string") {
|
|
575
|
+
return {
|
|
576
|
+
result: "waiting",
|
|
577
|
+
knockId: frame.knock_id,
|
|
578
|
+
...typeof frame.fingerprint === "string" ? { fingerprint: frame.fingerprint } : {}
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
return { result: "rejected", code: typeof frame.code === "string" ? frame.code : "unknown" };
|
|
582
|
+
} finally {
|
|
583
|
+
ws.close();
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
var RECONNECT_REPLY_TYPES = /* @__PURE__ */ new Set(["reconnect_ok", "reconnect_result"]);
|
|
587
|
+
async function attachOnce(params) {
|
|
588
|
+
const { ws, nonce } = await openAndAwaitNonce(params.relayUrl);
|
|
589
|
+
const signature = signNonce(params.keypair.privateKeyPem, nonce);
|
|
590
|
+
const buffered = [];
|
|
591
|
+
const collector = (data, isBinary) => {
|
|
592
|
+
buffered.push({ data: toBuffer(data), isBinary });
|
|
593
|
+
};
|
|
594
|
+
ws.on("message", collector);
|
|
595
|
+
const reply = awaitControlFrame(ws, RECONNECT_REPLY_TYPES);
|
|
596
|
+
ws.send(
|
|
597
|
+
JSON.stringify({
|
|
598
|
+
t: "reconnect",
|
|
599
|
+
link_id: params.linkId,
|
|
600
|
+
pubkey_pem: params.keypair.publicKeyPem,
|
|
601
|
+
signature: bytesToBase64Url(signature)
|
|
602
|
+
})
|
|
603
|
+
);
|
|
604
|
+
let frame;
|
|
605
|
+
try {
|
|
606
|
+
frame = await reply;
|
|
607
|
+
} catch (err) {
|
|
608
|
+
ws.off("message", collector);
|
|
609
|
+
ws.close();
|
|
610
|
+
throw err;
|
|
611
|
+
}
|
|
612
|
+
ws.off("message", collector);
|
|
613
|
+
if (frame.t === "reconnect_ok" && typeof frame.participant_id === "string") {
|
|
614
|
+
const replay = withoutConsumedReconnectReply(buffered);
|
|
615
|
+
const session = new RelaySession(ws, frame.participant_id, replay);
|
|
616
|
+
ws.send(JSON.stringify({ t: "resync" }));
|
|
617
|
+
return { result: "attached", session };
|
|
618
|
+
}
|
|
619
|
+
ws.close();
|
|
620
|
+
return { result: "rejected", code: typeof frame.code === "string" ? frame.code : "unknown" };
|
|
621
|
+
}
|
|
622
|
+
function withoutConsumedReconnectReply(buffered) {
|
|
623
|
+
const index = buffered.findIndex((f) => {
|
|
624
|
+
if (f.isBinary) return false;
|
|
625
|
+
try {
|
|
626
|
+
const parsed2 = JSON.parse(f.data.toString("utf8"));
|
|
627
|
+
return parsed2 !== null && typeof parsed2 === "object" && typeof parsed2.t === "string" && RECONNECT_REPLY_TYPES.has(parsed2.t);
|
|
628
|
+
} catch {
|
|
629
|
+
return false;
|
|
630
|
+
}
|
|
631
|
+
});
|
|
632
|
+
return index === -1 ? buffered : buffered.filter((_, i) => i !== index);
|
|
633
|
+
}
|
|
634
|
+
var RETRYABLE_ATTACH_CODES = /* @__PURE__ */ new Set(["not_admitted", "link_not_ready"]);
|
|
635
|
+
async function attachWithRetry(options) {
|
|
636
|
+
const maxAttempts = options.maxAttempts ?? 60;
|
|
637
|
+
const initialDelayMs = options.initialDelayMs ?? 1e3;
|
|
638
|
+
const maxDelayMs = options.maxDelayMs ?? 1e4;
|
|
639
|
+
let delayMs = initialDelayMs;
|
|
640
|
+
for (let attempt = 1; ; attempt++) {
|
|
641
|
+
const outcome = await attachOnce(options);
|
|
642
|
+
if (outcome.result === "attached" || !RETRYABLE_ATTACH_CODES.has(outcome.code) || attempt >= maxAttempts) {
|
|
643
|
+
return outcome;
|
|
644
|
+
}
|
|
645
|
+
options.onWaiting?.(attempt, delayMs);
|
|
646
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
647
|
+
delayMs = Math.min(delayMs * 2, maxDelayMs);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
var RelaySession = class {
|
|
651
|
+
participantId;
|
|
652
|
+
#ws;
|
|
653
|
+
#assembler = new FrameAssembler();
|
|
654
|
+
/** Mirror of the assembler's phase: a TEXT frame arriving while NOT awaiting a body is a
|
|
655
|
+
* standalone control frame (error/resync_ok/...) — never fed to the assembler. */
|
|
656
|
+
#awaitingBody = false;
|
|
657
|
+
#closedByUs = false;
|
|
658
|
+
#closeInfo = null;
|
|
659
|
+
#closeWaiters = [];
|
|
660
|
+
#messageQueue = [];
|
|
661
|
+
#messageWaiters = [];
|
|
662
|
+
#onMessage = null;
|
|
663
|
+
#onRelayError = null;
|
|
664
|
+
constructor(ws, participantId, replayFrames) {
|
|
665
|
+
this.#ws = ws;
|
|
666
|
+
this.participantId = participantId;
|
|
667
|
+
ws.on("message", (data, isBinary) => {
|
|
668
|
+
this.#handleFrame(toBuffer(data), isBinary);
|
|
669
|
+
});
|
|
670
|
+
ws.on("close", (code, reasonBuf) => {
|
|
671
|
+
this.#closeInfo = { code, reason: reasonBuf.toString("utf8") };
|
|
672
|
+
for (const waiter of this.#closeWaiters.splice(0)) waiter(this.#closeInfo);
|
|
673
|
+
});
|
|
674
|
+
ws.on("error", () => {
|
|
675
|
+
});
|
|
676
|
+
if (replayFrames) {
|
|
677
|
+
for (const frame of replayFrames) this.#handleFrame(frame.data, frame.isBinary);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
/** The single inbound-frame path — live 'message' events and handoff replay both land here. */
|
|
681
|
+
#handleFrame(buf, isBinary) {
|
|
682
|
+
if (!isBinary && !this.#awaitingBody) {
|
|
683
|
+
let parsed2 = null;
|
|
684
|
+
try {
|
|
685
|
+
parsed2 = JSON.parse(buf.toString("utf8"));
|
|
686
|
+
} catch {
|
|
687
|
+
parsed2 = null;
|
|
688
|
+
}
|
|
689
|
+
const t = parsed2 !== null && typeof parsed2 === "object" ? parsed2.t : void 0;
|
|
690
|
+
if (t !== "deliver" && t !== "send") {
|
|
691
|
+
if (t === "error") {
|
|
692
|
+
const frame = parsed2;
|
|
693
|
+
this.#onRelayError?.({ ...frame, code: typeof frame.code === "string" ? frame.code : "unknown" });
|
|
694
|
+
}
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
try {
|
|
699
|
+
const result = this.#assembler.feed(buf, isBinary);
|
|
700
|
+
if (result.kind === "awaiting-body") {
|
|
701
|
+
this.#awaitingBody = true;
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
this.#awaitingBody = false;
|
|
705
|
+
if (result.header.t === "deliver" && typeof result.header.attempt_token === "string") {
|
|
706
|
+
this.#ws.send(JSON.stringify({ t: "ack", attempt_token: result.header.attempt_token }));
|
|
707
|
+
}
|
|
708
|
+
const incoming = { header: result.header, text: result.bodyText };
|
|
709
|
+
if (this.#onMessage) {
|
|
710
|
+
this.#onMessage(incoming);
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
const waiter = this.#messageWaiters.shift();
|
|
714
|
+
if (waiter) waiter(incoming);
|
|
715
|
+
else this.#messageQueue.push(incoming);
|
|
716
|
+
} catch (err) {
|
|
717
|
+
const code = err instanceof FrameProtocolError ? err.closeCode : 1011;
|
|
718
|
+
this.close(code, err instanceof Error ? err.message : "frame handling failed");
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
/** Sends one §5.4 message: TEXT header `{t:'send', byte_len, client_msg_id}` then the BINARY
|
|
722
|
+
* body frame (UTF-8 bytes, ≤ MAX_BODY_BYTES — assertBodySize throws before anything hits
|
|
723
|
+
* the wire on oversize). */
|
|
724
|
+
sendText(text, clientMsgId = randomUUID()) {
|
|
725
|
+
const bodyBytes = Buffer.from(text, "utf8");
|
|
726
|
+
assertBodySize(bodyBytes);
|
|
727
|
+
const header = encodeHeaderFrame({ t: "send", byte_len: bodyBytes.length, client_msg_id: clientMsgId });
|
|
728
|
+
this.#ws.send(header);
|
|
729
|
+
this.#ws.send(bodyBytes, { binary: true });
|
|
730
|
+
}
|
|
731
|
+
/** Streams every inbound delivery to `handler` (drains anything already queued first). */
|
|
732
|
+
onMessage(handler) {
|
|
733
|
+
this.#onMessage = handler;
|
|
734
|
+
for (const queued of this.#messageQueue.splice(0)) handler(queued);
|
|
735
|
+
}
|
|
736
|
+
/** Relay `{t:'error'}` control frames (awaiting_funds, claim_rejected, ...). */
|
|
737
|
+
onRelayError(handler) {
|
|
738
|
+
this.#onRelayError = handler;
|
|
739
|
+
}
|
|
740
|
+
/** Test-driver convenience: awaits the next delivery (only when no onMessage handler is
|
|
741
|
+
* installed). */
|
|
742
|
+
waitForMessage(timeoutMs = 5e3) {
|
|
743
|
+
const queued = this.#messageQueue.shift();
|
|
744
|
+
if (queued) return Promise.resolve(queued);
|
|
745
|
+
return new Promise((resolve, reject) => {
|
|
746
|
+
const timer = setTimeout(() => reject(new Error(`waitForMessage timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
747
|
+
timer.unref();
|
|
748
|
+
this.#messageWaiters.push((message) => {
|
|
749
|
+
clearTimeout(timer);
|
|
750
|
+
resolve(message);
|
|
751
|
+
});
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
/** Resolves when the socket closes (either side). */
|
|
755
|
+
waitClose() {
|
|
756
|
+
if (this.#closeInfo) return Promise.resolve(this.#closeInfo);
|
|
757
|
+
return new Promise((resolve) => this.#closeWaiters.push(resolve));
|
|
758
|
+
}
|
|
759
|
+
get closedByUs() {
|
|
760
|
+
return this.#closedByUs;
|
|
761
|
+
}
|
|
762
|
+
close(code = 1e3, reason = "") {
|
|
763
|
+
this.#closedByUs = true;
|
|
764
|
+
try {
|
|
765
|
+
this.#ws.close(code, reason.slice(0, 123));
|
|
766
|
+
} catch {
|
|
767
|
+
this.#ws.terminate();
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
};
|
|
771
|
+
|
|
772
|
+
// src/join.ts
|
|
773
|
+
async function runJoin(options, io) {
|
|
774
|
+
let keypair;
|
|
775
|
+
try {
|
|
776
|
+
const loaded = loadOrCreateKeypair(options.keyFile);
|
|
777
|
+
keypair = loaded.keypair;
|
|
778
|
+
io.printErr(
|
|
779
|
+
loaded.created ? `generated a new Ed25519 keypair at ${options.keyFile} (0600)` : `using the Ed25519 keypair at ${options.keyFile}`
|
|
780
|
+
);
|
|
781
|
+
} catch (err) {
|
|
782
|
+
io.printErr(err.message);
|
|
783
|
+
return 1;
|
|
784
|
+
}
|
|
785
|
+
const keyPrefix = admissionKeyPrefix(options.admissionKey);
|
|
786
|
+
io.printErr(`knocking link ${options.linkId} with key ${keyPrefix ?? "(not mat_live_-shaped)"}\u2026`);
|
|
787
|
+
let knock;
|
|
788
|
+
try {
|
|
789
|
+
knock = await knockLink({
|
|
790
|
+
relayUrl: options.relayUrl,
|
|
791
|
+
linkId: options.linkId,
|
|
792
|
+
admissionKey: options.admissionKey,
|
|
793
|
+
keypair,
|
|
794
|
+
...options.displayName !== void 0 ? { displayName: options.displayName } : {}
|
|
795
|
+
});
|
|
796
|
+
} catch (err) {
|
|
797
|
+
io.printErr(`knock failed: ${sanitizeForTerminal(err.message)}`);
|
|
798
|
+
return 1;
|
|
799
|
+
}
|
|
800
|
+
if (knock.result === "rejected") {
|
|
801
|
+
io.printErr(`knock rejected: ${sanitizeForTerminal(knock.code)}`);
|
|
802
|
+
return 1;
|
|
803
|
+
}
|
|
804
|
+
io.printErr(`knock accepted (${knock.knockId}) \u2014 waiting for the Link owner to let you in\u2026`);
|
|
805
|
+
let attach;
|
|
806
|
+
try {
|
|
807
|
+
attach = await attachWithRetry({
|
|
808
|
+
relayUrl: options.relayUrl,
|
|
809
|
+
linkId: options.linkId,
|
|
810
|
+
keypair,
|
|
811
|
+
...options.pollMaxAttempts !== void 0 ? { maxAttempts: options.pollMaxAttempts } : {},
|
|
812
|
+
...options.pollInitialDelayMs !== void 0 ? { initialDelayMs: options.pollInitialDelayMs } : {},
|
|
813
|
+
...options.pollMaxDelayMs !== void 0 ? { maxDelayMs: options.pollMaxDelayMs } : {},
|
|
814
|
+
onWaiting: (attempt, nextDelayMs) => io.printErr(`not admitted yet (attempt ${attempt}) \u2014 retrying in ${nextDelayMs}ms`)
|
|
815
|
+
});
|
|
816
|
+
} catch (err) {
|
|
817
|
+
io.printErr(`reconnect failed: ${sanitizeForTerminal(err.message)}`);
|
|
818
|
+
return 1;
|
|
819
|
+
}
|
|
820
|
+
if (attach.result === "rejected") {
|
|
821
|
+
io.printErr(`could not attach: ${sanitizeForTerminal(attach.code)}`);
|
|
822
|
+
return 1;
|
|
823
|
+
}
|
|
824
|
+
const session = attach.session;
|
|
825
|
+
io.printErr(`attached as participant ${session.participantId} \u2014 type a message and press enter`);
|
|
826
|
+
session.onMessage(({ header, text }) => {
|
|
827
|
+
const from = header.name ?? header.from ?? "peer";
|
|
828
|
+
if (header.wake === true) io.print(DIRECTOR_WAKE_MARKER);
|
|
829
|
+
io.print(`[${sanitizeForTerminal(from)}] ${sanitizeForTerminal(text)}`);
|
|
830
|
+
});
|
|
831
|
+
session.onRelayError((frame) => io.printErr(`relay error: ${sanitizeForTerminal(frame.code)}`));
|
|
832
|
+
if (io.input) {
|
|
833
|
+
const rl = createInterface({ input: io.input });
|
|
834
|
+
rl.on("line", (line) => {
|
|
835
|
+
if (line.length === 0) return;
|
|
836
|
+
try {
|
|
837
|
+
session.sendText(line, randomUUID2());
|
|
838
|
+
} catch (err) {
|
|
839
|
+
io.printErr(`send failed: ${sanitizeForTerminal(err.message)}`);
|
|
840
|
+
}
|
|
841
|
+
});
|
|
842
|
+
rl.on("close", () => session.close(1e3, "input closed"));
|
|
843
|
+
}
|
|
844
|
+
const closeInfo = await session.waitClose();
|
|
845
|
+
const reason = closeInfo.reason ? `: ${sanitizeForTerminal(closeInfo.reason)}` : "";
|
|
846
|
+
io.printErr(`connection closed (${closeInfo.code}${reason})`);
|
|
847
|
+
return session.closedByUs || closeInfo.code === 1e3 ? 0 : 1;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// src/index.ts
|
|
851
|
+
var parsed = parseJoinArgs(process2.argv.slice(2));
|
|
852
|
+
if (!parsed.ok) {
|
|
853
|
+
console.error(parsed.error);
|
|
854
|
+
console.error("");
|
|
855
|
+
console.error(USAGE);
|
|
856
|
+
process2.exit(2);
|
|
857
|
+
}
|
|
858
|
+
var admissionKey;
|
|
859
|
+
try {
|
|
860
|
+
admissionKey = await resolveAdmissionKey({
|
|
861
|
+
...parsed.args.tokenFile !== void 0 ? { tokenFile: parsed.args.tokenFile } : {},
|
|
862
|
+
...parsed.args.insecureTokenArg !== void 0 ? { insecureTokenArg: parsed.args.insecureTokenArg } : {},
|
|
863
|
+
stdin: process2.stdin
|
|
864
|
+
});
|
|
865
|
+
} catch (err) {
|
|
866
|
+
console.error(err.message);
|
|
867
|
+
process2.exit(2);
|
|
868
|
+
}
|
|
869
|
+
var exitCode = await runJoin(
|
|
870
|
+
{
|
|
871
|
+
linkId: parsed.args.linkId,
|
|
872
|
+
relayUrl: parsed.args.relayUrl,
|
|
873
|
+
...parsed.args.displayName !== void 0 ? { displayName: parsed.args.displayName } : {},
|
|
874
|
+
keyFile: parsed.args.keyFile,
|
|
875
|
+
admissionKey
|
|
876
|
+
},
|
|
877
|
+
{
|
|
878
|
+
// If the key arrived via piped stdin, the SAME stream continues as the message source.
|
|
879
|
+
input: process2.stdin,
|
|
880
|
+
print: (line) => console.log(line),
|
|
881
|
+
printErr: (line) => console.error(line)
|
|
882
|
+
}
|
|
883
|
+
);
|
|
884
|
+
process2.exit(exitCode);
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "makeagentstalk",
|
|
3
|
+
"version": "1.0.0-beta.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Connect an AI agent to a MakeAgentsTalk Link.",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=22.18"
|
|
8
|
+
},
|
|
9
|
+
"bin": {
|
|
10
|
+
"makeagentstalk": "dist/index.js"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"dist/index.js",
|
|
14
|
+
"README.md"
|
|
15
|
+
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "esbuild src/index.ts --bundle --platform=node --format=esm --target=node22.18 --outfile=dist/index.js --external:ws",
|
|
18
|
+
"prepack": "npm run build",
|
|
19
|
+
"test": "node --test --test-concurrency=1 test/*.test.ts"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"ws": "^8.21.1"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@types/ws": "^8.18.1"
|
|
26
|
+
},
|
|
27
|
+
"license": "UNLICENSED"
|
|
28
|
+
}
|