@agenthippo-ai/agenthippo 1.110.9 → 1.110.11

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 (50) hide show
  1. package/out/agentpack/index.js +3 -1
  2. package/out/agentpack/paths.js +25 -2
  3. package/out/analytics/dashboard/localFleetAdapter.js +82 -2
  4. package/out/analytics/service.js +6 -0
  5. package/out/analytics/sessionEvents.js +4 -3
  6. package/out/auth-broker/server.js +23 -19
  7. package/out/cli/commands/data-plane.js +84 -0
  8. package/out/cli/commands/sandbox.js +28 -9
  9. package/out/cli/commands/serve.js +289 -97
  10. package/out/cli/credential-context.js +17 -0
  11. package/out/cli/data-plane/index.js +2 -1
  12. package/out/cli/data-plane/sidecar.js +66 -2
  13. package/out/cli/deploy/layers/identity.js +5 -2
  14. package/out/cli/deploy-init.js +4 -4
  15. package/out/cli/index.js +16 -1
  16. package/out/cli/serve-hardening.js +181 -12
  17. package/out/engines/claude.js +1 -1
  18. package/out/engines/codex.js +1 -1
  19. package/out/engines/custom.js +7 -1
  20. package/out/evidence/sessionShip.js +50 -17
  21. package/out/headless/index.js +8 -1
  22. package/out/pack/governor.js +32 -15
  23. package/out/pack/lease-schema.js +4 -1
  24. package/out/pack/lease.js +42 -0
  25. package/out/runner/host.js +24 -14
  26. package/out/runner/protocol.js +24 -4
  27. package/out/runner/remoteEngine.js +53 -17
  28. package/out/sandbox/containerDriver.js +4 -2
  29. package/out/sandbox/processDriver.js +4 -1
  30. package/out/sandbox/sandboxRunner.js +148 -36
  31. package/out/scripts/agentide_litellm_hooks_v1.py +85 -1
  32. package/out/scripts/analytics/agentide-litellm-otel.config.yaml +4 -0
  33. package/out/scripts/analytics/setup-analytics.ps1 +8 -1
  34. package/out/scripts/analytics/setup-analytics.sh +13 -2
  35. package/out/test/caps-governor.test.js +6 -1
  36. package/out/test/data-plane-engine-wiring.test.js +32 -0
  37. package/out/test/data-plane.test.js +313 -0
  38. package/out/test/deploy-init.test.js +7 -1
  39. package/out/test/governor-hardening.test.js +6 -1
  40. package/out/test/lease-validation.test.js +55 -0
  41. package/out/test/localFleetAdapter.test.js +91 -0
  42. package/out/test/sandbox-driver.test.js +2 -2
  43. package/out/test/sandbox-hardening.test.js +12 -1
  44. package/out/test/sandbox-runner.test.js +1 -1
  45. package/out/test/sandbox-scope.test.js +449 -0
  46. package/out/test/serve-cron-acceptance.test.js +370 -0
  47. package/out/test/serve-gateway-turn.test.js +122 -0
  48. package/out/test/serve-hardening.test.js +182 -9
  49. package/out/test/sessionShip.test.js +58 -0
  50. package/package.json +1 -1
@@ -45,6 +45,7 @@ const os = __importStar(require("os"));
45
45
  const path = __importStar(require("path"));
46
46
  const stream_1 = require("stream");
47
47
  const serve_hardening_1 = require("../cli/serve-hardening");
