@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
@@ -0,0 +1,560 @@
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
+ *--------------------------------------------------------------------------------------------*/
6
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
7
+ if (k2 === undefined) k2 = k;
8
+ var desc = Object.getOwnPropertyDescriptor(m, k);
9
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
10
+ desc = { enumerable: true, get: function() { return m[k]; } };
11
+ }
12
+ Object.defineProperty(o, k2, desc);
13
+ }) : (function(o, m, k, k2) {
14
+ if (k2 === undefined) k2 = k;
15
+ o[k2] = m[k];
16
+ }));
17
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
18
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
19
+ }) : function(o, v) {
20
+ o["default"] = v;
21
+ });
22
+ var __importStar = (this && this.__importStar) || (function () {
23
+ var ownKeys = function(o) {
24
+ ownKeys = Object.getOwnPropertyNames || function (o) {
25
+ var ar = [];
26
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
27
+ return ar;
28
+ };
29
+ return ownKeys(o);
30
+ };
31
+ return function (mod) {
32
+ if (mod && mod.__esModule) return mod;
33
+ var result = {};
34
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
35
+ __setModuleDefault(result, mod);
36
+ return result;
37
+ };
38
+ })();
39
+ Object.defineProperty(exports, "__esModule", { value: true });
40
+ require("mocha");
41
+ const assert = __importStar(require("assert"));
42
+ const path = __importStar(require("path"));
43
+ const os = __importStar(require("os"));
44
+ const fs_1 = require("fs");
45
+ const sessionShip_1 = require("../evidence/sessionShip");
46
+ /** A stand-in evidence server that records what it was actually sent. */
47
+ async function startFakeEvidenceServer() {
48
+ const http = await import('http');
49
+ const zlib = await import('zlib');
50
+ const ingests = [];
51
+ let failing = false;
52
+ const failingKinds = new Set();
53
+ const server = http.createServer((req, res) => {
54
+ if (req.method === 'POST' && req.url === '/ingest/batch') {
55
+ const chunks = [];
56
+ req.on('data', (c) => chunks.push(Buffer.from(c)));
57
+ req.on('end', () => {
58
+ let doc = {};
59
+ try {
60
+ doc = JSON.parse(Buffer.concat(chunks).toString('utf8'));
61
+ }
62
+ catch { /* malformed; treated as unknown kind */ }
63
+ if (failing || (doc.kind && failingKinds.has(doc.kind))) {
64
+ res.writeHead(500, { 'content-type': 'application/json' });
65
+ res.end(JSON.stringify({ error: 'nope' }));
66
+ return;
67
+ }
68
+ try {
69
+ ingests.push({
70
+ kind: doc.kind ?? '',
71
+ name: doc.name ?? '',
72
+ body: zlib.gunzipSync(Buffer.from(doc.content_gzip_b64 ?? '', 'base64')).toString('utf8'),
73
+ });
74
+ }
75
+ catch { /* recorded as nothing */ }
76
+ res.writeHead(200, { 'content-type': 'application/json' });
77
+ res.end(JSON.stringify({ batch_id: 'test', turns: 0 }));
78
+ });
79
+ return;
80
+ }
81
+ res.writeHead(404);
82
+ res.end();
83
+ });
84
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
85
+ const addr = server.address();
86
+ return {
87
+ port: typeof addr === 'object' && addr ? addr.port : 0,
88
+ ingests,
89
+ close: () => server.close(),
90
+ fail: (on) => { failing = on; },
91
+ failKind: (kind, on) => {
92
+ if (on) {
93
+ failingKinds.add(kind);
94
+ }
95
+ else {
96
+ failingKinds.delete(kind);
97
+ }
98
+ },
99
+ };
100
+ }
101
+ function sessionLine(overrides = {}) {
102
+ return JSON.stringify({
103
+ session_id: 's1',
104
+ request_id: 'r1',
105
+ timestamp: Date.now(),
106
+ event_type: 'tool_start',
107
+ payload: {},
108
+ ...overrides,
109
+ });
110
+ }
111
+ async function waitFor(predicate, timeoutMs = 8000) {
112
+ const deadline = Date.now() + timeoutMs;
113
+ while (Date.now() < deadline) {
114
+ if (predicate()) {
115
+ return;
116
+ }
117
+ await new Promise((r) => setTimeout(r, 25));
118
+ }
119
+ throw new Error('timed out waiting for condition');
120
+ }
121
+ suite('Evidence - sessionShip config', () => {
122
+ test('resolveSessionShipConfig returns undefined when URL/token missing', () => {
123
+ assert.strictEqual((0, sessionShip_1.resolveSessionShipConfig)('inst-1', '/tmp/sessions', '/tmp/state', {}), undefined);
124
+ });
125
+ test('resolveSessionShipConfig returns undefined when AGENTHIPPO_EVIDENCE_SHIP=0', () => {
126
+ const cfg = (0, sessionShip_1.resolveSessionShipConfig)('inst-1', '/tmp/sessions', '/tmp/state', {
127
+ AGENTHIPPO_EVIDENCE_URL: 'https://evidence.example.com',
128
+ AGENTHIPPO_EVIDENCE_SHIP_TOKEN: 'tok',
129
+ AGENTHIPPO_EVIDENCE_SHIP: '0',
130
+ });
131
+ assert.strictEqual(cfg, undefined);
132
+ });
133
+ /**
134
+ * Fix 2f: disabling the compliance record was a single character in an env file that produced
135
+ * no output whatsoever. It stays possible, but it can no longer be accidental or unnoticed.
136
+ */
137
+ test('disabling shipping prints an unmissable banner and shows up in status', () => {
138
+ const warnings = [];
139
+ const original = console.warn;
140
+ console.warn = (m) => { warnings.push(String(m)); };
141
+ try {
142
+ (0, sessionShip_1.resolveSessionShipConfig)('inst-1', '/tmp/sessions', '/tmp/state', {
143
+ AGENTHIPPO_EVIDENCE_URL: 'https://evidence.example.com',
144
+ AGENTHIPPO_EVIDENCE_SHIP_TOKEN: 'tok',
145
+ AGENTHIPPO_EVIDENCE_SHIP: '0',
146
+ });
147
+ }
148
+ finally {
149
+ console.warn = original;
150
+ }
151
+ assert.ok(warnings.some((w) => w.includes('EVIDENCE SHIPPING IS DISABLED')), `no banner: ${JSON.stringify(warnings)}`);
152
+ const status = (0, sessionShip_1.getEvidenceShipStatus)();
153
+ assert.strictEqual(status.enabled, false);
154
+ assert.strictEqual(status.disabledReason, 'disabled-by-env');
155
+ });
156
+ test('resolveSessionShipConfig builds config from env', () => {
157
+ const cfg = (0, sessionShip_1.resolveSessionShipConfig)('my-instance', '/data/sessions', '/data/state', {
158
+ AGENTHIPPO_EVIDENCE_URL: 'https://evidence.example.com',
159
+ AGENTHIPPO_EVIDENCE_SHIP_TOKEN: 'serve-tok',
160
+ AGENTHIPPO_EVIDENCE_SHIP_INTERVAL_SEC: '120',
161
+ });
162
+ assert.ok(cfg);
163
+ assert.strictEqual(cfg.instanceId, 'my-instance');
164
+ assert.strictEqual(cfg.evidenceUrl, 'https://evidence.example.com');
165
+ assert.strictEqual(cfg.shipToken, 'serve-tok');
166
+ assert.strictEqual(cfg.intervalSec, 120);
167
+ assert.strictEqual(cfg.sessionsDir, '/data/sessions');
168
+ });
169
+ /**
170
+ * Fix 2b: the window was hard-coded to 7 days, so an outage longer than a week deleted the
171
+ * backlog rather than delaying it — and the week that broke is the week you need.
172
+ */
173
+ test('the ship window is configurable and defaults to more than a week', () => {
174
+ const defaulted = (0, sessionShip_1.resolveSessionShipConfig)('i', '/s', '/st', {
175
+ AGENTHIPPO_EVIDENCE_URL: 'https://evidence.example.com',
176
+ AGENTHIPPO_EVIDENCE_SHIP_TOKEN: 'tok',
177
+ });
178
+ assert.ok((defaulted.sinceDays ?? 0) > 7, `default window is ${defaulted.sinceDays}`);
179
+ const explicit = (0, sessionShip_1.resolveSessionShipConfig)('i', '/s', '/st', {
180
+ AGENTHIPPO_EVIDENCE_URL: 'https://evidence.example.com',
181
+ AGENTHIPPO_EVIDENCE_SHIP_TOKEN: 'tok',
182
+ AGENTHIPPO_EVIDENCE_SHIP_SINCE_DAYS: '400',
183
+ });
184
+ assert.strictEqual(explicit.sinceDays, 400);
185
+ });
186
+ /** Fix 2d: 5 seconds is shorter than one in-flight ship with its retries. */
187
+ test('the final-drain timeout is configurable and defaults above 5s', () => {
188
+ const defaulted = (0, sessionShip_1.resolveSessionShipConfig)('i', '/s', '/st', {
189
+ AGENTHIPPO_EVIDENCE_URL: 'https://evidence.example.com',
190
+ AGENTHIPPO_EVIDENCE_SHIP_TOKEN: 'tok',
191
+ });
192
+ assert.ok((defaulted.drainTimeoutSec ?? 0) > 5);
193
+ const explicit = (0, sessionShip_1.resolveSessionShipConfig)('i', '/s', '/st', {
194
+ AGENTHIPPO_EVIDENCE_URL: 'https://evidence.example.com',
195
+ AGENTHIPPO_EVIDENCE_SHIP_TOKEN: 'tok',
196
+ AGENTHIPPO_EVIDENCE_SHIP_DRAIN_TIMEOUT_SEC: '90',
197
+ });
198
+ assert.strictEqual(explicit.drainTimeoutSec, 90);
199
+ });
200
+ test('a half-configured shipper says so instead of looking disabled', () => {
201
+ const warnings = [];
202
+ const original = console.warn;
203
+ console.warn = (m) => { warnings.push(String(m)); };
204
+ try {
205
+ // URL without token: the operator meant to ship and will otherwise see only silence.
206
+ (0, sessionShip_1.resolveSessionShipConfig)('inst-1', '/tmp/sessions', '/tmp/state', {
207
+ AGENTHIPPO_EVIDENCE_URL: 'https://evidence.example.com',
208
+ });
209
+ assert.ok(warnings.some((w) => w.includes('AGENTHIPPO_EVIDENCE_SHIP_TOKEN')));
210
+ warnings.length = 0;
211
+ // Neither set is a deliberate opt-out, and must stay quiet.
212
+ (0, sessionShip_1.resolveSessionShipConfig)('inst-1', '/tmp/sessions', '/tmp/state', {});
213
+ assert.deepStrictEqual(warnings, []);
214
+ }
215
+ finally {
216
+ console.warn = original;
217
+ }
218
+ });
219
+ });
220
+ // -----------------------------------------------------------------------------
221
+ // Fix 2g — the ingest URL is a bearer token plus an audit trail on the wire
222
+ // -----------------------------------------------------------------------------
223
+ suite('Evidence - sessionShip URL validation', () => {
224
+ test('https is accepted', () => {
225
+ assert.deepStrictEqual((0, sessionShip_1.validateEvidenceUrl)('https://evidence.example.com', {}), {
226
+ url: 'https://evidence.example.com',
227
+ });
228
+ });
229
+ test('loopback http is accepted for development', () => {
230
+ assert.ok('url' in (0, sessionShip_1.validateEvidenceUrl)('http://127.0.0.1:8787', {}));
231
+ assert.ok('url' in (0, sessionShip_1.validateEvidenceUrl)('http://localhost:8787', {}));
232
+ });
233
+ test('plaintext http to a remote host is refused', () => {
234
+ const result = (0, sessionShip_1.validateEvidenceUrl)('http://evidence:8787', {});
235
+ assert.ok('error' in result);
236
+ assert.ok(result.error.includes('https'));
237
+ });
238
+ test('plaintext http is recoverable with an explicit, documented opt-in', () => {
239
+ const warnings = [];
240
+ const original = console.warn;
241
+ console.warn = (m) => { warnings.push(String(m)); };
242
+ try {
243
+ const result = (0, sessionShip_1.validateEvidenceUrl)('http://evidence:8787', {
244
+ AGENTHIPPO_EVIDENCE_ALLOW_INSECURE_URL: '1',
245
+ });
246
+ assert.ok('url' in result);
247
+ assert.ok(warnings.some((w) => w.includes('INSECURE')));
248
+ }
249
+ finally {
250
+ console.warn = original;
251
+ }
252
+ });
253
+ test('junk and non-http schemes are refused', () => {
254
+ assert.ok('error' in (0, sessionShip_1.validateEvidenceUrl)('not a url', {}));
255
+ assert.ok('error' in (0, sessionShip_1.validateEvidenceUrl)('file:///etc/passwd', {}));
256
+ assert.ok('error' in (0, sessionShip_1.validateEvidenceUrl)('ftp://evidence.example.com', {}));
257
+ });
258
+ test('an invalid URL disables shipping loudly rather than half-working', () => {
259
+ const errors = [];
260
+ const original = console.error;
261
+ console.error = (m) => { errors.push(String(m)); };
262
+ try {
263
+ const cfg = (0, sessionShip_1.resolveSessionShipConfig)('i', '/s', '/st', {
264
+ AGENTHIPPO_EVIDENCE_URL: 'http://evidence:8787',
265
+ AGENTHIPPO_EVIDENCE_SHIP_TOKEN: 'tok',
266
+ });
267
+ assert.strictEqual(cfg, undefined);
268
+ assert.ok(errors.some((e) => e.includes('DISABLED')));
269
+ }
270
+ finally {
271
+ console.error = original;
272
+ }
273
+ assert.strictEqual((0, sessionShip_1.getEvidenceShipStatus)().disabledReason, 'invalid-url');
274
+ });
275
+ });
276
+ // -----------------------------------------------------------------------------
277
+ // Fix 1 / fix 3 — redaction applies to EVERY lane, not just `seat`
278
+ // -----------------------------------------------------------------------------
279
+ suite('Evidence - sessionShip redaction', () => {
280
+ test('content payload fields become sha256 + length', () => {
281
+ const line = JSON.stringify({
282
+ session_id: 's', request_id: 'r', timestamp: 1, event_type: 'tool_end',
283
+ payload: { toolName: 'Read', resultPreview: 'CUSTOMER SECRET', isError: false },
284
+ });
285
+ const out = JSON.parse((0, sessionShip_1.redactSessionLine)(line, false));
286
+ assert.strictEqual(out.payload.resultPreview, undefined);
287
+ assert.strictEqual(out.payload.resultPreview_length, 15);
288
+ assert.strictEqual(out.payload.resultPreview_redacted, true);
289
+ assert.strictEqual(typeof out.payload.resultPreview_sha256, 'string');
290
+ // Metadata is untouched — redaction must not cost attribution.
291
+ assert.strictEqual(out.payload.toolName, 'Read');
292
+ assert.strictEqual(out.payload.isError, false);
293
+ assert.strictEqual(out.session_id, 's');
294
+ });
295
+ test('the opt-in leaves content alone', () => {
296
+ const line = JSON.stringify({ payload: { inputPreview: 'ls -la' } });
297
+ assert.strictEqual((0, sessionShip_1.redactSessionLine)(line, true), line);
298
+ });
299
+ test('an unparseable line is passed through, never dropped', () => {
300
+ // Dropping it would be exactly the silent loss this module exists to prevent.
301
+ assert.strictEqual((0, sessionShip_1.redactSessionLine)('{not json', false), '{not json');
302
+ });
303
+ test('already-digested fields are left alone (no double hashing)', () => {
304
+ const line = JSON.stringify({ payload: { input_sha256: 'abc', input_length: 3 } });
305
+ const out = JSON.parse((0, sessionShip_1.redactSessionLine)(line, false));
306
+ assert.strictEqual(out.payload.input_sha256, 'abc');
307
+ assert.strictEqual(out.payload.input_length, 3);
308
+ });
309
+ });
310
+ // -----------------------------------------------------------------------------
311
+ // The shipper itself
312
+ // -----------------------------------------------------------------------------
313
+ suite('Evidence - sessionShip runtime', () => {
314
+ let root;
315
+ let stateDir;
316
+ let sessionsDir;
317
+ let server;
318
+ setup(async () => {
319
+ root = await fs_1.promises.mkdtemp(path.join(os.tmpdir(), 'evidence-ship-test-'));
320
+ stateDir = path.join(root, 'state');
321
+ sessionsDir = path.join(stateDir, 'analytics', 'sessions');
322
+ await fs_1.promises.mkdir(sessionsDir, { recursive: true });
323
+ server = await startFakeEvidenceServer();
324
+ });
325
+ teardown(async () => {
326
+ server.close();
327
+ await fs_1.promises.rm(root, { recursive: true, force: true });
328
+ });
329
+ function start(overrides = {}) {
330
+ return (0, sessionShip_1.startSessionShipper)({
331
+ evidenceUrl: `http://127.0.0.1:${server.port}`,
332
+ shipToken: 'test-token',
333
+ instanceId: 'test-instance',
334
+ sessionsDir,
335
+ stateDir,
336
+ intervalSec: 3600,
337
+ sinceDays: 30,
338
+ ...overrides,
339
+ });
340
+ }
341
+ test('cursor files stay outside the sessions dir', async function () {
342
+ this.timeout(15000);
343
+ await fs_1.promises.writeFile(path.join(sessionsDir, 'sess.jsonl'), `${sessionLine()}\n`);
344
+ const handle = start();
345
+ await waitFor(() => server.ingests.length > 0);
346
+ handle.stop();
347
+ assert.ok(await fs_1.promises.stat(path.join(stateDir, '.evidence-ship-cursor.json')).then(() => true).catch(() => false));
348
+ assert.ok(await fs_1.promises.stat(path.join(stateDir, '.evidence-stage-cursor.json')).then(() => true).catch(() => false));
349
+ const inSessions = await fs_1.promises.readdir(sessionsDir);
350
+ assert.deepStrictEqual(inSessions, ['sess.jsonl'], 'nothing extra written into the evidence dir');
351
+ });
352
+ /** Fix 1/3: the upstream shipper redacts only `source: 'seat'`; serve shipped raw. */
353
+ test('session content is redacted before it reaches the shipper', async function () {
354
+ this.timeout(15000);
355
+ await fs_1.promises.writeFile(path.join(sessionsDir, 'sess.jsonl'), `${sessionLine({ payload: { toolName: 'Read', resultPreview: 'CUSTOMER SECRET' } })}\n`);
356
+ const handle = start();
357
+ await waitFor(() => server.ingests.some((i) => i.kind === 'sessions'));
358
+ handle.stop();
359
+ const shipped = server.ingests.filter((i) => i.kind === 'sessions');
360
+ assert.ok(shipped.length > 0);
361
+ for (const batch of shipped) {
362
+ assert.ok(!batch.body.includes('CUSTOMER SECRET'), `raw content shipped: ${batch.body}`);
363
+ }
364
+ assert.ok(shipped.some((b) => b.body.includes('resultPreview_sha256')));
365
+ // The local file keeps its detail — redaction is about what LEAVES the machine.
366
+ const local = await fs_1.promises.readFile(path.join(sessionsDir, 'sess.jsonl'), 'utf8');
367
+ assert.ok(local.includes('CUSTOMER SECRET'));
368
+ });
369
+ /**
370
+ * Fix 2c: the upstream cursor compares mtime for EQUALITY, so an edit that preserves mtime is
371
+ * never re-shipped. Ours compares content.
372
+ */
373
+ test('an edit that preserves mtime is still shipped', async function () {
374
+ this.timeout(20000);
375
+ const file = path.join(sessionsDir, 'sess.jsonl');
376
+ await fs_1.promises.writeFile(file, `${sessionLine({ request_id: 'first' })}\n`);
377
+ // A whole-second timestamp, so restoring it later reproduces mtimeMs EXACTLY rather than
378
+ // to within the sub-millisecond precision utimes() round-trips.
379
+ const pinned = Math.floor(Date.now() / 1000);
380
+ await fs_1.promises.utimes(file, pinned, pinned);
381
+ const original = await fs_1.promises.stat(file);
382
+ const handle = start();
383
+ await waitFor(() => server.ingests.some((i) => i.kind === 'sessions' && i.body.includes('first')));
384
+ // Rewrite the file and put the timestamp back exactly as it was — the classic way an
385
+ // after-the-fact edit hides from an mtime cursor.
386
+ await fs_1.promises.writeFile(file, `${sessionLine({ request_id: 'tampered' })}\n`);
387
+ await fs_1.promises.utimes(file, pinned, pinned);
388
+ const after = await fs_1.promises.stat(file);
389
+ assert.strictEqual(after.mtimeMs, original.mtimeMs, 'precondition: mtime was preserved');
390
+ await handle.flush();
391
+ assert.ok(server.ingests.some((i) => i.kind === 'sessions' && i.body.includes('tampered')), 'the mtime-preserving edit was never shipped');
392
+ });
393
+ /**
394
+ * Fix 2b: files past the window are skipped forever. The skip must become a RECORD — a gap the
395
+ * server can see beats a gap only the local disk knows about.
396
+ */
397
+ test('a file too old to ship produces a counted, shipped loss record', async function () {
398
+ this.timeout(20000);
399
+ const stale = path.join(sessionsDir, 'stale.jsonl');
400
+ await fs_1.promises.writeFile(stale, `${sessionLine()}\n${sessionLine()}\n`);
401
+ const longAgo = new Date(Date.now() - 40 * 86400_000);
402
+ await fs_1.promises.utimes(stale, longAgo, longAgo);
403
+ const warnings = [];
404
+ const handle = start({
405
+ sinceDays: 7,
406
+ logger: { info: () => undefined, warn: (m) => warnings.push(m) },
407
+ });
408
+ await waitFor(() => server.ingests.some((i) => i.body.includes('evidence_skipped')));
409
+ handle.stop();
410
+ assert.ok(warnings.some((w) => w.includes('EVIDENCE PERMANENTLY SKIPPED')), `no loud warning: ${JSON.stringify(warnings)}`);
411
+ const loss = server.ingests.find((i) => i.body.includes('evidence_skipped'));
412
+ const record = JSON.parse(loss.body.trim().split('\n')[0]);
413
+ assert.strictEqual(record.payload.reason, 'older_than_ship_window');
414
+ assert.strictEqual(record.payload.file, 'stale.jsonl');
415
+ assert.strictEqual(record.payload.records, 2);
416
+ assert.strictEqual(record.payload.permanent, true);
417
+ const status = handle.status();
418
+ assert.strictEqual(status.filesSkippedTooOld, 1);
419
+ assert.strictEqual(status.recordsSkippedTooOld, 2);
420
+ });
421
+ test('the same permanent loss is reported once, not every tick', async function () {
422
+ this.timeout(20000);
423
+ const stale = path.join(sessionsDir, 'stale.jsonl');
424
+ await fs_1.promises.writeFile(stale, `${sessionLine()}\n`);
425
+ const longAgo = new Date(Date.now() - 40 * 86400_000);
426
+ await fs_1.promises.utimes(stale, longAgo, longAgo);
427
+ const handle = start({ sinceDays: 7 });
428
+ await waitFor(() => server.ingests.some((i) => i.body.includes('evidence_skipped')));
429
+ await handle.flush();
430
+ const lossRecords = server.ingests
431
+ .filter((i) => i.kind === 'sessions')
432
+ .flatMap((i) => i.body.trim().split('\n'))
433
+ .filter((l) => l.includes('evidence_skipped'));
434
+ // The staged loss file is re-sent whole, but it must not GROW a duplicate per tick.
435
+ const unique = new Set(lossRecords.map((l) => JSON.parse(l).payload.file));
436
+ assert.strictEqual(unique.size, 1);
437
+ assert.strictEqual(handle.status().filesSkippedTooOld, 1, 'counted once');
438
+ });
439
+ /**
440
+ * Fix 2e: the heartbeat was the literal '{}'. That proves a process was alive and nothing
441
+ * about whether the record is complete — which is the whole of R-13 gap detection.
442
+ */
443
+ test('the heartbeat carries window bounds and counts, not {}', async function () {
444
+ this.timeout(15000);
445
+ await fs_1.promises.writeFile(path.join(sessionsDir, 'sess.jsonl'), `${sessionLine()}\n${sessionLine()}\n`);
446
+ const handle = start();
447
+ await waitFor(() => server.ingests.some((i) => i.kind === 'heartbeat'));
448
+ handle.stop();
449
+ const beat = server.ingests.find((i) => i.kind === 'heartbeat');
450
+ assert.notStrictEqual(beat.body, '{}');
451
+ const doc = JSON.parse(beat.body);
452
+ assert.strictEqual(doc.instance_id, 'test-instance');
453
+ assert.ok(typeof doc.window_from_ms === 'number');
454
+ assert.ok(typeof doc.window_to_ms === 'number');
455
+ assert.ok(doc.window_to_ms >= doc.window_from_ms);
456
+ assert.strictEqual(doc.records_staged, 2);
457
+ assert.strictEqual(doc.records_shipped, 2);
458
+ assert.strictEqual(doc.records_skipped_too_old, 0);
459
+ assert.strictEqual(typeof doc.evidence_write_failures, 'number');
460
+ assert.strictEqual(doc.since_days, 30);
461
+ });
462
+ test('consecutive heartbeat windows are contiguous, so a gap is detectable', async function () {
463
+ this.timeout(20000);
464
+ await fs_1.promises.writeFile(path.join(sessionsDir, 'sess.jsonl'), `${sessionLine()}\n`);
465
+ const handle = start();
466
+ await waitFor(() => server.ingests.filter((i) => i.kind === 'heartbeat').length >= 1);
467
+ await handle.flush();
468
+ await waitFor(() => server.ingests.filter((i) => i.kind === 'heartbeat').length >= 2);
469
+ const beats = server.ingests.filter((i) => i.kind === 'heartbeat').map((i) => JSON.parse(i.body));
470
+ assert.ok(beats.length >= 2);
471
+ assert.strictEqual(beats[1].window_from_ms, beats[0].window_to_ms, 'window 2 must begin exactly where window 1 ended, or a gap is invisible');
472
+ });
473
+ test('counts accumulate across a failed heartbeat instead of vanishing with it', async function () {
474
+ this.timeout(25000);
475
+ // Sessions ship fine; only the heartbeat fails. If the window were closed optimistically,
476
+ // the records covered by the lost heartbeat would never be reported to anyone — the
477
+ // server would see a smaller count and have no way to know it was short.
478
+ server.failKind('heartbeat', true);
479
+ await fs_1.promises.writeFile(path.join(sessionsDir, 'sess.jsonl'), `${sessionLine()}\n`);
480
+ const handle = start();
481
+ await waitFor(() => server.ingests.some((i) => i.kind === 'sessions'));
482
+ server.failKind('heartbeat', false);
483
+ await fs_1.promises.appendFile(path.join(sessionsDir, 'sess.jsonl'), `${sessionLine()}\n`);
484
+ await handle.flush();
485
+ const beats = server.ingests.filter((i) => i.kind === 'heartbeat').map((i) => JSON.parse(i.body));
486
+ assert.ok(beats.length >= 1, 'a heartbeat eventually landed');
487
+ // The first heartbeat to survive covers BOTH intervals: 1 record from the tick whose
488
+ // heartbeat was lost, plus 2 from the restaged file.
489
+ assert.ok(beats[0].records_staged >= 3, `staged=${beats[0].records_staged}`);
490
+ });
491
+ /**
492
+ * SIGTERM is how every serve container ends. Without a final drain, each redeploy silently
493
+ * dropped whatever was written since the last tick — and on an ephemeral state dir the cursor
494
+ * went with it, so the window was unrecoverable rather than merely late.
495
+ */
496
+ test('flush() ships the tail written after the last tick', async function () {
497
+ this.timeout(15000);
498
+ const handle = start();
499
+ await waitFor(() => server.ingests.length > 0);
500
+ const afterBoot = server.ingests.length;
501
+ await fs_1.promises.writeFile(path.join(sessionsDir, 'late.jsonl'), `${sessionLine({ request_id: 'r-late' })}\n`);
502
+ await handle.flush();
503
+ assert.ok(server.ingests.length > afterBoot, 'flush() shipped after the loop stopped');
504
+ assert.ok(server.ingests.some((i) => i.kind === 'sessions' && i.body.includes('r-late')), 'the tail turn reached the server');
505
+ });
506
+ /** Fix 2d: the abandoned-drain path must say how much it abandoned. */
507
+ test('an abandoned final drain reports what was lost', async function () {
508
+ this.timeout(15000);
509
+ const warnings = [];
510
+ // Point at a dead port so the boot tick stays in flight through the whole drain window.
511
+ const handle = (0, sessionShip_1.startSessionShipper)({
512
+ evidenceUrl: 'http://127.0.0.1:1',
513
+ shipToken: 'test-token',
514
+ instanceId: 'test-instance',
515
+ sessionsDir,
516
+ stateDir,
517
+ intervalSec: 3600,
518
+ sinceDays: 30,
519
+ drainTimeoutSec: 1,
520
+ logger: { info: () => undefined, warn: (m) => warnings.push(m) },
521
+ });
522
+ await fs_1.promises.writeFile(path.join(sessionsDir, 'sess.jsonl'), `${sessionLine()}\n`);
523
+ await handle.flush();
524
+ handle.stop();
525
+ const abandoned = warnings.find((w) => w.includes('FINAL DRAIN ABANDONED'));
526
+ if (abandoned) {
527
+ assert.ok(/record\(s\)/.test(abandoned), `no record count in: ${abandoned}`);
528
+ assert.ok(abandoned.includes('AGENTHIPPO_EVIDENCE_SHIP_DRAIN_TIMEOUT_SEC'), 'names the knob to raise');
529
+ }
530
+ else {
531
+ // The tick finished inside the window; the run must then report leftovers explicitly.
532
+ assert.ok(warnings.some((w) => w.includes('unshipped') || w.includes('drain failed')), `neither abandonment nor failure was reported: ${JSON.stringify(warnings)}`);
533
+ }
534
+ });
535
+ test('status() exposes the counters /health needs', async function () {
536
+ this.timeout(15000);
537
+ await fs_1.promises.writeFile(path.join(sessionsDir, 'sess.jsonl'), `${sessionLine()}\n`);
538
+ const handle = start();
539
+ await waitFor(() => handle.status().recordsShipped > 0);
540
+ handle.stop();
541
+ const status = handle.status();
542
+ assert.strictEqual(status.enabled, true);
543
+ assert.strictEqual(status.recordsStaged, 1);
544
+ assert.strictEqual(status.recordsShipped, 1);
545
+ assert.strictEqual(status.consecutiveFailures, 0);
546
+ assert.strictEqual(status.contentCapture, false, 'content capture is off by default');
547
+ assert.ok((status.lastOkAt ?? 0) > 0);
548
+ });
549
+ test('a failing server is counted, not smoothed over', async function () {
550
+ this.timeout(20000);
551
+ await fs_1.promises.writeFile(path.join(sessionsDir, 'sess.jsonl'), `${sessionLine()}\n`);
552
+ server.fail(true);
553
+ const handle = start();
554
+ await waitFor(() => handle.status().consecutiveFailures > 0, 15000);
555
+ handle.stop();
556
+ assert.ok(handle.status().consecutiveFailures > 0);
557
+ assert.strictEqual(handle.status().recordsShipped, 0, 'nothing may be marked shipped');
558
+ assert.strictEqual(handle.status().lastOkAt, undefined);
559
+ });
560
+ });
@@ -296,4 +296,32 @@ suite('spotlightPublish', () => {
296
296
  assert.strictEqual(result.valid, false);
297
297
  assert.ok(result.errors.some(error => error.includes('no scripts')));
298
298
  });
