@agenthippo-ai/agenthippo 0.1.0 → 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 (68) 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 -4
  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/pkce.js +3 -1
  11. package/out/auth-broker/server.js +43 -1
  12. package/out/auth-broker/store.js +6 -5
  13. package/out/cli/commands/ask.js +3 -0
  14. package/out/cli/commands/chat.js +3 -0
  15. package/out/cli/commands/deploy.js +49 -10
  16. package/out/cli/commands/gateway.js +54 -0
  17. package/out/cli/commands/idp.js +85 -0
  18. package/out/cli/commands/serve.js +401 -49
  19. package/out/cli/credential-context.js +47 -69
  20. package/out/cli/data-plane/broker.js +78 -0
  21. package/out/cli/data-plane/connector-host.js +121 -0
  22. package/out/cli/data-plane/egress.js +189 -0
  23. package/out/cli/data-plane/gateway.js +195 -0
  24. package/out/cli/data-plane/index.js +137 -0
  25. package/out/cli/data-plane/registry.js +80 -0
  26. package/out/cli/deploy/blueprints/registry.js +84 -0
  27. package/out/cli/deploy/layers/identity.js +91 -0
  28. package/out/cli/deploy-init.js +1156 -285
  29. package/out/cli/idp-init.js +303 -0
  30. package/out/cli/index.js +46 -8
  31. package/out/cli/pack-loader.js +37 -2
  32. package/out/cli/runtime.js +10 -3
  33. package/out/cli/serve-hardening.js +217 -12
  34. package/out/cli/state-sync.js +318 -29
  35. package/out/engines/claude.js +26 -5
  36. package/out/engines/codex.js +24 -5
  37. package/out/engines/custom.js +46 -12
  38. package/out/extension.js +2 -0
  39. package/out/gateway/outboundSend.js +6 -6
  40. package/out/headless/index.js +24 -6
  41. package/out/live-probe/connector.mjs +8 -0
  42. package/out/live-probe/env-report.mjs +8 -0
  43. package/out/mcp.js +88 -61
  44. package/out/remote/handleRemoteChat.js +7 -1
  45. package/out/remote/serveClient.js +27 -13
  46. package/out/scripts/analytics/agentide-litellm-otel.config.yaml +14 -0
  47. package/out/shared/models.js +1 -1
  48. package/out/shared/providers.js +5 -2
  49. package/out/test/analytics.test.js +93 -0
  50. package/out/test/auth-broker.test.js +59 -2
  51. package/out/test/blueprint-registry.test.js +105 -0
  52. package/out/test/credential-context.test.js +55 -0
  53. package/out/test/custom-env-layering.test.js +151 -0
  54. package/out/test/data-plane-engine-wiring.test.js +250 -0
  55. package/out/test/data-plane.test.js +244 -0
  56. package/out/test/deploy-init.test.js +287 -5
  57. package/out/test/egress-broker.test.js +172 -0
  58. package/out/test/fleetDisplayUtils.test.js +4 -0
  59. package/out/test/idp-init.test.js +151 -0
  60. package/out/test/mcp-obo.test.js +52 -27
  61. package/out/test/mcp.test.js +60 -4
  62. package/out/test/model-routing.test.js +133 -0
  63. package/out/test/serve-gateway-turn.test.js +267 -0
  64. package/out/test/serve-hardening.test.js +187 -0
  65. package/out/test/serveClient.test.js +37 -2
  66. package/out/test/state-sync-peer.test.js +83 -1
  67. package/out/utils.js +27 -11
  68. package/package.json +3 -4
@@ -37,18 +37,26 @@ 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");
58
+ const sessionEvents_1 = require("../../analytics/sessionEvents");
59
+ const base_1 = require("../../engines/base");
52
60
  const state_sync_1 = require("../state-sync");
53
61
  const cli_agent_id_js_1 = require("../../gateway/cli-agent-id.js");
54
62
  const openclaw_channel_routing_js_1 = require("../../gateway/openclaw-channel-routing.js");
