@agenthippo-ai/agenthippo 0.1.1 → 0.2.3-dataplane

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/out/agentpack/index.js +2 -1
  2. package/out/agentpack/paths.js +23 -0
  3. package/out/analytics/dashboard/fleetDisplayUtils.js +3 -0
  4. package/out/analytics/dashboard/webview/panel.js +290 -80
  5. package/out/analytics/sessionEvents.js +21 -9
  6. package/out/auth/brokerClient.js +1 -1
  7. package/out/auth/onboardingPrompt.js +112 -0
  8. package/out/auth/provider.js +1 -1
  9. package/out/auth-broker/config.js +8 -3
  10. package/out/auth-broker/server.js +36 -0
  11. package/out/cli/commands/ask.js +3 -0
  12. package/out/cli/commands/chat.js +3 -0
  13. package/out/cli/commands/deploy.js +49 -10
  14. package/out/cli/commands/gateway.js +54 -0
  15. package/out/cli/commands/idp.js +85 -0
  16. package/out/cli/commands/serve.js +315 -55
  17. package/out/cli/credential-context.js +42 -80
  18. package/out/cli/data-plane/broker.js +78 -0
  19. package/out/cli/data-plane/connector-host.js +121 -0
  20. package/out/cli/data-plane/egress.js +189 -0
  21. package/out/cli/data-plane/gateway.js +195 -0
  22. package/out/cli/data-plane/index.js +137 -0
  23. package/out/cli/data-plane/registry.js +80 -0
  24. package/out/cli/deploy/blueprints/registry.js +84 -0
  25. package/out/cli/deploy/layers/identity.js +91 -0
  26. package/out/cli/deploy-init.js +1105 -335
  27. package/out/cli/idp-init.js +303 -0
  28. package/out/cli/index.js +37 -7
  29. package/out/cli/pack-loader.js +37 -2
  30. package/out/cli/runtime.js +10 -3
  31. package/out/cli/serve-hardening.js +60 -1
  32. package/out/cli/state-sync.js +16 -15
  33. package/out/engines/claude.js +26 -5
  34. package/out/engines/codex.js +24 -5
  35. package/out/engines/custom.js +46 -12
  36. package/out/extension.js +2 -0
  37. package/out/gateway/outboundSend.js +6 -6
  38. package/out/headless/index.js +24 -9
  39. package/out/live-probe/connector.mjs +8 -0
  40. package/out/live-probe/env-report.mjs +8 -0
  41. package/out/mcp.js +85 -67
  42. package/out/scripts/analytics/agentide-litellm-otel.config.yaml +14 -0
  43. package/out/shared/models.js +1 -1
  44. package/out/shared/providers.js +2 -2
  45. package/out/test/analytics.test.js +42 -17
  46. package/out/test/auth-broker.test.js +53 -0
  47. package/out/test/blueprint-registry.test.js +105 -0
  48. package/out/test/credential-context.test.js +23 -28
  49. package/out/test/custom-env-layering.test.js +151 -0
  50. package/out/test/data-plane-engine-wiring.test.js +250 -0
  51. package/out/test/data-plane.test.js +244 -0
  52. package/out/test/deploy-init.test.js +255 -4
  53. package/out/test/egress-broker.test.js +172 -0
  54. package/out/test/fleetDisplayUtils.test.js +4 -0
  55. package/out/test/idp-init.test.js +151 -0
  56. package/out/test/mcp-obo.test.js +52 -27
  57. package/out/test/mcp.test.js +43 -3
  58. package/out/test/model-routing.test.js +133 -0
  59. package/out/test/serve-gateway-turn.test.js +267 -0
  60. package/out/test/serve-hardening.test.js +80 -0
  61. package/out/test/serveClient.test.js +32 -0
  62. package/out/test/state-sync-peer.test.js +18 -5
  63. package/out/utils.js +27 -11
  64. package/package.json +1 -1
@@ -37,18 +37,24 @@ var __importStar = (this && this.__importStar) || (function () {
37
37
  })();
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
39
  exports.serveCommand = serveCommand;
40
+ exports.resolveGatewaySessionKey = resolveGatewaySessionKey;
41
+ exports.runGatewayTurn = runGatewayTurn;
40
42
  /**
41
43
  * Serve Command - HTTP Server
42
44
  */
43
45
  const fs = __importStar(require("fs"));
