@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
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,8 +290,12 @@ function createHeadlessRunner(config) {
283
290
  }
284
291
  return customEngine;
285
292
  };
286
- // Shared session event writer for Layer 2 analytics
287
- const sessionEventWriter = new sessionEvents_1.SessionEventWriter();
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'));
288
299
  return {
289
300
  async run(options) {
290
301
  const result = {
@@ -336,8 +347,10 @@ function createHeadlessRunner(config) {
336
347
  const requestId = (0, base_1.generateRequestId)();
337
348
  // Wrap emitter with observability (Layer 2 session events)
338
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.
339
352
  const sessionId = options.sessionId ?? `headless-${Date.now()}`;
340
- 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);
341
354
  const requestOptions = {
342
355
  modelId: options.modelId,
343
356
  engineId: options.engineId,
@@ -345,7 +358,11 @@ function createHeadlessRunner(config) {
345
358
  additionalDirectories: options.additionalDirectories,
346
359
  sessionId,
347
360
  analyticsUserId: resolveHeadlessAnalyticsUserId(options.analyticsUserId, config.analyticsUserId),
348
- 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,
349
366
  baseUrl: config.baseUrl,
350
367
  useLiteLLM: config.useLiteLLM,
351
368
  agentPackId: packId,
@@ -364,6 +381,7 @@ function createHeadlessRunner(config) {
364
381
  chatSessionId: sessionId,
365
382
  gatewaySessionKey: options.gatewaySessionKey,
366
383
  }),
384
+ ...(options.turnEnv ?? {}),
367
385
  },
368
386
  };
369
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());
package/out/mcp.js CHANGED
@@ -36,14 +36,14 @@ var __importStar = (this && this.__importStar) || (function () {
36
36
  };
37
37
  })();
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
- exports.AGENTHIPPO_MCP_SERVERS_ENV = exports.AGENTHIPPO_DATA_TOKEN_FILE_ENV = void 0;
39
+ exports.AGENTHIPPO_MCP_SERVERS_ENV = exports.ALLOW_INLINE_DATA_TOKEN_ENV = void 0;
40
40
  exports.loadMcpServers = loadMcpServers;
41
41
  exports.resolveMcpEnvVar = resolveMcpEnvVar;
42
- exports.mcpConfigCacheSignatureSuffix = mcpConfigCacheSignatureSuffix;
43
42
  exports.resolveMcpServerEnv = resolveMcpServerEnv;
44
43
  exports.generateCodexMcpConfig = generateCodexMcpConfig;
45
44
  exports.getDefaultMcpConfig = getDefaultMcpConfig;
46
45
  exports.loadBundledMcpServers = loadBundledMcpServers;
46
+ exports.resolveHttpMcpHeaders = resolveHttpMcpHeaders;
47
47
  exports.formatInlineMcpServers = formatInlineMcpServers;
48
48
  exports.serializeInlineMcpServers = serializeInlineMcpServers;
49
49
  exports.parseInlineMcpServers = parseInlineMcpServers;
@@ -64,11 +64,16 @@ const MCP_CONFIG_FILE = '.mcp.json';
64
64
  /** MCP servers subdirectory in base-prompts */
65
65
  const MCP_SERVERS_DIR = 'mcp-servers';
66
66
  /**
67
- * Env var passed to stdio MCP child processes: path to the per-turn data credential
68
- * file (OBO token, etc.). MCP servers read this file on each tool call — spawn env
69
- * is fixed at process start and cannot use AsyncLocalStorage.
67
+ * Explicit local-dev opt-in: allow the per-turn data credential to be resolved INLINE into
68
+ * MCP child env / `${credential:dataToken}` placeholders. Off by default — in any real
69
+ * deployment the credential must stay in the broker and reach tools only via the loopback
70
+ * data-plane gateway + turn nonce (secure-obo-design §2). Without this flag, a credential-
71
+ * backed value resolves to nothing and the pack's connector fails closed with its own error.
70
72
  */
