@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
@@ -0,0 +1,250 @@
1
+ "use strict";
2
+ /*---------------------------------------------------------------------------------------------
3
+ * Data-plane engine wiring tests (secure-obo-design §3.2, §6) — NO API KEY NEEDED.
4
+ *
5
+ * These exercise the *real* engine config paths (not just the pure helpers): given a pack
6
+ * that declares the loopback gateway MCP entry + skills, does each engine actually
7
+ * (a) put the turn-invariant gateway entry into its config, with the nonce sourced from
8
+ * the PROCESS env (never a per-turn value written to the file), and
9
+ * (b) sync the pack's skills into the engine's home so the agent can see them,
10
+ * and does the per-user CODEX_HOME derivation keep concurrent users' state apart while
11
+ * keeping the written config byte-identical (the property that kills defect 1)?
12
+ *--------------------------------------------------------------------------------------------*/
13
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
14
+ if (k2 === undefined) k2 = k;
15
+ var desc = Object.getOwnPropertyDescriptor(m, k);
16
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
17
+ desc = { enumerable: true, get: function() { return m[k]; } };
18
+ }
19
+ Object.defineProperty(o, k2, desc);
20
+ }) : (function(o, m, k, k2) {
21
+ if (k2 === undefined) k2 = k;
22
+ o[k2] = m[k];
23
+ }));
24
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
25
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
26
+ }) : function(o, v) {
27
+ o["default"] = v;
28
+ });
29
+ var __importStar = (this && this.__importStar) || (function () {
30
+ var ownKeys = function(o) {
31
+ ownKeys = Object.getOwnPropertyNames || function (o) {
32
+ var ar = [];
33
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
34
+ return ar;
35
+ };
36
+ return ownKeys(o);
37
+ };
38
+ return function (mod) {
39
+ if (mod && mod.__esModule) return mod;
40
+ var result = {};
41
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
42
+ __setModuleDefault(result, mod);
43
+ return result;
44
+ };
45
+ })();
46
+ Object.defineProperty(exports, "__esModule", { value: true });
47
+ require("mocha");
48
+ const assert = __importStar(require("assert"));
49
+ const fs = __importStar(require("fs"));
50
+ const os = __importStar(require("os"));
51
+ const path = __importStar(require("path"));
52
+ const codex_1 = require("../engines/codex");
53
+ const mcp_1 = require("../mcp");
54
+ const paths_1 = require("../agentpack/paths");
55
+ const credential_context_1 = require("../cli/credential-context");
56
+ const GATEWAY_MCP_JSON = JSON.stringify({
57
+ mcpServers: {
58
+ data: {
59
+ url: '${env:AGENTHIPPO_DATA_MCP_URL}',
60
+ bearerTokenEnv: 'AGENTHIPPO_TURN_NONCE',
61
+ },
62
+ },
63
+ });
64
+ function silentLogger() {
65
+ const lines = [];
66
+ const push = (m) => { lines.push(m); };
67
+ return { lines, info: push, warn: push, error: push, debug: push };
68
+ }
69
+ /** Build a pack on disk with the gateway MCP entry + N skills, return workspace + agentId. */
70
+ function makePack(skillNames) {
71
+ const workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'ah-wire-'));
72
+ const agentId = 'wire-agent';
73
+ const mcpDir = (0, paths_1.getAgentMcpDir)(workspace, agentId);
74
+ fs.mkdirSync(mcpDir, { recursive: true });
75
+ fs.writeFileSync(path.join(mcpDir, '.mcp.json'), GATEWAY_MCP_JSON, 'utf8');
76
+ const agentRoot = path.dirname(mcpDir);
77
+ fs.writeFileSync(path.join(agentRoot, 'AGENTS.md'), '# Wire Agent\n\nAnswer questions.\n', 'utf8');
78
+ const skillsDir = (0, paths_1.getAgentSkillsDir)(workspace, agentId);
79
+ for (const name of skillNames) {
80
+ const dir = path.join(skillsDir, name);
81
+ fs.mkdirSync(dir, { recursive: true });
82
+ fs.writeFileSync(path.join(dir, 'SKILL.md'), `---\nname: ${name}\ndescription: test skill ${name}\n---\n\nDo ${name} things.\n`, 'utf8');
83
+ // a bundled resource, to prove the whole skill dir is copied, not just SKILL.md
84
+ fs.mkdirSync(path.join(dir, 'scripts'), { recursive: true });
85
+ fs.writeFileSync(path.join(dir, 'scripts', 'run.sh'), `echo ${name}\n`, 'utf8');
86
+ }
87
+ return { workspace, agentId };
88
+ }
89
+ async function writeCodexConfig(workspace, agentId, codexHome) {
90
+ const logger = silentLogger();
91
+ // getSkillsSignature must return undefined so the sync is not skipped by the signature cache.
92
+ const engine = new codex_1.CodexEngine({ storagePath: workspace, codexHome: undefined, claudeConfigDir: undefined, litellmApiKey: 'k', litellmBaseUrl: 'http://litellm.local' }, () => undefined, () => undefined);
93
+ await engine.ensureCodexConfig(true, // useLiteLLM
94
+ 'http://litellm.local', // customBaseUrl
95
+ 'gpt-5.5', // modelId
96
+ workspace, // workspaceRoot
97
+ [], // additionalDirectories
98
+ logger, // logger
99
+ codexHome, // explicit codexHome
100
+ agentId, // agentId
101
+ true);
102
+ return fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8');
103
+ }
104
+ /** The `[mcp_servers.data]` block only — the part that must be turn- AND user-invariant. */
105
+ function mcpDataBlock(toml) {
106
+ const start = toml.indexOf('[mcp_servers.data]');
107
+ assert.ok(start >= 0, `config.toml missing [mcp_servers.data]:\n${toml}`);
108
+ const rest = toml.slice(start + '[mcp_servers.data]'.length);
109
+ const next = rest.search(/\n\[/);
110
+ return (next >= 0 ? rest.slice(0, next) : rest).trim();
111
+ }
112
+ suite('data-plane engine wiring: codex config + skills sync (no API key)', function () {
113
+ this.timeout(30_000);
114
+ test('config.toml carries the turn-invariant gateway entry; no per-turn value in the file', async () => {
115
+ const { workspace, agentId } = makePack(['alpha', 'beta']);
116
+ try {
117
+ process.env.AGENTHIPPO_DATA_MCP_URL = 'http://127.0.0.1:45999/mcp';
118
+ const codexHome = path.join(workspace, 'home');
119
+ fs.mkdirSync(codexHome, { recursive: true });
120
+ const toml = await writeCodexConfig(workspace, agentId, codexHome);
121
+ const block = mcpDataBlock(toml);
122
+ assert.ok(block.includes('url = "http://127.0.0.1:45999/mcp"'), 'gateway URL must resolve from process env');
123
+ assert.ok(block.includes('bearer_token_env_var = "AGENTHIPPO_TURN_NONCE"'), 'nonce must be read from the codex process env, not the file');
124
+ // The MCP entry must NOT contain any resolved nonce/header — that is what makes it turn-invariant.
125
+ // (The unrelated LiteLLM analytics block legitimately has an env_http_headers line of env-var NAMES.)
126
+ assert.ok(!/http_headers/.test(block), `no resolved auth header may be written into the MCP entry:\n${block}`);
127
+ assert.ok(!/Authorization/i.test(block), block);
128
+ }
129
+ finally {
130
+ delete process.env.AGENTHIPPO_DATA_MCP_URL;
131
+ fs.rmSync(workspace, { recursive: true, force: true });
132
+ }
133
+ });
134
+ test('the agent sees its skills: each SKILL.md (and its resources) is synced into $CODEX_HOME/skills', async () => {
135
+ const { workspace, agentId } = makePack(['alpha', 'beta']);
136
+ try {
137
+ process.env.AGENTHIPPO_DATA_MCP_URL = 'http://127.0.0.1:45999/mcp';
138
+ const codexHome = path.join(workspace, 'home');
139
+ fs.mkdirSync(codexHome, { recursive: true });
140
+ await writeCodexConfig(workspace, agentId, codexHome);
141
+ for (const name of ['alpha', 'beta']) {
142
+ const skillMd = path.join(codexHome, 'skills', name, 'SKILL.md');
143
+ assert.ok(fs.existsSync(skillMd), `skill ${name} must be visible in $CODEX_HOME/skills: ${skillMd}`);
144
+ assert.ok(fs.readFileSync(skillMd, 'utf8').includes(`description: test skill ${name}`));
145
+ // bundled resources come along, not just the SKILL.md
146
+ assert.ok(fs.existsSync(path.join(codexHome, 'skills', name, 'scripts', 'run.sh')), 'skill resource files must be copied too');
147
+ }
148
+ }
149
+ finally {
150
+ delete process.env.AGENTHIPPO_DATA_MCP_URL;
151
+ fs.rmSync(workspace, { recursive: true, force: true });
152
+ }
153
+ });
154
+ test('per-user CODEX_HOME: isolated homes+skills; the gateway entry is identical across users (only per-user paths differ)', async () => {
155
+ const { workspace, agentId } = makePack(['alpha']);
156
+ try {
157
+ process.env.AGENTHIPPO_DATA_MCP_URL = 'http://127.0.0.1:45999/mcp';
158
+ const base = path.join(workspace, 'home');
159
+ const aliceHome = (0, paths_1.userScopedEngineHome)(base, 'alice@corp.com');
160
+ const bobHome = (0, paths_1.userScopedEngineHome)(base, 'bob@corp.com');
161
+ assert.notStrictEqual(aliceHome, bobHome, 'distinct users must get distinct homes');
162
+ assert.ok(!aliceHome.includes('alice@corp.com'), 'user id must be hashed, never a raw path segment');
163
+ fs.mkdirSync(aliceHome, { recursive: true });
164
+ fs.mkdirSync(bobHome, { recursive: true });
165
+ const aliceToml = await (0, credential_context_1.runWithCredential)({ userId: 'alice@corp.com' }, () => writeCodexConfig(workspace, agentId, aliceHome));
166
+ const bobToml = await (0, credential_context_1.runWithCredential)({ userId: 'bob@corp.com' }, () => writeCodexConfig(workspace, agentId, bobHome));
167
+ // The MCP gateway entry carries NO per-user/per-turn value → identical across users.
168
+ // (The instructions-file path legitimately differs because it lives inside each user's
169
+ // own home — a per-user path, not a shared file, so it is not a cross-user race.)
170
+ assert.strictEqual(mcpDataBlock(aliceToml), mcpDataBlock(bobToml), 'the gateway MCP entry must be user-invariant');
171
+ assert.ok(fs.existsSync(path.join(aliceHome, 'skills', 'alpha', 'SKILL.md')));
172
+ assert.ok(fs.existsSync(path.join(bobHome, 'skills', 'alpha', 'SKILL.md')));
173
+ assert.ok(aliceHome.startsWith(path.join(base, 'users')) && bobHome.startsWith(path.join(base, 'users')));
174
+ }
175
+ finally {
176
+ delete process.env.AGENTHIPPO_DATA_MCP_URL;
177
+ fs.rmSync(workspace, { recursive: true, force: true });
178
+ }
179
+ });
180
+ test('defect-1 fix: two concurrent turns of the SAME user (same home) write byte-identical config.toml', async () => {
181
+ const { workspace, agentId } = makePack(['alpha']);
182
+ try {
183
+ process.env.AGENTHIPPO_DATA_MCP_URL = 'http://127.0.0.1:45999/mcp';
184
+ const base = path.join(workspace, 'home');
185
+ const home = (0, paths_1.userScopedEngineHome)(base, 'alice@corp.com');
186
+ fs.mkdirSync(home, { recursive: true });
187
+ // Two turns, same user, same home — historically the racing writers of defect 1.
188
+ const first = await (0, credential_context_1.runWithCredential)({ userId: 'alice@corp.com', dataToken: 'obo-turn-1' }, () => writeCodexConfig(workspace, agentId, home));
189
+ const second = await (0, credential_context_1.runWithCredential)({ userId: 'alice@corp.com', dataToken: 'obo-turn-2' }, () => writeCodexConfig(workspace, agentId, home));
190
+ assert.strictEqual(first, second, 'same-home concurrent turns must write identical bytes — the race is gone by construction');
191
+ }
192
+ finally {
193
+ delete process.env.AGENTHIPPO_DATA_MCP_URL;
194
+ fs.rmSync(workspace, { recursive: true, force: true });
195
+ }
196
+ });
197
+ test('no verified user (IDE path) → home is the un-derived base, unchanged', () => {
198
+ const base = '/tmp/some/codex/home';
199
+ assert.strictEqual((0, paths_1.userScopedEngineHome)(base, undefined), base);
200
+ assert.strictEqual((0, paths_1.userScopedEngineHome)(base, ' '), base);
201
+ });
202
+ });
203
+ suite('data-plane engine wiring: loadMcpServers + claude/custom shape (no API key)', function () {
204
+ this.timeout(30_000);
205
+ test('loadMcpServers reads a gateway .mcp.json into a url entry with bearerTokenEnv (URL resolved from env)', async () => {
206
+ const { workspace, agentId } = makePack([]);
207
+ try {
208
+ process.env.AGENTHIPPO_DATA_MCP_URL = 'http://127.0.0.1:47000/mcp';
209
+ const servers = await (0, mcp_1.loadMcpServers)(agentId, workspace);
210
+ assert.strictEqual(servers.length, 1);
211
+ assert.strictEqual(servers[0].name, 'data');
212
+ assert.strictEqual(servers[0].url, 'http://127.0.0.1:47000/mcp');
213
+ assert.strictEqual(servers[0].bearerTokenEnv, 'AGENTHIPPO_TURN_NONCE');
214
+ assert.strictEqual(servers[0].headers, undefined, 'no static header — the nonce is resolved per turn against the engine env');
215
+ }
216
+ finally {
217
+ delete process.env.AGENTHIPPO_DATA_MCP_URL;
218
+ fs.rmSync(workspace, { recursive: true, force: true });
219
+ }
220
+ });
221
+ test('a gateway entry whose URL placeholder does not resolve is dropped (never a broken empty-url entry)', async () => {
222
+ const { workspace, agentId } = makePack([]);
223
+ try {
224
+ delete process.env.AGENTHIPPO_DATA_MCP_URL; // e.g. loaded outside serve
225
+ const servers = await (0, mcp_1.loadMcpServers)(agentId, workspace);
226
+ assert.strictEqual(servers.length, 0, 'unresolved gateway URL → drop the server rather than hand engines an empty URL');
227
+ }
228
+ finally {
229
+ fs.rmSync(workspace, { recursive: true, force: true });
230
+ }
231
+ });
232
+ test('custom-engine transport: serialize→parse round-trips the http entry with the per-turn nonce header', async () => {
233
+ const { workspace, agentId } = makePack([]);
234
+ try {
235
+ process.env.AGENTHIPPO_DATA_MCP_URL = 'http://127.0.0.1:47000/mcp';
236
+ const servers = await (0, mcp_1.loadMcpServers)(agentId, workspace);
237
+ // This is exactly what custom.ts does: resolve against the per-turn child env, then
238
+ // serialize into AGENTHIPPO_MCP_SERVERS for the runner to parse back.
239
+ const inline = (0, mcp_1.formatInlineMcpServers)(servers, { AGENTHIPPO_TURN_NONCE: 'nonce-xyz' });
240
+ const roundTripped = (0, mcp_1.parseInlineMcpServers)((0, mcp_1.serializeInlineMcpServers)(inline));
241
+ assert.deepStrictEqual(roundTripped, {
242
+ data: { type: 'http', url: 'http://127.0.0.1:47000/mcp', headers: { Authorization: 'Bearer nonce-xyz' } },
243
+ });
244
+ }
245
+ finally {
246
+ delete process.env.AGENTHIPPO_DATA_MCP_URL;
247
+ fs.rmSync(workspace, { recursive: true, force: true });
248
+ }
249
+ });
250
+ });
@@ -0,0 +1,244 @@
1
+ "use strict";
2
+ /*---------------------------------------------------------------------------------------------
3
+ * Data-plane gateway integration tests (secure-obo-design §3, §5, §9)
4
+ *
5
+ * Boots the real thing end to end: TurnRegistry + CredentialBroker + a real connector
6
+ * subprocess (stdio MCP over a private pipe) + the loopback streamable-HTTP gateway —
7
+ * then drives it with the MCP SDK client exactly like an engine would.
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 fs = __importStar(require("fs"));
46
+ const path = __importStar(require("path"));
47
+ const index_js_1 = require("@modelcontextprotocol/sdk/client/index.js");
48
+ const streamableHttp_js_1 = require("@modelcontextprotocol/sdk/client/streamableHttp.js");
49
+ const data_plane_1 = require("../cli/data-plane");
50
+ // The fixture connector must live INSIDE the extension tree so its `import` of
51
+ // @modelcontextprotocol/sdk resolves by walking up to extensions/agentide/node_modules.
52
+ const FIXTURE_DIR = path.join(__dirname, 'data-plane-fixture');
53
+ const CONNECTOR_PATH = path.join(FIXTURE_DIR, 'connector.mjs');
54
+ const CONNECTOR_SOURCE = `
55
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
56
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
57
+ import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
58
+
59
+ const server = new Server({ name: 'test-connector', version: '0.0.1' }, { capabilities: { tools: {} } });
60
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
61
+ tools: [{
62
+ name: 'whoami',
63
+ description: 'echo the broker-supplied turn context',
64
+ inputSchema: { type: 'object', properties: { probe: { type: 'string' } } },
65
+ }],
66
+ }));
67
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
68
+ const turn = (req.params._meta ?? {})['ai.agenthippo/turn'] ?? null;
69
+ const probe = (req.params.arguments ?? {}).probe ?? '';
70
+ return {
71
+ content: [{ type: 'text', text: JSON.stringify({
72
+ userId: turn ? turn.userId : null,
73
+ credential: turn ? turn.credential : null,
74
+ envContainsProbe: probe ? JSON.stringify(process.env).includes(probe) : false,
75
+ }) }],
76
+ };
77
+ });
78
+ await server.connect(new StdioServerTransport());
79
+ `;
80
+ function mcpClient(url, nonce) {
81
+ const transport = new streamableHttp_js_1.StreamableHTTPClientTransport(new URL(url), {
82
+ requestInit: { headers: { Authorization: `Bearer ${nonce}` } },
83
+ });
84
+ const client = new index_js_1.Client({ name: 'test-engine', version: '0.0.1' });
85
+ return { client, transport };
86
+ }
87
+ async function callWhoami(url, nonce, probe) {
88
+ const { client, transport } = mcpClient(url, nonce);
89
+ try {
90
+ await client.connect(transport);
91
+ const res = await client.callTool({ name: 'whoami', arguments: probe ? { probe } : {} });
92
+ const content = res.content[0];
93
+ return JSON.parse(content.text);
94
+ }
95
+ finally {
96
+ await client.close().catch(() => undefined);
97
+ }
98
+ }
99
+ suite('data-plane: gateway + registry + broker end to end', function () {
100
+ this.timeout(30_000);
101
+ let plane;
102
+ suiteSetup(async () => {
103
+ fs.mkdirSync(FIXTURE_DIR, { recursive: true });
104
+ fs.writeFileSync(CONNECTOR_PATH, CONNECTOR_SOURCE, 'utf8');
105
+ plane = await data_plane_1.DataPlane.start({ connectorPath: CONNECTOR_PATH, provider: 'passthrough' });
106
+ });
107
+ suiteTeardown(async () => {
108
+ await plane?.close();
109
+ fs.rmSync(FIXTURE_DIR, { recursive: true, force: true });
110
+ });
111
+ test('gateway binds loopback only', () => {
112
+ assert.ok(plane.url.startsWith('http://127.0.0.1:'), plane.url);
113
+ });
114
+ test('a live nonce lists and calls tools as the registered user, credential selected server-side', async () => {
115
+ const turn = plane.registerTurn({ userId: 'alice@corp.com', sessionKey: 's1', inboundToken: 'alice-obo-token' });
116
+ try {
117
+ const { client, transport } = mcpClient(plane.url, turn.nonce);
118
+ await client.connect(transport);
119
+ const tools = await client.listTools();
120
+ assert.deepStrictEqual(tools.tools.map((t) => t.name), ['whoami']);
121
+ await client.close();
122
+ const who = await callWhoami(plane.url, turn.nonce);
123
+ assert.strictEqual(who.userId, 'alice@corp.com');
124
+ assert.strictEqual(who.credential, 'alice-obo-token', 'the broker must hand the connector the turn user\'s credential');
125
+ }
126
+ finally {
127
+ turn.release();
128
+ }
129
+ });
130
+ test('the credential never enters the connector process env — it rides only the per-call pipe', async () => {
131
+ const turn = plane.registerTurn({ userId: 'alice@corp.com', sessionKey: 's1', inboundToken: 'alice-obo-token' });
132
+ try {
133
+ const who = await callWhoami(plane.url, turn.nonce, 'alice-obo-token');
134
+ assert.strictEqual(who.envContainsProbe, false, 'raw credential must not appear anywhere in the connector env');
135
+ }
136
+ finally {
137
+ turn.release();
138
+ }
139
+ });
140
+ test('two concurrent turns each resolve their OWN user + credential (the §9.1 concurrency property)', async () => {
141
+ const alice = plane.registerTurn({ userId: 'alice@corp.com', sessionKey: 'sa', inboundToken: 'tok-alice' });
142
+ const bob = plane.registerTurn({ userId: 'bob@corp.com', sessionKey: 'sb', inboundToken: 'tok-bob' });
143
+ try {
144
+ const [a, b] = await Promise.all([
145
+ callWhoami(plane.url, alice.nonce),
146
+ callWhoami(plane.url, bob.nonce),
147
+ ]);
148
+ assert.strictEqual(a.userId, 'alice@corp.com');
149
+ assert.strictEqual(a.credential, 'tok-alice');
150
+ assert.strictEqual(b.userId, 'bob@corp.com');
151
+ assert.strictEqual(b.credential, 'tok-bob');
152
+ }
153
+ finally {
154
+ alice.release();
155
+ bob.release();
156
+ }
157
+ });
158
+ test('missing / unknown nonce → 401, fail closed', async () => {
159
+ await assert.rejects(() => callWhoami(plane.url, 'not-a-real-nonce'), /401|Unauthorized/i);
160
+ });
161
+ test('a released (turn-ended) nonce → 401 — a stale engine fails closed (§3.2)', async () => {
162
+ const turn = plane.registerTurn({ userId: 'alice@corp.com', sessionKey: 's1', inboundToken: 't' });
163
+ const nonce = turn.nonce;
164
+ const who = await callWhoami(plane.url, nonce);
165
+ assert.strictEqual(who.userId, 'alice@corp.com');
166
+ turn.release();
167
+ await assert.rejects(() => callWhoami(plane.url, nonce), /401|Unauthorized/i);
168
+ });
169
+ test('an expired deadline → 401 even if release() was never called (stranded-nonce backstop)', async () => {
170
+ const { nonce } = plane.registry.register({
171
+ userId: 'alice@corp.com',
172
+ sessionKey: 's1',
173
+ inboundToken: 't',
174
+ deadline: Date.now() - 1,
175
+ });
176
+ await assert.rejects(() => callWhoami(plane.url, nonce), /401|Unauthorized/i);
177
+ });
178
+ test('passthrough provider with no inbound token → the TOOL CALL errors, fail closed, turn itself unaffected', async () => {
179
+ const turn = plane.registerTurn({ userId: 'peer:telegram:123', sessionKey: 's2' });
180
+ try {
181
+ const { client, transport } = mcpClient(plane.url, turn.nonce);
182
+ await client.connect(transport);
183
+ const res = await client.callTool({ name: 'whoami', arguments: {} });
184
+ assert.strictEqual(res.isError, true, 'no inbound credential must fail the call closed');
185
+ const text = res.content[0].text;
186
+ assert.match(text, /fail-closed/);
187
+ await client.close();
188
+ }
189
+ finally {
190
+ turn.release();
191
+ }
192
+ });
193
+ test('a token-less turn fails closed even RIGHT AFTER the same user ran a turn WITH a token (no cache bridging)', async () => {
194
+ // Passthrough gets the platform token on every legitimate request; a broker cache
195
+ // that outlives the turn would let broken forwarding silently reuse the previous
196
+ // token for the cache TTL — the exact silent-fallback failure the provider forbids.
197
+ const withToken = plane.registerTurn({ userId: 'u@corp.com', sessionKey: 's3', inboundToken: 'tok-1' });
198
+ try {
199
+ const ok = await callWhoami(plane.url, withToken.nonce);
200
+ assert.strictEqual(ok.credential, 'tok-1');
201
+ }
202
+ finally {
203
+ withToken.release();
204
+ }
205
+ const withoutToken = plane.registerTurn({ userId: 'u@corp.com', sessionKey: 's3' });
206
+ try {
207
+ const { client, transport } = mcpClient(plane.url, withoutToken.nonce);
208
+ await client.connect(transport);
209
+ const res = await client.callTool({ name: 'whoami', arguments: {} });
210
+ assert.strictEqual(res.isError, true, 'the cached tok-1 must not serve a token-less turn');
211
+ await client.close();
212
+ }
213
+ finally {
214
+ withoutToken.release();
215
+ }
216
+ });
217
+ });
218
+ suite('data-plane: TurnRegistry unit properties', () => {
219
+ test('raw nonces are never map keys — lookups go through sha256', () => {
220
+ const reg = new data_plane_1.TurnRegistry();
221
+ const { nonce } = reg.register({ userId: 'u', sessionKey: 's' });
222
+ assert.ok(reg.resolve(nonce));
223
+ // resolve by the HASH must fail — only the raw nonce authenticates.
224
+ assert.strictEqual(reg.resolve((0, data_plane_1.hashNonce)(nonce)), undefined);
225
+ });
226
+ test('release is idempotent and deletes exactly its own entry', () => {
227
+ const reg = new data_plane_1.TurnRegistry();
228
+ const a = reg.register({ userId: 'a', sessionKey: 's' });
229
+ const b = reg.register({ userId: 'b', sessionKey: 's' });
230
+ a.release();
231
+ a.release();
232
+ assert.strictEqual(reg.resolve(a.nonce), undefined);
233
+ assert.strictEqual(reg.resolve(b.nonce)?.userId, 'b');
234
+ b.release();
235
+ assert.strictEqual(reg.size, 0);
236
+ });
237
+ test('expired entries are pruned on register', () => {
238
+ const reg = new data_plane_1.TurnRegistry();
239
+ reg.register({ userId: 'a', sessionKey: 's', deadline: Date.now() - 1 });
240
+ assert.strictEqual(reg.size, 1);
241
+ reg.register({ userId: 'b', sessionKey: 's' });
242
+ assert.strictEqual(reg.size, 1, 'the expired entry must have been pruned');
243
+ });
244
+ });