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