299
+ test('installSpotlightViewFromFiles rejects bundle entries that escape the install dir', async () => {
300
+ const viewId = 'evil-view';
301
+ const targetRoot = path.join(tempDir, 'traversal-target');
302
+ await fs_1.promises.mkdir(targetRoot, { recursive: true });
303
+ const manifest = [
304
+ 'version: 1.0.0',
305
+ `id: ${viewId}`,
306
+ 'name: Evil',
307
+ 'scope: workspace',
308
+ 'scripts:',
309
+ ' - w.js',
310
+ ].join('\n');
311
+ const files = [
312
+ { path: 'view.yaml', content: manifest },
313
+ { path: 'panel-entry.yaml', content: manifest },
314
+ { path: 'scripts/w.js', content: 'console.log(1)' },
315
+ // Passes the raw "scripts/" prefix check but resolves outside the install dir.
316
+ { path: 'scripts/../../../escaped.txt', content: 'pwned' },
317
+ ];
318
+ await assert.rejects((0, install_1.installSpotlightViewFromFiles)(files, {
319
+ slug: viewId,
320
+ workspaceRoot: targetRoot,
321
+ overwrite: true,
322
+ activate: false,
323
+ }), /escapes the install directory/);
324
+ // The traversal target (analyticsRoot/escaped.txt) must not have been written.
325
+ await assert.rejects(fs_1.promises.access(path.join(targetRoot, '.agent-hippo', 'analytics', 'escaped.txt')));
326
+ });
299
327
  });
