@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.
- package/out/agentpack/index.js +2 -1
- package/out/agentpack/paths.js +23 -0
- package/out/analytics/dashboard/fleetDisplayUtils.js +3 -0
- package/out/analytics/dashboard/webview/panel.js +290 -80
- package/out/analytics/sessionEvents.js +21 -4
- package/out/auth/brokerClient.js +1 -1
- package/out/auth/onboardingPrompt.js +112 -0
- package/out/auth/provider.js +1 -1
- package/out/auth-broker/config.js +8 -3
- package/out/auth-broker/pkce.js +3 -1
- package/out/auth-broker/server.js +43 -1
- package/out/auth-broker/store.js +6 -5
- package/out/cli/commands/ask.js +3 -0
- package/out/cli/commands/chat.js +3 -0
- package/out/cli/commands/deploy.js +49 -10
- package/out/cli/commands/gateway.js +54 -0
- package/out/cli/commands/idp.js +85 -0
- package/out/cli/commands/serve.js +401 -49
- package/out/cli/credential-context.js +47 -69
- package/out/cli/data-plane/broker.js +78 -0
- package/out/cli/data-plane/connector-host.js +121 -0
- package/out/cli/data-plane/egress.js +189 -0
- package/out/cli/data-plane/gateway.js +195 -0
- package/out/cli/data-plane/index.js +137 -0
- package/out/cli/data-plane/registry.js +80 -0
- package/out/cli/deploy/blueprints/registry.js +84 -0
- package/out/cli/deploy/layers/identity.js +91 -0
- package/out/cli/deploy-init.js +1156 -285
- package/out/cli/idp-init.js +303 -0
- package/out/cli/index.js +46 -8
- package/out/cli/pack-loader.js +37 -2
- package/out/cli/runtime.js +10 -3
- package/out/cli/serve-hardening.js +217 -12
- package/out/cli/state-sync.js +318 -29
- package/out/engines/claude.js +26 -5
- package/out/engines/codex.js +24 -5
- package/out/engines/custom.js +46 -12
- package/out/extension.js +2 -0
- package/out/gateway/outboundSend.js +6 -6
- package/out/headless/index.js +24 -6
- package/out/live-probe/connector.mjs +8 -0
- package/out/live-probe/env-report.mjs +8 -0
- package/out/mcp.js +88 -61
- package/out/remote/handleRemoteChat.js +7 -1
- package/out/remote/serveClient.js +27 -13
- package/out/scripts/analytics/agentide-litellm-otel.config.yaml +14 -0
- package/out/shared/models.js +1 -1
- package/out/shared/providers.js +5 -2
- package/out/test/analytics.test.js +93 -0
- package/out/test/auth-broker.test.js +59 -2
- package/out/test/blueprint-registry.test.js +105 -0
- package/out/test/credential-context.test.js +55 -0
- package/out/test/custom-env-layering.test.js +151 -0
- package/out/test/data-plane-engine-wiring.test.js +250 -0
- package/out/test/data-plane.test.js +244 -0
- package/out/test/deploy-init.test.js +287 -5
- package/out/test/egress-broker.test.js +172 -0
- package/out/test/fleetDisplayUtils.test.js +4 -0
- package/out/test/idp-init.test.js +151 -0
- package/out/test/mcp-obo.test.js +52 -27
- package/out/test/mcp.test.js +60 -4
- package/out/test/model-routing.test.js +133 -0
- package/out/test/serve-gateway-turn.test.js +267 -0
- package/out/test/serve-hardening.test.js +187 -0
- package/out/test/serveClient.test.js +37 -2
- package/out/test/state-sync-peer.test.js +83 -1
- package/out/utils.js +27 -11
- 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)
|
|
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);
|