@agenthippo-ai/agenthippo 0.1.1 → 0.2.3-dataplane

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/out/agentpack/index.js +2 -1
  2. package/out/agentpack/paths.js +23 -0
  3. package/out/analytics/dashboard/fleetDisplayUtils.js +3 -0
  4. package/out/analytics/dashboard/webview/panel.js +290 -80
  5. package/out/analytics/sessionEvents.js +21 -9
  6. package/out/auth/brokerClient.js +1 -1
  7. package/out/auth/onboardingPrompt.js +112 -0
  8. package/out/auth/provider.js +1 -1
  9. package/out/auth-broker/config.js +8 -3
  10. package/out/auth-broker/server.js +36 -0
  11. package/out/cli/commands/ask.js +3 -0
  12. package/out/cli/commands/chat.js +3 -0
  13. package/out/cli/commands/deploy.js +49 -10
  14. package/out/cli/commands/gateway.js +54 -0
  15. package/out/cli/commands/idp.js +85 -0
  16. package/out/cli/commands/serve.js +315 -55
  17. package/out/cli/credential-context.js +42 -80
  18. package/out/cli/data-plane/broker.js +78 -0
  19. package/out/cli/data-plane/connector-host.js +121 -0
  20. package/out/cli/data-plane/egress.js +189 -0
  21. package/out/cli/data-plane/gateway.js +195 -0
  22. package/out/cli/data-plane/index.js +137 -0
  23. package/out/cli/data-plane/registry.js +80 -0
  24. package/out/cli/deploy/blueprints/registry.js +84 -0
  25. package/out/cli/deploy/layers/identity.js +91 -0
  26. package/out/cli/deploy-init.js +1105 -335
  27. package/out/cli/idp-init.js +303 -0
  28. package/out/cli/index.js +37 -7
  29. package/out/cli/pack-loader.js +37 -2
  30. package/out/cli/runtime.js +10 -3
  31. package/out/cli/serve-hardening.js +60 -1
  32. package/out/cli/state-sync.js +16 -15
  33. package/out/engines/claude.js +26 -5
  34. package/out/engines/codex.js +24 -5
  35. package/out/engines/custom.js +46 -12
  36. package/out/extension.js +2 -0
  37. package/out/gateway/outboundSend.js +6 -6
  38. package/out/headless/index.js +24 -9
  39. package/out/live-probe/connector.mjs +8 -0
  40. package/out/live-probe/env-report.mjs +8 -0
  41. package/out/mcp.js +85 -67
  42. package/out/scripts/analytics/agentide-litellm-otel.config.yaml +14 -0
  43. package/out/shared/models.js +1 -1
  44. package/out/shared/providers.js +2 -2
  45. package/out/test/analytics.test.js +42 -17
  46. package/out/test/auth-broker.test.js +53 -0
  47. package/out/test/blueprint-registry.test.js +105 -0
  48. package/out/test/credential-context.test.js +23 -28
  49. package/out/test/custom-env-layering.test.js +151 -0
  50. package/out/test/data-plane-engine-wiring.test.js +250 -0
  51. package/out/test/data-plane.test.js +244 -0
  52. package/out/test/deploy-init.test.js +255 -4
  53. package/out/test/egress-broker.test.js +172 -0
  54. package/out/test/fleetDisplayUtils.test.js +4 -0
  55. package/out/test/idp-init.test.js +151 -0
  56. package/out/test/mcp-obo.test.js +52 -27
  57. package/out/test/mcp.test.js +43 -3
  58. package/out/test/model-routing.test.js +133 -0
  59. package/out/test/serve-gateway-turn.test.js +267 -0
  60. package/out/test/serve-hardening.test.js +80 -0
  61. package/out/test/serveClient.test.js +32 -0
  62. package/out/test/state-sync-peer.test.js +18 -5
  63. package/out/utils.js +27 -11
  64. package/package.json +1 -1
