@lumenflow/surfaces 5.1.0

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 (36) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +40 -0
  3. package/cli/__tests__/gates.test.ts +97 -0
  4. package/cli/__tests__/inspect.test.ts +184 -0
  5. package/cli/__tests__/task-lifecycle.test.ts +203 -0
  6. package/cli/gates.ts +46 -0
  7. package/cli/index.ts +6 -0
  8. package/cli/inspect.ts +138 -0
  9. package/cli/task-lifecycle.ts +46 -0
  10. package/http/__tests__/agent-runtime-remote-controls.test.ts +249 -0
  11. package/http/__tests__/auth-boundary.test.ts +57 -0
  12. package/http/__tests__/channel-send-governance.test.ts +158 -0
  13. package/http/__tests__/event-stream.test.ts +340 -0
  14. package/http/__tests__/phone-device-tool-api.test.ts +177 -0
  15. package/http/__tests__/remote-exposure.test.ts +212 -0
  16. package/http/__tests__/run-agent.test.ts +447 -0
  17. package/http/__tests__/scope-enforcement.test.ts +349 -0
  18. package/http/__tests__/sidecar-entry.test.ts +158 -0
  19. package/http/__tests__/tool-api-schema-validation.test.ts +213 -0
  20. package/http/__tests__/tool-api.test.ts +491 -0
  21. package/http/__tests__/tool-discovery.test.ts +384 -0
  22. package/http/ag-ui-adapter.ts +352 -0
  23. package/http/auth.ts +294 -0
  24. package/http/control-plane-event-subscriber.ts +233 -0
  25. package/http/event-stream.ts +216 -0
  26. package/http/index.ts +10 -0
  27. package/http/run-agent.ts +416 -0
  28. package/http/server.ts +329 -0
  29. package/http/sidecar-entry.ts +218 -0
  30. package/http/task-api.ts +307 -0
  31. package/http/tool-api.ts +373 -0
  32. package/http/tool-discovery.ts +159 -0
  33. package/mcp/__tests__/server.test.ts +554 -0
  34. package/mcp/index.ts +4 -0
  35. package/mcp/server.ts +250 -0
  36. package/package.json +51 -0
