@vrs-soft/wecom-aibot-mcp 3.3.3 → 3.4.1

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.
@@ -14,11 +14,14 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
14
14
  import { z } from 'zod';
15
15
  import * as fs from 'fs';
16
16
  import * as path from 'path';
17
- import { execSync } from 'child_process';
18
- import { VERSION, installSkill } from './config-wizard.js';
17
+ import { VERSION, installSkill, ensureHookFiles } from './config-wizard.js';
19
18
  import { addPermissionHook, registerActiveProject, unregisterActiveProject, updateWechatModeConfig, loadWechatModeConfig } from './project-config.js';
19
+ import { findClaudePid } from './platform.js';
20
20
  import { logger } from './logger.js';
21
21
  /**
22
+ * v3.4.0: findClaudePid 移到 src/platform.ts(跨平台 wmic / ps),下方旧实现保留为参考
23
+ * 但已经不再使用 —— 调用点 import 自 platform.js
24
+ *
22
25
  * 沿进程树向上查找 Claude Code TUI 的 PID。
23
26
  *
24
27
  * 背景:本地 dev (`command: "node"`) 时 channel-server 是 Claude TUI 的直接子进程,
@@ -32,32 +35,7 @@ import { logger } from './logger.js';
32
35
  * 此函数从 startPid 起向上遍历,找到第一个命令名为 "claude" 的进程,返回其 PID。
33
36
  * 找不到时回退到 startPid(保持旧行为,至少 dev 场景不退化)。
34
37
  */
35
- function findClaudePid(startPid) {
36
- let pid = startPid;
37
- for (let i = 0; i < 8; i++) {
38
- if (!pid || pid <= 1)
39
- break;
40
- try {
41
- const comm = execSync(`ps -p ${pid} -o comm=`, { stdio: ['ignore', 'pipe', 'ignore'] })
42
- .toString()
43
- .trim();
44
- // ps comm= 返回执行文件 basename。Claude Code TUI 安装名就是 "claude"
45
- if (comm === 'claude' || comm.endsWith('/claude'))
46
- return pid;
47
- const ppidStr = execSync(`ps -p ${pid} -o ppid=`, { stdio: ['ignore', 'pipe', 'ignore'] })
48
- .toString()
49
- .trim();
50
- const ppid = parseInt(ppidStr, 10);
51
- if (!ppid || ppid === pid)
52
- break;
53
- pid = ppid;
54
- }
55
- catch {
56
- break;
57
- }
58
- }
59
- return startPid;
60
- }
38
+ // 实现已迁移到 src/platform.ts(跨平台 wmic / ps),见 import { findClaudePid }
61
39
  const MCP_URL = process.env.MCP_URL || 'http://127.0.0.1:18963';
62
40
  const MCP_AUTH_TOKEN = process.env.MCP_AUTH_TOKEN;
63
41
  // ============================================
