@dofe/infra-clients 0.1.37 → 0.1.40

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 (109) hide show
  1. package/dist/index.d.ts +1 -0
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +1 -0
  4. package/dist/index.js.map +1 -1
  5. package/dist/internal/mlflow/mlflow.client.d.ts +44 -0
  6. package/dist/internal/mlflow/mlflow.client.d.ts.map +1 -0
  7. package/dist/internal/mlflow/mlflow.client.js +184 -0
  8. package/dist/internal/mlflow/mlflow.client.js.map +1 -0
  9. package/dist/internal/mlflow/mlflow.module.d.ts +3 -0
  10. package/dist/internal/mlflow/mlflow.module.d.ts.map +1 -0
  11. package/dist/internal/mlflow/mlflow.module.js +23 -0
  12. package/dist/internal/mlflow/mlflow.module.js.map +1 -0
  13. package/dist/internal/model-research-proxy/anthropic-proxy-research.client.d.ts +52 -0
  14. package/dist/internal/model-research-proxy/anthropic-proxy-research.client.d.ts.map +1 -0
  15. package/dist/internal/model-research-proxy/anthropic-proxy-research.client.js +174 -0
  16. package/dist/internal/model-research-proxy/anthropic-proxy-research.client.js.map +1 -0
  17. package/dist/internal/model-research-proxy/anthropic-proxy-research.module.d.ts +3 -0
  18. package/dist/internal/model-research-proxy/anthropic-proxy-research.module.d.ts.map +1 -0
  19. package/dist/internal/model-research-proxy/anthropic-proxy-research.module.js +30 -0
  20. package/dist/internal/model-research-proxy/anthropic-proxy-research.module.js.map +1 -0
  21. package/dist/internal/model-research-proxy/index.d.ts +6 -0
  22. package/dist/internal/model-research-proxy/index.d.ts.map +1 -0
  23. package/dist/internal/model-research-proxy/index.js +24 -0
  24. package/dist/internal/model-research-proxy/index.js.map +1 -0
  25. package/dist/internal/model-verify/index.d.ts +4 -0
  26. package/dist/internal/model-verify/index.d.ts.map +1 -0
  27. package/dist/internal/model-verify/index.js +26 -0
  28. package/dist/internal/model-verify/index.js.map +1 -0
  29. package/dist/internal/model-verify/model-verify.client.d.ts +77 -0
  30. package/dist/internal/model-verify/model-verify.client.d.ts.map +1 -0
  31. package/dist/internal/model-verify/model-verify.client.js +324 -0
  32. package/dist/internal/model-verify/model-verify.client.js.map +1 -0
  33. package/dist/internal/openclaw/docker-exec.service.d.ts +132 -0
  34. package/dist/internal/openclaw/docker-exec.service.d.ts.map +1 -0
  35. package/dist/internal/openclaw/docker-exec.service.js +544 -0
  36. package/dist/internal/openclaw/docker-exec.service.js.map +1 -0
  37. package/dist/internal/openclaw/index.d.ts +14 -0
  38. package/dist/internal/openclaw/index.d.ts.map +1 -0
  39. package/dist/internal/openclaw/index.js +32 -0
  40. package/dist/internal/openclaw/index.js.map +1 -0
  41. package/dist/internal/openclaw/openclaw-agent-coordination.client.d.ts +73 -0
  42. package/dist/internal/openclaw/openclaw-agent-coordination.client.d.ts.map +1 -0
  43. package/dist/internal/openclaw/openclaw-agent-coordination.client.js +249 -0
  44. package/dist/internal/openclaw/openclaw-agent-coordination.client.js.map +1 -0
  45. package/dist/internal/openclaw/openclaw-context-status.client.d.ts +66 -0
  46. package/dist/internal/openclaw/openclaw-context-status.client.d.ts.map +1 -0
  47. package/dist/internal/openclaw/openclaw-context-status.client.js +164 -0
  48. package/dist/internal/openclaw/openclaw-context-status.client.js.map +1 -0
  49. package/dist/internal/openclaw/openclaw-cron.client.d.ts +61 -0
  50. package/dist/internal/openclaw/openclaw-cron.client.d.ts.map +1 -0
  51. package/dist/internal/openclaw/openclaw-cron.client.js +416 -0
  52. package/dist/internal/openclaw/openclaw-cron.client.js.map +1 -0
  53. package/dist/internal/openclaw/openclaw-gateway.client.d.ts +41 -0
  54. package/dist/internal/openclaw/openclaw-gateway.client.d.ts.map +1 -0
  55. package/dist/internal/openclaw/openclaw-gateway.client.js +175 -0
  56. package/dist/internal/openclaw/openclaw-gateway.client.js.map +1 -0
  57. package/dist/internal/openclaw/openclaw-skill-sync.client.d.ts +222 -0
  58. package/dist/internal/openclaw/openclaw-skill-sync.client.d.ts.map +1 -0
  59. package/dist/internal/openclaw/openclaw-skill-sync.client.js +720 -0
  60. package/dist/internal/openclaw/openclaw-skill-sync.client.js.map +1 -0
  61. package/dist/internal/openclaw/openclaw.client.d.ts +602 -0
  62. package/dist/internal/openclaw/openclaw.client.d.ts.map +1 -0
  63. package/dist/internal/openclaw/openclaw.client.js +3062 -0
  64. package/dist/internal/openclaw/openclaw.client.js.map +1 -0
  65. package/dist/internal/openclaw/openclaw.module.d.ts +3 -0
  66. package/dist/internal/openclaw/openclaw.module.d.ts.map +1 -0
  67. package/dist/internal/openclaw/openclaw.module.js +62 -0
  68. package/dist/internal/openclaw/openclaw.module.js.map +1 -0
  69. package/dist/internal/openclaw/skill-translation.service.d.ts +39 -0
  70. package/dist/internal/openclaw/skill-translation.service.d.ts.map +1 -0
  71. package/dist/internal/openclaw/skill-translation.service.js +217 -0
  72. package/dist/internal/openclaw/skill-translation.service.js.map +1 -0
  73. package/dist/internal/openclaw/types/cron.types.d.ts +112 -0
  74. package/dist/internal/openclaw/types/cron.types.d.ts.map +1 -0
  75. package/dist/internal/openclaw/types/cron.types.js +9 -0
  76. package/dist/internal/openclaw/types/cron.types.js.map +1 -0
  77. package/dist/internal/provider-verify/index.d.ts +6 -0
  78. package/dist/internal/provider-verify/index.d.ts.map +1 -0
  79. package/dist/internal/provider-verify/index.js +24 -0
  80. package/dist/internal/provider-verify/index.js.map +1 -0
  81. package/dist/internal/provider-verify/provider-verify.client.d.ts +55 -0
  82. package/dist/internal/provider-verify/provider-verify.client.d.ts.map +1 -0
  83. package/dist/internal/provider-verify/provider-verify.client.js +284 -0
  84. package/dist/internal/provider-verify/provider-verify.client.js.map +1 -0
  85. package/dist/internal/provider-verify/provider-verify.module.d.ts +3 -0
  86. package/dist/internal/provider-verify/provider-verify.module.d.ts.map +1 -0
  87. package/dist/internal/provider-verify/provider-verify.module.js +28 -0
  88. package/dist/internal/provider-verify/provider-verify.module.js.map +1 -0
  89. package/dist/internal/sso/index.d.ts +5 -0
  90. package/dist/internal/sso/index.d.ts.map +1 -0
  91. package/dist/internal/sso/index.js +12 -0
  92. package/dist/internal/sso/index.js.map +1 -0
  93. package/dist/internal/sso/sso-auth.client.d.ts +33 -0
  94. package/dist/internal/sso/sso-auth.client.d.ts.map +1 -0
  95. package/dist/internal/sso/sso-auth.client.js +89 -0
  96. package/dist/internal/sso/sso-auth.client.js.map +1 -0
  97. package/dist/internal/sso/sso-message-proxy.service.d.ts +11 -0
  98. package/dist/internal/sso/sso-message-proxy.service.d.ts.map +1 -0
  99. package/dist/internal/sso/sso-message-proxy.service.js +51 -0
  100. package/dist/internal/sso/sso-message-proxy.service.js.map +1 -0
  101. package/dist/internal/sso/sso-message.client.d.ts +12 -0
  102. package/dist/internal/sso/sso-message.client.d.ts.map +1 -0
  103. package/dist/internal/sso/sso-message.client.js +62 -0
  104. package/dist/internal/sso/sso-message.client.js.map +1 -0
  105. package/dist/internal/sso/sso.module.d.ts +3 -0
  106. package/dist/internal/sso/sso.module.d.ts.map +1 -0
  107. package/dist/internal/sso/sso.module.js +26 -0
  108. package/dist/internal/sso/sso.module.js.map +1 -0
  109. package/package.json +110 -6