48
+ const serve_hardening_2 = require("../cli/serve-hardening");
48
49
  suite('serve-hardening: resolveStateDir', () => {
49
50
  test('explicit --state-dir wins and is absolute and non-ephemeral', () => {
50
51
  const r = (0, serve_hardening_1.resolveStateDir)('./state', {});
@@ -202,6 +203,45 @@ suite('serve-hardening: resolveUserId', () => {
202
203
  const pinned = { AGENTHIPPO_IDENTITY_HEADER: 'x-goog-authenticated-user-email' };
203
204
  assert.deepStrictEqual((0, serve_hardening_1.identityHeaderAdvice)(pinned, '0.0.0.0'), []);
204
205
  });
206
+ test('a pinned multi-user serve on tier 0 is told its filesystem is shared', () => {
207
+ const env = { AGENTHIPPO_IDENTITY_HEADER: 'x-agent-user' };
208
+ const tier0 = (0, serve_hardening_1.tenantIsolationAdvice)({ sandbox: 'process', workspace: '/work', stateDir: '/state' }, env);
209
+ assert.strictEqual(tier0.level, 'warn');
210
+ assert.ok(tier0.lines.some((l) => /--sandbox=process/.test(l)));
211
+ assert.ok(tier0.lines.some((l) => /--sandbox=container/.test(l)), 'the advice must name the fix');
212
+ // No sandbox at all is the same shared filesystem, and is the DEFAULT deployment shape.
213
+ const none = (0, serve_hardening_1.tenantIsolationAdvice)({ workspace: '/work', stateDir: '/state' }, env);
214
+ assert.strictEqual(none.level, 'warn');
215
+ assert.ok(none.lines.some((l) => /no sandbox/.test(l)));
216
+ });
217
+ test('nesting the workspace inside the state dir is called out on top of the shared-fs warning', () => {
218
+ const env = { AGENTHIPPO_IDENTITY_HEADER: 'x-agent-user' };
219
+ const nested = (0, serve_hardening_1.tenantIsolationAdvice)({ sandbox: 'process', workspace: '/state/workspace', stateDir: '/state' }, env);
220
+ const separate = (0, serve_hardening_1.tenantIsolationAdvice)({ sandbox: 'process', workspace: '/work', stateDir: '/state' }, env);
221
+ assert.ok(nested.lines.length > separate.lines.length, 'the shipped-sample shape must say strictly more');
222
+ assert.ok(nested.lines.some((l) => /sits INSIDE --state-dir/.test(l)));
223
+ // The reverse nesting is the same exposure reached without any traversal at all.
224
+ const inverted = (0, serve_hardening_1.tenantIsolationAdvice)({ sandbox: 'process', workspace: '/state', stateDir: '/state/serve' }, env);
225
+ assert.ok(inverted.lines.some((l) => /sits INSIDE --workspace/.test(l)));
226
+ });
227
+ test('tenant-isolation advice is quiet where it has nothing to say', () => {
228
+ // Unpinned: identityHeaderAdvice owns that case, and two warnings bury the actionable one.
229
+ assert.deepStrictEqual((0, serve_hardening_1.tenantIsolationAdvice)({ sandbox: 'process', workspace: '/state/workspace', stateDir: '/state' }, {}), { level: 'ok', lines: [] });
230
+ // A container sandbox gets a per-session workspace and state dir — the nesting is then an
231
+ // implementation detail of paths the sandbox never sees whole.
232
+ const env = { AGENTHIPPO_IDENTITY_HEADER: 'x-agent-user' };
233
+ assert.strictEqual((0, serve_hardening_1.tenantIsolationAdvice)({ sandbox: 'container', workspace: '/state/workspace', stateDir: '/state' }, env).level, 'ok');
234
+ // …unless the operator opted back into one workspace for everyone.
235
+ assert.strictEqual((0, serve_hardening_1.tenantIsolationAdvice)({ sandbox: 'container', sharedWorkspace: true, workspace: '/work', stateDir: '/state' }, env).level, 'warn');
236
+ });
237
+ test('the refusal is opt-in, so an upgrade cannot brick a deployment that already shipped', () => {
238
+ const base = { sandbox: 'process', workspace: '/state/workspace', stateDir: '/state' };
239
+ const pinned = { AGENTHIPPO_IDENTITY_HEADER: 'x-agent-user' };
240
+ assert.strictEqual((0, serve_hardening_1.tenantIsolationAdvice)(base, pinned).level, 'warn');
241
+ assert.strictEqual((0, serve_hardening_1.tenantIsolationAdvice)(base, { ...pinned, [serve_hardening_1.REQUIRE_TENANT_ISOLATION_ENV]: '1' }).level, 'refuse');
242
+ // A deployment that fixed the isolation is not refused just for asking for the check.
243
+ assert.strictEqual((0, serve_hardening_1.tenantIsolationAdvice)({ sandbox: 'container', workspace: '/work', stateDir: '/state' }, { ...pinned, [serve_hardening_1.REQUIRE_TENANT_ISOLATION_ENV]: '1' }).level, 'ok');
244
+ });
205
245
  test('Databricks X-Forwarded-Email', () => {
206
246
  assert.strictEqual((0, serve_hardening_1.resolveUserId)({ 'x-forwarded-email': 'priya@meridian.com' }, undefined), 'priya@meridian.com');
207
247
  });
@@ -227,6 +267,39 @@ suite('serve-hardening: resolveUserId', () => {
227
267
  assert.strictEqual((0, serve_hardening_1.resolveUserId)({}, undefined), undefined);
228
268
  assert.strictEqual((0, serve_hardening_1.resolveUserId)({}, { user_id: ' ' }, { AGENTHIPPO_ALLOW_BODY_IDENTITY: '1' }), undefined);
229
269
  });
270
+ /**
271
+ * IDENTITY_PROXY_SECRET_ENV: pinning declares WHICH header carries identity, not WHO may
272
+ * assert it. On a shared network any container that can dial serve's port can send the
273
+ * pinned header; when the secret is configured, identity requires the request to also carry
274
+ * the proxy's proof header — the same contract the auth-broker enforces for
275
+ * X-Auth-Request-Email.
276
+ */
277
+ suite('identity proxy secret (AGENTHIPPO_IDENTITY_PROXY_SECRET)', () => {
278
+ const env = {
279
+ AGENTHIPPO_IDENTITY_HEADER: 'x-agent-user',
280
+ AGENTHIPPO_IDENTITY_PROXY_SECRET: 'shhh',
281
+ };
282
+ test('a pinned header WITHOUT proof runs anonymous — the network-neighbour spoof', () => {
283
+ assert.strictEqual((0, serve_hardening_1.resolveUserId)({ 'x-agent-user': 'victim@corp.com' }, undefined, env), undefined);
284
+ });
285
+ test('a wrong secret is a missing secret', () => {
286
+ assert.strictEqual((0, serve_hardening_1.resolveUserId)({ 'x-agent-user': 'victim@corp.com', 'x-agenthippo-proxy-secret': 'nope' }, undefined, env), undefined);
287
+ });
288
+ test('the trusted proxy (correct proof) resolves identity as before', () => {
289
+ assert.strictEqual((0, serve_hardening_1.resolveUserId)({ 'x-agent-user': 'alice@corp.com', 'x-agenthippo-proxy-secret': 'shhh' }, undefined, env), 'alice@corp.com');
290
+ });
291
+ test('proof gates the alias list and the body fallback too, not just the pin', () => {
292
+ const unpinned = { AGENTHIPPO_IDENTITY_PROXY_SECRET: 'shhh', AGENTHIPPO_ALLOW_BODY_IDENTITY: '1' };
293
+ assert.strictEqual((0, serve_hardening_1.resolveUserId)({ 'x-forwarded-email': 'a@b.com' }, undefined, unpinned), undefined);
294
+ // A deployment that configured the secret has declared a real proxy; a caller without
295
+ // proof must not be able to name a victim via the client-controlled body either.
296
+ assert.strictEqual((0, serve_hardening_1.resolveUserId)({}, { user_id: 'bob' }, unpinned), undefined);
297
+ });
298
+ test('unset secret keeps existing behaviour — the gate is opt-in', () => {
299
+ const pinnedOnly = { AGENTHIPPO_IDENTITY_HEADER: 'x-agent-user' };
300
+ assert.strictEqual((0, serve_hardening_1.resolveUserId)({ 'x-agent-user': 'alice@corp.com' }, undefined, pinnedOnly), 'alice@corp.com');
301
+ });
302
+ });
230
303
  test('AGENTHIPPO_IDENTITY_HEADER pins trust to one header — spoofed aliases are ignored', () => {
231
304
  const env = { AGENTHIPPO_IDENTITY_HEADER: 'x-goog-authenticated-user-email' };
232
305
  // IAP strips X-Goog-* but forwards x-agent-user; pinning must ignore the spoof.
@@ -382,37 +455,137 @@ suite('serve-hardening: readBodyWithCap', () => {
382
455
  suite('serve-hardening: resolveServeSessionKey (secure-obo-design §3.4 Phase 0)', () => {
383
456
  const PINNED = { AGENTHIPPO_IDENTITY_HEADER: 'x-forwarded-email' };
384
457
  test('verified user + session_id → user-namespaced key', () => {
385
- const r = (0, serve_hardening_1.resolveServeSessionKey)('chat-1', 'alice@corp.com', PINNED);
458
+ const r = (0, serve_hardening_1.resolveServeSessionKey)('chat-1', 'alice@corp.com', 'session', PINNED);
386
459
  assert.strictEqual(r.sessionKey, `u${(0, serve_hardening_1.userNamespaceHash)('alice@corp.com')}--chat-1`);
387
460
  assert.strictEqual(r.rejected, undefined);
388
461
  });
389
462
  test('two users presenting the SAME session_id can never share a key (the defect this closes)', () => {
390
- const a = (0, serve_hardening_1.resolveServeSessionKey)('chat-1', 'alice@corp.com', PINNED);
391
- const b = (0, serve_hardening_1.resolveServeSessionKey)('chat-1', 'bob@corp.com', PINNED);
463
+ const a = (0, serve_hardening_1.resolveServeSessionKey)('chat-1', 'alice@corp.com', 'session', PINNED);
464
+ const b = (0, serve_hardening_1.resolveServeSessionKey)('chat-1', 'bob@corp.com', 'session', PINNED);
392
465
  assert.notStrictEqual(a.sessionKey, b.sessionKey);
393
466
  });
394
467
  test('verified user without session_id → stable per-user default key', () => {
395
- const r1 = (0, serve_hardening_1.resolveServeSessionKey)(undefined, 'alice@corp.com', PINNED);
396
- const r2 = (0, serve_hardening_1.resolveServeSessionKey)('', 'alice@corp.com', PINNED);
468
+ const r1 = (0, serve_hardening_1.resolveServeSessionKey)(undefined, 'alice@corp.com', 'session', PINNED);
469
+ const r2 = (0, serve_hardening_1.resolveServeSessionKey)('', 'alice@corp.com', 'session', PINNED);
397
470
  assert.strictEqual(r1.sessionKey, r2.sessionKey);
398
471
  assert.strictEqual(r1.sessionKey, `u${(0, serve_hardening_1.userNamespaceHash)('alice@corp.com')}--default`);
399
472
  });
400
473
  test('pinned identity + session_id but NO verified user → fail closed', () => {
401
- const r = (0, serve_hardening_1.resolveServeSessionKey)('chat-1', undefined, PINNED);
474
+ const r = (0, serve_hardening_1.resolveServeSessionKey)('chat-1', undefined, 'session', PINNED);
402
475
  assert.strictEqual(r.rejected, serve_hardening_1.SESSION_WITHOUT_USER_REASON);
403
476
  });
477
+ test('pinned identity + NO session_id + no verified user → fail closed (no shared anon bucket)', () => {
478
+ const r = (0, serve_hardening_1.resolveServeSessionKey)(undefined, undefined, 'session', PINNED);
479
+ assert.strictEqual(r.rejected, serve_hardening_1.SESSION_WITHOUT_USER_REASON);
480
+ assert.strictEqual(r.sessionKey, '');
481
+ });
404
482
  test('unpinned (local dev) keeps raw session_id behavior', () => {
405
- const r = (0, serve_hardening_1.resolveServeSessionKey)('chat-1', undefined, {});
483
+ const r = (0, serve_hardening_1.resolveServeSessionKey)('chat-1', undefined, 'session', {});
406
484
  assert.strictEqual(r.sessionKey, 'chat-1');
407
485
  assert.strictEqual(r.rejected, undefined);
408
486
  });
409
487
  test('unpinned, no user, no session_id → legacy anon bucket', () => {
410
- const r = (0, serve_hardening_1.resolveServeSessionKey)(undefined, undefined, {});
488
+ const r = (0, serve_hardening_1.resolveServeSessionKey)(undefined, undefined, 'session', {});
411
489
  assert.strictEqual(r.sessionKey, 'anon-unknown');
412
490
  });
413
491
  test('the key never embeds the raw user id (emails are not path segments)', () => {
414
- const r = (0, serve_hardening_1.resolveServeSessionKey)('s', 'alice@corp.com', PINNED);
492
+ const r = (0, serve_hardening_1.resolveServeSessionKey)('s', 'alice@corp.com', 'session', PINNED);
415
493
  assert.ok(!r.sessionKey.includes('alice@corp.com'));
416
494
  assert.ok(!r.sessionKey.includes('@'));
417
495
  });
418
496
  });
497
+ suite('serve-hardening: sandbox scope (what one sandbox holds)', () => {
498
+ const PINNED = { AGENTHIPPO_IDENTITY_HEADER: 'x-forwarded-email' };
499
+ test('session scope: the sandbox key IS the session key — today\'s behaviour, unchanged', () => {
500
+ const r = (0, serve_hardening_1.resolveServeSessionKey)('chat-1', 'alice@corp.com', 'session', PINNED);
501
+ assert.strictEqual(r.sandboxKey, r.sessionKey);
502
+ });
503
+ test('user scope: one user\'s sessions share a sandbox key but keep distinct session keys', () => {
504
+ const a = (0, serve_hardening_1.resolveServeSessionKey)('chat-1', 'alice@corp.com', 'user', PINNED);
505
+ const b = (0, serve_hardening_1.resolveServeSessionKey)('chat-2', 'alice@corp.com', 'user', PINNED);
506
+ assert.strictEqual(a.sandboxKey, b.sandboxKey, 'one container for this user');
507
+ assert.notStrictEqual(a.sessionKey, b.sessionKey, 'but two conversations inside it');
508
+ assert.strictEqual(a.sandboxKey, `u${(0, serve_hardening_1.userNamespaceHash)('alice@corp.com')}`);
509
+ });
510
+ test('user scope never merges two DIFFERENT users', () => {
511
+ const alice = (0, serve_hardening_1.resolveServeSessionKey)('chat-1', 'alice@corp.com', 'user', PINNED);
512
+ const bob = (0, serve_hardening_1.resolveServeSessionKey)('chat-1', 'bob@corp.com', 'user', PINNED);
513
+ assert.notStrictEqual(alice.sandboxKey, bob.sandboxKey);
514
+ });
515
+ test('user scope does NOT apply without a verified user', () => {
516
+ // Merging unidentified callers would group strangers on a fact nobody established.
517
+ const a = (0, serve_hardening_1.resolveServeSessionKey)('chat-1', undefined, 'user', {});
518
+ const b = (0, serve_hardening_1.resolveServeSessionKey)('chat-2', undefined, 'user', {});
519
+ assert.strictEqual(a.sandboxKey, a.sessionKey);
520
+ assert.notStrictEqual(a.sandboxKey, b.sandboxKey);
521
+ });
522
+ test('a rejected turn yields no sandbox key to create anything with', () => {
523
+ const r = (0, serve_hardening_1.resolveServeSessionKey)('chat-1', undefined, 'user', PINNED);
524
+ assert.strictEqual(r.rejected, serve_hardening_1.SESSION_WITHOUT_USER_REASON);
525
+ assert.strictEqual(r.sandboxKey, '');
526
+ });
527
+ test('the sandbox key never embeds the raw user id either', () => {
528
+ const r = (0, serve_hardening_1.resolveServeSessionKey)('s', 'alice@corp.com', 'user', PINNED);
529
+ assert.ok(!r.sandboxKey.includes('alice@corp.com'));
530
+ assert.ok(!r.sandboxKey.includes('@'));
531
+ });
532
+ });
533
+ /**
534
+ * PLAN/agent-identity.md §5.2 (build record §10.4).
535
+ *
536
+ * Shape, not vocabulary. An earlier draft required `^svc:[a-z0-9-]+$` and would have refused to
537
+ * boot on the two handles an enterprise operator is most likely to bind — a SPIFFE ID and an Entra
538
+ * agent object id — which are precisely the federated principals §8 wants this to grow into.
539
+ */
540
+ suite('resolveServiceIdentity: the unattended-run identity (PR-3)', () => {
541
+ const env = (v) => (v === undefined ? {} : { [serve_hardening_2.SERVICE_USER_ENV]: v });
542
+ test('unset is not an error — it just means unattended runs are off', () => {
543
+ const r = (0, serve_hardening_2.resolveServiceIdentity)({});
544
+ assert.strictEqual(r.identity, undefined);
545
+ assert.strictEqual(r.error, undefined);
546
+ assert.strictEqual(r.warning, undefined);
547
+ });
548
+ test('the local svc: convention is accepted silently', () => {
549
+ const r = (0, serve_hardening_2.resolveServiceIdentity)(env('svc:invoice-agent'));
550
+ assert.strictEqual(r.identity, 'svc:invoice-agent');
551
+ assert.strictEqual(r.warning, undefined);
552
+ });
553
+ test('a SPIFFE ID is accepted — the earlier ^svc: rule would have refused to boot on it', () => {
554
+ const r = (0, serve_hardening_2.resolveServiceIdentity)(env('spiffe://corp/ns/agents/invoice'));
555
+ assert.strictEqual(r.identity, 'spiffe://corp/ns/agents/invoice');
556
+ assert.strictEqual(r.error, undefined);
557
+ assert.strictEqual(r.warning, undefined);
558
+ });
559
+ test('an Entra agent object id (bare GUID) is accepted', () => {
560
+ const guid = '3f2504e0-4f89-11d3-9a0c-0305e82c3301';
561
+ const r = (0, serve_hardening_2.resolveServiceIdentity)(env(guid));
562
+ assert.strictEqual(r.identity, guid);
563
+ assert.strictEqual(r.warning, undefined);
564
+ });
565
+ test('an unnamespaced value still works, but warns about collision with a real user id', () => {
566
+ const r = (0, serve_hardening_2.resolveServiceIdentity)(env('invoicebot'));
567
+ assert.strictEqual(r.identity, 'invoicebot');
568
+ assert.strictEqual(r.error, undefined);
569
+ assert.match(r.warning ?? '', /namespace marker/);
570
+ });
571
+ test('set-but-empty is a boot REFUSAL, not a silently anonymous fleet', () => {
572
+ for (const v of ['', ' ']) {
573
+ const r = (0, serve_hardening_2.resolveServiceIdentity)(env(v));
574
+ assert.ok(r.error, `${JSON.stringify(v)} must be refused`);
575
+ assert.strictEqual(r.identity, undefined);
576
+ }
577
+ });
578
+ test('whitespace inside the value is refused rather than silently split or trimmed', () => {
579
+ const r = (0, serve_hardening_2.resolveServiceIdentity)(env('svc:invoice agent'));
580
+ assert.ok(r.error);
581
+ assert.match(r.error, /whitespace/);
582
+ });
583
+ test('an absurdly long value is refused — that is a pasted file, not an identity', () => {
584
+ const r = (0, serve_hardening_2.resolveServiceIdentity)(env(`svc:${'a'.repeat(300)}`));
585
+ assert.ok(r.error);
586
+ assert.match(r.error, /maximum/);
587
+ });
588
+ test('surrounding whitespace is trimmed rather than refused', () => {
589
+ assert.strictEqual((0, serve_hardening_2.resolveServiceIdentity)(env(' svc:nightly ')).identity, 'svc:nightly');
590
+ });
591
+ });
@@ -272,6 +272,25 @@ suite('Evidence - sessionShip URL validation', () => {
272
272
  }
273
273
  assert.strictEqual((0, sessionShip_1.getEvidenceShipStatus)().disabledReason, 'invalid-url');
274
274
  });
275
+ test('compose-internal http resolves when allow-insecure is opted in', () => {
276
+ const cfg = (0, sessionShip_1.resolveSessionShipConfig)('i', '/s', '/st', {
277
+ AGENTHIPPO_EVIDENCE_URL: 'http://evidence:8787',
278
+ AGENTHIPPO_EVIDENCE_SHIP_TOKEN: 'tok',
279
+ AGENTHIPPO_EVIDENCE_ALLOW_INSECURE_URL: '1',
280
+ });
281
+ assert.ok(cfg);
282
+ assert.strictEqual(cfg.evidenceUrl, 'http://evidence:8787');
283
+ assert.strictEqual(cfg.allowInsecure, true);
284
+ });
285
+ test('EVIDENCE_ALLOW_INSECURE_URL alone also enables compose-internal http', () => {
286
+ const cfg = (0, sessionShip_1.resolveSessionShipConfig)('i', '/s', '/st', {
287
+ AGENTHIPPO_EVIDENCE_URL: 'http://evidence:8787',
288
+ AGENTHIPPO_EVIDENCE_SHIP_TOKEN: 'tok',
289
+ EVIDENCE_ALLOW_INSECURE_URL: '1',
290
+ });
291
+ assert.ok(cfg);
292
+ assert.strictEqual(cfg.allowInsecure, true);
293
+ });
275
294
  });
276
295
  // -----------------------------------------------------------------------------
277
296
  // Fix 1 / fix 3 — redaction applies to EVERY lane, not just `seat`
@@ -557,4 +576,43 @@ suite('Evidence - sessionShip runtime', () => {
557
576
  assert.strictEqual(handle.status().recordsShipped, 0, 'nothing may be marked shipped');
558
577
  assert.strictEqual(handle.status().lastOkAt, undefined);
559
578
  });
579
+ test('compose-internal http creates a shipper when allowInsecure is set', async function () {
580
+ this.timeout(15000);
581
+ await fs_1.promises.writeFile(path.join(sessionsDir, 'sess.jsonl'), `${sessionLine()}\n`);
582
+ const warnings = [];
583
+ const handle = (0, sessionShip_1.startSessionShipper)({
584
+ evidenceUrl: 'http://evidence:8787',
585
+ allowInsecure: true,
586
+ shipToken: 'test-token',
587
+ instanceId: 'test-instance',
588
+ sessionsDir,
589
+ stateDir,
590
+ intervalSec: 3600,
591
+ sinceDays: 30,
592
+ logger: { info: () => undefined, warn: (m) => warnings.push(m) },
593
+ });
594
+ await handle.flush();
595
+ handle.stop();
596
+ assert.ok(!warnings.some((w) => w.includes('must use https')), `shipper init rejected compose-internal http: ${JSON.stringify(warnings)}`);
597
+ assert.ok(handle.status().consecutiveFailures > 0, 'drain fails without a server, but shipper was created');
598
+ });
599
+ test('R-13 heartbeat is still attempted when shipper init fails', async function () {
600
+ this.timeout(15000);
601
+ await fs_1.promises.writeFile(path.join(sessionsDir, 'sess.jsonl'), `${sessionLine()}\n`);
602
+ const warnings = [];
603
+ const handle = (0, sessionShip_1.startSessionShipper)({
604
+ evidenceUrl: 'http://evidence:8787',
605
+ shipToken: 'test-token',
606
+ instanceId: 'test-instance',
607
+ sessionsDir,
608
+ stateDir,
609
+ intervalSec: 3600,
610
+ sinceDays: 30,
611
+ logger: { info: () => undefined, warn: (m) => warnings.push(m) },
612
+ });
613
+ await handle.flush();
614
+ handle.stop();
615
+ assert.ok(warnings.some((w) => w.includes('must use https') || w.includes('plaintext http')), `expected shipper URL rejection: ${JSON.stringify(warnings)}`);
616
+ assert.ok(warnings.some((w) => w.includes('heartbeat failed')), `heartbeat must still be attempted in finally: ${JSON.stringify(warnings)}`);
617
+ });
560
618
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agenthippo-ai/agenthippo",
3
- "version": "1.110.9",
3
+ "version": "1.110.11",
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",