71
- exports.AGENTHIPPO_DATA_TOKEN_FILE_ENV = 'AGENTHIPPO_DATA_TOKEN_FILE';
73
+ exports.ALLOW_INLINE_DATA_TOKEN_ENV = 'AGENTHIPPO_ALLOW_INLINE_DATA_TOKEN';
74
+ function inlineDataTokenAllowed() {
75
+ return /^(1|true)$/i.test(process.env[exports.ALLOW_INLINE_DATA_TOKEN_ENV]?.trim() ?? '');
76
+ }
72
77
  const ENV_PLACEHOLDER = /\$\{env:(\w+)\}/g;
73
78
  const CREDENTIAL_PLACEHOLDER = /\$\{credential:(\w+)\}/g;
74
79
  // -----------------------------------------------------------------------------
@@ -146,14 +151,23 @@ async function resolveServerEntry(name, entry, mcpDir, bundledMcpDir, envSources
146
151
  }
147
152
  // HTTP Streamable MCP server — no subprocess needed
148
153
  if (entry.url) {
154
+ // `${env:VAR}` in the URL resolves against the serve process env (e.g. the
155
+ // boot-stable AGENTHIPPO_DATA_MCP_URL of the loopback data-plane gateway).
156
+ const url = entry.url.replace(ENV_PLACEHOLDER, (_, varName) => resolvePlainEnvVar(varName, envSources)).trim();
157
+ if (!url) {
158
+ // Placeholder didn't resolve (e.g. gateway-backed entry loaded outside serve):
159
+ // drop the server rather than hand engines a broken empty-URL entry.
160
+ return null;
161
+ }
149
162
  return {
150
163
  name,
151
164
  label: entry.label ?? name,
152
165
  description: entry.description,
153
166
  command: '',
154
167
  args: [],
155
- url: entry.url,
168
+ url,
156
169
  headers: resolveHeaders(entry.headers, envSources),
170
+ bearerTokenEnv: entry.bearerTokenEnv?.trim() || undefined,
157
171
  };
158
172
  }
159
173
  // Custom stdio server - resolve relative paths from mcpDir
