@agenthippo-ai/agenthippo 0.1.0 → 0.2.3-dataplane

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 (68) hide show
  1. package/out/agentpack/index.js +2 -1
  2. package/out/agentpack/paths.js +23 -0
  3. package/out/analytics/dashboard/fleetDisplayUtils.js +3 -0
  4. package/out/analytics/dashboard/webview/panel.js +290 -80
  5. package/out/analytics/sessionEvents.js +21 -4
  6. package/out/auth/brokerClient.js +1 -1
  7. package/out/auth/onboardingPrompt.js +112 -0
  8. package/out/auth/provider.js +1 -1
  9. package/out/auth-broker/config.js +8 -3
  10. package/out/auth-broker/pkce.js +3 -1
  11. package/out/auth-broker/server.js +43 -1
  12. package/out/auth-broker/store.js +6 -5
  13. package/out/cli/commands/ask.js +3 -0
  14. package/out/cli/commands/chat.js +3 -0
  15. package/out/cli/commands/deploy.js +49 -10
  16. package/out/cli/commands/gateway.js +54 -0
  17. package/out/cli/commands/idp.js +85 -0
  18. package/out/cli/commands/serve.js +401 -49
  19. package/out/cli/credential-context.js +47 -69
  20. package/out/cli/data-plane/broker.js +78 -0
  21. package/out/cli/data-plane/connector-host.js +121 -0
  22. package/out/cli/data-plane/egress.js +189 -0
  23. package/out/cli/data-plane/gateway.js +195 -0
  24. package/out/cli/data-plane/index.js +137 -0
  25. package/out/cli/data-plane/registry.js +80 -0
  26. package/out/cli/deploy/blueprints/registry.js +84 -0
  27. package/out/cli/deploy/layers/identity.js +91 -0
  28. package/out/cli/deploy-init.js +1156 -285
  29. package/out/cli/idp-init.js +303 -0
  30. package/out/cli/index.js +46 -8
  31. package/out/cli/pack-loader.js +37 -2
  32. package/out/cli/runtime.js +10 -3
  33. package/out/cli/serve-hardening.js +217 -12
  34. package/out/cli/state-sync.js +318 -29
  35. package/out/engines/claude.js +26 -5
  36. package/out/engines/codex.js +24 -5
  37. package/out/engines/custom.js +46 -12
  38. package/out/extension.js +2 -0
  39. package/out/gateway/outboundSend.js +6 -6
  40. package/out/headless/index.js +24 -6
  41. package/out/live-probe/connector.mjs +8 -0
  42. package/out/live-probe/env-report.mjs +8 -0
  43. package/out/mcp.js +88 -61
  44. package/out/remote/handleRemoteChat.js +7 -1
  45. package/out/remote/serveClient.js +27 -13
  46. package/out/scripts/analytics/agentide-litellm-otel.config.yaml +14 -0
  47. package/out/shared/models.js +1 -1
  48. package/out/shared/providers.js +5 -2
  49. package/out/test/analytics.test.js +93 -0
  50. package/out/test/auth-broker.test.js +59 -2
  51. package/out/test/blueprint-registry.test.js +105 -0
  52. package/out/test/credential-context.test.js +55 -0
  53. package/out/test/custom-env-layering.test.js +151 -0
  54. package/out/test/data-plane-engine-wiring.test.js +250 -0
  55. package/out/test/data-plane.test.js +244 -0
  56. package/out/test/deploy-init.test.js +287 -5
  57. package/out/test/egress-broker.test.js +172 -0
  58. package/out/test/fleetDisplayUtils.test.js +4 -0
  59. package/out/test/idp-init.test.js +151 -0
  60. package/out/test/mcp-obo.test.js +52 -27
  61. package/out/test/mcp.test.js +60 -4
  62. package/out/test/model-routing.test.js +133 -0
  63. package/out/test/serve-gateway-turn.test.js +267 -0
  64. package/out/test/serve-hardening.test.js +187 -0
  65. package/out/test/serveClient.test.js +37 -2
  66. package/out/test/state-sync-peer.test.js +83 -1
  67. package/out/utils.js +27 -11
  68. package/package.json +3 -4