44
46
  const http = __importStar(require("http"));
45
47
  const path = __importStar(require("path"));
48
+ const node_crypto_1 = require("node:crypto");
46
49
  const headless_1 = require("../../headless");
47
50
  const pack_loader_1 = require("../pack-loader");
48
51
  const utils_1 = require("../utils");
49
52
  const runtime_1 = require("../runtime");
50
53
  const serve_hardening_1 = require("../serve-hardening");
51
54
  const credential_context_1 = require("../credential-context");
55
+ const data_plane_1 = require("../data-plane");
56
+ const runtime_2 = require("../runtime");
57
+ const outboundSend_1 = require("../../gateway/outboundSend");
52
58
  const sessionEvents_1 = require("../../analytics/sessionEvents");
53
59
  const base_1 = require("../../engines/base");
54
60
  const state_sync_1 = require("../state-sync");
@@ -81,8 +87,11 @@ async function serveCommand(options) {
81
87
  const defaultModel = (0, headless_1.resolveModelAlias)(rawModel);
82
88
  const rawCheapModel = pack.manifest.spec.cheapModel?.trim();
83
89
  const claudeCheapModel = rawCheapModel ? (0, headless_1.resolveModelAlias)(rawCheapModel) : undefined;
84
- if (!apiConfig.apiKey && (defaultEngine === 'claude' || defaultEngine === 'codex')) {
85
- console.error('Error: No API key found. Set AGENTHIPPO_LITELLM_API_KEY or ANTHROPIC_API_KEY');
90
+ // Engine-aware: a codex deploy needs an OpenAI (or LiteLLM) key; a claude deploy needs an
91
+ // Anthropic (or LiteLLM) key. Fail loudly at boot rather than 401 at the first model call.
92
+ if (!(0, pack_loader_1.hasCredentialForEngine)(defaultEngine, apiConfig)) {
93
+ const need = defaultEngine === 'claude' ? 'ANTHROPIC_API_KEY' : 'OPENAI_API_KEY';
94
+ console.error(`Error: No usable API key for engine "${defaultEngine}". Set ${need}, or configure the LiteLLM proxy (AGENTHIPPO_LITELLM_API_KEY + AGENTHIPPO_LITELLM_BASE_URL).`);
86
95
  for (const line of (0, utils_1.getLiteLLMQuickstartHintLines)()) {
87
96
  console.error(line);
88
97
  }
@@ -92,14 +101,67 @@ async function serveCommand(options) {
92
101
  const bundledPromptsDir = (0, utils_1.findBundledBasePromptsDir)();
93
102
  await (0, headless_1.syncPackToWorkspace)(options.workspace, pack.packPath, packId, defaultEngine, syncLogger, bundledPromptsDir);
94
103
  console.log(`OK: Loaded agent: ${pack.manifest.metadata.name} v${pack.manifest.metadata.version}`);
104
+ // Structural attribution (pack-standard WS2) — greppable for CISO/SRE boot audits.
105
+ console.log(`agenthippo pack=${pack.manifest.metadata.name}@${pack.manifest.metadata.version} engine=${defaultEngine}`);
95
106
  // Resolve persistent session state (serve-v3 §A1: replaces the tmpdir default).
96
107
  const { dir: storagePath, ephemeral } = (0, serve_hardening_1.resolveStateDir)(options.stateDir);
97
108
  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'));
109
+ // Passed to createHeadlessRunner below so the runner's own SessionEventWriter uses the same
110
+ // root — a fail-closed warrant denial lands in the same per-session JSONL as the tool events
111
+ // it precedes. The denial happens before runner.run() is ever called, so it needs its own
112
+ // writer here too. See resolveAnalyticsSessionsDir's docstring for the precedence.
113
+ const analyticsSessionsDir = (0, serve_hardening_1.resolveAnalyticsSessionsDir)(storagePath, ephemeral);
114
+ const sessionEventWriter = new sessionEvents_1.SessionEventWriter(analyticsSessionsDir);
102
115
  const logWarrantDenied = (sessionKey) => (reason) => sessionEventWriter.append(sessionKey, (0, base_1.generateRequestId)(), 'warrant_denied', { reason });
116
+ // Data-plane gateway (secure-obo-design §3): when the pack declares spec.dataPlane,
117
+ // serve runs the connector broker-side and engines get a per-turn nonce instead of the
118
+ // credential. The raw data credential never crosses into the agent's trust domain.
119
+ const dataPlaneCfg = (0, data_plane_1.resolveDataPlaneConfig)(pack.manifest.spec, pack.packPath);
120
+ // One shared registry: a single per-turn nonce grants BOTH the data-plane and egress
121
+ // capabilities (secure-obo-design §7.6 step 2). Created when either broker will run.
122
+ const turnRegistry = (dataPlaneCfg || options.gatewayBridge) ? new data_plane_1.TurnRegistry() : null;
123
+ const auditEvent = (sessionKey, event, fields) => sessionEventWriter.append(sessionKey, (0, base_1.generateRequestId)(), event, fields);
124
+ let dataPlane = null;
125
+ if (dataPlaneCfg) {
126
+ try {
127
+ dataPlane = await data_plane_1.DataPlane.start(dataPlaneCfg, {
128
+ logger: options.verbose ? new headless_1.ConsoleLogger('[DataPlane]') : new headless_1.NoOpLogger(),
129
+ audit: auditEvent,
130
+ registry: turnRegistry ?? undefined,
131
+ });
132
+ // Boot-stable process env: engines resolve ${env:AGENTHIPPO_DATA_MCP_URL} from it,
133
+ // so codex's config.toml stays byte-identical across turns. The NONCE is never set
134
+ // here — it rides the per-spawn child env only.
135
+ process.env[data_plane_1.DATA_MCP_URL_ENV] = dataPlane.url;
136
+ console.log(`OK: Data gateway (provider=${dataPlane.broker.provider.name}) on loopback — connector: ${dataPlaneCfg.connectorPath}`);
137
+ }
138
+ catch (err) {
139
+ console.error(`${utils_1.COLORS.red}Error: failed to start the data-plane gateway:${utils_1.COLORS.reset} ${err instanceof Error ? err.message : err}`);
140
+ process.exit(1);
141
+ }
142
+ }
143
+ // Egress broker (secure-obo-design §7.6): capture the gateway token broker-side BEFORE any
144
+ // engine spawns, so it can be stripped from the process env the children inherit. The broker
145
+ // starts alongside the gateway bridge below; the token is held only in this closure.
146
+ const brokerGatewayToken = options.gatewayBridge ? (0, runtime_2.readOpenClawGatewayToken)() : undefined;
147
+ let egressBroker = null;
148
+ // Mint one nonce per turn and assemble the per-spawn child env. Returns null outside any
149
+ // broker (no capability to grant). The URLs ride process.env (boot-stable) for config
150
+ // invariance; only the nonce is per-spawn.
151
+ const registerServeTurn = (reg) => {
152
+ if (!turnRegistry) {
153
+ return null;
154
+ }
155
+ const { nonce, release } = turnRegistry.register(reg);
156
+ const env = { [data_plane_1.TURN_NONCE_ENV]: nonce };
157
+ if (dataPlane) {
158
+ env[data_plane_1.DATA_MCP_URL_ENV] = dataPlane.url;
159
+ }
160
+ if (egressBroker) {
161
+ env[data_plane_1.EGRESS_URL_ENV] = egressBroker.url;
162
+ }
163
+ return { nonce, env, release };
164
+ };
103
165
  const turnLimiter = new serve_hardening_1.TurnLimiter(options.maxTurnsPerSession ?? 0);
104
166
  // Per-identity request-rate cap (req/min). Keyed on the trusted user id (falling back to
105
167
  // client IP) so rotating session_id can't bypass it. AGENTHIPPO_SERVE_RATE_LIMIT=0 disables.
@@ -174,12 +236,18 @@ async function serveCommand(options) {
174
236
  };
175
237
  const runner = (0, headless_1.createHeadlessRunner)({
176
238
  storagePath,
239
+ analyticsSessionsDir,
177
240
  packId,
178
241
  packVersion: pack?.manifest.metadata.version,
179
242
  bundledPromptsDir,
180
243
  apiKey: apiConfig.apiKey,
181
244
  baseUrl: apiConfig.baseUrl,
182
245
  useLiteLLM: apiConfig.useLiteLLM,
246
+ // Per-destination keys so each engine authenticates to the endpoint it actually dials
247
+ // (codex→OpenAI, claude→Anthropic, or the LiteLLM proxy) — no provider-blind collapse.
248
+ litellmApiKey: apiConfig.litellmApiKey,
249
+ openaiApiKey: apiConfig.openaiApiKey,
250
+ anthropicApiKey: apiConfig.anthropicApiKey,
183
251
  logger,
184
252
  freshState: false,
185
253
  claudeCodeExecutablePath: claudeCodePath,
@@ -246,7 +314,15 @@ async function serveCommand(options) {
246
314
  res.end(JSON.stringify({ error: 'Rate limit exceeded' }));
247
315
  return;
248
316
  }
249
- const sessionKey = body.session_id || `anon-${userId ?? 'unknown'}`;
317
+ // User-namespaced session key (secure-obo-design §3.4): kills cross-user session
318
+ // attachment via a guessed session_id. Fails closed on pinned-identity deployments.
319
+ const { sessionKey, rejected } = (0, serve_hardening_1.resolveServeSessionKey)(body.session_id, userId);
320
+ if (rejected) {
321
+ sessionEventWriter.append('unverified', (0, base_1.generateRequestId)(), 'warrant_denied', { reason: rejected });
322
+ res.writeHead(403, { 'Content-Type': 'application/json' });
323
+ res.end(JSON.stringify({ error: 'session_id requires an authenticated user' }));
324
+ return;
325
+ }
250
326
  // Acquire before turn-limiter so a capacity reject does not burn a turn slot.
251
327
  const release = await turnGate.acquire();
252
328
  if (!release) {
@@ -258,6 +334,10 @@ async function serveCommand(options) {
258
334
  res.end(JSON.stringify({ error: 'At capacity' }));
259
335
  return;
260
336
  }
337
+ // Turn-scoped data capability: mint a nonce bound to the verified user; the
338
+ // engine gets it via per-spawn env only. Deleted in the finally — a stale
339
+ // engine/tool fails closed at the gateway after that (secure-obo-design §3.1).
340
+ let turnReg = null;
261
341
  try {
262
342
  if (!turnLimiter.tryConsume(sessionKey)) {
263
343
  res.writeHead(429, { 'Content-Type': 'application/json' });
@@ -265,12 +345,16 @@ async function serveCommand(options) {
265
345
  return;
266
346
  }
267
347
  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) });
348
+ if (userId?.trim()) {
349
+ turnReg = registerServeTurn({ userId: userId.trim(), sessionKey, inboundToken: dataToken });
350
+ }
351
+ const result = await (0, credential_context_1.runWithCredential)({ userId, dataToken }, () => handleChat(runner, body, sessionKey, defaultEngine, defaultModel, options.workspace, permissions, userId, turnReg?.env), { onDenied: logWarrantDenied(sessionKey) });
269
352
  res.writeHead(200, { 'Content-Type': 'application/json' });
270
353
  res.end(JSON.stringify(result));
271
354
  statePusher?.schedule(sessionKey); // per-session delta push, off the response path
272
355
  }
273
356
  finally {
357
+ turnReg?.release();
274
358
  release();
275
359
  }
276
360
  return;
@@ -288,7 +372,14 @@ async function serveCommand(options) {
288
372
  res.end(JSON.stringify({ error: 'Rate limit exceeded' }));
289
373
  return;
290
374
  }
291
- const sessionKey = body.session_id || `anon-${userId ?? 'unknown'}`;
375
+ // Same user-namespaced session key + fail-closed rule as /v1/chat above.
376
+ const { sessionKey, rejected } = (0, serve_hardening_1.resolveServeSessionKey)(body.session_id, userId);
377
+ if (rejected) {
378
+ sessionEventWriter.append('unverified', (0, base_1.generateRequestId)(), 'warrant_denied', { reason: rejected });
379
+ res.writeHead(403, { 'Content-Type': 'application/json' });
380
+ res.end(JSON.stringify({ error: 'session_id requires an authenticated user' }));
381
+ return;
382
+ }
292
383
  // Acquire BEFORE writeHead(200, SSE) so a reject still gets a clean 429.
293
384
  const release = await turnGate.acquire();
294
385
  if (!release) {
@@ -300,6 +391,8 @@ async function serveCommand(options) {
300
391
  res.end(JSON.stringify({ error: 'At capacity' }));
301
392
  return;
302
393
  }
394
+ // Turn-scoped data capability — same rules as /v1/chat above.
395
+ let turnReg = null;
303
396
  try {
304
397
  if (!turnLimiter.tryConsume(sessionKey)) {
305
398
  res.writeHead(429, { 'Content-Type': 'application/json' });
@@ -307,15 +400,25 @@ async function serveCommand(options) {
307
400
  return;
308
401
  }
309
402
  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) });
403
+ if (userId?.trim()) {
404
+ turnReg = registerServeTurn({ userId: userId.trim(), sessionKey, inboundToken: dataToken });
405
+ }
406
+ // Fail-closed credential check MUST run before SSE writeHead(200).
407
+ // Otherwise a warrant denial lands after headers are committed, the outer
408
+ // catch can only append raw JSON (not an SSE error event), and the IDE
409
+ // client's SSE parser falls through to emitter.done() → silent success.
410
+ await (0, credential_context_1.runWithCredential)({ userId, dataToken }, async () => {
411
+ res.writeHead(200, {
412
+ 'Content-Type': 'text/event-stream',
413
+ 'Cache-Control': 'no-cache',
414
+ 'Connection': 'keep-alive',
415
+ });
416
+ await handleStreamingChat(runner, body, sessionKey, defaultEngine, defaultModel, options.workspace, permissions, res, userId, turnReg?.env);
417
+ }, { onDenied: logWarrantDenied(sessionKey) });
316
418
  statePusher?.schedule(sessionKey); // per-session delta push, off the response path
317
419
  }
318
420
  finally {
421
+ turnReg?.release();
319
422
  release();
320
423
  }
321
424
  return;
@@ -357,7 +460,7 @@ async function serveCommand(options) {
357
460
  }
358
461
  console.log(`Workspace: ${options.workspace}`);
359
462
  if (pack) {
360
- console.log(`Pack: ${pack.manifest.metadata.name}`);
463
+ console.log(`Pack: ${pack.manifest.metadata.name}@${pack.manifest.metadata.version}`);
361
464
  console.log(`Gateway: ${gatewayAgentId}`);
362
465
  }
363
466
  console.log(BANNER_LINE);
@@ -380,17 +483,46 @@ async function serveCommand(options) {
380
483
  }
381
484
  }
382
485
  if (options.gatewayBridge) {
486
+ // Egress broker (secure-obo-design §7.6): start before the bridge so the URL is in
487
+ // process.env when the first channel turn spawns an engine. The broker holds the
488
+ // gateway token; the agent presents only the turn nonce.
489
+ if (turnRegistry) {
490
+ try {
491
+ egressBroker = await (0, data_plane_1.startEgressBroker)({
492
+ registry: turnRegistry,
493
+ workspaceRoot: options.workspace,
494
+ agentId: pack.manifest.metadata.name,
495
+ send: async (handle, message, media) => {
496
+ const r = await (0, outboundSend_1.sendGatewayOutboundMessage)(handle, { message, media, token: brokerGatewayToken });
497
+ return { ok: r.ok, via: r.via, stdout: r.stdout, stderr: r.stderr };
498
+ },
499
+ logger: options.verbose ? new headless_1.ConsoleLogger('[Egress]') : new headless_1.NoOpLogger(),
500
+ audit: auditEvent,
501
+ });
502
+ process.env[data_plane_1.EGRESS_URL_ENV] = egressBroker.url;
503
+ // Now that the broker holds the token, strip it from the process env so no
504
+ // engine child (all spread process.env) inherits it (§7.4 gap 1 close).
505
+ delete process.env.OPENCLAW_GATEWAY_TOKEN;
506
+ console.log('OK: Egress broker on loopback — gateway token stays broker-side; agent sends via turn nonce');
507
+ }
508
+ catch (err) {
509
+ console.error(`${utils_1.COLORS.yellow}Warning:${utils_1.COLORS.reset} Egress broker failed to start: ${err instanceof Error ? err.message : String(err)}`);
510
+ }
511
+ }
383
512
  try {
384
513
  gatewayBridge = await startServeGatewayBridge({
385
514
  gatewayAgentId,
386
515
  packName: pack.manifest.metadata.name,
387
516
  workspace: options.workspace,
388
- stateDir: storagePath,
517
+ registerTurn: registerServeTurn,
518
+ brokerGatewayToken,
389
519
  engine: defaultEngine,
390
520
  defaultModel,
391
521
  runner,
392
522
  permissions,
393
523
  turnGate,
524
+ rateLimiter,
525
+ turnLimiter,
394
526
  warnGateReject,
395
527
  prepareSession,
396
528
  onTurnComplete: (sessionId) => statePusher?.schedule(sessionId),
@@ -432,6 +564,22 @@ async function serveCommand(options) {
432
564
  }
433
565
  gatewayBridge = null;
434
566
  }
567
+ if (dataPlane) {
568
+ try {
569
+ await dataPlane.close();
570
+ }
571
+ catch {
572
+ // best-effort
573
+ }
574
+ }
575
+ if (egressBroker) {
576
+ try {
577
+ await egressBroker.close();
578
+ }
579
+ catch {
580
+ // best-effort
581
+ }
582
+ }
435
583
  server.close(() => {
436
584
  runner.dispose();
437
585
  process.exit(0);
@@ -455,15 +603,18 @@ async function parseBody(req) {
455
603
  throw new Error('Invalid JSON');
456
604
  }
457
605
  }
458
- async function handleChat(runner, body, defaultEngine, defaultModel, workspace, permissions, userId) {
606
+ async function handleChat(runner, body, sessionKey, defaultEngine, defaultModel, workspace, permissions, userId, turnEnv) {
459
607
  const result = await runner.run({
460
608
  engineId: resolveRequestedEngineId(body.engine, defaultEngine),
461
609
  modelId: body.model || defaultModel,
462
610
  message: body.message,
463
611
  workspaceRoot: workspace,
464
- sessionId: body.session_id,
612
+ // The user-namespaced key (never raw body.session_id) so engine conversation
613
+ // state and per-session artifacts are per-user — see resolveServeSessionKey.
614
+ sessionId: sessionKey,
465
615
  analyticsUserId: userId,
466
616
  permissions,
617
+ turnEnv,
467
618
  });
468
619
  return {
469
620
  success: result.success,
@@ -473,6 +624,85 @@ async function handleChat(runner, body, defaultEngine, defaultModel, workspace,
473
624
  error: result.error,
474
625
  };
475
626
  }
627
+ /**
628
+ * Session key for a gateway turn. Prefers the caller's own session identifiers; when the
629
+ * sender has a resolved identity but no session id, keys off that identity (stable, no
630
+ * collision risk — it's unique to that sender). Only when NEITHER a session id nor an
631
+ * identity is available does this fall back to a per-message unique key, rather than a
632
+ * shared 'gateway-anon' bucket — a shared bucket would let two unrelated, genuinely
633
+ * unidentified senders share one TurnLimiter budget and, when AGENTHIPPO_STATE_S3_URI is
634
+ * set, one S3 session-state prefix (ensureSessionLocal/pushSessionState), letting one
635
+ * anonymous sender's state be restored into another's turn.
636
+ *
637
+ * `durable` is false only for that one-off fallback key. A key that is by construction
638
+ * never reused can never hit its own TurnLimiter cap and can never have prior S3 state to
639
+ * restore or a future turn to restore it for — callers should skip per-session tracking
640
+ * (TurnLimiter, prepareSession/ensureSessionLocal, onTurnComplete/statePusher.schedule) for
641
+ * it entirely, rather than accumulate a permanent, never-useful entry in those stores.
642
+ *
643
+ * `sessionKey` is namespaced by the resolved sender identity when an explicit session id is
644
+ * supplied (secure-obo-design §3.4 — a caller-supplied id must not attach to another
645
+ * sender's engine session). `outboundKey` preserves the raw gateway key: the bridge records
646
+ * reply handles under it (bridge.ts upsertGatewayOutboundHandle), so the outbound-routing
647
+ * file must keep resolving from the un-namespaced key or reply-to-sender breaks.
648
+ */
649
+ function resolveGatewaySessionKey(opts, userId) {
650
+ const explicit = opts.sessionId?.trim() || opts.gatewaySessionKey?.trim();
651
+ if (explicit) {
652
+ const sessionKey = userId ? `u${(0, serve_hardening_1.userNamespaceHash)(userId)}--${explicit}` : explicit;
653
+ return { sessionKey, outboundKey: explicit, durable: true };
654
+ }
655
+ if (userId) {
656
+ // Already keyed on the sender's own identity — no cross-user attachment surface.
657
+ return { sessionKey: `gateway-${userId}`, outboundKey: `gateway-${userId}`, durable: true };
658
+ }
659
+ // randomUUID (122 bits), not generateRequestId (~32 bits) — this is the sole thing
660
+ // standing between two unrelated anonymous senders and a real collision, so it needs to
661
+ // be architecturally collision-free, not just statistically unlikely.
662
+ const anon = `gateway-anon-${(0, node_crypto_1.randomUUID)()}`;
663
+ return { sessionKey: anon, outboundKey: anon, durable: false };
664
+ }
665
+ /**
666
+ * Gateway-channel turn admission: rate limit → turn gate (capacity) → per-session turn cap,
667
+ * then run. Extracted from the gateway bridge's messageHandler so this is directly testable
668
+ * without a live gateway connection — this exact sequence (mirroring the HTTP endpoints'
669
+ * admission order) previously didn't exist for gateway-originated turns at all: channel
670
+ * messages skipped both the identity-keyed rate limiter and the per-session turn cap.
671
+ * Gateway channels can't receive an HTTP 429, so each rejection emits a short channel
672
+ * message instead (best-effort — a channel-notice failure must not mask the rejection).
673
+ */
674
+ async function runGatewayTurn(params) {
675
+ const notify = async (text) => {
676
+ try {
677
+ await params.emitter?.text(text);
678
+ await params.emitter?.done();
679
+ }
680
+ catch {
681
+ // best-effort channel notice
682
+ }
683
+ };
684
+ if (!params.rateLimiter.tryConsume(params.rlKey)) {
685
+ await notify(serve_hardening_1.RATE_LIMIT_BUSY_MESSAGE);
686
+ return { success: false, error: 'rate_limited' };
687
+ }
688
+ // Acquire before the turn-limiter so a capacity reject does not burn a turn slot.
689
+ const release = await params.turnGate.acquire();
690
+ if (!release) {
691
+ params.warnGateReject(`TurnGate: rejecting gateway turn (inflight=${params.turnGate.inflight} queued=${params.turnGate.queued} max=${params.turnGate.max})`);
692
+ await notify(serve_hardening_1.TURN_GATE_BUSY_MESSAGE);
693
+ return { success: false, error: 'busy' };
694
+ }
695
+ try {
696
+ if (params.trackSession !== false && !params.turnLimiter.tryConsume(params.sessionKey)) {
697
+ await notify(serve_hardening_1.TURN_LIMIT_BUSY_MESSAGE);
698
+ return { success: false, error: 'turn_limit' };
699
+ }
700
+ return await params.run();
701
+ }
702
+ finally {
703
+ release();
704
+ }
705
+ }
476
706
  /**
477
707
  * Register this serve process as an OpenClaw external agent (WebSocket `agent.run` → headless runner).
478
708
  * Enables `openclaw agent --agent <packId> --message "…"` against the same gateway.
@@ -481,7 +711,9 @@ async function startServeGatewayBridge(params) {
481
711
  const { GatewayBridge: Bridge } = await import('../../gateway/bridge.js');
482
712
  const DEFAULT_GATEWAY_PORT = 18789;
483
713
  const gatewayUrl = process.env.OPENCLAW_GATEWAY_URL ?? `ws://127.0.0.1:${DEFAULT_GATEWAY_PORT}`;
484
- const token = process.env.OPENCLAW_GATEWAY_TOKEN;
714
+ // Use the captured token — serve strips OPENCLAW_GATEWAY_TOKEN from process.env once the
715
+ // egress broker holds it, so the bridge must not depend on the env var (§7.6).
716
+ const token = params.brokerGatewayToken ?? process.env.OPENCLAW_GATEWAY_TOKEN;
485
717
  const bridge = new Bridge({ gatewayUrl, token });
486
718
  await bridge.connect();
487
719
  await bridge.registerAgent({
@@ -498,39 +730,65 @@ async function startServeGatewayBridge(params) {
498
730
  : (requestedEngine || params.engine);
499
731
  // #4: the channel sender becomes the per-user user_id (analytics + per-user data scoping).
500
732
  const userId = (0, serve_hardening_1.peerToUserId)(opts.peerInfo);
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
- }
733
+ const { sessionKey, outboundKey, durable } = resolveGatewaySessionKey(opts, userId);
734
+ return runGatewayTurn({
735
+ rateLimiter: params.rateLimiter,
736
+ turnLimiter: params.turnLimiter,
737
+ turnGate: params.turnGate,
738
+ warnGateReject: params.warnGateReject,
739
+ // Same identity-keyed cap the HTTP endpoints enforce — a channel sender is already
740
+ // trusted enough here to gate the OBO credential scope below, so it's trusted enough
741
+ // to rate-limit on directly. Unidentified senders share one 'gateway' bucket rather
742
+ // than being exempt from throttling entirely.
743
+ rlKey: userId ?? 'gateway',
744
+ sessionKey,
745
+ trackSession: durable,
746
+ emitter: opts.emitter,
747
+ run: async () => {
748
+ // A one-off anonymous sessionKey can never have prior state to restore, and
749
+ // nothing will ever look it up again to push state for — skip both S3 round-trips.
750
+ if (durable) {
751
+ await params.prepareSession(sessionKey);
752
+ }
753
+ // Channel-originated turns carry no inbound platform token: with the
754
+ // passthrough provider they fail closed at the gateway (an unlinked chat
755
+ // peer gets NO data credential — secure-obo-design §7.5 bind-before-
756
+ // credential); the identity provider maps the peer id server-side instead.
757
+ // egressStoreKey is the RAW gateway key (outboundKey) the bridge records reply
758
+ // handles under — it lets the egress broker resolve this session's bound
759
+ // destinations server-side (§7.6). userId gates BOTH capabilities: an
760
+ // unidentified peer gets neither a nonce nor an egress route.
761
+ const turnReg = userId?.trim()
762
+ ? params.registerTurn({ userId: userId.trim(), sessionKey, egressStoreKey: outboundKey })
763
+ : null;
764
+ try {
765
+ const result = await (0, credential_context_1.runWithCredential)({ userId }, () => params.runner.run({
766
+ engineId: effectiveEngine,
767
+ modelId: params.defaultModel,
768
+ message,
769
+ workspaceRoot: params.workspace,
770
+ // Use the resolved sessionKey (not the raw opts fields) so the engine's own
771
+ // session state and the S3 restore/push above key off the same, user-namespaced
772
+ // identity. The outbound-routing file (buildSessionEnvPatch) keeps the raw
773
+ // gateway key — the bridge records reply handles under it, so namespacing it
774
+ // here would break reply-to-sender. See resolveGatewaySessionKey.
775
+ sessionId: sessionKey,
776
+ gatewaySessionKey: outboundKey,
777
+ analyticsUserId: userId,
778
+ emitter: opts.emitter,
779
+ permissions: params.permissions,
780
+ turnEnv: turnReg?.env,
781
+ }), { onDenied: params.logWarrantDenied(sessionKey) });
782
+ if (durable) {
783
+ params.onTurnComplete?.(sessionKey);
784
+ }
785
+ return { success: result.success, error: result.error };
786
+ }
787
+ finally {
788
+ turnReg?.release();
789
+ }
790
+ },
791
+ });
534
792
  },
535
793
  });
536
794
  console.log(`${utils_1.COLORS.green}✓${utils_1.COLORS.reset} Gateway bridge: registered "${params.gatewayAgentId}" at ${gatewayUrl}`);
@@ -594,7 +852,7 @@ async function bindToGatewayChannels(agentId, bindSpec) {
594
852
  client.close();
595
853
  }
596
854
  }
597
- async function handleStreamingChat(runner, body, defaultEngine, defaultModel, workspace, permissions, res, userId) {
855
+ async function handleStreamingChat(runner, body, sessionKey, defaultEngine, defaultModel, workspace, permissions, res, userId, turnEnv) {
598
856
  let streamEnded = false;
599
857
  const sendEvent = (type, data) => {
600
858
  if (streamEnded || res.writableEnded) {
@@ -648,10 +906,12 @@ async function handleStreamingChat(runner, body, defaultEngine, defaultModel, wo
648
906
  modelId: body.model || defaultModel,
649
907
  message: body.message,
650
908
  workspaceRoot: workspace,
651
- sessionId: body.session_id,
909
+ // The user-namespaced key (never raw body.session_id) — see resolveServeSessionKey.
910
+ sessionId: sessionKey,
652
911
  analyticsUserId: userId,
653
912
  emitter,
654
913
  permissions,
914
+ turnEnv,
655
915
  });
656
916
  if (!streamEnded) {
657
917
  sendEvent('done', {});