@agenthippo-ai/agenthippo 0.1.0 → 0.2.3-dataplane

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/out/agentpack/index.js +2 -1
  2. package/out/agentpack/paths.js +23 -0
  3. package/out/analytics/dashboard/fleetDisplayUtils.js +3 -0
  4. package/out/analytics/dashboard/webview/panel.js +290 -80
  5. package/out/analytics/sessionEvents.js +21 -4
  6. package/out/auth/brokerClient.js +1 -1
  7. package/out/auth/onboardingPrompt.js +112 -0
  8. package/out/auth/provider.js +1 -1
  9. package/out/auth-broker/config.js +8 -3
  10. package/out/auth-broker/pkce.js +3 -1
  11. package/out/auth-broker/server.js +43 -1
  12. package/out/auth-broker/store.js +6 -5
  13. package/out/cli/commands/ask.js +3 -0
  14. package/out/cli/commands/chat.js +3 -0
  15. package/out/cli/commands/deploy.js +49 -10
  16. package/out/cli/commands/gateway.js +54 -0
  17. package/out/cli/commands/idp.js +85 -0
  18. package/out/cli/commands/serve.js +401 -49
  19. package/out/cli/credential-context.js +47 -69
  20. package/out/cli/data-plane/broker.js +78 -0
  21. package/out/cli/data-plane/connector-host.js +121 -0
  22. package/out/cli/data-plane/egress.js +189 -0
  23. package/out/cli/data-plane/gateway.js +195 -0
  24. package/out/cli/data-plane/index.js +137 -0
  25. package/out/cli/data-plane/registry.js +80 -0
  26. package/out/cli/deploy/blueprints/registry.js +84 -0
  27. package/out/cli/deploy/layers/identity.js +91 -0
  28. package/out/cli/deploy-init.js +1156 -285
  29. package/out/cli/idp-init.js +303 -0
  30. package/out/cli/index.js +46 -8
  31. package/out/cli/pack-loader.js +37 -2
  32. package/out/cli/runtime.js +10 -3
  33. package/out/cli/serve-hardening.js +217 -12
  34. package/out/cli/state-sync.js +318 -29
  35. package/out/engines/claude.js +26 -5
  36. package/out/engines/codex.js +24 -5
  37. package/out/engines/custom.js +46 -12
  38. package/out/extension.js +2 -0
  39. package/out/gateway/outboundSend.js +6 -6
  40. package/out/headless/index.js +24 -6
  41. package/out/live-probe/connector.mjs +8 -0
  42. package/out/live-probe/env-report.mjs +8 -0
  43. package/out/mcp.js +88 -61
  44. package/out/remote/handleRemoteChat.js +7 -1
  45. package/out/remote/serveClient.js +27 -13
  46. package/out/scripts/analytics/agentide-litellm-otel.config.yaml +14 -0
  47. package/out/shared/models.js +1 -1
  48. package/out/shared/providers.js +5 -2
  49. package/out/test/analytics.test.js +93 -0
  50. package/out/test/auth-broker.test.js +59 -2
  51. package/out/test/blueprint-registry.test.js +105 -0
  52. package/out/test/credential-context.test.js +55 -0
  53. package/out/test/custom-env-layering.test.js +151 -0
  54. package/out/test/data-plane-engine-wiring.test.js +250 -0
  55. package/out/test/data-plane.test.js +244 -0
  56. package/out/test/deploy-init.test.js +287 -5
  57. package/out/test/egress-broker.test.js +172 -0
  58. package/out/test/fleetDisplayUtils.test.js +4 -0
  59. package/out/test/idp-init.test.js +151 -0
  60. package/out/test/mcp-obo.test.js +52 -27
  61. package/out/test/mcp.test.js +60 -4
  62. package/out/test/model-routing.test.js +133 -0
  63. package/out/test/serve-gateway-turn.test.js +267 -0
  64. package/out/test/serve-hardening.test.js +187 -0
  65. package/out/test/serveClient.test.js +37 -2
  66. package/out/test/state-sync-peer.test.js +83 -1
  67. package/out/utils.js +27 -11
  68. package/package.json +3 -4