@@ -0,0 +1,69 @@
1
+ "use strict";
2
+ /*---------------------------------------------------------------------------------------------
3
+ * Copyright (c) AgentHippo.ai. All rights reserved.
4
+ *--------------------------------------------------------------------------------------------*/
5
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
6
+ if (k2 === undefined) k2 = k;
7
+ var desc = Object.getOwnPropertyDescriptor(m, k);
8
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
9
+ desc = { enumerable: true, get: function() { return m[k]; } };
10
+ }
11
+ Object.defineProperty(o, k2, desc);
12
+ }) : (function(o, m, k, k2) {
13
+ if (k2 === undefined) k2 = k;
14
+ o[k2] = m[k];
15
+ }));
16
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
17
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
18
+ }) : function(o, v) {
19
+ o["default"] = v;
20
+ });
21
+ var __importStar = (this && this.__importStar) || (function () {
22
+ var ownKeys = function(o) {
23
+ ownKeys = Object.getOwnPropertyNames || function (o) {
24
+ var ar = [];
25
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
26
+ return ar;
27
+ };
28
+ return ownKeys(o);
29
+ };
30
+ return function (mod) {
31
+ if (mod && mod.__esModule) return mod;
32
+ var result = {};
33
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
34
+ __setModuleDefault(result, mod);
35
+ return result;
36
+ };
37
+ })();
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ require("mocha");
40
+ const assert = __importStar(require("assert"));
41
+ const fs = __importStar(require("node:fs"));
42
+ const os = __importStar(require("node:os"));
43
+ const path = __importStar(require("node:path"));
44
+ const reconcile_1 = require("../pack/reconcile");
45
+ suite('store install path containment', () => {
46
+ test('safeJoin refuses traversal paths', () => {
47
+ const root = path.join(os.tmpdir(), 'pack-install-root');
48
+ assert.strictEqual((0, reconcile_1.safeJoin)(root, '../../etc/passwd'), undefined);
49
+ assert.strictEqual((0, reconcile_1.safeJoin)(root, '/etc/passwd'), undefined);
50
+ const ok = (0, reconcile_1.safeJoin)(root, 'agent.yaml');
51
+ assert.ok(ok?.endsWith(`${path.sep}agent.yaml`));
52
+ });
53
+ test('install extract pattern refuses escape before write', async () => {
54
+ const installDir = fs.mkdtempSync(path.join(os.tmpdir(), 'store-install-'));
55
+ const files = [{ path: '../escaped.txt', content: Buffer.from('nope') }];
56
+ let refused = false;
57
+ for (const file of files) {
58
+ const filePath = (0, reconcile_1.safeJoin)(installDir, file.path);
59
+ if (!filePath) {
60
+ refused = true;
61
+ continue;
62
+ }
63
+ await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
64
+ await fs.promises.writeFile(filePath, file.content);
65
+ }
66
+ assert.strictEqual(refused, true);
67
+ assert.equal(fs.existsSync(path.join(path.dirname(installDir), 'escaped.txt')), false);
68
+ });
69
+ });