@agenthippo-ai/agenthippo 0.1.0 → 0.2.3-dataplane

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 (68) hide show
  1. package/out/agentpack/index.js +2 -1
  2. package/out/agentpack/paths.js +23 -0
  3. package/out/analytics/dashboard/fleetDisplayUtils.js +3 -0
  4. package/out/analytics/dashboard/webview/panel.js +290 -80
  5. package/out/analytics/sessionEvents.js +21 -4
  6. package/out/auth/brokerClient.js +1 -1
  7. package/out/auth/onboardingPrompt.js +112 -0
  8. package/out/auth/provider.js +1 -1
  9. package/out/auth-broker/config.js +8 -3
  10. package/out/auth-broker/pkce.js +3 -1
  11. package/out/auth-broker/server.js +43 -1
  12. package/out/auth-broker/store.js +6 -5
  13. package/out/cli/commands/ask.js +3 -0
  14. package/out/cli/commands/chat.js +3 -0
  15. package/out/cli/commands/deploy.js +49 -10
  16. package/out/cli/commands/gateway.js +54 -0
  17. package/out/cli/commands/idp.js +85 -0
  18. package/out/cli/commands/serve.js +401 -49
  19. package/out/cli/credential-context.js +47 -69
  20. package/out/cli/data-plane/broker.js +78 -0
  21. package/out/cli/data-plane/connector-host.js +121 -0
  22. package/out/cli/data-plane/egress.js +189 -0
  23. package/out/cli/data-plane/gateway.js +195 -0
  24. package/out/cli/data-plane/index.js +137 -0
  25. package/out/cli/data-plane/registry.js +80 -0
  26. package/out/cli/deploy/blueprints/registry.js +84 -0
  27. package/out/cli/deploy/layers/identity.js +91 -0
  28. package/out/cli/deploy-init.js +1156 -285
  29. package/out/cli/idp-init.js +303 -0
  30. package/out/cli/index.js +46 -8
  31. package/out/cli/pack-loader.js +37 -2
  32. package/out/cli/runtime.js +10 -3
  33. package/out/cli/serve-hardening.js +217 -12
  34. package/out/cli/state-sync.js +318 -29
  35. package/out/engines/claude.js +26 -5
  36. package/out/engines/codex.js +24 -5
  37. package/out/engines/custom.js +46 -12
  38. package/out/extension.js +2 -0
  39. package/out/gateway/outboundSend.js +6 -6
  40. package/out/headless/index.js +24 -6
  41. package/out/live-probe/connector.mjs +8 -0
  42. package/out/live-probe/env-report.mjs +8 -0
  43. package/out/mcp.js +88 -61
  44. package/out/remote/handleRemoteChat.js +7 -1
  45. package/out/remote/serveClient.js +27 -13
  46. package/out/scripts/analytics/agentide-litellm-otel.config.yaml +14 -0
  47. package/out/shared/models.js +1 -1
  48. package/out/shared/providers.js +5 -2
  49. package/out/test/analytics.test.js +93 -0
  50. package/out/test/auth-broker.test.js +59 -2
  51. package/out/test/blueprint-registry.test.js +105 -0
  52. package/out/test/credential-context.test.js +55 -0
  53. package/out/test/custom-env-layering.test.js +151 -0
  54. package/out/test/data-plane-engine-wiring.test.js +250 -0
  55. package/out/test/data-plane.test.js +244 -0
  56. package/out/test/deploy-init.test.js +287 -5
  57. package/out/test/egress-broker.test.js +172 -0
  58. package/out/test/fleetDisplayUtils.test.js +4 -0
  59. package/out/test/idp-init.test.js +151 -0
  60. package/out/test/mcp-obo.test.js +52 -27
  61. package/out/test/mcp.test.js +60 -4
  62. package/out/test/model-routing.test.js +133 -0
  63. package/out/test/serve-gateway-turn.test.js +267 -0
  64. package/out/test/serve-hardening.test.js +187 -0
  65. package/out/test/serveClient.test.js +37 -2
  66. package/out/test/state-sync-peer.test.js +83 -1
  67. package/out/utils.js +27 -11
  68. package/package.json +3 -4
@@ -522,3 +522,96 @@ suite('Analytics - ObservableStreamEmitter', () => {
522
522
  assert.notStrictEqual(reqId1, reqId2, 'Request IDs should be different');
523
523
  });
524
524
  });