@@ -882,6 +860,15 @@ function registerChannelTools(server) {
882
860
  connectSSE(parsed.ccId);
883
861
  // Channel 模式:在本地项目写入 PermissionRequest hook
884
862
  const localProjectDir = project_dir || process.cwd();
863
+ // v3.4.0: 先确保 ~/.wecom-aibot-mcp/permission-hook.mjs 存在
864
+ // (addPermissionHook 只往项目 settings.json 写命令,不装文件;
865
+ // 没装文件的话 Claude TUI 执行 hook 命令会 ENOENT)
866
+ try {
867
+ ensureHookFiles();
868
+ }
869
+ catch (e) {
870
+ logger.error('ensureHookFiles 失败', { error: String(e) });
871
+ }
885
872
  const hookResult = addPermissionHook(localProjectDir);
886
873
  logger.info('本地 PermissionRequest hook 已写入', { path: hookResult.path, success: hookResult.success });
887
874
  // 注册 Claude TUI 的 PID(不能用 process.ppid,npx 部署时 ppid 是 npx 不是 Claude)
@@ -5,6 +5,7 @@ export declare function deleteHook(): void;
5
5
  export declare function deleteSkills(): void;
6
6
  export declare function uninstall(): void;
7
7
  export declare function ensureHookInstalled(): void;
8
+ export declare function ensureHookFiles(): void;
8
9
  export type InstallMode = 'channel-only' | 'remote' | 'remote-channel';
9
10
  export declare function getInstalledMode(): {
10
11
  mode?: InstallMode;
@@ -18,8 +18,13 @@ const VERSION_FILE = path.join(CONFIG_DIR, 'version.json');
18
18
  const SERVER_CONFIG_FILE = path.join(CONFIG_DIR, 'server.json');
19
19
  const CLAUDE_CONFIG_FILE = path.join(os.homedir(), '.claude.json');
20
20
  const CLAUDE_SETTINGS_FILE = path.join(os.homedir(), '.claude', 'settings.local.json');
21
- const HOOK_SCRIPT_PATH = path.join(CONFIG_DIR, 'permission-hook.sh');
22
- const STOP_HOOK_SCRIPT_PATH = path.join(CONFIG_DIR, 'stop-hook.sh');
21
+ // v3.4.0: hook 改 Node.js 实现,安装为 .mjs(hook 安装目录无 package.json,
22
+ // .js 会被 node 当 CommonJS,源文件的 import 语法报错)
23
+ const HOOK_SCRIPT_PATH = path.join(CONFIG_DIR, 'permission-hook.mjs');
24
+ const STOP_HOOK_SCRIPT_PATH = path.join(CONFIG_DIR, 'stop-hook.mjs');
25
+ // 旧路径(v3.3.x 及以前),安装时清理
26
+ const HOOK_SCRIPT_PATH_LEGACY = path.join(CONFIG_DIR, 'permission-hook.sh');
27
+ const STOP_HOOK_SCRIPT_PATH_LEGACY = path.join(CONFIG_DIR, 'stop-hook.sh');
23
28
  // Skill 模板路径(包内)- 使用 fileURLToPath 确保跨平台兼容
24
29
  const __filename = fileURLToPath(import.meta.url);
25
30
  const __dirname = path.dirname(__filename);
@@ -179,389 +184,48 @@ export function uninstall() {
179
184
  }
180
185
  // 生成并写入 hook 脚本(HTTP Transport 版本)
181
186
  function writeHookScript() {
182
- const script = `#!/bin/bash
183
- # wecom-aibot-mcp PermissionRequest hook
184
- # HTTP Transport 版本
185
- #
186
- # 固定端口: 18963
187
- # 通过 PID 树查 ~/.wecom-aibot-mcp/active-projects.json 匹配项目,读 wechatMode 开关
188
-
189
- MCP_PORT=18963
190
-
191
- # 先保存输入(只能读一次)
192
- INPUT=$(cat)
193
-
194
- # 日志输出:--debug 模式下输出到 stderr,否则静默
195
- DEBUG_FILE="$HOME/.wecom-aibot-mcp/debug"
196
- log_debug() {
197
- if [[ -f "$DEBUG_FILE" ]]; then
198
- echo "$1" >&2
199
- fi
200
- }
201
-
202
- log_debug "[$(date)] Hook called. TOOL_NAME: $(echo "$INPUT" | jq -r '.tool_name')"
203
-
204
- TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty')
205
-
206
- # MCP 工具本身不需要拦截
207
- if [[ "$TOOL_NAME" == mcp__* ]]; then
208
- log_debug "[$(date)] Allowed: MCP tool"
209
- printf '%s\\n' '{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}'
210
- exit 0
211
- fi
212
-
213
- # 只读工具不需要拦截
214
- case "$TOOL_NAME" in
215
- Read|Glob|Grep|LS|TaskList|TaskGet|TaskOutput|TaskStop|CronList|CronCreate|CronDelete|AskUserQuestion|Skill|ListMcpResourcesTool|EnterPlanMode|ExitPlanMode|WebSearch|WebFetch|NotebookEdit)
216
- log_debug "[$(date)] Allowed: read-only tool"
217
- printf '%s\\n' '{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}'
218
- exit 0
219
- ;;
220
- esac
221
-
222
- # 通过进程树匹配活跃项目(以 Claude 进程为准,不依赖 pwd)
223
- ACTIVE_INDEX="$HOME/.wecom-aibot-mcp/active-projects.json"
224
- log_debug "[$(date)] Checking active-projects index via PID tree (pid=$$, ppid=$PPID)"
225
-
226
- if [[ ! -f "$ACTIVE_INDEX" ]]; then
227
- log_debug "[$(date)] No active-projects index, exit 0"
228
- exit 0
229
- fi
230
-
231
- # 沿进程树向上查找,深度 8 层
232
- PROJECT_DIR=""
233
- SEARCH_PID=$PPID
234
- for i in {1..8}; do
235
- if [[ -z "$SEARCH_PID" ]] || [[ "$SEARCH_PID" -le 1 ]]; then
236
- break
237
- fi
238
- MATCH=$(jq -r --argjson p "$SEARCH_PID" '.[] | select(.pid==$p) | .projectDir' "$ACTIVE_INDEX" 2>/dev/null)
239
- if [[ -n "$MATCH" ]]; then
240
- PROJECT_DIR="$MATCH"
241
- log_debug "[$(date)] Found project via PID $SEARCH_PID (depth $i): $PROJECT_DIR"
242
- break
243
- fi
244
- SEARCH_PID=$(ps -o ppid= -p "$SEARCH_PID" 2>/dev/null | tr -d ' ')
245
- done
246
-
247
- if [[ -z "$PROJECT_DIR" ]]; then
248
- log_debug "[$(date)] No PID match in process tree, exit 0"
249
- exit 0
250
- fi
251
-
252
- CONFIG_FILE="$PROJECT_DIR/.claude/wecom-aibot.json"
253
- log_debug "[$(date)] Found project: $PROJECT_DIR"
254
-
255
- # 配置文件不存在,不在微信模式
256
- if [[ ! -f "$CONFIG_FILE" ]]; then
257
- log_debug "[$(date)] No wecom-aibot.json config, exit 0"
258
- exit 0
259
- fi
260
-
261
- # 检查 wechatMode 是否为 true(微信模式开关)
262
- WECHAT_MODE=$(jq -r '.wechatMode // false' "$CONFIG_FILE" 2>/dev/null)
263
- log_debug "[$(date)] wechatMode: $WECHAT_MODE"
264
- if [[ "$WECHAT_MODE" != "true" ]]; then
265
- log_debug "[$(date)] wechatMode not true, exit 0"
266
- exit 0
267
- fi
268
-
269
- # 确定 MCP Server 地址
270
- # channel 模式直接使用远程地址,http 模式先试本地再回退远程
271
- MODE=$(jq -r '.mode // "http"' "$CONFIG_FILE" 2>/dev/null)
272
- MCP_BASE_URL="http://127.0.0.1:$MCP_PORT"
273
- AUTH_ARGS=()
274
- # v3.3.3: 远端 daemon 用自签 / Let's Encrypt cert 时若过期或被信任链拒绝,会让 curl 静默失败。
275
- # hook 已经用 Bearer token 鉴权,TLS 只是传输加密,因此 https URL 一律加 -k 跳过 cert 校验。
276
- TLS_OPTS=()
277
-
278
- _try_remote() {
279
- CLAUDE_JSON="$HOME/.claude.json"
280
- if [[ ! -f "$CLAUDE_JSON" ]]; then
281
- log_debug "[$(date)] No ~/.claude.json found, exit 0"
282
- exit 0
283
- fi
284
- REMOTE_URL=$(jq -r '.mcpServers["wecom-aibot-channel"].env.MCP_URL // empty' "$CLAUDE_JSON" 2>/dev/null)
285
- REMOTE_TOKEN=$(jq -r '.mcpServers["wecom-aibot-channel"].env.MCP_AUTH_TOKEN // empty' "$CLAUDE_JSON" 2>/dev/null)
286
- if [[ -z "$REMOTE_URL" ]]; then
287
- log_debug "[$(date)] No remote URL configured, exit 0"
288
- exit 0
289
- fi
290
- # v3.3.3: 远端 https 时 -k 跳 cert 校验(cert 过期/不可信不再卡死 hook)
291
- if [[ "$REMOTE_URL" == https://* ]]; then
292
- TLS_OPTS=(-k)
293
- fi
294
- REMOTE_HEALTH=$(curl -s -m 5 "\${TLS_OPTS[@]}" \${REMOTE_TOKEN:+-H "Authorization: Bearer $REMOTE_TOKEN"} "$REMOTE_URL/health" 2>/dev/null)
295
- log_debug "[$(date)] Remote health check ($REMOTE_URL): $REMOTE_HEALTH"
296
- if echo "$REMOTE_HEALTH" | jq -e '.status == "ok"' > /dev/null 2>&1; then
297
- MCP_BASE_URL="$REMOTE_URL"
298
- [[ -n "$REMOTE_TOKEN" ]] && AUTH_ARGS=(-H "Authorization: Bearer $REMOTE_TOKEN")
299
- log_debug "[$(date)] Using remote server: $MCP_BASE_URL"
300
- else
301
- log_debug "[$(date)] Remote health check failed, exit 0"
302
- exit 0
303
- fi
304
- }
305
-
306
- if [[ "$MODE" == "channel" ]]; then
307
- # channel 模式:直接使用远程地址,跳过本地检查
308
- log_debug "[$(date)] Channel mode, using remote server directly"
309
- _try_remote
310
- else
311
- # http 模式:本地优先,失败则尝试远程
312
- HEALTH=$(curl -s -m 2 "$MCP_BASE_URL/health" 2>/dev/null)
313
- log_debug "[$(date)] Local health check: $HEALTH"
314
- if ! echo "$HEALTH" | jq -e '.status == "ok"' > /dev/null 2>&1; then
315
- log_debug "[$(date)] Local server not available, trying remote channel config..."
316
- _try_remote
317
- fi
318
- fi
319
-
320
- # 读取当前项目使用的机器人名称和 ccId
321
- ROBOT_NAME=$(jq -r '.robotName // empty' "$CONFIG_FILE" 2>/dev/null)
322
- CC_ID=$(jq -r '.ccId // empty' "$CONFIG_FILE" 2>/dev/null)
323
-
324
- # 发送审批请求(使用 pwd 作为 projectDir)
325
- TOOL_INPUT=$(echo "$INPUT" | jq -c '.tool_input // {}')
326
- BODY=$(jq -n --arg tool_name "$TOOL_NAME" --argjson tool_input "$TOOL_INPUT" --arg project_dir "$PROJECT_DIR" --arg robot_name "$ROBOT_NAME" --arg cc_id "$CC_ID" \\
327
- '{"tool_name":$tool_name,"tool_input":$tool_input,"projectDir":$project_dir,"robotName":$robot_name,"ccId":$cc_id}')
328
-
329
- log_debug "[$(date)] Sending approval request..."
330
- RESPONSE=$(curl -s -m 10 "\${TLS_OPTS[@]}" -X POST "$MCP_BASE_URL/approve" \\
331
- "\${AUTH_ARGS[@]}" \\
332
- -H "Content-Type: application/json" \\
333
- -d "$BODY")
334
-
335
- log_debug "[$(date)] Approval response: $RESPONSE"
336
- TASK_ID=$(echo "$RESPONSE" | jq -r '.taskId // empty')
337
- if [[ -z "$TASK_ID" ]]; then
338
- log_debug "[$(date)] No taskId, exit 0"
339
- exit 0
340
- fi
341
-
342
- log_debug "[$(date)] Waiting for approval, taskId: $TASK_ID"
343
-
344
- # 轮询审批结果(带超时:从配置读取)
345
- AUTO_APPROVE_TIMEOUT=$(jq -r '.autoApproveTimeout // 300' "$CONFIG_FILE" 2>/dev/null)
346
- # 超时时间(秒),转换为轮询次数(每次 sleep 2秒)
347
- # 使用向上取整补偿整数截断:MAX_POLL = ceil(timeout/2) = (timeout+1)/2
348
- MAX_POLL=$(( (AUTO_APPROVE_TIMEOUT + 1) / 2 ))
349
- if [[ $MAX_POLL -lt 1 ]]; then
350
- MAX_POLL=1
351
- fi
352
- POLL_COUNT=0
353
-
354
- log_debug "[$(date)] autoApproveTimeout: $AUTO_APPROVE_TIMEOUT seconds, MAX_POLL: $MAX_POLL (actual wait: ~$((MAX_POLL * 2))s)"
355
-
356
- while [[ $POLL_COUNT -lt $MAX_POLL ]]; do
357
- sleep 2
358
- POLL_COUNT=$((POLL_COUNT + 1))
359
-
360
- STATUS=$(curl -s -m 3 "\${TLS_OPTS[@]}" "\${AUTH_ARGS[@]}" "$MCP_BASE_URL/approval_status/$TASK_ID" 2>/dev/null)
361
- RESULT=$(echo "$STATUS" | jq -r '.result // empty')
362
- log_debug "[$(date)] Poll $POLL_COUNT/$MAX_POLL: result=$RESULT"
363
-
364
- if [[ "$RESULT" == "allow-once" || "$RESULT" == "allow-always" ]]; then
365
- log_debug "[$(date)] Approved by user"
366
- printf '%s\\n' '{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}'
367
- exit 0
368
- elif [[ "$RESULT" == "deny" ]]; then
369
- log_debug "[$(date)] Denied by user"
370
- printf '%s\\n' '{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"deny","message":"用户拒绝"}}}'
371
- exit 0
372
- fi
373
- done
374
-
375
- log_debug "[$(date)] Timeout reached, executing smart auto-approval"
376
-
377
- # 超时处理:必须立即决策,Claude Code 的 hook timeout 会杀掉阻塞进程。
378
- # 规则:删除命令→拒绝,项目内操作→允许,项目外操作→拒绝
379
-
380
- # 检查是否是删除命令(仅匹配命令行本身,不匹配 heredoc 内容)
381
- IS_DELETE=0
382
- if [[ "$TOOL_NAME" == "Bash" ]]; then
383
- # 只取命令的第一行(避免 heredoc 内容干扰)
384
- FIRST_LINE=$(echo "$TOOL_INPUT" | jq -r '.command // empty' | head -1)
385
- log_debug "[$(date)] Checking delete: FIRST_LINE=$FIRST_LINE"
386
- if [[ "$FIRST_LINE" == rm\\ * ]] || [[ "$FIRST_LINE" == rm ]] \\
387
- || echo "$FIRST_LINE" | grep -qE '(^|[;&|(] *)(rm |rmdir )'; then
388
- IS_DELETE=1
389
- fi
390
- fi
391
-
392
- log_debug "[$(date)] IS_DELETE: $IS_DELETE"
393
-
394
- # 删除操作 → 永远拒绝
395
- if [[ $IS_DELETE -eq 1 ]]; then
396
- log_debug "[$(date)] Auto-deny: delete operation"
397
- # 通知 MCP Server 发送微信消息
398
- curl -s -m 5 "\${TLS_OPTS[@]}" -X POST "$MCP_BASE_URL/approval_timeout/$TASK_ID" "\${AUTH_ARGS[@]}" -H "Content-Type: application/json" -d '{"result":"deny","reason":"超时自动拒绝:删除操作需人工确认"}' > /dev/null 2>&1 &
399
- printf '%s\\n' '{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"deny","message":"超时自动拒绝:删除操作需人工确认"}}}'
400
- exit 0
401
- fi
402
-
403
- # 检查操作路径是否在项目内
404
- IS_IN_PROJECT=0
405
-
406
- case "$TOOL_NAME" in
407
- Bash)
408
- CMD=$(echo "$TOOL_INPUT" | jq -r '.command // empty')
409
- EXEC_CWD=$(pwd)
410
- log_debug "[$(date)] Bash CMD=$CMD, EXEC_CWD=$EXEC_CWD"
411
- if [[ "$CMD" == *"$PROJECT_DIR"* ]]; then
412
- # 明确包含项目路径 → 项目内
413
- IS_IN_PROJECT=1
414
- elif echo "$CMD" | grep -qE '(^|[ \t])/[a-zA-Z0-9]'; then
415
- # 含有绝对路径:过滤掉项目路径和安全系统目录,看是否还有真正的项目外路径
416
- OUTSIDE=$(echo "$CMD" | grep -oE '(^| )/[a-zA-Z0-9][^ \t>|;&]*' | tr -d ' ' \
417
- | grep -v "^$PROJECT_DIR" \
418
- | grep -vE '^(/tmp/|/var/tmp/|/dev/null|/dev/stdin|/dev/stdout|/dev/stderr|/dev/fd/)')
419
- if [[ -z "$OUTSIDE" ]]; then
420
- # 绝对路径全是项目内或安全临时目录 → 以执行位置为准
421
- log_debug "[$(date)] Only safe abs paths, checking EXEC_CWD: $EXEC_CWD"
422
- if [[ "$EXEC_CWD" == "$PROJECT_DIR"* ]]; then
423
- IS_IN_PROJECT=1
424
- fi
425
- else
426
- log_debug "[$(date)] Outside abs path detected: $OUTSIDE"
427
- IS_IN_PROJECT=0
428
- fi
429
- else
430
- # 无绝对路径(相对路径或纯命令如 npm/git)→ 以执行位置为准
431
- log_debug "[$(date)] No absolute path, checking EXEC_CWD: $EXEC_CWD"
432
- if [[ "$EXEC_CWD" == "$PROJECT_DIR"* ]]; then
433
- IS_IN_PROJECT=1
434
- fi
435
- fi
436
- ;;
437
- Write|Edit)
438
- FILE_PATH=$(echo "$TOOL_INPUT" | jq -r '.file_path // empty')
439
- if [[ "$FILE_PATH" == "$PROJECT_DIR"* ]] || [[ "$FILE_PATH" != /* ]]; then
440
- IS_IN_PROJECT=1
441
- fi
442
- ;;
443
- *)
444
- FILE_PATH=$(echo "$TOOL_INPUT" | jq -r '.file_path // .path // .directory // empty')
445
- if [[ -n "$FILE_PATH" ]]; then
446
- if [[ "$FILE_PATH" == "$PROJECT_DIR"* ]] || [[ "$FILE_PATH" != /* ]]; then
447
- IS_IN_PROJECT=1
448
- fi
449
- fi
450
- ;;
451
- esac
452
-
453
- log_debug "[$(date)] IS_IN_PROJECT: $IS_IN_PROJECT"
454
-
455
- # 根据项目内/外决策
456
- if [[ $IS_IN_PROJECT -eq 1 ]]; then
457
- log_debug "[$(date)] Auto-allow: project operation"
458
- # 通知 MCP Server 发送微信消息
459
- curl -s -m 5 "\${TLS_OPTS[@]}" -X POST "$MCP_BASE_URL/approval_timeout/$TASK_ID" "\${AUTH_ARGS[@]}" -H "Content-Type: application/json" -d '{"result":"allow-once","reason":"超时自动允许:项目内操作"}' > /dev/null 2>&1 &
460
- printf '%s\\n' '{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow","message":"超时自动允许:项目内操作"}}}'
461
- else
462
- log_debug "[$(date)] Auto-deny: outside project"
463
- # 通知 MCP Server 发送微信消息
464
- curl -s -m 5 "\${TLS_OPTS[@]}" -X POST "$MCP_BASE_URL/approval_timeout/$TASK_ID" "\${AUTH_ARGS[@]}" -H "Content-Type: application/json" -d '{"result":"deny","reason":"超时自动拒绝:项目外操作需人工确认"}' > /dev/null 2>&1 &
465
- printf '%s\\n' '{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"deny","message":"超时自动拒绝:项目外操作需人工确认"}}}'
466
- fi
467
- `;
187
+ // v3.4.0: dist/hooks/ 拷贝预编译的 .js 替代旧 bash 模板
468
188
  ensureConfigDir();
469
- fs.writeFileSync(HOOK_SCRIPT_PATH, script, { mode: 0o755 });
189
+ const src = path.join(__dirname, 'hooks', 'permission-hook.js');
190
+ if (!fs.existsSync(src)) {
191
+ console.log('[config] ⚠️ 找不到 hook 源文件,跳过:', src);
192
+ return;
193
+ }
194
+ fs.copyFileSync(src, HOOK_SCRIPT_PATH);
195
+ try {
196
+ fs.chmodSync(HOOK_SCRIPT_PATH, 0o755);
197
+ }
198
+ catch { /* Windows 无 exec bit,忽略 */ }
199
+ // 清理 v3.3.x 及以前的 .sh 残留
200
+ if (fs.existsSync(HOOK_SCRIPT_PATH_LEGACY)) {
201
+ try {
202
+ fs.unlinkSync(HOOK_SCRIPT_PATH_LEGACY);
203
+ }
204
+ catch { /* ignore */ }
205
+ }
470
206
  console.log(`[config] Hook 脚本已写入: ${HOOK_SCRIPT_PATH}`);
471
207
  }
472
208
  // 生成并写入 Stop hook 脚本
473
209
  // HTTP 模式使用:阻止 Claude 停止,提示调用 get_pending_messages 恢复轮询
474
210
  function writeStopHookScript() {
475
- const script = `#!/bin/bash
476
- # wecom-aibot-mcp Stop hook
477
- # HTTP 模式使用:阻止 Claude 停止,提示调用 get_pending_messages 恢复轮询
478
- #
479
- # 固定端口: 18963
480
- # 只检查 $(pwd)/.claude/wecom-aibot.json 的 wechatMode 字段
481
-
482
- MCP_PORT=18963
483
-
484
- # 先保存输入(Stop 事件数据)
485
- INPUT=$(cat)
486
-
487
- # 日志输出:--debug 模式下输出到 stderr,否则静默
488
- DEBUG_FILE="$HOME/.wecom-aibot-mcp/debug"
489
- log_debug() {
490
- if [[ -f "$DEBUG_FILE" ]]; then
491
- echo "$1" >&2
492
- fi
493
- }
494
-
495
- log_debug "[$(date)] Stop hook called. INPUT: \${INPUT:0:200}"
496
-
497
- # 检查项目目录的微信模式配置文件
498
- PROJECT_DIR=$(pwd)
499
- CONFIG_FILE="$PROJECT_DIR/.claude/wecom-aibot.json"
500
-
501
- log_debug "[$(date)] Checking config: $CONFIG_FILE"
502
-
503
- # 配置文件不存在,不在微信模式,允许停止
504
- if [[ ! -f "$CONFIG_FILE" ]]; then
505
- log_debug "[$(date)] No config file, exit 0 (allow stop)"
506
- exit 0
507
- fi
508
-
509
- # 检查 wechatMode 是否为 true(微信模式开关)
510
- WECHAT_MODE=$(jq -r '.wechatMode // false' "$CONFIG_FILE" 2>/dev/null)
511
- log_debug "[$(date)] wechatMode: $WECHAT_MODE"
512
- if [[ "$WECHAT_MODE" != "true" ]]; then
513
- log_debug "[$(date)] wechatMode not true, exit 0 (allow stop)"
514
- exit 0
515
- fi
516
-
517
- # 确定 MCP Server 地址(本地优先,失败则尝试远程 channel 配置)
518
- MCP_BASE_URL="http://127.0.0.1:$MCP_PORT"
519
- AUTH_ARGS=()
520
-
521
- HEALTH=$(curl -s -m 2 "$MCP_BASE_URL/health" 2>/dev/null)
522
- log_debug "[$(date)] Local health check: $HEALTH"
523
- if ! echo "$HEALTH" | jq -e '.status == "ok"' > /dev/null 2>&1; then
524
- CLAUDE_JSON="$HOME/.claude.json"
525
- if [[ -f "$CLAUDE_JSON" ]]; then
526
- REMOTE_URL=$(jq -r '.mcpServers["wecom-aibot-channel"].env.MCP_URL // empty' "$CLAUDE_JSON" 2>/dev/null)
527
- REMOTE_TOKEN=$(jq -r '.mcpServers["wecom-aibot-channel"].env.MCP_AUTH_TOKEN // empty' "$CLAUDE_JSON" 2>/dev/null)
528
- if [[ -n "$REMOTE_URL" ]]; then
529
- REMOTE_HEALTH=$(curl -s -m 5 \${REMOTE_TOKEN:+-H "Authorization: Bearer $REMOTE_TOKEN"} "$REMOTE_URL/health" 2>/dev/null)
530
- if echo "$REMOTE_HEALTH" | jq -e '.status == "ok"' > /dev/null 2>&1; then
531
- MCP_BASE_URL="$REMOTE_URL"
532
- [[ -n "$REMOTE_TOKEN" ]] && AUTH_ARGS=(-H "Authorization: Bearer $REMOTE_TOKEN")
533
- log_debug "[$(date)] Using remote server: $MCP_BASE_URL"
534
- else
535
- log_debug "[$(date)] MCP Server offline, exit 0 (allow stop)"
536
- exit 0
537
- fi
538
- else
539
- log_debug "[$(date)] MCP Server offline, exit 0 (allow stop)"
540
- exit 0
541
- fi
542
- else
543
- log_debug "[$(date)] MCP Server offline, exit 0 (allow stop)"
544
- exit 0
545
- fi
546
- fi
547
-
548
- # 获取 ccId
549
- CC_ID=$(jq -r '.ccId // empty' "$CONFIG_FILE" 2>/dev/null)
550
- log_debug "[$(date)] ccId: $CC_ID"
551
- if [[ -z "$CC_ID" ]]; then
552
- log_debug "[$(date)] No ccId in config, exit 0 (allow stop)"
553
- exit 0
554
- fi
555
-
556
- # 处于微信模式,需要恢复轮询
557
- # 使用 exit code 2 阻止停止,并提示 Claude 调用 MCP 工具
558
- log_debug "[$(date)] ✅ WeChat mode active, blocking stop to resume polling"
559
- log_debug "[$(date)] ccId=$CC_ID, will prompt Claude to call get_pending_messages"
560
- echo "任务已完成,请调用 mcp__wecom-aibot__get_pending_messages(cc_id=\"$CC_ID\", timeout_ms=30000) 恢复微信消息轮询" >&2
561
- exit 2
562
- `;
211
+ // v3.4.0: 同上,从 dist/hooks/ 拷贝预编译的 .js
563
212
  ensureConfigDir();
564
- fs.writeFileSync(STOP_HOOK_SCRIPT_PATH, script, { mode: 0o755 });
213
+ const src = path.join(__dirname, 'hooks', 'stop-hook.js');
214
+ if (!fs.existsSync(src)) {
215
+ console.log('[config] ⚠️ 找不到 stop-hook 源文件,跳过:', src);
216
+ return;
217
+ }
218
+ fs.copyFileSync(src, STOP_HOOK_SCRIPT_PATH);
219
+ try {
220
+ fs.chmodSync(STOP_HOOK_SCRIPT_PATH, 0o755);
221
+ }
222
+ catch { /* ignore */ }
223
+ if (fs.existsSync(STOP_HOOK_SCRIPT_PATH_LEGACY)) {
224
+ try {
225
+ fs.unlinkSync(STOP_HOOK_SCRIPT_PATH_LEGACY);
226
+ }
227
+ catch { /* ignore */ }
228
+ }
565
229
  console.log(`[config] Stop Hook 脚本已写入: ${STOP_HOOK_SCRIPT_PATH}`);
566
230
  }
567
231
  // 写入 MCP 工具权限到 Claude settings
@@ -602,6 +266,13 @@ export function ensureHookInstalled() {
602
266
  writeMcpPermissions();
603
267
  writeStopHookScript();
604
268
  }
269
+ // v3.4.0: 只装 hook 文件本身(不动 ~/.claude/settings.local.json),
270
+ // 供 channel-server enter_headless_mode 在写项目级 settings.json 前
271
+ // 确保 ~/.wecom-aibot-mcp/permission-hook.mjs 真实存在
272
+ export function ensureHookFiles() {
273
+ writeHookScript();
274
+ writeStopHookScript();
275
+ }
605
276
  // 读取上次安装的模式 + 远程参数(来自 version.json)
606
277
  export function getInstalledMode() {
607
278
  if (!fs.existsSync(VERSION_FILE))
@@ -16,7 +16,14 @@
16
16
  * 6. 超时(autoApproveTimeout)→ 智能策略:
17
17
  * - rm 命令 → 拒
18
18
  * - 项目内 → 允许,项目外 → 拒
19
+ *
20
+ * v3.4.0 起替代 permission-hook.sh:去掉 jq / bash / curl / ps / grep 外部依赖,
21
+ * 跨平台(Windows wmic / Unix ps),方便部署到没装 jq 的机器(如 jorry)。
19
22
  */
23
+ // v3.4.0: daemon HTTPS 用 Let's Encrypt 自签或过期时 fetch 会拒,hook 走 Bearer
24
+ // token 鉴权,TLS 只是传输层加密,跳过 cert 校验更稳。设到环境变量是为了影响
25
+ // 整个 node 进程的全局 fetch / undici 行为。
26
+ process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
20
27
  import * as fs from 'fs';
21
28
  import * as path from 'path';
22
29
  import * as os from 'os';
@@ -4,7 +4,11 @@
4
4
  *
5
5
  * Claude 准备停止时触发。如果当前项目处于微信模式,输出 exit code 2 阻止停止,
6
6
  * 同时通过 stderr 提示 Claude 调用 get_pending_messages 恢复轮询。
7
+ *
8
+ * v3.4.0 起替代 stop-hook.sh:去掉 jq / bash / curl 依赖,跨平台。
7
9
  */
10
+ // v3.4.0: 同 permission-hook —— 跳过 TLS cert 校验(Bearer token 提供身份)
11
+ process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
8
12
  import * as fs from 'fs';
9
13
  import * as path from 'path';
10
14
  import * as os from 'os';
@@ -116,6 +116,8 @@ export declare function validateConfig(config: Partial<ProjectConfig>): boolean;
116
116
  export declare function getProjectSettingsPath(projectDir: string): string;
117
117
  export declare const PERMISSION_HOOK_SCRIPT_PATH: string;
118
118
  export declare const STOP_HOOK_SCRIPT_PATH: string;
119
+ export declare const PERMISSION_HOOK_SCRIPT_PATH_LEGACY: string;
120
+ export declare const STOP_HOOK_SCRIPT_PATH_LEGACY: string;
119
121
  /**
120
122
  * 添加 PermissionRequest hook 到项目 settings.json
121
123
  */
@@ -251,16 +251,21 @@ export function getProjectSettingsPath(projectDir) {
251
251
  }
252
252
  // ============================================
253
253
  // Hook 脚本路径(统一定义)
254
+ // v3.4.0: hook 改为 Node.js 实现 —— 去 jq/bash/curl 外部依赖,跨平台
254
255
  // ============================================
255
256
  const CONFIG_DIR = path.join(os.homedir(), '.wecom-aibot-mcp');
256
- export const PERMISSION_HOOK_SCRIPT_PATH = path.join(CONFIG_DIR, 'permission-hook.sh');
257
- export const STOP_HOOK_SCRIPT_PATH = path.join(CONFIG_DIR, 'stop-hook.sh');
257
+ export const PERMISSION_HOOK_SCRIPT_PATH = path.join(CONFIG_DIR, 'permission-hook.mjs');
258
+ export const STOP_HOOK_SCRIPT_PATH = path.join(CONFIG_DIR, 'stop-hook.mjs');
259
+ // v3.4.0 前的旧路径,安装新版本时一并删掉残留
260
+ export const PERMISSION_HOOK_SCRIPT_PATH_LEGACY = path.join(CONFIG_DIR, 'permission-hook.sh');
261
+ export const STOP_HOOK_SCRIPT_PATH_LEGACY = path.join(CONFIG_DIR, 'stop-hook.sh');
258
262
  /**
259
263
  * PermissionRequest hook 配置
264
+ * v3.4.0+: command 改为 `node "..."`,双引号包路径以兼容含空格的 Windows 路径
260
265
  */
261
266
  const PERMISSION_HOOK = {
262
267
  matcher: '',
263
- hooks: [{ type: 'command', command: PERMISSION_HOOK_SCRIPT_PATH, timeout: 3600 }],
268
+ hooks: [{ type: 'command', command: `node "${PERMISSION_HOOK_SCRIPT_PATH}"`, timeout: 3600 }],
264
269
  };
265
270
  /**
266
271
  * Stop hook 配置
@@ -268,7 +273,7 @@ const PERMISSION_HOOK = {
268
273
  */
269
274
  const STOP_HOOK = {
270
275
  matcher: '',
271
- hooks: [{ type: 'command', command: STOP_HOOK_SCRIPT_PATH }],
276
+ hooks: [{ type: 'command', command: `node "${STOP_HOOK_SCRIPT_PATH}"` }],
272
277
  };
273
278
  /**
274
279
  * 进入微信模式时默认预批的 MCP 工具通配(避免每次都走 hook 增加延迟)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vrs-soft/wecom-aibot-mcp",
3
- "version": "3.3.3",
3
+ "version": "3.4.1",
4
4
  "description": "企业微信智能机器人 MCP 客户端 - 连接 wecom-aibot-server daemon",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",