@miphamai/cli 0.81.8 → 0.81.9

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 (49) hide show
  1. package/bin/mipham.ts +35 -1
  2. package/package.json +1 -1
  3. package/src/agent/message-bus.ts +10 -3
  4. package/src/agent/sub-agent.ts +60 -12
  5. package/src/agent/types.ts +14 -1
  6. package/src/config/credential-crypto.ts +28 -5
  7. package/src/config/defaults.ts +18 -10
  8. package/src/config/keys-manager.ts +7 -1
  9. package/src/config/loader.ts +202 -63
  10. package/src/core/credential-masker/output-scrub.ts +16 -2
  11. package/src/core/engine.ts +7 -2
  12. package/src/core/hooks-executor.ts +30 -2
  13. package/src/core/hooks.ts +51 -4
  14. package/src/core/paths.ts +44 -1
  15. package/src/core/permission-config.ts +146 -14
  16. package/src/core/permission-rules.ts +17 -2
  17. package/src/core/permission.ts +81 -13
  18. package/src/core/rules-loader.ts +35 -5
  19. package/src/core/session-log.ts +5 -1
  20. package/src/core/workspace-trust.ts +42 -4
  21. package/src/daemon/auth.ts +15 -14
  22. package/src/daemon/engine-capabilities.ts +12 -2
  23. package/src/daemon/remote-engine.ts +9 -4
  24. package/src/daemon/server.ts +29 -1
  25. package/src/i18n-core/locales/en-US.json +12 -8
  26. package/src/i18n-core/locales/zh-CN.json +12 -8
  27. package/src/index.tsx +44 -17
  28. package/src/mcp/client.ts +24 -0
  29. package/src/mcp/http-transport.ts +35 -3
  30. package/src/plugin/plugin-manager.ts +13 -2
  31. package/src/providers/anthropic.ts +48 -11
  32. package/src/security/gate.ts +18 -0
  33. package/src/security/path.ts +19 -1
  34. package/src/shared/arg-validation.ts +37 -2
  35. package/src/shared/package-info.ts +1 -1
  36. package/src/shared/sanitize.ts +27 -2
  37. package/src/shared/types.ts +8 -0
  38. package/src/shared/update.ts +22 -5
  39. package/src/tools/agent/agent.ts +3 -0
  40. package/src/tools/exec/bash.ts +106 -6
  41. package/src/tools/exec/enter-worktree.ts +9 -3
  42. package/src/tools/exec/exit-worktree.ts +6 -3
  43. package/src/tools/exec/git.ts +76 -1
  44. package/src/tools/file/glob.ts +19 -3
  45. package/src/tools/file/grep.ts +33 -3
  46. package/src/tools/index.ts +12 -4
  47. package/src/ui/app.tsx +47 -11
  48. package/src/ui/commands.ts +160 -30
  49. package/src/workflow/primitives/agent.ts +4 -0
@@ -101,6 +101,10 @@ export function buildDaemonPermission(
101
101
  for (const msg of permission.getInvalidRules()) {
102
102
  process.stderr.write(`⚠ Mipham Code: ${msg}\n`)
103
103
  }
104
+ // Sibling channel for malformed restrictions (P1) — same as the CLI entry.
105
+ for (const msg of permission.getInvalidRestrictions()) {
106
+ process.stderr.write(`⚠ Mipham Code: ${msg}\n`)
107
+ }
104
108
  return permission
105
109
  }
106
110
 
@@ -140,6 +144,12 @@ export function createServer(config: ServerConfig): Server<WsData> {
140
144
  dingtalk,
141
145
  } = config
142
146
 
147
+ // Mutable: `POST /api/v1/auth/rotate` replaces the live token. Destructuring
148
+ // `token` above yields a const, so the rotate route could only ever write the
149
+ // new token to disk — the running server kept accepting the old one and
150
+ // rejected the one it had just handed out.
151
+ let activeToken = token
152
+
143
153
  const wsClients = new Map<string, Set<ServerWebSocket<WsData>>>()
144
154
 
145
155
  // Captured once: a later chdir must not move the boundary that callers'
