@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
@@ -47,6 +47,8 @@ const path = __importStar(require("path"));
47
47
  const index_js_1 = require("@modelcontextprotocol/sdk/client/index.js");
48
48
  const streamableHttp_js_1 = require("@modelcontextprotocol/sdk/client/streamableHttp.js");
49
49
  const data_plane_1 = require("../cli/data-plane");
50
+ const data_plane_2 = require("../cli/commands/data-plane");
51
+ const identity_1 = require("../analytics/identity");
50
52
  // The fixture connector must live INSIDE the extension tree so its `import` of
51
53
  // @modelcontextprotocol/sdk resolves by walking up to extensions/agentide/node_modules.
52
54
  const FIXTURE_DIR = path.join(__dirname, 'data-plane-fixture');
@@ -297,6 +299,14 @@ suite('data-plane: sidecar EDGE registration (P1 fix — no forging authority in
297
299
  await turn.release();
298
300
  await assert.rejects(() => callWhoami(sidecar.gatewayUrl, nonce), /401|Unauthorized/i);
299
301
  });
302
+ /**
303
+ * PR-F1 / agent-identity.md §8.5 G1.
304
+ *
305
+ * The edge forwarded `X-Agent-User` and nothing else, and `/edge/turn` registered with no
306
+ * inboundToken — so edge mode silently supported the `identity` provider ALONE. A
307
+ * `passthrough` deployment (Databricks-style OBO) booted clean and then failed closed on
308
+ * every single tool call, and an RFC 8693 `exchange` provider had no subject_token at all.
309
+ */
300
310
  test('release with a bogus nonce is a harmless no-op (204) and touches no other turn', async () => {
301
311
  const res = await edgeRegister('carol@corp.com');
302
312
  const nonce = res.headers.get(data_plane_1.TURN_NONCE_HEADER);
@@ -309,3 +319,306 @@ suite('data-plane: sidecar EDGE registration (P1 fix — no forging authority in
309
319
  assert.strictEqual(who.userId, 'carol@corp.com', 'an unrelated release must not end a live turn');
310
320
  });
311
321
  });
