@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,133 @@
1
+ "use strict";
2
+ /*---------------------------------------------------------------------------------------------
3
+ * Model routing / credential selection tests.
4
+ *
5
+ * Guards the fix that replaced the provider-blind precedence
6
+ * (apiKey = litellm || anthropic || openai) with destination-based selection: the credential
7
+ * is chosen by the endpoint the engine dials, so a codex turn never authenticates to OpenAI
8
+ * with an Anthropic key (the Databricks 401), and a claude turn never uses the OpenAI key.
9
+ *--------------------------------------------------------------------------------------------*/
10
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
11
+ if (k2 === undefined) k2 = k;
12
+ var desc = Object.getOwnPropertyDescriptor(m, k);
13
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
14
+ desc = { enumerable: true, get: function() { return m[k]; } };
15
+ }
16
+ Object.defineProperty(o, k2, desc);
17
+ }) : (function(o, m, k, k2) {
18
+ if (k2 === undefined) k2 = k;
19
+ o[k2] = m[k];
20
+ }));
21
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
22
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
23
+ }) : function(o, v) {
24
+ o["default"] = v;
25
+ });
26
+ var __importStar = (this && this.__importStar) || (function () {
27
+ var ownKeys = function(o) {
28
+ ownKeys = Object.getOwnPropertyNames || function (o) {
29
+ var ar = [];
30
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
31
+ return ar;
32
+ };
33
+ return ownKeys(o);
34
+ };
35
+ return function (mod) {
36
+ if (mod && mod.__esModule) return mod;
37
+ var result = {};
38
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
39
+ __setModuleDefault(result, mod);
40
+ return result;
41
+ };
42
+ })();
43
+ Object.defineProperty(exports, "__esModule", { value: true });
44
+ require("mocha");
45
+ const assert = __importStar(require("assert"));
46
+ const utils_1 = require("../utils");
47
+ const pack_loader_1 = require("../cli/pack-loader");
48
+ const OPENAI = 'sk-openai-xxx';
49
+ const ANTHROPIC = 'sk-ant-yyy';
50
+ const LITELLM = 'sk-litellm-zzz';
51
+ function opts(over = {}) {
52
+ return { workspaceRoot: '/ws', ...over };
53
+ }
54
+ suite('resolveApiRouting: direct path selects the ENGINE\'s provider key (no collapse)', () => {
55
+ // The exact Databricks scenario: both direct keys present, no proxy configured.
56
+ const bothKeys = { openaiApiKey: OPENAI, anthropicApiKey: ANTHROPIC };
57
+ test('codex (openai) picks the OpenAI key even when an Anthropic key is also present', () => {
58
+ const r = (0, utils_1.resolveApiRouting)(opts({ useLiteLLM: false }), bothKeys, 'openai');
59
+ assert.strictEqual(r.apiKey, OPENAI);
60
+ assert.strictEqual(r.useLiteLLM, false);
61
+ });
62
+ test('claude (anthropic) picks the Anthropic key even when an OpenAI key is also present', () => {
63
+ const r = (0, utils_1.resolveApiRouting)(opts({ useLiteLLM: false }), bothKeys, 'anthropic');
64
+ assert.strictEqual(r.apiKey, ANTHROPIC);
65
+ });
66
+ test('the codex→OpenAI-with-Anthropic-key 401 is impossible now', () => {
67
+ const r = (0, utils_1.resolveApiRouting)(opts({ useLiteLLM: false }), bothKeys, 'openai');
68
+ assert.notStrictEqual(r.apiKey, ANTHROPIC, 'codex must never receive the Anthropic key');
69
+ });
70
+ test('missing provider key → empty (fail visibly), never the other provider\'s key', () => {
71
+ const onlyAnthropic = { anthropicApiKey: ANTHROPIC };
72
+ const r = (0, utils_1.resolveApiRouting)(opts({ useLiteLLM: false }), onlyAnthropic, 'openai');
73
+ assert.strictEqual(r.apiKey, '', 'codex with only an Anthropic key gets no key, not the wrong one');
74
+ });
75
+ });
76
+ suite('resolveApiRouting: LiteLLM path uses the proxy key for every engine', () => {
77
+ const creds = { litellmApiKey: LITELLM, litellmBaseUrl: 'http://proxy:4000', openaiApiKey: OPENAI, anthropicApiKey: ANTHROPIC };
78
+ test('codex through the proxy uses the LiteLLM key + base URL', () => {
79
+ const r = (0, utils_1.resolveApiRouting)(opts({ useLiteLLM: true }), creds, 'openai');
80
+ assert.strictEqual(r.apiKey, LITELLM);
81
+ assert.strictEqual(r.baseUrl, 'http://proxy:4000');
82
+ assert.strictEqual(r.useLiteLLM, true);
83
+ });
84
+ test('claude through the proxy also uses the LiteLLM key (one key routes all)', () => {
85
+ const r = (0, utils_1.resolveApiRouting)(opts({ useLiteLLM: true }), creds, 'anthropic');
86
+ assert.strictEqual(r.apiKey, LITELLM);
87
+ });
88
+ });
89
+ suite('resolveApiRouting: explicit options.apiKey (IDE single key) still wins', () => {
90
+ test('IDE explicit key overrides the provider fallback on the direct path', () => {
91
+ const r = (0, utils_1.resolveApiRouting)(opts({ useLiteLLM: false, apiKey: 'sk-ide-explicit' }), { openaiApiKey: OPENAI }, 'openai');
92
+ assert.strictEqual(r.apiKey, 'sk-ide-explicit');
93
+ });
94
+ test('IDE with only an explicit key and no creds still works (back-compat)', () => {
95
+ const r = (0, utils_1.resolveApiRouting)(opts({ useLiteLLM: false, apiKey: 'sk-ide-explicit' }), {}, 'anthropic');
96
+ assert.strictEqual(r.apiKey, 'sk-ide-explicit');
97
+ });
98
+ test('proxy path keeps the sk-dummy fallback when nothing is configured', () => {
99
+ const r = (0, utils_1.resolveApiRouting)(opts({ useLiteLLM: true }), {}, 'openai');
100
+ assert.strictEqual(r.apiKey, 'sk-dummy');
101
+ });
102
+ });
103
+ suite('inferModelProvider: custom engines pick provider from the model id', () => {
104
+ test('claude/anthropic model ids → anthropic', () => {
105
+ assert.strictEqual((0, utils_1.inferModelProvider)('claude-sonnet-4-5'), 'anthropic');
106
+ assert.strictEqual((0, utils_1.inferModelProvider)('litellm/claude-haiku-4-5'), 'anthropic');
107
+ assert.strictEqual((0, utils_1.inferModelProvider)('anthropic/claude-opus'), 'anthropic');
108
+ });
109
+ test('everything else → openai (the safe default for gpt/codex/unknown)', () => {
110
+ assert.strictEqual((0, utils_1.inferModelProvider)('gpt-5.5'), 'openai');
111
+ assert.strictEqual((0, utils_1.inferModelProvider)('litellm/gpt-5.4-nano'), 'openai');
112
+ assert.strictEqual((0, utils_1.inferModelProvider)(undefined), 'openai');
113
+ });
114
+ });
115
+ suite('hasCredentialForEngine: engine-aware boot check', () => {
116
+ const base = { apiKey: undefined, baseUrl: undefined, useLiteLLM: false, litellmApiKey: undefined, openaiApiKey: undefined, anthropicApiKey: undefined };
117
+ test('codex needs an OpenAI key; an Anthropic-only env fails the check', () => {
118
+ assert.strictEqual((0, pack_loader_1.hasCredentialForEngine)('codex', { ...base, anthropicApiKey: ANTHROPIC }), false);
119
+ assert.strictEqual((0, pack_loader_1.hasCredentialForEngine)('codex', { ...base, openaiApiKey: OPENAI }), true);
120
+ });
121
+ test('claude needs an Anthropic key; an OpenAI-only env fails the check', () => {
122
+ assert.strictEqual((0, pack_loader_1.hasCredentialForEngine)('claude', { ...base, openaiApiKey: OPENAI }), false);
123
+ assert.strictEqual((0, pack_loader_1.hasCredentialForEngine)('claude', { ...base, anthropicApiKey: ANTHROPIC }), true);
124
+ });
125
+ test('a configured LiteLLM proxy satisfies every engine', () => {
126
+ const proxy = { ...base, useLiteLLM: true, litellmApiKey: LITELLM, baseUrl: 'http://proxy:4000' };
127
+ assert.strictEqual((0, pack_loader_1.hasCredentialForEngine)('codex', proxy), true);
128
+ assert.strictEqual((0, pack_loader_1.hasCredentialForEngine)('claude', proxy), true);
129
+ });
130
+ test('custom engines are not gated here (they authenticate via their own manifest env var)', () => {
131
+ assert.strictEqual((0, pack_loader_1.hasCredentialForEngine)('openclaw', base), true);
132
+ });
133
+ });
@@ -0,0 +1,267 @@
1
+ "use strict";
2
+ /*---------------------------------------------------------------------------------------------
3
+ * Copyright (c) AgentHippo.ai. All rights reserved.
4
+ *--------------------------------------------------------------------------------------------*/
5
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
6
+ if (k2 === undefined) k2 = k;
7
+ var desc = Object.getOwnPropertyDescriptor(m, k);
8
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
9
+ desc = { enumerable: true, get: function() { return m[k]; } };
10
+ }
11
+ Object.defineProperty(o, k2, desc);
12
+ }) : (function(o, m, k, k2) {
13
+ if (k2 === undefined) k2 = k;
14
+ o[k2] = m[k];
15
+ }));
16
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
17
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
18
+ }) : function(o, v) {
19
+ o["default"] = v;
20
+ });
21
+ var __importStar = (this && this.__importStar) || (function () {
22
+ var ownKeys = function(o) {
23
+ ownKeys = Object.getOwnPropertyNames || function (o) {
24
+ var ar = [];
25
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
26
+ return ar;
27
+ };
28
+ return ownKeys(o);
29
+ };
30
+ return function (mod) {
31
+ if (mod && mod.__esModule) return mod;
32
+ var result = {};
33
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
34
+ __setModuleDefault(result, mod);
35
+ return result;
36
+ };
37
+ })();
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ /**
40
+ * runGatewayTurn Test Suite
41
+ *
42
+ * Gateway/channel-originated turns (Slack, etc.) previously bypassed both the
43
+ * identity-keyed RateLimiter and the per-session TurnLimiter entirely — only the
44
+ * capacity gate (TurnGate) was ever checked. This suite exercises the extracted
45
+ * admission sequence directly, without a live gateway connection.
46
+ */
47
+ require("mocha");
48
+ const assert = __importStar(require("assert"));
49
+ const serve_1 = require("../cli/commands/serve");
50
+ const serve_hardening_1 = require("../cli/serve-hardening");
51
+ function fakeEmitter() {
52
+ const texts = [];
53
+ return {
54
+ texts,
55
+ doneCalled: false,
56
+ async thinking() { },
57
+ async text(delta) { texts.push(delta); },
58
+ async toolStart() { },
59
+ async toolEnd() { },
60
+ async progress() { },
61
+ async question() { return { selected: [], freeText: null }; },
62
+ async error() { },
63
+ async done() { this.doneCalled = true; },
64
+ };
65
+ }
66
+ function baseParams(overrides = {}) {
67
+ const noop = () => { };
68
+ return {
69
+ rateLimiter: new serve_hardening_1.RateLimiter(0), // disabled unless overridden
70
+ turnLimiter: new serve_hardening_1.TurnLimiter(0), // disabled unless overridden
71
+ turnGate: new serve_hardening_1.TurnGate(10, 10),
72
+ warnGateReject: noop,
73
+ rlKey: 'user-1',
74
+ sessionKey: 'session-1',
75
+ emitter: undefined,
76
+ run: async () => ({ success: true, error: undefined }),
77
+ ...overrides,
78
+ };
79
+ }
80
+ suite('runGatewayTurn: admission order and rejection messaging', () => {
81
+ test('rate limiter rejects: run() never called, channel gets RATE_LIMIT_BUSY_MESSAGE', async () => {
82
+ const rl = new serve_hardening_1.RateLimiter(1);
83
+ rl.tryConsume('user-1'); // pre-exhaust the one slot for this key
84
+ let ran = false;
85
+ const emitter = fakeEmitter();
86
+ const result = await (0, serve_1.runGatewayTurn)(baseParams({
87
+ rateLimiter: rl,
88
+ emitter,
89
+ run: async () => { ran = true; return { success: true }; },
90
+ }));
91
+ assert.strictEqual(result.success, false);
92
+ assert.strictEqual(result.error, 'rate_limited');
93
+ assert.strictEqual(ran, false, 'run() must not be called when the rate limiter rejects');
94
+ assert.deepStrictEqual(emitter.texts, [serve_hardening_1.RATE_LIMIT_BUSY_MESSAGE]);
95
+ assert.strictEqual(emitter.doneCalled, true);
96
+ });
97
+ test('rate limiter allows a fresh key through to run()', async () => {
98
+ let ran = false;
99
+ const result = await (0, serve_1.runGatewayTurn)(baseParams({
100
+ rateLimiter: new serve_hardening_1.RateLimiter(1),
101
+ run: async () => { ran = true; return { success: true }; },
102
+ }));
103
+ assert.strictEqual(result.success, true);
104
+ assert.strictEqual(ran, true);
105
+ });
106
+ test('turn gate at capacity rejects: run() never called, channel gets TURN_GATE_BUSY_MESSAGE', async () => {
107
+ const gate = new serve_hardening_1.TurnGate(1, 0); // one slot, no queue room
108
+ const held = await gate.acquire();
109
+ assert.ok(held, 'setup: hold the only slot');
110
+ let ran = false;
111
+ const emitter = fakeEmitter();
112
+ const result = await (0, serve_1.runGatewayTurn)(baseParams({
113
+ turnGate: gate,
114
+ emitter,
115
+ run: async () => { ran = true; return { success: true }; },
116
+ }));
117
+ assert.strictEqual(result.success, false);
118
+ assert.strictEqual(result.error, 'busy');
119
+ assert.strictEqual(ran, false);
120
+ assert.deepStrictEqual(emitter.texts, [serve_hardening_1.TURN_GATE_BUSY_MESSAGE]);
121
+ held();
122
+ });
123
+ test('per-session turn limiter rejects: run() never called, but the turn-gate slot is released', async () => {
124
+ const limiter = new serve_hardening_1.TurnLimiter(1);
125
+ limiter.tryConsume('session-1'); // exhaust the one slot for this session
126
+ const gate = new serve_hardening_1.TurnGate(1, 1);
127
+ let ran = false;
128
+ const emitter = fakeEmitter();
129
+ const result = await (0, serve_1.runGatewayTurn)(baseParams({
130
+ turnLimiter: limiter,
131
+ turnGate: gate,
132
+ emitter,
133
+ run: async () => { ran = true; return { success: true }; },
134
+ }));
135
+ assert.strictEqual(result.success, false);
136
+ assert.strictEqual(result.error, 'turn_limit');
137
+ assert.strictEqual(ran, false);
138
+ assert.deepStrictEqual(emitter.texts, [serve_hardening_1.TURN_LIMIT_BUSY_MESSAGE]);
139
+ // The turn-gate slot must be released even though the turn never ran.
140
+ assert.strictEqual(gate.inflight, 0);
141
+ });
142
+ test('all checks pass: run() executes and its result is returned; the gate slot releases after', async () => {
143
+ const gate = new serve_hardening_1.TurnGate(1, 1);
144
+ const result = await (0, serve_1.runGatewayTurn)(baseParams({
145
+ rateLimiter: new serve_hardening_1.RateLimiter(5),
146
+ turnLimiter: new serve_hardening_1.TurnLimiter(5),
147
+ turnGate: gate,
148
+ run: async () => ({ success: true, error: undefined }),
149
+ }));
150
+ assert.strictEqual(result.success, true);
151
+ assert.strictEqual(gate.inflight, 0, 'slot must be released after a successful turn');
152
+ });
153
+ test('run() throwing still releases the turn-gate slot', async () => {
154
+ const gate = new serve_hardening_1.TurnGate(1, 1);
155
+ await assert.rejects(() => (0, serve_1.runGatewayTurn)(baseParams({
156
+ rateLimiter: new serve_hardening_1.RateLimiter(5),
157
+ turnLimiter: new serve_hardening_1.TurnLimiter(5),
158
+ turnGate: gate,
159
+ run: async () => { throw new Error('boom'); },
160
+ })));
161
+ assert.strictEqual(gate.inflight, 0);
162
+ });
163
+ test('rate limit key and turn-limiter session key are independent buckets', async () => {
164
+ const rateLimiter = new serve_hardening_1.RateLimiter(1);
165
+ const turnLimiter = new serve_hardening_1.TurnLimiter(5);
166
+ // Exhaust the rate limiter for 'user-A' only.
167
+ rateLimiter.tryConsume('user-A');
168
+ const rejectedForA = await (0, serve_1.runGatewayTurn)(baseParams({
169
+ rateLimiter, turnLimiter, rlKey: 'user-A', sessionKey: 'sess-A',
170
+ run: async () => ({ success: true }),
171
+ }));
172
+ const okForB = await (0, serve_1.runGatewayTurn)(baseParams({
173
+ rateLimiter, turnLimiter, rlKey: 'user-B', sessionKey: 'sess-B',
174
+ run: async () => ({ success: true }),
175
+ }));
176
+ assert.strictEqual(rejectedForA.error, 'rate_limited', 'user-A already consumed its one slot in setup');
177
+ assert.strictEqual(okForB.success, true, 'user-B has an independent rate-limit bucket');
178
+ });
179
+ test('a missing emitter does not throw on rejection (channel-less callers are safe)', async () => {
180
+ const rl = new serve_hardening_1.RateLimiter(1);
181
+ rl.tryConsume('user-1');
182
+ const rejected = await (0, serve_1.runGatewayTurn)(baseParams({ rateLimiter: rl, emitter: undefined }));
183
+ assert.strictEqual(rejected.success, false);
184
+ assert.strictEqual(rejected.error, 'rate_limited');
185
+ });
186
+ });
187
+ suite('resolveGatewaySessionKey: never collapses two unidentified senders onto one key', () => {
188
+ test('prefers an explicit sessionId over everything else — durable, user-namespaced', () => {
189
+ const r = (0, serve_1.resolveGatewaySessionKey)({ sessionId: 'chat-42', gatewaySessionKey: 'gw-1' }, 'alice');
190
+ assert.strictEqual(r.sessionKey, `u${(0, serve_hardening_1.userNamespaceHash)('alice')}--chat-42`);
191
+ assert.strictEqual(r.outboundKey, 'chat-42', 'reply-handle routing must keep the raw gateway key');
192
+ assert.strictEqual(r.durable, true);
193
+ });
194
+ test('falls back to gatewaySessionKey when sessionId is absent — durable, user-namespaced', () => {
195
+ const r = (0, serve_1.resolveGatewaySessionKey)({ gatewaySessionKey: 'gw-1' }, 'alice');
196
+ assert.strictEqual(r.sessionKey, `u${(0, serve_hardening_1.userNamespaceHash)('alice')}--gw-1`);
197
+ assert.strictEqual(r.outboundKey, 'gw-1');
198
+ assert.strictEqual(r.durable, true);
199
+ });
200
+ test('the same explicit session id from two different senders never attaches to one engine session', () => {
201
+ const alice = (0, serve_1.resolveGatewaySessionKey)({ sessionId: 'chat-42' }, 'alice');
202
+ const bob = (0, serve_1.resolveGatewaySessionKey)({ sessionId: 'chat-42' }, 'bob');
203
+ assert.notStrictEqual(alice.sessionKey, bob.sessionKey);
204
+ });
205
+ test('an explicit session id with no resolved identity stays raw (dev/unidentified path)', () => {
206
+ const r = (0, serve_1.resolveGatewaySessionKey)({ sessionId: 'chat-42' }, undefined);
207
+ assert.strictEqual(r.sessionKey, 'chat-42');
208
+ assert.strictEqual(r.outboundKey, 'chat-42');
209
+ assert.strictEqual(r.durable, true);
210
+ });
211
+ test('with a resolved identity but no session id, keys off that identity — stable, durable, no collision risk', () => {
212
+ const r1 = (0, serve_1.resolveGatewaySessionKey)({}, 'slack:U0B6');
213
+ const r2 = (0, serve_1.resolveGatewaySessionKey)({}, 'slack:U0B6');
214
+ assert.strictEqual(r1.sessionKey, 'gateway-slack:U0B6');
215
+ assert.strictEqual(r1.sessionKey, r2.sessionKey, 'the same identified sender must get the same key across messages');
216
+ assert.strictEqual(r1.durable, true);
217
+ });
218
+ test('two different identified senders with no session id never collide', () => {
219
+ const alice = (0, serve_1.resolveGatewaySessionKey)({}, 'slack:alice');
220
+ const bob = (0, serve_1.resolveGatewaySessionKey)({}, 'slack:bob');
221
+ assert.notStrictEqual(alice.sessionKey, bob.sessionKey);
222
+ });
223
+ test('two genuinely unidentified senders (no userId, no session id) never collide — no shared "gateway-anon" bucket, and both flagged non-durable', () => {
224
+ const first = (0, serve_1.resolveGatewaySessionKey)({}, undefined);
225
+ const second = (0, serve_1.resolveGatewaySessionKey)({}, undefined);
226
+ assert.notStrictEqual(first.sessionKey, second.sessionKey, 'each unidentified message must get its own key, not a shared bucket');
227
+ assert.ok(first.sessionKey.startsWith('gateway-anon-'));
228
+ assert.ok(second.sessionKey.startsWith('gateway-anon-'));
229
+ assert.strictEqual(first.durable, false);
230
+ assert.strictEqual(second.durable, false);
231
+ });
232
+ test('whitespace-only sessionId/gatewaySessionKey are treated as absent', () => {
233
+ const r = (0, serve_1.resolveGatewaySessionKey)({ sessionId: ' ', gatewaySessionKey: ' ' }, 'alice');
234
+ assert.strictEqual(r.sessionKey, 'gateway-alice');
235
+ assert.strictEqual(r.durable, true);
236
+ });
237
+ });
238
+ suite('runGatewayTurn: trackSession=false skips the TurnLimiter for one-off keys', () => {
239
+ test('a never-repeated key would never hit its own cap anyway — trackSession:false just skips the dead-weight tracking', async () => {
240
+ const limiter = new serve_hardening_1.TurnLimiter(1);
241
+ let ran = false;
242
+ const result = await (0, serve_1.runGatewayTurn)(baseParams({
243
+ turnLimiter: limiter,
244
+ sessionKey: 'gateway-anon-unique-1',
245
+ trackSession: false,
246
+ run: async () => { ran = true; return { success: true }; },
247
+ }));
248
+ assert.strictEqual(result.success, true);
249
+ assert.strictEqual(ran, true);
250
+ assert.strictEqual(limiter.tryConsume('gateway-anon-unique-1'), true, 'trackSession:false must never call tryConsume — the key should still have its full budget');
251
+ });
252
+ test('trackSession:true (default) still enforces the cap as before', async () => {
253
+ const limiter = new serve_hardening_1.TurnLimiter(1);
254
+ limiter.tryConsume('sess-durable'); // exhaust the one slot
255
+ let ran = false;
256
+ const emitter = fakeEmitter();
257
+ const result = await (0, serve_1.runGatewayTurn)(baseParams({
258
+ turnLimiter: limiter,
259
+ sessionKey: 'sess-durable',
260
+ emitter,
261
+ run: async () => { ran = true; return { success: true }; },
262
+ }));
263
+ assert.strictEqual(result.success, false);
264
+ assert.strictEqual(result.error, 'turn_limit');
265
+ assert.strictEqual(ran, false);
266
+ });
267
+ });
@@ -63,6 +63,49 @@ suite('serve-hardening: resolveStateDir', () => {
63
63
  assert.ok(r.dir.startsWith(os.tmpdir()));
64
64
  });
