@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,213 @@
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
+ /*
12
+ * WU-2639 AC3: input_schema for each tool validates a known-good POST /tools/:name
13
+ * input using ajv or equivalent (unit test).
14
+ *
15
+ * This test validates that:
16
+ * 1. input_schema returned by GET /tools is a structurally valid JSON Schema
17
+ * (object with type:"object" and a properties/required declaration).
18
+ * 2. A known-good input payload conforms to that schema using a minimal
19
+ * JSON-Schema-object validator (ajv-equivalent scope sufficient for AC3).
20
+ * 3. That same payload is accepted by POST /tools/:name with status 200,
21
+ * proving the declared input_schema matches the live dispatch surface.
22
+ */
23
+
24
+ const SURFACES_VERSION = '4.23.0';
25
+ const WORKSPACE_ID = 'workspace-schema-1';
26
+ const CONTENT_TYPE_JSON = 'application/json; charset=utf-8';
27
+ const HEADER_CONTENT_TYPE = 'content-type';
28
+ const HTTP_STATUS_OK = 200;
29
+
30
+ class MockResponse extends EventEmitter {
31
+ statusCode = HTTP_STATUS_OK;
32
+ body = '';
33
+ readonly headers = new Map<string, string>();
34
+ setHeader(name: string, value: string | number | readonly string[]): this {
35
+ this.headers.set(name.toLowerCase(), String(value));
36
+ return this;
37
+ }
38
+ write(chunk: string | Buffer): boolean {
39
+ this.body += Buffer.isBuffer(chunk) ? chunk.toString('utf8') : chunk;
40
+ return true;
41
+ }
42
+ end(chunk?: string | Buffer): this {
43
+ if (chunk !== undefined) {
44
+ this.write(chunk);
45
+ }
46
+ this.emit('finish');
47
+ return this;
48
+ }
49
+ }
50
+
51
+ function createRequest(method: string, url: string, body?: unknown): IncomingMessage {
52
+ const request = new PassThrough() as unknown as IncomingMessage & {
53
+ method: string;
54
+ url: string;
55
+ headers: IncomingHttpHeaders;
56
+ };
57
+ request.method = method;
58
+ request.url = url;
59
+ request.headers = {
60
+ [HEADER_CONTENT_TYPE]: CONTENT_TYPE_JSON,
61
+ // WU-2779: POST /tools/:name requires Authorization. Attach a legacy
62
+ // opaque bearer token — this suite tests schema validation, not auth.
63
+ authorization: 'Bearer legacy-opaque-schema-validation-token',
64
+ };
65
+ const payload = body === undefined ? '' : JSON.stringify(body);
66
+ (request as unknown as PassThrough).end(payload);
67
+ return request;
68
+ }
69
+
70
+ // Minimal JSON-Schema-object validator (ajv-equivalent for object schemas
71
+ // with properties + required). Sufficient for AC3 which only requires that
72
+ // input_schema validates a known-good input.
73
+ interface JsonSchemaObject {
74
+ type: 'object';
75
+ required?: string[];
76
+ properties?: Record<string, { type: string }>;
77
+ }
78
+
79
+ function isJsonSchemaObject(value: unknown): value is JsonSchemaObject {
80
+ if (typeof value !== 'object' || value === null) {
81
+ return false;
82
+ }
83
+ const record = value as Record<string, unknown>;
84
+ return record.type === 'object';
85
+ }
86
+
87
+ function validateAgainstSchema(schema: unknown, value: unknown): string[] {
88
+ const errors: string[] = [];
89
+ if (!isJsonSchemaObject(schema)) {
90
+ errors.push('schema must declare type:"object"');
91
+ return errors;
92
+ }
93
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
94
+ errors.push('value must be an object');
95
+ return errors;
96
+ }
97
+
98
+ const record = value as Record<string, unknown>;
99
+ for (const required of schema.required ?? []) {
100
+ if (!(required in record)) {
101
+ errors.push(`missing required property "${required}"`);
102
+ }
103
+ }
104
+
105
+ for (const [key, propSchema] of Object.entries(schema.properties ?? {})) {
106
+ if (key in record && typeof record[key] !== propSchema.type) {
107
+ errors.push(`property "${key}" expected type ${propSchema.type}`);
108
+ }
109
+ }
110
+
111
+ return errors;
112
+ }
113
+
114
+ interface CatalogEntry {
115
+ name: string;
116
+ pack: string;
117
+ description: string;
118
+ input_schema: unknown;
119
+ output_schema: unknown;
120
+ requires_approval: boolean;
121
+ }
122
+
123
+ const TOOL_NAME = 'software-delivery:wu.claim';
124
+
125
+ function createCatalog(): CatalogEntry[] {
126
+ return [
127
+ {
128
+ name: TOOL_NAME,
129
+ pack: 'software-delivery',
130
+ description: 'Claim a WU for implementation.',
131
+ input_schema: {
132
+ type: 'object',
133
+ required: ['wu_id'],
134
+ properties: { wu_id: { type: 'string' } },
135
+ },
136
+ output_schema: {
137
+ type: 'object',
138
+ properties: { worktree_path: { type: 'string' } },
139
+ },
140
+ requires_approval: false,
141
+ },
142
+ ];
143
+ }
144
+
145
+ describe('tool-api schema validation (AC3)', () => {
146
+ it('input_schema from GET /tools validates a known-good POST input', async () => {
147
+ const runtime = {
148
+ executeTool: vi.fn(async () => ({ success: true, data: { worktree_path: '/wt' } })),
149
+ } as unknown as KernelRuntime;
150
+
151
+ const surface = createHttpSurface(runtime, {
152
+ allowlistedTools: [TOOL_NAME],
153
+ toolCatalog: createCatalog(),
154
+ surfacesVersion: SURFACES_VERSION,
155
+ workspaceId: WORKSPACE_ID,
156
+ });
157
+
158
+ // 1. Fetch catalog via GET /tools
159
+ const getRequest = createRequest('GET', '/tools');
160
+ const getResponse = new MockResponse();
161
+ await surface.handleRequest(
162
+ getRequest,
163
+ getResponse as unknown as ServerResponse<IncomingMessage>,
164
+ );
165
+ const catalog = JSON.parse(getResponse.body) as { tools: CatalogEntry[] };
166
+ const descriptor = catalog.tools.find((tool) => tool.name === TOOL_NAME);
167
+ expect(descriptor).toBeDefined();
168
+
169
+ // 2. Validate known-good input against declared input_schema
170
+ const knownGoodInput = { wu_id: 'WU-2639' };
171
+ const errors = validateAgainstSchema(descriptor?.input_schema, knownGoodInput);
172
+ expect(errors).toEqual([]);
173
+
174
+ // 3. POST that same payload to /tools/:name and observe success
175
+ const postRequest = createRequest('POST', `/tools/${TOOL_NAME}`, {
176
+ input: knownGoodInput,
177
+ context: {
178
+ run_id: 'run-schema',
179
+ task_id: 'WU-2639',
180
+ session_id: 'session-schema',
181
+ allowed_scopes: [],
182
+ },
183
+ });
184
+ const postResponse = new MockResponse();
185
+ await surface.handleRequest(
186
+ postRequest,
187
+ postResponse as unknown as ServerResponse<IncomingMessage>,
188
+ );
189
+ expect(postResponse.statusCode).toBe(HTTP_STATUS_OK);
190
+ });
191
+
192
+ it('input_schema rejects a payload missing a required property', async () => {
193
+ const surface = createHttpSurface({ executeTool: vi.fn() } as unknown as KernelRuntime, {
194
+ allowlistedTools: [TOOL_NAME],
195
+ toolCatalog: createCatalog(),
196
+ surfacesVersion: SURFACES_VERSION,
197
+ workspaceId: WORKSPACE_ID,
198
+ });
199
+
200
+ const getRequest = createRequest('GET', '/tools');
201
+ const getResponse = new MockResponse();
202
+ await surface.handleRequest(
203
+ getRequest,
204
+ getResponse as unknown as ServerResponse<IncomingMessage>,
205
+ );
206
+ const catalog = JSON.parse(getResponse.body) as { tools: CatalogEntry[] };
207
+ const descriptor = catalog.tools.find((tool) => tool.name === TOOL_NAME);
208
+
209
+ const missingRequired = {};
210
+ const errors = validateAgainstSchema(descriptor?.input_schema, missingRequired);
211
+ expect(errors).toContain('missing required property "wu_id"');
212
+ });
213
+ });
@@ -0,0 +1,491 @@
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 { createToolApiRouter } from '../tool-api.js';
9
+
10
+ const HTTP_METHOD = {
11
+ POST: 'POST',
12
+ GET: 'GET',
13
+ } as const;
14
+
15
+ const HTTP_STATUS = {
16
+ OK: 200,
17
+ BAD_REQUEST: 400,
18
+ FORBIDDEN: 403,
19
+ NOT_FOUND: 404,
20
+ METHOD_NOT_ALLOWED: 405,
21
+ INTERNAL_SERVER_ERROR: 500,
22
+ } as const;
23
+
24
+ const TOOL_NAME = {
25
+ ALLOWED: 'task:status',
26
+ DISALLOWED: 'task:delete',
27
+ } as const;
28
+
29
+ interface RequestOptions {
30
+ method: string;
31
+ body?: unknown;
32
+ }
33
+
34
+ class MockResponse extends EventEmitter {
35
+ statusCode = HTTP_STATUS.OK;
36
+ body = '';
37
+ private readonly headers = new Map<string, string>();
38
+
39
+ setHeader(name: string, value: string | number | readonly string[]): this {
40
+ this.headers.set(name.toLowerCase(), String(value));
41
+ return this;
42
+ }
43
+
44
+ write(chunk: string | Buffer): boolean {
45
+ this.body += Buffer.isBuffer(chunk) ? chunk.toString('utf8') : chunk;
46
+ return true;
47
+ }
48
+
49
+ end(chunk?: string | Buffer): this {
50
+ if (chunk !== undefined) {
51
+ this.write(chunk);
52
+ }
53
+ this.emit('finish');
54
+ return this;
55
+ }
56
+ }
57
+
58
+ function createRequest(options: RequestOptions): IncomingMessage {
59
+ const request = new PassThrough() as unknown as IncomingMessage & {
60
+ method: string;
61
+ url: string;
62
+ headers: IncomingHttpHeaders;
63
+ };
64
+
65
+ request.method = options.method;
66
+ request.url = '/';
67
+ request.headers = {
68
+ 'content-type': 'application/json; charset=utf-8',
69
+ // WU-2779: POST /tools/:name now requires an Authorization header. Use a
70
+ // legacy opaque token here — the tests below cover runtime behavior, not
71
+ // auth; scope enforcement is covered in scope-enforcement.test.ts.
72
+ authorization: 'Bearer legacy-opaque-test-token',
73
+ };
74
+
75
+ const payload = options.body === undefined ? '' : JSON.stringify(options.body);
76
+ (request as unknown as PassThrough).end(payload);
77
+ return request;
78
+ }
79
+
80
+ function createContext() {
81
+ return {
82
+ run_id: 'run-tool-api',
83
+ task_id: 'WU-tool-api',
84
+ session_id: 'session-tool-api',
85
+ allowed_scopes: [
86
+ {
87
+ type: 'path' as const,
88
+ pattern: 'workspace/**',
89
+ access: 'read' as const,
90
+ },
91
+ ],
92
+ };
93
+ }
94
+
95
+ function parseJsonBody(body: string): unknown {
96
+ return JSON.parse(body);
97
+ }
98
+
99
+ describe('http tool api router', () => {
100
+ // --- AC1: HTTP surface exposes POST /tools/:name ---
101
+
102
+ it('dispatches allowlisted tool via POST and returns success', async () => {
103
+ const runtime = {
104
+ executeTool: vi.fn(async () => ({
105
+ success: true,
106
+ data: { ok: true },
107
+ })),
108
+ };
109
+
110
+ const router = createToolApiRouter(runtime as never, {
111
+ allowlistedTools: [TOOL_NAME.ALLOWED],
112
+ });
113
+
114
+ const request = createRequest({
115
+ method: HTTP_METHOD.POST,
116
+ body: {
117
+ input: { taskId: 'WU-1' },
118
+ context: createContext(),
119
+ },
120
+ });
121
+ const response = new MockResponse();
122
+
123
+ await router.handleRequest(request, response as unknown as ServerResponse<IncomingMessage>, [
124
+ TOOL_NAME.ALLOWED,
125
+ ]);
126
+
127
+ expect(runtime.executeTool).toHaveBeenCalledTimes(1);
128
+ expect(runtime.executeTool).toHaveBeenCalledWith(
129
+ TOOL_NAME.ALLOWED,
130
+ { taskId: 'WU-1' },
131
+ createContext(),
132
+ );
133
+ expect(response.statusCode).toBe(HTTP_STATUS.OK);
134
+ });
135
+
136
+ it('passes empty object as input when input is omitted', async () => {
137
+ const runtime = {
138
+ executeTool: vi.fn(async () => ({
139
+ success: true,
140
+ data: {},
141
+ })),
142
+ };
143
+
144
+ const router = createToolApiRouter(runtime as never, {
145
+ allowlistedTools: [TOOL_NAME.ALLOWED],
146
+ });
147
+
148
+ const request = createRequest({
149
+ method: HTTP_METHOD.POST,
150
+ body: {
151
+ context: createContext(),
152
+ },
153
+ });
154
+ const response = new MockResponse();
155
+
156
+ await router.handleRequest(request, response as unknown as ServerResponse<IncomingMessage>, [
157
+ TOOL_NAME.ALLOWED,
158
+ ]);
159
+
160
+ expect(runtime.executeTool).toHaveBeenCalledWith(TOOL_NAME.ALLOWED, {}, createContext());
161
+ });
162
+
163
+ it('returns 405 for non-POST methods', async () => {
164
+ const runtime = {
165
+ executeTool: vi.fn(),
166
+ };
167
+
168
+ const router = createToolApiRouter(runtime as never, {
169
+ allowlistedTools: [TOOL_NAME.ALLOWED],
170
+ });
171
+
172
+ const request = createRequest({
173
+ method: HTTP_METHOD.GET,
174
+ body: {},
175
+ });
176
+ const response = new MockResponse();
177
+
178
+ await router.handleRequest(request, response as unknown as ServerResponse<IncomingMessage>, [
179
+ TOOL_NAME.ALLOWED,
180
+ ]);
181
+
182
+ expect(runtime.executeTool).not.toHaveBeenCalled();
183
+ expect(response.statusCode).toBe(HTTP_STATUS.METHOD_NOT_ALLOWED);
184
+ });
185
+
186
+ it('returns 404 for nested route segments', async () => {
187
+ const runtime = {
188
+ executeTool: vi.fn(),
189
+ };
190
+
191
+ const router = createToolApiRouter(runtime as never, {
192
+ allowlistedTools: [TOOL_NAME.ALLOWED],
193
+ });
194
+
195
+ const request = createRequest({
196
+ method: HTTP_METHOD.POST,
197
+ body: {
198
+ context: createContext(),
199
+ },
200
+ });
201
+ const response = new MockResponse();
202
+
203
+ await router.handleRequest(request, response as unknown as ServerResponse<IncomingMessage>, [
204
+ TOOL_NAME.ALLOWED,
205
+ 'extra',
206
+ ]);
207
+
208
+ expect(runtime.executeTool).not.toHaveBeenCalled();
209
+ expect(response.statusCode).toBe(HTTP_STATUS.NOT_FOUND);
210
+ });
211
+
212
+ // --- AC2: Endpoint dispatches only allowlisted tools ---
213
+
214
+ it('denies tools outside the allowlist with 403', async () => {
215
+ const runtime = {
216
+ executeTool: vi.fn(),
217
+ };
218
+
219
+ const router = createToolApiRouter(runtime as never, {
220
+ allowlistedTools: [TOOL_NAME.ALLOWED],
221
+ });
222
+
223
+ const request = createRequest({
224
+ method: HTTP_METHOD.POST,
225
+ body: {
226
+ input: {},
227
+ context: createContext(),
228
+ },
229
+ });
230
+ const response = new MockResponse();
231
+
232
+ await router.handleRequest(request, response as unknown as ServerResponse<IncomingMessage>, [
233
+ TOOL_NAME.DISALLOWED,
234
+ ]);
235
+
236
+ expect(runtime.executeTool).not.toHaveBeenCalled();
237
+ expect(response.statusCode).toBe(HTTP_STATUS.FORBIDDEN);
238
+
239
+ const body = parseJsonBody(response.body) as {
240
+ success: boolean;
241
+ error: { code: string; message: string };
242
+ };
243
+ expect(body.success).toBe(false);
244
+ expect(body.error.code).toBe('TOOL_NOT_ALLOWLISTED');
245
+ });
246
+
247
+ // --- AC3: Policy/scope/tool-not-found responses are enforced and tested ---
248
+
249
+ it('returns 404 when runtime reports TOOL_NOT_FOUND', async () => {
250
+ const runtime = {
251
+ executeTool: vi.fn(async () => ({
252
+ success: false,
253
+ error: {
254
+ code: 'TOOL_NOT_FOUND',
255
+ message: 'Tool not registered.',
256
+ },
257
+ })),
258
+ };
259
+
260
+ const router = createToolApiRouter(runtime as never, {
261
+ allowlistedTools: [TOOL_NAME.ALLOWED],
262
+ });
263
+
264
+ const request = createRequest({
265
+ method: HTTP_METHOD.POST,
266
+ body: {
267
+ input: {},
268
+ context: createContext(),
269
+ },
270
+ });
271
+ const response = new MockResponse();
272
+
273
+ await router.handleRequest(request, response as unknown as ServerResponse<IncomingMessage>, [
274
+ TOOL_NAME.ALLOWED,
275
+ ]);
276
+
277
+ expect(response.statusCode).toBe(HTTP_STATUS.NOT_FOUND);
278
+ });
279
+
280
+ it('returns 403 when runtime reports POLICY_DENIED', async () => {
281
+ const runtime = {
282
+ executeTool: vi.fn(async () => ({
283
+ success: false,
284
+ error: {
285
+ code: 'POLICY_DENIED',
286
+ message: 'Policy forbids this tool.',
287
+ },
288
+ })),
289
+ };
290
+
291
+ const router = createToolApiRouter(runtime as never, {
292
+ allowlistedTools: [TOOL_NAME.ALLOWED],
293
+ });
294
+
295
+ const request = createRequest({
296
+ method: HTTP_METHOD.POST,
297
+ body: {
298
+ input: {},
299
+ context: createContext(),
300
+ },
301
+ });
302
+ const response = new MockResponse();
303
+
304
+ await router.handleRequest(request, response as unknown as ServerResponse<IncomingMessage>, [
305
+ TOOL_NAME.ALLOWED,
306
+ ]);
307
+
308
+ expect(response.statusCode).toBe(HTTP_STATUS.FORBIDDEN);
309
+ });
310
+
311
+ it('returns 403 when runtime reports SCOPE_DENIED', async () => {
312
+ const runtime = {
313
+ executeTool: vi.fn(async () => ({
314
+ success: false,
315
+ error: {
316
+ code: 'SCOPE_DENIED',
317
+ message: 'Scope intersection denied.',
318
+ },
319
+ })),
320
+ };
321
+
322
+ const router = createToolApiRouter(runtime as never, {
323
+ allowlistedTools: [TOOL_NAME.ALLOWED],
324
+ });
325
+
326
+ const request = createRequest({
327
+ method: HTTP_METHOD.POST,
328
+ body: {
329
+ input: {},
330
+ context: createContext(),
331
+ },
332
+ });
333
+ const response = new MockResponse();
334
+
335
+ await router.handleRequest(request, response as unknown as ServerResponse<IncomingMessage>, [
336
+ TOOL_NAME.ALLOWED,
337
+ ]);
338
+
339
+ expect(response.statusCode).toBe(HTTP_STATUS.FORBIDDEN);
340
+ });
341
+
342
+ it('returns 403 when runtime reports APPROVAL_REQUIRED', async () => {
343
+ const runtime = {
344
+ executeTool: vi.fn(async () => ({
345
+ success: false,
346
+ error: {
347
+ code: 'APPROVAL_REQUIRED',
348
+ message: 'Approval needed.',
349
+ },
350
+ })),
351
+ };
352
+
353
+ const router = createToolApiRouter(runtime as never, {
354
+ allowlistedTools: [TOOL_NAME.ALLOWED],
355
+ });
356
+
357
+ const request = createRequest({
358
+ method: HTTP_METHOD.POST,
359
+ body: {
360
+ input: {},
361
+ context: createContext(),
362
+ },
363
+ });
364
+ const response = new MockResponse();
365
+
366
+ await router.handleRequest(request, response as unknown as ServerResponse<IncomingMessage>, [
367
+ TOOL_NAME.ALLOWED,
368
+ ]);
369
+
370
+ expect(response.statusCode).toBe(HTTP_STATUS.FORBIDDEN);
371
+ });
372
+
373
+ it('returns 400 when runtime reports INVALID_INPUT', async () => {
374
+ const runtime = {
375
+ executeTool: vi.fn(async () => ({
376
+ success: false,
377
+ error: {
378
+ code: 'INVALID_INPUT',
379
+ message: 'Bad input schema.',
380
+ },
381
+ })),
382
+ };
383
+
384
+ const router = createToolApiRouter(runtime as never, {
385
+ allowlistedTools: [TOOL_NAME.ALLOWED],
386
+ });
387
+
388
+ const request = createRequest({
389
+ method: HTTP_METHOD.POST,
390
+ body: {
391
+ input: {},
392
+ context: createContext(),
393
+ },
394
+ });
395
+ const response = new MockResponse();
396
+
397
+ await router.handleRequest(request, response as unknown as ServerResponse<IncomingMessage>, [
398
+ TOOL_NAME.ALLOWED,
399
+ ]);
400
+
401
+ expect(response.statusCode).toBe(HTTP_STATUS.BAD_REQUEST);
402
+ });
403
+
404
+ it('returns 400 when context is missing from request body', async () => {
405
+ const runtime = {
406
+ executeTool: vi.fn(),
407
+ };
408
+
409
+ const router = createToolApiRouter(runtime as never, {
410
+ allowlistedTools: [TOOL_NAME.ALLOWED],
411
+ });
412
+
413
+ const request = createRequest({
414
+ method: HTTP_METHOD.POST,
415
+ body: {
416
+ input: {},
417
+ },
418
+ });
419
+ const response = new MockResponse();
420
+
421
+ await router.handleRequest(request, response as unknown as ServerResponse<IncomingMessage>, [
422
+ TOOL_NAME.ALLOWED,
423
+ ]);
424
+
425
+ expect(runtime.executeTool).not.toHaveBeenCalled();
426
+ expect(response.statusCode).toBe(HTTP_STATUS.BAD_REQUEST);
427
+ });
428
+
429
+ it('returns 400 when request body is invalid JSON', async () => {
430
+ const runtime = {
431
+ executeTool: vi.fn(),
432
+ };
433
+
434
+ const router = createToolApiRouter(runtime as never, {
435
+ allowlistedTools: [TOOL_NAME.ALLOWED],
436
+ });
437
+
438
+ // Create request with raw invalid JSON
439
+ const request = new PassThrough() as unknown as IncomingMessage & {
440
+ method: string;
441
+ url: string;
442
+ headers: IncomingHttpHeaders;
443
+ };
444
+ request.method = HTTP_METHOD.POST;
445
+ request.url = '/';
446
+ request.headers = {
447
+ 'content-type': 'application/json; charset=utf-8',
448
+ // WU-2779: POST /tools/:name now requires an Authorization header.
449
+ authorization: 'Bearer legacy-opaque-test-token',
450
+ };
451
+ (request as unknown as PassThrough).end('not-valid-json{');
452
+
453
+ const response = new MockResponse();
454
+
455
+ await router.handleRequest(
456
+ request as IncomingMessage,
457
+ response as unknown as ServerResponse<IncomingMessage>,
458
+ [TOOL_NAME.ALLOWED],
459
+ );
460
+
461
+ expect(runtime.executeTool).not.toHaveBeenCalled();
462
+ expect(response.statusCode).toBe(HTTP_STATUS.BAD_REQUEST);
463
+ });
464
+
465
+ it('returns 500 when runtime throws an unexpected error', async () => {
466
+ const runtime = {
467
+ executeTool: vi.fn(async () => {
468
+ throw new Error('unexpected runtime crash');
469
+ }),
470
+ };
471
+
472
+ const router = createToolApiRouter(runtime as never, {
473
+ allowlistedTools: [TOOL_NAME.ALLOWED],
474
+ });
475
+
476
+ const request = createRequest({
477
+ method: HTTP_METHOD.POST,
478
+ body: {
479
+ input: {},
480
+ context: createContext(),
481
+ },
482
+ });
483
+ const response = new MockResponse();
484
+
485
+ await router.handleRequest(request, response as unknown as ServerResponse<IncomingMessage>, [
486
+ TOOL_NAME.ALLOWED,
487
+ ]);
488
+
489
+ expect(response.statusCode).toBe(HTTP_STATUS.INTERNAL_SERVER_ERROR);
490
+ });
491
+ });