@agenthippo-ai/agenthippo 0.2.3-dataplane → 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 (139) 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/dashboard/provider.js +13 -2
  5. package/out/analytics/dashboard/webview/panel.js +3 -2
  6. package/out/analytics/identity.js +0 -0
  7. package/out/analytics/service.js +29 -25
  8. package/out/analytics/sessionEvents.js +100 -24
  9. package/out/auth/authBaseUrl.js +59 -0
  10. package/out/auth/bootstrapApply.js +9 -10
  11. package/out/auth/brokerClient.js +48 -14
  12. package/out/auth/provider.js +8 -21
  13. package/out/auth/sessionStore.js +3 -40
  14. package/out/auth/signInFlow.js +2 -1
  15. package/out/auth-broker/config.js +50 -5
  16. package/out/auth-broker/server.js +126 -26
  17. package/out/auth-broker/store.js +25 -24
  18. package/out/authority/warrant.js +206 -0
  19. package/out/branding.js +27 -1
  20. package/out/cli/commands/conformance.js +249 -0
  21. package/out/cli/commands/data-plane.js +151 -0
  22. package/out/cli/commands/deploy.js +1 -0
  23. package/out/cli/commands/gateway.js +4 -0
  24. package/out/cli/commands/pack.js +182 -3
  25. package/out/cli/commands/sandbox.js +351 -0
  26. package/out/cli/commands/serve.js +1145 -130
  27. package/out/cli/commands/store.js +12 -4
  28. package/out/cli/credential-context.js +17 -0
  29. package/out/cli/data-plane/gateway.js +28 -5
  30. package/out/cli/data-plane/index.js +18 -2
  31. package/out/cli/data-plane/registry.js +9 -0
  32. package/out/cli/data-plane/remote.js +106 -0
  33. package/out/cli/data-plane/sidecar.js +225 -0
  34. package/out/cli/deploy/layers/identity.js +88 -4
  35. package/out/cli/deploy-init.js +89 -24
  36. package/out/cli/deps.js +106 -2
  37. package/out/cli/idp-init.js +35 -0
  38. package/out/cli/index.js +171 -17
  39. package/out/cli/pack-install-ref.js +15 -0
  40. package/out/cli/pack-loader.js +8 -1
  41. package/out/cli/pack-runtime.js +61 -0
  42. package/out/cli/runtime.js +35 -1
  43. package/out/cli/serve-hardening.js +260 -14
  44. package/out/cli/utils.js +12 -1
  45. package/out/engines/base.js +32 -3
  46. package/out/engines/claude.js +33 -15
  47. package/out/engines/codex.js +111 -27
  48. package/out/engines/custom.js +15 -1
  49. package/out/evidence/sessionShip.js +643 -0
  50. package/out/extension.js +11 -2
  51. package/out/gateway/bridge.js +40 -2
  52. package/out/gateway/client.js +34 -1
  53. package/out/gateway/installSource.js +68 -0
  54. package/out/gateway/lifecycle.js +185 -51
  55. package/out/gateway/outboundPaths.js +4 -1
  56. package/out/gateway/secretsPreflight.js +90 -0
  57. package/out/gateway/service.js +7 -2
  58. package/out/headless/index.js +25 -2
  59. package/out/live-e2e/mcp-demo/server.mjs +32 -0
  60. package/out/mcp.js +30 -0
  61. package/out/pack/caps.js +271 -0
  62. package/out/pack/governor.js +326 -0
  63. package/out/pack/lease-schema.js +273 -0
  64. package/out/pack/lease.js +682 -0
  65. package/out/pack/reconcile.js +217 -0
  66. package/out/pack/signature.js +839 -0
  67. package/out/remote/handleRemoteChat.js +44 -3
  68. package/out/remote/remoteTrust.js +277 -0
  69. package/out/remote/serveClient.js +52 -5
  70. package/out/runner/host.js +141 -0
  71. package/out/runner/protocol.js +128 -0
  72. package/out/runner/remoteEngine.js +166 -0
  73. package/out/runtime/interfaces/edit-tracker.js +2 -0
  74. package/out/runtime/vscode/vscode-edit-tracker.js +2 -0
  75. package/out/sandbox/containerDriver.js +572 -0
  76. package/out/sandbox/processDriver.js +318 -0
  77. package/out/sandbox/sandboxRunner.js +303 -0
  78. package/out/sandbox/support.js +301 -0
  79. package/out/sandbox/types.js +55 -0
  80. package/out/scripts/agentide-litellm.sh +67 -5
  81. package/out/scripts/agentide_litellm_hooks_v1.py +266 -21
  82. package/out/scripts/analytics/agentide-litellm-otel.config.yaml +98 -132
  83. package/out/scripts/analytics/setup-analytics.ps1 +45 -14
  84. package/out/scripts/analytics/setup-analytics.sh +106 -15
  85. package/out/scripts/analytics/start-analytics.sh +86 -14
  86. package/out/scripts/gateway/setup-gateway.sh +72 -32
  87. package/out/shared/models.js +11 -110
  88. package/out/spotlight/install.js +7 -1
  89. package/out/store/index.js +41 -10
  90. package/out/sync.js +3 -1
  91. package/out/test/analytics.test.js +162 -23
  92. package/out/test/auth-baseurl.test.js +132 -0
  93. package/out/test/auth-bootstrapApply.test.js +27 -6
  94. package/out/test/auth-broker-hardening.test.js +227 -0
  95. package/out/test/auth-broker.test.js +59 -9
  96. package/out/test/branding.test.js +13 -0
  97. package/out/test/caps-governor.test.js +460 -0
  98. package/out/test/codex-new-file-survival.test.js +119 -0
  99. package/out/test/configuration-scopes.test.js +131 -0
  100. package/out/test/data-plane-engine-wiring.test.js +32 -0
  101. package/out/test/data-plane.test.js +380 -0
  102. package/out/test/deploy-init.test.js +47 -1
  103. package/out/test/gateway-client.test.js +28 -0
  104. package/out/test/gateway-install-source.test.js +111 -0
  105. package/out/test/gateway-lifecycle.test.js +1 -1
  106. package/out/test/gateway-outbound.test.js +7 -0
  107. package/out/test/gateway-secrets-preflight.test.js +127 -0
  108. package/out/test/gateway-session-routing.test.js +18 -0
  109. package/out/test/governor-hardening.test.js +222 -0
  110. package/out/test/headless-sync.test.js +22 -0
  111. package/out/test/lease-signature.test.js +357 -0
  112. package/out/test/lease-trust-root.test.js +273 -0
  113. package/out/test/lease-validation.test.js +224 -0
  114. package/out/test/lease.test.js +114 -0
  115. package/out/test/localFleetAdapter.test.js +91 -0
  116. package/out/test/mcp.test.js +27 -1
  117. package/out/test/pack-admission.test.js +216 -0
  118. package/out/test/pack-digest-parity.test.js +140 -0
  119. package/out/test/pack-reconcile.test.js +183 -0
  120. package/out/test/pack-signature-artifact.test.js +103 -0
  121. package/out/test/pack-signature.test.js +176 -0
  122. package/out/test/pack-trust-root.test.js +202 -0
  123. package/out/test/remote-trust.test.js +242 -0
  124. package/out/test/runner-protocol.test.js +249 -0
  125. package/out/test/sandbox-driver.test.js +553 -0
  126. package/out/test/sandbox-hardening.test.js +406 -0
  127. package/out/test/sandbox-runner.test.js +343 -0
  128. package/out/test/sandbox-scope.test.js +449 -0
  129. package/out/test/serve-cron-acceptance.test.js +370 -0
  130. package/out/test/serve-gateway-turn.test.js +122 -0
  131. package/out/test/serve-hardening.test.js +227 -15
  132. package/out/test/serveClient.test.js +6 -5
  133. package/out/test/sessionShip.test.js +618 -0
  134. package/out/test/spotlightPublish.test.js +28 -0
  135. package/out/test/store-install-safe.test.js +69 -0
  136. package/out/test/tls-bypass.test.js +90 -0
  137. package/out/test/warrant.test.js +138 -0
  138. package/out/utils.js +46 -9
  139. package/package.json +1 -1