65
65
  });
66
+ suite('serve-hardening: resolveAnalyticsSessionsDir', () => {
67
+ // getAgentideHome() reads process.env directly (not injectable), so tests that need it to
68
+ // resolve deterministically save/restore the real env vars around themselves.
69
+ const saved = {};
70
+ setup(() => {
71
+ saved.AGENT_HOME_DIR = process.env.AGENT_HOME_DIR;
72
+ saved.AGENTHIPPO_HOME = process.env.AGENTHIPPO_HOME;
73
+ delete process.env.AGENT_HOME_DIR;
74
+ delete process.env.AGENTHIPPO_HOME;
75
+ });
76
+ teardown(() => {
77
+ if (saved.AGENT_HOME_DIR === undefined) {
78
+ delete process.env.AGENT_HOME_DIR;
79
+ }
80
+ else {
81
+ process.env.AGENT_HOME_DIR = saved.AGENT_HOME_DIR;
82
+ }
83
+ if (saved.AGENTHIPPO_HOME === undefined) {
84
+ delete process.env.AGENTHIPPO_HOME;
85
+ }
86
+ else {
87
+ process.env.AGENTHIPPO_HOME = saved.AGENTHIPPO_HOME;
88
+ }
89
+ });
90
+ test('AGENTHIPPO_HOME set → uses getAgentideHome(), not stateDir (stays discoverable to Spotlight/DuckDB)', () => {
91
+ process.env.AGENTHIPPO_HOME = '/data/.agent-hippo';
92
+ const dir = (0, serve_hardening_1.resolveAnalyticsSessionsDir)('/data/.agent-hippo/serve-state', false, { AGENTHIPPO_HOME: '/data/.agent-hippo' });
93
+ assert.strictEqual(dir, path.join('/data/.agent-hippo', 'analytics', 'sessions'));
94
+ });
95
+ test('AGENT_HOME_DIR set → same precedence as AGENTHIPPO_HOME', () => {
96
+ process.env.AGENT_HOME_DIR = '/data/.agent-hippo';
97
+ const dir = (0, serve_hardening_1.resolveAnalyticsSessionsDir)('/data/.agent-hippo/serve-state', false, { AGENT_HOME_DIR: '/data/.agent-hippo' });
98
+ assert.strictEqual(dir, path.join('/data/.agent-hippo', 'analytics', 'sessions'));
99
+ });
100
+ test('no home-dir env var, explicit non-ephemeral --state-dir → uses stateDir (never regress durability for discoverability)', () => {
101
+ const dir = (0, serve_hardening_1.resolveAnalyticsSessionsDir)('/mnt/durable-vol', false, {});
102
+ assert.strictEqual(dir, path.join('/mnt/durable-vol', 'analytics', 'sessions'));
103
+ });
104
+ test('no home-dir env var, ephemeral state (nothing durable configured) → falls back to getAgentideHome() default', () => {
105
+ const dir = (0, serve_hardening_1.resolveAnalyticsSessionsDir)(path.join(os.tmpdir(), 'agenthippo-server'), true, {});
106
+ assert.strictEqual(dir, path.join(os.homedir(), '.agent-hippo', 'analytics', 'sessions'));
107
+ });
108
+ });
66
109
  suite('serve-hardening: resolveBindHost', () => {
67
110
  test('defaults to loopback', () => {
68
111
  assert.strictEqual((0, serve_hardening_1.resolveBindHost)(undefined, {}), serve_hardening_1.DEFAULT_BIND_HOST);
@@ -297,3 +340,40 @@ suite('serve-hardening: readBodyWithCap', () => {
297
340
  assert.strictEqual(serve_hardening_1.DEFAULT_MAX_BODY_BYTES, 1024 * 1024);
298
341
  });
299
342
  });
343
+ suite('serve-hardening: resolveServeSessionKey (secure-obo-design §3.4 Phase 0)', () => {
344
+ const PINNED = { AGENTHIPPO_IDENTITY_HEADER: 'x-forwarded-email' };
345
+ test('verified user + session_id → user-namespaced key', () => {
346
+ const r = (0, serve_hardening_1.resolveServeSessionKey)('chat-1', 'alice@corp.com', PINNED);
347
+ assert.strictEqual(r.sessionKey, `u${(0, serve_hardening_1.userNamespaceHash)('alice@corp.com')}--chat-1`);
348
+ assert.strictEqual(r.rejected, undefined);
349
+ });
350
+ test('two users presenting the SAME session_id can never share a key (the defect this closes)', () => {
351
+ const a = (0, serve_hardening_1.resolveServeSessionKey)('chat-1', 'alice@corp.com', PINNED);
352
+ const b = (0, serve_hardening_1.resolveServeSessionKey)('chat-1', 'bob@corp.com', PINNED);
353
+ assert.notStrictEqual(a.sessionKey, b.sessionKey);
354
+ });
355
+ test('verified user without session_id → stable per-user default key', () => {
356
+ const r1 = (0, serve_hardening_1.resolveServeSessionKey)(undefined, 'alice@corp.com', PINNED);
357
+ const r2 = (0, serve_hardening_1.resolveServeSessionKey)('', 'alice@corp.com', PINNED);
358
+ assert.strictEqual(r1.sessionKey, r2.sessionKey);
359
+ assert.strictEqual(r1.sessionKey, `u${(0, serve_hardening_1.userNamespaceHash)('alice@corp.com')}--default`);
360
+ });
361
+ test('pinned identity + session_id but NO verified user → fail closed', () => {
362
+ const r = (0, serve_hardening_1.resolveServeSessionKey)('chat-1', undefined, PINNED);
363
+ assert.strictEqual(r.rejected, serve_hardening_1.SESSION_WITHOUT_USER_REASON);
364
+ });
365
+ test('unpinned (local dev) keeps raw session_id behavior', () => {
366
+ const r = (0, serve_hardening_1.resolveServeSessionKey)('chat-1', undefined, {});
367
+ assert.strictEqual(r.sessionKey, 'chat-1');
368
+ assert.strictEqual(r.rejected, undefined);
369
+ });
370
+ test('unpinned, no user, no session_id → legacy anon bucket', () => {
371
+ const r = (0, serve_hardening_1.resolveServeSessionKey)(undefined, undefined, {});
372
+ assert.strictEqual(r.sessionKey, 'anon-unknown');
373
+ });
374
+ test('the key never embeds the raw user id (emails are not path segments)', () => {
375
+ const r = (0, serve_hardening_1.resolveServeSessionKey)('s', 'alice@corp.com', PINNED);
376
+ assert.ok(!r.sessionKey.includes('alice@corp.com'));
377
+ assert.ok(!r.sessionKey.includes('@'));
378
+ });
379
+ });
@@ -173,4 +173,36 @@ suite('ServeClient', () => {
173
173
  });
174
174
  });
