agent-gear 1.3.0 → 2.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 (37) hide show
  1. package/dist/acp/bridge.d.ts +3 -1
  2. package/dist/acp/bridge.d.ts.map +1 -1
  3. package/dist/acp/bridge.js +13 -6
  4. package/dist/acp/bridge.js.map +1 -1
  5. package/dist/acp/bridge.test.js +26 -11
  6. package/dist/acp/bridge.test.js.map +1 -1
  7. package/dist/mcp/local-server.d.ts +11 -0
  8. package/dist/mcp/local-server.d.ts.map +1 -0
  9. package/dist/mcp/local-server.js +102 -0
  10. package/dist/mcp/local-server.js.map +1 -0
  11. package/dist/mcp/local-server.test.d.ts +2 -0
  12. package/dist/mcp/local-server.test.d.ts.map +1 -0
  13. package/dist/mcp/local-server.test.js +101 -0
  14. package/dist/mcp/local-server.test.js.map +1 -0
  15. package/dist/mcp/relay.d.ts +30 -0
  16. package/dist/mcp/relay.d.ts.map +1 -0
  17. package/dist/mcp/relay.js +65 -0
  18. package/dist/mcp/relay.js.map +1 -0
  19. package/dist/mcp/relay.test.d.ts +2 -0
  20. package/dist/mcp/relay.test.d.ts.map +1 -0
  21. package/dist/mcp/relay.test.js +76 -0
  22. package/dist/mcp/relay.test.js.map +1 -0
  23. package/dist/socketio-client.d.ts +25 -0
  24. package/dist/socketio-client.d.ts.map +1 -1
  25. package/dist/socketio-client.js +115 -4
  26. package/dist/socketio-client.js.map +1 -1
  27. package/package.json +2 -1
  28. package/src/acp/bridge.test.ts +30 -12
  29. package/src/acp/bridge.ts +22 -7
  30. package/src/mcp/local-server.test.ts +133 -0
  31. package/src/mcp/local-server.ts +106 -0
  32. package/src/mcp/relay.test.ts +91 -0
  33. package/src/mcp/relay.ts +94 -0
  34. package/src/socketio-client.ts +128 -7
  35. package/tests/acp-wire-mcp.test.ts +4 -4
  36. package/tests/endpoint-compat.test.ts +192 -0
  37. package/tests/socketio-client.test.ts +193 -2