525
+ // =============================================================================
526
+ // ObservableStreamEmitter warrant audit trail (constructor-captured userId)
527
+ // =============================================================================
528
+ suite('Analytics - ObservableStreamEmitter records the acting user (the warrant)', () => {
529
+ let testDir;
530
+ let writer;
531
+ let innerEmitter;
532
+ setup(async () => {
533
+ testDir = await createUniqueTempDir('agentide-warrant-test-');
534
+ writer = new sessionEvents_1.SessionEventWriter(testDir);
535
+ innerEmitter = {
536
+ async thinking() { },
537
+ async text() { },
538
+ async toolStart() { },
539
+ async toolEnd() { },
540
+ async progress() { },
541
+ async question() { return { selected: [], freeText: null }; },
542
+ async error() { },
543
+ async done() { },
544
+ };
545
+ });
546
+ teardown(async () => {
547
+ try {
548
+ await removeDirWithRetries(testDir);
549
+ }
550
+ catch {
551
+ // ignore
552
+ }
553
+ });
554
+ async function readEvents(sessionId) {
555
+ const content = await fs_1.promises.readFile(path.join(testDir, `${sessionId}.jsonl`), 'utf8');
556
+ return content.trim().split('\n').map((l) => JSON.parse(l));
557
+ }
558
+ test('toolStart/toolEnd record the userId passed at wrap', async () => {
559
+ const emitter = (0, sessionEvents_1.wrapEmitterWithObservability)(innerEmitter, writer, 'sess-warrant-1', 'req-1', 'priya@meridian.com');
560
+ await emitter.toolStart('Bash', 'echo hi', 'c1');
561
+ await emitter.toolEnd('Bash', 'c1', 'hi', false);
562
+ const events = await readEvents('sess-warrant-1');
563
+ assert.strictEqual(events.length, 2);
564
+ assert.strictEqual(events[0].payload.userId, 'priya@meridian.com');
565
+ assert.strictEqual(events[1].payload.userId, 'priya@meridian.com');
566
+ });
567
+ test('toolStart records no userId when wrap omitted it (IDE-local usage)', async () => {
568
+ const emitter = (0, sessionEvents_1.wrapEmitterWithObservability)(innerEmitter, writer, 'sess-warrant-2', 'req-1');
569
+ await emitter.toolStart('Bash', 'echo hi', 'c1');
570
+ const events = await readEvents('sess-warrant-2');
571
+ assert.strictEqual(events[0].payload.userId, undefined);
572
+ });
573
+ test('two emitters never attribute a tool call to the wrong user', async () => {
574
+ const alice = (0, sessionEvents_1.wrapEmitterWithObservability)(innerEmitter, writer, 'sess-warrant-3', 'req-a', 'alice@acme.com');
575
+ const bob = (0, sessionEvents_1.wrapEmitterWithObservability)(innerEmitter, writer, 'sess-warrant-3', 'req-b', 'bob@acme.com');
576
+ await Promise.all([
577
+ (async () => {
578
+ await new Promise((r) => setTimeout(r, 5));
579
+ await alice.toolStart('Bash', 'a', 'a1');
580
+ })(),
581
+ bob.toolStart('Bash', 'b', 'b1'),
582
+ ]);
583
+ const events = await readEvents('sess-warrant-3');
584
+ const byCallId = Object.fromEntries(events.map((e) => [e.payload.callId, e.payload.userId]));
585
+ assert.strictEqual(byCallId['a1'], 'alice@acme.com');
586
+ assert.strictEqual(byCallId['b1'], 'bob@acme.com');
587
+ });
588
+ test('toolStart/toolEnd keep wrap-time userId even when called outside any credential scope', async () => {
589
+ // Simulates the custom-engine persistent-worker path: wrap captures identity on the
590
+ // request continuation, then tool events fire later on a detached IPC callback with
591
+ // no ambient runWithCredential (or a sticky wrong one). Attribution must not depend
592
+ // on ambient AsyncLocalStorage at emit time.
593
+ const emitter = (0, sessionEvents_1.wrapEmitterWithObservability)(innerEmitter, writer, 'sess-warrant-4', 'req-1', 'alice@acme.com');
594
+ await emitter.toolStart('Bash', 'echo hi', 'c1');
595
+ await emitter.toolEnd('Bash', 'c1', 'hi', false);
596
+ const events = await readEvents('sess-warrant-4');
597
+ assert.strictEqual(events[0].payload.userId, 'alice@acme.com');
598
+ assert.strictEqual(events[1].payload.userId, 'alice@acme.com');
599
+ });
600
+ test('error/turn_start/turn_end also carry the wrap-time userId, not just tool events', async () => {
601
+ // A turn that errors before any tool call (or has no tool calls at all) must still
602
+ // carry identity attribution — the mechanism was already on the instance, it just
603
+ // wasn't threaded into these three payloads.
604
+ const emitter = (0, sessionEvents_1.wrapEmitterWithObservability)(innerEmitter, writer, 'sess-warrant-5', 'req-1', 'bob@acme.com');
605
+ await emitter.emitTurnStart();
606
+ await emitter.error('boom');
607
+ await emitter.emitTurnEnd(false);
608
+ const events = await readEvents('sess-warrant-5');
609
+ assert.strictEqual(events.length, 3);
610
+ assert.strictEqual(events[0].event_type, 'turn_start');
611
+ assert.strictEqual(events[0].payload.userId, 'bob@acme.com');
612
+ assert.strictEqual(events[1].event_type, 'error');
613
+ assert.strictEqual(events[1].payload.userId, 'bob@acme.com');
614
+ assert.strictEqual(events[2].event_type, 'turn_end');
615
+ assert.strictEqual(events[2].payload.userId, 'bob@acme.com');
616
+ });
617
+ });
@@ -154,7 +154,10 @@ suite('AuthBroker', () => {
154
154
  code_challenge: (0, pkce_js_1.codeChallengeFromVerifier)((0, pkce_js_1.generateCodeVerifier)()),
155
155
  }),
