@agenthippo-ai/agenthippo 0.1.0 → 0.1.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/out/cli/index.js CHANGED
@@ -212,7 +212,15 @@ program
212
212
  .option('--state-dir <path>', 'Persist session state here (honors AGENTHIPPO_HOME; replaces the tmpdir default)')
213
213
  .option('--max-turns-per-session <n>', 'Cap turns per session (abuse/runaway guard); 0 = unlimited', '0')
214
214
  .option('--www-dir <path>', 'Serve deploy/www/index.html at GET / (static browser chat UI)')
215
+ // Deprecated no-ops: kept so deploy artifacts generated by older CLIs (which baked these
216
+ // into the serve command line) don't crash with "unknown option" after a runtime upgrade.
217
+ // Serve now trusts its front proxy for auth/CORS; these flags no longer do anything.
218
+ .option('--production', 'Deprecated: no-op (serve trusts its front proxy; hardening moved to the deploy edge)')
219
+ .option('--cors-origin <origins>', 'Deprecated: no-op (CORS is handled by the front proxy)')
215
220
  .action(async (options) => {
221
+ if (options.production || options.corsOrigin) {
222
+ console.warn('[serve] --production/--cors-origin are deprecated no-ops; auth and CORS are enforced by the deploy edge proxy.');
223
+ }
216
224
  const verbose = program.opts().verbose;
217
225
  await (0, deps_1.ensureDeps)(verbose);
218
226
  await (0, serve_1.serveCommand)({
@@ -243,7 +251,7 @@ program
243
251
  .option('-t, --target <target>', 'Deploy target', 'vm')
244
252
  .option('-o, --out <path>', 'Staging directory (default: ./deploy-out/<pack>-<target>)')
245
253
  .option('--packdir <path>', 'Pack directory (defaults to [pack] arg or cwd)')
246
- .option('--image <ref>', 'Runtime image ref', 'ghcr.io/agenthippo/runtime:latest')
254
+ .option('--image <ref>', 'Runtime image ref (default: engine runtime:latest, or :latest-cursor for cursor packs)')
247
255
  .option('-p, --port <port>', 'Container port', '8080')
248
256
  .option('--gateway <channels>', 'Agent Anywhere: bind channels (e.g. "slack") + add OpenClaw gateway service (compose target)')
249
257
  .option('--hostname <host>', 'Public hostname for SSO edge (Caddy, OAuth, tunnel)', 'auth.agenthippo.ai')
@@ -36,7 +36,7 @@ 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_GATE_BUSY_MESSAGE = exports.TURN_GATE_RETRY_AFTER_SECONDS = exports.TURN_QUEUE_ENV = exports.MAX_CONCURRENT_TURNS_ENV = exports.TurnLimiter = 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
41
  exports.resolveBindHost = resolveBindHost;
42
42
  exports.clientIp = clientIp;
@@ -44,6 +44,8 @@ exports.resolveUserId = resolveUserId;
44
44
  exports.resolveDataToken = resolveDataToken;
45
45
  exports.peerToUserId = peerToUserId;
46
46
  exports.corsHeaders = corsHeaders;
47
+ exports.createTurnGateFromEnv = createTurnGateFromEnv;
48
+ exports.createRejectionWarner = createRejectionWarner;
47
49
  exports.readBodyWithCap = readBodyWithCap;
48
50
  /**
49
51
  * Serve helpers: state-dir resolution, identity header reading, CORS, turn limiting, body cap.
@@ -96,21 +98,29 @@ exports.CONTAINER_SERVE_HOST = '0.0.0.0';
96
98
  function resolveBindHost(hostOpt, env = process.env) {
97
99
  return hostOpt?.trim() || env.AGENTHIPPO_SERVE_HOST?.trim() || exports.DEFAULT_BIND_HOST;
98
100
  }
101
+ /** Env var: number of trusted proxy hops in front of serve (default 1 — the single Caddy). */
102
+ exports.XFF_TRUSTED_HOPS_ENV = 'AGENTHIPPO_XFF_TRUSTED_HOPS';
99
103
  /**
100
104
  * Best-effort client IP for rate-limiting keys.
101
105
  *
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.
106
+ * Each trusted proxy appends the IP it observed to `X-Forwarded-For`, so with N trusted hops
107
+ * the real client is the Nth entry from the right (a client-spoofed left-most entry is just
108
+ * prepended and never reached). Default is 1 (the documented single Caddy). Behind
109
+ * Cloudflare Tunnel → Caddy there are two hops — Caddy appends cloudflared's container IP,
110
+ * which is identical for every client, so leaving hops at 1 there collapses all users into one
111
+ * rate-limit bucket. Set AGENTHIPPO_XFF_TRUSTED_HOPS=2 for the tunnel profile.
112
+ * Falls back to the socket address for direct/local callers. This is a coarse abuse key, not
113
+ * an identity — never use it for authorization.
106
114
  */
107
- function clientIp(req) {
115
+ function clientIp(req, env = process.env) {
108
116
  const xff = req.headers['x-forwarded-for'];
109
117
  const raw = Array.isArray(xff) ? xff[0] : xff;
110
118
  if (raw) {
111
119
  const parts = raw.split(',').map((s) => s.trim()).filter(Boolean);
112
120
  if (parts.length > 0) {
113
- return parts[parts.length - 1];
121
+ const hopsRaw = Number(env[exports.XFF_TRUSTED_HOPS_ENV]);
122
+ const hops = Number.isInteger(hopsRaw) && hopsRaw >= 1 ? hopsRaw : 1;
123
+ return parts[Math.max(0, parts.length - hops)];
114
124
  }
115
125
  }
116
126
  return req.socket?.remoteAddress || 'unknown';
@@ -176,12 +186,25 @@ const HEADER_ALIASES = [
176
186
  'x-goog-authenticated-user-email', // Cloud Run IAP (prefixed value, stripped below)
177
187
  'cf-access-authenticated-user-email', // Cloudflare Access
178
188
  ];
189
+ /** Env var: pin the single identity header the deploy layer actually sets (and strips). */
190
+ exports.IDENTITY_HEADER_ENV = 'AGENTHIPPO_IDENTITY_HEADER';
179
191
  /**
180
192
  * Resolve the end-user identity for a request.
181
193
  *
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).
194
+ * Proxy headers win over the request body. When AGENTHIPPO_IDENTITY_HEADER pins
195
+ * trust to one header, that pin is a hard boundary: if the pinned header is
196
+ * absent, identity resolves to undefined (never the request body) — a deployment
197
+ * that pins a header has declared it has a real proxy-verified identity, so an
198
+ * unauthenticated caller must not be able to name themselves via the client-
199
+ * controlled body. Body fallback is dev/internal convenience for the *unpinned*
200
+ * case only (e.g. direct curl against a local serve with no identity proxy).
201
+ *
202
+ * The alias-list default (used when unpinned) is only safe when the proxy strips
203
+ * or overwrites ALL of the aliases; platforms that strip only their own header
204
+ * (Cloud Run IAP strips `X-Goog-*` but forwards a client-supplied `x-agent-user`
205
+ * untouched, which the alias list checks first) MUST pin theirs — deploy
206
+ * artifacts set it (e.g. cloudrun-deploy.sh pins `x-goog-authenticated-user-email`)
207
+ * so an authenticated caller cannot impersonate another user.
185
208
  *
186
209
  * Resolves identity for `analyticsUserId` attribution and the request-scoped
187
210
  * credential context (per-user data scoping). Not folded into the session key —
@@ -190,8 +213,10 @@ const HEADER_ALIASES = [
190
213
  * Per-turn *data credentials* (OBO token, e.g. X-Forwarded-Access-Token) are
191
214
  * handled by resolveDataToken — not read here.
192
215
  */
193
- function resolveUserId(headers, body) {
194
- for (const name of HEADER_ALIASES) {
216
+ function resolveUserId(headers, body, env = process.env) {
217
+ const pinned = env[exports.IDENTITY_HEADER_ENV]?.trim().toLowerCase();
218
+ const names = pinned ? [pinned] : HEADER_ALIASES;
219
+ for (const name of names) {
195
220
  const v = headers[name];
196
221
  const raw = (Array.isArray(v) ? v[0] : v)?.trim();
197
222
  if (raw) {
@@ -201,6 +226,12 @@ function resolveUserId(headers, body) {
201
226
  return value;
202
227
  }
203
228
  }
229
+ if (pinned) {
230
+ // Pinned but absent: fail closed. Falling back to the body here would let any
231
+ // caller that merely reaches this port (proxy misconfiguration, direct network
232
+ // access, gcloud run services proxy bypassing IAP) name an arbitrary victim.
233
+ return undefined;
234
+ }
204
235
  const fromBody = body?.user_id?.trim();
205
236
  return fromBody || undefined;
206
237
  }
@@ -281,6 +312,121 @@ class TurnLimiter {
281
312
  }
282
313
  }
283
314
  exports.TurnLimiter = TurnLimiter;
315
+ /** Env: max concurrent engine turns per process (default 10). `0` disables the gate. */
316
+ exports.MAX_CONCURRENT_TURNS_ENV = 'AGENTHIPPO_MAX_CONCURRENT_TURNS';
317
+ /** Env: bounded FIFO waiter depth when at inflight cap (default 20). */
318
+ exports.TURN_QUEUE_ENV = 'AGENTHIPPO_TURN_QUEUE';
319
+ /** Fixed Retry-After seconds when the turn gate rejects (matches default queue timeout). */
320
+ exports.TURN_GATE_RETRY_AFTER_SECONDS = 15;
321
+ /** User-visible capacity message for gateway channels (Slack/Telegram can't receive HTTP 429). */
322
+ exports.TURN_GATE_BUSY_MESSAGE = 'At capacity — please retry in a moment';
323
+ /**
324
+ * Process-wide concurrency gate for engine turns (semaphore + bounded FIFO).
325
+ *
326
+ * Caps simultaneous engine subprocesses so a burst cannot OOM the container.
327
+ * `maxInflight <= 0` disables. Waiters time out after `queueTimeoutMs` (burst
328
+ * absorber, not a job queue). Rejected callers get `null` — HTTP maps that to
329
+ * 429 + Retry-After; the gateway path emits a short channel message instead.
330
+ */
331
+ class TurnGate {
332
+ maxInflight;
333
+ maxQueue;
334
+ queueTimeoutMs;
335
+ inflightCount = 0;
336
+ waiters = [];
337
+ constructor(maxInflight, maxQueue, queueTimeoutMs = exports.TURN_GATE_RETRY_AFTER_SECONDS * 1000) {
338
+ this.maxInflight = maxInflight;
339
+ this.maxQueue = maxQueue;
340
+ this.queueTimeoutMs = queueTimeoutMs;
341
+ }
342
+ /**
343
+ * Acquire a turn slot. Resolves with a `release()` callback, or `null` when
344
+ * the queue is full or the wait timed out. Always call `release()` in a
345
+ * `finally` when non-null.
346
+ */
347
+ async acquire() {
348
+ if (!Number.isFinite(this.maxInflight) || this.maxInflight <= 0) {
349
+ return () => { };
350
+ }
351
+ if (this.inflightCount < this.maxInflight) {
352
+ this.inflightCount += 1;
353
+ return () => this.releaseSlot();
354
+ }
355
+ if (this.waiters.length >= this.maxQueue) {
356
+ return null;
357
+ }
358
+ return new Promise((resolve) => {
359
+ let settled = false;
360
+ const settle = (release) => {
361
+ if (settled) {
362
+ // Timed out after a slot was already offered — give the slot back.
363
+ if (release) {
364
+ release();
365
+ }
366
+ return;
367
+ }
368
+ settled = true;
369
+ clearTimeout(timer);
370
+ resolve(release);
371
+ };
372
+ const entry = {
373
+ resolve: settle,
374
+ timer: null,
375
+ };
376
+ const timer = setTimeout(() => {
377
+ const idx = this.waiters.indexOf(entry);
378
+ if (idx >= 0) {
379
+ this.waiters.splice(idx, 1);
380
+ }
381
+ settle(null);
382
+ }, this.queueTimeoutMs);
383
+ entry.timer = timer;
384
+ this.waiters.push(entry);
385
+ });
386
+ }
387
+ releaseSlot() {
388
+ const next = this.waiters.shift();
389
+ if (next) {
390
+ // Transfer the slot to the head waiter — do not decrement inflight.
391
+ next.resolve(() => this.releaseSlot());
392
+ return;
393
+ }
394
+ this.inflightCount = Math.max(0, this.inflightCount - 1);
395
+ }
396
+ get inflight() {
397
+ return this.inflightCount;
398
+ }
399
+ get queued() {
400
+ return this.waiters.length;
401
+ }
402
+ get max() {
403
+ return this.maxInflight > 0 ? this.maxInflight : 0;
404
+ }
405
+ }
406
+ exports.TurnGate = TurnGate;
407
+ /** Build a TurnGate from env (serve-v4 P0.1). Defaults: 10 inflight, 20 queued. */
408
+ function createTurnGateFromEnv(env = process.env) {
409
+ const inflightRaw = Number(env[exports.MAX_CONCURRENT_TURNS_ENV] ?? 10);
410
+ const queueRaw = Number(env[exports.TURN_QUEUE_ENV] ?? 20);
411
+ const maxInflight = Number.isFinite(inflightRaw) ? inflightRaw : 10;
412
+ const maxQueue = Number.isFinite(queueRaw) && queueRaw >= 0 ? queueRaw : 20;
413
+ return new TurnGate(maxInflight, maxQueue);
414
+ }
415
+ /**
416
+ * Rate-limited rejection logger — at most one warn per `intervalMs` so a flood
417
+ * of 429s does not spam the console.
418
+ */
419
+ function createRejectionWarner(intervalMs = 5_000) {
420
+ let lastAt = 0;
421
+ return (msg) => {
422
+ const now = Date.now();
423
+ if (now - lastAt < intervalMs) {
424
+ return;
425
+ }
426
+ lastAt = now;
427
+ console.warn(msg);
428
+ };
429
+ }
284
430
  /**
285
431
  * Read a request body with a hard size cap. Past the cap it stops accumulating
286
432
  * and rejects with a `tooLarge`-tagged error so the caller can respond 413.