@@ -362,6 +372,23 @@ export function createServer(config: ServerConfig): Server<WsData> {
362
372
  const server = Bun.serve<WsData>({
363
373
  port,
364
374
  hostname,
375
+ // The fetch handler below has no top-level try/catch, so anything it throws
376
+ // reaches Bun — and `development` is true unless NODE_ENV=production, which
377
+ // for a daemon bound to MIPHAM_BIND=0.0.0.0 means an unauthenticated peer
378
+ // gets Bun's development error overlay. Measured on bun 1.3.14: with the
379
+ // flag off the body is a 67,154-byte HTML application; with it on, 21 bytes
380
+ // of plain text.
381
+ //
382
+ // Not established, and so not claimed: that the overlay hands back the
383
+ // source. Re-requesting the failing URL with the overlay's own Accept
384
+ // header, guessing the on-disk path, and /bun:info all failed to return the
385
+ // thrown text in a local probe (every unmatched path returned the same
386
+ // shell). What is certain is the shape: a developer debugging UI, shipped
387
+ // with its own JS, served to whoever sent the request.
388
+ //
389
+ // Set explicitly rather than relying on NODE_ENV — how an exposed daemon
390
+ // answers an error must not depend on the user's environment.
391
+ development: false,
365
392
  async fetch(req, server) {
366
393
  // ── CORS preflight ──────────────────────────────
367
394
  const corsResponse = corsMiddleware(req)
@@ -397,7 +424,7 @@ export function createServer(config: ServerConfig): Server<WsData> {
397
424
  }
398
425
 
399
426
  // ── Auth check ──────────────────────────────────
400
- const authError = authMiddleware(req, token, server.requestIP(req)?.address)
427
+ const authError = authMiddleware(req, activeToken, server.requestIP(req)?.address)
401
428
  if (authError) return addCorsHeaders(authError, req)
402
429
 
403
430
  // Helper: create JSON response with CORS headers for external origins
@@ -691,6 +718,7 @@ export function createServer(config: ServerConfig): Server<WsData> {
691
718
  // ── Auth: rotate token ───────────────────────────
692
719
  if (method === 'POST' && path === '/api/v1/auth/rotate') {
693
720
  const newToken = rotateToken(tokenPath)
721
+ activeToken = newToken
694
722
  return json({ ok: true, data: { token: newToken } })
695
723
  }
696
724
 
@@ -311,27 +311,27 @@
311
311
  },
312
312
  "resume": {
313
313
  "no_sessions": "No saved sessions.",
314
- "restored": "─ Session Restored ─",
314
+ "restored": "─ Session History Loaded ─",
315
315
  "not_found": "Session \"{name}\" not found.",
316
316
  "saved_title": "─ Saved Sessions ─",
317
317
  "found_title": "─ Session Found ─",
318
318
  "not_found_title": "─ Session Not Found ─",
319
319
  "load_failed": "─ Load Failed ─",
320
320
  "restored_content": "Name: {name}\nMessages: {total} total{truncated}\nProvider: {provider} / {model}\nUpdated: {date}",
321
- "restored_footer": "{loaded} of {total} messages loaded. Older context summarized above.",
322
- "restored_full_footer": "{loaded} messages loaded. Context has been restored.",
321
+ "restored_footer": "{loaded} of {total} messages loaded into the view (older ones not shown).",
322
+ "restored_full_footer": "{loaded} messages loaded into the view. The model's context is unchanged — to continue this session, run: mipham --resume \"{name}\"",
323
323
  "delete_usage": "Usage: /resume delete <session-name>\n\nDelete a saved session. Use /resume to list all sessions.",
324
324
  "delete_confirmed": "✓ Session \"{name}\" deleted.",
325
325
  "delete_not_found": "✗ Session \"{name}\" not found. Use /resume to list all sessions.",
326
326
  "empty_footer": "Sessions are auto-saved to ~/.mipham/sessions/ when Mipham Code exits.\nStart a conversation — it will be saved automatically.",
327
327
  "total_footer": "Total: {count} session(s) • Location: ~/.mipham/sessions/",
328
- "resume_hint": "To resume a session: /resume <name>",
329
- "resume_last_hint": "To resume most recent: /resume last",
328
+ "resume_hint": "Session details: /resume <name>",
329
+ "resume_last_hint": "Load recent history: /resume last",
330
330
  "delete_hint": "To delete a session: /resume delete <name>",
331
331
  "cli_hint": "To resume from CLI: mipham --resume \"<name>\"",
332
- "auto_save_hint": "Sessions are auto-saved on exit. The most recent session loads automatically on restart.",
332
+ "auto_save_hint": "Sessions are auto-saved on exit. On restart only the most recent session's summary is injected as context — to reopen a session: mipham --resume \"<name>\"",
333
333
  "not_found_content": "No session named \"{name}\".\n\nUse /resume without arguments to list all saved sessions.",
334
- "found_content": "Name: {name}\nMessages: {messages}\nProvider: {provider} / {model}\nUpdated: {updated}\n\nTo resume this session:\n /resume last — restore the most recent session\n mipham --resume \"{target}\"\n\nOr restart Mipham Code — the most recent session loads automatically."
334
+ "found_content": "Name: {name}\nMessages: {messages}\nProvider: {provider} / {model}\nUpdated: {updated}\n\nTo continue this session, start a new process with:\n mipham --resume \"{target}\""
335
335
  },
336
336
  "help": { "title": "── Mipham Code Help ──" },
337
337
  "lang": {
@@ -423,6 +423,7 @@
423
423
  },
424
424
  "upgrade": {
425
425
  "uptodate": "── Upgrade Mipham Code ──\n\nCurrent version: v{current}\nLatest: v{latest}\n\n✓ Already up to date.\n\nTo check manually: https://www.npmjs.com/package/@miphamai/cli",
426
+ "check_failed": "── Upgrade Mipham Code ──\n\n✗ Could not reach the npm registry — unable to tell whether v{current} is the latest.\n Nothing was changed. Check your network, then retry: /upgrade",
426
427
  "title": "── Upgrade Mipham Code ──",
427
428
  "available": "Current version: v{current}\nLatest: v{latest}\n\n→ New version available! Updating...",
428
429
  "updated": "✓ Updated to @miphamai/cli v{version}",
@@ -558,7 +559,7 @@
558
559
  "stats": {
559
560
  "title": "── Session Stats ──",
560
561
  "messages": "Messages: {total} ({user} user, {assistant} AI, {system} system)",
561
- "tokens": "Tokens: ~{tokens} / 200,000",
562
+ "tokens": "Tokens: ~{tokens} / {max}",
562
563
  "tools": "Tools available: {count}",
563
564
  "provider": "Provider: {provider}",
564
565
  "model": "Model: {model}",
@@ -585,6 +586,9 @@
585
586
  "title": "── Hooks ──",
586
587
  "no_hooks": "── Hooks ──\n\nNo hooks configured.\n\nConfigure hooks in .mipham/settings.json under the hooks section:\n PreToolUse — before each tool\n PostToolUse — after each tool\n Stop — on session end\n\nCommand hooks read JSON from stdin; exit 2 blocks (stderr as reason).\nUse /loop init to scaffold settings.json.",
587
588
  "location": "Location: {path}",
589
+ "source_user": "user",
590
+ "source_project": "project",
591
+ "gated": "⚠️ workspace not trusted — will not run",
588
592
  "found": "{count} hook(s) found.",
589
593
  "error": "── Hooks ──\n\nCould not read hooks directory."
590
594
  },
@@ -311,27 +311,27 @@
311
311
  },
312
312
  "resume": {
313
313
  "no_sessions": "没有已保存的会话。",
314
- "restored": "─ 会话已恢复 ─",
314
+ "restored": "─ 会话历史已载入 ─",
315
315
  "not_found": "未找到会话 \"{name}\"。",
316
316
  "saved_title": "─ 已保存的会话 ─",
317
317
  "found_title": "─ 找到会话 ─",
318
318
  "not_found_title": "─ 未找到会话 ─",
319
319
  "load_failed": "─ 加载失败 ─",
320
320
  "restored_content": "名称: {name}\n消息数: {total} 条{truncated}\n提供商: {provider} / {model}\n更新时间: {date}",
321
- "restored_footer": "已加载 {loaded}/{total} 条消息。较早的上下文已概括。",
322
- "restored_full_footer": "已加载 {loaded} 条消息。上下文已恢复。",
321
+ "restored_footer": "已载入视图 {loaded}/{total} 条消息(较早的未显示)。",
322
+ "restored_full_footer": "已载入视图 {loaded} 条消息;模型上下文未变 —— 要接着此会话继续,请运行: mipham --resume \"{name}\"",
323
323
  "delete_usage": "用法: /resume delete <session-name>\n\n删除已保存的会话。使用 /resume 列出所有会话。",
324
324
  "delete_confirmed": "✓ 会话 \"{name}\" 已删除。",
325
325
  "delete_not_found": "✗ 未找到会话 \"{name}\"。使用 /resume 列出所有会话。",
326
326
  "empty_footer": "会话在 Mipham Code 退出时自动保存到 ~/.mipham/sessions/。\n开始对话 — 它将自动保存。",
327
327
  "total_footer": "总计: {count} 个会话 • 位置: ~/.mipham/sessions/",
328
- "resume_hint": "恢复会话: /resume <name>",
329
- "resume_last_hint": "恢复最近: /resume last",
328
+ "resume_hint": "查看会话详情: /resume <name>",
329
+ "resume_last_hint": "载入最近历史: /resume last",
330
330
  "delete_hint": "删除会话: /resume delete <name>",
331
331
  "cli_hint": "命令行恢复: mipham --resume \"<name>\"",
332
- "auto_save_hint": "会话在退出时自动保存。最近会话在重启时自动加载。",
332
+ "auto_save_hint": "会话在退出时自动保存。重启时仅注入最近会话的摘要作为上下文 —— 要重新打开某个会话: mipham --resume \"<name>\"",
333
333
  "not_found_content": "未找到名为 \"{name}\" 的会话。\n\n不带参数运行 /resume 列出所有已保存的会话。",
334
- "found_content": "名称: {name}\n消息数: {messages}\n提供商: {provider} / {model}\n更新时间: {updated}\n\n恢复此会话:\n /resume last — 恢复最近的会话\n mipham --resume \"{target}\"\n\n或重启 Mipham Code — 最近会话将自动加载。"
334
+ "found_content": "名称: {name}\n消息数: {messages}\n提供商: {provider} / {model}\n更新时间: {updated}\n\n要接着此会话继续,请另起进程运行:\n mipham --resume \"{target}\""
335
335
  },
336
336
  "help": { "title": "── Mipham Code 帮助 ──" },
337
337
  "lang": {
@@ -423,6 +423,7 @@
423
423
  },
424
424
  "upgrade": {
425
425
  "uptodate": "── 升级 Mipham Code ──\n\n当前版本: v{current}\n最新版本: v{latest}\n\n✓ 已是最新版本。\n\n手动检查: https://www.npmjs.com/package/@miphamai/cli",
426
+ "check_failed": "── 升级 Mipham Code ──\n\n✗ 无法连接 npm registry —— 无法判断 v{current} 是否已是最新版本。\n 未做任何改动。请检查网络后重试: /upgrade",
426
427
  "title": "── 升级 Mipham Code ──",
427
428
  "available": "当前版本: v{current}\n最新版本: v{latest}\n\n→ 发现新版本!正在更新...",
428
429
  "updated": "✓ 已更新至 @miphamai/cli v{version}",
@@ -558,7 +559,7 @@
558
559
  "stats": {
559
560
  "title": "── 会话统计 ──",
560
561
  "messages": "消息: {total}({user} 用户, {assistant} AI, {system} 系统)",
561
- "tokens": "Token: ~{tokens} / 200,000",
562
+ "tokens": "Token: ~{tokens} / {max}",
562
563
  "tools": "可用工具: {count}",
563
564
  "provider": "提供商: {provider}",
564
565
  "model": "模型: {model}",
@@ -585,6 +586,9 @@
585
586
  "title": "── 钩子 ──",
586
587
  "no_hooks": "── 钩子 ──\n\n未配置钩子。\n\n在 .mipham/settings.json 的 hooks 段配置钩子:\n PreToolUse — 工具执行前\n PostToolUse — 工具执行后\n Stop — 会话结束\n\n命令钩子从 stdin 读 JSON,exit 2 拦截(stderr 作理由)。\n使用 /loop init 初始化 settings.json。",
587
588
  "location": "位置: {path}",
589
+ "source_user": "用户级",
590
+ "source_project": "项目级",
591
+ "gated": "⚠️ 工作区未受信任,不会运行",
588
592
  "found": "找到 {count} 个钩子。",
589
593
  "error": "── 钩子 ──\n\n无法读取钩子目录。"
590
594
  },
package/src/index.tsx CHANGED
@@ -32,6 +32,7 @@ import { ExperienceRuleEngine } from './core/rule-engine.js'
32
32
  import { SessionLog } from './core/session-log'
33
33
  import { SessionStore } from './core/session-store'
34
34
  import type { PermissionLevel, MiphamConfig, McpServerConfig } from './shared/types'
35
+ import { PermissionSystem } from './core/permission'
35
36
  import { SkillsLoader } from './skills/loader'
36
37
  import { PluginManager } from './plugin/plugin-manager'
37
38
  import { loadPlugins } from './plugin/plugin-loader'
@@ -52,7 +53,7 @@ import { getMetrics } from './core/metrics'
52
53
  import { initTelemetry, enableTelemetryNow } from './telemetry/index'
53
54
  import { wasPrompted, markPrompted, isInteractive, setTelemetryEnabled } from './telemetry/consent'
54
55
  import { officialEndpointHost } from './telemetry/endpoint'
55
- import { getWorkspaceTrust } from './core/workspace-trust'
56
+ import { getWorkspaceTrust, warnProjectHooksSkipped } from './core/workspace-trust'
56
57
  import { ARTIFACT_PORT } from './shared/constants'
57
58
  import { artifactsRoot } from './artifacts/paths'
58
59
  import { AgentViewManager } from './agent-view/agent-view-manager'
@@ -375,6 +376,22 @@ export async function runApp(options: RunOptions): Promise<void> {
375
376
  // Load configuration
376
377
  const config = loadConfig()
377
378
 
379
+ // Permission policy is built **here**, before the system prompt exists, so the prompt
380
+ // can describe the mode the engine will actually run in. Building the prompt from
381
+ // `config.permission` handed the model a value that org restrictions can silently
382
+ // rewrite (and that may not even be a mode name — `bypass`/`auto`/`ask` inject nothing
383
+ // at all). The engine is created much later, after tools/hooks/vajra are mounted, so
384
+ // this instance is what gets handed to it rather than a second one built inside.
385
+ const permission = new PermissionSystem('default')
386
+ // Sync with config (fix: UI shows "auto" but engine defaulted to bypass-legacy)
387
+ if (config.permission) {
388
+ permission.setDefaultLevel(config.permission as PermissionLevel)
389
+ }
390
+ // Apply org-level permission restrictions (P0: bypassPermissions policy gap)
391
+ if (config.permissionRestrictions) {
392
+ permission.setRestrictions(config.permissionRestrictions)
393
+ }
394
+
378
395
  // Detect locale and create translation function
379
396
  const locale = detectLocale({ lang: options.lang })
380
397
  const t = createT(localeBundles[locale] || enUS, enUS)
@@ -479,12 +496,12 @@ export async function runApp(options: RunOptions): Promise<void> {
479
496
  }
480
497
  }
481
498
  context.restoreLog(log)
482
- context.setSystemPrompt(instructions.buildSystemPrompt(config.permission as string))
499
+ context.setSystemPrompt(instructions.buildSystemPrompt(permission.getMode()))
483
500
  }
484
501
  }
485
502
 
486
503
  if (context.getMessageCount() === 0) {
487
- const basePrompt = instructions.buildSystemPrompt(config.permission as string)
504
+ const basePrompt = instructions.buildSystemPrompt(permission.getMode())
488
505
  const memoryReminder = loadSessionMemories(basePrompt)
489
506
  const skillsReminder = skillsLoader.buildSystemReminder(5000, config.skills?.reminder ?? 'full')
490
507
 
@@ -582,8 +599,10 @@ export async function runApp(options: RunOptions): Promise<void> {
582
599
  // A dead server (e.g. a 15s connect timeout) no longer blocks startup.
583
600
  const mcpConnectPromise = connectMcpServers(config.skills?.mcpServers ?? [], tools)
584
601
 
585
- // Initialize hook engine — register skill-defined hooks
586
- const hookEngine = new HookEngine()
602
+ // Initialize hook engine — register skill-defined hooks.
603
+ // CLI 是一次性进程,会话 cwd 就是 `process.cwd()`;写出来是为了与 daemon 那处
604
+ // (`daemon/engine-capabilities.ts` 传会话 cwd)成对照,别让哪边看起来像漏了。
605
+ const hookEngine = new HookEngine(process.cwd())
587
606
  for (const skill of skillsLoader.list()) {
588
607
  if (skill.hooks) {
589
608
  for (const hook of skill.hooks) {
@@ -592,8 +611,17 @@ export async function runApp(options: RunOptions): Promise<void> {
592
611
  }
593
612
  }
594
613
 
595
- // Register settings.json hooks (Claude Code convention — additive across levels)
596
- const settingsJson = loadSettingsJson(process.cwd())
614
+ // Register settings.json hooks (Claude Code convention — additive across levels).
615
+ // Project-level hooks are repository-controlled shell commands, so they are only
616
+ // read once this workspace is trusted. On a TTY `checkWorkspaceTrust()` above has
617
+ // already asked — and exited the process on "no". With no TTY it *cannot* ask, so
618
+ // the answer is "no" and the hooks stay out rather than running unasked; that
619
+ // skip is announced, since silence would look the same as having passed.
620
+ const projectHooksTrusted = getWorkspaceTrust().isTrusted(process.cwd())
621
+ const settingsJson = loadSettingsJson(process.cwd(), {
622
+ includeProjectHooks: projectHooksTrusted,
623
+ })
624
+ if (settingsJson.projectHooksSkipped) warnProjectHooksSkipped(process.cwd())
597
625
  for (const def of loadHookConfigs(settingsJson.hooks)) {
598
626
  hookEngine.register(def)
599
627
  }
@@ -604,7 +632,7 @@ export async function runApp(options: RunOptions): Promise<void> {
604
632
 
605
633
  // Create query engine
606
634
  const ruleEngine = new ExperienceRuleEngine()
607
- const engine = new QueryEngine(registry, context, tools, undefined, ruleEngine)
635
+ const engine = new QueryEngine(registry, context, tools, permission, ruleEngine)
608
636
  engine.setHookEngine(hookEngine)
609
637
  engine.setArtifactServer(artifactServer)
610
638
  engine.setAgentViewManager(agentViewManager)
@@ -619,15 +647,8 @@ export async function runApp(options: RunOptions): Promise<void> {
619
647
  const inferenceHookConfig = loadInferenceHookConfig()
620
648
  engine.setInferenceHookConfig(inferenceHookConfig)
621
649
 
622
- // Sync engine permission with config (fix: UI shows "auto" but engine defaulted to bypass-legacy)
623
- if (config.permission) {
624
- engine.getPermission().setDefaultLevel(config.permission as PermissionLevel)
625
- }
626
-
627
- // Apply org-level permission restrictions (P0: bypassPermissions policy gap)
628
- if (config.permissionRestrictions) {
629
- engine.getPermission().setRestrictions(config.permissionRestrictions)
630
- }
650
+ // `config.permission` / `permissionRestrictions` are applied up top, on the instance
651
+ // handed to `new QueryEngine(...)` — the same one whose mode the system prompt reads.
631
652
 
632
653
  // Apply user-defined permission rules (allow/deny) — wire the rule system into runtime
633
654
  if (config.permissionRules) {
@@ -645,6 +666,12 @@ export async function runApp(options: RunOptions): Promise<void> {
645
666
  process.stderr.write(`⚠ Mipham Code: ${msg}\n`)
646
667
  }
647
668
 
669
+ // Same warning channel for malformed restrictions (P1) — a typo'd
670
+ // `forbiddenModes`/`maxAllowedMode` used to leave the policy silently inert.
671
+ for (const msg of engine.getPermission().getInvalidRestrictions()) {
672
+ process.stderr.write(`⚠ Mipham Code: ${msg}\n`)
673
+ }
674
+
648
675
  // Initialize agent registry and load plugin agents/skills/MCP/hooks
649
676
  const agentRegistry = new AgentRegistry()
650
677
  agentRegistry.loadUserAgents()
package/src/mcp/client.ts CHANGED
@@ -187,6 +187,12 @@ export class McpClient {
187
187
  connection.toolsRefreshInFlight = true
188
188
  try {
189
189
  await this.applyToolsChanged(name)
190
+ } catch {
191
+ // A refresh against a server that has gone away must not surface as an
192
+ // unhandled rejection out of a timer callback — record the lost
193
+ // connection instead.
194
+ const current = this.connections.get(name)
195
+ if (current) this.markIfTransportLost(name, current)
190
196
  } finally {
191
197
  // Re-read: the server may have been disconnected while we were awaiting.
192
198
  const current = this.connections.get(name)
@@ -427,10 +433,28 @@ export class McpClient {
427
433
  try {
428
434
  return await conn.protocol.callTool(toolName, params)
429
435
  } catch (err) {
436
+ this.markIfTransportLost(serverName, conn)
430
437
  return {
431
438
  content: [{ type: 'text', text: t('errors.mcp_tool_error', { error: String(err) }) }],
432
439
  isError: true,
433
440
  }
434
441
  }
435
442
  }
443
+
444
+ /**
445
+ * Downgrade a connection whose transport has gone away.
446
+ *
447
+ * A tool call can fail because the *tool* failed or because the server did, and
448
+ * only the transport can tell those apart: a closed transport answers nothing
449
+ * from now on. Without this the connection stays 'connected' and `/mcp` keeps
450
+ * showing green for a server that is gone.
451
+ */
452
+ private markIfTransportLost(name: string, connection: ActiveConnection): void {
453
+ if (connection.transport.isConnected()) return
454
+ if (connection.status === 'error') return
455
+
456
+ connection.status = 'error'
457
+ connection.error = 'Connection lost — the transport is no longer connected'
458
+ this.emit('disconnected', name, connection.error)
459
+ }
436
460
  }
@@ -12,7 +12,10 @@ type FetchFn = (input: string, init?: RequestInit) => Promise<Response>
12
12
  * result text (this is how Forge's `/mcp` streams progressive chunks). We
13
13
  * reassemble those slices so callers see one complete result.
14
14
  */
15
- function parseSseResponse(body: string): unknown {
15
+ function parseSseResponse(
16
+ body: string,
17
+ onNotification?: (notification: JsonRpcNotification) => void,
18
+ ): unknown {
16
19
  const messages: Array<{ id?: number; result?: unknown; error?: JsonRpcError }> = []
17
20
 
18
21
  for (const block of body.split(/\n\n/)) {
@@ -21,7 +24,16 @@ function parseSseResponse(body: string): unknown {
21
24
  const payload = line.slice('data:'.length).trim()
22
25
  if (!payload) continue
23
26
  try {
24
- messages.push(JSON.parse(payload))
27
+ const message = JSON.parse(payload) as JsonRpcResponse & JsonRpcNotification
28
+ // Server-initiated notifications (e.g. tools/list_changed) arrive on the
29
+ // same stream as the response to the request in flight. They carry no id
30
+ // and are not part of that response, so hand them off rather than letting
31
+ // them count as a result — or as a chunk of one.
32
+ if (message.method !== undefined && message.id === undefined) {
33
+ onNotification?.(message as JsonRpcNotification)
34
+ continue
35
+ }
36
+ messages.push(message)
25
37
  } catch {
26
38
  // Skip unparseable event lines
27
39
  }
@@ -111,6 +123,10 @@ export class HttpTransport implements Transport {
111
123
  const controller = new AbortController()
112
124
  const timer = setTimeout(() => controller.abort(), this.requestTimeoutMs)
113
125
 
126
+ // Did an HTTP response come back at all? A rejected fetch or a timeout means
127
+ // the endpoint is unreachable; an error *status* only means it said no.
128
+ let reachable = false
129
+
114
130
  try {
115
131
  const response = await this.fetchImpl(this.url, {
116
132
  method: 'POST',
@@ -123,6 +139,7 @@ export class HttpTransport implements Transport {
123
139
  signal: controller.signal,
124
140
  redirect: 'manual',
125
141
  })
142
+ reachable = true
126
143
 
127
144
  if (!response.ok) {
128
145
  let detail = `HTTP ${response.status}`
@@ -136,7 +153,7 @@ export class HttpTransport implements Transport {
136
153
 
137
154
  const contentType = response.headers.get('content-type') || ''
138
155
  if (contentType.includes('text/event-stream')) {
139
- return parseSseResponse(await response.text())
156
+ return parseSseResponse(await response.text(), (n) => this.dispatchNotification(n))
140
157
  }
141
158
 
142
159
  const json = (await response.json()) as JsonRpcResponse
@@ -146,8 +163,16 @@ export class HttpTransport implements Transport {
146
163
  return json.result
147
164
  } catch (err) {
148
165
  if (controller.signal.aborted) {
166
+ // The endpoint never answered. Every later request would hang the same
167
+ // way, so the transport reports itself disconnected — the same state a
168
+ // stdio transport reaches when its process exits — until reconnect()
169
+ // replaces it.
170
+ this.closed = true
149
171
  throw requestTimeoutError(method, this.requestTimeoutMs)
150
172
  }
173
+ if (!reachable) {
174
+ this.closed = true
175
+ }
151
176
  throw err
152
177
  } finally {
153
178
  clearTimeout(timer)
@@ -182,6 +207,13 @@ export class HttpTransport implements Transport {
182
207
  this.notificationHandlers.push(handler)
183
208
  }
184
209
 
210
+ /** Hand a server-initiated notification to every registered handler. */
211
+ private dispatchNotification(notification: JsonRpcNotification): void {
212
+ for (const handler of this.notificationHandlers) {
213
+ handler(notification)
214
+ }
215
+ }
216
+
185
217
  async close(): Promise<void> {
186
218
  this.closed = true
187
219
  this.url = null
@@ -11,6 +11,7 @@ import { join } from 'node:path'
11
11
  import { homedir } from 'node:os'
12
12
  import { execFileSync } from 'node:child_process'
13
13
  import { validatePlugin } from './plugin-validator'
14
+ import { atomicWriteFileSync } from '../shared/atomic-write'
14
15
 
15
16
  const PLUGIN_DIR = join(homedir(), '.mipham', 'plugins')
16
17
 
@@ -287,7 +288,12 @@ export class PluginManager {
287
288
  private loadState(): void {
288
289
  try {
289
290
  if (existsSync(this.statePath)) {
290
- this.plugins = JSON.parse(readFileSync(this.statePath, 'utf-8'))
291
+ const parsed: unknown = JSON.parse(readFileSync(this.statePath, 'utf-8'))
292
+ // Parsing is not validating: `{}` / `null` / `"x"` are all valid JSON and
293
+ // all make the readers below throw (`.find`/`.filter`/`.map` on a
294
+ // non-array), so a file that is merely *shaped* wrong took down every
295
+ // plugin command instead of starting empty.
296
+ this.plugins = Array.isArray(parsed) ? (parsed as InstalledPlugin[]) : []
291
297
  }
292
298
  } catch {
293
299
  this.plugins = []
@@ -295,6 +301,11 @@ export class PluginManager {
295
301
  }
296
302
 
297
303
  private saveState(): void {
298
- writeFileSync(this.statePath, JSON.stringify(this.plugins, null, 2), 'utf-8')
304
+ // Was a bare writeFileSync: an interrupted write left unparseable JSON, and
305
+ // `loadState` swallows that into `[]` ⇒ the installed-plugin list silently
306
+ // vanished. Atomic write means a reader sees the old list or the new one.
307
+ atomicWriteFileSync(this.statePath, JSON.stringify(this.plugins, null, 2) + '\n', {
308
+ mode: 0o600,
309
+ })
299
310
  }
300
311
  }
@@ -39,6 +39,27 @@ interface AnthropicSSEEvent {
39
39
  usage?: { input_tokens: number; output_tokens: number }
40
40
  }
41
41
 
42
+ /**
43
+ * Stand-in for a tool result that has no content of its own.
44
+ *
45
+ * `tool_result.content` is normalized to a text block server-side, and an empty
46
+ * one is rejected with a 400 — which fails the *whole* request, history
47
+ * included. Multiple paths produce one: a tool that reports success with no
48
+ * output (`content: ''`), a failed tool with no message, and a log projection
49
+ * whose `content` never got written (`undefined`). All of them mean the same
50
+ * thing to the model, so they get the same words.
51
+ */
52
+ const NO_TOOL_OUTPUT = '(no output)'
53
+
54
+ /**
55
+ * An empty text block is not a stylistic wart — Anthropic rejects it with a 400,
56
+ * and since every message in the request is history, one such block makes the
57
+ * conversation permanently unsendable (every later turn re-sends it).
58
+ */
59
+ function isEmptyTextBlock(block: Record<string, unknown>): boolean {
60
+ return block.type === 'text' && block.text === ''
61
+ }
62
+
42
63
  export class AnthropicProvider implements ProviderInstance {
43
64
  private baseUrl = 'https://api.anthropic.com/v1'
44
65
  private anthropicVersion = '2023-06-01'
@@ -302,17 +323,23 @@ export class AnthropicProvider implements ProviderInstance {
302
323
  if (msg.role === 'system') continue
303
324
 
304
325
  if (typeof msg.content === 'string') {
305
- const content: unknown[] = [{ type: 'text', text: msg.content }]
326
+ const content: unknown[] = []
327
+ // 空串不产出 text 块(见 isEmptyTextBlock):用户发了个空消息、或工具返回
328
+ // 空内容时会走到这里,而它会让之后每一轮都 400。
329
+ if (msg.content !== '') content.push({ type: 'text', text: msg.content })
306
330
  // DeepSeek V4 thinking mode via Anthropic endpoint: every assistant
307
331
  // message must contain a thinking block if any message in history does.
308
332
  if (msg.role === 'assistant') {
309
333
  const thinkingText = (msg as any).reasoning_content || ''
310
334
  content.unshift({ type: 'thinking', thinking: thinkingText })
311
335
  }
312
- result.push({
313
- role: msg.role,
314
- content,
315
- })
336
+ // 全部内容都被滤掉的消息**整条**不下发 —— 空 content 数组同样被 API 拒。
337
+ if (content.length > 0) {
338
+ result.push({
339
+ role: msg.role,
340
+ content,
341
+ })
342
+ }
316
343
  } else {
317
344
  const blocks = (msg.content as ContentBlock[]).map((block) => {
318
345
  switch (block.type) {
@@ -362,22 +389,32 @@ export class AnthropicProvider implements ProviderInstance {
362
389
  return {
363
390
  type: 'tool_result',
364
391
  tool_use_id: block.tool_use_id,
365
- content: block.content,
392
+ // 空 content 同样整条请求被拒(见 NO_TOOL_OUTPUT)。`||` 而非 `=== ''`:
393
+ // 投影层缺失该字段时这里是 `undefined`,空数组同理。
394
+ content: block.content || NO_TOOL_OUTPUT,
366
395
  // 只在失败时下发 —— 成功请求体与改动前逐字节相同,不引入 prompt-cache 前缀抖动
367
396
  ...(block.is_error === true ? { is_error: true } : {}),
368
397
  }
369
398
 
370
399
  default:
371
- return { type: 'text', text: '' }
400
+ // 未知块类型**无法**原样表达,而 `{type:'text',text:''}` 是一个必然被拒
401
+ // 的载荷 —— 改成丢这一个块(下面的 filter),而不是拿它毒掉整条请求。
402
+ return null
372
403
  }
373
- })
404
+ }) as (Record<string, unknown> | null)[]
405
+
406
+ // 丢掉表达不出来的块(未知类型)与空的 text 块。
407
+ const kept = blocks.filter(
408
+ (b): b is Record<string, unknown> => b !== null && !isEmptyTextBlock(b),
409
+ )
374
410
 
375
411
  // DeepSeek V4 thinking mode via Anthropic endpoint: every assistant
376
412
  // message must contain a thinking block if any message in history does.
377
- if (msg.role === 'assistant' && !blocks.some((b: any) => b.type === 'thinking')) {
378
- blocks.unshift({ type: 'thinking', thinking: '' })
413
+ if (msg.role === 'assistant' && !kept.some((b) => b.type === 'thinking')) {
414
+ kept.unshift({ type: 'thinking', thinking: '' })
379
415
  }
380
- result.push({ role: msg.role, content: blocks })
416
+ if (kept.length === 0) continue
417
+ result.push({ role: msg.role, content: kept })
381
418
  }
382
419
  }
383
420
 
@@ -1,3 +1,21 @@
1
+ /**
2
+ * SecurityGate — 判定函数集合。
3
+ *
4
+ * ⚠️ **本文件不是一个已生效的安全门。** 它是一个**判定函数的库**,其中只有一部分
5
+ * 被接进生产路径。别把「文件被 import」读成「这些检查在生产里跑」:
6
+ *
7
+ * - `redactCredentialLeak` — **已接线**(`core/behavior-tasks.ts`、
8
+ * `core/credential-masker/output-scrub.ts`)。
9
+ * - 其余三个 `check*` 方法 — **生产零调用点**,唯一消费者是
10
+ * `test/security/penetration/`。因此 CI 的 `penetration-test` job 全绿只说明
11
+ * **这些判定函数被自己测过**,不说明生产有这三道防线。三条各自的去路(为什么不接、
12
+ * 生产另有哪条防线)登记在
13
+ * `test/integrity/unwired-disposition.test.ts` 的 `KEPT_UNWIRED_METHODS` 里,
14
+ * 由机器两向强制(存在 + 生产零引用)。
15
+ *
16
+ * 想让某条真正生效时:先读那条登记的理由,再决定是接线还是改判据 —— 直接 `import`
17
+ * 进来调是最容易的一步,也是最容易多出一道更弱、更难维护、判据还与被取代者不一致的门。
18
+ */
1
19
  export interface GateResult {
2
20
  blocked: boolean
3
21
  reason?: string
@@ -7,6 +7,24 @@ import { realpathSync, existsSync } from 'node:fs'
7
7
  */
8
8
  const BLOCKED_PATHS = ['/etc', '/proc', '/sys', '/dev', '/boot', '/root']
9
9
 
10
+ /**
11
+ * The same list in canonical form.
12
+ *
13
+ * The check below compares against an already-resolved path, so the entries
14
+ * must be resolved too: on macOS `/etc` is a symlink to `/private/etc`, and
15
+ * comparing `/private/etc/x` against the literal `/etc` never matched — the
16
+ * check was dead there. Entries that don't exist yet keep their literal form
17
+ * (there is nothing to resolve, and a later `realpathSync` of the same path
18
+ * cannot produce a different spelling).
19
+ */
20
+ const BLOCKED_CANONICAL: string[] = BLOCKED_PATHS.map((p) => {
21
+ try {
22
+ return existsSync(p) ? realpathSync(p) : p
23
+ } catch {
24
+ return p
25
+ }
26
+ })
27
+
10
28
  /**
11
29
  * Windows UNC paths and NT/Win32/DOS device-namespace prefixes.
12
30
  *
@@ -86,7 +104,7 @@ export function resolveSafe(cwd: string, inputPath: string): string {
86
104
  }
87
105
 
88
106
  // Check 2: must not target sensitive system directories
89
- for (const blocked of BLOCKED_PATHS) {
107
+ for (const blocked of BLOCKED_CANONICAL) {
90
108
  if (canonical === blocked || canonical.startsWith(blocked + '/')) {
91
109
  throw new Error(
92
110
  `Path rejected: "${inputPath}" resolves to a protected system directory (${blocked}).`,