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.
- package/bin/launcher.mjs +194 -42
- 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
|
|
143
|
-
//
|
|
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
|
|
146
|
-
const
|
|
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
|
-
|
|
151
|
-
|
|
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
|
-
|
|
154
|
-
|
|
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
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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(
|
|
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
|
-
|
|
236
|
-
|
|
363
|
+
const creds = loadCredsFile();
|
|
364
|
+
const baseUrl =
|
|
237
365
|
args.baseUrl || process.env.ATHENA_BASE_URL || creds.base_url || DEFAULT_BASE_URL;
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
378
|
+
`[athena-agent] no saved key for ${agentId} — starting SSO authorization.\n`,
|
|
246
379
|
);
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
|
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
|
-
//
|
|
266
|
-
//
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
401
|
+
// A saved key that's been revoked server-side returns 401 — re-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.
|
|
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": {
|