@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
@@ -0,0 +1,643 @@
1
+ "use strict";
2
+ /*---------------------------------------------------------------------------------------------
3
+ * Copyright (c) AgentHippo.ai. All rights reserved.
4
+ * Licensed under the MIT License. See LICENSE.txt in the project root for license information.
5
+ * In-process session evidence shipper — drains Layer 2 JSONL to evidence-server.
6
+ *--------------------------------------------------------------------------------------------*/
7
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
8
+ if (k2 === undefined) k2 = k;
9
+ var desc = Object.getOwnPropertyDescriptor(m, k);
10
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
11
+ desc = { enumerable: true, get: function() { return m[k]; } };
12
+ }
13
+ Object.defineProperty(o, k2, desc);
14
+ }) : (function(o, m, k, k2) {
15
+ if (k2 === undefined) k2 = k;
16
+ o[k2] = m[k];
17
+ }));
18
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
19
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
20
+ }) : function(o, v) {
21
+ o["default"] = v;
22
+ });
23
+ var __importStar = (this && this.__importStar) || (function () {
24
+ var ownKeys = function(o) {
25
+ ownKeys = Object.getOwnPropertyNames || function (o) {
26
+ var ar = [];
27
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
28
+ return ar;
29
+ };
30
+ return ownKeys(o);
31
+ };
32
+ return function (mod) {
33
+ if (mod && mod.__esModule) return mod;
34
+ var result = {};
35
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
36
+ __setModuleDefault(result, mod);
37
+ return result;
38
+ };
39
+ })();
40
+ Object.defineProperty(exports, "__esModule", { value: true });
41
+ exports.getEvidenceShipStatus = getEvidenceShipStatus;
42
+ exports.validateEvidenceUrl = validateEvidenceUrl;
43
+ exports.resolveSessionShipConfig = resolveSessionShipConfig;
44
+ exports.redactSessionLine = redactSessionLine;
45
+ exports.startSessionShipper = startSessionShipper;
46
+ const path = __importStar(require("path"));
47
+ const fs_1 = require("fs");
48
+ const crypto_1 = require("crypto");
49
+ const zlib_1 = require("zlib");
50
+ const identity_1 = require("../analytics/identity");
51
+ const sessionEvents_1 = require("../analytics/sessionEvents");
52
+ const DISABLE_ENV = 'AGENTHIPPO_EVIDENCE_SHIP';
53
+ const URL_ENV = 'AGENTHIPPO_EVIDENCE_URL';
54
+ const TOKEN_ENV = 'AGENTHIPPO_EVIDENCE_SHIP_TOKEN';
55
+ const INTERVAL_ENV = 'AGENTHIPPO_EVIDENCE_SHIP_INTERVAL_SEC';
56
+ const SINCE_DAYS_ENV = 'AGENTHIPPO_EVIDENCE_SHIP_SINCE_DAYS';
57
+ const DRAIN_TIMEOUT_ENV = 'AGENTHIPPO_EVIDENCE_SHIP_DRAIN_TIMEOUT_SEC';
58
+ const ALLOW_INSECURE_ENV = 'AGENTHIPPO_EVIDENCE_ALLOW_INSECURE_URL';
59
+ /**
60
+ * Default ship window, in days.
61
+ *
62
+ * Was hard-coded to 7. An outage longer than the window makes the backlog PERMANENTLY
63
+ * unshippable — the shipper skips those files forever, so a two-week incident silently deletes
64
+ * the two weeks of evidence that matter most. 30 days is a working default; set
65
+ * AGENTHIPPO_EVIDENCE_SHIP_SINCE_DAYS higher for a longer guaranteed recovery window.
66
+ */
67
+ const DEFAULT_SINCE_DAYS = 30;
68
+ /** Was 5s, which is shorter than a single in-flight ship attempt with its retries. */
69
+ const DEFAULT_DRAIN_TIMEOUT_SEC = 30;
70
+ /** Session-event payload keys that can carry prompt/tool CONTENT rather than metadata. */
71
+ const CONTENT_KEY_RE = /(^|_)(input|output|result|prompt|completion|content|text|messages?)(_|$)|preview$/i;
72
+ let lastStatus = {
73
+ enabled: false,
74
+ disabledReason: 'not-configured',
75
+ consecutiveFailures: 0,
76
+ recordsStaged: 0,
77
+ recordsShipped: 0,
78
+ recordsSkippedTooOld: 0,
79
+ filesSkippedTooOld: 0,
80
+ writeFailures: 0,
81
+ contentCapture: false,
82
+ };
83
+ /**
84
+ * Evidence shipping status for /health.
85
+ *
86
+ * Deliberately available even when shipping is OFF: "disabled" must be a visible state, not the
87
+ * absence of one, or a deployment that silently stopped shipping looks identical to a quiet one.
88
+ */
89
+ function getEvidenceShipStatus() {
90
+ return { ...lastStatus, writeFailures: (0, sessionEvents_1.getSessionEventWriterStats)().failed };
91
+ }
92
+ function setStatus(patch) {
93
+ lastStatus = { ...lastStatus, ...patch };
94
+ }
95
+ function isTruthy(value) {
96
+ return ['1', 'true', 'yes', 'on'].includes((value ?? '').trim().toLowerCase());
97
+ }
98
+ /** evidence-ship reads EVIDENCE_ALLOW_INSECURE_URL; serve documents AGENTHIPPO_EVIDENCE_ALLOW_INSECURE_URL. */
99
+ function resolveAllowInsecure(env) {
100
+ return isTruthy(env[ALLOW_INSECURE_ENV]) || isTruthy(env.EVIDENCE_ALLOW_INSECURE_URL);
101
+ }
102
+ const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]', '0.0.0.0']);
103
+ /**
104
+ * Validate the ingest URL.
105
+ *
106
+ * This is a bearer token plus the organisation's audit trail; plaintext http on anything but
107
+ * loopback puts both on the wire. Private-network deployments (`http://evidence:8787` on a
108
+ * compose network) can opt back in with AGENTHIPPO_EVIDENCE_ALLOW_INSECURE_URL=1.
109
+ */
110
+ function validateEvidenceUrl(raw, env = process.env) {
111
+ let parsed;
112
+ try {
113
+ parsed = new URL(raw);
114
+ }
115
+ catch {
116
+ return { error: `${URL_ENV} is not a valid URL: ${JSON.stringify(raw)}` };
117
+ }
118
+ if (parsed.protocol === 'https:') {
119
+ return { url: parsed.toString().replace(/\/$/, '') };
120
+ }
121
+ if (parsed.protocol !== 'http:') {
122
+ return { error: `${URL_ENV} must be https (got ${parsed.protocol.replace(':', '')})` };
123
+ }
124
+ if (LOOPBACK_HOSTS.has(parsed.hostname)) {
125
+ return { url: parsed.toString().replace(/\/$/, '') };
126
+ }
127
+ if (resolveAllowInsecure(env)) {
128
+ const reason = isTruthy(env[ALLOW_INSECURE_ENV])
129
+ ? ALLOW_INSECURE_ENV
130
+ : 'EVIDENCE_ALLOW_INSECURE_URL';
131
+ console.warn(`[evidence-ship] INSECURE: shipping evidence and the ingest token over plaintext http ` +
132
+ `to ${parsed.host} because ${reason} is set. Use https unless this network ` +
133
+ `is genuinely private.`);
134
+ return { url: parsed.toString().replace(/\/$/, '') };
135
+ }
136
+ return {
137
+ error: `${URL_ENV} must use https — plaintext http to ${parsed.host} would put the ingest ` +
138
+ `token and the audit trail on the wire. Use https, a loopback address, or set ` +
139
+ `${ALLOW_INSECURE_ENV}=1 (or EVIDENCE_ALLOW_INSECURE_URL=1) if the network is genuinely private.`,
140
+ };
141
+ }
142
+ /** Resolve config from env; returns undefined when shipping is disabled or misconfigured. */
143
+ function resolveSessionShipConfig(instanceId, sessionsDir, stateDir, env = process.env) {
144
+ if (env[DISABLE_ENV] === '0' || env[DISABLE_ENV]?.toLowerCase() === 'false') {
145
+ // Disabling evidence shipping is legitimate, but it must never be quiet: this is the one
146
+ // setting that turns the compliance record off, and it was a single character in an env
147
+ // file that produced no output at all.
148
+ console.warn('\n' +
149
+ ' ############################################################\n' +
150
+ ' ## EVIDENCE SHIPPING IS DISABLED ##\n' +
151
+ ` ## ${DISABLE_ENV}=${env[DISABLE_ENV]}\n` +
152
+ ' ## Session evidence stays on local disk and is lost when ##\n' +
153
+ ' ## this instance goes away. Unset to re-enable. ##\n' +
154
+ ' ############################################################\n');
155
+ setStatus({ enabled: false, disabledReason: 'disabled-by-env' });
156
+ return undefined;
157
+ }
158
+ const rawUrl = env[URL_ENV]?.trim();
159
+ const shipToken = env[TOKEN_ENV]?.trim();
160
+ if (!rawUrl || !shipToken) {
161
+ // Half-configured is a mistake, not a choice: an operator who set one of the pair meant to
162
+ // ship. Silence here made "misconfigured" look exactly like "deliberately off" — and
163
+ // /health only grows evidence_ship_last_ok_at after the first SUCCESS, so nothing else
164
+ // distinguishes them either.
165
+ if (rawUrl || shipToken) {
166
+ const missing = rawUrl ? TOKEN_ENV : URL_ENV;
167
+ console.warn(`[evidence-ship] disabled — ${missing} is not set (the other half of the pair is)`);
168
+ }
169
+ setStatus({ enabled: false, disabledReason: 'not-configured' });
170
+ return undefined;
171
+ }
172
+ const checked = validateEvidenceUrl(rawUrl, env);
173
+ if ('error' in checked) {
174
+ console.error(`[evidence-ship] DISABLED — ${checked.error}`);
175
+ setStatus({ enabled: false, disabledReason: 'invalid-url' });
176
+ return undefined;
177
+ }
178
+ const intervalSec = Math.max(15, Number(env[INTERVAL_ENV] ?? 60) || 60);
179
+ const sinceDays = Math.max(1, Number(env[SINCE_DAYS_ENV] ?? DEFAULT_SINCE_DAYS) || DEFAULT_SINCE_DAYS);
180
+ const drainTimeoutSec = Math.max(1, Number(env[DRAIN_TIMEOUT_ENV] ?? DEFAULT_DRAIN_TIMEOUT_SEC) || DEFAULT_DRAIN_TIMEOUT_SEC);
181
+ return {
182
+ evidenceUrl: checked.url,
183
+ shipToken,
184
+ instanceId,
185
+ sessionsDir,
186
+ stateDir,
187
+ intervalSec,
188
+ sinceDays,
189
+ drainTimeoutSec,
190
+ allowInsecure: resolveAllowInsecure(env),
191
+ };
192
+ }
193
+ // -----------------------------------------------------------------------------
194
+ // Redaction (all lanes) — see fix note 3 at the top of this file
195
+ // -----------------------------------------------------------------------------
196
+ function digest(text) {
197
+ return {
198
+ sha256: (0, crypto_1.createHash)('sha256').update(text, 'utf8').digest('hex'),
199
+ length: Buffer.byteLength(text, 'utf8'),
200
+ };
201
+ }
202
+ /**
203
+ * Strip content-bearing payload fields from one session-event line, leaving a sha256 + length.
204
+ *
205
+ * Applied on EVERY lane. The upstream shipper only redacts when `source === 'seat'`, so serve —
206
+ * the multi-tenant lane, the one with other people's data in it — shipped raw. A line we cannot
207
+ * parse is passed through unchanged: it is already not a record we produced, and dropping it
208
+ * would be exactly the silent evidence loss this file exists to prevent.
209
+ */
210
+ function redactSessionLine(line, captureContent) {
211
+ if (captureContent) {
212
+ return line;
213
+ }
214
+ let doc;
215
+ try {
216
+ doc = JSON.parse(line);
217
+ }
218
+ catch {
219
+ return line;
220
+ }
221
+ if (!doc || typeof doc !== 'object') {
222
+ return line;
223
+ }
224
+ const record = doc;
225
+ if (!record.payload || typeof record.payload !== 'object') {
226
+ return line;
227
+ }
228
+ const payload = record.payload;
229
+ let changed = false;
230
+ for (const key of Object.keys(payload)) {
231
+ if (!CONTENT_KEY_RE.test(key) || key.endsWith('_sha256') || key.endsWith('_length')) {
232
+ continue;
233
+ }
234
+ const value = payload[key];
235
+ if (value === undefined || value === null) {
236
+ continue;
237
+ }
238
+ const text = typeof value === 'string' ? value : JSON.stringify(value);
239
+ const d = digest(text);
240
+ delete payload[key];
241
+ payload[`${key}_sha256`] = d.sha256;
242
+ payload[`${key}_length`] = d.length;
243
+ payload[`${key}_redacted`] = true;
244
+ changed = true;
245
+ }
246
+ return changed ? JSON.stringify(record) : line;
247
+ }
248
+ function emptyCursor() {
249
+ return { version: 1, files: {}, pending: { staged: 0, shipped: 0, skippedRecords: 0, skippedFiles: 0 } };
250
+ }
251
+ function countRecords(text) {
252
+ let n = 0;
253
+ for (const line of text.split('\n')) {
254
+ if (line.trim()) {
255
+ n++;
256
+ }
257
+ }
258
+ return n;
259
+ }
260
+ /** Start in-process session shipper: one drain on boot, then interval loop. */
261
+ function startSessionShipper(config) {
262
+ const cursorFile = path.join(config.stateDir, '.evidence-ship-cursor.json');
263
+ const stageCursorFile = path.join(config.stateDir, '.evidence-stage-cursor.json');
264
+ // Redacted copies live outside sessionsDir so the shipper never re-reads its own output and
265
+ // DuckDB queries keep pointing at the unredacted local store.
266
+ const stagingDir = path.join(config.stateDir, 'evidence-staging', 'sessions');
267
+ const log = config.logger?.info ?? (() => undefined);
268
+ const warn = config.logger?.warn ?? (() => undefined);
269
+ const sinceDays = config.sinceDays ?? DEFAULT_SINCE_DAYS;
270
+ const intervalSec = config.intervalSec ?? 60;
271
+ const drainTimeoutMs = (config.drainTimeoutSec ?? DEFAULT_DRAIN_TIMEOUT_SEC) * 1000;
272
+ const captureContent = (0, identity_1.isContentCaptureEnabled)();
273
+ const allowInsecure = config.allowInsecure === true;
274
+ let stopped = false;
275
+ let inFlight = false;
276
+ let lastOkAt;
277
+ let consecutiveFailures = 0;
278
+ let timer;
279
+ let shipperPromise;
280
+ let cursor;
281
+ /** Records staged but not yet confirmed shipped — what `flush()` reports as abandoned. */
282
+ let unshippedRecords = 0;
283
+ const totals = { staged: 0, shipped: 0, skippedRecords: 0, skippedFiles: 0 };
284
+ setStatus({
285
+ enabled: true,
286
+ disabledReason: undefined,
287
+ evidenceUrl: config.evidenceUrl,
288
+ intervalSec,
289
+ sinceDays,
290
+ consecutiveFailures: 0,
291
+ recordsStaged: 0,
292
+ recordsShipped: 0,
293
+ recordsSkippedTooOld: 0,
294
+ filesSkippedTooOld: 0,
295
+ contentCapture: captureContent,
296
+ });
297
+ const getShipper = () => {
298
+ if (!shipperPromise) {
299
+ shipperPromise = import('@agenthippo-ai/evidence-ship').then(({ createShipper }) => createShipper({
300
+ serverUrl: config.evidenceUrl,
301
+ token: config.shipToken,
302
+ instanceId: config.instanceId,
303
+ source: 'serve',
304
+ sinceDays,
305
+ cursorFile,
306
+ // evidence-ship >=0.2.0 strips content on every lane by default. serve already
307
+ // redacts before staging and that redaction is idempotent, so leaving this unset
308
+ // is safe — but it would also strip content on a deployment that deliberately
309
+ // opted in, turning the opt-in into a no-op. Pass our own decision through.
310
+ captureContent,
311
+ // Compose stacks use http://evidence:8787 on a private network. serve accepts that
312
+ // via AGENTHIPPO_EVIDENCE_ALLOW_INSECURE_URL; evidence-ship needs allowInsecure here
313
+ // (or EVIDENCE_ALLOW_INSECURE_URL in the process env).
314
+ allowInsecure,
315
+ // Heartbeat is posted here instead: the upstream one is the literal '{}', which
316
+ // proves the process was alive and nothing about whether records are complete.
317
+ heartbeat: false,
318
+ log: (m) => log(m),
319
+ error: (m) => warn(m),
320
+ }));
321
+ }
322
+ return shipperPromise;
323
+ };
324
+ const loadCursor = async () => {
325
+ if (cursor) {
326
+ return cursor;
327
+ }
328
+ try {
329
+ const raw = await fs_1.promises.readFile(stageCursorFile, 'utf8');
330
+ const parsed = JSON.parse(raw);
331
+ cursor = {
332
+ version: 1,
333
+ files: parsed.files ?? {},
334
+ windowFrom: parsed.windowFrom,
335
+ pending: parsed.pending ?? emptyCursor().pending,
336
+ };
337
+ }
338
+ catch {
339
+ cursor = emptyCursor();
340
+ }
341
+ return cursor;
342
+ };
343
+ const saveCursor = async () => {
344
+ if (!cursor) {
345
+ return;
346
+ }
347
+ try {
348
+ await fs_1.promises.mkdir(path.dirname(stageCursorFile), { recursive: true });
349
+ await fs_1.promises.writeFile(stageCursorFile, JSON.stringify(cursor, null, 1));
350
+ }
351
+ catch (err) {
352
+ warn(`[evidence-ship] could not persist stage cursor: ${err instanceof Error ? err.message : String(err)}`);
353
+ }
354
+ };
355
+ /**
356
+ * Redact + stage changed session files, and record permanent losses.
357
+ *
358
+ * Returns the number of records staged this pass. Staged files get a strictly increasing
359
+ * mtime so the upstream mtime-equality cursor always re-ships them; the decision about
360
+ * WHETHER to restage is ours and is made on content (size + sha256), which an editor that
361
+ * preserves mtime cannot fool.
362
+ */
363
+ const stage = async () => {
364
+ const state = await loadCursor();
365
+ const now = Date.now();
366
+ const sinceMs = now - sinceDays * 86400_000;
367
+ let staged = 0;
368
+ let names;
369
+ try {
370
+ names = (await fs_1.promises.readdir(config.sessionsDir)).filter((f) => f.endsWith('.jsonl'));
371
+ }
372
+ catch {
373
+ return 0; // no sessions dir yet
374
+ }
375
+ await fs_1.promises.mkdir(stagingDir, { recursive: true });
376
+ const losses = [];
377
+ for (const name of names) {
378
+ if (name.startsWith('_evidence-loss')) {
379
+ continue;
380
+ }
381
+ const src = path.join(config.sessionsDir, name);
382
+ let stat;
383
+ try {
384
+ stat = await fs_1.promises.stat(src);
385
+ }
386
+ catch {
387
+ continue;
388
+ }
389
+ if (stat.mtimeMs < sinceMs) {
390
+ // PERMANENT loss, not a deferral: the shipper will skip this file on every future
391
+ // tick, so these records are never going to arrive. Say so, once, loudly, and put
392
+ // a record of the loss into the evidence stream itself — a gap the server can see
393
+ // beats a gap only the disk knows about.
394
+ const entry = state.files[name];
395
+ if (!entry?.lostReported) {
396
+ let records = 0;
397
+ try {
398
+ records = countRecords(await fs_1.promises.readFile(src, 'utf8'));
399
+ }
400
+ catch { /* size-only report below */ }
401
+ totals.skippedFiles++;
402
+ totals.skippedRecords += records;
403
+ state.pending.skippedFiles++;
404
+ state.pending.skippedRecords += records;
405
+ state.files[name] = {
406
+ size: stat.size,
407
+ sha256: '',
408
+ stagedAt: 0,
409
+ stagedMtimeMs: entry?.stagedMtimeMs ?? 0,
410
+ lostReported: true,
411
+ };
412
+ warn(`[evidence-ship] EVIDENCE PERMANENTLY SKIPPED: ${name} last changed ` +
413
+ `${new Date(stat.mtimeMs).toISOString()}, older than the ${sinceDays}-day ship ` +
414
+ `window — ${records} record(s) will never be shipped. Raise ` +
415
+ `${SINCE_DAYS_ENV} before the next restart to recover a backlog this old.`);
416
+ losses.push(JSON.stringify({
417
+ session_id: '_evidence-loss',
418
+ request_id: name,
419
+ timestamp: now,
420
+ event_type: 'evidence_skipped',
421
+ payload: {
422
+ reason: 'older_than_ship_window',
423
+ file: name,
424
+ file_mtime_ms: stat.mtimeMs,
425
+ file_bytes: stat.size,
426
+ records: records,
427
+ since_days: sinceDays,
428
+ instance_id: config.instanceId,
429
+ permanent: true,
430
+ },
431
+ }));
432
+ }
433
+ continue;
434
+ }
435
+ let content;
436
+ try {
437
+ content = await fs_1.promises.readFile(src, 'utf8');
438
+ }
439
+ catch (err) {
440
+ warn(`[evidence-ship] cannot read ${name}: ${err instanceof Error ? err.message : String(err)}`);
441
+ continue;
442
+ }
443
+ const sha256 = (0, crypto_1.createHash)('sha256').update(content, 'utf8').digest('hex');
444
+ const prev = state.files[name];
445
+ if (prev && prev.sha256 === sha256 && prev.size === content.length) {
446
+ continue; // genuinely unchanged — content, not a timestamp, says so
447
+ }
448
+ const redacted = content
449
+ .split('\n')
450
+ .map((line) => (line.trim() ? redactSessionLine(line, captureContent) : line))
451
+ .join('\n');
452
+ const dest = path.join(stagingDir, name);
453
+ await fs_1.promises.writeFile(dest, redacted);
454
+ // Strictly increasing: the upstream cursor skips a file whose mtime it has already
455
+ // seen, so a same-millisecond restage would otherwise be dropped.
456
+ const stagedMtimeMs = Math.max(Date.now(), (prev?.stagedMtimeMs ?? 0) + 1000);
457
+ const seconds = stagedMtimeMs / 1000;
458
+ await fs_1.promises.utimes(dest, seconds, seconds);
459
+ const records = countRecords(redacted);
460
+ staged += records;
461
+ state.files[name] = { size: content.length, sha256, stagedAt: now, stagedMtimeMs };
462
+ }
463
+ if (losses.length > 0) {
464
+ // Appended, never truncated: a loss record must outlive the tick that noticed it.
465
+ //
466
+ // The NAME matters and cannot be `_evidence-loss.jsonl`: @agenthippo-ai/evidence-ship's
467
+ // shipSessions() skips every file whose name starts with `_evidence-loss` (it posts its
468
+ // OWN losses under that name from its cursor, and refuses to re-ingest one found on
469
+ // disk). Staging ours under that name made it a dead letter — the warning printed, the
470
+ // counters incremented, and the record never left the machine, which is precisely the
471
+ // silent gap this feature exists to close.
472
+ const lossFile = path.join(stagingDir, 'evidence-loss.jsonl');
473
+ await fs_1.promises.appendFile(lossFile, `${losses.join('\n')}\n`);
474
+ const seconds = Date.now() / 1000;
475
+ await fs_1.promises.utimes(lossFile, seconds, seconds);
476
+ staged += losses.length;
477
+ }
478
+ totals.staged += staged;
479
+ state.pending.staged += staged;
480
+ if (state.windowFrom === undefined) {
481
+ state.windowFrom = now;
482
+ }
483
+ await saveCursor();
484
+ return staged;
485
+ };
486
+ /**
487
+ * Heartbeat with substance (R-13).
488
+ *
489
+ * The upstream shipper posts the literal `'{}'`. That proves a process was alive; it says
490
+ * nothing about whether the window it covers is complete, which is the entire question gap
491
+ * detection asks. Window bounds plus counts let the server spot both a missing interval and an
492
+ * interval that shipped fewer records than were produced.
493
+ */
494
+ const postHeartbeat = async () => {
495
+ const state = await loadCursor();
496
+ const now = Date.now();
497
+ const writeStats = (0, sessionEvents_1.getSessionEventWriterStats)();
498
+ const body = {
499
+ kind: 'session_ship_heartbeat',
500
+ instance_id: config.instanceId,
501
+ wire: 'agenthippo/session-ship/1',
502
+ window_from_ms: state.windowFrom ?? now,
503
+ window_to_ms: now,
504
+ interval_sec: intervalSec,
505
+ since_days: sinceDays,
506
+ records_staged: state.pending.staged,
507
+ records_shipped: state.pending.shipped,
508
+ records_skipped_too_old: state.pending.skippedRecords,
509
+ files_skipped_too_old: state.pending.skippedFiles,
510
+ evidence_write_failures: writeStats.failed,
511
+ evidence_writes: writeStats.written,
512
+ consecutive_ship_failures: consecutiveFailures,
513
+ content_capture: captureContent,
514
+ totals: { ...totals },
515
+ };
516
+ let INGEST_WIRE_VERSION = 1;
517
+ try {
518
+ ({ INGEST_WIRE_VERSION } = await import('@agenthippo-ai/evidence-ship'));
519
+ }
520
+ catch { /* keep the default */ }
521
+ const text = JSON.stringify(body);
522
+ const res = await fetch(`${config.evidenceUrl.replace(/\/$/, '')}/ingest/batch`, {
523
+ method: 'POST',
524
+ headers: {
525
+ authorization: `Bearer ${config.shipToken}`,
526
+ 'content-type': 'application/json',
527
+ 'X-Evidence-Ingest-Version': String(INGEST_WIRE_VERSION),
528
+ },
529
+ body: JSON.stringify({
530
+ instance_id: config.instanceId,
531
+ kind: 'heartbeat',
532
+ name: 'heartbeat.json',
533
+ content_gzip_b64: (0, zlib_1.gzipSync)(Buffer.from(text)).toString('base64'),
534
+ }),
535
+ });
536
+ if (!res.ok) {
537
+ throw new Error(`heartbeat HTTP ${res.status}`);
538
+ }
539
+ // Only now is the window closed: a failed heartbeat must keep accumulating, or the counts
540
+ // for the lost interval disappear along with it.
541
+ state.windowFrom = now;
542
+ state.pending = { staged: 0, shipped: 0, skippedRecords: 0, skippedFiles: 0 };
543
+ setStatus({ lastHeartbeatAt: now });
544
+ await saveCursor();
545
+ return true;
546
+ };
547
+ const tick = async () => {
548
+ if (stopped || inFlight) {
549
+ return;
550
+ }
551
+ inFlight = true;
552
+ try {
553
+ const staged = await stage();
554
+ unshippedRecords += staged;
555
+ try {
556
+ const shipper = await getShipper();
557
+ const ok = await shipper.drain({ sessions: stagingDir, heartbeat: false });
558
+ if (ok) {
559
+ const state = await loadCursor();
560
+ state.pending.shipped += unshippedRecords;
561
+ totals.shipped += unshippedRecords;
562
+ unshippedRecords = 0;
563
+ lastOkAt = Date.now();
564
+ consecutiveFailures = 0;
565
+ }
566
+ else {
567
+ consecutiveFailures++;
568
+ warn(`[evidence-ship] drain failed (${consecutiveFailures} in a row); ` +
569
+ `${unshippedRecords} record(s) still unshipped`);
570
+ }
571
+ }
572
+ catch (shipErr) {
573
+ consecutiveFailures++;
574
+ warn(`[evidence-ship] ${shipErr instanceof Error ? shipErr.message : String(shipErr)}`);
575
+ }
576
+ }
577
+ catch (err) {
578
+ consecutiveFailures++;
579
+ warn(`[evidence-ship] ${err instanceof Error ? err.message : String(err)}`);
580
+ }
581
+ finally {
582
+ // R-13: heartbeat runs every tick even when drain/shipper fails, so gap detection
583
+ // sees window bounds and staged-vs-shipped counts instead of silence.
584
+ try {
585
+ await postHeartbeat();
586
+ }
587
+ catch (hbErr) {
588
+ warn(`[evidence-ship] heartbeat failed: ${hbErr instanceof Error ? hbErr.message : String(hbErr)}`);
589
+ }
590
+ inFlight = false;
591
+ setStatus({
592
+ lastOkAt,
593
+ consecutiveFailures,
594
+ recordsStaged: totals.staged,
595
+ recordsShipped: totals.shipped,
596
+ recordsSkippedTooOld: totals.skippedRecords,
597
+ filesSkippedTooOld: totals.skippedFiles,
598
+ });
599
+ }
600
+ };
601
+ void tick();
602
+ timer = setInterval(() => void tick(), intervalSec * 1000);
603
+ timer.unref?.();
604
+ log(`[evidence-ship] enabled → ${config.evidenceUrl} (every ${intervalSec}s, ` +
605
+ `${sinceDays}-day window, content capture ${captureContent ? 'ON' : 'off'})`);
606
+ const stop = () => {
607
+ stopped = true;
608
+ if (timer) {
609
+ clearInterval(timer);
610
+ }
611
+ };
612
+ return {
613
+ stop,
614
+ async flush() {
615
+ stop();
616
+ // A tick already running holds the cursor; let it finish rather than racing it, then
617
+ // ship the tail. `stopped` is cleared only for this final pass.
618
+ const deadline = Date.now() + drainTimeoutMs;
619
+ while (inFlight && Date.now() < deadline) {
620
+ await new Promise((r) => setTimeout(r, 50));
621
+ }
622
+ if (inFlight) {
623
+ warn(`[evidence-ship] FINAL DRAIN ABANDONED after ${Math.round(drainTimeoutMs / 1000)}s ` +
624
+ `— a ship was still in flight. ${unshippedRecords} staged record(s) plus anything ` +
625
+ `written since are lost if this state dir is ephemeral. Raise ` +
626
+ `${DRAIN_TIMEOUT_ENV} if this recurs.`);
627
+ return;
628
+ }
629
+ stopped = false;
630
+ try {
631
+ await tick();
632
+ }
633
+ finally {
634
+ stopped = true;
635
+ }
636
+ if (unshippedRecords > 0) {
637
+ warn(`[evidence-ship] final drain finished with ${unshippedRecords} record(s) still unshipped`);
638
+ }
639
+ },
640
+ lastOkAt: () => lastOkAt,
641
+ status: () => getEvidenceShipStatus(),
642
+ };
643
+ }
package/out/extension.js CHANGED
@@ -272,6 +272,15 @@ async function activate(context) {
272
272
  const config = {
273
273
  storagePath: storageBase.fsPath,
274
274
  };
275
+ // Spotlight setup generates the local proxy's key into ~/.agent-hippo/.env (already merged
276
+ // into process.env above). Adopt it so a first-run user never has to copy a key by hand;
277
+ // a key the user enters in Settings arrives later via setParticipantApiKey and wins.
278
+ const generatedLiteLLMKey = process.env.AGENTHIPPO_LITELLM_API_KEY?.trim() ||
279
+ process.env.LITELLM_MASTER_KEY?.trim();
280
+ if (generatedLiteLLMKey) {
281
+ config.litellmApiKey = generatedLiteLLMKey;
282
+ log(outputChannel, "[AgentIDE] Using local LiteLLM key from ~/.agent-hippo/.env");
283
+ }
275
284
  for (const engine of Object.values(types_1.AGENT_ENGINES)) {
276
285
  const dirPath = path.join(storageBase.fsPath, engine.id);
277
286
  try {
@@ -842,7 +851,7 @@ function registerCommands(context, state) {
842
851
  const configuredCliPath = cliConfig.get("path", "");
843
852
  const installSource = cliConfig.get("installSource", "").trim();
844
853
  if (installSource && /[;`|$&\n\r"']/.test(installSource)) {
845
- showError(`${branding_1.AGENTIDE_BRAND_NAME}: Invalid CLI install source. Remove shell control characters from 'agentide.cli.installSource'.`);
854
+ showError(`${branding_1.AGENTIDE_BRAND_NAME}: Invalid CLI install source. Remove shell control characters from '${(0, branding_1.describeSettingKey)("agentide.cli.installSource")}'.`);
846
855
  return { success: false, error: "Invalid install source" };
847
856
  }
848
857
  const health = await (0, runtime_1.getCliHealth)({
@@ -873,7 +882,7 @@ function registerCommands(context, state) {
873
882
  }
874
883
  });
875
884
  if (!scriptPath) {
876
- showError(`${branding_1.AGENTIDE_BRAND_NAME}: CLI setup script not found. Build CLI via extensions/agentide/scripts/build-cli.sh and set agentide.cli.path.`);
885
+ showError(`${branding_1.AGENTIDE_BRAND_NAME}: CLI setup script not found. Build CLI via extensions/agentide/scripts/build-cli.sh and set ${(0, branding_1.describeSettingKey)("agentide.cli.path")}.`);
877
886
  return { success: false, error: "Script not found" };
878
887
  }
879
888
  const setupResult = await runCliSetup(scriptPath, outputChannel, installSource);