@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.
@@ -51,6 +51,7 @@ exports.wrapEmitterWithObservability = wrapEmitterWithObservability;
51
51
  const path = __importStar(require("path"));
52
52
  const fs_1 = require("fs");
53
53
  const branding_1 = require("../branding");
54
+ const credential_context_1 = require("../cli/credential-context");
54
55
  const os = __importStar(require("os"));
55
56
  // -----------------------------------------------------------------------------
56
57
  // Session Event Writer
@@ -175,6 +176,9 @@ class ObservableStreamEmitter {
175
176
  callId: loggedCallId,
176
177
  // Truncate input to avoid huge JSONL files
177
178
  inputPreview: input?.slice(0, 500),
179
+ // The warrant: who this tool call ran as, read live at call time (never cached).
180
+ // Undefined outside a runWithCredential scope (e.g. IDE-local, non-serve usage).
181
+ userId: (0, credential_context_1.currentCredential)()?.userId,
178
182
  });
179
183
  return this.inner.toolStart(toolName, input, toolCallId, metadata);
180
184
  }
@@ -188,6 +192,7 @@ class ObservableStreamEmitter {
188
192
  isError: isError ?? false,
189
193
  // Truncate result to avoid huge JSONL files
190
194
  resultPreview: result?.slice(0, 500),
195
+ userId: (0, credential_context_1.currentCredential)()?.userId,
191
196
  });
192
197
  return this.inner.toolEnd(toolName, toolCallId, result, isError, metadata);
193
198
  }
@@ -53,5 +53,7 @@ function codeChallengeFromVerifier(verifier) {
53
53
  return crypto.createHash('sha256').update(verifier).digest('base64url');
54
54
  }
55
55
  function verifyPkceChallenge(verifier, challenge) {
56
- return codeChallengeFromVerifier(verifier) === challenge;
56
+ const expected = Buffer.from(codeChallengeFromVerifier(verifier));
57
+ const actual = Buffer.from(challenge);
58
+ return expected.length === actual.length && crypto.timingSafeEqual(expected, actual);
57
59
  }
@@ -508,10 +508,16 @@ function createAuthBrokerServer(config) {
508
508
  sendJson(res, 404, { error: 'not_found' });
509
509
  }
