@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
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,89 +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); never bake the OBO token
275
- // into spawn env. The explicit ${credential:dataToken} placeholder resolves to empty
276
- // (AgentHippo-aware servers read the file). A plain ${env:ALIAS} reference falls through to
277
- // any operator-provided ambient value so a stock server that reads the var directly still
278
- // works, instead of being clobbered to an empty string.
279
- if (varName === 'dataToken') {
280
- return '';
281
- }
282
- return process.env[varName] ?? envSources[varName] ?? '';
283
- }
284
284
  if (varName === 'dataToken') {
285
- return cred?.dataToken?.trim() ?? '';
285
+ const tok = (0, credential_context_1.currentCredential)()?.dataToken?.trim();
286
+ return tok && inlineDataTokenAllowed() ? tok : '';
286
287
  }
287
- if (cred?.dataToken?.trim() && aliases.includes(varName)) {
288
- 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
+ }
289
293
  }
290
294
  return process.env[varName] ?? envSources[varName] ?? '';
291
295
  }
292
296
  /**
293
297
  * Env-var names that resolve to the turn's OBO data token when a pack references
294
- * `${env:VAR}` directly. Defaults to `DATABRICKS_TOKEN`; override at deploy time via
295
- * `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.
296
301
  */
297
302
  function getDataTokenEnvAliases() {
298
303
  const raw = process.env.AGENTHIPPO_DATA_TOKEN_ENV_VARS;
299
304
  if (raw !== undefined) {
300
305
  return raw.split(',').map((v) => v.trim()).filter(Boolean);
301
306
  }
302
- return ['DATABRICKS_TOKEN'];
307
+ return [];
303
308
  }
304
309
  function envValueUsesCredentialDataToken(value) {
305
310
  return /\$\{credential:dataToken\}/.test(value);
306
311
  }