175
175
  });
176
+ test('streamRemoteServeChat surfaces fail-closed credential rejection as HTTP error (not silent success)', async () => {
177
+ // Mirrors serve.ts after the fix: warrant denial happens BEFORE SSE writeHead(200),
178
+ // so the client sees a real non-2xx JSON error instead of 200 + raw body fallthrough.
179
+ const server = http.createServer((_req, res) => {
180
+ res.writeHead(500, { 'Content-Type': 'application/json' });
181
+ res.end(JSON.stringify({
182
+ error: 'OBO data token present without a verified user in request scope (fail-closed: token_without_verified_user)',
183
+ }));
184
+ });
185
+ await new Promise((resolve, reject) => {
186
+ server.listen(0, '127.0.0.1', async () => {
187
+ try {
188
+ const address = server.address();
189
+ if (!address || typeof address === 'string') {
190
+ throw new Error('bad address');
191
+ }
192
+ const emitter = createCaptureEmitter();
193
+ const result = await (0, serveClient_js_1.streamRemoteServeChat)({ baseUrl: `http://127.0.0.1:${address.port}`, message: 'ping' }, emitter);
194
+ assert.strictEqual(result.ok, false);
195
+ assert.match(result.error ?? '', /token_without_verified_user/);
196
+ assert.deepStrictEqual(emitter.events.map((e) => e.type), ['error', 'done']);
197
+ resolve();
198
+ }
199
+ catch (err) {
200
+ reject(err);
201
+ }
202
+ finally {
203
+ server.close();
204
+ }
205
+ });
206
+ });
207
+ });
176
208
  });
