@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,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',
@@ -0,0 +1,151 @@
1
+ "use strict";
2
+ /*---------------------------------------------------------------------------------------------
3
+ * IdP Init Test Suite (PLAN/deploy.md — "customer 0")
4
+ *
5
+ * Verifies `agenthippo idp init` generates a self-contained stack: no repo dependency in the
6
+ * auth-broker image, identity routes reused verbatim from renderIdentityLayer (no drift risk),
7
+ * and valid YAML/bash throughout.
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 os = __importStar(require("os"));
47
+ const path = __importStar(require("path"));
48
+ const child_process_1 = require("child_process");
49
+ const yaml = __importStar(require("js-yaml"));
50
+ const idp_init_1 = require("../cli/idp-init");
51
+ const idp_1 = require("../cli/commands/idp");
52
+ suite('idp-init: renderIdpArtifacts', () => {
53
+ test('generates a self-contained stack — no monorepo build context', () => {
54
+ const files = (0, idp_init_1.renderIdpArtifacts)({ hostname: 'auth.corp.example' });
55
+ assert.ok(files['Dockerfile.auth-broker'].includes('FROM ghcr.io/agenthippoai/runtime'), 'auth-broker image must come from the published runtime image, not repo build artifacts');
56
+ assert.ok(!files['Dockerfile.auth-broker'].includes('extensions/agentide/dist'), 'must not reference this repo\'s local build output — a customer never has it');
57
+ assert.ok(files['docker-compose.yml'].includes('dockerfile: Dockerfile.auth-broker')
58
+ && files['docker-compose.yml'].includes('context: .'), 'auth-broker builds from the generated dir itself, not a monorepo path');
59
+ });
60
+ test('docker-compose.yml is valid YAML with the expected services and networks', () => {
61
+ const files = (0, idp_init_1.renderIdpArtifacts)({ hostname: 'auth.corp.example' });
62
+ const compose = yaml.load(files['docker-compose.yml']);
63
+ assert.deepStrictEqual(Object.keys(compose.services), ['oauth2-proxy', 'auth-broker', 'caddy', 'cloudflared']);
64
+ assert.ok(!('agent' in compose.services), 'the IdP runs no agent');
65
+ assert.deepStrictEqual(Object.keys(compose.networks), ['default', 'colocate']);
66
+ assert.ok(files['docker-compose.yml'].includes('name: agenthippo-idp-colocate'));
67
+ });
68
+ test('Caddyfile reuses renderIdentityLayer verbatim — self-issued, no agent backend', () => {
69
+ const files = (0, idp_init_1.renderIdpArtifacts)({ hostname: 'auth.corp.example' });
70
+ const caddy = files['Caddyfile'];
71
+ assert.ok(caddy.includes('import agents/*.caddy'), 'colocated agents import in');
72
+ assert.ok(caddy.includes('handle /oauth/authorize {') && caddy.includes('handle /oauth/browser-login {'));
73
+ assert.ok(caddy.includes('forward_auth oauth2-proxy:4180'), 'self-issued: gates via oauth2-proxy');
74
+ assert.ok(caddy.includes('reverse_proxy auth-broker:9780'), 'health checks the broker, not an agent');
75
+ assert.ok(caddy.includes('respond "not_found" 404'), 'no /v1/* API here — Bearer requests 404, not silently passed through');
76
+ assert.ok(caddy.includes('respond "AgentHippo IdP" 200'), 'browser catch-all, past the SSO gate');
77
+ assert.ok(caddy.includes('http://{$AGENTHIPPO_PUBLIC_HOSTNAME} {') && !caddy.includes('https://{$AGENTHIPPO_PUBLIC_HOSTNAME}'), 'plain HTTP — auto_https is off, TLS terminates in front');
78
+ });
79
+ test('.env.auth.example carries the shared JWT secret + Google OAuth client, no fed vars', () => {
80
+ const files = (0, idp_init_1.renderIdpArtifacts)({ hostname: 'auth.corp.example' });
81
+ const auth = files['.env.auth.example'];
82
+ assert.ok(auth.includes('AGENTHIPPO_AUTH_JWT_SECRET') && auth.includes('OAUTH2_PROXY_CLIENT_ID'));
83
+ assert.ok(!auth.includes('AGENTHIPPO_AUTH_IDP_URL'), 'the IdP is not itself federated against anything');
84
+ });
85
+ test('cookie domain defaults to the hostname\'s own parent domain when not given explicitly', () => {
86
+ const files = (0, idp_init_1.renderIdpArtifacts)({ hostname: 'auth.corp.example' });
87
+ assert.ok(files['.env.auth.example'].includes('AGENTHIPPO_AUTH_COOKIE_DOMAIN=.corp.example'));
88
+ });
89
+ test('explicit cookieDomain overrides the derived default', () => {
90
+ const files = (0, idp_init_1.renderIdpArtifacts)({ hostname: 'auth.eu.corp.example', cookieDomain: '.corp.example' });
91
+ assert.ok(files['.env.auth.example'].includes('AGENTHIPPO_AUTH_COOKIE_DOMAIN=.corp.example'));
92
+ });
93
+ test('run.sh is valid bash and reflects the hostname', () => {
94
+ const files = (0, idp_init_1.renderIdpArtifacts)({ hostname: 'auth.corp.example' });
95
+ assert.doesNotThrow(() => (0, child_process_1.execFileSync)('bash', ['-n', '-c', files['run.sh']]));
96
+ assert.ok(files['run.sh'].includes('auth.corp.example'));
97
+ });
98
+ // Bare metal / BYO nginx or ALB (e.g. Hetzner): run.sh must not hard-require a Cloudflare
99
+ // Tunnel, and Caddy's host port must be reconfigurable when 80/443 are already taken by the
100
+ // operator's own reverse proxy.
101
+ test('run.sh branches on an explicit tunnel-credentials path argument, not a fixed file location', () => {
102
+ const sh = (0, idp_init_1.renderIdpArtifacts)({ hostname: 'auth.corp.example' })['run.sh'];
103
+ assert.ok(sh.includes('if [[ $# -ge 1 ]]; then'), 'branches on whether an argument was passed');
104
+ assert.ok(!sh.includes('-f "$HOME/.agent-hippo/tunnel-credentials.json"'), 'must not infer tunnel mode from a fixed default file path');
105
+ assert.ok(sh.includes('AGENTHIPPO_TUNNEL_CREDENTIALS="$1"'), 'the argument becomes the credentials path');
106
+ assert.ok(sh.includes('docker compose --profile edge --profile tunnel up -d --build'));
107
+ assert.ok(sh.includes('docker compose --profile edge up -d --build'), 'no-argument branch: --profile edge only, no --profile tunnel');
108
+ });
109
+ test('run.sh fails loudly if a given tunnel-credentials path doesn\'t exist', () => {
110
+ const sh = (0, idp_init_1.renderIdpArtifacts)({ hostname: 'auth.corp.example' })['run.sh'];
111
+ assert.ok(sh.includes('[[ -f "$AGENTHIPPO_TUNNEL_CREDENTIALS" ]] || {'));
112
+ assert.ok(sh.includes('exit 1'));
113
+ });
114
+ test('docker-compose.yml wires the cloudflared credentials mount to AGENTHIPPO_TUNNEL_CREDENTIALS', () => {
115
+ const compose = (0, idp_init_1.renderIdpArtifacts)({ hostname: 'auth.corp.example' })['docker-compose.yml'];
116
+ assert.ok(compose.includes('${AGENTHIPPO_TUNNEL_CREDENTIALS:-${HOME}/.agent-hippo/tunnel-credentials.json}'));
117
+ });
118
+ test('Caddy host ports are configurable via AGENTHIPPO_CADDY_HTTP_PORT/_HTTPS_PORT (Hetzner: 80/443 may already be taken)', () => {
119
+ const compose = (0, idp_init_1.renderIdpArtifacts)({ hostname: 'auth.corp.example' })['docker-compose.yml'];
120
+ assert.ok(compose.includes('${AGENTHIPPO_CADDY_HTTP_PORT:-80}:80'));
121
+ assert.ok(compose.includes('${AGENTHIPPO_CADDY_HTTPS_PORT:-443}:443'));
122
+ });
123
+ test('README documents both the Cloudflare Tunnel path and the bare-metal/BYO-nginx path', () => {
124
+ const readme = (0, idp_init_1.renderIdpArtifacts)({ hostname: 'auth.corp.example' })['README.md'];
125
+ assert.ok(readme.includes('Cloudflare Tunnel'));
126
+ assert.ok(readme.includes('Bare metal') || readme.includes('BYO nginx'));
127
+ });
128
+ });
129
+ suite('idp-init: idpCommand writes files to disk', () => {
130
+ let outDir;
131
+ setup(() => {
132
+ outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ah-idp-'));
133
+ });
134
+ teardown(() => {
135
+ fs.rmSync(outDir, { recursive: true, force: true });
136
+ });
137
+ test('writes every generated file under --out', async () => {
138
+ await (0, idp_1.idpCommand)('init', { hostname: 'auth.corp.example', out: outDir });
139
+ for (const rel of ['docker-compose.yml', 'Dockerfile.auth-broker', 'Caddyfile', '.env.example', '.env.auth.example', 'run.sh', 'agents/README.md', 'README.md']) {
140
+ assert.ok(fs.existsSync(path.join(outDir, rel)), `${rel} should be written`);
141
+ }
142
+ });
143
+ test('refuses to overwrite existing files without --force', async () => {
144
+ await (0, idp_1.idpCommand)('init', { hostname: 'auth.corp.example', out: outDir });
145
+ fs.writeFileSync(path.join(outDir, 'README.md'), 'hand-edited\n');
146
+ await (0, idp_1.idpCommand)('init', { hostname: 'auth.corp.example', out: outDir });
147
+ assert.strictEqual(fs.readFileSync(path.join(outDir, 'README.md'), 'utf8'), 'hand-edited\n');
148
+ await (0, idp_1.idpCommand)('init', { hostname: 'auth.corp.example', out: outDir, force: true });
149
+ assert.notStrictEqual(fs.readFileSync(path.join(outDir, 'README.md'), 'utf8'), 'hand-edited\n');
150
+ });
151
+ });
@@ -34,49 +34,74 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  const assert = __importStar(require("assert"));
37
- const node_fs_1 = require("node:fs");
38
- const node_os_1 = require("node:os");
39
- const node_path_1 = require("node:path");
40
37
  const credential_context_1 = require("../cli/credential-context");
41
38
  const mcp_1 = require("../mcp");
42
- suite('mcp: OBO token in MCP env (Design B §11.1)', () => {
43
- const orig = process.env.DATABRICKS_TOKEN;
44
- let tokenDir;
39
+ /**
40
+ * The per-turn OBO credential must never reach MCP child env implicitly: the supported
41
+ * transport is the loopback data-plane gateway + turn nonce (secure-obo-design §2/§3).
42
+ * Inline resolution exists only behind the explicit local-dev opt-in.
43
+ */
44
+ suite('mcp: OBO token in MCP env (secure-obo-design §3.4 — inline is opt-in only)', () => {
45
+ const ENV_KEYS = ['DATABRICKS_TOKEN', 'AGENTHIPPO_DATA_TOKEN_ENV_VARS', mcp_1.ALLOW_INLINE_DATA_TOKEN_ENV];
46
+ const saved = {};
45
47
  setup(() => {
46
- tokenDir = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), 'mcp-obo-'));
48
+ for (const k of ENV_KEYS) {
49
+ saved[k] = process.env[k];
50
+ delete process.env[k];
51
+ }
47
52
  });
