agent-relay 3.1.0 → 3.1.2

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 (159) hide show
  1. package/bin/agent-relay-broker-linux-x64 +0 -0
  2. package/package.json +9 -9
  3. package/packages/acp-bridge/package.json +2 -2
  4. package/packages/config/package.json +1 -1
  5. package/packages/hooks/package.json +4 -4
  6. package/packages/memory/package.json +2 -2
  7. package/packages/openclaw/README.md +78 -0
  8. package/packages/openclaw/bin/relay-openclaw.mjs +2 -0
  9. package/packages/openclaw/bridge/bridge.mjs +305 -0
  10. package/packages/openclaw/dist/__tests__/gateway-control.test.d.ts +2 -0
  11. package/packages/openclaw/dist/__tests__/gateway-control.test.d.ts.map +1 -0
  12. package/packages/openclaw/dist/__tests__/gateway-control.test.js +250 -0
  13. package/packages/openclaw/dist/__tests__/gateway-control.test.js.map +1 -0
  14. package/packages/openclaw/dist/__tests__/gateway-threads.test.d.ts +2 -0
  15. package/packages/openclaw/dist/__tests__/gateway-threads.test.d.ts.map +1 -0
  16. package/packages/openclaw/dist/__tests__/gateway-threads.test.js +937 -0
  17. package/packages/openclaw/dist/__tests__/gateway-threads.test.js.map +1 -0
  18. package/packages/openclaw/dist/__tests__/naming.test.d.ts +2 -0
  19. package/packages/openclaw/dist/__tests__/naming.test.d.ts.map +1 -0
  20. package/packages/openclaw/dist/__tests__/naming.test.js +21 -0
  21. package/packages/openclaw/dist/__tests__/naming.test.js.map +1 -0
  22. package/packages/openclaw/dist/__tests__/spawn-manager.test.d.ts +2 -0
  23. package/packages/openclaw/dist/__tests__/spawn-manager.test.d.ts.map +1 -0
  24. package/packages/openclaw/dist/__tests__/spawn-manager.test.js +155 -0
  25. package/packages/openclaw/dist/__tests__/spawn-manager.test.js.map +1 -0
  26. package/packages/openclaw/dist/__tests__/ws-client.test.d.ts +2 -0
  27. package/packages/openclaw/dist/__tests__/ws-client.test.d.ts.map +1 -0
  28. package/packages/openclaw/dist/__tests__/ws-client.test.js +324 -0
  29. package/packages/openclaw/dist/__tests__/ws-client.test.js.map +1 -0
  30. package/packages/openclaw/dist/auth/converter.d.ts +28 -0
  31. package/packages/openclaw/dist/auth/converter.d.ts.map +1 -0
  32. package/packages/openclaw/dist/auth/converter.js +64 -0
  33. package/packages/openclaw/dist/auth/converter.js.map +1 -0
  34. package/packages/openclaw/dist/cli.d.ts +2 -0
  35. package/packages/openclaw/dist/cli.d.ts.map +1 -0
  36. package/packages/openclaw/dist/cli.js +230 -0
  37. package/packages/openclaw/dist/cli.js.map +1 -0
  38. package/packages/openclaw/dist/config.d.ts +27 -0
  39. package/packages/openclaw/dist/config.d.ts.map +1 -0
  40. package/packages/openclaw/dist/config.js +97 -0
  41. package/packages/openclaw/dist/config.js.map +1 -0
  42. package/packages/openclaw/dist/control.d.ts +22 -0
  43. package/packages/openclaw/dist/control.d.ts.map +1 -0
  44. package/packages/openclaw/dist/control.js +58 -0
  45. package/packages/openclaw/dist/control.js.map +1 -0
  46. package/packages/openclaw/dist/gateway.d.ts +97 -0
  47. package/packages/openclaw/dist/gateway.d.ts.map +1 -0
  48. package/packages/openclaw/dist/gateway.js +836 -0
  49. package/packages/openclaw/dist/gateway.js.map +1 -0
  50. package/packages/openclaw/dist/identity/contract.d.ts +11 -0
  51. package/packages/openclaw/dist/identity/contract.d.ts.map +1 -0
  52. package/packages/openclaw/dist/identity/contract.js +40 -0
  53. package/packages/openclaw/dist/identity/contract.js.map +1 -0
  54. package/packages/openclaw/dist/identity/files.d.ts +33 -0
  55. package/packages/openclaw/dist/identity/files.d.ts.map +1 -0
  56. package/packages/openclaw/dist/identity/files.js +145 -0
  57. package/packages/openclaw/dist/identity/files.js.map +1 -0
  58. package/packages/openclaw/dist/identity/model.d.ts +11 -0
  59. package/packages/openclaw/dist/identity/model.d.ts.map +1 -0
  60. package/packages/openclaw/dist/identity/model.js +28 -0
  61. package/packages/openclaw/dist/identity/model.js.map +1 -0
  62. package/packages/openclaw/dist/identity/naming.d.ts +5 -0
  63. package/packages/openclaw/dist/identity/naming.d.ts.map +1 -0
  64. package/packages/openclaw/dist/identity/naming.js +7 -0
  65. package/packages/openclaw/dist/identity/naming.js.map +1 -0
  66. package/packages/openclaw/dist/index.d.ts +20 -0
  67. package/packages/openclaw/dist/index.d.ts.map +1 -0
  68. package/packages/openclaw/dist/index.js +27 -0
  69. package/packages/openclaw/dist/index.js.map +1 -0
  70. package/packages/openclaw/dist/inject.d.ts +14 -0
  71. package/packages/openclaw/dist/inject.d.ts.map +1 -0
  72. package/packages/openclaw/dist/inject.js +66 -0
  73. package/packages/openclaw/dist/inject.js.map +1 -0
  74. package/packages/openclaw/dist/mcp/server.d.ts +8 -0
  75. package/packages/openclaw/dist/mcp/server.d.ts.map +1 -0
  76. package/packages/openclaw/dist/mcp/server.js +105 -0
  77. package/packages/openclaw/dist/mcp/server.js.map +1 -0
  78. package/packages/openclaw/dist/mcp/tools.d.ts +17 -0
  79. package/packages/openclaw/dist/mcp/tools.d.ts.map +1 -0
  80. package/packages/openclaw/dist/mcp/tools.js +145 -0
  81. package/packages/openclaw/dist/mcp/tools.js.map +1 -0
  82. package/packages/openclaw/dist/runtime/openclaw-config.d.ts +20 -0
  83. package/packages/openclaw/dist/runtime/openclaw-config.d.ts.map +1 -0
  84. package/packages/openclaw/dist/runtime/openclaw-config.js +50 -0
  85. package/packages/openclaw/dist/runtime/openclaw-config.js.map +1 -0
  86. package/packages/openclaw/dist/runtime/patch.d.ts +24 -0
  87. package/packages/openclaw/dist/runtime/patch.d.ts.map +1 -0
  88. package/packages/openclaw/dist/runtime/patch.js +92 -0
  89. package/packages/openclaw/dist/runtime/patch.js.map +1 -0
  90. package/packages/openclaw/dist/runtime/setup.d.ts +26 -0
  91. package/packages/openclaw/dist/runtime/setup.d.ts.map +1 -0
  92. package/packages/openclaw/dist/runtime/setup.js +58 -0
  93. package/packages/openclaw/dist/runtime/setup.js.map +1 -0
  94. package/packages/openclaw/dist/setup.d.ts +29 -0
  95. package/packages/openclaw/dist/setup.d.ts.map +1 -0
  96. package/packages/openclaw/dist/setup.js +300 -0
  97. package/packages/openclaw/dist/setup.js.map +1 -0
  98. package/packages/openclaw/dist/spawn/docker.d.ts +58 -0
  99. package/packages/openclaw/dist/spawn/docker.d.ts.map +1 -0
  100. package/packages/openclaw/dist/spawn/docker.js +222 -0
  101. package/packages/openclaw/dist/spawn/docker.js.map +1 -0
  102. package/packages/openclaw/dist/spawn/manager.d.ts +45 -0
  103. package/packages/openclaw/dist/spawn/manager.d.ts.map +1 -0
  104. package/packages/openclaw/dist/spawn/manager.js +140 -0
  105. package/packages/openclaw/dist/spawn/manager.js.map +1 -0
  106. package/packages/openclaw/dist/spawn/process.d.ts +16 -0
  107. package/packages/openclaw/dist/spawn/process.d.ts.map +1 -0
  108. package/packages/openclaw/dist/spawn/process.js +241 -0
  109. package/packages/openclaw/dist/spawn/process.js.map +1 -0
  110. package/packages/openclaw/dist/spawn/types.d.ts +42 -0
  111. package/packages/openclaw/dist/spawn/types.d.ts.map +1 -0
  112. package/packages/openclaw/dist/spawn/types.js +2 -0
  113. package/packages/openclaw/dist/spawn/types.js.map +1 -0
  114. package/packages/openclaw/dist/types.d.ts +41 -0
  115. package/packages/openclaw/dist/types.d.ts.map +1 -0
  116. package/packages/openclaw/dist/types.js +2 -0
  117. package/packages/openclaw/dist/types.js.map +1 -0
  118. package/packages/openclaw/package.json +63 -0
  119. package/packages/openclaw/skill/SKILL.md +216 -0
  120. package/packages/openclaw/src/__tests__/SPEC-ws-client-testing.md +192 -0
  121. package/packages/openclaw/src/__tests__/gateway-control.test.ts +288 -0
  122. package/packages/openclaw/src/__tests__/gateway-threads.test.ts +1130 -0
  123. package/packages/openclaw/src/__tests__/naming.test.ts +24 -0
  124. package/packages/openclaw/src/__tests__/spawn-manager.test.ts +189 -0
  125. package/packages/openclaw/src/__tests__/ws-client.test.ts +395 -0
  126. package/packages/openclaw/src/auth/converter.ts +90 -0
  127. package/packages/openclaw/src/cli.ts +269 -0
  128. package/packages/openclaw/src/config.ts +124 -0
  129. package/packages/openclaw/src/control.ts +100 -0
  130. package/packages/openclaw/src/gateway.ts +1014 -0
  131. package/packages/openclaw/src/identity/contract.ts +44 -0
  132. package/packages/openclaw/src/identity/files.ts +198 -0
  133. package/packages/openclaw/src/identity/model.ts +27 -0
  134. package/packages/openclaw/src/identity/naming.ts +6 -0
  135. package/packages/openclaw/src/index.ts +59 -0
  136. package/packages/openclaw/src/inject.ts +77 -0
  137. package/packages/openclaw/src/mcp/server.ts +121 -0
  138. package/packages/openclaw/src/mcp/tools.ts +174 -0
  139. package/packages/openclaw/src/runtime/openclaw-config.ts +64 -0
  140. package/packages/openclaw/src/runtime/patch.ts +103 -0
  141. package/packages/openclaw/src/runtime/setup.ts +89 -0
  142. package/packages/openclaw/src/setup.ts +336 -0
  143. package/packages/openclaw/src/spawn/docker.ts +261 -0
  144. package/packages/openclaw/src/spawn/manager.ts +181 -0
  145. package/packages/openclaw/src/spawn/process.ts +272 -0
  146. package/packages/openclaw/src/spawn/types.ts +43 -0
  147. package/packages/openclaw/src/types.ts +42 -0
  148. package/packages/openclaw/templates/SOUL.md.template +34 -0
  149. package/packages/openclaw/tsconfig.json +12 -0
  150. package/packages/policy/package.json +2 -2
  151. package/packages/sdk/package.json +2 -2
  152. package/packages/sdk-py/pyproject.toml +1 -1
  153. package/packages/telemetry/package.json +1 -1
  154. package/packages/trajectory/package.json +2 -2
  155. package/packages/user-directory/package.json +2 -2
  156. package/packages/utils/package.json +2 -2
  157. package/bin/agent-relay-broker-darwin-arm64 +0 -0
  158. package/bin/agent-relay-broker-darwin-x64 +0 -0
  159. package/bin/agent-relay-broker-linux-arm64 +0 -0
