@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.
- 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 -4
- 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/pkce.js +3 -1
- package/out/auth-broker/server.js +43 -1
- package/out/auth-broker/store.js +6 -5
- 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 +401 -49
- package/out/cli/credential-context.js +47 -69
- 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 +1156 -285
- package/out/cli/idp-init.js +303 -0
- package/out/cli/index.js +46 -8
- package/out/cli/pack-loader.js +37 -2
- package/out/cli/runtime.js +10 -3
- package/out/cli/serve-hardening.js +217 -12
- package/out/cli/state-sync.js +318 -29
- 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 -6
- package/out/live-probe/connector.mjs +8 -0
- package/out/live-probe/env-report.mjs +8 -0
- package/out/mcp.js +88 -61
- package/out/remote/handleRemoteChat.js +7 -1
- package/out/remote/serveClient.js +27 -13
- package/out/scripts/analytics/agentide-litellm-otel.config.yaml +14 -0
- package/out/shared/models.js +1 -1
- package/out/shared/providers.js +5 -2
- package/out/test/analytics.test.js +93 -0
- package/out/test/auth-broker.test.js +59 -2
- package/out/test/blueprint-registry.test.js +105 -0
- package/out/test/credential-context.test.js +55 -0
- 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 +287 -5
- 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 +60 -4
- 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 +187 -0
- package/out/test/serveClient.test.js +37 -2
- package/out/test/state-sync-peer.test.js +83 -1
- package/out/utils.js +27 -11
- package/package.json +3 -4
|
@@ -49,8 +49,10 @@ const child_process_1 = require("child_process");
|
|
|
49
49
|
const yaml = __importStar(require("js-yaml"));
|
|
50
50
|
const deploy_init_1 = require("../cli/deploy-init");
|
|
51
51
|
const deploy_1 = require("../cli/commands/deploy");
|
|
52
|
+
const idp_init_1 = require("../cli/idp-init");
|
|
52
53
|
const CTX = {
|
|
53
54
|
packName: 'databricks-analyst',
|
|
55
|
+
packVersion: '0.1.0',
|
|
54
56
|
image: 'ghcr.io/agenthippo/runtime:2026.06',
|
|
55
57
|
port: 8080,
|
|
56
58
|
engine: 'claude',
|
|
@@ -115,6 +117,7 @@ suite('deploy-init: renderEnvExample', () => {
|
|
|
115
117
|
assert.ok(out.includes('OAUTH2_PROXY_REDIRECT_URL='));
|
|
116
118
|
assert.ok(out.includes('OAUTH2_PROXY_REVERSE_PROXY=true'));
|
|
117
119
|
assert.ok(out.includes('AGENTHIPPO_AUTH_JWT_SECRET='));
|
|
120
|
+
assert.ok(out.includes('AGENTHIPPO_AUTH_DEV=0'), 'AUTH_DEV must default off in generated auth env');
|
|
118
121
|
});
|
|
119
122
|
test('renderAuthEnvExample honors custom hostname', () => {
|
|
120
123
|
const out = (0, deploy_init_1.renderAuthEnvExample)({ hostname: 'agents.example.com' });
|
|
@@ -159,6 +162,43 @@ suite('deploy-init: renderTarget produces well-formed artifacts', () => {
|
|
|
159
162
|
assert.ok(files['README.md'], `${t} missing README.md`);
|
|
160
163
|
}
|
|
161
164
|
});
|
|
165
|
+
test('every target ships a DATA-ACCESS.md summary (governance layer)', () => {
|
|
166
|
+
for (const t of deploy_init_1.DEPLOY_TARGETS) {
|
|
167
|
+
const files = (0, deploy_init_1.renderTarget)(t, CTX);
|
|
168
|
+
const doc = files['DATA-ACCESS.md'];
|
|
169
|
+
assert.ok(doc, `${t} missing DATA-ACCESS.md`);
|
|
170
|
+
assert.ok(doc.includes('## Secrets') && doc.includes('## Egress') && doc.includes('## Blast radius per component'), `${t} DATA-ACCESS.md missing a required section`);
|
|
171
|
+
assert.ok(doc.includes('api.anthropic.com:443'), `${t} DATA-ACCESS.md must name the model API host`);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
test('DATA-ACCESS.md lists auth secrets + oauth2-proxy blast radius only for self-issued edge targets', () => {
|
|
175
|
+
const selfIssued = (0, deploy_init_1.renderTarget)('vm', CTX)['DATA-ACCESS.md'];
|
|
176
|
+
assert.ok(selfIssued.includes('AGENTHIPPO_AUTH_JWT_SECRET'));
|
|
177
|
+
assert.ok(selfIssued.includes('OAUTH2_PROXY_CLIENT_SECRET'));
|
|
178
|
+
assert.ok(selfIssued.includes('**oauth2-proxy**'));
|
|
179
|
+
const federated = (0, deploy_init_1.renderTarget)('vm', { ...CTX, ingress: 'edge-fed', idpUrl: 'https://auth.corp.example' })['DATA-ACCESS.md'];
|
|
180
|
+
assert.ok(federated.includes('AGENTHIPPO_AUTH_JWT_SECRET'));
|
|
181
|
+
assert.ok(!federated.includes('OAUTH2_PROXY_CLIENT_SECRET'), 'federated deployments mint no Google OAuth client');
|
|
182
|
+
assert.ok(!federated.includes('**oauth2-proxy**'), 'federated deployments run no oauth2-proxy');
|
|
183
|
+
const noEdge = (0, deploy_init_1.renderTarget)('databricks-app', CTX)['DATA-ACCESS.md'];
|
|
184
|
+
assert.ok(!noEdge.includes('AGENTHIPPO_AUTH_JWT_SECRET'), 'platform-native targets mint no JWT secret');
|
|
185
|
+
});
|
|
186
|
+
test('image-based targets emit pack provenance header and OCI labels', () => {
|
|
187
|
+
const marker = 'Generated from Agent Pack databricks-analyst@0.1.0 — packs.agenthippo.ai';
|
|
188
|
+
for (const t of ['vm', 'compose', 'cloudrun', 'spcs', 'cloudflare']) {
|
|
189
|
+
const files = (0, deploy_init_1.renderTarget)(t, CTX);
|
|
190
|
+
const df = files['Dockerfile'];
|
|
191
|
+
assert.ok(df.includes(marker), `${t} Dockerfile missing attribution`);
|
|
192
|
+
assert.ok(df.includes('LABEL ai.agenthippo.pack.name="databricks-analyst"'), `${t} missing pack.name label`);
|
|
193
|
+
assert.ok(df.includes('LABEL ai.agenthippo.pack.version="0.1.0"'), `${t} missing pack.version label`);
|
|
194
|
+
assert.ok(df.includes('LABEL ai.agenthippo.pack.engine="claude"'), `${t} missing pack.engine label`);
|
|
195
|
+
assert.ok(files['.env.example'].includes(marker), `${t} .env.example missing attribution`);
|
|
196
|
+
assert.ok(files['README.md'].includes(marker), `${t} README missing attribution`);
|
|
197
|
+
}
|
|
198
|
+
const vmRun = (0, deploy_init_1.renderTarget)('vm', CTX)['run.sh'];
|
|
199
|
+
assert.ok(vmRun.startsWith('#!'), 'run.sh keeps shebang first');
|
|
200
|
+
assert.ok(vmRun.includes(`# ${marker}`), 'run.sh attributes after shebang');
|
|
201
|
+
});
|
|
162
202
|
test('image-based targets emit a valid 5-line-style Dockerfile referencing the runtime image', () => {
|
|
163
203
|
for (const t of ['vm', 'compose', 'cloudrun', 'spcs', 'cloudflare']) {
|
|
164
204
|
const df = (0, deploy_init_1.renderTarget)(t, CTX)['Dockerfile'];
|
|
@@ -181,9 +221,13 @@ suite('deploy-init: renderTarget produces well-formed artifacts', () => {
|
|
|
181
221
|
// Ship only deploy/www/ from deploy/ (drops deploy/.env, credentials, build artifacts).
|
|
182
222
|
assert.ok(di.includes('deploy/*'), `${t} should exclude deploy/*`);
|
|
183
223
|
assert.ok(di.includes('!deploy/www/**'), `${t} should re-include deploy/www/**`);
|
|
184
|
-
// Defensive secret ignores
|
|
224
|
+
// Defensive secret ignores — any-extension credentials + common private-key formats,
|
|
225
|
+
// not just *credentials*.json (see isSecretLikeBasename in deploy-init.ts).
|
|
185
226
|
assert.ok(di.includes('**/.env'), `${t} should ignore **/.env`);
|
|
186
|
-
assert.ok(di.includes('**/*credentials
|
|
227
|
+
assert.ok(di.includes('**/*credentials*'), `${t} should ignore credentials of any extension`);
|
|
228
|
+
assert.ok(di.includes('**/*.pem'), `${t} should ignore .pem files`);
|
|
229
|
+
assert.ok(di.includes('**/*.key'), `${t} should ignore .key files`);
|
|
230
|
+
assert.ok(di.includes('**/id_rsa'), `${t} should ignore id_rsa`);
|
|
187
231
|
// skills/, scripts/, references/ must NOT be ignored lines.
|
|
188
232
|
const ignored = di.split('\n').map((l) => l.trim()).filter((l) => l && !l.startsWith('#'));
|
|
189
233
|
for (const keep of ['skills/', 'scripts/', 'references/', 'skills', 'scripts', 'references']) {
|
|
@@ -220,7 +264,7 @@ suite('deploy-init: renderTarget produces well-formed artifacts', () => {
|
|
|
220
264
|
assert.deepStrictEqual(app.user_authorization?.scopes, ['sql']);
|
|
221
265
|
const pkg = JSON.parse(files['package.json']);
|
|
222
266
|
assert.ok(pkg.dependencies['@anthropic-ai/claude-code']);
|
|
223
|
-
assert.ok(pkg.dependencies['@agenthippo-ai/
|
|
267
|
+
assert.ok(pkg.dependencies['@agenthippo-ai/agenthippo']);
|
|
224
268
|
});
|
|
225
269
|
test('YAML artifacts parse', () => {
|
|
226
270
|
assert.doesNotThrow(() => yaml.load((0, deploy_init_1.renderTarget)('vm', CTX)['docker-compose.yml']));
|
|
@@ -286,6 +330,11 @@ suite('deploy-init: renderTarget produces well-formed artifacts', () => {
|
|
|
286
330
|
assert.ok(!caddy.includes('oauth2-proxy:4180') && !caddy.includes('auth-broker:9780') && !caddy.includes('agent:8080'), 'edge Caddyfile must use localhost upstreams, not docker-DNS names');
|
|
287
331
|
assert.ok(caddy.includes('localhost:4180') && caddy.includes('localhost:9780') && caddy.includes('localhost:8090'), 'edge Caddyfile routes to oauth2-proxy / auth-broker / agent on localhost');
|
|
288
332
|
assert.ok(caddy.includes(':{$PORT}') && caddy.includes('auto_https off'), 'Caddy listens on HTTP :$PORT (Cloud Run terminates TLS)');
|
|
333
|
+
// IDE PKCE authorize must be SSO-gated before the catch-all /oauth/* → broker route.
|
|
334
|
+
assert.ok(caddy.includes('handle /oauth/authorize {'), 'edge Caddyfile gates /oauth/authorize');
|
|
335
|
+
assert.ok(/handle \/oauth\/authorize \{[\s\S]*?forward_auth localhost:4180[\s\S]*?copy_headers X-Auth-Request-Email/.test(caddy), '/oauth/authorize forward_auth copies X-Auth-Request-Email via localhost oauth2-proxy');
|
|
336
|
+
assert.ok(caddy.indexOf('handle /oauth/authorize {') < caddy.indexOf('handle /oauth/* {'), '/oauth/authorize must precede the catch-all /oauth/* handle');
|
|
337
|
+
assert.ok(caddy.includes('handle /oauth/browser-login {'), 'edge Caddyfile gates /oauth/browser-login');
|
|
289
338
|
// Caddyfile is mounted from a Secret (the deploy script upserts it).
|
|
290
339
|
assert.ok(svc.includes('${SERVICE}-caddyfile'), 'Caddyfile mounted from a secret');
|
|
291
340
|
// auth-broker reuses the agent image (CLI ships `auth-broker`).
|
|
@@ -304,6 +353,30 @@ suite('deploy-init: renderTarget produces well-formed artifacts', () => {
|
|
|
304
353
|
assert.ok(files['.env.auth.example'].includes('OAUTH2_PROXY_CLIENT_ID') && files['.env.auth.example'].includes('AGENTHIPPO_AUTH_JWT_SECRET'));
|
|
305
354
|
// Note: service.yaml is a sed-template (${SERVICE}/${IMAGE}/${PUBLIC_HOSTNAME}/…), so it is
|
|
306
355
|
// only valid YAML after the deploy script substitutes placeholders — same as the IAP template.
|
|
356
|
+
assert.ok(files['README.md'].includes('**Egress:**'), 'edge README documents the egress allowlist');
|
|
357
|
+
});
|
|
358
|
+
test('cloudrun --ingress edge-fed --model-routing gateway drops LiteLLM/OTEL — agent calls gateway.agenthippo.ai', () => {
|
|
359
|
+
const files = (0, deploy_init_1.renderTarget)('cloudrun', {
|
|
360
|
+
...CTX, ingress: 'edge-fed', modelRouting: 'gateway', model: 'ah-auto',
|
|
361
|
+
idpUrl: 'https://auth.agenthippo.ai', cookieDomain: '.agenthippo.ai',
|
|
362
|
+
});
|
|
363
|
+
const svc = files['service.yaml'];
|
|
364
|
+
const sh = files['cloudrun-deploy.sh'];
|
|
365
|
+
assert.ok(svc.includes('name: caddy') && svc.includes('name: auth-broker') && svc.includes('name: agent'));
|
|
366
|
+
assert.ok(!svc.includes('name: litellm') && !svc.includes('name: otel-collector'), 'no Spotlight sidecars');
|
|
367
|
+
assert.ok(svc.includes('https://gateway.agenthippo.ai/v1'), 'agent points at org gateway');
|
|
368
|
+
assert.ok(!svc.includes('http://localhost:4443'), 'agent must not route through in-pod LiteLLM');
|
|
369
|
+
assert.ok(!files['otel-collector.yaml'], 'no otel config artifact');
|
|
370
|
+
assert.ok(!sh.includes('LITELLM_KEY') && !sh.includes('otel-config'), 'deploy skips litellm/otel secrets');
|
|
371
|
+
assert.ok(sh.includes('${SERVICE}-provider-key'), 'gateway key stored as provider-key secret');
|
|
372
|
+
assert.ok(files['.env.example'].includes('AGENTHIPPO_LITELLM_BASE_URL=https://gateway.agenthippo.ai/v1'));
|
|
373
|
+
assert.doesNotThrow(() => (0, child_process_1.execFileSync)('bash', ['-n', '-c', sh]));
|
|
374
|
+
const rendered = svc
|
|
375
|
+
.replace(/\$\{SERVICE\}/g, 'emoji-gateway')
|
|
376
|
+
.replace(/\$\{IMAGE\}/g, 'us-docker.pkg.dev/x/y/z:latest')
|
|
377
|
+
.replace(/\$\{PUBLIC_HOSTNAME\}/g, 'emoji.agenthippo.ai')
|
|
378
|
+
.replace(/\$\{ALLOWED_DOMAINS\}/g, 'agenthippo.ai');
|
|
379
|
+
assert.doesNotThrow(() => yaml.load(rendered), 'rendered gateway-direct service.yaml is valid YAML');
|
|
307
380
|
});
|
|
308
381
|
test('cloudrun --ingress edge-fed (A′) verifies the shared JWT — no oauth2-proxy, no per-service OAuth', () => {
|
|
309
382
|
const files = (0, deploy_init_1.renderTarget)('cloudrun', {
|
|
@@ -355,6 +428,7 @@ suite('deploy-init: renderTarget produces well-formed artifacts', () => {
|
|
|
355
428
|
.replace(/\$\{PUBLIC_HOSTNAME\}/g, 'cs.agenthippo.ai')
|
|
356
429
|
.replace(/\$\{ALLOWED_DOMAINS\}/g, 'agenthippo.ai');
|
|
357
430
|
assert.doesNotThrow(() => yaml.load(rendered), 'rendered service.yaml is valid YAML');
|
|
431
|
+
assert.ok(files['README.md'].includes('**Egress:**'), 'edge-fed README documents the egress allowlist');
|
|
358
432
|
});
|
|
359
433
|
test('renderAuthEnvExample fed variant has no Google OAuth client', () => {
|
|
360
434
|
const out = (0, deploy_init_1.renderAuthEnvExample)({ hostname: 'cs.agenthippo.ai', fed: true, idpUrl: 'https://auth.agenthippo.ai', cookieDomain: '.agenthippo.ai' });
|
|
@@ -393,11 +467,115 @@ suite('deploy-init: renderTarget produces well-formed artifacts', () => {
|
|
|
393
467
|
const env = (0, deploy_init_1.renderTarget)('cloudrun', ctx)['.env.example'];
|
|
394
468
|
assert.ok(env.includes('CURSOR_API_KEY='));
|
|
395
469
|
});
|
|
470
|
+
test('cloudrun IAP paths pin AGENTHIPPO_IDENTITY_HEADER so X-Agent-User cannot spoof identity', () => {
|
|
471
|
+
// Claude/codex → Spotlight multi-container path (renderCloudrunServiceYaml).
|
|
472
|
+
const spotlight = (0, deploy_init_1.renderTarget)('cloudrun', CTX);
|
|
473
|
+
assert.ok(spotlight['service.yaml'].includes('AGENTHIPPO_IDENTITY_HEADER'), 'Spotlight service.yaml must declare AGENTHIPPO_IDENTITY_HEADER');
|
|
474
|
+
assert.ok(spotlight['cloudrun-deploy.sh'].includes('x-goog-authenticated-user-email'), 'Spotlight deploy script must fill the pin with the IAP header when ENABLE_IAP=1');
|
|
475
|
+
// Cursor → T1-generic single-container path.
|
|
476
|
+
const cursorCtx = { ...CTX, engine: 'cursor', model: 'auto', image: 'ghcr.io/agenthippo/runtime:latest-cursor' };
|
|
477
|
+
const generic = (0, deploy_init_1.renderTarget)('cloudrun', cursorCtx);
|
|
478
|
+
assert.ok(generic['cloudrun-deploy.sh'].includes('AGENTHIPPO_IDENTITY_HEADER=x-goog-authenticated-user-email'), 'T1-generic deploy script must pin AGENTHIPPO_IDENTITY_HEADER to the IAP header');
|
|
479
|
+
// Both must skip the pin when ENABLE_IAP=0 (IAP never sets the header then — pinning
|
|
480
|
+
// unconditionally would make every request resolve to anonymous during bootstrap).
|
|
481
|
+
assert.ok(spotlight['cloudrun-deploy.sh'].includes('ENABLE_IAP'), 'Spotlight pin must be ENABLE_IAP-conditional');
|
|
482
|
+
assert.ok(generic['cloudrun-deploy.sh'].includes('IDENTITY_ENV='), 'T1-generic pin must be ENABLE_IAP-conditional');
|
|
483
|
+
});
|
|
484
|
+
test('databricks-app pins AGENTHIPPO_IDENTITY_HEADER to x-forwarded-email', () => {
|
|
485
|
+
const start = (0, deploy_init_1.renderTarget)('databricks-app', CTX)['start.js'];
|
|
486
|
+
assert.ok(start.includes("AGENTHIPPO_IDENTITY_HEADER: 'x-forwarded-email'"), 'Databricks App start.js must pin identity to the workspace SSO header');
|
|
487
|
+
// Bootstrap escape hatch (analogous to Cloud Run ENABLE_IAP=0).
|
|
488
|
+
assert.ok(start.includes("AGENTHIPPO_PIN_IDENTITY === '0'"), 'Databricks pin must be skippable via AGENTHIPPO_PIN_IDENTITY=0');
|
|
489
|
+
});
|
|
396
490
|
test('spcs egress is limited to the model API via one EAI', () => {
|
|
397
491
|
const sql = (0, deploy_init_1.renderTarget)('spcs', CTX)['deploy.sql'];
|
|
398
492
|
assert.ok(sql.includes('api.anthropic.com:443'));
|
|
399
493
|
assert.ok(/EXTERNAL ACCESS INTEGRATION/.test(sql));
|
|
400
494
|
});
|
|
495
|
+
test('aws-ecs bare (no ingress) is a single-container task with no edge opinion', () => {
|
|
496
|
+
const files = (0, deploy_init_1.renderTarget)('aws-ecs', CTX);
|
|
497
|
+
assert.ok(!files['Caddyfile'], 'bare aws-ecs ships no Caddy');
|
|
498
|
+
assert.ok(!files['.env.auth.example'], 'bare aws-ecs has no local auth secrets');
|
|
499
|
+
const task = JSON.parse(files['task-definition.json']);
|
|
500
|
+
assert.strictEqual(task.networkMode, 'awsvpc');
|
|
501
|
+
assert.deepStrictEqual(task.requiresCompatibilities, ['FARGATE']);
|
|
502
|
+
assert.strictEqual(task.containerDefinitions.length, 1, 'exactly one container: the agent');
|
|
503
|
+
assert.strictEqual(task.containerDefinitions[0].name, 'agent');
|
|
504
|
+
assert.doesNotThrow(() => (0, child_process_1.execFileSync)('bash', ['-n', '-c', files['deploy.sh']]));
|
|
505
|
+
});
|
|
506
|
+
test('aws-ecs --ingress edge reuses the Cloud Run edge Caddyfile verbatim (shared sidecar identity layer)', () => {
|
|
507
|
+
const files = (0, deploy_init_1.renderTarget)('aws-ecs', { ...CTX, ingress: 'edge' });
|
|
508
|
+
const task = JSON.parse(files['task-definition.json']);
|
|
509
|
+
const names = task.containerDefinitions.map((c) => c.name);
|
|
510
|
+
assert.deepStrictEqual(names, ['caddy', 'oauth2-proxy', 'auth-broker', 'agent']);
|
|
511
|
+
assert.strictEqual(task.networkMode, 'awsvpc', 'one task, containers share localhost — same shape as Cloud Run sidecars');
|
|
512
|
+
assert.ok(files['Caddyfile'].includes('localhost:4180') && files['Caddyfile'].includes('localhost:9780'), 'localhost addressing (awsvpc shared network), not docker-DNS names');
|
|
513
|
+
assert.ok(files['.env.auth.example'].includes('OAUTH2_PROXY_CLIENT_ID'));
|
|
514
|
+
assert.doesNotThrow(() => (0, child_process_1.execFileSync)('bash', ['-n', '-c', files['deploy.sh']]));
|
|
515
|
+
});
|
|
516
|
+
test('aws-ecs --ingress edge-fed drops oauth2-proxy from the task and Caddyfile', () => {
|
|
517
|
+
const files = (0, deploy_init_1.renderTarget)('aws-ecs', { ...CTX, ingress: 'edge-fed', idpUrl: 'https://auth.corp.example' });
|
|
518
|
+
const task = JSON.parse(files['task-definition.json']);
|
|
519
|
+
const names = task.containerDefinitions.map((c) => c.name);
|
|
520
|
+
assert.deepStrictEqual(names, ['caddy', 'auth-broker', 'agent']);
|
|
521
|
+
assert.ok(!files['Caddyfile'].includes('localhost:4180'), 'no oauth2-proxy upstream in a federated Caddyfile');
|
|
522
|
+
assert.ok(files['Caddyfile'].includes('forward_auth localhost:9780'), 'verifies via auth-broker');
|
|
523
|
+
assert.ok(files['Caddyfile'].includes('https://auth.corp.example/oauth/browser-login'));
|
|
524
|
+
assert.ok(!files['.env.auth.example'].includes('OAUTH2_PROXY_CLIENT_ID'));
|
|
525
|
+
assert.doesNotThrow(() => (0, child_process_1.execFileSync)('bash', ['-n', '-c', files['deploy.sh']]));
|
|
526
|
+
});
|
|
527
|
+
test('azure-container-apps bare (no ingress) is a single-container app with no edge opinion', () => {
|
|
528
|
+
const files = (0, deploy_init_1.renderTarget)('azure-container-apps', CTX);
|
|
529
|
+
assert.ok(!files['Caddyfile'], 'bare azure-container-apps ships no Caddy');
|
|
530
|
+
assert.ok(!files['.env.auth.example'], 'bare azure-container-apps has no local auth secrets');
|
|
531
|
+
const spec = JSON.parse(files['containerapp.json']);
|
|
532
|
+
assert.strictEqual(spec.properties.template.containers.length, 1, 'exactly one container: the agent');
|
|
533
|
+
assert.strictEqual(spec.properties.template.containers[0].name, 'agent');
|
|
534
|
+
assert.strictEqual(spec.properties.configuration.ingress.external, true);
|
|
535
|
+
assert.doesNotThrow(() => (0, child_process_1.execFileSync)('bash', ['-n', '-c', files['deploy.sh']]));
|
|
536
|
+
});
|
|
537
|
+
test('azure-container-apps --ingress edge reuses the Cloud Run edge Caddyfile verbatim (shared sidecar identity layer)', () => {
|
|
538
|
+
const files = (0, deploy_init_1.renderTarget)('azure-container-apps', { ...CTX, ingress: 'edge' });
|
|
539
|
+
const spec = JSON.parse(files['containerapp.json']);
|
|
540
|
+
const names = spec.properties.template.containers.map((c) => c.name);
|
|
541
|
+
assert.deepStrictEqual(names, ['caddy', 'oauth2-proxy', 'auth-broker', 'agent']);
|
|
542
|
+
assert.ok(files['Caddyfile'].includes('localhost:4180') && files['Caddyfile'].includes('localhost:9780'), 'localhost addressing (containers in one replica share it), not docker-DNS names');
|
|
543
|
+
assert.ok(files['.env.auth.example'].includes('OAUTH2_PROXY_CLIENT_ID'));
|
|
544
|
+
assert.doesNotThrow(() => (0, child_process_1.execFileSync)('bash', ['-n', '-c', files['deploy.sh']]));
|
|
545
|
+
});
|
|
546
|
+
test('azure-container-apps --ingress edge-fed drops oauth2-proxy from the spec and Caddyfile', () => {
|
|
547
|
+
const files = (0, deploy_init_1.renderTarget)('azure-container-apps', { ...CTX, ingress: 'edge-fed', idpUrl: 'https://auth.corp.example' });
|
|
548
|
+
const spec = JSON.parse(files['containerapp.json']);
|
|
549
|
+
const names = spec.properties.template.containers.map((c) => c.name);
|
|
550
|
+
assert.deepStrictEqual(names, ['caddy', 'auth-broker', 'agent']);
|
|
551
|
+
assert.ok(!files['Caddyfile'].includes('localhost:4180'), 'no oauth2-proxy upstream in a federated Caddyfile');
|
|
552
|
+
assert.ok(files['Caddyfile'].includes('forward_auth localhost:9780'), 'verifies via auth-broker');
|
|
553
|
+
assert.ok(files['Caddyfile'].includes('https://auth.corp.example/oauth/browser-login'));
|
|
554
|
+
assert.ok(!files['.env.auth.example'].includes('OAUTH2_PROXY_CLIENT_ID'));
|
|
555
|
+
assert.doesNotThrow(() => (0, child_process_1.execFileSync)('bash', ['-n', '-c', files['deploy.sh']]));
|
|
556
|
+
});
|
|
557
|
+
// Governance (PLAN/deploy.md §6): every target must document what it can reach outbound —
|
|
558
|
+
// spcs enforces it via a network rule (tested above); every other target must at least name
|
|
559
|
+
// the host in its README so a security review doesn't have to reverse-engineer it.
|
|
560
|
+
test('every non-spcs target documents its egress allowlist in README', () => {
|
|
561
|
+
for (const target of deploy_init_1.DEPLOY_TARGETS) {
|
|
562
|
+
if (target === 'spcs') {
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
const readme = (0, deploy_init_1.renderTarget)(target, CTX)['README.md'];
|
|
566
|
+
assert.ok(readme?.includes('**Egress:**'), `${target} README must document its egress allowlist`);
|
|
567
|
+
assert.ok(readme.includes('api.anthropic.com:443'), `${target} README must name the model API host`);
|
|
568
|
+
}
|
|
569
|
+
});
|
|
570
|
+
// Governance (PLAN/deploy.md §6): a container that might ever be reached directly (bypassing
|
|
571
|
+
// its fronting proxy/Worker) must pin AGENTHIPPO_IDENTITY_HEADER so it can't be spoofed by an
|
|
572
|
+
// unauthenticated caller naming themselves via the same header name serve otherwise trusts as
|
|
573
|
+
// an unpinned alias. iap/databricks-app are covered by their own tests above; cloudflare here.
|
|
574
|
+
test('cloudflare pins AGENTHIPPO_IDENTITY_HEADER so a directly-reached container cannot be spoofed', () => {
|
|
575
|
+
const files = (0, deploy_init_1.renderTarget)('cloudflare', CTX);
|
|
576
|
+
assert.ok(files['Dockerfile'].includes('ENV AGENTHIPPO_IDENTITY_HEADER=x-agent-user'), 'cloudflare Dockerfile must pin identity to the header its own Worker sets');
|
|
577
|
+
assert.ok(files['README.md'].includes('Identity pin'), 'README must document the pin for whoever wires the Worker');
|
|
578
|
+
});
|
|
401
579
|
test('vm target includes edge compose profile; agent has no SSO-bypass host port', () => {
|
|
402
580
|
const files = (0, deploy_init_1.renderTarget)('vm', CTX);
|
|
403
581
|
const compose = files['docker-compose.yml'];
|
|
@@ -411,10 +589,17 @@ suite('deploy-init: renderTarget produces well-formed artifacts', () => {
|
|
|
411
589
|
assert.ok(compose.includes('./www:/srv/www'));
|
|
412
590
|
assert.ok(files['run.sh'].includes('127.0.0.1:8080'));
|
|
413
591
|
assert.ok(files['run-edge-tunnel.sh'].includes('--profile edge --profile tunnel'));
|
|
592
|
+
assert.ok(files['Caddyfile'].includes('lb_policy header X-Agent-User'));
|
|
414
593
|
assert.ok(files['Caddyfile'].includes('reverse_proxy agent:8080'));
|
|
415
594
|
assert.ok(files['Caddyfile'].includes('{$AGENTHIPPO_PUBLIC_HOSTNAME}'));
|
|
416
595
|
assert.ok(files['Caddyfile'].includes('@health path /health /ready'));
|
|
417
596
|
assert.ok(!files['Caddyfile'].includes('@api path /v1/* /health /ready'));
|
|
597
|
+
// IDE PKCE authorize must be SSO-gated (oauth2-proxy → X-Auth-Request-Email), not dumped via /oauth/*.
|
|
598
|
+
const caddy = files['Caddyfile'];
|
|
599
|
+
assert.ok(caddy.includes('handle /oauth/authorize {'), 'Caddyfile gates /oauth/authorize');
|
|
600
|
+
assert.ok(/handle \/oauth\/authorize \{[\s\S]*?forward_auth oauth2-proxy:4180[\s\S]*?copy_headers X-Auth-Request-Email/.test(caddy), '/oauth/authorize forward_auth copies X-Auth-Request-Email');
|
|
601
|
+
assert.ok(caddy.indexOf('handle /oauth/authorize {') < caddy.indexOf('handle /oauth/* {'), '/oauth/authorize must precede the catch-all /oauth/* handle');
|
|
602
|
+
assert.ok(caddy.includes('handle /oauth/browser-login {'), 'Caddyfile gates /oauth/browser-login');
|
|
418
603
|
assert.ok(files['www/index.html'].includes('<form id="chat">'));
|
|
419
604
|
// In-compose config-file tunnel (the proven-live path): cloudflared container → caddy:80.
|
|
420
605
|
assert.ok(files['cloudflared.yml'].includes('http://caddy:80'), 'in-compose cloudflared reaches Caddy on the docker network');
|
|
@@ -437,6 +622,65 @@ suite('deploy-init: renderTarget produces well-formed artifacts', () => {
|
|
|
437
622
|
assert.ok(files['Caddyfile']);
|
|
438
623
|
assert.ok(files['.env.auth.example'].includes('OAUTH2_PROXY_PROVIDER'));
|
|
439
624
|
});
|
|
625
|
+
for (const target of ['vm', 'compose']) {
|
|
626
|
+
test(`${target} --ingress edge-fed verifies the shared JWT — no oauth2-proxy, no per-agent OAuth`, () => {
|
|
627
|
+
const fedCtx = {
|
|
628
|
+
...CTX, ingress: 'edge-fed', idpUrl: 'https://auth.corp.example', cookieDomain: '.corp.example',
|
|
629
|
+
};
|
|
630
|
+
const files = (0, deploy_init_1.renderTarget)(target, fedCtx);
|
|
631
|
+
const compose = files['docker-compose.yml'];
|
|
632
|
+
const caddy = files['Caddyfile'];
|
|
633
|
+
const auth = files['.env.auth.example'];
|
|
634
|
+
assert.ok(!/^\s*oauth2-proxy:/m.test(compose), `${target} edge-fed must not ship an oauth2-proxy container`);
|
|
635
|
+
assert.ok(compose.includes('depends_on: [agent, auth-broker]'), 'caddy no longer depends on oauth2-proxy');
|
|
636
|
+
assert.ok(!caddy.includes('oauth2-proxy:4180') && !caddy.includes('/oauth2/*'), 'no oauth2-proxy routes in a federated Caddyfile');
|
|
637
|
+
assert.ok(caddy.includes('forward_auth auth-broker:9780'), 'Caddy verifies via auth-broker');
|
|
638
|
+
assert.ok(caddy.includes('https://auth.corp.example/oauth/browser-login'), 'browser 401 redirects to the central IdP browser-login endpoint');
|
|
639
|
+
assert.ok(caddy.includes('return_to='), 'redirect carries a return_to for the round trip');
|
|
640
|
+
// Target-specific behavior (sticky lb / static file split) must survive federation —
|
|
641
|
+
// identity mode and deployment shape are independent axes.
|
|
642
|
+
assert.ok(caddy.includes('lb_policy header X-Agent-User'), 'sticky agent proxy unaffected by identity mode');
|
|
643
|
+
assert.ok(caddy.includes('root * /srv/www'), 'static file serving unaffected by identity mode');
|
|
644
|
+
assert.ok(auth.includes('AGENTHIPPO_AUTH_JWT_SECRET') && auth.includes('AGENTHIPPO_AUTH_IDP_URL'));
|
|
645
|
+
assert.ok(!auth.includes('OAUTH2_PROXY_CLIENT_ID'), 'federated auth env has no Google OAuth client');
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
for (const target of ['vm', 'compose']) {
|
|
649
|
+
test(`${target} --ingress edge-fed --colocate ships only the agent — no Caddy/oauth2-proxy/auth-broker`, () => {
|
|
650
|
+
const colocateCtx = {
|
|
651
|
+
...CTX, ingress: 'edge-fed', idpUrl: 'https://auth.corp.example', colocate: true,
|
|
652
|
+
};
|
|
653
|
+
const files = (0, deploy_init_1.renderTarget)(target, colocateCtx);
|
|
654
|
+
const compose = files['docker-compose.yml'];
|
|
655
|
+
const snippet = files['agent-route.caddy'];
|
|
656
|
+
assert.ok(!files['Caddyfile'], `${target} colocate must not ship its own Caddyfile`);
|
|
657
|
+
assert.ok(!files['.env.auth.example'], `${target} colocate must not ship any local auth secrets`);
|
|
658
|
+
assert.ok(!/^\s*(caddy|oauth2-proxy|auth-broker):/m.test(compose), `${target} colocate compose must contain only the agent service`);
|
|
659
|
+
assert.ok(compose.includes('container_name: databricks-analyst-agent'), 'fixed, predictable container name');
|
|
660
|
+
assert.ok(compose.includes('networks: [colocate]') && compose.includes('external: true'), 'agent joins the IdP\'s external colocate network');
|
|
661
|
+
assert.ok(!compose.includes('ports:'), 'no host port — reached only via the IdP\'s Caddy, over the shared network');
|
|
662
|
+
assert.ok(snippet, 'route snippet generated for the operator to drop onto the IdP host');
|
|
663
|
+
// http:// (not https://) — must match the IdP Caddyfile's TLS model: auto_https is
|
|
664
|
+
// off there and TLS terminates in front, so an imported https:// block wouldn't
|
|
665
|
+
// match plaintext traffic forwarded from the front proxy.
|
|
666
|
+
assert.ok(snippet.includes(`http://${CTX.publicHostname} {`), 'snippet is a plain-HTTP site block, matching the IdP\'s own');
|
|
667
|
+
assert.ok(!snippet.includes(`https://${CTX.publicHostname}`), 'must not use https:// — the IdP Caddyfile has auto_https off');
|
|
668
|
+
assert.ok(snippet.includes('reverse_proxy databricks-analyst-agent:8080'), 'snippet proxies to the fixed container name');
|
|
669
|
+
assert.ok(snippet.includes('forward_auth auth-broker:9780'), 'identity is verified via the IdP\'s own auth-broker');
|
|
670
|
+
assert.ok(!snippet.includes('oauth2-proxy'), 'no oauth2-proxy — federated identity only');
|
|
671
|
+
assert.ok(snippet.includes('https://auth.corp.example/oauth/browser-login'), 'unauthenticated browsers redirect to the IdP');
|
|
672
|
+
const dataAccess = files['DATA-ACCESS.md'];
|
|
673
|
+
assert.ok(!dataAccess.includes('AGENTHIPPO_AUTH_JWT_SECRET'), 'colocated agent holds no JWT secret of its own');
|
|
674
|
+
assert.ok(!dataAccess.includes('**oauth2-proxy**'), 'colocated agent runs no oauth2-proxy');
|
|
675
|
+
assert.ok(files['www/index.html'], 'colocated agent ships browser landing page at GET /');
|
|
676
|
+
assert.ok(files['Dockerfile'].includes('--www-dir'), 'serve enables static chat UI after SSO');
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
test('--colocate is a no-op without --ingress edge-fed (still ships the normal self-issued edge)', () => {
|
|
680
|
+
const files = (0, deploy_init_1.renderTarget)('vm', { ...CTX, colocate: true });
|
|
681
|
+
assert.ok(files['Caddyfile'], 'no ingress edge-fed → colocate does not apply, normal vm artifacts render');
|
|
682
|
+
assert.ok(!files['agent-route.caddy']);
|
|
683
|
+
});
|
|
440
684
|
test('agent service does not load .env.auth; broker + oauth2-proxy do', () => {
|
|
441
685
|
const compose = (0, deploy_init_1.renderTarget)('compose', CTX)['docker-compose.yml'];
|
|
442
686
|
// The agent runs model-driven tool calls — it must never receive the token-signing key.
|
|
@@ -452,7 +696,7 @@ suite('deploy-init: deployCommand stages a self-contained pack', () => {
|
|
|
452
696
|
setup(() => {
|
|
453
697
|
pack = fs.mkdtempSync(path.join(os.tmpdir(), 'ah-pack-'));
|
|
454
698
|
staging = fs.mkdtempSync(path.join(os.tmpdir(), 'ah-stage-'));
|
|
455
|
-
fs.writeFileSync(path.join(pack, 'agent.yaml'), 'apiVersion: agenthippo.ai/v1\nkind: Agent\nmetadata:\n name: cs-test\nspec:\n engine: claude\n model: litellm/gpt-5.5\n');
|
|
699
|
+
fs.writeFileSync(path.join(pack, 'agent.yaml'), 'apiVersion: agenthippo.ai/v1\nkind: Agent\nmetadata:\n name: cs-test\n version: 0.1.0\nspec:\n engine: claude\n model: litellm/gpt-5.5\n');
|
|
456
700
|
fs.writeFileSync(path.join(pack, 'AGENTS.md'), '# cs-test\n');
|
|
457
701
|
fs.mkdirSync(path.join(pack, 'skills', 'demo', 'scripts'), { recursive: true });
|
|
458
702
|
fs.writeFileSync(path.join(pack, 'skills', 'demo', 'scripts', 'run.sh'), 'echo hi\n');
|
|
@@ -462,6 +706,8 @@ suite('deploy-init: deployCommand stages a self-contained pack', () => {
|
|
|
462
706
|
fs.writeFileSync(path.join(pack, 'node_modules', 'foo', 'index.js'), 'x');
|
|
463
707
|
fs.writeFileSync(path.join(pack, '.env'), 'SECRET=1\n');
|
|
464
708
|
fs.writeFileSync(path.join(pack, 'my-credentials.json'), '{}\n');
|
|
709
|
+
fs.writeFileSync(path.join(pack, 'service-account.pem'), 'FAKE PEM\n');
|
|
710
|
+
fs.writeFileSync(path.join(pack, 'id_rsa'), 'FAKE PRIVATE KEY\n');
|
|
465
711
|
fs.mkdirSync(path.join(pack, 'deploy'), { recursive: true });
|
|
466
712
|
fs.writeFileSync(path.join(pack, 'deploy', 'leftover.yaml'), 'stale\n');
|
|
467
713
|
});
|
|
@@ -479,6 +725,8 @@ suite('deploy-init: deployCommand stages a self-contained pack', () => {
|
|
|
479
725
|
assert.ok(!fs.existsSync(path.join(staging, 'node_modules')), 'node_modules must not be copied into staging');
|
|
480
726
|
assert.ok(!fs.existsSync(path.join(staging, '.env')), '.env must not be copied into staging');
|
|
481
727
|
assert.ok(!fs.existsSync(path.join(staging, 'my-credentials.json')), 'credentials json must not be copied');
|
|
728
|
+
assert.ok(!fs.existsSync(path.join(staging, 'service-account.pem')), '.pem key must not be copied');
|
|
729
|
+
assert.ok(!fs.existsSync(path.join(staging, 'id_rsa')), 'id_rsa key must not be copied');
|
|
482
730
|
// The source pack's own deploy/ is not copied (the generator owns staging/deploy/).
|
|
483
731
|
assert.ok(!fs.existsSync(path.join(staging, 'deploy', 'leftover.yaml')), 'source deploy/ must not be copied');
|
|
484
732
|
// Generated artifacts under staging/deploy/.
|
|
@@ -498,6 +746,25 @@ suite('deploy-init: deployCommand stages a self-contained pack', () => {
|
|
|
498
746
|
assert.ok(fs.existsSync(path.join(staging, 'agent.yaml')), 'packdir flag resolved the pack');
|
|
499
747
|
assert.ok(fs.existsSync(path.join(staging, 'deploy', 'Dockerfile')), 'artifacts generated from packdir pack');
|
|
500
748
|
});
|
|
749
|
+
test('--blueprint team-pilot --platform cloudrun resolves to IAP (no Caddyfile, no oauth2-proxy)', async () => {
|
|
750
|
+
await (0, deploy_1.deployCommand)('init', pack, {
|
|
751
|
+
target: 'vm', blueprint: 'team-pilot', platform: 'cloudrun', out: staging, port: 8080, force: true,
|
|
752
|
+
});
|
|
753
|
+
assert.ok(fs.existsSync(path.join(staging, 'deploy', 'cloudrun-deploy.sh')), 'cloudrun artifacts generated');
|
|
754
|
+
assert.ok(!fs.existsSync(path.join(staging, 'deploy', 'Caddyfile')), 'team-pilot on cloudrun is IAP — no Caddy edge');
|
|
755
|
+
const sh = fs.readFileSync(path.join(staging, 'deploy', 'cloudrun-deploy.sh'), 'utf8');
|
|
756
|
+
assert.ok(sh.includes('--iap'), 'blueprint-resolved --ingress iap reaches the actual IAP deploy path');
|
|
757
|
+
});
|
|
758
|
+
test('--blueprint enterprise-fleet --platform vm resolves to a federated (no oauth2-proxy) edge', async () => {
|
|
759
|
+
await (0, deploy_1.deployCommand)('init', pack, {
|
|
760
|
+
target: 'cloudrun', blueprint: 'enterprise-fleet', platform: 'vm', out: staging, port: 8080, force: true,
|
|
761
|
+
idpUrl: 'https://auth.corp.example',
|
|
762
|
+
});
|
|
763
|
+
const caddy = fs.readFileSync(path.join(staging, 'deploy', 'Caddyfile'), 'utf8');
|
|
764
|
+
const compose = fs.readFileSync(path.join(staging, 'deploy', 'docker-compose.yml'), 'utf8');
|
|
765
|
+
assert.ok(!caddy.includes('oauth2-proxy:4180'), 'enterprise-fleet federates — no oauth2-proxy in the Caddyfile');
|
|
766
|
+
assert.ok(!/^\s*oauth2-proxy:/m.test(compose), 'enterprise-fleet federates — no oauth2-proxy container');
|
|
767
|
+
});
|
|
501
768
|
});
|
|
502
769
|
suite('deploy-init: copyPackToStaging dereferences symlinks + skips broken ones', () => {
|
|
503
770
|
let pack;
|
|
@@ -505,7 +772,7 @@ suite('deploy-init: copyPackToStaging dereferences symlinks + skips broken ones'
|
|
|
505
772
|
setup(() => {
|
|
506
773
|
pack = fs.mkdtempSync(path.join(os.tmpdir(), 'ah-pack-'));
|
|
507
774
|
staging = fs.mkdtempSync(path.join(os.tmpdir(), 'ah-stage-'));
|
|
508
|
-
fs.writeFileSync(path.join(pack, 'agent.yaml'), 'apiVersion: agenthippo.ai/v1\nkind: Agent\nmetadata:\n name: link-test\nspec:\n engine: claude\n');
|
|
775
|
+
fs.writeFileSync(path.join(pack, 'agent.yaml'), 'apiVersion: agenthippo.ai/v1\nkind: Agent\nmetadata:\n name: link-test\n version: 0.1.0\nspec:\n engine: claude\n');
|
|
509
776
|
// Real skill content + a shared file the skill links to (relative symlink → resolved on copy).
|
|
510
777
|
fs.mkdirSync(path.join(pack, 'skills', 'demo'), { recursive: true });
|
|
511
778
|
fs.writeFileSync(path.join(pack, 'skills', 'demo', 'REAL.md'), '# real content\n');
|
|
@@ -530,3 +797,18 @@ suite('deploy-init: copyPackToStaging dereferences symlinks + skips broken ones'
|
|
|
530
797
|
assert.ok(broken.includes('skills/broken/SKILL.md'), 'broken symlink reported in returned list');
|
|
531
798
|
});
|
|
532
799
|
});
|
|
800
|
+
suite('deploy-init: enterprise-deploy/idp/ (customer 0) stays in sync with `agenthippo idp init`', () => {
|
|
801
|
+
// PLAN/deploy.md — AgentHippo's own auth.agenthippo.ai instance is "customer 0": it must be
|
|
802
|
+
// stood up with the exact same `agenthippo idp init` a real enterprise customer would use,
|
|
803
|
+
// not a bespoke hand-maintained copy. This fails loudly if the checked-in files drift from
|
|
804
|
+
// what the generator produces for the same hostname (regenerate with:
|
|
805
|
+
// `agenthippo idp init --hostname auth.agenthippo.ai --out enterprise-deploy/idp --force`).
|
|
806
|
+
test('checked-in files are byte-identical to renderIdpArtifacts({ hostname: "auth.agenthippo.ai" })', () => {
|
|
807
|
+
const idpDir = path.join(__dirname, '..', '..', '..', '..', 'enterprise-deploy', 'idp');
|
|
808
|
+
const files = (0, idp_init_1.renderIdpArtifacts)({ hostname: 'auth.agenthippo.ai' });
|
|
809
|
+
for (const [rel, expected] of Object.entries(files)) {
|
|
810
|
+
const actual = fs.readFileSync(path.join(idpDir, rel), 'utf8');
|
|
811
|
+
assert.strictEqual(actual, expected, `enterprise-deploy/idp/${rel} is stale — regenerate with: agenthippo idp init --hostname auth.agenthippo.ai --out enterprise-deploy/idp --force`);
|
|
812
|
+
}
|
|
813
|
+
});
|
|
814
|
+
});
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/*---------------------------------------------------------------------------------------------
|
|
3
|
+
* Egress broker tests (secure-obo-design §7.6)
|
|
4
|
+
*
|
|
5
|
+
* Boots the real broker (loopback HTTP + shared TurnRegistry) against a temp workspace whose
|
|
6
|
+
* gateway-outbound handle store is seeded like the bridge would seed it, then drives it exactly
|
|
7
|
+
* as the agent-facing `gateway send` broker mode does: bearer nonce, `{message, to?}` body.
|
|
8
|
+
*
|
|
9
|
+
* The `send` function is a spy — no live OpenClaw gateway — so we assert on WHAT the broker
|
|
10
|
+
* resolved and WHETHER it delivered, not on the platform call itself.
|
|
11
|
+
*--------------------------------------------------------------------------------------------*/
|
|
12
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
13
|
+
if (k2 === undefined) k2 = k;
|
|
14
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
15
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
16
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
17
|
+
}
|
|
18
|
+
Object.defineProperty(o, k2, desc);
|
|
19
|
+
}) : (function(o, m, k, k2) {
|
|
20
|
+
if (k2 === undefined) k2 = k;
|
|
21
|
+
o[k2] = m[k];
|
|
22
|
+
}));
|
|
23
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
24
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
25
|
+
}) : function(o, v) {
|
|
26
|
+
o["default"] = v;
|
|
27
|
+
});
|
|
28
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
29
|
+
var ownKeys = function(o) {
|
|
30
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
31
|
+
var ar = [];
|
|
32
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
33
|
+
return ar;
|
|
34
|
+
};
|
|
35
|
+
return ownKeys(o);
|
|
36
|
+
};
|
|
37
|
+
return function (mod) {
|
|
38
|
+
if (mod && mod.__esModule) return mod;
|
|
39
|
+
var result = {};
|
|
40
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
41
|
+
__setModuleDefault(result, mod);
|
|
42
|
+
return result;
|
|
43
|
+
};
|
|
44
|
+
})();
|
|
45
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
46
|
+
require("mocha");
|
|
47
|
+
const assert = __importStar(require("assert"));
|
|
48
|
+
const fs = __importStar(require("fs"));
|
|
49
|
+
const os = __importStar(require("os"));
|
|
50
|
+
const path = __importStar(require("path"));
|
|
51
|
+
const data_plane_1 = require("../cli/data-plane");
|
|
52
|
+
const outboundPaths_1 = require("../gateway/outboundPaths");
|
|
53
|
+
const AGENT_ID = 'demo-agent';
|
|
54
|
+
/** Seed a handle store the way the bridge's upsertGatewayOutboundHandle would. */
|
|
55
|
+
function seedStore(workspaceRoot, storeKey, handle) {
|
|
56
|
+
const p = (0, outboundPaths_1.getGatewayOutboundFilePath)(workspaceRoot, (0, outboundPaths_1.safeGatewaySessionDirId)(storeKey));
|
|
57
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
58
|
+
fs.writeFileSync(p, JSON.stringify({
|
|
59
|
+
version: 1,
|
|
60
|
+
lastByAgent: { [AGENT_ID]: handle },
|
|
61
|
+
targetsByAgent: { [AGENT_ID]: { [handle.target]: handle } },
|
|
62
|
+
}), 'utf8');
|
|
63
|
+
}
|
|
64
|
+
function handle(target, overrides = {}) {
|
|
65
|
+
return {
|
|
66
|
+
agentId: AGENT_ID, channel: 'telegram', target,
|
|
67
|
+
firstSeenAt: 1, lastSeenAt: 2, inboundCount: 1, ...overrides,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
async function post(url, nonce, body) {
|
|
71
|
+
const res = await fetch(url, {
|
|
72
|
+
method: 'POST',
|
|
73
|
+
headers: { 'Content-Type': 'application/json', ...(nonce !== undefined ? { Authorization: `Bearer ${nonce}` } : {}) },
|
|
74
|
+
body: JSON.stringify(body),
|
|
75
|
+
});
|
|
76
|
+
return { status: res.status, json: await res.json().catch(() => ({})) };
|
|
77
|
+
}
|
|
78
|
+
suite('egress broker: server-side resolution + fail-closed (§7.6)', function () {
|
|
79
|
+
this.timeout(15_000);
|
|
80
|
+
let workspaceRoot;
|
|
81
|
+
let registry;
|
|
82
|
+
let broker;
|
|
83
|
+
let sends;
|
|
84
|
+
suiteSetup(async () => {
|
|
85
|
+
workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'egress-ws-'));
|
|
86
|
+
registry = new data_plane_1.TurnRegistry();
|
|
87
|
+
sends = [];
|
|
88
|
+
broker = await (0, data_plane_1.startEgressBroker)({
|
|
89
|
+
registry,
|
|
90
|
+
workspaceRoot,
|
|
91
|
+
agentId: AGENT_ID,
|
|
92
|
+
send: async (h, message, media) => {
|
|
93
|
+
sends.push({ handle: h, message, media });
|
|
94
|
+
return { ok: true, via: 'spy' };
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
suiteTeardown(async () => {
|
|
99
|
+
await broker?.close();
|
|
100
|
+
fs.rmSync(workspaceRoot, { recursive: true, force: true });
|
|
101
|
+
});
|
|
102
|
+
test('binds loopback only', () => {
|
|
103
|
+
assert.ok(broker.url.startsWith('http://127.0.0.1:'), broker.url);
|
|
104
|
+
});
|
|
105
|
+
test('reply-to-sender: a live nonce delivers to the session\'s bound handle', async () => {
|
|
106
|
+
seedStore(workspaceRoot, 'chat-1', handle('telegram:dm:100'));
|
|
107
|
+
const turn = registry.register({ userId: 'u@corp.com', sessionKey: 's', egressStoreKey: 'chat-1' });
|
|
108
|
+
try {
|
|
109
|
+
const { status, json } = await post(broker.url, turn.nonce, { message: 'hello' });
|
|
110
|
+
assert.strictEqual(status, 200);
|
|
111
|
+
assert.strictEqual(json.ok, true);
|
|
112
|
+
assert.strictEqual(sends.at(-1)?.handle.target, 'telegram:dm:100');
|
|
113
|
+
assert.strictEqual(sends.at(-1)?.message, 'hello');
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
turn.release();
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
test('`to` may only SELECT among bound handles — a new destination is refused, no send', async () => {
|
|
120
|
+
seedStore(workspaceRoot, 'chat-2', handle('telegram:dm:200'));
|
|
121
|
+
const turn = registry.register({ userId: 'u@corp.com', sessionKey: 's', egressStoreKey: 'chat-2' });
|
|
122
|
+
const before = sends.length;
|
|
123
|
+
try {
|
|
124
|
+
const { status, json } = await post(broker.url, turn.nonce, { message: 'exfil', to: 'telegram:dm:ATTACKER' });
|
|
125
|
+
assert.strictEqual(status, 404, 'a target not bound to this session must not resolve');
|
|
126
|
+
assert.strictEqual(json.ok, false);
|
|
127
|
+
assert.strictEqual(sends.length, before, 'nothing may be delivered when resolution fails');
|
|
128
|
+
}
|
|
129
|
+
finally {
|
|
130
|
+
turn.release();
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
test('off-turn / released nonce → 401, fail closed, no send', async () => {
|
|
134
|
+
seedStore(workspaceRoot, 'chat-3', handle('telegram:dm:300'));
|
|
135
|
+
const turn = registry.register({ userId: 'u@corp.com', sessionKey: 's', egressStoreKey: 'chat-3' });
|
|
136
|
+
const nonce = turn.nonce;
|
|
137
|
+
turn.release();
|
|
138
|
+
const before = sends.length;
|
|
139
|
+
const { status, json } = await post(broker.url, nonce, { message: 'stale' });
|
|
140
|
+
assert.strictEqual(status, 401);
|
|
141
|
+
assert.strictEqual(json.ok, false);
|
|
142
|
+
assert.strictEqual(sends.length, before);
|
|
143
|
+
});
|
|
144
|
+
test('unknown / missing nonce → 401', async () => {
|
|
145
|
+
assert.strictEqual((await post(broker.url, 'never-issued-nonce', { message: 'x' })).status, 401);
|
|
146
|
+
assert.strictEqual((await post(broker.url, undefined, { message: 'x' })).status, 401);
|
|
147
|
+
});
|
|
148
|
+
test('a turn with no egress store key (bare HTTP data turn) cannot send → 403', async () => {
|
|
149
|
+
const turn = registry.register({ userId: 'u@corp.com', sessionKey: 's' }); // no egressStoreKey
|
|
150
|
+
try {
|
|
151
|
+
const { status } = await post(broker.url, turn.nonce, { message: 'x' });
|
|
152
|
+
assert.strictEqual(status, 403);
|
|
153
|
+
}
|
|
154
|
+
finally {
|
|
155
|
+
turn.release();
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
test('one user cannot address another session\'s bound handle (server-side scoping)', async () => {
|
|
159
|
+
// Alice's session is bound to her peer; Bob's turn (his own store key) must not reach it.
|
|
160
|
+
seedStore(workspaceRoot, 'alice-chat', handle('telegram:dm:ALICE'));
|
|
161
|
+
const bob = registry.register({ userId: 'bob@corp.com', sessionKey: 'sb', egressStoreKey: 'bob-chat' }); // no store seeded
|
|
162
|
+
const before = sends.length;
|
|
163
|
+
try {
|
|
164
|
+
const { status } = await post(broker.url, bob.nonce, { message: 'to alice?', to: 'telegram:dm:ALICE' });
|
|
165
|
+
assert.strictEqual(status, 404, 'Bob\'s turn resolves only against Bob\'s own session store');
|
|
166
|
+
assert.strictEqual(sends.length, before);
|
|
167
|
+
}
|
|
168
|
+
finally {
|
|
169
|
+
bob.release();
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
});
|
|
@@ -45,6 +45,10 @@ suite('fleetDisplayUtils', () => {
|
|
|
45
45
|
assert.strictEqual((0, fleetDisplayUtils_1.inferFleetEngineId)('agentide-cursor::auto'), 'cursor');
|
|
46
46
|
assert.strictEqual((0, fleetDisplayUtils_1.formatFleetEngineLabel)('agentide-cursor::auto'), 'Cursor');
|
|
47
47
|
});
|
|
48
|
+
test('formats agenthippo auto model label', () => {
|
|
49
|
+
assert.strictEqual((0, fleetDisplayUtils_1.formatFleetModelLabel)('ah-auto', ''), 'Auto');
|
|
50
|
+
assert.strictEqual((0, fleetDisplayUtils_1.formatFleetModelLabel)('agenthippo::ah-auto', ''), 'Auto');
|
|
51
|
+
});
|
|
48
52
|
test('enriches in_progress row from schedule hint', () => {
|
|
49
53
|
const hints = (0, fleetDisplayUtils_1.buildScheduleHintsByChatSessionId)([{
|
|
50
54
|
id: 'sched-1',
|