@@ -79,8 +87,11 @@ async function serveCommand(options) {
79
87
  const defaultModel = (0, headless_1.resolveModelAlias)(rawModel);
80
88
  const rawCheapModel = pack.manifest.spec.cheapModel?.trim();
81
89
  const claudeCheapModel = rawCheapModel ? (0, headless_1.resolveModelAlias)(rawCheapModel) : undefined;
82
- if (!apiConfig.apiKey && (defaultEngine === 'claude' || defaultEngine === 'codex')) {
83
- 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).`);
84
95
  for (const line of (0, utils_1.getLiteLLMQuickstartHintLines)()) {
85
96
  console.error(line);
86
97
  }
@@ -90,14 +101,76 @@ async function serveCommand(options) {
90
101
  const bundledPromptsDir = (0, utils_1.findBundledBasePromptsDir)();
91
102
  await (0, headless_1.syncPackToWorkspace)(options.workspace, pack.packPath, packId, defaultEngine, syncLogger, bundledPromptsDir);
92
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}`);
93
106
  // Resolve persistent session state (serve-v3 §A1: replaces the tmpdir default).
94
107
  const { dir: storagePath, ephemeral } = (0, serve_hardening_1.resolveStateDir)(options.stateDir);
95
108
  const bindHost = (0, serve_hardening_1.resolveBindHost)(options.host);
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);
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
+ };
96
165
  const turnLimiter = new serve_hardening_1.TurnLimiter(options.maxTurnsPerSession ?? 0);
97
166
  // Per-identity request-rate cap (req/min). Keyed on the trusted user id (falling back to
98
167
  // client IP) so rotating session_id can't bypass it. AGENTHIPPO_SERVE_RATE_LIMIT=0 disables.
99
168
  const rateLimitPerMin = Number(process.env.AGENTHIPPO_SERVE_RATE_LIMIT ?? 120);
100
169
  const rateLimiter = new serve_hardening_1.RateLimiter(Number.isFinite(rateLimitPerMin) ? rateLimitPerMin : 120);
170
+ // Process-wide inflight cap (serve-v4 P0.1). Prevents a burst from spawning N engine
171
+ // subprocesses and OOMing the container. AGENTHIPPO_MAX_CONCURRENT_TURNS=0 disables.
172
+ const turnGate = (0, serve_hardening_1.createTurnGateFromEnv)();
173
+ const warnGateReject = (0, serve_hardening_1.createRejectionWarner)();
101
174
  let landingPageHtml;