@@ -0,0 +1,384 @@
1
+ // Copyright (c) 2026 Hellmai Ltd
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import type { IncomingHttpHeaders, IncomingMessage, ServerResponse } from 'node:http';
5
+ import { EventEmitter } from 'node:events';
6
+ import { PassThrough } from 'node:stream';
7
+ import { describe, expect, it, vi } from 'vitest';
8
+ import { createHttpSurface } from '../index.js';
9
+ import type { KernelRuntime } from '@lumenflow/kernel';
10
+
11
+ const HTTP_METHOD = {
12
+ GET: 'GET',
13
+ POST: 'POST',
14
+ PUT: 'PUT',
15
+ } as const;
16
+
17
+ const HTTP_STATUS = {
18
+ OK: 200,
19
+ NOT_FOUND: 404,
20
+ METHOD_NOT_ALLOWED: 405,
21
+ } as const;
22
+
23
+ const HEADER = {
24
+ CONTENT_TYPE: 'content-type',
25
+ } as const;
26
+
27
+ const CONTENT_TYPE_JSON = 'application/json; charset=utf-8';
28
+
29
+ const PACK = {
30
+ SOFTWARE_DELIVERY: 'software-delivery',
31
+ SIDEKICK: 'sidekick',
32
+ AGENT_RUNTIME: 'agent-runtime',
33
+ } as const;
34
+
35
+ const SURFACES_VERSION = '4.23.0';
36
+ const WORKSPACE_ID = 'workspace-discovery-1';
37
+
38
+ interface RequestOptions {
39
+ method: string;
40
+ url: string;
41
+ body?: unknown;
42
+ authorization?: string;
43
+ }
44
+
45
+ class MockResponse extends EventEmitter {
46
+ statusCode = HTTP_STATUS.OK;
47
+ body = '';
48
+ readonly headers = new Map<string, string>();
49
+
50
+ setHeader(name: string, value: string | number | readonly string[]): this {
51
+ this.headers.set(name.toLowerCase(), String(value));
52
+ return this;
53
+ }
54
+
55
+ writeHead(statusCode: number, headers?: Record<string, string>): this {
56
+ this.statusCode = statusCode;
57
+ if (headers) {
58
+ for (const [name, value] of Object.entries(headers)) {
59
+ this.setHeader(name, value);
60
+ }
61
+ }
62
+ return this;
63
+ }
64
+
65
+ write(chunk: string | Buffer): boolean {
66
+ this.body += Buffer.isBuffer(chunk) ? chunk.toString('utf8') : chunk;
67
+ return true;
68
+ }
69
+
70
+ end(chunk?: string | Buffer): this {
71
+ if (chunk !== undefined) {
72
+ this.write(chunk);
73
+ }
74
+ this.emit('finish');
75
+ return this;
76
+ }
77
+ }
78
+
79
+ function createRequest(options: RequestOptions): IncomingMessage {
80
+ const request = new PassThrough() as unknown as IncomingMessage & {
81
+ method: string;
82
+ url: string;
83
+ headers: IncomingHttpHeaders;
84
+ };
85
+
86
+ request.method = options.method;
87
+ request.url = options.url;
88
+ request.headers = {
89
+ [HEADER.CONTENT_TYPE]: CONTENT_TYPE_JSON,
90
+ ...(options.authorization ? { authorization: options.authorization } : {}),
91
+ };
92
+
93
+ const payload = options.body === undefined ? '' : JSON.stringify(options.body);
94
+ (request as unknown as PassThrough).end(payload);
95
+ return request;
96
+ }
97
+
98
+ function createRuntimeStub(): KernelRuntime {
99
+ return {
100
+ executeTool: vi.fn(async () => ({ success: true, data: { ok: true } })),
101
+ } as unknown as KernelRuntime;
102
+ }
103
+
104
+ interface ToolCatalogEntry {
105
+ name: string;
106
+ pack: string;
107
+ description: string;
108
+ input_schema: unknown;
109
+ output_schema: unknown;
110
+ requires_approval: boolean;
111
+ capabilities_required?: string[];
112
+ estimated_cost?: { tokens?: number; wall_time_ms?: number };
113
+ }
114
+
115
+ function createToolCatalog(): ToolCatalogEntry[] {
116
+ return [
117
+ {
118
+ name: 'software-delivery:wu.claim',
119
+ pack: PACK.SOFTWARE_DELIVERY,
120
+ description: 'Claim a WU for implementation.',
121
+ input_schema: {
122
+ type: 'object',
123
+ required: ['wu_id'],
124
+ properties: { wu_id: { type: 'string' } },
125
+ },
126
+ output_schema: {
127
+ type: 'object',
128
+ properties: { worktree_path: { type: 'string' } },
129
+ },
130
+ requires_approval: false,
131
+ },
132
+ {
133
+ name: 'sidekick:channel.post',
134
+ pack: PACK.SIDEKICK,
135
+ description: 'Post a message to a channel.',
136
+ input_schema: {
137
+ type: 'object',
138
+ required: ['channel', 'message'],
139
+ properties: {
140
+ channel: { type: 'string' },
141
+ message: { type: 'string' },
142
+ },
143
+ },
144
+ output_schema: {
145
+ type: 'object',
146
+ properties: { message_id: { type: 'string' } },
147
+ },
148
+ requires_approval: true,
149
+ },
150
+ {
151
+ name: 'agent-runtime:turn.execute',
152
+ pack: PACK.AGENT_RUNTIME,
153
+ description: 'Execute an agent turn.',
154
+ input_schema: {
155
+ type: 'object',
156
+ required: ['agent_id'],
157
+ properties: { agent_id: { type: 'string' } },
158
+ },
159
+ output_schema: {
160
+ type: 'object',
161
+ properties: { turn_id: { type: 'string' } },
162
+ },
163
+ requires_approval: false,
164
+ estimated_cost: { tokens: 2000, wall_time_ms: 5000 },
165
+ },
166
+ ];
167
+ }
168
+
169
+ function parseJson(body: string): unknown {
170
+ return JSON.parse(body);
171
+ }
172
+
173
+ describe('http tool discovery (GET /tools)', () => {
174
+ // --- AC1 + AC5: GET /tools returns 200 with tools[], surfaces_version, workspace_id ---
175
+
176
+ it('GET /tools returns 200 with tools array plus surfaces_version and workspace_id', async () => {
177
+ const surface = createHttpSurface(createRuntimeStub(), {
178
+ allowlistedTools: createToolCatalog().map((tool) => tool.name),
179
+ toolCatalog: createToolCatalog(),
180
+ surfacesVersion: SURFACES_VERSION,
181
+ workspaceId: WORKSPACE_ID,
182
+ });
183
+
184
+ const request = createRequest({ method: HTTP_METHOD.GET, url: '/tools' });
185
+ const response = new MockResponse();
186
+
187
+ await surface.handleRequest(request, response as unknown as ServerResponse<IncomingMessage>);
188
+
189
+ expect(response.statusCode).toBe(HTTP_STATUS.OK);
190
+ expect(response.headers.get(HEADER.CONTENT_TYPE)).toBe(CONTENT_TYPE_JSON);
191
+
192
+ const body = parseJson(response.body) as {
193
+ tools: ToolCatalogEntry[];
194
+ surfaces_version: string;
195
+ workspace_id: string;
196
+ };
197
+
198
+ expect(body.surfaces_version).toBe(SURFACES_VERSION);
199
+ expect(body.workspace_id).toBe(WORKSPACE_ID);
200
+ expect(Array.isArray(body.tools)).toBe(true);
201
+ expect(body.tools).toHaveLength(createToolCatalog().length);
202
+ });
203
+
204
+ // --- AC1: every tool has required metadata fields ---
205
+
206
+ it('each tool entry includes name, pack, description, schemas, requires_approval and capabilities_required', async () => {
207
+ const surface = createHttpSurface(createRuntimeStub(), {
208
+ allowlistedTools: createToolCatalog().map((tool) => tool.name),
209
+ toolCatalog: createToolCatalog(),
210
+ surfacesVersion: SURFACES_VERSION,
211
+ workspaceId: WORKSPACE_ID,
212
+ });
213
+
214
+ const request = createRequest({ method: HTTP_METHOD.GET, url: '/tools' });
215
+ const response = new MockResponse();
216
+
217
+ await surface.handleRequest(request, response as unknown as ServerResponse<IncomingMessage>);
218
+
219
+ const body = parseJson(response.body) as { tools: ToolCatalogEntry[] };
220
+
221
+ for (const tool of body.tools) {
222
+ expect(typeof tool.name).toBe('string');
223
+ expect(typeof tool.pack).toBe('string');
224
+ expect(typeof tool.description).toBe('string');
225
+ expect(tool.input_schema).toBeDefined();
226
+ expect(tool.output_schema).toBeDefined();
227
+ expect(typeof tool.requires_approval).toBe('boolean');
228
+ expect(Array.isArray(tool.capabilities_required)).toBe(true);
229
+ expect((tool.capabilities_required ?? []).length).toBeGreaterThan(0);
230
+ }
231
+ });
232
+
233
+ // --- AC2: multi-pack inventory returns full list without duplicates ---
234
+
235
+ it('returns the full inventory without duplicates when all three packs are loaded', async () => {
236
+ const catalog = createToolCatalog();
237
+ const surface = createHttpSurface(createRuntimeStub(), {
238
+ allowlistedTools: catalog.map((tool) => tool.name),
239
+ toolCatalog: [...catalog, catalog[0]!], // duplicate entry to prove dedup
240
+ surfacesVersion: SURFACES_VERSION,
241
+ workspaceId: WORKSPACE_ID,
242
+ });
243
+
244
+ const request = createRequest({ method: HTTP_METHOD.GET, url: '/tools' });
245
+ const response = new MockResponse();
246
+
247
+ await surface.handleRequest(request, response as unknown as ServerResponse<IncomingMessage>);
248
+
249
+ const body = parseJson(response.body) as { tools: ToolCatalogEntry[] };
250
+ const names = body.tools.map((tool) => tool.name);
251
+ const uniqueNames = new Set(names);
252
+ expect(names).toHaveLength(uniqueNames.size);
253
+
254
+ const packs = new Set(body.tools.map((tool) => tool.pack));
255
+ expect(packs.has(PACK.SOFTWARE_DELIVERY)).toBe(true);
256
+ expect(packs.has(PACK.SIDEKICK)).toBe(true);
257
+ expect(packs.has(PACK.AGENT_RUNTIME)).toBe(true);
258
+ });
259
+
260
+ // --- AC4: capabilities_required aligns with scope grammar from ADR-058 ---
261
+
262
+ it('capabilities_required defaults to the tool:<pack>:<tool> scope grammar when omitted', async () => {
263
+ const surface = createHttpSurface(createRuntimeStub(), {
264
+ allowlistedTools: createToolCatalog().map((tool) => tool.name),
265
+ toolCatalog: createToolCatalog(),
266
+ surfacesVersion: SURFACES_VERSION,
267
+ workspaceId: WORKSPACE_ID,
268
+ });
269
+
270
+ const request = createRequest({ method: HTTP_METHOD.GET, url: '/tools' });
271
+ const response = new MockResponse();
272
+
273
+ await surface.handleRequest(request, response as unknown as ServerResponse<IncomingMessage>);
274
+
275
+ const body = parseJson(response.body) as { tools: ToolCatalogEntry[] };
276
+
277
+ const wuClaim = body.tools.find((tool) => tool.name === 'software-delivery:wu.claim');
278
+ expect(wuClaim).toBeDefined();
279
+ expect(wuClaim?.capabilities_required).toEqual(['tool:software-delivery:wu.claim']);
280
+
281
+ const channelPost = body.tools.find((tool) => tool.name === 'sidekick:channel.post');
282
+ expect(channelPost?.capabilities_required).toEqual(['tool:sidekick:channel.post']);
283
+
284
+ const turnExecute = body.tools.find((tool) => tool.name === 'agent-runtime:turn.execute');
285
+ expect(turnExecute?.capabilities_required).toEqual(['tool:agent-runtime:turn.execute']);
286
+ });
287
+
288
+ it('preserves explicit capabilities_required when supplied by the catalog entry', async () => {
289
+ const explicit = [
290
+ {
291
+ ...createToolCatalog()[0]!,
292
+ capabilities_required: ['tool:software-delivery:*'],
293
+ },
294
+ ];
295
+
296
+ const surface = createHttpSurface(createRuntimeStub(), {
297
+ allowlistedTools: explicit.map((tool) => tool.name),
298
+ toolCatalog: explicit,
299
+ surfacesVersion: SURFACES_VERSION,
300
+ workspaceId: WORKSPACE_ID,
301
+ });
302
+
303
+ const request = createRequest({ method: HTTP_METHOD.GET, url: '/tools' });
304
+ const response = new MockResponse();
305
+
306
+ await surface.handleRequest(request, response as unknown as ServerResponse<IncomingMessage>);
307
+
308
+ const body = parseJson(response.body) as { tools: ToolCatalogEntry[] };
309
+ expect(body.tools[0]?.capabilities_required).toEqual(['tool:software-delivery:*']);
310
+ });
311
+
312
+ // --- Method handling ---
313
+
314
+ it('returns 405 for non-GET methods on /tools root', async () => {
315
+ const surface = createHttpSurface(createRuntimeStub(), {
316
+ allowlistedTools: createToolCatalog().map((tool) => tool.name),
317
+ toolCatalog: createToolCatalog(),
318
+ surfacesVersion: SURFACES_VERSION,
319
+ workspaceId: WORKSPACE_ID,
320
+ });
321
+
322
+ const request = createRequest({ method: HTTP_METHOD.PUT, url: '/tools' });
323
+ const response = new MockResponse();
324
+
325
+ await surface.handleRequest(request, response as unknown as ServerResponse<IncomingMessage>);
326
+
327
+ expect(response.statusCode).toBe(HTTP_STATUS.METHOD_NOT_ALLOWED);
328
+ });
329
+
330
+ // --- AC6: Existing POST /tools/:name behavior unchanged ---
331
+
332
+ it('existing POST /tools/:name dispatch behavior is unchanged when discovery is enabled', async () => {
333
+ const runtime = {
334
+ executeTool: vi.fn(async () => ({ success: true, data: { status: 'ok' } })),
335
+ } as unknown as KernelRuntime;
336
+
337
+ const surface = createHttpSurface(runtime, {
338
+ allowlistedTools: ['software-delivery:wu.claim'],
339
+ toolCatalog: createToolCatalog(),
340
+ surfacesVersion: SURFACES_VERSION,
341
+ workspaceId: WORKSPACE_ID,
342
+ });
343
+
344
+ const request = createRequest({
345
+ method: HTTP_METHOD.POST,
346
+ url: '/tools/software-delivery:wu.claim',
347
+ // WU-2779: POST /tools/:name requires Authorization. Use legacy opaque
348
+ // token; this test covers discovery + dispatch, not auth.
349
+ authorization: 'Bearer legacy-opaque-discovery-token',
350
+ body: {
351
+ input: { wu_id: 'WU-2639' },
352
+ context: {
353
+ run_id: 'run-discovery',
354
+ task_id: 'WU-2639',
355
+ session_id: 'session-discovery',
356
+ allowed_scopes: [],
357
+ },
358
+ },
359
+ });
360
+ const response = new MockResponse();
361
+
362
+ await surface.handleRequest(request, response as unknown as ServerResponse<IncomingMessage>);
363
+
364
+ expect(runtime.executeTool).toHaveBeenCalledWith(
365
+ 'software-delivery:wu.claim',
366
+ { wu_id: 'WU-2639' },
367
+ expect.objectContaining({ run_id: 'run-discovery' }),
368
+ );
369
+ expect(response.statusCode).toBe(HTTP_STATUS.OK);
370
+ });
371
+
372
+ it('GET /tools returns 404 when toolCatalog is not provided', async () => {
373
+ const surface = createHttpSurface(createRuntimeStub(), {
374
+ // no toolCatalog, no allowlistedTools
375
+ });
376
+
377
+ const request = createRequest({ method: HTTP_METHOD.GET, url: '/tools' });
378
+ const response = new MockResponse();
379
+
380
+ await surface.handleRequest(request, response as unknown as ServerResponse<IncomingMessage>);
381
+
382
+ expect(response.statusCode).toBe(HTTP_STATUS.NOT_FOUND);
383
+ });
384
+ });