@@ -36,14 +36,19 @@ var __importStar = (this && this.__importStar) || (function () {
36
36
  };
37
37
  })();
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
- exports.TurnLimiter = exports.RateLimiter = exports.CONTAINER_SERVE_HOST = exports.SERVE_HOST_ENV = exports.DEFAULT_BIND_HOST = exports.DEFAULT_MAX_BODY_BYTES = void 0;
39
+ exports.TurnGate = exports.TURN_LIMIT_BUSY_MESSAGE = exports.RATE_LIMIT_BUSY_MESSAGE = exports.TURN_GATE_BUSY_MESSAGE = exports.TURN_GATE_RETRY_AFTER_SECONDS = exports.TURN_QUEUE_ENV = exports.MAX_CONCURRENT_TURNS_ENV = exports.TurnLimiter = exports.SESSION_WITHOUT_USER_REASON = exports.IDENTITY_HEADER_ENV = exports.RateLimiter = exports.XFF_TRUSTED_HOPS_ENV = exports.CONTAINER_SERVE_HOST = exports.SERVE_HOST_ENV = exports.DEFAULT_BIND_HOST = exports.DEFAULT_MAX_BODY_BYTES = void 0;
40
40
  exports.resolveStateDir = resolveStateDir;
41
+ exports.resolveAnalyticsSessionsDir = resolveAnalyticsSessionsDir;
41
42
  exports.resolveBindHost = resolveBindHost;
42
43
  exports.clientIp = clientIp;
43
44
  exports.resolveUserId = resolveUserId;
44
45
  exports.resolveDataToken = resolveDataToken;
45
46
  exports.peerToUserId = peerToUserId;
47
+ exports.userNamespaceHash = userNamespaceHash;
48
+ exports.resolveServeSessionKey = resolveServeSessionKey;
46
49
  exports.corsHeaders = corsHeaders;
50
+ exports.createTurnGateFromEnv = createTurnGateFromEnv;
51
+ exports.createRejectionWarner = createRejectionWarner;
47
52
  exports.readBodyWithCap = readBodyWithCap;
48
53
  /**
49
54
  * Serve helpers: state-dir resolution, identity header reading, CORS, turn limiting, body cap.
@@ -58,6 +63,8 @@ exports.readBodyWithCap = readBodyWithCap;
58
63
  */
59
64
  const os = __importStar(require("os"));
60
65
  const path = __importStar(require("path"));
66
+ const crypto = __importStar(require("crypto"));
67
+ const paths_1 = require("../utils/paths");
61
68
  /** Default cap on a single request body (1 MiB). Chat messages are small; large bodies are abuse or bugs. */
62
69
  exports.DEFAULT_MAX_BODY_BYTES = 1024 * 1024;
63
70
  /**
@@ -80,6 +87,26 @@ function resolveStateDir(stateDirOpt, env = process.env) {
80
87
  }
81
88
  return { dir: path.join(os.tmpdir(), 'agenthippo-server'), ephemeral: true };
82
89
  }
90
+ /**
91
+ * Where serve's Layer 2 analytics (tool-call/warrant JSONL) should live. Precedence:
92
+ * 1. AGENT_HOME_DIR / AGENTHIPPO_HOME set → getAgentideHome()/analytics/sessions — the
93
+ * exact location sessionJsonlFleet.ts and query-analytics.sh already scan by default,
94
+ * so this data stays discoverable to Spotlight/DuckDB.
95
+ * 2. Neither set, but the resolved state dir is non-ephemeral (an explicit --state-dir
96
+ * was given) → <stateDir>/analytics/sessions. Not the canonical scan location, but
97
+ * still durable — never regress below whatever durability the caller already
98
+ * established just to be discoverable.
99
+ * 3. Neither set and state is ephemeral (no --state-dir, no home-dir env var at all) →
100
+ * falls through to getAgentideHome()'s own os.homedir() default, matching the
101
+ * already-correct IDE-local behavior — nothing durable was configured either way.
102
+ */
103
+ function resolveAnalyticsSessionsDir(stateDir, stateDirEphemeral, env = process.env) {
104
+ const homeOverride = env.AGENT_HOME_DIR?.trim() || env.AGENTHIPPO_HOME?.trim();
105
+ if (!homeOverride && !stateDirEphemeral) {
106
+ return path.join(stateDir, 'analytics', 'sessions');
107
+ }
108
+ return path.join((0, paths_1.getAgentideHome)(), 'analytics', 'sessions');
109
+ }
83
110
  /** Loopback default — safe everywhere a human runs `serve` locally. */
84
111
  exports.DEFAULT_BIND_HOST = '127.0.0.1';
85
112
  /** Env var for the listen address (used by generated deploy Dockerfiles). */
@@ -96,21 +123,29 @@ exports.CONTAINER_SERVE_HOST = '0.0.0.0';
96
123
  function resolveBindHost(hostOpt, env = process.env) {
97
124
  return hostOpt?.trim() || env.AGENTHIPPO_SERVE_HOST?.trim() || exports.DEFAULT_BIND_HOST;
98
125
  }
