|
| 1 | +#!/usr/bin/env node |
| 2 | +/** |
| 3 | + * OpenClaw startup initializer for Docker / Websoft9. |
| 4 | + * |
| 5 | + * Runs BEFORE the gateway starts. Auto-configures AI providers based on |
| 6 | + * environment variables — no CLI commands needed for users. |
| 7 | + * |
| 8 | + * Writes to THREE locations (all layers of OpenClaw's auth stack): |
| 9 | + * 1. openclaw.json — gateway-level provider/model config |
| 10 | + * 2. agents/main/agent/models.json — agent model catalog |
| 11 | + * 3. agents/main/agent/auth-profiles.json — agent API key store |
| 12 | + * |
| 13 | + * Supported env vars (set in .env): |
| 14 | + * 国内主流大模型 (Domestic AI providers): |
| 15 | + * DEEPSEEK_API_KEY → deepseek/deepseek-chat (custom provider) |
| 16 | + * BAIDU_API_KEY/SECRET → baidu/ernie-* (百度文心一言) |
| 17 | + * ALIBABA_API_KEY → alibaba/qwen-* (阿里通义千问) |
| 18 | + * MOONSHOT_API_KEY → moonshot/moonshot-v1-* (月之暗面 Kimi) |
| 19 | + * |
| 20 | + * WeCom channel (@sunnoy/wecom — community-enhanced plugin): |
| 21 | + * WebSocket persistent connection, no callback URL needed. |
| 22 | + * WECOM_BOT_ID → Bot ID (机器人ID) from WeCom AI Bot console |
| 23 | + * WECOM_BOT_SECRET → Bot Secret (机器人密钥) from WeCom AI Bot console |
| 24 | + * WECOM_DM_POLICY → Private chat policy: open|pairing|allowlist|disabled (default: open) |
| 25 | + * Optional Agent (enhanced outbound — file/image/dept/tag sending): |
| 26 | + * WECOM_AGENT_CORP_ID → corpId of self-built app (wwXXXXXX) |
| 27 | + * WECOM_AGENT_SECRET → corpSecret of self-built app |
| 28 | + * WECOM_AGENT_ID → agentId of self-built app (number) |
| 29 | + */ |
| 30 | + |
| 31 | +'use strict'; |
| 32 | + |
| 33 | +const fs = require('fs'); |
| 34 | +const path = require('path'); |
| 35 | + |
| 36 | +const OPENCLAW_DIR = '/home/node/.openclaw'; |
| 37 | +const CONFIG_PATH = path.join(OPENCLAW_DIR, 'openclaw.json'); |
| 38 | +const AGENT_DIR = path.join(OPENCLAW_DIR, 'agents/main/agent'); |
| 39 | +const AGENT_MODELS = path.join(AGENT_DIR, 'models.json'); |
| 40 | +const AUTH_PROFILES = path.join(AGENT_DIR, 'auth-profiles.json'); |
| 41 | + |
| 42 | +fs.mkdirSync(OPENCLAW_DIR, { recursive: true }); |
| 43 | +fs.mkdirSync(AGENT_DIR, { recursive: true }); |
| 44 | + |
| 45 | +// ── Environment variable mapping (W9_*_SET UI-configurable vars) ───────────── |
| 46 | +// This allows Websoft9 app store to expose these as form fields while |
| 47 | +// maintaining backward compatibility with non-W9 variable names. |
| 48 | +const env = { |
| 49 | + // AI Models — W9_*_SET takes precedence, fallback to original name |
| 50 | + DEEPSEEK_API_KEY: process.env.W9_DEEPSEEK_API_KEY_SET || process.env.DEEPSEEK_API_KEY, |
| 51 | + BAIDU_API_KEY: process.env.W9_BAIDU_API_KEY_SET, |
| 52 | + BAIDU_SECRET_KEY: process.env.W9_BAIDU_SECRET_KEY_SET, |
| 53 | + ALIBABA_API_KEY: process.env.W9_ALIBABA_API_KEY_SET, |
| 54 | + MOONSHOT_API_KEY: process.env.W9_MOONSHOT_API_KEY_SET, |
| 55 | + |
| 56 | + // WeCom (企业微信) — @sunnoy/wecom plugin |
| 57 | + WECOM_BOT_ID: process.env.W9_WECOM_BOT_ID_SET || process.env.WECOM_BOT_ID, |
| 58 | + WECOM_BOT_SECRET: process.env.W9_WECOM_BOT_SECRET_SET || process.env.WECOM_BOT_SECRET, |
| 59 | + WECOM_DM_POLICY: process.env.W9_WECOM_DM_POLICY_SET || process.env.WECOM_DM_POLICY || 'open', |
| 60 | + // Optional: Self-built App for enhanced outbound (file/image/dept/tag) |
| 61 | + WECOM_AGENT_CORP_ID: process.env.W9_WECOM_AGENT_CORP_ID_SET || process.env.WECOM_AGENT_CORP_ID, |
| 62 | + WECOM_AGENT_SECRET: process.env.W9_WECOM_AGENT_SECRET_SET || process.env.WECOM_AGENT_SECRET, |
| 63 | + WECOM_AGENT_ID: process.env.W9_WECOM_AGENT_ID_SET || process.env.WECOM_AGENT_ID, |
| 64 | + |
| 65 | +}; |
| 66 | + |
| 67 | +// ── Load existing openclaw.json ─────────────────────────────────────────────── |
| 68 | +let config = {}; |
| 69 | +try { |
| 70 | + config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')); |
| 71 | + console.log('[init] Loaded existing openclaw.json'); |
| 72 | +} catch (_) { |
| 73 | + console.log('[init] Starting fresh openclaw.json'); |
| 74 | +} |
| 75 | + |
| 76 | +// ── Gateway (always overwrite from env) ─────────────────────────────────────── |
| 77 | +config.gateway = { |
| 78 | + mode: 'local', |
| 79 | + bind: process.env.OPENCLAW_GATEWAY_BIND || 'lan', |
| 80 | + auth: { mode: 'token', token: process.env.OPENCLAW_GATEWAY_TOKEN }, |
| 81 | + controlUi: { |
| 82 | + allowedOrigins: ['*'], |
| 83 | + // Disable per-device pairing for Docker deployments — the gateway token |
| 84 | + // already provides sufficient access control. Without this, every new |
| 85 | + // browser requires manual `openclaw devices approve` which is impractical. |
| 86 | + dangerouslyDisableDeviceAuth: true |
| 87 | + } |
| 88 | +}; |
| 89 | + |
| 90 | +// Only allow @sunnoy/wecom plugin (plugin id = "wecom", directory = "wecom") |
| 91 | +config.plugins = { allow: ['wecom'] }; |
| 92 | + |
| 93 | +// ── Build provider + auth lists ─────────────────────────────────────────────── |
| 94 | +const gatewayProviders = (config.models && config.models.providers) || {}; |
| 95 | +const agentModels = { providers: {} }; |
| 96 | +const authProfiles = { version: 1, profiles: {} }; |
| 97 | + |
| 98 | +// ── DeepSeek (custom — needs config in all three files) ─────────────────────── |
| 99 | +if (env.DEEPSEEK_API_KEY) { |
| 100 | + const deepseekDef = { |
| 101 | + baseUrl: 'https://api.deepseek.com/v1', |
| 102 | + apiKey: env.DEEPSEEK_API_KEY, |
| 103 | + api: 'openai-completions', |
| 104 | + authHeader: true, |
| 105 | + models: [ |
| 106 | + { |
| 107 | + id: 'deepseek-chat', name: 'DeepSeek V3 (deepseek-chat)', |
| 108 | + api: 'openai-completions', reasoning: false, input: ['text'], |
| 109 | + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, |
| 110 | + contextWindow: 65536, maxTokens: 8192 |
| 111 | + }, |
| 112 | + { |
| 113 | + id: 'deepseek-reasoner', name: 'DeepSeek R1 (deepseek-reasoner)', |
| 114 | + api: 'openai-completions', reasoning: true, input: ['text'], |
| 115 | + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, |
| 116 | + contextWindow: 65536, maxTokens: 8192 |
| 117 | + } |
| 118 | + ] |
| 119 | + }; |
| 120 | + gatewayProviders.deepseek = deepseekDef; |
| 121 | + agentModels.providers.deepseek = deepseekDef; |
| 122 | + authProfiles.profiles['deepseek:default'] = { |
| 123 | + type: 'api_key', mode: 'api_key', |
| 124 | + provider: 'deepseek', |
| 125 | + apiKey: env.DEEPSEEK_API_KEY |
| 126 | + }; |
| 127 | + console.log('[init] DeepSeek configured (gateway + models + auth-profiles)'); |
| 128 | +} else { |
| 129 | + delete gatewayProviders.deepseek; |
| 130 | +} |
| 131 | + |
| 132 | +// ── Baidu ERNIE (百度文心) ──────────────────────────────────────────────────── |
| 133 | +// Baidu Qianfan uses OAuth2: must exchange API Key + Secret Key for access_token |
| 134 | +// API doc: https://cloud.baidu.com/doc/WENXINWORKSHOP/s/flfmc9do2 |
| 135 | +if (env.BAIDU_API_KEY && env.BAIDU_SECRET_KEY) { |
| 136 | + const baiduDef = { |
| 137 | + baseUrl: 'https://aip.baidubce.com/rpc/2.0/ai_custom/v1/wenxinworkshop/chat', |
| 138 | + apiKey: env.BAIDU_API_KEY, // For OAuth token fetch |
| 139 | + secretKey: env.BAIDU_SECRET_KEY, // For OAuth token fetch |
| 140 | + api: 'baidu-chat', // Custom API adapter (OpenClaw will need to implement this) |
| 141 | + authHeader: false, // Auth via ?access_token= query param |
| 142 | + models: [ |
| 143 | + { |
| 144 | + id: 'ernie-4.0-turbo-8k', name: 'ERNIE 4.0 Turbo', |
| 145 | + api: 'baidu-chat', reasoning: false, input: ['text'], |
| 146 | + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, |
| 147 | + contextWindow: 8192, maxTokens: 2048 |
| 148 | + }, |
| 149 | + { |
| 150 | + id: 'ernie-3.5-8k', name: 'ERNIE 3.5', |
| 151 | + api: 'baidu-chat', reasoning: false, input: ['text'], |
| 152 | + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, |
| 153 | + contextWindow: 8192, maxTokens: 2048 |
| 154 | + }, |
| 155 | + { |
| 156 | + id: 'ernie-speed-128k', name: 'ERNIE Speed 128K', |
| 157 | + api: 'baidu-chat', reasoning: false, input: ['text'], |
| 158 | + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, |
| 159 | + contextWindow: 131072, maxTokens: 4096 |
| 160 | + } |
| 161 | + ] |
| 162 | + }; |
| 163 | + gatewayProviders.baidu = baiduDef; |
| 164 | + agentModels.providers.baidu = baiduDef; |
| 165 | + authProfiles.profiles['baidu:default'] = { |
| 166 | + type: 'api_key', mode: 'api_key', |
| 167 | + provider: 'baidu', |
| 168 | + apiKey: env.BAIDU_API_KEY, |
| 169 | + secretKey: env.BAIDU_SECRET_KEY // Non-standard: Baidu needs both keys |
| 170 | + }; |
| 171 | + console.log('[init] Baidu ERNIE configured (requires baidu-chat API adapter)'); |
| 172 | +} else { |
| 173 | + delete gatewayProviders.baidu; |
| 174 | +} |
| 175 | + |
| 176 | +// ── Alibaba Qwen (阿里通义千问) ─────────────────────────────────────────────── |
| 177 | +// DashScope API: https://help.aliyun.com/zh/dashscope/developer-reference/api-details |
| 178 | +if (env.ALIBABA_API_KEY) { |
| 179 | + const aliDef = { |
| 180 | + baseUrl: 'https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation', |
| 181 | + apiKey: env.ALIBABA_API_KEY, |
| 182 | + api: 'qwen-chat', // Custom API adapter |
| 183 | + authHeader: true, // Authorization: Bearer <API-KEY> |
| 184 | + models: [ |
| 185 | + { |
| 186 | + id: 'qwen-max', name: 'Qwen Max (最强推理)', |
| 187 | + api: 'qwen-chat', reasoning: false, input: ['text'], |
| 188 | + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, |
| 189 | + contextWindow: 30720, maxTokens: 8192 |
| 190 | + }, |
| 191 | + { |
| 192 | + id: 'qwen-plus', name: 'Qwen Plus (平衡性价比)', |
| 193 | + api: 'qwen-chat', reasoning: false, input: ['text'], |
| 194 | + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, |
| 195 | + contextWindow: 131072, maxTokens: 8192 |
| 196 | + }, |
| 197 | + { |
| 198 | + id: 'qwen-turbo', name: 'Qwen Turbo (极速响应)', |
| 199 | + api: 'qwen-chat', reasoning: false, input: ['text'], |
| 200 | + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, |
| 201 | + contextWindow: 131072, maxTokens: 8192 |
| 202 | + } |
| 203 | + ] |
| 204 | + }; |
| 205 | + gatewayProviders.alibaba = aliDef; |
| 206 | + agentModels.providers.alibaba = aliDef; |
| 207 | + authProfiles.profiles['alibaba:default'] = { |
| 208 | + type: 'api_key', mode: 'api_key', |
| 209 | + provider: 'alibaba', |
| 210 | + apiKey: env.ALIBABA_API_KEY |
| 211 | + }; |
| 212 | + console.log('[init] Alibaba Qwen configured (requires qwen-chat API adapter)'); |
| 213 | +} else { |
| 214 | + delete gatewayProviders.alibaba; |
| 215 | +} |
| 216 | + |
| 217 | +// ── Moonshot (月之暗面 Kimi) ────────────────────────────────────────────────── |
| 218 | +// OpenAI-compatible API: https://platform.moonshot.cn/docs/api-reference |
| 219 | +if (env.MOONSHOT_API_KEY) { |
| 220 | + const moonshotDef = { |
| 221 | + baseUrl: 'https://api.moonshot.cn/v1', |
| 222 | + apiKey: env.MOONSHOT_API_KEY, |
| 223 | + api: 'openai-completions', // OpenAI-compatible |
| 224 | + authHeader: true, |
| 225 | + models: [ |
| 226 | + { |
| 227 | + id: 'moonshot-v1-8k', name: 'Moonshot v1 8K', |
| 228 | + api: 'openai-completions', reasoning: false, input: ['text'], |
| 229 | + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, |
| 230 | + contextWindow: 8192, maxTokens: 4096 |
| 231 | + }, |
| 232 | + { |
| 233 | + id: 'moonshot-v1-32k', name: 'Moonshot v1 32K', |
| 234 | + api: 'openai-completions', reasoning: false, input: ['text'], |
| 235 | + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, |
| 236 | + contextWindow: 32768, maxTokens: 4096 |
| 237 | + }, |
| 238 | + { |
| 239 | + id: 'moonshot-v1-128k', name: 'Moonshot v1 128K', |
| 240 | + api: 'openai-completions', reasoning: false, input: ['text'], |
| 241 | + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, |
| 242 | + contextWindow: 131072, maxTokens: 4096 |
| 243 | + } |
| 244 | + ] |
| 245 | + }; |
| 246 | + gatewayProviders.moonshot = moonshotDef; |
| 247 | + agentModels.providers.moonshot = moonshotDef; |
| 248 | + authProfiles.profiles['moonshot:default'] = { |
| 249 | + type: 'api_key', mode: 'api_key', |
| 250 | + provider: 'moonshot', |
| 251 | + apiKey: env.MOONSHOT_API_KEY |
| 252 | + }; |
| 253 | + console.log('[init] Moonshot configured (OpenAI-compatible)'); |
| 254 | +} else { |
| 255 | + delete gatewayProviders.moonshot; |
| 256 | +} |
| 257 | + |
| 258 | +// ── WeCom Channel (@sunnoy/wecom) ──────────────────────────────────────────── |
| 259 | +// WebSocket persistent connection — no callback URL needed. |
| 260 | +// Plugin is guaranteed by Dockerfile, no filesystem check required. |
| 261 | +// Optional Agent config enables file/image/dept/tag outbound sending. |
| 262 | +const hasWeCom = !!(env.WECOM_BOT_ID && env.WECOM_BOT_SECRET); |
| 263 | +const hasWeComAgent = !!(env.WECOM_AGENT_CORP_ID && env.WECOM_AGENT_SECRET && env.WECOM_AGENT_ID); |
| 264 | + |
| 265 | +if (hasWeCom) { |
| 266 | + const wecomConfig = { |
| 267 | + enabled: true, |
| 268 | + botId: env.WECOM_BOT_ID, |
| 269 | + secret: env.WECOM_BOT_SECRET, |
| 270 | + // dmPolicy: open = all members can chat directly (no pairing approval needed) |
| 271 | + // options: open | pairing | allowlist | disabled |
| 272 | + dmPolicy: env.WECOM_DM_POLICY |
| 273 | + }; |
| 274 | + |
| 275 | + // Optional: Self-built App for enhanced outbound (file/image/dept/tag sending) |
| 276 | + // Does NOT need token/encodingAESKey — Agent only handles outbound, not inbound. |
| 277 | + if (hasWeComAgent) { |
| 278 | + wecomConfig.agent = { |
| 279 | + corpId: env.WECOM_AGENT_CORP_ID, |
| 280 | + corpSecret: env.WECOM_AGENT_SECRET, |
| 281 | + agentId: parseInt(env.WECOM_AGENT_ID, 10) |
| 282 | + }; |
| 283 | + } |
| 284 | + |
| 285 | + config.channels = config.channels || {}; |
| 286 | + config.channels['wecom'] = wecomConfig; |
| 287 | + |
| 288 | + // Add/update binding to the main agent (preserving any other bindings) |
| 289 | + const wecomBinding = { agentId: 'main', match: { channel: 'wecom' } }; |
| 290 | + const otherBindings = (Array.isArray(config.bindings) ? config.bindings : []) |
| 291 | + .filter(b => !(b.match && b.match.channel === 'wecom')); |
| 292 | + config.bindings = [...otherBindings, wecomBinding]; |
| 293 | + |
| 294 | + const agentLabel = hasWeComAgent ? ' + Agent(outbound)' : ''; |
| 295 | + console.log('[init] WeCom configured (WebSocket' + agentLabel + ', dmPolicy=' + env.WECOM_DM_POLICY + ', botId=' + env.WECOM_BOT_ID + ')'); |
| 296 | +} else { |
| 297 | + // Remove WeCom config when credentials not provided |
| 298 | + if (config.channels && config.channels['wecom']) { |
| 299 | + delete config.channels['wecom']; |
| 300 | + if (Object.keys(config.channels).length === 0) delete config.channels; |
| 301 | + } |
| 302 | + if (Array.isArray(config.bindings)) { |
| 303 | + config.bindings = config.bindings.filter( |
| 304 | + b => !(b.match && b.match.channel === 'wecom') |
| 305 | + ); |
| 306 | + if (config.bindings.length === 0) delete config.bindings; |
| 307 | + } |
| 308 | + console.log('[init] WeCom skipped — set W9_WECOM_BOT_ID_SET and W9_WECOM_BOT_SECRET_SET to enable'); |
| 309 | +} |
| 310 | + |
| 311 | +// ── Write models.json ───────────────────────────────────────────────────────── |
| 312 | +if (Object.keys(agentModels.providers).length > 0) { |
| 313 | + fs.writeFileSync(AGENT_MODELS, JSON.stringify(agentModels, null, 2)); |
| 314 | + console.log('[init] models.json written'); |
| 315 | +} else { |
| 316 | + try { fs.unlinkSync(AGENT_MODELS); } catch (_) { } |
| 317 | +} |
| 318 | + |
| 319 | +// ── Write auth-profiles.json ────────────────────────────────────────────────── |
| 320 | +if (Object.keys(authProfiles.profiles).length > 0) { |
| 321 | + fs.writeFileSync(AUTH_PROFILES, JSON.stringify(authProfiles, null, 2)); |
| 322 | + console.log('[init] auth-profiles.json written'); |
| 323 | +} else { |
| 324 | + try { fs.unlinkSync(AUTH_PROFILES); } catch (_) { } |
| 325 | +} |
| 326 | + |
| 327 | +// ── Write openclaw.json (gateway + model defaults) ──────────────────────────── |
| 328 | +if (Object.keys(gatewayProviders).length > 0) { |
| 329 | + config.models = { mode: 'merge', providers: gatewayProviders }; |
| 330 | +} else { |
| 331 | + delete config.models; |
| 332 | +} |
| 333 | + |
| 334 | +// Default model — always recalculate so stale refs are cleared if key removed |
| 335 | +// Priority: DeepSeek → Moonshot → Baidu → Alibaba |
| 336 | +let defaultModel = null; |
| 337 | +if (env.DEEPSEEK_API_KEY) defaultModel = 'deepseek/deepseek-chat'; |
| 338 | +else if (env.MOONSHOT_API_KEY) defaultModel = 'moonshot/moonshot-v1-32k'; |
| 339 | +else if (env.BAIDU_API_KEY) defaultModel = 'baidu/ernie-4.0-turbo-8k'; |
| 340 | +else if (env.ALIBABA_API_KEY) defaultModel = 'alibaba/qwen-max'; |
| 341 | + |
| 342 | +if (defaultModel) { |
| 343 | + config.agents = config.agents || {}; |
| 344 | + config.agents.defaults = config.agents.defaults || {}; |
| 345 | + config.agents.defaults.model = { primary: defaultModel }; |
| 346 | + config.agents.defaults.workspace = config.agents.defaults.workspace || '~/.openclaw/workspace'; |
| 347 | + console.log('[init] Default model:', defaultModel); |
| 348 | +} else { |
| 349 | + if (config.agents && config.agents.defaults) delete config.agents.defaults.model; |
| 350 | + console.log('[init] No API key — configure one in .env and restart'); |
| 351 | +} |
| 352 | + |
| 353 | +fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2)); |
| 354 | +console.log('[init] openclaw.json written'); |
0 commit comments