aws-runtime-bridge 1.8.4 → 1.8.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapter/AcodeSdkAdapter.d.ts.map +1 -1
- package/dist/adapter/AcodeSdkAdapter.js +6 -98
- package/dist/adapter/AcodeSdkAdapter.test.js +2 -313
- package/dist/adapter/OpencodeSdkAdapter.js +1 -1
- package/dist/adapter/types.d.ts.map +1 -1
- package/dist/adapter/types.js +3 -7
- package/dist/config.d.ts +3 -2
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +13 -36
- package/dist/config.test.js +20 -24
- package/dist/routes/ai-sources.js +1 -5
- package/dist/routes/ai-sources.test.js +0 -21
- package/dist/routes/instance.test.js +70 -1
- package/dist/routes/runtime-binding.d.ts.map +1 -1
- package/dist/routes/runtime-binding.js +1 -36
- package/dist/routes/terminal.d.ts +0 -2
- package/dist/routes/terminal.d.ts.map +1 -1
- package/dist/routes/terminal.js +7 -109
- package/dist/routes/terminal.test.js +1 -118
- package/dist/services/mcp-launch-binding-queue.d.ts +0 -7
- package/dist/services/mcp-launch-binding-queue.d.ts.map +1 -1
- package/dist/services/mcp-launch-binding-queue.js +0 -25
- package/dist/services/session-output.d.ts +3 -7
- package/dist/services/session-output.d.ts.map +1 -1
- package/package/acode/dist/runtime.d.ts +0 -1
- package/package/acode/dist/runtime.d.ts.map +1 -1
- package/package/acode/dist/runtime.js +31 -265
- package/package/acode/dist/types.d.ts +1 -1
- package/package/acode/dist/types.d.ts.map +1 -1
- package/package/aws-client-agent-mcp/dist/config.d.ts +0 -4
- package/package/aws-client-agent-mcp/dist/config.d.ts.map +1 -1
- package/package/aws-client-agent-mcp/dist/config.js +0 -15
- package/package/aws-client-agent-mcp/dist/config.js.map +1 -1
- package/package/aws-client-agent-mcp/dist/http-client.d.ts +0 -10
- package/package/aws-client-agent-mcp/dist/http-client.d.ts.map +1 -1
- package/package/aws-client-agent-mcp/dist/http-client.js +1 -79
- package/package/aws-client-agent-mcp/dist/http-client.js.map +1 -1
- package/package/aws-client-agent-mcp/dist/http-client.test.js +265 -129
- package/package/aws-client-agent-mcp/dist/http-client.test.js.map +1 -1
- package/package.json +1 -1
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
|
+
import { EventEmitter } from 'node:events';
|
|
2
3
|
import { afterEach, beforeEach, describe, it, mock } from 'node:test';
|
|
4
|
+
import WebSocket from 'ws';
|
|
3
5
|
import { HttpClient } from './http-client.js';
|
|
6
|
+
import { WebSocketClient } from './websocket-client.js';
|
|
7
|
+
class FakeSocket extends EventEmitter {
|
|
8
|
+
readyState = WebSocket.CONNECTING;
|
|
9
|
+
send() { }
|
|
10
|
+
close() {
|
|
11
|
+
this.readyState = WebSocket.CLOSED;
|
|
12
|
+
}
|
|
13
|
+
terminate() {
|
|
14
|
+
this.readyState = WebSocket.CLOSED;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
4
17
|
describe('HttpClient', () => {
|
|
5
18
|
const originalFetch = global.fetch;
|
|
6
19
|
let fetchCalls = [];
|
|
@@ -283,12 +296,12 @@ describe('HttpClient', () => {
|
|
|
283
296
|
statusText: 'Unauthorized',
|
|
284
297
|
};
|
|
285
298
|
}
|
|
286
|
-
if (url.toString() === 'http://127.0.0.1:18081/runtime/binding/
|
|
299
|
+
if (url.toString() === 'http://127.0.0.1:18081/runtime/binding/request-token') {
|
|
287
300
|
return {
|
|
288
301
|
ok: true,
|
|
289
302
|
status: 200,
|
|
290
303
|
statusText: 'OK',
|
|
291
|
-
json: async () => ({ runtimeAccessToken: 'fresh-token' }),
|
|
304
|
+
json: async () => ({ ok: true, runtimeAccessToken: 'fresh-token' }),
|
|
292
305
|
};
|
|
293
306
|
}
|
|
294
307
|
return {
|
|
@@ -302,15 +315,17 @@ describe('HttpClient', () => {
|
|
|
302
315
|
assert.deepEqual(result, { data: 'retried' });
|
|
303
316
|
assert.equal(fetchCalls.length, 3);
|
|
304
317
|
assert.equal(fetchCalls[0][1].headers['X-Runtime-Token'], 'stale-token');
|
|
305
|
-
assert.equal(fetchCalls[1][0], 'http://127.0.0.1:18081/runtime/binding/
|
|
318
|
+
assert.equal(fetchCalls[1][0], 'http://127.0.0.1:18081/runtime/binding/request-token');
|
|
306
319
|
assert.equal(fetchCalls[1][1].body, JSON.stringify({
|
|
307
|
-
|
|
308
|
-
|
|
320
|
+
userId: 'user-1',
|
|
321
|
+
serverUrl: 'ws://localhost:8080/ws/agent',
|
|
322
|
+
forceRefresh: true,
|
|
323
|
+
rejectedRuntimeAccessToken: 'stale-token',
|
|
309
324
|
}));
|
|
310
325
|
assert.equal(fetchCalls[2][1].headers['X-Runtime-Token'], 'fresh-token');
|
|
311
326
|
assert.equal(config.runtimeAccessToken, 'fresh-token');
|
|
312
327
|
});
|
|
313
|
-
it('shares one
|
|
328
|
+
it('shares one forced runtime token refresh across concurrent 401 responses', async () => {
|
|
314
329
|
const config = {
|
|
315
330
|
serverUrl: 'ws://localhost:8080/ws/agent',
|
|
316
331
|
heartbeatInterval: 15000,
|
|
@@ -324,13 +339,13 @@ describe('HttpClient', () => {
|
|
|
324
339
|
const staleTokenCalls = new Set();
|
|
325
340
|
global.fetch = async (url, options) => {
|
|
326
341
|
fetchCalls.push([url.toString(), options]);
|
|
327
|
-
if (url.toString() === 'http://127.0.0.1:18081/runtime/binding/
|
|
342
|
+
if (url.toString() === 'http://127.0.0.1:18081/runtime/binding/request-token') {
|
|
328
343
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
329
344
|
return {
|
|
330
345
|
ok: true,
|
|
331
346
|
status: 200,
|
|
332
347
|
statusText: 'OK',
|
|
333
|
-
json: async () => ({ runtimeAccessToken: 'fresh-token' }),
|
|
348
|
+
json: async () => ({ ok: true, runtimeAccessToken: 'fresh-token' }),
|
|
334
349
|
};
|
|
335
350
|
}
|
|
336
351
|
const body = JSON.parse(String(options?.body || '{}'));
|
|
@@ -357,10 +372,10 @@ describe('HttpClient', () => {
|
|
|
357
372
|
assert.deepEqual(first, { tool: 'get_memory_stats', token: 'fresh-token' });
|
|
358
373
|
assert.deepEqual(second, { tool: 'discover_group_rooms', token: 'fresh-token' });
|
|
359
374
|
assert.deepEqual(staleTokenCalls, new Set(['get_memory_stats', 'discover_group_rooms']));
|
|
360
|
-
assert.equal(fetchCalls.filter(([url]) => url === 'http://127.0.0.1:18081/runtime/binding/
|
|
375
|
+
assert.equal(fetchCalls.filter(([url]) => url === 'http://127.0.0.1:18081/runtime/binding/request-token').length, 1);
|
|
361
376
|
assert.equal(config.runtimeAccessToken, 'fresh-token');
|
|
362
377
|
});
|
|
363
|
-
it('
|
|
378
|
+
it('shares one initial runtime token request across concurrent calls without a token', async () => {
|
|
364
379
|
const config = {
|
|
365
380
|
serverUrl: 'ws://localhost:8080/ws/agent',
|
|
366
381
|
heartbeatInterval: 15000,
|
|
@@ -370,139 +385,270 @@ describe('HttpClient', () => {
|
|
|
370
385
|
runtimeBridgeBaseUrl: 'http://127.0.0.1:18081',
|
|
371
386
|
};
|
|
372
387
|
const client = new HttpClient(config);
|
|
373
|
-
global.fetch = async () =>
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
388
|
+
global.fetch = async (url, options) => {
|
|
389
|
+
fetchCalls.push([url.toString(), options]);
|
|
390
|
+
if (url.toString() === 'http://127.0.0.1:18081/runtime/binding/request-token') {
|
|
391
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
392
|
+
return {
|
|
393
|
+
ok: true,
|
|
394
|
+
status: 200,
|
|
395
|
+
statusText: 'OK',
|
|
396
|
+
json: async () => ({ ok: true, runtimeAccessToken: 'startup-token' }),
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
const body = JSON.parse(String(options?.body || '{}'));
|
|
400
|
+
return {
|
|
401
|
+
ok: true,
|
|
402
|
+
status: 200,
|
|
403
|
+
statusText: 'OK',
|
|
404
|
+
json: async () => ({ tool: body.tool, token: options.headers['X-Runtime-Token'] }),
|
|
405
|
+
};
|
|
406
|
+
};
|
|
407
|
+
const [first, second] = await Promise.all([
|
|
408
|
+
client.callTool('get_memory_stats', { agentId: 'agent-1' }),
|
|
409
|
+
client.callTool('discover_group_rooms', { agentId: 'agent-1' }),
|
|
410
|
+
]);
|
|
411
|
+
assert.deepEqual(first, { tool: 'get_memory_stats', token: 'startup-token' });
|
|
412
|
+
assert.deepEqual(second, { tool: 'discover_group_rooms', token: 'startup-token' });
|
|
413
|
+
assert.equal(fetchCalls.filter(([url]) => url === 'http://127.0.0.1:18081/runtime/binding/request-token').length, 1);
|
|
414
|
+
assert.equal(fetchCalls[0][1].body, JSON.stringify({
|
|
415
|
+
userId: 'user-1',
|
|
416
|
+
serverUrl: 'ws://localhost:8080/ws/agent',
|
|
417
|
+
forceRefresh: false,
|
|
418
|
+
}));
|
|
419
|
+
assert.equal(config.runtimeAccessToken, 'startup-token');
|
|
380
420
|
});
|
|
381
|
-
it('
|
|
421
|
+
it('clears failed runtime token refresh so later calls can retry', async () => {
|
|
382
422
|
const config = {
|
|
383
423
|
serverUrl: 'ws://localhost:8080/ws/agent',
|
|
384
424
|
heartbeatInterval: 15000,
|
|
385
425
|
reconnectInterval: 200,
|
|
386
426
|
maxReconnectAttempts: -1,
|
|
387
427
|
userId: 'user-1',
|
|
388
|
-
runtimeAccessToken: 'stale-token',
|
|
389
428
|
runtimeBridgeBaseUrl: 'http://127.0.0.1:18081',
|
|
390
429
|
};
|
|
391
430
|
const client = new HttpClient(config);
|
|
431
|
+
let tokenRequestCount = 0;
|
|
392
432
|
global.fetch = async (url, options) => {
|
|
393
433
|
fetchCalls.push([url.toString(), options]);
|
|
394
|
-
if (url.toString() === 'http://127.0.0.1:18081/runtime/binding/
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
statusText: 'Unauthorized',
|
|
405
|
-
};
|
|
406
|
-
};
|
|
407
|
-
await assert.rejects(() => client.callTool('get_memory_stats', { agentId: 'agent-1' }), /MCP tool get_memory_stats call failed: 401 Unauthorized/);
|
|
408
|
-
// Can retry again after failure
|
|
409
|
-
global.fetch = async (url, options) => {
|
|
410
|
-
fetchCalls.push([url.toString(), options]);
|
|
411
|
-
if (url.toString() === 'http://127.0.0.1:18081/runtime/binding/refresh-token') {
|
|
434
|
+
if (url.toString() === 'http://127.0.0.1:18081/runtime/binding/request-token') {
|
|
435
|
+
tokenRequestCount += 1;
|
|
436
|
+
if (tokenRequestCount === 1) {
|
|
437
|
+
return {
|
|
438
|
+
ok: false,
|
|
439
|
+
status: 503,
|
|
440
|
+
statusText: 'Service Unavailable',
|
|
441
|
+
text: async () => 'bridge unavailable',
|
|
442
|
+
};
|
|
443
|
+
}
|
|
412
444
|
return {
|
|
413
445
|
ok: true,
|
|
414
446
|
status: 200,
|
|
415
447
|
statusText: 'OK',
|
|
416
|
-
json: async () => ({ runtimeAccessToken: 'recovered-token' }),
|
|
417
|
-
};
|
|
418
|
-
}
|
|
419
|
-
const token = options.headers['X-Runtime-Token'];
|
|
420
|
-
if (token === 'recovered-token') {
|
|
421
|
-
return {
|
|
422
|
-
ok: true,
|
|
423
|
-
status: 200,
|
|
424
|
-
statusText: 'OK',
|
|
425
|
-
json: async () => ({ token }),
|
|
448
|
+
json: async () => ({ ok: true, runtimeAccessToken: 'recovered-token' }),
|
|
426
449
|
};
|
|
427
450
|
}
|
|
428
451
|
return {
|
|
429
|
-
ok:
|
|
430
|
-
status:
|
|
431
|
-
statusText: '
|
|
452
|
+
ok: true,
|
|
453
|
+
status: 200,
|
|
454
|
+
statusText: 'OK',
|
|
455
|
+
json: async () => ({ token: options.headers['X-Runtime-Token'] }),
|
|
432
456
|
};
|
|
433
457
|
};
|
|
458
|
+
await assert.rejects(() => client.callTool('get_memory_stats', { agentId: 'agent-1' }), /Failed to request runtime token from runtime-bridge: 503 Service Unavailable - bridge unavailable/);
|
|
434
459
|
const result = await client.callTool('get_memory_stats', { agentId: 'agent-1' });
|
|
435
460
|
assert.deepEqual(result, { token: 'recovered-token' });
|
|
461
|
+
assert.equal(tokenRequestCount, 2);
|
|
436
462
|
assert.equal(config.runtimeAccessToken, 'recovered-token');
|
|
437
463
|
});
|
|
438
|
-
it('
|
|
464
|
+
it('shares one runtime token refresh between HTTP tools and websocket recovery', async () => {
|
|
439
465
|
const config = {
|
|
440
466
|
serverUrl: 'ws://localhost:8080/ws/agent',
|
|
441
|
-
heartbeatInterval:
|
|
442
|
-
|
|
443
|
-
|
|
467
|
+
heartbeatInterval: 1000,
|
|
468
|
+
heartbeatTimeout: 3000,
|
|
469
|
+
reconnectInterval: 10,
|
|
470
|
+
maxReconnectAttempts: 2,
|
|
471
|
+
userId: 'user-1',
|
|
444
472
|
runtimeAccessToken: 'stale-token',
|
|
445
473
|
runtimeBridgeBaseUrl: 'http://127.0.0.1:18081',
|
|
446
474
|
};
|
|
447
|
-
const
|
|
475
|
+
const httpClient = new HttpClient(config);
|
|
476
|
+
let tokenRequestCount = 0;
|
|
477
|
+
let websocketAttempt = 0;
|
|
478
|
+
const websocketUrls = [];
|
|
479
|
+
let releaseRefresh;
|
|
480
|
+
const refreshGate = new Promise((resolve) => {
|
|
481
|
+
releaseRefresh = resolve;
|
|
482
|
+
});
|
|
448
483
|
global.fetch = async (url, options) => {
|
|
449
484
|
fetchCalls.push([url.toString(), options]);
|
|
450
|
-
if (url.toString() === 'http://127.0.0.1:18081/runtime/binding/
|
|
451
|
-
|
|
485
|
+
if (url.toString() === 'http://127.0.0.1:18081/runtime/binding/request-token') {
|
|
486
|
+
tokenRequestCount += 1;
|
|
487
|
+
await refreshGate;
|
|
488
|
+
return {
|
|
489
|
+
ok: true,
|
|
490
|
+
status: 200,
|
|
491
|
+
statusText: 'OK',
|
|
492
|
+
json: async () => ({ ok: true, runtimeAccessToken: 'shared-fresh-token' }),
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
const body = JSON.parse(String(options?.body || '{}'));
|
|
496
|
+
const token = options.headers['X-Runtime-Token'];
|
|
497
|
+
if (token === 'stale-token') {
|
|
452
498
|
return {
|
|
453
499
|
ok: false,
|
|
454
|
-
status:
|
|
455
|
-
statusText: '
|
|
456
|
-
json: async () => ({ error: 'token_unchanged' }),
|
|
500
|
+
status: 401,
|
|
501
|
+
statusText: 'Unauthorized',
|
|
457
502
|
};
|
|
458
503
|
}
|
|
459
504
|
return {
|
|
460
|
-
ok:
|
|
461
|
-
status:
|
|
462
|
-
statusText: '
|
|
505
|
+
ok: true,
|
|
506
|
+
status: 200,
|
|
507
|
+
statusText: 'OK',
|
|
508
|
+
json: async () => ({ tool: body.tool, token }),
|
|
463
509
|
};
|
|
464
510
|
};
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
511
|
+
const wsClient = new WebSocketClient(config, (url) => {
|
|
512
|
+
websocketUrls.push(url);
|
|
513
|
+
websocketAttempt += 1;
|
|
514
|
+
const socket = new FakeSocket();
|
|
515
|
+
setImmediate(() => {
|
|
516
|
+
socket.readyState = WebSocket.OPEN;
|
|
517
|
+
socket.emit('open');
|
|
518
|
+
if (websocketAttempt === 1) {
|
|
519
|
+
socket.readyState = WebSocket.CLOSED;
|
|
520
|
+
socket.emit('close', 1008, Buffer.from('policy violation'));
|
|
521
|
+
}
|
|
522
|
+
});
|
|
523
|
+
return socket;
|
|
524
|
+
});
|
|
525
|
+
try {
|
|
526
|
+
wsClient.on('error', () => {
|
|
527
|
+
// suppress unhandled error event in test environment
|
|
528
|
+
});
|
|
529
|
+
const websocketConnected = wsClient.connect('agent-1');
|
|
530
|
+
const httpCall = httpClient.callTool('get_memory_stats', { agentId: 'agent-1' });
|
|
531
|
+
await new Promise((resolve, reject) => {
|
|
532
|
+
const timer = setTimeout(() => reject(new Error('expected both refresh waiters')), 500);
|
|
533
|
+
const interval = setInterval(() => {
|
|
534
|
+
if (tokenRequestCount === 1 && config.runtimeAccessTokenRefreshPromise) {
|
|
535
|
+
clearInterval(interval);
|
|
536
|
+
clearTimeout(timer);
|
|
537
|
+
resolve();
|
|
538
|
+
}
|
|
539
|
+
}, 5);
|
|
540
|
+
});
|
|
541
|
+
const websocketReconnected = new Promise((resolve, reject) => {
|
|
542
|
+
const timer = setTimeout(() => reject(new Error('expected websocket reconnect')), 500);
|
|
543
|
+
wsClient.on('connected', () => {
|
|
544
|
+
if (websocketAttempt >= 2) {
|
|
545
|
+
clearTimeout(timer);
|
|
546
|
+
resolve();
|
|
547
|
+
}
|
|
548
|
+
});
|
|
549
|
+
});
|
|
550
|
+
releaseRefresh?.();
|
|
551
|
+
const result = await httpCall;
|
|
552
|
+
await websocketConnected;
|
|
553
|
+
await websocketReconnected;
|
|
554
|
+
assert.deepEqual(result, { tool: 'get_memory_stats', token: 'shared-fresh-token' });
|
|
555
|
+
assert.equal(tokenRequestCount, 1);
|
|
556
|
+
assert.equal(config.runtimeAccessToken, 'shared-fresh-token');
|
|
557
|
+
assert.equal(new URL(websocketUrls[1] || '').searchParams.get('runtimeAccessToken'), 'shared-fresh-token');
|
|
558
|
+
}
|
|
559
|
+
finally {
|
|
560
|
+
releaseRefresh?.();
|
|
561
|
+
wsClient.disconnect();
|
|
562
|
+
}
|
|
563
|
+
});
|
|
564
|
+
it('shares one forced refresh among multiple waiters after an initial non-forced refresh', async () => {
|
|
472
565
|
const config = {
|
|
473
566
|
serverUrl: 'ws://localhost:8080/ws/agent',
|
|
474
567
|
heartbeatInterval: 15000,
|
|
475
568
|
reconnectInterval: 200,
|
|
476
569
|
maxReconnectAttempts: -1,
|
|
477
570
|
userId: 'user-1',
|
|
478
|
-
runtimeAccessToken: 'stale-token',
|
|
479
571
|
runtimeBridgeBaseUrl: 'http://127.0.0.1:18081',
|
|
480
572
|
};
|
|
481
|
-
const
|
|
573
|
+
const initialClient = new HttpClient(config);
|
|
574
|
+
const forceClient = new HttpClient(config);
|
|
575
|
+
let tokenRequestCount = 0;
|
|
576
|
+
let forceRequestCount = 0;
|
|
577
|
+
let releaseInitialRefresh;
|
|
578
|
+
const initialRefreshGate = new Promise((resolve) => {
|
|
579
|
+
releaseInitialRefresh = resolve;
|
|
580
|
+
});
|
|
482
581
|
global.fetch = async (url, options) => {
|
|
483
582
|
fetchCalls.push([url.toString(), options]);
|
|
484
|
-
if (url.toString() === 'http://127.0.0.1:18081/runtime/binding/
|
|
485
|
-
|
|
583
|
+
if (url.toString() === 'http://127.0.0.1:18081/runtime/binding/request-token') {
|
|
584
|
+
tokenRequestCount += 1;
|
|
585
|
+
const body = JSON.parse(String(options?.body || '{}'));
|
|
586
|
+
if (body.forceRefresh) {
|
|
587
|
+
forceRequestCount += 1;
|
|
588
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
589
|
+
return {
|
|
590
|
+
ok: true,
|
|
591
|
+
status: 200,
|
|
592
|
+
statusText: 'OK',
|
|
593
|
+
json: async () => ({ ok: true, runtimeAccessToken: 'forced-token' }),
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
await initialRefreshGate;
|
|
597
|
+
return {
|
|
598
|
+
ok: true,
|
|
599
|
+
status: 200,
|
|
600
|
+
statusText: 'OK',
|
|
601
|
+
json: async () => ({ ok: true, runtimeAccessToken: 'initial-token' }),
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
const body = JSON.parse(String(options?.body || '{}'));
|
|
605
|
+
const token = options.headers['X-Runtime-Token'];
|
|
606
|
+
if (token === 'initial-token') {
|
|
486
607
|
return {
|
|
487
608
|
ok: false,
|
|
488
|
-
status:
|
|
489
|
-
statusText: '
|
|
490
|
-
json: async () => ({ error: 'no cached token for agent' }),
|
|
609
|
+
status: 401,
|
|
610
|
+
statusText: 'Unauthorized',
|
|
491
611
|
};
|
|
492
612
|
}
|
|
493
613
|
return {
|
|
494
|
-
ok:
|
|
495
|
-
status:
|
|
496
|
-
statusText: '
|
|
614
|
+
ok: true,
|
|
615
|
+
status: 200,
|
|
616
|
+
statusText: 'OK',
|
|
617
|
+
json: async () => ({ tool: body.tool, token }),
|
|
497
618
|
};
|
|
498
619
|
};
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
620
|
+
try {
|
|
621
|
+
const initialCall = initialClient.callTool('get_profile', { agentId: 'agent-1' });
|
|
622
|
+
await new Promise((resolve, reject) => {
|
|
623
|
+
const timer = setTimeout(() => reject(new Error('expected initial refresh to start')), 500);
|
|
624
|
+
const interval = setInterval(() => {
|
|
625
|
+
if (tokenRequestCount === 1 && config.runtimeAccessTokenRefreshPromise) {
|
|
626
|
+
clearInterval(interval);
|
|
627
|
+
clearTimeout(timer);
|
|
628
|
+
resolve();
|
|
629
|
+
}
|
|
630
|
+
}, 5);
|
|
631
|
+
});
|
|
632
|
+
const firstForceCall = forceClient.callTool('get_memory_stats', { agentId: 'agent-1' });
|
|
633
|
+
const secondForceCall = forceClient.callTool('discover_group_rooms', { agentId: 'agent-1' });
|
|
634
|
+
releaseInitialRefresh?.();
|
|
635
|
+
const [initialResult, firstForceResult, secondForceResult] = await Promise.all([
|
|
636
|
+
initialCall,
|
|
637
|
+
firstForceCall,
|
|
638
|
+
secondForceCall,
|
|
639
|
+
]);
|
|
640
|
+
assert.deepEqual(initialResult, { tool: 'get_profile', token: 'forced-token' });
|
|
641
|
+
assert.deepEqual(firstForceResult, { tool: 'get_memory_stats', token: 'forced-token' });
|
|
642
|
+
assert.deepEqual(secondForceResult, { tool: 'discover_group_rooms', token: 'forced-token' });
|
|
643
|
+
assert.equal(tokenRequestCount, 2);
|
|
644
|
+
assert.equal(forceRequestCount, 1);
|
|
645
|
+
assert.equal(config.runtimeAccessToken, 'forced-token');
|
|
646
|
+
}
|
|
647
|
+
finally {
|
|
648
|
+
releaseInitialRefresh?.();
|
|
649
|
+
}
|
|
650
|
+
});
|
|
651
|
+
it('does not retry 401 more than once after forced token refresh', async () => {
|
|
506
652
|
const config = {
|
|
507
653
|
serverUrl: 'ws://localhost:8080/ws/agent',
|
|
508
654
|
heartbeatInterval: 15000,
|
|
@@ -514,13 +660,12 @@ describe('HttpClient', () => {
|
|
|
514
660
|
const client = new HttpClient(config);
|
|
515
661
|
global.fetch = async (url, options) => {
|
|
516
662
|
fetchCalls.push([url.toString(), options]);
|
|
517
|
-
if (url.toString() === 'http://127.0.0.1:18081/runtime/binding/
|
|
518
|
-
// Bridge returns same token
|
|
663
|
+
if (url.toString() === 'http://127.0.0.1:18081/runtime/binding/request-token') {
|
|
519
664
|
return {
|
|
520
665
|
ok: true,
|
|
521
666
|
status: 200,
|
|
522
667
|
statusText: 'OK',
|
|
523
|
-
json: async () => ({ runtimeAccessToken: '
|
|
668
|
+
json: async () => ({ ok: true, runtimeAccessToken: 'fresh-token' }),
|
|
524
669
|
};
|
|
525
670
|
}
|
|
526
671
|
return {
|
|
@@ -530,11 +675,11 @@ describe('HttpClient', () => {
|
|
|
530
675
|
};
|
|
531
676
|
};
|
|
532
677
|
await assert.rejects(() => client.callTool('my_task', { agentId: 'agent-1' }), /MCP tool my_task call failed: 401 Unauthorized/);
|
|
533
|
-
|
|
534
|
-
assert.equal(fetchCalls.filter(([url]) => url === 'http://127.0.0.1:8080/mcp/call').length,
|
|
535
|
-
assert.equal(fetchCalls.filter(([url]) => url === 'http://127.0.0.1:18081/runtime/binding/
|
|
678
|
+
assert.equal(fetchCalls.length, 3);
|
|
679
|
+
assert.equal(fetchCalls.filter(([url]) => url === 'http://127.0.0.1:8080/mcp/call').length, 2);
|
|
680
|
+
assert.equal(fetchCalls.filter(([url]) => url === 'http://127.0.0.1:18081/runtime/binding/request-token').length, 1);
|
|
536
681
|
});
|
|
537
|
-
it('
|
|
682
|
+
it('does not repeatedly force-refresh the same rejected runtime token across calls', async () => {
|
|
538
683
|
const config = {
|
|
539
684
|
serverUrl: 'ws://localhost:8080/ws/agent',
|
|
540
685
|
heartbeatInterval: 15000,
|
|
@@ -545,24 +690,14 @@ describe('HttpClient', () => {
|
|
|
545
690
|
runtimeBridgeBaseUrl: 'http://127.0.0.1:18081',
|
|
546
691
|
};
|
|
547
692
|
const client = new HttpClient(config);
|
|
548
|
-
let refreshCount = 0;
|
|
549
693
|
global.fetch = async (url, options) => {
|
|
550
694
|
fetchCalls.push([url.toString(), options]);
|
|
551
|
-
if (url.toString() === 'http://127.0.0.1:18081/runtime/binding/
|
|
552
|
-
refreshCount += 1;
|
|
695
|
+
if (url.toString() === 'http://127.0.0.1:18081/runtime/binding/request-token') {
|
|
553
696
|
return {
|
|
554
697
|
ok: true,
|
|
555
698
|
status: 200,
|
|
556
699
|
statusText: 'OK',
|
|
557
|
-
json: async () => ({ runtimeAccessToken:
|
|
558
|
-
};
|
|
559
|
-
}
|
|
560
|
-
const token = options.headers['X-Runtime-Token'];
|
|
561
|
-
if (token && token.startsWith('fresh-token-')) {
|
|
562
|
-
return {
|
|
563
|
-
ok: false,
|
|
564
|
-
status: 401,
|
|
565
|
-
statusText: 'Unauthorized',
|
|
700
|
+
json: async () => ({ ok: true, runtimeAccessToken: 'fresh-but-rejected-token' }),
|
|
566
701
|
};
|
|
567
702
|
}
|
|
568
703
|
return {
|
|
@@ -572,12 +707,13 @@ describe('HttpClient', () => {
|
|
|
572
707
|
};
|
|
573
708
|
};
|
|
574
709
|
await assert.rejects(() => client.callTool('discover_group_rooms', { agentId: 'agent-1' }), /MCP tool discover_group_rooms call failed: 401 Unauthorized/);
|
|
575
|
-
// Second call also tries refresh
|
|
576
710
|
await assert.rejects(() => client.callTool('poll_message', { agentId: 'agent-1' }), /MCP tool poll_message call failed: 401 Unauthorized/);
|
|
577
|
-
assert.equal(config.runtimeAccessToken, 'fresh-token
|
|
578
|
-
assert.equal(
|
|
711
|
+
assert.equal(config.runtimeAccessToken, 'fresh-but-rejected-token');
|
|
712
|
+
assert.equal(config.lastForceRefreshAttemptedForToken, 'fresh-but-rejected-token');
|
|
713
|
+
assert.equal(fetchCalls.filter(([url]) => url === 'http://127.0.0.1:18081/runtime/binding/request-token').length, 1);
|
|
714
|
+
assert.equal(fetchCalls.filter(([url]) => url === 'http://127.0.0.1:8080/mcp/call').length, 3);
|
|
579
715
|
});
|
|
580
|
-
it('
|
|
716
|
+
it('does not force-refresh again while the fresh runtime token is being validated', async () => {
|
|
581
717
|
const config = {
|
|
582
718
|
serverUrl: 'ws://localhost:8080/ws/agent',
|
|
583
719
|
heartbeatInterval: 15000,
|
|
@@ -588,27 +724,32 @@ describe('HttpClient', () => {
|
|
|
588
724
|
runtimeBridgeBaseUrl: 'http://127.0.0.1:18081',
|
|
589
725
|
};
|
|
590
726
|
const client = new HttpClient(config);
|
|
591
|
-
let
|
|
592
|
-
|
|
593
|
-
|
|
727
|
+
let resolveFreshTokenValidationStarted = () => { };
|
|
728
|
+
let releaseFreshTokenValidation = () => { };
|
|
729
|
+
const freshTokenValidationStarted = new Promise((resolve) => {
|
|
730
|
+
resolveFreshTokenValidationStarted = resolve;
|
|
731
|
+
});
|
|
732
|
+
const freshTokenValidationReleased = new Promise((resolve) => {
|
|
733
|
+
releaseFreshTokenValidation = resolve;
|
|
594
734
|
});
|
|
595
|
-
let refreshCallCount = 0;
|
|
596
735
|
let freshTokenMcpCallCount = 0;
|
|
597
736
|
global.fetch = async (url, options) => {
|
|
598
737
|
fetchCalls.push([url.toString(), options]);
|
|
599
|
-
if (url.toString() === 'http://127.0.0.1:18081/runtime/binding/
|
|
600
|
-
refreshCallCount += 1;
|
|
601
|
-
await refreshGate;
|
|
738
|
+
if (url.toString() === 'http://127.0.0.1:18081/runtime/binding/request-token') {
|
|
602
739
|
return {
|
|
603
740
|
ok: true,
|
|
604
741
|
status: 200,
|
|
605
742
|
statusText: 'OK',
|
|
606
|
-
json: async () => ({ runtimeAccessToken: 'fresh-but-rejected-token' }),
|
|
743
|
+
json: async () => ({ ok: true, runtimeAccessToken: 'fresh-but-rejected-token' }),
|
|
607
744
|
};
|
|
608
745
|
}
|
|
609
746
|
const requestHeaders = (options?.headers ?? {});
|
|
610
747
|
if (requestHeaders.Authorization === 'Bearer fresh-but-rejected-token') {
|
|
611
748
|
freshTokenMcpCallCount += 1;
|
|
749
|
+
if (freshTokenMcpCallCount === 1) {
|
|
750
|
+
resolveFreshTokenValidationStarted();
|
|
751
|
+
await freshTokenValidationReleased;
|
|
752
|
+
}
|
|
612
753
|
}
|
|
613
754
|
return {
|
|
614
755
|
ok: false,
|
|
@@ -617,19 +758,14 @@ describe('HttpClient', () => {
|
|
|
617
758
|
};
|
|
618
759
|
};
|
|
619
760
|
const firstCall = client.callTool('discover_group_rooms', { agentId: 'agent-1' });
|
|
620
|
-
|
|
621
|
-
await
|
|
622
|
-
|
|
623
|
-
const secondCall = client.callTool('poll_message', { agentId: 'agent-1' });
|
|
624
|
-
resolveRefresh();
|
|
761
|
+
await freshTokenValidationStarted;
|
|
762
|
+
await assert.rejects(() => client.callTool('poll_message', { agentId: 'agent-1' }), /MCP tool poll_message call failed: 401 Unauthorized/);
|
|
763
|
+
releaseFreshTokenValidation();
|
|
625
764
|
await assert.rejects(() => firstCall, /MCP tool discover_group_rooms call failed: 401 Unauthorized/);
|
|
626
|
-
await assert.rejects(() => secondCall, /MCP tool poll_message call failed: 401 Unauthorized/);
|
|
627
765
|
assert.equal(config.runtimeAccessToken, 'fresh-but-rejected-token');
|
|
628
|
-
|
|
629
|
-
assert.equal(
|
|
630
|
-
assert.equal(fetchCalls.filter(([url]) => url === 'http://127.0.0.1:
|
|
631
|
-
// Both tools retried once with fresh token
|
|
632
|
-
assert.equal(freshTokenMcpCallCount, 2);
|
|
766
|
+
assert.equal(config.lastForceRefreshAttemptedForToken, 'fresh-but-rejected-token');
|
|
767
|
+
assert.equal(fetchCalls.filter(([url]) => url === 'http://127.0.0.1:18081/runtime/binding/request-token').length, 1);
|
|
768
|
+
assert.equal(fetchCalls.filter(([url]) => url === 'http://127.0.0.1:8080/mcp/call').length, 3);
|
|
633
769
|
});
|
|
634
770
|
it('handles 404 not found response', async () => {
|
|
635
771
|
const config = {
|