agent-gear 1.3.0 → 2.0.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.
@@ -0,0 +1,91 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest';
2
+ import { McpRelay, type McpRelayResult } from './relay.js';
3
+
4
+ function createRelay() {
5
+ const emitted: Array<{ requestId: string; sessionId: string; serverName: string; token: string; body: unknown }> = [];
6
+ const relay = new McpRelay((payload) => emitted.push(payload));
7
+ return { relay, emitted };
8
+ }
9
+
10
+ afterEach(() => {
11
+ vi.useRealTimers();
12
+ });
13
+
14
+ describe('McpRelay', () => {
15
+ it('T-C6 resolves a pending request by requestId on mcp:response', async () => {
16
+ const { relay, emitted } = createRelay();
17
+ relay.registerSession('sess-1', { token: 'tok-1', serverNames: ['github-mcp'] });
18
+
19
+ const promise = relay.forward('sess-1', 'github-mcp', { method: 'tools/list' });
20
+ expect(emitted).toHaveLength(1);
21
+ expect(emitted[0].requestId).toMatch(/^mcpreq_/);
22
+ expect(emitted[0]).toMatchObject({
23
+ sessionId: 'sess-1',
24
+ serverName: 'github-mcp',
25
+ token: 'tok-1',
26
+ body: { method: 'tools/list' },
27
+ });
28
+
29
+ relay.handleMcpResponse(emitted[0].requestId, 200, { result: [] });
30
+ await expect(promise).resolves.toEqual({ status: 200, body: { result: [] } });
31
+ });
32
+
33
+ it('T-C7 times out after 60s with 504 and cleans up pending', async () => {
34
+ vi.useFakeTimers();
35
+ const { relay, emitted } = createRelay();
36
+ relay.registerSession('sess-1', { token: 'tok-1', serverNames: ['github-mcp'] });
37
+
38
+ const first = relay.forward('sess-1', 'github-mcp', { method: 'tools/list' });
39
+ await vi.advanceTimersByTimeAsync(60_000);
40
+ await expect(first).resolves.toEqual(expect.objectContaining({ status: 504 }));
41
+
42
+ const second = relay.forward('sess-1', 'github-mcp', { method: 'tools/call' });
43
+ emitted[1] && relay.handleMcpResponse(emitted[1].requestId, 200, { ok: true });
44
+ await expect(second).resolves.toEqual({ status: 200, body: { ok: true } });
45
+ });
46
+
47
+ it('T-C8 fails all pending immediately with 503 on disconnect', async () => {
48
+ const { relay, emitted } = createRelay();
49
+ relay.registerSession('sess-1', { token: 'tok-1', serverNames: ['a-mcp'] });
50
+ const promises = [
51
+ relay.forward('sess-1', 'a-mcp', { id: 1 }),
52
+ relay.forward('sess-1', 'a-mcp', { id: 2 }),
53
+ ];
54
+ expect(emitted).toHaveLength(2);
55
+
56
+ relay.handleDisconnect();
57
+ const results = await Promise.all(promises);
58
+ expect(results).toEqual([
59
+ expect.objectContaining({ status: 503 }),
60
+ expect.objectContaining({ status: 503 }),
61
+ ]);
62
+ });
63
+
64
+ it('T-C9 unregistering a session fails its pending with 410, other sessions unaffected', async () => {
65
+ const { relay, emitted } = createRelay();
66
+ relay.registerSession('sess-1', { token: 'tok-1', serverNames: ['a-mcp'] });
67
+ relay.registerSession('sess-2', { token: 'tok-2', serverNames: ['b-mcp'] });
68
+
69
+ const sess1 = relay.forward('sess-1', 'a-mcp', { id: 1 });
70
+ const sess2 = relay.forward('sess-2', 'b-mcp', { id: 2 });
71
+
72
+ relay.unregisterSession('sess-1');
73
+ await expect(sess1).resolves.toEqual(expect.objectContaining({ status: 410 }));
74
+ expect(relay.hasSession('sess-1')).toBe(false);
75
+ expect(relay.hasSession('sess-2')).toBe(true);
76
+
77
+ emitted[1] && relay.handleMcpResponse(emitted[1].requestId, 200, { ok: true });
78
+ await expect(sess2).resolves.toEqual({ status: 200, body: { ok: true } });
79
+ });
80
+
81
+ it('T-C10 ignores mcp:response for an unknown requestId', async () => {
82
+ const { relay } = createRelay();
83
+ expect(() => relay.handleMcpResponse('mcpreq_unknown', 200, {})).not.toThrow();
84
+ });
85
+
86
+ it('forward returns 404 for an unregistered session', async () => {
87
+ const { relay } = createRelay();
88
+ const result: McpRelayResult = await relay.forward('missing', 'a-mcp', {});
89
+ expect(result.status).toBe(404);
90
+ });
91
+ });
@@ -0,0 +1,94 @@
1
+ export interface SessionEndpoint {
2
+ token: string;
3
+ serverNames: string[];
4
+ }
5
+
6
+ export interface McpRelayResult {
7
+ status: number;
8
+ body: unknown;
9
+ }
10
+
11
+ interface McpRequestPayload {
12
+ requestId: string;
13
+ sessionId: string;
14
+ serverName: string;
15
+ token: string;
16
+ body: unknown;
17
+ }
18
+
19
+ interface PendingRequest {
20
+ sessionId: string;
21
+ resolve: (result: McpRelayResult) => void;
22
+ timer: NodeJS.Timeout;
23
+ }
24
+
25
+ const REQUEST_TIMEOUT_MS = 60_000;
26
+
27
+ export class McpRelay {
28
+ private pendingRequests = new Map<string, PendingRequest>();
29
+ private sessionEndpoints = new Map<string, SessionEndpoint>();
30
+ private emitMcpRequest: (payload: McpRequestPayload) => void;
31
+
32
+ constructor(emitMcpRequest: (payload: McpRequestPayload) => void) {
33
+ this.emitMcpRequest = emitMcpRequest;
34
+ }
35
+
36
+ registerSession(sessionId: string, endpoint: SessionEndpoint): void {
37
+ this.sessionEndpoints.set(sessionId, endpoint);
38
+ }
39
+
40
+ hasSession(sessionId: string): boolean {
41
+ return this.sessionEndpoints.has(sessionId);
42
+ }
43
+
44
+ hasServer(sessionId: string, serverName: string): boolean {
45
+ const endpoint = this.sessionEndpoints.get(sessionId);
46
+ return endpoint ? endpoint.serverNames.includes(serverName) : false;
47
+ }
48
+
49
+ unregisterSession(sessionId: string): void {
50
+ if (!this.sessionEndpoints.delete(sessionId)) return;
51
+ for (const [requestId, pending] of this.pendingRequests) {
52
+ if (pending.sessionId !== sessionId) continue;
53
+ clearTimeout(pending.timer);
54
+ this.pendingRequests.delete(requestId);
55
+ pending.resolve({ status: 410, body: { error: 'SESSION_ENDED', message: 'MCP session has ended' } });
56
+ }
57
+ }
58
+
59
+ async forward(sessionId: string, serverName: string, body: unknown): Promise<McpRelayResult> {
60
+ const endpoint = this.sessionEndpoints.get(sessionId);
61
+ if (!endpoint) return { status: 404, body: { error: 'SESSION_NOT_FOUND' } };
62
+ const requestId = `mcpreq_${Date.now()}_${Math.random().toString(36).slice(2)}`;
63
+ console.log(`[MCP relay] forward requestId=${requestId} session=${sessionId} server=${serverName} method=${(body as { method?: string })?.method ?? '?'}`);
64
+ return new Promise<McpRelayResult>((resolve) => {
65
+ const timer = setTimeout(() => {
66
+ this.pendingRequests.delete(requestId);
67
+ console.log(`[MCP relay] requestId=${requestId} -> 504 (timeout after ${REQUEST_TIMEOUT_MS}ms, no mcp:response received)`);
68
+ resolve({ status: 504, body: { error: 'TIMEOUT', message: 'MCP request timed out' } });
69
+ }, REQUEST_TIMEOUT_MS);
70
+ this.pendingRequests.set(requestId, { sessionId, resolve, timer });
71
+ this.emitMcpRequest({ requestId, sessionId, serverName, token: endpoint.token, body });
72
+ });
73
+ }
74
+
75
+ handleMcpResponse(requestId: string, status: number, body: unknown): void {
76
+ const pending = this.pendingRequests.get(requestId);
77
+ if (!pending) {
78
+ console.log(`[MCP relay] response requestId=${requestId} status=${status} IGNORED (no pending request)`);
79
+ return;
80
+ }
81
+ console.log(`[MCP relay] response requestId=${requestId} status=${status}`);
82
+ clearTimeout(pending.timer);
83
+ this.pendingRequests.delete(requestId);
84
+ pending.resolve({ status, body });
85
+ }
86
+
87
+ handleDisconnect(): void {
88
+ for (const [requestId, pending] of this.pendingRequests) {
89
+ clearTimeout(pending.timer);
90
+ this.pendingRequests.delete(requestId);
91
+ pending.resolve({ status: 503, body: { error: 'CONTROLLER_DISCONNECTED', message: 'Controller disconnected' } });
92
+ }
93
+ }
94
+ }
@@ -16,8 +16,10 @@ import { worktreeHandler } from './worktree-handler.js';
16
16
  import { fsHandler } from './fs-handler.js';