510
510
  catch (err) {
511
- if (err.status === 413) {
511
+ const status = err.status;
512
+ if (status === 413) {
512
513
  sendJson(res, 413, { error: 'request_entity_too_large' });
513
514
  return;
514
515
  }
516
+ if (status === 403) {
517
+ audit('forbidden', { reason: 'email_domain' });
518
+ sendJson(res, 403, { error: 'forbidden', error_description: 'email domain not allowed' });
519
+ return;
520
+ }
515
521
  // Log the real error internally; return a generic message so clients don't see
516
522
  // stack fragments / internal paths in the 500 body.
517
523
  console.error('[auth-broker] internal error', err);
@@ -123,7 +123,9 @@ class AuthBrokerStore {
123
123
  upsertUser(email, displayName) {
124
124
  const normalized = email.trim().toLowerCase();
125
125
  if (!(0, config_js_1.emailDomainAllowed)(normalized, this.config.allowedEmailDomains)) {
126
- throw new Error(`Email domain not allowed for sign-in: ${normalized}`);
126
+ const err = new Error(`Email domain not allowed for sign-in: ${normalized}`);
127
+ err.status = 403;
128
+ throw err;
127
129
  }
128
130
  for (const user of this.users.values()) {
129
131
  if (user.email === normalized) {
@@ -219,7 +221,6 @@ class AuthBrokerStore {
219
221
  };
220
222
  }
221
223
  buildBootstrap(user) {
222
- const expiresAt = new Date(Date.now() + this.config.accessTokenTtlSec * 1000).toISOString();
223
224
  return {
224
225
  version: 1,
225
226
  user: {
@@ -240,7 +241,8 @@ class AuthBrokerStore {
240
241
  routing: {
241
242
  litellmBaseUrl: this.config.team.litellmBaseUrl,
242
243
  litellmApiKey: this.config.team.litellmApiKey,
243
- litellmApiKeyExpiresAt: expiresAt,
244
+ // No litellmApiKeyExpiresAt: the team key is static — a fabricated expiry
245
+ // would only pretend rotation exists. Per-user virtual keys are the upgrade path.
244
246
  },
245
247
  policy: {
246
248
  byokAllowed: true,
@@ -249,10 +251,9 @@ class AuthBrokerStore {
249
251
  };
250
252
  }
251
253
  refreshRoutingCredentials() {
252
- const litellmApiKeyExpiresAt = new Date(Date.now() + this.config.accessTokenTtlSec * 1000).toISOString();
254
+ // The team key is static (no rotation backend yet), so no expiry is reported.
253
255
  return {
254
256
  litellmApiKey: this.config.team.litellmApiKey,
255
- litellmApiKeyExpiresAt,
256
257
  };
257
258
  }
258
259
  buildMe(user) {
@@ -49,6 +49,8 @@ const utils_1 = require("../utils");
49
49
  const runtime_1 = require("../runtime");
50
50
  const serve_hardening_1 = require("../serve-hardening");
51
51
  const credential_context_1 = require("../credential-context");
52
+ const sessionEvents_1 = require("../../analytics/sessionEvents");
53
+ const base_1 = require("../../engines/base");
52
54
  const state_sync_1 = require("../state-sync");
53
55
  const cli_agent_id_js_1 = require("../../gateway/cli-agent-id.js");
54
56
  const openclaw_channel_routing_js_1 = require("../../gateway/openclaw-channel-routing.js");
@@ -93,11 +95,20 @@ async function serveCommand(options) {
93
95
  // Resolve persistent session state (serve-v3 §A1: replaces the tmpdir default).
94
96
  const { dir: storagePath, ephemeral } = (0, serve_hardening_1.resolveStateDir)(options.stateDir);
95
97
  const bindHost = (0, serve_hardening_1.resolveBindHost)(options.host);
98
+ // Same durable root + subpath the runner's own SessionEventWriter uses (headless/index.ts),
99
+ // so a fail-closed warrant denial lands in the same per-session JSONL as the tool events it
100
+ // precedes — the denial happens before runner.run() is ever called, so it needs its own writer.
101
+ const sessionEventWriter = new sessionEvents_1.SessionEventWriter(path.join(storagePath, 'analytics', 'sessions'));
102
+ const logWarrantDenied = (sessionKey) => (reason) => sessionEventWriter.append(sessionKey, (0, base_1.generateRequestId)(), 'warrant_denied', { reason });
96
103
  const turnLimiter = new serve_hardening_1.TurnLimiter(options.maxTurnsPerSession ?? 0);
97
104
  // Per-identity request-rate cap (req/min). Keyed on the trusted user id (falling back to
98
105
  // client IP) so rotating session_id can't bypass it. AGENTHIPPO_SERVE_RATE_LIMIT=0 disables.
99
106
  const rateLimitPerMin = Number(process.env.AGENTHIPPO_SERVE_RATE_LIMIT ?? 120);
100
107
  const rateLimiter = new serve_hardening_1.RateLimiter(Number.isFinite(rateLimitPerMin) ? rateLimitPerMin : 120);
108
+ // Process-wide inflight cap (serve-v4 P0.1). Prevents a burst from spawning N engine
109
+ // subprocesses and OOMing the container. AGENTHIPPO_MAX_CONCURRENT_TURNS=0 disables.
110
+ const turnGate = (0, serve_hardening_1.createTurnGateFromEnv)();
111
+ const warnGateReject = (0, serve_hardening_1.createRejectionWarner)();
101
112
  let landingPageHtml;
102
113
  if (options.wwwDir?.trim()) {
103
114
  const indexPath = path.join(path.resolve(options.wwwDir.trim()), 'index.html');
@@ -111,21 +122,34 @@ async function serveCommand(options) {
111
122
  if (ephemeral) {
112
123
  console.warn(`${utils_1.COLORS.yellow}Warning:${utils_1.COLORS.reset} Session state is in a tmpdir (ephemeral). Pass --state-dir or set AGENTHIPPO_HOME to persist sessions across restarts.`);
113
124
  }
114
- // Q1: object-store state sync (no-op unless AGENTHIPPO_STATE_S3_URI is set).
115
- // Boot-restore so sessions survive a fresh instance with no FS mount (R2/S3/Databricks).
125
+ // Q1 + serve-v4 P1.2: object-store state sync (no-op unless AGENTHIPPO_STATE_S3_URI is set).
126
+ // Multi-replica: AGENTHIPPO_STATE_RESTORE_BOOT=none + restore-on-miss per session.
127
+ // Single-instance default: boot-restore the whole prefix (backward compatible).
116
128
  const s3State = (0, state_sync_1.resolveS3StateConfig)();
129
+ const restoreBoot = (0, state_sync_1.resolveRestoreBootMode)();
117
130
  if (s3State) {
118
131
  // Preflight: state sync shells out to `aws`. If it's missing, sessions would
119
132
  // silently never persist — fail fast since durable state was explicitly opted into.
120
133
  if (!(await (0, state_sync_1.isAwsCliAvailable)())) {
121
134
  console.error(`${utils_1.COLORS.red}Error: AGENTHIPPO_STATE_S3_URI is set but the \`aws\` CLI was not found.${utils_1.COLORS.reset}`);
122
- console.error(' State sync requires the AWS CLI. Install it (https://aws.amazon.com/cli/) or unset AGENTHIPPO_STATE_S3_URI.');
135
+ console.error(' State sync requires the AWS CLI (S3-compatible via --endpoint-url). Install it or unset AGENTHIPPO_STATE_S3_URI.');
123
136
  process.exit(1);
124
137
  }
125
- console.log(`State sync: restoring from ${s3State.uri} …`);
126
- await (0, state_sync_1.restoreState)(s3State, storagePath, (m) => console.log(` ${m}`));
138
+ if (restoreBoot === 'all') {
139
+ console.log(`State sync: boot-restore from ${s3State.uri} …`);
140
+ await (0, state_sync_1.restoreState)(s3State, storagePath, (m) => console.log(` ${m}`));
141
+ }
142
+ else {
143
+ console.log(`State sync: restore-on-miss from ${s3State.uri} (boot restore disabled)`);
144
+ }
127
145
  }
128
146
  const statePusher = s3State ? (0, state_sync_1.makeStatePusher)(s3State, storagePath, options.verbose ? (m) => console.error(m) : undefined) : null;
147
+ const stateLog = options.verbose ? (m) => console.error(m) : undefined;
148
+ const prepareSession = async (sessionKey) => {
149
+ if (s3State) {
150
+ await (0, state_sync_1.ensureSessionLocal)(s3State, storagePath, sessionKey, stateLog);
151
+ }
152
+ };
129
153
  const logger = options.verbose ? new headless_1.ConsoleLogger('[Server]') : new headless_1.NoOpLogger();
130
154
  // Find SDK CLI paths
131
155
  const claudeCodePath = (0, utils_1.findClaudeCodePath)();
@@ -192,9 +216,15 @@ async function serveCommand(options) {
192
216
  return;
193
217
  }
194
218
  // Readiness — pack synced (+ gateway connected when bridged).
219
+ // Inflight/queue depth for operators and load balancers (serve-v4 P0.1).
195
220
  if (req.method === 'GET' && req.url === '/ready') {
196
221
  res.writeHead(ready ? 200 : 503, { 'Content-Type': 'application/json' });
197
- res.end(JSON.stringify({ ready }));
222
+ res.end(JSON.stringify({
223
+ ready,
224
+ inflight: turnGate.inflight,
225
+ queued: turnGate.queued,
226
+ max: turnGate.max,
227
+ }));
198
228
  return;
199
229
  }
200
230
  const urlPath = req.url?.split('?')[0] ?? '';
@@ -208,22 +238,41 @@ async function serveCommand(options) {
208
238
  const body = await parseBody(req);
209
239
  const userId = (0, serve_hardening_1.resolveUserId)(req.headers, body);
210
240
  const dataToken = (0, serve_hardening_1.resolveDataToken)(req.headers);
211
- const rlKey = userId ?? (0, serve_hardening_1.clientIp)(req);
241
+ // Rate-limit on the trusted proxy identity (or client IP), never the client-supplied
242
+ // body user_id — otherwise a caller rotates user_id per request to reset the bucket.
243
+ const rlKey = (0, serve_hardening_1.resolveUserId)(req.headers, undefined) ?? (0, serve_hardening_1.clientIp)(req);
212
244
  if (!rateLimiter.tryConsume(rlKey)) {
213
245
  res.writeHead(429, { 'Content-Type': 'application/json', 'Retry-After': String(rateLimiter.retryAfterSeconds(rlKey)) });
214
246
  res.end(JSON.stringify({ error: 'Rate limit exceeded' }));
215
247
  return;
216
248
  }
217
249
  const sessionKey = body.session_id || `anon-${userId ?? 'unknown'}`;
218
- if (!turnLimiter.tryConsume(sessionKey)) {
219
- res.writeHead(429, { 'Content-Type': 'application/json' });
220
- res.end(JSON.stringify({ error: 'Per-session turn limit reached' }));
250
+ // Acquire before turn-limiter so a capacity reject does not burn a turn slot.
251
+ const release = await turnGate.acquire();
252
+ if (!release) {
253
+ warnGateReject(`TurnGate: rejecting /v1/chat (inflight=${turnGate.inflight} queued=${turnGate.queued} max=${turnGate.max})`);
254
+ res.writeHead(429, {
255
+ 'Content-Type': 'application/json',
256
+ 'Retry-After': String(serve_hardening_1.TURN_GATE_RETRY_AFTER_SECONDS),
257
+ });
258
+ res.end(JSON.stringify({ error: 'At capacity' }));
221
259
  return;
222
260
  }
223
- const result = await (0, credential_context_1.runWithCredential)({ userId: userId ?? 'anonymous', dataToken }, () => handleChat(runner, body, defaultEngine, defaultModel, options.workspace, permissions, userId), { dataTokenDir: storagePath });
224
- res.writeHead(200, { 'Content-Type': 'application/json' });
225
- res.end(JSON.stringify(result));
226
- statePusher?.schedule(); // async-delta push, off the response path
261
+ try {
262
+ if (!turnLimiter.tryConsume(sessionKey)) {
263
+ res.writeHead(429, { 'Content-Type': 'application/json' });
264
+ res.end(JSON.stringify({ error: 'Per-session turn limit reached' }));
265
+ return;
266
+ }
267
+ await prepareSession(sessionKey);
268
+ const result = await (0, credential_context_1.runWithCredential)({ userId, dataToken }, () => handleChat(runner, body, defaultEngine, defaultModel, options.workspace, permissions, userId), { dataTokenDir: storagePath, onDenied: logWarrantDenied(sessionKey) });
269
+ res.writeHead(200, { 'Content-Type': 'application/json' });
270
+ res.end(JSON.stringify(result));
271
+ statePusher?.schedule(sessionKey); // per-session delta push, off the response path
272
+ }
273
+ finally {
274
+ release();
275
+ }
227
276
  return;
228
277
  }
229
278
  // Streaming chat endpoint
@@ -231,25 +280,44 @@ async function serveCommand(options) {
231
280
  const body = await parseBody(req);
232
281
  const userId = (0, serve_hardening_1.resolveUserId)(req.headers, body);
233
282
  const dataToken = (0, serve_hardening_1.resolveDataToken)(req.headers);
234
- const rlKey = userId ?? (0, serve_hardening_1.clientIp)(req);
283
+ // Rate-limit on the trusted proxy identity (or client IP), never the client-supplied
284
+ // body user_id — otherwise a caller rotates user_id per request to reset the bucket.
285
+ const rlKey = (0, serve_hardening_1.resolveUserId)(req.headers, undefined) ?? (0, serve_hardening_1.clientIp)(req);
235
286
  if (!rateLimiter.tryConsume(rlKey)) {
236
287
  res.writeHead(429, { 'Content-Type': 'application/json', 'Retry-After': String(rateLimiter.retryAfterSeconds(rlKey)) });
237
288
  res.end(JSON.stringify({ error: 'Rate limit exceeded' }));
238
289
  return;
239
290
  }
240
291
  const sessionKey = body.session_id || `anon-${userId ?? 'unknown'}`;
241
- if (!turnLimiter.tryConsume(sessionKey)) {
242
- res.writeHead(429, { 'Content-Type': 'application/json' });
243
- res.end(JSON.stringify({ error: 'Per-session turn limit reached' }));
292
+ // Acquire BEFORE writeHead(200, SSE) so a reject still gets a clean 429.
293
+ const release = await turnGate.acquire();
294
+ if (!release) {
295
+ warnGateReject(`TurnGate: rejecting /v1/chat/stream (inflight=${turnGate.inflight} queued=${turnGate.queued} max=${turnGate.max})`);
296
+ res.writeHead(429, {
297
+ 'Content-Type': 'application/json',
298
+ 'Retry-After': String(serve_hardening_1.TURN_GATE_RETRY_AFTER_SECONDS),
299
+ });
300
+ res.end(JSON.stringify({ error: 'At capacity' }));
244
301
  return;
245
302
  }
246
- res.writeHead(200, {
247
- 'Content-Type': 'text/event-stream',
248
- 'Cache-Control': 'no-cache',
249
- 'Connection': 'keep-alive',
250
- });
251
- await (0, credential_context_1.runWithCredential)({ userId: userId ?? 'anonymous', dataToken }, () => handleStreamingChat(runner, body, defaultEngine, defaultModel, options.workspace, permissions, res, userId), { dataTokenDir: storagePath });
252
- statePusher?.schedule(); // async-delta push, off the response path
303
+ try {
304
+ if (!turnLimiter.tryConsume(sessionKey)) {
305
+ res.writeHead(429, { 'Content-Type': 'application/json' });
306
+ res.end(JSON.stringify({ error: 'Per-session turn limit reached' }));
307
+ return;
308
+ }
309
+ await prepareSession(sessionKey);
310
+ res.writeHead(200, {
311
+ 'Content-Type': 'text/event-stream',
312
+ 'Cache-Control': 'no-cache',
313
+ 'Connection': 'keep-alive',
314
+ });
315
+ await (0, credential_context_1.runWithCredential)({ userId, dataToken }, () => handleStreamingChat(runner, body, defaultEngine, defaultModel, options.workspace, permissions, res, userId), { dataTokenDir: storagePath, onDenied: logWarrantDenied(sessionKey) });
316
+ statePusher?.schedule(sessionKey); // per-session delta push, off the response path
317
+ }
318
+ finally {
319
+ release();
320
+ }
253
321
  return;
254
322
  }
255
323
  // 404
@@ -322,7 +390,11 @@ async function serveCommand(options) {
322
390
  defaultModel,
323
391
  runner,
324
392
  permissions,
325
- onTurnComplete: () => statePusher?.schedule(),
393
+ turnGate,
394
+ warnGateReject,
395
+ prepareSession,
396
+ onTurnComplete: (sessionId) => statePusher?.schedule(sessionId),
397
+ logWarrantDenied,
326
398
  });
327
399
  }
328
400
  catch (err) {
@@ -426,19 +498,39 @@ async function startServeGatewayBridge(params) {
426
498
  : (requestedEngine || params.engine);
427
499
  // #4: the channel sender becomes the per-user user_id (analytics + per-user data scoping).
428
500
  const userId = (0, serve_hardening_1.peerToUserId)(opts.peerInfo);
429
- const result = await (0, credential_context_1.runWithCredential)({ userId: userId ?? 'gateway' }, () => params.runner.run({
430
- engineId: effectiveEngine,
431
- modelId: params.defaultModel,
432
- message,
433
- workspaceRoot: params.workspace,
434
- sessionId: opts.sessionId,
435
- gatewaySessionKey: opts.gatewaySessionKey,
436
- analyticsUserId: userId,
437
- emitter: opts.emitter,
438
- permissions: params.permissions,
439
- }), { dataTokenDir: params.stateDir });
440
- params.onTurnComplete?.();
441
- return { success: result.success, error: result.error };
501
+ // Gateway can't return HTTP 429 — emit a short channel message and return busy.
502
+ const release = await params.turnGate.acquire();
503
+ if (!release) {
504
+ params.warnGateReject(`TurnGate: rejecting gateway turn (inflight=${params.turnGate.inflight} queued=${params.turnGate.queued} max=${params.turnGate.max})`);
505
+ try {
506
+ await opts.emitter?.text(serve_hardening_1.TURN_GATE_BUSY_MESSAGE);
507
+ await opts.emitter?.done();
508
+ }
509
+ catch {
510
+ // best-effort channel notice
511
+ }
512
+ return { success: false, error: 'busy' };
513
+ }
514
+ try {
515
+ const sessionKey = opts.sessionId?.trim() || opts.gatewaySessionKey?.trim() || `gateway-${userId ?? 'anon'}`;
516
+ await params.prepareSession(sessionKey);
517
+ const result = await (0, credential_context_1.runWithCredential)({ userId }, () => params.runner.run({
518
+ engineId: effectiveEngine,
519
+ modelId: params.defaultModel,
520
+ message,
521
+ workspaceRoot: params.workspace,
522
+ sessionId: opts.sessionId,
523
+ gatewaySessionKey: opts.gatewaySessionKey,
524
+ analyticsUserId: userId,
525
+ emitter: opts.emitter,
526
+ permissions: params.permissions,
527
+ }), { dataTokenDir: params.stateDir, onDenied: params.logWarrantDenied(sessionKey) });
528
+ params.onTurnComplete?.(sessionKey);
529
+ return { success: result.success, error: result.error };
530
+ }
531
+ finally {
532
+ release();
533
+ }
442
534
  },
443
535
  });
444
536
  console.log(`${utils_1.COLORS.green}✓${utils_1.COLORS.reset} Gateway bridge: registered "${params.gatewayAgentId}" at ${gatewayUrl}`);
@@ -36,6 +36,7 @@ var __importStar = (this && this.__importStar) || (function () {
36
36
  };
37
37
  })();
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.WARRANT_DENIED_REASON = void 0;
39
40
  exports.runWithCredential = runWithCredential;
40
41
  exports.currentCredential = currentCredential;
41
42
  exports.requireUserId = requireUserId;
@@ -58,12 +59,27 @@ const async_hooks_1 = require("async_hooks");
58
59
  const node_crypto_1 = require("node:crypto");
59
60
  const node_fs_1 = require("node:fs");
60
61
  const path = __importStar(require("path"));
62
+ /** Stable, queryable reason code for the one case prepareCredentialScope rejects. */
63
+ exports.WARRANT_DENIED_REASON = 'token_without_verified_user';
61
64
  const storage = new async_hooks_1.AsyncLocalStorage();
62
65
  function tokenDir(baseDir) {
63
66
  return path.join(baseDir, 'mcp-data-tokens');
64
67
  }
65
68
  async function prepareCredentialScope(cred, options) {
66
69
  const token = cred.dataToken?.trim();
70
+ // A per-turn OBO credential without a verified identity would hand a tool someone's
71
+ // live data-plane token while the turn can't attribute or scope its use to anyone —
72
+ // fail closed before the credential is ever materialized (file or in-process), rather
73
+ // than let a tool run with a real credential attached to an unknown/placeholder user.
74
+ if (token && !cred.userId?.trim()) {
75
+ try {
76
+ await options?.onDenied?.(exports.WARRANT_DENIED_REASON);
77
+ }
78
+ catch {
79
+ // Evidence logging must never suppress or replace the fail-closed error below.
80
+ }
81
+ throw new Error(`OBO data token present without a verified user in request scope (fail-closed: ${exports.WARRANT_DENIED_REASON})`);
82
+ }
67
83
  const baseDir = options?.dataTokenDir?.trim();
68
84
  if (!token || !baseDir) {
69
85
  return cred;
@@ -108,11 +108,11 @@ exports.DEFAULT_DEPLOY_PUBLIC_HOSTNAME = 'auth.agenthippo.ai';
108
108
  /** @deprecated Use {@link DEFAULT_DEPLOY_PUBLIC_HOSTNAME}. */
109
109
  exports.DEPLOY_PUBLIC_HOSTNAME = exports.DEFAULT_DEPLOY_PUBLIC_HOSTNAME;
110
110
  /** npm package for Databricks Apps source deploy (org: agenthippo-ai). */
111
- exports.AGENTHIPPO_CLI_NPM_PACKAGE = '@agenthippo-ai/cli';
111
+ exports.AGENTHIPPO_CLI_NPM_PACKAGE = '@agenthippo-ai/agenthippo';
112
112
  /** Fallback when monorepo package.json is not present (customer runs published CLI). */
113
113
  exports.AGENTHIPPO_CLI_NPM_VERSION_FALLBACK = 'latest';
114
114
  /**
115
- * Pin @agenthippo-ai/cli to the monorepo release version when `deploy init` runs from
115
+ * Pin @agenthippo-ai/agenthippo to the monorepo release version when `deploy init` runs from
116
116
  * a dev checkout; otherwise fall back to `latest`.
117
117
  */
118
118
  function resolveAgentHippoCliNpmVersion() {
@@ -202,7 +202,7 @@ function renderEnvExample(vars, opts) {
202
202
  }
203
203
  }
204
204
  if (opts?.edge) {
205
- lines.push('', '# Public hostname — set once; Caddyfile reads {$AGENTHIPPO_PUBLIC_HOSTNAME}.', `AGENTHIPPO_PUBLIC_HOSTNAME=${hostname}`, '', '# Edge auth secrets (JWT signing key, Google OAuth client/cookie) live in .env.auth,', '# which is loaded ONLY by the auth-broker and oauth2-proxy services — never the agent.', '# Keeping the token-signing key out of the agent container limits blast radius if the', '# agent (which runs model-driven tool calls) is compromised. Copy .env.auth.example.', '', '# Cloudflare Tunnel (free — no Access seats), config-file mode in compose:', '# cloudflared tunnel create <name> → save the JSON as deploy/tunnel-credentials.json (gitignored)', '# set the tunnel id in deploy/cloudflared.yml; cloudflared tunnel route dns <name> <hostname>', '# No token/env needed — the credentials JSON is mounted into the cloudflared container.');
205
+ lines.push('', '# Public hostname — set once; Caddyfile reads {$AGENTHIPPO_PUBLIC_HOSTNAME}.', `AGENTHIPPO_PUBLIC_HOSTNAME=${hostname}`, '', '# Multi-replica (serve-v4 P1): sticky by X-Agent-User in Caddyfile.', '# docker compose up --scale agent=N', '# AGENTHIPPO_STATE_S3_URI=s3://bucket/prefix # S3-compatible (MinIO/R2/GCS/AWS)', '# AGENTHIPPO_STATE_S3_ENDPOINT= # optional, e.g. http://minio:9000', '# AGENTHIPPO_STATE_RESTORE_BOOT=none # restore-on-miss (required when N>1)', '# Scale-down: drain (/ready inflight=0) then remove replica — see serve-v3-RUNBOOK.', '', '# Edge auth secrets (JWT signing key, Google OAuth client/cookie) live in .env.auth,', '# which is loaded ONLY by the auth-broker and oauth2-proxy services — never the agent.', '# Keeping the token-signing key out of the agent container limits blast radius if the', '# agent (which runs model-driven tool calls) is compromised. Copy .env.auth.example.', '', '# Cloudflare Tunnel (free — no Access seats), config-file mode in compose:', '# cloudflared tunnel create <name> → save the JSON as deploy/tunnel-credentials.json (gitignored)', '# set the tunnel id in deploy/cloudflared.yml; cloudflared tunnel route dns <name> <hostname>', '# No token/env needed — the credentials JSON is mounted into the cloudflared container.');
206
206
  }
207
207
  return lines.join('\n') + '\n';
208
208
  }
@@ -229,6 +229,7 @@ function renderAuthEnvExample(opts) {
229
229
  'AGENTHIPPO_AUTH_JWT_SECRET= # copy from auth.agenthippo.ai (openssl rand -hex 32 to generate one)',
230
230
  `AGENTHIPPO_AUTH_PUBLIC_URL=https://${hostname}`,
231
231
  'AGENTHIPPO_AUTH_ALLOWED_DOMAINS=agenthippo.ai',
232
+ 'AGENTHIPPO_AUTH_DEV=0 # 1 enables /oauth/dev/login (tests only; never in production)',
232
233
  `AGENTHIPPO_AUTH_COOKIE_DOMAIN=${cookieDomain} # scope the shared cookie to all subdomains`,
233
234
  `AGENTHIPPO_AUTH_IDP_URL=${idp} # where browsers are sent to log in`,
234
235
  'AGENTHIPPO_AUTH_COOKIE_NAME=ah_token',
@@ -267,6 +268,7 @@ function renderAuthEnvExample(opts) {
267
268
  'AGENTHIPPO_AUTH_JWT_SECRET= # openssl rand -hex 32',
268
269
  'AGENTHIPPO_AUTH_PUBLIC_URL=https://' + hostname,
269
270
  'AGENTHIPPO_AUTH_ALLOWED_DOMAINS=example.com',
271
+ 'AGENTHIPPO_AUTH_DEV=0 # 1 enables /oauth/dev/login (tests only; never in production)',
270
272
  'AGENTHIPPO_AUTH_COOKIE_NAME=ah_token # federated cookie holding the AgentHippo JWT',
271
273
  'AGENTHIPPO_AUTH_COOKIE_DOMAIN=.agenthippo.ai # set so /oauth/browser-login stamps a cross-subdomain cookie',
272
274
  'AGENTHIPPO_TEAM_ID=team_default',
@@ -448,6 +450,23 @@ function renderLandingPage(packName) {
448
450
  '',
449
451
  ].join('\n');
450
452
  }
453
+ /** Sticky upstream for multi-replica serve (serve-v4 P1.1).
454
+ * Hash on X-Agent-User (SSO identity the edge already injects) so a user's
455
+ * sessions stay on one replica — keeps local state + in-process RateLimiter
456
+ * correct without Redis. Fallback ip_hash for tokened API callers without SSO.
457
+ * Scale: `docker compose up --scale agent=N` (set AGENTHIPPO_STATE_RESTORE_BOOT=none).
458
+ */
459
+ function renderStickyAgentProxy(port, indent = ' ') {
460
+ return [
461
+ `${indent}# serve-v4 P1.1: sticky by user identity (not session body — edge can't hash POST).`,
462
+ `${indent}# In-process RateLimiter is globally correct per user under this policy.`,
463
+ `${indent}reverse_proxy agent:${port} {`,
464
+ `${indent} lb_policy header X-Agent-User {`,
465
+ `${indent} fallback ip_hash`,
466
+ `${indent} }`,
467
+ `${indent}}`,
468
+ ];
469
+ }
451
470
  /** Caddy config for the `edge` compose profile (oauth2-proxy + agent on Docker network). */
452
471
  function renderCaddyfile(ctx) {
453
472
  return [
@@ -458,6 +477,24 @@ function renderCaddyfile(ctx) {
458
477
  '}',
459
478
  '',
460
479
  'http://{$AGENTHIPPO_PUBLIC_HOSTNAME} {',
480
+ ' # IDE PKCE sign-in: broker requires X-Auth-Request-Email from oauth2-proxy. Must come',
481
+ ' # before the generic /oauth/* handle so authorize is gated (not dumped straight to broker).',
482
+ ' handle /oauth/authorize {',
483
+ ' forward_auth oauth2-proxy:4180 {',
484
+ ' uri /oauth2/auth',
485
+ ' copy_headers X-Auth-Request-Email',
486
+ ' header_up X-Forwarded-Proto https',
487
+ ' header_up X-Forwarded-Host {http.request.host}',
488
+ ' @unauth status 401',
489
+ ' handle_response @unauth {',
490
+ ' redir * "/oauth2/sign_in?rd={http.request.uri}"',
491
+ ' }',
492
+ ' }',
493
+ ' reverse_proxy auth-broker:9780 {',
494
+ ' header_up X-Forwarded-Proto https',
495
+ ' header_up X-Forwarded-Host {http.request.host}',
496
+ ' }',
497
+ ' }',
461
498
  ' # Federated SSO (A′): browsers from *.agenthippo.ai subdomains land here to log in.',
462
499
  ' # Gated behind oauth2-proxy (Google) → auth-broker mints + sets the shared .agenthippo.ai',
463
500
  ' # cookie (ah_token), then redirects back to the agent subdomain (`return_to`). Must come',
@@ -498,7 +535,7 @@ function renderCaddyfile(ctx) {
498
535
  ' }',
499
536
  ' @health path /health /ready',
500
537
  ' handle @health {',
501
- ` reverse_proxy agent:${ctx.port}`,
538
+ ...renderStickyAgentProxy(ctx.port, ' '),
502
539
  ' }',
503
540
  ' # API clients (IDE) with a Bearer token: validate via auth-broker, copy identity header.',
504
541
  ' @apiWithAuth {',
@@ -516,7 +553,7 @@ function renderCaddyfile(ctx) {
516
553
  ' respond "Unauthorized" 401',
517
554
  ' }',
518
555
  ' }',
519
- ` reverse_proxy agent:${ctx.port}`,
556
+ ...renderStickyAgentProxy(ctx.port, ' '),
520
557
  ' }',
521
558
  ' # Browser clients: SSO via oauth2-proxy, identity injected as X-Agent-User.',
522
559
  ' handle {',
@@ -532,7 +569,7 @@ function renderCaddyfile(ctx) {
532
569
  ' }',
533
570
  ' @api path /v1/*',
534
571
  ' handle @api {',
535
- ` reverse_proxy agent:${ctx.port}`,
572
+ ...renderStickyAgentProxy(ctx.port, ' '),
536
573
  ' }',
537
574
  ' root * /srv/www',
538
575
  ' file_server',
@@ -967,6 +1004,11 @@ function renderCloudrunServiceYaml(ctx) {
967
1004
  ' env:',
968
1005
  ' - { name: AGENTHIPPO_HOME, value: /state }',
969
1006
  ' - { name: AGENTHIPPO_NO_AUTO_INSTALL, value: "1" }',
1007
+ // Pin serve to trust ONLY the IAP-verified header, so an authenticated caller
1008
+ // cannot impersonate another user via a spoofed X-Agent-User header. The deploy
1009
+ // script fills this with x-goog-authenticated-user-email when IAP is enabled, or
1010
+ // leaves it empty (unpinned) during an ENABLE_IAP=0 bootstrap deploy.
1011
+ ' - { name: AGENTHIPPO_IDENTITY_HEADER, value: "${IDENTITY_HEADER_VALUE}" }',
970
1012
  agentServeHostEnvYaml(),
971
1013
  ' - { name: AGENTHIPPO_LITELLM_BASE_URL, value: http://localhost:4443 }',
972
1014
  ' - name: AGENTHIPPO_LITELLM_API_KEY',
@@ -1088,10 +1130,18 @@ function renderCloudrunSpotlightDeploy(ctx) {
1088
1130
  'ensure_secret "${SERVICE}-litellm-key"; printf %s "$LITELLM_KEY" | gcloud secrets versions add "${SERVICE}-litellm-key" --data-file=-',
1089
1131
  'ensure_secret "${SERVICE}-provider-key"; printf %s "$PROVIDER_KEY" | gcloud secrets versions add "${SERVICE}-provider-key" --data-file=-',
1090
1132
  '',
1133
+ '# IAP (finalized below) forwards X-Goog-Authenticated-User-Email; pin serve to trust ONLY',
1134
+ '# that header so an authenticated caller cannot impersonate another user via a spoofed',
1135
+ '# X-Agent-User header. Skipped when ENABLE_IAP=0 (first-deploy bootstrap) since IAP never',
1136
+ '# sets the header then.',
1137
+ 'IDENTITY_HEADER_VALUE=""',
1138
+ '[[ "${ENABLE_IAP:-1}" == "1" ]] && IDENTITY_HEADER_VALUE="x-goog-authenticated-user-email" || true',
1139
+ '',
1091
1140
  '# Render the multi-container template for this project (sed — no gettext dependency) and deploy.',
1092
1141
  'RENDERED="$(mktemp)"',
1093
1142
  'sed -e "s|\\${SERVICE}|${SERVICE}|g" -e "s|\\${IMAGE}|${IMAGE}|g" \\',
1094
1143
  ' -e "s|\\${LITELLM_IMAGE}|${LITELLM_IMAGE}|g" -e "s|\\${STATE_BUCKET}|${STATE_BUCKET}|g" \\',
1144
+ ' -e "s|\\${IDENTITY_HEADER_VALUE}|${IDENTITY_HEADER_VALUE}|g" \\',
1095
1145
  ' deploy/service.yaml > "$RENDERED"',
1096
1146
  'gcloud run services replace "$RENDERED" --region "$REGION"',
1097
1147
  '',
@@ -1128,7 +1178,43 @@ function renderCloudrunEdgeCaddyfile() {
1128
1178
  '}',
1129
1179
  '',
1130
1180
  ':{$PORT} {',
1131
- ' # Auth-broker: IDE sign-in callback + JWT validation endpoint.',
1181
+ ' # IDE PKCE sign-in: broker requires X-Auth-Request-Email from oauth2-proxy. Must come',
1182
+ ' # before the generic /oauth/* handle so authorize is gated (not dumped straight to broker).',
1183
+ ' handle /oauth/authorize {',
1184
+ ' forward_auth localhost:4180 {',
1185
+ ' uri /oauth2/auth',
1186
+ ' copy_headers X-Auth-Request-Email',
1187
+ ' header_up X-Forwarded-Proto https',
1188
+ ' header_up X-Forwarded-Host {http.request.host}',
1189
+ ' @unauth status 401',
1190
+ ' handle_response @unauth {',
1191
+ ' redir * "/oauth2/sign_in?rd={http.request.uri}"',
1192
+ ' }',
1193
+ ' }',
1194
+ ' reverse_proxy localhost:9780 {',
1195
+ ' header_up X-Forwarded-Proto https',
1196
+ ' header_up X-Forwarded-Host {http.request.host}',
1197
+ ' }',
1198
+ ' }',
1199
+ ' # Federated SSO (A′): browsers from other subdomains land here to stamp the shared cookie.',
1200
+ ' # Same oauth2-proxy gate as authorize; must precede the generic /oauth/* handle.',
1201
+ ' handle /oauth/browser-login {',
1202
+ ' forward_auth localhost:4180 {',
1203
+ ' uri /oauth2/auth',
1204
+ ' copy_headers X-Auth-Request-Email',
1205
+ ' header_up X-Forwarded-Proto https',
1206
+ ' header_up X-Forwarded-Host {http.request.host}',
1207
+ ' @unauth status 401',
1208
+ ' handle_response @unauth {',
1209
+ ' redir * "/oauth2/sign_in?rd=/oauth/browser-login{http.request.uri.query}"',
1210
+ ' }',
1211
+ ' }',
1212
+ ' reverse_proxy localhost:9780 {',
1213
+ ' header_up X-Forwarded-Proto https',
1214
+ ' header_up X-Forwarded-Host {http.request.host}',
1215
+ ' }',
1216
+ ' }',
1217
+ ' # Auth-broker: token exchange + other /oauth/* routes that must stay ungated.',
1132
1218
  ' handle /oauth/* {',
1133
1219
  ' reverse_proxy localhost:9780 {',
1134
1220
  ' header_up X-Forwarded-Proto https',
@@ -1456,6 +1542,14 @@ function cloudrunArtifacts(ctx) {
1456
1542
  'docker build --pull --platform linux/amd64 -f "$DF_TMP" -t "$IMAGE" .',
1457
1543
  'docker push "$IMAGE"',
1458
1544
  '',
1545
+ '# IAP (when enabled below) forwards X-Goog-Authenticated-User-Email; pin serve to trust',
1546
+ '# ONLY that header so an authenticated caller cannot impersonate another user via a',
1547
+ '# spoofed X-Agent-User header. Skipped when ENABLE_IAP=0 (first-deploy bootstrap) since',
1548
+ '# IAP never sets the header then — pinning to an absent header would make every request',
1549
+ '# anonymous instead of leaving body-supplied identity usable during bootstrap.',
1550
+ 'IDENTITY_ENV=""',
1551
+ '[[ "${ENABLE_IAP:-1}" == "1" ]] && IDENTITY_ENV=",AGENTHIPPO_IDENTITY_HEADER=x-goog-authenticated-user-email" || true',
1552
+ '',
1459
1553
  '# Deploy private (no --allow-unauthenticated). IAP + role grants are applied below on the beta track.',
1460
1554
  'gcloud run deploy "$SERVICE" \\',
1461
1555
  ' --image "$IMAGE" \\',
@@ -1465,7 +1559,7 @@ function cloudrunArtifacts(ctx) {
1465
1559
  ' --timeout 3600 \\',
1466
1560
  ' --add-volume name=state,type=cloud-storage,bucket="$STATE_BUCKET" \\',
1467
1561
  ' --add-volume-mount volume=state,mount-path=/state \\',
1468
- ` --set-env-vars ${runEnvVars} \\`,
1562
+ ` --set-env-vars "${runEnvVars}\${IDENTITY_ENV}" \\`,
1469
1563
  ...(runSecrets ? [` --set-secrets ${runSecrets} \\`] : []),
1470
1564
  ' --region "$REGION"',
1471
1565
  '',
@@ -1929,7 +2023,14 @@ function databricksAppArtifacts(ctx) {
1929
2023
  " '--workspace', '/tmp/agenthippo/workspace',",
1930
2024
  " '--state-dir', '/tmp/agenthippo/state',",
1931
2025
  " '--port', port,",
1932
- "], { stdio: 'inherit', env: { ...process.env, AGENTHIPPO_SERVE_HOST: '0.0.0.0' } });",
2026
+ "], { stdio: 'inherit', env: {",
2027
+ " ...process.env,",
2028
+ " AGENTHIPPO_SERVE_HOST: '0.0.0.0',",
2029
+ " // Databricks Apps workspace SSO sets X-Forwarded-Email; pin serve to trust ONLY that",
2030
+ " // header so an authenticated caller cannot impersonate another user via a spoofed",
2031
+ " // X-Agent-User header (Databricks does not know about or strip that header).",
2032
+ " AGENTHIPPO_IDENTITY_HEADER: 'x-forwarded-email',",
2033
+ "} });",
1933
2034
  "child.on('exit', (code) => process.exit(code ?? 0));",
1934
2035
  '',
1935
2036
  ].join('\n');