@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.
- package/out/agentpack/index.js +2 -1
- package/out/agentpack/paths.js +23 -0
- package/out/analytics/dashboard/fleetDisplayUtils.js +3 -0
- package/out/analytics/dashboard/webview/panel.js +290 -80
- package/out/analytics/sessionEvents.js +21 -9
- package/out/auth/brokerClient.js +1 -1
- package/out/auth/onboardingPrompt.js +112 -0
- package/out/auth/provider.js +1 -1
- package/out/auth-broker/config.js +8 -3
- package/out/auth-broker/server.js +36 -0
- package/out/cli/commands/ask.js +3 -0
- package/out/cli/commands/chat.js +3 -0
- package/out/cli/commands/deploy.js +49 -10
- package/out/cli/commands/gateway.js +54 -0
- package/out/cli/commands/idp.js +85 -0
- package/out/cli/commands/serve.js +315 -55
- package/out/cli/credential-context.js +42 -80
- package/out/cli/data-plane/broker.js +78 -0
- package/out/cli/data-plane/connector-host.js +121 -0
- package/out/cli/data-plane/egress.js +189 -0
- package/out/cli/data-plane/gateway.js +195 -0
- package/out/cli/data-plane/index.js +137 -0
- package/out/cli/data-plane/registry.js +80 -0
- package/out/cli/deploy/blueprints/registry.js +84 -0
- package/out/cli/deploy/layers/identity.js +91 -0
- package/out/cli/deploy-init.js +1105 -335
- package/out/cli/idp-init.js +303 -0
- package/out/cli/index.js +37 -7
- package/out/cli/pack-loader.js +37 -2
- package/out/cli/runtime.js +10 -3
- package/out/cli/serve-hardening.js +60 -1
- package/out/cli/state-sync.js +16 -15
- package/out/engines/claude.js +26 -5
- package/out/engines/codex.js +24 -5
- package/out/engines/custom.js +46 -12
- package/out/extension.js +2 -0
- package/out/gateway/outboundSend.js +6 -6
- package/out/headless/index.js +24 -9
- package/out/live-probe/connector.mjs +8 -0
- package/out/live-probe/env-report.mjs +8 -0
- package/out/mcp.js +85 -67
- package/out/scripts/analytics/agentide-litellm-otel.config.yaml +14 -0
- package/out/shared/models.js +1 -1
- package/out/shared/providers.js +2 -2
- package/out/test/analytics.test.js +42 -17
- package/out/test/auth-broker.test.js +53 -0
- package/out/test/blueprint-registry.test.js +105 -0
- package/out/test/credential-context.test.js +23 -28
- package/out/test/custom-env-layering.test.js +151 -0
- package/out/test/data-plane-engine-wiring.test.js +250 -0
- package/out/test/data-plane.test.js +244 -0
- package/out/test/deploy-init.test.js +255 -4
- package/out/test/egress-broker.test.js +172 -0
- package/out/test/fleetDisplayUtils.test.js +4 -0
- package/out/test/idp-init.test.js +151 -0
- package/out/test/mcp-obo.test.js +52 -27
- package/out/test/mcp.test.js +43 -3
- package/out/test/model-routing.test.js +133 -0
- package/out/test/serve-gateway-turn.test.js +267 -0
- package/out/test/serve-hardening.test.js +80 -0
- package/out/test/serveClient.test.js +32 -0
- package/out/test/state-sync-peer.test.js +18 -5
- package/out/utils.js +27 -11
- 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.
|
|
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
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
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.
|
|
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
|
|
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
|
|
261
|
-
*
|
|
262
|
-
*
|
|
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
|
-
*
|
|
266
|
-
*
|
|
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
|
-
|
|
285
|
+
const tok = (0, credential_context_1.currentCredential)()?.dataToken?.trim();
|
|
286
|
+
return tok && inlineDataTokenAllowed() ? tok : '';
|
|
286
287
|
}
|
|
287
|
-
if (
|
|
288
|
-
|
|
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.
|
|
295
|
-
*
|
|
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 [
|
|
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.
|
|
332
|
-
//
|
|
333
|
-
//
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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 = "
|
|
440
|
-
//
|
|
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.
|
|
443
|
-
|
|
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
|
-
...(
|
|
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"
|
package/out/shared/models.js
CHANGED
package/out/shared/providers.js
CHANGED
|
@@ -73,7 +73,7 @@ exports.PROVIDERS = {
|
|
|
73
73
|
},
|
|
74
74
|
litellm: {
|
|
75
75
|
id: 'litellm',
|
|
76
|
-
displayName: 'LiteLLM
|
|
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://
|
|
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
|
|
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
|
|
560
|
-
const emitter = (0, sessionEvents_1.wrapEmitterWithObservability)(innerEmitter, writer, 'sess-warrant-1', 'req-1');
|
|
561
|
-
await (
|
|
562
|
-
|
|
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
|
|
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
|
|
577
|
-
const
|
|
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
|
-
(
|
|
577
|
+
(async () => {
|
|
580
578
|
await new Promise((r) => setTimeout(r, 5));
|
|
581
|
-
await
|
|
582
|
-
}),
|
|
583
|
-
(
|
|
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
|
+
});
|