@agenthippo-ai/agenthippo 0.2.3-dataplane → 1.110.9

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 (130) hide show
  1. package/out/analytics/dashboard/provider.js +13 -2
  2. package/out/analytics/dashboard/webview/panel.js +3 -2
  3. package/out/analytics/identity.js +0 -0
  4. package/out/analytics/service.js +23 -25
  5. package/out/analytics/sessionEvents.js +98 -23
  6. package/out/auth/authBaseUrl.js +59 -0
  7. package/out/auth/bootstrapApply.js +9 -10
  8. package/out/auth/brokerClient.js +48 -14
  9. package/out/auth/provider.js +8 -21
  10. package/out/auth/sessionStore.js +3 -40
  11. package/out/auth/signInFlow.js +2 -1
  12. package/out/auth-broker/config.js +50 -5
  13. package/out/auth-broker/server.js +122 -26
  14. package/out/auth-broker/store.js +25 -24
  15. package/out/authority/warrant.js +206 -0
  16. package/out/branding.js +27 -1
  17. package/out/cli/commands/conformance.js +249 -0
  18. package/out/cli/commands/data-plane.js +67 -0
  19. package/out/cli/commands/deploy.js +1 -0
  20. package/out/cli/commands/gateway.js +4 -0
  21. package/out/cli/commands/pack.js +182 -3
  22. package/out/cli/commands/sandbox.js +332 -0
  23. package/out/cli/commands/serve.js +875 -52
  24. package/out/cli/commands/store.js +12 -4
  25. package/out/cli/data-plane/gateway.js +28 -5
  26. package/out/cli/data-plane/index.js +17 -2
  27. package/out/cli/data-plane/registry.js +9 -0
  28. package/out/cli/data-plane/remote.js +106 -0
  29. package/out/cli/data-plane/sidecar.js +161 -0
  30. package/out/cli/deploy/layers/identity.js +85 -4
  31. package/out/cli/deploy-init.js +89 -24
  32. package/out/cli/deps.js +106 -2
  33. package/out/cli/idp-init.js +35 -0
  34. package/out/cli/index.js +155 -16
  35. package/out/cli/pack-install-ref.js +15 -0
  36. package/out/cli/pack-loader.js +8 -1
  37. package/out/cli/pack-runtime.js +61 -0
  38. package/out/cli/runtime.js +35 -1
  39. package/out/cli/serve-hardening.js +80 -3
  40. package/out/cli/utils.js +12 -1
  41. package/out/engines/base.js +32 -3
  42. package/out/engines/claude.js +33 -15
  43. package/out/engines/codex.js +110 -26
  44. package/out/engines/custom.js +8 -0
  45. package/out/evidence/sessionShip.js +610 -0
  46. package/out/extension.js +11 -2
  47. package/out/gateway/bridge.js +40 -2
  48. package/out/gateway/client.js +34 -1
  49. package/out/gateway/installSource.js +68 -0
  50. package/out/gateway/lifecycle.js +185 -51
  51. package/out/gateway/outboundPaths.js +4 -1
  52. package/out/gateway/secretsPreflight.js +90 -0
  53. package/out/gateway/service.js +7 -2
  54. package/out/headless/index.js +17 -1
  55. package/out/live-e2e/mcp-demo/server.mjs +32 -0
  56. package/out/mcp.js +30 -0
  57. package/out/pack/caps.js +271 -0
  58. package/out/pack/governor.js +309 -0
  59. package/out/pack/lease-schema.js +270 -0
  60. package/out/pack/lease.js +640 -0
  61. package/out/pack/reconcile.js +217 -0
  62. package/out/pack/signature.js +839 -0
  63. package/out/remote/handleRemoteChat.js +44 -3
  64. package/out/remote/remoteTrust.js +277 -0
  65. package/out/remote/serveClient.js +52 -5
  66. package/out/runner/host.js +131 -0
  67. package/out/runner/protocol.js +108 -0
  68. package/out/runner/remoteEngine.js +130 -0
  69. package/out/runtime/interfaces/edit-tracker.js +2 -0
  70. package/out/runtime/vscode/vscode-edit-tracker.js +2 -0
  71. package/out/sandbox/containerDriver.js +570 -0
  72. package/out/sandbox/processDriver.js +315 -0
  73. package/out/sandbox/sandboxRunner.js +191 -0
  74. package/out/sandbox/support.js +301 -0
  75. package/out/sandbox/types.js +55 -0
  76. package/out/scripts/agentide-litellm.sh +67 -5
  77. package/out/scripts/agentide_litellm_hooks_v1.py +182 -21
  78. package/out/scripts/analytics/agentide-litellm-otel.config.yaml +94 -132
  79. package/out/scripts/analytics/setup-analytics.ps1 +37 -13
  80. package/out/scripts/analytics/setup-analytics.sh +93 -13
  81. package/out/scripts/analytics/start-analytics.sh +86 -14
  82. package/out/scripts/gateway/setup-gateway.sh +72 -32
  83. package/out/shared/models.js +11 -110
  84. package/out/spotlight/install.js +7 -1
  85. package/out/store/index.js +41 -10
  86. package/out/sync.js +3 -1
  87. package/out/test/analytics.test.js +162 -23
  88. package/out/test/auth-baseurl.test.js +132 -0
  89. package/out/test/auth-bootstrapApply.test.js +27 -6
  90. package/out/test/auth-broker-hardening.test.js +227 -0
  91. package/out/test/auth-broker.test.js +59 -9
  92. package/out/test/branding.test.js +13 -0
  93. package/out/test/caps-governor.test.js +455 -0
  94. package/out/test/codex-new-file-survival.test.js +119 -0
  95. package/out/test/configuration-scopes.test.js +131 -0
  96. package/out/test/data-plane.test.js +67 -0
  97. package/out/test/deploy-init.test.js +40 -0
  98. package/out/test/gateway-client.test.js +28 -0
  99. package/out/test/gateway-install-source.test.js +111 -0
  100. package/out/test/gateway-lifecycle.test.js +1 -1
  101. package/out/test/gateway-outbound.test.js +7 -0
  102. package/out/test/gateway-secrets-preflight.test.js +127 -0
  103. package/out/test/gateway-session-routing.test.js +18 -0
  104. package/out/test/governor-hardening.test.js +217 -0
  105. package/out/test/headless-sync.test.js +22 -0
  106. package/out/test/lease-signature.test.js +357 -0
  107. package/out/test/lease-trust-root.test.js +273 -0
  108. package/out/test/lease-validation.test.js +169 -0
  109. package/out/test/lease.test.js +114 -0
  110. package/out/test/mcp.test.js +27 -1
  111. package/out/test/pack-admission.test.js +216 -0
  112. package/out/test/pack-digest-parity.test.js +140 -0
  113. package/out/test/pack-reconcile.test.js +183 -0
  114. package/out/test/pack-signature-artifact.test.js +103 -0
  115. package/out/test/pack-signature.test.js +176 -0
  116. package/out/test/pack-trust-root.test.js +202 -0
  117. package/out/test/remote-trust.test.js +242 -0
  118. package/out/test/runner-protocol.test.js +249 -0
  119. package/out/test/sandbox-driver.test.js +553 -0
  120. package/out/test/sandbox-hardening.test.js +395 -0
  121. package/out/test/sandbox-runner.test.js +343 -0
  122. package/out/test/serve-hardening.test.js +45 -6
  123. package/out/test/serveClient.test.js +6 -5
  124. package/out/test/sessionShip.test.js +560 -0
  125. package/out/test/spotlightPublish.test.js +28 -0
  126. package/out/test/store-install-safe.test.js +69 -0
  127. package/out/test/tls-bypass.test.js +90 -0
  128. package/out/test/warrant.test.js +138 -0
  129. package/out/utils.js +46 -9
  130. package/package.json +1 -1
