@agenthippo-ai/agenthippo 0.1.0 → 0.1.1

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.
@@ -43,6 +43,7 @@ const os = __importStar(require("os"));
43
43
  const fs_1 = require("fs");
44
44
  const base_1 = require("../engines/base");
45
45
  const sessionEvents_1 = require("../analytics/sessionEvents");
46
+ const credential_context_1 = require("../cli/credential-context");
46
47
  async function createUniqueTempDir(prefix) {
47
48
  return fs_1.promises.mkdtemp(path.join(os.tmpdir(), prefix));
48
49
  }
@@ -522,3 +523,70 @@ suite('Analytics - ObservableStreamEmitter', () => {
522
523
  assert.notStrictEqual(reqId1, reqId2, 'Request IDs should be different');
523
524
  });
524
525
  });
526
+ // =============================================================================
527
+ // ObservableStreamEmitter + credential context (the "warrant" audit trail)
528
+ // =============================================================================
529
+ suite('Analytics - ObservableStreamEmitter records the acting user (the warrant)', () => {
530
+ let testDir;
531
+ let writer;
532
+ let innerEmitter;
533
+ setup(async () => {
534
+ testDir = await createUniqueTempDir('agentide-warrant-test-');
535
+ writer = new sessionEvents_1.SessionEventWriter(testDir);
536
+ innerEmitter = {
537
+ async thinking() { },
538
+ async text() { },
539
+ async toolStart() { },
540
+ async toolEnd() { },
541
+ async progress() { },
542
+ async question() { return { selected: [], freeText: null }; },
543
+ async error() { },
544
+ async done() { },
545
+ };
546
+ });
547
+ teardown(async () => {
548
+ try {
549
+ await removeDirWithRetries(testDir);
550
+ }
551
+ catch {
552
+ // ignore
553
+ }
554
+ });
555
+ async function readEvents(sessionId) {
556
+ const content = await fs_1.promises.readFile(path.join(testDir, `${sessionId}.jsonl`), 'utf8');
557
+ return content.trim().split('\n').map((l) => JSON.parse(l));
558
+ }
559
+ test('toolStart/toolEnd record userId when inside a runWithCredential scope', async () => {
560
+ const emitter = (0, sessionEvents_1.wrapEmitterWithObservability)(innerEmitter, writer, 'sess-warrant-1', 'req-1');
561
+ await (0, credential_context_1.runWithCredential)({ userId: 'priya@meridian.com' }, async () => {
562
+ await emitter.toolStart('Bash', 'echo hi', 'c1');
563
+ await emitter.toolEnd('Bash', 'c1', 'hi', false);
564
+ });
565
+ const events = await readEvents('sess-warrant-1');
566
+ assert.strictEqual(events.length, 2);
567
+ assert.strictEqual(events[0].payload.userId, 'priya@meridian.com');
568
+ assert.strictEqual(events[1].payload.userId, 'priya@meridian.com');
569
+ });
570
+ test('toolStart records no userId outside any credential scope (IDE-local usage)', async () => {
571
+ const emitter = (0, sessionEvents_1.wrapEmitterWithObservability)(innerEmitter, writer, 'sess-warrant-2', 'req-1');
572
+ await emitter.toolStart('Bash', 'echo hi', 'c1');
573
+ const events = await readEvents('sess-warrant-2');
574
+ assert.strictEqual(events[0].payload.userId, undefined);
575
+ });
576
+ test('two interleaved turns never attribute a tool call to the wrong user', async () => {
577
+ const emitter = (0, sessionEvents_1.wrapEmitterWithObservability)(innerEmitter, writer, 'sess-warrant-3', 'req-1');
578
+ await Promise.all([
579
+ (0, credential_context_1.runWithCredential)({ userId: 'alice@acme.com' }, async () => {
580
+ await new Promise((r) => setTimeout(r, 5));
581
+ await emitter.toolStart('Bash', 'a', 'a1');
582
+ }),
583
+ (0, credential_context_1.runWithCredential)({ userId: 'bob@acme.com' }, async () => {
584
+ await emitter.toolStart('Bash', 'b', 'b1');
585
+ }),
586
+ ]);
587
+ const events = await readEvents('sess-warrant-3');
588
+ const byCallId = Object.fromEntries(events.map((e) => [e.payload.callId, e.payload.userId]));
589
+ assert.strictEqual(byCallId['a1'], 'alice@acme.com');
590
+ assert.strictEqual(byCallId['b1'], 'bob@acme.com');
591
+ });
592
+ });
@@ -154,7 +154,10 @@ suite('AuthBroker', () => {
154
154
  code_challenge: (0, pkce_js_1.codeChallengeFromVerifier)((0, pkce_js_1.generateCodeVerifier)()),
155
155
  }),