322
+ /**
323
+ * PLAN/agent-identity.md §8.5 G1 (build record §10.5).
324
+ *
325
+ * The edge forwarded `X-Agent-User` (an identifier) and nothing else, and `/edge/turn` registered
326
+ * with no inboundToken. So edge mode silently supported the `identity` provider ALONE: a
327
+ * `passthrough` deployment (Databricks-style OBO) booted clean and then failed closed on every
328
+ * single tool call, and an RFC 8693 `exchange` provider had no `subject_token` to exchange at all.
329
+ *
330
+ * This suite runs a PASSTHROUGH sidecar behind the edge — the combination that could not work.
331
+ */
332
+ suite('data-plane: sidecar EDGE + passthrough (PR-F1 — the edge must forward the assertion)', function () {
333
+ this.timeout(30_000);
334
+ const EDGE_TOKEN = 'edge-assertion-token';
335
+ let sidecar;
336
+ let controlBase;
337
+ suiteSetup(async () => {
338
+ fs.mkdirSync(FIXTURE_DIR, { recursive: true });
339
+ fs.writeFileSync(CONNECTOR_PATH, CONNECTOR_SOURCE, 'utf8');
340
+ sidecar = await (0, data_plane_1.startDataPlaneSidecar)({
341
+ config: { connectorPath: CONNECTOR_PATH, provider: 'passthrough' },
342
+ registerToken: EDGE_TOKEN,
343
+ gatewayPort: 0,
344
+ controlPort: 0,
345
+ bindHost: '127.0.0.1',
346
+ });
347
+ controlBase = `http://127.0.0.1:${sidecar.controlPort}`;
348
+ });
349
+ suiteTeardown(async () => {
350
+ await sidecar?.close();
351
+ fs.rmSync(FIXTURE_DIR, { recursive: true, force: true });
352
+ });
353
+ const register = (user, assertion) => fetch(`${controlBase}/edge/turn`, {
354
+ headers: {
355
+ Authorization: `Bearer ${EDGE_TOKEN}`,
356
+ 'X-Agent-User': user,
357
+ ...(assertion ? { [data_plane_1.ASSERTION_HEADER]: assertion } : {}),
358
+ },
359
+ });
360
+ test('with the assertion forwarded, a passthrough turn reaches the backend as that user', async () => {
361
+ const res = await register('alice@corp.com', 'Bearer alice-platform-token');
362
+ assert.strictEqual(res.status, 200);
363
+ const who = await callWhoami(sidecar.gatewayUrl, res.headers.get(data_plane_1.TURN_NONCE_HEADER));
364
+ assert.strictEqual(who.userId, 'alice@corp.com');
365
+ assert.strictEqual(who.credential, 'alice-platform-token', 'the assertion becomes the turn inboundToken, and passthrough hands it to the connector');
366
+ });
367
+ test('a bare "Bearer " prefix is stripped — the connector gets the token, not the scheme', async () => {
368
+ const res = await register('bob@corp.com', 'Bearer bob-token');
369
+ const who = await callWhoami(sidecar.gatewayUrl, res.headers.get(data_plane_1.TURN_NONCE_HEADER));
370
+ assert.strictEqual(who.credential, 'bob-token');
371
+ });
372
+ test('an assertion sent WITHOUT the scheme prefix is taken verbatim', async () => {
373
+ const res = await register('carol@corp.com', 'carol-raw-token');
374
+ const who = await callWhoami(sidecar.gatewayUrl, res.headers.get(data_plane_1.TURN_NONCE_HEADER));
375
+ assert.strictEqual(who.credential, 'carol-raw-token');
376
+ });
377
+ test('WITHOUT the assertion the turn still registers, but every call fails closed — the pre-PR-F1 behaviour', async () => {
378
+ const res = await register('dave@corp.com');
379
+ assert.strictEqual(res.status, 200, 'registration itself must not depend on the assertion');
380
+ const { client, transport } = mcpClient(sidecar.gatewayUrl, res.headers.get(data_plane_1.TURN_NONCE_HEADER));
381
+ await client.connect(transport);
382
+ const out = await client.callTool({ name: 'whoami', arguments: {} });
383
+ await client.close();
384
+ assert.strictEqual(out.isError, true);
385
+ assert.match(out.content[0].text, /fail-closed/, 'no inbound credential must never fall back to a shared one');
386
+ });
387
+ });
388
+ /**
389
+ * PLAN/agent-identity.md §10.1 — the audit writer.
390
+ *
391
+ * The sidecar accepted an optional `audit` and `dataPlaneSidecarCommand` never passed one. Because
392
+ * every emit site is `opts.audit?.(…)`, the entire data-plane trust-boundary record was silently
393
+ * discarded in sidecar and edge mode — the two shapes the blueprints deploy. The compliance claim
394
+ * ("which user touched which rows") had no writer exactly where it was being made.
395
+ *
396
+ * These tests pin both halves: the sink the command builds actually writes, and the sidecar
397
+ * actually emits through it on the authorize / deny / reject paths.
398
+ */
399
+ suite('data-plane: sidecar audit sink (PR-0 — the record must have a writer)', function () {
400
+ this.timeout(30_000);
401
+ const TOKEN = 'audit-suite-token';
402
+ const AUDIT_ROOT = path.join(FIXTURE_DIR, 'audit-state');
403
+ let sidecar;
404
+ let controlBase;
405
+ let auditDir;
406
+ const rows = () => {
407
+ if (!fs.existsSync(auditDir)) {
408
+ return [];
409
+ }
410
+ return fs.readdirSync(auditDir)
411
+ .filter((f) => f.endsWith('.jsonl'))
412
+ .flatMap((f) => fs.readFileSync(path.join(auditDir, f), 'utf8')
413
+ .split('\n').filter(Boolean).map((l) => JSON.parse(l)));
414
+ };
415
+ // The writer is fire-and-forget (`void writer.append(...)`) so the audit write must never
416
+ // delay a tool call. Poll briefly rather than sleeping a fixed amount.
417
+ const waitForEvent = async (eventType,
418
+ // Without a predicate this matches the FIRST row of the type, which may be one an earlier
419
+ // test wrote — and then the row under test may not have landed yet. Pass a predicate
420
+ // whenever a specific row is meant.
421
+ where = () => true, timeoutMs = 5_000) => {
422
+ const deadline = Date.now() + timeoutMs;
423
+ for (;;) {
424
+ const hit = rows().find((r) => r.event_type === eventType && where(r.payload));
425
+ if (hit) {
426
+ return hit;
427
+ }
428
+ if (Date.now() > deadline) {
429
+ assert.fail(`no matching "${eventType}" event within ${timeoutMs}ms; saw: ${JSON.stringify(rows().map((r) => r.event_type))}`);
430
+ }
431
+ await new Promise((r) => setTimeout(r, 50));
432
+ }
433
+ };
434
+ suiteSetup(async () => {
435
+ fs.mkdirSync(FIXTURE_DIR, { recursive: true });
436
+ fs.writeFileSync(CONNECTOR_PATH, CONNECTOR_SOURCE, 'utf8');
437
+ const sink = await (0, data_plane_2.createDataPlaneAuditSink)(AUDIT_ROOT);
438
+ assert.strictEqual(sink.ok, true, sink.error);
439
+ assert.strictEqual(sink.ephemeral, false, 'an explicit state dir is durable');
440
+ auditDir = sink.auditDir;
441
+ sidecar = await (0, data_plane_1.startDataPlaneSidecar)({
442
+ config: { connectorPath: CONNECTOR_PATH, provider: 'identity' },
443
+ registerToken: TOKEN,
444
+ gatewayPort: 0,
445
+ controlPort: 0,
446
+ bindHost: '127.0.0.1',
447
+ audit: sink.audit,
448
+ });
449
+ controlBase = `http://127.0.0.1:${sidecar.controlPort}`;
450
+ });
451
+ suiteTeardown(async () => {
452
+ await sidecar?.close();
453
+ fs.rmSync(FIXTURE_DIR, { recursive: true, force: true });
454
+ });
455
+ test('an authorized tool call is recorded with the tool and the verified principal', async () => {
456
+ const res = await fetch(`${controlBase}/edge/turn`, {
457
+ headers: { Authorization: `Bearer ${TOKEN}`, 'X-Agent-User': 'dana@corp.com' },
458
+ });
459
+ const nonce = res.headers.get(data_plane_1.TURN_NONCE_HEADER);
460
+ const who = await callWhoami(sidecar.gatewayUrl, nonce);
461
+ assert.strictEqual(who.userId, 'dana@corp.com');
462
+ const ev = await waitForEvent('tool_authorize');
463
+ const payload = ev.payload;
464
+ assert.strictEqual(payload.tool, 'whoami');
465
+ assert.strictEqual(payload.userId, (0, identity_1.hashIdentity)('dana@corp.com'));
466
+ assert.strictEqual(payload.provider, 'identity');
467
+ });
468
+ test('an edge registration is recorded', async () => {
469
+ const ev = await waitForEvent('sidecar_edge_register');
470
+ assert.strictEqual(ev.payload.userId, (0, identity_1.hashIdentity)('dana@corp.com'));
471
+ });
472
+ /**
473
+ * These records ship wholesale to the evidence server, so whatever lands here leaves the
474
+ * machine. The tool-event lane already pseudonymises (ObservableStreamEmitter → hashIdentity);
475
+ * the data-plane lane must not be the one that exports raw addresses. In EDGE mode the sidecar
476
+ * registers with `sessionKey: userId`, so without this the evidence FILE is `alice@corp.com.jsonl`.
477
+ */
478
+ test('no raw address reaches the record — not in a payload, not in a filename', () => {
479
+ const files = fs.readdirSync(auditDir);
480
+ assert.ok(files.length > 0, 'the suite must have produced evidence by now');
481
+ for (const f of files) {
482
+ assert.ok(!f.includes('@'), `evidence filename must not carry an address: ${f}`);
483
+ }
484
+ const serialized = JSON.stringify(rows());
485
+ const anon = (0, identity_1.hashIdentity)('dana@corp.com');
486
+ assert.ok(anon, 'hashIdentity must yield a pseudonym for a non-empty address');
487
+ assert.ok(!serialized.includes('dana@corp.com'), 'a raw address must never reach the record');
488
+ assert.ok(serialized.includes(anon), 'the salted hash is the join key and must be present');
489
+ });
490
+ test('a rejected registration is recorded — the reject path is the one an auditor asks about', async () => {
491
+ const res = await fetch(`${controlBase}/edge/turn`, {
492
+ headers: { Authorization: 'Bearer wrong-token', 'X-Agent-User': 'mallory@corp.com' },
493
+ });
494
+ assert.strictEqual(res.status, 401);
495
+ const ev = await waitForEvent('sidecar_register_reject');
496
+ assert.strictEqual(ev.payload.reason, 'bad_token');
497
+ });
498
+ test('an unknown nonce at the gateway is recorded, and never logs the raw nonce', async () => {
499
+ await assert.rejects(() => callWhoami(sidecar.gatewayUrl, 'forged-nonce-value'), /401|Unauthorized/i);
500
+ const ev = await waitForEvent('nonce_reject');
501
+ const payload = ev.payload;
502
+ assert.strictEqual(payload.reason, 'unknown_or_expired');
503
+ assert.ok(payload.nonce_sha256_prefix, 'a hash prefix must be present');
504
+ const serialized = JSON.stringify(rows());
505
+ assert.ok(!serialized.includes('forged-nonce-value'), 'a raw nonce must never reach the audit record');
506
+ });
507
+ test('the assertion is fingerprinted in the record, never carried raw', async () => {
508
+ const secret = 'super-secret-user-assertion-value';
509
+ await fetch(`${controlBase}/edge/turn`, {
510
+ headers: { Authorization: `Bearer ${TOKEN}`, 'X-Agent-User': 'erin@corp.com', [data_plane_1.ASSERTION_HEADER]: `Bearer ${secret}` },
511
+ });
512
+ const ev = await waitForEvent('sidecar_edge_register', (p) => p.userId === (0, identity_1.hashIdentity)('erin@corp.com'));
513
+ assert.match(String(ev.payload.assertion), /^sha256:[0-9a-f]{12}$/, 'a hash prefix must prove which assertion the turn used');
514
+ assert.ok(!JSON.stringify(rows()).includes(secret), 'a user assertion is a bearer credential and must never reach the record');
515
+ });
516
+ test('an unwritable audit root reports ok:false rather than throwing, so the caller sets the policy', async () => {
517
+ // A path whose parent is a FILE — mkdir -p cannot succeed.
518
+ const blocker = path.join(FIXTURE_DIR, 'not-a-dir');
519
+ fs.writeFileSync(blocker, 'x', 'utf8');
520
+ const sink = await (0, data_plane_2.createDataPlaneAuditSink)(path.join(blocker, 'nested'));
521
+ assert.strictEqual(sink.ok, false);
522
+ assert.ok(sink.error, 'the failure reason must be reportable to the operator');
523
+ });
524
+ });
525
+ /**
526
+ * PLAN/agent-identity.md §10.6 — the audit stamp and the agent field.
527
+ *
528
+ * Design §1's third failure was "the auditor's second question is unanswerable" — *why could the
529
+ * invoice agent reach the HR schema at all*. PR-0 gave the record a writer, which made "which user
530
+ * touched which rows" answerable. It did not make "via which agent" answerable: rows carried
531
+ * `{tool, userId, provider}` and nothing about the pack. The agent was only ever IMPLIED, by which
532
+ * deployment happened to write the file — and that implication is exactly what breaks when two
533
+ * serves share one connector (§4.4).
534
+ */
535
+ suite('data-plane: every audit row names the AGENT, not just the user', function () {
536
+ this.timeout(30_000);
537
+ const TOKEN = 'agent-stamp-token';
538
+ const AUDIT_ROOT = path.join(FIXTURE_DIR, 'agent-stamp-state');
539
+ const LOADED = { pack: 'invoice-agent', version: '1.4.0' };
540
+ let sidecar;
541
+ let controlBase;
542
+ let auditDir;
543
+ const rows = () => (fs.existsSync(auditDir) ? fs.readdirSync(auditDir) : [])
544
+ .filter((f) => f.endsWith('.jsonl'))
545
+ .flatMap((f) => fs.readFileSync(path.join(auditDir, f), 'utf8')
546
+ .split('\n').filter(Boolean).map((l) => JSON.parse(l)));
547
+ const waitFor = async (pred) => {
548
+ const deadline = Date.now() + 5_000;
549
+ for (;;) {
550
+ const hit = rows().find(pred);
551
+ if (hit) {
552
+ return hit;
553
+ }
554
+ if (Date.now() > deadline) {
555
+ assert.fail(`no matching row; saw ${JSON.stringify(rows().map((r) => r.event_type))}`);
556
+ }
557
+ await new Promise((r) => setTimeout(r, 50));
558
+ }
559
+ };
560
+ suiteSetup(async () => {
561
+ fs.mkdirSync(FIXTURE_DIR, { recursive: true });
562
+ fs.writeFileSync(CONNECTOR_PATH, CONNECTOR_SOURCE, 'utf8');
563
+ const sink = await (0, data_plane_2.createDataPlaneAuditSink)(AUDIT_ROOT, LOADED);
564
+ auditDir = sink.auditDir;
565
+ sidecar = await (0, data_plane_1.startDataPlaneSidecar)({
566
+ config: { connectorPath: CONNECTOR_PATH, provider: 'identity' },
567
+ registerToken: TOKEN, gatewayPort: 0, controlPort: 0, bindHost: '127.0.0.1',
568
+ audit: sink.audit, agent: LOADED,
569
+ });
570
+ controlBase = `http://127.0.0.1:${sidecar.controlPort}`;
571
+ });
572
+ suiteTeardown(async () => {
573
+ await sidecar?.close();
574
+ fs.rmSync(FIXTURE_DIR, { recursive: true, force: true });
575
+ });
576
+ test('a tool call records WHICH AGENT served it — the auditor\'s second question', async () => {
577
+ const res = await fetch(`${controlBase}/edge/turn`, {
578
+ headers: { Authorization: `Bearer ${TOKEN}`, 'X-Agent-User': 'alice@corp.com' },
579
+ });
580
+ await callWhoami(sidecar.gatewayUrl, res.headers.get(data_plane_1.TURN_NONCE_HEADER));
581
+ const ev = await waitFor((r) => r.event_type === 'tool_authorize');
582
+ assert.deepStrictEqual(ev.payload.agent, { ...LOADED, digest: null }, 'without this the row says which USER touched the rows and nothing about the agent');
583
+ });
584
+ test('the sidecar claims no digest it has not verified', async () => {
585
+ const ev = await waitFor((r) => r.event_type === 'tool_authorize');
586
+ assert.strictEqual(ev.payload.agent.digest, null, 'pack-loader does not verify signatures in the sidecar; a digest here would dress an '
587
+ + 'unverified read as an attestation');
588
+ });
589
+ test('PR-8: a registration records the agent it CLAIMS, marked as asserted', async () => {
590
+ await fetch(`${controlBase}/turns`, {
591
+ method: 'POST',
592
+ headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
593
+ body: JSON.stringify({ userId: 'bob@corp.com', sessionKey: 's-claim', agent: { pack: 'invoice-agent' } }),
594
+ });
595
+ const ev = await waitFor((r) => r.event_type === 'sidecar_register' && r.payload.claimedAgent === 'invoice-agent');
596
+ assert.strictEqual(ev.payload.binding, 'asserted', 'the register token is shared, so the claim is only as good as whoever holds it — '
597
+ + 'evidence must never present it as verified');
598
+ assert.strictEqual(ev.payload.agentMismatch, undefined);
599
+ });
600
+ test('PR-8 / §4.4: a DIFFERENT agent on the same connector is flagged, not silently accepted', async () => {
601
+ // The invariant E2 rests on: one connector, one agent. It is unenforceable over a shared
602
+ // bearer, so the deliverable is that it becomes VISIBLE the moment it is violated.
603
+ const res = await fetch(`${controlBase}/turns`, {
604
+ method: 'POST',
605
+ headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
606
+ body: JSON.stringify({ userId: 'mallory@corp.com', sessionKey: 's-other', agent: { pack: 'hr-agent' } }),
607
+ });
608
+ assert.strictEqual(res.status, 200, 'it must still register — refusing on an unverifiable claim would let anyone with the '
609
+ + 'token deny service by lying about the pack');
610
+ const ev = await waitFor((r) => r.event_type === 'sidecar_register' && r.payload.claimedAgent === 'hr-agent');
611
+ assert.strictEqual(ev.payload.agentMismatch, true);
612
+ assert.strictEqual(ev.payload.loadedAgent, 'invoice-agent');
613
+ });
614
+ test('a registration with no agent claim still works — the field is optional', async () => {
615
+ const res = await fetch(`${controlBase}/turns`, {
616
+ method: 'POST',
617
+ headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
618
+ body: JSON.stringify({ userId: 'carol@corp.com', sessionKey: 's-none' }),
619
+ });
620
+ assert.strictEqual(res.status, 200);
621
+ const ev = await waitFor((r) => r.event_type === 'sidecar_register' && r.payload.claimedAgent === 'none');
622
+ assert.strictEqual(ev.payload.agentMismatch, undefined, 'absent is not a mismatch');
623
+ });
624
+ });
@@ -424,7 +424,7 @@ suite('deploy-init: renderTarget produces well-formed artifacts', () => {
424
424
  const rendered = svc
425
425
  .replace(/\$\{SERVICE\}/g, 'customer-service-edge')
426
426
  .replace(/\$\{IMAGE\}/g, 'us-docker.pkg.dev/x/y/z:latest')
427
- .replace(/\$\{LITELLM_IMAGE\}/g, 'ghcr.io/agenthippo/litellm-gateway:2026.06')
427
+ .replace(/\$\{LITELLM_IMAGE\}/g, 'ghcr.io/agenthippoai/agenthippo-litellm:2026.06')
428
428
  .replace(/\$\{PUBLIC_HOSTNAME\}/g, 'cs.agenthippo.ai')
429
429
  .replace(/\$\{ALLOWED_DOMAINS\}/g, 'agenthippo.ai');
430
430
  assert.doesNotThrow(() => yaml.load(rendered), 'rendered service.yaml is valid YAML');
@@ -684,6 +684,12 @@ suite('deploy-init: renderTarget produces well-formed artifacts', () => {
684
684
  const snippet = (0, deploy_init_1.renderTarget)('vm', ctx)['agent-route.caddy'];
685
685
  // Hygiene: a client-supplied nonce header must never pass through.
686
686
  assert.ok(snippet.includes('request_header -X-Agent-Turn-Nonce'), 'inbound X-Agent-Turn-Nonce is stripped at the edge');
687
+ // PR-F1 (agent-identity.md §8.5 G1): the edge must forward the user ASSERTION, not just
688
+ // the identifier, or the turn carries no inboundToken — `passthrough` then fails closed on
689
+ // every call and an `exchange` provider has no subject_token. Forged copies are stripped
690
+ // for the same reason as the nonce: this one becomes the turn's inboundToken.
691
+ assert.ok(snippet.includes('request_header -X-Agent-User-Assertion'), 'inbound X-Agent-User-Assertion is stripped at the edge');
692
+ assert.ok(snippet.includes('copy_headers X-Agent-User X-Agent-User-Assertion'), 'the verified assertion is copied from /api/ide/me alongside the identity');
687
693
  // Only POST chat turns register.
688
694
  assert.ok(snippet.includes('@turnStart'), 'chat-turn matcher present');
689
695
  assert.ok(/@turnStart\s*\{\s*\n\s*method POST\s*\n\s*path \/v1\/chat \/v1\/chat\/stream/m.test(snippet), '@turnStart matches POST /v1/chat and /v1/chat/stream only');
@@ -47,7 +47,12 @@ function fakeSandboxes(initial = [], supportsInFlightTermination = true) {
47
47
  destroyed: [],
48
48
  revoked: [],
49
49
  supportsInFlightTermination,
50
- sandboxes() { return state.live.map((sessionKey) => ({ sessionKey, sandboxId: `sandbox-${sessionKey}` })); },
50
+ // Session scope: one sandbox per session, so its key IS the session key.
51
+ sandboxes() {
52
+ return state.live.map((sessionKey) => ({
53
+ sandboxKey: sessionKey, sandboxId: `sandbox-${sessionKey}`, sessionKeys: [sessionKey],
54
+ }));
55
+ },
51
56
  async destroySession(sessionKey) {
52
57
  state.destroyed.push(sessionKey);
53
58
  state.live = state.live.filter((s) => s !== sessionKey);
@@ -47,6 +47,7 @@ const assert = __importStar(require("assert"));
47
47
  const caps_1 = require("../pack/caps");
48
48
  const caps_2 = require("../pack/caps");
49
49
  const lease_schema_1 = require("../pack/lease-schema");
50
+ const lease_1 = require("../pack/lease");
50
51
  const base = { pack: 'analyst', enabled: true, expiresAt: 1_800_003_600 };
51
52
  suite('lease validation — structural junk is refused, not trusted', () => {
52
53
  const rejected = [
@@ -63,6 +64,7 @@ suite('lease validation — structural junk is refused, not trusted', () => {
63
64
  ['an unknown deny scope', { ...base, deny: [{ scope: 'tenant', match: 'x', mode: 'kill' }] }],
64
65
  ['a deny rule with no match', { ...base, deny: [{ scope: 'pack', match: '', mode: 'kill' }] }],
65
66
  ['a non-boolean requireSignedPack', { ...base, requireSignedPack: 'yes' }],
67
+ ['a non-boolean allowServiceTurns', { ...base, allowServiceTurns: 'true' }],
66
68
  ['not an object at all', 'nope'],
67
69
  ];
68
70
  for (const [name, doc] of rejected) {
@@ -167,3 +169,56 @@ suite('deprecated cell vocabulary is aliased, loudly', () => {
167
169
  assert.strictEqual(caps.maxConcurrentSandboxes, 7);
168
170
  });
169
171
  });
172
+ /**
173
+ * PLAN/agent-identity.md §10.3 — the lease flag that governs unattended runs.
174
+ *
175
+ * Its polarity is the INVERSE of every other flag on the lease. `requireSignedPack` is
176
+ * tighten-only, so absent safely means "nobody asked for the restriction". `allowServiceTurns`
177
+ * GRANTS a standing unattended identity, so absent has to mean deny — otherwise a control plane
178
+ * that has not been taught the field silently authorises unsupervised runs across a fleet.
179
+ */
180
+ suite('lease: allowServiceTurns (PR-2 — a grant, so absent means deny)', () => {
181
+ test('parseLease carries a real boolean through', () => {
182
+ const res = (0, lease_schema_1.parseLease)({ ...base, allowServiceTurns: true });
183
+ assert.ok(res.ok);
184
+ assert.strictEqual(res.lease.allowServiceTurns, true);
185
+ });
186
+ test('the string "false" is refused, not coerced — it would flip deny into allow', () => {
187
+ // `Boolean("false") === true`. This is the single most dangerous coercion on the document,
188
+ // which is why the flag joined the existing strict-boolean loop rather than getting its own.
189
+ assert.strictEqual((0, lease_schema_1.parseLease)({ ...base, allowServiceTurns: 'false' }).ok, false);
190
+ });
191
+ test('an unknown field is ignored, so an OLDER serve simply never runs service turns', () => {
192
+ // Forward compatibility with no lockstep hazard: field-by-field parsing means a serve that
193
+ // predates the flag reads a lease containing it without erroring, and — because it never
194
+ // consults the flag — denies. Fail-safe in the skew direction that matters.
195
+ const res = (0, lease_schema_1.parseLease)({ ...base, someFutureField: { nested: true } });
196
+ assert.ok(res.ok);
197
+ assert.strictEqual(res.lease.someFutureField, undefined);
198
+ });
199
+ test('managed + lease says true ⇒ allowed', () => {
200
+ assert.deepStrictEqual((0, lease_1.resolveAllowServiceTurns)({ lease: true, managed: true }), { allowed: true, gate: 'lease_allows' });
201
+ });
202
+ test('managed + field absent ⇒ DENIED, and the reason distinguishes it from an explicit false', () => {
203
+ const absent = (0, lease_1.resolveAllowServiceTurns)({ lease: undefined, managed: true });
204
+ assert.strictEqual(absent.allowed, false);
205
+ assert.strictEqual(absent.gate, 'lease_absent_field');
206
+ const explicit = (0, lease_1.resolveAllowServiceTurns)({ lease: false, managed: true });
207
+ assert.strictEqual(explicit.allowed, false);
208
+ assert.strictEqual(explicit.gate, 'lease_denies');
209
+ // Same outcome, different explanation: an operator reading the audit row must be able to
210
+ // tell "nobody enabled it" from "somebody disabled it".
211
+ assert.notStrictEqual((0, lease_1.describeServiceTurnGate)(absent.gate), (0, lease_1.describeServiceTurnGate)(explicit.gate));
212
+ });
213
+ test('unmanaged (no control plane) ⇒ permitted, local env is the only gate', () => {
214
+ const r = (0, lease_1.resolveAllowServiceTurns)({ lease: undefined, managed: false });
215
+ assert.strictEqual(r.allowed, true);
216
+ assert.strictEqual(r.gate, 'unmanaged_no_lease');
217
+ assert.match((0, lease_1.describeServiceTurnGate)(r.gate), /AGENTHIPPO_SERVICE_USER/);
218
+ });
219
+ test('every gate has an operator-facing explanation naming what to change', () => {
220
+ for (const gate of ['lease_allows', 'unmanaged_no_lease', 'lease_absent_field', 'lease_denies']) {
221
+ assert.ok((0, lease_1.describeServiceTurnGate)(gate).length > 20, `${gate} needs a real explanation`);
222
+ }
223
+ });
224
+ });
@@ -0,0 +1,91 @@
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
+ const assert = __importStar(require("assert"));
40
+ const fs = __importStar(require("fs"));
41
+ const os = __importStar(require("os"));
42
+ const path = __importStar(require("path"));
43
+ const fleetRowDefaults_1 = require("../analytics/dashboard/fleetRowDefaults");
44
+ const localFleetAdapter_1 = require("../analytics/dashboard/localFleetAdapter");
45
+ suite('localFleetAdapter', () => {
46
+ test('mergeSessionJsonlIntoFleetRows adds completed one-shot session to Done', () => {
47
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fleet-jsonl-merge-'));
48
+ const sessionId = '2e156640-96ed-4106-8e75-44018d221758';
49
+ const scheduleId = 'sched-then-why';
50
+ const started = Date.now() - 2 * 60 * 60_000;
51
+ const ended = started + 60_000;
52
+ fs.writeFileSync(path.join(dir, `${sessionId}.jsonl`), [
53
+ JSON.stringify({ session_id: sessionId, request_id: 'req-1', timestamp: started, event_type: 'turn_start', payload: {} }),
54
+ JSON.stringify({ session_id: sessionId, request_id: 'req-1', timestamp: ended, event_type: 'turn_end', payload: { success: true } }),
55
+ ].join('\n') + '\n', 'utf8');
56
+ fs.utimesSync(path.join(dir, `${sessionId}.jsonl`), ended / 1000, ended / 1000);
57
+ const sessionsById = new Map();
58
+ sessionsById.set(`schedule:${scheduleId}`, (0, fleetRowDefaults_1.buildFleetSessionRow)('local', {
59
+ sessionId: `schedule:${scheduleId}`,
60
+ status: 'scheduled',
61
+ startedAt: new Date(started - 60_000).toISOString(),
62
+ endedAt: '',
63
+ taskTitle: 'then why',
64
+ model: 'agentide-cursor::auto',
65
+ engine: 'cursor',
66
+ channelType: 'none',
67
+ channelTarget: sessionId,
68
+ turns: 0,
69
+ totalTokens: 0,
70
+ disconnectCapable: false,
71
+ }));
72
+ const scheduleByChatSessionId = new Map([
73
+ [sessionId, {
74
+ id: scheduleId,
75
+ sessionId,
76
+ sessionResource: `vscode-chat-session://local/${sessionId}`,
77
+ messageText: 'then why',
78
+ runAt: started - 60_000,
79
+ model: 'agentide-cursor::auto',
80
+ engine: 'cursor',
81
+ }],
82
+ ]);
83
+ (0, localFleetAdapter_1.mergeSessionJsonlIntoFleetRows)(sessionsById, scheduleByChatSessionId, { scope: 'global' }, dir);
84
+ assert.strictEqual(sessionsById.has(`schedule:${scheduleId}`), false, 'schedule card should be replaced');
85
+ const row = sessionsById.get(sessionId);
86
+ assert.ok(row, 'expected chat session row');
87
+ assert.strictEqual(row.status, 'done');
88
+ assert.strictEqual(row.turns, 1);
89
+ assert.strictEqual(row.taskTitle, 'then why');
90
+ });
91
+ });
@@ -62,7 +62,7 @@ const IMAGE = 'ghcr.io/agenthippoai/pack@sha256:' + 'a'.repeat(64);
62
62
  const TEST_SOCK_DIR = path.join(os.tmpdir(), 'ah-sandbox-tests');
63
63
  function spec(over = {}) {
64
64
  return {
65
- sessionKey: 'sess-1',
65
+ sandboxKey: 'sess-1',
66
66
  image: IMAGE,
67
67
  tier: 2,
68
68
  routes: [
@@ -222,7 +222,7 @@ suite('container driver — the boundary it actually delivers', () => {
222
222
  const driver = new containerDriver_1.ContainerSandboxDriver({ exec, socketDir: TEST_SOCK_DIR, connectFn: () => stubChannel(), socketWaitMs: 0 });
223
223
  const h1 = await driver.create(spec());
224
224
  await driver.revoke(h1, 'all');
225
- const h2 = await driver.create(spec({ sessionKey: 'sess-2' }));
225
+ const h2 = await driver.create(spec({ sandboxKey: 'sess-2' }));
226
226
  await driver.destroy(h2);
227
227
  assert.strictEqual(calls.filter((c) => c[0] === 'rm').length, 2);
228
228
  });
@@ -56,7 +56,7 @@ function tmpdir() {
56
56
  }
57
57
  function spec(over = {}) {
58
58
  return {
59
- sessionKey: 'sess-1',
59
+ sandboxKey: 'sess-1',
60
60
  image: IMAGE,
61
61
  tier: 2,
62
62
  routes: [{ kind: 'inference', target: 'litellm:4000' }],
@@ -297,6 +297,17 @@ suite('provider key passthrough into a sandbox', () => {
297
297
  });
298
298
  assert.deepStrictEqual(Object.keys(env).sort(), ['PATH']);
299
299
  });
300
+ test('boot-stable route URLs cross into a tier-0 sandbox (MCP + inference proxy)', () => {
301
+ const env = (0, processDriver_1.baseSandboxEnv)({}, {
302
+ PATH: '/usr/bin',
303
+ AGENTHIPPO_DATA_MCP_URL: 'http://connector:9090/mcp',
304
+ AGENTHIPPO_LITELLM_BASE_URL: 'http://gateway:3456/v1',
305
+ ANTHROPIC_API_KEY: 'sk-ant',
306
+ });
307
+ assert.strictEqual(env.AGENTHIPPO_DATA_MCP_URL, 'http://connector:9090/mcp');
308
+ assert.strictEqual(env.AGENTHIPPO_LITELLM_BASE_URL, 'http://gateway:3456/v1');
309
+ assert.strictEqual(env.ANTHROPIC_API_KEY, undefined);
310
+ });
300
311
  test('a sandbox holding a provider key is flagged in its own environment', async () => {
301
312
  const spawned = [];
302
313
  const driver = new processDriver_1.ProcessSandboxDriver({
@@ -65,7 +65,7 @@ function fakeDriver(engineFor) {
65
65
  kind: 'process', tier: 0,
66
66
  async create(spec) {
67
67
  const id = `sandbox-${++seq}`;
68
- created.push(spec.sessionKey);
68
+ created.push(spec.sandboxKey);
69
69
  specs.push(spec);
70
70
  const toRunner = new node_stream_1.PassThrough();
71
71
  const toServe = new node_stream_1.PassThrough();