156
156
  });
157
- assert.strictEqual(res.status, 500);
157
+ assert.strictEqual(res.status, 403);
158
+ const body = await res.json();
159
+ assert.strictEqual(body.error, 'forbidden');
160
+ assert.ok(body.error_description?.includes('domain'));
158
161
  }
159
162
  finally {
160
163
  await broker.close();
@@ -198,7 +201,8 @@ suite('AuthBroker', () => {
198
201
  assert.strictEqual(refreshRes.status, 200);
199
202
  const body = await refreshRes.json();
200
203
  assert.strictEqual(body.routing.litellmApiKey, 'ahk_test_key');
201
- assert.ok(body.routing.litellmApiKeyExpiresAt);
204
+ // Static team key — the broker must not fabricate an expiry it can't enforce.
205
+ assert.strictEqual(body.routing.litellmApiKeyExpiresAt, undefined);
202
206
  }
203
207
  finally {
204
208
  await broker.close();
@@ -431,4 +435,57 @@ suite('AuthBroker', () => {
431
435
  await broker.close();
432
436
  }
433
437
  });
438
+ test('authorize rejects a redirect_uri outside the loopback/IDE-callback allow-list', async () => {
439
+ const broker = (0, server_js_1.createAuthBrokerServer)(testConfig(0));
440
+ await new Promise((resolve) => broker.server.listen(0, '127.0.0.1', resolve));
441
+ const address = broker.server.address();
442
+ if (!address || typeof address === 'string') {
443
+ throw new Error('bad listen address');
444
+ }
445
+ const baseUrl = `http://127.0.0.1:${address.port}`;
446
+ try {
447
+ const challenge = (0, pkce_js_1.codeChallengeFromVerifier)((0, pkce_js_1.generateCodeVerifier)());
448
+ // An attacker-controlled destination — must never receive an auth code (open-redirect /
449
+ // code-theft guard). Real clients only ever send the loopback or IDE-scheme callback.
450
+ const url = `${baseUrl}/oauth/authorize?client_id=${types_js_1.AUTH_CLIENT_ID}`
451
+ + `&redirect_uri=${encodeURIComponent('https://attacker.example/collect')}`
452
+ + `&code_challenge=${challenge}&code_challenge_method=S256&state=x&login_hint=dev@agenthippo.ai`;
453
+ const res = await fetch(url, { redirect: 'manual' });
454
+ assert.strictEqual(res.status, 400);
455
+ const body = await res.json();
456
+ assert.strictEqual(body.error, 'invalid_request');
457
+ }
458
+ finally {
459
+ await broker.close();
460
+ }
461
+ });
462
+ test('authorize accepts the loopback and IDE-scheme redirect_uri shapes real clients send', async () => {
463
+ const broker = (0, server_js_1.createAuthBrokerServer)(testConfig(0));
464
+ await new Promise((resolve) => broker.server.listen(0, '127.0.0.1', resolve));
465
+ const address = broker.server.address();
466
+ if (!address || typeof address === 'string') {
467
+ throw new Error('bad listen address');
468
+ }
469
+ const baseUrl = `http://127.0.0.1:${address.port}`;
470
+ try {
471
+ const redirectUris = [
472
+ 'http://127.0.0.1:54123/callback', // CLI/IDE loopback callback (any OS-assigned port)
473
+ 'vscode://agenthippo.agenthippo/auth/callback', // IDE URI-scheme callback
474
+ ];
475
+ for (const redirectUri of redirectUris) {
476
+ const challenge = (0, pkce_js_1.codeChallengeFromVerifier)((0, pkce_js_1.generateCodeVerifier)());
477
+ const url = `${baseUrl}/oauth/authorize?client_id=${types_js_1.AUTH_CLIENT_ID}`
478
+ + `&redirect_uri=${encodeURIComponent(redirectUri)}`
479
+ + `&code_challenge=${challenge}&code_challenge_method=S256&state=x&login_hint=dev@agenthippo.ai`;
480
+ const res = await fetch(url, { redirect: 'manual' });
481
+ assert.strictEqual(res.status, 302, `expected redirect for ${redirectUri}`);
482
+ const location = res.headers.get('location') || '';
483
+ assert.ok(location.startsWith(redirectUri), `location "${location}" should start with "${redirectUri}"`);
484
+ assert.ok(new URL(location).searchParams.get('code'), 'redirect carries an auth code');
485
+ }
486
+ }
487
+ finally {
488
+ await broker.close();
489
+ }
490
+ });
434
491
  });