102
175
  if (options.wwwDir?.trim()) {
103
176
  const indexPath = path.join(path.resolve(options.wwwDir.trim()), 'index.html');
@@ -111,21 +184,34 @@ async function serveCommand(options) {
111
184
  if (ephemeral) {
112
185
  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
186
  }
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).
187
+ // Q1 + serve-v4 P1.2: object-store state sync (no-op unless AGENTHIPPO_STATE_S3_URI is set).
188
+ // Multi-replica: AGENTHIPPO_STATE_RESTORE_BOOT=none + restore-on-miss per session.
189
+ // Single-instance default: boot-restore the whole prefix (backward compatible).
116
190
  const s3State = (0, state_sync_1.resolveS3StateConfig)();
191
+ const restoreBoot = (0, state_sync_1.resolveRestoreBootMode)();
117
192
  if (s3State) {
118
193
  // Preflight: state sync shells out to `aws`. If it's missing, sessions would
119
194
  // silently never persist — fail fast since durable state was explicitly opted into.
120
195
  if (!(await (0, state_sync_1.isAwsCliAvailable)())) {
121
196
  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.');
197
+ console.error(' State sync requires the AWS CLI (S3-compatible via --endpoint-url). Install it or unset AGENTHIPPO_STATE_S3_URI.');
123
198
  process.exit(1);
124
199
  }
125
- console.log(`State sync: restoring from ${s3State.uri} …`);
126
- await (0, state_sync_1.restoreState)(s3State, storagePath, (m) => console.log(` ${m}`));
200
+ if (restoreBoot === 'all') {
201
+ console.log(`State sync: boot-restore from ${s3State.uri} …`);
202
+ await (0, state_sync_1.restoreState)(s3State, storagePath, (m) => console.log(` ${m}`));
203
+ }
204
+ else {
205
+ console.log(`State sync: restore-on-miss from ${s3State.uri} (boot restore disabled)`);
206
+ }
127
207
  }
128
208
  const statePusher = s3State ? (0, state_sync_1.makeStatePusher)(s3State, storagePath, options.verbose ? (m) => console.error(m) : undefined) : null;
209
+ const stateLog = options.verbose ? (m) => console.error(m) : undefined;
210
+ const prepareSession = async (sessionKey) => {
211
+ if (s3State) {
212
+ await (0, state_sync_1.ensureSessionLocal)(s3State, storagePath, sessionKey, stateLog);
213
+ }
214
+ };
129
215
  const logger = options.verbose ? new headless_1.ConsoleLogger('[Server]') : new headless_1.NoOpLogger();
130
216
  // Find SDK CLI paths
131
217
  const claudeCodePath = (0, utils_1.findClaudeCodePath)();
@@ -150,12 +236,18 @@ async function serveCommand(options) {
150
236
  };
151
237
  const runner = (0, headless_1.createHeadlessRunner)({
152
238
  storagePath,
239
+ analyticsSessionsDir,
153
240
  packId,
154
241
  packVersion: pack?.manifest.metadata.version,
155
242
  bundledPromptsDir,
156
243
  apiKey: apiConfig.apiKey,
157
244
  baseUrl: apiConfig.baseUrl,
158
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,
159
251
  logger,
160
252
  freshState: false,
161
253
  claudeCodeExecutablePath: claudeCodePath,
@@ -192,9 +284,15 @@ async function serveCommand(options) {
192
284
  return;
193
285
  }
194
286
  // Readiness — pack synced (+ gateway connected when bridged).
287
+ // Inflight/queue depth for operators and load balancers (serve-v4 P0.1).
195
288
  if (req.method === 'GET' && req.url === '/ready') {
196
289
  res.writeHead(ready ? 200 : 503, { 'Content-Type': 'application/json' });
197
- res.end(JSON.stringify({ ready }));
290
+ res.end(JSON.stringify({
291
+ ready,
292
+ inflight: turnGate.inflight,
293
+ queued: turnGate.queued,
294
+ max: turnGate.max,
295
+ }));
198
296
  return;
199
297
  }
200
298
  const urlPath = req.url?.split('?')[0] ?? '';
@@ -208,22 +306,57 @@ async function serveCommand(options) {
208
306
  const body = await parseBody(req);
209
307
  const userId = (0, serve_hardening_1.resolveUserId)(req.headers, body);
210
308
  const dataToken = (0, serve_hardening_1.resolveDataToken)(req.headers);
211
- const rlKey = userId ?? (0, serve_hardening_1.clientIp)(req);
309
+ // Rate-limit on the trusted proxy identity (or client IP), never the client-supplied
310
+ // body user_id — otherwise a caller rotates user_id per request to reset the bucket.
311
+ const rlKey = (0, serve_hardening_1.resolveUserId)(req.headers, undefined) ?? (0, serve_hardening_1.clientIp)(req);
212
312
  if (!rateLimiter.tryConsume(rlKey)) {
213
313
  res.writeHead(429, { 'Content-Type': 'application/json', 'Retry-After': String(rateLimiter.retryAfterSeconds(rlKey)) });
214
314
  res.end(JSON.stringify({ error: 'Rate limit exceeded' }));
215
315
  return;
216
316
  }
217
- 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' }));
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' }));
221
324
  return;
222
325
  }
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
326
+ // Acquire before turn-limiter so a capacity reject does not burn a turn slot.
327
+ const release = await turnGate.acquire();
328
+ if (!release) {
329
+ warnGateReject(`TurnGate: rejecting /v1/chat (inflight=${turnGate.inflight} queued=${turnGate.queued} max=${turnGate.max})`);
330
+ res.writeHead(429, {
331
+ 'Content-Type': 'application/json',
332
+ 'Retry-After': String(serve_hardening_1.TURN_GATE_RETRY_AFTER_SECONDS),
333
+ });
334
+ res.end(JSON.stringify({ error: 'At capacity' }));
335
+ return;
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;
341
+ try {
342
+ if (!turnLimiter.tryConsume(sessionKey)) {
343
+ res.writeHead(429, { 'Content-Type': 'application/json' });
344
+ res.end(JSON.stringify({ error: 'Per-session turn limit reached' }));
345
+ return;
346
+ }
347
+ await prepareSession(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) });
352
+ res.writeHead(200, { 'Content-Type': 'application/json' });
353
+ res.end(JSON.stringify(result));
354
+ statePusher?.schedule(sessionKey); // per-session delta push, off the response path
355
+ }
356
+ finally {
357
+ turnReg?.release();
358
+ release();
359
+ }
227
360
  return;
228
361
  }
229
362
  // Streaming chat endpoint
@@ -231,25 +364,63 @@ async function serveCommand(options) {
231
364
  const body = await parseBody(req);
232
365
  const userId = (0, serve_hardening_1.resolveUserId)(req.headers, body);
233
366
  const dataToken = (0, serve_hardening_1.resolveDataToken)(req.headers);
234
- const rlKey = userId ?? (0, serve_hardening_1.clientIp)(req);
367
+ // Rate-limit on the trusted proxy identity (or client IP), never the client-supplied
368
+ // body user_id — otherwise a caller rotates user_id per request to reset the bucket.
369
+ const rlKey = (0, serve_hardening_1.resolveUserId)(req.headers, undefined) ?? (0, serve_hardening_1.clientIp)(req);
235
370
  if (!rateLimiter.tryConsume(rlKey)) {
236
371
  res.writeHead(429, { 'Content-Type': 'application/json', 'Retry-After': String(rateLimiter.retryAfterSeconds(rlKey)) });
237
372
  res.end(JSON.stringify({ error: 'Rate limit exceeded' }));
238
373
  return;
239
374
  }
240
- 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' }));
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
+ }
383
+ // Acquire BEFORE writeHead(200, SSE) so a reject still gets a clean 429.
384
+ const release = await turnGate.acquire();
385
+ if (!release) {
386
+ warnGateReject(`TurnGate: rejecting /v1/chat/stream (inflight=${turnGate.inflight} queued=${turnGate.queued} max=${turnGate.max})`);
387
+ res.writeHead(429, {
388
+ 'Content-Type': 'application/json',
389
+ 'Retry-After': String(serve_hardening_1.TURN_GATE_RETRY_AFTER_SECONDS),
390
+ });
391
+ res.end(JSON.stringify({ error: 'At capacity' }));
244
392
  return;
245
393
  }
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
394
+ // Turn-scoped data capability — same rules as /v1/chat above.
395
+ let turnReg = null;
396
+ try {
397
+ if (!turnLimiter.tryConsume(sessionKey)) {
398
+ res.writeHead(429, { 'Content-Type': 'application/json' });
399
+ res.end(JSON.stringify({ error: 'Per-session turn limit reached' }));
400
+ return;
401
+ }
402
+ await prepareSession(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) });
418
+ statePusher?.schedule(sessionKey); // per-session delta push, off the response path
419
+ }
420
+ finally {
421
+ turnReg?.release();
422
+ release();
423
+ }
253
424
  return;