@@ -0,0 +1,192 @@
1
+ import { describe, it, expect, afterEach, vi } from 'vitest';
2
+ import { createServer, type Server as HttpServer } from 'node:http';
3
+ import { Server as IOServer, type Socket as ServerSocket } from 'socket.io';
4
+
5
+ // 只测连接层:屏蔽守护进程的其余副作用(本地 MCP 回环服务、心跳定时器、~/.zag 配置写入)。
6
+ vi.mock('../src/mcp/local-server.js', () => ({
7
+ LocalMcpServer: class {
8
+ port = 0;
9
+ start = vi.fn(async () => {});
10
+ stop = vi.fn(async () => {});
11
+ },
12
+ }));
13
+
14
+ vi.mock('../src/heartbeat.js', () => ({
15
+ Heartbeat: class {
16
+ start = vi.fn();
17
+ stop = vi.fn();
18
+ },
19
+ }));
20
+
21
+ vi.mock('../src/config.js', () => ({
22
+ clearConfigKey: vi.fn(),
23
+ saveKeyToConfig: vi.fn(),
24
+ saveEndpointToConfig: vi.fn(),
25
+ getResumeTokenFromConfig: vi.fn(() => undefined),
26
+ saveResumeTokenToConfig: vi.fn(),
27
+ clearResumeTokenFromConfig: vi.fn(),
28
+ }));
29
+
30
+ import { connectToServer, parseEndpoint, extractHandshakeToken, stripTokenFromEndpoint } from '../src/socketio-client.js';
31
+ import type { Socket } from 'socket.io-client';
32
+
33
+ interface Harness {
34
+ port: number;
35
+ handshakes: Array<Record<string, string>>;
36
+ auths: Array<Record<string, unknown>>;
37
+ registerPayloads: unknown[];
38
+ url: (path?: string) => string;
39
+ close: () => Promise<void>;
40
+ }
41
+
42
+ const openSockets: Socket[] = [];
43
+
44
+ async function startServer(): Promise<Harness> {
45
+ const httpServer: HttpServer = createServer();
46
+ const ioServer = new IOServer(httpServer);
47
+ const handshakes: Array<Record<string, string>> = [];
48
+ const auths: Array<Record<string, unknown>> = [];
49
+ const registerPayloads: unknown[] = [];
50
+
51
+ const attach = (nspName: string) => {
52
+ const nsp = ioServer.of(nspName);
53
+ nsp.use((socket, next) => {
54
+ handshakes.push(socket.handshake.query as Record<string, string>);
55
+ auths.push(socket.handshake.auth as Record<string, unknown>);
56
+ next();
57
+ });
58
+ nsp.on('connection', (socket: ServerSocket) => {
59
+ socket.on('register', (payload: unknown) => {
60
+ registerPayloads.push(payload);
61
+ socket.emit('registered', { controllerId: 'test-controller' });
62
+ });
63
+ });
64
+ };
65
+ attach('/');
66
+ attach('/worker');
67
+
68
+ await new Promise<void>((resolve) => httpServer.listen(0, '127.0.0.1', () => resolve()));
69
+ const address = httpServer.address();
70
+ if (!address || typeof address === 'string') throw new Error('failed to bind test server');
71
+ const port = address.port;
72
+
73
+ return {
74
+ port,
75
+ handshakes,
76
+ auths,
77
+ registerPayloads,
78
+ url: (path = '') => `http://127.0.0.1:${port}${path}`,
79
+ close: async () => {
80
+ await ioServer.close();
81
+ await new Promise<void>((resolve) => httpServer.close(() => resolve()));
82
+ },
83
+ };
84
+ }
85
+
86
+ afterEach(async () => {
87
+ for (const socket of openSockets.splice(0)) {
88
+ socket.close();
89
+ }
90
+ });
91
+
92
+ describe('parseEndpoint', () => {
93
+ it('T-P1:旧 endpoint 解析为默认 namespace `/` 且无 query', () => {
94
+ const parsed = parseEndpoint('http://host:4001');
95
+ expect(parsed.namespace).toBe('/');
96
+ expect(parsed.query).toEqual({});
97
+ expect(parsed.origin).toBe('http://host:4001');
98
+ });
99
+
100
+ it('T-P2:新 endpoint 解析出 `/worker` namespace 与 token query', () => {
101
+ const parsed = parseEndpoint('wss://cloud.example.com/worker?token=abc');
102
+ expect(parsed.namespace).toBe('/worker');
103
+ expect(parsed.query).toEqual({ token: 'abc' });
104
+ expect(parsed.origin).toBe('wss://cloud.example.com');
105
+ });
106
+
107
+ it('忽略尾部斜杠、保留多 query 参数', () => {
108
+ const parsed = parseEndpoint('wss://cloud.example.com/worker/?token=a.b&x=1');
109
+ expect(parsed.namespace).toBe('/worker');
110
+ expect(parsed.query).toEqual({ token: 'a.b', x: '1' });
111
+ });
112
+
113
+ it('extractHandshakeToken 取 query token;stripTokenFromEndpoint 移除之而保留其他参数', () => {
114
+ expect(extractHandshakeToken('wss://h/worker?token=a.b')).toBe('a.b');
115
+ expect(extractHandshakeToken('wss://h/worker')).toBeUndefined();
116
+ expect(extractHandshakeToken('not-a-url')).toBeUndefined();
117
+
118
+ expect(stripTokenFromEndpoint('wss://h/worker?token=a.b&x=1')).toBe('wss://h/worker?x=1');
119
+ expect(stripTokenFromEndpoint('wss://h/worker')).toBe('wss://h/worker');
120
+ });
121
+ });
122
+
123
+ describe('connectToServer endpoint 兼容(真实 socket.io 服务端)', () => {
124
+ it('T-P1:旧 endpoint 连接默认 namespace、handshake 无 token(兼容锚点)', async () => {
125
+ const server = await startServer();
126
+ try {
127
+ const socket = connectToServer(server.url(), 'zag_k_legacy');
128
+
129
+ openSockets.push(socket);
130
+
131
+ await new Promise<void>((resolve, reject) => {
132
+ socket.on('connect', () => resolve());
133
+ socket.on('connect_error', (err) => reject(err));
134
+ setTimeout(() => reject(new Error('connect timeout')), 3000);
135
+ });
136
+
137
+ // 原生解析:无 pathname → 默认 namespace `/`
138
+ expect(socket.nsp).toBe('/');
139
+ const handshake = server.handshakes.at(-1);
140
+ expect(handshake?.token).toBeUndefined();
141
+ } finally {
142
+ await server.close();
143
+ }
144
+ });
145
+
146
+ it('T-P2:新 endpoint(`/worker?token=abc`)握手成功,token 经 auth 传递且不进 URL', async () => {
147
+ const server = await startServer();
148
+ try {
149
+ const socket = connectToServer(server.url('/worker?token=abc'), 'ignored-by-cloud');
150
+ openSockets.push(socket);
151
+
152
+ await new Promise<void>((resolve, reject) => {
153
+ socket.on('connect', () => resolve());
154
+ socket.on('connect_error', (err) => reject(err));
155
+ setTimeout(() => reject(new Error('connect timeout')), 3000);
156
+ });
157
+
158
+ expect(socket.nsp).toBe('/worker');
159
+ const auth = server.auths.at(-1);
160
+ expect(auth?.token).toBe('abc');
161
+ // token 不再经 handshake query / URL 传递
162
+ expect(server.handshakes.at(-1)?.token).toBeUndefined();
163
+
164
+ // register 仍照常发送(cloud-controller 侧忽略 key,保持握手兼容)
165
+ await vi.waitFor(() => {
166
+ expect(server.registerPayloads.length).toBeGreaterThan(0);
167
+ });
168
+ } finally {
169
+ await server.close();
170
+ }
171
+ });
172
+
173
+ it('T-P3:无 query token 的新 endpoint 用 key 作为握手 auth token(cloud-controller 场景)', async () => {
174
+ const server = await startServer();
175
+ try {
176
+ const socket = connectToServer(server.url('/worker'), 'container-1.secret');
177
+ openSockets.push(socket);
178
+
179
+ await new Promise<void>((resolve, reject) => {
180
+ socket.on('connect', () => resolve());
181
+ socket.on('connect_error', (err) => reject(err));
182
+ setTimeout(() => reject(new Error('connect timeout')), 3000);
183
+ });
184
+
185
+ expect(socket.nsp).toBe('/worker');
186
+ expect(server.auths.at(-1)?.token).toBe('container-1.secret');
187
+ expect(server.handshakes.at(-1)?.token).toBeUndefined();
188
+ } finally {
189
+ await server.close();
190
+ }
191
+ });
192
+ });
@@ -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(() => {});
@@ -181,6 +214,7 @@ describe('SocketIO Client', () => {
181
214
  reconnection: true,
182
215
  reconnectionDelay: 1000,
183
216
  reconnectionDelayMax: 60000,
217
+ auth: { token: 'zag_k_1_abc123' },
184
218
  });