@@ -0,0 +1,244 @@
1
+ "use strict";
2
+ /*---------------------------------------------------------------------------------------------
3
+ * Data-plane gateway integration tests (secure-obo-design §3, §5, §9)
4
+ *
5
+ * Boots the real thing end to end: TurnRegistry + CredentialBroker + a real connector
6
+ * subprocess (stdio MCP over a private pipe) + the loopback streamable-HTTP gateway —
7
+ * then drives it with the MCP SDK client exactly like an engine would.
8
+ *--------------------------------------------------------------------------------------------*/
9
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ var desc = Object.getOwnPropertyDescriptor(m, k);
12
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
13
+ desc = { enumerable: true, get: function() { return m[k]; } };
14
+ }
15
+ Object.defineProperty(o, k2, desc);
16
+ }) : (function(o, m, k, k2) {
17
+ if (k2 === undefined) k2 = k;
18
+ o[k2] = m[k];
19
+ }));
20
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
21
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
22
+ }) : function(o, v) {
23
+ o["default"] = v;
24
+ });
25
+ var __importStar = (this && this.__importStar) || (function () {
26
+ var ownKeys = function(o) {
27
+ ownKeys = Object.getOwnPropertyNames || function (o) {
28
+ var ar = [];
29
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
30
+ return ar;
31
+ };
32
+ return ownKeys(o);
33
+ };
34
+ return function (mod) {
35
+ if (mod && mod.__esModule) return mod;
36
+ var result = {};
37
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
38
+ __setModuleDefault(result, mod);
39
+ return result;
40
+ };
41
+ })();
42
+ Object.defineProperty(exports, "__esModule", { value: true });
43
+ require("mocha");
44
+ const assert = __importStar(require("assert"));
45
+ const fs = __importStar(require("fs"));
46
+ const path = __importStar(require("path"));
47
+ const index_js_1 = require("@modelcontextprotocol/sdk/client/index.js");
48
+ const streamableHttp_js_1 = require("@modelcontextprotocol/sdk/client/streamableHttp.js");
49
+ const data_plane_1 = require("../cli/data-plane");
50
+ // The fixture connector must live INSIDE the extension tree so its `import` of
51
+ // @modelcontextprotocol/sdk resolves by walking up to extensions/agentide/node_modules.
52
+ const FIXTURE_DIR = path.join(__dirname, 'data-plane-fixture');
53
+ const CONNECTOR_PATH = path.join(FIXTURE_DIR, 'connector.mjs');
54
+ const CONNECTOR_SOURCE = `
55
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
56
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
57
+ import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
58
+
59
+ const server = new Server({ name: 'test-connector', version: '0.0.1' }, { capabilities: { tools: {} } });
60
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
61
+ tools: [{
62
+ name: 'whoami',
63
+ description: 'echo the broker-supplied turn context',
64
+ inputSchema: { type: 'object', properties: { probe: { type: 'string' } } },
65
+ }],
66
+ }));
67
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
68
+ const turn = (req.params._meta ?? {})['ai.agenthippo/turn'] ?? null;
69
+ const probe = (req.params.arguments ?? {}).probe ?? '';
70
+ return {
71
+ content: [{ type: 'text', text: JSON.stringify({
72
+ userId: turn ? turn.userId : null,
73
+ credential: turn ? turn.credential : null,
74
+ envContainsProbe: probe ? JSON.stringify(process.env).includes(probe) : false,
75
+ }) }],
76
+ };
77
+ });
78
+ await server.connect(new StdioServerTransport());
79
+ `;
80
+ function mcpClient(url, nonce) {
81
+ const transport = new streamableHttp_js_1.StreamableHTTPClientTransport(new URL(url), {
82
+ requestInit: { headers: { Authorization: `Bearer ${nonce}` } },
83
+ });
84
+ const client = new index_js_1.Client({ name: 'test-engine', version: '0.0.1' });
85
+ return { client, transport };
86
+ }
87
+ async function callWhoami(url, nonce, probe) {
88
+ const { client, transport } = mcpClient(url, nonce);
89
+ try {
90
+ await client.connect(transport);
91
+ const res = await client.callTool({ name: 'whoami', arguments: probe ? { probe } : {} });
92
+ const content = res.content[0];
93
+ return JSON.parse(content.text);
94
+ }
95
+ finally {
96
+ await client.close().catch(() => undefined);
97
+ }
98
+ }
99
+ suite('data-plane: gateway + registry + broker end to end', function () {
100
+ this.timeout(30_000);
101
+ let plane;
102
+ suiteSetup(async () => {
103
+ fs.mkdirSync(FIXTURE_DIR, { recursive: true });
104
+ fs.writeFileSync(CONNECTOR_PATH, CONNECTOR_SOURCE, 'utf8');
105
+ plane = await data_plane_1.DataPlane.start({ connectorPath: CONNECTOR_PATH, provider: 'passthrough' });
106
+ });
107
+ suiteTeardown(async () => {
108
+ await plane?.close();
109
+ fs.rmSync(FIXTURE_DIR, { recursive: true, force: true });
110
+ });
111
+ test('gateway binds loopback only', () => {
112
+ assert.ok(plane.url.startsWith('http://127.0.0.1:'), plane.url);
113
+ });
114
+ test('a live nonce lists and calls tools as the registered user, credential selected server-side', async () => {
115
+ const turn = plane.registerTurn({ userId: 'alice@corp.com', sessionKey: 's1', inboundToken: 'alice-obo-token' });
116
+ try {
117
+ const { client, transport } = mcpClient(plane.url, turn.nonce);
118
+ await client.connect(transport);
119
+ const tools = await client.listTools();
120
+ assert.deepStrictEqual(tools.tools.map((t) => t.name), ['whoami']);
121
+ await client.close();
122
+ const who = await callWhoami(plane.url, turn.nonce);
123
+ assert.strictEqual(who.userId, 'alice@corp.com');
124
+ assert.strictEqual(who.credential, 'alice-obo-token', 'the broker must hand the connector the turn user\'s credential');
125
+ }
126
+ finally {
127
+ turn.release();
128
+ }
129
+ });
130
+ test('the credential never enters the connector process env — it rides only the per-call pipe', async () => {
131
+ const turn = plane.registerTurn({ userId: 'alice@corp.com', sessionKey: 's1', inboundToken: 'alice-obo-token' });
132
+ try {
133
+ const who = await callWhoami(plane.url, turn.nonce, 'alice-obo-token');
134
+ assert.strictEqual(who.envContainsProbe, false, 'raw credential must not appear anywhere in the connector env');
135
+ }
136
+ finally {
137
+ turn.release();
138
+ }
139
+ });
140
+ test('two concurrent turns each resolve their OWN user + credential (the §9.1 concurrency property)', async () => {
141
+ const alice = plane.registerTurn({ userId: 'alice@corp.com', sessionKey: 'sa', inboundToken: 'tok-alice' });
142
+ const bob = plane.registerTurn({ userId: 'bob@corp.com', sessionKey: 'sb', inboundToken: 'tok-bob' });
143
+ try {
144
+ const [a, b] = await Promise.all([
145
+ callWhoami(plane.url, alice.nonce),
146
+ callWhoami(plane.url, bob.nonce),
147
+ ]);
148
+ assert.strictEqual(a.userId, 'alice@corp.com');
149
+ assert.strictEqual(a.credential, 'tok-alice');
150
+ assert.strictEqual(b.userId, 'bob@corp.com');
151
+ assert.strictEqual(b.credential, 'tok-bob');
152
+ }
153
+ finally {
154
+ alice.release();
155
+ bob.release();
156
+ }
157
+ });
158
+ test('missing / unknown nonce → 401, fail closed', async () => {
159
+ await assert.rejects(() => callWhoami(plane.url, 'not-a-real-nonce'), /401|Unauthorized/i);
160
+ });
161
+ test('a released (turn-ended) nonce → 401 — a stale engine fails closed (§3.2)', async () => {
162
+ const turn = plane.registerTurn({ userId: 'alice@corp.com', sessionKey: 's1', inboundToken: 't' });
163
+ const nonce = turn.nonce;
164
+ const who = await callWhoami(plane.url, nonce);
165
+ assert.strictEqual(who.userId, 'alice@corp.com');
166
+ turn.release();
167
+ await assert.rejects(() => callWhoami(plane.url, nonce), /401|Unauthorized/i);
168
+ });
169
+ test('an expired deadline → 401 even if release() was never called (stranded-nonce backstop)', async () => {
170
+ const { nonce } = plane.registry.register({
171
+ userId: 'alice@corp.com',
172
+ sessionKey: 's1',
173
+ inboundToken: 't',
174
+ deadline: Date.now() - 1,
175
+ });
176
+ await assert.rejects(() => callWhoami(plane.url, nonce), /401|Unauthorized/i);
177
+ });
178
+ test('passthrough provider with no inbound token → the TOOL CALL errors, fail closed, turn itself unaffected', async () => {
179
+ const turn = plane.registerTurn({ userId: 'peer:telegram:123', sessionKey: 's2' });
180
+ try {
181
+ const { client, transport } = mcpClient(plane.url, turn.nonce);
182
+ await client.connect(transport);
183
+ const res = await client.callTool({ name: 'whoami', arguments: {} });
184
+ assert.strictEqual(res.isError, true, 'no inbound credential must fail the call closed');
185
+ const text = res.content[0].text;
186
+ assert.match(text, /fail-closed/);
187
+ await client.close();
188
+ }
189
+ finally {
190
+ turn.release();
191
+ }
192
+ });
193
+ test('a token-less turn fails closed even RIGHT AFTER the same user ran a turn WITH a token (no cache bridging)', async () => {
194
+ // Passthrough gets the platform token on every legitimate request; a broker cache
195
+ // that outlives the turn would let broken forwarding silently reuse the previous
196
+ // token for the cache TTL — the exact silent-fallback failure the provider forbids.
197
+ const withToken = plane.registerTurn({ userId: 'u@corp.com', sessionKey: 's3', inboundToken: 'tok-1' });
198
+ try {
199
+ const ok = await callWhoami(plane.url, withToken.nonce);
200
+ assert.strictEqual(ok.credential, 'tok-1');
201
+ }
202
+ finally {
203
+ withToken.release();
204
+ }
205
+ const withoutToken = plane.registerTurn({ userId: 'u@corp.com', sessionKey: 's3' });
206
+ try {
207
+ const { client, transport } = mcpClient(plane.url, withoutToken.nonce);
208
+ await client.connect(transport);
209
+ const res = await client.callTool({ name: 'whoami', arguments: {} });
210
+ assert.strictEqual(res.isError, true, 'the cached tok-1 must not serve a token-less turn');
211
+ await client.close();
212
+ }
213
+ finally {
214
+ withoutToken.release();
215
+ }
216
+ });
217
+ });
218
+ suite('data-plane: TurnRegistry unit properties', () => {
219
+ test('raw nonces are never map keys — lookups go through sha256', () => {
220
+ const reg = new data_plane_1.TurnRegistry();
221
+ const { nonce } = reg.register({ userId: 'u', sessionKey: 's' });
222
+ assert.ok(reg.resolve(nonce));
223
+ // resolve by the HASH must fail — only the raw nonce authenticates.
224
+ assert.strictEqual(reg.resolve((0, data_plane_1.hashNonce)(nonce)), undefined);
225
+ });
226
+ test('release is idempotent and deletes exactly its own entry', () => {
227
+ const reg = new data_plane_1.TurnRegistry();
228
+ const a = reg.register({ userId: 'a', sessionKey: 's' });
229
+ const b = reg.register({ userId: 'b', sessionKey: 's' });
230
+ a.release();
231
+ a.release();
232
+ assert.strictEqual(reg.resolve(a.nonce), undefined);
233
+ assert.strictEqual(reg.resolve(b.nonce)?.userId, 'b');
234
+ b.release();
235
+ assert.strictEqual(reg.size, 0);
236
+ });
237
+ test('expired entries are pruned on register', () => {
238
+ const reg = new data_plane_1.TurnRegistry();
239
+ reg.register({ userId: 'a', sessionKey: 's', deadline: Date.now() - 1 });
240
+ assert.strictEqual(reg.size, 1);
241
+ reg.register({ userId: 'b', sessionKey: 's' });
242
+ assert.strictEqual(reg.size, 1, 'the expired entry must have been pruned');
243
+ });
244
+ });
@@ -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',
@@ -160,6 +162,43 @@ suite('deploy-init: renderTarget produces well-formed artifacts', () => {
160
162
  assert.ok(files['README.md'], `${t} missing README.md`);
161
163
  }
162
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
+ });
163
202
  test('image-based targets emit a valid 5-line-style Dockerfile referencing the runtime image', () => {
164
203
  for (const t of ['vm', 'compose', 'cloudrun', 'spcs', 'cloudflare']) {
165
204
  const df = (0, deploy_init_1.renderTarget)(t, CTX)['Dockerfile'];
@@ -182,9 +221,13 @@ suite('deploy-init: renderTarget produces well-formed artifacts', () => {
182
221
  // Ship only deploy/www/ from deploy/ (drops deploy/.env, credentials, build artifacts).
183
222
  assert.ok(di.includes('deploy/*'), `${t} should exclude deploy/*`);
184
223
  assert.ok(di.includes('!deploy/www/**'), `${t} should re-include deploy/www/**`);
185
- // 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).
186
226
  assert.ok(di.includes('**/.env'), `${t} should ignore **/.env`);
187
- 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`);
188
231
  // skills/, scripts/, references/ must NOT be ignored lines.
189
232
  const ignored = di.split('\n').map((l) => l.trim()).filter((l) => l && !l.startsWith('#'));
190
233
  for (const keep of ['skills/', 'scripts/', 'references/', 'skills', 'scripts', 'references']) {
@@ -310,6 +353,30 @@ suite('deploy-init: renderTarget produces well-formed artifacts', () => {
310
353
  assert.ok(files['.env.auth.example'].includes('OAUTH2_PROXY_CLIENT_ID') && files['.env.auth.example'].includes('AGENTHIPPO_AUTH_JWT_SECRET'));
311
354
  // Note: service.yaml is a sed-template (${SERVICE}/${IMAGE}/${PUBLIC_HOSTNAME}/…), so it is
312
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');
313
380
  });
314
381
  test('cloudrun --ingress edge-fed (A′) verifies the shared JWT — no oauth2-proxy, no per-service OAuth', () => {
315
382
  const files = (0, deploy_init_1.renderTarget)('cloudrun', {
@@ -361,6 +428,7 @@ suite('deploy-init: renderTarget produces well-formed artifacts', () => {
361
428
  .replace(/\$\{PUBLIC_HOSTNAME\}/g, 'cs.agenthippo.ai')
362
429
  .replace(/\$\{ALLOWED_DOMAINS\}/g, 'agenthippo.ai');
363
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');
364
432
  });
365
433
  test('renderAuthEnvExample fed variant has no Google OAuth client', () => {
366
434
  const out = (0, deploy_init_1.renderAuthEnvExample)({ hostname: 'cs.agenthippo.ai', fed: true, idpUrl: 'https://auth.agenthippo.ai', cookieDomain: '.agenthippo.ai' });
@@ -416,12 +484,98 @@ suite('deploy-init: renderTarget produces well-formed artifacts', () => {
416
484
  test('databricks-app pins AGENTHIPPO_IDENTITY_HEADER to x-forwarded-email', () => {
417
485
  const start = (0, deploy_init_1.renderTarget)('databricks-app', CTX)['start.js'];
418
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');
419
489
  });
420
490
  test('spcs egress is limited to the model API via one EAI', () => {
421
491
  const sql = (0, deploy_init_1.renderTarget)('spcs', CTX)['deploy.sql'];
422
492
  assert.ok(sql.includes('api.anthropic.com:443'));
423
493
  assert.ok(/EXTERNAL ACCESS INTEGRATION/.test(sql));
424
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
+ });
425
579
  test('vm target includes edge compose profile; agent has no SSO-bypass host port', () => {
426
580
  const files = (0, deploy_init_1.renderTarget)('vm', CTX);
427
581
  const compose = files['docker-compose.yml'];
@@ -468,6 +622,65 @@ suite('deploy-init: renderTarget produces well-formed artifacts', () => {
468
622
  assert.ok(files['Caddyfile']);
469
623
  assert.ok(files['.env.auth.example'].includes('OAUTH2_PROXY_PROVIDER'));
470
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
+ });
471
684
  test('agent service does not load .env.auth; broker + oauth2-proxy do', () => {
472
685
  const compose = (0, deploy_init_1.renderTarget)('compose', CTX)['docker-compose.yml'];
473
686
  // The agent runs model-driven tool calls — it must never receive the token-signing key.
@@ -483,7 +696,7 @@ suite('deploy-init: deployCommand stages a self-contained pack', () => {
483
696
  setup(() => {
484
697
  pack = fs.mkdtempSync(path.join(os.tmpdir(), 'ah-pack-'));
485
698
  staging = fs.mkdtempSync(path.join(os.tmpdir(), 'ah-stage-'));
486
- 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');
487
700
  fs.writeFileSync(path.join(pack, 'AGENTS.md'), '# cs-test\n');
488
701
  fs.mkdirSync(path.join(pack, 'skills', 'demo', 'scripts'), { recursive: true });
489
702
  fs.writeFileSync(path.join(pack, 'skills', 'demo', 'scripts', 'run.sh'), 'echo hi\n');
@@ -493,6 +706,8 @@ suite('deploy-init: deployCommand stages a self-contained pack', () => {
493
706
  fs.writeFileSync(path.join(pack, 'node_modules', 'foo', 'index.js'), 'x');
494
707
  fs.writeFileSync(path.join(pack, '.env'), 'SECRET=1\n');
495
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');
496
711
  fs.mkdirSync(path.join(pack, 'deploy'), { recursive: true });
497
712
  fs.writeFileSync(path.join(pack, 'deploy', 'leftover.yaml'), 'stale\n');
498
713
  });
@@ -510,6 +725,8 @@ suite('deploy-init: deployCommand stages a self-contained pack', () => {
510
725
  assert.ok(!fs.existsSync(path.join(staging, 'node_modules')), 'node_modules must not be copied into staging');
511
726
  assert.ok(!fs.existsSync(path.join(staging, '.env')), '.env must not be copied into staging');
512
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');
513
730
  // The source pack's own deploy/ is not copied (the generator owns staging/deploy/).
514
731
  assert.ok(!fs.existsSync(path.join(staging, 'deploy', 'leftover.yaml')), 'source deploy/ must not be copied');
515
732
  // Generated artifacts under staging/deploy/.
@@ -529,6 +746,25 @@ suite('deploy-init: deployCommand stages a self-contained pack', () => {
529
746
  assert.ok(fs.existsSync(path.join(staging, 'agent.yaml')), 'packdir flag resolved the pack');
530
747
  assert.ok(fs.existsSync(path.join(staging, 'deploy', 'Dockerfile')), 'artifacts generated from packdir pack');
531
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
+ });
532
768
  });
533
769
  suite('deploy-init: copyPackToStaging dereferences symlinks + skips broken ones', () => {
534
770
  let pack;
@@ -536,7 +772,7 @@ suite('deploy-init: copyPackToStaging dereferences symlinks + skips broken ones'
536
772
  setup(() => {
537
773
  pack = fs.mkdtempSync(path.join(os.tmpdir(), 'ah-pack-'));
538
774
  staging = fs.mkdtempSync(path.join(os.tmpdir(), 'ah-stage-'));
539
- 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');
540
776
  // Real skill content + a shared file the skill links to (relative symlink → resolved on copy).
541
777
  fs.mkdirSync(path.join(pack, 'skills', 'demo'), { recursive: true });
542
778
  fs.writeFileSync(path.join(pack, 'skills', 'demo', 'REAL.md'), '# real content\n');
@@ -561,3 +797,18 @@ suite('deploy-init: copyPackToStaging dereferences symlinks + skips broken ones'
561
797
  assert.ok(broken.includes('skills/broken/SKILL.md'), 'broken symlink reported in returned list');
562
798
  });
563
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
+ });