@@ -30,72 +30,15 @@ exports.BUILTIN_MODELS = [
30
30
  contextWindow: 200000,
31
31
  maxOutputTokens: 8192,
32
32
  capabilities: ['chat', 'vision', 'function-calling', 'code'],
33
- description: 'AgentHippo automatically picks the best model for your request',
33
+ // The picker must say where the prompt goes: this route leaves the machine through
34
+ // AgentHippo's servers on the account JWT, NOT through any API key the user configured.
35
+ description: 'Managed gateway — routed through AgentHippo using your account, not your own API keys',
34
36
  recommended: true,
35
37
  },
36
- // ===========================================
37
- // OpenAI Models
38
- // ===========================================
39
- {
40
- id: 'gpt-4.1-nano',
41
- name: 'GPT-4.1 Nano',
42
- provider: 'openai',
43
- contextWindow: 128000,
44
- maxOutputTokens: 2048,
45
- capabilities: ['chat', 'vision', 'function-calling', 'code'],
46
- description: 'Lightweight GPT-4.1 option for quick completions',
47
- },
48
- {
49
- id: 'gpt-5.3-codex',
50
- name: 'GPT-5.3 Codex (ChatGPT)',
51
- provider: 'openai-codex',
52
- contextWindow: 256000,
53
- maxOutputTokens: 8192,
54
- capabilities: ['chat', 'code', 'function-calling'],
55
- description: 'Codex model authenticated via ChatGPT OAuth subscription',
56
- recommended: true,
57
- },
58
- // ===========================================
59
- // LiteLLM Proxy Models
60
- // These route through your LiteLLM deployment
61
- // ===========================================
62
- {
63
- id: 'litellm/gpt-4.1-nano',
64
- name: 'LiteLLM GPT-4.1 Nano',
65
- provider: 'litellm',
66
- contextWindow: 128000,
67
- maxOutputTokens: 2048,
68
- capabilities: ['chat', 'vision', 'function-calling', 'code'],
69
- description: 'GPT-4.1 Nano via LiteLLM proxy',
70
- },
71
- {
72
- id: 'litellm/gpt-5-nano',
73
- name: 'LiteLLM GPT-5 Nano',
74
- provider: 'litellm',
75
- contextWindow: 256000,
76
- maxOutputTokens: 8192,
77
- capabilities: ['chat', 'vision', 'function-calling', 'code'],
78
- description: 'GPT-5 Nano via LiteLLM proxy',
79
- },
80
- // Claude Models: Claude Code will auto translate models into dated aliases
81
- {
82
- id: 'claude-sonnet-4-5-20250929',
83
- name: 'Claude Sonnet 4.5 (SDK alias)',
84
- provider: 'litellm',
85
- contextWindow: 200000,
86
- maxOutputTokens: 8192,
87
- capabilities: ['chat', 'vision', 'artifacts', 'code'],
88
- description: 'Compatibility alias used by Claude Agent SDK (maps to Claude 3.5 Sonnet)',
89
- },
90
- {
91
- id: 'claude-haiku-4-5-20251001',
92
- name: 'Claude Haiku 4.5 20251001',
93
- provider: 'litellm',
94
- contextWindow: 200000,
95
- maxOutputTokens: 8192,
96
- capabilities: ['chat', 'vision', 'artifacts', 'code'],
97
- description: 'Compatibility alias used by Claude Agent SDK (maps to Claude 3 Haiku)',
98
- },
38
+ // LiteLLM models are NOT hardcoded here.
39
+ // They come from two dynamic sources:
40
+ // 1. Auto-discovery via LiteLLM's /model/info endpoint (require explicit enablement)
41
+ // 2. User-defined custom models in agentide.models.custom
99
42
  ];