126
+ /** Env var: number of trusted proxy hops in front of serve (default 1 — the single Caddy). */
127
+ exports.XFF_TRUSTED_HOPS_ENV = 'AGENTHIPPO_XFF_TRUSTED_HOPS';
99
128
  /**
100
129
  * Best-effort client IP for rate-limiting keys.
101
130
  *
102
- * Behind the documented single trusted proxy (Caddy), `X-Forwarded-For` ends with the IP the
103
- * proxy observed, so the right-most entry is the real client (a client-spoofed left-most entry
104
- * is just prepended). Falls back to the socket address for direct/local callers. This is a
105
- * coarse abuse key, not an identity — never use it for authorization.
131
+ * Each trusted proxy appends the IP it observed to `X-Forwarded-For`, so with N trusted hops
132
+ * the real client is the Nth entry from the right (a client-spoofed left-most entry is just
133
+ * prepended and never reached). Default is 1 (the documented single Caddy). Behind
134
+ * Cloudflare Tunnel → Caddy there are two hops — Caddy appends cloudflared's container IP,
135
+ * which is identical for every client, so leaving hops at 1 there collapses all users into one
136
+ * rate-limit bucket. Set AGENTHIPPO_XFF_TRUSTED_HOPS=2 for the tunnel profile.
137
+ * Falls back to the socket address for direct/local callers. This is a coarse abuse key, not
138
+ * an identity — never use it for authorization.
106
139
  */