17
17
  import { gatewayManager } from './gateway.js';
18
18
  import { processRegistry, isProcessAlive } from './acp/process-registry.js';
19
+ import { McpRelay } from './mcp/relay.js';
20
+ import { LocalMcpServer } from './mcp/local-server.js';
19
21
 
20
- const CONTROLLER_VERSION = '1.1.1';
22
+ const CONTROLLER_VERSION = '2.0.0';
21
23
  const acpBridges = new Map<string, AcpSessionBridge>();
22
24
  const permissionRequestBridge = new Map<string, AcpSessionBridge>();
23
25
  const elicitationRequestBridge = new Map<string, AcpSessionBridge>();
@@ -60,7 +62,7 @@ interface AcpStartPayload {
60
62
  serverType: string;
61
63
  cwd: string;
62
64
  mcpServers?: Array<{ name: string; command: string; args?: string[]; env?: Array<{ name: string; value: string }> }>;
63
- mcpEndpoint?: { url: string; token: string; serverNames: string[] };
65
+ mcpEndpoint?: { token: string; serverNames: string[] };
64
66
  additionalDirectories?: string[];
65
67
  modeId?: string;
66
68
  modelId?: string;
@@ -73,13 +75,13 @@ interface AcpMessagePayload {
73
75
 
74
76
  // Summarize the MCP configuration of an acp:start/acp:resume payload for logs,
75
77
  // without leaking the endpoint token.
76
- function describeMcpConfig(data: { mcpServers?: unknown[]; mcpEndpoint?: { url: string; serverNames?: string[] } }): string {
78
+ function describeMcpConfig(data: { mcpServers?: unknown[]; mcpEndpoint?: { serverNames?: string[] } }): string {
77
79
  const parts: string[] = [];
78
80
  if (data.mcpServers && data.mcpServers.length > 0) {
79
81
  parts.push(`mcpServers=${data.mcpServers.length}`);
80
82
  }
81
83
  if (data.mcpEndpoint) {
82
- parts.push(`mcpEndpoint(url=${data.mcpEndpoint.url}, serverNames=${JSON.stringify(data.mcpEndpoint.serverNames ?? null)})`);
84
+ parts.push(`mcpEndpoint(serverNames=${JSON.stringify(data.mcpEndpoint.serverNames ?? null)})`);
83
85
  }
84
86
  return parts.length > 0 ? `mcp=[${parts.join(' ')}]` : 'mcp=[none]';
85
87
  }
@@ -132,7 +134,7 @@ interface AcpResumePayload {
132
134
  externalSessionId: string;
133
135
  serverType: string;
134
136
  cwd: string;
135
- mcpEndpoint?: { url: string; token: string; serverNames: string[] };
137
+ mcpEndpoint?: { token: string; serverNames: string[] };
136
138
  additionalDirectories?: string[];
137
139
  modeId?: string;
138
140
  modelId?: string;
@@ -215,6 +217,15 @@ export function connectToServer(
215
217
 
216
218
  const heartbeat = new Heartbeat();
217
219
  const controllerName = options?.serverName || hostname();
220
+ const mcpRelay = new McpRelay((payload) => socket.emit('mcp:request', payload));
221
+ const localServer = new LocalMcpServer(mcpRelay);
222
+ // 回环 HTTP 服务在守护进程内只启动一次、进程退出前不停止(提案 Phase 2 要求全守护进程共享一个端口)。
223
+ // 断线快速失败由 mcpRelay.handleDisconnect()(503)承担,无需随 socket 连接启停 HTTP 服务。
224
+ // ready promise 保证 acp:start/acp:resume 读取 localServer.port 前服务已 listening(启动只 await 一次)。
225
+ const mcpServerReady = localServer.start();
226
+ mcpServerReady.catch((err: Error) => {
227
+ console.error(`[MCP] failed to start local MCP endpoint: ${err.message}`);
228
+ });
218
229
 
219
230
  // Seed the fs:remove-dir whitelist with cwds of ACP processes that survived
220
231
  // a controller restart (their worktrees remain controller-managed).
@@ -246,6 +257,13 @@ export function connectToServer(
246
257
 
247
258
  socket.on('error', (data: ErrorPayload) => {
248
259
  console.error(`Server error: ${data.code} - ${data.message}`);
260
+ if (data.code === 'VERSION_MISMATCH') {
261
+ console.error('The agent-controller version is not supported by the server.');
262
+ console.error('Please upgrade agent-controller to 2.x (npm i -g agent-gear@2 or the matching release).');
263
+ socket.disconnect();
264
+ process.exit(1);
265
+ return;
266
+ }
249
267
  if (data.code === 'KEY_ALREADY_ONLINE') {
250
268
  console.log('Clearing resume token and retrying connection...');
251
269
  clearResumeTokenFromConfig();
@@ -319,11 +337,23 @@ export function connectToServer(
319
337
  await existingBridge.stop().catch(() => {});
320
338
  }
321
339
  const spawner = createSpawner(data.serverType, data.sessionId, data.cwd);
340
+ if (data.mcpEndpoint) {
341
+ // 确保回环服务已 listening 后再读取 port 并注入(启动失败时 fail loudly,不静默注入端口 0)
342
+ await mcpServerReady;
343
+ if (localServer.port <= 0) {
344
+ throw new Error(`MCP endpoint unavailable: local loopback server not listening (port=${localServer.port})`);
345
+ }
346
+ mcpRelay.registerSession(data.sessionId, {
347
+ token: data.mcpEndpoint.token,
348
+ serverNames: data.mcpEndpoint.serverNames,
349
+ });
350
+ }
322
351
  const bridge = new AcpSessionBridge(socket, {
323
352
  sessionId: data.sessionId,
324
353
  cwd: data.cwd,
325
354
  mcpServers: data.mcpServers,
326
355
  mcpEndpoint: data.mcpEndpoint,
356
+ mcpPort: localServer.port,
327
357
  additionalDirectories: data.additionalDirectories,
328
358
  initialModeId: data.modeId,
329
359
  initialModelId: data.modelId,
@@ -338,6 +368,7 @@ export function connectToServer(
338
368
  bridge.on('acp:stopped', (payload: { sessionId: string }) => {
339
369
  if (payload.sessionId === data.sessionId) {
340
370
  processRegistry.deregister(data.sessionId);
371
+ mcpRelay.unregisterSession(data.sessionId);
341
372
  }
342
373
  });
343
374
 
@@ -406,6 +437,7 @@ export function connectToServer(
406
437
  bridge.off('acp:stopped', onStopped);
407
438
  acpBridges.delete(data.sessionId);
408
439
  processRegistry.deregister(data.sessionId);
440
+ mcpRelay.unregisterSession(data.sessionId);
409
441
  console.log(`[ACP ctrl] emitting acp:start:result: requestId=${data.requestId} sessionId=${data.sessionId} success=false reason=${payload.reason} error=${payload.error ?? 'n/a'}`);
410
442
  socket.emit('acp:start:result', {
411
443
  requestId: data.requestId,
@@ -422,6 +454,7 @@ export function connectToServer(
422
454
  await bridge.start();
423
455
  } catch (error) {
424
456
  console.error(`[ACP ctrl] bridge.start() failed: ${(error as Error).message}`);
457
+ mcpRelay.unregisterSession(data.sessionId);
425
458
  socket.emit('acp:start:result', {
426
459
  requestId: data.requestId,
427
460
  sessionId: data.sessionId,
@@ -443,10 +476,22 @@ export function connectToServer(
443
476
  await existingBridge.stop().catch(() => {});
444
477
  }
445
478
  const spawner = createSpawner(data.serverType, data.sessionId, data.cwd);
479
+ if (data.mcpEndpoint) {
480
+ // 确保回环服务已 listening 后再读取 port 并注入(启动失败时 fail loudly,不静默注入端口 0)
481
+ await mcpServerReady;
482
+ if (localServer.port <= 0) {
483
+ throw new Error(`MCP endpoint unavailable: local loopback server not listening (port=${localServer.port})`);
484
+ }
485
+ mcpRelay.registerSession(data.sessionId, {
486
+ token: data.mcpEndpoint.token,
487
+ serverNames: data.mcpEndpoint.serverNames,
488
+ });
489
+ }
446
490
  const bridge = new AcpSessionBridge(socket, {
447
491
  sessionId: data.sessionId,
448
492
  cwd: data.cwd,
449
493
  mcpEndpoint: data.mcpEndpoint,
494
+ mcpPort: localServer.port,
450
495
  additionalDirectories: data.additionalDirectories,
451
496
  // 初始 mode/model:resume 完成后、acp:initialized 之前应用(时序同 acp:start)
452
497
  initialModeId: data.modeId,
@@ -459,6 +504,7 @@ export function connectToServer(
459
504
  bridge.on('acp:stopped', (payload: { sessionId: string }) => {
460
505
  if (payload.sessionId === data.sessionId) {
461
506
  processRegistry.deregister(data.sessionId);
507
+ mcpRelay.unregisterSession(data.sessionId);
462
508
  }
463
509
  });
464
510
 
@@ -497,6 +543,7 @@ export function connectToServer(
497
543
  bridge.off('acp:stopped', onStopped);
498
544
  acpBridges.delete(data.sessionId);
499
545
  processRegistry.deregister(data.sessionId);
546
+ mcpRelay.unregisterSession(data.sessionId);
500
547
  console.log(`[ACP ctrl] emitting acp:resume:result: requestId=${data.requestId} sessionId=${data.sessionId} success=false reason=${payload.reason} error=${payload.error ?? 'n/a'}`);
501
548
  socket.emit('acp:resume:result', {
502
549
  requestId: data.requestId,
@@ -512,6 +559,7 @@ export function connectToServer(
512
559
  await bridge.resume(data.externalSessionId);
513
560
  } catch (error) {
514
561
  console.error(`[ACP ctrl] bridge.resume() failed: ${(error as Error).message}`);
562
+ mcpRelay.unregisterSession(data.sessionId);
515
563
  socket.emit('acp:resume:result', {
516
564
  requestId: data.requestId,
517
565
  sessionId: data.sessionId,
@@ -693,6 +741,7 @@ export function connectToServer(
693
741
  processRegistry.deregister(data.sessionId);
694
742
  }
695
743
  }
744
+ mcpRelay.unregisterSession(data.sessionId);
696
745
  if (data.ackRequired) {
697
746
  socket.emit('acp:stopped', {
698
747
  sessionId: data.sessionId,
@@ -967,6 +1016,7 @@ export function connectToServer(
967
1016
  }
968
1017
  } catch { /* process may already be dead */ }
969
1018
  processRegistry.deregister(entry.sessionId);
1019
+ mcpRelay.unregisterSession(entry.sessionId);
970
1020
  // Also clean up bridge if present
971
1021
  const bridge = acpBridges.get(entry.sessionId);
972
1022
  if (bridge) {
@@ -1014,12 +1064,17 @@ export function connectToServer(
1014
1064
  }
1015
1065
  });
1016
1066
 
1067
+ socket.on('mcp:response', (data: { requestId: string; status: number; body: unknown }) => {
1068
+ mcpRelay.handleMcpResponse(data.requestId, data.status, data.body);
1069
+ });
1070
+
1017
1071
  socket.on('disconnect', (reason) => {
1018
1072
  console.log(`Disconnected: ${reason}`);
1019
1073
  heartbeat.stop();
1020
1074
  gatewayManager.closeAll();
1021
1075
  permissionRequestBridge.clear();
1022
1076
  elicitationRequestBridge.clear();
1077
+ mcpRelay.handleDisconnect();
1023
1078
  });
1024
1079
 
1025
1080
  socket.on('connect_error', (err) => {
@@ -49,10 +49,10 @@ describe('AcpSessionBridge - MCP wire format (ACP spec)', () => {
49
49
  sessionId: 'sess-wire',
50
50
  cwd: workdir,
51
51
  mcpEndpoint: {
52
- url: 'http://gateway/mcp/sess-wire',
53
52
  token: 'wire-token',
54
53
  serverNames: ['fluentui-mcp', 'fs-mcp'],
55
54
  },
55
+ mcpPort: 41234,
56
56
  }, spawner);
57
57
  return socket;
58
58
  }
@@ -76,13 +76,13 @@ describe('AcpSessionBridge - MCP wire format (ACP spec)', () => {
76
76
  {
77
77
  type: 'http',
78
78
  name: 'fluentui-mcp',
79
- url: 'http://gateway/mcp/sess-wire/fluentui-mcp',
79
+ url: 'http://127.0.0.1:41234/mcp/sess-wire/fluentui-mcp',
80
80
  headers: [{ name: 'Authorization', value: 'Bearer wire-token' }],
81
81
  },
82
82
  {
83
83
  type: 'http',
84
84
  name: 'fs-mcp',
85
- url: 'http://gateway/mcp/sess-wire/fs-mcp',
85
+ url: 'http://127.0.0.1:41234/mcp/sess-wire/fs-mcp',
86
86
  headers: [{ name: 'Authorization', value: 'Bearer wire-token' }],
87
87
  },
88
88
  ]);
@@ -92,7 +92,7 @@ describe('AcpSessionBridge - MCP wire format (ACP spec)', () => {
92
92
  const logLines = logSpy.mock.calls.map((c) => String(c[0]));
93
93
  const mcpLog = logLines.find((l) => l.includes('passing MCP servers via session/new'));
94
94
  expect(mcpLog).toBeDefined();
95
- expect(mcpLog).toContain('fluentui-mcp(type=http, url=http://gateway/mcp/sess-wire/fluentui-mcp, headers=Authorization)');
95
+ expect(mcpLog).toContain('fluentui-mcp(type=http, url=http://127.0.0.1:41234/mcp/sess-wire/fluentui-mcp, headers=Authorization)');
96
96
  expect(mcpLog).not.toContain('wire-token');
97
97
  }, 15000);
98
98
 
@@ -9,6 +9,11 @@ const { mockStart, mockStop, MockHeartbeat } = vi.hoisted(() => {
9
9
  return { mockStart: start, mockStop: stop, MockHeartbeat: HB };
10
10
  });
11
11
 
12
+ const { relayInstances, localServerInstances } = vi.hoisted(() => ({
13
+ relayInstances: [] as Array<Record<string, ReturnType<typeof vi.fn>>>,
14
+ localServerInstances: [] as Array<{ start: ReturnType<typeof vi.fn>; stop: ReturnType<typeof vi.fn>; port: number }>,
15
+ }));
16
+
12
17
  const { MockAcpSyncBridge, mockBridge } = vi.hoisted(() => {
13
18
  const bridge = {
14
19
  connect: vi.fn().mockResolvedValue(undefined),
@@ -106,6 +111,32 @@ vi.mock('../src/heartbeat.js', () => ({
106
111
  Heartbeat: MockHeartbeat,
107
112
  }));
108
113
 
114
+ vi.mock('../src/mcp/relay.js', () => ({
115
+ McpRelay: class {
116
+ registerSession = vi.fn();
117
+ unregisterSession = vi.fn();
118
+ handleDisconnect = vi.fn();
119
+ handleMcpResponse = vi.fn();
120
+ hasSession = vi.fn(() => true);
121
+ hasServer = vi.fn(() => true);
122
+ forward = vi.fn();
123
+ constructor() {
124
+ relayInstances.push(this as never);
125
+ }
126
+ },
127
+ }));
128
+
129
+ vi.mock('../src/mcp/local-server.js', () => ({
130
+ LocalMcpServer: class {
131
+ port = 0;
132
+ start = vi.fn(async () => {});
133
+ stop = vi.fn(async () => {});
134
+ constructor() {
135
+ localServerInstances.push(this as never);
136
+ }
137
+ },
138
+ }));
139
+
109
140
  vi.mock('../src/config.js', () => ({
110
141
  clearConfigKey: vi.fn(),
111
142
  saveKeyToConfig: vi.fn(),
@@ -159,6 +190,8 @@ beforeEach(() => {
159
190
  vi.clearAllMocks();
160
191
  mockStart.mockClear();
161
192
  mockStop.mockClear();
193
+ relayInstances.length = 0;
194
+ localServerInstances.length = 0;
162
195
  vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);
163
196
  vi.spyOn(console, 'log').mockImplementation(() => {});
164
197
  vi.spyOn(console, 'error').mockImplementation(() => {});
@@ -195,7 +228,7 @@ describe('SocketIO Client', () => {
195
228
  expect(mockSocket.emit).toHaveBeenCalledWith('register', {
196
229
  key: 'zag_k_1_abc123',
197
230
  hostname: 'my-machine',
198
- version: '1.1.1',
231
+ version: '2.0.0',
199
232
  resumeToken: undefined,
200
233
  homeDir: '/home/user',
201
234
  platform: 'linux',
@@ -213,7 +246,7 @@ describe('SocketIO Client', () => {
213
246
  expect(mockSocket.emit).toHaveBeenCalledWith('register', {
214
247
  key: 'zag_k_1_abc123',
215
248
  hostname: 'test-machine',
216
- version: '1.1.1',
249
+ version: '2.0.0',
217
250
  resumeToken: undefined,
218
251
  homeDir: '/home/user',
219
252
  platform: 'linux',
@@ -819,4 +852,161 @@ describe('SocketIO Client', () => {
819
852
  expect(killSpy).not.toHaveBeenCalled();
820
853
  });
821
854
  });
855
+
856
+ describe('MCP local endpoint lifecycle', () => {
857
+ function setup() {
858
+ bridgeInstances.length = 0;
859
+ const mockSocket = createMockSocket();
860
+ vi.mocked(io).mockReturnValue(mockSocket as never);
861
+ connectToServer('http://localhost:4001', 'zag_k_1_abc123');
862
+ return mockSocket;
863
+ }
864
+
865
+ it('starts the local loopback server exactly once at daemon startup and never stops on disconnect', async () => {
866
+ const mockSocket = setup();
867
+ const server = localServerInstances[0];
868
+ expect(server).toBeDefined();
869
+ expect(server.start).toHaveBeenCalledTimes(1);
870
+
871
+ // 断线→重连后服务不停止、端口不变(存活会话注入的旧 URL 仍可用)
872
+ mockSocket._trigger('disconnect', 'transport close');
873
+ expect(server.stop).not.toHaveBeenCalled();
874
+ mockSocket._trigger('connect');
875
+ expect(server.start).toHaveBeenCalledTimes(1);
876
+ // disconnect 仅触发 relay 快速失败(503),不触碰 HTTP 服务
877
+ const relay = relayInstances[0];
878
+ expect(relay.handleDisconnect).toHaveBeenCalled();
879
+ });
880
+
881
+ it('registers the session endpoint and injects the loopback port on acp:start', async () => {
882
+ const mockSocket = setup();
883
+ localServerInstances[0].port = 34567;
884
+ const relay = relayInstances[0];
885
+
886
+ mockSocket._trigger('acp:start', {
887
+ requestId: 'req-mcp',
888
+ sessionId: 'sess-mcp',
889
+ serverType: 'opencode',
890
+ cwd: '/home/user/wt',
891
+ mcpEndpoint: { token: 'tok-1', serverNames: ['tools', 'proxy'] },
892
+ });
893
+
894
+ await vi.waitFor(() => {
895
+ expect(relay.registerSession).toHaveBeenCalledWith('sess-mcp', {
896
+ token: 'tok-1',
897
+ serverNames: ['tools', 'proxy'],
898
+ });
899
+ });
900
+ await vi.waitFor(() => {
901
+ expect(mockSocket.emit).toHaveBeenCalledWith('acp:start:result', expect.objectContaining({ sessionId: 'sess-mcp', success: true }));
902
+ });
903
+ });
904
+
905
+ it('fails loudly (does not inject port 0) when the loopback server is not listening', async () => {
906
+ const mockSocket = setup();
907
+ localServerInstances[0].port = 0;
908
+ const relay = relayInstances[0];
909
+
910
+ mockSocket._trigger('acp:start', {
911
+ requestId: 'req-mcp0',
912
+ sessionId: 'sess-mcp0',
913
+ serverType: 'opencode',
914
+ cwd: '/home/user/wt',
915
+ mcpEndpoint: { token: 'tok-0', serverNames: ['tools'] },
916
+ });
917
+
918
+ await vi.waitFor(() => {
919
+ expect(mockSocket.emit).toHaveBeenCalledWith('acp:start:result', expect.objectContaining({
920
+ sessionId: 'sess-mcp0',
921
+ success: false,
922
+ }));
923
+ });
924
+ // registerSession 不得被调用(端口校验失败,不注入坏 URL)
925
+ expect(relay.registerSession).not.toHaveBeenCalledWith('sess-mcp0', expect.anything());
926
+ });
927
+
928
+ it('routes mcp:response to the relay', async () => {
929
+ const mockSocket = setup();
930
+ const relay = relayInstances[0];
931
+
932
+ mockSocket._trigger('mcp:response', { requestId: 'mcpreq_1', status: 200, body: { jsonrpc: '2.0', id: 1, result: {} } });
933
+
934
+ expect(relay.handleMcpResponse).toHaveBeenCalledWith('mcpreq_1', 200, { jsonrpc: '2.0', id: 1, result: {} });
935
+ });
936
+
937
+ it('outputs an upgrade guide and exits on VERSION_MISMATCH', () => {
938
+ const mockSocket = setup();
939
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
940
+
941
+ mockSocket._trigger('error', { code: 'VERSION_MISMATCH', message: 'Version mismatch' });
942
+
943
+ expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('upgrade agent-controller'));
944
+ expect(mockSocket.disconnect).toHaveBeenCalled();
945
+ expect(process.exit).toHaveBeenCalledWith(1);
946
+ });
947
+
948
+ it('unregisters the session endpoint when the agent process exits on its own (onStopped)', async () => {
949
+ const mockSocket = setup();
950
+ localServerInstances[0].port = 34567;
951
+ const relay = relayInstances[0];
952
+
953
+ mockSocket._trigger('acp:start', {
954
+ requestId: 'req-exit',
955
+ sessionId: 'sess-exit',
956
+ serverType: 'opencode',
957
+ cwd: '/home/user/wt',
958
+ mcpEndpoint: { token: 'tok-x', serverNames: ['tools'] },
959
+ });
960
+ await vi.waitFor(() => {
961
+ expect(mockSocket.emit).toHaveBeenCalledWith('acp:start:result', expect.objectContaining({ sessionId: 'sess-exit', success: true }));
962
+ });
963
+
964
+ vi.mocked(relay.unregisterSession).mockClear();
965
+ bridgeInstances[0].emit('acp:stopped', { sessionId: 'sess-exit', reason: 'user_close' });
966
+
967
+ expect(relay.unregisterSession).toHaveBeenCalledWith('sess-exit');
968
+ });
969
+
970
+ it('unregisters the session endpoint on acp:stop', async () => {
971
+ const mockSocket = setup();
972
+ localServerInstances[0].port = 34567;
973
+ const relay = relayInstances[0];
974
+
975
+ mockSocket._trigger('acp:start', {
976
+ requestId: 'req-stop',
977
+ sessionId: 'sess-stop',
978
+ serverType: 'opencode',
979
+ cwd: '/home/user/wt',
980
+ mcpEndpoint: { token: 'tok-s', serverNames: ['tools'] },
981
+ });
982
+ await vi.waitFor(() => {
983
+ expect(mockSocket.emit).toHaveBeenCalledWith('acp:start:result', expect.objectContaining({ sessionId: 'sess-stop', success: true }));
984
+ });
985
+
986
+ vi.mocked(relay.unregisterSession).mockClear();
987
+ mockSocket._trigger('acp:stop', { sessionId: 'sess-stop' });
988
+
989
+ await vi.waitFor(() => {
990
+ expect(relay.unregisterSession).toHaveBeenCalledWith('sess-stop');
991
+ });
992
+ });
993
+
994
+ it('unregisters the session endpoint on acp:reconcile kill', async () => {
995
+ const mockSocket = setup();
996
+ vi.mocked(processRegistry.listEntries).mockReturnValue([
997
+ { sessionId: 's-recon', pid: 4444, cwd: '/x', acpServerType: 'opencode', startedAt: '2026-01-01' },
998
+ ]);
999
+ const killSpy = vi.spyOn(process, 'kill').mockReturnValue(true as never);
1000
+ vi.mocked(isProcessAlive).mockReturnValue(false);
1001
+ const relay = relayInstances[0];
1002
+ vi.mocked(relay.unregisterSession).mockClear();
1003
+
1004
+ mockSocket._trigger('acp:reconcile', { activeSessionIds: [], pendingKillSessionIds: [] });
1005
+
1006
+ await vi.waitFor(() => {
1007
+ expect(relay.unregisterSession).toHaveBeenCalledWith('s-recon');
1008
+ });
1009
+ expect(killSpy).toHaveBeenCalledWith(-4444, 'SIGTERM');
1010
+ });
1011
+ });
822
1012
  });