@@ -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);
@@ -83,6 +126,18 @@ suite('serve-hardening: clientIp', () => {
83
126
  test('uses the right-most XFF entry (the IP the trusted proxy observed)', () => {
84
127
  assert.strictEqual((0, serve_hardening_1.clientIp)(reqWith({ 'x-forwarded-for': 'evil-spoof, 198.51.100.7' })), '198.51.100.7');
85
128
  });
129
+ test('AGENTHIPPO_XFF_TRUSTED_HOPS=2 skips the tunnel hop (cloudflared → Caddy)', () => {
130
+ const req = reqWith({ 'x-forwarded-for': '198.51.100.7, 172.18.0.5' });
131
+ assert.strictEqual((0, serve_hardening_1.clientIp)(req, { AGENTHIPPO_XFF_TRUSTED_HOPS: '2' }), '198.51.100.7');
132
+ });
133
+ test('trusted hops beyond the XFF length clamps to the left-most entry', () => {
134
+ const req = reqWith({ 'x-forwarded-for': '198.51.100.7' });
135
+ assert.strictEqual((0, serve_hardening_1.clientIp)(req, { AGENTHIPPO_XFF_TRUSTED_HOPS: '5' }), '198.51.100.7');
136
+ });
137
+ test('invalid trusted hops falls back to 1', () => {
138
+ const req = reqWith({ 'x-forwarded-for': 'evil-spoof, 198.51.100.7' });
139
+ assert.strictEqual((0, serve_hardening_1.clientIp)(req, { AGENTHIPPO_XFF_TRUSTED_HOPS: 'nope' }), '198.51.100.7');
140
+ });
86
141
  });
