im-hub-pro 0.2.29
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.
- package/LICENSE +21 -0
- package/README.md +497 -0
- package/README.zh-CN.md +496 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +921 -0
- package/dist/cli.js.map +1 -0
- package/dist/core/acp-server.d.ts +8 -0
- package/dist/core/acp-server.d.ts.map +1 -0
- package/dist/core/acp-server.js +266 -0
- package/dist/core/acp-server.js.map +1 -0
- package/dist/core/agent-base.d.ts +94 -0
- package/dist/core/agent-base.d.ts.map +1 -0
- package/dist/core/agent-base.js +374 -0
- package/dist/core/agent-base.js.map +1 -0
- package/dist/core/agent-cwd.d.ts +45 -0
- package/dist/core/agent-cwd.d.ts.map +1 -0
- package/dist/core/agent-cwd.js +178 -0
- package/dist/core/agent-cwd.js.map +1 -0
- package/dist/core/agent-cwd.test.d.ts +2 -0
- package/dist/core/agent-cwd.test.d.ts.map +1 -0
- package/dist/core/agent-cwd.test.js +149 -0
- package/dist/core/agent-cwd.test.js.map +1 -0
- package/dist/core/approval-bus.d.ts +232 -0
- package/dist/core/approval-bus.d.ts.map +1 -0
- package/dist/core/approval-bus.js +703 -0
- package/dist/core/approval-bus.js.map +1 -0
- package/dist/core/approval-bus.synthetic.test.d.ts +2 -0
- package/dist/core/approval-bus.synthetic.test.d.ts.map +1 -0
- package/dist/core/approval-bus.synthetic.test.js +182 -0
- package/dist/core/approval-bus.synthetic.test.js.map +1 -0
- package/dist/core/approval-bus.test.d.ts +2 -0
- package/dist/core/approval-bus.test.d.ts.map +1 -0
- package/dist/core/approval-bus.test.js +537 -0
- package/dist/core/approval-bus.test.js.map +1 -0
- package/dist/core/approval-router.d.ts +95 -0
- package/dist/core/approval-router.d.ts.map +1 -0
- package/dist/core/approval-router.js +450 -0
- package/dist/core/approval-router.js.map +1 -0
- package/dist/core/approval-router.test.d.ts +2 -0
- package/dist/core/approval-router.test.d.ts.map +1 -0
- package/dist/core/approval-router.test.js +413 -0
- package/dist/core/approval-router.test.js.map +1 -0
- package/dist/core/audit-log.d.ts +55 -0
- package/dist/core/audit-log.d.ts.map +1 -0
- package/dist/core/audit-log.js +203 -0
- package/dist/core/audit-log.js.map +1 -0
- package/dist/core/bgjob-reader.d.ts +65 -0
- package/dist/core/bgjob-reader.d.ts.map +1 -0
- package/dist/core/bgjob-reader.js +212 -0
- package/dist/core/bgjob-reader.js.map +1 -0
- package/dist/core/bgjob-reader.test.d.ts +2 -0
- package/dist/core/bgjob-reader.test.d.ts.map +1 -0
- package/dist/core/bgjob-reader.test.js +178 -0
- package/dist/core/bgjob-reader.test.js.map +1 -0
- package/dist/core/circuit-breaker.d.ts +37 -0
- package/dist/core/circuit-breaker.d.ts.map +1 -0
- package/dist/core/circuit-breaker.js +115 -0
- package/dist/core/circuit-breaker.js.map +1 -0
- package/dist/core/commands/agent.d.ts +4 -0
- package/dist/core/commands/agent.d.ts.map +1 -0
- package/dist/core/commands/agent.js +21 -0
- package/dist/core/commands/agent.js.map +1 -0
- package/dist/core/commands/approval.d.ts +3 -0
- package/dist/core/commands/approval.d.ts.map +1 -0
- package/dist/core/commands/approval.js +44 -0
- package/dist/core/commands/approval.js.map +1 -0
- package/dist/core/commands/approval.test.d.ts +2 -0
- package/dist/core/commands/approval.test.d.ts.map +1 -0
- package/dist/core/commands/approval.test.js +85 -0
- package/dist/core/commands/approval.test.js.map +1 -0
- package/dist/core/commands/audit.d.ts +3 -0
- package/dist/core/commands/audit.d.ts.map +1 -0
- package/dist/core/commands/audit.js +84 -0
- package/dist/core/commands/audit.js.map +1 -0
- package/dist/core/commands/builtin.d.ts +3 -0
- package/dist/core/commands/builtin.d.ts.map +1 -0
- package/dist/core/commands/builtin.js +26 -0
- package/dist/core/commands/builtin.js.map +1 -0
- package/dist/core/commands/job.d.ts +3 -0
- package/dist/core/commands/job.d.ts.map +1 -0
- package/dist/core/commands/job.js +195 -0
- package/dist/core/commands/job.js.map +1 -0
- package/dist/core/commands/model.d.ts +9 -0
- package/dist/core/commands/model.d.ts.map +1 -0
- package/dist/core/commands/model.js +183 -0
- package/dist/core/commands/model.js.map +1 -0
- package/dist/core/commands/plan.d.ts +3 -0
- package/dist/core/commands/plan.d.ts.map +1 -0
- package/dist/core/commands/plan.js +75 -0
- package/dist/core/commands/plan.js.map +1 -0
- package/dist/core/commands/plan.test.d.ts +2 -0
- package/dist/core/commands/plan.test.d.ts.map +1 -0
- package/dist/core/commands/plan.test.js +122 -0
- package/dist/core/commands/plan.test.js.map +1 -0
- package/dist/core/commands/router.d.ts +3 -0
- package/dist/core/commands/router.d.ts.map +1 -0
- package/dist/core/commands/router.js +71 -0
- package/dist/core/commands/router.js.map +1 -0
- package/dist/core/commands/schedule.d.ts +3 -0
- package/dist/core/commands/schedule.d.ts.map +1 -0
- package/dist/core/commands/schedule.js +123 -0
- package/dist/core/commands/schedule.js.map +1 -0
- package/dist/core/commands/sessions.d.ts +3 -0
- package/dist/core/commands/sessions.d.ts.map +1 -0
- package/dist/core/commands/sessions.js +88 -0
- package/dist/core/commands/sessions.js.map +1 -0
- package/dist/core/commands/stats.d.ts +3 -0
- package/dist/core/commands/stats.d.ts.map +1 -0
- package/dist/core/commands/stats.js +73 -0
- package/dist/core/commands/stats.js.map +1 -0
- package/dist/core/commands/think.d.ts +3 -0
- package/dist/core/commands/think.d.ts.map +1 -0
- package/dist/core/commands/think.js +28 -0
- package/dist/core/commands/think.js.map +1 -0
- package/dist/core/commands/workspaces.d.ts +3 -0
- package/dist/core/commands/workspaces.d.ts.map +1 -0
- package/dist/core/commands/workspaces.js +47 -0
- package/dist/core/commands/workspaces.js.map +1 -0
- package/dist/core/config-schema.d.ts +58 -0
- package/dist/core/config-schema.d.ts.map +1 -0
- package/dist/core/config-schema.js +63 -0
- package/dist/core/config-schema.js.map +1 -0
- package/dist/core/cron.d.ts +29 -0
- package/dist/core/cron.d.ts.map +1 -0
- package/dist/core/cron.js +184 -0
- package/dist/core/cron.js.map +1 -0
- package/dist/core/event-bus.d.ts +80 -0
- package/dist/core/event-bus.d.ts.map +1 -0
- package/dist/core/event-bus.js +62 -0
- package/dist/core/event-bus.js.map +1 -0
- package/dist/core/intent-llm.d.ts +27 -0
- package/dist/core/intent-llm.d.ts.map +1 -0
- package/dist/core/intent-llm.js +170 -0
- package/dist/core/intent-llm.js.map +1 -0
- package/dist/core/intent.d.ts +12 -0
- package/dist/core/intent.d.ts.map +1 -0
- package/dist/core/intent.js +187 -0
- package/dist/core/intent.js.map +1 -0
- package/dist/core/job-board.d.ts +84 -0
- package/dist/core/job-board.d.ts.map +1 -0
- package/dist/core/job-board.js +379 -0
- package/dist/core/job-board.js.map +1 -0
- package/dist/core/logger.d.ts +6 -0
- package/dist/core/logger.d.ts.map +1 -0
- package/dist/core/logger.js +54 -0
- package/dist/core/logger.js.map +1 -0
- package/dist/core/metrics.d.ts +55 -0
- package/dist/core/metrics.d.ts.map +1 -0
- package/dist/core/metrics.js +291 -0
- package/dist/core/metrics.js.map +1 -0
- package/dist/core/onboarding.d.ts +94 -0
- package/dist/core/onboarding.d.ts.map +1 -0
- package/dist/core/onboarding.js +426 -0
- package/dist/core/onboarding.js.map +1 -0
- package/dist/core/onboarding.test.d.ts +2 -0
- package/dist/core/onboarding.test.d.ts.map +1 -0
- package/dist/core/onboarding.test.js +112 -0
- package/dist/core/onboarding.test.js.map +1 -0
- package/dist/core/rate-limiter.d.ts +44 -0
- package/dist/core/rate-limiter.d.ts.map +1 -0
- package/dist/core/rate-limiter.js +115 -0
- package/dist/core/rate-limiter.js.map +1 -0
- package/dist/core/registry.d.ts +32 -0
- package/dist/core/registry.d.ts.map +1 -0
- package/dist/core/registry.js +122 -0
- package/dist/core/registry.js.map +1 -0
- package/dist/core/router.d.ts +41 -0
- package/dist/core/router.d.ts.map +1 -0
- package/dist/core/router.js +431 -0
- package/dist/core/router.js.map +1 -0
- package/dist/core/schedule.d.ts +65 -0
- package/dist/core/schedule.d.ts.map +1 -0
- package/dist/core/schedule.js +316 -0
- package/dist/core/schedule.js.map +1 -0
- package/dist/core/session-subtasks.test.d.ts +2 -0
- package/dist/core/session-subtasks.test.d.ts.map +1 -0
- package/dist/core/session-subtasks.test.js +88 -0
- package/dist/core/session-subtasks.test.js.map +1 -0
- package/dist/core/session.d.ts +182 -0
- package/dist/core/session.d.ts.map +1 -0
- package/dist/core/session.js +774 -0
- package/dist/core/session.js.map +1 -0
- package/dist/core/sqlite-helper.d.ts +37 -0
- package/dist/core/sqlite-helper.d.ts.map +1 -0
- package/dist/core/sqlite-helper.js +79 -0
- package/dist/core/sqlite-helper.js.map +1 -0
- package/dist/core/transcribe.d.ts +25 -0
- package/dist/core/transcribe.d.ts.map +1 -0
- package/dist/core/transcribe.js +217 -0
- package/dist/core/transcribe.js.map +1 -0
- package/dist/core/transcribe.test.d.ts +2 -0
- package/dist/core/transcribe.test.d.ts.map +1 -0
- package/dist/core/transcribe.test.js +163 -0
- package/dist/core/transcribe.test.js.map +1 -0
- package/dist/core/types.d.ts +352 -0
- package/dist/core/types.d.ts.map +1 -0
- package/dist/core/types.js +3 -0
- package/dist/core/types.js.map +1 -0
- package/dist/core/workspace.d.ts +67 -0
- package/dist/core/workspace.d.ts.map +1 -0
- package/dist/core/workspace.js +113 -0
- package/dist/core/workspace.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/plugins/agents/acp/acp-adapter.d.ts +16 -0
- package/dist/plugins/agents/acp/acp-adapter.d.ts.map +1 -0
- package/dist/plugins/agents/acp/acp-adapter.js +49 -0
- package/dist/plugins/agents/acp/acp-adapter.js.map +1 -0
- package/dist/plugins/agents/acp/acp-client.d.ts +32 -0
- package/dist/plugins/agents/acp/acp-client.d.ts.map +1 -0
- package/dist/plugins/agents/acp/acp-client.js +175 -0
- package/dist/plugins/agents/acp/acp-client.js.map +1 -0
- package/dist/plugins/agents/acp/discovery.d.ts +19 -0
- package/dist/plugins/agents/acp/discovery.d.ts.map +1 -0
- package/dist/plugins/agents/acp/discovery.js +109 -0
- package/dist/plugins/agents/acp/discovery.js.map +1 -0
- package/dist/plugins/agents/acp/index.d.ts +4 -0
- package/dist/plugins/agents/acp/index.d.ts.map +1 -0
- package/dist/plugins/agents/acp/index.js +4 -0
- package/dist/plugins/agents/acp/index.js.map +1 -0
- package/dist/plugins/agents/acp/types.d.ts +62 -0
- package/dist/plugins/agents/acp/types.d.ts.map +1 -0
- package/dist/plugins/agents/acp/types.js +5 -0
- package/dist/plugins/agents/acp/types.js.map +1 -0
- package/dist/plugins/agents/claude-code/adapter.test.d.ts +2 -0
- package/dist/plugins/agents/claude-code/adapter.test.d.ts.map +1 -0
- package/dist/plugins/agents/claude-code/adapter.test.js +195 -0
- package/dist/plugins/agents/claude-code/adapter.test.js.map +1 -0
- package/dist/plugins/agents/claude-code/index.d.ts +25 -0
- package/dist/plugins/agents/claude-code/index.d.ts.map +1 -0
- package/dist/plugins/agents/claude-code/index.js +184 -0
- package/dist/plugins/agents/claude-code/index.js.map +1 -0
- package/dist/plugins/agents/claude-code/mcp-approval-server.d.ts +42 -0
- package/dist/plugins/agents/claude-code/mcp-approval-server.d.ts.map +1 -0
- package/dist/plugins/agents/claude-code/mcp-approval-server.js +235 -0
- package/dist/plugins/agents/claude-code/mcp-approval-server.js.map +1 -0
- package/dist/plugins/agents/claude-code/mcp-approval-server.test.d.ts +2 -0
- package/dist/plugins/agents/claude-code/mcp-approval-server.test.d.ts.map +1 -0
- package/dist/plugins/agents/claude-code/mcp-approval-server.test.js +188 -0
- package/dist/plugins/agents/claude-code/mcp-approval-server.test.js.map +1 -0
- package/dist/plugins/agents/codex/adapter.test.d.ts +2 -0
- package/dist/plugins/agents/codex/adapter.test.d.ts.map +1 -0
- package/dist/plugins/agents/codex/adapter.test.js +192 -0
- package/dist/plugins/agents/codex/adapter.test.js.map +1 -0
- package/dist/plugins/agents/codex/index.d.ts +37 -0
- package/dist/plugins/agents/codex/index.d.ts.map +1 -0
- package/dist/plugins/agents/codex/index.js +254 -0
- package/dist/plugins/agents/codex/index.js.map +1 -0
- package/dist/plugins/agents/copilot/index.d.ts +35 -0
- package/dist/plugins/agents/copilot/index.d.ts.map +1 -0
- package/dist/plugins/agents/copilot/index.js +182 -0
- package/dist/plugins/agents/copilot/index.js.map +1 -0
- package/dist/plugins/agents/opencode/adapter.test.d.ts +2 -0
- package/dist/plugins/agents/opencode/adapter.test.d.ts.map +1 -0
- package/dist/plugins/agents/opencode/adapter.test.js +139 -0
- package/dist/plugins/agents/opencode/adapter.test.js.map +1 -0
- package/dist/plugins/agents/opencode/http-adapter.test.d.ts +2 -0
- package/dist/plugins/agents/opencode/http-adapter.test.d.ts.map +1 -0
- package/dist/plugins/agents/opencode/http-adapter.test.js +492 -0
- package/dist/plugins/agents/opencode/http-adapter.test.js.map +1 -0
- package/dist/plugins/agents/opencode/index.d.ts +5 -0
- package/dist/plugins/agents/opencode/index.d.ts.map +1 -0
- package/dist/plugins/agents/opencode/index.js +30 -0
- package/dist/plugins/agents/opencode/index.js.map +1 -0
- package/dist/plugins/agents/opencode/opencode-http-adapter.d.ts +138 -0
- package/dist/plugins/agents/opencode/opencode-http-adapter.d.ts.map +1 -0
- package/dist/plugins/agents/opencode/opencode-http-adapter.js +549 -0
- package/dist/plugins/agents/opencode/opencode-http-adapter.js.map +1 -0
- package/dist/plugins/agents/opencode/opencode-stdio-adapter.d.ts +24 -0
- package/dist/plugins/agents/opencode/opencode-stdio-adapter.d.ts.map +1 -0
- package/dist/plugins/agents/opencode/opencode-stdio-adapter.js +103 -0
- package/dist/plugins/agents/opencode/opencode-stdio-adapter.js.map +1 -0
- package/dist/plugins/agents/opencode/serve-manager.d.ts +27 -0
- package/dist/plugins/agents/opencode/serve-manager.d.ts.map +1 -0
- package/dist/plugins/agents/opencode/serve-manager.js +190 -0
- package/dist/plugins/agents/opencode/serve-manager.js.map +1 -0
- package/dist/plugins/messengers/discord/discord-adapter.d.ts +22 -0
- package/dist/plugins/messengers/discord/discord-adapter.d.ts.map +1 -0
- package/dist/plugins/messengers/discord/discord-adapter.js +241 -0
- package/dist/plugins/messengers/discord/discord-adapter.js.map +1 -0
- package/dist/plugins/messengers/discord/discord-adapter.test.d.ts +2 -0
- package/dist/plugins/messengers/discord/discord-adapter.test.d.ts.map +1 -0
- package/dist/plugins/messengers/discord/discord-adapter.test.js +332 -0
- package/dist/plugins/messengers/discord/discord-adapter.test.js.map +1 -0
- package/dist/plugins/messengers/discord/index.d.ts +4 -0
- package/dist/plugins/messengers/discord/index.d.ts.map +1 -0
- package/dist/plugins/messengers/discord/index.js +4 -0
- package/dist/plugins/messengers/discord/index.js.map +1 -0
- package/dist/plugins/messengers/discord/markdown-to-discord.d.ts +11 -0
- package/dist/plugins/messengers/discord/markdown-to-discord.d.ts.map +1 -0
- package/dist/plugins/messengers/discord/markdown-to-discord.js +59 -0
- package/dist/plugins/messengers/discord/markdown-to-discord.js.map +1 -0
- package/dist/plugins/messengers/discord/types.d.ts +9 -0
- package/dist/plugins/messengers/discord/types.d.ts.map +1 -0
- package/dist/plugins/messengers/discord/types.js +3 -0
- package/dist/plugins/messengers/discord/types.js.map +1 -0
- package/dist/plugins/messengers/feishu/card-builder.d.ts +23 -0
- package/dist/plugins/messengers/feishu/card-builder.d.ts.map +1 -0
- package/dist/plugins/messengers/feishu/card-builder.js +89 -0
- package/dist/plugins/messengers/feishu/card-builder.js.map +1 -0
- package/dist/plugins/messengers/feishu/feishu-adapter.d.ts +33 -0
- package/dist/plugins/messengers/feishu/feishu-adapter.d.ts.map +1 -0
- package/dist/plugins/messengers/feishu/feishu-adapter.js +195 -0
- package/dist/plugins/messengers/feishu/feishu-adapter.js.map +1 -0
- package/dist/plugins/messengers/feishu/feishu-client.d.ts +44 -0
- package/dist/plugins/messengers/feishu/feishu-client.d.ts.map +1 -0
- package/dist/plugins/messengers/feishu/feishu-client.js +120 -0
- package/dist/plugins/messengers/feishu/feishu-client.js.map +1 -0
- package/dist/plugins/messengers/feishu/feishu-dedup.test.d.ts +2 -0
- package/dist/plugins/messengers/feishu/feishu-dedup.test.d.ts.map +1 -0
- package/dist/plugins/messengers/feishu/feishu-dedup.test.js +70 -0
- package/dist/plugins/messengers/feishu/feishu-dedup.test.js.map +1 -0
- package/dist/plugins/messengers/feishu/index.d.ts +4 -0
- package/dist/plugins/messengers/feishu/index.d.ts.map +1 -0
- package/dist/plugins/messengers/feishu/index.js +4 -0
- package/dist/plugins/messengers/feishu/index.js.map +1 -0
- package/dist/plugins/messengers/feishu/types.d.ts +113 -0
- package/dist/plugins/messengers/feishu/types.d.ts.map +1 -0
- package/dist/plugins/messengers/feishu/types.js +4 -0
- package/dist/plugins/messengers/feishu/types.js.map +1 -0
- package/dist/plugins/messengers/telegram/index.d.ts +4 -0
- package/dist/plugins/messengers/telegram/index.d.ts.map +1 -0
- package/dist/plugins/messengers/telegram/index.js +4 -0
- package/dist/plugins/messengers/telegram/index.js.map +1 -0
- package/dist/plugins/messengers/telegram/markdown-to-html.d.ts +5 -0
- package/dist/plugins/messengers/telegram/markdown-to-html.d.ts.map +1 -0
- package/dist/plugins/messengers/telegram/markdown-to-html.js +186 -0
- package/dist/plugins/messengers/telegram/markdown-to-html.js.map +1 -0
- package/dist/plugins/messengers/telegram/media-download.d.ts +51 -0
- package/dist/plugins/messengers/telegram/media-download.d.ts.map +1 -0
- package/dist/plugins/messengers/telegram/media-download.js +224 -0
- package/dist/plugins/messengers/telegram/media-download.js.map +1 -0
- package/dist/plugins/messengers/telegram/media-download.test.d.ts +2 -0
- package/dist/plugins/messengers/telegram/media-download.test.d.ts.map +1 -0
- package/dist/plugins/messengers/telegram/media-download.test.js +125 -0
- package/dist/plugins/messengers/telegram/media-download.test.js.map +1 -0
- package/dist/plugins/messengers/telegram/telegram-adapter.d.ts +62 -0
- package/dist/plugins/messengers/telegram/telegram-adapter.d.ts.map +1 -0
- package/dist/plugins/messengers/telegram/telegram-adapter.js +653 -0
- package/dist/plugins/messengers/telegram/telegram-adapter.js.map +1 -0
- package/dist/plugins/messengers/telegram/types.d.ts +47 -0
- package/dist/plugins/messengers/telegram/types.d.ts.map +1 -0
- package/dist/plugins/messengers/telegram/types.js +3 -0
- package/dist/plugins/messengers/telegram/types.js.map +1 -0
- package/dist/plugins/messengers/wechat/ilink-adapter.d.ts +68 -0
- package/dist/plugins/messengers/wechat/ilink-adapter.d.ts.map +1 -0
- package/dist/plugins/messengers/wechat/ilink-adapter.js +483 -0
- package/dist/plugins/messengers/wechat/ilink-adapter.js.map +1 -0
- package/dist/plugins/messengers/wechat/ilink-client.d.ts +66 -0
- package/dist/plugins/messengers/wechat/ilink-client.d.ts.map +1 -0
- package/dist/plugins/messengers/wechat/ilink-client.js +288 -0
- package/dist/plugins/messengers/wechat/ilink-client.js.map +1 -0
- package/dist/plugins/messengers/wechat/ilink-types.d.ts +173 -0
- package/dist/plugins/messengers/wechat/ilink-types.d.ts.map +1 -0
- package/dist/plugins/messengers/wechat/ilink-types.js +12 -0
- package/dist/plugins/messengers/wechat/ilink-types.js.map +1 -0
- package/dist/utils/backoff.d.ts +35 -0
- package/dist/utils/backoff.d.ts.map +1 -0
- package/dist/utils/backoff.js +59 -0
- package/dist/utils/backoff.js.map +1 -0
- package/dist/utils/cross-platform.d.ts +26 -0
- package/dist/utils/cross-platform.d.ts.map +1 -0
- package/dist/utils/cross-platform.js +58 -0
- package/dist/utils/cross-platform.js.map +1 -0
- package/dist/utils/message-split.d.ts +14 -0
- package/dist/utils/message-split.d.ts.map +1 -0
- package/dist/utils/message-split.js +65 -0
- package/dist/utils/message-split.js.map +1 -0
- package/dist/utils/safe-equal.d.ts +2 -0
- package/dist/utils/safe-equal.d.ts.map +1 -0
- package/dist/utils/safe-equal.js +11 -0
- package/dist/utils/safe-equal.js.map +1 -0
- package/dist/web/public/_app.js +196 -0
- package/dist/web/public/index.html +935 -0
- package/dist/web/public/settings.html +1181 -0
- package/dist/web/public/tasks.html +1827 -0
- package/dist/web/server.d.ts +11 -0
- package/dist/web/server.d.ts.map +1 -0
- package/dist/web/server.js +1820 -0
- package/dist/web/server.js.map +1 -0
- package/package.json +73 -0
|
@@ -0,0 +1,703 @@
|
|
|
1
|
+
// approval-bus — IM 端人工审批的进程内总线
|
|
2
|
+
//
|
|
3
|
+
// 角色:在 im-hub 主进程里跑一个 unix socket 服务,等 claude 子进程的
|
|
4
|
+
// MCP "approval sidecar" 通过 socket 连进来发审批请求。bus 自己不决策,
|
|
5
|
+
// 只做三件事:
|
|
6
|
+
// 1. 把请求转给 notifier(由 messenger 层注入:负责推 IM 卡片)
|
|
7
|
+
// 2. 维护 pending 队列,按 threadId 索引,等 resolvePending 回流决策
|
|
8
|
+
// 3. 超时 / 进程退出 / 连接断开 时自动 deny,保证 sidecar 端不会卡死
|
|
9
|
+
//
|
|
10
|
+
// 协议:unix socket + NDJSON,每行一个 JSON 对象。
|
|
11
|
+
// sidecar → bus: {v:1, type:"approval", runId, reqId, toolName, input, toolUseId}
|
|
12
|
+
// bus → sidecar: {v:1, type:"decision", reqId, behavior:"allow"|"deny", ...}
|
|
13
|
+
//
|
|
14
|
+
// 单实例 export approvalBus;测试可 new ApprovalBus({approvalTimeoutMs}) 调小超时。
|
|
15
|
+
import { createServer } from 'net';
|
|
16
|
+
import { unlink, stat as fsStat, chmod } from 'fs/promises';
|
|
17
|
+
import { tmpdir } from 'os';
|
|
18
|
+
import { join } from 'path';
|
|
19
|
+
import { randomBytes } from 'crypto';
|
|
20
|
+
import { logger as rootLogger } from './logger.js';
|
|
21
|
+
import { eventBus } from './event-bus.js';
|
|
22
|
+
const log = rootLogger.child({ component: 'approval-bus' });
|
|
23
|
+
// Bumped from 5 min to 30 min so slower IM channels (e.g. Telegram, where
|
|
24
|
+
// notifications can land minutes after the bot.api.sendMessage logs success)
|
|
25
|
+
// have time to round-trip a y/n reply. Aligned with Claude's own 30 min hard
|
|
26
|
+
// timeout so we never outlive the underlying agent process.
|
|
27
|
+
const DEFAULT_APPROVAL_TIMEOUT_MS = parseEnvMs(process.env.IMHUB_APPROVAL_TIMEOUT_MS, 30 * 60 * 1000);
|
|
28
|
+
const DEFAULT_AUTO_ALLOW_GRACE_MS = 5 * 1000;
|
|
29
|
+
function parseEnvMs(raw, fallback) {
|
|
30
|
+
if (!raw)
|
|
31
|
+
return fallback;
|
|
32
|
+
const n = Number(raw);
|
|
33
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
34
|
+
return fallback;
|
|
35
|
+
return n;
|
|
36
|
+
}
|
|
37
|
+
const MAX_LINE_BYTES = 256 * 1024;
|
|
38
|
+
const MAX_BUFFER_BYTES = MAX_LINE_BYTES * 4;
|
|
39
|
+
/** Length of the input prefix used to fingerprint an auto-allow rule.
|
|
40
|
+
* `(toolName, input-prefix)` is the dedup key.
|
|
41
|
+
*
|
|
42
|
+
* Bumped from 5 → 10 (M13). 5 collapsed `bash::git s` so the rule
|
|
43
|
+
* matched `git status` AND `git stash` AND `git submodule update` — far
|
|
44
|
+
* broader than users meant when they OK'd a single command. 10 keeps
|
|
45
|
+
* the three common families distinct (`git status` ≠ `git stash` ≠
|
|
46
|
+
* `git submo`) while still grouping benign variations of the same
|
|
47
|
+
* operation (e.g. `git status` ≈ `git status -s`).
|
|
48
|
+
*
|
|
49
|
+
* Why not longer? Most realistic commands are 6–14 chars, and pushing
|
|
50
|
+
* past 10 means even `git status` vs `git status -s` are treated as
|
|
51
|
+
* distinct, defeating the "approve once for variants" UX. 10 is the
|
|
52
|
+
* sweet spot per CR-2026-05-06. */
|
|
53
|
+
const AUTO_ALLOW_PREFIX_LEN = 10;
|
|
54
|
+
export class ApprovalBus {
|
|
55
|
+
server = null;
|
|
56
|
+
socketPath = null;
|
|
57
|
+
approvalTimeoutMs;
|
|
58
|
+
autoAllowGraceMs;
|
|
59
|
+
runContexts = new Map();
|
|
60
|
+
pendingById = new Map();
|
|
61
|
+
pendingByThread = new Map();
|
|
62
|
+
connections = new Set();
|
|
63
|
+
notifier = null;
|
|
64
|
+
resolutionListener = null;
|
|
65
|
+
/** threadId → set of `${toolName}::${prefix}` keys the user has marked
|
|
66
|
+
* as auto-allow within this conversation. Cleared by clearAutoAllowForThread
|
|
67
|
+
* (called from session.resetConversation) and on stop(). */
|
|
68
|
+
autoAllowByThread = new Map();
|
|
69
|
+
/**
|
|
70
|
+
* Lifetime counters surfaced via {@link getMetrics}. Help ops detect
|
|
71
|
+
* leaks (pending growing unbounded), spikes (totalRequests rate), and
|
|
72
|
+
* approval skew (deny:allow ratio). Reset on stop() so the gauge for a
|
|
73
|
+
* fresh process starts at zero.
|
|
74
|
+
*/
|
|
75
|
+
metricsSnapshot = {
|
|
76
|
+
totalRequests: 0,
|
|
77
|
+
totalResolved: 0,
|
|
78
|
+
totalAllowed: 0,
|
|
79
|
+
totalDenied: 0,
|
|
80
|
+
totalTimedOut: 0,
|
|
81
|
+
};
|
|
82
|
+
constructor(opts = {}) {
|
|
83
|
+
this.approvalTimeoutMs = opts.approvalTimeoutMs ?? DEFAULT_APPROVAL_TIMEOUT_MS;
|
|
84
|
+
this.autoAllowGraceMs = opts.autoAllowGraceMs ?? DEFAULT_AUTO_ALLOW_GRACE_MS;
|
|
85
|
+
}
|
|
86
|
+
/** 注入"通知 IM 推送"的回调。messenger 层启动时调一次。 */
|
|
87
|
+
setNotifier(n) {
|
|
88
|
+
this.notifier = n;
|
|
89
|
+
}
|
|
90
|
+
/** Subscribe to resolution events. Replaces any previous listener.
|
|
91
|
+
* approval-router uses this to keep its UI cards in sync with bus-side
|
|
92
|
+
* cancellations (timeout / sidecar disconnect / run terminated). The
|
|
93
|
+
* user-driven path (button or y/n text) already edits its own card; the
|
|
94
|
+
* listener still fires there with cause='user' so consumers can dedup. */
|
|
95
|
+
setResolutionListener(l) {
|
|
96
|
+
this.resolutionListener = l;
|
|
97
|
+
}
|
|
98
|
+
/** 启动 unix socket 服务。返回最终使用的 socket 路径。 */
|
|
99
|
+
async start(socketPath) {
|
|
100
|
+
if (this.server)
|
|
101
|
+
throw new Error('approval-bus already started');
|
|
102
|
+
const path = socketPath ?? defaultSocketPath();
|
|
103
|
+
try {
|
|
104
|
+
await unlink(path);
|
|
105
|
+
}
|
|
106
|
+
catch { /* stale socket cleanup */ }
|
|
107
|
+
return new Promise((resolve, reject) => {
|
|
108
|
+
const server = createServer((socket) => this.handleConnection(socket));
|
|
109
|
+
const onErr = (err) => { reject(err); };
|
|
110
|
+
server.once('error', onErr);
|
|
111
|
+
server.listen(path, () => {
|
|
112
|
+
server.removeListener('error', onErr);
|
|
113
|
+
this.server = server;
|
|
114
|
+
this.socketPath = path;
|
|
115
|
+
// Harden file permissions: net.Server.listen creates the socket file
|
|
116
|
+
// with the current umask (typically 0022 → 0644 / 0666). chmod 0o600
|
|
117
|
+
// ensures only the current user can connect, and the post-chmod stat
|
|
118
|
+
// surfaces a warning if (somehow) it's still loose — useful in shared
|
|
119
|
+
// hosts where umask was relaxed at some startup script.
|
|
120
|
+
void (async () => {
|
|
121
|
+
try {
|
|
122
|
+
await chmod(path, 0o600);
|
|
123
|
+
const st = await fsStat(path);
|
|
124
|
+
if (process.platform !== 'win32' && (st.mode & 0o077) !== 0) {
|
|
125
|
+
log.warn({
|
|
126
|
+
event: 'approval.bus.socket_perms_loose',
|
|
127
|
+
mode: (st.mode & 0o777).toString(8),
|
|
128
|
+
path,
|
|
129
|
+
}, 'Approval socket file is group/world accessible — set umask=0077 to harden');
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
catch { /* non-fatal */ }
|
|
133
|
+
})();
|
|
134
|
+
log.info({ event: 'approval.bus.started', path });
|
|
135
|
+
resolve(path);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
async stop() {
|
|
140
|
+
// Reject everything still pending
|
|
141
|
+
for (const p of [...this.pendingById.values()]) {
|
|
142
|
+
this.cancelPending(p, { behavior: 'deny', message: 'approval-bus shutting down' }, 'shutdown');
|
|
143
|
+
}
|
|
144
|
+
this.runContexts.clear();
|
|
145
|
+
this.pendingById.clear();
|
|
146
|
+
this.pendingByThread.clear();
|
|
147
|
+
this.autoAllowByThread.clear();
|
|
148
|
+
// Reset lifetime counters so a restarted process starts at 0 — keeps
|
|
149
|
+
// `rate(im_hub_approval_requests_total)` correct without ops needing
|
|
150
|
+
// to track process restarts in the alert query.
|
|
151
|
+
this.metricsSnapshot = { totalRequests: 0, totalResolved: 0, totalAllowed: 0, totalDenied: 0, totalTimedOut: 0 };
|
|
152
|
+
// server.close() doesn't terminate existing connections. Half-close each
|
|
153
|
+
// (so the deny payload buffered above gets flushed) and fall back to
|
|
154
|
+
// destroy() after a short grace window if the peer doesn't disconnect.
|
|
155
|
+
const conns = [...this.connections];
|
|
156
|
+
this.connections.clear();
|
|
157
|
+
await Promise.all(conns.map((s) => new Promise((resolve) => {
|
|
158
|
+
if (s.destroyed) {
|
|
159
|
+
resolve();
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
s.once('close', () => resolve());
|
|
163
|
+
try {
|
|
164
|
+
s.end();
|
|
165
|
+
}
|
|
166
|
+
catch { /* ignore */ }
|
|
167
|
+
setTimeout(() => { if (!s.destroyed)
|
|
168
|
+
s.destroy(); }, 200).unref();
|
|
169
|
+
})));
|
|
170
|
+
const srv = this.server;
|
|
171
|
+
if (!srv)
|
|
172
|
+
return;
|
|
173
|
+
await new Promise((resolve) => srv.close(() => resolve()));
|
|
174
|
+
this.server = null;
|
|
175
|
+
if (this.socketPath) {
|
|
176
|
+
try {
|
|
177
|
+
await unlink(this.socketPath);
|
|
178
|
+
}
|
|
179
|
+
catch { /* ignore */ }
|
|
180
|
+
this.socketPath = null;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
registerRun(runId, ctx) {
|
|
184
|
+
this.runContexts.set(runId, ctx);
|
|
185
|
+
}
|
|
186
|
+
/** 进程结束时调。pending 全 deny,runContext 清掉。 */
|
|
187
|
+
unregisterRun(runId) {
|
|
188
|
+
this.runContexts.delete(runId);
|
|
189
|
+
for (const p of [...this.pendingById.values()]) {
|
|
190
|
+
if (p.runId === runId) {
|
|
191
|
+
this.cancelPending(p, { behavior: 'deny', message: 'run terminated' }, 'run-terminated');
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
hasPendingFor(threadId) {
|
|
196
|
+
return (this.pendingByThread.get(threadId)?.length ?? 0) > 0;
|
|
197
|
+
}
|
|
198
|
+
/** True iff a notifier has been installed (i.e. messenger layer has wired
|
|
199
|
+
* the bus into IM). Callers that have a fallback path (e.g. the opencode
|
|
200
|
+
* HTTP adapter) check this before registerSyntheticPending so they can
|
|
201
|
+
* short-circuit when the bus is dormant — mostly relevant in tests and in
|
|
202
|
+
* non-IM call paths (web, scheduler). */
|
|
203
|
+
hasNotifier() {
|
|
204
|
+
return this.notifier !== null;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* 由 messenger.onMessage 拦截层调用。把 thread 队列头部的 pending 用
|
|
208
|
+
* 给定决策 resolve 掉。返回被 resolve 的 pending 描述(platform / tool /
|
|
209
|
+
* fingerprint / 是否处于 auto-allow grace 模式);router 用这些信息发回执。
|
|
210
|
+
* 没有 pending 时返回 null。
|
|
211
|
+
*
|
|
212
|
+
* Auto-allow side-effect: a user-initiated deny against a pending that
|
|
213
|
+
* was running in auto-allow mode revokes the matching rule (the user is
|
|
214
|
+
* signaling "stop auto-approving this"). Revocation is intentionally
|
|
215
|
+
* scoped to this user-path so sidecar disconnects / shutdown / run-
|
|
216
|
+
* terminated denies don't accidentally clear rules the user still wants.
|
|
217
|
+
*/
|
|
218
|
+
resolvePending(threadId, decision) {
|
|
219
|
+
const q = this.pendingByThread.get(threadId);
|
|
220
|
+
const head = q?.[0];
|
|
221
|
+
if (!head)
|
|
222
|
+
return null;
|
|
223
|
+
const platform = this.runContexts.get(head.runId)?.platform ?? '';
|
|
224
|
+
const info = {
|
|
225
|
+
runId: head.runId,
|
|
226
|
+
threadId: head.threadId,
|
|
227
|
+
platform,
|
|
228
|
+
toolName: head.toolName,
|
|
229
|
+
fingerprint: head.fingerprint,
|
|
230
|
+
wasAutoAllow: head.autoAllow,
|
|
231
|
+
};
|
|
232
|
+
if (decision.behavior === 'deny' && head.autoAllow) {
|
|
233
|
+
this.removeAutoAllowRule(head.threadId, head.toolName, head.fingerprint);
|
|
234
|
+
}
|
|
235
|
+
this.cancelPending(head, decision, 'user');
|
|
236
|
+
return info;
|
|
237
|
+
}
|
|
238
|
+
/** Drop every auto-allow rule registered for this thread. Called from
|
|
239
|
+
* session.resetConversation so `/new` truly returns to "ask every time". */
|
|
240
|
+
clearAutoAllowForThread(threadId) {
|
|
241
|
+
this.autoAllowByThread.delete(threadId);
|
|
242
|
+
}
|
|
243
|
+
/** Test/diagnostic helper — current rule keys for a thread. */
|
|
244
|
+
getAutoAllowKeys(threadId) {
|
|
245
|
+
return [...(this.autoAllowByThread.get(threadId) ?? [])];
|
|
246
|
+
}
|
|
247
|
+
/** 测试用:当前 socket 路径。 */
|
|
248
|
+
getSocketPath() { return this.socketPath; }
|
|
249
|
+
/**
|
|
250
|
+
* Operational metrics snapshot used by /api/metrics (M14). `pending` is
|
|
251
|
+
* a live count; the totals are lifetime counters that monotonically
|
|
252
|
+
* increase until stop(). The three result buckets (allowed / denied /
|
|
253
|
+
* timedOut) are mutually exclusive and sum to totalResolved — see
|
|
254
|
+
* cancelPending for the bucketing rule. Cheap to call — no allocations
|
|
255
|
+
* beyond the returned object.
|
|
256
|
+
*/
|
|
257
|
+
getMetrics() {
|
|
258
|
+
return {
|
|
259
|
+
pending: this.pendingById.size,
|
|
260
|
+
totalRequests: this.metricsSnapshot.totalRequests,
|
|
261
|
+
totalResolved: this.metricsSnapshot.totalResolved,
|
|
262
|
+
totalAllowed: this.metricsSnapshot.totalAllowed,
|
|
263
|
+
totalDenied: this.metricsSnapshot.totalDenied,
|
|
264
|
+
totalTimedOut: this.metricsSnapshot.totalTimedOut,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Snapshot of every currently-pending approval, sanitized for surface
|
|
269
|
+
* to the operator dashboard. Used by the web `/api/approvals` endpoint
|
|
270
|
+
* (and any future ops tooling). Returns a stable JSON shape — input
|
|
271
|
+
* is included verbatim so the UI can render the same preview the
|
|
272
|
+
* IM-side card would; sockets / dispatch closures / timer handles are
|
|
273
|
+
* intentionally omitted.
|
|
274
|
+
*
|
|
275
|
+
* Sorted oldest-first so the queue head is at index 0 (matches the
|
|
276
|
+
* head-of-thread queue semantics used by resolvePending).
|
|
277
|
+
*/
|
|
278
|
+
listPending() {
|
|
279
|
+
const now = Date.now();
|
|
280
|
+
const out = [];
|
|
281
|
+
for (const p of this.pendingById.values()) {
|
|
282
|
+
if (p.resolved)
|
|
283
|
+
continue;
|
|
284
|
+
out.push({
|
|
285
|
+
reqId: p.reqId,
|
|
286
|
+
runId: p.runId,
|
|
287
|
+
threadId: p.threadId,
|
|
288
|
+
platform: p.platform,
|
|
289
|
+
toolName: p.toolName,
|
|
290
|
+
input: p.input,
|
|
291
|
+
fingerprint: p.fingerprint,
|
|
292
|
+
autoAllow: p.autoAllow,
|
|
293
|
+
registeredAt: p.registeredAt,
|
|
294
|
+
ageMs: now - p.registeredAt,
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
out.sort((a, b) => a.registeredAt - b.registeredAt);
|
|
298
|
+
return out;
|
|
299
|
+
}
|
|
300
|
+
// --- internals ---
|
|
301
|
+
handleConnection(socket) {
|
|
302
|
+
this.connections.add(socket);
|
|
303
|
+
let buf = '';
|
|
304
|
+
socket.setEncoding('utf8');
|
|
305
|
+
socket.on('data', (chunk) => {
|
|
306
|
+
buf += chunk;
|
|
307
|
+
if (buf.length > MAX_BUFFER_BYTES) {
|
|
308
|
+
log.warn({ event: 'approval.bus.buffer_overflow', bytes: buf.length });
|
|
309
|
+
// L10: best-effort signal to the sidecar BEFORE we close. The
|
|
310
|
+
// legacy `socket.destroy()` left the sidecar's pending request
|
|
311
|
+
// hanging on its own timeout (Claude's MCP layer typically
|
|
312
|
+
// 30 min). A wire-shaped 'fatal' line gives the sidecar a
|
|
313
|
+
// recognizable reason; if it doesn't parse the new type it still
|
|
314
|
+
// observes the socket close and bails through its disconnect
|
|
315
|
+
// handler — strictly better than the silent destroy.
|
|
316
|
+
try {
|
|
317
|
+
socket.write(JSON.stringify({ v: 1, type: 'fatal', reason: 'buffer overflow' }) + '\n');
|
|
318
|
+
socket.end();
|
|
319
|
+
}
|
|
320
|
+
catch {
|
|
321
|
+
socket.destroy();
|
|
322
|
+
}
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
let nl;
|
|
326
|
+
while ((nl = buf.indexOf('\n')) !== -1) {
|
|
327
|
+
const line = buf.slice(0, nl);
|
|
328
|
+
buf = buf.slice(nl + 1);
|
|
329
|
+
if (line.length === 0)
|
|
330
|
+
continue;
|
|
331
|
+
if (line.length > MAX_LINE_BYTES) {
|
|
332
|
+
log.warn({ event: 'approval.bus.line_too_long', len: line.length });
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
this.handleLine(line, socket).catch((err) => {
|
|
336
|
+
log.error({ event: 'approval.bus.handle_error', err: String(err) });
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
socket.on('error', (err) => {
|
|
341
|
+
log.warn({ event: 'approval.bus.socket_error', err: String(err) });
|
|
342
|
+
});
|
|
343
|
+
socket.on('close', () => {
|
|
344
|
+
this.connections.delete(socket);
|
|
345
|
+
// sidecar 掉线:相关 pending 全 deny(claude 那边大概率也已经死了,写不写都无所谓)
|
|
346
|
+
for (const p of [...this.pendingById.values()]) {
|
|
347
|
+
if (p.socket === socket) {
|
|
348
|
+
this.cancelPending(p, { behavior: 'deny', message: 'sidecar disconnected' }, 'sidecar-disconnect');
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
async handleLine(line, socket) {
|
|
354
|
+
let msg;
|
|
355
|
+
try {
|
|
356
|
+
msg = JSON.parse(line);
|
|
357
|
+
}
|
|
358
|
+
catch {
|
|
359
|
+
log.warn({ event: 'approval.bus.bad_json', line: line.slice(0, 200) });
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
if (!msg || typeof msg !== 'object') {
|
|
363
|
+
log.warn({ event: 'approval.bus.bad_msg' });
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
const m = msg;
|
|
367
|
+
if (m.v !== 1) {
|
|
368
|
+
log.warn({ event: 'approval.bus.unsupported_version', v: m.v });
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
if (m.type === 'approval') {
|
|
372
|
+
await this.handleApproval(m, socket);
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
log.warn({ event: 'approval.bus.unknown_type', type: m.type });
|
|
376
|
+
}
|
|
377
|
+
async handleApproval(m, socket) {
|
|
378
|
+
const runId = typeof m.runId === 'string' ? m.runId : null;
|
|
379
|
+
const reqId = typeof m.reqId === 'string' ? m.reqId : null;
|
|
380
|
+
const toolName = typeof m.toolName === 'string' ? m.toolName : null;
|
|
381
|
+
const toolUseId = typeof m.toolUseId === 'string' ? m.toolUseId : '';
|
|
382
|
+
const input = (m.input && typeof m.input === 'object' && !Array.isArray(m.input))
|
|
383
|
+
? m.input
|
|
384
|
+
: {};
|
|
385
|
+
if (!reqId) {
|
|
386
|
+
log.warn({ event: 'approval.bus.missing_reqId' });
|
|
387
|
+
return; // 没 reqId 没法回包,丢弃
|
|
388
|
+
}
|
|
389
|
+
if (!runId || !toolName) {
|
|
390
|
+
this.sendDecision(socket, reqId, { behavior: 'deny', message: 'invalid approval message' });
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
const ctx = this.runContexts.get(runId);
|
|
394
|
+
if (!ctx) {
|
|
395
|
+
this.sendDecision(socket, reqId, { behavior: 'deny', message: `unknown runId: ${runId}` });
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
if (this.pendingById.has(reqId)) {
|
|
399
|
+
// 重复 reqId(sidecar bug):拒绝新的,老的留着
|
|
400
|
+
this.sendDecision(socket, reqId, { behavior: 'deny', message: 'duplicate reqId' });
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
if (!this.notifier) {
|
|
404
|
+
this.sendDecision(socket, reqId, { behavior: 'deny', message: 'no notifier installed' });
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
await this._registerPending({
|
|
408
|
+
runId, reqId, toolName, toolUseId, input, ctx,
|
|
409
|
+
transport: { socket },
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Register an approval request that did NOT come from the unix-socket
|
|
414
|
+
* sidecar. Used by the opencode HTTP bridge (P2): SSE event from opencode
|
|
415
|
+
* → bridge calls this with a `dispatch` callback that POSTs the decision
|
|
416
|
+
* back to opencode's REST API.
|
|
417
|
+
*
|
|
418
|
+
* Behavior is identical to the socket path — same notifier, same timeout,
|
|
419
|
+
* same auto-allow rules — just the delivery channel differs. The
|
|
420
|
+
* `dispatch` is invoked with the final Decision exactly once, on:
|
|
421
|
+
* - user reply via {@link resolvePending}
|
|
422
|
+
* - timeout (deny in normal mode, allow in auto-allow mode)
|
|
423
|
+
* - {@link unregisterRun} (deny: "run terminated")
|
|
424
|
+
* - {@link stop} (deny: "approval-bus shutting down")
|
|
425
|
+
*
|
|
426
|
+
* dispatch errors are logged and swallowed — the bus must not crash on
|
|
427
|
+
* a misbehaving callback.
|
|
428
|
+
*
|
|
429
|
+
* Idempotent on duplicate reqId: returns silently without firing notify
|
|
430
|
+
* (matches the socket path's "duplicate reqId" handling, minus the wire
|
|
431
|
+
* deny since the synthetic caller has no socket to deny on).
|
|
432
|
+
*
|
|
433
|
+
* Throws synchronously only when the caller's bus state is invalid (no
|
|
434
|
+
* notifier installed). The caller should avoid registering the synthetic
|
|
435
|
+
* pending in that case and fall back to its own deny path.
|
|
436
|
+
*/
|
|
437
|
+
async registerSyntheticPending(input) {
|
|
438
|
+
if (!this.notifier) {
|
|
439
|
+
// Caller is responsible for handling this case — they have the only
|
|
440
|
+
// backchannel to whatever spawned the request (opencode HTTP, etc.).
|
|
441
|
+
throw new Error('no notifier installed');
|
|
442
|
+
}
|
|
443
|
+
if (this.pendingById.has(input.reqId)) {
|
|
444
|
+
log.warn({ event: 'approval.bus.duplicate_reqId', reqId: input.reqId, source: 'synthetic' });
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
// Register run context lazily so callers don't have to pre-call
|
|
448
|
+
// registerRun for every prompt — synthetic pendings already carry full
|
|
449
|
+
// ctx in their argument.
|
|
450
|
+
if (!this.runContexts.has(input.runId)) {
|
|
451
|
+
this.runContexts.set(input.runId, input.ctx);
|
|
452
|
+
}
|
|
453
|
+
await this._registerPending({
|
|
454
|
+
runId: input.runId,
|
|
455
|
+
reqId: input.reqId,
|
|
456
|
+
toolName: input.toolName,
|
|
457
|
+
toolUseId: input.toolUseId ?? '',
|
|
458
|
+
input: input.input,
|
|
459
|
+
ctx: input.ctx,
|
|
460
|
+
transport: { dispatch: input.dispatch },
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* Shared register-and-notify pipeline used by both the socket path and the
|
|
465
|
+
* synthetic path. Builds the PendingApproval, wires the timer, fires the
|
|
466
|
+
* notifier, and ensures the timer is cleared if the notifier itself throws.
|
|
467
|
+
*/
|
|
468
|
+
async _registerPending(args) {
|
|
469
|
+
const fingerprint = inputFingerprint(args.input);
|
|
470
|
+
const ruleKey = autoAllowRuleKey(args.toolName, fingerprint);
|
|
471
|
+
const isAutoAllow = this.autoAllowByThread.get(args.ctx.threadId)?.has(ruleKey) ?? false;
|
|
472
|
+
const timeoutMs = isAutoAllow ? this.autoAllowGraceMs : this.approvalTimeoutMs;
|
|
473
|
+
const transport = args.transport;
|
|
474
|
+
const pending = {
|
|
475
|
+
runId: args.runId,
|
|
476
|
+
reqId: args.reqId,
|
|
477
|
+
toolName: args.toolName,
|
|
478
|
+
threadId: args.ctx.threadId,
|
|
479
|
+
platform: args.ctx.platform,
|
|
480
|
+
fingerprint,
|
|
481
|
+
input: args.input,
|
|
482
|
+
registeredAt: Date.now(),
|
|
483
|
+
socket: 'socket' in transport ? transport.socket : undefined,
|
|
484
|
+
dispatch: 'dispatch' in transport ? transport.dispatch : undefined,
|
|
485
|
+
resolved: false,
|
|
486
|
+
autoAllow: isAutoAllow,
|
|
487
|
+
timer: setTimeout(() => {
|
|
488
|
+
if (isAutoAllow) {
|
|
489
|
+
this.cancelPending(pending, { behavior: 'allow' }, 'timeout');
|
|
490
|
+
}
|
|
491
|
+
else {
|
|
492
|
+
this.cancelPending(pending, { behavior: 'deny', message: 'approval timeout' }, 'timeout');
|
|
493
|
+
}
|
|
494
|
+
}, timeoutMs),
|
|
495
|
+
};
|
|
496
|
+
this.pendingById.set(args.reqId, pending);
|
|
497
|
+
const q = this.pendingByThread.get(args.ctx.threadId) ?? [];
|
|
498
|
+
q.push(pending);
|
|
499
|
+
this.pendingByThread.set(args.ctx.threadId, q);
|
|
500
|
+
this.metricsSnapshot.totalRequests++;
|
|
501
|
+
// Event-bus fan-out so the dashboard's /events SSE consumer can pop
|
|
502
|
+
// up an "approval pending" indicator without waiting on /api/approvals
|
|
503
|
+
// poll. publish() swallows listener errors — never breaks the bus.
|
|
504
|
+
eventBus.publish({
|
|
505
|
+
type: 'approval',
|
|
506
|
+
phase: 'requested',
|
|
507
|
+
reqId: args.reqId,
|
|
508
|
+
threadId: args.ctx.threadId,
|
|
509
|
+
platform: args.ctx.platform,
|
|
510
|
+
toolName: args.toolName,
|
|
511
|
+
});
|
|
512
|
+
log.info({
|
|
513
|
+
event: 'approval.bus.request',
|
|
514
|
+
runId: args.runId, reqId: args.reqId, toolName: args.toolName,
|
|
515
|
+
threadId: args.ctx.threadId, autoAllow: isAutoAllow,
|
|
516
|
+
transport: 'socket' in transport ? 'socket' : 'synthetic',
|
|
517
|
+
});
|
|
518
|
+
try {
|
|
519
|
+
await this.notifier({
|
|
520
|
+
runId: args.runId, reqId: args.reqId, toolName: args.toolName,
|
|
521
|
+
input: args.input, toolUseId: args.toolUseId, ctx: args.ctx,
|
|
522
|
+
...(isAutoAllow ? { autoAllow: { graceMs: this.autoAllowGraceMs } } : {}),
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
catch (err) {
|
|
526
|
+
log.error({ event: 'approval.bus.notifier_error', reqId: args.reqId, err: String(err) });
|
|
527
|
+
this.cancelPending(pending, { behavior: 'deny', message: 'notifier error' }, 'notifier-error');
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
cancelPending(p, decision, cause = 'sidecar-disconnect') {
|
|
531
|
+
if (p.resolved)
|
|
532
|
+
return;
|
|
533
|
+
p.resolved = true;
|
|
534
|
+
clearTimeout(p.timer);
|
|
535
|
+
// M14: account for the resolution. Bucket into exactly ONE of
|
|
536
|
+
// allowed / denied / timedOut so the three sub-counters sum to
|
|
537
|
+
// totalResolved (Prometheus counters must monotonically increase;
|
|
538
|
+
// double-counting could push a derived "allow = resolved-deny-timeout"
|
|
539
|
+
// negative, which Prom interprets as a counter reset). Timeouts win
|
|
540
|
+
// over the wire decision so an auto-allow grace expiry counts as
|
|
541
|
+
// "timeout" (we said yes by default), distinct from "user said yes",
|
|
542
|
+
// and a normal timeout-deny counts as "timeout", not "deny".
|
|
543
|
+
this.metricsSnapshot.totalResolved++;
|
|
544
|
+
let outcome;
|
|
545
|
+
if (cause === 'timeout') {
|
|
546
|
+
this.metricsSnapshot.totalTimedOut++;
|
|
547
|
+
outcome = 'timeout';
|
|
548
|
+
}
|
|
549
|
+
else if (decision.behavior === 'deny') {
|
|
550
|
+
this.metricsSnapshot.totalDenied++;
|
|
551
|
+
outcome = 'deny';
|
|
552
|
+
}
|
|
553
|
+
else {
|
|
554
|
+
this.metricsSnapshot.totalAllowed++;
|
|
555
|
+
outcome = 'allow';
|
|
556
|
+
}
|
|
557
|
+
// Event-bus fan-out (dashboard SSE). Publish post-counter-update so a
|
|
558
|
+
// metrics-snapshot consumer that reads on a 'resolved' event sees the
|
|
559
|
+
// already-incremented counter.
|
|
560
|
+
eventBus.publish({
|
|
561
|
+
type: 'approval',
|
|
562
|
+
phase: 'resolved',
|
|
563
|
+
reqId: p.reqId,
|
|
564
|
+
threadId: p.threadId,
|
|
565
|
+
platform: p.platform,
|
|
566
|
+
toolName: p.toolName,
|
|
567
|
+
outcome,
|
|
568
|
+
});
|
|
569
|
+
// The "register on allow+all" side-effect lives here (covers any path
|
|
570
|
+
// through cancelPending — both resolvePending and the grace timer).
|
|
571
|
+
// The "revoke on user-deny" side-effect lives in resolvePending instead,
|
|
572
|
+
// so non-user denies (run terminated, sidecar disconnect, shutdown)
|
|
573
|
+
// don't accidentally clear rules the user still wants.
|
|
574
|
+
if (decision.behavior === 'allow' && decision.autoAllowFurther) {
|
|
575
|
+
this.addAutoAllowRule(p.threadId, p.toolName, p.fingerprint);
|
|
576
|
+
}
|
|
577
|
+
if (p.dispatch) {
|
|
578
|
+
// Synthetic path (opencode HTTP bridge). Caller owns the wire — they
|
|
579
|
+
// translate Decision → their backend's reply schema. Errors thrown
|
|
580
|
+
// here are isolated; the bus just logs.
|
|
581
|
+
try {
|
|
582
|
+
p.dispatch(decision);
|
|
583
|
+
}
|
|
584
|
+
catch (err) {
|
|
585
|
+
log.warn({ event: 'approval.bus.dispatch_error', reqId: p.reqId, err: String(err) });
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
else if (p.socket) {
|
|
589
|
+
this.sendDecision(p.socket, p.reqId, decision);
|
|
590
|
+
}
|
|
591
|
+
// (Neither set is impossible — _registerPending guarantees one of the two.)
|
|
592
|
+
this.removePending(p);
|
|
593
|
+
// Fire resolution listener last so subscribers see fully-cleaned state
|
|
594
|
+
// (pending already removed, rules already updated). Listener errors are
|
|
595
|
+
// isolated — the bus must not crash on a bad subscriber.
|
|
596
|
+
if (this.resolutionListener) {
|
|
597
|
+
try {
|
|
598
|
+
this.resolutionListener({
|
|
599
|
+
reqId: p.reqId,
|
|
600
|
+
runId: p.runId,
|
|
601
|
+
threadId: p.threadId,
|
|
602
|
+
platform: p.platform,
|
|
603
|
+
toolName: p.toolName,
|
|
604
|
+
fingerprint: p.fingerprint,
|
|
605
|
+
decision,
|
|
606
|
+
wasAutoAllow: p.autoAllow,
|
|
607
|
+
cause,
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
catch (err) {
|
|
611
|
+
log.warn({ event: 'approval.bus.listener_error', reqId: p.reqId, err: String(err) });
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
addAutoAllowRule(threadId, toolName, fingerprint) {
|
|
616
|
+
const key = autoAllowRuleKey(toolName, fingerprint);
|
|
617
|
+
let set = this.autoAllowByThread.get(threadId);
|
|
618
|
+
if (!set) {
|
|
619
|
+
set = new Set();
|
|
620
|
+
this.autoAllowByThread.set(threadId, set);
|
|
621
|
+
}
|
|
622
|
+
set.add(key);
|
|
623
|
+
log.info({ event: 'approval.bus.autoallow_added', threadId, toolName, fingerprint });
|
|
624
|
+
}
|
|
625
|
+
removeAutoAllowRule(threadId, toolName, fingerprint) {
|
|
626
|
+
const key = autoAllowRuleKey(toolName, fingerprint);
|
|
627
|
+
const set = this.autoAllowByThread.get(threadId);
|
|
628
|
+
if (!set)
|
|
629
|
+
return;
|
|
630
|
+
if (set.delete(key) && set.size === 0)
|
|
631
|
+
this.autoAllowByThread.delete(threadId);
|
|
632
|
+
log.info({ event: 'approval.bus.autoallow_removed', threadId, toolName, fingerprint });
|
|
633
|
+
}
|
|
634
|
+
removePending(p) {
|
|
635
|
+
this.pendingById.delete(p.reqId);
|
|
636
|
+
const q = this.pendingByThread.get(p.threadId);
|
|
637
|
+
if (!q)
|
|
638
|
+
return;
|
|
639
|
+
const idx = q.indexOf(p);
|
|
640
|
+
if (idx >= 0)
|
|
641
|
+
q.splice(idx, 1);
|
|
642
|
+
if (q.length === 0)
|
|
643
|
+
this.pendingByThread.delete(p.threadId);
|
|
644
|
+
}
|
|
645
|
+
sendDecision(socket, reqId, decision) {
|
|
646
|
+
if (!socket.writable)
|
|
647
|
+
return;
|
|
648
|
+
// Strip internal-only flags (e.g. autoAllowFurther) — sidecar only
|
|
649
|
+
// understands the wire schema.
|
|
650
|
+
const wire = { v: 1, type: 'decision', reqId, behavior: decision.behavior };
|
|
651
|
+
if (decision.behavior === 'allow' && decision.updatedInput) {
|
|
652
|
+
wire.updatedInput = decision.updatedInput;
|
|
653
|
+
}
|
|
654
|
+
else if (decision.behavior === 'deny' && decision.message) {
|
|
655
|
+
wire.message = decision.message;
|
|
656
|
+
}
|
|
657
|
+
const payload = JSON.stringify(wire) + '\n';
|
|
658
|
+
socket.write(payload, (err) => {
|
|
659
|
+
if (err)
|
|
660
|
+
log.warn({ event: 'approval.bus.write_failed', reqId, err: String(err) });
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
function autoAllowRuleKey(toolName, fingerprint) {
|
|
665
|
+
return `${toolName}::${fingerprint}`;
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* Pick a stable, short prefix of the input as the auto-allow fingerprint.
|
|
669
|
+
*
|
|
670
|
+
* Strategy: try the field most users mean when they say "this kind of
|
|
671
|
+
* call" for the common Claude tools (Bash → command, Write/Edit/Read →
|
|
672
|
+
* file_path, etc.); fall back to a stringified snapshot. Always truncated
|
|
673
|
+
* to AUTO_ALLOW_PREFIX_LEN so the rule covers small variations of the
|
|
674
|
+
* same operation but not unrelated ones.
|
|
675
|
+
*/
|
|
676
|
+
function inputFingerprint(input) {
|
|
677
|
+
const FIELDS = ['command', 'file_path', 'path', 'url', 'pattern', 'query'];
|
|
678
|
+
for (const f of FIELDS) {
|
|
679
|
+
const v = input[f];
|
|
680
|
+
if (typeof v === 'string' && v.length > 0) {
|
|
681
|
+
return v.slice(0, AUTO_ALLOW_PREFIX_LEN);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
let s;
|
|
685
|
+
try {
|
|
686
|
+
s = JSON.stringify(input);
|
|
687
|
+
}
|
|
688
|
+
catch {
|
|
689
|
+
s = '';
|
|
690
|
+
}
|
|
691
|
+
return s.slice(0, AUTO_ALLOW_PREFIX_LEN);
|
|
692
|
+
}
|
|
693
|
+
function defaultSocketPath() {
|
|
694
|
+
// 16 random bytes (32 hex chars) — defeats path prediction attacks that the
|
|
695
|
+
// earlier `${pid}-${Date.now().toString(36)}` form was vulnerable to in
|
|
696
|
+
// multi-tenant containers where pid is often 1 and the timestamp window
|
|
697
|
+
// is small. With 128 bits of entropy a TOCTOU pre-occupy attempt is
|
|
698
|
+
// statistically infeasible.
|
|
699
|
+
return join(tmpdir(), `imhub-approval-${randomBytes(16).toString('hex')}.sock`);
|
|
700
|
+
}
|
|
701
|
+
/** 进程级单例。im-hub 启动时 await approvalBus.start() 一次。 */
|
|
702
|
+
export const approvalBus = new ApprovalBus();
|
|
703
|
+
//# sourceMappingURL=approval-bus.js.map
|