307
- /** Suffix for Codex config.toml cache invalidation when MCP credential scope changes. */
308
- function mcpConfigCacheSignatureSuffix() {
309
- const cred = (0, credential_context_1.currentCredential)();
310
- const aliases = getDataTokenEnvAliases();
311
- if (cred?.dataTokenFile) {
312
- return `file:${cred.dataTokenFile}:aliases=${aliases.join(',')}`;
313
- }
314
- if (cred?.dataToken?.trim()) {
315
- return `inline-token:aliases=${aliases.join(',')}`;
316
- }
317
- return `no-token:aliases=${aliases.join(',')}`;
318
- }
319
312
  function resolveEnv(env, envSources = {}) {
320
313
  if (!env) {
321
314
  return undefined;
322
315
  }
323
316
  const resolved = {};
324
- const tokenFile = (0, credential_context_1.currentCredential)()?.dataTokenFile;
325
- if (tokenFile) {
326
- resolved[exports.AGENTHIPPO_DATA_TOKEN_FILE_ENV] = tokenFile;
327
- }
328
317
  for (const [key, value] of Object.entries(env)) {
329
318
  CREDENTIAL_PLACEHOLDER.lastIndex = 0;
330
319
  if (envValueUsesCredentialDataToken(value)) {
331
- // Credential-backed secret. When a per-turn token file exists, the key is intentionally
332
- // NOT set here: the MCP server reads the token from AGENTHIPPO_DATA_TOKEN_FILE
333
- // (injected above) on each tool call, so baking it into spawn env would be redundant
334
- // and would leak the turn-scoped token into the long-lived child process env.
335
- // Prefer the OBO token file, then inline token, then env fallback.
336
- if (tokenFile) {
337
- // AGENTHIPPO_DATA_TOKEN_FILE already injected above; don't bake the token here.
338
- }
339
- else if ((0, credential_context_1.currentCredential)()?.dataToken?.trim()) {
340
- 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.
341
329
  }
342
330
  else {
343
331
  // No OBO credential (local dev / non-serve context): fall back to env var.
@@ -436,12 +424,14 @@ function generateCodexMcpConfig(servers, options) {
436
424
  if (servers.length === 0) {
437
425
  return '';
438
426
  }
439
- // `default_tools_approval_mode = "auto"` is the documented Codex 0.122+ per-server key
440
- // (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).
441
432
  // Only emit it in headless/serve auto-approve contexts — the interactive IDE path leaves
442
- // it unset so users get Codex's normal approval prompts. The previous `approval_mode =
443
- // "never"` was an invalid key/value and was silently ignored by the Codex CLI.
444
- 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;
445
435
  const sections = servers.map(s => {
446
436
  // HTTP Streamable MCP server — codex connects via URL, no subprocess
447
437
  if (s.url) {
@@ -449,6 +439,13 @@ function generateCodexMcpConfig(servers, options) {
449
439
  `[mcp_servers.${s.name}]`,
450
440
  `url = ${JSON.stringify(s.url)}`,
451
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
+ }
452
449
  if (s.headers && Object.keys(s.headers).length > 0) {
453
450
  const headerEntries = Object.entries(s.headers)
454
451
  .map(([k, v]) => `${JSON.stringify(k)} = ${JSON.stringify(v)}`)
@@ -541,6 +538,23 @@ async function loadBundledMcpServers(basePromptsDir) {
541
538
  }
542
539
  /** Env var carrying JSON-serialized {@link InlineMcpServers} into custom engine workers. */
543
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
+ }
544
558
  /**
545
559
  * Convert resolved pack MCP servers to the inline record shape engines consume.
546
560
  *
@@ -548,15 +562,19 @@ exports.AGENTHIPPO_MCP_SERVERS_ENV = 'AGENTHIPPO_MCP_SERVERS';
548
562
  * `{ type: 'http', url, headers? }` — the Cursor SDK (`@cursor/sdk` McpServerConfig) accepts
549
563
  * this shape inline, so remote MCP servers are available to cursor/custom engines, not just
550
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.
551
568
  */
552
- function formatInlineMcpServers(servers) {
569
+ function formatInlineMcpServers(servers, turnEnv) {
553
570
  const mcpServers = {};
554
571
  for (const server of servers) {
555
572
  if (server.url) {
573
+ const headers = resolveHttpMcpHeaders(server, turnEnv ?? process.env);
556
574
  mcpServers[server.name] = {
557
575
  type: 'http',
558
576
  url: server.url,
559
- ...(server.headers && Object.keys(server.headers).length > 0 ? { headers: server.headers } : {}),
577
+ ...(headers ? { headers } : {}),
560
578
  };
561
579
  continue;
562
580
  }
@@ -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,7 +73,7 @@ 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.',
@@ -96,7 +96,7 @@ exports.PROVIDERS = {
96
96
  agenthippo: {
97
97
  id: 'agenthippo',
98
98
  displayName: 'AgentHippo Gateway',
99
- defaultEndpoint: 'https://api.agenthippo.ai/v1',
99
+ defaultEndpoint: 'https://gateway.agenthippo.ai/v1',
100
100
  endpointSettingKey: 'agentide.AgentHippoModelGateway.url',
101
101
  requiresApiKey: true,
102
102
  description: 'AgentHippo managed gateway — uses account JWT, picks model automatically',
@@ -43,7 +43,6 @@ const os = __importStar(require("os"));
43
43
  const fs_1 = require("fs");
44
44
  const base_1 = require("../engines/base");
45
45
  const sessionEvents_1 = require("../analytics/sessionEvents");
46
- const credential_context_1 = require("../cli/credential-context");
47
46
  async function createUniqueTempDir(prefix) {
48
47
  return fs_1.promises.mkdtemp(path.join(os.tmpdir(), prefix));
49
48
  }
@@ -524,7 +523,7 @@ suite('Analytics - ObservableStreamEmitter', () => {
524
523
  });
525
524
  });
526
525
  // =============================================================================
527
- // ObservableStreamEmitter + credential context (the "warrant" audit trail)
526
+ // ObservableStreamEmitter warrant audit trail (constructor-captured userId)
528
527
  // =============================================================================
529
528
  suite('Analytics - ObservableStreamEmitter records the acting user (the warrant)', () => {
530
529
  let testDir;
@@ -556,37 +555,63 @@ suite('Analytics - ObservableStreamEmitter records the acting user (the warrant)
556
555
  const content = await fs_1.promises.readFile(path.join(testDir, `${sessionId}.jsonl`), 'utf8');
557
556
  return content.trim().split('\n').map((l) => JSON.parse(l));
558
557
  }
559
- test('toolStart/toolEnd record userId when inside a runWithCredential scope', async () => {
560
- const emitter = (0, sessionEvents_1.wrapEmitterWithObservability)(innerEmitter, writer, 'sess-warrant-1', 'req-1');
561
- await (0, credential_context_1.runWithCredential)({ userId: 'priya@meridian.com' }, async () => {
562
- await emitter.toolStart('Bash', 'echo hi', 'c1');
563
- await emitter.toolEnd('Bash', 'c1', 'hi', false);
564
- });
558
+ test('toolStart/toolEnd record the userId passed at wrap', async () => {
559
+ const emitter = (0, sessionEvents_1.wrapEmitterWithObservability)(innerEmitter, writer, 'sess-warrant-1', 'req-1', 'priya@meridian.com');
560
+ await emitter.toolStart('Bash', 'echo hi', 'c1');
561
+ await emitter.toolEnd('Bash', 'c1', 'hi', false);
565
562
  const events = await readEvents('sess-warrant-1');
566
563
  assert.strictEqual(events.length, 2);
567
564
  assert.strictEqual(events[0].payload.userId, 'priya@meridian.com');
568
565
  assert.strictEqual(events[1].payload.userId, 'priya@meridian.com');
569
566
  });
570
- test('toolStart records no userId outside any credential scope (IDE-local usage)', async () => {
567
+ test('toolStart records no userId when wrap omitted it (IDE-local usage)', async () => {
571
568
  const emitter = (0, sessionEvents_1.wrapEmitterWithObservability)(innerEmitter, writer, 'sess-warrant-2', 'req-1');
572
569
  await emitter.toolStart('Bash', 'echo hi', 'c1');
573
570
  const events = await readEvents('sess-warrant-2');
574
571
  assert.strictEqual(events[0].payload.userId, undefined);
575
572
  });
576
- test('two interleaved turns never attribute a tool call to the wrong user', async () => {
577
- const emitter = (0, sessionEvents_1.wrapEmitterWithObservability)(innerEmitter, writer, 'sess-warrant-3', 'req-1');
573
+ test('two emitters never attribute a tool call to the wrong user', async () => {
574
+ const alice = (0, sessionEvents_1.wrapEmitterWithObservability)(innerEmitter, writer, 'sess-warrant-3', 'req-a', 'alice@acme.com');
575
+ const bob = (0, sessionEvents_1.wrapEmitterWithObservability)(innerEmitter, writer, 'sess-warrant-3', 'req-b', 'bob@acme.com');
578
576
  await Promise.all([
579
- (0, credential_context_1.runWithCredential)({ userId: 'alice@acme.com' }, async () => {
577
+ (async () => {
580
578
  await new Promise((r) => setTimeout(r, 5));
581
- await emitter.toolStart('Bash', 'a', 'a1');
582
- }),
583
- (0, credential_context_1.runWithCredential)({ userId: 'bob@acme.com' }, async () => {
584
- await emitter.toolStart('Bash', 'b', 'b1');
585
- }),
579
+ await alice.toolStart('Bash', 'a', 'a1');
580
+ })(),
581
+ bob.toolStart('Bash', 'b', 'b1'),
586
582
  ]);
587
583
  const events = await readEvents('sess-warrant-3');
588
584
  const byCallId = Object.fromEntries(events.map((e) => [e.payload.callId, e.payload.userId]));
589
585
  assert.strictEqual(byCallId['a1'], 'alice@acme.com');
590
586
  assert.strictEqual(byCallId['b1'], 'bob@acme.com');
591
587
  });
588
+ test('toolStart/toolEnd keep wrap-time userId even when called outside any credential scope', async () => {
589
+ // Simulates the custom-engine persistent-worker path: wrap captures identity on the
590
+ // request continuation, then tool events fire later on a detached IPC callback with
591
+ // no ambient runWithCredential (or a sticky wrong one). Attribution must not depend
592
+ // on ambient AsyncLocalStorage at emit time.
593
+ const emitter = (0, sessionEvents_1.wrapEmitterWithObservability)(innerEmitter, writer, 'sess-warrant-4', 'req-1', 'alice@acme.com');
594
+ await emitter.toolStart('Bash', 'echo hi', 'c1');
595
+ await emitter.toolEnd('Bash', 'c1', 'hi', false);
596
+ const events = await readEvents('sess-warrant-4');
597
+ assert.strictEqual(events[0].payload.userId, 'alice@acme.com');
598
+ assert.strictEqual(events[1].payload.userId, 'alice@acme.com');
599
+ });
600
+ test('error/turn_start/turn_end also carry the wrap-time userId, not just tool events', async () => {
601
+ // A turn that errors before any tool call (or has no tool calls at all) must still
602
+ // carry identity attribution — the mechanism was already on the instance, it just
603
+ // wasn't threaded into these three payloads.
604
+ const emitter = (0, sessionEvents_1.wrapEmitterWithObservability)(innerEmitter, writer, 'sess-warrant-5', 'req-1', 'bob@acme.com');
605
+ await emitter.emitTurnStart();
606
+ await emitter.error('boom');
607
+ await emitter.emitTurnEnd(false);
608
+ const events = await readEvents('sess-warrant-5');
609
+ assert.strictEqual(events.length, 3);
610
+ assert.strictEqual(events[0].event_type, 'turn_start');
611
+ assert.strictEqual(events[0].payload.userId, 'bob@acme.com');
612
+ assert.strictEqual(events[1].event_type, 'error');
613
+ assert.strictEqual(events[1].payload.userId, 'bob@acme.com');
614
+ assert.strictEqual(events[2].event_type, 'turn_end');
615
+ assert.strictEqual(events[2].payload.userId, 'bob@acme.com');
616
+ });
592
617
  });
@@ -435,4 +435,57 @@ suite('AuthBroker', () => {
435
435
  await broker.close();
436
436
  }
437
437
  });
438
+ test('authorize rejects a redirect_uri outside the loopback/IDE-callback allow-list', async () => {
439
+ const broker = (0, server_js_1.createAuthBrokerServer)(testConfig(0));
440
+ await new Promise((resolve) => broker.server.listen(0, '127.0.0.1', resolve));
441
+ const address = broker.server.address();
442
+ if (!address || typeof address === 'string') {
443
+ throw new Error('bad listen address');
444
+ }
445
+ const baseUrl = `http://127.0.0.1:${address.port}`;
446
+ try {
447
+ const challenge = (0, pkce_js_1.codeChallengeFromVerifier)((0, pkce_js_1.generateCodeVerifier)());
448
+ // An attacker-controlled destination — must never receive an auth code (open-redirect /
449
+ // code-theft guard). Real clients only ever send the loopback or IDE-scheme callback.
450
+ const url = `${baseUrl}/oauth/authorize?client_id=${types_js_1.AUTH_CLIENT_ID}`
451
+ + `&redirect_uri=${encodeURIComponent('https://attacker.example/collect')}`
452
+ + `&code_challenge=${challenge}&code_challenge_method=S256&state=x&login_hint=dev@agenthippo.ai`;
453
+ const res = await fetch(url, { redirect: 'manual' });
454
+ assert.strictEqual(res.status, 400);
455
+ const body = await res.json();
456
+ assert.strictEqual(body.error, 'invalid_request');
457
+ }
458
+ finally {
459
+ await broker.close();
460
+ }
461
+ });
462
+ test('authorize accepts the loopback and IDE-scheme redirect_uri shapes real clients send', async () => {
463
+ const broker = (0, server_js_1.createAuthBrokerServer)(testConfig(0));
464
+ await new Promise((resolve) => broker.server.listen(0, '127.0.0.1', resolve));
465
+ const address = broker.server.address();
466
+ if (!address || typeof address === 'string') {
467
+ throw new Error('bad listen address');
468
+ }
469
+ const baseUrl = `http://127.0.0.1:${address.port}`;
470
+ try {
471
+ const redirectUris = [
472
+ 'http://127.0.0.1:54123/callback', // CLI/IDE loopback callback (any OS-assigned port)
473
+ 'vscode://agenthippo.agenthippo/auth/callback', // IDE URI-scheme callback
474
+ ];
475
+ for (const redirectUri of redirectUris) {
476
+ const challenge = (0, pkce_js_1.codeChallengeFromVerifier)((0, pkce_js_1.generateCodeVerifier)());
477
+ const url = `${baseUrl}/oauth/authorize?client_id=${types_js_1.AUTH_CLIENT_ID}`
478
+ + `&redirect_uri=${encodeURIComponent(redirectUri)}`
479
+ + `&code_challenge=${challenge}&code_challenge_method=S256&state=x&login_hint=dev@agenthippo.ai`;
480
+ const res = await fetch(url, { redirect: 'manual' });
481
+ assert.strictEqual(res.status, 302, `expected redirect for ${redirectUri}`);
482
+ const location = res.headers.get('location') || '';
483
+ assert.ok(location.startsWith(redirectUri), `location "${location}" should start with "${redirectUri}"`);
484
+ assert.ok(new URL(location).searchParams.get('code'), 'redirect carries an auth code');
485
+ }
486
+ }
487
+ finally {
488
+ await broker.close();
489
+ }
490
+ });
438
491
  });
@@ -0,0 +1,105 @@
1
+ "use strict";
2
+ /*---------------------------------------------------------------------------------------------
3
+ * Blueprint Registry Test Suite (PLAN/deploy.md §3, §7 d7-blueprint-registry)
4
+ *
5
+ * Pure resolver tests: a blueprint (+ optional platform) must resolve to exactly the
6
+ * --target/--ingress pair deploy-init.ts already renders correctly — these tests never
7
+ * call deployCommand, so they can assert on the error paths too without a process.exit risk.
8
+ *--------------------------------------------------------------------------------------------*/
9
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ var desc = Object.getOwnPropertyDescriptor(m, k);
12
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
13
+ desc = { enumerable: true, get: function() { return m[k]; } };
14
+ }
15
+ Object.defineProperty(o, k2, desc);
16
+ }) : (function(o, m, k, k2) {
17
+ if (k2 === undefined) k2 = k;
18
+ o[k2] = m[k];
19
+ }));
20
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
21
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
22
+ }) : function(o, v) {
23
+ o["default"] = v;
24
+ });
25
+ var __importStar = (this && this.__importStar) || (function () {
26
+ var ownKeys = function(o) {
27
+ ownKeys = Object.getOwnPropertyNames || function (o) {
28
+ var ar = [];
29
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
30
+ return ar;
31
+ };
32
+ return ownKeys(o);
33
+ };
34
+ return function (mod) {
35
+ if (mod && mod.__esModule) return mod;
36
+ var result = {};
37
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
38
+ __setModuleDefault(result, mod);
39
+ return result;
40
+ };
41
+ })();
42
+ Object.defineProperty(exports, "__esModule", { value: true });
43
+ require("mocha");
44
+ const assert = __importStar(require("assert"));
45
+ const registry_1 = require("../cli/deploy/blueprints/registry");
46
+ suite('blueprint registry: isBlueprintId', () => {
47
+ test('recognizes every declared blueprint id', () => {
48
+ for (const id of registry_1.BLUEPRINT_IDS) {
49
+ assert.ok((0, registry_1.isBlueprintId)(id));
50
+ }
51
+ });
52
+ test('rejects unknown ids', () => {
53
+ assert.ok(!(0, registry_1.isBlueprintId)('enterprise'));
54
+ assert.ok(!(0, registry_1.isBlueprintId)(''));
55
+ });
56
+ });
57
+ suite('blueprint registry: resolveBlueprint', () => {
58
+ test('sandbox resolves to vm with no ingress', () => {
59
+ assert.deepStrictEqual((0, registry_1.resolveBlueprint)('sandbox'), { target: 'vm' });
60
+ });
61
+ test('regulated-air-gapped resolves to vm with no ingress', () => {
62
+ assert.deepStrictEqual((0, registry_1.resolveBlueprint)('regulated-air-gapped'), { target: 'vm' });
63
+ });
64
+ test('prospect-demo defaults to vm with edge ingress; compose is also supported', () => {
65
+ assert.deepStrictEqual((0, registry_1.resolveBlueprint)('prospect-demo'), { target: 'vm', ingress: 'edge' });
66
+ assert.deepStrictEqual((0, registry_1.resolveBlueprint)('prospect-demo', 'compose'), { target: 'compose', ingress: 'edge' });
67
+ });
68
+ test('enterprise-fleet always resolves to edge-fed, on any of its supported platforms', () => {
69
+ assert.deepStrictEqual((0, registry_1.resolveBlueprint)('enterprise-fleet'), { target: 'cloudrun', ingress: 'edge-fed' });
70
+ assert.deepStrictEqual((0, registry_1.resolveBlueprint)('enterprise-fleet', 'vm'), { target: 'vm', ingress: 'edge-fed' });
71
+ assert.deepStrictEqual((0, registry_1.resolveBlueprint)('enterprise-fleet', 'compose'), { target: 'compose', ingress: 'edge-fed' });
72
+ });
73
+ test('team-pilot rides platform-native identity where one exists', () => {
74
+ assert.deepStrictEqual((0, registry_1.resolveBlueprint)('team-pilot', 'cloudrun'), { target: 'cloudrun', ingress: 'iap' });
75
+ assert.deepStrictEqual((0, registry_1.resolveBlueprint)('team-pilot', 'databricks-app'), { target: 'databricks-app' });
76
+ assert.deepStrictEqual((0, registry_1.resolveBlueprint)('team-pilot', 'spcs'), { target: 'spcs' });
77
+ assert.deepStrictEqual((0, registry_1.resolveBlueprint)('team-pilot', 'cloudflare'), { target: 'cloudflare' });
78
+ });
79
+ test('team-pilot falls back to a self-issued edge on vm/compose/aws-ecs/azure-container-apps (no platform-native option there)', () => {
80
+ assert.deepStrictEqual((0, registry_1.resolveBlueprint)('team-pilot', 'vm'), { target: 'vm', ingress: 'edge' });
81
+ assert.deepStrictEqual((0, registry_1.resolveBlueprint)('team-pilot', 'compose'), { target: 'compose', ingress: 'edge' });
82
+ assert.deepStrictEqual((0, registry_1.resolveBlueprint)('team-pilot', 'aws-ecs'), { target: 'aws-ecs', ingress: 'edge' });
83
+ assert.deepStrictEqual((0, registry_1.resolveBlueprint)('team-pilot', 'azure-container-apps'), { target: 'azure-container-apps', ingress: 'edge' });
84
+ });
85
+ test('enterprise-fleet supports aws-ecs/azure-container-apps too', () => {
86
+ assert.deepStrictEqual((0, registry_1.resolveBlueprint)('enterprise-fleet', 'aws-ecs'), { target: 'aws-ecs', ingress: 'edge-fed' });
87
+ assert.deepStrictEqual((0, registry_1.resolveBlueprint)('enterprise-fleet', 'azure-container-apps'), { target: 'azure-container-apps', ingress: 'edge-fed' });
88
+ });
89
+ test('team-pilot with no --platform defaults to its first supported target (cloudrun, iap)', () => {
90
+ assert.deepStrictEqual((0, registry_1.resolveBlueprint)('team-pilot'), { target: 'cloudrun', ingress: 'iap' });
91
+ });
92
+ test('throws when --platform is outside the blueprint\'s supported set', () => {
93
+ assert.throws(() => (0, registry_1.resolveBlueprint)('sandbox', 'cloudrun'), /does not support --platform cloudrun/);
94
+ assert.throws(() => (0, registry_1.resolveBlueprint)('regulated-air-gapped', 'databricks-app'), /Supported: vm/);
95
+ });
96
+ });
97
+ suite('blueprint registry: BLUEPRINTS catalog', () => {
98
+ test('every blueprint declares at least one supported target and a description', () => {
99
+ for (const id of registry_1.BLUEPRINT_IDS) {
100
+ const def = registry_1.BLUEPRINTS[id];
101
+ assert.ok(def.targets.length > 0, `${id} must support at least one platform`);
102
+ assert.ok(def.description.trim().length > 0, `${id} must have a description`);
103
+ }
104
+ });
105
+ });