@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
@@ -43,6 +43,8 @@ const base_1 = require("./base");
43
43
  const utils_1 = require("../utils");
44
44
  const permissions_1 = require("../permissions");
45
45
  const mcp_1 = require("../mcp");
46
+ const data_plane_1 = require("../cli/data-plane");
47
+ const runtime_1 = require("../cli/runtime");
46
48
  const session_1 = require("../session");
47
49
  const agentpack_1 = require("../agentpack");
48
50
  const shared_1 = require("../shared");
@@ -241,7 +243,8 @@ class ClaudeEngine {
241
243
  // Edit tracker from runtime context (optional)
242
244
  const editTracker = runtime.editTracker;
243
245
  // Resolve API routing (LiteLLM proxy vs direct Anthropic)
244
- const { apiKey, baseUrl, useLiteLLM } = (0, utils_1.resolveApiRouting)(options, this.config.litellmApiKey, this.config.litellmBaseUrl);
246
+ // claude routes to Anthropic when direct (or the LiteLLM proxy when configured) — never OpenAI.
247
+ const { apiKey, baseUrl, useLiteLLM } = (0, utils_1.resolveApiRouting)(options, this.config, 'anthropic');
245
248
  if (!apiKey) {
246
249
  throw new Error('Claude API key not provided');
247
250
  }
@@ -267,6 +270,11 @@ class ClaudeEngine {
267
270
  'DEBUG_CLAUDE_AGENT_SDK': '1',
268
271
  'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC': '1',
269
272
  };
273
+ // Egress broker active ⇒ the gateway token stays broker-side; scrub it from the child
274
+ // env so it never reaches the model (secure-obo-design §7.6 / §7.4 gap 1).
275
+ if (claudeEnv[data_plane_1.EGRESS_URL_ENV]) {
276
+ delete claudeEnv[runtime_1.OPENCLAW_GATEWAY_TOKEN_ENV];
277
+ }
270
278
  const isSetupTokenAuth = (0, shared_1.isAnthropicSetupToken)(apiKey);
271
279
  if (isSetupTokenAuth) {
272
280
  const normalizedSetupToken = (0, shared_1.normalizeAnthropicSetupToken)(apiKey);
@@ -541,8 +549,10 @@ class ClaudeEngine {
541
549
  logger.debug(`[AgentIDE] SessionStart hook called! event=${input.hook_event_name}`);
542
550
  return CONTINUE;
543
551
  };
544
- // Load MCP servers for the active agent and pass directly to SDK
545
- const mcpServers = await this.loadMcpServersForClaude(logger, options.workspaceRoot, agentId);
552
+ // Load MCP servers for the active agent and pass directly to SDK. claudeEnv is the
553
+ // per-turn child env — URL servers resolve their bearerTokenEnv (turn nonce) from it,
554
+ // in memory, so no per-turn value ever lands in a shared config file.
555
+ const mcpServers = await this.loadMcpServersForClaude(logger, options.workspaceRoot, agentId, claudeEnv);
546
556
  // Determine cwd and settingSources
547
557
  // cwd is ALWAYS workspaceRoot - active agent content has been synced to workspace's .claude/
548
558
  // Agent packs: settingSources is 'project' only (no user ~/.claude/ mixing)
@@ -1191,9 +1201,11 @@ class ClaudeEngine {
1191
1201
  * - Default agent: uses bundled MCP servers from extension
1192
1202
  * - Pack agents: uses pack's mcp/ directory
1193
1203
  *
1194
- * The SDK accepts mcpServers directly in query options.
1204
+ * The SDK accepts mcpServers directly in query options — including HTTP Streamable
1205
+ * servers (`{type:'http', url, headers}`). `turnEnv` is the per-turn child env; a URL
1206
+ * server's `bearerTokenEnv` (e.g. AGENTHIPPO_TURN_NONCE) resolves against it in memory.
1195
1207
  */
1196
- async loadMcpServersForClaude(logger, workspaceRoot, agentId = 'default') {
1208
+ async loadMcpServersForClaude(logger, workspaceRoot, agentId = 'default', turnEnv) {
1197
1209
  const basePromptsDir = this.getBundledBasePromptsDir();
1198
1210
  const servers = await (0, mcp_1.loadMcpServers)(agentId, workspaceRoot, basePromptsDir);
1199
1211
  if (servers.length === 0) {
@@ -1201,6 +1213,15 @@ class ClaudeEngine {
1201
1213
  }
1202
1214
  const mcpServers = {};
1203
1215
  for (const s of servers) {
1216
+ if (s.url) {
1217
+ const headers = (0, mcp_1.resolveHttpMcpHeaders)(s, turnEnv ?? process.env);
1218
+ mcpServers[s.name] = {
1219
+ type: 'http',
1220
+ url: s.url,
1221
+ ...(headers ? { headers } : {}),
1222
+ };
1223
+ continue;
1224
+ }
1204
1225
  mcpServers[s.name] = {
1205
1226
  command: s.command,
1206
1227
  args: s.args,
@@ -46,6 +46,9 @@ const utils_1 = require("../utils");
46
46
  const permissions_1 = require("../permissions");
47
47
  const session_1 = require("../session");
48
48
  const agentpack_1 = require("../agentpack");
49
+ const credential_context_1 = require("../cli/credential-context");
50
+ const data_plane_1 = require("../cli/data-plane");
51
+ const runtime_1 = require("../cli/runtime");
49
52
  // Note: HTTP edit server removed - file changes are now handled directly via file_change events
50
53
  // -----------------------------------------------------------------------------
51
54
  // Constants
@@ -63,7 +66,7 @@ const AGENTIDE_CODEX_SOURCE_ENV_VAR = 'AGENTIDE_ANALYTICS_SOURCE';
63
66
  const AGENTIDE_CODEX_ENGINE_ENV_VAR = 'AGENTIDE_ANALYTICS_ENGINE';
64
67
  const AGENTIDE_CODEX_REQUEST_ID_ENV_VAR = 'AGENTIDE_ANALYTICS_REQUEST_ID';
65
68
  // Bump when we change generated config semantics so long-lived sessions regenerate config.toml.
66
- const AGENTIDE_CODEX_CONFIG_VERSION = 18; // v18: Codex CLI dropped wire_api=chat; always use responses
69
+ const AGENTIDE_CODEX_CONFIG_VERSION = 20; // v20: default_tools_approval_mode "auto"→"approve" ("auto" is invalid on codex ≥0.131 and cancels every headless MCP tool call)
67
70
  // Per-process runtime context for agents (not analytics): lets the agent locate its per-session context export.
68
71
  const AGENTIDE_CONTEXT_EXPORT_PATH_ENV_VAR = 'AGENTIDE_CONTEXT_EXPORT_PATH';
69
72
  const AGENTIDE_TERMINAL_LOG_PATH_ENV_VAR = 'AGENTIDE_TERMINAL_LOG_PATH';
@@ -100,7 +103,8 @@ class CodexEngine {
100
103
  const threadModelId = options.modelId;
101
104
  // Resolve API routing (LiteLLM proxy vs direct OpenAI)
102
105
  // Use openaiBaseUrl which has overrideOpenAIBaseUrl applied
103
- const routing = (0, utils_1.resolveApiRouting)(options, this.config.litellmApiKey, this.config.litellmBaseUrl);
106
+ // codex routes to OpenAI when direct (or the LiteLLM proxy when configured) — never Anthropic.
107
+ const routing = (0, utils_1.resolveApiRouting)(options, this.config, 'openai');
104
108
  const apiKey = routing.apiKey;
105
109
  const baseUrl = routing.openaiBaseUrl; // Use OpenAI-specific URL
106
110
  const useLiteLLM = routing.useLiteLLM;
@@ -126,6 +130,13 @@ class CodexEngine {
126
130
  ...(runtime.cliEnvPatch ?? {}),
127
131
  OPENAI_LOG: 'debug',
128
132
  };
133
+ // When serve runs the egress broker, the OpenClaw gateway token must never enter the
134
+ // agent's trust domain (secure-obo-design §7.6 / §7.4 gap 1). Scrub it from the child env
135
+ // at the spawn boundary — belt-and-suspenders with serve's boot-time process.env strip,
136
+ // so a token in process.env (e.g. captured before the strip) still cannot reach the model.
137
+ if (codexEnv[data_plane_1.EGRESS_URL_ENV]) {
138
+ delete codexEnv[runtime_1.OPENCLAW_GATEWAY_TOKEN_ENV];
139
+ }
129
140
  // Always expose the VS Code chat session id to the agent process so it can locate:
130
141
  // .agentide/sessions/<sessionId>/context.md
131
142
  const rawChatSessionId = sessionId?.trim();
@@ -141,8 +152,12 @@ class CodexEngine {
141
152
  // Note: workingDirectory is ALWAYS workspaceRoot - active agent content is synced to workspace's .codex/
142
153
  const agentId = options.agentPackId ?? 'default';
143
154
  const runtimePaths = (0, agentpack_1.resolveRuntimePaths)(options.workspaceRoot, this.config.storagePath, agentId, options.agentPackVersion);
144
- // Use agent-isolated CODEX_HOME (for session/state isolation)
145
- const codexHome = runtimePaths.codexHome;
155
+ // Use agent-isolated CODEX_HOME (for session/state isolation). In multi-tenant serve,
156
+ // additionally key it by the verified request user: config.toml is rewritten per turn,
157
+ // and a shared per-agent home lets concurrent turns of different users race it
158
+ // (secure-obo-design §1 defect 1 / §8 Phase 0). No credential frame (IDE,
159
+ // single-tenant CLI) ⇒ unchanged per-agent path.
160
+ const codexHome = (0, agentpack_1.userScopedEngineHome)(runtimePaths.codexHome, (0, credential_context_1.currentCredential)()?.userId);
146
161
  codexEnv['CODEX_HOME'] = codexHome;
147
162
  await fs_1.promises.mkdir(codexHome, { recursive: true });
148
163
  // Always set AGENT_ID for agent to know its identity
@@ -601,7 +616,11 @@ class CodexEngine {
601
616
  // Codex CLI requires wire_api=responses (chat wire was removed in newer Codex releases).
602
617
  const wireApi = 'responses';
603
618
  const routingType = useLiteLLM ? 'litellm' : (customBaseUrl ? 'custom' : 'openai');
604
- const signature = `${AGENTIDE_CODEX_CONFIG_VERSION}:${routingType}:${customBaseUrl ?? ''}:${wireApi}:${agentId}:${codexInstructionsFile ?? ''}:${(0, mcp_1.mcpConfigCacheSignatureSuffix)()}`;
619
+ // No per-turn component in this signature: config.toml must be byte-identical across
620
+ // turns (per-turn auth rides the process env via bearer_token_env_var, never the file),
621
+ // so concurrent same-home turns write identical bytes and the old cross-turn race is
622
+ // gone by construction (secure-obo-design §3.2).
623
+ const signature = `${AGENTIDE_CODEX_CONFIG_VERSION}:${routingType}:${customBaseUrl ?? ''}:${wireApi}:${agentId}:${codexInstructionsFile ?? ''}`;
605
624
  if (this.lastWrittenConfigSignature === signature) {
606
625
  return;
607
626
  }
@@ -38,6 +38,8 @@ var __importStar = (this && this.__importStar) || (function () {
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
39
  exports.CustomEngine = void 0;
40
40
  exports.resolveCustomEngineLoadStrategy = resolveCustomEngineLoadStrategy;
41
+ exports.deliverModelRoutingEnv = deliverModelRoutingEnv;
42
+ exports.buildRunnerChildEnv = buildRunnerChildEnv;
41
43
  exports.probeCustomEngineImport = probeCustomEngineImport;
42
44
  const node_child_process_1 = require("node:child_process");
43
45
  const node_readline_1 = require("node:readline");
@@ -83,10 +85,45 @@ function prependNodePath(env, engineDir) {
83
85
  ? `${engineNodeModules}${path.delimiter}${existing}`
84
86
  : engineNodeModules;
85
87
  }
88
+ /**
89
+ * Deliver the per-turn model-routing values into the env vars a manifest declares
90
+ * (`model.apiKeyEnvVar` / `baseUrlEnvVar` / `openaiBaseUrlEnvVar`).
91
+ *
92
+ * NON-CLOBBERING: a var already set in the host's own ambient env is left alone. Two reasons:
93
+ * (1) it preserves the effective behavior deployments already relied on — the old
94
+ * `{...turn.env, ...process.env}` child-env spread accidentally let ambient values win,
95
+ * and operators' engine-owned keys (e.g. a real CURSOR_API_KEY vs a stale manifest that
96
+ * wrongly declared it as a delivery slot) must keep winning after that spread is fixed;
97
+ * (2) it makes the manifest contract safe by construction: mis-declaring an operator-owned
98
+ * credential as a delivery slot can shadow nothing.
99
+ * The flip side is documented on CustomEngineModelConfig: ambient-exporting a genuine
100
+ * delivery var (e.g. LITELLM_API_KEY in serve's shell) shadows per-turn rotation.
101
+ */
102
+ function deliverModelRoutingEnv(env, manifest, routing, ambientEnv, logger) {
103
+ const deliver = (envVar, value, label) => {
104
+ if (!envVar || !value) {
105
+ return;
106
+ }
107
+ if (ambientEnv[envVar]?.trim()) {
108
+ logger?.info(`[AgentIDE] Custom engine "${manifest.id}": ${envVar} already set in ambient env — keeping the operator's value, skipping per-turn ${label} delivery`);
109
+ return;
110
+ }
111
+ env[envVar] = value;
112
+ };
113
+ deliver(manifest.model?.apiKeyEnvVar, routing.apiKey, 'API key');
114
+ deliver(manifest.model?.baseUrlEnvVar, routing.baseUrl, 'base URL');
115
+ deliver(manifest.model?.openaiBaseUrlEnvVar, routing.openaiBaseUrl, 'OpenAI base URL');
116
+ }
86
117
  function buildRunnerChildEnv(manifest, turn, probeImport) {
87
118
  const childEnv = {
88
- ...turn.env,
119
+ // Per-turn values WIN over the host's ambient env. turn.env starts from a spread of
120
+ // process.env, so for undeclared vars this is identical either way; the difference is
121
+ // deliberate per-turn state (AGENTHIPPO_* control vars, the data-plane turn nonce,
122
+ // session paths, manifest-declared deliveries) which must never be reverted by a
123
+ // same-named var in the host's own environment. Ambient-vs-delivery precedence for
124
+ // manifest credential vars is decided earlier, in deliverModelRoutingEnv (non-clobber).
89
125
  ...process.env,
126
+ ...turn.env,
90
127
  AGENTHIPPO_ENGINE_ENTRY: manifest.entryPath,
91
128
  AGENTHIPPO_MESSAGE: turn.message,
92
129
  AGENTHIPPO_WORKSPACE_ROOT: turn.workspaceRoot,
@@ -466,7 +503,10 @@ class CustomEngine {
466
503
  const attachmentsDir = path.join(workspaceSessionDir, 'attachments');
467
504
  const nativeSessionId = this.activeSessions.get(conversationKey)?.nativeSessionId ??
468
505
  (0, base_1.getPersistedSessionId)(`custom:${manifest.id}`, conversationKey);
469
- const routing = (0, utils_1.resolveApiRouting)(options, this.config.litellmApiKey, this.config.litellmBaseUrl, runtime.configProvider);
506
+ // A custom engine isn't pinned to one vendor — infer its direct provider from the model
507
+ // (e.g. openclaw's litellm/* routes through the proxy anyway; a claude-* model → Anthropic).
508
+ // The engine's manifest still decides which env var receives the resolved key.
509
+ const routing = (0, utils_1.resolveApiRouting)(options, this.config, (0, utils_1.inferModelProvider)(options.modelId), runtime.configProvider);
470
510
  const routingApiKey = routing.apiKey?.trim() || undefined;
471
511
  const routingBaseUrl = routing.baseUrl?.trim() || undefined;
472
512
  const routingOpenAIBaseUrl = routing.openaiBaseUrl?.trim() || undefined;
@@ -532,15 +572,7 @@ class CustomEngine {
532
572
  if (manifest.homeEnvVar) {
533
573
  env[manifest.homeEnvVar] = engineHomeDir;
534
574
  }
535
- if (manifest.model?.apiKeyEnvVar && routingApiKey) {
536
- env[manifest.model.apiKeyEnvVar] = routingApiKey;
537
- }
538
- if (manifest.model?.baseUrlEnvVar && routingBaseUrl) {
539
- env[manifest.model.baseUrlEnvVar] = routingBaseUrl;
540
- }
541
- if (manifest.model?.openaiBaseUrlEnvVar && routingOpenAIBaseUrl) {
542
- env[manifest.model.openaiBaseUrlEnvVar] = routingOpenAIBaseUrl;
543
- }
575
+ deliverModelRoutingEnv(env, manifest, { apiKey: routingApiKey, baseUrl: routingBaseUrl, openaiBaseUrl: routingOpenAIBaseUrl }, process.env, runtime.logger);
544
576
  if ((routingBaseUrl && (0, utils_1.applyLocalTlsBypass)(env, routingBaseUrl))
545
577
  || (routingOpenAIBaseUrl && routingOpenAIBaseUrl !== routingBaseUrl && (0, utils_1.applyLocalTlsBypass)(env, routingOpenAIBaseUrl))) {
546
578
  runtime.logger.info(`[AgentIDE] Disabling TLS verification for local HTTPS (${manifest.id})`);
@@ -550,7 +582,9 @@ class CustomEngine {
550
582
  : (routingBaseUrl ? 'custom provider' : 'native provider');
551
583
  runtime.logger.info(`[AgentIDE] Custom engine "${manifest.id}" routing: ${routingDescription}, baseUrl=${routingBaseUrl || 'default'}, model=${modelId}`);
552
584
  const resolvedMcp = await (0, mcp_1.loadMcpServers)(agentId, options.workspaceRoot, this.getBundledBasePromptsDir());
553
- const mcpServers = (0, mcp_1.formatInlineMcpServers)(resolvedMcp);
585
+ // `env` is this turn's child env — URL servers resolve bearerTokenEnv (turn nonce)
586
+ // from it in memory, so per-turn auth never lands in a shared file.
587
+ const mcpServers = (0, mcp_1.formatInlineMcpServers)(resolvedMcp, env);
554
588
  if (resolvedMcp.length > 0) {
555
589
  runtime.logger.debug(`[AgentIDE] Custom engine "${manifest.id}" MCP (${agentId}): ${resolvedMcp.map(s => s.name).join(', ')}`);
556
590
  }
package/out/extension.js CHANGED
@@ -68,6 +68,7 @@ const serveClient_js_1 = require("./remote/serveClient.js");
68
68
  const remotePackStatusBar_js_1 = require("./remote/remotePackStatusBar.js");
69
69
  const provider_js_1 = require("./auth/provider.js");
70
70
  const signInFlow_js_1 = require("./auth/signInFlow.js");
71
+ const onboardingPrompt_js_1 = require("./auth/onboardingPrompt.js");
71
72
  const githubStoreGate_js_1 = require("./auth/githubStoreGate.js");
72
73
  const sessionEvents_1 = require("./analytics/sessionEvents");
73
74
  const base_1 = require("./engines/base");
@@ -323,6 +324,7 @@ async function activate(context) {
323
324
  (0, provider_js_1.registerAgentHippoAuth)(context);
324
325
  void (0, provider_js_1.refreshBootstrapIfSignedIn)(context);
325
326
  context.subscriptions.push((0, provider_js_1.startAgentHippoTokenRefreshTimer)(context));
327
+ (0, onboardingPrompt_js_1.scheduleSignInOnboardingPrompt)(context);
326
328
  // Register commands
327
329
  registerCommands(context, _state);
328
330
  const remotePackStatusBar = new remotePackStatusBar_js_1.RemotePackStatusBar(syncService, () => getWorkspaceRoot(outputChannel).workspaceRoot, outputChannel);
@@ -172,12 +172,12 @@ function buildGatewaySendParams(handle, message, media) {
172
172
  function buildGatewayRpcCommand(handle, message, media) {
173
173
  return [exports.GATEWAY_OUTBOUND_RPC_METHOD, JSON.stringify(buildGatewaySendParams(handle, message, media))];
174
174
  }
175
- async function sendViaGatewayRpc(handle, message, media) {
175
+ async function sendViaGatewayRpc(handle, message, media, explicitToken) {
176
176
  const gatewayUrl = process.env.OPENCLAW_GATEWAY_URL?.trim() || `ws://127.0.0.1:${DEFAULT_GATEWAY_PORT}`;
177
- const token = (0, runtime_js_1.readOpenClawGatewayToken)();
178
- if (token && !process.env.OPENCLAW_GATEWAY_TOKEN?.trim()) {
179
- process.env.OPENCLAW_GATEWAY_TOKEN = token;
180
- }
177
+ // Prefer the broker-supplied token; the env re-population that used to happen here is gone
178
+ // deliberately — under the egress broker it would re-leak the token into process.env, and
179
+ // every engine child spreads process.env (secure-obo-design §7.6).
180
+ const token = explicitToken?.trim() || (0, runtime_js_1.readOpenClawGatewayToken)();
181
181
  const { client, clientInfo } = (0, client_js_1.createEphemeralGatewayClient)(gatewayUrl, token);
182
182
  await client.connect(clientInfo);
183
183
  try {
@@ -218,7 +218,7 @@ async function sendGatewayOutboundMessage(handle, options) {
218
218
  const cliCommand = [cli, ...cliArgs];
219
219
  const rpcCommand = buildGatewayRpcCommand(handle, options.message, media);
220
220
  try {
221
- const rpc = await sendViaGatewayRpc(handle, options.message, media);
221
+ const rpc = await sendViaGatewayRpc(handle, options.message, media, options.token);
222
222
  return {
223
223
  ok: true,
224
224
  via: 'gateway-rpc',
@@ -175,6 +175,8 @@ const session_1 = require("../session");
175
175
  const sync_1 = require("./sync");
176
176
  const sessionEvents_1 = require("../analytics/sessionEvents");
177
177
  const runtime_1 = require("../cli/runtime");
178
+ const credential_context_1 = require("../cli/credential-context");
179
+ const paths_1 = require("../utils/paths");
178
180
  function resolveHeadlessAnalyticsUserId(runOverride, configOverride) {
179
181
  const fromRun = runOverride?.trim();
180
182
  if (fromRun) {
@@ -223,13 +225,18 @@ function createHeadlessRunner(config) {
223
225
  // the legacy single file at provider-sessions.json on first load.
224
226
  (0, session_1.initSessionStoreWithStore)(new interfaces_1.DirectorySessionStore(path.join(config.storagePath, 'provider-sessions')));
225
227
  }
226
- // Build engine config
228
+ // Per-destination credentials. When a caller supplies the split keys (serve), use them so
229
+ // each engine picks the key for the endpoint it dials. Otherwise fall back to the single
230
+ // `apiKey` as the LiteLLM key (back-compat: eval/CLI runners configured one key).
231
+ const hasSplitKeys = Boolean(config.litellmApiKey || config.openaiApiKey || config.anthropicApiKey);
227
232
  const engineConfig = {
228
233
  storagePath: config.storagePath,
229
234
  codexHome: undefined,
230
235
  claudeConfigDir: undefined,
231
- litellmApiKey: config.apiKey,
236
+ litellmApiKey: config.litellmApiKey ?? (hasSplitKeys ? undefined : config.apiKey),
232
237
  litellmBaseUrl: config.baseUrl,
238
+ openaiApiKey: config.openaiApiKey,
239
+ anthropicApiKey: config.anthropicApiKey,
233
240
  claudeCodeExecutablePath: config.claudeCodeExecutablePath,
234
241
  codexExecutablePath: config.codexExecutablePath,
235
242
  };
@@ -283,11 +290,12 @@ function createHeadlessRunner(config) {
283
290
  }
284
291
  return customEngine;
285
292
  };
286
- // Shared session event writer for Layer 2 analytics. Rooted under storagePath (not the
287
- // os.homedir() default) so tool-call/warrant events land on the same durable volume as
288
- // session state — a container's home directory is ephemeral, storagePath honors
289
- // AGENTHIPPO_HOME / --state-dir and is what actually survives a restart or scale event.
290
- const sessionEventWriter = new sessionEvents_1.SessionEventWriter(path.join(config.storagePath, 'analytics', 'sessions'));
293
+ // Shared session event writer for Layer 2 analytics. Defaults to getAgentideHome() — the
294
+ // exact directory sessionJsonlFleet.ts and query-analytics.sh already scan, so tool-call/
295
+ // warrant events stay discoverable — but honors an explicit override (config.analyticsSessionsDir)
296
+ // so callers with their own state-dir precedence (serve.ts) don't regress durability when
297
+ // no AGENTHIPPO_HOME-style env var is set but a durable --state-dir was given explicitly.
298
+ const sessionEventWriter = new sessionEvents_1.SessionEventWriter(config.analyticsSessionsDir ?? path.join((0, paths_1.getAgentideHome)(), 'analytics', 'sessions'));
291
299
  return {
292
300
  async run(options) {
293
301
  const result = {
@@ -339,8 +347,10 @@ function createHeadlessRunner(config) {
339
347
  const requestId = (0, base_1.generateRequestId)();
340
348
  // Wrap emitter with observability (Layer 2 session events)
341
349
  // Session events are written to ~/.agent-hippo/analytics/sessions/<session_id>.jsonl
350
+ // Capture warrant userId here (inside serve's runWithCredential continuation), not
351
+ // later at tool emit — custom-engine IPC callbacks can lose/stick ambient ALS.
342
352
  const sessionId = options.sessionId ?? `headless-${Date.now()}`;
343
- const observableEmitter = (0, sessionEvents_1.wrapEmitterWithObservability)(baseEmitter, sessionEventWriter, sessionId, requestId);
353
+ const observableEmitter = (0, sessionEvents_1.wrapEmitterWithObservability)(baseEmitter, sessionEventWriter, sessionId, requestId, (0, credential_context_1.currentCredential)()?.userId);
344
354
  const requestOptions = {
345
355
  modelId: options.modelId,
346
356
  engineId: options.engineId,
@@ -348,7 +358,11 @@ function createHeadlessRunner(config) {
348
358
  additionalDirectories: options.additionalDirectories,
349
359
  sessionId,
350
360
  analyticsUserId: resolveHeadlessAnalyticsUserId(options.analyticsUserId, config.analyticsUserId),
351
- apiKey: config.apiKey,
361
+ // With split keys (serve), leave options.apiKey UNSET so resolveApiRouting selects
362
+ // the key for the engine's destination — passing the collapsed key here would let
363
+ // it win over the correct provider key (the original bug). Back-compat single-key
364
+ // callers keep options.apiKey.
365
+ apiKey: hasSplitKeys ? undefined : config.apiKey,
352
366
  baseUrl: config.baseUrl,
353
367
  useLiteLLM: config.useLiteLLM,
354
368
  agentPackId: packId,
@@ -367,6 +381,7 @@ function createHeadlessRunner(config) {
367
381
  chatSessionId: sessionId,
368
382
  gatewaySessionKey: options.gatewaySessionKey,
369
383
  }),
384
+ ...(options.turnEnv ?? {}),
370
385
  },
371
386
  };
372
387
  try {
@@ -0,0 +1,8 @@
1
+
2
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
3
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
+ import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
5
+ const server = new Server({ name: 'probe', version: '0.0.1' }, { capabilities: { tools: {} } });
6
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [{ name: 'probe_tool', description: 'x', inputSchema: { type: 'object' } }] }));
7
+ server.setRequestHandler(CallToolRequestSchema, async () => ({ content: [{ type: 'text', text: 'MAGIC=zebra42' }] }));
8
+ await server.connect(new StdioServerTransport());
@@ -0,0 +1,8 @@
1
+
2
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
3
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
+ import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
5
+ const server = new Server({ name: 'env-report', version: '0.0.1' }, { capabilities: { tools: {} } });
6
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [{ name: 'report_env', description: 'reports a probe env var', inputSchema: { type: 'object' } }] }));
7
+ server.setRequestHandler(CallToolRequestSchema, async () => ({ content: [{ type: 'text', text: 'PROBE_SEEN=' + (process.env.AGENTHIPPO_PROBE_ENV || 'ABSENT') }] }));
8
+ await server.connect(new StdioServerTransport());