athena-agent-launcher 0.2.0 → 0.3.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.
Files changed (2) hide show
  1. package/bin/launcher.mjs +125 -24
  2. package/package.json +1 -1
package/bin/launcher.mjs CHANGED
@@ -181,6 +181,83 @@ async function ensureHermesInstalled() {
181
181
  return await locateHermes();
182
182
  }
183
183
 
184
+ function sleep(ms) {
185
+ return new Promise((r) => setTimeout(r, ms));
186
+ }
187
+
188
+ // Open a URL in the user's default browser (best-effort, cross-platform).
189
+ function openBrowser(url) {
190
+ const cmd =
191
+ process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
192
+ const cmdArgs = process.platform === "win32" ? ["/c", "start", "", url] : [url];
193
+ try {
194
+ spawn(cmd, cmdArgs, { stdio: "ignore", detached: true }).unref();
195
+ } catch {
196
+ /* best-effort; the URL is also printed */
197
+ }
198
+ }
199
+
200
+ // SSO device pairing: open the browser to authorize, poll until a key bound to
201
+ // the verified identity is issued. Returns the ak_ key.
202
+ async function pairAndGetKey(baseUrl, agentId) {
203
+ const startRes = await fetch(`${baseUrl}/api/launcher/pair/start`, {
204
+ method: "POST",
205
+ headers: { "content-type": "application/json" },
206
+ body: JSON.stringify({ agent_id: agentId }),
207
+ });
208
+ if (!startRes.ok) {
209
+ throw new Error(`pair/start failed (${startRes.status}): ${await startRes.text()}`);
210
+ }
211
+ const { device_code, user_code, verification_url } = await startRes.json();
212
+ process.stderr.write(
213
+ `\n[athena-agent] Sign in with your Sonance account to authorize this device:\n` +
214
+ `\n ${verification_url}\n\n` +
215
+ ` (opening your browser… code: ${user_code})\n\n`,
216
+ );
217
+ openBrowser(verification_url);
218
+
219
+ const deadline = Date.now() + 10 * 60 * 1000;
220
+ let notified = false;
221
+ while (Date.now() < deadline) {
222
+ await sleep(3000);
223
+ let j;
224
+ try {
225
+ const pollRes = await fetch(`${baseUrl}/api/launcher/pair/poll`, {
226
+ method: "POST",
227
+ headers: { "content-type": "application/json" },
228
+ body: JSON.stringify({ device_code }),
229
+ });
230
+ j = await pollRes.json();
231
+ } catch {
232
+ continue; // transient network blip — keep polling
233
+ }
234
+ if (j.status === "approved" && j.api_key) {
235
+ process.stderr.write("[athena-agent] ✓ authorized.\n");
236
+ return j.api_key;
237
+ }
238
+ if (j.status === "denied") throw new Error(`Authorization denied: ${j.error || "no access"}`);
239
+ if (j.status === "expired") throw new Error("Pairing expired — run the command again.");
240
+ if (!notified && j.status === "pending") {
241
+ process.stderr.write("[athena-agent] waiting for you to approve in the browser…\n");
242
+ notified = true;
243
+ }
244
+ }
245
+ throw new Error("Pairing timed out — run the command again.");
246
+ }
247
+
248
+ // Persist a per-agent key (new format) without clobbering other agents' keys
249
+ // or the legacy flat `api_key`.
250
+ function saveAgentKey(agentId, apiKey, baseUrl) {
251
+ const credsPath = join(homedir(), ".athena", "credentials.json");
252
+ const creds = loadCredsFile();
253
+ if (!creds.base_url) creds.base_url = baseUrl;
254
+ creds.agent_keys = creds.agent_keys || {};
255
+ creds.agent_keys[agentId] = apiKey;
256
+ mkdirSync(dirname(credsPath), { recursive: true });
257
+ writeFileSync(credsPath, JSON.stringify(creds, null, 2), "utf8");
258
+ chmodSync(credsPath, 0o600);
259
+ }
260
+
184
261
  async function fetchManifest({ baseUrl, agentId, apiKey }) {
185
262
  const res = await fetch(`${baseUrl}/api/agents/${agentId}/launcher-manifest`, {
186
263
  headers: { authorization: `Bearer ${apiKey}` },
@@ -232,28 +309,36 @@ async function main() {
232
309
  }
233
310
 
234
311
  const agentId = args._[1];
235
- let creds = loadCredsFile();
236
- let baseUrl =
312
+ const creds = loadCredsFile();
313
+ const baseUrl =
237
314
  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).
315
+ // Key precedence: explicit flag/env > per-agent saved key > legacy flat key.
316
+ let apiKey =
317
+ args.apiKey ||
318
+ process.env.ATHENA_API_KEY ||
319
+ creds.agent_keys?.[agentId] ||
320
+ creds.api_key;
321
+
322
+ // No saved key for this agent → authorize this device via Sonance SSO. The
323
+ // browser flow verifies the user's Okta identity and (if they hold an access
324
+ // grant) hands back a key bound to their account, which we cache per-agent.
243
325
  if (!apiKey && !args.apiKey) {
244
326
  process.stderr.write(
245
- "[athena-agent] no credentials foundrunning first-time setup.\n",
327
+ `[athena-agent] no saved key for ${agentId} starting SSO authorization.\n`,
246
328
  );
247
- await runSetup();
248
- creds = loadCredsFile();
249
- baseUrl = creds.base_url || baseUrl;
250
- apiKey = creds.api_key;
329
+ try {
330
+ apiKey = await pairAndGetKey(baseUrl, agentId);
331
+ } catch (err) {
332
+ process.stderr.write(`[athena-agent] ${err.message}\n`);
333
+ process.exit(1);
334
+ }
335
+ saveAgentKey(agentId, apiKey, baseUrl);
251
336
  }
252
337
 
253
338
  if (!apiKey) {
254
339
  process.stderr.write(
255
340
  "Error: no API key. Pass --api-key ak_..., set $ATHENA_API_KEY, " +
256
- "or write {\"api_key\": \"ak_...\"} to ~/.athena/credentials.json\n",
341
+ "or run without flags to authorize via Sonance SSO.\n",
257
342
  );
258
343
  process.exit(2);
259
344
  }
@@ -262,17 +347,33 @@ async function main() {
262
347
  try {
263
348
  manifest = await fetchManifest({ baseUrl, agentId, apiKey });
264
349
  } 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);
350
+ // A saved key that's been revoked server-side returns 401re-authorize
351
+ // via SSO once and retry, unless the key was supplied explicitly.
352
+ if (/\(401\)/.test(err.message) && !args.apiKey && !process.env.ATHENA_API_KEY) {
353
+ process.stderr.write(
354
+ "[athena-agent] saved key was rejected (revoked?) — re-authorizing via SSO.\n",
355
+ );
356
+ try {
357
+ apiKey = await pairAndGetKey(baseUrl, agentId);
358
+ saveAgentKey(agentId, apiKey, baseUrl);
359
+ manifest = await fetchManifest({ baseUrl, agentId, apiKey });
360
+ } catch (err2) {
361
+ process.stderr.write(`[athena-agent] ${err2.message}\n`);
362
+ process.exit(1);
363
+ }
364
+ } else {
365
+ // `fetch failed` from undici is opaque on its own — the real cause
366
+ // (ENOTFOUND, ECONNREFUSED, TLS, etc.) lives on err.cause.
367
+ const cause = err?.cause
368
+ ? ` (${err.cause.code || err.cause.message || err.cause})`
369
+ : "";
370
+ process.stderr.write(
371
+ `[athena-agent] ${err.message}${cause}\n` +
372
+ ` base URL: ${baseUrl}\n` +
373
+ ` agent: ${agentId}\n`,
374
+ );
375
+ process.exit(1);
376
+ }
276
377
  }
277
378
 
278
379
  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.0",
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": {