@openape/nest 2.0.2 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.mjs +539 -12
- package/package.json +3 -1
package/dist/index.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
4
|
import { watch } from "fs";
|
|
5
|
-
import
|
|
5
|
+
import process4 from "process";
|
|
6
6
|
|
|
7
7
|
// src/lib/registry.ts
|
|
8
8
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
@@ -31,7 +31,7 @@ function listAgents() {
|
|
|
31
31
|
import { execFile } from "child_process";
|
|
32
32
|
import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
33
33
|
import { join as join2 } from "path";
|
|
34
|
-
import
|
|
34
|
+
import process2 from "process";
|
|
35
35
|
import { promisify } from "util";
|
|
36
36
|
var execFileAsync = promisify(execFile);
|
|
37
37
|
var AGENTS_DIR = "/var/openape/agents";
|
|
@@ -49,7 +49,7 @@ function ecosystemContents(apesBin, agentName) {
|
|
|
49
49
|
module.exports = {
|
|
50
50
|
apps: [{
|
|
51
51
|
name: '${pm2AppName(agentName)}',
|
|
52
|
-
script: '
|
|
52
|
+
script: 'ape-agent',
|
|
53
53
|
autorestart: true,
|
|
54
54
|
max_restarts: 10,
|
|
55
55
|
min_uptime: '30s',
|
|
@@ -154,7 +154,7 @@ var Pm2Supervisor = class {
|
|
|
154
154
|
return await execFileAsync(
|
|
155
155
|
this.deps.apesBin,
|
|
156
156
|
["run", "--as", agentName, "--wait", "--", ...args],
|
|
157
|
-
{ maxBuffer: 1024 * 1024, env:
|
|
157
|
+
{ maxBuffer: 1024 * 1024, env: process2.env, timeout: 6e4, cwd: "/tmp" }
|
|
158
158
|
);
|
|
159
159
|
} catch (err) {
|
|
160
160
|
const e = err;
|
|
@@ -167,7 +167,7 @@ var Pm2Supervisor = class {
|
|
|
167
167
|
|
|
168
168
|
// src/lib/troop-sync.ts
|
|
169
169
|
import { execFile as execFile2 } from "child_process";
|
|
170
|
-
import
|
|
170
|
+
import process3 from "process";
|
|
171
171
|
import { promisify as promisify2 } from "util";
|
|
172
172
|
var execFileAsync2 = promisify2(execFile2);
|
|
173
173
|
var TICK_MS = 5 * 60 * 1e3;
|
|
@@ -206,7 +206,7 @@ var TroopSync = class {
|
|
|
206
206
|
await execFileAsync2(
|
|
207
207
|
this.deps.apesBin,
|
|
208
208
|
["run", "--as", name, "--wait", "--", "apes", "agents", "sync"],
|
|
209
|
-
{ maxBuffer: 1024 * 1024, env:
|
|
209
|
+
{ maxBuffer: 1024 * 1024, env: process3.env, timeout: 6e4 }
|
|
210
210
|
);
|
|
211
211
|
} catch (err) {
|
|
212
212
|
this.deps.log(`troop-sync: ${name} failed: ${err instanceof Error ? err.message.split("\n")[0] : String(err)}`);
|
|
@@ -214,15 +214,539 @@ var TroopSync = class {
|
|
|
214
214
|
}
|
|
215
215
|
};
|
|
216
216
|
|
|
217
|
+
// src/lib/troop-ws.ts
|
|
218
|
+
import { execFile as execFile3, execFileSync } from "child_process";
|
|
219
|
+
import { createHash } from "crypto";
|
|
220
|
+
import { readFileSync as readFileSync3 } from "fs";
|
|
221
|
+
import { hostname, networkInterfaces } from "os";
|
|
222
|
+
|
|
223
|
+
// ../../packages/cli-auth/dist/index.js
|
|
224
|
+
import { ofetch } from "ofetch";
|
|
225
|
+
import { existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync2, readdirSync, unlinkSync, writeFileSync as writeFileSync3 } from "fs";
|
|
226
|
+
import { homedir as homedir2 } from "os";
|
|
227
|
+
import { join as join3 } from "path";
|
|
228
|
+
import { ofetch as ofetch3 } from "ofetch";
|
|
229
|
+
import { Buffer as Buffer2 } from "buffer";
|
|
230
|
+
import { sign } from "crypto";
|
|
231
|
+
import { existsSync as existsSync22, readFileSync as readFileSync22 } from "fs";
|
|
232
|
+
import { homedir as homedir22 } from "os";
|
|
233
|
+
import { join as join22 } from "path";
|
|
234
|
+
import { ofetch as ofetch2 } from "ofetch";
|
|
235
|
+
import { Buffer as Buffer3 } from "buffer";
|
|
236
|
+
import { createPrivateKey } from "crypto";
|
|
237
|
+
import { ofetch as ofetch4 } from "ofetch";
|
|
238
|
+
function getConfigDir() {
|
|
239
|
+
const override = process.env.OPENAPE_CLI_AUTH_HOME;
|
|
240
|
+
if (override) return override;
|
|
241
|
+
return join3(homedir2(), ".config", "apes");
|
|
242
|
+
}
|
|
243
|
+
function getAuthFile() {
|
|
244
|
+
return join3(getConfigDir(), "auth.json");
|
|
245
|
+
}
|
|
246
|
+
function ensureConfigDir() {
|
|
247
|
+
const dir = getConfigDir();
|
|
248
|
+
if (!existsSync2(dir)) {
|
|
249
|
+
mkdirSync3(dir, { recursive: true, mode: 448 });
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
function loadIdpAuth() {
|
|
253
|
+
const file = getAuthFile();
|
|
254
|
+
if (!existsSync2(file)) return null;
|
|
255
|
+
try {
|
|
256
|
+
const raw = readFileSync2(file, "utf-8");
|
|
257
|
+
if (!raw.trim()) return null;
|
|
258
|
+
return JSON.parse(raw);
|
|
259
|
+
} catch {
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
function saveIdpAuth(auth) {
|
|
264
|
+
ensureConfigDir();
|
|
265
|
+
const file = getAuthFile();
|
|
266
|
+
let extra = {};
|
|
267
|
+
if (existsSync2(file)) {
|
|
268
|
+
try {
|
|
269
|
+
const raw = readFileSync2(file, "utf-8");
|
|
270
|
+
if (raw.trim()) {
|
|
271
|
+
const prev = JSON.parse(raw);
|
|
272
|
+
for (const key of Object.keys(prev)) {
|
|
273
|
+
if (!(key in auth)) {
|
|
274
|
+
extra[key] = prev[key];
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
} catch {
|
|
279
|
+
extra = {};
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
const merged = { ...extra, ...auth };
|
|
283
|
+
writeFileSync3(file, JSON.stringify(merged, null, 2), { mode: 384 });
|
|
284
|
+
}
|
|
285
|
+
var AuthError = class extends Error {
|
|
286
|
+
status;
|
|
287
|
+
hint;
|
|
288
|
+
constructor(status, message, hint) {
|
|
289
|
+
super(hint ? `${message}
|
|
290
|
+
${hint}` : message);
|
|
291
|
+
this.name = "AuthError";
|
|
292
|
+
this.status = status;
|
|
293
|
+
this.hint = hint;
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
var NotLoggedInError = class extends AuthError {
|
|
297
|
+
constructor(hint) {
|
|
298
|
+
super(
|
|
299
|
+
401,
|
|
300
|
+
"Not logged in",
|
|
301
|
+
hint ?? "Run `apes login <email>` once on this device to authenticate against the OpenApe IdP."
|
|
302
|
+
);
|
|
303
|
+
this.name = "NotLoggedInError";
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
var OPENSSH_MAGIC = "openssh-key-v1\0";
|
|
307
|
+
function loadEd25519PrivateKey(pem) {
|
|
308
|
+
if (pem.includes("BEGIN OPENSSH PRIVATE KEY")) {
|
|
309
|
+
return parseOpenSSHEd25519(pem);
|
|
310
|
+
}
|
|
311
|
+
return createPrivateKey(pem);
|
|
312
|
+
}
|
|
313
|
+
function parseOpenSSHEd25519(pem) {
|
|
314
|
+
const b64 = pem.replace(/-----BEGIN OPENSSH PRIVATE KEY-----/, "").replace(/-----END OPENSSH PRIVATE KEY-----/, "").replace(/\s/g, "");
|
|
315
|
+
const buf = Buffer3.from(b64, "base64");
|
|
316
|
+
let offset = 0;
|
|
317
|
+
const magic = buf.subarray(0, OPENSSH_MAGIC.length).toString("ascii");
|
|
318
|
+
if (magic !== OPENSSH_MAGIC) {
|
|
319
|
+
throw new Error("Not an OpenSSH private key");
|
|
320
|
+
}
|
|
321
|
+
offset += OPENSSH_MAGIC.length;
|
|
322
|
+
const cipherLen = buf.readUInt32BE(offset);
|
|
323
|
+
offset += 4;
|
|
324
|
+
const cipher = buf.subarray(offset, offset + cipherLen).toString();
|
|
325
|
+
offset += cipherLen;
|
|
326
|
+
if (cipher !== "none") {
|
|
327
|
+
throw new Error(`Encrypted keys not supported (cipher: ${cipher}). Decrypt first with: ssh-keygen -p -f <key>`);
|
|
328
|
+
}
|
|
329
|
+
const kdfLen = buf.readUInt32BE(offset);
|
|
330
|
+
offset += 4;
|
|
331
|
+
offset += kdfLen;
|
|
332
|
+
const kdfOptsLen = buf.readUInt32BE(offset);
|
|
333
|
+
offset += 4;
|
|
334
|
+
offset += kdfOptsLen;
|
|
335
|
+
const numKeys = buf.readUInt32BE(offset);
|
|
336
|
+
offset += 4;
|
|
337
|
+
if (numKeys !== 1) {
|
|
338
|
+
throw new Error(`Expected 1 key, got ${numKeys}`);
|
|
339
|
+
}
|
|
340
|
+
const pubSectionLen = buf.readUInt32BE(offset);
|
|
341
|
+
offset += 4;
|
|
342
|
+
offset += pubSectionLen;
|
|
343
|
+
const privSectionLen = buf.readUInt32BE(offset);
|
|
344
|
+
offset += 4;
|
|
345
|
+
const privSection = buf.subarray(offset, offset + privSectionLen);
|
|
346
|
+
let pOffset = 0;
|
|
347
|
+
const check1 = privSection.readUInt32BE(pOffset);
|
|
348
|
+
pOffset += 4;
|
|
349
|
+
const check2 = privSection.readUInt32BE(pOffset);
|
|
350
|
+
pOffset += 4;
|
|
351
|
+
if (check1 !== check2) {
|
|
352
|
+
throw new Error("Check integers mismatch \u2014 key may be corrupted or encrypted");
|
|
353
|
+
}
|
|
354
|
+
const keyTypeLen = privSection.readUInt32BE(pOffset);
|
|
355
|
+
pOffset += 4;
|
|
356
|
+
const keyType = privSection.subarray(pOffset, pOffset + keyTypeLen).toString();
|
|
357
|
+
pOffset += keyTypeLen;
|
|
358
|
+
if (keyType !== "ssh-ed25519") {
|
|
359
|
+
throw new Error(`Expected ssh-ed25519, got ${keyType}`);
|
|
360
|
+
}
|
|
361
|
+
const pubKeyLen = privSection.readUInt32BE(pOffset);
|
|
362
|
+
pOffset += 4;
|
|
363
|
+
const pubKey = privSection.subarray(pOffset, pOffset + pubKeyLen);
|
|
364
|
+
pOffset += pubKeyLen;
|
|
365
|
+
const privKeyLen = privSection.readUInt32BE(pOffset);
|
|
366
|
+
pOffset += 4;
|
|
367
|
+
const privKeyData = privSection.subarray(pOffset, pOffset + privKeyLen);
|
|
368
|
+
const seed = privKeyData.subarray(0, 32);
|
|
369
|
+
return createPrivateKey({
|
|
370
|
+
key: { kty: "OKP", crv: "Ed25519", d: seed.toString("base64url"), x: pubKey.toString("base64url") },
|
|
371
|
+
format: "jwk"
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
async function getEndpoints(idp) {
|
|
375
|
+
let disco = {};
|
|
376
|
+
try {
|
|
377
|
+
disco = await ofetch2(`${idp}/.well-known/openid-configuration`);
|
|
378
|
+
} catch {
|
|
379
|
+
}
|
|
380
|
+
return {
|
|
381
|
+
challenge: disco.ddisa_agent_challenge_endpoint ?? `${idp}/api/agent/challenge`,
|
|
382
|
+
authenticate: disco.ddisa_agent_authenticate_endpoint ?? `${idp}/api/agent/authenticate`
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
function resolveKeyPath(p) {
|
|
386
|
+
if (p.startsWith("~")) return join22(homedir22(), p.slice(1));
|
|
387
|
+
return p;
|
|
388
|
+
}
|
|
389
|
+
function findSigningKey(auth) {
|
|
390
|
+
const candidates = [];
|
|
391
|
+
if (auth.key_path) candidates.push(resolveKeyPath(auth.key_path));
|
|
392
|
+
candidates.push(join22(homedir22(), ".ssh", "id_ed25519"));
|
|
393
|
+
for (const p of candidates) {
|
|
394
|
+
if (existsSync22(p)) {
|
|
395
|
+
try {
|
|
396
|
+
return { keyPath: p, keyContent: readFileSync22(p, "utf-8") };
|
|
397
|
+
} catch {
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
return null;
|
|
402
|
+
}
|
|
403
|
+
async function refreshAgentToken(auth, now = Math.floor(Date.now() / 1e3)) {
|
|
404
|
+
const key = findSigningKey(auth);
|
|
405
|
+
if (!key) return null;
|
|
406
|
+
let privateKey;
|
|
407
|
+
try {
|
|
408
|
+
privateKey = loadEd25519PrivateKey(key.keyContent);
|
|
409
|
+
} catch {
|
|
410
|
+
return null;
|
|
411
|
+
}
|
|
412
|
+
let endpoints;
|
|
413
|
+
try {
|
|
414
|
+
endpoints = await getEndpoints(auth.idp);
|
|
415
|
+
} catch {
|
|
416
|
+
return null;
|
|
417
|
+
}
|
|
418
|
+
let challenge;
|
|
419
|
+
try {
|
|
420
|
+
const resp = await ofetch2(endpoints.challenge, {
|
|
421
|
+
method: "POST",
|
|
422
|
+
headers: { "Content-Type": "application/json" },
|
|
423
|
+
body: { agent_id: auth.email }
|
|
424
|
+
});
|
|
425
|
+
challenge = resp.challenge;
|
|
426
|
+
} catch {
|
|
427
|
+
return null;
|
|
428
|
+
}
|
|
429
|
+
let signature;
|
|
430
|
+
try {
|
|
431
|
+
signature = sign(null, Buffer2.from(challenge), privateKey).toString("base64");
|
|
432
|
+
} catch {
|
|
433
|
+
return null;
|
|
434
|
+
}
|
|
435
|
+
let authResp;
|
|
436
|
+
try {
|
|
437
|
+
authResp = await ofetch2(endpoints.authenticate, {
|
|
438
|
+
method: "POST",
|
|
439
|
+
headers: { "Content-Type": "application/json" },
|
|
440
|
+
body: { agent_id: auth.email, challenge, signature }
|
|
441
|
+
});
|
|
442
|
+
} catch {
|
|
443
|
+
return null;
|
|
444
|
+
}
|
|
445
|
+
return {
|
|
446
|
+
...auth,
|
|
447
|
+
access_token: authResp.token,
|
|
448
|
+
expires_at: now + (authResp.expires_in || 3600),
|
|
449
|
+
key_path: auth.key_path ?? key.keyPath
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
var EXPIRY_SKEW_SECONDS = 30;
|
|
453
|
+
async function getTokenEndpoint(idp) {
|
|
454
|
+
try {
|
|
455
|
+
const disco = await ofetch3(`${idp}/.well-known/openid-configuration`);
|
|
456
|
+
if (disco.token_endpoint) return disco.token_endpoint;
|
|
457
|
+
} catch {
|
|
458
|
+
}
|
|
459
|
+
return `${idp}/token`;
|
|
460
|
+
}
|
|
461
|
+
async function ensureFreshIdpAuth(now = Math.floor(Date.now() / 1e3)) {
|
|
462
|
+
const auth = loadIdpAuth();
|
|
463
|
+
if (!auth) {
|
|
464
|
+
throw new NotLoggedInError();
|
|
465
|
+
}
|
|
466
|
+
if (auth.expires_at > now + EXPIRY_SKEW_SECONDS) {
|
|
467
|
+
return auth;
|
|
468
|
+
}
|
|
469
|
+
if (!auth.refresh_token) {
|
|
470
|
+
const refreshed = await refreshAgentToken(auth, now);
|
|
471
|
+
if (refreshed) {
|
|
472
|
+
saveIdpAuth(refreshed);
|
|
473
|
+
return refreshed;
|
|
474
|
+
}
|
|
475
|
+
throw new NotLoggedInError(
|
|
476
|
+
`IdP token expired at ${new Date(auth.expires_at * 1e3).toISOString()} and no refresh_token is stored. Run \`apes login\` again.`
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
const tokenEndpoint = await getTokenEndpoint(auth.idp);
|
|
480
|
+
const body = new URLSearchParams({
|
|
481
|
+
grant_type: "refresh_token",
|
|
482
|
+
refresh_token: auth.refresh_token
|
|
483
|
+
});
|
|
484
|
+
let response;
|
|
485
|
+
try {
|
|
486
|
+
response = await ofetch3(tokenEndpoint, {
|
|
487
|
+
method: "POST",
|
|
488
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
489
|
+
body: body.toString()
|
|
490
|
+
});
|
|
491
|
+
} catch (err) {
|
|
492
|
+
const status = err.status ?? err.statusCode ?? 0;
|
|
493
|
+
if (status === 400 || status === 401) {
|
|
494
|
+
saveIdpAuth({ ...auth, refresh_token: void 0 });
|
|
495
|
+
throw new NotLoggedInError(
|
|
496
|
+
`Refresh token rejected by ${auth.idp}. Run \`apes login\` again.`
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
throw new AuthError(
|
|
500
|
+
0,
|
|
501
|
+
`Network error refreshing IdP token at ${tokenEndpoint}`,
|
|
502
|
+
`Underlying: ${err.message ?? err}`
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
if (!response.access_token) {
|
|
506
|
+
throw new AuthError(0, `IdP refresh response missing access_token (endpoint: ${tokenEndpoint})`);
|
|
507
|
+
}
|
|
508
|
+
const next = {
|
|
509
|
+
...auth,
|
|
510
|
+
access_token: response.access_token,
|
|
511
|
+
refresh_token: response.refresh_token ?? auth.refresh_token,
|
|
512
|
+
expires_at: now + (response.expires_in ?? 3600)
|
|
513
|
+
};
|
|
514
|
+
saveIdpAuth(next);
|
|
515
|
+
return next;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// src/lib/troop-ws.ts
|
|
519
|
+
import WebSocket from "ws";
|
|
520
|
+
var HEARTBEAT_INTERVAL_MS = 3e4;
|
|
521
|
+
var RECONNECT_BASE_MS = 1e3;
|
|
522
|
+
var RECONNECT_MAX_MS = 3e4;
|
|
523
|
+
var TroopWs = class {
|
|
524
|
+
constructor(opts) {
|
|
525
|
+
this.opts = opts;
|
|
526
|
+
this.troopUrl = (opts.troopUrl ?? process.env.OPENAPE_TROOP_WS_URL ?? "wss://troop.openape.ai").replace(/\/$/, "");
|
|
527
|
+
this.hostId = readHostId();
|
|
528
|
+
this.hostname = hostname();
|
|
529
|
+
}
|
|
530
|
+
socket = null;
|
|
531
|
+
heartbeatTimer = null;
|
|
532
|
+
reconnectTimer = null;
|
|
533
|
+
reconnectAttempts = 0;
|
|
534
|
+
stopped = false;
|
|
535
|
+
troopUrl;
|
|
536
|
+
hostId;
|
|
537
|
+
hostname;
|
|
538
|
+
start() {
|
|
539
|
+
this.stopped = false;
|
|
540
|
+
void this.connect();
|
|
541
|
+
}
|
|
542
|
+
stop() {
|
|
543
|
+
this.stopped = true;
|
|
544
|
+
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
|
545
|
+
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
|
|
546
|
+
this.reconnectTimer = null;
|
|
547
|
+
this.heartbeatTimer = null;
|
|
548
|
+
if (this.socket) {
|
|
549
|
+
try {
|
|
550
|
+
this.socket.close();
|
|
551
|
+
} catch {
|
|
552
|
+
}
|
|
553
|
+
this.socket = null;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
async connect() {
|
|
557
|
+
if (this.stopped) return;
|
|
558
|
+
let token;
|
|
559
|
+
try {
|
|
560
|
+
const auth = await ensureFreshIdpAuth();
|
|
561
|
+
token = auth.access_token;
|
|
562
|
+
} catch (err) {
|
|
563
|
+
if (err instanceof NotLoggedInError) {
|
|
564
|
+
this.opts.log("troop-ws: not logged in (apes login) \u2014 skip connect, will retry");
|
|
565
|
+
} else {
|
|
566
|
+
this.opts.log(`troop-ws: auth refresh failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
567
|
+
}
|
|
568
|
+
this.scheduleReconnect();
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
const url = `${this.troopUrl}/api/nest-ws?token=${encodeURIComponent(token)}`;
|
|
572
|
+
const ws = new WebSocket(url);
|
|
573
|
+
this.socket = ws;
|
|
574
|
+
ws.on("open", () => {
|
|
575
|
+
this.reconnectAttempts = 0;
|
|
576
|
+
this.opts.log(`troop-ws: connected to ${this.troopUrl}`);
|
|
577
|
+
ws.send(JSON.stringify({
|
|
578
|
+
type: "hello",
|
|
579
|
+
host_id: this.hostId,
|
|
580
|
+
hostname: this.hostname,
|
|
581
|
+
version: this.opts.version ?? "unknown"
|
|
582
|
+
}));
|
|
583
|
+
this.heartbeatTimer = setInterval(() => {
|
|
584
|
+
try {
|
|
585
|
+
ws.send(JSON.stringify({ type: "heartbeat" }));
|
|
586
|
+
} catch {
|
|
587
|
+
}
|
|
588
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
589
|
+
});
|
|
590
|
+
ws.on("message", (data) => {
|
|
591
|
+
const text = typeof data === "string" ? data : Buffer.isBuffer(data) ? data.toString("utf8") : "";
|
|
592
|
+
if (!text) return;
|
|
593
|
+
let frame;
|
|
594
|
+
try {
|
|
595
|
+
frame = JSON.parse(text);
|
|
596
|
+
} catch {
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
this.handleFrame(frame).catch((err) => {
|
|
600
|
+
this.opts.log(`troop-ws: frame handler error: ${err instanceof Error ? err.message : String(err)}`);
|
|
601
|
+
});
|
|
602
|
+
});
|
|
603
|
+
ws.on("close", (code, reason) => {
|
|
604
|
+
this.opts.log(`troop-ws: disconnected (${code}${reason.length > 0 ? ` ${reason.toString()}` : ""}) \u2014 reconnecting`);
|
|
605
|
+
if (this.heartbeatTimer) {
|
|
606
|
+
clearInterval(this.heartbeatTimer);
|
|
607
|
+
this.heartbeatTimer = null;
|
|
608
|
+
}
|
|
609
|
+
this.socket = null;
|
|
610
|
+
this.scheduleReconnect();
|
|
611
|
+
});
|
|
612
|
+
ws.on("error", (err) => {
|
|
613
|
+
this.opts.log(`troop-ws: socket error: ${err.message}`);
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
scheduleReconnect() {
|
|
617
|
+
if (this.stopped) return;
|
|
618
|
+
if (this.reconnectTimer) return;
|
|
619
|
+
const attempt = Math.min(this.reconnectAttempts, 5);
|
|
620
|
+
this.reconnectAttempts++;
|
|
621
|
+
const delay = Math.min(RECONNECT_BASE_MS * 2 ** attempt, RECONNECT_MAX_MS);
|
|
622
|
+
this.reconnectTimer = setTimeout(() => {
|
|
623
|
+
this.reconnectTimer = null;
|
|
624
|
+
void this.connect();
|
|
625
|
+
}, delay);
|
|
626
|
+
}
|
|
627
|
+
async handleFrame(frame) {
|
|
628
|
+
if (frame.type === "welcome") {
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
if (frame.type === "ack") {
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
if (frame.type === "config-update") {
|
|
635
|
+
await this.handleConfigUpdate(frame);
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
if (frame.type === "spawn-intent") {
|
|
639
|
+
await this.handleSpawnIntent(frame);
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
if (frame.type === "reload-bridge") {
|
|
643
|
+
await this.handleReloadBridge(frame);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
async handleConfigUpdate(frame) {
|
|
647
|
+
const local = frame.agent_email.split("+")[0];
|
|
648
|
+
if (!local) return;
|
|
649
|
+
const dash = local.lastIndexOf("-");
|
|
650
|
+
const name = dash > 0 ? local.slice(0, dash) : local;
|
|
651
|
+
this.opts.log(`troop-ws: config-update for ${name} \u2014 running sync`);
|
|
652
|
+
await this.runApes(["run", "--as", name, "--wait", "--", "apes", "agents", "sync"], `config-update sync ${name}`);
|
|
653
|
+
}
|
|
654
|
+
async handleSpawnIntent(frame) {
|
|
655
|
+
this.opts.log(`troop-ws: spawn-intent ${frame.name} (intent ${frame.intent_id})`);
|
|
656
|
+
const args = ["agents", "spawn", frame.name];
|
|
657
|
+
if (frame.bridge?.key) args.push("--bridge-key", frame.bridge.key);
|
|
658
|
+
if (frame.bridge?.base_url) args.push("--bridge-base-url", frame.bridge.base_url);
|
|
659
|
+
if (frame.bridge?.model) args.push("--bridge-model", frame.bridge.model);
|
|
660
|
+
try {
|
|
661
|
+
const { stdout } = await runWithCapture(this.opts.apesBin, args);
|
|
662
|
+
const match = stdout.match(/Registered as\s+(\S+@\S+)/);
|
|
663
|
+
const agentEmail = match?.[1];
|
|
664
|
+
this.send({ type: "spawn-result", intent_id: frame.intent_id, ok: true, agent_email: agentEmail });
|
|
665
|
+
} catch (err) {
|
|
666
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
667
|
+
this.send({ type: "spawn-result", intent_id: frame.intent_id, ok: false, error });
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
async handleReloadBridge(frame) {
|
|
671
|
+
this.opts.log(`troop-ws: reload-bridge ${frame.name}`);
|
|
672
|
+
await this.runApes(
|
|
673
|
+
["run", "--as", frame.name, "--wait", "--", "pm2", "reload", `openape-bridge-${frame.name}`, "--update-env"],
|
|
674
|
+
`reload-bridge ${frame.name}`
|
|
675
|
+
);
|
|
676
|
+
}
|
|
677
|
+
async runApes(args, label) {
|
|
678
|
+
try {
|
|
679
|
+
await runWithCapture(this.opts.apesBin, args);
|
|
680
|
+
} catch (err) {
|
|
681
|
+
this.opts.log(`troop-ws: ${label} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
send(frame) {
|
|
685
|
+
const ws = this.socket;
|
|
686
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
687
|
+
try {
|
|
688
|
+
ws.send(JSON.stringify(frame));
|
|
689
|
+
} catch (err) {
|
|
690
|
+
this.opts.log(`troop-ws: send failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
};
|
|
694
|
+
function runWithCapture(bin, args) {
|
|
695
|
+
return new Promise((resolve, reject) => {
|
|
696
|
+
execFile3(bin, args, { maxBuffer: 4 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
697
|
+
if (err) {
|
|
698
|
+
const msg = stderr.toString() || err.message;
|
|
699
|
+
reject(new Error(msg.split("\n").filter(Boolean).slice(-3).join(" / ")));
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
resolve({ stdout: stdout.toString(), stderr: stderr.toString() });
|
|
703
|
+
});
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
function readHostId() {
|
|
707
|
+
try {
|
|
708
|
+
if (process.platform === "darwin") {
|
|
709
|
+
const out = execFileSync("/usr/sbin/ioreg", ["-d2", "-c", "IOPlatformExpertDevice"], { encoding: "utf8" });
|
|
710
|
+
const match = out.match(/"IOPlatformUUID"\s*=\s*"([^"]+)"/);
|
|
711
|
+
if (match) return match[1];
|
|
712
|
+
}
|
|
713
|
+
} catch {
|
|
714
|
+
}
|
|
715
|
+
return hashBasedHostId();
|
|
716
|
+
}
|
|
717
|
+
function hashBasedHostId() {
|
|
718
|
+
const nics = networkInterfaces();
|
|
719
|
+
const macs = [];
|
|
720
|
+
for (const list of Object.values(nics)) {
|
|
721
|
+
if (!list) continue;
|
|
722
|
+
for (const nic of list) {
|
|
723
|
+
if (!nic.mac || nic.mac === "00:00:00:00:00:00" || nic.internal) continue;
|
|
724
|
+
macs.push(nic.mac);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
const seed = `${hostname()}|${macs.toSorted().join(",")}`;
|
|
728
|
+
return createHash("sha256").update(seed).digest("hex").slice(0, 32);
|
|
729
|
+
}
|
|
730
|
+
function readNestVersion() {
|
|
731
|
+
try {
|
|
732
|
+
const root = new URL("../../package.json", import.meta.url);
|
|
733
|
+
const pkg = JSON.parse(readFileSync3(root, "utf8"));
|
|
734
|
+
return typeof pkg.version === "string" ? pkg.version : "unknown";
|
|
735
|
+
} catch {
|
|
736
|
+
return "unknown";
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
217
740
|
// src/index.ts
|
|
218
|
-
var APES_BIN =
|
|
741
|
+
var APES_BIN = process4.env.OPENAPE_APES_BIN ?? "apes";
|
|
219
742
|
var RECONCILE_DEBOUNCE_MS = 1e3;
|
|
220
743
|
function log(line) {
|
|
221
|
-
|
|
744
|
+
process4.stderr.write(`${(/* @__PURE__ */ new Date()).toISOString()} ${line}
|
|
222
745
|
`);
|
|
223
746
|
}
|
|
224
747
|
var supervisor = new Pm2Supervisor({ apesBin: APES_BIN, log });
|
|
225
748
|
var troopSync = new TroopSync({ apesBin: APES_BIN, log });
|
|
749
|
+
var troopWs = new TroopWs({ apesBin: APES_BIN, log, version: readNestVersion() });
|
|
226
750
|
async function reconcile() {
|
|
227
751
|
try {
|
|
228
752
|
await supervisor.reconcile(listAgents());
|
|
@@ -233,6 +757,7 @@ async function reconcile() {
|
|
|
233
757
|
}
|
|
234
758
|
void reconcile();
|
|
235
759
|
troopSync.start();
|
|
760
|
+
troopWs.start();
|
|
236
761
|
var reconcileTimer;
|
|
237
762
|
try {
|
|
238
763
|
watch(REGISTRY_PATH, () => {
|
|
@@ -248,15 +773,17 @@ try {
|
|
|
248
773
|
void reconcile();
|
|
249
774
|
}, 5e3).unref();
|
|
250
775
|
}
|
|
251
|
-
|
|
776
|
+
process4.on("SIGTERM", () => {
|
|
252
777
|
log("nest: SIGTERM \u2014 stopping");
|
|
253
778
|
troopSync.stop();
|
|
779
|
+
troopWs.stop();
|
|
254
780
|
if (reconcileTimer) clearTimeout(reconcileTimer);
|
|
255
|
-
|
|
781
|
+
process4.exit(0);
|
|
256
782
|
});
|
|
257
|
-
|
|
783
|
+
process4.on("SIGINT", () => {
|
|
258
784
|
log("nest: SIGINT \u2014 stopping");
|
|
259
785
|
troopSync.stop();
|
|
786
|
+
troopWs.stop();
|
|
260
787
|
if (reconcileTimer) clearTimeout(reconcileTimer);
|
|
261
|
-
|
|
788
|
+
process4.exit(0);
|
|
262
789
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openape/nest",
|
|
3
|
-
"version": "2.0
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "OpenApe Nest — local control-plane daemon that supervises agent processes on this computer. Talks to troop SP for ownership state, spawns/destroys agents via DDISA always-grants, supervises chat-bridge children (replacing per-agent launchd plists).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -17,12 +17,14 @@
|
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
19
|
"ofetch": "^1.4.1",
|
|
20
|
+
"ws": "^8.18.0",
|
|
20
21
|
"@openape/cli-auth": "0.4.0",
|
|
21
22
|
"@openape/core": "0.16.0"
|
|
22
23
|
},
|
|
23
24
|
"devDependencies": {
|
|
24
25
|
"@antfu/eslint-config": "^7.6.1",
|
|
25
26
|
"@types/node": "^22.19.13",
|
|
27
|
+
"@types/ws": "^8.5.13",
|
|
26
28
|
"eslint": "^9.35.0",
|
|
27
29
|
"tsup": "^8.5.1",
|
|
28
30
|
"typescript": "^5.9.3",
|