185
219
  });
186
220
 
@@ -195,7 +229,7 @@ describe('SocketIO Client', () => {
195
229
  expect(mockSocket.emit).toHaveBeenCalledWith('register', {
196
230
  key: 'zag_k_1_abc123',
197
231
  hostname: 'my-machine',
198
- version: '1.1.1',
232
+ version: '2.1.0',
199
233
  resumeToken: undefined,
200
234
  homeDir: '/home/user',
201
235
  platform: 'linux',
@@ -213,7 +247,7 @@ describe('SocketIO Client', () => {
213
247
  expect(mockSocket.emit).toHaveBeenCalledWith('register', {
214
248
  key: 'zag_k_1_abc123',
215
249
  hostname: 'test-machine',
216
- version: '1.1.1',
250
+ version: '2.1.0',
217
251
  resumeToken: undefined,
218
252
  homeDir: '/home/user',
219
253
  platform: 'linux',
@@ -819,4 +853,161 @@ describe('SocketIO Client', () => {
819
853
  expect(killSpy).not.toHaveBeenCalled();
820
854
  });
821
855
  });
856
+
857
+ describe('MCP local endpoint lifecycle', () => {
858
+ function setup() {
859
+ bridgeInstances.length = 0;
860
+ const mockSocket = createMockSocket();
861
+ vi.mocked(io).mockReturnValue(mockSocket as never);
862
+ connectToServer('http://localhost:4001', 'zag_k_1_abc123');
863
+ return mockSocket;
864
+ }
865
+
866
+ it('starts the local loopback server exactly once at daemon startup and never stops on disconnect', async () => {
867
+ const mockSocket = setup();
868
+ const server = localServerInstances[0];
869
+ expect(server).toBeDefined();
870
+ expect(server.start).toHaveBeenCalledTimes(1);
871
+
872
+ // 断线→重连后服务不停止、端口不变(存活会话注入的旧 URL 仍可用)
873
+ mockSocket._trigger('disconnect', 'transport close');
874
+ expect(server.stop).not.toHaveBeenCalled();
875
+ mockSocket._trigger('connect');
876
+ expect(server.start).toHaveBeenCalledTimes(1);
877
+ // disconnect 仅触发 relay 快速失败(503),不触碰 HTTP 服务
878
+ const relay = relayInstances[0];
879
+ expect(relay.handleDisconnect).toHaveBeenCalled();
880
+ });
881
+
882
+ it('registers the session endpoint and injects the loopback port on acp:start', async () => {
883
+ const mockSocket = setup();
884
+ localServerInstances[0].port = 34567;
885
+ const relay = relayInstances[0];
886
+
887
+ mockSocket._trigger('acp:start', {
888
+ requestId: 'req-mcp',
889
+ sessionId: 'sess-mcp',
890
+ serverType: 'opencode',
891
+ cwd: '/home/user/wt',
892
+ mcpEndpoint: { token: 'tok-1', serverNames: ['tools', 'proxy'] },
893
+ });
894
+
895
+ await vi.waitFor(() => {
896
+ expect(relay.registerSession).toHaveBeenCalledWith('sess-mcp', {
897
+ token: 'tok-1',
898
+ serverNames: ['tools', 'proxy'],
899
+ });
900
+ });
901
+ await vi.waitFor(() => {
902
+ expect(mockSocket.emit).toHaveBeenCalledWith('acp:start:result', expect.objectContaining({ sessionId: 'sess-mcp', success: true }));
903
+ });
904
+ });
905
+
906
+ it('fails loudly (does not inject port 0) when the loopback server is not listening', async () => {
907
+ const mockSocket = setup();
908
+ localServerInstances[0].port = 0;
909
+ const relay = relayInstances[0];
910
+
911
+ mockSocket._trigger('acp:start', {
912
+ requestId: 'req-mcp0',
913
+ sessionId: 'sess-mcp0',
914
+ serverType: 'opencode',
915
+ cwd: '/home/user/wt',
916
+ mcpEndpoint: { token: 'tok-0', serverNames: ['tools'] },
917
+ });
918
+
919
+ await vi.waitFor(() => {
920
+ expect(mockSocket.emit).toHaveBeenCalledWith('acp:start:result', expect.objectContaining({
921
+ sessionId: 'sess-mcp0',
922
+ success: false,
923
+ }));
924
+ });
925
+ // registerSession 不得被调用(端口校验失败,不注入坏 URL)
926
+ expect(relay.registerSession).not.toHaveBeenCalledWith('sess-mcp0', expect.anything());
927
+ });
928
+
929
+ it('routes mcp:response to the relay', async () => {
930
+ const mockSocket = setup();
931
+ const relay = relayInstances[0];
932
+
933
+ mockSocket._trigger('mcp:response', { requestId: 'mcpreq_1', status: 200, body: { jsonrpc: '2.0', id: 1, result: {} } });
934
+
935
+ expect(relay.handleMcpResponse).toHaveBeenCalledWith('mcpreq_1', 200, { jsonrpc: '2.0', id: 1, result: {} });
936
+ });
937
+
938
+ it('outputs an upgrade guide and exits on VERSION_MISMATCH', () => {
939
+ const mockSocket = setup();
940
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
941
+
942
+ mockSocket._trigger('error', { code: 'VERSION_MISMATCH', message: 'Version mismatch' });
943
+
944
+ expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('upgrade agent-controller'));
945
+ expect(mockSocket.disconnect).toHaveBeenCalled();
946
+ expect(process.exit).toHaveBeenCalledWith(1);
947
+ });
948
+
949
+ it('unregisters the session endpoint when the agent process exits on its own (onStopped)', async () => {
950
+ const mockSocket = setup();
951
+ localServerInstances[0].port = 34567;
952
+ const relay = relayInstances[0];
953
+
954
+ mockSocket._trigger('acp:start', {
955
+ requestId: 'req-exit',
956
+ sessionId: 'sess-exit',
957
+ serverType: 'opencode',
958
+ cwd: '/home/user/wt',
959
+ mcpEndpoint: { token: 'tok-x', serverNames: ['tools'] },
960
+ });
961
+ await vi.waitFor(() => {
962
+ expect(mockSocket.emit).toHaveBeenCalledWith('acp:start:result', expect.objectContaining({ sessionId: 'sess-exit', success: true }));
963
+ });
964
+
965
+ vi.mocked(relay.unregisterSession).mockClear();
966
+ bridgeInstances[0].emit('acp:stopped', { sessionId: 'sess-exit', reason: 'user_close' });
967
+
968
+ expect(relay.unregisterSession).toHaveBeenCalledWith('sess-exit');
969
+ });
970
+
971
+ it('unregisters the session endpoint on acp:stop', async () => {
972
+ const mockSocket = setup();
973
+ localServerInstances[0].port = 34567;
974
+ const relay = relayInstances[0];
975
+
976
+ mockSocket._trigger('acp:start', {
977
+ requestId: 'req-stop',
978
+ sessionId: 'sess-stop',
979
+ serverType: 'opencode',
980
+ cwd: '/home/user/wt',
981
+ mcpEndpoint: { token: 'tok-s', serverNames: ['tools'] },
982
+ });
983
+ await vi.waitFor(() => {
984
+ expect(mockSocket.emit).toHaveBeenCalledWith('acp:start:result', expect.objectContaining({ sessionId: 'sess-stop', success: true }));
985
+ });
986
+
987
+ vi.mocked(relay.unregisterSession).mockClear();
988
+ mockSocket._trigger('acp:stop', { sessionId: 'sess-stop' });
989
+
990
+ await vi.waitFor(() => {
991
+ expect(relay.unregisterSession).toHaveBeenCalledWith('sess-stop');
992
+ });
993
+ });
994
+
995
+ it('unregisters the session endpoint on acp:reconcile kill', async () => {
996
+ const mockSocket = setup();
997
+ vi.mocked(processRegistry.listEntries).mockReturnValue([
998
+ { sessionId: 's-recon', pid: 4444, cwd: '/x', acpServerType: 'opencode', startedAt: '2026-01-01' },
999
+ ]);
1000
+ const killSpy = vi.spyOn(process, 'kill').mockReturnValue(true as never);
1001
+ vi.mocked(isProcessAlive).mockReturnValue(false);
1002
+ const relay = relayInstances[0];
1003
+ vi.mocked(relay.unregisterSession).mockClear();
1004
+
1005
+ mockSocket._trigger('acp:reconcile', { activeSessionIds: [], pendingKillSessionIds: [] });
1006
+
1007
+ await vi.waitFor(() => {
1008
+ expect(relay.unregisterSession).toHaveBeenCalledWith('s-recon');
1009
+ });
1010
+ expect(killSpy).toHaveBeenCalledWith(-4444, 'SIGTERM');
1011
+ });
1012
+ });
822
1013
  });