254
425
  }
255
426
  // 404
@@ -289,7 +460,7 @@ async function serveCommand(options) {
289
460
  }
290
461
  console.log(`Workspace: ${options.workspace}`);
291
462
  if (pack) {
292
- console.log(`Pack: ${pack.manifest.metadata.name}`);
463
+ console.log(`Pack: ${pack.manifest.metadata.name}@${pack.manifest.metadata.version}`);
293
464
  console.log(`Gateway: ${gatewayAgentId}`);
294
465
  }
295
466
  console.log(BANNER_LINE);
@@ -312,17 +483,50 @@ async function serveCommand(options) {
312
483
  }
313
484
  }
314
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
+ }
315
512
  try {
316
513
  gatewayBridge = await startServeGatewayBridge({
317
514
  gatewayAgentId,
318
515
  packName: pack.manifest.metadata.name,
319
516
  workspace: options.workspace,
320
- stateDir: storagePath,
517
+ registerTurn: registerServeTurn,
518
+ brokerGatewayToken,
321
519
  engine: defaultEngine,
322
520
  defaultModel,
323
521
  runner,
324
522
  permissions,
325
- onTurnComplete: () => statePusher?.schedule(),
523
+ turnGate,
524
+ rateLimiter,
525
+ turnLimiter,
526
+ warnGateReject,
527
+ prepareSession,
528
+ onTurnComplete: (sessionId) => statePusher?.schedule(sessionId),
529
+ logWarrantDenied,
326
530
  });
327
531
  }
