@agenthippo-ai/agenthippo 1.110.9 → 1.110.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/out/agentpack/index.js +3 -1
- package/out/agentpack/paths.js +25 -2
- package/out/analytics/dashboard/localFleetAdapter.js +82 -2
- package/out/analytics/service.js +6 -0
- package/out/analytics/sessionEvents.js +4 -3
- package/out/auth-broker/server.js +23 -19
- package/out/cli/commands/data-plane.js +84 -0
- package/out/cli/commands/sandbox.js +28 -9
- package/out/cli/commands/serve.js +289 -97
- package/out/cli/credential-context.js +17 -0
- package/out/cli/data-plane/index.js +2 -1
- package/out/cli/data-plane/sidecar.js +66 -2
- package/out/cli/deploy/layers/identity.js +5 -2
- package/out/cli/deploy-init.js +4 -4
- package/out/cli/index.js +16 -1
- package/out/cli/serve-hardening.js +181 -12
- package/out/engines/claude.js +1 -1
- package/out/engines/codex.js +1 -1
- package/out/engines/custom.js +7 -1
- package/out/evidence/sessionShip.js +50 -17
- package/out/headless/index.js +8 -1
- package/out/pack/governor.js +32 -15
- package/out/pack/lease-schema.js +4 -1
- package/out/pack/lease.js +42 -0
- package/out/runner/host.js +24 -14
- package/out/runner/protocol.js +24 -4
- package/out/runner/remoteEngine.js +53 -17
- package/out/sandbox/containerDriver.js +4 -2
- package/out/sandbox/processDriver.js +4 -1
- package/out/sandbox/sandboxRunner.js +148 -36
- package/out/scripts/agentide_litellm_hooks_v1.py +85 -1
- package/out/scripts/analytics/agentide-litellm-otel.config.yaml +4 -0
- package/out/scripts/analytics/setup-analytics.ps1 +8 -1
- package/out/scripts/analytics/setup-analytics.sh +13 -2
- package/out/test/caps-governor.test.js +6 -1
- package/out/test/data-plane-engine-wiring.test.js +32 -0
- package/out/test/data-plane.test.js +313 -0
- package/out/test/deploy-init.test.js +7 -1
- package/out/test/governor-hardening.test.js +6 -1
- package/out/test/lease-validation.test.js +55 -0
- package/out/test/localFleetAdapter.test.js +91 -0
- package/out/test/sandbox-driver.test.js +2 -2
- package/out/test/sandbox-hardening.test.js +12 -1
- package/out/test/sandbox-runner.test.js +1 -1
- package/out/test/sandbox-scope.test.js +449 -0
- package/out/test/serve-cron-acceptance.test.js +370 -0
- package/out/test/serve-gateway-turn.test.js +122 -0
- package/out/test/serve-hardening.test.js +182 -9
- package/out/test/sessionShip.test.js +58 -0
- package/package.json +1 -1
|
@@ -45,6 +45,7 @@ const os = __importStar(require("os"));
|
|
|
45
45
|
const path = __importStar(require("path"));
|
|
46
46
|
const stream_1 = require("stream");
|
|
47
47
|
const serve_hardening_1 = require("../cli/serve-hardening");
|
|
48
|
+
const serve_hardening_2 = require("../cli/serve-hardening");
|
|
48
49
|
suite('serve-hardening: resolveStateDir', () => {
|
|
49
50
|
test('explicit --state-dir wins and is absolute and non-ephemeral', () => {
|
|
50
51
|
const r = (0, serve_hardening_1.resolveStateDir)('./state', {});
|
|
@@ -202,6 +203,45 @@ suite('serve-hardening: resolveUserId', () => {
|
|
|
202
203
|
const pinned = { AGENTHIPPO_IDENTITY_HEADER: 'x-goog-authenticated-user-email' };
|
|
203
204
|
assert.deepStrictEqual((0, serve_hardening_1.identityHeaderAdvice)(pinned, '0.0.0.0'), []);
|
|
204
205
|
});
|
|
206
|
+
test('a pinned multi-user serve on tier 0 is told its filesystem is shared', () => {
|
|
207
|
+
const env = { AGENTHIPPO_IDENTITY_HEADER: 'x-agent-user' };
|
|
208
|
+
const tier0 = (0, serve_hardening_1.tenantIsolationAdvice)({ sandbox: 'process', workspace: '/work', stateDir: '/state' }, env);
|
|
209
|
+
assert.strictEqual(tier0.level, 'warn');
|
|
210
|
+
assert.ok(tier0.lines.some((l) => /--sandbox=process/.test(l)));
|
|
211
|
+
assert.ok(tier0.lines.some((l) => /--sandbox=container/.test(l)), 'the advice must name the fix');
|
|
212
|
+
// No sandbox at all is the same shared filesystem, and is the DEFAULT deployment shape.
|
|
213
|
+
const none = (0, serve_hardening_1.tenantIsolationAdvice)({ workspace: '/work', stateDir: '/state' }, env);
|
|
214
|
+
assert.strictEqual(none.level, 'warn');
|
|
215
|
+
assert.ok(none.lines.some((l) => /no sandbox/.test(l)));
|
|
216
|
+
});
|
|
217
|
+
test('nesting the workspace inside the state dir is called out on top of the shared-fs warning', () => {
|
|
218
|
+
const env = { AGENTHIPPO_IDENTITY_HEADER: 'x-agent-user' };
|
|
219
|
+
const nested = (0, serve_hardening_1.tenantIsolationAdvice)({ sandbox: 'process', workspace: '/state/workspace', stateDir: '/state' }, env);
|
|
220
|
+
const separate = (0, serve_hardening_1.tenantIsolationAdvice)({ sandbox: 'process', workspace: '/work', stateDir: '/state' }, env);
|
|
221
|
+
assert.ok(nested.lines.length > separate.lines.length, 'the shipped-sample shape must say strictly more');
|
|
222
|
+
assert.ok(nested.lines.some((l) => /sits INSIDE --state-dir/.test(l)));
|
|
223
|
+
// The reverse nesting is the same exposure reached without any traversal at all.
|
|
224
|
+
const inverted = (0, serve_hardening_1.tenantIsolationAdvice)({ sandbox: 'process', workspace: '/state', stateDir: '/state/serve' }, env);
|
|
225
|
+
assert.ok(inverted.lines.some((l) => /sits INSIDE --workspace/.test(l)));
|
|
226
|
+
});
|
|
227
|
+
test('tenant-isolation advice is quiet where it has nothing to say', () => {
|
|
228
|
+
// Unpinned: identityHeaderAdvice owns that case, and two warnings bury the actionable one.
|
|
229
|
+
assert.deepStrictEqual((0, serve_hardening_1.tenantIsolationAdvice)({ sandbox: 'process', workspace: '/state/workspace', stateDir: '/state' }, {}), { level: 'ok', lines: [] });
|
|
230
|
+
// A container sandbox gets a per-session workspace and state dir — the nesting is then an
|
|
231
|
+
// implementation detail of paths the sandbox never sees whole.
|
|
232
|
+
const env = { AGENTHIPPO_IDENTITY_HEADER: 'x-agent-user' };
|
|
233
|
+
assert.strictEqual((0, serve_hardening_1.tenantIsolationAdvice)({ sandbox: 'container', workspace: '/state/workspace', stateDir: '/state' }, env).level, 'ok');
|
|
234
|
+
// …unless the operator opted back into one workspace for everyone.
|
|
235
|
+
assert.strictEqual((0, serve_hardening_1.tenantIsolationAdvice)({ sandbox: 'container', sharedWorkspace: true, workspace: '/work', stateDir: '/state' }, env).level, 'warn');
|
|
236
|
+
});
|
|
237
|
+
test('the refusal is opt-in, so an upgrade cannot brick a deployment that already shipped', () => {
|
|
238
|
+
const base = { sandbox: 'process', workspace: '/state/workspace', stateDir: '/state' };
|
|
239
|
+
const pinned = { AGENTHIPPO_IDENTITY_HEADER: 'x-agent-user' };
|
|
240
|
+
assert.strictEqual((0, serve_hardening_1.tenantIsolationAdvice)(base, pinned).level, 'warn');
|
|
241
|
+
assert.strictEqual((0, serve_hardening_1.tenantIsolationAdvice)(base, { ...pinned, [serve_hardening_1.REQUIRE_TENANT_ISOLATION_ENV]: '1' }).level, 'refuse');
|
|
242
|
+
// A deployment that fixed the isolation is not refused just for asking for the check.
|
|
243
|
+
assert.strictEqual((0, serve_hardening_1.tenantIsolationAdvice)({ sandbox: 'container', workspace: '/work', stateDir: '/state' }, { ...pinned, [serve_hardening_1.REQUIRE_TENANT_ISOLATION_ENV]: '1' }).level, 'ok');
|
|
244
|
+
});
|
|
205
245
|
test('Databricks X-Forwarded-Email', () => {
|
|
206
246
|
assert.strictEqual((0, serve_hardening_1.resolveUserId)({ 'x-forwarded-email': 'priya@meridian.com' }, undefined), 'priya@meridian.com');
|
|
207
247
|
});
|
|
@@ -227,6 +267,39 @@ suite('serve-hardening: resolveUserId', () => {
|
|
|
227
267
|
assert.strictEqual((0, serve_hardening_1.resolveUserId)({}, undefined), undefined);
|
|
228
268
|
assert.strictEqual((0, serve_hardening_1.resolveUserId)({}, { user_id: ' ' }, { AGENTHIPPO_ALLOW_BODY_IDENTITY: '1' }), undefined);
|
|
229
269
|
});
|
|
270
|
+
/**
|
|
271
|
+
* IDENTITY_PROXY_SECRET_ENV: pinning declares WHICH header carries identity, not WHO may
|
|
272
|
+
* assert it. On a shared network any container that can dial serve's port can send the
|
|
273
|
+
* pinned header; when the secret is configured, identity requires the request to also carry
|
|
274
|
+
* the proxy's proof header — the same contract the auth-broker enforces for
|
|
275
|
+
* X-Auth-Request-Email.
|
|
276
|
+
*/
|
|
277
|
+
suite('identity proxy secret (AGENTHIPPO_IDENTITY_PROXY_SECRET)', () => {
|
|
278
|
+
const env = {
|
|
279
|
+
AGENTHIPPO_IDENTITY_HEADER: 'x-agent-user',
|
|
280
|
+
AGENTHIPPO_IDENTITY_PROXY_SECRET: 'shhh',
|
|
281
|
+
};
|
|
282
|
+
test('a pinned header WITHOUT proof runs anonymous — the network-neighbour spoof', () => {
|
|
283
|
+
assert.strictEqual((0, serve_hardening_1.resolveUserId)({ 'x-agent-user': 'victim@corp.com' }, undefined, env), undefined);
|
|
284
|
+
});
|
|
285
|
+
test('a wrong secret is a missing secret', () => {
|
|
286
|
+
assert.strictEqual((0, serve_hardening_1.resolveUserId)({ 'x-agent-user': 'victim@corp.com', 'x-agenthippo-proxy-secret': 'nope' }, undefined, env), undefined);
|
|
287
|
+
});
|
|
288
|
+
test('the trusted proxy (correct proof) resolves identity as before', () => {
|
|
289
|
+
assert.strictEqual((0, serve_hardening_1.resolveUserId)({ 'x-agent-user': 'alice@corp.com', 'x-agenthippo-proxy-secret': 'shhh' }, undefined, env), 'alice@corp.com');
|
|
290
|
+
});
|
|
291
|
+
test('proof gates the alias list and the body fallback too, not just the pin', () => {
|
|
292
|
+
const unpinned = { AGENTHIPPO_IDENTITY_PROXY_SECRET: 'shhh', AGENTHIPPO_ALLOW_BODY_IDENTITY: '1' };
|
|
293
|
+
assert.strictEqual((0, serve_hardening_1.resolveUserId)({ 'x-forwarded-email': 'a@b.com' }, undefined, unpinned), undefined);
|
|
294
|
+
// A deployment that configured the secret has declared a real proxy; a caller without
|
|
295
|
+
// proof must not be able to name a victim via the client-controlled body either.
|
|
296
|
+
assert.strictEqual((0, serve_hardening_1.resolveUserId)({}, { user_id: 'bob' }, unpinned), undefined);
|
|
297
|
+
});
|
|
298
|
+
test('unset secret keeps existing behaviour — the gate is opt-in', () => {
|
|
299
|
+
const pinnedOnly = { AGENTHIPPO_IDENTITY_HEADER: 'x-agent-user' };
|
|
300
|
+
assert.strictEqual((0, serve_hardening_1.resolveUserId)({ 'x-agent-user': 'alice@corp.com' }, undefined, pinnedOnly), 'alice@corp.com');
|
|
301
|
+
});
|
|
302
|
+
});
|
|
230
303
|
test('AGENTHIPPO_IDENTITY_HEADER pins trust to one header — spoofed aliases are ignored', () => {
|
|
231
304
|
const env = { AGENTHIPPO_IDENTITY_HEADER: 'x-goog-authenticated-user-email' };
|
|
232
305
|
// IAP strips X-Goog-* but forwards x-agent-user; pinning must ignore the spoof.
|
|
@@ -382,37 +455,137 @@ suite('serve-hardening: readBodyWithCap', () => {
|
|
|
382
455
|
suite('serve-hardening: resolveServeSessionKey (secure-obo-design §3.4 Phase 0)', () => {
|
|
383
456
|
const PINNED = { AGENTHIPPO_IDENTITY_HEADER: 'x-forwarded-email' };
|
|
384
457
|
test('verified user + session_id → user-namespaced key', () => {
|
|
385
|
-
const r = (0, serve_hardening_1.resolveServeSessionKey)('chat-1', 'alice@corp.com', PINNED);
|
|
458
|
+
const r = (0, serve_hardening_1.resolveServeSessionKey)('chat-1', 'alice@corp.com', 'session', PINNED);
|
|
386
459
|
assert.strictEqual(r.sessionKey, `u${(0, serve_hardening_1.userNamespaceHash)('alice@corp.com')}--chat-1`);
|
|
387
460
|
assert.strictEqual(r.rejected, undefined);
|
|
388
461
|
});
|
|
389
462
|
test('two users presenting the SAME session_id can never share a key (the defect this closes)', () => {
|
|
390
|
-
const a = (0, serve_hardening_1.resolveServeSessionKey)('chat-1', 'alice@corp.com', PINNED);
|
|
391
|
-
const b = (0, serve_hardening_1.resolveServeSessionKey)('chat-1', 'bob@corp.com', PINNED);
|
|
463
|
+
const a = (0, serve_hardening_1.resolveServeSessionKey)('chat-1', 'alice@corp.com', 'session', PINNED);
|
|
464
|
+
const b = (0, serve_hardening_1.resolveServeSessionKey)('chat-1', 'bob@corp.com', 'session', PINNED);
|
|
392
465
|
assert.notStrictEqual(a.sessionKey, b.sessionKey);
|
|
393
466
|
});
|
|
394
467
|
test('verified user without session_id → stable per-user default key', () => {
|
|
395
|
-
const r1 = (0, serve_hardening_1.resolveServeSessionKey)(undefined, 'alice@corp.com', PINNED);
|
|
396
|
-
const r2 = (0, serve_hardening_1.resolveServeSessionKey)('', 'alice@corp.com', PINNED);
|
|
468
|
+
const r1 = (0, serve_hardening_1.resolveServeSessionKey)(undefined, 'alice@corp.com', 'session', PINNED);
|
|
469
|
+
const r2 = (0, serve_hardening_1.resolveServeSessionKey)('', 'alice@corp.com', 'session', PINNED);
|
|
397
470
|
assert.strictEqual(r1.sessionKey, r2.sessionKey);
|
|
398
471
|
assert.strictEqual(r1.sessionKey, `u${(0, serve_hardening_1.userNamespaceHash)('alice@corp.com')}--default`);
|
|
399
472
|
});
|
|
400
473
|
test('pinned identity + session_id but NO verified user → fail closed', () => {
|
|
401
|
-
const r = (0, serve_hardening_1.resolveServeSessionKey)('chat-1', undefined, PINNED);
|
|
474
|
+
const r = (0, serve_hardening_1.resolveServeSessionKey)('chat-1', undefined, 'session', PINNED);
|
|
402
475
|
assert.strictEqual(r.rejected, serve_hardening_1.SESSION_WITHOUT_USER_REASON);
|
|
403
476
|
});
|
|
477
|
+
test('pinned identity + NO session_id + no verified user → fail closed (no shared anon bucket)', () => {
|
|
478
|
+
const r = (0, serve_hardening_1.resolveServeSessionKey)(undefined, undefined, 'session', PINNED);
|
|
479
|
+
assert.strictEqual(r.rejected, serve_hardening_1.SESSION_WITHOUT_USER_REASON);
|
|
480
|
+
assert.strictEqual(r.sessionKey, '');
|
|
481
|
+
});
|
|
404
482
|
test('unpinned (local dev) keeps raw session_id behavior', () => {
|
|
405
|
-
const r = (0, serve_hardening_1.resolveServeSessionKey)('chat-1', undefined, {});
|
|
483
|
+
const r = (0, serve_hardening_1.resolveServeSessionKey)('chat-1', undefined, 'session', {});
|
|
406
484
|
assert.strictEqual(r.sessionKey, 'chat-1');
|
|
407
485
|
assert.strictEqual(r.rejected, undefined);
|
|
408
486
|
});
|
|
409
487
|
test('unpinned, no user, no session_id → legacy anon bucket', () => {
|
|
410
|
-
const r = (0, serve_hardening_1.resolveServeSessionKey)(undefined, undefined, {});
|
|
488
|
+
const r = (0, serve_hardening_1.resolveServeSessionKey)(undefined, undefined, 'session', {});
|
|
411
489
|
assert.strictEqual(r.sessionKey, 'anon-unknown');
|
|
412
490
|
});
|
|
413
491
|
test('the key never embeds the raw user id (emails are not path segments)', () => {
|
|
414
|
-
const r = (0, serve_hardening_1.resolveServeSessionKey)('s', 'alice@corp.com', PINNED);
|
|
492
|
+
const r = (0, serve_hardening_1.resolveServeSessionKey)('s', 'alice@corp.com', 'session', PINNED);
|
|
415
493
|
assert.ok(!r.sessionKey.includes('alice@corp.com'));
|
|
416
494
|
assert.ok(!r.sessionKey.includes('@'));
|
|
417
495
|
});
|
|
418
496
|
});
|
|
497
|
+
suite('serve-hardening: sandbox scope (what one sandbox holds)', () => {
|
|
498
|
+
const PINNED = { AGENTHIPPO_IDENTITY_HEADER: 'x-forwarded-email' };
|
|
499
|
+
test('session scope: the sandbox key IS the session key — today\'s behaviour, unchanged', () => {
|
|
500
|
+
const r = (0, serve_hardening_1.resolveServeSessionKey)('chat-1', 'alice@corp.com', 'session', PINNED);
|
|
501
|
+
assert.strictEqual(r.sandboxKey, r.sessionKey);
|
|
502
|
+
});
|
|
503
|
+
test('user scope: one user\'s sessions share a sandbox key but keep distinct session keys', () => {
|
|
504
|
+
const a = (0, serve_hardening_1.resolveServeSessionKey)('chat-1', 'alice@corp.com', 'user', PINNED);
|
|
505
|
+
const b = (0, serve_hardening_1.resolveServeSessionKey)('chat-2', 'alice@corp.com', 'user', PINNED);
|
|
506
|
+
assert.strictEqual(a.sandboxKey, b.sandboxKey, 'one container for this user');
|
|
507
|
+
assert.notStrictEqual(a.sessionKey, b.sessionKey, 'but two conversations inside it');
|
|
508
|
+
assert.strictEqual(a.sandboxKey, `u${(0, serve_hardening_1.userNamespaceHash)('alice@corp.com')}`);
|
|
509
|
+
});
|
|
510
|
+
test('user scope never merges two DIFFERENT users', () => {
|
|
511
|
+
const alice = (0, serve_hardening_1.resolveServeSessionKey)('chat-1', 'alice@corp.com', 'user', PINNED);
|
|
512
|
+
const bob = (0, serve_hardening_1.resolveServeSessionKey)('chat-1', 'bob@corp.com', 'user', PINNED);
|
|
513
|
+
assert.notStrictEqual(alice.sandboxKey, bob.sandboxKey);
|
|
514
|
+
});
|
|
515
|
+
test('user scope does NOT apply without a verified user', () => {
|
|
516
|
+
// Merging unidentified callers would group strangers on a fact nobody established.
|
|
517
|
+
const a = (0, serve_hardening_1.resolveServeSessionKey)('chat-1', undefined, 'user', {});
|
|
518
|
+
const b = (0, serve_hardening_1.resolveServeSessionKey)('chat-2', undefined, 'user', {});
|
|
519
|
+
assert.strictEqual(a.sandboxKey, a.sessionKey);
|
|
520
|
+
assert.notStrictEqual(a.sandboxKey, b.sandboxKey);
|
|
521
|
+
});
|
|
522
|
+
test('a rejected turn yields no sandbox key to create anything with', () => {
|
|
523
|
+
const r = (0, serve_hardening_1.resolveServeSessionKey)('chat-1', undefined, 'user', PINNED);
|
|
524
|
+
assert.strictEqual(r.rejected, serve_hardening_1.SESSION_WITHOUT_USER_REASON);
|
|
525
|
+
assert.strictEqual(r.sandboxKey, '');
|
|
526
|
+
});
|
|
527
|
+
test('the sandbox key never embeds the raw user id either', () => {
|
|
528
|
+
const r = (0, serve_hardening_1.resolveServeSessionKey)('s', 'alice@corp.com', 'user', PINNED);
|
|
529
|
+
assert.ok(!r.sandboxKey.includes('alice@corp.com'));
|
|
530
|
+
assert.ok(!r.sandboxKey.includes('@'));
|
|
531
|
+
});
|
|
532
|
+
});
|
|
533
|
+
/**
|
|
534
|
+
* PLAN/agent-identity.md §5.2 (build record §10.4).
|
|
535
|
+
*
|
|
536
|
+
* Shape, not vocabulary. An earlier draft required `^svc:[a-z0-9-]+$` and would have refused to
|
|
537
|
+
* boot on the two handles an enterprise operator is most likely to bind — a SPIFFE ID and an Entra
|
|
538
|
+
* agent object id — which are precisely the federated principals §8 wants this to grow into.
|
|
539
|
+
*/
|
|
540
|
+
suite('resolveServiceIdentity: the unattended-run identity (PR-3)', () => {
|
|
541
|
+
const env = (v) => (v === undefined ? {} : { [serve_hardening_2.SERVICE_USER_ENV]: v });
|
|
542
|
+
test('unset is not an error — it just means unattended runs are off', () => {
|
|
543
|
+
const r = (0, serve_hardening_2.resolveServiceIdentity)({});
|
|
544
|
+
assert.strictEqual(r.identity, undefined);
|
|
545
|
+
assert.strictEqual(r.error, undefined);
|
|
546
|
+
assert.strictEqual(r.warning, undefined);
|
|
547
|
+
});
|
|
548
|
+
test('the local svc: convention is accepted silently', () => {
|
|
549
|
+
const r = (0, serve_hardening_2.resolveServiceIdentity)(env('svc:invoice-agent'));
|
|
550
|
+
assert.strictEqual(r.identity, 'svc:invoice-agent');
|
|
551
|
+
assert.strictEqual(r.warning, undefined);
|
|
552
|
+
});
|
|
553
|
+
test('a SPIFFE ID is accepted — the earlier ^svc: rule would have refused to boot on it', () => {
|
|
554
|
+
const r = (0, serve_hardening_2.resolveServiceIdentity)(env('spiffe://corp/ns/agents/invoice'));
|
|
555
|
+
assert.strictEqual(r.identity, 'spiffe://corp/ns/agents/invoice');
|
|
556
|
+
assert.strictEqual(r.error, undefined);
|
|
557
|
+
assert.strictEqual(r.warning, undefined);
|
|
558
|
+
});
|
|
559
|
+
test('an Entra agent object id (bare GUID) is accepted', () => {
|
|
560
|
+
const guid = '3f2504e0-4f89-11d3-9a0c-0305e82c3301';
|
|
561
|
+
const r = (0, serve_hardening_2.resolveServiceIdentity)(env(guid));
|
|
562
|
+
assert.strictEqual(r.identity, guid);
|
|
563
|
+
assert.strictEqual(r.warning, undefined);
|
|
564
|
+
});
|
|
565
|
+
test('an unnamespaced value still works, but warns about collision with a real user id', () => {
|
|
566
|
+
const r = (0, serve_hardening_2.resolveServiceIdentity)(env('invoicebot'));
|
|
567
|
+
assert.strictEqual(r.identity, 'invoicebot');
|
|
568
|
+
assert.strictEqual(r.error, undefined);
|
|
569
|
+
assert.match(r.warning ?? '', /namespace marker/);
|
|
570
|
+
});
|
|
571
|
+
test('set-but-empty is a boot REFUSAL, not a silently anonymous fleet', () => {
|
|
572
|
+
for (const v of ['', ' ']) {
|
|
573
|
+
const r = (0, serve_hardening_2.resolveServiceIdentity)(env(v));
|
|
574
|
+
assert.ok(r.error, `${JSON.stringify(v)} must be refused`);
|
|
575
|
+
assert.strictEqual(r.identity, undefined);
|
|
576
|
+
}
|
|
577
|
+
});
|
|
578
|
+
test('whitespace inside the value is refused rather than silently split or trimmed', () => {
|
|
579
|
+
const r = (0, serve_hardening_2.resolveServiceIdentity)(env('svc:invoice agent'));
|
|
580
|
+
assert.ok(r.error);
|
|
581
|
+
assert.match(r.error, /whitespace/);
|
|
582
|
+
});
|
|
583
|
+
test('an absurdly long value is refused — that is a pasted file, not an identity', () => {
|
|
584
|
+
const r = (0, serve_hardening_2.resolveServiceIdentity)(env(`svc:${'a'.repeat(300)}`));
|
|
585
|
+
assert.ok(r.error);
|
|
586
|
+
assert.match(r.error, /maximum/);
|
|
587
|
+
});
|
|
588
|
+
test('surrounding whitespace is trimmed rather than refused', () => {
|
|
589
|
+
assert.strictEqual((0, serve_hardening_2.resolveServiceIdentity)(env(' svc:nightly ')).identity, 'svc:nightly');
|
|
590
|
+
});
|
|
591
|
+
});
|
|
@@ -272,6 +272,25 @@ suite('Evidence - sessionShip URL validation', () => {
|
|
|
272
272
|
}
|
|
273
273
|
assert.strictEqual((0, sessionShip_1.getEvidenceShipStatus)().disabledReason, 'invalid-url');
|
|
274
274
|
});
|
|
275
|
+
test('compose-internal http resolves when allow-insecure is opted in', () => {
|
|
276
|
+
const cfg = (0, sessionShip_1.resolveSessionShipConfig)('i', '/s', '/st', {
|
|
277
|
+
AGENTHIPPO_EVIDENCE_URL: 'http://evidence:8787',
|
|
278
|
+
AGENTHIPPO_EVIDENCE_SHIP_TOKEN: 'tok',
|
|
279
|
+
AGENTHIPPO_EVIDENCE_ALLOW_INSECURE_URL: '1',
|
|
280
|
+
});
|
|
281
|
+
assert.ok(cfg);
|
|
282
|
+
assert.strictEqual(cfg.evidenceUrl, 'http://evidence:8787');
|
|
283
|
+
assert.strictEqual(cfg.allowInsecure, true);
|
|
284
|
+
});
|
|
285
|
+
test('EVIDENCE_ALLOW_INSECURE_URL alone also enables compose-internal http', () => {
|
|
286
|
+
const cfg = (0, sessionShip_1.resolveSessionShipConfig)('i', '/s', '/st', {
|
|
287
|
+
AGENTHIPPO_EVIDENCE_URL: 'http://evidence:8787',
|
|
288
|
+
AGENTHIPPO_EVIDENCE_SHIP_TOKEN: 'tok',
|
|
289
|
+
EVIDENCE_ALLOW_INSECURE_URL: '1',
|
|
290
|
+
});
|
|
291
|
+
assert.ok(cfg);
|
|
292
|
+
assert.strictEqual(cfg.allowInsecure, true);
|
|
293
|
+
});
|
|
275
294
|
});
|
|
276
295
|
// -----------------------------------------------------------------------------
|
|
277
296
|
// Fix 1 / fix 3 — redaction applies to EVERY lane, not just `seat`
|
|
@@ -557,4 +576,43 @@ suite('Evidence - sessionShip runtime', () => {
|
|
|
557
576
|
assert.strictEqual(handle.status().recordsShipped, 0, 'nothing may be marked shipped');
|
|
558
577
|
assert.strictEqual(handle.status().lastOkAt, undefined);
|
|
559
578
|
});
|
|
579
|
+
test('compose-internal http creates a shipper when allowInsecure is set', async function () {
|
|
580
|
+
this.timeout(15000);
|
|
581
|
+
await fs_1.promises.writeFile(path.join(sessionsDir, 'sess.jsonl'), `${sessionLine()}\n`);
|
|
582
|
+
const warnings = [];
|
|
583
|
+
const handle = (0, sessionShip_1.startSessionShipper)({
|
|
584
|
+
evidenceUrl: 'http://evidence:8787',
|
|
585
|
+
allowInsecure: true,
|
|
586
|
+
shipToken: 'test-token',
|
|
587
|
+
instanceId: 'test-instance',
|
|
588
|
+
sessionsDir,
|
|
589
|
+
stateDir,
|
|
590
|
+
intervalSec: 3600,
|
|
591
|
+
sinceDays: 30,
|
|
592
|
+
logger: { info: () => undefined, warn: (m) => warnings.push(m) },
|
|
593
|
+
});
|
|
594
|
+
await handle.flush();
|
|
595
|
+
handle.stop();
|
|
596
|
+
assert.ok(!warnings.some((w) => w.includes('must use https')), `shipper init rejected compose-internal http: ${JSON.stringify(warnings)}`);
|
|
597
|
+
assert.ok(handle.status().consecutiveFailures > 0, 'drain fails without a server, but shipper was created');
|
|
598
|
+
});
|
|
599
|
+
test('R-13 heartbeat is still attempted when shipper init fails', async function () {
|
|
600
|
+
this.timeout(15000);
|
|
601
|
+
await fs_1.promises.writeFile(path.join(sessionsDir, 'sess.jsonl'), `${sessionLine()}\n`);
|
|
602
|
+
const warnings = [];
|
|
603
|
+
const handle = (0, sessionShip_1.startSessionShipper)({
|
|
604
|
+
evidenceUrl: 'http://evidence:8787',
|
|
605
|
+
shipToken: 'test-token',
|
|
606
|
+
instanceId: 'test-instance',
|
|
607
|
+
sessionsDir,
|
|
608
|
+
stateDir,
|
|
609
|
+
intervalSec: 3600,
|
|
610
|
+
sinceDays: 30,
|
|
611
|
+
logger: { info: () => undefined, warn: (m) => warnings.push(m) },
|
|
612
|
+
});
|
|
613
|
+
await handle.flush();
|
|
614
|
+
handle.stop();
|
|
615
|
+
assert.ok(warnings.some((w) => w.includes('must use https') || w.includes('plaintext http')), `expected shipper URL rejection: ${JSON.stringify(warnings)}`);
|
|
616
|
+
assert.ok(warnings.some((w) => w.includes('heartbeat failed')), `heartbeat must still be attempted in finally: ${JSON.stringify(warnings)}`);
|
|
617
|
+
});
|
|
560
618
|
});
|
package/package.json
CHANGED