@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/analytics/sessionEvents.js +5 -0
- package/out/auth-broker/pkce.js +3 -1
- package/out/auth-broker/server.js +7 -1
- package/out/auth-broker/store.js +6 -5
- package/out/cli/commands/serve.js +131 -39
- package/out/cli/credential-context.js +16 -0
- package/out/cli/deploy-init.js +110 -9
- package/out/cli/index.js +9 -1
- package/out/cli/serve-hardening.js +158 -12
- package/out/cli/state-sync.js +317 -29
- package/out/headless/index.js +5 -2
- package/out/mcp.js +12 -3
- package/out/remote/handleRemoteChat.js +7 -1
- package/out/remote/serveClient.js +27 -13
- package/out/shared/providers.js +3 -0
- package/out/test/analytics.test.js +68 -0
- package/out/test/auth-broker.test.js +6 -2
- package/out/test/credential-context.test.js +60 -0
- package/out/test/deploy-init.test.js +32 -1
- package/out/test/mcp.test.js +17 -1
- package/out/test/serve-hardening.test.js +107 -0
- package/out/test/serveClient.test.js +5 -2
- package/out/test/state-sync-peer.test.js +70 -1
- package/package.json +3 -4
|
@@ -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
|
}
|
package/out/auth-broker/pkce.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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);
|
package/out/auth-broker/store.js
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
126
|
-
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
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
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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;
|
package/out/cli/deploy-init.js
CHANGED
|
@@ -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/
|
|
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/
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
' #
|
|
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: {
|
|
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');
|