@agenthippo-ai/agenthippo 0.1.0 → 0.2.3-dataplane

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/out/agentpack/index.js +2 -1
  2. package/out/agentpack/paths.js +23 -0
  3. package/out/analytics/dashboard/fleetDisplayUtils.js +3 -0
  4. package/out/analytics/dashboard/webview/panel.js +290 -80
  5. package/out/analytics/sessionEvents.js +21 -4
  6. package/out/auth/brokerClient.js +1 -1
  7. package/out/auth/onboardingPrompt.js +112 -0
  8. package/out/auth/provider.js +1 -1
  9. package/out/auth-broker/config.js +8 -3
  10. package/out/auth-broker/pkce.js +3 -1
  11. package/out/auth-broker/server.js +43 -1
  12. package/out/auth-broker/store.js +6 -5
  13. package/out/cli/commands/ask.js +3 -0
  14. package/out/cli/commands/chat.js +3 -0
  15. package/out/cli/commands/deploy.js +49 -10
  16. package/out/cli/commands/gateway.js +54 -0
  17. package/out/cli/commands/idp.js +85 -0
  18. package/out/cli/commands/serve.js +401 -49
  19. package/out/cli/credential-context.js +47 -69
  20. package/out/cli/data-plane/broker.js +78 -0
  21. package/out/cli/data-plane/connector-host.js +121 -0
  22. package/out/cli/data-plane/egress.js +189 -0
  23. package/out/cli/data-plane/gateway.js +195 -0
  24. package/out/cli/data-plane/index.js +137 -0
  25. package/out/cli/data-plane/registry.js +80 -0
  26. package/out/cli/deploy/blueprints/registry.js +84 -0
  27. package/out/cli/deploy/layers/identity.js +91 -0
  28. package/out/cli/deploy-init.js +1156 -285
  29. package/out/cli/idp-init.js +303 -0
  30. package/out/cli/index.js +46 -8
  31. package/out/cli/pack-loader.js +37 -2
  32. package/out/cli/runtime.js +10 -3
  33. package/out/cli/serve-hardening.js +217 -12
  34. package/out/cli/state-sync.js +318 -29
  35. package/out/engines/claude.js +26 -5
  36. package/out/engines/codex.js +24 -5
  37. package/out/engines/custom.js +46 -12
  38. package/out/extension.js +2 -0
  39. package/out/gateway/outboundSend.js +6 -6
  40. package/out/headless/index.js +24 -6
  41. package/out/live-probe/connector.mjs +8 -0
  42. package/out/live-probe/env-report.mjs +8 -0
  43. package/out/mcp.js +88 -61
  44. package/out/remote/handleRemoteChat.js +7 -1
  45. package/out/remote/serveClient.js +27 -13
  46. package/out/scripts/analytics/agentide-litellm-otel.config.yaml +14 -0
  47. package/out/shared/models.js +1 -1
  48. package/out/shared/providers.js +5 -2
  49. package/out/test/analytics.test.js +93 -0
  50. package/out/test/auth-broker.test.js +59 -2
  51. package/out/test/blueprint-registry.test.js +105 -0
  52. package/out/test/credential-context.test.js +55 -0
  53. package/out/test/custom-env-layering.test.js +151 -0
  54. package/out/test/data-plane-engine-wiring.test.js +250 -0
  55. package/out/test/data-plane.test.js +244 -0
  56. package/out/test/deploy-init.test.js +287 -5
  57. package/out/test/egress-broker.test.js +172 -0
  58. package/out/test/fleetDisplayUtils.test.js +4 -0
  59. package/out/test/idp-init.test.js +151 -0
  60. package/out/test/mcp-obo.test.js +52 -27
  61. package/out/test/mcp.test.js +60 -4
  62. package/out/test/model-routing.test.js +133 -0
  63. package/out/test/serve-gateway-turn.test.js +267 -0
  64. package/out/test/serve-hardening.test.js +187 -0
  65. package/out/test/serveClient.test.js +37 -2
  66. package/out/test/state-sync-peer.test.js +83 -1
  67. package/out/utils.js +27 -11
  68. package/package.json +3 -4
@@ -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*.json'), `${t} should ignore credentials json`);
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/cli']);
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',