100
43
  // -----------------------------------------------------------------------------
101
44
  // Registry Key Functions
@@ -178,58 +121,16 @@ function getActiveModels() {
178
121
  // Model Resolution (for CLI)
179
122
  // -----------------------------------------------------------------------------
180
123
  /**
181
- * Common model aliases that users might type.
182
- * Maps short names to full SDK-compatible model IDs.
183
- */
184
- const MODEL_ALIASES = {
185
- // Claude aliases
186
- 'claude-sonnet-4': 'claude-sonnet-4-5-20250929',
187
- 'claude-sonnet': 'claude-sonnet-4-5-20250929',
188
- 'sonnet': 'claude-sonnet-4-5-20250929',
189
- 'claude-haiku-4': 'claude-haiku-4-5-20251001',
190
- 'claude-haiku': 'claude-haiku-4-5-20251001',
191
- 'haiku': 'claude-haiku-4-5-20251001',
192
- 'claude-opus-4': 'claude-opus-4-20250514',
193
- 'claude-opus': 'claude-opus-4-20250514',
194
- 'opus': 'claude-opus-4-20250514',
195
- // OpenAI aliases (these work as-is with SDK, but include for consistency)
196
- 'gpt-4o': 'gpt-4o',
197
- 'gpt-4': 'gpt-4o',
198
- 'o3': 'o3',
199
- 'o1': 'o1',
200
- };
201
- /**
202
- * Resolve a model alias to a full model ID.
124
+ * Normalize a model id for CLI/headless use.
203
125
  *
204
- * Resolution order:
205
- * 1. Check if it's an exact match in BUILTIN_MODELS
206
- * 2. Check if it's a known alias
207
- * 3. Check if any model ID starts with the input (prefix match)
208
- * 4. Return as-is (let SDK handle it)
209
- *
210
- * @param input - User-provided model name (e.g., 'claude-sonnet-4', 'sonnet')
211
- * @returns Resolved model ID suitable for SDK
126
+ * Only canonicalizes ids that match a built-in catalog entry (currently `ah-auto`).
127
+ * All other ids are passed through unchanged for LiteLLM, pack specs, or provider SDKs.
212
128
  */
213
129
  function resolveModelAlias(input) {
214
130
  if (!input) {
215
131
  return input;
216
132
  }
217
133
  const normalized = input.toLowerCase().trim();
218
- // 1. Exact match in BUILTIN_MODELS
219
134
  const exactMatch = exports.BUILTIN_MODELS.find(m => m.id.toLowerCase() === normalized);
220
- if (exactMatch) {
221
- return exactMatch.id;
222
- }
223
- // 2. Known alias
224
- const aliasMatch = MODEL_ALIASES[normalized];
225
- if (aliasMatch) {
226
- return aliasMatch;
227
- }
228
- // 3. Prefix match (e.g., 'claude-sonnet-4' matches 'claude-sonnet-4-5-20250929')
229
- const prefixMatch = exports.BUILTIN_MODELS.find(m => m.id.toLowerCase().startsWith(normalized));
230
- if (prefixMatch) {
231
- return prefixMatch.id;
232
- }
233
- // 4. Return as-is
234
- return input;
135
+ return exactMatch?.id ?? input;
235
136
  }
@@ -194,7 +194,13 @@ async function installSpotlightViewFromFiles(files, options) {
194
194
  if (!INSTALLABLE_ROOT_FILES.has(relPath) && !INSTALLABLE_PREFIXES.some(prefix => relPath.startsWith(prefix))) {
195
195
  continue;
196
196
  }
197
- const destination = path.join(installDir, relPath);
197
+ // The prefix check above is on the raw string — "scripts/../../x" passes it. Resolve and
198
+ // require containment so a store bundle can never write outside its own install dir.
199
+ const resolvedInstallDir = path.resolve(installDir);
200
+ const destination = path.resolve(resolvedInstallDir, relPath);
201
+ if (!destination.startsWith(resolvedInstallDir + path.sep)) {
202
+ throw new Error(`Store bundle entry "${relPath}" escapes the install directory`);
203
+ }
198
204
  await fs_1.promises.mkdir(path.dirname(destination), { recursive: true });
199
205
  await fs_1.promises.writeFile(destination, content);
200
206
  }
@@ -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
  });