87
142
  suite('serve-hardening: RateLimiter', () => {
88
143
  test('allows up to the cap within a window then rejects', () => {
@@ -133,6 +188,16 @@ suite('serve-hardening: resolveUserId', () => {
133
188
  assert.strictEqual((0, serve_hardening_1.resolveUserId)({}, undefined), undefined);
134
189
  assert.strictEqual((0, serve_hardening_1.resolveUserId)({}, { user_id: ' ' }), undefined);
135
190
  });
191
+ test('AGENTHIPPO_IDENTITY_HEADER pins trust to one header — spoofed aliases are ignored', () => {
192
+ const env = { AGENTHIPPO_IDENTITY_HEADER: 'x-goog-authenticated-user-email' };
193
+ // IAP strips X-Goog-* but forwards x-agent-user; pinning must ignore the spoof.
194
+ const id = (0, serve_hardening_1.resolveUserId)({ 'x-agent-user': 'spoofed@evil.com', 'x-goog-authenticated-user-email': 'accounts.google.com:marco@meridian.com' }, undefined, env);
195
+ assert.strictEqual(id, 'marco@meridian.com');
196
+ });
197
+ test('pinned header absent → fails closed (never the alias list, never the body)', () => {
198
+ const env = { AGENTHIPPO_IDENTITY_HEADER: 'x-goog-authenticated-user-email' };
199
+ assert.strictEqual((0, serve_hardening_1.resolveUserId)({ 'x-agent-user': 'spoofed@evil.com' }, { user_id: 'bob' }, env), undefined);
200
+ });
136
201
  });
137
202
  suite('serve-hardening: corsHeaders', () => {
138
203
  test('always wide-open (serve is always behind a proxy)', () => {
@@ -169,6 +234,91 @@ suite('serve-hardening: TurnLimiter', () => {
169
234
  assert.strictEqual(lim.tryConsume('s1'), true);
170
235
  });
171
236
  });
237
+ suite('serve-hardening: TurnGate', () => {
238
+ test('runs immediately under the inflight cap', async () => {
239
+ const gate = new serve_hardening_1.TurnGate(2, 4, 1_000);
240
+ const a = await gate.acquire();
241
+ const b = await gate.acquire();
242
+ assert.ok(a);
243
+ assert.ok(b);
244
+ assert.strictEqual(gate.inflight, 2);
245
+ assert.strictEqual(gate.queued, 0);
246
+ a();
247
+ b();
248
+ assert.strictEqual(gate.inflight, 0);
249
+ });
250
+ test('queues when at capacity and wakes FIFO on release', async () => {
251
+ const gate = new serve_hardening_1.TurnGate(1, 4, 5_000);
252
+ const first = await gate.acquire();
253
+ assert.ok(first);
254
+ assert.strictEqual(gate.inflight, 1);
255
+ let secondResolved = false;
256
+ const secondPromise = gate.acquire().then((release) => {
257
+ secondResolved = true;
258
+ return release;
259
+ });
260
+ // Let the microtask queue settle so the waiter is enqueued.
261
+ await new Promise((r) => setImmediate(r));
262
+ assert.strictEqual(secondResolved, false);
263
+ assert.strictEqual(gate.queued, 1);
264
+ first();
265
+ const second = await secondPromise;
266
+ assert.strictEqual(secondResolved, true);
267
+ assert.ok(second);
268
+ assert.strictEqual(gate.inflight, 1);
269
+ assert.strictEqual(gate.queued, 0);
270
+ second();
271
+ assert.strictEqual(gate.inflight, 0);
272
+ });
273
+ test('rejects immediately when the queue is full', async () => {
274
+ const gate = new serve_hardening_1.TurnGate(1, 1, 5_000);
275
+ const held = await gate.acquire();
276
+ assert.ok(held);
277
+ const waiting = gate.acquire(); // fills the single queue slot
278
+ await new Promise((r) => setImmediate(r));
279
+ assert.strictEqual(gate.queued, 1);
280
+ const rejected = await gate.acquire();
281
+ assert.strictEqual(rejected, null);
282
+ held();
283
+ const released = await waiting;
284
+ assert.ok(released);
285
+ released();
286
+ });
287
+ test('times out a waiter and returns null', async () => {
288
+ const gate = new serve_hardening_1.TurnGate(1, 4, 30);
289
+ const held = await gate.acquire();
290
+ assert.ok(held);
291
+ const timedOut = await gate.acquire();
292
+ assert.strictEqual(timedOut, null);
293
+ assert.strictEqual(gate.queued, 0);
294
+ held();
295
+ assert.strictEqual(gate.inflight, 0);
296
+ });
297
+ test('zero maxInflight disables the gate', async () => {
298
+ const gate = new serve_hardening_1.TurnGate(0, 0, 1_000);
299
+ const releases = [];
300
+ for (let i = 0; i < 50; i++) {
301
+ const r = await gate.acquire();
302
+ assert.ok(r);
303
+ releases.push(r);
304
+ }
305
+ assert.strictEqual(gate.inflight, 0);
306
+ for (const r of releases) {
307
+ r();
308
+ }
309
+ });
310
+ test('createTurnGateFromEnv honors env defaults and overrides', () => {
311
+ const def = (0, serve_hardening_1.createTurnGateFromEnv)({});
312
+ assert.strictEqual(def.max, 10);
313
+ const custom = (0, serve_hardening_1.createTurnGateFromEnv)({ AGENTHIPPO_MAX_CONCURRENT_TURNS: '3', AGENTHIPPO_TURN_QUEUE: '7' });
314
+ assert.strictEqual(custom.max, 3);
315
+ const disabled = (0, serve_hardening_1.createTurnGateFromEnv)({ AGENTHIPPO_MAX_CONCURRENT_TURNS: '0' });
316
+ assert.strictEqual(disabled.max, 0);
317
+ });
318
+ test('Retry-After constant matches the default queue timeout', () => {
319
+ assert.strictEqual(serve_hardening_1.TURN_GATE_RETRY_AFTER_SECONDS, 15);
320
+ });
321
+ });
172
322
  function fakeReq(body) {
173
323
  const stream = new stream_1.PassThrough();
174
324
  stream.end(Buffer.from(body, 'utf8'));
@@ -190,3 +340,40 @@ suite('serve-hardening: readBodyWithCap', () => {
190
340
  assert.strictEqual(serve_hardening_1.DEFAULT_MAX_BODY_BYTES, 1024 * 1024);
191
341
  });
192
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
+ });
@@ -131,13 +131,14 @@ suite('ServeClient', () => {
131
131
  ]);
132
132
  try {
133
133
  const emitter = createCaptureEmitter();
134
- await (0, serveClient_js_1.streamRemoteServeChat)({
134
+ const result = await (0, serveClient_js_1.streamRemoteServeChat)({
135
135
  baseUrl: mock.url,
136
136
  message: 'ping',
137
137
  sessionId: 'sess-1',
138
138
  }, emitter);
139
139
  assert.deepStrictEqual(emitter.events.map((e) => e.type), ['thinking', 'text', 'toolStart', 'toolEnd', 'done']);
140
140
  assert.strictEqual(emitter.events[1].payload, 'hello');
141
+ assert.deepStrictEqual(result, { ok: true });
141
142
  }
142
143
  finally {
143
144
  await mock.close();
@@ -156,9 +157,43 @@ suite('ServeClient', () => {
156
157
  throw new Error('bad address');
157
158
  }
158
159
  const emitter = createCaptureEmitter();
159
- await (0, serveClient_js_1.streamRemoteServeChat)({ baseUrl: `http://127.0.0.1:${address.port}`, message: 'ping' }, emitter);
160
+ const result = await (0, serveClient_js_1.streamRemoteServeChat)({ baseUrl: `http://127.0.0.1:${address.port}`, message: 'ping' }, emitter);
160
161
  assert.deepStrictEqual(emitter.events.map((e) => e.type), ['error', 'done']);
161
162
  assert.strictEqual(emitter.events[0].payload, 'Unauthorized');
163
+ // The turn failed: the caller must be able to see it (not record success).
164
+ assert.deepStrictEqual(result, { ok: false, error: 'Unauthorized' });
165
+ resolve();
166
+ }
167
+ catch (err) {
168
+ reject(err);
169
+ }
170
+ finally {
171
+ server.close();
172
+ }
173
+ });
174
+ });
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']);
162
197
  resolve();
163
198
  }
164
199
  catch (err) {
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  /*---------------------------------------------------------------------------------------------
3
- * Tests for serve-v3 #2 (S3 state config) and #4 (PeerInfo → user_id).
3
+ * Tests for serve-v3 #2 (S3 state config), #4 (PeerInfo → user_id), and serve-v4 P1.2.
4
4
  *--------------------------------------------------------------------------------------------*/
5
5
  var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
6
6
  if (k2 === undefined) k2 = k;
@@ -38,6 +38,9 @@ var __importStar = (this && this.__importStar) || (function () {
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
39
  require("mocha");
40
40
  const assert = __importStar(require("assert"));
41
+ const fs = __importStar(require("fs"));
42
+ const os = __importStar(require("os"));
43
+ const path = __importStar(require("path"));
41
44
  const state_sync_1 = require("../cli/state-sync");
42
45
  const serve_hardening_1 = require("../cli/serve-hardening");
43
46
  suite('state-sync: resolveS3StateConfig', () => {
@@ -53,6 +56,85 @@ suite('state-sync: resolveS3StateConfig', () => {
53
56
  assert.strictEqual(c?.endpoint, 'http://minio:9000');
54
57
  });
55
58
  });
59
+ suite('state-sync: restore boot mode', () => {
60
+ test('defaults to all', () => {
61
+ assert.strictEqual((0, state_sync_1.resolveRestoreBootMode)({}), 'all');
62
+ });
63
+ test('none when set', () => {
64
+ assert.strictEqual((0, state_sync_1.resolveRestoreBootMode)({ AGENTHIPPO_STATE_RESTORE_BOOT: 'none' }), 'none');
65
+ });
66
+ });
67
+ suite('state-sync: session id helpers', () => {
68
+ test('safeSessionId strips path chars', () => {
69
+ assert.strictEqual((0, state_sync_1.safeSessionId)('a/b:c'), 'a_b_c');
70
+ assert.strictEqual((0, state_sync_1.safeSessionId)(''), 'session');
71
+ });
72
+ test('sessionRemoteUri nests under sessions/', () => {
73
+ assert.strictEqual((0, state_sync_1.sessionRemoteUri)({ uri: 's3://bkt/p' }, 's1'), 's3://bkt/p/sessions/s1');
74
+ });
75
+ test('shardBelongsToSession matches key and value', () => {
76
+ assert.ok((0, state_sync_1.shardBelongsToSession)('claude:s1:pack:abc', 's1'));
77
+ assert.ok((0, state_sync_1.shardBelongsToSession)('x:other:pack:abc', 's1', { chatSessionId: 's1' }));
78
+ assert.ok(!(0, state_sync_1.shardBelongsToSession)('claude:s2:pack:abc', 's1'));
79
+ });
80
+ });
81
+ suite('state-sync: stage + merge session dirs', () => {
82
+ let tmp;
83
+ setup(() => {
84
+ tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'state-sync-'));
85
+ (0, state_sync_1.clearRestoreAttemptCache)();
86
+ });
87
+ teardown(() => {
88
+ fs.rmSync(tmp, { recursive: true, force: true });
89
+ (0, state_sync_1.clearRestoreAttemptCache)();
90
+ });
91
+ test('stages only matching provider-session shards; the shared agents/ SDK cache is never staged', async () => {
92
+ const ps = path.join(tmp, 'provider-sessions');
93
+ fs.mkdirSync(ps, { recursive: true });
94
+ const key1 = 'claude:sess-a:pack:sig';
95
+ const key2 = 'claude:sess-b:pack:sig';
96
+ const f1 = (0, state_sync_1.providerSessionShardName)(key1);
97
+ const f2 = (0, state_sync_1.providerSessionShardName)(key2);
98
+ fs.writeFileSync(path.join(ps, f1), JSON.stringify({ key: key1, value: { chatSessionId: 'sess-a', id: 'n1' } }));
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.
102
+ fs.mkdirSync(path.join(tmp, 'agents', 'pack', 'claude'), { recursive: true });
103
+ fs.writeFileSync(path.join(tmp, 'agents', 'pack', 'claude', 'marker'), 'x');
104
+ const staging = path.join(tmp, 'staging');
105
+ const ok = await (0, state_sync_1.stageSessionPushDir)(tmp, 'sess-a', staging);
106
+ assert.strictEqual(ok, true);
107
+ assert.ok(fs.existsSync(path.join(staging, 'provider-sessions', f1)));
108
+ assert.ok(!fs.existsSync(path.join(staging, 'provider-sessions', f2)));
109
+ assert.ok(!fs.existsSync(path.join(staging, 'agents')), 'agents/ must not be staged into a per-session push');
110
+ const shards = await (0, state_sync_1.listSessionShardFiles)(tmp, 'sess-a');
111
+ assert.deepStrictEqual(shards, [f1]);
112
+ assert.strictEqual(await (0, state_sync_1.hasLocalSessionState)(tmp, 'sess-a'), true);
113
+ assert.strictEqual(await (0, state_sync_1.hasLocalSessionState)(tmp, 'sess-missing'), false);
114
+ });
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 () => {
123
+ const staging = path.join(tmp, 'from');
124
+ const dest = path.join(tmp, 'to');
125
+ fs.mkdirSync(path.join(staging, 'provider-sessions'), { recursive: true });
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.
129
+ fs.mkdirSync(path.join(staging, 'agents', 'p'), { recursive: true });
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');
133
+ await (0, state_sync_1.mergeSessionStagingIntoState)(staging, dest);
134
+ assert.ok(fs.existsSync(path.join(dest, 'provider-sessions', 'abc.json')));
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');
136
+ });
137
+ });
56
138
  suite('peerToUserId (#4 — channel sender → per-user id)', () => {
57
139
  test('undefined for no peer', () => {
58
140
  assert.strictEqual((0, serve_hardening_1.peerToUserId)(undefined), undefined);