156
156
  });
157
- assert.strictEqual(res.status, 500);
157
+ assert.strictEqual(res.status, 403);
158
+ const body = await res.json();
159
+ assert.strictEqual(body.error, 'forbidden');
160
+ assert.ok(body.error_description?.includes('domain'));
158
161
  }
159
162
  finally {
160
163
  await broker.close();
@@ -198,7 +201,8 @@ suite('AuthBroker', () => {
198
201
  assert.strictEqual(refreshRes.status, 200);
199
202
  const body = await refreshRes.json();
200
203
  assert.strictEqual(body.routing.litellmApiKey, 'ahk_test_key');
201
- assert.ok(body.routing.litellmApiKeyExpiresAt);
204
+ // Static team key — the broker must not fabricate an expiry it can't enforce.
205
+ assert.strictEqual(body.routing.litellmApiKeyExpiresAt, undefined);
202
206
  }
203
207
  finally {
204
208
  await broker.close();
@@ -41,6 +41,9 @@ var __importStar = (this && this.__importStar) || (function () {
41
41
  Object.defineProperty(exports, "__esModule", { value: true });
42
42
  require("mocha");
43
43
  const assert = __importStar(require("assert"));
44
+ const fs = __importStar(require("fs"));
45
+ const os = __importStar(require("os"));
46
+ const path = __importStar(require("path"));
44
47
  const credential_context_1 = require("../cli/credential-context");
45
48
  const serve_hardening_1 = require("../cli/serve-hardening");
46
49
  suite('credential-context: presence + fail-closed', () => {
@@ -74,6 +77,63 @@ suite('credential-context: presence + fail-closed', () => {
74
77
  });
75
78
  });
76
79
  });
80
+ suite('credential-context: unresolved identity (userId is optional, never a placeholder)', () => {
81
+ test('no userId, no data token: runs fine, requireUserId throws inside the scope', async () => {
82
+ const seen = await (0, credential_context_1.runWithCredential)({ userId: undefined }, async () => {
83
+ assert.throws(() => (0, credential_context_1.requireUserId)(), /fail-closed/);
84
+ return 'ran';
85
+ });
86
+ assert.strictEqual(seen, 'ran');
87
+ });
88
+ test('a data token without a verified userId fails closed before the turn runs', async () => {
89
+ let ran = false;
90
+ await assert.rejects(() => (0, credential_context_1.runWithCredential)({ userId: undefined, dataToken: 'obo-secret' }, async () => {
91
+ ran = true;
92
+ }), /OBO data token present without a verified user/);
93
+ assert.strictEqual(ran, false, 'fn must never run when the credential fails closed');
94
+ });
95
+ test('a data token without userId fails closed even when dataTokenDir would write a file', async () => {
96
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ah-cred-'));
97
+ try {
98
+ await assert.rejects(() => (0, credential_context_1.runWithCredential)({ userId: undefined, dataToken: 'obo-secret' }, async () => 'ran', { dataTokenDir: dir }), /fail-closed/);
99
+ assert.deepStrictEqual(fs.readdirSync(dir), [], 'no token file must be written when fail-closed');
100
+ }
101
+ finally {
102
+ fs.rmSync(dir, { recursive: true, force: true });
103
+ }
104
+ });
105
+ test('onDenied fires with the stable reason code exactly when the credential fails closed', async () => {
106
+ const denials = [];
107
+ await assert.rejects(() => (0, credential_context_1.runWithCredential)({ userId: undefined, dataToken: 'obo-secret' }, async () => 'ran', { onDenied: (reason) => { denials.push(reason); } }));
108
+ assert.deepStrictEqual(denials, [credential_context_1.WARRANT_DENIED_REASON]);
109
+ });
110
+ test('onDenied does not fire on any success path (verified user, or no token at all)', async () => {
111
+ const denials = [];
112
+ const onDenied = (reason) => { denials.push(reason); };
113
+ await (0, credential_context_1.runWithCredential)({ userId: 'priya@meridian.com', dataToken: 'tok' }, async () => 'ran', { onDenied });
114
+ await (0, credential_context_1.runWithCredential)({ userId: undefined }, async () => 'ran', { onDenied });
115
+ assert.deepStrictEqual(denials, []);
116
+ });
117
+ test('a throwing onDenied callback is swallowed — the fail-closed rejection still wins', async () => {
118
+ await assert.rejects(() => (0, credential_context_1.runWithCredential)({ userId: undefined, dataToken: 'obo-secret' }, async () => 'ran', { onDenied: () => { throw new Error('logging backend is down'); } }), /fail-closed/);
119
+ });
120
+ test('a verified userId with a data token still writes the per-turn token file normally', async () => {
121
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ah-cred-'));
122
+ try {
123
+ let fileDuringRun;
124
+ await (0, credential_context_1.runWithCredential)({ userId: 'priya@meridian.com', dataToken: 'obo-secret' }, async () => {
125
+ fileDuringRun = (0, credential_context_1.currentCredential)()?.dataTokenFile;
126
+ assert.ok(fileDuringRun, 'dataTokenFile must be set inside the scope');
127
+ assert.strictEqual(fs.readFileSync(fileDuringRun, 'utf8'), 'obo-secret');
128
+ }, { dataTokenDir: dir });
129
+ assert.ok(fileDuringRun);
130
+ assert.strictEqual(fs.existsSync(fileDuringRun), false, 'token file must be cleaned up after the turn');
131
+ }
132
+ finally {
133
+ fs.rmSync(dir, { recursive: true, force: true });
134
+ }
135
+ });
136
+ });
77
137
  suite('credential-context: concurrent isolation (the load-bearing property)', () => {
78
138
  test('two interleaved turns never see each other\'s user', async () => {
79
139
  // Interleave alice and bob with deliberate awaits; each must keep its own identity.
@@ -115,6 +115,7 @@ suite('deploy-init: renderEnvExample', () => {
115
115
  assert.ok(out.includes('OAUTH2_PROXY_REDIRECT_URL='));
116
116
  assert.ok(out.includes('OAUTH2_PROXY_REVERSE_PROXY=true'));
117
117
  assert.ok(out.includes('AGENTHIPPO_AUTH_JWT_SECRET='));
118
+ assert.ok(out.includes('AGENTHIPPO_AUTH_DEV=0'), 'AUTH_DEV must default off in generated auth env');
118
119
  });
119
120
  test('renderAuthEnvExample honors custom hostname', () => {
120
121
  const out = (0, deploy_init_1.renderAuthEnvExample)({ hostname: 'agents.example.com' });
@@ -220,7 +221,7 @@ suite('deploy-init: renderTarget produces well-formed artifacts', () => {
220
221
  assert.deepStrictEqual(app.user_authorization?.scopes, ['sql']);
221
222
  const pkg = JSON.parse(files['package.json']);
222
223
  assert.ok(pkg.dependencies['@anthropic-ai/claude-code']);
223
- assert.ok(pkg.dependencies['@agenthippo-ai/cli']);
224
+ assert.ok(pkg.dependencies['@agenthippo-ai/agenthippo']);
224
225
  });
225
226
  test('YAML artifacts parse', () => {
226
227
  assert.doesNotThrow(() => yaml.load((0, deploy_init_1.renderTarget)('vm', CTX)['docker-compose.yml']));
@@ -286,6 +287,11 @@ suite('deploy-init: renderTarget produces well-formed artifacts', () => {
286
287
  assert.ok(!caddy.includes('oauth2-proxy:4180') && !caddy.includes('auth-broker:9780') && !caddy.includes('agent:8080'), 'edge Caddyfile must use localhost upstreams, not docker-DNS names');
287
288
  assert.ok(caddy.includes('localhost:4180') && caddy.includes('localhost:9780') && caddy.includes('localhost:8090'), 'edge Caddyfile routes to oauth2-proxy / auth-broker / agent on localhost');
288
289
  assert.ok(caddy.includes(':{$PORT}') && caddy.includes('auto_https off'), 'Caddy listens on HTTP :$PORT (Cloud Run terminates TLS)');
290
+ // IDE PKCE authorize must be SSO-gated before the catch-all /oauth/* → broker route.
291
+ assert.ok(caddy.includes('handle /oauth/authorize {'), 'edge Caddyfile gates /oauth/authorize');
292
+ assert.ok(/handle \/oauth\/authorize \{[\s\S]*?forward_auth localhost:4180[\s\S]*?copy_headers X-Auth-Request-Email/.test(caddy), '/oauth/authorize forward_auth copies X-Auth-Request-Email via localhost oauth2-proxy');
293
+ assert.ok(caddy.indexOf('handle /oauth/authorize {') < caddy.indexOf('handle /oauth/* {'), '/oauth/authorize must precede the catch-all /oauth/* handle');
294
+ assert.ok(caddy.includes('handle /oauth/browser-login {'), 'edge Caddyfile gates /oauth/browser-login');
289
295
  // Caddyfile is mounted from a Secret (the deploy script upserts it).
290
296
  assert.ok(svc.includes('${SERVICE}-caddyfile'), 'Caddyfile mounted from a secret');
291
297
  // auth-broker reuses the agent image (CLI ships `auth-broker`).
@@ -393,6 +399,24 @@ suite('deploy-init: renderTarget produces well-formed artifacts', () => {
393
399
  const env = (0, deploy_init_1.renderTarget)('cloudrun', ctx)['.env.example'];
394
400
  assert.ok(env.includes('CURSOR_API_KEY='));
395
401
  });
402
+ test('cloudrun IAP paths pin AGENTHIPPO_IDENTITY_HEADER so X-Agent-User cannot spoof identity', () => {
403
+ // Claude/codex → Spotlight multi-container path (renderCloudrunServiceYaml).
404
+ const spotlight = (0, deploy_init_1.renderTarget)('cloudrun', CTX);
405
+ assert.ok(spotlight['service.yaml'].includes('AGENTHIPPO_IDENTITY_HEADER'), 'Spotlight service.yaml must declare AGENTHIPPO_IDENTITY_HEADER');
406
+ assert.ok(spotlight['cloudrun-deploy.sh'].includes('x-goog-authenticated-user-email'), 'Spotlight deploy script must fill the pin with the IAP header when ENABLE_IAP=1');
407
+ // Cursor → T1-generic single-container path.
408
+ const cursorCtx = { ...CTX, engine: 'cursor', model: 'auto', image: 'ghcr.io/agenthippo/runtime:latest-cursor' };
409
+ const generic = (0, deploy_init_1.renderTarget)('cloudrun', cursorCtx);
410
+ assert.ok(generic['cloudrun-deploy.sh'].includes('AGENTHIPPO_IDENTITY_HEADER=x-goog-authenticated-user-email'), 'T1-generic deploy script must pin AGENTHIPPO_IDENTITY_HEADER to the IAP header');
411
+ // Both must skip the pin when ENABLE_IAP=0 (IAP never sets the header then — pinning
412
+ // unconditionally would make every request resolve to anonymous during bootstrap).
413
+ assert.ok(spotlight['cloudrun-deploy.sh'].includes('ENABLE_IAP'), 'Spotlight pin must be ENABLE_IAP-conditional');
414
+ assert.ok(generic['cloudrun-deploy.sh'].includes('IDENTITY_ENV='), 'T1-generic pin must be ENABLE_IAP-conditional');
415
+ });
416
+ test('databricks-app pins AGENTHIPPO_IDENTITY_HEADER to x-forwarded-email', () => {
417
+ const start = (0, deploy_init_1.renderTarget)('databricks-app', CTX)['start.js'];
418
+ assert.ok(start.includes("AGENTHIPPO_IDENTITY_HEADER: 'x-forwarded-email'"), 'Databricks App start.js must pin identity to the workspace SSO header');
419
+ });
396
420
  test('spcs egress is limited to the model API via one EAI', () => {
397
421
  const sql = (0, deploy_init_1.renderTarget)('spcs', CTX)['deploy.sql'];
398
422
  assert.ok(sql.includes('api.anthropic.com:443'));
@@ -411,10 +435,17 @@ suite('deploy-init: renderTarget produces well-formed artifacts', () => {
411
435
  assert.ok(compose.includes('./www:/srv/www'));
412
436
  assert.ok(files['run.sh'].includes('127.0.0.1:8080'));
413
437
  assert.ok(files['run-edge-tunnel.sh'].includes('--profile edge --profile tunnel'));
438
+ assert.ok(files['Caddyfile'].includes('lb_policy header X-Agent-User'));
414
439
  assert.ok(files['Caddyfile'].includes('reverse_proxy agent:8080'));
415
440
  assert.ok(files['Caddyfile'].includes('{$AGENTHIPPO_PUBLIC_HOSTNAME}'));
416
441
  assert.ok(files['Caddyfile'].includes('@health path /health /ready'));
417
442
  assert.ok(!files['Caddyfile'].includes('@api path /v1/* /health /ready'));
443
+ // IDE PKCE authorize must be SSO-gated (oauth2-proxy → X-Auth-Request-Email), not dumped via /oauth/*.
444
+ const caddy = files['Caddyfile'];
445
+ assert.ok(caddy.includes('handle /oauth/authorize {'), 'Caddyfile gates /oauth/authorize');
446
+ assert.ok(/handle \/oauth\/authorize \{[\s\S]*?forward_auth oauth2-proxy:4180[\s\S]*?copy_headers X-Auth-Request-Email/.test(caddy), '/oauth/authorize forward_auth copies X-Auth-Request-Email');
447
+ assert.ok(caddy.indexOf('handle /oauth/authorize {') < caddy.indexOf('handle /oauth/* {'), '/oauth/authorize must precede the catch-all /oauth/* handle');
448
+ assert.ok(caddy.includes('handle /oauth/browser-login {'), 'Caddyfile gates /oauth/browser-login');
418
449
  assert.ok(files['www/index.html'].includes('<form id="chat">'));
419
450
  // In-compose config-file tunnel (the proven-live path): cloudflared container → caddy:80.
420
451
  assert.ok(files['cloudflared.yml'].includes('http://caddy:80'), 'in-compose cloudflared reaches Caddy on the docker network');
@@ -225,10 +225,26 @@ suite('MCP Server Loading', () => {
225
225
  assert.ok(result.includes('[mcp_servers.test-server]'));
226
226
  assert.ok(result.includes('command = "node"'));
227
227
  assert.ok(result.includes('args = ["/path/to/server.js"]'));
228
- assert.ok(result.includes('API_KEY = "secret"'));
228
+ assert.ok(result.includes('"API_KEY" = "secret"'));
229
229
  // No approval line by default — interactive IDE keeps Codex's normal prompts.
230
230
  assert.ok(!result.includes('default_tools_approval_mode'));
231
231
  });
232
+ test('env keys are quoted so names with dots/spaces stay valid TOML', () => {
233
+ const servers = [
234
+ {
235
+ name: 'test-server',
236
+ label: 'Test Server',
237
+ command: 'node',
238
+ args: ['s.js'],
239
+ env: { 'my.token': 'a', 'NPM CONFIG': 'b' },
240
+ },
241
+ ];
242
+ const result = (0, mcp_1.generateCodexMcpConfig)(servers);
243
+ assert.ok(result.includes('"my.token" = "a"'));
244
+ assert.ok(result.includes('"NPM CONFIG" = "b"'));
245
+ // A bare (unquoted) dotted key would be misparsed as a nested table.
246
+ assert.ok(!result.includes('my.token = '));
247
+ });
232
248
  test('autoApprove emits default_tools_approval_mode = "auto" per server', () => {
233
249
  const servers = [
234
250
  { name: 'stdio-srv', label: 's', command: 'node', args: ['s.js'] },
@@ -83,6 +83,18 @@ suite('serve-hardening: clientIp', () => {
83
83
  test('uses the right-most XFF entry (the IP the trusted proxy observed)', () => {
84
84
  assert.strictEqual((0, serve_hardening_1.clientIp)(reqWith({ 'x-forwarded-for': 'evil-spoof, 198.51.100.7' })), '198.51.100.7');
85
85
  });
86
+ test('AGENTHIPPO_XFF_TRUSTED_HOPS=2 skips the tunnel hop (cloudflared → Caddy)', () => {
87
+ const req = reqWith({ 'x-forwarded-for': '198.51.100.7, 172.18.0.5' });
88
+ assert.strictEqual((0, serve_hardening_1.clientIp)(req, { AGENTHIPPO_XFF_TRUSTED_HOPS: '2' }), '198.51.100.7');
89
+ });
90
+ test('trusted hops beyond the XFF length clamps to the left-most entry', () => {
91
+ const req = reqWith({ 'x-forwarded-for': '198.51.100.7' });
92
+ assert.strictEqual((0, serve_hardening_1.clientIp)(req, { AGENTHIPPO_XFF_TRUSTED_HOPS: '5' }), '198.51.100.7');
93
+ });
94
+ test('invalid trusted hops falls back to 1', () => {
95
+ const req = reqWith({ 'x-forwarded-for': 'evil-spoof, 198.51.100.7' });
96
+ assert.strictEqual((0, serve_hardening_1.clientIp)(req, { AGENTHIPPO_XFF_TRUSTED_HOPS: 'nope' }), '198.51.100.7');
97
+ });
86
98
  });
87
99
  suite('serve-hardening: RateLimiter', () => {
88
100
  test('allows up to the cap within a window then rejects', () => {
@@ -133,6 +145,16 @@ suite('serve-hardening: resolveUserId', () => {
133
145
  assert.strictEqual((0, serve_hardening_1.resolveUserId)({}, undefined), undefined);
134
146
  assert.strictEqual((0, serve_hardening_1.resolveUserId)({}, { user_id: ' ' }), undefined);
135
147
  });
148
+ test('AGENTHIPPO_IDENTITY_HEADER pins trust to one header — spoofed aliases are ignored', () => {
149
+ const env = { AGENTHIPPO_IDENTITY_HEADER: 'x-goog-authenticated-user-email' };
150
+ // IAP strips X-Goog-* but forwards x-agent-user; pinning must ignore the spoof.
151
+ 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);
152
+ assert.strictEqual(id, 'marco@meridian.com');
153
+ });
154
+ test('pinned header absent → fails closed (never the alias list, never the body)', () => {
155
+ const env = { AGENTHIPPO_IDENTITY_HEADER: 'x-goog-authenticated-user-email' };
156
+ assert.strictEqual((0, serve_hardening_1.resolveUserId)({ 'x-agent-user': 'spoofed@evil.com' }, { user_id: 'bob' }, env), undefined);
157
+ });
136
158
  });
137
159
  suite('serve-hardening: corsHeaders', () => {
138
160
  test('always wide-open (serve is always behind a proxy)', () => {
@@ -169,6 +191,91 @@ suite('serve-hardening: TurnLimiter', () => {
169
191
  assert.strictEqual(lim.tryConsume('s1'), true);
170
192
  });
171
193
  });
194
+ suite('serve-hardening: TurnGate', () => {
195
+ test('runs immediately under the inflight cap', async () => {
196
+ const gate = new serve_hardening_1.TurnGate(2, 4, 1_000);
197
+ const a = await gate.acquire();
198
+ const b = await gate.acquire();
199
+ assert.ok(a);
200
+ assert.ok(b);
201
+ assert.strictEqual(gate.inflight, 2);
202
+ assert.strictEqual(gate.queued, 0);
203
+ a();
204
+ b();
205
+ assert.strictEqual(gate.inflight, 0);
206
+ });
207
+ test('queues when at capacity and wakes FIFO on release', async () => {
208
+ const gate = new serve_hardening_1.TurnGate(1, 4, 5_000);
209
+ const first = await gate.acquire();
210
+ assert.ok(first);
211
+ assert.strictEqual(gate.inflight, 1);
212
+ let secondResolved = false;
213
+ const secondPromise = gate.acquire().then((release) => {
214
+ secondResolved = true;
215
+ return release;
216
+ });
217
+ // Let the microtask queue settle so the waiter is enqueued.
218
+ await new Promise((r) => setImmediate(r));
219
+ assert.strictEqual(secondResolved, false);
220
+ assert.strictEqual(gate.queued, 1);
221
+ first();
222
+ const second = await secondPromise;
223
+ assert.strictEqual(secondResolved, true);
224
+ assert.ok(second);
225
+ assert.strictEqual(gate.inflight, 1);
226
+ assert.strictEqual(gate.queued, 0);
227
+ second();
228
+ assert.strictEqual(gate.inflight, 0);
229
+ });
230
+ test('rejects immediately when the queue is full', async () => {
231
+ const gate = new serve_hardening_1.TurnGate(1, 1, 5_000);
232
+ const held = await gate.acquire();
233
+ assert.ok(held);
234
+ const waiting = gate.acquire(); // fills the single queue slot
235
+ await new Promise((r) => setImmediate(r));
236
+ assert.strictEqual(gate.queued, 1);
237
+ const rejected = await gate.acquire();
238
+ assert.strictEqual(rejected, null);
239
+ held();
240
+ const released = await waiting;
241
+ assert.ok(released);
242
+ released();
243
+ });
244
+ test('times out a waiter and returns null', async () => {
245
+ const gate = new serve_hardening_1.TurnGate(1, 4, 30);
246
+ const held = await gate.acquire();
247
+ assert.ok(held);
248
+ const timedOut = await gate.acquire();
249
+ assert.strictEqual(timedOut, null);
250
+ assert.strictEqual(gate.queued, 0);
251
+ held();
252
+ assert.strictEqual(gate.inflight, 0);
253
+ });
254
+ test('zero maxInflight disables the gate', async () => {
255
+ const gate = new serve_hardening_1.TurnGate(0, 0, 1_000);
256
+ const releases = [];
257
+ for (let i = 0; i < 50; i++) {
258
+ const r = await gate.acquire();
259
+ assert.ok(r);
260
+ releases.push(r);
261
+ }
262
+ assert.strictEqual(gate.inflight, 0);
263
+ for (const r of releases) {
264
+ r();
265
+ }
266
+ });
267
+ test('createTurnGateFromEnv honors env defaults and overrides', () => {
268
+ const def = (0, serve_hardening_1.createTurnGateFromEnv)({});
269
+ assert.strictEqual(def.max, 10);
270
+ const custom = (0, serve_hardening_1.createTurnGateFromEnv)({ AGENTHIPPO_MAX_CONCURRENT_TURNS: '3', AGENTHIPPO_TURN_QUEUE: '7' });
271
+ assert.strictEqual(custom.max, 3);
272
+ const disabled = (0, serve_hardening_1.createTurnGateFromEnv)({ AGENTHIPPO_MAX_CONCURRENT_TURNS: '0' });
273
+ assert.strictEqual(disabled.max, 0);
274
+ });
275
+ test('Retry-After constant matches the default queue timeout', () => {
276
+ assert.strictEqual(serve_hardening_1.TURN_GATE_RETRY_AFTER_SECONDS, 15);
277
+ });
278
+ });
172
279
  function fakeReq(body) {
173
280
  const stream = new stream_1.PassThrough();
174
281
  stream.end(Buffer.from(body, 'utf8'));
@@ -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,11 @@ 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' });
162
165
  resolve();
163
166
  }
164
167
  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,72 @@ 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 + agents', 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
+ fs.mkdirSync(path.join(tmp, 'agents', 'pack', 'claude'), { recursive: true });
101
+ fs.writeFileSync(path.join(tmp, 'agents', 'pack', 'claude', 'marker'), 'x');
102
+ const staging = path.join(tmp, 'staging');
103
+ const ok = await (0, state_sync_1.stageSessionPushDir)(tmp, 'sess-a', staging);
104
+ assert.strictEqual(ok, true);
105
+ assert.ok(fs.existsSync(path.join(staging, 'provider-sessions', f1)));
106
+ assert.ok(!fs.existsSync(path.join(staging, 'provider-sessions', f2)));
107
+ assert.ok(fs.existsSync(path.join(staging, 'agents', 'pack', 'claude', 'marker')));
108
+ const shards = await (0, state_sync_1.listSessionShardFiles)(tmp, 'sess-a');
109
+ assert.deepStrictEqual(shards, [f1]);
110
+ assert.strictEqual(await (0, state_sync_1.hasLocalSessionState)(tmp, 'sess-a'), true);
111
+ assert.strictEqual(await (0, state_sync_1.hasLocalSessionState)(tmp, 'sess-missing'), false);
112
+ });
113
+ test('mergeSessionStagingIntoState copies shards and agents', async () => {
114
+ const staging = path.join(tmp, 'from');
115
+ const dest = path.join(tmp, 'to');
116
+ fs.mkdirSync(path.join(staging, 'provider-sessions'), { recursive: true });
117
+ fs.writeFileSync(path.join(staging, 'provider-sessions', 'abc.json'), JSON.stringify({ key: 'claude:s1:p:s', value: { chatSessionId: 's1' } }));
118
+ fs.mkdirSync(path.join(staging, 'agents', 'p'), { recursive: true });
119
+ fs.writeFileSync(path.join(staging, 'agents', 'p', 'f'), '1');
120
+ await (0, state_sync_1.mergeSessionStagingIntoState)(staging, dest);
121
+ assert.ok(fs.existsSync(path.join(dest, 'provider-sessions', 'abc.json')));
122
+ assert.ok(fs.existsSync(path.join(dest, 'agents', 'p', 'f')));
123
+ });
124
+ });
56
125
  suite('peerToUserId (#4 — channel sender → per-user id)', () => {
57
126
  test('undefined for no peer', () => {
58
127
  assert.strictEqual((0, serve_hardening_1.peerToUserId)(undefined), undefined);
package/package.json CHANGED
@@ -1,16 +1,15 @@
1
1
  {
2
2
  "name": "@agenthippo-ai/agenthippo",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "AgentHippo CLI — serve, chat, and deploy agent packs",
5
5
  "author": "AgentHippo <dev@agenthippo.ai>",
6
6
  "license": "SEE LICENSE IN LICENSE",
7
7
  "repository": {
8
8
  "type": "git",
9
- "url": "https://github.com/agenthippoai/agenthippo-vscode.git",
10
- "directory": "extensions/agentide"
9
+ "url": "https://github.com/agenthippoai/agenthippo-store.git"
11
10
  },
12
11
  "homepage": "https://agenthippo.ai",
13
- "bugs": "https://github.com/agenthippoai/agenthippo-vscode/issues",
12
+ "bugs": "https://github.com/agenthippoai/agenthippo-store/issues",
14
13
  "engines": {
15
14
  "node": ">=20"
16
15
  },