48
53
  teardown(() => {
49
- if (orig === undefined) {
50
- delete process.env.DATABRICKS_TOKEN;
51
- }
52
- else {
53
- process.env.DATABRICKS_TOKEN = orig;
54
+ for (const k of ENV_KEYS) {
55
+ if (saved[k] === undefined) {
56
+ delete process.env[k];
57
+ }
58
+ else {
59
+ process.env[k] = saved[k];
60
+ }
54
61
  }
55
- (0, node_fs_1.rmSync)(tokenDir, { recursive: true, force: true });
56
62
  });
57
- test('static PAT when no credential scope', () => {
63
+ test('static PAT resolves from plain env when no credential scope', () => {
58
64
  process.env.DATABRICKS_TOKEN = 'pat-static';
59
65
  assert.strictEqual((0, mcp_1.resolveMcpEnvVar)('DATABRICKS_TOKEN'), 'pat-static');
60
66
  });
61
- test('OBO token overrides static PAT inside runWithCredential (inline fallback)', async () => {
62
- process.env.DATABRICKS_TOKEN = 'pat-static';
67
+ test('WITHOUT the opt-in, a live OBO token never bakes into MCP child env', async () => {
68
+ process.env.AGENTHIPPO_DATA_TOKEN_ENV_VARS = 'DATABRICKS_TOKEN';
63
69
  await (0, credential_context_1.runWithCredential)({ userId: 'u@x.com', dataToken: 'obo-live' }, async () => {
64
- assert.strictEqual((0, mcp_1.resolveMcpEnvVar)('DATABRICKS_TOKEN'), 'obo-live');
65
- });
66
- });
67
- test('credential placeholder resolves to token file for stdio MCP', async () => {
68
- await (0, credential_context_1.runWithCredential)({ userId: 'u@x.com', dataToken: 'obo-file-token' }, async () => {
70
+ // alias reference falls back to ambient env (unset here → empty), never the credential
71
+ assert.strictEqual((0, mcp_1.resolveMcpEnvVar)('DATABRICKS_TOKEN'), '');
72
+ // explicit placeholder resolves to nothing → the connector fails closed
73
+ assert.strictEqual((0, mcp_1.resolveMcpEnvVar)('dataToken'), '');
69
74
  const resolved = (0, mcp_1.resolveMcpServerEnv)({
70
75
  DATABRICKS_HOST: 'dbc.example.com',
71
76
  DATABRICKS_TOKEN: '${credential:dataToken}',
72
77
  });
73
78
  assert.ok(resolved);
74
79
  assert.strictEqual(resolved.DATABRICKS_HOST, 'dbc.example.com');
75
- assert.strictEqual(resolved.DATABRICKS_TOKEN, undefined);
76
- const file = resolved[mcp_1.AGENTHIPPO_DATA_TOKEN_FILE_ENV];
77
- assert.ok(file);
78
- assert.strictEqual((0, node_fs_1.readFileSync)(file, 'utf8'), 'obo-file-token');
79
- assert.strictEqual((0, mcp_1.resolveMcpEnvVar)('DATABRICKS_TOKEN'), '');
80
- }, { dataTokenDir: tokenDir });
80
+ assert.strictEqual(resolved.DATABRICKS_TOKEN, undefined, 'credential must not appear in spawn env without the opt-in');
81
+ assert.ok(!JSON.stringify(resolved).includes('obo-live'), 'the raw token must not leak anywhere in the resolved env');
82
+ });
83
+ });
84
+ test('WITH the explicit local-dev opt-in, inline resolution works (alias + placeholder)', async () => {
85
+ process.env[mcp_1.ALLOW_INLINE_DATA_TOKEN_ENV] = '1';
86
+ process.env.AGENTHIPPO_DATA_TOKEN_ENV_VARS = 'DATABRICKS_TOKEN';
87
+ process.env.DATABRICKS_TOKEN = 'pat-static';
88
+ await (0, credential_context_1.runWithCredential)({ userId: 'u@x.com', dataToken: 'obo-live' }, async () => {
89
+ assert.strictEqual((0, mcp_1.resolveMcpEnvVar)('DATABRICKS_TOKEN'), 'obo-live', 'OBO token overrides static PAT when opted in');
90
+ const resolved = (0, mcp_1.resolveMcpServerEnv)({ DATABRICKS_TOKEN: '${credential:dataToken}' });
91
+ assert.strictEqual(resolved.DATABRICKS_TOKEN, 'obo-live');
92
+ });
93
+ });
94
+ test('alias list is empty by default — DATABRICKS_TOKEN is no longer an implicit credential alias', async () => {
95
+ process.env[mcp_1.ALLOW_INLINE_DATA_TOKEN_ENV] = '1';
96
+ process.env.DATABRICKS_TOKEN = 'pat-static';
97
+ await (0, credential_context_1.runWithCredential)({ userId: 'u@x.com', dataToken: 'obo-live' }, async () => {
98
+ // No AGENTHIPPO_DATA_TOKEN_ENV_VARS declared → the plain env var wins even opted-in.
99
+ assert.strictEqual((0, mcp_1.resolveMcpEnvVar)('DATABRICKS_TOKEN'), 'pat-static');
100
+ });
101
+ });
102
+ test('no credential scope + placeholder → falls back to ambient env for local dev', () => {
103
+ process.env.DATABRICKS_TOKEN = 'pat-static';
104
+ const resolved = (0, mcp_1.resolveMcpServerEnv)({ DATABRICKS_TOKEN: '${credential:dataToken}' });
105
+ assert.strictEqual(resolved.DATABRICKS_TOKEN, 'pat-static');
81
106
  });
82
107
  });
@@ -43,6 +43,7 @@ const os = __importStar(require("os"));
43
43
  const fs_1 = require("fs");
44
44
  const mcp_1 = require("../mcp");
45
45
  const branding_1 = require("../branding");
46
+ const credential_context_1 = require("../cli/credential-context");
46
47
  suite('MCP Server Loading', () => {
47
48
  let testDir;
48
49
  const getAgentMcpDir = (agentId) => path.join(testDir, branding_1.AGENTIDE_DIR_NAME, 'agents', agentId, 'mcp');
@@ -245,16 +246,19 @@ suite('MCP Server Loading', () => {
245
246
  // A bare (unquoted) dotted key would be misparsed as a nested table.
246
247
  assert.ok(!result.includes('my.token = '));
247
248
  });
248
- test('autoApprove emits default_tools_approval_mode = "auto" per server', () => {
249
+ test('autoApprove emits default_tools_approval_mode = "approve" per server', () => {
249
250
  const servers = [
250
251
  { name: 'stdio-srv', label: 's', command: 'node', args: ['s.js'] },
251
252
  { name: 'http-srv', label: 'h', command: '', args: [], url: 'https://mcp.example.com/sse' },
252
253
  ];
253
254
  const result = (0, mcp_1.generateCodexMcpConfig)(servers, { autoApprove: true });
254
- assert.ok(result.includes('default_tools_approval_mode = "auto"'));
255
+ assert.ok(result.includes('default_tools_approval_mode = "approve"'));
255
256
  assert.ok(result.includes('url = "https://mcp.example.com/sse"'));
256
- // Invalid legacy key/value must never be emitted.
257
+ // Invalid legacy key/value must never be emitted. "auto" is not a valid enum value
258
+ // either — Codex ≥0.131 ignores it, falls back to `prompt`, and headless exec then
259
+ // cancels every MCP tool call ("user cancelled MCP tool call").
257
260
  assert.ok(!result.includes('approval_mode = "never"'));
261
+ assert.ok(!result.includes('"auto"'));
258
262
  });
259
263
  test('generateCodexMcpConfig emits http_headers for URL servers', () => {
260
264
  const servers = [
@@ -271,6 +275,27 @@ suite('MCP Server Loading', () => {
271
275
  assert.ok(result.includes('url = "https://mcp.example.com/sse"'));
272
276
  assert.ok(result.includes('http_headers = { "Authorization" = "Bearer abc", "X-Custom" = "val" }'));
273
277
  });
278
+ test('bearerTokenEnv emits bearer_token_env_var and NEVER a per-turn value — config.toml stays turn-invariant (secure-obo-design §3.2)', async () => {
279
+ const servers = [
280
+ {
281
+ name: 'data',
282
+ label: 'data',
283
+ command: '',
284
+ args: [],
285
+ url: 'http://127.0.0.1:39999/mcp',
286
+ bearerTokenEnv: 'AGENTHIPPO_TURN_NONCE',
287
+ },
288
+ ];
289
+ const outsideScope = (0, mcp_1.generateCodexMcpConfig)(servers);
290
+ assert.ok(outsideScope.includes('bearer_token_env_var = "AGENTHIPPO_TURN_NONCE"'));
291
+ assert.ok(!outsideScope.includes('http_headers'), 'no resolved header may carry the nonce into the file');
292
+ // Byte-identical no matter which user's credential scope is ambient — this is the
293
+ // exact property that removes the cross-user config.toml race (defect 1).
294
+ const inAlice = await (0, credential_context_1.runWithCredential)({ userId: 'alice@corp.com', dataToken: 'tok-a' }, async () => (0, mcp_1.generateCodexMcpConfig)(servers));
295
+ const inBob = await (0, credential_context_1.runWithCredential)({ userId: 'bob@corp.com', dataToken: 'tok-b' }, async () => (0, mcp_1.generateCodexMcpConfig)(servers));
296
+ assert.strictEqual(inAlice, outsideScope);
297
+ assert.strictEqual(inBob, outsideScope);
298
+ });
274
299
  });
275
300
  suite('inline MCP helpers', () => {
276
301
  test('formatInlineMcpServers maps resolved servers by name', () => {
@@ -306,6 +331,21 @@ suite('MCP Server Loading', () => {
306
331
  remote: { type: 'http', url: 'https://mcp.example.com/sse', headers: { Authorization: 'Bearer abc' } },
307
332
  });
308
333
  });
334
+ test('bearerTokenEnv resolves against the PER-TURN env into an Authorization header (claude/custom path)', () => {
335
+ const server = {
336
+ name: 'data', label: 'data', command: '', args: [],
337
+ url: 'http://127.0.0.1:39999/mcp', bearerTokenEnv: 'AGENTHIPPO_TURN_NONCE',
338
+ };
339
+ const inline = (0, mcp_1.formatInlineMcpServers)([server], { AGENTHIPPO_TURN_NONCE: 'nonce-123' });
340
+ assert.deepStrictEqual(inline, {
341
+ data: { type: 'http', url: 'http://127.0.0.1:39999/mcp', headers: { Authorization: 'Bearer nonce-123' } },
342
+ });
343
+ // No nonce in the turn env (e.g. IDE) → no Authorization header, entry still valid.
344
+ const withoutNonce = (0, mcp_1.formatInlineMcpServers)([server], {});
345
+ assert.deepStrictEqual(withoutNonce, {
346
+ data: { type: 'http', url: 'http://127.0.0.1:39999/mcp' },
347
+ });
348
+ });
309
349
  test('serialize and parse round-trip inline MCP', () => {
310
350
  const inline = (0, mcp_1.formatInlineMcpServers)([
311
351
  { name: 'echo', label: 'echo', command: 'echo', args: ['hi'] },