@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
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
|
|
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
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
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
|
-
|
|
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
|
|
183
|
-
*
|
|
184
|
-
*
|
|
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
|
-
|
|
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.
|