@@ -88,7 +88,7 @@ suite('state-sync: stage + merge session dirs', () => {
88
88
  fs.rmSync(tmp, { recursive: true, force: true });
89
89
  (0, state_sync_1.clearRestoreAttemptCache)();
90
90
  });
91
- test('stages only matching provider-session shards + agents', async () => {
91
+ test('stages only matching provider-session shards; the shared agents/ SDK cache is never staged', async () => {
92
92
  const ps = path.join(tmp, 'provider-sessions');
93
93
  fs.mkdirSync(ps, { recursive: true });
94
94
  const key1 = 'claude:sess-a:pack:sig';
@@ -97,6 +97,8 @@ suite('state-sync: stage + merge session dirs', () => {
97
97
  const f2 = (0, state_sync_1.providerSessionShardName)(key2);
98
98
  fs.writeFileSync(path.join(ps, f1), JSON.stringify({ key: key1, value: { chatSessionId: 'sess-a', id: 'n1' } }));
99
99
  fs.writeFileSync(path.join(ps, f2), JSON.stringify({ key: key2, value: { chatSessionId: 'sess-b', id: 'n2' } }));
100
+ // agents/ holds the SDK's own cache, shared across every session/user of this pack
101
+ // on the replica — must never be swept into a single session's staging tree.
100
102
  fs.mkdirSync(path.join(tmp, 'agents', 'pack', 'claude'), { recursive: true });
101
103
  fs.writeFileSync(path.join(tmp, 'agents', 'pack', 'claude', 'marker'), 'x');
102
104
  const staging = path.join(tmp, 'staging');
@@ -104,22 +106,33 @@ suite('state-sync: stage + merge session dirs', () => {
104
106
  assert.strictEqual(ok, true);
105
107
  assert.ok(fs.existsSync(path.join(staging, 'provider-sessions', f1)));
106
108
  assert.ok(!fs.existsSync(path.join(staging, 'provider-sessions', f2)));
107
- assert.ok(fs.existsSync(path.join(staging, 'agents', 'pack', 'claude', 'marker')));
109
+ assert.ok(!fs.existsSync(path.join(staging, 'agents')), 'agents/ must not be staged into a per-session push');
108
110
  const shards = await (0, state_sync_1.listSessionShardFiles)(tmp, 'sess-a');
109
111
  assert.deepStrictEqual(shards, [f1]);
110
112
  assert.strictEqual(await (0, state_sync_1.hasLocalSessionState)(tmp, 'sess-a'), true);
111
113
  assert.strictEqual(await (0, state_sync_1.hasLocalSessionState)(tmp, 'sess-missing'), false);
112
114
  });
113
- test('mergeSessionStagingIntoState copies shards and agents', async () => {
115
+ test('a session with no shards but a live agents/ dir has nothing to push', async () => {
116
+ fs.mkdirSync(path.join(tmp, 'agents', 'pack', 'claude'), { recursive: true });
117
+ fs.writeFileSync(path.join(tmp, 'agents', 'pack', 'claude', 'marker'), 'x');
118
+ const staging = path.join(tmp, 'staging');
119
+ const ok = await (0, state_sync_1.stageSessionPushDir)(tmp, 'sess-none', staging);
120
+ assert.strictEqual(ok, false, 'agents/ content alone must not count as this session having content to push');
121
+ });
122
+ test('mergeSessionStagingIntoState copies shards only; never touches the destination agents/ dir', async () => {
114
123
  const staging = path.join(tmp, 'from');
115
124
  const dest = path.join(tmp, 'to');
116
125
  fs.mkdirSync(path.join(staging, 'provider-sessions'), { recursive: true });
117
126
  fs.writeFileSync(path.join(staging, 'provider-sessions', 'abc.json'), JSON.stringify({ key: 'claude:s1:p:s', value: { chatSessionId: 's1' } }));
127
+ // Simulate a stale staging tree that (pre-fix) would have carried an old agents/
128
+ // snapshot — restoring it must not force-overwrite another session's live SDK cache.
118
129
  fs.mkdirSync(path.join(staging, 'agents', 'p'), { recursive: true });
119
- fs.writeFileSync(path.join(staging, 'agents', 'p', 'f'), '1');
130
+ fs.writeFileSync(path.join(staging, 'agents', 'p', 'f'), 'stale');
131
+ fs.mkdirSync(path.join(dest, 'agents', 'p'), { recursive: true });
132
+ fs.writeFileSync(path.join(dest, 'agents', 'p', 'f'), 'live-for-another-session');
120
133
  await (0, state_sync_1.mergeSessionStagingIntoState)(staging, dest);
121
134
  assert.ok(fs.existsSync(path.join(dest, 'provider-sessions', 'abc.json')));
122
- assert.ok(fs.existsSync(path.join(dest, 'agents', 'p', 'f')));
135
+ assert.strictEqual(fs.readFileSync(path.join(dest, 'agents', 'p', 'f'), 'utf8'), 'live-for-another-session', 'the live agents/ dir must survive a restore of an unrelated staging tree untouched');
123
136
  });
124
137
  });
125
138
  suite('peerToUserId (#4 — channel sender → per-user id)', () => {
package/out/utils.js CHANGED
@@ -55,6 +55,7 @@ exports.shortSig = shortSig;
55
55
  exports.isLocalHttps = isLocalHttps;
56
56
  exports.applyLocalTlsBypass = applyLocalTlsBypass;
57
57
  exports.getOverrideOpenAIBaseUrl = getOverrideOpenAIBaseUrl;
58
+ exports.inferModelProvider = inferModelProvider;
58
59
  exports.resolveApiRouting = resolveApiRouting;
59
60
  exports.getExplicitConfigValue = getExplicitConfigValue;
60
61
  exports.getAgentideEngineSettings = getAgentideEngineSettings;
@@ -651,20 +652,35 @@ function getOverrideOpenAIBaseUrl(baseUrl, configProvider) {
651
652
  }
652
653
  return undefined;
653
654
  }
655
+ /** Infer the direct provider from a model id — used by engines (e.g. custom) that aren't pinned to one vendor. */
656
+ function inferModelProvider(modelId) {
657
+ return modelId && /claude|anthropic/i.test(modelId) ? 'anthropic' : 'openai';
658
+ }
654
659
  /**
655
- * Resolve API key and base URL based on LiteLLM routing configuration.
656
- * Handles fallback to global litellm* settings when useLiteLLM is true.
657
- * Returns both baseUrl (for Claude/Anthropic) and openaiBaseUrl (with overrideOpenAIBaseUrl applied for Codex).
660
+ * Resolve the API key + base URL for a turn by the DESTINATION it dials, not a global
661
+ * precedence over provider keys.
662
+ *
663
+ * - LiteLLM proxy selected (`useLiteLLM`) → the LiteLLM key.
664
+ * - direct → the key for THIS engine's provider
665
+ * (codex→openai, claude→anthropic; custom infers
666
+ * from its model). Never the "other" provider's key.
667
+ *
668
+ * An explicit per-request `options.apiKey` (the IDE's single configured key) still wins — the
669
+ * per-provider `creds` are the headless/serve fallback that replaces the old
670
+ * `anthropic || openai` collapse (which sent, e.g., a codex turn to OpenAI with an Anthropic key).
658
671
  *
659
- * @param options - Agent request options
660
- * @param litellmApiKey - Fallback LiteLLM API key
661
- * @param litellmBaseUrl - Fallback LiteLLM base URL
662
- * @param configProvider - Optional config provider for resolving overrides
672
+ * @param options - Agent request options (carries an optional explicit apiKey / baseUrl / useLiteLLM)
673
+ * @param creds - Per-destination credentials (EngineConfig satisfies this)
674
+ * @param provider - The engine's direct provider, used only when NOT routing through LiteLLM
675
+ * @param configProvider - Optional config provider for resolving overrideOpenAIBaseUrl
663
676
  */
664
- function resolveApiRouting(options, litellmApiKey, litellmBaseUrl, configProvider) {
677
+ function resolveApiRouting(options, creds, provider, configProvider) {
665
678
  const useLiteLLM = options.useLiteLLM === true;
666
- const baseUrl = options.baseUrl || (useLiteLLM ? litellmBaseUrl : undefined);
667
- const liteLLMResolvedApiKey = options.apiKey || litellmApiKey || 'sk-dummy';
679
+ const baseUrl = options.baseUrl || (useLiteLLM ? creds.litellmBaseUrl : undefined);
680
+ const directKey = provider === 'anthropic' ? creds.anthropicApiKey : creds.openaiApiKey;
681
+ const resolvedApiKey = useLiteLLM
682
+ ? (options.apiKey || creds.litellmApiKey || 'sk-dummy')
683
+ : (options.apiKey || directKey || '');
668
684
  // For OpenAI-compatible endpoints, check for per-provider overrideOpenAIBaseUrl
669
685
  let openaiBaseUrl = baseUrl;
670
686
  if (baseUrl) {
@@ -674,7 +690,7 @@ function resolveApiRouting(options, litellmApiKey, litellmBaseUrl, configProvide
674
690
  }
675
691
  }
676
692
  return {
677
- apiKey: useLiteLLM ? liteLLMResolvedApiKey : (options.apiKey || ''),
693
+ apiKey: resolvedApiKey,
678
694
  baseUrl,
679
695
  openaiBaseUrl,
680
696
  useLiteLLM,