107
- function clientIp(req) {
140
+ function clientIp(req, env = process.env) {
108
141
  const xff = req.headers['x-forwarded-for'];
109
142
  const raw = Array.isArray(xff) ? xff[0] : xff;
110
143
  if (raw) {
111
144
  const parts = raw.split(',').map((s) => s.trim()).filter(Boolean);
112
145
  if (parts.length > 0) {
113
- return parts[parts.length - 1];
146
+ const hopsRaw = Number(env[exports.XFF_TRUSTED_HOPS_ENV]);
147
+ const hops = Number.isInteger(hopsRaw) && hopsRaw >= 1 ? hopsRaw : 1;
148
+ return parts[Math.max(0, parts.length - hops)];
114
149
  }
115
150
  }
116
151
  return req.socket?.remoteAddress || 'unknown';
@@ -176,12 +211,25 @@ const HEADER_ALIASES = [
176
211
  'x-goog-authenticated-user-email', // Cloud Run IAP (prefixed value, stripped below)
177
212
  'cf-access-authenticated-user-email', // Cloudflare Access
178
213
  ];
214
+ /** Env var: pin the single identity header the deploy layer actually sets (and strips). */
215
+ exports.IDENTITY_HEADER_ENV = 'AGENTHIPPO_IDENTITY_HEADER';
179
216
  /**
180
217
  * Resolve the end-user identity for a request.
181
218
  *
182
- * Proxy headers win over the request body — the header is set by the SSO proxy
183
- * serve sits behind; the body is client-controlled. Falls back to body.user_id
184
- * for dev/internal use (e.g. direct curl against a local serve).
219
+ * Proxy headers win over the request body. When AGENTHIPPO_IDENTITY_HEADER pins
220
+ * trust to one header, that pin is a hard boundary: if the pinned header is
221
+ * absent, identity resolves to undefined (never the request body) — a deployment
222
+ * that pins a header has declared it has a real proxy-verified identity, so an
223
+ * unauthenticated caller must not be able to name themselves via the client-
224
+ * controlled body. Body fallback is dev/internal convenience for the *unpinned*
225
+ * case only (e.g. direct curl against a local serve with no identity proxy).
226
+ *
227
+ * The alias-list default (used when unpinned) is only safe when the proxy strips
228
+ * or overwrites ALL of the aliases; platforms that strip only their own header
229
+ * (Cloud Run IAP strips `X-Goog-*` but forwards a client-supplied `x-agent-user`
230
+ * untouched, which the alias list checks first) MUST pin theirs — deploy
231
+ * artifacts set it (e.g. cloudrun-deploy.sh pins `x-goog-authenticated-user-email`)
232
+ * so an authenticated caller cannot impersonate another user.
185
233
  *
186
234
  * Resolves identity for `analyticsUserId` attribution and the request-scoped
187
235
  * credential context (per-user data scoping). Not folded into the session key —
@@ -190,8 +238,10 @@ const HEADER_ALIASES = [
190
238
  * Per-turn *data credentials* (OBO token, e.g. X-Forwarded-Access-Token) are
191
239
  * handled by resolveDataToken — not read here.
192
240
  */
193
- function resolveUserId(headers, body) {
194
- for (const name of HEADER_ALIASES) {
241
+ function resolveUserId(headers, body, env = process.env) {
242
+ const pinned = env[exports.IDENTITY_HEADER_ENV]?.trim().toLowerCase();
243
+ const names = pinned ? [pinned] : HEADER_ALIASES;
244
+ for (const name of names) {
195
245
  const v = headers[name];
196
246
  const raw = (Array.isArray(v) ? v[0] : v)?.trim();
197
247
  if (raw) {
@@ -201,6 +251,12 @@ function resolveUserId(headers, body) {
201
251
  return value;
202
252
  }
203
253
  }
254
+ if (pinned) {
255
+ // Pinned but absent: fail closed. Falling back to the body here would let any
256
+ // caller that merely reaches this port (proxy misconfiguration, direct network
257
+ // access, gcloud run services proxy bypassing IAP) name an arbitrary victim.
258
+ return undefined;
259
+ }
204
260
  const fromBody = body?.user_id?.trim();
205
261
  return fromBody || undefined;
206
262
  }
@@ -244,6 +300,36 @@ function peerToUserId(peer) {
244
300
  const channel = peer.channel?.trim();
245
301
  return channel ? `${channel}:${id}` : id;
246
302
  }
303
+ /** Stable reason code when a session_id is presented without a verified user on a pinned-identity deployment. */
304
+ exports.SESSION_WITHOUT_USER_REASON = 'session_without_verified_user';
305
+ /** Filesystem/S3-safe per-user namespace segment (user ids are emails/subjects — never raw path segments). */
306
+ function userNamespaceHash(userId) {
307
+ return crypto.createHash('sha256').update(userId).digest('hex').slice(0, 16);
308
+ }
309
+ /**
310
+ * Namespace the session key by the verified user (secure-obo-design §3.4 / §8 Phase 0).
311
+ *
312
+ * `session_id` is client-supplied; un-namespaced, any authenticated user who guesses another
313
+ * user's session_id attaches to that session's conversation state (which holds the other
314
+ * user's row-filtered query results). Prefixing the key with a hash of the verified user makes
315
+ * cross-user attachment structurally impossible without changing client behavior.
316
+ *
317
+ * Fail-closed rule: when the deployment pins an identity header (it declared a real
318
+ * proxy-verified identity exists) and a request carries a session_id but no verified user,
319
+ * reject — never let an unauthenticated caller name a session. Unpinned deployments
320
+ * (local dev, no identity boundary at all) keep today's raw-key behavior.
321
+ */
322
+ function resolveServeSessionKey(sessionId, userId, env = process.env) {
323
+ const sid = sessionId?.trim();
324
+ const uid = userId?.trim();
325
+ if (uid) {
326
+ return { sessionKey: `u${userNamespaceHash(uid)}--${sid || 'default'}` };
327
+ }
328
+ if (sid && env[exports.IDENTITY_HEADER_ENV]?.trim()) {
329
+ return { sessionKey: '', rejected: exports.SESSION_WITHOUT_USER_REASON };
330
+ }
331
+ return { sessionKey: sid || 'anon-unknown' };
332
+ }
247
333
  /** Build CORS headers. Serve is always behind a proxy; CORS is wide-open here. */
248
334
  function corsHeaders() {
249
335
  return {
@@ -281,6 +367,125 @@ class TurnLimiter {
281
367
  }
282
368
  }
283
369
  exports.TurnLimiter = TurnLimiter;
370
+ /** Env: max concurrent engine turns per process (default 10). `0` disables the gate. */
371
+ exports.MAX_CONCURRENT_TURNS_ENV = 'AGENTHIPPO_MAX_CONCURRENT_TURNS';
372
+ /** Env: bounded FIFO waiter depth when at inflight cap (default 20). */
373
+ exports.TURN_QUEUE_ENV = 'AGENTHIPPO_TURN_QUEUE';
374
+ /** Fixed Retry-After seconds when the turn gate rejects (matches default queue timeout). */
375
+ exports.TURN_GATE_RETRY_AFTER_SECONDS = 15;
376
+ /** User-visible capacity message for gateway channels (Slack/Telegram can't receive HTTP 429). */
377
+ exports.TURN_GATE_BUSY_MESSAGE = 'At capacity — please retry in a moment';
378
+ /** User-visible message for gateway channels when the per-identity RateLimiter rejects. */
379
+ exports.RATE_LIMIT_BUSY_MESSAGE = 'Rate limit exceeded — please retry in a moment';
380
+ /** User-visible message for gateway channels when the per-session TurnLimiter rejects. */
381
+ exports.TURN_LIMIT_BUSY_MESSAGE = 'Per-session turn limit reached';
382
+ /**
383
+ * Process-wide concurrency gate for engine turns (semaphore + bounded FIFO).
384
+ *
385
+ * Caps simultaneous engine subprocesses so a burst cannot OOM the container.
386
+ * `maxInflight <= 0` disables. Waiters time out after `queueTimeoutMs` (burst
387
+ * absorber, not a job queue). Rejected callers get `null` — HTTP maps that to
388
+ * 429 + Retry-After; the gateway path emits a short channel message instead.
389
+ */
390
+ class TurnGate {
391
+ maxInflight;
392
+ maxQueue;
393
+ queueTimeoutMs;
394
+ inflightCount = 0;
395
+ waiters = [];
396
+ constructor(maxInflight, maxQueue, queueTimeoutMs = exports.TURN_GATE_RETRY_AFTER_SECONDS * 1000) {
397
+ this.maxInflight = maxInflight;
398
+ this.maxQueue = maxQueue;
399
+ this.queueTimeoutMs = queueTimeoutMs;
400
+ }
401
+ /**
402
+ * Acquire a turn slot. Resolves with a `release()` callback, or `null` when
403
+ * the queue is full or the wait timed out. Always call `release()` in a
404
+ * `finally` when non-null.
405
+ */
406
+ async acquire() {
407
+ if (!Number.isFinite(this.maxInflight) || this.maxInflight <= 0) {
408
+ return () => { };
409
+ }
410
+ if (this.inflightCount < this.maxInflight) {
411
+ this.inflightCount += 1;
412
+ return () => this.releaseSlot();
413
+ }
414
+ if (this.waiters.length >= this.maxQueue) {
415
+ return null;
416
+ }
417
+ return new Promise((resolve) => {
418
+ let settled = false;
419
+ const settle = (release) => {
420
+ if (settled) {
421
+ // Timed out after a slot was already offered — give the slot back.
422
+ if (release) {
423
+ release();
424
+ }
425
+ return;
426
+ }
427
+ settled = true;
428
+ clearTimeout(timer);
429
+ resolve(release);
430
+ };
431
+ const entry = {
432
+ resolve: settle,
433
+ timer: null,
434
+ };
435
+ const timer = setTimeout(() => {
436
+ const idx = this.waiters.indexOf(entry);
437
+ if (idx >= 0) {
438
+ this.waiters.splice(idx, 1);
439
+ }
440
+ settle(null);
441
+ }, this.queueTimeoutMs);
442
+ entry.timer = timer;
443
+ this.waiters.push(entry);
444
+ });
445
+ }
446
+ releaseSlot() {
447
+ const next = this.waiters.shift();
448
+ if (next) {
449
+ // Transfer the slot to the head waiter — do not decrement inflight.
450
+ next.resolve(() => this.releaseSlot());
451
+ return;
452
+ }
453
+ this.inflightCount = Math.max(0, this.inflightCount - 1);
454
+ }
455
+ get inflight() {
456
+ return this.inflightCount;
457
+ }
458
+ get queued() {
459
+ return this.waiters.length;
460
+ }
461
+ get max() {
462
+ return this.maxInflight > 0 ? this.maxInflight : 0;
463
+ }
464
+ }
465
+ exports.TurnGate = TurnGate;
466
+ /** Build a TurnGate from env (serve-v4 P0.1). Defaults: 10 inflight, 20 queued. */
467
+ function createTurnGateFromEnv(env = process.env) {
468
+ const inflightRaw = Number(env[exports.MAX_CONCURRENT_TURNS_ENV] ?? 10);
469
+ const queueRaw = Number(env[exports.TURN_QUEUE_ENV] ?? 20);
470
+ const maxInflight = Number.isFinite(inflightRaw) ? inflightRaw : 10;
471
+ const maxQueue = Number.isFinite(queueRaw) && queueRaw >= 0 ? queueRaw : 20;
472
+ return new TurnGate(maxInflight, maxQueue);
473
+ }
474
+ /**
475
+ * Rate-limited rejection logger — at most one warn per `intervalMs` so a flood
476
+ * of 429s does not spam the console.
477
+ */
478
+ function createRejectionWarner(intervalMs = 5_000) {
479
+ let lastAt = 0;
480
+ return (msg) => {
481
+ const now = Date.now();
482
+ if (now - lastAt < intervalMs) {
483
+ return;
484
+ }
485
+ lastAt = now;
486
+ console.warn(msg);
487
+ };
488
+ }
284
489
  /**
285
490
  * Read a request body with a hard size cap. Past the cap it stops accumulating
286
491
  * and rejects with a `tooLarge`-tagged error so the caller can respond 413.