@@ -0,0 +1,3062 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
19
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
20
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
21
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
22
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
23
+ };
24
+ var __importStar = (this && this.__importStar) || (function () {
25
+ var ownKeys = function(o) {
26
+ ownKeys = Object.getOwnPropertyNames || function (o) {
27
+ var ar = [];
28
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
29
+ return ar;
30
+ };
31
+ return ownKeys(o);
32
+ };
33
+ return function (mod) {
34
+ if (mod && mod.__esModule) return mod;
35
+ var result = {};
36
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
37
+ __setModuleDefault(result, mod);
38
+ return result;
39
+ };
40
+ })();
41
+ var __metadata = (this && this.__metadata) || function (k, v) {
42
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
43
+ };
44
+ var __param = (this && this.__param) || function (paramIndex, decorator) {
45
+ return function (target, key) { decorator(target, key, paramIndex); }
46
+ };
47
+ var __importDefault = (this && this.__importDefault) || function (mod) {
48
+ return (mod && mod.__esModule) ? mod : { "default": mod };
49
+ };
50
+ Object.defineProperty(exports, "__esModule", { value: true });
51
+ exports.OpenClawClient = void 0;
52
+ /**
53
+ * OpenClaw 客户端
54
+ *
55
+ * 职责:
56
+ * - 与 OpenClaw Gateway 通信
57
+ * - 发送消息到 OpenClaw 并获取 AI 响应
58
+ * - 使用 WebSocket 进行实时通信
59
+ */
60
+ const common_1 = require("@nestjs/common");
61
+ const axios_1 = require("@nestjs/axios");
62
+ const nest_winston_1 = require("nest-winston");
63
+ const winston_1 = require("winston");
64
+ const rxjs_1 = require("rxjs");
65
+ const ws_1 = __importDefault(require("ws"));
66
+ const crypto_1 = require("crypto");
67
+ const path = __importStar(require("path"));
68
+ const env_config_service_1 = require("@dofe/infra-common/config/env-config.service");
69
+ const docker_exec_service_1 = require("./docker-exec.service");
70
+ const DEFAULT_OPENCLAW_HOME = '/home/node/.openclaw';
71
+ function resolveOpenClawHomeFromEnv(envHome) {
72
+ const trimmed = envHome?.trim();
73
+ return trimmed || DEFAULT_OPENCLAW_HOME;
74
+ }
75
+ function buildOpenClawPath(envHome, ...segments) {
76
+ const base = resolveOpenClawHomeFromEnv(envHome).replace(/\/+$/, '');
77
+ const normalizedSegments = segments.map((segment) => segment.replace(/^\/+|\/+$/g, ''));
78
+ return [base, ...normalizedSegments].filter(Boolean).join('/');
79
+ }
80
+ let OpenClawClient = class OpenClawClient {
81
+ logger;
82
+ httpService;
83
+ dockerExec;
84
+ requestTimeout = 120000; // 2 分钟超时
85
+ wsTimeout = 120000; // WebSocket 响应超时
86
+ /** 缓存容器的 proxy token(containerId → token) */
87
+ proxyTokenCache = new Map();
88
+ constructor(logger, httpService, dockerExec) {
89
+ this.logger = logger;
90
+ this.httpService = httpService;
91
+ this.dockerExec = dockerExec;
92
+ }
93
+ /**
94
+ * 发送消息到 OpenClaw Gateway 并获取 AI 响应
95
+ * 使用 WebSocket 进行通信
96
+ * @param port OpenClaw Gateway 端口
97
+ * @param token Gateway 认证 token
98
+ * @param message 用户消息(字符串或多模态内容数组)
99
+ * @param options 可选的聊天选项(上下文、模型、路由提示等)
100
+ */
101
+ async chat(port, token, message, options) {
102
+ const messageInfo = typeof message === 'string'
103
+ ? { length: message.length, type: 'text' }
104
+ : {
105
+ length: message.length,
106
+ type: 'multimodal',
107
+ imageCount: message.filter((p) => p.type === 'image').length,
108
+ };
109
+ this.logger.info('OpenClawClient: 发送消息到 OpenClaw', {
110
+ port,
111
+ ...messageInfo,
112
+ contextLength: options?.context?.length || 0,
113
+ model: options?.model,
114
+ routingHint: options?.routingHint,
115
+ sessionKey: options?.sessionKey || 'main',
116
+ agentId: options?.agentId,
117
+ });
118
+ try {
119
+ // 如果指定了模型且提供了容器 ID,先切换模型
120
+ if (options?.model && options?.containerId) {
121
+ await this.switchModel(options.containerId, options.model);
122
+ }
123
+ const response = await this.sendMessageViaWebSocket(port, token, message, options?.context, options?.sessionKey, options?.agentId);
124
+ this.logger.info('OpenClawClient: 收到 AI 响应', {
125
+ port,
126
+ responseLength: response.length,
127
+ });
128
+ return response;
129
+ }
130
+ catch (error) {
131
+ this.logger.error('OpenClawClient: 通信失败', {
132
+ port,
133
+ error: error instanceof Error ? error.message : 'Unknown error',
134
+ });
135
+ throw error;
136
+ }
137
+ }
138
+ /**
139
+ * 发送消息到 OpenClaw Gateway(兼容旧接口)
140
+ * @deprecated 使用带 options 参数的 chat 方法
141
+ */
142
+ async chatLegacy(port, token, message, context) {
143
+ return this.chat(port, token, message, { context });
144
+ }
145
+ /**
146
+ * 通过 Docker exec 切换 OpenClaw 容器的模型
147
+ * @param containerId Docker 容器 ID
148
+ * @param model 目标模型名称
149
+ */
150
+ async switchModel(containerId, model) {
151
+ this.logger.info('OpenClawClient: 切换模型', { containerId, model });
152
+ const result = await this.dockerExec.executeCommand(containerId, ['node', '/app/openclaw.mjs', 'models', 'set', model], { timeout: 10000 });
153
+ if (result.success) {
154
+ this.logger.info('OpenClawClient: 模型切换成功', {
155
+ containerId,
156
+ model,
157
+ durationMs: result.durationMs,
158
+ });
159
+ }
160
+ else {
161
+ this.logger.warn('OpenClawClient: 模型切换失败', {
162
+ containerId,
163
+ model,
164
+ stderr: result.stderr,
165
+ durationMs: result.durationMs,
166
+ });
167
+ // 不抛出错误,允许继续使用当前模型
168
+ }
169
+ }
170
+ /**
171
+ * 从容器环境变量中读取 Proxy Token(带缓存)
172
+ * @param containerId Docker 容器 ID
173
+ * @returns Proxy Token 字符串,失败返回 null
174
+ */
175
+ async getContainerProxyToken(containerId) {
176
+ // 检查缓存
177
+ const cached = this.proxyTokenCache.get(containerId);
178
+ if (cached)
179
+ return cached;
180
+ const result = await this.dockerExec.executeCommand(containerId, ['printenv', 'PROXY_TOKEN'], { timeout: 5000 });
181
+ if (result.success && result.stdout.trim()) {
182
+ const token = result.stdout.trim();
183
+ this.proxyTokenCache.set(containerId, token);
184
+ return token;
185
+ }
186
+ this.logger.warn('OpenClawClient: 无法读取容器 Proxy Token', {
187
+ containerId,
188
+ stderr: result.stderr,
189
+ });
190
+ return null;
191
+ }
192
+ /**
193
+ * 通过 Keyring Proxy 直接发送多模态消息(绕过 OpenClaw Gateway)
194
+ *
195
+ * 用于包含图片的视觉请求,因为 OpenClaw Gateway 的 chat.send
196
+ * WebSocket 协议不支持多模态内容数组。
197
+ *
198
+ * 流程:
199
+ * 1. 从容器读取 Proxy Token
200
+ * 2. 构建 OpenAI 兼容的 chat/completions 请求
201
+ * 3. 直接调用 Keyring Proxy HTTP 端点
202
+ * 4. 收集并返回响应文本
203
+ */
204
+ async chatViaProxy(options) {
205
+ const { containerId, proxyBaseUrl, visionModel, content } = options;
206
+ this.logger.info('OpenClawClient: 通过 Proxy 发送视觉请求', {
207
+ containerId,
208
+ visionModel,
209
+ contentParts: content.length,
210
+ imageCount: content.filter((p) => p.type === 'image').length,
211
+ fileCount: content.filter((p) => p.type === 'file').length,
212
+ });
213
+ // 1. 获取 Proxy Token
214
+ const proxyToken = await this.getContainerProxyToken(containerId);
215
+ if (!proxyToken) {
216
+ throw new Error('无法获取 Proxy Token,无法发送视觉请求');
217
+ }
218
+ // 2. 过滤并转换有效的内容部分
219
+ const validContentParts = content
220
+ .filter((part) => {
221
+ // 过滤空文本
222
+ if (part.type === 'text') {
223
+ return part.text && part.text.trim().length > 0;
224
+ }
225
+ // 过滤无效的图片 URL
226
+ if (part.type === 'image') {
227
+ return part.image_url && part.image_url.url;
228
+ }
229
+ // 过滤无效的文件 URL
230
+ if (part.type === 'file') {
231
+ return part.file_url && part.file_url.url;
232
+ }
233
+ return false;
234
+ })
235
+ .map((part) => {
236
+ if (part.type === 'text') {
237
+ return { type: 'text', text: part.text };
238
+ }
239
+ if (part.type === 'file' && part.file_url) {
240
+ return {
241
+ type: 'file_url',
242
+ file_url: part.file_url,
243
+ };
244
+ }
245
+ // 图片文件
246
+ return {
247
+ type: 'image_url',
248
+ image_url: part.image_url,
249
+ };
250
+ });
251
+ // 验证是否有有效内容
252
+ if (validContentParts.length === 0) {
253
+ throw new Error('没有有效的多模态内容可发送');
254
+ }
255
+ this.logger.info('OpenClawClient: 有效内容部分', {
256
+ totalParts: content.length,
257
+ validParts: validContentParts.length,
258
+ textParts: validContentParts.filter((p) => p.type === 'text').length,
259
+ imageParts: validContentParts.filter((p) => p.type === 'image_url')
260
+ .length,
261
+ fileParts: validContentParts.filter((p) => p.type === 'file_url').length,
262
+ });
263
+ // 3. 构建 OpenAI 兼容请求体
264
+ const requestBody = {
265
+ model: visionModel,
266
+ messages: [
267
+ {
268
+ role: 'user',
269
+ content: validContentParts,
270
+ },
271
+ ],
272
+ stream: false,
273
+ max_tokens: 4096,
274
+ };
275
+ // 4. 调用 Proxy HTTP 端点
276
+ // 使用 openai-compatible vendor 触发自动路由
277
+ const url = `${proxyBaseUrl}/v1/openai-compatible/chat/completions`;
278
+ try {
279
+ const response = await (0, rxjs_1.firstValueFrom)(this.httpService
280
+ .post(url, requestBody, {
281
+ headers: {
282
+ Authorization: `Bearer ${proxyToken}`,
283
+ 'Content-Type': 'application/json',
284
+ },
285
+ timeout: this.requestTimeout,
286
+ })
287
+ .pipe((0, rxjs_1.catchError)((error) => {
288
+ this.logger.error('OpenClawClient: Proxy 视觉请求失败', {
289
+ error: error.message,
290
+ status: error.response?.status,
291
+ data: error.response?.data
292
+ ? JSON.stringify(error.response.data).substring(0, 500)
293
+ : undefined,
294
+ });
295
+ throw error;
296
+ })));
297
+ // 5. 提取响应文本
298
+ const data = response.data;
299
+ const responseText = data?.choices?.[0]?.message?.content || '';
300
+ this.logger.info('OpenClawClient: Proxy 视觉请求成功', {
301
+ visionModel,
302
+ responseLength: responseText.length,
303
+ responsePreview: responseText.substring(0, 200),
304
+ });
305
+ return responseText;
306
+ }
307
+ catch (error) {
308
+ this.logger.error('OpenClawClient: Proxy 视觉请求异常', {
309
+ containerId,
310
+ visionModel,
311
+ error: error instanceof Error ? error.message : 'Unknown error',
312
+ });
313
+ throw error;
314
+ }
315
+ }
316
+ /**
317
+ * 在容器内执行技能脚本
318
+ * 仅允许执行白名单中的脚本(如 init.sh)
319
+ */
320
+ async execSkillScript(containerId, skillName, scriptName = 'init.sh') {
321
+ // 使用 DockerExecService 的安全验证
322
+ if (!this.dockerExec.isValidName(skillName) ||
323
+ !this.dockerExec.isValidName(scriptName)) {
324
+ this.logger.warn('OpenClawClient: 非法技能名或脚本名', {
325
+ skillName,
326
+ scriptName,
327
+ });
328
+ return null;
329
+ }
330
+ const openclawHome = await this.resolveOpenClawHome(containerId);
331
+ const scriptPath = buildOpenClawPath(openclawHome, 'skills', skillName, 'scripts', scriptName);
332
+ this.logger.info('OpenClawClient: 执行技能脚本', {
333
+ containerId,
334
+ skillName,
335
+ scriptPath,
336
+ });
337
+ const result = await this.dockerExec.executeCommand(containerId, ['sh', scriptPath], { user: 'node', timeout: 30000 });
338
+ if (result.success) {
339
+ this.logger.info('OpenClawClient: 脚本执行完成', {
340
+ containerId,
341
+ skillName,
342
+ outputLength: result.stdout.length,
343
+ durationMs: result.durationMs,
344
+ });
345
+ }
346
+ else {
347
+ this.logger.error('OpenClawClient: 脚本执行失败', {
348
+ containerId,
349
+ skillName,
350
+ stderr: result.stderr,
351
+ durationMs: result.durationMs,
352
+ });
353
+ }
354
+ return { stdout: result.stdout, success: result.success };
355
+ }
356
+ /**
357
+ * 通过 WebSocket 发送消息并获取响应
358
+ * 使用 OpenClaw Gateway 协议:
359
+ * 1. 连接后发送 connect 请求进行认证
360
+ * 2. 收到 hello-ok 后发送 chat.send 请求
361
+ * 3. 监听 chat 事件获取响应
362
+ * @param sessionKey 会话标识,用于隔离不同会话的对话历史,默认 'main'
363
+ * @param agentId Agent 标识,用于多 Agent 模式下指定目标 Agent
364
+ */
365
+ sendMessageViaWebSocket(port, token, message, _context, sessionKey = 'main', agentId) {
366
+ return new Promise((resolve, reject) => {
367
+ // OpenClaw gateway WebSocket 端点(使用 GATEWAY_HOST 环境变量)
368
+ const host = (0, env_config_service_1.getEnvWithDefault)('GATEWAY_HOST', 'localhost');
369
+ const wsUrl = `ws://${host}:${port}`;
370
+ this.logger.info('OpenClawClient: 建立 WebSocket 连接', {
371
+ port,
372
+ host,
373
+ url: wsUrl,
374
+ });
375
+ // OpenClaw gateway 需要 Origin 和 User-Agent 头
376
+ const ws = new ws_1.default(wsUrl, {
377
+ origin: `http://${host}:${port}`,
378
+ headers: {
379
+ 'User-Agent': 'ClawbotManager/1.0',
380
+ },
381
+ });
382
+ let responseText = '';
383
+ let isResolved = false;
384
+ let isConnected = false;
385
+ let requestId = 0;
386
+ let connectRequestId = '';
387
+ let chatRequestId = '';
388
+ const generateId = () => `req-${++requestId}-${(0, crypto_1.randomUUID)().slice(0, 8)}`;
389
+ const timeoutId = setTimeout(() => {
390
+ if (!isResolved) {
391
+ isResolved = true;
392
+ ws.close();
393
+ reject(new Error('WebSocket 响应超时'));
394
+ }
395
+ }, this.wsTimeout);
396
+ // 发送请求帧
397
+ const sendRequest = (method, params) => {
398
+ const frame = {
399
+ type: 'req',
400
+ id: generateId(),
401
+ method,
402
+ params,
403
+ };
404
+ this.logger.info('OpenClawClient: 发送请求', { method, id: frame.id });
405
+ ws.send(JSON.stringify(frame));
406
+ return frame.id;
407
+ };
408
+ ws.on('open', () => {
409
+ this.logger.info('OpenClawClient: WebSocket 连接已建立', { port });
410
+ // 第一步:发送 connect 请求进行认证
411
+ connectRequestId = sendRequest('connect', {
412
+ minProtocol: 3,
413
+ maxProtocol: 3,
414
+ client: {
415
+ id: 'gateway-client',
416
+ version: '1.0.0',
417
+ platform: 'node',
418
+ mode: 'backend',
419
+ },
420
+ auth: {
421
+ token: token,
422
+ },
423
+ });
424
+ });
425
+ ws.on('message', (data) => {
426
+ try {
427
+ const frame = JSON.parse(data.toString());
428
+ this.logger.info('OpenClawClient: 收到消息', {
429
+ type: frame.type,
430
+ event: frame.event,
431
+ ok: frame.ok,
432
+ id: frame.id,
433
+ payload: frame.payload
434
+ ? JSON.stringify(frame.payload).substring(0, 500)
435
+ : undefined,
436
+ });
437
+ // 处理 hello-ok 响应(connect 成功 - 旧协议)
438
+ if (frame.type === 'hello-ok') {
439
+ isConnected = true;
440
+ this.logger.info('OpenClawClient: 认证成功 (hello-ok)', { port });
441
+ // 第二步:发送聊天消息
442
+ const chatParams = {
443
+ sessionKey: sessionKey,
444
+ message: message,
445
+ idempotencyKey: (0, crypto_1.randomUUID)(),
446
+ };
447
+ // 多 Agent 模式:指定目标 Agent
448
+ if (agentId) {
449
+ chatParams.agentId = agentId;
450
+ }
451
+ chatRequestId = sendRequest('chat.send', chatParams);
452
+ return;
453
+ }
454
+ // 处理响应帧
455
+ if (frame.type === 'res') {
456
+ // connect 请求成功响应
457
+ if (frame.id === connectRequestId && frame.ok && !isConnected) {
458
+ isConnected = true;
459
+ this.logger.info('OpenClawClient: 认证成功 (res)', { port });
460
+ // 第二步:发送聊天消息
461
+ const chatParams = {
462
+ sessionKey: sessionKey,
463
+ message: message,
464
+ idempotencyKey: (0, crypto_1.randomUUID)(),
465
+ };
466
+ // 多 Agent 模式:指定目标 Agent
467
+ if (agentId) {
468
+ chatParams.agentId = agentId;
469
+ }
470
+ chatRequestId = sendRequest('chat.send', chatParams);
471
+ return;
472
+ }
473
+ // chat.send 请求成功响应
474
+ if (frame.id === chatRequestId && frame.ok) {
475
+ this.logger.info('OpenClawClient: chat.send 请求成功', { port });
476
+ // 等待 chat 事件返回响应
477
+ return;
478
+ }
479
+ // 错误响应
480
+ if (!frame.ok && frame.error) {
481
+ this.logger.error('OpenClawClient: 请求失败', {
482
+ error: frame.error,
483
+ });
484
+ if (!isResolved) {
485
+ isResolved = true;
486
+ clearTimeout(timeoutId);
487
+ ws.close();
488
+ reject(new Error(frame.error.message || 'Request failed'));
489
+ }
490
+ }
491
+ return;
492
+ }
493
+ // 处理事件帧(聊天响应)
494
+ if (frame.type === 'event') {
495
+ const { event } = frame;
496
+ // payload 可能是 JSON 字符串,需要解析
497
+ let payload;
498
+ try {
499
+ payload =
500
+ typeof frame.payload === 'string'
501
+ ? JSON.parse(frame.payload)
502
+ : frame.payload;
503
+ }
504
+ catch {
505
+ this.logger.warn('OpenClawClient: 解析 payload 失败', {
506
+ payload: String(frame.payload).slice(0, 200),
507
+ });
508
+ payload = undefined;
509
+ }
510
+ // 处理 agent 事件(流式文本)
511
+ // 格式: { stream: 'assistant', data: { text: '...' } }
512
+ if (event === 'agent' && payload?.stream === 'assistant') {
513
+ const data = payload.data;
514
+ if (data?.text) {
515
+ // 流式文本累积 - 但 agent 事件发送的是累积文本,不是增量
516
+ // 所以我们只保存最新的完整文本
517
+ responseText = data.text;
518
+ this.logger.debug('OpenClawClient: 收到 agent 流式文本', {
519
+ textLength: responseText.length,
520
+ });
521
+ }
522
+ return;
523
+ }
524
+ // 处理 agent lifecycle 事件(结束信号)
525
+ // 格式: { stream: 'lifecycle', data: { phase: 'end' } }
526
+ if (event === 'agent' && payload?.stream === 'lifecycle') {
527
+ const data = payload.data;
528
+ if (data?.phase === 'end') {
529
+ this.logger.info('OpenClawClient: agent 生命周期结束', {
530
+ responseLength: responseText.length,
531
+ });
532
+ }
533
+ // 不在这里 resolve,等待 chat 事件的 final 状态
534
+ return;
535
+ }
536
+ // 处理 chat 事件(最终结果)
537
+ // 格式: { state: 'final', message: { role: 'assistant', content: [{ type: 'text', text: '...' }] } }
538
+ if (event === 'chat') {
539
+ const chatEvent = payload;
540
+ this.logger.info('OpenClawClient: 处理 chat 事件', {
541
+ state: chatEvent?.state,
542
+ hasMessage: !!chatEvent?.message,
543
+ currentResponseLength: responseText.length,
544
+ });
545
+ // 处理 final 状态
546
+ if (chatEvent?.state === 'final') {
547
+ // 尝试从 message.content 提取最终文本
548
+ const message = chatEvent?.message;
549
+ if (message?.content && Array.isArray(message.content)) {
550
+ let finalText = '';
551
+ for (const item of message.content) {
552
+ if (item.type === 'text' && item.text) {
553
+ finalText += item.text;
554
+ }
555
+ }
556
+ if (finalText) {
557
+ responseText = finalText;
558
+ }
559
+ }
560
+ this.logger.info('OpenClawClient: 聊天完成 (final)', {
561
+ finalResponseLength: responseText.length,
562
+ responsePreview: responseText.substring(0, 200),
563
+ });
564
+ // 无论是否有 message,final 状态都应该 resolve
565
+ if (!isResolved) {
566
+ isResolved = true;
567
+ clearTimeout(timeoutId);
568
+ ws.close();
569
+ resolve(responseText);
570
+ }
571
+ return;
572
+ }
573
+ // 兼容旧格式: payload.type === 'text' / 'result' / 'error'
574
+ if (chatEvent?.type === 'text' && chatEvent?.text) {
575
+ const text = String(chatEvent.text);
576
+ responseText += text;
577
+ this.logger.debug('OpenClawClient: 累积响应文本 (旧格式)', {
578
+ addedLength: text.length,
579
+ totalLength: responseText.length,
580
+ });
581
+ }
582
+ else if (chatEvent?.type === 'result') {
583
+ this.logger.info('OpenClawClient: 聊天完成 (result)', {
584
+ finalResponseLength: responseText.length,
585
+ responsePreview: responseText.substring(0, 200),
586
+ });
587
+ if (!isResolved) {
588
+ isResolved = true;
589
+ clearTimeout(timeoutId);
590
+ ws.close();
591
+ resolve(responseText);
592
+ }
593
+ }
594
+ else if (chatEvent?.type === 'error') {
595
+ const errorMessage = String(chatEvent.message || 'Chat error');
596
+ this.logger.error('OpenClawClient: 聊天错误', {
597
+ errorMessage,
598
+ });
599
+ if (!isResolved) {
600
+ isResolved = true;
601
+ clearTimeout(timeoutId);
602
+ ws.close();
603
+ reject(new Error(errorMessage));
604
+ }
605
+ }
606
+ }
607
+ return;
608
+ }
609
+ }
610
+ catch (e) {
611
+ this.logger.warn('OpenClawClient: 解析消息失败', {
612
+ error: e instanceof Error ? e.message : 'Unknown error',
613
+ data: data.toString().slice(0, 200),
614
+ });
615
+ }
616
+ });
617
+ ws.on('error', (error) => {
618
+ this.logger.error('OpenClawClient: WebSocket 错误', {
619
+ port,
620
+ error: error.message,
621
+ });
622
+ if (!isResolved) {
623
+ isResolved = true;
624
+ clearTimeout(timeoutId);
625
+ reject(error);
626
+ }
627
+ });
628
+ ws.on('close', (code, reason) => {
629
+ this.logger.debug('OpenClawClient: WebSocket 连接关闭', {
630
+ port,
631
+ code,
632
+ reason: reason.toString(),
633
+ isConnected,
634
+ });
635
+ if (!isResolved) {
636
+ isResolved = true;
637
+ clearTimeout(timeoutId);
638
+ // 如果有响应文本,返回它
639
+ if (responseText) {
640
+ resolve(responseText);
641
+ }
642
+ else if (code === 1008) {
643
+ // 1008 = Policy Violation (unauthorized)
644
+ reject(new Error(`WebSocket 认证失败: ${reason.toString() || 'gateway token missing'}`));
645
+ }
646
+ else {
647
+ reject(new Error(`WebSocket 连接意外关闭: code=${code}, reason=${reason.toString()}`));
648
+ }
649
+ }
650
+ });
651
+ });
652
+ }
653
+ /**
654
+ * 检查 OpenClaw Gateway 健康状态
655
+ * 使用 GATEWAY_HOST(默认 localhost);Docker 部署时需设为 host.docker.internal 以便 API 容器访问宿主机上的 Gateway 端口
656
+ */
657
+ async checkHealth(port) {
658
+ const host = (0, env_config_service_1.getEnvWithDefault)('GATEWAY_HOST', 'localhost');
659
+ const url = `http://${host}:${port}/health`;
660
+ try {
661
+ const response = await (0, rxjs_1.firstValueFrom)(this.httpService.get(url).pipe((0, rxjs_1.timeout)(5000), (0, rxjs_1.catchError)(() => {
662
+ return Promise.resolve({ status: 500 });
663
+ })));
664
+ return response.status === 200;
665
+ }
666
+ catch {
667
+ return false;
668
+ }
669
+ }
670
+ /**
671
+ * 通过 Docker exec 获取容器内安装的技能列表
672
+ * 优先使用 `openclaw skills list --json`,失败则 fallback 到读取配置文件
673
+ * 获取技能列表后,还会尝试读取每个技能的 SKILL.md 内容
674
+ * @param containerId Docker 容器 ID
675
+ * @returns 技能列表或 null(exec 失败时)
676
+ */
677
+ async listContainerSkills(containerId) {
678
+ this.logger.info('OpenClawClient: 获取容器内置技能', { containerId });
679
+ const openclawHome = await this.resolveOpenClawHome(containerId);
680
+ const configPath = buildOpenClawPath(openclawHome, 'openclaw.json');
681
+ // 尝试 CLI 命令
682
+ const cliOutput = await this.execInContainer(containerId, [
683
+ 'node',
684
+ '/app/openclaw.mjs',
685
+ 'skills',
686
+ 'list',
687
+ '--json',
688
+ ]);
689
+ let skills = null;
690
+ if (cliOutput) {
691
+ try {
692
+ // CLI 可能在 JSON 前输出 Doctor warnings 等非 JSON 内容,尝试提取 JSON 部分
693
+ const jsonStart = cliOutput.search(/[[]{]/);
694
+ const jsonStr = jsonStart >= 0 ? cliOutput.slice(jsonStart) : cliOutput;
695
+ const parsed = JSON.parse(jsonStr);
696
+ skills = this.normalizeSkillsList(parsed);
697
+ }
698
+ catch {
699
+ // CLI 可能只输出 Doctor warnings 而无 JSON(正常情况),fallback 到配置文件
700
+ this.logger.debug('OpenClawClient: CLI 输出无有效 JSON,尝试读取配置文件', {
701
+ containerId,
702
+ outputPreview: cliOutput.substring(0, 200),
703
+ });
704
+ }
705
+ }
706
+ // Fallback 1: 读取容器内的 openclaw.json 配置
707
+ if (!skills) {
708
+ const configOutput = await this.execInContainer(containerId, [
709
+ 'cat',
710
+ configPath,
711
+ ]);
712
+ if (configOutput) {
713
+ try {
714
+ const config = JSON.parse(configOutput);
715
+ const parsed = this.parseSkillsFromConfig(config);
716
+ // 只有解析到真实技能列表时才使用(排除 nativeSkills:auto 的占位符)
717
+ if (parsed.length > 0 && parsed[0].name !== 'native-skills') {
718
+ skills = parsed;
719
+ }
720
+ }
721
+ catch {
722
+ this.logger.warn('OpenClawClient: 配置文件解析失败', { containerId });
723
+ }
724
+ }
725
+ }
726
+ // Fallback 2: 直接列举 /app/skills/ 目录(处理 nativeSkills:auto 场景)
727
+ if (!skills) {
728
+ const lsOutput = await this.execInContainer(containerId, [
729
+ 'sh',
730
+ '-c',
731
+ 'ls /app/skills/ 2>/dev/null',
732
+ ]);
733
+ if (lsOutput) {
734
+ const names = lsOutput
735
+ .trim()
736
+ .split('\n')
737
+ .map((n) => n.trim())
738
+ .filter(Boolean);
739
+ if (names.length > 0) {
740
+ skills = names.map((name) => ({
741
+ name,
742
+ enabled: true,
743
+ description: null,
744
+ version: null,
745
+ content: null,
746
+ }));
747
+ this.logger.info('OpenClawClient: 从 /app/skills/ 获取内置技能列表', {
748
+ containerId,
749
+ count: skills.length,
750
+ });
751
+ }
752
+ }
753
+ }
754
+ if (!skills)
755
+ return null;
756
+ // 批量读取每个技能的 SKILL.md 内容
757
+ await this.enrichSkillsWithContent(containerId, skills);
758
+ return skills;
759
+ }
760
+ /**
761
+ * 在容器内执行命令并返回 stdout 输出
762
+ * 使用 DockerExecService 统一处理
763
+ */
764
+ async execInContainer(containerId, cmd) {
765
+ const result = await this.dockerExec.executeCommand(containerId, cmd, {
766
+ timeout: 15000,
767
+ });
768
+ return result.success ? result.stdout : null;
769
+ }
770
+ async resolveOpenClawHome(containerId) {
771
+ // OpenClaw 在 $OPENCLAW_HOME/.openclaw/ 下读写配置,
772
+ // 所以需要拼接 /.openclaw 后缀得到实际配置目录
773
+ const result = await this.dockerExec.executeCommand(containerId, [
774
+ 'sh',
775
+ '-lc',
776
+ 'printf %s "${OPENCLAW_HOME:-/home/node}/.openclaw"',
777
+ ]);
778
+ return resolveOpenClawHomeFromEnv(result.stdout);
779
+ }
780
+ /**
781
+ * 标准化技能列表输出
782
+ * 处理不同格式:数组、{ skills: [] }、{ builtin: {} } 等
783
+ */
784
+ normalizeSkillsList(parsed) {
785
+ if (Array.isArray(parsed)) {
786
+ return parsed.map((item) => ({
787
+ name: String(item.name || item.slug || 'unknown'),
788
+ enabled: item.enabled !== false,
789
+ description: item.description || null,
790
+ version: item.version || null,
791
+ content: null,
792
+ }));
793
+ }
794
+ if (parsed && typeof parsed === 'object') {
795
+ const obj = parsed;
796
+ // { skills: [...] }
797
+ if (Array.isArray(obj.skills)) {
798
+ return this.normalizeSkillsList(obj.skills);
799
+ }
800
+ // { builtin: { skill_name: true/false, ... } }
801
+ if (obj.builtin && typeof obj.builtin === 'object') {
802
+ return Object.entries(obj.builtin).map(([name, enabled]) => ({
803
+ name,
804
+ enabled: enabled !== false,
805
+ description: null,
806
+ version: null,
807
+ content: null,
808
+ }));
809
+ }
810
+ }
811
+ return [];
812
+ }
813
+ /**
814
+ * 从 openclaw.json 配置中解析技能信息
815
+ */
816
+ parseSkillsFromConfig(config) {
817
+ if (!config || typeof config !== 'object')
818
+ return [];
819
+ const cfg = config;
820
+ // 尝试 skills.builtin 路径
821
+ const skills = cfg.skills;
822
+ if (skills?.builtin && typeof skills.builtin === 'object') {
823
+ return Object.entries(skills.builtin).map(([name, enabled]) => ({
824
+ name,
825
+ enabled: enabled !== false,
826
+ description: null,
827
+ version: null,
828
+ content: null,
829
+ }));
830
+ }
831
+ // 尝试 commands.nativeSkills 路径
832
+ const commands = cfg.commands;
833
+ if (commands?.nativeSkills && commands.nativeSkills !== 'auto') {
834
+ if (typeof commands.nativeSkills === 'object' &&
835
+ !Array.isArray(commands.nativeSkills)) {
836
+ return Object.entries(commands.nativeSkills).map(([name, enabled]) => ({
837
+ name,
838
+ enabled: enabled !== false,
839
+ description: null,
840
+ version: null,
841
+ content: null,
842
+ }));
843
+ }
844
+ }
845
+ // nativeSkills: "auto" 表示全部启用,但无法获取具体列表
846
+ if (commands?.nativeSkills === 'auto') {
847
+ return [
848
+ {
849
+ name: 'native-skills',
850
+ enabled: true,
851
+ description: 'All native skills enabled (auto mode)',
852
+ version: null,
853
+ content: null,
854
+ },
855
+ ];
856
+ }
857
+ return [];
858
+ }
859
+ /**
860
+ * 注入 MCP Server 配置到 OpenClaw 容器的 openclaw.json
861
+ * 在插件安装后,将 mcpConfig 实际注入到容器的配置文件中
862
+ * @param containerId Docker 容器 ID
863
+ * @param mcpServers Record<string, McpServerConfig>
864
+ * McpServerConfig 格式:{ "plugin-slug": { "command": "npx", "args": [...], "env": {...} }
865
+ * 注意:这会合并(而非覆盖)openclaw.json 中的 mcpServers 配置
866
+ */
867
+ async injectMcpConfig(containerId, mcpServers) {
868
+ this.logger.info('OpenClawClient: 注入 MCP 配置', { containerId });
869
+ const openclawHome = await this.resolveOpenClawHome(containerId);
870
+ const configPath = buildOpenClawPath(openclawHome, 'openclaw.json');
871
+ // P0-3: 使用 Base64 编码传递数据,彻底消除 JSON 注入风险
872
+ const encoded = Buffer.from(JSON.stringify(mcpServers)).toString('base64');
873
+ const nodeScript = `
874
+ const fs = require("fs");
875
+ const configPath = ${JSON.stringify(configPath)};
876
+ const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
877
+ config.mcpServers = config.mcpServers || {};
878
+ const newServers = JSON.parse(Buffer.from("${encoded}", "base64").toString("utf8"));
879
+ for (const [name, server] of Object.entries(newServers)) {
880
+ config.mcpServers[name] = server;
881
+ }
882
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf8");
883
+ console.log(JSON.stringify({ success: true }));
884
+ `;
885
+ const result = await this.dockerExec.executeNodeScript(containerId, nodeScript, { timeout: 15000, throwOnError: true });
886
+ this.logger.info('OpenClawClient: MCP 配置注入完成', {
887
+ containerId,
888
+ plugins: Object.keys(mcpServers),
889
+ output: result.stdout,
890
+ durationMs: result.durationMs,
891
+ });
892
+ }
893
+ /**
894
+ * 移除指定 MCP Server 配置
895
+ * @param containerId Docker 容器 ID
896
+ * @param serverName MCP Server plugin slug(如 "mcp-server-slack")
897
+ */
898
+ async removeMcpConfig(containerId, serverName) {
899
+ this.logger.info('OpenClawClient: 移除 MCP 配置', {
900
+ containerId,
901
+ serverName,
902
+ });
903
+ // 安全校验:只允许合法字符(防止 shell 注入)
904
+ if (!this.dockerExec.isValidName(serverName)) {
905
+ throw new Error(`Invalid server name: ${serverName}`);
906
+ }
907
+ const openclawHome = await this.resolveOpenClawHome(containerId);
908
+ const configPath = buildOpenClawPath(openclawHome, 'openclaw.json');
909
+ // 构建 node 脚本:读取 openclaw.json,删除指定 mcpServers,写回文件
910
+ // 使用引号包裹属性名,避免注入风险
911
+ const nodeScript = `
912
+ const fs = require("fs");
913
+ const configPath = ${JSON.stringify(configPath)};
914
+ const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
915
+ if (config.mcpServers) {
916
+ delete config.mcpServers["${serverName}"];
917
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf8");
918
+ console.log(JSON.stringify({ success: true, removed: "${serverName}" }));
919
+ } else {
920
+ console.log(JSON.stringify({ success: true, message: "No mcpServers found" }));
921
+ }
922
+ `;
923
+ const result = await this.dockerExec.executeNodeScript(containerId, nodeScript, { timeout: 10000, throwOnError: true });
924
+ this.logger.info('OpenClawClient: MCP 配置移除完成', {
925
+ containerId,
926
+ serverName,
927
+ output: result.stdout,
928
+ durationMs: result.durationMs,
929
+ });
930
+ }
931
+ // ============================================================================
932
+ // Agent 管理(Gateway Pool 模式)
933
+ // ============================================================================
934
+ /**
935
+ * 向 Gateway 注册一个 Agent
936
+ * 通过 HTTP POST /agents 接口注册
937
+ * @param port Gateway 端口
938
+ * @param token Gateway 认证 token
939
+ * @param agentConfig Agent 配置
940
+ */
941
+ async registerAgent(port, token, agentConfig) {
942
+ const primaryUrl = `http://localhost:${port}/agents`;
943
+ const fallbackUrl = `http://localhost:${port}/agents/${agentConfig.agentId}`;
944
+ const classifyErrorCategory = (status, code) => {
945
+ if (status === 404 || status === 405)
946
+ return 'HTTP_4XX_PROTOCOL';
947
+ if (status === 401 || status === 403)
948
+ return 'HTTP_4XX_AUTH';
949
+ if (status === 409)
950
+ return 'HTTP_4XX_CONFLICT';
951
+ if (status !== undefined && status >= 400 && status < 500) {
952
+ return 'HTTP_4XX_OTHER';
953
+ }
954
+ if (status !== undefined && status >= 500)
955
+ return 'HTTP_5XX';
956
+ if (code === 'ECONNABORTED')
957
+ return 'NETWORK_TIMEOUT';
958
+ if (['ECONNREFUSED', 'ENOTFOUND', 'EHOSTUNREACH'].includes(code || '')) {
959
+ return 'NETWORK_UNREACHABLE';
960
+ }
961
+ if (code === 'ECONNRESET')
962
+ return 'NETWORK_RESET';
963
+ return 'UNKNOWN';
964
+ };
965
+ const extractError = (error) => {
966
+ const status = error?.response?.status;
967
+ const code = error?.code;
968
+ const message = error instanceof Error ? error.message : String(error);
969
+ return {
970
+ status,
971
+ code,
972
+ message,
973
+ category: classifyErrorCategory(status, code),
974
+ };
975
+ };
976
+ this.logger.info('OpenClawClient: 注册 Agent', {
977
+ port,
978
+ agentId: agentConfig.agentId,
979
+ endpoint: 'POST /agents',
980
+ });
981
+ try {
982
+ const response = await (0, rxjs_1.firstValueFrom)(this.httpService.post(primaryUrl, agentConfig, {
983
+ headers: { Authorization: `Bearer ${token}` },
984
+ timeout: 10000,
985
+ }));
986
+ return {
987
+ success: response.status === 200 ||
988
+ response.status === 201 ||
989
+ response.status === 204,
990
+ httpStatus: response.status,
991
+ attemptedEndpoint: 'POST /agents',
992
+ };
993
+ }
994
+ catch (primaryError) {
995
+ const primary = extractError(primaryError);
996
+ this.logger.warn('OpenClawClient: 注册 Agent 失败', {
997
+ stage: 'primary',
998
+ port,
999
+ agentId: agentConfig.agentId,
1000
+ status: primary.status,
1001
+ code: primary.code,
1002
+ errorCategory: primary.category,
1003
+ error: primary.message,
1004
+ endpoint: 'POST /agents',
1005
+ });
1006
+ if (primary.status === 409) {
1007
+ this.logger.info('OpenClawClient: Agent 已存在,按幂等成功处理', {
1008
+ port,
1009
+ agentId: agentConfig.agentId,
1010
+ endpoint: 'POST /agents',
1011
+ });
1012
+ return {
1013
+ success: true,
1014
+ httpStatus: 409,
1015
+ errorCategory: 'HTTP_4XX_CONFLICT',
1016
+ attemptedEndpoint: 'POST /agents',
1017
+ fallbackUsed: false,
1018
+ };
1019
+ }
1020
+ if (primary.status !== 404 && primary.status !== 405) {
1021
+ return {
1022
+ success: false,
1023
+ error: primary.message,
1024
+ httpStatus: primary.status,
1025
+ errorCategory: primary.category,
1026
+ attemptedEndpoint: 'POST /agents',
1027
+ fallbackUsed: false,
1028
+ };
1029
+ }
1030
+ this.logger.info('OpenClawClient: register.fallback.start', {
1031
+ port,
1032
+ agentId: agentConfig.agentId,
1033
+ reasonStatus: primary.status,
1034
+ fromEndpoint: 'POST /agents',
1035
+ toEndpoint: 'PUT /agents/:agentId',
1036
+ });
1037
+ try {
1038
+ const response = await (0, rxjs_1.firstValueFrom)(this.httpService.put(fallbackUrl, {
1039
+ name: agentConfig.name,
1040
+ persona: agentConfig.persona,
1041
+ workspaceDir: agentConfig.workspaceDir,
1042
+ }, {
1043
+ headers: { Authorization: `Bearer ${token}` },
1044
+ timeout: 10000,
1045
+ }));
1046
+ this.logger.info('OpenClawClient: register.fallback.success', {
1047
+ port,
1048
+ agentId: agentConfig.agentId,
1049
+ status: response.status,
1050
+ endpoint: 'PUT /agents/:agentId',
1051
+ });
1052
+ return {
1053
+ success: response.status === 200 ||
1054
+ response.status === 201 ||
1055
+ response.status === 204,
1056
+ httpStatus: response.status,
1057
+ attemptedEndpoint: 'PUT /agents/:agentId',
1058
+ fallbackUsed: true,
1059
+ };
1060
+ }
1061
+ catch (fallbackError) {
1062
+ const fallback = extractError(fallbackError);
1063
+ this.logger.warn('OpenClawClient: 注册 Agent 失败', {
1064
+ stage: 'fallback',
1065
+ port,
1066
+ agentId: agentConfig.agentId,
1067
+ status: fallback.status,
1068
+ code: fallback.code,
1069
+ errorCategory: fallback.category,
1070
+ error: fallback.message,
1071
+ endpoint: 'PUT /agents/:agentId',
1072
+ });
1073
+ if (fallback.status === 409) {
1074
+ this.logger.info('OpenClawClient: Agent 已存在,回退路径按幂等成功处理', {
1075
+ port,
1076
+ agentId: agentConfig.agentId,
1077
+ endpoint: 'PUT /agents/:agentId',
1078
+ });
1079
+ return {
1080
+ success: true,
1081
+ httpStatus: 409,
1082
+ errorCategory: 'HTTP_4XX_CONFLICT',
1083
+ attemptedEndpoint: 'PUT /agents/:agentId',
1084
+ fallbackUsed: true,
1085
+ };
1086
+ }
1087
+ // 兼容模式:当回退也返回 405/404 时,视为网关不支持 Agent 注册 API(单 Agent 模式,Agent 已就绪)
1088
+ if (fallback.status === 405 || fallback.status === 404) {
1089
+ this.logger.info('OpenClawClient: 注册 Agent 返回兼容模式', {
1090
+ port,
1091
+ agentId: agentConfig.agentId,
1092
+ compatibilityMode: 'registration-not-supported',
1093
+ primaryStatus: primary.status,
1094
+ fallbackStatus: fallback.status,
1095
+ note: '该网关版本不支持 Agent 注册 API,按已就绪处理',
1096
+ });
1097
+ return {
1098
+ success: true,
1099
+ httpStatus: fallback.status,
1100
+ errorCategory: 'HTTP_4XX_PROTOCOL',
1101
+ attemptedEndpoint: 'PUT /agents/:agentId',
1102
+ fallbackUsed: true,
1103
+ compatibilityMode: 'registration-not-supported',
1104
+ };
1105
+ }
1106
+ return {
1107
+ success: false,
1108
+ error: fallback.message,
1109
+ httpStatus: fallback.status,
1110
+ errorCategory: fallback.category,
1111
+ attemptedEndpoint: 'PUT /agents/:agentId',
1112
+ fallbackUsed: true,
1113
+ };
1114
+ }
1115
+ }
1116
+ }
1117
+ /**
1118
+ * 通过 CLI 命令注册 Agent(用于 Pool 模式)
1119
+ * 使用 docker exec 执行 openclaw agents add 命令
1120
+ * @param containerId Gateway 容器 ID
1121
+ * @param agentConfig Agent 配置
1122
+ */
1123
+ async registerAgentViaCli(containerId, agentConfig) {
1124
+ this.logger.info('OpenClawClient: 通过 CLI 注册 Agent', {
1125
+ containerId,
1126
+ agentId: agentConfig.agentId,
1127
+ });
1128
+ try {
1129
+ // 构建 openclaw agents add 命令
1130
+ const args = [
1131
+ 'agents',
1132
+ 'add',
1133
+ agentConfig.name,
1134
+ '--non-interactive',
1135
+ '--json',
1136
+ ];
1137
+ if (agentConfig.workspaceDir) {
1138
+ args.push('--workspace', agentConfig.workspaceDir);
1139
+ }
1140
+ // 执行命令(增加超时时间到 60 秒,因为 agent 注册可能需要创建 workspace)
1141
+ // 指定 user: 'node' 确保创建的 workspace 目录权限正确
1142
+ const result = await this.dockerExec.executeCommand(containerId, ['openclaw', ...args], { timeout: 60000, user: 'node' });
1143
+ const { stdout, stderr, success } = result;
1144
+ // 检查执行是否成功
1145
+ if (!success) {
1146
+ this.logger.warn('OpenClawClient: Agent 注册失败(命令执行失败)', {
1147
+ containerId,
1148
+ agentId: agentConfig.agentId,
1149
+ stderr,
1150
+ });
1151
+ return { success: false, error: stderr || 'Command execution failed' };
1152
+ }
1153
+ // 解析 JSON 输出
1154
+ try {
1155
+ const parsed = JSON.parse(stdout);
1156
+ this.logger.info('OpenClawClient: Agent 注册成功', {
1157
+ containerId,
1158
+ agentId: agentConfig.agentId,
1159
+ result: parsed,
1160
+ });
1161
+ return { success: true };
1162
+ }
1163
+ catch {
1164
+ // 如果不是 JSON,检查 stderr 是否包含真正的错误
1165
+ // OpenClaw 会将配置更新信息输出到 stderr,这不是错误
1166
+ const isRealError = stderr &&
1167
+ (stderr.includes('Error:') ||
1168
+ stderr.includes('error:') ||
1169
+ stderr.includes('failed') ||
1170
+ stderr.includes('Failed'));
1171
+ if (isRealError) {
1172
+ this.logger.warn('OpenClawClient: Agent 注册失败', {
1173
+ containerId,
1174
+ agentId: agentConfig.agentId,
1175
+ stderr,
1176
+ });
1177
+ return { success: false, error: stderr };
1178
+ }
1179
+ // 没有真正的错误,认为成功
1180
+ this.logger.info('OpenClawClient: Agent 注册成功(无 JSON 输出)', {
1181
+ containerId,
1182
+ agentId: agentConfig.agentId,
1183
+ stderr: stderr ? '有配置更新信息' : '无',
1184
+ });
1185
+ return { success: true };
1186
+ }
1187
+ }
1188
+ catch (error) {
1189
+ this.logger.error('OpenClawClient: Agent 注册失败', {
1190
+ containerId,
1191
+ agentId: agentConfig.agentId,
1192
+ error: error instanceof Error ? error.message : String(error),
1193
+ });
1194
+ return {
1195
+ success: false,
1196
+ error: error instanceof Error ? error.message : 'Unknown error',
1197
+ };
1198
+ }
1199
+ }
1200
+ /**
1201
+ * 通过 HTTP PUT /agents/:agentId 向运行中的 Gateway 发送 Agent 配置更新请求(best-effort)
1202
+ *
1203
+ * 说明:
1204
+ * - 该方法仅转发增量更新请求
1205
+ * - 是否被运行中 Gateway 接受并立即生效,取决于 OpenClaw 当前版本能力
1206
+ * - 调用方不应依赖此方法替代 DB 驱动的配置产物重建与生命周期链路
1207
+ *
1208
+ * @param port Gateway 端口
1209
+ * @param token Gateway 认证 token
1210
+ * @param agentId Agent ID
1211
+ * @param updates 需要更新的配置字段
1212
+ */
1213
+ async hotReloadAgent(port, token, agentId, updates) {
1214
+ const url = `http://localhost:${port}/agents/${agentId}`;
1215
+ this.logger.info('OpenClawClient: sending Agent update request', {
1216
+ port,
1217
+ agentId,
1218
+ });
1219
+ try {
1220
+ const response = await (0, rxjs_1.firstValueFrom)(this.httpService
1221
+ .put(url, updates, {
1222
+ headers: { Authorization: `Bearer ${token}` },
1223
+ timeout: 10000,
1224
+ })
1225
+ .pipe((0, rxjs_1.catchError)((error) => {
1226
+ this.logger.warn('OpenClawClient: Agent update request failed', {
1227
+ port,
1228
+ agentId,
1229
+ error: error.message,
1230
+ });
1231
+ throw error;
1232
+ })));
1233
+ return { success: response.status === 200 };
1234
+ }
1235
+ catch (error) {
1236
+ return {
1237
+ success: false,
1238
+ error: error instanceof Error ? error.message : 'Unknown error',
1239
+ };
1240
+ }
1241
+ }
1242
+ /**
1243
+ * 从 Gateway 注销 Agent
1244
+ * 通过 HTTP DELETE /agents/:agentId 接口注销
1245
+ * @deprecated 推荐使用 unregisterAgentViaCli 以避免 404 错误
1246
+ * @param port Gateway 端口
1247
+ * @param token Gateway 认证 token
1248
+ * @param agentId Agent ID
1249
+ */
1250
+ async unregisterAgent(port, token, agentId) {
1251
+ const url = `http://localhost:${port}/agents/${agentId}`;
1252
+ this.logger.info('OpenClawClient: 注销 Agent', { port, agentId });
1253
+ try {
1254
+ const response = await (0, rxjs_1.firstValueFrom)(this.httpService
1255
+ .delete(url, {
1256
+ headers: { Authorization: `Bearer ${token}` },
1257
+ timeout: 10000,
1258
+ })
1259
+ .pipe((0, rxjs_1.catchError)((error) => {
1260
+ this.logger.warn('OpenClawClient: 注销 Agent 失败', {
1261
+ port,
1262
+ agentId,
1263
+ error: error.message,
1264
+ });
1265
+ throw error;
1266
+ })));
1267
+ return { success: response.status === 200 || response.status === 204 };
1268
+ }
1269
+ catch (error) {
1270
+ return {
1271
+ success: false,
1272
+ error: error instanceof Error ? error.message : 'Unknown error',
1273
+ };
1274
+ }
1275
+ }
1276
+ /**
1277
+ * 通过 CLI 命令注销 Agent(用于 Pool 模式)
1278
+ * 使用 docker exec 执行 openclaw agents delete 命令
1279
+ * 相比 HTTP DELETE 方式,CLI 方式对"agent 不存在"的情况处理更优雅,不会产生 404 错误
1280
+ * @param containerId Gateway 容器 ID
1281
+ * @param agentId Agent ID
1282
+ */
1283
+ async unregisterAgentViaCli(containerId, agentId) {
1284
+ this.logger.info('OpenClawClient: 通过 CLI 注销 Agent', {
1285
+ containerId,
1286
+ agentId,
1287
+ });
1288
+ try {
1289
+ // 构建 openclaw agents delete 命令
1290
+ const args = ['agents', 'delete', agentId, '--force', '--json'];
1291
+ // 执行命令
1292
+ const result = await this.dockerExec.executeCommand(containerId, [
1293
+ 'openclaw',
1294
+ ...args,
1295
+ ]);
1296
+ const { stdout, stderr, success } = result;
1297
+ // 检查执行是否成功
1298
+ if (!success) {
1299
+ // 检查是否是 "agent not found" 错误
1300
+ const isNotFoundError = stderr &&
1301
+ (stderr.includes('not found') ||
1302
+ stderr.includes('does not exist') ||
1303
+ stderr.includes('No agent'));
1304
+ if (isNotFoundError) {
1305
+ // Agent 不存在,视为成功(幂等性)
1306
+ this.logger.info('OpenClawClient: Agent 已不存在,无需注销', {
1307
+ containerId,
1308
+ agentId,
1309
+ });
1310
+ return { success: true };
1311
+ }
1312
+ this.logger.warn('OpenClawClient: Agent 注销失败(命令执行失败)', {
1313
+ containerId,
1314
+ agentId,
1315
+ stderr,
1316
+ });
1317
+ return { success: false, error: stderr || 'Command execution failed' };
1318
+ }
1319
+ // 解析 JSON 输出
1320
+ try {
1321
+ const parsed = JSON.parse(stdout);
1322
+ this.logger.info('OpenClawClient: Agent 注销成功', {
1323
+ containerId,
1324
+ agentId,
1325
+ result: parsed,
1326
+ });
1327
+ return { success: true };
1328
+ }
1329
+ catch {
1330
+ // 如果不是 JSON,检查 stderr 是否包含真正的错误
1331
+ const isRealError = stderr &&
1332
+ (stderr.includes('Error:') ||
1333
+ stderr.includes('error:') ||
1334
+ stderr.includes('failed') ||
1335
+ stderr.includes('Failed'));
1336
+ if (isRealError) {
1337
+ this.logger.warn('OpenClawClient: Agent 注销失败', {
1338
+ containerId,
1339
+ agentId,
1340
+ stderr,
1341
+ });
1342
+ return { success: false, error: stderr };
1343
+ }
1344
+ // 没有真正的错误,认为成功
1345
+ this.logger.info('OpenClawClient: Agent 注销成功(无 JSON 输出)', {
1346
+ containerId,
1347
+ agentId,
1348
+ });
1349
+ return { success: true };
1350
+ }
1351
+ }
1352
+ catch (error) {
1353
+ this.logger.error('OpenClawClient: Agent 注销失败', {
1354
+ containerId,
1355
+ agentId,
1356
+ error: error instanceof Error ? error.message : String(error),
1357
+ });
1358
+ return {
1359
+ success: false,
1360
+ error: error instanceof Error ? error.message : 'Unknown error',
1361
+ };
1362
+ }
1363
+ }
1364
+ /**
1365
+ * 获取 Gateway 中的所有 Agent 列表
1366
+ * @param port Gateway 端口
1367
+ * @param token Gateway 认证 token
1368
+ */
1369
+ async listAgents(port, token) {
1370
+ this.logger.info('OpenClawClient: 获取 Agent 列表 (从配置文件)', {
1371
+ port,
1372
+ hasToken: Boolean(token),
1373
+ });
1374
+ try {
1375
+ // OpenClaw Gateway 的 /agents 端点返回 HTML 页面,不是 JSON API
1376
+ // 因此我们需要从容器内的配置文件读取 agent 信息
1377
+ // 注意: 这个方法需要 containerId,但当前签名只有 port
1378
+ // 作为临时方案,我们返回空列表,让调用方从 DB 获取信息
1379
+ // TODO: 重构此方法,接受 containerId 参数,然后从容器内读取配置文件
1380
+ // 或者使用 WebSocket 协议与 Gateway 通信获取 agent 状态
1381
+ this.logger.warn('OpenClawClient: listAgents 方法需要重构以支持从配置文件读取', { port });
1382
+ return {
1383
+ success: false,
1384
+ error: 'listAgents API endpoint not available, use config-based approach',
1385
+ };
1386
+ }
1387
+ catch (error) {
1388
+ return {
1389
+ success: false,
1390
+ error: error instanceof Error ? error.message : 'Unknown error',
1391
+ };
1392
+ }
1393
+ }
1394
+ /**
1395
+ * 从容器内的配置文件读取 Agent 列表
1396
+ * 这是 listAgents 的替代方法,直接从配置文件读取而不是调用 HTTP API
1397
+ *
1398
+ * 会合并两个来源的 agent:
1399
+ * 1. openclaw.json 的 agents.list
1400
+ * 2. preload/agents-config/ 目录下的 per-agent 配置文件
1401
+ */
1402
+ async listAgentsFromConfig(containerId) {
1403
+ this.logger.info('OpenClawClient: 从配置文件读取 Agent 列表', {
1404
+ containerId,
1405
+ });
1406
+ try {
1407
+ const openclawHome = await this.resolveOpenClawHome(containerId);
1408
+ const configPath = buildOpenClawPath(openclawHome, 'openclaw.json');
1409
+ const result = await this.dockerExec.executeCommand(containerId, [
1410
+ 'cat',
1411
+ configPath,
1412
+ ]);
1413
+ if (!result.stdout) {
1414
+ return {
1415
+ success: false,
1416
+ error: 'Gateway config read failed: openclaw.json not found or empty',
1417
+ };
1418
+ }
1419
+ const config = JSON.parse(result.stdout);
1420
+ const agentsList = config.agents?.list || [];
1421
+ const bindings = config.bindings || [];
1422
+ const agentsFromMainConfig = new Set();
1423
+ // 构建 agent 信息(从主配置)
1424
+ const agents = agentsList.map((agent) => {
1425
+ agentsFromMainConfig.add(agent.id);
1426
+ // 统计该 agent 的路由规则数量
1427
+ const routingRules = bindings.filter((b) => b.agentId === agent.id).length;
1428
+ return {
1429
+ id: agent.id,
1430
+ name: agent.name,
1431
+ model: agent.model?.primary || config.agents?.defaults?.model,
1432
+ workspace: agent.workspace || config.agents?.defaults?.workspace,
1433
+ routingRules,
1434
+ };
1435
+ });
1436
+ // Pool 模式:读取 preload/agents-config/ 目录下的 per-agent 配置文件
1437
+ const agentsConfigDir = buildOpenClawPath(openclawHome, 'preload/agents-config');
1438
+ const listDirResult = await this.dockerExec.executeCommand(containerId, [
1439
+ 'ls',
1440
+ '-1',
1441
+ agentsConfigDir,
1442
+ ]);
1443
+ if (listDirResult.stdout && listDirResult.success) {
1444
+ const agentConfigFiles = listDirResult.stdout
1445
+ .split('\n')
1446
+ .filter((f) => f.endsWith('.json'));
1447
+ for (const configFile of agentConfigFiles) {
1448
+ const agentKey = configFile.replace('.json', '');
1449
+ // 如果已经在主配置中,跳过
1450
+ if (agentsFromMainConfig.has(agentKey))
1451
+ continue;
1452
+ // 读取 per-agent 配置文件
1453
+ const agentConfigPath = `${agentsConfigDir}/${configFile}`;
1454
+ const agentConfigResult = await this.dockerExec.executeCommand(containerId, ['cat', agentConfigPath]);
1455
+ if (agentConfigResult.stdout && agentConfigResult.success) {
1456
+ try {
1457
+ const agentConfig = JSON.parse(agentConfigResult.stdout);
1458
+ // 从 per-agent 配置中提取 model 信息
1459
+ const primaryModel = agentConfig.agents?.defaults?.model?.primary;
1460
+ agents.push({
1461
+ id: agentKey,
1462
+ name: agentKey,
1463
+ model: primaryModel,
1464
+ workspace: `/app/workspace/${agentKey}`,
1465
+ routingRules: 0,
1466
+ });
1467
+ this.logger.debug('OpenClawClient: 从 per-agent 配置读取 agent', {
1468
+ containerId,
1469
+ agentKey,
1470
+ primaryModel,
1471
+ });
1472
+ }
1473
+ catch {
1474
+ this.logger.warn('OpenClawClient: 无法解析 per-agent 配置文件', {
1475
+ containerId,
1476
+ configFile,
1477
+ });
1478
+ }
1479
+ }
1480
+ }
1481
+ }
1482
+ this.logger.info('OpenClawClient: 成功读取 Agent 列表', {
1483
+ containerId,
1484
+ agentCount: agents.length,
1485
+ fromMainConfig: agentsFromMainConfig.size,
1486
+ fromPerAgentConfig: agents.length - agentsFromMainConfig.size,
1487
+ });
1488
+ return {
1489
+ success: true,
1490
+ agents,
1491
+ };
1492
+ }
1493
+ catch (error) {
1494
+ this.logger.error('OpenClawClient: 读取 Agent 列表失败', {
1495
+ containerId,
1496
+ error: error instanceof Error ? error.message : String(error),
1497
+ });
1498
+ return {
1499
+ success: false,
1500
+ error: error instanceof Error
1501
+ ? `Gateway agents list failed: ${error.message}`
1502
+ : 'Gateway agents list failed',
1503
+ };
1504
+ }
1505
+ }
1506
+ /**
1507
+ * 读取容器内 openclaw.json
1508
+ */
1509
+ async readGatewayConfig(containerId) {
1510
+ const openclawHome = await this.resolveOpenClawHome(containerId);
1511
+ const configPath = buildOpenClawPath(openclawHome, 'openclaw.json');
1512
+ const nodeScript = `
1513
+ const fs = require("fs");
1514
+ const configPath = ${JSON.stringify(configPath)};
1515
+ if (!fs.existsSync(configPath)) {
1516
+ console.log(JSON.stringify({}));
1517
+ process.exit(0);
1518
+ }
1519
+ const raw = fs.readFileSync(configPath, "utf8");
1520
+ if (!raw) {
1521
+ console.log(JSON.stringify({}));
1522
+ process.exit(0);
1523
+ }
1524
+ const parsed = JSON.parse(raw);
1525
+ console.log(JSON.stringify(parsed && typeof parsed === "object" ? parsed : {}));
1526
+ `;
1527
+ const result = await this.dockerExec.executeNodeScript(containerId, nodeScript, {
1528
+ timeout: 15000,
1529
+ throwOnError: true,
1530
+ });
1531
+ const parsed = JSON.parse(result.stdout || '{}');
1532
+ return parsed && typeof parsed === 'object'
1533
+ ? parsed
1534
+ : {};
1535
+ }
1536
+ /**
1537
+ * 写入容器内 openclaw.json
1538
+ */
1539
+ async writeGatewayConfig(containerId, config) {
1540
+ const openclawHome = await this.resolveOpenClawHome(containerId);
1541
+ const configPath = buildOpenClawPath(openclawHome, 'openclaw.json');
1542
+ const payload = Buffer.from(JSON.stringify(config)).toString('base64');
1543
+ const nodeScript = `
1544
+ const fs = require("fs");
1545
+ const path = require("path");
1546
+ const configPath = ${JSON.stringify(configPath)};
1547
+ const dirPath = path.dirname(configPath);
1548
+ if (!fs.existsSync(dirPath)) {
1549
+ fs.mkdirSync(dirPath, { recursive: true });
1550
+ }
1551
+ const parsed = JSON.parse(Buffer.from("${payload}", "base64").toString("utf8"));
1552
+ fs.writeFileSync(configPath, JSON.stringify(parsed, null, 2), "utf8");
1553
+ console.log("ok");
1554
+ `;
1555
+ await this.dockerExec.executeNodeScript(containerId, nodeScript, {
1556
+ timeout: 15000,
1557
+ throwOnError: true,
1558
+ });
1559
+ }
1560
+ /**
1561
+ * 运行 openclaw devices list
1562
+ */
1563
+ async listGatewayDevices(containerId) {
1564
+ const result = await this.dockerExec.executeCommand(containerId, ['openclaw', 'devices', 'list'], {
1565
+ timeout: 30000,
1566
+ throwOnError: true,
1567
+ });
1568
+ return (result.stdout || '').trim();
1569
+ }
1570
+ /**
1571
+ * 运行 openclaw devices approve <requestId>
1572
+ */
1573
+ async approveGatewayDevice(containerId, requestId) {
1574
+ const result = await this.dockerExec.executeCommand(containerId, ['openclaw', 'devices', 'approve', requestId], {
1575
+ timeout: 30000,
1576
+ });
1577
+ if (!result.success) {
1578
+ return {
1579
+ success: false,
1580
+ error: result.stderr || 'openclaw devices approve failed',
1581
+ };
1582
+ }
1583
+ return {
1584
+ success: true,
1585
+ output: result.stdout,
1586
+ };
1587
+ }
1588
+ /**
1589
+ * 运行 openclaw devices reject <requestId>
1590
+ */
1591
+ async rejectGatewayDevice(containerId, requestId) {
1592
+ const result = await this.dockerExec.executeCommand(containerId, ['openclaw', 'devices', 'reject', requestId], {
1593
+ timeout: 30000,
1594
+ });
1595
+ if (!result.success) {
1596
+ return {
1597
+ success: false,
1598
+ error: result.stderr || 'openclaw devices reject failed',
1599
+ };
1600
+ }
1601
+ return {
1602
+ success: true,
1603
+ output: result.stdout,
1604
+ };
1605
+ }
1606
+ /**
1607
+ * 读取 openclaw.json 配置文件
1608
+ * 用于检查配置状态和比对配置变化
1609
+ */
1610
+ async readOpenclawConfig(containerId) {
1611
+ const openclawHome = await this.resolveOpenClawHome(containerId);
1612
+ const configPath = buildOpenClawPath(openclawHome, 'openclaw.json');
1613
+ const nodeScript = `
1614
+ const fs = require("fs");
1615
+ const configPath = ${JSON.stringify(configPath)};
1616
+ if (!fs.existsSync(configPath)) {
1617
+ console.log(JSON.stringify(null));
1618
+ process.exit(0);
1619
+ }
1620
+ const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
1621
+ console.log(JSON.stringify(config));
1622
+ `;
1623
+ const result = await this.dockerExec.executeNodeScript(containerId, nodeScript, {
1624
+ timeout: 15000,
1625
+ throwOnError: true,
1626
+ });
1627
+ return JSON.parse(result.stdout || 'null');
1628
+ }
1629
+ /**
1630
+ * 读取 Gateway 运行态插件列表(plugins.entries)
1631
+ */
1632
+ async listRuntimePlugins(containerId) {
1633
+ const openclawHome = await this.resolveOpenClawHome(containerId);
1634
+ const configPath = buildOpenClawPath(openclawHome, 'openclaw.json');
1635
+ const nodeScript = `
1636
+ const fs = require("fs");
1637
+ const configPath = ${JSON.stringify(configPath)};
1638
+ if (!fs.existsSync(configPath)) {
1639
+ console.log(JSON.stringify([]));
1640
+ process.exit(0);
1641
+ }
1642
+ const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
1643
+ const entries = config?.plugins?.entries || {};
1644
+ const list = Object.entries(entries).map(([id, raw]) => {
1645
+ const entry = raw && typeof raw === "object" ? raw : {};
1646
+ return {
1647
+ id,
1648
+ enabled: entry.enabled !== false,
1649
+ sourceType: entry.sourceType ?? null,
1650
+ installSpec: entry.installSpec ?? null,
1651
+ hasConfig: !!(entry.config && typeof entry.config === "object"),
1652
+ config:
1653
+ entry.config && typeof entry.config === "object" ? entry.config : null,
1654
+ };
1655
+ });
1656
+ console.log(JSON.stringify(list));
1657
+ `;
1658
+ const result = await this.dockerExec.executeNodeScript(containerId, nodeScript, {
1659
+ timeout: 15000,
1660
+ throwOnError: true,
1661
+ });
1662
+ const parsed = JSON.parse(result.stdout || '[]');
1663
+ if (!Array.isArray(parsed)) {
1664
+ return [];
1665
+ }
1666
+ return parsed.map((item) => ({
1667
+ id: String(item.id || ''),
1668
+ enabled: item.enabled !== false,
1669
+ sourceType: item.sourceType === 'npm' ||
1670
+ item.sourceType === 'path' ||
1671
+ item.sourceType === 'bundled'
1672
+ ? item.sourceType
1673
+ : null,
1674
+ installSpec: typeof item.installSpec === 'string' ? item.installSpec : null,
1675
+ hasConfig: item.hasConfig === true,
1676
+ config: item.config && typeof item.config === 'object'
1677
+ ? item.config
1678
+ : null,
1679
+ }));
1680
+ }
1681
+ /**
1682
+ * 在容器内执行 openclaw plugins install
1683
+ * 支持 npm 和 path 两种安装方式
1684
+ *
1685
+ * @param containerId 容器 ID
1686
+ * @param spec 安装规格 (npm 包名或本地路径)
1687
+ * @param sourceType 安装类型 ('npm' | 'path')
1688
+ */
1689
+ async installRuntimePlugin(containerId, spec, sourceType, pluginId) {
1690
+ // 在安装前清理旧的插件文件
1691
+ if (pluginId) {
1692
+ await this.cleanOldPluginFiles(containerId, pluginId);
1693
+ }
1694
+ // 如果是 path 类型安装,先拷贝目录到容器
1695
+ if (sourceType === 'path') {
1696
+ const copyResult = await this.copyLocalPluginToContainer(containerId, spec);
1697
+ if (!copyResult.success) {
1698
+ return {
1699
+ success: false,
1700
+ error: copyResult.error || 'Failed to copy plugin to container',
1701
+ };
1702
+ }
1703
+ // 使用容器内的路径执行安装
1704
+ spec = copyResult.containerPath;
1705
+ // 验证文件已拷贝到容器
1706
+ const verifyResult = await this.dockerExec.executeCommand(containerId, ['ls', '-la', spec], { timeout: 10000, waitForReady: true });
1707
+ this.logger.info('[OpenClaw] Verifying plugin files in container', {
1708
+ containerId: containerId.substring(0, 12),
1709
+ containerPath: spec,
1710
+ lsOutput: verifyResult.stdout,
1711
+ lsError: verifyResult.stderr,
1712
+ });
1713
+ }
1714
+ this.logger.info('[OpenClaw] Running openclaw plugins install', {
1715
+ containerId: containerId.substring(0, 12),
1716
+ spec,
1717
+ sourceType,
1718
+ });
1719
+ // 对于本地插件安装,添加 --dangerously-force-unsafe-install 参数
1720
+ // 以绕过安全检测的误判(如 Playwright 的 $eval API)
1721
+ // 参考: https://docs.openclaw.ai/gateway/security
1722
+ const installArgs = ['openclaw', 'plugins', 'install', spec];
1723
+ if (sourceType === 'path') {
1724
+ installArgs.push('--dangerously-force-unsafe-install');
1725
+ }
1726
+ const result = await this.dockerExec.executeCommand(containerId, installArgs, {
1727
+ timeout: 120000,
1728
+ waitForReady: true,
1729
+ });
1730
+ this.logger.info('[OpenClaw] openclaw plugins install result', {
1731
+ containerId: containerId.substring(0, 12),
1732
+ spec,
1733
+ success: result.success,
1734
+ stdout: result.stdout?.substring(0, 1000),
1735
+ stderr: result.stderr?.substring(0, 1000),
1736
+ });
1737
+ // 检查是否有下载失败的错误(即使命令返回 success,npm 下载可能失败)
1738
+ // openclaw plugins install 可能返回 success 但 stderr 包含下载失败信息
1739
+ const stderrLower = (result.stderr || '').toLowerCase();
1740
+ const downloadFailedPatterns = [
1741
+ 'download failed',
1742
+ 'rate limit exceeded',
1743
+ '429',
1744
+ '404',
1745
+ 'network error',
1746
+ 'connection refused',
1747
+ 'timeout',
1748
+ 'package not found',
1749
+ 'cannot find package',
1750
+ 'npm err',
1751
+ 'fetch failed',
1752
+ ];
1753
+ const hasDownloadError = downloadFailedPatterns.some((pattern) => stderrLower.includes(pattern));
1754
+ if (hasDownloadError) {
1755
+ this.logger.error('[OpenClaw] npm package download failed (detected in stderr)', {
1756
+ containerId: containerId.substring(0, 12),
1757
+ spec,
1758
+ stderr: result.stderr,
1759
+ detectedPatterns: downloadFailedPatterns.filter((p) => stderrLower.includes(p)),
1760
+ });
1761
+ // 即使命令返回 success,npm 下载失败应该视为安装失败
1762
+ if (sourceType === 'npm') {
1763
+ return {
1764
+ success: false,
1765
+ error: `npm package download failed: ${result.stderr || 'Rate limit exceeded or network error'}`,
1766
+ };
1767
+ }
1768
+ }
1769
+ if (!result.success) {
1770
+ return {
1771
+ success: false,
1772
+ error: result.stderr || 'openclaw plugins install failed',
1773
+ };
1774
+ }
1775
+ // 验证目标插件是否真正被安装(检查 stdout 是否包含插件注册信息)
1776
+ // 对于 npm 安装,需要确认插件确实被加载
1777
+ if (sourceType === 'npm' && pluginId) {
1778
+ const stdoutLower = (result.stdout || '').toLowerCase();
1779
+ const stderrLower = (result.stderr || '').toLowerCase();
1780
+ // 检查是否有插件注册成功的迹象
1781
+ const pluginIdLower = pluginId.toLowerCase();
1782
+ const pluginRegisteredPatterns = [
1783
+ `registered`,
1784
+ `initialized`,
1785
+ `plugin registered`,
1786
+ `plugin loaded`,
1787
+ ];
1788
+ const hasPluginInOutput = stdoutLower.includes(pluginIdLower) ||
1789
+ stderrLower.includes(pluginIdLower);
1790
+ const hasRegistrationSuccess = pluginRegisteredPatterns.some((p) => stdoutLower.includes(p) && stdoutLower.includes(pluginIdLower));
1791
+ // 如果插件名称不在输出中,可能安装未成功
1792
+ if (!hasPluginInOutput && !hasRegistrationSuccess) {
1793
+ this.logger.warn('[OpenClaw] Plugin may not have been installed successfully', {
1794
+ containerId: containerId.substring(0, 12),
1795
+ spec,
1796
+ pluginId,
1797
+ stdoutPreview: result.stdout?.substring(0, 200),
1798
+ stderrPreview: result.stderr?.substring(0, 200),
1799
+ });
1800
+ // 不立即返回失败,但记录警告,后续的 configVerify 会进一步验证
1801
+ }
1802
+ }
1803
+ // === 诊断:验证安装结果 ===
1804
+ // 检查 openclaw.json 是否更新了 plugins.entries
1805
+ const openclawHome = await this.resolveOpenClawHome(containerId);
1806
+ const configPath = buildOpenClawPath(openclawHome, 'openclaw.json');
1807
+ const verifyConfigScript = `
1808
+ const fs = require('fs');
1809
+ const configPath = '${configPath}';
1810
+ try {
1811
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
1812
+ const entries = config?.plugins?.entries || {};
1813
+ console.log(JSON.stringify({
1814
+ configExists: true,
1815
+ pluginCount: Object.keys(entries).length,
1816
+ plugins: Object.keys(entries),
1817
+ entriesDetail: entries
1818
+ }));
1819
+ } catch (e) {
1820
+ console.log(JSON.stringify({ configExists: false, error: e.message }));
1821
+ }
1822
+ `;
1823
+ const configVerifyResult = await this.dockerExec.executeCommand(containerId, ['node', '-e', verifyConfigScript], { timeout: 10000 });
1824
+ this.logger.info('[OpenClaw] Post-install config verification', {
1825
+ containerId: containerId.substring(0, 12),
1826
+ spec,
1827
+ sourceType,
1828
+ configVerify: configVerifyResult.stdout,
1829
+ configVerifyError: configVerifyResult.stderr,
1830
+ });
1831
+ // 验证目标插件是否被添加到 plugins.entries
1832
+ if (configVerifyResult.success && configVerifyResult.stdout) {
1833
+ try {
1834
+ const verifyParsed = JSON.parse(configVerifyResult.stdout.trim());
1835
+ const installedPlugins = verifyParsed.plugins || [];
1836
+ // 检查目标插件是否在列表中
1837
+ // pluginId 已作为参数传入,直接使用
1838
+ if (pluginId && !installedPlugins.includes(pluginId)) {
1839
+ this.logger.error('[OpenClaw] Plugin not found in plugins.entries after install', {
1840
+ containerId: containerId.substring(0, 12),
1841
+ spec,
1842
+ sourceType,
1843
+ pluginId,
1844
+ installedPlugins,
1845
+ });
1846
+ // 插件未添加到配置,视为安装失败
1847
+ return {
1848
+ success: false,
1849
+ error: `Plugin ${pluginId} was not added to plugins.entries. The npm package may have failed to download or install.`,
1850
+ };
1851
+ }
1852
+ }
1853
+ catch (parseError) {
1854
+ this.logger.warn('[OpenClaw] Failed to parse config verification result', {
1855
+ containerId: containerId.substring(0, 12),
1856
+ parseError,
1857
+ });
1858
+ }
1859
+ }
1860
+ // 扫描可能安装位置的文件结构
1861
+ const scanPathsScript = `
1862
+ const fs = require('fs');
1863
+ const path = require('path');
1864
+
1865
+ const basePaths = [
1866
+ '${openclawHome}/plugins',
1867
+ '${openclawHome}/node_modules',
1868
+ '/app/plugins',
1869
+ '/app/node_modules'
1870
+ ];
1871
+
1872
+ const results = {};
1873
+ for (const basePath of basePaths) {
1874
+ try {
1875
+ if (fs.existsSync(basePath)) {
1876
+ const items = fs.readdirSync(basePath, { withFileTypes: true });
1877
+ results[basePath] = items.map(i => ({
1878
+ name: i.name,
1879
+ isDir: i.isDirectory()
1880
+ }));
1881
+ } else {
1882
+ results[basePath] = { exists: false };
1883
+ }
1884
+ } catch (e) {
1885
+ results[basePath] = { error: e.message };
1886
+ }
1887
+ }
1888
+
1889
+ console.log(JSON.stringify(results));
1890
+ `;
1891
+ const scanResult = await this.dockerExec.executeCommand(containerId, ['node', '-e', scanPathsScript], { timeout: 10000 });
1892
+ return {
1893
+ success: true,
1894
+ output: result.stdout,
1895
+ };
1896
+ }
1897
+ /**
1898
+ * 读取已安装插件的 manifest 文件 (openclaw.plugin.json)
1899
+ * 用于获取 configSchema 和 uiHints 等元数据
1900
+ *
1901
+ * @param containerId 容器 ID
1902
+ * @param pluginId 插件 ID
1903
+ * @returns 插件 manifest 信息,包含 configSchema、uiHints、name、description、version 等
1904
+ */
1905
+ async getPluginManifest(containerId, pluginId) {
1906
+ const openclawHome = await this.resolveOpenClawHome(containerId);
1907
+ // 尝试多个可能的路径来查找 manifest 文件
1908
+ // npm 安装的插件可能在不同的位置
1909
+ const possiblePaths = [
1910
+ // npm 安装的插件路径(openclaw home 下的 plugins 目录)
1911
+ `${openclawHome}/plugins/${pluginId}/openclaw.plugin.json`,
1912
+ `${openclawHome}/plugins/node_modules/${pluginId}/openclaw.plugin.json`,
1913
+ // 另一种可能的 npm 路径(openclaw home 下的 node_modules)
1914
+ `${openclawHome}/node_modules/${pluginId}/openclaw.plugin.json`,
1915
+ // /app 目录下的路径(Docker 容器内)
1916
+ `/app/plugins/${pluginId}/openclaw.plugin.json`,
1917
+ `/app/node_modules/${pluginId}/openclaw.plugin.json`,
1918
+ // extensions 目录下的插件(开发模式)
1919
+ `${openclawHome}/../../extensions/${pluginId}/openclaw.plugin.json`,
1920
+ // 用户主目录下的路径
1921
+ `/home/node/.openclaw/plugins/${pluginId}/openclaw.plugin.json`,
1922
+ `/home/node/.openclaw/plugins/node_modules/${pluginId}/openclaw.plugin.json`,
1923
+ `/home/node/.openclaw/node_modules/${pluginId}/openclaw.plugin.json`,
1924
+ ];
1925
+ this.logger.info('[OpenClaw] Searching for plugin manifest', {
1926
+ containerId: containerId.substring(0, 12),
1927
+ pluginId,
1928
+ openclawHome,
1929
+ searchPaths: possiblePaths,
1930
+ });
1931
+ const nodeScript = `
1932
+ const fs = require('fs');
1933
+ const paths = ${JSON.stringify(possiblePaths)};
1934
+ const searchResults = [];
1935
+ for (const path of paths) {
1936
+ const exists = fs.existsSync(path);
1937
+ searchResults.push({ path, exists });
1938
+ if (exists) {
1939
+ try {
1940
+ const content = fs.readFileSync(path, 'utf8');
1941
+ const manifest = JSON.parse(content);
1942
+ console.log(JSON.stringify({
1943
+ found: true,
1944
+ path: path,
1945
+ manifest: manifest,
1946
+ searchResults: searchResults
1947
+ }));
1948
+ process.exit(0);
1949
+ } catch (e) {
1950
+ searchResults[searchResults.length - 1].parseError = e.message;
1951
+ }
1952
+ }
1953
+ }
1954
+ console.log(JSON.stringify({ found: false, searchResults: searchResults }));
1955
+ `;
1956
+ try {
1957
+ const result = await this.dockerExec.executeCommand(containerId, ['node', '-e', nodeScript], { timeout: 10000 });
1958
+ if (result.success && result.stdout) {
1959
+ const parsed = JSON.parse(result.stdout.trim());
1960
+ if (parsed.found && parsed.manifest) {
1961
+ return {
1962
+ configSchema: parsed.manifest.configSchema,
1963
+ uiHints: parsed.manifest.uiHints,
1964
+ name: parsed.manifest.name,
1965
+ description: parsed.manifest.description,
1966
+ version: parsed.manifest.version,
1967
+ kind: parsed.manifest.kind,
1968
+ };
1969
+ }
1970
+ }
1971
+ }
1972
+ catch (error) {
1973
+ this.logger.warn('[OpenClaw] Failed to read plugin manifest', {
1974
+ containerId: containerId.substring(0, 12),
1975
+ pluginId,
1976
+ error,
1977
+ });
1978
+ }
1979
+ return null;
1980
+ }
1981
+ /**
1982
+ * 诊断 npm 插件安装位置
1983
+ * 用于调试 npm 安装后插件文件的实际位置
1984
+ *
1985
+ * @param containerId 容器 ID
1986
+ * @param pluginId 插件 ID(可选,如果不提供则扫描所有可能位置)
1987
+ * @returns 所有找到的 openclaw.plugin.json 文件及其路径
1988
+ */
1989
+ async diagnosePluginLocations(containerId, pluginId) {
1990
+ const openclawHome = await this.resolveOpenClawHome(containerId);
1991
+ // 构建搜索脚本 - 扫描所有可能的插件目录
1992
+ const diagnoseScript = `
1993
+ const fs = require('fs');
1994
+ const path = require('path');
1995
+
1996
+ const openclawHome = '${openclawHome}';
1997
+ const targetPluginId = ${pluginId ? `'${pluginId}'` : 'null'};
1998
+
1999
+ const basePaths = [
2000
+ openclawHome + '/plugins',
2001
+ openclawHome + '/plugins/node_modules',
2002
+ openclawHome + '/node_modules',
2003
+ '/app/plugins',
2004
+ '/app/node_modules',
2005
+ '/home/node/.openclaw/plugins',
2006
+ '/home/node/.openclaw/plugins/node_modules',
2007
+ '/home/node/.openclaw/node_modules',
2008
+ ];
2009
+
2010
+ const searchPaths = {};
2011
+ const foundManifests = [];
2012
+
2013
+ // 递归查找 openclaw.plugin.json 文件
2014
+ function findManifests(dir, depth = 0) {
2015
+ if (depth > 3) return; // 限制递归深度
2016
+ try {
2017
+ if (!fs.existsSync(dir)) return;
2018
+ const items = fs.readdirSync(dir, { withFileTypes: true });
2019
+ for (const item of items) {
2020
+ const fullPath = path.join(dir, item.name);
2021
+ if (item.isDirectory()) {
2022
+ // 检查是否有 openclaw.plugin.json
2023
+ const manifestPath = path.join(fullPath, 'openclaw.plugin.json');
2024
+ if (fs.existsSync(manifestPath)) {
2025
+ try {
2026
+ const content = fs.readFileSync(manifestPath, 'utf8');
2027
+ const manifest = JSON.parse(content);
2028
+ const foundId = manifest.id || item.name;
2029
+ if (!targetPluginId || foundId === targetPluginId || item.name === targetPluginId) {
2030
+ foundManifests.push({
2031
+ path: manifestPath,
2032
+ pluginId: foundId,
2033
+ manifest: manifest
2034
+ });
2035
+ }
2036
+ } catch (e) {}
2037
+ }
2038
+ // 继续递归(针对 node_modules 子目录)
2039
+ if (item.name === 'node_modules' || depth < 2) {
2040
+ findManifests(fullPath, depth + 1);
2041
+ }
2042
+ }
2043
+ }
2044
+ } catch (e) {}
2045
+ }
2046
+
2047
+ // 检查每个基础路径
2048
+ for (const basePath of basePaths) {
2049
+ try {
2050
+ if (fs.existsSync(basePath)) {
2051
+ const items = fs.readdirSync(basePath, { withFileTypes: true }).map(i => i.name);
2052
+ searchPaths[basePath] = { exists: true, items };
2053
+ findManifests(basePath);
2054
+ } else {
2055
+ searchPaths[basePath] = { exists: false };
2056
+ }
2057
+ } catch (e) {
2058
+ searchPaths[basePath] = { exists: false, error: e.message };
2059
+ }
2060
+ }
2061
+
2062
+ console.log(JSON.stringify({ openclawHome, searchPaths, foundManifests }));
2063
+ `;
2064
+ try {
2065
+ const result = await this.dockerExec.executeCommand(containerId, ['node', '-e', diagnoseScript], { timeout: 30000 });
2066
+ if (result.success && result.stdout) {
2067
+ const parsed = JSON.parse(result.stdout.trim());
2068
+ this.logger.info('[OpenClaw] Plugin location diagnosis result', {
2069
+ containerId: containerId.substring(0, 12),
2070
+ targetPluginId: pluginId,
2071
+ openclawHome: parsed.openclawHome,
2072
+ foundManifestsCount: parsed.foundManifests?.length || 0,
2073
+ foundManifests: parsed.foundManifests?.map((m) => ({
2074
+ path: m.path,
2075
+ pluginId: m.pluginId,
2076
+ })),
2077
+ });
2078
+ return parsed;
2079
+ }
2080
+ }
2081
+ catch (error) {
2082
+ this.logger.error('[OpenClaw] Plugin location diagnosis failed', {
2083
+ containerId: containerId.substring(0, 12),
2084
+ pluginId,
2085
+ error,
2086
+ });
2087
+ }
2088
+ return {
2089
+ openclawHome,
2090
+ searchPaths: {},
2091
+ foundManifests: [],
2092
+ };
2093
+ }
2094
+ /**
2095
+ * 清理旧的插件文件
2096
+ * 在安装/更新插件前调用,确保旧文件被完全清理
2097
+ */
2098
+ async cleanOldPluginFiles(containerId, pluginId) {
2099
+ const openclawHome = await this.resolveOpenClawHome(containerId);
2100
+ const extensionsDir = buildOpenClawPath(openclawHome, 'extensions', pluginId);
2101
+ const appPluginsDir = `/app/plugins/${pluginId}`;
2102
+ const nodeScript = `
2103
+ const fs = require('fs');
2104
+ const path = require('path');
2105
+
2106
+ const dirs = [
2107
+ '${extensionsDir}',
2108
+ '${appPluginsDir}'
2109
+ ];
2110
+
2111
+ for (const dir of dirs) {
2112
+ if (fs.existsSync(dir)) {
2113
+ try {
2114
+ fs.rmSync(dir, { recursive: true, force: true });
2115
+ console.log('Cleaned: ' + dir);
2116
+ } catch (e) {
2117
+ console.log('Failed to clean ' + dir + ': ' + e.message);
2118
+ }
2119
+ }
2120
+ }
2121
+ `;
2122
+ try {
2123
+ await this.dockerExec.executeNodeScript(containerId, nodeScript, {
2124
+ timeout: 15000,
2125
+ throwOnError: false,
2126
+ });
2127
+ this.logger.info('[OpenClaw] Cleaned old plugin files before install', {
2128
+ containerId: containerId.substring(0, 12),
2129
+ pluginId,
2130
+ extensionsDir,
2131
+ appPluginsDir,
2132
+ });
2133
+ }
2134
+ catch (error) {
2135
+ // 不阻断安装流程,仅记录警告
2136
+ this.logger.warn('[OpenClaw] Failed to clean old plugin files', {
2137
+ containerId: containerId.substring(0, 12),
2138
+ pluginId,
2139
+ error: error instanceof Error ? error.message : String(error),
2140
+ });
2141
+ }
2142
+ }
2143
+ /**
2144
+ * 将本地插件目录拷贝到容器
2145
+ *
2146
+ * @param containerId 容器 ID
2147
+ * @param localPath 本地插件路径
2148
+ * @returns 拷贝结果和容器内路径
2149
+ */
2150
+ async copyLocalPluginToContainer(containerId, localPath) {
2151
+ try {
2152
+ // 解析本地路径
2153
+ // spec 可能是 ./extensions/plugin-name 或 extensions/plugin-name
2154
+ const normalizedPath = localPath.replace(/^\.\//, '');
2155
+ let sourceDir;
2156
+ if (path.isAbsolute(normalizedPath)) {
2157
+ // 绝对路径直接使用
2158
+ sourceDir = normalizedPath;
2159
+ }
2160
+ else if (normalizedPath.startsWith('extensions/')) {
2161
+ // extensions/ 路径相对于项目根目录 (PROJECT_ROOT 是 apps/api,需要向上两级)
2162
+ const projectRoot = process.env.PROJECT_ROOT || process.cwd();
2163
+ sourceDir = path.join(projectRoot, '../../', normalizedPath);
2164
+ }
2165
+ else {
2166
+ // 其他相对路径
2167
+ const projectRoot = process.env.PROJECT_ROOT || process.cwd();
2168
+ sourceDir = path.join(projectRoot, normalizedPath);
2169
+ }
2170
+ // 获取插件名称
2171
+ const pluginName = path.basename(sourceDir);
2172
+ // 容器内目标路径 - 使用 plugins 目录安装本地插件
2173
+ const containerPluginDir = '/app/plugins';
2174
+ const containerPath = `${containerPluginDir}/${pluginName}`;
2175
+ this.logger.info('[OpenClaw] Copying local plugin to container', {
2176
+ containerId: containerId.substring(0, 12),
2177
+ sourceDir,
2178
+ containerPath,
2179
+ });
2180
+ // 删除已存在的插件目录(确保使用最新版本)
2181
+ const rmResult = await this.dockerExec.executeCommand(containerId, ['rm', '-rf', containerPath], { timeout: 10000, waitForReady: true });
2182
+ // 确保 /app/plugins 目录存在
2183
+ const mkdirResult = await this.dockerExec.executeCommand(containerId, ['mkdir', '-p', containerPluginDir], { timeout: 10000, waitForReady: true });
2184
+ if (!mkdirResult.success) {
2185
+ this.logger.warn('[OpenClaw] Failed to create plugins directory, attempting copy anyway', {
2186
+ containerId: containerId.substring(0, 12),
2187
+ containerPluginDir,
2188
+ error: mkdirResult.stderr,
2189
+ });
2190
+ }
2191
+ // 拷贝目录到容器
2192
+ await this.dockerExec.copyDirectoryToContainer(containerId, sourceDir, containerPluginDir);
2193
+ this.logger.info('[OpenClaw] Local plugin copied to container successfully', {
2194
+ containerId: containerId.substring(0, 12),
2195
+ containerPath,
2196
+ });
2197
+ return {
2198
+ success: true,
2199
+ containerPath,
2200
+ };
2201
+ }
2202
+ catch (error) {
2203
+ const errorMessage = error instanceof Error ? error.message : String(error);
2204
+ this.logger.error('[OpenClaw] Failed to copy local plugin to container', {
2205
+ containerId: containerId.substring(0, 12),
2206
+ localPath,
2207
+ error: errorMessage,
2208
+ });
2209
+ return {
2210
+ success: false,
2211
+ error: errorMessage,
2212
+ };
2213
+ }
2214
+ }
2215
+ /**
2216
+ * 更新 plugins.entries.<pluginId> 配置并写回 openclaw.json
2217
+ *
2218
+ * 注意:plugins.entries 只接受 enabled 和 config 字段
2219
+ * sourceType 和 installSpec 不应写入,否则会导致 OpenClaw 校验失败
2220
+ */
2221
+ async upsertRuntimePluginEntry(containerId, params) {
2222
+ const openclawHome = await this.resolveOpenClawHome(containerId);
2223
+ const configPath = buildOpenClawPath(openclawHome, 'openclaw.json');
2224
+ const payload = Buffer.from(JSON.stringify(params)).toString('base64');
2225
+ const nodeScript = `
2226
+ const fs = require("fs");
2227
+ const configPath = ${JSON.stringify(configPath)};
2228
+ const payload = JSON.parse(Buffer.from("${payload}", "base64").toString("utf8"));
2229
+
2230
+ const config = fs.existsSync(configPath)
2231
+ ? JSON.parse(fs.readFileSync(configPath, "utf8"))
2232
+ : {};
2233
+
2234
+ config.plugins = config.plugins || {};
2235
+ config.plugins.entries = config.plugins.entries || {};
2236
+
2237
+ const current = config.plugins.entries[payload.pluginId] || {};
2238
+
2239
+ // 只写入 plugins.entries 允许的字段:enabled 和 config
2240
+ // 注意:sourceType 和 installSpec 不能写入 plugins.entries,会导致 OpenClaw 校验失败
2241
+ if (payload.enabled !== undefined) {
2242
+ current.enabled = payload.enabled;
2243
+ }
2244
+ if (payload.config !== undefined) {
2245
+ current.config = payload.config;
2246
+ }
2247
+
2248
+ config.plugins.entries[payload.pluginId] = current;
2249
+
2250
+ // 同步维护 plugins.allow 列表
2251
+ config.plugins.allow = config.plugins.allow || [];
2252
+ const isEnabled = current.enabled !== false;
2253
+ const idx = config.plugins.allow.indexOf(payload.pluginId);
2254
+ if (isEnabled && idx === -1) {
2255
+ config.plugins.allow.push(payload.pluginId);
2256
+ } else if (!isEnabled && idx !== -1) {
2257
+ config.plugins.allow.splice(idx, 1);
2258
+ }
2259
+
2260
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf8");
2261
+
2262
+ console.log(JSON.stringify({
2263
+ id: payload.pluginId,
2264
+ enabled: current.enabled !== false,
2265
+ sourceType: payload.sourceType ?? null,
2266
+ installSpec: payload.installSpec ?? null,
2267
+ hasConfig: !!(current.config && typeof current.config === "object"),
2268
+ config: current.config && typeof current.config === "object" ? current.config : null,
2269
+ }));
2270
+ `;
2271
+ const result = await this.dockerExec.executeNodeScript(containerId, nodeScript, {
2272
+ timeout: 15000,
2273
+ throwOnError: true,
2274
+ });
2275
+ const parsed = JSON.parse(result.stdout || '{}');
2276
+ return {
2277
+ id: String(parsed.id || params.pluginId),
2278
+ enabled: parsed.enabled !== false,
2279
+ sourceType: parsed.sourceType === 'npm' ||
2280
+ parsed.sourceType === 'path' ||
2281
+ parsed.sourceType === 'bundled'
2282
+ ? parsed.sourceType
2283
+ : null,
2284
+ installSpec: typeof parsed.installSpec === 'string' ? parsed.installSpec : null,
2285
+ hasConfig: parsed.hasConfig === true,
2286
+ config: parsed.config && typeof parsed.config === 'object'
2287
+ ? parsed.config
2288
+ : null,
2289
+ };
2290
+ }
2291
+ /**
2292
+ * 卸载/移除 Gateway 运行态插件(从 plugins.entries 中删除)
2293
+ */
2294
+ async uninstallRuntimePlugin(containerId, pluginId) {
2295
+ const openclawHome = await this.resolveOpenClawHome(containerId);
2296
+ const configPath = buildOpenClawPath(openclawHome, 'openclaw.json');
2297
+ const payload = Buffer.from(JSON.stringify({ pluginId })).toString('base64');
2298
+ const nodeScript = `
2299
+ const fs = require("fs");
2300
+ const path = require("path");
2301
+ const configPath = ${JSON.stringify(configPath)};
2302
+ const openclawHome = ${JSON.stringify(openclawHome)};
2303
+ const payload = JSON.parse(Buffer.from("${payload}", "base64").toString("utf8"));
2304
+
2305
+ if (!fs.existsSync(configPath)) {
2306
+ console.log(JSON.stringify({ success: true, message: "Config file not found" }));
2307
+ process.exit(0);
2308
+ }
2309
+
2310
+ const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
2311
+ config.plugins = config.plugins || {};
2312
+ config.plugins.entries = config.plugins.entries || {};
2313
+
2314
+ if (!config.plugins.entries[payload.pluginId]) {
2315
+ console.log(JSON.stringify({ success: true, message: "Plugin not found in entries" }));
2316
+ process.exit(0);
2317
+ }
2318
+
2319
+ // 1. 从 plugins.entries 删除
2320
+ delete config.plugins.entries[payload.pluginId];
2321
+
2322
+ // 2. 从 plugins.allow 列表移除
2323
+ if (config.plugins.allow && Array.isArray(config.plugins.allow)) {
2324
+ const idx = config.plugins.allow.indexOf(payload.pluginId);
2325
+ if (idx !== -1) {
2326
+ config.plugins.allow.splice(idx, 1);
2327
+ }
2328
+ }
2329
+
2330
+ // 写入配置
2331
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf8");
2332
+
2333
+ // 3. 删除 /app/plugins/{pluginId} 目录(用户安装的插件)
2334
+ const appPluginsDir = "/app/plugins/" + payload.pluginId;
2335
+ if (fs.existsSync(appPluginsDir)) {
2336
+ fs.rmSync(appPluginsDir, { recursive: true, force: true });
2337
+ console.log("Deleted: " + appPluginsDir);
2338
+ }
2339
+
2340
+ // 4. 删除 {openclawHome}/extensions/{pluginId} 目录
2341
+ const extensionsDir = path.join(openclawHome, "extensions", payload.pluginId);
2342
+ if (fs.existsSync(extensionsDir)) {
2343
+ fs.rmSync(extensionsDir, { recursive: true, force: true });
2344
+ console.log("Deleted: " + extensionsDir);
2345
+ }
2346
+
2347
+ console.log(JSON.stringify({ success: true, pluginId: payload.pluginId }));
2348
+ `;
2349
+ try {
2350
+ await this.dockerExec.executeNodeScript(containerId, nodeScript, {
2351
+ timeout: 15000,
2352
+ throwOnError: true,
2353
+ });
2354
+ return { success: true };
2355
+ }
2356
+ catch (error) {
2357
+ const errorMessage = error instanceof Error ? error.message : String(error);
2358
+ this.logger.error('[OpenClawClient] Failed to uninstall plugin', {
2359
+ containerId,
2360
+ pluginId,
2361
+ error: errorMessage,
2362
+ });
2363
+ return { success: false, error: errorMessage };
2364
+ }
2365
+ }
2366
+ /**
2367
+ * 读取插件 Tool Access(全局 tools + agents.list[].tools)
2368
+ */
2369
+ async getRuntimePluginToolAccess(containerId, pluginId) {
2370
+ const openclawHome = await this.resolveOpenClawHome(containerId);
2371
+ const configPath = buildOpenClawPath(openclawHome, 'openclaw.json');
2372
+ const payload = Buffer.from(JSON.stringify({ pluginId })).toString('base64');
2373
+ const nodeScript = `
2374
+ const fs = require("fs");
2375
+ const configPath = ${JSON.stringify(configPath)};
2376
+ const payload = JSON.parse(Buffer.from("${payload}", "base64").toString("utf8"));
2377
+
2378
+ const config = fs.existsSync(configPath)
2379
+ ? JSON.parse(fs.readFileSync(configPath, "utf8"))
2380
+ : {};
2381
+
2382
+ const entries = config?.plugins?.entries || {};
2383
+ if (!entries[payload.pluginId]) {
2384
+ throw new Error("PLUGIN_NOT_FOUND:" + payload.pluginId);
2385
+ }
2386
+
2387
+ const globalTools =
2388
+ config.tools && typeof config.tools === "object" ? config.tools : {};
2389
+ const agents = Array.isArray(config?.agents?.list) ? config.agents.list : [];
2390
+
2391
+ const agentRules = agents
2392
+ .map((agent) => {
2393
+ if (!agent || typeof agent !== "object") {
2394
+ return null;
2395
+ }
2396
+
2397
+ const id = typeof agent.id === "string" ? agent.id : null;
2398
+ if (!id) {
2399
+ return null;
2400
+ }
2401
+
2402
+ const tools =
2403
+ agent.tools && typeof agent.tools === "object" ? agent.tools : null;
2404
+
2405
+ return {
2406
+ agentId: id,
2407
+ allow: Array.isArray(tools?.allow)
2408
+ ? tools.allow.map(String)
2409
+ : undefined,
2410
+ deny: Array.isArray(tools?.deny)
2411
+ ? tools.deny.map(String)
2412
+ : undefined,
2413
+ };
2414
+ })
2415
+ .filter(Boolean);
2416
+
2417
+ const output = {
2418
+ profile:
2419
+ typeof globalTools.profile === "string"
2420
+ ? globalTools.profile
2421
+ : undefined,
2422
+ allow: Array.isArray(globalTools.allow)
2423
+ ? globalTools.allow.map(String)
2424
+ : undefined,
2425
+ deny: Array.isArray(globalTools.deny)
2426
+ ? globalTools.deny.map(String)
2427
+ : undefined,
2428
+ agents: agentRules,
2429
+ };
2430
+
2431
+ console.log(JSON.stringify(output));
2432
+ `;
2433
+ const result = await this.dockerExec.executeNodeScript(containerId, nodeScript, {
2434
+ timeout: 15000,
2435
+ throwOnError: true,
2436
+ });
2437
+ const parsed = JSON.parse(result.stdout || '{}');
2438
+ return {
2439
+ profile: parsed.profile === 'minimal' ||
2440
+ parsed.profile === 'messaging' ||
2441
+ parsed.profile === 'coding' ||
2442
+ parsed.profile === 'full'
2443
+ ? parsed.profile
2444
+ : undefined,
2445
+ allow: Array.isArray(parsed.allow)
2446
+ ? parsed.allow.map((item) => String(item))
2447
+ : undefined,
2448
+ deny: Array.isArray(parsed.deny)
2449
+ ? parsed.deny.map((item) => String(item))
2450
+ : undefined,
2451
+ agents: Array.isArray(parsed.agents)
2452
+ ? parsed.agents
2453
+ .map((agent) => ({
2454
+ agentId: String(agent.agentId || ''),
2455
+ allow: Array.isArray(agent.allow)
2456
+ ? agent.allow.map((item) => String(item))
2457
+ : undefined,
2458
+ deny: Array.isArray(agent.deny)
2459
+ ? agent.deny.map((item) => String(item))
2460
+ : undefined,
2461
+ }))
2462
+ .filter((agent) => agent.agentId)
2463
+ : [],
2464
+ };
2465
+ }
2466
+ /**
2467
+ * 更新插件 Tool Access(全局 tools + agents.list[].tools)
2468
+ */
2469
+ async updateRuntimePluginToolAccess(containerId, params) {
2470
+ const openclawHome = await this.resolveOpenClawHome(containerId);
2471
+ const configPath = buildOpenClawPath(openclawHome, 'openclaw.json');
2472
+ const payload = Buffer.from(JSON.stringify(params)).toString('base64');
2473
+ const nodeScript = `
2474
+ const fs = require("fs");
2475
+ const configPath = ${JSON.stringify(configPath)};
2476
+ const payload = JSON.parse(Buffer.from("${payload}", "base64").toString("utf8"));
2477
+
2478
+ const config = fs.existsSync(configPath)
2479
+ ? JSON.parse(fs.readFileSync(configPath, "utf8"))
2480
+ : {};
2481
+
2482
+ config.plugins = config.plugins || {};
2483
+ config.plugins.entries = config.plugins.entries || {};
2484
+ if (!config.plugins.entries[payload.pluginId]) {
2485
+ throw new Error("PLUGIN_NOT_FOUND:" + payload.pluginId);
2486
+ }
2487
+
2488
+ config.tools = config.tools && typeof config.tools === "object" ? config.tools : {};
2489
+
2490
+ if (payload.profile !== undefined) {
2491
+ config.tools.profile = payload.profile;
2492
+ }
2493
+ if (payload.allow !== undefined) {
2494
+ config.tools.allow = payload.allow;
2495
+ }
2496
+ if (payload.deny !== undefined) {
2497
+ config.tools.deny = payload.deny;
2498
+ }
2499
+
2500
+ const agents = Array.isArray(config?.agents?.list) ? config.agents.list : [];
2501
+
2502
+ if (Array.isArray(payload.agents)) {
2503
+ const ruleMap = new Map();
2504
+ payload.agents.forEach((rule) => {
2505
+ if (rule && typeof rule.agentId === "string" && rule.agentId) {
2506
+ ruleMap.set(rule.agentId, rule);
2507
+ }
2508
+ });
2509
+
2510
+ for (const agent of agents) {
2511
+ if (!agent || typeof agent !== "object") {
2512
+ continue;
2513
+ }
2514
+
2515
+ const agentId = typeof agent.id === "string" ? agent.id : null;
2516
+ if (!agentId || !ruleMap.has(agentId)) {
2517
+ continue;
2518
+ }
2519
+
2520
+ const rule = ruleMap.get(agentId);
2521
+ agent.tools = agent.tools && typeof agent.tools === "object" ? agent.tools : {};
2522
+
2523
+ if (rule.allow !== undefined) {
2524
+ agent.tools.allow = rule.allow;
2525
+ }
2526
+ if (rule.deny !== undefined) {
2527
+ agent.tools.deny = rule.deny;
2528
+ }
2529
+ }
2530
+ }
2531
+
2532
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf8");
2533
+
2534
+ const outputAgents = agents
2535
+ .map((agent) => {
2536
+ if (!agent || typeof agent !== "object") {
2537
+ return null;
2538
+ }
2539
+
2540
+ const id = typeof agent.id === "string" ? agent.id : null;
2541
+ if (!id) {
2542
+ return null;
2543
+ }
2544
+
2545
+ const tools =
2546
+ agent.tools && typeof agent.tools === "object" ? agent.tools : null;
2547
+
2548
+ return {
2549
+ agentId: id,
2550
+ allow: Array.isArray(tools?.allow)
2551
+ ? tools.allow.map(String)
2552
+ : undefined,
2553
+ deny: Array.isArray(tools?.deny)
2554
+ ? tools.deny.map(String)
2555
+ : undefined,
2556
+ };
2557
+ })
2558
+ .filter(Boolean);
2559
+
2560
+ console.log(JSON.stringify({
2561
+ profile:
2562
+ typeof config.tools.profile === "string"
2563
+ ? config.tools.profile
2564
+ : undefined,
2565
+ allow: Array.isArray(config.tools.allow)
2566
+ ? config.tools.allow.map(String)
2567
+ : undefined,
2568
+ deny: Array.isArray(config.tools.deny)
2569
+ ? config.tools.deny.map(String)
2570
+ : undefined,
2571
+ agents: outputAgents,
2572
+ }));
2573
+ `;
2574
+ const result = await this.dockerExec.executeNodeScript(containerId, nodeScript, {
2575
+ timeout: 15000,
2576
+ throwOnError: true,
2577
+ });
2578
+ const parsed = JSON.parse(result.stdout || '{}');
2579
+ return {
2580
+ profile: parsed.profile === 'minimal' ||
2581
+ parsed.profile === 'messaging' ||
2582
+ parsed.profile === 'coding' ||
2583
+ parsed.profile === 'full'
2584
+ ? parsed.profile
2585
+ : undefined,
2586
+ allow: Array.isArray(parsed.allow)
2587
+ ? parsed.allow.map((item) => String(item))
2588
+ : undefined,
2589
+ deny: Array.isArray(parsed.deny)
2590
+ ? parsed.deny.map((item) => String(item))
2591
+ : undefined,
2592
+ agents: Array.isArray(parsed.agents)
2593
+ ? parsed.agents
2594
+ .map((agent) => ({
2595
+ agentId: String(agent.agentId || ''),
2596
+ allow: Array.isArray(agent.allow)
2597
+ ? agent.allow.map((item) => String(item))
2598
+ : undefined,
2599
+ deny: Array.isArray(agent.deny)
2600
+ ? agent.deny.map((item) => String(item))
2601
+ : undefined,
2602
+ }))
2603
+ .filter((agent) => agent.agentId)
2604
+ : [],
2605
+ };
2606
+ }
2607
+ /**
2608
+ * 运行态插件诊断(基于配置静态检查)
2609
+ */
2610
+ async doctorRuntimePlugin(containerId, pluginId) {
2611
+ const plugins = await this.listRuntimePlugins(containerId);
2612
+ const plugin = plugins.find((item) => item.id === pluginId);
2613
+ if (!plugin) {
2614
+ return {
2615
+ pluginId,
2616
+ healthy: false,
2617
+ checks: [
2618
+ {
2619
+ code: 'PLUGIN_NOT_FOUND',
2620
+ level: 'error',
2621
+ message: `Plugin ${pluginId} not found in plugins.entries`,
2622
+ },
2623
+ ],
2624
+ };
2625
+ }
2626
+ const checks = [
2627
+ {
2628
+ code: 'PLUGIN_PRESENT',
2629
+ level: 'info',
2630
+ message: `Plugin ${pluginId} is present in plugins.entries`,
2631
+ },
2632
+ ];
2633
+ if (!plugin.enabled) {
2634
+ checks.push({
2635
+ code: 'PLUGIN_DISABLED',
2636
+ level: 'warn',
2637
+ message: `Plugin ${pluginId} is disabled`,
2638
+ });
2639
+ }
2640
+ if (!plugin.hasConfig) {
2641
+ checks.push({
2642
+ code: 'PLUGIN_CONFIG_EMPTY',
2643
+ level: 'warn',
2644
+ message: `Plugin ${pluginId} has no config object`,
2645
+ });
2646
+ }
2647
+ const healthy = !checks.some((check) => check.level === 'error');
2648
+ return {
2649
+ pluginId,
2650
+ healthy,
2651
+ checks,
2652
+ };
2653
+ }
2654
+ // ============================================================================
2655
+ // 运行中进程通知接口(best-effort,不作为主生效路径)
2656
+ // ============================================================================
2657
+ /**
2658
+ * 通知 OpenClaw 重新扫描 Skills 目录(best-effort)
2659
+ *
2660
+ * 说明:
2661
+ * - 当前实现主要用于记录通知动作
2662
+ * - OpenClaw 是否已通过文件系统 watch 自动感知变更,取决于运行时行为
2663
+ * - 调用方不应将此方法视为唯一或强保证的生效机制
2664
+ *
2665
+ * @param containerId Docker 容器 ID
2666
+ */
2667
+ async reloadSkills(containerId) {
2668
+ this.logger.info('OpenClawClient: notifying Skills rescan', {
2669
+ containerId,
2670
+ });
2671
+ // 通过 kill -SIGUSR1 通知 OpenClaw 主进程重新扫描技能
2672
+ try {
2673
+ await this.dockerExec.executeCommand(containerId, ['sh', '-c', 'kill -USR1 1 2>/dev/null || true'], { throwOnError: false });
2674
+ this.logger.info('OpenClawClient: SIGUSR1 sent to PID 1 for skill rescan', {
2675
+ containerId,
2676
+ });
2677
+ }
2678
+ catch (error) {
2679
+ this.logger.warn('OpenClawClient: Failed to send SIGUSR1, skills may need container restart', {
2680
+ containerId,
2681
+ error: error instanceof Error ? error.message : 'Unknown error',
2682
+ });
2683
+ }
2684
+ }
2685
+ /**
2686
+ * 通知 OpenClaw 重新扫描 MCP Servers 配置(best-effort)
2687
+ *
2688
+ * @param containerId Docker 容器 ID
2689
+ */
2690
+ async reloadMcpServers(containerId) {
2691
+ this.logger.info('OpenClawClient: notifying MCP server rescan', {
2692
+ containerId,
2693
+ });
2694
+ // 当前实现仅记录通知动作;实际生效仍取决于 OpenClaw 运行时能力
2695
+ this.logger.info('OpenClawClient: MCP server rescan notification recorded', {
2696
+ containerId,
2697
+ hint: 'Runtime may still rely on config watch or restart paths to apply changes',
2698
+ });
2699
+ }
2700
+ /**
2701
+ * 检查 Skill 是否存在于容器内
2702
+ * @param containerId Docker 容器 ID
2703
+ * @param skillName 技能名称
2704
+ */
2705
+ async checkSkillExists(containerId, skillName) {
2706
+ // 安全校验
2707
+ if (!this.dockerExec.isValidName(skillName)) {
2708
+ return false;
2709
+ }
2710
+ const openclawHome = await this.resolveOpenClawHome(containerId);
2711
+ const skillPath = buildOpenClawPath(openclawHome, 'skills', skillName);
2712
+ const result = await this.dockerExec.executeCommand(containerId, [
2713
+ 'test',
2714
+ '-d',
2715
+ skillPath,
2716
+ ]);
2717
+ return result.success;
2718
+ }
2719
+ /**
2720
+ * 批量列出容器内已安装的 Skill 目录名
2721
+ * 使用单次 exec 调用代替逐个 checkSkillExists,减少 Docker API 调用次数
2722
+ * @param containerId Docker 容器 ID
2723
+ * @returns 容器内 OPENCLAW_HOME/skills/ 下所有目录名的 Set
2724
+ */
2725
+ async listInstalledSkillDirs(containerId) {
2726
+ const openclawHome = await this.resolveOpenClawHome(containerId);
2727
+ const skillsDir = buildOpenClawPath(openclawHome, 'skills');
2728
+ const output = await this.execInContainer(containerId, [
2729
+ 'sh',
2730
+ '-c',
2731
+ `ls -1 ${JSON.stringify(skillsDir)} 2>/dev/null`,
2732
+ ]);
2733
+ if (!output)
2734
+ return new Set();
2735
+ const dirs = output
2736
+ .trim()
2737
+ .split('\n')
2738
+ .map((n) => n.trim())
2739
+ .filter(Boolean);
2740
+ return new Set(dirs);
2741
+ }
2742
+ /**
2743
+ * 列出容器内特定 Bot 的技能目录名
2744
+ * 兼容两种挂载模式(以 Gateway 为核心):
2745
+ * - 本地开发模式: /app/workspace/skills/
2746
+ * - 容器化模式: /data/bots/gateway-{gatewayId[:8]}/{hostname}/skills/
2747
+ * @param containerId Docker 容器 ID
2748
+ * @param isolationKey Bot 的 isolationKey(格式:gateway-{gatewayId[:8]})
2749
+ * @param hostname Bot 的 hostname(保留参数,用于日志追踪)
2750
+ * @returns 容器内技能目录下所有目录名的 Set
2751
+ */
2752
+ async listBotSkillDirs(containerId, isolationKey, hostname) {
2753
+ // 安全校验:确保 isolationKey 和 hostname 是合法的路径组件
2754
+ if (!this.dockerExec.isValidName(isolationKey) ||
2755
+ !this.dockerExec.isValidName(hostname)) {
2756
+ this.logger.warn('OpenClawClient: 无效的 isolationKey 或 hostname', {
2757
+ containerId,
2758
+ isolationKey,
2759
+ hostname,
2760
+ });
2761
+ return new Set();
2762
+ }
2763
+ // 优先使用本地开发模式路径(/app/workspace),fallback 到容器化模式路径(/data/bots)
2764
+ // 路径规范:以 Gateway 为核心,hostname 区分不同 Bot
2765
+ // /data/bots/gateway-{gatewayId[:8]}/{hostname}/skills/
2766
+ const primaryDir = `/app/workspace/${hostname}/skills`;
2767
+ const fallbackDir = `/data/bots/${isolationKey}/${hostname}/skills`;
2768
+ const output = await this.execInContainer(containerId, [
2769
+ 'sh',
2770
+ '-c',
2771
+ `ls -1 ${JSON.stringify(primaryDir)} 2>/dev/null || ls -1 ${JSON.stringify(fallbackDir)} 2>/dev/null`,
2772
+ ]);
2773
+ if (!output)
2774
+ return new Set();
2775
+ const dirs = output
2776
+ .trim()
2777
+ .split('\n')
2778
+ .map((n) => n.trim())
2779
+ .filter(Boolean);
2780
+ return new Set(dirs);
2781
+ }
2782
+ /**
2783
+ * 获取容器内特定 Bot 技能的 SKILL.md 内容
2784
+ * 兼容两种挂载模式(以 Gateway 为核心,hostname 区分不同 Bot):
2785
+ * - 本地开发模式: /app/workspace/{hostname}/skills/{skillSlug}/SKILL.md
2786
+ * - 容器化模式: /data/bots/gateway-{gatewayId[:8]}/{hostname}/skills/{skillSlug}/SKILL.md
2787
+ * @param containerId Docker 容器 ID
2788
+ * @param isolationKey Bot 的 isolationKey
2789
+ * @param hostname Bot 的 hostname
2790
+ * @param skillSlug 技能的 slug
2791
+ * @returns 技能内容或 null
2792
+ */
2793
+ async getBotSkillContent(containerId, isolationKey, hostname, skillSlug) {
2794
+ // 安全校验
2795
+ if (!this.dockerExec.isValidName(isolationKey) ||
2796
+ !this.dockerExec.isValidName(hostname) ||
2797
+ !this.dockerExec.isValidName(skillSlug)) {
2798
+ return null;
2799
+ }
2800
+ // 优先使用本地开发模式路径,fallback 到容器化模式路径
2801
+ // 路径规范:以 Gateway 为核心,hostname 区分不同 Bot
2802
+ // /data/bots/gateway-{gatewayId[:8]}/{hostname}/skills/{skillSlug}/SKILL.md
2803
+ const primaryPath = `/app/workspace/${hostname}/skills/${skillSlug}/SKILL.md`;
2804
+ const fallbackPath = `/data/bots/${isolationKey}/${hostname}/skills/${skillSlug}/SKILL.md`;
2805
+ const output = await this.execInContainer(containerId, [
2806
+ 'sh',
2807
+ '-c',
2808
+ `cat ${JSON.stringify(primaryPath)} 2>/dev/null || cat ${JSON.stringify(fallbackPath)} 2>/dev/null`,
2809
+ ]);
2810
+ return output || null;
2811
+ }
2812
+ /**
2813
+ * 批量获取容器内特定 Bot 所有技能的 SKILL.md 内容
2814
+ * @param containerId Docker 容器 ID
2815
+ * @param isolationKey Bot 的 isolationKey
2816
+ * @param hostname Bot 的 hostname
2817
+ * @param skillSlugs 技能 slug 列表
2818
+ * @returns Map<skillSlug, content>
2819
+ */
2820
+ async getBotSkillsContent(containerId, isolationKey, hostname, skillSlugs) {
2821
+ const contentMap = new Map();
2822
+ // 安全校验
2823
+ if (!this.dockerExec.isValidName(isolationKey) ||
2824
+ !this.dockerExec.isValidName(hostname)) {
2825
+ return contentMap;
2826
+ }
2827
+ const safeSlugs = skillSlugs.filter((s) => this.dockerExec.isValidName(s));
2828
+ if (safeSlugs.length === 0)
2829
+ return contentMap;
2830
+ // 兼容两种挂载模式(以 Gateway 为核心,hostname 区分不同 Bot):
2831
+ // - 本地开发模式: /app/workspace/{hostname}/skills/
2832
+ // - 容器化模式: /data/bots/gateway-{gatewayId[:8]}/{hostname}/skills/
2833
+ const primarySkillsDir = `/app/workspace/${hostname}/skills`;
2834
+ const fallbackSkillsDir = `/data/bots/${isolationKey}/${hostname}/skills`;
2835
+ // 批量读取所有技能的 SKILL.md,优先读取本地开发模式路径,fallback 到容器化模式路径
2836
+ const output = await this.execInContainer(containerId, [
2837
+ 'sh',
2838
+ '-c',
2839
+ `for slug in ${safeSlugs.map((s) => JSON.stringify(s)).join(' ')}; do echo "===START:$slug==="; cat ${primarySkillsDir}/$slug/SKILL.md 2>/dev/null || cat ${fallbackSkillsDir}/$slug/SKILL.md 2>/dev/null; echo "===END:$slug==="; done`,
2840
+ ]);
2841
+ if (!output)
2842
+ return contentMap;
2843
+ // 解析输出,提取每个技能的内容
2844
+ const startMarker = '===START:';
2845
+ const endMarker = '===END:';
2846
+ const lines = output.split('\n');
2847
+ let currentSlug = '';
2848
+ let currentContent = [];
2849
+ let inContent = false;
2850
+ for (const line of lines) {
2851
+ if (line.startsWith(startMarker)) {
2852
+ currentSlug = line.slice(startMarker.length).replace(/===$/, '');
2853
+ inContent = true;
2854
+ currentContent = [];
2855
+ }
2856
+ else if (line.startsWith(endMarker)) {
2857
+ inContent = false;
2858
+ if (currentSlug) {
2859
+ contentMap.set(currentSlug, currentContent.join('\n'));
2860
+ }
2861
+ currentSlug = '';
2862
+ }
2863
+ else if (inContent) {
2864
+ currentContent.push(line);
2865
+ }
2866
+ }
2867
+ return contentMap;
2868
+ }
2869
+ /**
2870
+ * 批量读取容器内每个技能的 SKILL.md 内容
2871
+ * 使用单次 exec 调用读取所有技能的 MD 文件,减少 Docker API 调用次数
2872
+ */
2873
+ async enrichSkillsWithContent(containerId, skills) {
2874
+ if (skills.length === 0)
2875
+ return;
2876
+ // 安全校验:只允许合法字符的技能名参与 shell 命令(防止注入)
2877
+ const safeSkills = skills.filter((s) => this.dockerExec.isValidName(s.name));
2878
+ if (safeSkills.length === 0)
2879
+ return;
2880
+ const openclawHome = await this.resolveOpenClawHome(containerId);
2881
+ const openclawSkillsDir = buildOpenClawPath(openclawHome, 'skills');
2882
+ // 构建 shell 命令:遍历已知技能名,尝试多个路径读取 SKILL.md
2883
+ const script = safeSkills
2884
+ .map((s) => `echo "===SKILL:${s.name}==="; cat ${JSON.stringify(buildOpenClawPath(openclawSkillsDir, s.name, 'SKILL.md'))} 2>/dev/null || cat ${JSON.stringify(`/app/skills/${s.name}/SKILL.md`)} 2>/dev/null || echo ""`)
2885
+ .join('; ');
2886
+ const output = await this.execInContainer(containerId, [
2887
+ 'sh',
2888
+ '-c',
2889
+ script,
2890
+ ]);
2891
+ if (!output)
2892
+ return;
2893
+ // 解析输出,按 ===SKILL:name=== 分隔符拆分
2894
+ const sections = output.split(/===SKILL:([^=]+)===/);
2895
+ // sections: ['', name1, content1, name2, content2, ...]
2896
+ for (let i = 1; i < sections.length; i += 2) {
2897
+ const name = sections[i].trim();
2898
+ const content = sections[i + 1]?.trim() || null;
2899
+ const skill = skills.find((s) => s.name === name);
2900
+ if (skill && content) {
2901
+ skill.content = content;
2902
+ }
2903
+ }
2904
+ }
2905
+ // ============================================================================
2906
+ // Cron Job Management
2907
+ // ============================================================================
2908
+ /**
2909
+ * 删除 Gateway 中的 Cron Job
2910
+ *
2911
+ * @param gatewayPort - Gateway 端口
2912
+ * @param jobId - Cron Job ID
2913
+ * @returns Promise<void>
2914
+ */
2915
+ async deleteCronJob(gatewayPort, jobId) {
2916
+ const gatewayHost = (0, env_config_service_1.getEnvWithDefault)('GATEWAY_HOST', '127.0.0.1');
2917
+ const url = `http://${gatewayHost}:${gatewayPort}/cron/${jobId}`;
2918
+ this.logger.info(`[OpenClawClient] Deleting cron job: ${jobId} from gateway port ${gatewayPort}`);
2919
+ try {
2920
+ const response = await (0, rxjs_1.firstValueFrom)(this.httpService.delete(url).pipe((0, rxjs_1.timeout)(10000), (0, rxjs_1.catchError)((error) => {
2921
+ this.logger.error(`[OpenClawClient] Failed to delete cron job ${jobId}:`, error.response?.data || error.message);
2922
+ throw error;
2923
+ })));
2924
+ this.logger.info(`[OpenClawClient] Successfully deleted cron job: ${jobId}`);
2925
+ return response.data;
2926
+ }
2927
+ catch (error) {
2928
+ if (error.response?.status === 404) {
2929
+ this.logger.warn(`[OpenClawClient] Cron job ${jobId} not found (already deleted)`);
2930
+ return; // 任务不存在,视为删除成功
2931
+ }
2932
+ throw error;
2933
+ }
2934
+ }
2935
+ /**
2936
+ * 批量删除 Gateway 中的 Cron Jobs
2937
+ *
2938
+ * @param gatewayPort - Gateway 端口
2939
+ * @param jobIds - Cron Job ID 数组
2940
+ * @returns Promise<{ success: number; failed: number }>
2941
+ */
2942
+ async deleteCronJobs(gatewayPort, jobIds) {
2943
+ this.logger.info(`[OpenClawClient] Batch deleting ${jobIds.length} cron jobs from gateway port ${gatewayPort}`);
2944
+ let success = 0;
2945
+ let failed = 0;
2946
+ for (const jobId of jobIds) {
2947
+ try {
2948
+ await this.deleteCronJob(gatewayPort, jobId);
2949
+ success++;
2950
+ }
2951
+ catch (error) {
2952
+ this.logger.error(`[OpenClawClient] Failed to delete cron job ${jobId}:`, error instanceof Error ? error.message : String(error));
2953
+ failed++;
2954
+ }
2955
+ }
2956
+ this.logger.info(`[OpenClawClient] Batch delete completed: ${success} succeeded, ${failed} failed`);
2957
+ return { success, failed };
2958
+ }
2959
+ // ============================================================================
2960
+ // Agent Reload & Verification
2961
+ // ============================================================================
2962
+ /**
2963
+ * 通知 OpenClaw 重新扫描 Agents 配置(best-effort)
2964
+ *
2965
+ * 说明:
2966
+ * - OpenClaw 已有 chokidar 文件监听,通常会自动感知变更
2967
+ * - 此方法通过 touch 配置文件触发 chokidar 的 change 事件
2968
+ * - 调用方不应将此方法视为唯一或强保证的生效机制
2969
+ *
2970
+ * @param containerId Docker 容器 ID
2971
+ * @param agentKey 可选:指定的 agent key,仅用于日志
2972
+ */
2973
+ async reloadAgents(containerId, agentKey) {
2974
+ this.logger.info('OpenClawClient: notifying Agents rescan', {
2975
+ containerId,
2976
+ agentKey,
2977
+ });
2978
+ // 通过 "touch" 配置文件,触发 chokidar 的 change 事件
2979
+ // 这是最安全的方式,利用 OpenClaw 已有的文件监听机制
2980
+ try {
2981
+ const openclawHome = await this.resolveOpenClawHome(containerId);
2982
+ const configPath = buildOpenClawPath(openclawHome, 'openclaw.json');
2983
+ await this.dockerExec.executeCommand(containerId, ['touch', configPath]);
2984
+ this.logger.info('OpenClawClient: Agents rescan notification sent (touch config)', {
2985
+ containerId,
2986
+ configPath,
2987
+ hint: 'Gateway should detect config change via chokidar watch',
2988
+ });
2989
+ }
2990
+ catch (error) {
2991
+ this.logger.warn('OpenClawClient: Failed to touch config for agents rescan', {
2992
+ containerId,
2993
+ error: error instanceof Error ? error.message : String(error),
2994
+ });
2995
+ // 不抛出异常,这是 best-effort 通知
2996
+ }
2997
+ }
2998
+ /**
2999
+ * 验证 Agent 是否在 Gateway 中可见
3000
+ *
3001
+ * @param containerId Docker 容器 ID
3002
+ * @param agentKey Agent key
3003
+ * @returns Agent 是否可见
3004
+ */
3005
+ async verifyAgentVisible(containerId, agentKey) {
3006
+ try {
3007
+ // 使用 openclaw agents list 命令检查
3008
+ const result = await this.dockerExec.executeCommand(containerId, [
3009
+ 'openclaw',
3010
+ 'agents',
3011
+ 'list',
3012
+ '--json',
3013
+ ]);
3014
+ if (!result.success) {
3015
+ this.logger.warn('OpenClawClient: Failed to list agents for verification', {
3016
+ containerId,
3017
+ agentKey,
3018
+ stderr: result.stderr,
3019
+ });
3020
+ return false;
3021
+ }
3022
+ try {
3023
+ const agents = JSON.parse(result.stdout);
3024
+ const found = Array.isArray(agents) &&
3025
+ agents.some((a) => a.id === agentKey || a.name === agentKey);
3026
+ this.logger.info('OpenClawClient: Agent visibility verification', {
3027
+ containerId,
3028
+ agentKey,
3029
+ visible: found,
3030
+ });
3031
+ return found;
3032
+ }
3033
+ catch {
3034
+ // 如果 JSON 解析失败,尝试简单的字符串匹配
3035
+ const found = result.stdout.includes(agentKey);
3036
+ this.logger.info('OpenClawClient: Agent visibility verification (string match)', {
3037
+ containerId,
3038
+ agentKey,
3039
+ visible: found,
3040
+ });
3041
+ return found;
3042
+ }
3043
+ }
3044
+ catch (error) {
3045
+ this.logger.warn('OpenClawClient: Failed to verify agent visibility', {
3046
+ containerId,
3047
+ agentKey,
3048
+ error: error instanceof Error ? error.message : String(error),
3049
+ });
3050
+ return false;
3051
+ }
3052
+ }
3053
+ };
3054
+ exports.OpenClawClient = OpenClawClient;
3055
+ exports.OpenClawClient = OpenClawClient = __decorate([
3056
+ (0, common_1.Injectable)(),
3057
+ __param(0, (0, common_1.Inject)(nest_winston_1.WINSTON_MODULE_PROVIDER)),
3058
+ __metadata("design:paramtypes", [winston_1.Logger,
3059
+ axios_1.HttpService,
3060
+ docker_exec_service_1.DockerExecService])
3061
+ ], OpenClawClient);
3062
+ //# sourceMappingURL=openclaw.client.js.map