@@ -0,0 +1,24 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { buildAgentName } from '../identity/naming.js';
3
+
4
+ describe('buildAgentName', () => {
5
+ it('should build agent name from workspace and claw name', () => {
6
+ const result = buildAgentName('ws123', 'researcher');
7
+ expect(result).toBe('claw-ws123-researcher');
8
+ });
9
+
10
+ it('should handle hyphens in workspace id', () => {
11
+ const result = buildAgentName('ws-abc-123', 'coder');
12
+ expect(result).toBe('claw-ws-abc-123-coder');
13
+ });
14
+
15
+ it('should handle hyphens in claw name', () => {
16
+ const result = buildAgentName('workspace', 'code-reviewer');
17
+ expect(result).toBe('claw-workspace-code-reviewer');
18
+ });
19
+
20
+ it('should handle empty strings', () => {
21
+ const result = buildAgentName('', '');
22
+ expect(result).toBe('claw--');
23
+ });
24
+ });
@@ -0,0 +1,189 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { SpawnManager } from '../spawn/manager.js';
3
+
4
+ // Mock the spawn providers
5
+ vi.mock('../spawn/docker.js', () => ({
6
+ DockerSpawnProvider: vi.fn().mockImplementation(() => ({
7
+ spawn: vi.fn().mockResolvedValue({
8
+ id: 'test-id-1',
9
+ displayName: 'test-claw',
10
+ agentName: 'claw-ws123-test-claw',
11
+ gatewayPort: 18789,
12
+ destroy: vi.fn().mockResolvedValue(undefined),
13
+ }),
14
+ destroy: vi.fn().mockResolvedValue(undefined),
15
+ list: vi.fn().mockResolvedValue([]),
16
+ })),
17
+ }));
18
+
19
+ vi.mock('../spawn/process.js', () => ({
20
+ ProcessSpawnProvider: vi.fn().mockImplementation(() => {
21
+ let callCount = 0;
22
+ return {
23
+ spawn: vi.fn().mockImplementation((options: { name: string }) => {
24
+ callCount++;
25
+ return Promise.resolve({
26
+ id: `test-id-${callCount}`,
27
+ displayName: options.name,
28
+ agentName: `claw-ws123-${options.name}`,
29
+ gatewayPort: 18790,
30
+ destroy: vi.fn().mockResolvedValue(undefined),
31
+ });
32
+ }),
33
+ destroy: vi.fn().mockResolvedValue(undefined),
34
+ list: vi.fn().mockResolvedValue([]),
35
+ };
36
+ }),
37
+ }));
38
+
39
+ // Mock fs operations
40
+ vi.mock('node:fs/promises', () => ({
41
+ readFile: vi.fn().mockResolvedValue('{"spawns":[]}'),
42
+ writeFile: vi.fn().mockResolvedValue(undefined),
43
+ mkdir: vi.fn().mockResolvedValue(undefined),
44
+ }));
45
+
46
+ vi.mock('node:fs', () => ({
47
+ existsSync: vi.fn().mockReturnValue(false),
48
+ }));
49
+
50
+ describe('SpawnManager', () => {
51
+ beforeEach(() => {
52
+ vi.clearAllMocks();
53
+ });
54
+
55
+ it('should initialize with default values', () => {
56
+ const manager = new SpawnManager({ mode: 'process' });
57
+ expect(manager.size).toBe(0);
58
+ });
59
+
60
+ it('should enforce maxSpawns limit', async () => {
61
+ const manager = new SpawnManager({ mode: 'process', maxSpawns: 1 });
62
+
63
+ // First spawn should succeed
64
+ await manager.spawn({
65
+ name: 'claw-1',
66
+ relayApiKey: 'rk_live_test',
67
+ });
68
+
69
+ expect(manager.size).toBe(1);
70
+
71
+ // Second spawn should fail due to limit
72
+ await expect(manager.spawn({
73
+ name: 'claw-2',
74
+ relayApiKey: 'rk_live_test',
75
+ })).rejects.toThrow(/Maximum concurrent spawns reached/);
76
+ });
77
+
78
+ it('should enforce maxDepth limit', async () => {
79
+ const manager = new SpawnManager({
80
+ mode: 'process',
81
+ maxDepth: 2,
82
+ spawnDepth: 2, // Already at max depth
83
+ });
84
+
85
+ await expect(manager.spawn({
86
+ name: 'claw-1',
87
+ relayApiKey: 'rk_live_test',
88
+ })).rejects.toThrow(/Spawn depth limit reached/);
89
+ });
90
+
91
+ it('should prevent duplicate spawns by name', async () => {
92
+ const manager = new SpawnManager({ mode: 'process' });
93
+
94
+ await manager.spawn({
95
+ name: 'researcher',
96
+ relayApiKey: 'rk_live_test',
97
+ });
98
+
99
+ await expect(manager.spawn({
100
+ name: 'researcher',
101
+ relayApiKey: 'rk_live_test',
102
+ })).rejects.toThrow(/already running/);
103
+ });
104
+
105
+ it('should list spawned handles', async () => {
106
+ const manager = new SpawnManager({ mode: 'process' });
107
+
108
+ await manager.spawn({
109
+ name: 'worker-1',
110
+ relayApiKey: 'rk_live_test',
111
+ });
112
+
113
+ const list = manager.list();
114
+ expect(list).toHaveLength(1);
115
+ expect(list[0].displayName).toBe('worker-1');
116
+ });
117
+
118
+ it('should release by id', async () => {
119
+ const manager = new SpawnManager({ mode: 'process' });
120
+
121
+ const handle = await manager.spawn({
122
+ name: 'worker-1',
123
+ relayApiKey: 'rk_live_test',
124
+ });
125
+
126
+ expect(manager.size).toBe(1);
127
+
128
+ const released = await manager.release(handle.id);
129
+ expect(released).toBe(true);
130
+ expect(manager.size).toBe(0);
131
+ });
132
+
133
+ it('should release by name', async () => {
134
+ const manager = new SpawnManager({ mode: 'process' });
135
+
136
+ await manager.spawn({
137
+ name: 'worker-1',
138
+ relayApiKey: 'rk_live_test',
139
+ });
140
+
141
+ const released = await manager.releaseByName('worker-1');
142
+ expect(released).toBe(true);
143
+ expect(manager.size).toBe(0);
144
+ });
145
+
146
+ it('should return false when releasing non-existent spawn', async () => {
147
+ const manager = new SpawnManager({ mode: 'process' });
148
+
149
+ const released = await manager.release('non-existent-id');
150
+ expect(released).toBe(false);
151
+ });
152
+
153
+ it('should return handle by id via get()', async () => {
154
+ const manager = new SpawnManager({ mode: 'process' });
155
+
156
+ const handle = await manager.spawn({
157
+ name: 'getter-test',
158
+ relayApiKey: 'rk_live_test',
159
+ });
160
+
161
+ expect(manager.get(handle.id)).toBeDefined();
162
+ expect(manager.get(handle.id)!.displayName).toBe('getter-test');
163
+ expect(manager.get('nonexistent')).toBeUndefined();
164
+ });
165
+
166
+ it('should persist state on spawn', async () => {
167
+ const { writeFile } = await import('node:fs/promises');
168
+
169
+ const manager = new SpawnManager({ mode: 'process' });
170
+ await manager.spawn({
171
+ name: 'persist-test',
172
+ relayApiKey: 'rk_live_test',
173
+ });
174
+
175
+ expect(writeFile).toHaveBeenCalled();
176
+ const writeCall = vi.mocked(writeFile).mock.calls[0];
177
+ expect(writeCall[0]).toContain('spawns.json');
178
+ const written = JSON.parse(writeCall[1] as string) as { spawns: Array<{ displayName: string }> };
179
+ expect(written.spawns).toHaveLength(1);
180
+ expect(written.spawns[0].displayName).toBe('persist-test');
181
+ });
182
+
183
+ it('should return empty array from loadPersistedState when no file exists', async () => {
184
+ const manager = new SpawnManager({ mode: 'process' });
185
+
186
+ const state = await manager.loadPersistedState();
187
+ expect(state).toEqual([]);
188
+ });
189
+ });
@@ -0,0 +1,395 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { WebSocketServer, type WebSocket as WsType } from 'ws';
3
+
4
+ // Mock spawn/manager and relaycast SDK to prevent side-effects from gateway.ts module load
5
+ vi.mock('../spawn/manager.js', () => ({
6
+ SpawnManager: vi.fn().mockImplementation(() => ({
7
+ size: 0,
8
+ spawn: vi.fn(),
9
+ release: vi.fn(),
10
+ releaseByName: vi.fn(),
11
+ releaseAll: vi.fn().mockResolvedValue(undefined),
12
+ list: vi.fn().mockReturnValue([]),
13
+ get: vi.fn(),
14
+ })),
15
+ }));
16
+
17
+ vi.mock('@relaycast/sdk', () => ({
18
+ RelayCast: vi.fn().mockImplementation(() => ({
19
+ agents: { registerOrGet: vi.fn().mockResolvedValue({ name: 'viewer-test', token: 'tok' }) },
20
+ as: vi.fn().mockReturnValue({
21
+ connect: vi.fn(),
22
+ disconnect: vi.fn().mockResolvedValue(undefined),
23
+ subscribe: vi.fn(),
24
+ on: {
25
+ connected: vi.fn().mockReturnValue(() => {}),
26
+ messageCreated: vi.fn().mockReturnValue(() => {}),
27
+ threadReply: vi.fn().mockReturnValue(() => {}),
28
+ dmReceived: vi.fn().mockReturnValue(() => {}),
29
+ groupDmReceived: vi.fn().mockReturnValue(() => {}),
30
+ commandInvoked: vi.fn().mockReturnValue(() => {}),
31
+ reactionAdded: vi.fn().mockReturnValue(() => {}),
32
+ reactionRemoved: vi.fn().mockReturnValue(() => {}),
33
+ reconnecting: vi.fn().mockReturnValue(() => {}),
34
+ disconnected: vi.fn().mockReturnValue(() => {}),
35
+ error: vi.fn().mockReturnValue(() => {}),
36
+ },
37
+ }),
38
+ })),
39
+ }));
40
+
41
+ vi.mock('node:fs/promises', () => ({
42
+ readFile: vi.fn().mockResolvedValue('{"spawns":[]}'),
43
+ writeFile: vi.fn().mockResolvedValue(undefined),
44
+ mkdir: vi.fn().mockResolvedValue(undefined),
45
+ }));
46
+
47
+ vi.mock('node:fs', () => ({
48
+ existsSync: vi.fn().mockReturnValue(false),
49
+ }));
50
+
51
+ import { OpenClawGatewayClient } from '../gateway.js';
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Mock OpenClaw Gateway WebSocket Server
55
+ // ---------------------------------------------------------------------------
56
+
57
+ interface MockServerOptions {
58
+ /** Whether to accept or reject auth. Default: true */
59
+ acceptAuth?: boolean;
60
+ /** Delay before sending challenge (ms). 0 = immediate. */
61
+ challengeDelay?: number;
62
+ /** Whether to send a challenge at all. Default: true */
63
+ sendChallenge?: boolean;
64
+ /** Delay before responding to chat.send RPCs (ms). Default: 0 */
65
+ chatDelay?: number;
66
+ /** Whether chat.send succeeds. Default: true */
67
+ chatOk?: boolean;
68
+ }
69
+
70
+ class MockOpenClawServer {
71
+ private wss: WebSocketServer;
72
+ private clients: Set<WsType> = new Set();
73
+ port = 0;
74
+
75
+ private acceptAuth: boolean;
76
+ private challengeDelay: number;
77
+ private sendChallenge: boolean;
78
+ private chatDelay: number;
79
+ private chatOk: boolean;
80
+
81
+ constructor(options: MockServerOptions = {}) {
82
+ this.acceptAuth = options.acceptAuth ?? true;
83
+ this.challengeDelay = options.challengeDelay ?? 0;
84
+ this.sendChallenge = options.sendChallenge ?? true;
85
+ this.chatDelay = options.chatDelay ?? 0;
86
+ this.chatOk = options.chatOk ?? true;
87
+
88
+ this.wss = new WebSocketServer({ port: 0 });
89
+ this.port = (this.wss.address() as { port: number }).port;
90
+
91
+ this.wss.on('connection', (ws) => {
92
+ this.clients.add(ws);
93
+ ws.on('close', () => this.clients.delete(ws));
94
+
95
+ if (this.sendChallenge) {
96
+ const challenge = JSON.stringify({
97
+ type: 'event',
98
+ event: 'connect.challenge',
99
+ payload: { nonce: 'test-nonce-123', ts: Date.now() },
100
+ });
101
+
102
+ if (this.challengeDelay > 0) {
103
+ setTimeout(() => ws.send(challenge), this.challengeDelay);
104
+ } else {
105
+ ws.send(challenge);
106
+ }
107
+ }
108
+
109
+ ws.on('message', (data) => {
110
+ const msg = JSON.parse(data.toString()) as Record<string, unknown>;
111
+
112
+ // Handle connect request
113
+ if (msg.method === 'connect' && msg.id === 'connect-1') {
114
+ if (this.acceptAuth) {
115
+ ws.send(JSON.stringify({ type: 'res', id: 'connect-1', ok: true }));
116
+ } else {
117
+ ws.send(JSON.stringify({
118
+ type: 'res',
119
+ id: 'connect-1',
120
+ ok: false,
121
+ error: { code: 'auth_failed', message: 'Invalid token' },
122
+ }));
123
+ }
124
+ return;
125
+ }
126
+
127
+ // Handle chat.send RPC
128
+ if (msg.method === 'chat.send') {
129
+ const respond = () => {
130
+ if (this.chatOk) {
131
+ ws.send(JSON.stringify({
132
+ type: 'res',
133
+ id: msg.id,
134
+ ok: true,
135
+ payload: { runId: 'run-1', status: 'accepted' },
136
+ }));
137
+ } else {
138
+ ws.send(JSON.stringify({
139
+ type: 'res',
140
+ id: msg.id,
141
+ ok: false,
142
+ error: { code: 'rate_limited', message: 'Too many requests' },
143
+ }));
144
+ }
145
+ };
146
+
147
+ if (this.chatDelay > 0) {
148
+ setTimeout(respond, this.chatDelay);
149
+ } else {
150
+ respond();
151
+ }
152
+ }
153
+ });
154
+ });
155
+ }
156
+
157
+ /** Force-close all connected clients. */
158
+ closeAllClients(code = 1000): void {
159
+ for (const ws of this.clients) {
160
+ ws.close(code);
161
+ }
162
+ }
163
+
164
+ async close(): Promise<void> {
165
+ this.closeAllClients();
166
+ return new Promise((resolve) => {
167
+ this.wss.close(() => resolve());
168
+ });
169
+ }
170
+ }
171
+
172
+ // ---------------------------------------------------------------------------
173
+ // Tests
174
+ // ---------------------------------------------------------------------------
175
+
176
+ describe('OpenClawGatewayClient', () => {
177
+ let server: MockOpenClawServer | null = null;
178
+
179
+ afterEach(async () => {
180
+ if (server) {
181
+ await server.close();
182
+ server = null;
183
+ }
184
+ });
185
+
186
+ it('should connect and authenticate (happy path)', async () => {
187
+ server = new MockOpenClawServer();
188
+ const client = new OpenClawGatewayClient('test-token', server.port);
189
+
190
+ await client.connect();
191
+ // Should resolve without throwing
192
+ await client.disconnect();
193
+ });
194
+
195
+ it('should reject when auth is rejected', async () => {
196
+ server = new MockOpenClawServer({ acceptAuth: false });
197
+ const client = new OpenClawGatewayClient('bad-token', server.port);
198
+
199
+ await expect(client.connect()).rejects.toThrow(/auth failed/i);
200
+ await client.disconnect();
201
+ });
202
+
203
+ it('should no-op when already connected', async () => {
204
+ server = new MockOpenClawServer();
205
+ const client = new OpenClawGatewayClient('test-token', server.port);
206
+
207
+ await client.connect();
208
+ // Second connect should be a no-op (early return)
209
+ await client.connect();
210
+ await client.disconnect();
211
+ });
212
+
213
+ it('should timeout when no challenge is sent', async () => {
214
+ server = new MockOpenClawServer({ sendChallenge: false });
215
+ const client = new OpenClawGatewayClient('test-token', server.port);
216
+
217
+ // Monkey-patch the timeout to be short for the test
218
+ (OpenClawGatewayClient as unknown as Record<string, number>).CONNECT_TIMEOUT_MS = 200;
219
+
220
+ await expect(client.connect()).rejects.toThrow(/timed out/i);
221
+ await client.disconnect();
222
+
223
+ // Restore
224
+ (OpenClawGatewayClient as unknown as Record<string, number>).CONNECT_TIMEOUT_MS = 30_000;
225
+ });
226
+
227
+ it('should reject connect when WS closes before auth', async () => {
228
+ server = new MockOpenClawServer({ sendChallenge: false });
229
+ const client = new OpenClawGatewayClient('test-token', server.port);
230
+
231
+ // Start connecting, then close server connections immediately
232
+ const connectPromise = client.connect();
233
+ // Give the WS time to establish before closing
234
+ await new Promise((r) => setTimeout(r, 50));
235
+ server.closeAllClients(1000);
236
+
237
+ await expect(connectPromise).rejects.toThrow(/closed before authentication|timed out/i);
238
+ await client.disconnect();
239
+ });
240
+
241
+ it('should return true on successful sendChatMessage', async () => {
242
+ server = new MockOpenClawServer();
243
+ const client = new OpenClawGatewayClient('test-token', server.port);
244
+ await client.connect();
245
+
246
+ const result = await client.sendChatMessage('hello world');
247
+ expect(result).toBe(true);
248
+
249
+ await client.disconnect();
250
+ });
251
+
252
+ it('should pass idempotencyKey in sendChatMessage params', async () => {
253
+ let receivedParams: Record<string, unknown> = {};
254
+ server = new MockOpenClawServer();
255
+
256
+ // Intercept the server to capture params
257
+ const origWss = (server as unknown as { wss: WebSocketServer }).wss;
258
+ const origListeners = origWss.listeners('connection');
259
+ origWss.removeAllListeners('connection');
260
+ origWss.on('connection', (ws) => {
261
+ // Re-emit for original handler
262
+ for (const listener of origListeners) {
263
+ (listener as (ws: WsType) => void)(ws);
264
+ }
265
+ ws.on('message', (data) => {
266
+ const msg = JSON.parse(data.toString()) as Record<string, unknown>;
267
+ if (msg.method === 'chat.send') {
268
+ receivedParams = msg.params as Record<string, unknown>;
269
+ }
270
+ });
271
+ });
272
+
273
+ const client = new OpenClawGatewayClient('test-token', server.port);
274
+ await client.connect();
275
+
276
+ await client.sendChatMessage('test', 'idem-key-123');
277
+ expect(receivedParams.idempotencyKey).toBe('idem-key-123');
278
+
279
+ await client.disconnect();
280
+ });
281
+
282
+ it('should return false on RPC error', async () => {
283
+ server = new MockOpenClawServer({ chatOk: false });
284
+ const client = new OpenClawGatewayClient('test-token', server.port);
285
+ await client.connect();
286
+
287
+ const result = await client.sendChatMessage('hello');
288
+ expect(result).toBe(false);
289
+
290
+ await client.disconnect();
291
+ });
292
+
293
+ it('should return false on sendChatMessage when not connected', async () => {
294
+ // No server at all — connect should fail
295
+ const client = new OpenClawGatewayClient('test-token', 1);
296
+
297
+ const result = await client.sendChatMessage('hello');
298
+ expect(result).toBe(false);
299
+
300
+ await client.disconnect();
301
+ });
302
+
303
+ it('should reject pending RPCs on disconnect', async () => {
304
+ // Use a server that never responds to chat.send
305
+ server = new MockOpenClawServer();
306
+ // Override: don't respond to chat.send
307
+ const origWss = (server as unknown as { wss: WebSocketServer }).wss;
308
+ origWss.removeAllListeners('connection');
309
+ origWss.on('connection', (ws) => {
310
+ // Send challenge
311
+ ws.send(JSON.stringify({
312
+ type: 'event',
313
+ event: 'connect.challenge',
314
+ payload: { nonce: 'nonce-1', ts: Date.now() },
315
+ }));
316
+ ws.on('message', (data) => {
317
+ const msg = JSON.parse(data.toString()) as Record<string, unknown>;
318
+ if (msg.method === 'connect') {
319
+ ws.send(JSON.stringify({ type: 'res', id: 'connect-1', ok: true }));
320
+ }
321
+ // Deliberately don't respond to chat.send
322
+ });
323
+ });
324
+
325
+ const client = new OpenClawGatewayClient('test-token', server.port);
326
+ await client.connect();
327
+
328
+ // Send a message that won't get a response
329
+ const chatPromise = client.sendChatMessage('will be rejected');
330
+
331
+ // Disconnect while the RPC is pending
332
+ await client.disconnect();
333
+
334
+ const result = await chatPromise;
335
+ expect(result).toBe(false);
336
+ });
337
+
338
+ it('should not reconnect after disconnect()', async () => {
339
+ server = new MockOpenClawServer();
340
+ const client = new OpenClawGatewayClient('test-token', server.port);
341
+ await client.connect();
342
+ await client.disconnect();
343
+
344
+ // After disconnect, the stopped flag should prevent reconnection.
345
+ // Verify by checking that sendChatMessage returns false without hanging.
346
+ const result = await client.sendChatMessage('should fail');
347
+ expect(result).toBe(false);
348
+ });
349
+
350
+ it('should handle non-JSON messages gracefully', async () => {
351
+ server = new MockOpenClawServer({ sendChallenge: false });
352
+ const origWss = (server as unknown as { wss: WebSocketServer }).wss;
353
+ origWss.removeAllListeners('connection');
354
+ origWss.on('connection', (ws) => {
355
+ // Send garbage first, then a proper challenge
356
+ ws.send('not json at all');
357
+ ws.send(JSON.stringify({
358
+ type: 'event',
359
+ event: 'connect.challenge',
360
+ payload: { nonce: 'nonce-2', ts: Date.now() },
361
+ }));
362
+ ws.on('message', (data) => {
363
+ const msg = JSON.parse(data.toString()) as Record<string, unknown>;
364
+ if (msg.method === 'connect') {
365
+ ws.send(JSON.stringify({ type: 'res', id: 'connect-1', ok: true }));
366
+ }
367
+ });
368
+ });
369
+
370
+ const client = new OpenClawGatewayClient('test-token', server.port);
371
+ await client.connect();
372
+ await client.disconnect();
373
+ });
374
+
375
+ it('should silently ignore unrecognized event messages', async () => {
376
+ server = new MockOpenClawServer();
377
+ const origWss = (server as unknown as { wss: WebSocketServer }).wss;
378
+ const origListeners = origWss.listeners('connection');
379
+ origWss.removeAllListeners('connection');
380
+ origWss.on('connection', (ws) => {
381
+ for (const listener of origListeners) {
382
+ (listener as (ws: WsType) => void)(ws);
383
+ }
384
+ // Send some random event after auth
385
+ setTimeout(() => {
386
+ ws.send(JSON.stringify({ type: 'event', event: 'chat.tick', payload: {} }));
387
+ }, 100);
388
+ });
389
+
390
+ const client = new OpenClawGatewayClient('test-token', server.port);
391
+ await client.connect();
392
+ await new Promise((r) => setTimeout(r, 150));
393
+ await client.disconnect();
394
+ });
395
+ });
@@ -0,0 +1,90 @@
1
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { existsSync } from 'node:fs';
4
+
5
+ export interface CodexOAuthTokens {
6
+ access_token: string;
7
+ refresh_token?: string;
8
+ }
9
+
10
+ export interface CodexAuth {
11
+ tokens?: CodexOAuthTokens;
12
+ OPENAI_API_KEY?: string;
13
+ }
14
+
15
+ export interface ConvertResult {
16
+ /** Whether auth was written. */
17
+ ok: boolean;
18
+ /** Provider hint derived from auth source (e.g. 'openai-codex' for OAuth). */
19
+ preferredProvider: string;
20
+ }
21
+
22
+ /**
23
+ * Convert Codex CLI auth.json into OpenClaw's legacy auth format.
24
+ *
25
+ * Reads ~/.codex/auth.json (or codexAuthPath) and writes the converted
26
+ * auth to ~/.openclaw/agents/main/agent/auth.json (or openclawAuthDir).
27
+ *
28
+ * Falls back to OPENAI_API_KEY env var if no codex auth file exists.
29
+ */
30
+ export async function convertCodexAuth(options?: {
31
+ codexAuthPath?: string;
32
+ openclawAuthDir?: string;
33
+ openaiApiKey?: string;
34
+ }): Promise<ConvertResult> {
35
+ const home = process.env.HOME ?? '/home/node';
36
+ const codexPath = options?.codexAuthPath ?? join(home, '.codex', 'auth.json');
37
+ const openclawAgentDir = options?.openclawAuthDir ?? join(home, '.openclaw', 'agents', 'main', 'agent');
38
+ const openclawAuthPath = join(openclawAgentDir, 'auth.json');
39
+ let preferredProvider = 'openai';
40
+
41
+ if (existsSync(codexPath)) {
42
+ const codex: CodexAuth = JSON.parse(await readFile(codexPath, 'utf8'));
43
+ await mkdir(openclawAgentDir, { recursive: true });
44
+
45
+ if (codex.tokens?.access_token) {
46
+ // OAuth tokens from codex subscription
47
+ const auth = {
48
+ 'openai-codex': {
49
+ type: 'oauth',
50
+ provider: 'openai-codex',
51
+ access: codex.tokens.access_token,
52
+ refresh: codex.tokens.refresh_token ?? '',
53
+ expires: Date.now() + 3600000,
54
+ },
55
+ };
56
+ await writeFile(openclawAuthPath, JSON.stringify(auth, null, 2), 'utf8');
57
+ preferredProvider = 'openai-codex';
58
+ return { ok: true, preferredProvider };
59
+ }
60
+
61
+ if (codex.OPENAI_API_KEY && typeof codex.OPENAI_API_KEY === 'string') {
62
+ const auth = {
63
+ openai: {
64
+ type: 'api_key',
65
+ provider: 'openai',
66
+ key: codex.OPENAI_API_KEY,
67
+ },
68
+ };
69
+ await writeFile(openclawAuthPath, JSON.stringify(auth, null, 2), 'utf8');
70
+ return { ok: true, preferredProvider };
71
+ }
72
+ }
73
+
74
+ // Fallback: use OPENAI_API_KEY from env
75
+ const envKey = options?.openaiApiKey ?? process.env.OPENAI_API_KEY;
76
+ if (envKey) {
77
+ await mkdir(openclawAgentDir, { recursive: true });
78
+ const auth = {
79
+ openai: {
80
+ type: 'api_key',
81
+ provider: 'openai',
82
+ key: envKey,
83
+ },
84
+ };
85
+ await writeFile(openclawAuthPath, JSON.stringify(auth, null, 2), 'utf8');
86
+ return { ok: true, preferredProvider };
87
+ }
88
+
89
+ return { ok: false, preferredProvider };
90
+ }