@@ -25,6 +25,7 @@ exports.detectArtifactType = detectArtifactType;
25
25
  exports.isStoreUrl = isStoreUrl;
26
26
  exports.listStoreIndex = listStoreIndex;
27
27
  const utils_1 = require("../utils");
28
+ const branding_1 = require("../branding");
28
29
  const github_1 = require("../agentpack/github");
29
30
  // Import shared types and utilities for local use
30
31
  const shared_1 = require("./shared");
@@ -158,28 +159,58 @@ async function getLatestStoreVersion(type, slug, includePrerelease = false) {
158
159
  const zlib_1 = require("zlib");
159
160
  /**
160
161
  * Parse a tar archive into a map of path → content.
162
+ *
163
+ * Long paths are the whole difficulty here. The ustar `name` field is 100 bytes, and anything
164
+ * longer is carried one of two other ways:
165
+ *
166
+ * - ustar `prefix` (offset 345, 155 bytes): the real path is `prefix + "/" + name`.
167
+ * - PAX extended header (typeflag 'x') or GNU LongName ('L'): a pseudo-entry whose BODY holds
168
+ * the path for the entry that follows.
169
+ *
170
+ * Reading only the `name` field silently DROPPED every such file — a store install of a pack with
171
+ * deep skill directories quietly produced an incomplete pack, which is worse than failing, because
172
+ * nothing anywhere reported a problem. Found when a signed pack verified cryptographically but its
173
+ * digest did not match: the download was missing two files the signature covered.
161
174
  */
162
175
  function parseTar(data) {
163
176
  const files = new Map();
164
177
  let offset = 0;
178
+ /** Set by a PAX/GNU pseudo-entry, consumed by the next real entry. */
179
+ let pendingLongName;
180
+ const str = (buf) => buf.toString('utf-8').replace(/\0/g, '').trim();
165
181
  while (offset < data.length - 512) {
166
182
  const header = data.subarray(offset, offset + 512);
167
183
  if (header.every(b => b === 0)) {
168
184
  break;
169
185
  }
170
- const name = header.subarray(0, 100).toString('utf-8').replace(/\0/g, '').trim();
171
- const sizeOctal = header.subarray(124, 136).toString('utf-8').replace(/\0/g, '').trim();
186
+ const name = str(header.subarray(0, 100));
187
+ const prefix = str(header.subarray(345, 500));
188
+ const sizeOctal = str(header.subarray(124, 136));
172
189
  const typeFlag = header[156];
173
- if (!name) {
174
- offset += 512;
190
+ const size = parseInt(sizeOctal, 8) || 0;
191
+ const body = data.subarray(offset + 512, offset + 512 + size);
192
+ const advance = 512 + Math.ceil(size / 512) * 512;
193
+ // 'x' (120) PAX extended header, 'L' (76) GNU LongName: the path for the NEXT entry.
194
+ if (typeFlag === 120 || typeFlag === 103) {
195
+ // PAX records are "<len> <key>=<value>\n"; we only care about path.
196
+ const match = body.toString('utf-8').match(/\d+ path=([^\n]+)\n/);
197
+ if (match) {
198
+ pendingLongName = match[1];
199
+ }
200
+ offset += advance;
175
201
  continue;
176
202
  }
177
- const size = parseInt(sizeOctal, 8) || 0;
178
- offset += 512;
179
- if (typeFlag === 0 || typeFlag === 48) {
180
- files.set(name, Buffer.from(data.subarray(offset, offset + size)));
203
+ if (typeFlag === 76) {
204
+ pendingLongName = str(body);
205
+ offset += advance;
206
+ continue;
207
+ }
208
+ const fullName = pendingLongName ?? (prefix ? `${prefix}/${name}` : name);
209
+ pendingLongName = undefined;
210
+ if (fullName && (typeFlag === 0 || typeFlag === 48)) {
211
+ files.set(fullName, Buffer.from(body));
181
212
  }
182
- offset += Math.ceil(size / 512) * 512;
213
+ offset += advance;
183
214
  }
184
215
  return files;
185
216
  }
@@ -422,7 +453,7 @@ async function ensureFork() {
422
453
  if (!sourceRepo) {
423
454
  throw new Error(`Store repo not found: ${exports.STORE_CONFIG.owner}/${exports.STORE_CONFIG.repo}\n\n` +
424
455
  `If using a custom store, ensure the repo exists and is accessible.\n` +
425
- `Setting: agentide.storeRepo`);
456
+ `Setting: ${(0, branding_1.toSettingsDisplayKey)('agentide.storeRepo')}`);
426
457
  }
427
458
  // Create fork
428
459
  const response = await fetch(`https://api.github.com/repos/${exports.STORE_CONFIG.owner}/${exports.STORE_CONFIG.repo}/forks`, {
package/out/sync.js CHANGED
@@ -962,7 +962,9 @@ class SyncService {
962
962
  const roots = Array.from(new Set([workspaceRoot, ...additionalWorkspaceRoots].map(r => r.trim()).filter(Boolean))).filter(r => r !== os.homedir());
963
963
  for (const root of roots) {
964
964
  // Ensure git is initialized and root .gitignore has AgentIDE entries
965
- await (0, utils_1.ensureWorkspaceGitAndGitignore)(root, outputChannel);
965
+ await (0, utils_1.ensureWorkspaceGitAndGitignore)(root, outputChannel, {
966
+ extraSearchPath: getConfiguredAdditionalEngineDir(),
967
+ });
966
968
  const agentideDir = this.getAgentideProjectDir(root);
967
969
  await fs_1.promises.mkdir(agentideDir, { recursive: true });
968
970
  await this.ensureAgentideGitignore(root, outputChannel);
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  /*---------------------------------------------------------------------------------------------
3
3
  * Copyright (c) AgentHippo.ai. All rights reserved.
4
+ * Licensed under the MIT License. See LICENSE.txt in the project root for license information.
4
5
  *--------------------------------------------------------------------------------------------*/
5
6
  var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
6
7
  if (k2 === undefined) k2 = k;
@@ -43,6 +44,28 @@ const os = __importStar(require("os"));
43
44
  const fs_1 = require("fs");
44
45
  const base_1 = require("../engines/base");
45
46
  const sessionEvents_1 = require("../analytics/sessionEvents");
47
+ const identity_1 = require("../analytics/identity");
48
+ /**
49
+ * Run `fn` with prompt/tool content capture opted in.
50
+ *
51
+ * Content capture is off by default now (PLAN/compliance-req.md §6), so the tests that assert on
52
+ * previews have to say out loud that they are testing the opt-in path.
53
+ */
54
+ async function withContentCapture(fn) {
55
+ const previous = process.env[identity_1.CONTENT_CAPTURE_ENV];
56
+ process.env[identity_1.CONTENT_CAPTURE_ENV] = '1';
57
+ try {
58
+ return await fn();
59
+ }
60
+ finally {
61
+ if (previous === undefined) {
62
+ delete process.env[identity_1.CONTENT_CAPTURE_ENV];
63
+ }
64
+ else {
65
+ process.env[identity_1.CONTENT_CAPTURE_ENV] = previous;
66
+ }
67
+ }
68
+ }
46
69
  async function createUniqueTempDir(prefix) {
47
70
  return fs_1.promises.mkdtemp(path.join(os.tmpdir(), prefix));
48
71
  }
@@ -96,9 +119,17 @@ suite('Analytics - buildLiteLLMMetadataHeaders', () => {
96
119
  const headers = (0, base_1.buildLiteLLMMetadataHeaders)(true, { sessionId: 'sess-123' });
97
120
  assert.ok(headers.some(h => h === 'x-litellm-metadata-session-id: sess-123'), `Missing session_id header. Headers: ${JSON.stringify(headers)}`);
98
121
  });
99
- test('includes user_id header', () => {
122
+ test('includes user_id header, hashed', () => {
100
123
  const headers = (0, base_1.buildLiteLLMMetadataHeaders)(true, { analyticsUserId: 'user-42' });
101
- assert.ok(headers.some(h => h === 'x-litellm-metadata-user-id: user-42'), `Missing user_id header. Headers: ${JSON.stringify(headers)}`);
124
+ assert.ok(headers.some(h => h === `x-litellm-metadata-user-id: ${(0, identity_1.hashIdentity)('user-42')}`), `Missing user_id header. Headers: ${JSON.stringify(headers)}`);
125
+ });
126
+ test('the raw user id never leaves the process on a header', () => {
127
+ // The proxy logs every request and may forward to a third-party observability backend, so
128
+ // a raw address here is a directory of who works at the customer.
129
+ const headers = (0, base_1.buildLiteLLMMetadataHeaders)(true, { analyticsUserId: 'priya@meridian.com' });
130
+ assert.ok(!headers.some(h => h.includes('priya@meridian.com')), JSON.stringify(headers));
131
+ assert.ok(headers.some(h => h.startsWith('x-litellm-metadata-user-id: anon_')));
132
+ assert.ok(headers.some(h => h.startsWith('langfuse_trace_user_id: anon_')));
102
133
  });
103
134
  test('includes project_id header', () => {
104
135
  const headers = (0, base_1.buildLiteLLMMetadataHeaders)(true, { analyticsProjectId: 'agentide-vscode' });
@@ -130,7 +161,7 @@ suite('Analytics - buildLiteLLMMetadataHeaders', () => {
130
161
  analyticsUserId: 'user-1',
131
162
  });
132
163
  assert.ok(headers.some(h => h === 'langfuse_session_id: sess-1'), 'Should include langfuse_session_id for Langfuse callback compatibility');
133
- assert.ok(headers.some(h => h === 'langfuse_trace_user_id: user-1'), 'Should include langfuse_trace_user_id for Langfuse callback compatibility');
164
+ assert.ok(headers.some(h => h === `langfuse_trace_user_id: ${(0, identity_1.hashIdentity)('user-1')}`), 'Should include langfuse_trace_user_id for Langfuse callback compatibility');
134
165
  });
135
166
  // --- Use Case: AgentHippo pack (Mode A) — fully automatic ---
136
167
  test('Mode A: AgentHippo pack produces all 10 headers (8 analytics + 2 langfuse)', () => {
@@ -376,8 +407,31 @@ suite('Analytics - ObservableStreamEmitter', () => {
376
407
  assert.strictEqual(event.event_type, 'tool_start');
377
408
  assert.strictEqual(event.payload.toolName, 'Bash');
378
409
  assert.strictEqual(event.payload.callId, 'call-1');
379
- assert.strictEqual(event.payload.inputPreview, 'ls -la');
380
410
  assert.strictEqual(event.request_id, 'req-1');
411
+ // Tool arguments are content: digest by default, no preview.
412
+ assert.strictEqual(event.payload.inputPreview, undefined);
413
+ assert.strictEqual(event.payload.input_length, 6);
414
+ assert.strictEqual(typeof event.payload.input_sha256, 'string');
415
+ });
416
+ test('tool input preview appears only under the content-capture opt-in', async () => {
417
+ await withContentCapture(async () => {
418
+ const emitter = (0, sessionEvents_1.wrapEmitterWithObservability)(innerEmitter, writer, 'sess-optin', 'req-1');
419
+ await emitter.toolStart('Bash', 'ls -la', 'call-1');
420
+ });
421
+ const content = await fs_1.promises.readFile(path.join(testDir, 'sess-optin.jsonl'), 'utf8');
422
+ const event = JSON.parse(content.trim());
423
+ assert.strictEqual(event.payload.inputPreview, 'ls -la');
424
+ // The digest is present in both modes, so a query written against it never breaks.
425
+ assert.strictEqual(typeof event.payload.input_sha256, 'string');
426
+ });
427
+ test('tool result content is a digest, not a preview, by default', async () => {
428
+ const emitter = (0, sessionEvents_1.wrapEmitterWithObservability)(innerEmitter, writer, 'sess-nores', 'req-1');
429
+ await emitter.toolEnd('Read', 'call-1', 'CUSTOMER SECRET', false);
430
+ const content = await fs_1.promises.readFile(path.join(testDir, 'sess-nores.jsonl'), 'utf8');
431
+ assert.ok(!content.includes('CUSTOMER SECRET'), 'raw tool output must not reach the JSONL');
432
+ const event = JSON.parse(content.trim());
433
+ assert.strictEqual(event.payload.resultPreview, undefined);
434
+ assert.strictEqual(event.payload.result_length, 15);
381
435
  });
382
436
  test('toolEnd writes session event AND passes through', async () => {
383
437
  const emitter = (0, sessionEvents_1.wrapEmitterWithObservability)(innerEmitter, writer, 'sess-1', 'req-1');
@@ -426,21 +480,28 @@ suite('Analytics - ObservableStreamEmitter', () => {
426
480
  assert.strictEqual(event.event_type, 'turn_end');
427
481
  assert.strictEqual(event.payload.success, true);
428
482
  });
429
- test('truncates long tool input to 500 chars', async () => {
430
- const emitter = (0, sessionEvents_1.wrapEmitterWithObservability)(innerEmitter, writer, 'sess-1', 'req-1');
431
- const longInput = 'x'.repeat(1000);
432
- await emitter.toolStart('Read', longInput, 'call-1');
483
+ test('truncates long tool input to 500 chars (content-capture opt-in)', async () => {
484
+ await withContentCapture(async () => {
485
+ const emitter = (0, sessionEvents_1.wrapEmitterWithObservability)(innerEmitter, writer, 'sess-1', 'req-1');
486
+ const longInput = 'x'.repeat(1000);
487
+ await emitter.toolStart('Read', longInput, 'call-1');
488
+ });
433
489
  const content = await fs_1.promises.readFile(path.join(testDir, 'sess-1.jsonl'), 'utf8');
434
490
  const event = JSON.parse(content.trim());
435
491
  assert.strictEqual(event.payload.inputPreview.length, 500);
436
- });
437
- test('truncates long tool result to 500 chars', async () => {
438
- const emitter = (0, sessionEvents_1.wrapEmitterWithObservability)(innerEmitter, writer, 'sess-1', 'req-1');
439
- const longResult = 'y'.repeat(1000);
440
- await emitter.toolEnd('Read', 'call-1', longResult, false);
492
+ // The digest covers the WHOLE input, not the truncated preview.
493
+ assert.strictEqual(event.payload.input_length, 1000);
494
+ });
495
+ test('truncates long tool result to 500 chars (content-capture opt-in)', async () => {
496
+ await withContentCapture(async () => {
497
+ const emitter = (0, sessionEvents_1.wrapEmitterWithObservability)(innerEmitter, writer, 'sess-1', 'req-1');
498
+ const longResult = 'y'.repeat(1000);
499
+ await emitter.toolEnd('Read', 'call-1', longResult, false);
500
+ });
441
501
  const content = await fs_1.promises.readFile(path.join(testDir, 'sess-1.jsonl'), 'utf8');
442
502
  const event = JSON.parse(content.trim());
443
503
  assert.strictEqual(event.payload.resultPreview.length, 500);
504
+ assert.strictEqual(event.payload.result_length, 1000);
444
505
  });
445
506
  // --- Use Case: Full agent request lifecycle ---
446
507
  test('Use Case: full request lifecycle produces correct event sequence', async () => {
@@ -555,14 +616,24 @@ suite('Analytics - ObservableStreamEmitter records the acting user (the warrant)
555
616
  const content = await fs_1.promises.readFile(path.join(testDir, `${sessionId}.jsonl`), 'utf8');
556
617
  return content.trim().split('\n').map((l) => JSON.parse(l));
557
618
  }
558
- test('toolStart/toolEnd record the userId passed at wrap', async () => {
619
+ test('toolStart/toolEnd record the userId passed at wrap, as a salted hash', async () => {
559
620
  const emitter = (0, sessionEvents_1.wrapEmitterWithObservability)(innerEmitter, writer, 'sess-warrant-1', 'req-1', 'priya@meridian.com');
560
621
  await emitter.toolStart('Bash', 'echo hi', 'c1');
561
622
  await emitter.toolEnd('Bash', 'c1', 'hi', false);
562
623
  const events = await readEvents('sess-warrant-1');
563
624
  assert.strictEqual(events.length, 2);
564
- assert.strictEqual(events[0].payload.userId, 'priya@meridian.com');
565
- assert.strictEqual(events[1].payload.userId, 'priya@meridian.com');
625
+ const expected = (0, identity_1.hashIdentity)('priya@meridian.com');
626
+ assert.strictEqual(events[0].payload.userId, expected);
627
+ assert.strictEqual(events[1].payload.userId, expected);
628
+ // Attribution is preserved; the address itself is not.
629
+ assert.ok(String(expected).startsWith('anon_'));
630
+ });
631
+ test('the raw address never reaches the evidence file', async () => {
632
+ const emitter = (0, sessionEvents_1.wrapEmitterWithObservability)(innerEmitter, writer, 'sess-warrant-raw', 'req-1', 'priya@meridian.com');
633
+ await emitter.emitTurnStart();
634
+ const raw = await fs_1.promises.readFile(path.join(testDir, 'sess-warrant-raw.jsonl'), 'utf8');
635
+ assert.ok(!raw.includes('priya@meridian.com'), 'raw user id must not be written to evidence');
636
+ assert.ok(!raw.includes('meridian.com'), 'not even the domain');
566
637
  });
567
638
  test('toolStart records no userId when wrap omitted it (IDE-local usage)', async () => {
568
639
  const emitter = (0, sessionEvents_1.wrapEmitterWithObservability)(innerEmitter, writer, 'sess-warrant-2', 'req-1');
@@ -582,8 +653,9 @@ suite('Analytics - ObservableStreamEmitter records the acting user (the warrant)
582
653
  ]);
583
654
  const events = await readEvents('sess-warrant-3');
584
655
  const byCallId = Object.fromEntries(events.map((e) => [e.payload.callId, e.payload.userId]));
585
- assert.strictEqual(byCallId['a1'], 'alice@acme.com');
586
- assert.strictEqual(byCallId['b1'], 'bob@acme.com');
656
+ assert.strictEqual(byCallId['a1'], (0, identity_1.hashIdentity)('alice@acme.com'));
657
+ assert.strictEqual(byCallId['b1'], (0, identity_1.hashIdentity)('bob@acme.com'));
658
+ assert.notStrictEqual(byCallId['a1'], byCallId['b1'], 'distinct users must stay distinguishable');
587
659
  });
588
660
  test('toolStart/toolEnd keep wrap-time userId even when called outside any credential scope', async () => {
589
661
  // Simulates the custom-engine persistent-worker path: wrap captures identity on the
@@ -594,8 +666,8 @@ suite('Analytics - ObservableStreamEmitter records the acting user (the warrant)
594
666
  await emitter.toolStart('Bash', 'echo hi', 'c1');
595
667
  await emitter.toolEnd('Bash', 'c1', 'hi', false);
596
668
  const events = await readEvents('sess-warrant-4');
597
- assert.strictEqual(events[0].payload.userId, 'alice@acme.com');
598
- assert.strictEqual(events[1].payload.userId, 'alice@acme.com');
669
+ assert.strictEqual(events[0].payload.userId, (0, identity_1.hashIdentity)('alice@acme.com'));
670
+ assert.strictEqual(events[1].payload.userId, (0, identity_1.hashIdentity)('alice@acme.com'));
599
671
  });
600
672
  test('error/turn_start/turn_end also carry the wrap-time userId, not just tool events', async () => {
601
673
  // A turn that errors before any tool call (or has no tool calls at all) must still
@@ -607,11 +679,78 @@ suite('Analytics - ObservableStreamEmitter records the acting user (the warrant)
607
679
  await emitter.emitTurnEnd(false);
608
680
  const events = await readEvents('sess-warrant-5');
609
681
  assert.strictEqual(events.length, 3);
682
+ const bob = (0, identity_1.hashIdentity)('bob@acme.com');
610
683
  assert.strictEqual(events[0].event_type, 'turn_start');
611
- assert.strictEqual(events[0].payload.userId, 'bob@acme.com');
684
+ assert.strictEqual(events[0].payload.userId, bob);
612
685
  assert.strictEqual(events[1].event_type, 'error');
613
- assert.strictEqual(events[1].payload.userId, 'bob@acme.com');
686
+ assert.strictEqual(events[1].payload.userId, bob);
614
687
  assert.strictEqual(events[2].event_type, 'turn_end');
615
- assert.strictEqual(events[2].payload.userId, 'bob@acme.com');
688
+ assert.strictEqual(events[2].payload.userId, bob);
689
+ });
690
+ });
691
+ // =============================================================================
692
+ // Evidence-write failures must be LOUD (compliance fix 2a)
693
+ //
694
+ // `append()` used to swallow every error in an empty catch block. A full disk, a read-only
695
+ // volume, a bad mount — all of them produced a turn that ran with no record of it, and nothing
696
+ // anywhere said so. An absent record is the failure mode that matters (SOC 2 CC7.2 / R-13), so
697
+ // these tests exist to keep the silence from coming back.
698
+ // =============================================================================
699
+ suite('Analytics - evidence write failures are surfaced, never swallowed', () => {
700
+ let blockedDir;
701
+ let errors;
702
+ let originalError;
703
+ setup(async () => {
704
+ (0, sessionEvents_1.resetSessionEventWriterStatsForTest)();
705
+ // A FILE where the writer expects a directory: mkdir fails, so every append fails.
706
+ const root = await createUniqueTempDir('agentide-evidence-fail-');
707
+ blockedDir = path.join(root, 'not-a-dir');
708
+ await fs_1.promises.writeFile(blockedDir, 'this is a file, not a directory');
709
+ errors = [];
710
+ originalError = console.error;
711
+ console.error = (msg) => { errors.push(String(msg)); };
712
+ });
713
+ teardown(() => {
714
+ console.error = originalError;
715
+ delete process.env.AGENTHIPPO_EVIDENCE_REQUIRED;
716
+ (0, sessionEvents_1.resetSessionEventWriterStatsForTest)();
717
+ });
718
+ test('a failed append logs at error level and counts the loss', async () => {
719
+ const writer = new sessionEvents_1.SessionEventWriter(blockedDir);
720
+ await writer.append('sess-x', 'req-1', 'tool_start', { toolName: 'Bash' });
721
+ assert.strictEqual(writer.getStats().failed, 1, 'per-writer counter');
722
+ assert.strictEqual(writer.getStats().written, 0);
723
+ assert.strictEqual((0, sessionEvents_1.getSessionEventWriterStats)().failed, 1, 'process-wide counter for /health');
724
+ assert.ok(errors.some((e) => e.includes('LOST session event')), `no error logged: ${JSON.stringify(errors)}`);
725
+ assert.ok(errors.some((e) => e.includes('tool_start')), 'the lost event type is named');
726
+ });
727
+ test('the last failure is retained for /health output', async () => {
728
+ const writer = new sessionEvents_1.SessionEventWriter(blockedDir);
729
+ await writer.append('sess-x', 'req-1', 'turn_end', { success: true });
730
+ const stats = (0, sessionEvents_1.getSessionEventWriterStats)();
731
+ assert.strictEqual(stats.lastError?.eventType, 'turn_end');
732
+ assert.ok((stats.lastError?.at ?? 0) > 0);
733
+ assert.ok((stats.lastError?.message ?? '').length > 0);
734
+ });
735
+ test('AGENTHIPPO_EVIDENCE_REQUIRED makes the turn fail rather than run unrecorded', async () => {
736
+ process.env.AGENTHIPPO_EVIDENCE_REQUIRED = '1';
737
+ const writer = new sessionEvents_1.SessionEventWriter(blockedDir);
738
+ await assert.rejects(() => writer.append('sess-x', 'req-1', 'tool_start', {}), /evidence write failed for tool_start/);
739
+ assert.strictEqual((0, sessionEvents_1.getSessionEventWriterStats)().failed, 1);
740
+ });
741
+ test('without the flag the agent keeps running, but the loss is still counted', async () => {
742
+ const writer = new sessionEvents_1.SessionEventWriter(blockedDir);
743
+ await writer.append('sess-x', 'req-1', 'tool_start', {});
744
+ await writer.append('sess-x', 'req-1', 'tool_end', {});
745
+ assert.strictEqual((0, sessionEvents_1.getSessionEventWriterStats)().failed, 2);
746
+ });
747
+ test('successful writes are counted too, so /health can show a ratio', async () => {
748
+ const dir = await createUniqueTempDir('agentide-evidence-ok-');
749
+ const writer = new sessionEvents_1.SessionEventWriter(dir);
750
+ await writer.append('sess-ok', 'req-1', 'turn_start', {});
751
+ assert.strictEqual(writer.getStats().written, 1);
752
+ assert.strictEqual(writer.getStats().failed, 0);
753
+ assert.strictEqual((0, sessionEvents_1.getSessionEventWriterStats)().written, 1);
754
+ await removeDirWithRetries(dir);
616
755
  });
617
756
  });
@@ -0,0 +1,132 @@
1
+ "use strict";
2
+ /*---------------------------------------------------------------------------------------------
3
+ * Copyright (c) AgentHippo.ai. All rights reserved.
4
+ *
5
+ * The auth base URL is the endpoint that receives the PKCE code, then the user's AgentHippo
6
+ * access token, and whose bootstrap response is written into the user's model-routing settings.
7
+ * These tests pin the two gates that keep a workspace from steering it.
8
+ *--------------------------------------------------------------------------------------------*/
9
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ var desc = Object.getOwnPropertyDescriptor(m, k);
12
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
13
+ desc = { enumerable: true, get: function() { return m[k]; } };
14
+ }
15
+ Object.defineProperty(o, k2, desc);
16
+ }) : (function(o, m, k, k2) {
17
+ if (k2 === undefined) k2 = k;
18
+ o[k2] = m[k];
19
+ }));
20
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
21
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
22
+ }) : function(o, v) {
23
+ o["default"] = v;
24
+ });
25
+ var __importStar = (this && this.__importStar) || (function () {
26
+ var ownKeys = function(o) {
27
+ ownKeys = Object.getOwnPropertyNames || function (o) {
28
+ var ar = [];
29
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
30
+ return ar;
31
+ };
32
+ return ownKeys(o);
33
+ };
34
+ return function (mod) {
35
+ if (mod && mod.__esModule) return mod;
36
+ var result = {};
37
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
38
+ __setModuleDefault(result, mod);
39
+ return result;
40
+ };
41
+ })();
42
+ Object.defineProperty(exports, "__esModule", { value: true });
43
+ require("mocha");
44
+ const assert = __importStar(require("assert"));
45
+ const brokerClient_1 = require("../auth/brokerClient");
46
+ const authBaseUrl_1 = require("../auth/authBaseUrl");
47
+ suite('auth base URL — transport gate', () => {
48
+ test('accepts https and strips trailing slashes', () => {
49
+ assert.strictEqual((0, brokerClient_1.assertSafeAuthBaseUrl)('https://auth.agenthippo.ai/'), 'https://auth.agenthippo.ai');
50
+ assert.strictEqual((0, brokerClient_1.assertSafeAuthBaseUrl)('https://auth.example.com/base//'), 'https://auth.example.com/base');
51
+ });
52
+ test('accepts plain http only for loopback (local broker development)', () => {
53
+ assert.strictEqual((0, brokerClient_1.assertSafeAuthBaseUrl)('http://127.0.0.1:9780'), 'http://127.0.0.1:9780');
54
+ assert.strictEqual((0, brokerClient_1.assertSafeAuthBaseUrl)('http://localhost:9780'), 'http://localhost:9780');
55
+ });
56
+ test('refuses cleartext http to a remote host — the bearer token would be on the wire', () => {
57
+ assert.throws(() => (0, brokerClient_1.assertSafeAuthBaseUrl)('http://attacker.example'), /must be https/);
58
+ });
59
+ test('refuses a hostname that merely CONTAINS a loopback name', () => {
60
+ assert.throws(() => (0, brokerClient_1.assertSafeAuthBaseUrl)('http://localhost.attacker.tld'), /must be https/);
61
+ });
62
+ test('refuses a non-http scheme and a malformed URL', () => {
63
+ assert.throws(() => (0, brokerClient_1.assertSafeAuthBaseUrl)('file:///etc/passwd'), /must be https/);
64
+ assert.throws(() => (0, brokerClient_1.assertSafeAuthBaseUrl)('not a url'), /not a valid URL/);
65
+ });
66
+ test('falls back to the product default when nothing is configured', () => {
67
+ const previous = process.env.AGENTHIPPO_AUTH_BASE_URL;
68
+ delete process.env.AGENTHIPPO_AUTH_BASE_URL;
69
+ try {
70
+ assert.strictEqual((0, brokerClient_1.getAuthBrokerBaseUrl)(undefined), brokerClient_1.DEFAULT_AUTH_BROKER_BASE_URL);
71
+ assert.strictEqual((0, brokerClient_1.getAuthBrokerBaseUrl)(' '), brokerClient_1.DEFAULT_AUTH_BROKER_BASE_URL);
72
+ }
73
+ finally {
74
+ if (previous === undefined) {
75
+ delete process.env.AGENTHIPPO_AUTH_BASE_URL;
76
+ }
77
+ else {
78
+ process.env.AGENTHIPPO_AUTH_BASE_URL = previous;
79
+ }
80
+ }
81
+ });
82
+ test('a bad configured value throws rather than silently falling back', () => {
83
+ assert.throws(() => (0, brokerClient_1.getAuthBrokerBaseUrl)('http://evil.example'), /Refusing to use/);
84
+ });
85
+ });
86
+ suite('auth base URL — scope gate', () => {
87
+ test('a workspace value is ignored in favour of the user value', () => {
88
+ const picked = (0, authBaseUrl_1.pickTrustedAuthBaseUrl)({
89
+ defaultValue: 'https://auth.agenthippo.ai',
90
+ globalValue: 'https://auth.corp.example',
91
+ workspaceValue: 'https://attacker.example',
92
+ });
93
+ assert.strictEqual(picked.value, 'https://auth.corp.example');
94
+ assert.strictEqual(picked.ignoredWorkspaceValue, 'https://attacker.example');
95
+ });
96
+ test('a workspace-FOLDER value is ignored too', () => {
97
+ const picked = (0, authBaseUrl_1.pickTrustedAuthBaseUrl)({
98
+ defaultValue: 'https://auth.agenthippo.ai',
99
+ workspaceFolderValue: 'https://attacker.example',
100
+ });
101
+ assert.strictEqual(picked.value, 'https://auth.agenthippo.ai');
102
+ assert.strictEqual(picked.ignoredWorkspaceValue, 'https://attacker.example');
103
+ });
104
+ test('a remote user value wins over a local one (VS Code precedence)', () => {
105
+ const picked = (0, authBaseUrl_1.pickTrustedAuthBaseUrl)({
106
+ defaultValue: 'https://auth.agenthippo.ai',
107
+ globalValue: 'https://a.example',
108
+ globalLocalValue: 'https://b.example',
109
+ globalRemoteValue: 'https://c.example',
110
+ });
111
+ assert.strictEqual(picked.value, 'https://c.example');
112
+ });
113
+ test('a workspace value identical to the trusted value is not reported as an override', () => {
114
+ const picked = (0, authBaseUrl_1.pickTrustedAuthBaseUrl)({
115
+ defaultValue: 'https://auth.agenthippo.ai',
116
+ workspaceValue: 'https://auth.agenthippo.ai',
117
+ });
118
+ assert.strictEqual(picked.ignoredWorkspaceValue, undefined);
119
+ });
120
+ test('the attack end-to-end: a repo setting agentide.auth.baseUrl never reaches the network layer', () => {
121
+ const warnings = [];
122
+ const resolved = (0, authBaseUrl_1.resolveAuthBrokerBaseUrl)({
123
+ inspect: () => ({
124
+ defaultValue: 'https://auth.agenthippo.ai',
125
+ workspaceValue: 'https://attacker.example',
126
+ }),
127
+ }, (m) => warnings.push(m));
128
+ assert.strictEqual(resolved, 'https://auth.agenthippo.ai');
129
+ assert.strictEqual(warnings.length, 1);
130
+ assert.ok(warnings[0].includes('attacker.example'), 'the ignored value is named in the warning');
131
+ });
132
+ });
@@ -78,25 +78,46 @@ const sampleBootstrap = {
78
78
  user: { id: 'usr_1', email: 'you@acme.com', displayName: 'You' },
79
79
  team: { id: 'team_acme', name: 'Acme', role: 'member' },
80
80
  store: { repo: 'acme/store', indexUrl: 'https://acme.github.io/store', githubOrg: 'acme' },
81
- routing: { litellmBaseUrl: 'http://llm.acme', litellmApiKey: 'ahk_123' },
82
- policy: { byokAllowed: true, teamManagedSettings: ['store', 'routing'] },
81
+ routing: { inferenceGatewayUrl: 'https://gw.acme/v1' },
82
+ policy: { teamManagedSettings: ['store', 'routing'] },
83
+ };
84
+ const soloBootstrap = {
85
+ version: 1,
86
+ user: { id: 'usr_2', email: 'solo@example.com', displayName: 'Solo' },
87
+ store: { repo: 'acme/store', indexUrl: 'https://acme.github.io/store', githubOrg: 'acme' },
88
+ routing: { inferenceGatewayUrl: 'https://gw.acme/v1' },
89
+ policy: { teamManagedSettings: [] },
83
90
  };
84
91
  suite('BootstrapApply', () => {
85
- test('applyIdeBootstrap writes store and routing settings', async () => {
92
+ test('applyIdeBootstrap writes store and gateway routing; the litellm slot stays untouched', async () => {
86
93
  const { target, settings, global, getApiKeyCalls } = createMockTarget();
94
+ settings.set('agentide.providers.litellm.baseUrl', 'http://127.0.0.1:4000');
87
95
  const result = await (0, bootstrapApply_js_1.applyIdeBootstrap)(sampleBootstrap, target);
88
96
  assert.deepStrictEqual(result.applied, ['store', 'routing']);
89
97
  assert.strictEqual(settings.get('agentide.storeRepo'), 'acme/store');
90
- assert.strictEqual(settings.get('agentide.providers.litellm.baseUrl'), 'http://llm.acme');
91
- assert.strictEqual(getApiKeyCalls(), 1);
98
+ assert.strictEqual(settings.get('agentide.AgentHippoModelGateway.url'), 'https://gw.acme/v1');
99
+ // Slot split: sign-in must not overwrite the user-owned litellm provider or install a key.
100
+ assert.strictEqual(settings.get('agentide.providers.litellm.baseUrl'), 'http://127.0.0.1:4000');
101
+ assert.strictEqual(getApiKeyCalls(), 0);
92
102
  assert.strictEqual(global.get('agentide.team.id'), 'team_acme');
93
103
  });
104
+ test('a solo bootstrap (no team) applies settings without locking them', async () => {
105
+ const { target, settings, global } = createMockTarget();
106
+ const result = await (0, bootstrapApply_js_1.applyIdeBootstrap)(soloBootstrap, target);
107
+ // Settings still apply as defaults…
108
+ assert.deepStrictEqual(result.applied, ['store', 'routing']);
109
+ assert.strictEqual(settings.get('agentide.AgentHippoModelGateway.url'), 'https://gw.acme/v1');
110
+ // …but nothing is marked team-managed and no team identity is recorded.
111
+ assert.strictEqual(global.get('agentide.teamManaged.store'), false);
112
+ assert.strictEqual(global.get('agentide.teamManaged.routing'), false);
113
+ assert.strictEqual(global.get('agentide.team.id'), undefined);
114
+ });
94
115
  test('revertTeamManagedBootstrap restores defaults', async () => {
95
116
  const { target, settings, global } = createMockTarget();
96
117
  await (0, bootstrapApply_js_1.applyIdeBootstrap)(sampleBootstrap, target);
97
118
  await (0, bootstrapApply_js_1.revertTeamManagedBootstrap)(target);
98
119
  assert.strictEqual(settings.get('agentide.storeRepo'), 'agenthippoai/agenthippo-store');
99
- assert.strictEqual(settings.get('agentide.providers.litellm.baseUrl'), '');
120
+ assert.strictEqual(settings.get('agentide.AgentHippoModelGateway.url'), undefined);
100
121
  assert.strictEqual(global.get('agentide.team.id'), undefined);
101
122
  });
102
123
  });