328
532
  catch (err) {
@@ -360,6 +564,22 @@ async function serveCommand(options) {
360
564
  }
361
565
  gatewayBridge = null;
362
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
+ }
363
583
  server.close(() => {
364
584
  runner.dispose();
365
585
  process.exit(0);
@@ -383,15 +603,18 @@ async function parseBody(req) {
383
603
  throw new Error('Invalid JSON');
384
604
  }
385
605
  }
386
- async function handleChat(runner, body, defaultEngine, defaultModel, workspace, permissions, userId) {
606
+ async function handleChat(runner, body, sessionKey, defaultEngine, defaultModel, workspace, permissions, userId, turnEnv) {
387
607
  const result = await runner.run({
388
608
  engineId: resolveRequestedEngineId(body.engine, defaultEngine),
389
609
  modelId: body.model || defaultModel,
390
610
  message: body.message,
391
611
  workspaceRoot: workspace,
392
- 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,
393
615
  analyticsUserId: userId,
394
616
  permissions,
617
+ turnEnv,
395
618
  });
396
619
  return {
397
620
  success: result.success,
@@ -401,6 +624,85 @@ async function handleChat(runner, body, defaultEngine, defaultModel, workspace,
401
624
  error: result.error,
402
625
  };
403
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
+ }
404
706
  /**
405
707
  * Register this serve process as an OpenClaw external agent (WebSocket `agent.run` → headless runner).
406
708
  * Enables `openclaw agent --agent <packId> --message "…"` against the same gateway.
@@ -409,7 +711,9 @@ async function startServeGatewayBridge(params) {
409
711
  const { GatewayBridge: Bridge } = await import('../../gateway/bridge.js');
410
712
  const DEFAULT_GATEWAY_PORT = 18789;
411
713
  const gatewayUrl = process.env.OPENCLAW_GATEWAY_URL ?? `ws://127.0.0.1:${DEFAULT_GATEWAY_PORT}`;
412
- 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;
413
717
  const bridge = new Bridge({ gatewayUrl, token });
414
718
  await bridge.connect();
415
719
  await bridge.registerAgent({
@@ -426,19 +730,65 @@ async function startServeGatewayBridge(params) {
426
730
  : (requestedEngine || params.engine);
427
731
  // #4: the channel sender becomes the per-user user_id (analytics + per-user data scoping).
428
732
  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,
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,
437
746
  emitter: opts.emitter,
438
- permissions: params.permissions,
439
- }), { dataTokenDir: params.stateDir });
440
- params.onTurnComplete?.();
441
- return { success: result.success, error: result.error };
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
+ });
442
792
  },
443
793
  });
444
794
  console.log(`${utils_1.COLORS.green}✓${utils_1.COLORS.reset} Gateway bridge: registered "${params.gatewayAgentId}" at ${gatewayUrl}`);
@@ -502,7 +852,7 @@ async function bindToGatewayChannels(agentId, bindSpec) {
502
852
  client.close();
503
853
  }
504
854
  }
505
- 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) {
506
856
  let streamEnded = false;
507
857
  const sendEvent = (type, data) => {
508
858
  if (streamEnded || res.writableEnded) {
@@ -556,10 +906,12 @@ async function handleStreamingChat(runner, body, defaultEngine, defaultModel, wo
556
906
  modelId: body.model || defaultModel,
557
907
  message: body.message,
558
908
  workspaceRoot: workspace,
559
- sessionId: body.session_id,
909
+ // The user-namespaced key (never raw body.session_id) — see resolveServeSessionKey.
910
+ sessionId: sessionKey,
560
911
  analyticsUserId: userId,
561
912
  emitter,
562
913
  permissions,
914
+ turnEnv,
563
915
  });
564
916
  if (!streamEnded) {
565
917
  sendEvent('done', {});