aws-runtime-bridge 1.8.6 → 1.8.7

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 (40) hide show
  1. package/dist/adapter/AcodeSdkAdapter.d.ts.map +1 -1
  2. package/dist/adapter/AcodeSdkAdapter.js +98 -6
  3. package/dist/adapter/AcodeSdkAdapter.test.js +313 -2
  4. package/dist/adapter/OpencodeSdkAdapter.js +1 -1
  5. package/dist/adapter/types.d.ts.map +1 -1
  6. package/dist/adapter/types.js +7 -3
  7. package/dist/config.d.ts +2 -3
  8. package/dist/config.d.ts.map +1 -1
  9. package/dist/config.js +9 -10
  10. package/dist/config.test.js +1 -22
  11. package/dist/routes/ai-sources.js +5 -1
  12. package/dist/routes/ai-sources.test.js +21 -0
  13. package/dist/routes/instance.test.js +1 -70
  14. package/dist/routes/runtime-binding.d.ts.map +1 -1
  15. package/dist/routes/runtime-binding.js +36 -1
  16. package/dist/routes/terminal.d.ts +2 -0
  17. package/dist/routes/terminal.d.ts.map +1 -1
  18. package/dist/routes/terminal.js +109 -7
  19. package/dist/routes/terminal.test.js +118 -1
  20. package/dist/services/mcp-launch-binding-queue.d.ts +7 -0
  21. package/dist/services/mcp-launch-binding-queue.d.ts.map +1 -1
  22. package/dist/services/mcp-launch-binding-queue.js +25 -0
  23. package/dist/services/session-output.d.ts +7 -3
  24. package/dist/services/session-output.d.ts.map +1 -1
  25. package/package/acode/dist/runtime.d.ts +1 -0
  26. package/package/acode/dist/runtime.d.ts.map +1 -1
  27. package/package/acode/dist/runtime.js +265 -31
  28. package/package/acode/dist/types.d.ts +1 -1
  29. package/package/acode/dist/types.d.ts.map +1 -1
  30. package/package/aws-client-agent-mcp/dist/config.d.ts +4 -0
  31. package/package/aws-client-agent-mcp/dist/config.d.ts.map +1 -1
  32. package/package/aws-client-agent-mcp/dist/config.js +15 -0
  33. package/package/aws-client-agent-mcp/dist/config.js.map +1 -1
  34. package/package/aws-client-agent-mcp/dist/http-client.d.ts +10 -0
  35. package/package/aws-client-agent-mcp/dist/http-client.d.ts.map +1 -1
  36. package/package/aws-client-agent-mcp/dist/http-client.js +79 -1
  37. package/package/aws-client-agent-mcp/dist/http-client.js.map +1 -1
  38. package/package/aws-client-agent-mcp/dist/http-client.test.js +129 -265
  39. package/package/aws-client-agent-mcp/dist/http-client.test.js.map +1 -1
  40. package/package.json +1 -1
@@ -1,19 +1,6 @@
1
1
  import assert from 'node:assert/strict';
2
- import { EventEmitter } from 'node:events';
3
2
  import { afterEach, beforeEach, describe, it, mock } from 'node:test';
4
- import WebSocket from 'ws';
5
3
  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