@@ -0,0 +1,105 @@
1
+ "use strict";
2
+ /*---------------------------------------------------------------------------------------------
3
+ * Blueprint Registry Test Suite (PLAN/deploy.md §3, §7 d7-blueprint-registry)
4
+ *
5
+ * Pure resolver tests: a blueprint (+ optional platform) must resolve to exactly the
6
+ * --target/--ingress pair deploy-init.ts already renders correctly — these tests never
7
+ * call deployCommand, so they can assert on the error paths too without a process.exit risk.
8
+ *--------------------------------------------------------------------------------------------*/
9
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ var desc = Object.getOwnPropertyDescriptor(m, k);
12
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
13
+ desc = { enumerable: true, get: function() { return m[k]; } };
14
+ }
15
+ Object.defineProperty(o, k2, desc);
16
+ }) : (function(o, m, k, k2) {
17
+ if (k2 === undefined) k2 = k;
18
+ o[k2] = m[k];
19
+ }));
20
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
21
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
22
+ }) : function(o, v) {
23
+ o["default"] = v;
24
+ });
25
+ var __importStar = (this && this.__importStar) || (function () {
26
+ var ownKeys = function(o) {
27
+ ownKeys = Object.getOwnPropertyNames || function (o) {
28
+ var ar = [];
29
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
30
+ return ar;
31
+ };
32
+ return ownKeys(o);
33
+ };
34
+ return function (mod) {
35
+ if (mod && mod.__esModule) return mod;
36
+ var result = {};
37
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
38
+ __setModuleDefault(result, mod);
39
+ return result;
40
+ };
41
+ })();
42
+ Object.defineProperty(exports, "__esModule", { value: true });
43
+ require("mocha");
44
+ const assert = __importStar(require("assert"));
45
+ const registry_1 = require("../cli/deploy/blueprints/registry");
46
+ suite('blueprint registry: isBlueprintId', () => {
47
+ test('recognizes every declared blueprint id', () => {
48
+ for (const id of registry_1.BLUEPRINT_IDS) {
49
+ assert.ok((0, registry_1.isBlueprintId)(id));
50
+ }
51
+ });
52
+ test('rejects unknown ids', () => {
53
+ assert.ok(!(0, registry_1.isBlueprintId)('enterprise'));
54
+ assert.ok(!(0, registry_1.isBlueprintId)(''));
55
+ });
56
+ });
57
+ suite('blueprint registry: resolveBlueprint', () => {
58
+ test('sandbox resolves to vm with no ingress', () => {
59
+ assert.deepStrictEqual((0, registry_1.resolveBlueprint)('sandbox'), { target: 'vm' });
60
+ });
61
+ test('regulated-air-gapped resolves to vm with no ingress', () => {
62
+ assert.deepStrictEqual((0, registry_1.resolveBlueprint)('regulated-air-gapped'), { target: 'vm' });
63
+ });
64
+ test('prospect-demo defaults to vm with edge ingress; compose is also supported', () => {
65
+ assert.deepStrictEqual((0, registry_1.resolveBlueprint)('prospect-demo'), { target: 'vm', ingress: 'edge' });
66
+ assert.deepStrictEqual((0, registry_1.resolveBlueprint)('prospect-demo', 'compose'), { target: 'compose', ingress: 'edge' });
67
+ });
68
+ test('enterprise-fleet always resolves to edge-fed, on any of its supported platforms', () => {
69
+ assert.deepStrictEqual((0, registry_1.resolveBlueprint)('enterprise-fleet'), { target: 'cloudrun', ingress: 'edge-fed' });
70
+ assert.deepStrictEqual((0, registry_1.resolveBlueprint)('enterprise-fleet', 'vm'), { target: 'vm', ingress: 'edge-fed' });
71
+ assert.deepStrictEqual((0, registry_1.resolveBlueprint)('enterprise-fleet', 'compose'), { target: 'compose', ingress: 'edge-fed' });
72
+ });
73
+ test('team-pilot rides platform-native identity where one exists', () => {
74
+ assert.deepStrictEqual((0, registry_1.resolveBlueprint)('team-pilot', 'cloudrun'), { target: 'cloudrun', ingress: 'iap' });
75
+ assert.deepStrictEqual((0, registry_1.resolveBlueprint)('team-pilot', 'databricks-app'), { target: 'databricks-app' });
76
+ assert.deepStrictEqual((0, registry_1.resolveBlueprint)('team-pilot', 'spcs'), { target: 'spcs' });
77
+ assert.deepStrictEqual((0, registry_1.resolveBlueprint)('team-pilot', 'cloudflare'), { target: 'cloudflare' });
78
+ });
79
+ test('team-pilot falls back to a self-issued edge on vm/compose/aws-ecs/azure-container-apps (no platform-native option there)', () => {
80
+ assert.deepStrictEqual((0, registry_1.resolveBlueprint)('team-pilot', 'vm'), { target: 'vm', ingress: 'edge' });
81
+ assert.deepStrictEqual((0, registry_1.resolveBlueprint)('team-pilot', 'compose'), { target: 'compose', ingress: 'edge' });
82
+ assert.deepStrictEqual((0, registry_1.resolveBlueprint)('team-pilot', 'aws-ecs'), { target: 'aws-ecs', ingress: 'edge' });
83
+ assert.deepStrictEqual((0, registry_1.resolveBlueprint)('team-pilot', 'azure-container-apps'), { target: 'azure-container-apps', ingress: 'edge' });
84
+ });
85
+ test('enterprise-fleet supports aws-ecs/azure-container-apps too', () => {
86
+ assert.deepStrictEqual((0, registry_1.resolveBlueprint)('enterprise-fleet', 'aws-ecs'), { target: 'aws-ecs', ingress: 'edge-fed' });
87
+ assert.deepStrictEqual((0, registry_1.resolveBlueprint)('enterprise-fleet', 'azure-container-apps'), { target: 'azure-container-apps', ingress: 'edge-fed' });
88
+ });
89
+ test('team-pilot with no --platform defaults to its first supported target (cloudrun, iap)', () => {
90
+ assert.deepStrictEqual((0, registry_1.resolveBlueprint)('team-pilot'), { target: 'cloudrun', ingress: 'iap' });
91
+ });
92
+ test('throws when --platform is outside the blueprint\'s supported set', () => {
93
+ assert.throws(() => (0, registry_1.resolveBlueprint)('sandbox', 'cloudrun'), /does not support --platform cloudrun/);
94
+ assert.throws(() => (0, registry_1.resolveBlueprint)('regulated-air-gapped', 'databricks-app'), /Supported: vm/);
95
+ });
96
+ });
97
+ suite('blueprint registry: BLUEPRINTS catalog', () => {
98
+ test('every blueprint declares at least one supported target and a description', () => {
99
+ for (const id of registry_1.BLUEPRINT_IDS) {
100
+ const def = registry_1.BLUEPRINTS[id];
101
+ assert.ok(def.targets.length > 0, `${id} must support at least one platform`);
102
+ assert.ok(def.description.trim().length > 0, `${id} must have a description`);
103
+ }
104
+ });
105
+ });
@@ -74,6 +74,61 @@ suite('credential-context: presence + fail-closed', () => {
74
74
  });
