athena-agent-launcher 0.3.2 → 0.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.
package/bin/launcher.mjs CHANGED
@@ -15,6 +15,7 @@ import { homedir } from "node:os";
15
15
  import { join, dirname } from "node:path";
16
16
  import readline from "node:readline";
17
17
  import { startWebChat } from "../lib/webchat.mjs";
18
+ import { startHostedGateway } from "../lib/hosted-gateway.mjs";
18
19
 
19
20
  const DEFAULT_BASE_URL = "https://athena-v2-pi.vercel.app";
20
21
 
@@ -472,8 +473,13 @@ async function main() {
472
473
  const hermesHome = join(homedir(), ".athena", "hermes", agentId);
473
474
  mkdirSync(hermesHome, { recursive: true });
474
475
 
476
+ // Tell the config route which launch mode this is. Web chat (ACP) is an
477
+ // attended/interactive session, so it identifies itself differently from an
478
+ // unattended TUI/headless loop — this lets Cortex's protection layer treat
479
+ // human-driven web-chat tool calls distinctly. See hermes-config.ts.
480
+ const launchMode = manifest.web_chat?.enabled ? "web_chat" : "cli";
475
481
  const cfgRes = await fetch(
476
- `${baseUrl}/api/agents/${agentId}/hermes-config`,
482
+ `${baseUrl}/api/agents/${agentId}/hermes-config?mode=${encodeURIComponent(launchMode)}`,
477
483
  { headers: { authorization: `Bearer ${apiKey}` } },
478
484
  );
479
485
  if (!cfgRes.ok) {
@@ -539,6 +545,37 @@ async function main() {
539
545
  ANTHROPIC_TOKEN: "",
540
546
  };
541
547
 
548
+ // Hosted gateway (DAE-6): unattended container mode. Run Hermes headless in
549
+ // ACP mode behind an internet-facing HTTP API (/health, /message) that the
550
+ // Athena Teams turn route calls — NOT the interactive TUI (which exits with
551
+ // "Input is not a terminal (fd=0)" in a container and crash-loops).
552
+ // Selected by manifest.hosted_gateway.enabled, or by the container setting
553
+ // ATHENA_HOSTED_GATEWAY_ENABLED=1 (so the ACA can opt in via env even before
554
+ // the manifest route carries the flag). Takes precedence over web chat.
555
+ const hostedGatewayEnabled =
556
+ manifest.hosted_gateway?.enabled === true ||
557
+ /^(1|true|yes)$/i.test(env.ATHENA_HOSTED_GATEWAY_ENABLED || "");
558
+ if (hostedGatewayEnabled) {
559
+ const gw = await startHostedGateway({
560
+ hermesBin: cmd,
561
+ env,
562
+ hermesHome,
563
+ hostedGateway: manifest.hosted_gateway || {},
564
+ });
565
+ process.stderr.write(
566
+ `\n[athena-agent] 🛰 Hosted gateway listening on ${gw.url}\n` +
567
+ `[athena-agent] Hermes is running headless (ACP). POST /message to drive turns.\n` +
568
+ `[athena-agent] Press Ctrl+C to stop.\n\n`,
569
+ );
570
+ const shutdown = () => {
571
+ gw.close();
572
+ process.exit(0);
573
+ };
574
+ process.on("SIGINT", shutdown);
575
+ process.on("SIGTERM", shutdown);
576
+ return;
577
+ }
578
+
542
579
  // Web Chat gateway: run Hermes headless in ACP mode behind a local browser
543
580
  // chat instead of the interactive terminal TUI. startWebChat owns the
544
581
  // hermes child + local server and keeps the process alive.
@@ -0,0 +1,235 @@
1
+ // Shared ACP client for Hermes runtimes.
2
+ //
3
+ // Speaks the Agent Client Protocol (newline-delimited JSON-RPC 2.0) to a
4
+ // `hermes acp --accept-hooks` child over stdio. Extracted verbatim from
5
+ // webchat.mjs (DAE-6) so both the attended web-chat bridge and the unattended
6
+ // hosted gateway share one battle-tested transport.
7
+ //
8
+ // client ──ACP JSON-RPC/stdio──► hermes acp
9
+ //
10
+ // Two usage shapes are supported:
11
+ // 1. Single-session (web chat): `await acp.init(cwd)` then `acp.prompt(text)`.
12
+ // Backwards-compatible with the original webchat.mjs API.
13
+ // 2. Multi-session (hosted gateway): `await acp.initialize()` once, then
14
+ // `acp.newSession(cwd)` per logical session and `acp.prompt(text, sid)`.
15
+ //
16
+ // Every emitted event carries `sessionId` so a multi-session caller can route
17
+ // chunks to the right turn. Single-session callers can ignore it.
18
+
19
+ import { spawn } from "node:child_process";
20
+
21
+ export class AcpClient {
22
+ constructor(hermesBin, env) {
23
+ this.child = spawn(hermesBin, ["acp", "--accept-hooks"], {
24
+ stdio: ["pipe", "pipe", "inherit"],
25
+ env,
26
+ });
27
+ this.nextId = 1;
28
+ this.pending = new Map(); // jsonrpc id -> {resolve, reject}
29
+ this.permissionWaiters = new Map(); // requestId -> resolve(optionId|null)
30
+ this.sessionId = null; // single-session convenience slot
31
+ this.buf = "";
32
+ // Event sink set by the server: (event) => void. Events include sessionId.
33
+ this.onEvent = () => {};
34
+ // Permission policy: "auto" | "ask". Unattended hosts MUST use "auto" —
35
+ // there is no human at the ACP layer to answer a permission prompt; the
36
+ // consent gate lives upstream (Teams turn route + hosted-start gate).
37
+ this.toolApproval = "auto";
38
+
39
+ this.child.stdout.on("data", (c) => this._onData(c));
40
+ this.child.on("exit", (code) =>
41
+ this.onEvent({ type: "error", message: `Hermes exited (code ${code})` }),
42
+ );
43
+ }
44
+
45
+ _send(obj) {
46
+ this.child.stdin.write(JSON.stringify(obj) + "\n");
47
+ }
48
+
49
+ _request(method, params) {
50
+ const id = this.nextId++;
51
+ this._send({ jsonrpc: "2.0", id, method, params });
52
+ return new Promise((resolve, reject) => this.pending.set(id, { resolve, reject }));
53
+ }
54
+
55
+ _onData(chunk) {
56
+ this.buf += chunk.toString("utf8");
57
+ let nl;
58
+ while ((nl = this.buf.indexOf("\n")) >= 0) {
59
+ const line = this.buf.slice(0, nl).trim();
60
+ this.buf = this.buf.slice(nl + 1);
61
+ if (!line) continue;
62
+ let msg;
63
+ try {
64
+ msg = JSON.parse(line);
65
+ } catch {
66
+ continue; // non-JSON banner line on stdout — ignore
67
+ }
68
+ this._handle(msg);
69
+ }
70
+ }
71
+
72
+ _handle(msg) {
73
+ // Response to one of our requests.
74
+ if (msg.id !== undefined && (msg.result !== undefined || msg.error !== undefined)) {
75
+ const p = this.pending.get(msg.id);
76
+ if (p) {
77
+ this.pending.delete(msg.id);
78
+ if (msg.error) p.reject(new Error(JSON.stringify(msg.error)));
79
+ else p.resolve(msg.result);
80
+ }
81
+ return;
82
+ }
83
+ // Request from the agent that we must answer.
84
+ if (msg.id !== undefined && msg.method) {
85
+ this._onAgentRequest(msg);
86
+ return;
87
+ }
88
+ // Notification.
89
+ if (msg.method === "session/update") {
90
+ this._onUpdate(msg.params?.update, msg.params?.sessionId);
91
+ }
92
+ }
93
+
94
+ _onUpdate(u, sessionId) {
95
+ if (!u) return;
96
+ switch (u.sessionUpdate) {
97
+ case "agent_message_chunk":
98
+ if (u.content?.text)
99
+ this.onEvent({ type: "assistant_chunk", text: u.content.text, sessionId });
100
+ break;
101
+ case "tool_call":
102
+ this.onEvent({
103
+ type: "tool",
104
+ id: u.toolCallId,
105
+ title: u.title || u.toolCallId,
106
+ status: u.status || "pending",
107
+ sessionId,
108
+ });
109
+ break;
110
+ case "tool_call_update": {
111
+ const { isError, detail } = extractToolOutcome(u);
112
+ // A blocked/failed MCP call often arrives as status "completed" carrying
113
+ // an error *result* (e.g. the Cortex protection layer returns a JSON
114
+ // error body, not an ACP failure). Forward the extracted error so the
115
+ // UI doesn't render a blocked push as a green checkmark.
116
+ this.onEvent({
117
+ type: "tool_update",
118
+ id: u.toolCallId,
119
+ status: u.status || (isError ? "failed" : "completed"),
120
+ isError,
121
+ detail: isError ? detail : "",
122
+ sessionId,
123
+ });
124
+ break;
125
+ }
126
+ default:
127
+ break; // agent_thought_chunk, plan, etc. — ignored for now
128
+ }
129
+ }
130
+
131
+ async _onAgentRequest(msg) {
132
+ if (msg.method === "session/request_permission") {
133
+ const opts = msg.params?.options || [];
134
+ const title = msg.params?.toolCall?.title || msg.params?.toolCall?.toolCallId || "tool";
135
+ if (this.toolApproval === "auto") {
136
+ const allow = opts.find((o) => /allow/i.test(o.kind || o.optionId || "")) || opts[0];
137
+ this._send({
138
+ jsonrpc: "2.0",
139
+ id: msg.id,
140
+ result: { outcome: allow ? { outcome: "selected", optionId: allow.optionId } : { outcome: "cancelled" } },
141
+ });
142
+ return;
143
+ }
144
+ // ask: surface to the browser and wait for a decision.
145
+ const requestId = String(msg.id);
146
+ this.onEvent({ type: "permission", requestId, title, options: opts, sessionId: msg.params?.sessionId });
147
+ const optionId = await new Promise((resolve) => this.permissionWaiters.set(requestId, resolve));
148
+ this._send({
149
+ jsonrpc: "2.0",
150
+ id: msg.id,
151
+ result: { outcome: optionId ? { outcome: "selected", optionId } : { outcome: "cancelled" } },
152
+ });
153
+ return;
154
+ }
155
+ // fs/* and terminal/* — we declared no such capabilities; decline cleanly.
156
+ this._send({
157
+ jsonrpc: "2.0",
158
+ id: msg.id,
159
+ error: { code: -32601, message: `client does not implement ${msg.method}` },
160
+ });
161
+ }
162
+
163
+ resolvePermission(requestId, optionId) {
164
+ const w = this.permissionWaiters.get(requestId);
165
+ if (w) {
166
+ this.permissionWaiters.delete(requestId);
167
+ w(optionId || null);
168
+ }
169
+ }
170
+
171
+ /** Send the ACP `initialize` handshake. Call once per child. */
172
+ async initialize() {
173
+ await this._request("initialize", {
174
+ protocolVersion: 1,
175
+ clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false },
176
+ clientInfo: { name: "athena-acp-client", title: "Athena ACP Client", version: "0.1.0" },
177
+ });
178
+ }
179
+
180
+ /** Create a new ACP session; returns its sessionId. */
181
+ async newSession(cwd) {
182
+ const ns = await this._request("session/new", { cwd, mcpServers: [] });
183
+ return ns?.sessionId;
184
+ }
185
+
186
+ /** Single-session convenience: initialize + one session, stored on this.sessionId. */
187
+ async init(cwd) {
188
+ await this.initialize();
189
+ this.sessionId = await this.newSession(cwd);
190
+ return this.sessionId;
191
+ }
192
+
193
+ /** Drive a turn. `sessionId` defaults to the single-session slot. Resolves
194
+ * with the ACP session/prompt result ({ stopReason }) when the turn ends. */
195
+ async prompt(text, sessionId) {
196
+ return this._request("session/prompt", {
197
+ sessionId: sessionId ?? this.sessionId,
198
+ prompt: [{ type: "text", text }],
199
+ });
200
+ }
201
+
202
+ kill() {
203
+ try { this.child.kill("SIGTERM"); } catch {}
204
+ }
205
+ }
206
+
207
+ // Pull human-readable output and an error flag out of an ACP tool_call_update.
208
+ // ACP reports genuine failures as status "failed", but MCP tools that return an
209
+ // error *result* (isError, or a JSON {error|success:false} body) are reported
210
+ // "completed" — sniff those so blocked calls surface as failures.
211
+ export function extractToolOutcome(u) {
212
+ let text = "";
213
+ for (const part of Array.isArray(u.content) ? u.content : []) {
214
+ const c = part?.content || part;
215
+ if (c?.type === "text" && typeof c.text === "string") text += c.text;
216
+ }
217
+ let isError = u.status === "failed";
218
+ const raw = u.rawOutput;
219
+ if (raw && typeof raw === "object") {
220
+ if (raw.isError === true) isError = true;
221
+ if (!text && Array.isArray(raw.content)) {
222
+ for (const c of raw.content) if (c?.type === "text" && c.text) text += c.text;
223
+ }
224
+ if (!text && typeof raw.error === "string") text = raw.error;
225
+ }
226
+ if (!isError && text) {
227
+ try {
228
+ const j = JSON.parse(text);
229
+ if (j && (j.error || j.success === false)) isError = true;
230
+ } catch {
231
+ /* not JSON — treat as a normal text result */
232
+ }
233
+ }
234
+ return { isError, detail: text.slice(0, 2000) };
235
+ }
@@ -0,0 +1,349 @@
1
+ // Hosted gateway for Hermes agents (DAE-6).
2
+ //
3
+ // Unattended counterpart to webchat.mjs. Where web chat runs Hermes behind a
4
+ // LOCAL browser UI for an attended user, the hosted gateway runs Hermes inside
5
+ // a container (Azure Container App `agent003`) and exposes a small internet-
6
+ // facing HTTP API that the Athena control plane's Teams turn route calls.
7
+ //
8
+ // Athena /api/gateway/teams/turn ──HTTP /message──► hosted-gateway ──ACP──► hermes acp
9
+ //
10
+ // It does NOT spawn the interactive Hermes TUI (which exits with
11
+ // "Input is not a terminal (fd=0)" in a container and crash-loops). It drives
12
+ // `hermes acp --accept-hooks` via the shared AcpClient instead.
13
+ //
14
+ // Endpoints (see docs/design/teams-hermes-bridge.md for the full contract):
15
+ // GET /health → 200 {"ok":true} (no auth, no agent logic)
16
+ // POST /message {session_id, text} → 200 {"text": "..."}
17
+ //
18
+ // Auth on /message — the locked DAE-6 / THE-5 contract (D1):
19
+ // Authorization: Bearer $HERMES_GATEWAY_SECRET (timing-safe — M1)
20
+ // X-Hermes-Timestamp: <unix-ms> (≤60s skew — M2)
21
+ // X-Hermes-Signature: HMAC-SHA256(`${ts}.${rawBody}`, secret) hex (M2)
22
+ // X-Request-Id: <uuid> (Prometheus-minted; echoed in our logs — M7)
23
+ // Plus a nonce/replay cache (M2), a token-bucket rate limit and a hard
24
+ // max-concurrency (M6, default 1 for the pilot). If the secret env var is UNSET
25
+ // the endpoint refuses to bring up agent logic (M1). All error bodies are
26
+ // generic (no secret/body/upstream detail — M7). /health is unauthenticated by
27
+ // design (Azure liveness/readiness probes) and runs no agent logic.
28
+
29
+ import { createServer } from "node:http";
30
+ import { timingSafeEqual, createHmac, randomUUID } from "node:crypto";
31
+ import { AcpClient } from "./acp-client.mjs";
32
+
33
+ const DEFAULT_PORT = 8080;
34
+ const SESSION_IDLE_MS = 30 * 60 * 1000; // evict ACP sessions idle > 30 min
35
+ const SESSION_SWEEP_MS = 5 * 60 * 1000; // sweep cadence
36
+ const TURN_TIMEOUT_MS = 120_000; // hard cap on a single turn
37
+ const SKEW_MS = 60_000; // M2: max clock skew for the HMAC timestamp
38
+ const REPLAY_TTL_MS = 120_000; // M2: nonce cache window (> skew)
39
+ const DEFAULT_RATE_PER_MIN = 30; // M6: token-bucket refill
40
+ const DEFAULT_MAX_CONCURRENCY = 1; // M6: in-flight cap (pilot = 1)
41
+
42
+ function sendJson(res, status, obj) {
43
+ const body = JSON.stringify(obj);
44
+ res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
45
+ res.end(body);
46
+ }
47
+
48
+ /** Read the raw request body as a string (needed verbatim for HMAC). */
49
+ function readRawBody(req) {
50
+ return new Promise((resolve) => {
51
+ let data = "";
52
+ req.on("data", (c) => (data += c));
53
+ req.on("end", () => resolve(data));
54
+ });
55
+ }
56
+
57
+ /** Structured one-line log with the correlation id (M7). Never logs secrets,
58
+ * signatures, the Authorization header, or request bodies. */
59
+ function logEvent(o) {
60
+ try { process.stderr.write(JSON.stringify({ ts: new Date().toISOString(), ...o }) + "\n"); } catch {}
61
+ }
62
+
63
+ /** Constant-time bearer check (M1). Unset secret → false (fail closed). */
64
+ function bearerOk(req, secret) {
65
+ if (!secret) return false;
66
+ const auth = req.headers["authorization"] || "";
67
+ const token = auth.toLowerCase().startsWith("bearer ") ? auth.slice(7).trim() : "";
68
+ const a = Buffer.from(token);
69
+ const b = Buffer.from(secret);
70
+ if (a.length !== b.length) return false;
71
+ try { return timingSafeEqual(a, b); } catch { return false; }
72
+ }
73
+
74
+ /** Verify the HMAC request signature (M2): signature == HMAC-SHA256 over
75
+ * `${X-Hermes-Timestamp}.${rawBody}`, within ±SKEW_MS. Timing-safe compare.
76
+ * Returns { ok, reason?, sig? }. `sig` is the verified signature (used as the
77
+ * replay nonce). */
78
+ function hmacOk(req, rawBody, secret) {
79
+ const ts = req.headers["x-hermes-timestamp"];
80
+ const sig = req.headers["x-hermes-signature"];
81
+ if (!ts || !sig || typeof ts !== "string" || typeof sig !== "string") {
82
+ return { ok: false, reason: "missing_signature" };
83
+ }
84
+ const tsNum = Number(ts);
85
+ if (!Number.isFinite(tsNum)) return { ok: false, reason: "bad_timestamp" };
86
+ if (Math.abs(Date.now() - tsNum) > SKEW_MS) return { ok: false, reason: "timestamp_skew" };
87
+ const expected = createHmac("sha256", secret).update(`${ts}.${rawBody}`).digest("hex");
88
+ const a = Buffer.from(sig);
89
+ const b = Buffer.from(expected);
90
+ if (a.length !== b.length) return { ok: false, reason: "bad_signature" };
91
+ try {
92
+ if (!timingSafeEqual(a, b)) return { ok: false, reason: "bad_signature" };
93
+ } catch {
94
+ return { ok: false, reason: "bad_signature" };
95
+ }
96
+ return { ok: true, sig };
97
+ }
98
+
99
+ /**
100
+ * Start the hosted gateway. Resolves once the HTTP server is listening.
101
+ *
102
+ * @param {object} o
103
+ * @param {string} o.hermesBin absolute path to the hermes binary
104
+ * @param {object} o.env environment for the hermes child (HERMES_HOME etc.)
105
+ * @param {string} o.hermesHome ACP session cwd
106
+ * @param {object} [o.hostedGateway] manifest.hosted_gateway block (optional)
107
+ * @returns {Promise<{url:string, port:number, close:()=>void}>}
108
+ */
109
+ export async function startHostedGateway({ hermesBin, env, hermesHome, hostedGateway = {} }) {
110
+ const secret = env.HERMES_GATEWAY_SECRET || process.env.HERMES_GATEWAY_SECRET || "";
111
+ const port = Number(env.PORT || process.env.PORT || hostedGateway.port || DEFAULT_PORT);
112
+
113
+ // M1 (THE-5): boot-refuse-if-unset. Without the secret we must NOT bring up
114
+ // any agent logic — no `hermes acp` child is spawned. /health stays up (so
115
+ // liveness probes don't crash-loop the container) but runs no agent logic;
116
+ // /message fails closed (503). The endpoint is internet-facing once ingress
117
+ // is on, so a misconfigured deploy must never reach Hermes.
118
+ if (!secret) {
119
+ process.stderr.write(
120
+ "[athena-agent] ⚠ HERMES_GATEWAY_SECRET is unset — refusing to start agent logic. " +
121
+ "/health up, /message FAILS CLOSED (503).\n",
122
+ );
123
+ const healthOnly = createServer((req, res) => {
124
+ const url = (req.url || "/").split("?")[0];
125
+ if (req.method === "GET" && url === "/health") return sendJson(res, 200, { ok: true });
126
+ if (req.method === "POST" && url === "/message")
127
+ return sendJson(res, 503, { error: "hosted gateway not configured (secret unset) — failing closed" });
128
+ return sendJson(res, 404, { error: "not found" });
129
+ });
130
+ await new Promise((resolve, reject) => {
131
+ healthOnly.once("error", reject);
132
+ healthOnly.listen(port, "0.0.0.0", resolve);
133
+ });
134
+ const p = healthOnly.address().port;
135
+ return { url: `http://0.0.0.0:${p}`, port: p, close: () => healthOnly.close() };
136
+ }
137
+
138
+ // One hermes acp child, many ACP sessions. ACP supports multiple sessions per
139
+ // connection (session/new returns a sessionId; session/prompt takes one), so
140
+ // a single child serves every Teams conversation that maps to this agent.
141
+ const acp = new AcpClient(hermesBin, env);
142
+ acp.toolApproval = "auto"; // unattended — no human at the ACP layer to approve
143
+
144
+ // Route assistant chunks to the in-flight turn for their session.
145
+ // turnState: acpSessionId -> { buf: string }
146
+ const turnState = new Map();
147
+ acp.onEvent = (event) => {
148
+ if (event.type === "assistant_chunk" && event.sessionId) {
149
+ const st = turnState.get(event.sessionId);
150
+ if (st) st.buf += event.text;
151
+ }
152
+ if (event.type === "error") {
153
+ process.stderr.write(`[athena-agent] hosted-gateway ACP error: ${event.message}\n`);
154
+ }
155
+ };
156
+
157
+ process.stderr.write("[athena-agent] starting Hermes in ACP mode for hosted gateway…\n");
158
+ await acp.initialize();
159
+ process.stderr.write("[athena-agent] ACP initialized.\n");
160
+
161
+ // session_id (from Athena) -> { acpSessionId, lastUsed, chain }
162
+ // `chain` serializes turns within a session: ACP allows one prompt per
163
+ // session at a time, so concurrent /message calls for the same session_id
164
+ // queue behind each other rather than interleaving on the same ACP session.
165
+ const sessions = new Map();
166
+
167
+ async function ensureSession(externalId) {
168
+ let s = sessions.get(externalId);
169
+ if (!s) {
170
+ const acpSessionId = await acp.newSession(hermesHome);
171
+ if (!acpSessionId) throw new Error("session/new returned no sessionId");
172
+ s = { acpSessionId, lastUsed: Date.now(), chain: Promise.resolve() };
173
+ sessions.set(externalId, s);
174
+ process.stderr.write(
175
+ `[athena-agent] new ACP session ${acpSessionId} for ${externalId}\n`,
176
+ );
177
+ }
178
+ s.lastUsed = Date.now();
179
+ return s;
180
+ }
181
+
182
+ /** Drive one turn on an ACP session, accumulating chunks until the prompt
183
+ * resolves (turn end) or the timeout fires. Returns the reply text. */
184
+ async function driveTurn(acpSessionId, text) {
185
+ turnState.set(acpSessionId, { buf: "" });
186
+ let timer;
187
+ const timeout = new Promise((_, reject) => {
188
+ timer = setTimeout(() => reject(new Error("turn timed out")), TURN_TIMEOUT_MS);
189
+ });
190
+ try {
191
+ await Promise.race([acp.prompt(text, acpSessionId), timeout]);
192
+ } finally {
193
+ clearTimeout(timer);
194
+ }
195
+ const out = turnState.get(acpSessionId)?.buf || "";
196
+ turnState.delete(acpSessionId);
197
+ return out;
198
+ }
199
+
200
+ async function handleMessage(externalId, text) {
201
+ const s = await ensureSession(externalId);
202
+ // Serialize on the session's chain so same-session turns don't overlap.
203
+ const run = s.chain.then(() => driveTurn(s.acpSessionId, text));
204
+ // Keep the chain alive even if this turn throws.
205
+ s.chain = run.then(() => {}, () => {});
206
+ return run;
207
+ }
208
+
209
+ // ── M2 replay cache + M6 rate limiter / concurrency cap ──────────────
210
+ // ⚠ CONCURRENCY = 1 IS LOAD-BEARING, not just blast-radius control.
211
+ // Iris's per-turn consent custody (IRIS-7) arms the MCP-gateway credential by
212
+ // *user* for the duration of a turn; with exactly one turn in flight that
213
+ // arm-by-user lookup is unambiguous. Raising max_concurrency > 1 would let two
214
+ // armed turns overlap and a tool call could resolve to the wrong user's
215
+ // credential — a consent escape. Do NOT raise this above 1 until the custody
216
+ // layer is re-keyed to a per-turn token (not per-user). Hard cap, fail-closed.
217
+ const maxConcurrency = Number(hostedGateway.max_concurrency ?? DEFAULT_MAX_CONCURRENCY);
218
+ const ratePerMin = Number(hostedGateway.rate_limit_per_min ?? DEFAULT_RATE_PER_MIN);
219
+ // The limiter is in-process (no external store), so it cannot be "down" — a
220
+ // failure to obtain a token can only mean the bucket is empty ⇒ 429. There is
221
+ // no code path where the limiter is unavailable and we fail open.
222
+ const replayCache = new Map(); // signature -> expiry epoch ms
223
+ const bucket = { tokens: ratePerMin, last: Date.now() }; // token bucket (global; single-employee container)
224
+ let inFlight = 0;
225
+
226
+ function seenBefore(sig) {
227
+ if (replayCache.has(sig)) return true;
228
+ replayCache.set(sig, Date.now() + REPLAY_TTL_MS);
229
+ return false;
230
+ }
231
+ function takeToken() {
232
+ const now = Date.now();
233
+ bucket.tokens = Math.min(ratePerMin, bucket.tokens + ((now - bucket.last) * ratePerMin) / 60_000);
234
+ bucket.last = now;
235
+ if (bucket.tokens >= 1) { bucket.tokens -= 1; return true; }
236
+ return false;
237
+ }
238
+
239
+ // Periodic idle eviction + replay-cache pruning.
240
+ const sweeper = setInterval(() => {
241
+ const now = Date.now();
242
+ for (const [id, s] of sessions) {
243
+ if (now - s.lastUsed > SESSION_IDLE_MS) {
244
+ sessions.delete(id);
245
+ turnState.delete(s.acpSessionId);
246
+ process.stderr.write(`[athena-agent] evicted idle session ${id}\n`);
247
+ }
248
+ }
249
+ for (const [sig, exp] of replayCache) if (exp <= now) replayCache.delete(sig);
250
+ }, SESSION_SWEEP_MS);
251
+ if (sweeper.unref) sweeper.unref();
252
+
253
+ const server = createServer(async (req, res) => {
254
+ const url = (req.url || "/").split("?")[0];
255
+
256
+ if (req.method === "GET" && url === "/health") {
257
+ sendJson(res, 200, { ok: true });
258
+ return;
259
+ }
260
+
261
+ if (req.method === "POST" && url === "/message") {
262
+ const requestId =
263
+ (typeof req.headers["x-request-id"] === "string" && req.headers["x-request-id"]) ||
264
+ randomUUID();
265
+ // Read the raw body first — the HMAC (M2) is computed over it verbatim.
266
+ const rawBody = await readRawBody(req);
267
+
268
+ if (!secret) {
269
+ logEvent({ event: "message", request_id: requestId, status: 503, reason: "secret_unset" });
270
+ sendJson(res, 503, { error: "service unavailable" });
271
+ return;
272
+ }
273
+ // M1 — bearer first (cheapest, no body needed).
274
+ if (!bearerOk(req, secret)) {
275
+ logEvent({ event: "auth_fail", request_id: requestId, status: 401, reason: "bearer" });
276
+ sendJson(res, 401, { error: "unauthorized" });
277
+ return;
278
+ }
279
+ // M2 — HMAC signature over `${ts}.${rawBody}`, ±60s skew.
280
+ const sig = hmacOk(req, rawBody, secret);
281
+ if (!sig.ok) {
282
+ logEvent({ event: "auth_fail", request_id: requestId, status: 401, reason: sig.reason });
283
+ sendJson(res, 401, { error: "unauthorized" });
284
+ return;
285
+ }
286
+ // M2 — replay/nonce: a given signature is single-use within its TTL.
287
+ if (seenBefore(sig.sig)) {
288
+ logEvent({ event: "auth_fail", request_id: requestId, status: 401, reason: "replay" });
289
+ sendJson(res, 401, { error: "unauthorized" });
290
+ return;
291
+ }
292
+ // M6 — rate limit, then hard concurrency cap. Fail closed (429), never open.
293
+ if (!takeToken()) {
294
+ logEvent({ event: "rate_limited", request_id: requestId, status: 429 });
295
+ sendJson(res, 429, { error: "rate limited" });
296
+ return;
297
+ }
298
+ if (inFlight >= maxConcurrency) {
299
+ logEvent({ event: "over_capacity", request_id: requestId, status: 429, in_flight: inFlight });
300
+ sendJson(res, 429, { error: "busy" });
301
+ return;
302
+ }
303
+
304
+ let body;
305
+ try { body = JSON.parse(rawBody || "{}"); } catch { body = {}; }
306
+ const sessionId = typeof body.session_id === "string" ? body.session_id.trim() : "";
307
+ const text = typeof body.text === "string" ? body.text : "";
308
+ // M3 — identity is NEVER taken from the body; only session_id + text are read.
309
+ if (!sessionId || !text.trim()) {
310
+ logEvent({ event: "message", request_id: requestId, status: 400, reason: "bad_request" });
311
+ sendJson(res, 400, { error: "session_id and text are required" });
312
+ return;
313
+ }
314
+
315
+ inFlight++;
316
+ const startedAt = Date.now();
317
+ try {
318
+ const reply = await handleMessage(sessionId, text);
319
+ logEvent({ event: "turn", request_id: requestId, session_id: sessionId, status: 200, ms: Date.now() - startedAt });
320
+ sendJson(res, 200, { text: reply });
321
+ } catch (err) {
322
+ // M7 — generic error body; the detail stays in our own logs only.
323
+ logEvent({ event: "turn", request_id: requestId, session_id: sessionId, status: 502, error: String(err?.message || err) });
324
+ sendJson(res, 502, { error: "turn_failed" });
325
+ } finally {
326
+ inFlight--;
327
+ }
328
+ return;
329
+ }
330
+
331
+ sendJson(res, 404, { error: "not found" });
332
+ });
333
+
334
+ await new Promise((resolve, reject) => {
335
+ server.once("error", reject);
336
+ // 0.0.0.0 — the container's ingress fronts this; bind to all interfaces.
337
+ server.listen(port, "0.0.0.0", resolve);
338
+ });
339
+
340
+ const boundPort = server.address().port;
341
+ function close() {
342
+ clearInterval(sweeper);
343
+ acp.kill();
344
+ server.close();
345
+ }
346
+ acp.child.on("exit", () => { clearInterval(sweeper); server.close(); });
347
+
348
+ return { url: `http://0.0.0.0:${boundPort}`, port: boundPort, close };
349
+ }
@@ -41,6 +41,9 @@
41
41
  .tool { background: var(--tool); border: 1px solid var(--border); border-radius: 8px; padding: 8px 12px; margin: 8px 0; font-size: 13px; color: var(--fg2); }
42
42
  .tool .t-title { font-weight: 600; color: var(--fg); }
43
43
  .tool .t-status { font-size: 11px; color: var(--fg3); }
44
+ .tool.err { border-color: var(--err); }
45
+ .tool.err .t-status { color: var(--err); }
46
+ .tool .t-detail { margin-top: 6px; font-size: 12px; color: var(--err); white-space: pre-wrap; word-wrap: break-word; }
44
47
  .perm { background: var(--bg3); border: 1px solid var(--accent); border-radius: 8px; padding: 12px; margin: 8px 0; }
45
48
  .perm .q { margin-bottom: 8px; }
46
49
  .perm button { margin-right: 8px; }
@@ -143,7 +146,18 @@ function handle(ev) {
143
146
  }
144
147
  case "tool_update": {
145
148
  const el = toolEls.get(ev.id);
146
- if (el) el.querySelector(".t-status").textContent = "· " + ev.status; break;
149
+ if (el) {
150
+ el.querySelector(".t-status").textContent = "· " + (ev.isError ? "failed" : ev.status);
151
+ if (ev.isError) {
152
+ el.classList.add("err");
153
+ if (ev.detail) {
154
+ let d = el.querySelector(".t-detail");
155
+ if (!d) { d = document.createElement("div"); d.className = "t-detail"; el.append(d); }
156
+ d.textContent = ev.detail;
157
+ }
158
+ }
159
+ }
160
+ break;
147
161
  }
148
162
  case "permission":
149
163
  renderPermission(ev); break;
package/lib/webchat.mjs CHANGED
@@ -14,165 +14,14 @@ import { createServer } from "node:http";
14
14
  import { readFileSync } from "node:fs";
15
15
  import { fileURLToPath } from "node:url";
16
16
  import { dirname, join } from "node:path";
17
+ import { AcpClient } from "./acp-client.mjs";
17
18
 
18
19
  const __dirname = dirname(fileURLToPath(import.meta.url));
19
20
  const UI_HTML = readFileSync(join(__dirname, "webchat-ui.html"), "utf8");
20
21
 
21
- // ---- ACP client over a hermes acp child -----------------------------------
22
- class AcpClient {
23
- constructor(hermesBin, env) {
24
- this.child = spawn(hermesBin, ["acp", "--accept-hooks"], {
25
- stdio: ["pipe", "pipe", "inherit"],
26
- env,
27
- });
28
- this.nextId = 1;
29
- this.pending = new Map(); // jsonrpc id -> {resolve, reject}
30
- this.permissionWaiters = new Map(); // requestId -> resolve(optionId|null)
31
- this.sessionId = null;
32
- this.buf = "";
33
- // Event sink set by the server: (event) => void
34
- this.onEvent = () => {};
35
- // Permission policy: "auto" | "ask"
36
- this.toolApproval = "auto";
37
-
38
- this.child.stdout.on("data", (c) => this._onData(c));
39
- this.child.on("exit", (code) =>
40
- this.onEvent({ type: "error", message: `Hermes exited (code ${code})` }),
41
- );
42
- }
43
-
44
- _send(obj) {
45
- this.child.stdin.write(JSON.stringify(obj) + "\n");
46
- }
47
-
48
- _request(method, params) {
49
- const id = this.nextId++;
50
- this._send({ jsonrpc: "2.0", id, method, params });
51
- return new Promise((resolve, reject) => this.pending.set(id, { resolve, reject }));
52
- }
53
-
54
- _onData(chunk) {
55
- this.buf += chunk.toString("utf8");
56
- let nl;
57
- while ((nl = this.buf.indexOf("\n")) >= 0) {
58
- const line = this.buf.slice(0, nl).trim();
59
- this.buf = this.buf.slice(nl + 1);
60
- if (!line) continue;
61
- let msg;
62
- try {
63
- msg = JSON.parse(line);
64
- } catch {
65
- continue; // non-JSON banner line on stdout — ignore
66
- }
67
- this._handle(msg);
68
- }
69
- }
70
-
71
- _handle(msg) {
72
- // Response to one of our requests.
73
- if (msg.id !== undefined && (msg.result !== undefined || msg.error !== undefined)) {
74
- const p = this.pending.get(msg.id);
75
- if (p) {
76
- this.pending.delete(msg.id);
77
- if (msg.error) p.reject(new Error(JSON.stringify(msg.error)));
78
- else p.resolve(msg.result);
79
- }
80
- return;
81
- }
82
- // Request from the agent that we must answer.
83
- if (msg.id !== undefined && msg.method) {
84
- this._onAgentRequest(msg);
85
- return;
86
- }
87
- // Notification.
88
- if (msg.method === "session/update") this._onUpdate(msg.params?.update);
89
- }
90
-
91
- _onUpdate(u) {
92
- if (!u) return;
93
- switch (u.sessionUpdate) {
94
- case "agent_message_chunk":
95
- if (u.content?.text) this.onEvent({ type: "assistant_chunk", text: u.content.text });
96
- break;
97
- case "tool_call":
98
- this.onEvent({
99
- type: "tool",
100
- id: u.toolCallId,
101
- title: u.title || u.toolCallId,
102
- status: u.status || "pending",
103
- });
104
- break;
105
- case "tool_call_update":
106
- if (u.status)
107
- this.onEvent({ type: "tool_update", id: u.toolCallId, status: u.status });
108
- break;
109
- default:
110
- break; // agent_thought_chunk, plan, etc. — ignored for now
111
- }
112
- }
113
-
114
- async _onAgentRequest(msg) {
115
- if (msg.method === "session/request_permission") {
116
- const opts = msg.params?.options || [];
117
- const title = msg.params?.toolCall?.title || msg.params?.toolCall?.toolCallId || "tool";
118
- if (this.toolApproval === "auto") {
119
- const allow = opts.find((o) => /allow/i.test(o.kind || o.optionId || "")) || opts[0];
120
- this._send({
121
- jsonrpc: "2.0",
122
- id: msg.id,
123
- result: { outcome: allow ? { outcome: "selected", optionId: allow.optionId } : { outcome: "cancelled" } },
124
- });
125
- return;
126
- }
127
- // ask: surface to the browser and wait for a decision.
128
- const requestId = String(msg.id);
129
- this.onEvent({ type: "permission", requestId, title, options: opts });
130
- const optionId = await new Promise((resolve) => this.permissionWaiters.set(requestId, resolve));
131
- this._send({
132
- jsonrpc: "2.0",
133
- id: msg.id,
134
- result: { outcome: optionId ? { outcome: "selected", optionId } : { outcome: "cancelled" } },
135
- });
136
- return;
137
- }
138
- // fs/* and terminal/* — we declared no such capabilities; decline cleanly.
139
- this._send({
140
- jsonrpc: "2.0",
141
- id: msg.id,
142
- error: { code: -32601, message: `web chat does not implement ${msg.method}` },
143
- });
144
- }
145
-
146
- resolvePermission(requestId, optionId) {
147
- const w = this.permissionWaiters.get(requestId);
148
- if (w) {
149
- this.permissionWaiters.delete(requestId);
150
- w(optionId || null);
151
- }
152
- }
153
-
154
- async init(cwd) {
155
- await this._request("initialize", {
156
- protocolVersion: 1,
157
- clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false },
158
- clientInfo: { name: "athena-web-chat", title: "Athena Web Chat", version: "0.1.0" },
159
- });
160
- const ns = await this._request("session/new", { cwd, mcpServers: [] });
161
- this.sessionId = ns?.sessionId;
162
- return this.sessionId;
163
- }
164
-
165
- async prompt(text) {
166
- return this._request("session/prompt", {
167
- sessionId: this.sessionId,
168
- prompt: [{ type: "text", text }],
169
- });
170
- }
171
-
172
- kill() {
173
- try { this.child.kill("SIGTERM"); } catch {}
174
- }
175
- }
22
+ // The ACP client (newline-delimited JSON-RPC over a `hermes acp` child) now
23
+ // lives in ./acp-client.mjs so the hosted gateway (DAE-6) shares the exact
24
+ // same transport. webchat uses it in single-session mode (init + prompt).
176
25
 
177
26
  function openBrowser(url) {
178
27
  const cmd =
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "athena-agent-launcher",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
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": {