@openape/nest 2.3.1 → 2.3.3

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.
Files changed (2) hide show
  1. package/dist/index.mjs +91 -337
  2. package/package.json +2 -2
package/dist/index.mjs CHANGED
@@ -59,6 +59,24 @@ function ecosystemPath(agentName) {
59
59
  }
60
60
  function ecosystemContents(apesBin, agentName) {
61
61
  void apesBin;
62
+ const envForwards = [
63
+ "APE_CHAT_BRIDGE_MODEL",
64
+ "LITELLM_BASE_URL",
65
+ "LITELLM_API_KEY",
66
+ "APE_CHAT_BRIDGE_TOOLS",
67
+ "APE_CHAT_BRIDGE_MAX_STEPS",
68
+ "APE_CHAT_BRIDGE_SYSTEM_PROMPT",
69
+ // Chat backend selection (chat.openape.ai vs troop.openape.ai) —
70
+ // honoured by the bridge at startup. See ape-agent/src/bridge.ts.
71
+ "OPENAPE_BRIDGE_TARGET",
72
+ "APE_CHAT_ENDPOINT"
73
+ ];
74
+ const envLines = envForwards.filter((k) => process2.env[k] !== void 0).map((k) => ` ${k}: ${JSON.stringify(process2.env[k])},`).join("\n");
75
+ const envBlock = envLines ? `
76
+ env: {
77
+ ${envLines}
78
+ },
79
+ ` : "";
62
80
  return `// Auto-generated by Pm2Supervisor for agent '${agentName}'.
63
81
  // Edit at runtime via:
64
82
  // apes run --as ${agentName} -- pm2 reload ${pm2AppName(agentName)}
@@ -70,7 +88,7 @@ module.exports = {
70
88
  max_restarts: 10,
71
89
  min_uptime: '30s',
72
90
  restart_delay: 2000,
73
- merge_logs: true,
91
+ merge_logs: true,${envBlock}
74
92
  }],
75
93
  }
76
94
  `;
@@ -85,8 +103,12 @@ function startScriptContents(agentName) {
85
103
  # Auto-generated by Pm2Supervisor for agent '${agentName}'.
86
104
  set -e
87
105
  ME="$(whoami)"
88
- export HOME="$(dscl . -read "/Users/$ME" NFSHomeDirectory 2>/dev/null | awk '{print $2}')"
89
- test -n "$HOME" || { echo "no NFSHomeDirectory for $ME (agent ${agentName})" >&2; exit 1; }
106
+ if command -v getent >/dev/null 2>&1; then
107
+ export HOME="$(getent passwd "$ME" | cut -d: -f6)"
108
+ else
109
+ export HOME="$(dscl . -read "/Users/$ME" NFSHomeDirectory 2>/dev/null | awk '{print $2}')"
110
+ fi
111
+ test -n "$HOME" || { echo "no home dir for $ME (agent ${agentName})" >&2; exit 1; }
90
112
  export PM2_HOME="$HOME/.pm2"
91
113
  mkdir -p "$(dirname "${log2}")"
92
114
  exec pm2 startOrReload ${ecosystem} >> ${log2} 2>&1 < /dev/null
@@ -171,12 +193,15 @@ var Pm2Supervisor = class {
171
193
  * always-writable location.
172
194
  */
173
195
  async runAsAgent(agentName, args) {
196
+ const bin = process2.env.OPENAPE_BYPASS_APE_SHELL === "1" ? "sudo" : this.deps.apesBin;
197
+ const argv = process2.env.OPENAPE_BYPASS_APE_SHELL === "1" ? ["-n", "-H", "-u", agentName, "--", ...args] : ["run", "--as", agentName, "--wait", "--", ...args];
174
198
  try {
175
- return await execFileAsync(
176
- this.deps.apesBin,
177
- ["run", "--as", agentName, "--wait", "--", ...args],
178
- { maxBuffer: 1024 * 1024, env: process2.env, timeout: 6e4, cwd: "/tmp" }
179
- );
199
+ return await execFileAsync(bin, argv, {
200
+ maxBuffer: 1024 * 1024,
201
+ env: process2.env,
202
+ timeout: 6e4,
203
+ cwd: "/tmp"
204
+ });
180
205
  } catch (err) {
181
206
  const e = err;
182
207
  const detail = (e.stderr ?? "").trim().split("\n").slice(-3).join(" / ");
@@ -225,11 +250,10 @@ var TroopSync = class {
225
250
  }
226
251
  async syncOne(name) {
227
252
  try {
228
- await execFileAsync2(
229
- this.deps.apesBin,
230
- ["run", "--as", name, "--wait", "--", "apes", "agents", "sync"],
231
- { maxBuffer: 1024 * 1024, env: process3.env, timeout: 6e4 }
232
- );
253
+ const bypassApeShell = process3.env.OPENAPE_BYPASS_APE_SHELL === "1";
254
+ const cmd = bypassApeShell ? "/usr/bin/sudo" : this.deps.apesBin;
255
+ const argv = bypassApeShell ? ["-n", "-H", "-u", name, this.deps.apesBin, "agents", "sync"] : ["run", "--as", name, "--wait", "--", "apes", "agents", "sync"];
256
+ await execFileAsync2(cmd, argv, { maxBuffer: 1024 * 1024, env: process3.env, timeout: 6e4 });
233
257
  } catch (err) {
234
258
  this.deps.log(`troop-sync: ${name} failed: ${err instanceof Error ? err.message.split("\n")[0] : String(err)}`);
235
259
  }
@@ -237,309 +261,46 @@ var TroopSync = class {
237
261
  };
238
262
 
239
263
  // src/lib/troop-ws.ts
240
- import { execFile as execFile3, execFileSync, spawn } from "child_process";
241
- import { createHash } from "crypto";
264
+ import { execFile as execFile3, spawn } from "child_process";
242
265
  import { readFileSync as readFileSync3 } from "fs";
243
- import { hostname, networkInterfaces } from "os";
266
+ import { hostname } from "os";
267
+ import WebSocket from "ws";
244
268
 
245
- // ../../packages/cli-auth/dist/index.js
246
- import { ofetch } from "ofetch";
247
- import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync2, readdirSync, unlinkSync, writeFileSync as writeFileSync3 } from "fs";
269
+ // src/lib/nest-device.ts
270
+ import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
248
271
  import { homedir as homedir2 } from "os";
249
272
  import { join as join3 } from "path";
250
- import { ofetch as ofetch3 } from "ofetch";
251
- import { Buffer as Buffer2 } from "buffer";
252
- import { sign } from "crypto";
253
- import { existsSync as existsSync22, readFileSync as readFileSync22 } from "fs";
254
- import { homedir as homedir22 } from "os";
255
- import { join as join22 } from "path";
256
- import { ofetch as ofetch2 } from "ofetch";
257
- import { Buffer as Buffer3 } from "buffer";
258
- import { createPrivateKey } from "crypto";
259
- import { ofetch as ofetch4 } from "ofetch";
260
- function getConfigDir() {
261
- const override = process.env.OPENAPE_CLI_AUTH_HOME;
262
- if (override) return override;
263
- return join3(homedir2(), ".config", "apes");
264
- }
265
- function getAuthFile() {
266
- return join3(getConfigDir(), "auth.json");
267
- }
268
- function ensureConfigDir() {
269
- const dir = getConfigDir();
270
- if (!existsSync3(dir)) {
271
- mkdirSync3(dir, { recursive: true, mode: 448 });
272
- }
273
- }
274
- function loadIdpAuth() {
275
- const file = getAuthFile();
276
- if (!existsSync3(file)) return null;
277
- try {
278
- const raw = readFileSync2(file, "utf-8");
279
- if (!raw.trim()) return null;
280
- return JSON.parse(raw);
281
- } catch {
282
- return null;
283
- }
284
- }
285
- function saveIdpAuth(auth) {
286
- ensureConfigDir();
287
- const file = getAuthFile();
288
- let extra = {};
289
- if (existsSync3(file)) {
290
- try {
291
- const raw = readFileSync2(file, "utf-8");
292
- if (raw.trim()) {
293
- const prev = JSON.parse(raw);
294
- for (const key of Object.keys(prev)) {
295
- if (!(key in auth)) {
296
- extra[key] = prev[key];
297
- }
298
- }
299
- }
300
- } catch {
301
- extra = {};
302
- }
303
- }
304
- const merged = { ...extra, ...auth };
305
- writeFileSync3(file, JSON.stringify(merged, null, 2), { mode: 384 });
306
- }
307
- var AuthError = class extends Error {
308
- status;
309
- hint;
310
- constructor(status, message, hint) {
311
- super(hint ? `${message}
312
- ${hint}` : message);
313
- this.name = "AuthError";
314
- this.status = status;
315
- this.hint = hint;
316
- }
317
- };
318
- var NotLoggedInError = class extends AuthError {
319
- constructor(hint) {
320
- super(
321
- 401,
322
- "Not logged in",
323
- hint ?? "Run `apes login <email>` once on this device to authenticate against the OpenApe IdP."
324
- );
325
- this.name = "NotLoggedInError";
326
- }
327
- };
328
- var OPENSSH_MAGIC = "openssh-key-v1\0";
329
- function loadEd25519PrivateKey(pem) {
330
- if (pem.includes("BEGIN OPENSSH PRIVATE KEY")) {
331
- return parseOpenSSHEd25519(pem);
332
- }
333
- return createPrivateKey(pem);
334
- }
335
- function parseOpenSSHEd25519(pem) {
336
- const b64 = pem.replace(/-----BEGIN OPENSSH PRIVATE KEY-----/, "").replace(/-----END OPENSSH PRIVATE KEY-----/, "").replace(/\s/g, "");
337
- const buf = Buffer3.from(b64, "base64");
338
- let offset = 0;
339
- const magic = buf.subarray(0, OPENSSH_MAGIC.length).toString("ascii");
340
- if (magic !== OPENSSH_MAGIC) {
341
- throw new Error("Not an OpenSSH private key");
342
- }
343
- offset += OPENSSH_MAGIC.length;
344
- const cipherLen = buf.readUInt32BE(offset);
345
- offset += 4;
346
- const cipher = buf.subarray(offset, offset + cipherLen).toString();
347
- offset += cipherLen;
348
- if (cipher !== "none") {
349
- throw new Error(`Encrypted keys not supported (cipher: ${cipher}). Decrypt first with: ssh-keygen -p -f <key>`);
350
- }
351
- const kdfLen = buf.readUInt32BE(offset);
352
- offset += 4;
353
- offset += kdfLen;
354
- const kdfOptsLen = buf.readUInt32BE(offset);
355
- offset += 4;
356
- offset += kdfOptsLen;
357
- const numKeys = buf.readUInt32BE(offset);
358
- offset += 4;
359
- if (numKeys !== 1) {
360
- throw new Error(`Expected 1 key, got ${numKeys}`);
361
- }
362
- const pubSectionLen = buf.readUInt32BE(offset);
363
- offset += 4;
364
- offset += pubSectionLen;
365
- const privSectionLen = buf.readUInt32BE(offset);
366
- offset += 4;
367
- const privSection = buf.subarray(offset, offset + privSectionLen);
368
- let pOffset = 0;
369
- const check1 = privSection.readUInt32BE(pOffset);
370
- pOffset += 4;
371
- const check2 = privSection.readUInt32BE(pOffset);
372
- pOffset += 4;
373
- if (check1 !== check2) {
374
- throw new Error("Check integers mismatch \u2014 key may be corrupted or encrypted");
375
- }
376
- const keyTypeLen = privSection.readUInt32BE(pOffset);
377
- pOffset += 4;
378
- const keyType = privSection.subarray(pOffset, pOffset + keyTypeLen).toString();
379
- pOffset += keyTypeLen;
380
- if (keyType !== "ssh-ed25519") {
381
- throw new Error(`Expected ssh-ed25519, got ${keyType}`);
382
- }
383
- const pubKeyLen = privSection.readUInt32BE(pOffset);
384
- pOffset += 4;
385
- const pubKey = privSection.subarray(pOffset, pOffset + pubKeyLen);
386
- pOffset += pubKeyLen;
387
- const privKeyLen = privSection.readUInt32BE(pOffset);
388
- pOffset += 4;
389
- const privKeyData = privSection.subarray(pOffset, pOffset + privKeyLen);
390
- const seed = privKeyData.subarray(0, 32);
391
- return createPrivateKey({
392
- key: { kty: "OKP", crv: "Ed25519", d: seed.toString("base64url"), x: pubKey.toString("base64url") },
393
- format: "jwk"
394
- });
395
- }
396
- async function getEndpoints(idp) {
397
- let disco = {};
398
- try {
399
- disco = await ofetch2(`${idp}/.well-known/openid-configuration`);
400
- } catch {
401
- }
402
- return {
403
- challenge: disco.ddisa_agent_challenge_endpoint ?? `${idp}/api/agent/challenge`,
404
- authenticate: disco.ddisa_agent_authenticate_endpoint ?? `${idp}/api/agent/authenticate`
405
- };
406
- }
407
- function resolveKeyPath(p) {
408
- if (p.startsWith("~")) return join22(homedir22(), p.slice(1));
409
- return p;
410
- }
411
- function findSigningKey(auth) {
412
- const candidates = [];
413
- if (auth.key_path) candidates.push(resolveKeyPath(auth.key_path));
414
- candidates.push(join22(homedir22(), ".ssh", "id_ed25519"));
415
- for (const p of candidates) {
416
- if (existsSync22(p)) {
417
- try {
418
- return { keyPath: p, keyContent: readFileSync22(p, "utf-8") };
419
- } catch {
420
- }
421
- }
422
- }
423
- return null;
424
- }
425
- async function refreshAgentToken(auth, now = Math.floor(Date.now() / 1e3)) {
426
- const key = findSigningKey(auth);
427
- if (!key) return null;
428
- let privateKey;
429
- try {
430
- privateKey = loadEd25519PrivateKey(key.keyContent);
431
- } catch {
432
- return null;
433
- }
434
- let endpoints;
435
- try {
436
- endpoints = await getEndpoints(auth.idp);
437
- } catch {
438
- return null;
439
- }
440
- let challenge;
441
- try {
442
- const resp = await ofetch2(endpoints.challenge, {
443
- method: "POST",
444
- headers: { "Content-Type": "application/json" },
445
- body: { agent_id: auth.email }
446
- });
447
- challenge = resp.challenge;
448
- } catch {
449
- return null;
450
- }
451
- let signature;
452
- try {
453
- signature = sign(null, Buffer2.from(challenge), privateKey).toString("base64");
454
- } catch {
455
- return null;
456
- }
457
- let authResp;
273
+ import { ofetch } from "ofetch";
274
+ function resolveDevicePath() {
275
+ return process.env.OPENAPE_NEST_DEVICE_PATH ?? join3(homedir2(), "nest-device.json");
276
+ }
277
+ function readDeviceCreds() {
278
+ const envHost = process.env.OPENAPE_NEST_HOST_ID?.trim();
279
+ const envSecret = process.env.OPENAPE_NEST_DEVICE_SECRET?.trim();
280
+ if (envHost && envSecret) return { hostId: envHost, deviceSecret: envSecret };
281
+ const path = resolveDevicePath();
282
+ if (!existsSync3(path)) return null;
458
283
  try {
459
- authResp = await ofetch2(endpoints.authenticate, {
460
- method: "POST",
461
- headers: { "Content-Type": "application/json" },
462
- body: { agent_id: auth.email, challenge, signature }
463
- });
284
+ const parsed = JSON.parse(readFileSync2(path, "utf8"));
285
+ const hostId = typeof parsed.host_id === "string" ? parsed.host_id.trim() : "";
286
+ const deviceSecret = typeof parsed.device_secret === "string" ? parsed.device_secret : "";
287
+ if (!hostId || !deviceSecret) return null;
288
+ return { hostId, deviceSecret };
464
289
  } catch {
465
290
  return null;
466
291
  }
467
- return {
468
- ...auth,
469
- access_token: authResp.token,
470
- expires_at: now + (authResp.expires_in || 3600),
471
- key_path: auth.key_path ?? key.keyPath
472
- };
473
292
  }
474
- var EXPIRY_SKEW_SECONDS = 30;
475
- async function getTokenEndpoint(idp) {
476
- try {
477
- const disco = await ofetch3(`${idp}/.well-known/openid-configuration`);
478
- if (disco.token_endpoint) return disco.token_endpoint;
479
- } catch {
480
- }
481
- return `${idp}/token`;
293
+ function troopHttpUrl(troopWsUrl) {
294
+ return troopWsUrl.replace(/^wss:\/\//, "https://").replace(/^ws:\/\//, "http://");
482
295
  }
483
- async function ensureFreshIdpAuth(now = Math.floor(Date.now() / 1e3)) {
484
- const auth = loadIdpAuth();
485
- if (!auth) {
486
- throw new NotLoggedInError();
487
- }
488
- if (auth.expires_at > now + EXPIRY_SKEW_SECONDS) {
489
- return auth;
490
- }
491
- if (!auth.refresh_token) {
492
- const refreshed = await refreshAgentToken(auth, now);
493
- if (refreshed) {
494
- saveIdpAuth(refreshed);
495
- return refreshed;
496
- }
497
- throw new NotLoggedInError(
498
- `IdP token expired at ${new Date(auth.expires_at * 1e3).toISOString()} and no refresh_token is stored. Run \`apes login\` again.`
499
- );
500
- }
501
- const tokenEndpoint = await getTokenEndpoint(auth.idp);
502
- const body = new URLSearchParams({
503
- grant_type: "refresh_token",
504
- refresh_token: auth.refresh_token
505
- });
506
- let response;
507
- try {
508
- response = await ofetch3(tokenEndpoint, {
509
- method: "POST",
510
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
511
- body: body.toString()
512
- });
513
- } catch (err) {
514
- const status = err.status ?? err.statusCode ?? 0;
515
- if (status === 400 || status === 401) {
516
- saveIdpAuth({ ...auth, refresh_token: void 0 });
517
- throw new NotLoggedInError(
518
- `Refresh token rejected by ${auth.idp}. Run \`apes login\` again.`
519
- );
520
- }
521
- throw new AuthError(
522
- 0,
523
- `Network error refreshing IdP token at ${tokenEndpoint}`,
524
- `Underlying: ${err.message ?? err}`
525
- );
526
- }
527
- if (!response.access_token) {
528
- throw new AuthError(0, `IdP refresh response missing access_token (endpoint: ${tokenEndpoint})`);
529
- }
530
- const next = {
531
- ...auth,
532
- access_token: response.access_token,
533
- refresh_token: response.refresh_token ?? auth.refresh_token,
534
- expires_at: now + (response.expires_in ?? 3600)
535
- };
536
- saveIdpAuth(next);
537
- return next;
296
+ async function mintNestToken(troopWsUrl, creds) {
297
+ const res = await ofetch(
298
+ `${troopHttpUrl(troopWsUrl)}/api/nests/token`,
299
+ { method: "POST", body: { host_id: creds.hostId, device_secret: creds.deviceSecret } }
300
+ );
301
+ return { token: res.access_token, expiresAt: res.expires_at };
538
302
  }
539
303
 
540
- // src/lib/troop-ws.ts
541
- import WebSocket from "ws";
542
-
543
304
  // src/lib/secret-relay.ts
544
305
  var SECRETS_REL_DIR = ".config/openape/secrets.d";
545
306
  var ENV_RE = /^[A-Z][A-Z0-9_]*$/;
@@ -570,7 +331,6 @@ var TroopWs = class {
570
331
  constructor(opts) {
571
332
  this.opts = opts;
572
333
  this.troopUrl = (opts.troopUrl ?? process.env.OPENAPE_TROOP_WS_URL ?? "wss://troop.openape.ai").replace(/\/$/, "");
573
- this.hostId = readHostId();
574
334
  this.hostname = hostname();
575
335
  }
576
336
  opts;
@@ -580,8 +340,14 @@ var TroopWs = class {
580
340
  reconnectAttempts = 0;
581
341
  stopped = false;
582
342
  troopUrl;
583
- hostId;
343
+ // Set from the device creds on each connect (troop is authoritative for
344
+ // host_id now; the daemon no longer self-fingerprints).
345
+ hostId = "";
584
346
  hostname;
347
+ // Short-lived device token, held in memory only and re-minted before
348
+ // expiry. Never persisted — only the long-lived device_secret is on disk.
349
+ cachedToken = null;
350
+ cachedTokenExp = 0;
585
351
  start() {
586
352
  this.stopped = false;
587
353
  void this.connect();
@@ -600,18 +366,30 @@ var TroopWs = class {
600
366
  this.socket = null;
601
367
  }
602
368
  }
369
+ // Mint (or reuse) a short-lived device token. Cached in memory and
370
+ // re-minted ~1 min before expiry; never written to disk.
371
+ async mintToken(creds) {
372
+ const nowSec = Math.floor(Date.now() / 1e3);
373
+ if (this.cachedToken && this.cachedTokenExp - nowSec > 60) return this.cachedToken;
374
+ const { token, expiresAt } = await mintNestToken(this.troopUrl, creds);
375
+ this.cachedToken = token;
376
+ this.cachedTokenExp = expiresAt;
377
+ return token;
378
+ }
603
379
  async connect() {
604
380
  if (this.stopped) return;
381
+ const creds = readDeviceCreds();
382
+ if (!creds) {
383
+ this.opts.log("troop-ws: no device creds (set OPENAPE_NEST_HOST_ID + OPENAPE_NEST_DEVICE_SECRET, or ~/nest-device.json) \u2014 not connecting, will retry");
384
+ this.scheduleReconnect();
385
+ return;
386
+ }
387
+ this.hostId = creds.hostId;
605
388
  let token;
606
389
  try {
607
- const auth = await ensureFreshIdpAuth();
608
- token = auth.access_token;
390
+ token = await this.mintToken(creds);
609
391
  } catch (err) {
610
- if (err instanceof NotLoggedInError) {
611
- this.opts.log("troop-ws: not logged in (apes login) \u2014 skip connect, will retry");
612
- } else {
613
- this.opts.log(`troop-ws: auth refresh failed: ${err instanceof Error ? err.message : String(err)}`);
614
- }
392
+ this.opts.log(`troop-ws: token mint failed (nest revoked or bad secret?): ${err instanceof Error ? err.message : String(err)}`);
615
393
  this.scheduleReconnect();
616
394
  return;
617
395
  }
@@ -825,30 +603,6 @@ function runWithInput(bin, args, input) {
825
603
  child.stdin?.end(input);
826
604
  });
827
605
  }
828
- function readHostId() {
829
- try {
830
- if (process.platform === "darwin") {
831
- const out = execFileSync("/usr/sbin/ioreg", ["-d2", "-c", "IOPlatformExpertDevice"], { encoding: "utf8" });
832
- const match = out.match(/"IOPlatformUUID"\s*=\s*"([^"]+)"/);
833
- if (match) return match[1];
834
- }
835
- } catch {
836
- }
837
- return hashBasedHostId();
838
- }
839
- function hashBasedHostId() {
840
- const nics = networkInterfaces();
841
- const macs = [];
842
- for (const list of Object.values(nics)) {
843
- if (!list) continue;
844
- for (const nic of list) {
845
- if (!nic.mac || nic.mac === "00:00:00:00:00:00" || nic.internal) continue;
846
- macs.push(nic.mac);
847
- }
848
- }
849
- const seed = `${hostname()}|${macs.toSorted().join(",")}`;
850
- return createHash("sha256").update(seed).digest("hex").slice(0, 32);
851
- }
852
606
  function readNestVersion() {
853
607
  try {
854
608
  const root = new URL("../../package.json", import.meta.url);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openape/nest",
3
- "version": "2.3.1",
3
+ "version": "2.3.3",
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",
@@ -18,7 +18,7 @@
18
18
  "dependencies": {
19
19
  "ofetch": "^1.4.1",
20
20
  "ws": "^8.18.0",
21
- "@openape/cli-auth": "0.4.1",
21
+ "@openape/cli-auth": "0.5.0",
22
22
  "@openape/core": "0.17.0"
23
23
  },
24
24
  "devDependencies": {