75
75
  });
76
76
  });
77
+ suite('credential-context: unresolved identity (userId is optional, never a placeholder)', () => {
78
+ test('no userId, no data token: runs fine, requireUserId throws inside the scope', async () => {
79
+ const seen = await (0, credential_context_1.runWithCredential)({ userId: undefined }, async () => {
80
+ assert.throws(() => (0, credential_context_1.requireUserId)(), /fail-closed/);
81
+ return 'ran';
82
+ });
83
+ assert.strictEqual(seen, 'ran');
84
+ });
85
+ test('a data token without a verified userId fails closed before the turn runs', async () => {
86
+ let ran = false;
87
+ await assert.rejects(() => (0, credential_context_1.runWithCredential)({ userId: undefined, dataToken: 'obo-secret' }, async () => {
88
+ ran = true;
89
+ }), /OBO data token present without a verified user/);
90
+ assert.strictEqual(ran, false, 'fn must never run when the credential fails closed');
91
+ });
92
+ test('onDenied fires with the stable reason code exactly when the credential fails closed', async () => {
93
+ const denials = [];
94
+ await assert.rejects(() => (0, credential_context_1.runWithCredential)({ userId: undefined, dataToken: 'obo-secret' }, async () => 'ran', { onDenied: (reason) => { denials.push(reason); } }));
95
+ assert.deepStrictEqual(denials, [credential_context_1.WARRANT_DENIED_REASON]);
96
+ });
97
+ test('onDenied does not fire on any success path (verified user, or no token at all)', async () => {
98
+ const denials = [];
99
+ const onDenied = (reason) => { denials.push(reason); };
100
+ await (0, credential_context_1.runWithCredential)({ userId: 'priya@meridian.com', dataToken: 'tok' }, async () => 'ran', { onDenied });
101
+ await (0, credential_context_1.runWithCredential)({ userId: undefined }, async () => 'ran', { onDenied });
102
+ assert.deepStrictEqual(denials, []);
103
+ });
104
+ test('a throwing onDenied callback is swallowed — the fail-closed rejection still wins', async () => {
105
+ await assert.rejects(() => (0, credential_context_1.runWithCredential)({ userId: undefined, dataToken: 'obo-secret' }, async () => 'ran', { onDenied: () => { throw new Error('logging backend is down'); } }), /fail-closed/);
106
+ });
107
+ test('a hung onDenied callback cannot stall fail-closed forever', async () => {
108
+ const started = Date.now();
109
+ let resolved = false;
110
+ await assert.rejects(() => (0, credential_context_1.runWithCredential)({ userId: undefined, dataToken: 'obo-secret' }, async () => 'ran', {
111
+ onDeniedTimeoutMs: 50,
112
+ onDenied: () => new Promise(() => {
113
+ /* never settles */
114
+ }),
115
+ }), /fail-closed/);
116
+ resolved = true;
117
+ const elapsed = Date.now() - started;
118
+ assert.ok(resolved);
119
+ assert.ok(elapsed < 500, `fail-closed should win after onDenied timeout (elapsed=${elapsed}ms)`);
120
+ });
121
+ test('the credential never touches disk — it exists only in the async scope (secure-obo-design §2)', async () => {
122
+ await (0, credential_context_1.runWithCredential)({ userId: 'priya@meridian.com', dataToken: 'obo-secret' }, async () => {
123
+ const cred = (0, credential_context_1.currentCredential)();
124
+ assert.strictEqual(cred?.dataToken, 'obo-secret');
125
+ // The RequestCredential shape has no file-based transport anymore: the token
126
+ // reaches tools only via the loopback data-plane gateway + turn nonce.
127
+ assert.ok(!('dataTokenFile' in cred), 'no dataTokenFile surface may exist');
128
+ });
129
+ assert.strictEqual((0, credential_context_1.currentCredential)(), undefined);
130
+ });
131
+ });
77
132
  suite('credential-context: concurrent isolation (the load-bearing property)', () => {
78
133
  test('two interleaved turns never see each other\'s user', async () => {
79
134
  // Interleave alice and bob with deliberate awaits; each must keep its own identity.
@@ -0,0 +1,151 @@
1
+ "use strict";
2
+ /*---------------------------------------------------------------------------------------------
3
+ * Custom-engine env layering tests.
4
+ *
5
+ * Pins the deliberate precedence contract that replaced two accidents:
6
+ * 1. buildRunnerChildEnv: PER-TURN values win over the host's ambient env (the old
7
+ * `{...turn.env, ...process.env}` order silently reverted per-turn state — including,
8
+ * latently, manifest-delivered credentials and any same-named ambient var).
9
+ * 2. deliverModelRoutingEnv: manifest `model.*EnvVar` delivery is NON-CLOBBERING — a var the
10
+ * operator already set in the host env is never overwritten. This is what keeps a STALE
11
+ * cursor manifest (one that still declares apiKeyEnvVar: CURSOR_API_KEY) from stomping the
12
+ * operator's real crsr_ key now that per-turn values win (the version-pairing case).
13
+ *--------------------------------------------------------------------------------------------*/
14
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
15
+ if (k2 === undefined) k2 = k;
16
+ var desc = Object.getOwnPropertyDescriptor(m, k);
17
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
18
+ desc = { enumerable: true, get: function() { return m[k]; } };
19
+ }
20
+ Object.defineProperty(o, k2, desc);
21
+ }) : (function(o, m, k, k2) {
22
+ if (k2 === undefined) k2 = k;
23
+ o[k2] = m[k];
24
+ }));
25
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
26
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
27
+ }) : function(o, v) {
28
+ o["default"] = v;
29
+ });
30
+ var __importStar = (this && this.__importStar) || (function () {
31
+ var ownKeys = function(o) {
32
+ ownKeys = Object.getOwnPropertyNames || function (o) {
33
+ var ar = [];
34
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
35
+ return ar;
36
+ };
37
+ return ownKeys(o);
38
+ };
39
+ return function (mod) {
40
+ if (mod && mod.__esModule) return mod;
41
+ var result = {};
42
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
43
+ __setModuleDefault(result, mod);
44
+ return result;
45
+ };
46
+ })();
47
+ Object.defineProperty(exports, "__esModule", { value: true });
48
+ require("mocha");
49
+ const assert = __importStar(require("assert"));
50
+ const custom_1 = require("../engines/custom");
51
+ function fakeManifest(model) {
52
+ return {
53
+ id: 'fake-engine',
54
+ entryPath: '/tmp/fake/engine.mjs',
55
+ manifestDir: '/tmp/fake',
56
+ model: model ? { defaultModel: 'auto', ...model } : undefined,
57
+ };
58
+ }
59
+ function fakeTurn(env) {
60
+ return {
61
+ message: 'hi',
62
+ modelId: 'auto',
63
+ workspaceRoot: '/tmp/ws',
64
+ env,
65
+ session: { key: 'k', chatSessionId: 'c', isEphemeral: false },
66
+ };
67
+ }
68
+ /** Run fn with process.env temporarily patched; always restores. */
69
+ function withProcessEnv(patch, fn) {
70
+ const saved = new Map();
71
+ for (const [k, v] of Object.entries(patch)) {
72
+ saved.set(k, process.env[k]);
73
+ if (v === undefined) {
74
+ delete process.env[k];
75
+ }
76
+ else {
77
+ process.env[k] = v;
78
+ }
79
+ }
80
+ try {
81
+ fn();
82
+ }
83
+ finally {
84
+ for (const [k, v] of saved) {
85
+ if (v === undefined) {
86
+ delete process.env[k];
87
+ }
88
+ else {
89
+ process.env[k] = v;
90
+ }
91
+ }
92
+ }
93
+ }
94
+ suite('buildRunnerChildEnv: per-turn values win over the host ambient env', () => {
95
+ test('a per-turn var beats the same-named ambient var (nonce/session-path class)', () => {
96
+ withProcessEnv({ TEST_LAYERING_VAR: 'ambient-stale' }, () => {
97
+ const child = (0, custom_1.buildRunnerChildEnv)(fakeManifest(), fakeTurn({ TEST_LAYERING_VAR: 'per-turn-fresh' }), false);
98
+ assert.strictEqual(child.TEST_LAYERING_VAR, 'per-turn-fresh');
99
+ });
100
+ });
101
+ test('ambient vars absent from turn.env still flow through (plain inheritance)', () => {
102
+ withProcessEnv({ TEST_AMBIENT_ONLY: 'inherited' }, () => {
103
+ const child = (0, custom_1.buildRunnerChildEnv)(fakeManifest(), fakeTurn({}), false);
104
+ assert.strictEqual(child.TEST_AMBIENT_ONLY, 'inherited');
105
+ });
106
+ });
107
+ test('AGENTHIPPO control vars come from the turn, never ambient', () => {
108
+ withProcessEnv({ AGENTHIPPO_MODEL_ID: 'ambient-wrong' }, () => {
109
+ const child = (0, custom_1.buildRunnerChildEnv)(fakeManifest(), fakeTurn({}), false);
110
+ assert.strictEqual(child.AGENTHIPPO_MODEL_ID, 'auto', 'explicit per-turn block must win');
111
+ });
112
+ });
113
+ });
114
+ suite('deliverModelRoutingEnv: non-clobbering manifest credential delivery', () => {
115
+ test('delivers the routing key when the declared var is NOT in the ambient env (openclaw case)', () => {
116
+ const env = {};
117
+ (0, custom_1.deliverModelRoutingEnv)(env, fakeManifest({ apiKeyEnvVar: 'LITELLM_API_KEY' }), { apiKey: 'sk-proxy-key' }, {});
118
+ assert.strictEqual(env.LITELLM_API_KEY, 'sk-proxy-key');
119
+ });
120
+ test('SKIPS delivery when the operator already set the var — ambient wins (stale-cursor-manifest case)', () => {
121
+ // Version-pairing regression: old manifest still declares apiKeyEnvVar: CURSOR_API_KEY,
122
+ // operator has the real crsr_ key ambient. With per-turn-wins layering, only this
123
+ // non-clobber rule keeps the real key from being stomped by a model key.
124
+ const env = { CURSOR_API_KEY: 'crsr_real_operator_key' };
125
+ (0, custom_1.deliverModelRoutingEnv)(env, fakeManifest({ apiKeyEnvVar: 'CURSOR_API_KEY' }), { apiKey: 'sk-model-routing-key' }, { CURSOR_API_KEY: 'crsr_real_operator_key' });
126
+ assert.strictEqual(env.CURSOR_API_KEY, 'crsr_real_operator_key', 'operator credential must never be overwritten');
127
+ });
128
+ test('stale-manifest end to end: child env keeps the operator key after the spread flip', () => {
129
+ withProcessEnv({ CURSOR_API_KEY: 'crsr_real_operator_key' }, () => {
130
+ // Simulate the turn-env build with an OLD manifest: ambient spread + (non-clobbered) delivery.
131
+ const turnEnv = { ...process.env };
132
+ (0, custom_1.deliverModelRoutingEnv)(turnEnv, fakeManifest({ apiKeyEnvVar: 'CURSOR_API_KEY' }), { apiKey: 'sk-model-key' }, process.env);
133
+ const child = (0, custom_1.buildRunnerChildEnv)(fakeManifest({ apiKeyEnvVar: 'CURSOR_API_KEY' }), fakeTurn(turnEnv), false);
134
+ assert.strictEqual(child.CURSOR_API_KEY, 'crsr_real_operator_key');
135
+ });
136
+ });
137
+ test('base URL vars follow the same rule (deliver when absent, keep ambient when present)', () => {
138
+ const env = {};
139
+ (0, custom_1.deliverModelRoutingEnv)(env, fakeManifest({ baseUrlEnvVar: 'ENGINE_BASE_URL' }), { baseUrl: 'http://proxy:4000' }, {});
140
+ assert.strictEqual(env.ENGINE_BASE_URL, 'http://proxy:4000');
141
+ const env2 = { ENGINE_BASE_URL: 'http://operator-choice' };
142
+ (0, custom_1.deliverModelRoutingEnv)(env2, fakeManifest({ baseUrlEnvVar: 'ENGINE_BASE_URL' }), { baseUrl: 'http://proxy:4000' }, { ENGINE_BASE_URL: 'http://operator-choice' });
143
+ assert.strictEqual(env2.ENGINE_BASE_URL, 'http://operator-choice');
144
+ });
145
+ test('no declaration → no delivery, whatever the routing key is (the fixed cursor manifest)', () => {
146
+ const env = { CURSOR_API_KEY: 'crsr_real_operator_key' };
147
+ (0, custom_1.deliverModelRoutingEnv)(env, fakeManifest(), { apiKey: 'sk-model-key' }, {});
148
+ assert.strictEqual(env.CURSOR_API_KEY, 'crsr_real_operator_key');
149
+ assert.strictEqual(Object.keys(env).length, 1, 'nothing else may be written');
150
+ });
151
+ });