athena-agent-launcher 0.2.0 → 0.3.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.
Files changed (2) hide show
  1. package/bin/launcher.mjs +194 -42
  2. package/package.json +1 -1
package/bin/launcher.mjs CHANGED
@@ -139,48 +139,176 @@ async function runSetup() {
139
139
  process.stdout.write(" Run an agent with: athena-agent run <agent-id>\n\n");
140
140
  }
141
141
 
142
- // Return the absolute path to `hermes` if it's findable on PATH, in
143
- // ~/.local/bin, or in a few other common locations. Returns null if not found.
142
+ // Return the absolute path to `hermes` if it's findable on PATH or in a few
143
+ // common install locations. Cross-platform (uses `where` on Windows, `which`
144
+ // elsewhere) and never throws if the finder binary is missing. Returns null if
145
+ // not found.
144
146
  async function locateHermes() {
145
- const userLocalBin = join(homedir(), ".local", "bin", "hermes");
146
- const candidates = [userLocalBin, "/usr/local/bin/hermes", "/opt/homebrew/bin/hermes"];
147
+ const isWin = process.platform === "win32";
148
+ const home = homedir();
149
+ const candidates = isWin
150
+ ? [
151
+ join(home, ".local", "bin", "hermes.exe"),
152
+ join(home, ".local", "bin", "hermes.cmd"),
153
+ join(home, ".local", "bin", "hermes"),
154
+ ]
155
+ : [join(home, ".local", "bin", "hermes"), "/usr/local/bin/hermes", "/opt/homebrew/bin/hermes"];
147
156
  for (const p of candidates) {
148
157
  if (existsSync(p)) return p;
149
158
  }
150
- const which = await new Promise((resolve) => {
151
- const c = spawn("which", ["hermes"], { stdio: ["ignore", "pipe", "ignore"] });
159
+ // `where` (Windows) / `which` (POSIX). Wrapped so a missing finder binary
160
+ // surfaces as null instead of an unhandled 'error' event (the ENOENT crash).
161
+ const finder = isWin ? "where" : "which";
162
+ return await new Promise((resolve) => {
152
163
  let out = "";
153
- c.stdout.on("data", (d) => (out += d.toString()));
154
- c.on("exit", () => resolve(out.trim()));
164
+ let child;
165
+ try {
166
+ child = spawn(finder, ["hermes"], { stdio: ["ignore", "pipe", "ignore"] });
167
+ } catch {
168
+ return resolve(null);
169
+ }
170
+ child.on("error", () => resolve(null));
171
+ child.stdout.on("data", (d) => (out += d.toString()));
172
+ child.on("exit", () => {
173
+ const first = out
174
+ .split(/\r?\n/)
175
+ .map((s) => s.trim())
176
+ .filter(Boolean)[0];
177
+ resolve(first || null);
178
+ });
155
179
  });
156
- return which || null;
157
180
  }
158
181
 
159
182
  async function ensureHermesInstalled() {
160
183
  const found = await locateHermes();
161
184
  if (found) return found;
162
185
 
186
+ const isWin = process.platform === "win32";
163
187
  process.stderr.write(
164
- "[athena-agent] Hermes is not installed. Installing silently (this is a one-time setup)…\n",
188
+ "[athena-agent] Hermes is not installed. Installing (this is a one-time setup)…\n",
165
189
  );
166
- // `bash -s -- --skip-setup` passes the flag to the install script and
167
- // suppresses its post-install wizard, so the user only ever sees Athena's UX.
168
190
  const code = await new Promise((resolve) => {
169
- const c = spawn(
170
- "bash",
171
- ["-c", "curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash -s -- --skip-setup"],
172
- { stdio: "inherit" },
173
- );
191
+ let c;
192
+ try {
193
+ c = isWin
194
+ ? // Native Windows installer (PowerShell). Hermes' CLI + ACP run
195
+ // natively; only its own dashboard pane needs WSL2, which we don't use.
196
+ spawn(
197
+ "powershell",
198
+ [
199
+ "-NoProfile",
200
+ "-ExecutionPolicy",
201
+ "Bypass",
202
+ "-Command",
203
+ "iex (irm https://hermes-agent.nousresearch.com/install.ps1)",
204
+ ],
205
+ { stdio: "inherit" },
206
+ )
207
+ : // `bash -s -- --skip-setup` suppresses the installer's wizard so the
208
+ // user only ever sees Athena's UX.
209
+ spawn(
210
+ "bash",
211
+ [
212
+ "-c",
213
+ "curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash -s -- --skip-setup",
214
+ ],
215
+ { stdio: "inherit" },
216
+ );
217
+ } catch {
218
+ return resolve(1);
219
+ }
220
+ c.on("error", () => resolve(1));
174
221
  c.on("exit", (code) => resolve(code ?? 1));
175
222
  });
176
223
  if (code !== 0) {
177
- process.stderr.write("[athena-agent] Hermes install failed. See output above.\n");
224
+ process.stderr.write(
225
+ "[athena-agent] Hermes install failed. On Windows you can also install it manually\n" +
226
+ " (PowerShell): iex (irm https://hermes-agent.nousresearch.com/install.ps1)\n" +
227
+ " then re-run this command. Or use WSL2 for the fully-supported Linux path.\n",
228
+ );
178
229
  return null;
179
230
  }
180
231
  process.stderr.write("[athena-agent] Hermes installed.\n");
181
232
  return await locateHermes();
182
233
  }
183
234
 
235
+ function sleep(ms) {
236
+ return new Promise((r) => setTimeout(r, ms));
237
+ }
238
+
239
+ // Open a URL in the user's default browser (best-effort, cross-platform).
240
+ function openBrowser(url) {
241
+ const cmd =
242
+ process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
243
+ const cmdArgs = process.platform === "win32" ? ["/c", "start", "", url] : [url];
244
+ try {
245
+ spawn(cmd, cmdArgs, { stdio: "ignore", detached: true }).unref();
246
+ } catch {
247
+ /* best-effort; the URL is also printed */
248
+ }
249
+ }
250
+
251
+ // SSO device pairing: open the browser to authorize, poll until a key bound to
252
+ // the verified identity is issued. Returns the ak_ key.
253
+ async function pairAndGetKey(baseUrl, agentId) {
254
+ const startRes = await fetch(`${baseUrl}/api/launcher/pair/start`, {
255
+ method: "POST",
256
+ headers: { "content-type": "application/json" },
257
+ body: JSON.stringify({ agent_id: agentId }),
258
+ });
259
+ if (!startRes.ok) {
260
+ throw new Error(`pair/start failed (${startRes.status}): ${await startRes.text()}`);
261
+ }
262
+ const { device_code, user_code, verification_url } = await startRes.json();
263
+ process.stderr.write(
264
+ `\n[athena-agent] Sign in with your Sonance account to authorize this device:\n` +
265
+ `\n ${verification_url}\n\n` +
266
+ ` (opening your browser… code: ${user_code})\n\n`,
267
+ );
268
+ openBrowser(verification_url);
269
+
270
+ const deadline = Date.now() + 10 * 60 * 1000;
271
+ let notified = false;
272
+ while (Date.now() < deadline) {
273
+ await sleep(3000);
274
+ let j;
275
+ try {
276
+ const pollRes = await fetch(`${baseUrl}/api/launcher/pair/poll`, {
277
+ method: "POST",
278
+ headers: { "content-type": "application/json" },
279
+ body: JSON.stringify({ device_code }),
280
+ });
281
+ j = await pollRes.json();
282
+ } catch {
283
+ continue; // transient network blip — keep polling
284
+ }
285
+ if (j.status === "approved" && j.api_key) {
286
+ process.stderr.write("[athena-agent] ✓ authorized.\n");
287
+ return j.api_key;
288
+ }
289
+ if (j.status === "denied") throw new Error(`Authorization denied: ${j.error || "no access"}`);
290
+ if (j.status === "expired") throw new Error("Pairing expired — run the command again.");
291
+ if (!notified && j.status === "pending") {
292
+ process.stderr.write("[athena-agent] waiting for you to approve in the browser…\n");
293
+ notified = true;
294
+ }
295
+ }
296
+ throw new Error("Pairing timed out — run the command again.");
297
+ }
298
+
299
+ // Persist a per-agent key (new format) without clobbering other agents' keys
300
+ // or the legacy flat `api_key`.
301
+ function saveAgentKey(agentId, apiKey, baseUrl) {
302
+ const credsPath = join(homedir(), ".athena", "credentials.json");
303
+ const creds = loadCredsFile();
304
+ if (!creds.base_url) creds.base_url = baseUrl;
305
+ creds.agent_keys = creds.agent_keys || {};
306
+ creds.agent_keys[agentId] = apiKey;
307
+ mkdirSync(dirname(credsPath), { recursive: true });
308
+ writeFileSync(credsPath, JSON.stringify(creds, null, 2), "utf8");
309
+ chmodSync(credsPath, 0o600);
310
+ }
311
+
184
312
  async function fetchManifest({ baseUrl, agentId, apiKey }) {
185
313
  const res = await fetch(`${baseUrl}/api/agents/${agentId}/launcher-manifest`, {
186
314
  headers: { authorization: `Bearer ${apiKey}` },
@@ -232,28 +360,36 @@ async function main() {
232
360
  }
233
361
 
234
362
  const agentId = args._[1];
235
- let creds = loadCredsFile();
236
- let baseUrl =
363
+ const creds = loadCredsFile();
364
+ const baseUrl =
237
365
  args.baseUrl || process.env.ATHENA_BASE_URL || creds.base_url || DEFAULT_BASE_URL;
238
- let apiKey = args.apiKey || process.env.ATHENA_API_KEY || creds.api_key;
239
-
240
- // First-run UX: if no credentials are available, drop straight into the
241
- // setup prompt instead of bailing with an error. Skip when --api-key was
242
- // passed explicitly (caller is scripting and supplied creds inline).
366
+ // Key precedence: explicit flag/env > per-agent saved key > legacy flat key.
367
+ let apiKey =
368
+ args.apiKey ||
369
+ process.env.ATHENA_API_KEY ||
370
+ creds.agent_keys?.[agentId] ||
371
+ creds.api_key;
372
+
373
+ // No saved key for this agent → authorize this device via Sonance SSO. The
374
+ // browser flow verifies the user's Okta identity and (if they hold an access
375
+ // grant) hands back a key bound to their account, which we cache per-agent.
243
376
  if (!apiKey && !args.apiKey) {
244
377
  process.stderr.write(
245
- "[athena-agent] no credentials foundrunning first-time setup.\n",
378
+ `[athena-agent] no saved key for ${agentId} starting SSO authorization.\n`,
246
379
  );
247
- await runSetup();
248
- creds = loadCredsFile();
249
- baseUrl = creds.base_url || baseUrl;
250
- apiKey = creds.api_key;
380
+ try {
381
+ apiKey = await pairAndGetKey(baseUrl, agentId);
382
+ } catch (err) {
383
+ process.stderr.write(`[athena-agent] ${err.message}\n`);
384
+ process.exit(1);
385
+ }
386
+ saveAgentKey(agentId, apiKey, baseUrl);
251
387
  }
252
388
 
253
389
  if (!apiKey) {
254
390
  process.stderr.write(
255
391
  "Error: no API key. Pass --api-key ak_..., set $ATHENA_API_KEY, " +
256
- "or write {\"api_key\": \"ak_...\"} to ~/.athena/credentials.json\n",
392
+ "or run without flags to authorize via Sonance SSO.\n",
257
393
  );
258
394
  process.exit(2);
259
395
  }
@@ -262,17 +398,33 @@ async function main() {
262
398
  try {
263
399
  manifest = await fetchManifest({ baseUrl, agentId, apiKey });
264
400
  } catch (err) {
265
- // `fetch failed` from undici is opaque on its ownthe real cause
266
- // (ENOTFOUND, ECONNREFUSED, TLS, etc.) lives on err.cause.
267
- const cause = err?.cause
268
- ? ` (${err.cause.code || err.cause.message || err.cause})`
269
- : "";
270
- process.stderr.write(
271
- `[athena-agent] ${err.message}${cause}\n` +
272
- ` base URL: ${baseUrl}\n` +
273
- ` agent: ${agentId}\n`,
274
- );
275
- process.exit(1);
401
+ // A saved key that's been revoked server-side returns 401re-authorize
402
+ // via SSO once and retry, unless the key was supplied explicitly.
403
+ if (/\(401\)/.test(err.message) && !args.apiKey && !process.env.ATHENA_API_KEY) {
404
+ process.stderr.write(
405
+ "[athena-agent] saved key was rejected (revoked?) — re-authorizing via SSO.\n",
406
+ );
407
+ try {
408
+ apiKey = await pairAndGetKey(baseUrl, agentId);
409
+ saveAgentKey(agentId, apiKey, baseUrl);
410
+ manifest = await fetchManifest({ baseUrl, agentId, apiKey });
411
+ } catch (err2) {
412
+ process.stderr.write(`[athena-agent] ${err2.message}\n`);
413
+ process.exit(1);
414
+ }
415
+ } else {
416
+ // `fetch failed` from undici is opaque on its own — the real cause
417
+ // (ENOTFOUND, ECONNREFUSED, TLS, etc.) lives on err.cause.
418
+ const cause = err?.cause
419
+ ? ` (${err.cause.code || err.cause.message || err.cause})`
420
+ : "";
421
+ process.stderr.write(
422
+ `[athena-agent] ${err.message}${cause}\n` +
423
+ ` base URL: ${baseUrl}\n` +
424
+ ` agent: ${agentId}\n`,
425
+ );
426
+ process.exit(1);
427
+ }
276
428
  }
277
429
 
278
430
  if (args.manifestOnly) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "athena-agent-launcher",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Run an Athena-configured agent locally. Resolves runtime + bindings from the Athena control plane and spawns the correct agent binary (Anthropic SDK, OpenClaw, or Hermes).",
5
5
  "type": "module",
6
6
  "bin": {