@@ -255,82 +269,63 @@ function resolveArgs(args, serverDir) {
255
269
  /**
256
270
  * Resolve a single ${env:VAR} reference for MCP child processes.
257
271
  *
258
- * The turn's OBO data token is exposed two ways:
272
+ * The turn's OBO data token is exposed two ways, BOTH gated behind the explicit
273
+ * local-dev opt-in {@link ALLOW_INLINE_DATA_TOKEN_ENV}:
259
274
  * - `${credential:dataToken}` placeholder (pack-author-facing; handled in resolveEnv).
260
- * - A configurable set of env-var aliases (default `DATABRICKS_TOKEN`) for packs that
261
- * reference the token via `${env:VAR}` directly. Override the alias list with the
262
- * `AGENTHIPPO_DATA_TOKEN_ENV_VARS` env var (comma-separated) — keeps this helper
263
- * vendor-neutral instead of hardcoding one provider's token name.
275
+ * - A configurable set of env-var aliases for packs that reference the token via
276
+ * `${env:VAR}` directly (`AGENTHIPPO_DATA_TOKEN_ENV_VARS`, comma-separated; empty
277
+ * by default — inline aliasing must be declared, never ambient).
264
278
  *
265
- * When a turn-scoped data token file exists, the token is NOT baked into spawn env —
266
- * MCP servers read it from AGENTHIPPO_DATA_TOKEN_FILE on each tool call instead.
279
+ * In a real deployment neither fires: the credential stays in the broker and tools reach
280
+ * it via the loopback data-plane gateway + turn nonce (secure-obo-design §3).
267
281
  */
268
282
  function resolveMcpEnvVar(varName, envSources = {}) {
269
- const cred = (0, credential_context_1.currentCredential)();
270
- const tokenFile = cred?.dataTokenFile;
271
283
  const aliases = getDataTokenEnvAliases();
272
- const isDataTokenVar = varName === 'dataToken' || aliases.includes(varName);
273
- if (tokenFile && isDataTokenVar) {
274
- // Token lives in the per-turn file (AGENTHIPPO_DATA_TOKEN_FILE); don't bake into spawn env.
275
- return '';
276
- }
277
284
  if (varName === 'dataToken') {
278
- return cred?.dataToken?.trim() ?? '';
285
+ const tok = (0, credential_context_1.currentCredential)()?.dataToken?.trim();
286
+ return tok && inlineDataTokenAllowed() ? tok : '';
279
287
  }
280
- if (cred?.dataToken?.trim() && aliases.includes(varName)) {
281
- return cred.dataToken.trim();
288
+ if (aliases.includes(varName)) {
289
+ const tok = (0, credential_context_1.currentCredential)()?.dataToken?.trim();
290
+ if (tok && inlineDataTokenAllowed()) {
291
+ return tok;
292
+ }
282
293
  }
283
294
  return process.env[varName] ?? envSources[varName] ?? '';
284
295
  }
285
296
  /**
286
297
  * Env-var names that resolve to the turn's OBO data token when a pack references
287
- * `${env:VAR}` directly. Defaults to `DATABRICKS_TOKEN`; override at deploy time via
288
- * `AGENTHIPPO_DATA_TOKEN_ENV_VARS=VAR1,VAR2` (empty value → no aliases).
298
+ * `${env:VAR}` directly. Empty by default — set `AGENTHIPPO_DATA_TOKEN_ENV_VARS=VAR1,VAR2`
299
+ * (together with the inline opt-in) to enable. The old implicit `DATABRICKS_TOKEN` default
300
+ * was removed with the disk token path: inline credential exposure is now always explicit.
289
301
  */
290
302
  function getDataTokenEnvAliases() {
291
303
  const raw = process.env.AGENTHIPPO_DATA_TOKEN_ENV_VARS;
292
304
  if (raw !== undefined) {
293
305
  return raw.split(',').map((v) => v.trim()).filter(Boolean);
294
306
  }
295
- return ['DATABRICKS_TOKEN'];
307
+ return [];
296
308
  }
297
309
  function envValueUsesCredentialDataToken(value) {
298
310
  return /\$\{credential:dataToken\}/.test(value);
299
311
  }
300
- /** Suffix for Codex config.toml cache invalidation when MCP credential scope changes. */
301
- function mcpConfigCacheSignatureSuffix() {
302
- const cred = (0, credential_context_1.currentCredential)();
303
- const aliases = getDataTokenEnvAliases();
304
- if (cred?.dataTokenFile) {
305
- return `file:${cred.dataTokenFile}:aliases=${aliases.join(',')}`;
306
- }
307
- if (cred?.dataToken?.trim()) {
308
- return `inline-token:aliases=${aliases.join(',')}`;
309
- }
310
- return `no-token:aliases=${aliases.join(',')}`;
311
- }
312
312
  function resolveEnv(env, envSources = {}) {
313
313
  if (!env) {
314
314
  return undefined;
315
315
  }
316
316
  const resolved = {};
317
- const tokenFile = (0, credential_context_1.currentCredential)()?.dataTokenFile;
318
- if (tokenFile) {
319
- resolved[exports.AGENTHIPPO_DATA_TOKEN_FILE_ENV] = tokenFile;
320
- }
321
317
  for (const [key, value] of Object.entries(env)) {
322
318
  CREDENTIAL_PLACEHOLDER.lastIndex = 0;
323
319
  if (envValueUsesCredentialDataToken(value)) {
324
- // Credential-backed secret. When a per-turn token file exists, the key is intentionally
325
- // NOT set here: the MCP server reads the token from AGENTHIPPO_DATA_TOKEN_FILE
326
- // (injected above) on each tool call, so baking it into spawn env would be redundant
327
- // and would leak the turn-scoped token into the long-lived child process env.
328
- // Prefer the OBO token file, then inline token, then env fallback.
329
- if (tokenFile) {
330
- // AGENTHIPPO_DATA_TOKEN_FILE already injected above; don't bake the token here.
331
- }
332
- else if ((0, credential_context_1.currentCredential)()?.dataToken?.trim()) {
333
- resolved[key] = (0, credential_context_1.currentCredential)().dataToken.trim();
320
+ // Credential-backed secret. Baked into spawn env ONLY behind the explicit
321
+ // local-dev opt-in — the supported transport is the data-plane gateway, where
322
+ // the credential never leaves the broker process (secure-obo-design §2).
323
+ const tok = (0, credential_context_1.currentCredential)()?.dataToken?.trim();
324
+ if (tok) {
325
+ if (inlineDataTokenAllowed()) {
326
+ resolved[key] = tok;
327
+ }
328
+ // else: intentionally unset — the connector fails closed with its own error.
334
329
  }
335
330
  else {
336
331
  // No OBO credential (local dev / non-serve context): fall back to env var.
@@ -429,12 +424,14 @@ function generateCodexMcpConfig(servers, options) {
429
424
  if (servers.length === 0) {
430
425
  return '';
431
426
  }
432
- // `default_tools_approval_mode = "auto"` is the documented Codex 0.122+ per-server key
433
- // (values: auto/prompt/approve) that lets MCP tools run without an interactive prompt.
427
+ // `default_tools_approval_mode = "approve"` is the Codex per-server key that lets MCP
428
+ // tools run without an interactive prompt. The value enum is prompt|approve — "auto" is
429
+ // NOT valid: Codex ≥0.131 silently ignores it, defaults the server to `prompt`, and a
430
+ // headless `codex exec` turn then auto-Cancels the approval elicitation, surfacing as
431
+ // "user cancelled MCP tool call" on every tool call (verified live against 0.131.0).
434
432
  // Only emit it in headless/serve auto-approve contexts — the interactive IDE path leaves
435
- // it unset so users get Codex's normal approval prompts. The previous `approval_mode =
436
- // "never"` was an invalid key/value and was silently ignored by the Codex CLI.
437
- const approvalLine = options?.autoApprove ? 'default_tools_approval_mode = "auto"' : undefined;
433
+ // it unset so users get Codex's normal approval prompts.
434
+ const approvalLine = options?.autoApprove ? 'default_tools_approval_mode = "approve"' : undefined;
438
435
  const sections = servers.map(s => {
439
436
  // HTTP Streamable MCP server — codex connects via URL, no subprocess
440
437
  if (s.url) {
@@ -442,6 +439,13 @@ function generateCodexMcpConfig(servers, options) {
442
439
  `[mcp_servers.${s.name}]`,
443
440
  `url = ${JSON.stringify(s.url)}`,
444
441
  ];
442
+ if (s.bearerTokenEnv) {
443
+ // The auth token is read from the codex PROCESS env at request time
444
+ // (per-spawn codexEnv carries the turn nonce) — the config file itself
445
+ // stays byte-identical across turns, which is what removes the
446
+ // cross-user config.toml race (secure-obo-design §3.2).
447
+ urlLines.push(`bearer_token_env_var = ${JSON.stringify(s.bearerTokenEnv)}`);
448
+ }
445
449
  if (s.headers && Object.keys(s.headers).length > 0) {
446
450
  const headerEntries = Object.entries(s.headers)
447
451
  .map(([k, v]) => `${JSON.stringify(k)} = ${JSON.stringify(v)}`)
@@ -465,7 +469,9 @@ function generateCodexMcpConfig(servers, options) {
465
469
  // Use separate [mcp_servers.name.env] section — codex binary requires this form
466
470
  lines.push('', `[mcp_servers.${s.name}.env]`);
467
471
  for (const [k, v] of Object.entries(s.env)) {
468
- lines.push(`${k} = ${JSON.stringify(v)}`);
472
+ // Quote the key: env-var names may contain '.', spaces, etc. that are not valid
473
+ // TOML bare keys (a bare `my.token` would parse as a nested table).
474
+ lines.push(`${JSON.stringify(k)} = ${JSON.stringify(v)}`);
469
475
  }
470
476
  }
471
477
  return lines.join('\n');
@@ -532,6 +538,23 @@ async function loadBundledMcpServers(basePromptsDir) {
532
538
  }
533
539
  /** Env var carrying JSON-serialized {@link InlineMcpServers} into custom engine workers. */
534
540
  exports.AGENTHIPPO_MCP_SERVERS_ENV = 'AGENTHIPPO_MCP_SERVERS';
541
+ /**
542
+ * Resolve the final HTTP headers for a URL-based MCP server, applying `bearerTokenEnv`
543
+ * against the ENGINE'S per-turn env (process.env + per-spawn patch). Engines that build
544
+ * their MCP config in memory per turn (claude, custom) call this with the same env they
545
+ * hand the engine subprocess, so the turn nonce lands in the request header without ever
546
+ * touching a shared file.
547
+ */
548
+ function resolveHttpMcpHeaders(server, turnEnv) {
549
+ const headers = { ...(server.headers ?? {}) };
550
+ if (server.bearerTokenEnv) {
551
+ const token = turnEnv[server.bearerTokenEnv]?.trim();
552
+ if (token) {
553
+ headers['Authorization'] = `Bearer ${token}`;
554
+ }
555
+ }
556
+ return Object.keys(headers).length > 0 ? headers : undefined;
557
+ }
535
558
  /**
536
559
  * Convert resolved pack MCP servers to the inline record shape engines consume.
537
560
  *
@@ -539,15 +562,19 @@ exports.AGENTHIPPO_MCP_SERVERS_ENV = 'AGENTHIPPO_MCP_SERVERS';
539
562
  * `{ type: 'http', url, headers? }` — the Cursor SDK (`@cursor/sdk` McpServerConfig) accepts
540
563
  * this shape inline, so remote MCP servers are available to cursor/custom engines, not just
541
564
  * to the Codex engine (which gets them via config.toml).
565
+ *
566
+ * `turnEnv` (when provided) resolves each URL server's `bearerTokenEnv` into a concrete
567
+ * Authorization header — pass the same env the engine subprocess will receive.
542
568
  */
543
- function formatInlineMcpServers(servers) {
569
+ function formatInlineMcpServers(servers, turnEnv) {
544
570
  const mcpServers = {};
545
571
  for (const server of servers) {
546
572
  if (server.url) {
573
+ const headers = resolveHttpMcpHeaders(server, turnEnv ?? process.env);
547
574
  mcpServers[server.name] = {
548
575
  type: 'http',
549
576
  url: server.url,
550
- ...(server.headers && Object.keys(server.headers).length > 0 ? { headers: server.headers } : {}),
577
+ ...(headers ? { headers } : {}),
551
578
  };
552
579
  continue;
553
580
  }
@@ -96,13 +96,19 @@ async function handleRemoteServeChat(deps, args) {
96
96
  try {
97
97
  syncService.incrementActiveAgentTurns();
98
98
  await observableEmitter.emitTurnStart();
99
- await (0, serveClient_js_1.streamRemoteServeChat)({
99
+ const result = await (0, serveClient_js_1.streamRemoteServeChat)({
100
100
  baseUrl: remoteUrl,
101
101
  message,
102
102
  sessionId: remoteSessionId,
103
103
  signal: abortController.signal,
104
104
  auth,
105
105
  }, observableEmitter);
106
+ if (!result.ok) {
107
+ // streamRemoteServeChat already surfaced the failure to the emitter (error + done);
108
+ // record the turn as failed and propagate to the caller without re-emitting.
109
+ await observableEmitter.emitTurnEnd(false);
110
+ return { error: result.error ?? 'Remote serve failed' };
111
+ }
106
112
  await observableEmitter.emitTurnEnd(true);
107
113
  return { streaming: true };
108
114
  }
@@ -77,7 +77,7 @@ async function streamRemoteServeChat(options, emitter) {
77
77
  const message = err instanceof Error ? err.message : 'Remote serve connection failed';
78
78
  await emitter.error(message);
79
79
  await emitter.done();
80
- return;
80
+ return { ok: false, error: message };
81
81
  }
82
82
  if (!response.ok) {
83
83
  clearTimers();
@@ -93,29 +93,39 @@ async function streamRemoteServeChat(options, emitter) {
93
93
  }
94
94
  await emitter.error(errorMessage);
95
95
  await emitter.done();
96
- return;
96
+ return { ok: false, error: errorMessage };
97
97
  }
98
98
  if (!response.body) {
99
99
  clearTimers();
100
- await emitter.error('Remote serve returned empty body');
100
+ const message = 'Remote serve returned empty body';
101
+ await emitter.error(message);
101
102
  await emitter.done();
102
- return;
103
+ return { ok: false, error: message };
103
104
  }
104
105
  try {
105
- await consumeServeSse(response.body, emitter, resetIdle);
106
+ const streamError = await consumeServeSse(response.body, emitter, resetIdle);
107
+ return streamError ? { ok: false, error: streamError } : { ok: true };
106
108
  }
107
109
  finally {
108
110
  clearTimers();
109
111
  }
110
112
  }
113
+ /**
114
+ * Consume the SSE stream. Returns the message of an `error` event if one was seen
115
+ * (the turn failed even though the stream ended cleanly), otherwise undefined.
116
+ */
111
117
  async function consumeServeSse(body, emitter, resetIdle) {
112
118
  const reader = body.getReader();
113
119
  const decoder = new TextDecoder();
114
120
  let buffer = '';
115
121
  let doneEmitted = false;
122
+ let streamError;
116
123
  const markDone = () => {
117
124
  doneEmitted = true;
118
125
  };
126
+ const markError = (message) => {
127
+ streamError = message;
128
+ };
119
129
  try {
120
130
  while (true) {
121
131
  const { value, done } = await reader.read();
@@ -125,24 +135,25 @@ async function consumeServeSse(body, emitter, resetIdle) {
125
135
  // Any data on the wire means the turn is progressing — push the idle window out.
126
136
  resetIdle();
127
137
  buffer += decoder.decode(value, { stream: true });
128
- buffer = await drainServeSseBuffer(buffer, emitter, markDone);
138
+ buffer = await drainServeSseBuffer(buffer, emitter, markDone, markError);
129
139
  if (doneEmitted) {
130
- return;
140
+ return streamError;
131
141
  }
132
142
  }
133
143
  buffer += decoder.decode();
134
144
  if (buffer.trim()) {
135
- await drainServeSseBuffer(`${buffer}\n`, emitter, markDone);
145
+ await drainServeSseBuffer(`${buffer}\n`, emitter, markDone, markError);
136
146
  }
137
147
  if (!doneEmitted) {
138
148
  await emitter.done();
139
149
  }
150
+ return streamError;
140
151
  }
141
152
  finally {
142
153
  reader.releaseLock();
143
154
  }
144
155
  }
145
- async function drainServeSseBuffer(buffer, emitter, markDone) {
156
+ async function drainServeSseBuffer(buffer, emitter, markDone, markError) {
146
157
  const lines = buffer.split('\n');
147
158
  const remainder = lines.pop() ?? '';
148
159
  for (const line of lines) {
@@ -160,7 +171,7 @@ async function drainServeSseBuffer(buffer, emitter, markDone) {
160
171
  catch {
161
172
  continue;
162
173
  }
163
- const finished = await dispatchServeEvent(event, emitter);
174
+ const finished = await dispatchServeEvent(event, emitter, markError);
164
175
  if (finished) {
165
176
  markDone();
166
177
  return '';
@@ -168,7 +179,7 @@ async function drainServeSseBuffer(buffer, emitter, markDone) {
168
179
  }
169
180
  return remainder;
170
181
  }
171
- async function dispatchServeEvent(event, emitter) {
182
+ async function dispatchServeEvent(event, emitter, markError) {
172
183
  const type = typeof event.type === 'string' ? event.type : '';
173
184
  switch (type) {
174
185
  case 'thinking':
@@ -204,9 +215,12 @@ async function dispatchServeEvent(event, emitter) {
204
215
  }
205
216
  return false;
206
217
  }
207
- case 'error':
208
- await emitter.error(typeof event.message === 'string' ? event.message : 'Remote serve error');
218
+ case 'error': {
219
+ const message = typeof event.message === 'string' ? event.message : 'Remote serve error';
220
+ markError(message);
221
+ await emitter.error(message);
209
222
  return false;
223
+ }
210
224
  case 'done':
211
225
  await emitter.done();
212
226
  return true;
@@ -158,3 +158,17 @@ model_list:
158
158
  api_base: https://api.openai.com/v1
159
159
  model_info:
160
160
  upstream_provider: "openai"
161
+
162
+ # Fleet / Cloud Run: ah-auto resolves via the central AgentHippo model gateway (same as
163
+ # colocated agents with AGENTHIPPO_LITELLM_BASE_URL=https://gateway.agenthippo.ai/v1).
164
+ # Set AGENTHIPPO_GATEWAY_KEY to an ahgw_… service key (create-gateway-key on the gateway host).
165
+ - model_name: ah-auto
166
+ litellm_params:
167
+ # openai/ prefix required — bare `ah-auto` fails LiteLLM router deployment (no provider).
168
+ model: openai/ah-auto
169
+ api_base: https://gateway.agenthippo.ai/v1
170
+ api_key: os.environ/AGENTHIPPO_GATEWAY_KEY
171
+ drop_params: true
172
+ additional_drop_params: ["user", "max_output_tokens", "reasoning.effort", "reasoning_effort"]
173
+ model_info:
174
+ upstream_provider: "agenthippo-gateway"
@@ -25,7 +25,7 @@ exports.BUILTIN_MODELS = [
25
25
  // ===========================================
26
26
  {
27
27
  id: 'ah-auto',
28
- name: 'AH-Auto',
28
+ name: 'Auto',
29
29
  provider: 'agenthippo',
30
30
  contextWindow: 200000,
31
31
  maxOutputTokens: 8192,
@@ -73,12 +73,13 @@ exports.PROVIDERS = {
73
73
  },
74
74
  litellm: {
75
75
  id: 'litellm',
76
- displayName: 'LiteLLM (Recommended)',
76
+ displayName: 'LiteLLM',
77
77
  endpointSettingKey: 'agentide.providers.litellm.baseUrl',
78
78
  requiresApiKey: true,
79
79
  description: 'local adapter for agents to work with Spotlight and all models from OpenAI, Anthropic, Ollama etc.',
80
80
  showInAddApiKeyUI: true,
81
81
  isGateway: true, // Can route to any backend
82
+ deferMissingApiKey: true, // base-url configured; key may be empty in dev
82
83
  },
83
84
  ollama: {
84
85
  id: 'ollama',
@@ -95,13 +96,15 @@ exports.PROVIDERS = {
95
96
  agenthippo: {
96
97
  id: 'agenthippo',
97
98
  displayName: 'AgentHippo Gateway',
98
- defaultEndpoint: 'https://api.agenthippo.ai/v1',
99
+ defaultEndpoint: 'https://gateway.agenthippo.ai/v1',
99
100
  endpointSettingKey: 'agentide.AgentHippoModelGateway.url',
100
101
  requiresApiKey: true,
101
102
  description: 'AgentHippo managed gateway — uses account JWT, picks model automatically',
102
103
  showInAddApiKeyUI: false,
103
104
  isGateway: true,
104
105
  staticHeaders: { 'x-agenthippo-source': 'chat-ui' },
106
+ credentialRefreshCommand: 'agentide.auth.refreshToken', // refresh stale sign-in JWT on 401
107
+ deferMissingApiKey: true, // key is the sign-in JWT; route branch prompts sign-in
105
108
  },
106
109
  };
107
110
  // -----------------------------------------------------------------------------