- }
17
4
  describe('HttpClient', () => {
18
5
  const originalFetch = global.fetch;
19
6
  let fetchCalls = [];
@@ -296,12 +283,12 @@ describe('HttpClient', () => {
296
283
  statusText: 'Unauthorized',
297
284
  };
298
285
  }
299
- if (url.toString() === 'http://127.0.0.1:18081/runtime/binding/request-token') {
286
+ if (url.toString() === 'http://127.0.0.1:18081/runtime/binding/refresh-token') {
300
287
  return {
301
288
  ok: true,
302
289
  status: 200,
303
290
  statusText: 'OK',
304
- json: async () => ({ ok: true, runtimeAccessToken: 'fresh-token' }),
291
+ json: async () => ({ runtimeAccessToken: 'fresh-token' }),
305
292
  };
306
293
  }
307
294
  return {
@@ -315,17 +302,15 @@ describe('HttpClient', () => {
315
302
  assert.deepEqual(result, { data: 'retried' });
316
303
  assert.equal(fetchCalls.length, 3);
317
304
  assert.equal(fetchCalls[0][1].headers['X-Runtime-Token'], 'stale-token');
318
- assert.equal(fetchCalls[1][0], 'http://127.0.0.1:18081/runtime/binding/request-token');
305
+ assert.equal(fetchCalls[1][0], 'http://127.0.0.1:18081/runtime/binding/refresh-token');
319
306
  assert.equal(fetchCalls[1][1].body, JSON.stringify({
320
- userId: 'user-1',
321
- serverUrl: 'ws://localhost:8080/ws/agent',
322
- forceRefresh: true,
323
- rejectedRuntimeAccessToken: 'stale-token',
307
+ agentId: 'agent-1',
308
+ currentToken: 'stale-token',
324
309
  }));
325
310
  assert.equal(fetchCalls[2][1].headers['X-Runtime-Token'], 'fresh-token');
326
311
  assert.equal(config.runtimeAccessToken, 'fresh-token');
327
312
  });
328
- it('shares one forced runtime token refresh across concurrent 401 responses', async () => {
313
+ it('shares one bridge token refresh across concurrent 401 responses', async () => {
329
314
  const config = {
330
315
  serverUrl: 'ws://localhost:8080/ws/agent',
331
316
  heartbeatInterval: 15000,
@@ -339,13 +324,13 @@ describe('HttpClient', () => {
339
324
  const staleTokenCalls = new Set();
340
325
  global.fetch = async (url, options) => {
341
326
  fetchCalls.push([url.toString(), options]);
342
- if (url.toString() === 'http://127.0.0.1:18081/runtime/binding/request-token') {
327
+ if (url.toString() === 'http://127.0.0.1:18081/runtime/binding/refresh-token') {
343
328
  await new Promise((resolve) => setTimeout(resolve, 10));
344
329
  return {
345
330
  ok: true,
346
331
  status: 200,
347
332
  statusText: 'OK',
348
- json: async () => ({ ok: true, runtimeAccessToken: 'fresh-token' }),
333
+ json: async () => ({ runtimeAccessToken: 'fresh-token' }),
349
334
  };
350
335
  }
351
336
  const body = JSON.parse(String(options?.body || '{}'));
@@ -372,10 +357,10 @@ describe('HttpClient', () => {
372
357
  assert.deepEqual(first, { tool: 'get_memory_stats', token: 'fresh-token' });
373
358
  assert.deepEqual(second, { tool: 'discover_group_rooms', token: 'fresh-token' });
374
359
  assert.deepEqual(staleTokenCalls, new Set(['get_memory_stats', 'discover_group_rooms']));
375
- assert.equal(fetchCalls.filter(([url]) => url === 'http://127.0.0.1:18081/runtime/binding/request-token').length, 1);
360
+ assert.equal(fetchCalls.filter(([url]) => url === 'http://127.0.0.1:18081/runtime/binding/refresh-token').length, 1);
376
361
  assert.equal(config.runtimeAccessToken, 'fresh-token');
377
362
  });
378
- it('shares one initial runtime token request across concurrent calls without a token', async () => {
363
+ it('throws when no runtimeAccessToken and no bridge for startup', async () => {
379
364
  const config = {
380
365
  serverUrl: 'ws://localhost:8080/ws/agent',
381
366
  heartbeatInterval: 15000,
@@ -385,270 +370,139 @@ describe('HttpClient', () => {
385
370
  runtimeBridgeBaseUrl: 'http://127.0.0.1:18081',
386
371
  };
387
372
  const client = new HttpClient(config);
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');
373
+ global.fetch = async () => ({
374
+ ok: true,
375
+ status: 200,
376
+ statusText: 'OK',
377
+ json: async () => ({ result: 'success' }),
378
+ });
379
+ await assert.rejects(() => client.callTool('get_memory_stats', { agentId: 'agent-1' }), /runtimeAccessToken is required; MCP nodes must claim a session token from launch binding/);
420
380
  });
421
- it('clears failed runtime token refresh so later calls can retry', async () => {
381
+ it('throws original 401 when bridge refresh fails', async () => {
422
382
  const config = {
423
383
  serverUrl: 'ws://localhost:8080/ws/agent',
424
384
  heartbeatInterval: 15000,
425
385
  reconnectInterval: 200,
426
386
  maxReconnectAttempts: -1,
427
387
  userId: 'user-1',
388
+ runtimeAccessToken: 'stale-token',
428
389
  runtimeBridgeBaseUrl: 'http://127.0.0.1:18081',
429
390
  };
430
391
  const client = new HttpClient(config);
431
- let tokenRequestCount = 0;
432
392
  global.fetch = async (url, options) => {
433
393
  fetchCalls.push([url.toString(), options]);
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
- }
394
+ if (url.toString() === 'http://127.0.0.1:18081/runtime/binding/refresh-token') {
395
+ return {
396
+ ok: false,
397
+ status: 503,
398
+ statusText: 'Service Unavailable',
399
+ };
400
+ }
401
+ return {
402
+ ok: false,
403
+ status: 401,
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') {
444
412
  return {
445
413
  ok: true,
446
414
  status: 200,
447
415
  statusText: 'OK',
448
- json: async () => ({ ok: true, runtimeAccessToken: 'recovered-token' }),
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 }),
449
426
  };
450
427
  }
451
428
  return {
452
- ok: true,
453
- status: 200,
454
- statusText: 'OK',
455
- json: async () => ({ token: options.headers['X-Runtime-Token'] }),
429
+ ok: false,
430
+ status: 401,
431
+ statusText: 'Unauthorized',
456
432
  };
457
433
  };
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/);
459
434
  const result = await client.callTool('get_memory_stats', { agentId: 'agent-1' });
460
435
  assert.deepEqual(result, { token: 'recovered-token' });
461
- assert.equal(tokenRequestCount, 2);
462
436
  assert.equal(config.runtimeAccessToken, 'recovered-token');
463
437
  });
464
- it('shares one runtime token refresh between HTTP tools and websocket recovery', async () => {
438
+ it('returns 401 when bridge refresh has no new token for running agent', async () => {
465
439
  const config = {
466
440
  serverUrl: 'ws://localhost:8080/ws/agent',
467
- heartbeatInterval: 1000,
468
- heartbeatTimeout: 3000,
469
- reconnectInterval: 10,
470
- maxReconnectAttempts: 2,
471
- userId: 'user-1',
441
+ heartbeatInterval: 15000,
442
+ reconnectInterval: 200,
443
+ maxReconnectAttempts: -1,
472
444
  runtimeAccessToken: 'stale-token',
473
445
  runtimeBridgeBaseUrl: 'http://127.0.0.1:18081',
474
446
  };
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
- });
447
+ const client = new HttpClient(config);
483
448
  global.fetch = async (url, options) => {
484
449
  fetchCalls.push([url.toString(), options]);
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') {
450
+ if (url.toString() === 'http://127.0.0.1:18081/runtime/binding/refresh-token') {
451
+ // Bridge says no new token available (409 conflict)
498
452
  return {
499
453
  ok: false,
500
- status: 401,
501
- statusText: 'Unauthorized',
454
+ status: 409,
455
+ statusText: 'Conflict',
456
+ json: async () => ({ error: 'token_unchanged' }),
502
457
  };
503
458
  }
504
459
  return {
505
- ok: true,
506
- status: 200,
507
- statusText: 'OK',
508
- json: async () => ({ tool: body.tool, token }),
460
+ ok: false,
461
+ status: 401,
462
+ statusText: 'Unauthorized',
509
463
  };
510
464
  };
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 () => {
465
+ await assert.rejects(() => client.callTool('get_memory_stats', { agentId: 'agent-1' }), /MCP tool get_memory_stats call failed: 401 Unauthorized/);
466
+ // 1 initial MCP call + 1 refresh attempt + 0 retries (refresh failed)
467
+ assert.equal(fetchCalls.filter(([url]) => url === 'http://127.0.0.1:8080/mcp/call').length, 1);
468
+ assert.equal(fetchCalls.filter(([url]) => url === 'http://127.0.0.1:18081/runtime/binding/refresh-token').length, 1);
469
+ assert.equal(config.runtimeAccessToken, 'stale-token');
470
+ });
471
+ it('returns 404 from bridge refresh when agent has no cached token', async () => {
565
472
  const config = {
566
473
  serverUrl: 'ws://localhost:8080/ws/agent',
567
474
  heartbeatInterval: 15000,
568
475
  reconnectInterval: 200,
569
476
  maxReconnectAttempts: -1,
570
477
  userId: 'user-1',
478
+ runtimeAccessToken: 'stale-token',
571
479
  runtimeBridgeBaseUrl: 'http://127.0.0.1:18081',
572
480
  };
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
- });
481
+ const client = new HttpClient(config);
581
482
  global.fetch = async (url, options) => {
582
483
  fetchCalls.push([url.toString(), options]);
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') {
484
+ if (url.toString() === 'http://127.0.0.1:18081/runtime/binding/refresh-token') {
485
+ // Bridge has no cached token for this agent
607
486
  return {
608
487
  ok: false,
609
- status: 401,
610
- statusText: 'Unauthorized',
488
+ status: 404,
489
+ statusText: 'Not Found',
490
+ json: async () => ({ error: 'no cached token for agent' }),
611
491
  };
612
492
  }
613
493
  return {
614
- ok: true,
615
- status: 200,
616
- statusText: 'OK',
617
- json: async () => ({ tool: body.tool, token }),
494
+ ok: false,
495
+ status: 401,
496
+ statusText: 'Unauthorized',
618
497
  };
619
498
  };
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 () => {
499
+ await assert.rejects(() => client.callTool('get_profile', { agentId: 'agent-unknown' }), /MCP tool get_profile call failed: 401 Unauthorized/);
500
+ // 1 initial MCP call + 1 refresh attempt (404) + 0 retries
501
+ assert.equal(fetchCalls.filter(([url]) => url === 'http://127.0.0.1:8080/mcp/call').length, 1);
502
+ assert.equal(fetchCalls.filter(([url]) => url === 'http://127.0.0.1:18081/runtime/binding/refresh-token').length, 1);
503
+ assert.equal(config.runtimeAccessToken, 'stale-token');
504
+ });
505
+ it('does not retry 401 after bridge refresh returns same token', async () => {
652
506
  const config = {
653
507
  serverUrl: 'ws://localhost:8080/ws/agent',
654
508
  heartbeatInterval: 15000,
@@ -660,12 +514,13 @@ describe('HttpClient', () => {
660
514
  const client = new HttpClient(config);
661
515
  global.fetch = async (url, options) => {
662
516
  fetchCalls.push([url.toString(), options]);
663
- if (url.toString() === 'http://127.0.0.1:18081/runtime/binding/request-token') {
517
+ if (url.toString() === 'http://127.0.0.1:18081/runtime/binding/refresh-token') {
518
+ // Bridge returns same token
664
519
  return {
665
520
  ok: true,
666
521
  status: 200,
667
522
  statusText: 'OK',
668
- json: async () => ({ ok: true, runtimeAccessToken: 'fresh-token' }),
523
+ json: async () => ({ runtimeAccessToken: 'stale-token' }),
669
524
  };
670
525
  }
671
526
  return {
@@ -675,11 +530,11 @@ describe('HttpClient', () => {
675
530
  };
676
531
  };
677
532
  await assert.rejects(() => client.callTool('my_task', { agentId: 'agent-1' }), /MCP tool my_task call failed: 401 Unauthorized/);
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);
533
+ // 1 initial MCP call + 1 refresh + 0 retries (same token, not updated)
534
+ assert.equal(fetchCalls.filter(([url]) => url === 'http://127.0.0.1:8080/mcp/call').length, 1);
535
+ assert.equal(fetchCalls.filter(([url]) => url === 'http://127.0.0.1:18081/runtime/binding/refresh-token').length, 1);
681
536
  });
682
- it('does not repeatedly force-refresh the same rejected runtime token across calls', async () => {
537
+ it('retries refresh on second call if first call 401 with different fresh token', async () => {
683
538
  const config = {
684
539
  serverUrl: 'ws://localhost:8080/ws/agent',
685
540
  heartbeatInterval: 15000,
@@ -690,14 +545,24 @@ describe('HttpClient', () => {
690
545
  runtimeBridgeBaseUrl: 'http://127.0.0.1:18081',
691
546
  };
692
547
  const client = new HttpClient(config);
548
+ let refreshCount = 0;
693
549
  global.fetch = async (url, options) => {
694
550
  fetchCalls.push([url.toString(), options]);
695
- if (url.toString() === 'http://127.0.0.1:18081/runtime/binding/request-token') {
551
+ if (url.toString() === 'http://127.0.0.1:18081/runtime/binding/refresh-token') {
552
+ refreshCount += 1;
696
553
  return {
697
554
  ok: true,
698
555
  status: 200,
699
556
  statusText: 'OK',
700
- json: async () => ({ ok: true, runtimeAccessToken: 'fresh-but-rejected-token' }),
557
+ json: async () => ({ runtimeAccessToken: `fresh-token-${refreshCount}` }),
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',
701
566
  };
702
567
  }
703
568
  return {
@@ -707,13 +572,12 @@ describe('HttpClient', () => {
707
572
  };
708
573
  };
709
574
  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
710
576
  await assert.rejects(() => client.callTool('poll_message', { agentId: 'agent-1' }), /MCP tool poll_message call failed: 401 Unauthorized/);
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);
577
+ assert.equal(config.runtimeAccessToken, 'fresh-token-2');
578
+ assert.equal(fetchCalls.filter(([url]) => url === 'http://127.0.0.1:18081/runtime/binding/refresh-token').length, 2);
715
579
  });
716
- it('does not force-refresh again while the fresh runtime token is being validated', async () => {
580
+ it('shares one refresh promise among concurrent 401 calls', async () => {
717
581
  const config = {
718
582
  serverUrl: 'ws://localhost:8080/ws/agent',
719
583
  heartbeatInterval: 15000,
@@ -724,32 +588,27 @@ describe('HttpClient', () => {
724
588
  runtimeBridgeBaseUrl: 'http://127.0.0.1:18081',
725
589
  };
726
590
  const client = new HttpClient(config);
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;
591
+ let resolveRefresh = () => { };
592
+ const refreshGate = new Promise((resolve) => {
593
+ resolveRefresh = resolve;
734
594
  });
595
+ let refreshCallCount = 0;
735
596
  let freshTokenMcpCallCount = 0;
736
597
  global.fetch = async (url, options) => {
737
598
  fetchCalls.push([url.toString(), options]);
738
- if (url.toString() === 'http://127.0.0.1:18081/runtime/binding/request-token') {
599
+ if (url.toString() === 'http://127.0.0.1:18081/runtime/binding/refresh-token') {
600
+ refreshCallCount += 1;
601
+ await refreshGate;
739
602
  return {
740
603
  ok: true,
741
604
  status: 200,
742
605
  statusText: 'OK',
743
- json: async () => ({ ok: true, runtimeAccessToken: 'fresh-but-rejected-token' }),
606
+ json: async () => ({ runtimeAccessToken: 'fresh-but-rejected-token' }),
744
607
  };
745
608
  }
746
609
  const requestHeaders = (options?.headers ?? {});
747
610
  if (requestHeaders.Authorization === 'Bearer fresh-but-rejected-token') {
748
611
  freshTokenMcpCallCount += 1;
749
- if (freshTokenMcpCallCount === 1) {
750
- resolveFreshTokenValidationStarted();
751
- await freshTokenValidationReleased;
752
- }
753
612
  }
754
613
  return {
755
614
  ok: false,
@@ -758,14 +617,19 @@ describe('HttpClient', () => {
758
617
  };
759
618
  };
760
619
  const firstCall = client.callTool('discover_group_rooms', { agentId: 'agent-1' });
761
- await freshTokenValidationStarted;
762
- await assert.rejects(() => client.callTool('poll_message', { agentId: 'agent-1' }), /MCP tool poll_message call failed: 401 Unauthorized/);
763
- releaseFreshTokenValidation();
620
+ // Wait for the refresh to start
621
+ await new Promise((resolve) => setTimeout(resolve, 20));
622
+ // Second call should share the same refresh promise
623
+ const secondCall = client.callTool('poll_message', { agentId: 'agent-1' });
624
+ resolveRefresh();
764
625
  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/);
765
627
  assert.equal(config.runtimeAccessToken, 'fresh-but-rejected-token');
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);
628
+ // Only one refresh call to bridge
629
+ assert.equal(refreshCallCount, 1);
630
+ assert.equal(fetchCalls.filter(([url]) => url === 'http://127.0.0.1:18081/runtime/binding/refresh-token').length, 1);
631
+ // Both tools retried once with fresh token
632
+ assert.equal(freshTokenMcpCallCount, 2);
769
633
  });
770
634
  it('handles 404 not found response', async () => {
771
635
  const config = {