cli-claw-kit 0.0.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.
- package/LICENSE +21 -0
- package/README.md +245 -0
- package/config/default-groups.json +1 -0
- package/config/global-agents-md.template.md +37 -0
- package/config/mount-allowlist.json +11 -0
- package/container/Dockerfile +160 -0
- package/container/agent-runner/dist/.tsbuildinfo +1 -0
- package/container/agent-runner/dist/agent-definitions.js +22 -0
- package/container/agent-runner/dist/channel-prefixes.js +16 -0
- package/container/agent-runner/dist/codex-config.js +29 -0
- package/container/agent-runner/dist/image-detector.js +96 -0
- package/container/agent-runner/dist/index.js +2587 -0
- package/container/agent-runner/dist/mcp-tools.js +1076 -0
- package/container/agent-runner/dist/stream-event.types.js +5 -0
- package/container/agent-runner/dist/stream-processor.js +867 -0
- package/container/agent-runner/dist/types.js +6 -0
- package/container/agent-runner/dist/utils.js +115 -0
- package/container/agent-runner/package.json +36 -0
- package/container/agent-runner/prompts/security-rules.md +31 -0
- package/container/agent-runner/src/agent-definitions.ts +27 -0
- package/container/agent-runner/src/channel-prefixes.ts +16 -0
- package/container/agent-runner/src/codex-config.ts +40 -0
- package/container/agent-runner/src/image-detector.ts +116 -0
- package/container/agent-runner/src/index.ts +3107 -0
- package/container/agent-runner/src/mcp-tools.ts +1295 -0
- package/container/agent-runner/src/stream-event.types.ts +10 -0
- package/container/agent-runner/src/stream-processor.ts +932 -0
- package/container/agent-runner/src/types.ts +75 -0
- package/container/agent-runner/src/utils.ts +114 -0
- package/container/agent-runner/tsconfig.json +17 -0
- package/container/build.sh +28 -0
- package/container/entrypoint.sh +64 -0
- package/container/skills/agent-browser/SKILL.md +159 -0
- package/container/skills/install-skill/SKILL.md +64 -0
- package/container/skills/post-test-cleanup/SKILL.md +121 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/agent-output-parser.js +459 -0
- package/dist/app-root.js +52 -0
- package/dist/assistant-meta-footer.js +1 -0
- package/dist/auth.js +91 -0
- package/dist/billing.js +694 -0
- package/dist/channel-prefixes.js +16 -0
- package/dist/cli.js +86 -0
- package/dist/commands.js +79 -0
- package/dist/config.js +120 -0
- package/dist/container-runner.js +981 -0
- package/dist/daily-summary.js +210 -0
- package/dist/db.js +3683 -0
- package/dist/dingtalk.js +1347 -0
- package/dist/feishu-markdown-style.js +97 -0
- package/dist/feishu-streaming-card.js +1875 -0
- package/dist/feishu.js +1628 -0
- package/dist/file-manager.js +270 -0
- package/dist/group-queue.js +1070 -0
- package/dist/group-runtime.js +35 -0
- package/dist/host-workspace-cwd.js +85 -0
- package/dist/im-channel.js +384 -0
- package/dist/im-command-utils.js +142 -0
- package/dist/im-downloader.js +45 -0
- package/dist/im-manager.js +527 -0
- package/dist/im-utils.js +53 -0
- package/dist/image-detector.js +96 -0
- package/dist/index.js +5828 -0
- package/dist/logger.js +22 -0
- package/dist/mcp-utils.js +66 -0
- package/dist/message-attachments.js +69 -0
- package/dist/message-notifier.js +36 -0
- package/dist/middleware/auth.js +85 -0
- package/dist/mount-security.js +315 -0
- package/dist/permissions.js +67 -0
- package/dist/project-memory.js +6 -0
- package/dist/provider-pool.js +189 -0
- package/dist/qq.js +826 -0
- package/dist/reset-admin.js +42 -0
- package/dist/routes/admin.js +543 -0
- package/dist/routes/agent-definitions.js +241 -0
- package/dist/routes/agents.js +533 -0
- package/dist/routes/auth.js +675 -0
- package/dist/routes/billing.js +490 -0
- package/dist/routes/browse.js +210 -0
- package/dist/routes/bug-report.js +387 -0
- package/dist/routes/config.js +1868 -0
- package/dist/routes/files.js +671 -0
- package/dist/routes/groups.js +1367 -0
- package/dist/routes/mcp-servers.js +320 -0
- package/dist/routes/memory.js +523 -0
- package/dist/routes/monitor.js +307 -0
- package/dist/routes/skills.js +777 -0
- package/dist/routes/tasks.js +509 -0
- package/dist/routes/usage.js +64 -0
- package/dist/routes/workspace-config.js +458 -0
- package/dist/runtime-build.js +112 -0
- package/dist/runtime-command-handler.js +189 -0
- package/dist/runtime-command-registry.js +1 -0
- package/dist/runtime-config.js +1777 -0
- package/dist/runtime-identity.js +52 -0
- package/dist/schemas.js +590 -0
- package/dist/script-runner.js +64 -0
- package/dist/sdk-query.js +82 -0
- package/dist/skill-utils.js +145 -0
- package/dist/sqlite-compat.js +19 -0
- package/dist/stream-event.types.js +5 -0
- package/dist/streaming-runtime-meta.js +29 -0
- package/dist/task-scheduler.js +695 -0
- package/dist/task-utils.js +13 -0
- package/dist/telegram-pairing.js +59 -0
- package/dist/telegram.js +897 -0
- package/dist/terminal-manager.js +307 -0
- package/dist/tool-step-display.js +1 -0
- package/dist/types.js +1 -0
- package/dist/utils.js +85 -0
- package/dist/web-context.js +161 -0
- package/dist/web.js +1377 -0
- package/dist/wechat-crypto.js +182 -0
- package/dist/wechat.js +589 -0
- package/dist/workspace-runtime-reset.js +35 -0
- package/package.json +107 -0
- package/shared/assistant-meta-footer.ts +127 -0
- package/shared/channel-prefixes.ts +16 -0
- package/shared/dist/assistant-meta-footer.d.ts +29 -0
- package/shared/dist/assistant-meta-footer.js +85 -0
- package/shared/dist/channel-prefixes.d.ts +4 -0
- package/shared/dist/channel-prefixes.js +16 -0
- package/shared/dist/image-detector.d.ts +20 -0
- package/shared/dist/image-detector.js +96 -0
- package/shared/dist/runtime-command-registry.d.ts +38 -0
- package/shared/dist/runtime-command-registry.js +185 -0
- package/shared/dist/stream-event.d.ts +65 -0
- package/shared/dist/stream-event.js +8 -0
- package/shared/dist/tool-step-display.d.ts +4 -0
- package/shared/dist/tool-step-display.js +11 -0
- package/shared/image-detector.ts +116 -0
- package/shared/runtime-command-registry.ts +252 -0
- package/shared/stream-event.ts +67 -0
- package/shared/tool-step-display.ts +21 -0
- package/shared/tsconfig.json +24 -0
- package/web/dist/assets/BillingPage-B1wBR_o-.js +52 -0
- package/web/dist/assets/ChatPage-6GBZ9nXN.css +32 -0
- package/web/dist/assets/ChatPage-BOJcXtaj.js +161 -0
- package/web/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/web/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/web/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/web/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/web/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/web/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/web/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/web/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/web/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/web/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/web/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/web/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/web/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/web/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/web/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/web/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/web/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/web/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/web/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/web/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/web/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/web/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/web/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/web/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/web/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/web/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/web/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/web/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/web/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/web/dist/assets/SettingsPage-DoY7FoZ_.js +153 -0
- package/web/dist/assets/ShareImageDialog-C1ga8b7l.js +22 -0
- package/web/dist/assets/TasksPage-CRivnNsx.js +14 -0
- package/web/dist/assets/_basePickBy-Bf-bSoS9.js +1 -0
- package/web/dist/assets/_baseUniq-zAOaCuKw.js +1 -0
- package/web/dist/assets/arc-Dm9mVQ9U.js +1 -0
- package/web/dist/assets/architectureDiagram-2XIMDMQ5-BLmzX1wr.js +36 -0
- package/web/dist/assets/band-CquvqAHh.js +1 -0
- package/web/dist/assets/blockDiagram-WCTKOSBZ-B9pcqm3j.js +132 -0
- package/web/dist/assets/c4Diagram-IC4MRINW-Cytx1q3b.js +10 -0
- package/web/dist/assets/channel-BOVj73LR.js +1 -0
- package/web/dist/assets/channel-meta-CQD0Pei-.js +41 -0
- package/web/dist/assets/chunk-4BX2VUAB-0ToDr6RE.js +1 -0
- package/web/dist/assets/chunk-55IACEB6-DQDjnXfS.js +1 -0
- package/web/dist/assets/chunk-FMBD7UC4-Di8ABm6c.js +15 -0
- package/web/dist/assets/chunk-JSJVCQXG-BZQN6rnX.js +1 -0
- package/web/dist/assets/chunk-KX2RTZJC-zBbcpaN_.js +1 -0
- package/web/dist/assets/chunk-NQ4KR5QH-BCrLoU88.js +220 -0
- package/web/dist/assets/chunk-QZHKN3VN-Bqk8juan.js +1 -0
- package/web/dist/assets/chunk-WL4C6EOR-D2YX-MHY.js +189 -0
- package/web/dist/assets/classDiagram-VBA2DB6C-DUUoMyaK.js +1 -0
- package/web/dist/assets/classDiagram-v2-RAHNMMFH-DUUoMyaK.js +1 -0
- package/web/dist/assets/clone-BmaCesfa.js +1 -0
- package/web/dist/assets/cose-bilkent-S5V4N54A-CTsv6qQA.js +1 -0
- package/web/dist/assets/cytoscape.esm-BQaXIfA_.js +331 -0
- package/web/dist/assets/dagre-KLK3FWXG-Ci4Jh9nu.js +4 -0
- package/web/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
- package/web/dist/assets/diagram-E7M64L7V-BFRnfTI2.js +24 -0
- package/web/dist/assets/diagram-IFDJBPK2-B7Zhnp0b.js +43 -0
- package/web/dist/assets/diagram-P4PSJMXO-BVyP7nwq.js +24 -0
- package/web/dist/assets/erDiagram-INFDFZHY-NorKdTOF.js +70 -0
- package/web/dist/assets/error-CGD5mp5f.js +1 -0
- package/web/dist/assets/flowDiagram-PKNHOUZH-Ch97nABF.js +162 -0
- package/web/dist/assets/ganttDiagram-A5KZAMGK-BQ2pLWsy.js +292 -0
- package/web/dist/assets/gitGraphDiagram-K3NZZRJ6-bcvnBsD2.js +65 -0
- package/web/dist/assets/graph-CeAEckur.js +1 -0
- package/web/dist/assets/index-CPnL1_qC.js +768 -0
- package/web/dist/assets/index-DVevCbcO.css +10 -0
- package/web/dist/assets/infoDiagram-LFFYTUFH-CcsrFdj-.js +2 -0
- package/web/dist/assets/init-Dmth1JHB.js +1 -0
- package/web/dist/assets/ishikawaDiagram-PHBUUO56-1upyMfHN.js +70 -0
- package/web/dist/assets/journeyDiagram-4ABVD52K-CKUi-V0c.js +139 -0
- package/web/dist/assets/kanban-definition-K7BYSVSG-DOnQwXfL.js +89 -0
- package/web/dist/assets/layout-BmMMqTnJ.js +1 -0
- package/web/dist/assets/linear-DiaJloY5.js +1 -0
- package/web/dist/assets/mermaid.core-BWLV1B2v.js +254 -0
- package/web/dist/assets/mindmap-definition-YRQLILUH-BeAKHVWP.js +68 -0
- package/web/dist/assets/ordinal-DILIJJjt.js +1 -0
- package/web/dist/assets/pieDiagram-SKSYHLDU-DfiMSfWo.js +30 -0
- package/web/dist/assets/quadrantDiagram-337W2JSQ-wZxZOJxd.js +7 -0
- package/web/dist/assets/requirementDiagram-Z7DCOOCP-BK4HHm17.js +73 -0
- package/web/dist/assets/sankeyDiagram-WA2Y5GQK-BX6t2avX.js +10 -0
- package/web/dist/assets/sequenceDiagram-2WXFIKYE-BPQlkbAa.js +145 -0
- package/web/dist/assets/sheet-rI0FfB1g.js +6 -0
- package/web/dist/assets/sliders-horizontal-CuijWFNK.js +6 -0
- package/web/dist/assets/sparkles-BsMYXJoT.js +11 -0
- package/web/dist/assets/square-0CqMX1Q3.js +11 -0
- package/web/dist/assets/stateDiagram-RAJIS63D-DxkV0Vwd.js +1 -0
- package/web/dist/assets/stateDiagram-v2-FVOUBMTO-qLYoiOPe.js +1 -0
- package/web/dist/assets/step-D51IIHGA.js +1 -0
- package/web/dist/assets/tasks-D8JjBTwx.js +1 -0
- package/web/dist/assets/time-O8zIGux3.js +1 -0
- package/web/dist/assets/timeline-definition-YZTLITO2-kNp1DyFc.js +61 -0
- package/web/dist/assets/treemap-KZPCXAKY-CkrClVhk.js +162 -0
- package/web/dist/assets/utils-KGAn0XTg.js +11 -0
- package/web/dist/assets/vennDiagram-LZ73GAT5-CgdzEZz4.js +34 -0
- package/web/dist/assets/xychartDiagram-JWTSCODW-DfYGPfNB.js +7 -0
- package/web/dist/assets/zap-_hKJYy7J.js +6 -0
- package/web/dist/favicon.svg +332 -0
- package/web/dist/fonts/AlibabaPuHuiTi-3-55-Regular.woff2 +0 -0
- package/web/dist/fonts/AlibabaPuHuiTi-3-65-Medium.woff2 +0 -0
- package/web/dist/fonts/AlibabaPuHuiTi-3-75-SemiBold.woff2 +0 -0
- package/web/dist/fonts/DMSans-latin-ext.woff2 +0 -0
- package/web/dist/fonts/DMSans-latin.woff2 +0 -0
- package/web/dist/icons/README.md +20 -0
- package/web/dist/icons/apple-touch-icon-180.png +0 -0
- package/web/dist/icons/icon-128.png +0 -0
- package/web/dist/icons/icon-144.png +0 -0
- package/web/dist/icons/icon-152.png +0 -0
- package/web/dist/icons/icon-192.png +0 -0
- package/web/dist/icons/icon-192.svg +332 -0
- package/web/dist/icons/icon-384.png +0 -0
- package/web/dist/icons/icon-48.png +0 -0
- package/web/dist/icons/icon-512-maskable.png +0 -0
- package/web/dist/icons/icon-512.png +0 -0
- package/web/dist/icons/icon-512.svg +332 -0
- package/web/dist/icons/icon-72.png +0 -0
- package/web/dist/icons/icon-96.png +0 -0
- package/web/dist/icons/loading-logo.svg +332 -0
- package/web/dist/icons/logo-1024.png +0 -0
- package/web/dist/icons/logo-icon.svg +332 -0
- package/web/dist/icons/logo-text.svg +332 -0
- package/web/dist/index.html +30 -0
- package/web/dist/manifest.webmanifest +1 -0
- package/web/dist/registerSW.js +1 -0
- package/web/dist/sw.js +1 -0
- package/web/dist/workbox-08d6266a.js +1 -0
|
@@ -0,0 +1,1777 @@
|
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { ASSISTANT_NAME, DATA_DIR } from './config.js';
|
|
5
|
+
import { logger } from './logger.js';
|
|
6
|
+
const MAX_FIELD_LENGTH = 2000;
|
|
7
|
+
const CLAUDE_CONFIG_DIR = path.join(DATA_DIR, 'config');
|
|
8
|
+
const CLAUDE_CONFIG_FILE = path.join(CLAUDE_CONFIG_DIR, 'claude-provider.json');
|
|
9
|
+
const CLAUDE_CONFIG_KEY_FILE = path.join(CLAUDE_CONFIG_DIR, 'claude-provider.key');
|
|
10
|
+
const CLAUDE_CONFIG_AUDIT_FILE = path.join(CLAUDE_CONFIG_DIR, 'claude-provider.audit.log');
|
|
11
|
+
const FEISHU_CONFIG_FILE = path.join(CLAUDE_CONFIG_DIR, 'feishu-provider.json');
|
|
12
|
+
const TELEGRAM_CONFIG_FILE = path.join(CLAUDE_CONFIG_DIR, 'telegram-provider.json');
|
|
13
|
+
const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
14
|
+
const RESERVED_CLAUDE_ENV_KEYS = new Set([
|
|
15
|
+
'CLAUDE_CODE_OAUTH_TOKEN',
|
|
16
|
+
'ANTHROPIC_BASE_URL',
|
|
17
|
+
'ANTHROPIC_AUTH_TOKEN',
|
|
18
|
+
'ANTHROPIC_MODEL',
|
|
19
|
+
]);
|
|
20
|
+
const DANGEROUS_ENV_VARS = new Set([
|
|
21
|
+
// Code execution / preload attacks
|
|
22
|
+
'LD_PRELOAD',
|
|
23
|
+
'LD_LIBRARY_PATH',
|
|
24
|
+
'LD_AUDIT',
|
|
25
|
+
'DYLD_INSERT_LIBRARIES',
|
|
26
|
+
'DYLD_LIBRARY_PATH',
|
|
27
|
+
'DYLD_FRAMEWORK_PATH',
|
|
28
|
+
'NODE_OPTIONS',
|
|
29
|
+
'JAVA_TOOL_OPTIONS',
|
|
30
|
+
'PERL5OPT',
|
|
31
|
+
// Path manipulation
|
|
32
|
+
'PATH',
|
|
33
|
+
'PYTHONPATH',
|
|
34
|
+
'RUBYLIB',
|
|
35
|
+
'PERL5LIB',
|
|
36
|
+
'GIT_EXEC_PATH',
|
|
37
|
+
'CDPATH',
|
|
38
|
+
// Shell behavior
|
|
39
|
+
'BASH_ENV',
|
|
40
|
+
'ENV',
|
|
41
|
+
'PROMPT_COMMAND',
|
|
42
|
+
'ZDOTDIR',
|
|
43
|
+
// Editor / terminal (可被利用执行命令)
|
|
44
|
+
'EDITOR',
|
|
45
|
+
'VISUAL',
|
|
46
|
+
'PAGER',
|
|
47
|
+
// SSH / Git(防止凭据泄露或命令注入)
|
|
48
|
+
'SSH_AUTH_SOCK',
|
|
49
|
+
'SSH_AGENT_PID',
|
|
50
|
+
'GIT_SSH',
|
|
51
|
+
'GIT_SSH_COMMAND',
|
|
52
|
+
'GIT_ASKPASS',
|
|
53
|
+
// Sensitive directories
|
|
54
|
+
'HOME',
|
|
55
|
+
'TMPDIR',
|
|
56
|
+
'TEMP',
|
|
57
|
+
'TMP',
|
|
58
|
+
// cli-claw 内部路径映射
|
|
59
|
+
'CLI_CLAW_WORKSPACE_GROUP',
|
|
60
|
+
'CLI_CLAW_WORKSPACE_GLOBAL',
|
|
61
|
+
'CLI_CLAW_WORKSPACE_IPC',
|
|
62
|
+
'CLAUDE_CONFIG_DIR',
|
|
63
|
+
]);
|
|
64
|
+
const MAX_CUSTOM_ENV_ENTRIES = 50;
|
|
65
|
+
// Fallback scopes for .credentials.json when stored credentials lack scopes.
|
|
66
|
+
// Differs from OAUTH_SCOPES in routes/config.ts (the authorize-flow request):
|
|
67
|
+
// authorize requests org:create_api_key; credential files need user:sessions:claude_code.
|
|
68
|
+
const DEFAULT_CREDENTIAL_SCOPES = [
|
|
69
|
+
'user:inference',
|
|
70
|
+
'user:profile',
|
|
71
|
+
'user:sessions:claude_code',
|
|
72
|
+
];
|
|
73
|
+
const DEFAULT_BALANCING_CONFIG = {
|
|
74
|
+
strategy: 'round-robin',
|
|
75
|
+
unhealthyThreshold: 3,
|
|
76
|
+
recoveryIntervalMs: 300_000,
|
|
77
|
+
};
|
|
78
|
+
const MAX_PROVIDERS = 20;
|
|
79
|
+
function normalizeSecret(input, fieldName) {
|
|
80
|
+
if (typeof input !== 'string') {
|
|
81
|
+
throw new Error(`Invalid field: ${fieldName}`);
|
|
82
|
+
}
|
|
83
|
+
// Strip ALL whitespace and non-ASCII characters — API keys/tokens are always ASCII;
|
|
84
|
+
// users often paste with accidental spaces, line breaks, or smart quotes (e.g. U+2019).
|
|
85
|
+
// eslint-disable-next-line no-control-regex
|
|
86
|
+
const value = input.replace(/\s+/g, '').replace(/[^\x00-\x7F]/g, '');
|
|
87
|
+
if (value.length > MAX_FIELD_LENGTH) {
|
|
88
|
+
throw new Error(`Field too long: ${fieldName}`);
|
|
89
|
+
}
|
|
90
|
+
return value;
|
|
91
|
+
}
|
|
92
|
+
function normalizeBaseUrl(input) {
|
|
93
|
+
if (typeof input !== 'string') {
|
|
94
|
+
throw new Error('Invalid field: anthropicBaseUrl');
|
|
95
|
+
}
|
|
96
|
+
const value = input.trim();
|
|
97
|
+
if (!value)
|
|
98
|
+
return '';
|
|
99
|
+
if (value.length > MAX_FIELD_LENGTH) {
|
|
100
|
+
throw new Error('Field too long: anthropicBaseUrl');
|
|
101
|
+
}
|
|
102
|
+
let parsed;
|
|
103
|
+
try {
|
|
104
|
+
parsed = new URL(value);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
throw new Error('Invalid field: anthropicBaseUrl');
|
|
108
|
+
}
|
|
109
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
110
|
+
throw new Error('Invalid field: anthropicBaseUrl');
|
|
111
|
+
}
|
|
112
|
+
return value;
|
|
113
|
+
}
|
|
114
|
+
function normalizeModel(input) {
|
|
115
|
+
if (typeof input !== 'string') {
|
|
116
|
+
throw new Error('Invalid field: anthropicModel');
|
|
117
|
+
}
|
|
118
|
+
const value = input.trim();
|
|
119
|
+
if (!value)
|
|
120
|
+
return '';
|
|
121
|
+
if (value.length > 128) {
|
|
122
|
+
throw new Error('Field too long: anthropicModel');
|
|
123
|
+
}
|
|
124
|
+
return value;
|
|
125
|
+
}
|
|
126
|
+
function normalizeFeishuAppId(input) {
|
|
127
|
+
if (typeof input !== 'string') {
|
|
128
|
+
throw new Error('Invalid field: appId');
|
|
129
|
+
}
|
|
130
|
+
const value = input.trim();
|
|
131
|
+
if (!value)
|
|
132
|
+
return '';
|
|
133
|
+
if (value.length > MAX_FIELD_LENGTH) {
|
|
134
|
+
throw new Error('Field too long: appId');
|
|
135
|
+
}
|
|
136
|
+
return value;
|
|
137
|
+
}
|
|
138
|
+
function normalizeTelegramProxyUrl(input) {
|
|
139
|
+
if (input === undefined || input === null)
|
|
140
|
+
return '';
|
|
141
|
+
if (typeof input !== 'string') {
|
|
142
|
+
throw new Error('Invalid field: proxyUrl');
|
|
143
|
+
}
|
|
144
|
+
const value = input.trim();
|
|
145
|
+
if (!value)
|
|
146
|
+
return '';
|
|
147
|
+
if (value.length > MAX_FIELD_LENGTH) {
|
|
148
|
+
throw new Error('Field too long: proxyUrl');
|
|
149
|
+
}
|
|
150
|
+
let parsed;
|
|
151
|
+
try {
|
|
152
|
+
parsed = new URL(value);
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
throw new Error('Invalid field: proxyUrl');
|
|
156
|
+
}
|
|
157
|
+
const protocol = parsed.protocol.toLowerCase();
|
|
158
|
+
if (!['http:', 'https:', 'socks:', 'socks5:'].includes(protocol)) {
|
|
159
|
+
throw new Error('Invalid field: proxyUrl');
|
|
160
|
+
}
|
|
161
|
+
return value;
|
|
162
|
+
}
|
|
163
|
+
function normalizeProfileName(input) {
|
|
164
|
+
if (typeof input !== 'string') {
|
|
165
|
+
throw new Error('Invalid field: name');
|
|
166
|
+
}
|
|
167
|
+
const value = input.trim();
|
|
168
|
+
if (!value) {
|
|
169
|
+
throw new Error('Invalid field: name');
|
|
170
|
+
}
|
|
171
|
+
if (value.length > 64) {
|
|
172
|
+
throw new Error('Field too long: name');
|
|
173
|
+
}
|
|
174
|
+
return value;
|
|
175
|
+
}
|
|
176
|
+
function sanitizeCustomEnvMap(input, options) {
|
|
177
|
+
const entries = Object.entries(input);
|
|
178
|
+
if (entries.length > MAX_CUSTOM_ENV_ENTRIES) {
|
|
179
|
+
throw new Error(`customEnv must have at most ${MAX_CUSTOM_ENV_ENTRIES} entries`);
|
|
180
|
+
}
|
|
181
|
+
const out = {};
|
|
182
|
+
for (const [key, rawValue] of entries) {
|
|
183
|
+
if (!ENV_KEY_RE.test(key)) {
|
|
184
|
+
throw new Error(`Invalid env key: ${key}`);
|
|
185
|
+
}
|
|
186
|
+
if (options?.skipReservedClaudeKeys && RESERVED_CLAUDE_ENV_KEYS.has(key)) {
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
out[key] = sanitizeEnvValue(typeof rawValue === 'string' ? rawValue : String(rawValue));
|
|
190
|
+
}
|
|
191
|
+
return out;
|
|
192
|
+
}
|
|
193
|
+
function normalizeConfig(input) {
|
|
194
|
+
return {
|
|
195
|
+
anthropicBaseUrl: normalizeBaseUrl(input.anthropicBaseUrl),
|
|
196
|
+
anthropicAuthToken: normalizeSecret(input.anthropicAuthToken, 'anthropicAuthToken'),
|
|
197
|
+
anthropicApiKey: normalizeSecret(input.anthropicApiKey, 'anthropicApiKey'),
|
|
198
|
+
claudeCodeOauthToken: normalizeSecret(input.claudeCodeOauthToken, 'claudeCodeOauthToken'),
|
|
199
|
+
claudeOAuthCredentials: input.claudeOAuthCredentials ?? null,
|
|
200
|
+
anthropicModel: normalizeModel(input.anthropicModel),
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
function buildConfig(input, updatedAt) {
|
|
204
|
+
return {
|
|
205
|
+
...normalizeConfig(input),
|
|
206
|
+
updatedAt,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
function getOrCreateEncryptionKey() {
|
|
210
|
+
fs.mkdirSync(CLAUDE_CONFIG_DIR, { recursive: true });
|
|
211
|
+
if (fs.existsSync(CLAUDE_CONFIG_KEY_FILE)) {
|
|
212
|
+
const raw = fs.readFileSync(CLAUDE_CONFIG_KEY_FILE, 'utf-8').trim();
|
|
213
|
+
const key = Buffer.from(raw, 'hex');
|
|
214
|
+
if (key.length === 32)
|
|
215
|
+
return key;
|
|
216
|
+
throw new Error('Invalid encryption key file');
|
|
217
|
+
}
|
|
218
|
+
const key = crypto.randomBytes(32);
|
|
219
|
+
fs.writeFileSync(CLAUDE_CONFIG_KEY_FILE, key.toString('hex') + '\n', {
|
|
220
|
+
encoding: 'utf-8',
|
|
221
|
+
mode: 0o600,
|
|
222
|
+
});
|
|
223
|
+
return key;
|
|
224
|
+
}
|
|
225
|
+
function encryptSecrets(payload) {
|
|
226
|
+
const key = getOrCreateEncryptionKey();
|
|
227
|
+
const iv = crypto.randomBytes(12);
|
|
228
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
|
229
|
+
const plaintext = Buffer.from(JSON.stringify(payload), 'utf-8');
|
|
230
|
+
const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
231
|
+
const tag = cipher.getAuthTag();
|
|
232
|
+
return {
|
|
233
|
+
iv: iv.toString('base64'),
|
|
234
|
+
tag: tag.toString('base64'),
|
|
235
|
+
data: encrypted.toString('base64'),
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
function decryptSecrets(secrets) {
|
|
239
|
+
const key = getOrCreateEncryptionKey();
|
|
240
|
+
const iv = Buffer.from(secrets.iv, 'base64');
|
|
241
|
+
const tag = Buffer.from(secrets.tag, 'base64');
|
|
242
|
+
const encrypted = Buffer.from(secrets.data, 'base64');
|
|
243
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
|
|
244
|
+
decipher.setAuthTag(tag);
|
|
245
|
+
const decrypted = Buffer.concat([
|
|
246
|
+
decipher.update(encrypted),
|
|
247
|
+
decipher.final(),
|
|
248
|
+
]).toString('utf-8');
|
|
249
|
+
const parsed = JSON.parse(decrypted);
|
|
250
|
+
const result = {
|
|
251
|
+
anthropicAuthToken: normalizeSecret(parsed.anthropicAuthToken ?? '', 'anthropicAuthToken'),
|
|
252
|
+
anthropicApiKey: normalizeSecret(parsed.anthropicApiKey ?? '', 'anthropicApiKey'),
|
|
253
|
+
claudeCodeOauthToken: normalizeSecret(parsed.claudeCodeOauthToken ?? '', 'claudeCodeOauthToken'),
|
|
254
|
+
};
|
|
255
|
+
// Restore OAuth credentials if present
|
|
256
|
+
if (parsed.claudeOAuthCredentials &&
|
|
257
|
+
typeof parsed.claudeOAuthCredentials === 'object') {
|
|
258
|
+
const creds = parsed.claudeOAuthCredentials;
|
|
259
|
+
if (typeof creds.accessToken === 'string' &&
|
|
260
|
+
typeof creds.refreshToken === 'string') {
|
|
261
|
+
result.claudeOAuthCredentials = {
|
|
262
|
+
accessToken: creds.accessToken,
|
|
263
|
+
refreshToken: creds.refreshToken,
|
|
264
|
+
expiresAt: typeof creds.expiresAt === 'number' ? creds.expiresAt : 0,
|
|
265
|
+
scopes: Array.isArray(creds.scopes) ? creds.scopes : [],
|
|
266
|
+
...(typeof creds.subscriptionType === 'string'
|
|
267
|
+
? { subscriptionType: creds.subscriptionType }
|
|
268
|
+
: {}),
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return result;
|
|
273
|
+
}
|
|
274
|
+
function encryptChannelSecret(payload) {
|
|
275
|
+
const key = getOrCreateEncryptionKey();
|
|
276
|
+
const iv = crypto.randomBytes(12);
|
|
277
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
|
278
|
+
const plaintext = Buffer.from(JSON.stringify(payload), 'utf-8');
|
|
279
|
+
const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
280
|
+
const tag = cipher.getAuthTag();
|
|
281
|
+
return {
|
|
282
|
+
iv: iv.toString('base64'),
|
|
283
|
+
tag: tag.toString('base64'),
|
|
284
|
+
data: encrypted.toString('base64'),
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
function decryptChannelSecret(secrets) {
|
|
288
|
+
const key = getOrCreateEncryptionKey();
|
|
289
|
+
const iv = Buffer.from(secrets.iv, 'base64');
|
|
290
|
+
const tag = Buffer.from(secrets.tag, 'base64');
|
|
291
|
+
const encrypted = Buffer.from(secrets.data, 'base64');
|
|
292
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
|
|
293
|
+
decipher.setAuthTag(tag);
|
|
294
|
+
const decrypted = Buffer.concat([
|
|
295
|
+
decipher.update(encrypted),
|
|
296
|
+
decipher.final(),
|
|
297
|
+
]).toString('utf-8');
|
|
298
|
+
return JSON.parse(decrypted);
|
|
299
|
+
}
|
|
300
|
+
function toStoredProviderV4(provider) {
|
|
301
|
+
const secrets = {
|
|
302
|
+
anthropicAuthToken: provider.anthropicAuthToken || '',
|
|
303
|
+
anthropicApiKey: provider.anthropicApiKey || '',
|
|
304
|
+
claudeCodeOauthToken: provider.claudeCodeOauthToken || '',
|
|
305
|
+
claudeOAuthCredentials: provider.claudeOAuthCredentials ?? null,
|
|
306
|
+
};
|
|
307
|
+
const sanitizedEnv = sanitizeCustomEnvMap(provider.customEnv || {}, {
|
|
308
|
+
skipReservedClaudeKeys: true,
|
|
309
|
+
});
|
|
310
|
+
return {
|
|
311
|
+
id: provider.id,
|
|
312
|
+
name: provider.name,
|
|
313
|
+
type: provider.type,
|
|
314
|
+
enabled: provider.enabled,
|
|
315
|
+
weight: Math.max(1, Math.min(100, provider.weight || 1)),
|
|
316
|
+
anthropicBaseUrl: provider.anthropicBaseUrl || '',
|
|
317
|
+
anthropicModel: provider.anthropicModel || '',
|
|
318
|
+
secrets: encryptSecrets(secrets),
|
|
319
|
+
...(Object.keys(sanitizedEnv).length > 0
|
|
320
|
+
? { customEnv: sanitizedEnv }
|
|
321
|
+
: {}),
|
|
322
|
+
updatedAt: provider.updatedAt || new Date().toISOString(),
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
function fromStoredProviderV4(stored) {
|
|
326
|
+
const secrets = decryptSecrets(stored.secrets);
|
|
327
|
+
return {
|
|
328
|
+
id: stored.id,
|
|
329
|
+
name: stored.name,
|
|
330
|
+
type: stored.type,
|
|
331
|
+
enabled: stored.enabled,
|
|
332
|
+
weight: Math.max(1, Math.min(100, stored.weight || 1)),
|
|
333
|
+
anthropicBaseUrl: stored.anthropicBaseUrl || '',
|
|
334
|
+
anthropicAuthToken: secrets.anthropicAuthToken || '',
|
|
335
|
+
anthropicModel: stored.anthropicModel || '',
|
|
336
|
+
anthropicApiKey: secrets.anthropicApiKey || '',
|
|
337
|
+
claudeCodeOauthToken: secrets.claudeCodeOauthToken || '',
|
|
338
|
+
claudeOAuthCredentials: secrets.claudeOAuthCredentials ?? null,
|
|
339
|
+
customEnv: sanitizeCustomEnvMap(stored.customEnv || {}, {
|
|
340
|
+
skipReservedClaudeKeys: true,
|
|
341
|
+
}),
|
|
342
|
+
updatedAt: stored.updatedAt || '',
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
function readStoredStateV4() {
|
|
346
|
+
if (!fs.existsSync(CLAUDE_CONFIG_FILE))
|
|
347
|
+
return null;
|
|
348
|
+
try {
|
|
349
|
+
const content = fs.readFileSync(CLAUDE_CONFIG_FILE, 'utf-8');
|
|
350
|
+
const parsed = JSON.parse(content);
|
|
351
|
+
if (parsed.version === 4) {
|
|
352
|
+
const v4 = parsed;
|
|
353
|
+
return {
|
|
354
|
+
providers: v4.providers.map(fromStoredProviderV4),
|
|
355
|
+
balancing: {
|
|
356
|
+
strategy: v4.balancing?.strategy || DEFAULT_BALANCING_CONFIG.strategy,
|
|
357
|
+
unhealthyThreshold: v4.balancing?.unhealthyThreshold ??
|
|
358
|
+
DEFAULT_BALANCING_CONFIG.unhealthyThreshold,
|
|
359
|
+
recoveryIntervalMs: v4.balancing?.recoveryIntervalMs ??
|
|
360
|
+
DEFAULT_BALANCING_CONFIG.recoveryIntervalMs,
|
|
361
|
+
},
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
logger.warn({
|
|
365
|
+
file: CLAUDE_CONFIG_FILE,
|
|
366
|
+
version: typeof parsed.version === 'number' ? parsed.version : 'unknown',
|
|
367
|
+
}, 'Ignoring unsupported Claude provider config version');
|
|
368
|
+
return null;
|
|
369
|
+
}
|
|
370
|
+
catch (err) {
|
|
371
|
+
logger.error({ err, file: CLAUDE_CONFIG_FILE }, 'Failed to read Claude provider config V4');
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
function writeStoredStateV4(providers, balancing) {
|
|
376
|
+
const payload = {
|
|
377
|
+
version: 4,
|
|
378
|
+
providers: providers.map(toStoredProviderV4),
|
|
379
|
+
balancing,
|
|
380
|
+
updatedAt: new Date().toISOString(),
|
|
381
|
+
};
|
|
382
|
+
fs.mkdirSync(CLAUDE_CONFIG_DIR, { recursive: true });
|
|
383
|
+
const tmp = `${CLAUDE_CONFIG_FILE}.tmp`;
|
|
384
|
+
fs.writeFileSync(tmp, JSON.stringify(payload, null, 2) + '\n', 'utf-8');
|
|
385
|
+
fs.renameSync(tmp, CLAUDE_CONFIG_FILE);
|
|
386
|
+
}
|
|
387
|
+
export function getProviders() {
|
|
388
|
+
const state = readStoredStateV4();
|
|
389
|
+
return state?.providers ?? [];
|
|
390
|
+
}
|
|
391
|
+
export function getEnabledProviders() {
|
|
392
|
+
return getProviders().filter((p) => p.enabled);
|
|
393
|
+
}
|
|
394
|
+
export function getBalancingConfig() {
|
|
395
|
+
const state = readStoredStateV4();
|
|
396
|
+
return state?.balancing ?? { ...DEFAULT_BALANCING_CONFIG };
|
|
397
|
+
}
|
|
398
|
+
export function saveBalancingConfig(config) {
|
|
399
|
+
const state = readStoredStateV4() || {
|
|
400
|
+
providers: [],
|
|
401
|
+
balancing: { ...DEFAULT_BALANCING_CONFIG },
|
|
402
|
+
};
|
|
403
|
+
const merged = {
|
|
404
|
+
...state.balancing,
|
|
405
|
+
...config,
|
|
406
|
+
};
|
|
407
|
+
writeStoredStateV4(state.providers, merged);
|
|
408
|
+
return merged;
|
|
409
|
+
}
|
|
410
|
+
export function createProvider(input) {
|
|
411
|
+
const state = readStoredStateV4() || {
|
|
412
|
+
providers: [],
|
|
413
|
+
balancing: { ...DEFAULT_BALANCING_CONFIG },
|
|
414
|
+
};
|
|
415
|
+
if (state.providers.length >= MAX_PROVIDERS) {
|
|
416
|
+
throw new Error(`最多只能创建 ${MAX_PROVIDERS} 个供应商`);
|
|
417
|
+
}
|
|
418
|
+
const now = new Date().toISOString();
|
|
419
|
+
const provider = {
|
|
420
|
+
id: crypto.randomBytes(8).toString('hex'),
|
|
421
|
+
name: normalizeProfileName(input.name),
|
|
422
|
+
type: input.type,
|
|
423
|
+
enabled: input.enabled ?? state.providers.length === 0,
|
|
424
|
+
weight: Math.max(1, Math.min(100, input.weight ?? 1)),
|
|
425
|
+
anthropicBaseUrl: input.anthropicBaseUrl
|
|
426
|
+
? normalizeBaseUrl(input.anthropicBaseUrl)
|
|
427
|
+
: '',
|
|
428
|
+
anthropicAuthToken: input.anthropicAuthToken
|
|
429
|
+
? normalizeSecret(input.anthropicAuthToken, 'anthropicAuthToken')
|
|
430
|
+
: '',
|
|
431
|
+
anthropicModel: input.anthropicModel
|
|
432
|
+
? normalizeModel(input.anthropicModel)
|
|
433
|
+
: '',
|
|
434
|
+
anthropicApiKey: input.anthropicApiKey
|
|
435
|
+
? normalizeSecret(input.anthropicApiKey, 'anthropicApiKey')
|
|
436
|
+
: '',
|
|
437
|
+
claudeCodeOauthToken: input.claudeCodeOauthToken
|
|
438
|
+
? normalizeSecret(input.claudeCodeOauthToken, 'claudeCodeOauthToken')
|
|
439
|
+
: '',
|
|
440
|
+
claudeOAuthCredentials: input.claudeOAuthCredentials ?? null,
|
|
441
|
+
customEnv: sanitizeCustomEnvMap(input.customEnv || {}, {
|
|
442
|
+
skipReservedClaudeKeys: true,
|
|
443
|
+
}),
|
|
444
|
+
updatedAt: now,
|
|
445
|
+
};
|
|
446
|
+
state.providers.push(provider);
|
|
447
|
+
writeStoredStateV4(state.providers, state.balancing);
|
|
448
|
+
return provider;
|
|
449
|
+
}
|
|
450
|
+
export function updateProvider(id, patch) {
|
|
451
|
+
const state = readStoredStateV4();
|
|
452
|
+
if (!state)
|
|
453
|
+
throw new Error('Claude 配置不存在');
|
|
454
|
+
const idx = state.providers.findIndex((p) => p.id === id);
|
|
455
|
+
if (idx < 0)
|
|
456
|
+
throw new Error('未找到指定供应商');
|
|
457
|
+
const current = state.providers[idx];
|
|
458
|
+
const updated = {
|
|
459
|
+
...current,
|
|
460
|
+
...(patch.name !== undefined
|
|
461
|
+
? { name: normalizeProfileName(patch.name) }
|
|
462
|
+
: {}),
|
|
463
|
+
...(patch.anthropicBaseUrl !== undefined
|
|
464
|
+
? { anthropicBaseUrl: normalizeBaseUrl(patch.anthropicBaseUrl) }
|
|
465
|
+
: {}),
|
|
466
|
+
...(patch.anthropicModel !== undefined
|
|
467
|
+
? { anthropicModel: normalizeModel(patch.anthropicModel) }
|
|
468
|
+
: {}),
|
|
469
|
+
...(patch.customEnv !== undefined
|
|
470
|
+
? {
|
|
471
|
+
customEnv: sanitizeCustomEnvMap(patch.customEnv, {
|
|
472
|
+
skipReservedClaudeKeys: true,
|
|
473
|
+
}),
|
|
474
|
+
}
|
|
475
|
+
: {}),
|
|
476
|
+
...(patch.weight !== undefined
|
|
477
|
+
? { weight: Math.max(1, Math.min(100, patch.weight)) }
|
|
478
|
+
: {}),
|
|
479
|
+
updatedAt: new Date().toISOString(),
|
|
480
|
+
};
|
|
481
|
+
state.providers[idx] = updated;
|
|
482
|
+
writeStoredStateV4(state.providers, state.balancing);
|
|
483
|
+
return updated;
|
|
484
|
+
}
|
|
485
|
+
export function updateProviderSecrets(id, secrets) {
|
|
486
|
+
const state = readStoredStateV4();
|
|
487
|
+
if (!state)
|
|
488
|
+
throw new Error('Claude 配置不存在');
|
|
489
|
+
const idx = state.providers.findIndex((p) => p.id === id);
|
|
490
|
+
if (idx < 0)
|
|
491
|
+
throw new Error('未找到指定供应商');
|
|
492
|
+
const current = state.providers[idx];
|
|
493
|
+
const updated = { ...current, updatedAt: new Date().toISOString() };
|
|
494
|
+
if (typeof secrets.anthropicAuthToken === 'string') {
|
|
495
|
+
updated.anthropicAuthToken = normalizeSecret(secrets.anthropicAuthToken, 'anthropicAuthToken');
|
|
496
|
+
}
|
|
497
|
+
else if (secrets.clearAnthropicAuthToken) {
|
|
498
|
+
updated.anthropicAuthToken = '';
|
|
499
|
+
}
|
|
500
|
+
if (typeof secrets.anthropicApiKey === 'string') {
|
|
501
|
+
updated.anthropicApiKey = normalizeSecret(secrets.anthropicApiKey, 'anthropicApiKey');
|
|
502
|
+
}
|
|
503
|
+
else if (secrets.clearAnthropicApiKey) {
|
|
504
|
+
updated.anthropicApiKey = '';
|
|
505
|
+
}
|
|
506
|
+
if (typeof secrets.claudeCodeOauthToken === 'string') {
|
|
507
|
+
updated.claudeCodeOauthToken = normalizeSecret(secrets.claudeCodeOauthToken, 'claudeCodeOauthToken');
|
|
508
|
+
}
|
|
509
|
+
else if (secrets.clearClaudeCodeOauthToken) {
|
|
510
|
+
updated.claudeCodeOauthToken = '';
|
|
511
|
+
}
|
|
512
|
+
if (secrets.claudeOAuthCredentials) {
|
|
513
|
+
updated.claudeOAuthCredentials = secrets.claudeOAuthCredentials;
|
|
514
|
+
// When full OAuth creds set, clear legacy single token
|
|
515
|
+
updated.claudeCodeOauthToken = '';
|
|
516
|
+
}
|
|
517
|
+
else if (secrets.clearClaudeOAuthCredentials) {
|
|
518
|
+
updated.claudeOAuthCredentials = null;
|
|
519
|
+
}
|
|
520
|
+
state.providers[idx] = updated;
|
|
521
|
+
writeStoredStateV4(state.providers, state.balancing);
|
|
522
|
+
return updated;
|
|
523
|
+
}
|
|
524
|
+
export function toggleProvider(id) {
|
|
525
|
+
const state = readStoredStateV4();
|
|
526
|
+
if (!state)
|
|
527
|
+
throw new Error('Claude 配置不存在');
|
|
528
|
+
const idx = state.providers.findIndex((p) => p.id === id);
|
|
529
|
+
if (idx < 0)
|
|
530
|
+
throw new Error('未找到指定供应商');
|
|
531
|
+
const provider = state.providers[idx];
|
|
532
|
+
const newEnabled = !provider.enabled;
|
|
533
|
+
// Prevent disabling the last enabled provider
|
|
534
|
+
if (!newEnabled && state.providers.filter((p) => p.enabled).length <= 1) {
|
|
535
|
+
throw new Error('至少需要保留一个启用的供应商');
|
|
536
|
+
}
|
|
537
|
+
state.providers[idx] = {
|
|
538
|
+
...provider,
|
|
539
|
+
enabled: newEnabled,
|
|
540
|
+
updatedAt: new Date().toISOString(),
|
|
541
|
+
};
|
|
542
|
+
writeStoredStateV4(state.providers, state.balancing);
|
|
543
|
+
return state.providers[idx];
|
|
544
|
+
}
|
|
545
|
+
export function deleteProvider(id) {
|
|
546
|
+
const state = readStoredStateV4();
|
|
547
|
+
if (!state)
|
|
548
|
+
throw new Error('Claude 配置不存在');
|
|
549
|
+
const idx = state.providers.findIndex((p) => p.id === id);
|
|
550
|
+
if (idx < 0)
|
|
551
|
+
throw new Error('未找到指定供应商');
|
|
552
|
+
if (state.providers.length <= 1) {
|
|
553
|
+
throw new Error('至少需要保留一个供应商');
|
|
554
|
+
}
|
|
555
|
+
const wasEnabled = state.providers[idx].enabled;
|
|
556
|
+
state.providers.splice(idx, 1);
|
|
557
|
+
// If deleted provider was the only enabled one, enable the first remaining
|
|
558
|
+
if (wasEnabled && !state.providers.some((p) => p.enabled)) {
|
|
559
|
+
state.providers[0].enabled = true;
|
|
560
|
+
}
|
|
561
|
+
writeStoredStateV4(state.providers, state.balancing);
|
|
562
|
+
}
|
|
563
|
+
/** Convert a UnifiedProvider to the flat ClaudeProviderConfig used by container runner */
|
|
564
|
+
export function providerToConfig(provider) {
|
|
565
|
+
return {
|
|
566
|
+
anthropicBaseUrl: provider.anthropicBaseUrl,
|
|
567
|
+
anthropicAuthToken: provider.anthropicAuthToken,
|
|
568
|
+
anthropicApiKey: provider.anthropicApiKey,
|
|
569
|
+
claudeCodeOauthToken: provider.claudeCodeOauthToken,
|
|
570
|
+
claudeOAuthCredentials: provider.claudeOAuthCredentials,
|
|
571
|
+
anthropicModel: provider.anthropicModel,
|
|
572
|
+
updatedAt: provider.updatedAt,
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
/** Convert UnifiedProvider to public (masked) representation */
|
|
576
|
+
export function toPublicProvider(provider) {
|
|
577
|
+
return {
|
|
578
|
+
id: provider.id,
|
|
579
|
+
name: provider.name,
|
|
580
|
+
type: provider.type,
|
|
581
|
+
enabled: provider.enabled,
|
|
582
|
+
weight: provider.weight,
|
|
583
|
+
anthropicBaseUrl: provider.anthropicBaseUrl,
|
|
584
|
+
anthropicModel: provider.anthropicModel,
|
|
585
|
+
hasAnthropicAuthToken: !!provider.anthropicAuthToken,
|
|
586
|
+
anthropicAuthTokenMasked: maskSecret(provider.anthropicAuthToken),
|
|
587
|
+
hasAnthropicApiKey: !!provider.anthropicApiKey,
|
|
588
|
+
anthropicApiKeyMasked: maskSecret(provider.anthropicApiKey),
|
|
589
|
+
hasClaudeCodeOauthToken: !!provider.claudeCodeOauthToken,
|
|
590
|
+
claudeCodeOauthTokenMasked: maskSecret(provider.claudeCodeOauthToken),
|
|
591
|
+
hasClaudeOAuthCredentials: !!provider.claudeOAuthCredentials,
|
|
592
|
+
claudeOAuthCredentialsExpiresAt: provider.claudeOAuthCredentials?.expiresAt ?? null,
|
|
593
|
+
claudeOAuthCredentialsAccessTokenMasked: provider.claudeOAuthCredentials
|
|
594
|
+
? maskSecret(provider.claudeOAuthCredentials.accessToken)
|
|
595
|
+
: null,
|
|
596
|
+
customEnv: provider.customEnv || {},
|
|
597
|
+
updatedAt: provider.updatedAt,
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* Resolve a provider by ID to { config, customEnv } in a single disk read.
|
|
602
|
+
* Used by container-runner for pool-selected providers.
|
|
603
|
+
*/
|
|
604
|
+
export function resolveProviderById(providerId) {
|
|
605
|
+
const state = readStoredStateV4();
|
|
606
|
+
if (!state)
|
|
607
|
+
return { config: defaultsFromEnv(), customEnv: {} };
|
|
608
|
+
const provider = state.providers.find((p) => p.id === providerId);
|
|
609
|
+
if (!provider) {
|
|
610
|
+
logger.warn({ providerId }, 'resolveProviderById: provider not found, falling back to first enabled');
|
|
611
|
+
const fallback = state.providers.find((p) => p.enabled) || state.providers[0];
|
|
612
|
+
if (!fallback)
|
|
613
|
+
return { config: defaultsFromEnv(), customEnv: {} };
|
|
614
|
+
return {
|
|
615
|
+
config: providerToConfig(fallback),
|
|
616
|
+
customEnv: fallback.customEnv,
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
return {
|
|
620
|
+
config: providerToConfig(provider),
|
|
621
|
+
customEnv: provider.customEnv,
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
function defaultsFromEnv() {
|
|
625
|
+
const raw = {
|
|
626
|
+
anthropicBaseUrl: process.env.ANTHROPIC_BASE_URL || '',
|
|
627
|
+
anthropicAuthToken: process.env.ANTHROPIC_AUTH_TOKEN || '',
|
|
628
|
+
anthropicApiKey: process.env.ANTHROPIC_API_KEY || '',
|
|
629
|
+
claudeCodeOauthToken: process.env.CLAUDE_CODE_OAUTH_TOKEN || '',
|
|
630
|
+
claudeOAuthCredentials: null,
|
|
631
|
+
anthropicModel: process.env.ANTHROPIC_MODEL || '',
|
|
632
|
+
};
|
|
633
|
+
try {
|
|
634
|
+
return buildConfig(raw, null);
|
|
635
|
+
}
|
|
636
|
+
catch {
|
|
637
|
+
return {
|
|
638
|
+
anthropicBaseUrl: '',
|
|
639
|
+
anthropicAuthToken: raw.anthropicAuthToken.trim(),
|
|
640
|
+
anthropicApiKey: raw.anthropicApiKey.trim(),
|
|
641
|
+
claudeCodeOauthToken: raw.claudeCodeOauthToken.trim(),
|
|
642
|
+
claudeOAuthCredentials: null,
|
|
643
|
+
anthropicModel: raw.anthropicModel.trim(),
|
|
644
|
+
updatedAt: null,
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
function readStoredFeishuConfig() {
|
|
649
|
+
if (!fs.existsSync(FEISHU_CONFIG_FILE))
|
|
650
|
+
return null;
|
|
651
|
+
const content = fs.readFileSync(FEISHU_CONFIG_FILE, 'utf-8');
|
|
652
|
+
const parsed = JSON.parse(content);
|
|
653
|
+
if (parsed.version !== 1)
|
|
654
|
+
return null;
|
|
655
|
+
const stored = parsed;
|
|
656
|
+
const secret = decryptChannelSecret(stored.secret);
|
|
657
|
+
return {
|
|
658
|
+
appId: normalizeFeishuAppId(stored.appId ?? ''),
|
|
659
|
+
appSecret: secret.appSecret,
|
|
660
|
+
enabled: stored.enabled,
|
|
661
|
+
updatedAt: stored.updatedAt || null,
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
function defaultsFeishuFromEnv() {
|
|
665
|
+
const raw = {
|
|
666
|
+
appId: process.env.FEISHU_APP_ID || '',
|
|
667
|
+
appSecret: process.env.FEISHU_APP_SECRET || '',
|
|
668
|
+
};
|
|
669
|
+
return {
|
|
670
|
+
appId: raw.appId.trim(),
|
|
671
|
+
appSecret: raw.appSecret.trim(),
|
|
672
|
+
updatedAt: null,
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
export function getFeishuProviderConfigWithSource() {
|
|
676
|
+
try {
|
|
677
|
+
const stored = readStoredFeishuConfig();
|
|
678
|
+
if (stored)
|
|
679
|
+
return { config: stored, source: 'runtime' };
|
|
680
|
+
}
|
|
681
|
+
catch (err) {
|
|
682
|
+
logger.warn({ err }, 'Failed to read runtime Feishu config, falling back to env');
|
|
683
|
+
}
|
|
684
|
+
const fromEnv = defaultsFeishuFromEnv();
|
|
685
|
+
if (fromEnv.appId || fromEnv.appSecret) {
|
|
686
|
+
return { config: fromEnv, source: 'env' };
|
|
687
|
+
}
|
|
688
|
+
return { config: fromEnv, source: 'none' };
|
|
689
|
+
}
|
|
690
|
+
export function getFeishuProviderConfig() {
|
|
691
|
+
return getFeishuProviderConfigWithSource().config;
|
|
692
|
+
}
|
|
693
|
+
export function saveFeishuProviderConfig(next) {
|
|
694
|
+
const normalized = {
|
|
695
|
+
appId: normalizeFeishuAppId(next.appId),
|
|
696
|
+
appSecret: normalizeSecret(next.appSecret, 'appSecret'),
|
|
697
|
+
enabled: next.enabled,
|
|
698
|
+
updatedAt: new Date().toISOString(),
|
|
699
|
+
};
|
|
700
|
+
const payload = {
|
|
701
|
+
version: 1,
|
|
702
|
+
appId: normalized.appId,
|
|
703
|
+
enabled: normalized.enabled,
|
|
704
|
+
updatedAt: normalized.updatedAt || new Date().toISOString(),
|
|
705
|
+
secret: encryptChannelSecret({
|
|
706
|
+
appSecret: normalized.appSecret,
|
|
707
|
+
}),
|
|
708
|
+
};
|
|
709
|
+
fs.mkdirSync(CLAUDE_CONFIG_DIR, { recursive: true });
|
|
710
|
+
const tmp = `${FEISHU_CONFIG_FILE}.tmp`;
|
|
711
|
+
fs.writeFileSync(tmp, JSON.stringify(payload, null, 2) + '\n', 'utf-8');
|
|
712
|
+
fs.renameSync(tmp, FEISHU_CONFIG_FILE);
|
|
713
|
+
return normalized;
|
|
714
|
+
}
|
|
715
|
+
export function toPublicFeishuProviderConfig(config, source) {
|
|
716
|
+
return {
|
|
717
|
+
appId: config.appId,
|
|
718
|
+
hasAppSecret: !!config.appSecret,
|
|
719
|
+
appSecretMasked: maskSecret(config.appSecret),
|
|
720
|
+
enabled: config.enabled !== false,
|
|
721
|
+
updatedAt: config.updatedAt,
|
|
722
|
+
source,
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
// ========== Telegram Provider Config ==========
|
|
726
|
+
function readStoredTelegramConfig() {
|
|
727
|
+
if (!fs.existsSync(TELEGRAM_CONFIG_FILE))
|
|
728
|
+
return null;
|
|
729
|
+
const content = fs.readFileSync(TELEGRAM_CONFIG_FILE, 'utf-8');
|
|
730
|
+
const parsed = JSON.parse(content);
|
|
731
|
+
if (parsed.version !== 1)
|
|
732
|
+
return null;
|
|
733
|
+
const stored = parsed;
|
|
734
|
+
const secret = decryptChannelSecret(stored.secret);
|
|
735
|
+
return {
|
|
736
|
+
botToken: secret.botToken,
|
|
737
|
+
proxyUrl: normalizeTelegramProxyUrl(stored.proxyUrl ?? ''),
|
|
738
|
+
enabled: stored.enabled,
|
|
739
|
+
updatedAt: stored.updatedAt || null,
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
function defaultsTelegramFromEnv() {
|
|
743
|
+
const raw = {
|
|
744
|
+
botToken: process.env.TELEGRAM_BOT_TOKEN || '',
|
|
745
|
+
proxyUrl: process.env.TELEGRAM_PROXY_URL || '',
|
|
746
|
+
};
|
|
747
|
+
return {
|
|
748
|
+
botToken: raw.botToken.trim(),
|
|
749
|
+
proxyUrl: normalizeTelegramProxyUrl(raw.proxyUrl),
|
|
750
|
+
updatedAt: null,
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
export function getTelegramProviderConfigWithSource() {
|
|
754
|
+
try {
|
|
755
|
+
const stored = readStoredTelegramConfig();
|
|
756
|
+
if (stored)
|
|
757
|
+
return { config: stored, source: 'runtime' };
|
|
758
|
+
}
|
|
759
|
+
catch (err) {
|
|
760
|
+
logger.warn({ err }, 'Failed to read runtime Telegram config, falling back to env');
|
|
761
|
+
}
|
|
762
|
+
const fromEnv = defaultsTelegramFromEnv();
|
|
763
|
+
if (fromEnv.botToken) {
|
|
764
|
+
return { config: fromEnv, source: 'env' };
|
|
765
|
+
}
|
|
766
|
+
return { config: fromEnv, source: 'none' };
|
|
767
|
+
}
|
|
768
|
+
export function getTelegramProviderConfig() {
|
|
769
|
+
return getTelegramProviderConfigWithSource().config;
|
|
770
|
+
}
|
|
771
|
+
export function saveTelegramProviderConfig(next) {
|
|
772
|
+
const normalized = {
|
|
773
|
+
botToken: normalizeSecret(next.botToken, 'botToken'),
|
|
774
|
+
proxyUrl: normalizeTelegramProxyUrl(next.proxyUrl),
|
|
775
|
+
enabled: next.enabled,
|
|
776
|
+
updatedAt: new Date().toISOString(),
|
|
777
|
+
};
|
|
778
|
+
const payload = {
|
|
779
|
+
version: 1,
|
|
780
|
+
proxyUrl: normalized.proxyUrl,
|
|
781
|
+
enabled: normalized.enabled,
|
|
782
|
+
updatedAt: normalized.updatedAt || new Date().toISOString(),
|
|
783
|
+
secret: encryptChannelSecret({
|
|
784
|
+
botToken: normalized.botToken,
|
|
785
|
+
}),
|
|
786
|
+
};
|
|
787
|
+
fs.mkdirSync(CLAUDE_CONFIG_DIR, { recursive: true });
|
|
788
|
+
const tmp = `${TELEGRAM_CONFIG_FILE}.tmp`;
|
|
789
|
+
fs.writeFileSync(tmp, JSON.stringify(payload, null, 2) + '\n', 'utf-8');
|
|
790
|
+
fs.renameSync(tmp, TELEGRAM_CONFIG_FILE);
|
|
791
|
+
return normalized;
|
|
792
|
+
}
|
|
793
|
+
export function toPublicTelegramProviderConfig(config, source) {
|
|
794
|
+
return {
|
|
795
|
+
hasBotToken: !!config.botToken,
|
|
796
|
+
botTokenMasked: maskSecret(config.botToken),
|
|
797
|
+
proxyUrl: config.proxyUrl ?? '',
|
|
798
|
+
enabled: config.enabled !== false,
|
|
799
|
+
updatedAt: config.updatedAt,
|
|
800
|
+
source,
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
function maskSecret(value) {
|
|
804
|
+
if (!value)
|
|
805
|
+
return null;
|
|
806
|
+
if (value.length <= 8)
|
|
807
|
+
return `${'*'.repeat(Math.max(value.length - 2, 1))}${value.slice(-2)}`;
|
|
808
|
+
return `${value.slice(0, 3)}${'*'.repeat(Math.max(value.length - 7, 4))}${value.slice(-4)}`;
|
|
809
|
+
}
|
|
810
|
+
export function toPublicClaudeProviderConfig(config) {
|
|
811
|
+
return {
|
|
812
|
+
anthropicBaseUrl: config.anthropicBaseUrl,
|
|
813
|
+
anthropicModel: config.anthropicModel,
|
|
814
|
+
updatedAt: config.updatedAt,
|
|
815
|
+
hasAnthropicAuthToken: !!config.anthropicAuthToken,
|
|
816
|
+
hasAnthropicApiKey: !!config.anthropicApiKey,
|
|
817
|
+
hasClaudeCodeOauthToken: !!config.claudeCodeOauthToken,
|
|
818
|
+
anthropicAuthTokenMasked: maskSecret(config.anthropicAuthToken),
|
|
819
|
+
anthropicApiKeyMasked: maskSecret(config.anthropicApiKey),
|
|
820
|
+
claudeCodeOauthTokenMasked: maskSecret(config.claudeCodeOauthToken),
|
|
821
|
+
hasClaudeOAuthCredentials: !!config.claudeOAuthCredentials,
|
|
822
|
+
claudeOAuthCredentialsExpiresAt: config.claudeOAuthCredentials?.expiresAt ?? null,
|
|
823
|
+
claudeOAuthCredentialsAccessTokenMasked: config.claudeOAuthCredentials
|
|
824
|
+
? maskSecret(config.claudeOAuthCredentials.accessToken)
|
|
825
|
+
: null,
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
export function validateClaudeProviderConfig(config) {
|
|
829
|
+
const errors = [];
|
|
830
|
+
if (config.anthropicAuthToken && !config.anthropicBaseUrl) {
|
|
831
|
+
errors.push('使用 ANTHROPIC_AUTH_TOKEN 时必须配置 ANTHROPIC_BASE_URL');
|
|
832
|
+
}
|
|
833
|
+
if (config.anthropicBaseUrl) {
|
|
834
|
+
try {
|
|
835
|
+
const parsed = new URL(config.anthropicBaseUrl);
|
|
836
|
+
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
|
837
|
+
errors.push('ANTHROPIC_BASE_URL 必须是 http 或 https 地址');
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
catch {
|
|
841
|
+
errors.push('ANTHROPIC_BASE_URL 格式不正确');
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
return errors;
|
|
845
|
+
}
|
|
846
|
+
export function getClaudeProviderConfig() {
|
|
847
|
+
try {
|
|
848
|
+
const state = readStoredStateV4();
|
|
849
|
+
if (state) {
|
|
850
|
+
const enabled = state.providers.find((p) => p.enabled) || state.providers[0];
|
|
851
|
+
if (enabled)
|
|
852
|
+
return providerToConfig(enabled);
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
catch {
|
|
856
|
+
// ignore corrupted file and use env fallback
|
|
857
|
+
}
|
|
858
|
+
return defaultsFromEnv();
|
|
859
|
+
}
|
|
860
|
+
/** Strip control characters from a value before writing to env file (defense-in-depth) */
|
|
861
|
+
function sanitizeEnvValue(value) {
|
|
862
|
+
return value.replace(/[\r\n\0]/g, '');
|
|
863
|
+
}
|
|
864
|
+
/** Convert KEY=value lines to shell-safe format by single-quoting values.
|
|
865
|
+
* Used when writing env files that are `source`d by bash. */
|
|
866
|
+
export function shellQuoteEnvLines(lines) {
|
|
867
|
+
return lines.map((line) => {
|
|
868
|
+
const eqIdx = line.indexOf('=');
|
|
869
|
+
if (eqIdx <= 0)
|
|
870
|
+
return line;
|
|
871
|
+
const key = line.slice(0, eqIdx);
|
|
872
|
+
const value = line.slice(eqIdx + 1);
|
|
873
|
+
// Escape embedded single quotes: ' → '\''
|
|
874
|
+
const quoted = "'" + value.replace(/'/g, "'\\''") + "'";
|
|
875
|
+
return `${key}=${quoted}`;
|
|
876
|
+
});
|
|
877
|
+
}
|
|
878
|
+
export function buildClaudeEnvLines(config, profileCustomEnv) {
|
|
879
|
+
const lines = [];
|
|
880
|
+
// When full OAuth credentials exist, authentication is handled by .credentials.json file.
|
|
881
|
+
// Only fall back to CLAUDE_CODE_OAUTH_TOKEN env var for legacy single-token mode.
|
|
882
|
+
if (!config.claudeOAuthCredentials && config.claudeCodeOauthToken) {
|
|
883
|
+
lines.push(`CLAUDE_CODE_OAUTH_TOKEN=${sanitizeEnvValue(config.claudeCodeOauthToken)}`);
|
|
884
|
+
}
|
|
885
|
+
if (config.anthropicApiKey) {
|
|
886
|
+
lines.push(`ANTHROPIC_API_KEY=${sanitizeEnvValue(config.anthropicApiKey)}`);
|
|
887
|
+
}
|
|
888
|
+
if (config.anthropicBaseUrl) {
|
|
889
|
+
lines.push(`ANTHROPIC_BASE_URL=${sanitizeEnvValue(config.anthropicBaseUrl)}`);
|
|
890
|
+
}
|
|
891
|
+
if (config.anthropicAuthToken) {
|
|
892
|
+
lines.push(`ANTHROPIC_AUTH_TOKEN=${sanitizeEnvValue(config.anthropicAuthToken)}`);
|
|
893
|
+
}
|
|
894
|
+
if (config.anthropicModel) {
|
|
895
|
+
lines.push(`ANTHROPIC_MODEL=${sanitizeEnvValue(config.anthropicModel)}`);
|
|
896
|
+
}
|
|
897
|
+
// Use explicit profileCustomEnv if provided (pool mode), otherwise active profile
|
|
898
|
+
const customEnv = profileCustomEnv ?? getActiveProfileCustomEnv();
|
|
899
|
+
for (const [key, value] of Object.entries(customEnv)) {
|
|
900
|
+
if (RESERVED_CLAUDE_ENV_KEYS.has(key))
|
|
901
|
+
continue;
|
|
902
|
+
lines.push(`${key}=${sanitizeEnvValue(value)}`);
|
|
903
|
+
}
|
|
904
|
+
return lines;
|
|
905
|
+
}
|
|
906
|
+
export function getActiveProfileCustomEnv() {
|
|
907
|
+
const state = readStoredStateV4();
|
|
908
|
+
if (!state)
|
|
909
|
+
return {};
|
|
910
|
+
const enabled = state.providers.find((p) => p.enabled) || state.providers[0];
|
|
911
|
+
if (!enabled)
|
|
912
|
+
return {};
|
|
913
|
+
return sanitizeCustomEnvMap(enabled.customEnv || {}, {
|
|
914
|
+
skipReservedClaudeKeys: true,
|
|
915
|
+
});
|
|
916
|
+
}
|
|
917
|
+
export function appendClaudeConfigAudit(actor, action, changedFields, metadata) {
|
|
918
|
+
const entry = {
|
|
919
|
+
timestamp: new Date().toISOString(),
|
|
920
|
+
actor,
|
|
921
|
+
action,
|
|
922
|
+
changedFields,
|
|
923
|
+
metadata,
|
|
924
|
+
};
|
|
925
|
+
fs.mkdirSync(CLAUDE_CONFIG_DIR, { recursive: true });
|
|
926
|
+
fs.appendFileSync(CLAUDE_CONFIG_AUDIT_FILE, `${JSON.stringify(entry)}\n`, 'utf-8');
|
|
927
|
+
}
|
|
928
|
+
// ─── Per-container environment config ───────────────────────────
|
|
929
|
+
const CONTAINER_ENV_DIR = path.join(DATA_DIR, 'config', 'container-env');
|
|
930
|
+
function containerEnvPath(folder) {
|
|
931
|
+
if (folder.includes('..') || folder.includes('/')) {
|
|
932
|
+
throw new Error('Invalid folder name');
|
|
933
|
+
}
|
|
934
|
+
return path.join(CONTAINER_ENV_DIR, `${folder}.json`);
|
|
935
|
+
}
|
|
936
|
+
export function getContainerEnvConfig(folder) {
|
|
937
|
+
const filePath = containerEnvPath(folder);
|
|
938
|
+
try {
|
|
939
|
+
if (fs.existsSync(filePath)) {
|
|
940
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
catch (err) {
|
|
944
|
+
logger.warn({ err, folder }, 'Failed to read container env config, returning defaults');
|
|
945
|
+
}
|
|
946
|
+
return {};
|
|
947
|
+
}
|
|
948
|
+
export function saveContainerEnvConfig(folder, config) {
|
|
949
|
+
// Sanitize all string fields to prevent env injection
|
|
950
|
+
const sanitized = { ...config };
|
|
951
|
+
if (sanitized.anthropicBaseUrl)
|
|
952
|
+
sanitized.anthropicBaseUrl = sanitizeEnvValue(sanitized.anthropicBaseUrl);
|
|
953
|
+
if (sanitized.anthropicAuthToken)
|
|
954
|
+
sanitized.anthropicAuthToken = sanitizeEnvValue(sanitized.anthropicAuthToken);
|
|
955
|
+
if (sanitized.anthropicApiKey)
|
|
956
|
+
sanitized.anthropicApiKey = sanitizeEnvValue(sanitized.anthropicApiKey);
|
|
957
|
+
if (sanitized.claudeCodeOauthToken)
|
|
958
|
+
sanitized.claudeCodeOauthToken = sanitizeEnvValue(sanitized.claudeCodeOauthToken);
|
|
959
|
+
if (sanitized.anthropicModel)
|
|
960
|
+
sanitized.anthropicModel = sanitizeEnvValue(sanitized.anthropicModel);
|
|
961
|
+
if (sanitized.customEnv) {
|
|
962
|
+
const cleanEnv = {};
|
|
963
|
+
for (const [k, v] of Object.entries(sanitized.customEnv)) {
|
|
964
|
+
if (DANGEROUS_ENV_VARS.has(k)) {
|
|
965
|
+
logger.warn({ key: k }, 'Rejected dangerous env variable in saveContainerEnvConfig');
|
|
966
|
+
continue;
|
|
967
|
+
}
|
|
968
|
+
cleanEnv[k] = sanitizeEnvValue(v);
|
|
969
|
+
}
|
|
970
|
+
sanitized.customEnv = cleanEnv;
|
|
971
|
+
}
|
|
972
|
+
fs.mkdirSync(CONTAINER_ENV_DIR, { recursive: true });
|
|
973
|
+
const tmp = `${containerEnvPath(folder)}.tmp`;
|
|
974
|
+
fs.writeFileSync(tmp, JSON.stringify(sanitized, null, 2) + '\n', 'utf-8');
|
|
975
|
+
fs.renameSync(tmp, containerEnvPath(folder));
|
|
976
|
+
}
|
|
977
|
+
export function deleteContainerEnvConfig(folder) {
|
|
978
|
+
const filePath = containerEnvPath(folder);
|
|
979
|
+
try {
|
|
980
|
+
if (fs.existsSync(filePath))
|
|
981
|
+
fs.unlinkSync(filePath);
|
|
982
|
+
}
|
|
983
|
+
catch {
|
|
984
|
+
// ignore
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
export function toPublicContainerEnvConfig(config) {
|
|
988
|
+
return {
|
|
989
|
+
anthropicBaseUrl: config.anthropicBaseUrl || '',
|
|
990
|
+
hasAnthropicAuthToken: !!config.anthropicAuthToken,
|
|
991
|
+
hasAnthropicApiKey: !!config.anthropicApiKey,
|
|
992
|
+
hasClaudeCodeOauthToken: !!config.claudeCodeOauthToken,
|
|
993
|
+
anthropicAuthTokenMasked: maskSecret(config.anthropicAuthToken || ''),
|
|
994
|
+
anthropicApiKeyMasked: maskSecret(config.anthropicApiKey || ''),
|
|
995
|
+
claudeCodeOauthTokenMasked: maskSecret(config.claudeCodeOauthToken || ''),
|
|
996
|
+
anthropicModel: config.anthropicModel || '',
|
|
997
|
+
customEnv: config.customEnv || {},
|
|
998
|
+
};
|
|
999
|
+
}
|
|
1000
|
+
/**
|
|
1001
|
+
* Merge global config with per-container overrides.
|
|
1002
|
+
* Non-empty per-container fields override the global value.
|
|
1003
|
+
*/
|
|
1004
|
+
export function mergeClaudeEnvConfig(global, override) {
|
|
1005
|
+
return {
|
|
1006
|
+
anthropicBaseUrl: override.anthropicBaseUrl || global.anthropicBaseUrl,
|
|
1007
|
+
anthropicAuthToken: override.anthropicAuthToken || global.anthropicAuthToken,
|
|
1008
|
+
anthropicApiKey: override.anthropicApiKey || global.anthropicApiKey,
|
|
1009
|
+
claudeCodeOauthToken: override.claudeCodeOauthToken || global.claudeCodeOauthToken,
|
|
1010
|
+
claudeOAuthCredentials: override.claudeOAuthCredentials ?? global.claudeOAuthCredentials,
|
|
1011
|
+
anthropicModel: override.anthropicModel || global.anthropicModel,
|
|
1012
|
+
updatedAt: global.updatedAt,
|
|
1013
|
+
};
|
|
1014
|
+
}
|
|
1015
|
+
// ─── Registration config (plain JSON, no encryption) ─────────────
|
|
1016
|
+
const REGISTRATION_CONFIG_FILE = path.join(CLAUDE_CONFIG_DIR, 'registration.json');
|
|
1017
|
+
const DEFAULT_REGISTRATION_CONFIG = {
|
|
1018
|
+
allowRegistration: true,
|
|
1019
|
+
requireInviteCode: true,
|
|
1020
|
+
updatedAt: null,
|
|
1021
|
+
};
|
|
1022
|
+
export function getRegistrationConfig() {
|
|
1023
|
+
try {
|
|
1024
|
+
if (!fs.existsSync(REGISTRATION_CONFIG_FILE)) {
|
|
1025
|
+
return { ...DEFAULT_REGISTRATION_CONFIG };
|
|
1026
|
+
}
|
|
1027
|
+
const raw = JSON.parse(fs.readFileSync(REGISTRATION_CONFIG_FILE, 'utf-8'));
|
|
1028
|
+
return {
|
|
1029
|
+
allowRegistration: typeof raw.allowRegistration === 'boolean'
|
|
1030
|
+
? raw.allowRegistration
|
|
1031
|
+
: true,
|
|
1032
|
+
requireInviteCode: typeof raw.requireInviteCode === 'boolean'
|
|
1033
|
+
? raw.requireInviteCode
|
|
1034
|
+
: true,
|
|
1035
|
+
updatedAt: typeof raw.updatedAt === 'string' ? raw.updatedAt : null,
|
|
1036
|
+
};
|
|
1037
|
+
}
|
|
1038
|
+
catch (err) {
|
|
1039
|
+
logger.warn({ err }, 'Failed to read registration config, returning defaults');
|
|
1040
|
+
return { ...DEFAULT_REGISTRATION_CONFIG };
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
export function saveRegistrationConfig(next) {
|
|
1044
|
+
const config = {
|
|
1045
|
+
allowRegistration: next.allowRegistration,
|
|
1046
|
+
requireInviteCode: next.requireInviteCode,
|
|
1047
|
+
updatedAt: new Date().toISOString(),
|
|
1048
|
+
};
|
|
1049
|
+
fs.mkdirSync(CLAUDE_CONFIG_DIR, { recursive: true });
|
|
1050
|
+
const tmp = `${REGISTRATION_CONFIG_FILE}.tmp`;
|
|
1051
|
+
fs.writeFileSync(tmp, JSON.stringify(config, null, 2) + '\n', 'utf-8');
|
|
1052
|
+
fs.renameSync(tmp, REGISTRATION_CONFIG_FILE);
|
|
1053
|
+
return config;
|
|
1054
|
+
}
|
|
1055
|
+
/**
|
|
1056
|
+
* Build full env lines: merged Claude config + custom env vars.
|
|
1057
|
+
*/
|
|
1058
|
+
export function buildContainerEnvLines(global, override, profileCustomEnv) {
|
|
1059
|
+
const merged = mergeClaudeEnvConfig(global, override);
|
|
1060
|
+
const lines = buildClaudeEnvLines(merged, profileCustomEnv);
|
|
1061
|
+
// Append custom env vars (with safety sanitization as defense-in-depth)
|
|
1062
|
+
if (override.customEnv) {
|
|
1063
|
+
for (const [key, value] of Object.entries(override.customEnv)) {
|
|
1064
|
+
if (!key || value === undefined)
|
|
1065
|
+
continue;
|
|
1066
|
+
if (!ENV_KEY_RE.test(key)) {
|
|
1067
|
+
logger.warn({ key }, 'Skipping invalid env key in buildContainerEnvLines');
|
|
1068
|
+
continue;
|
|
1069
|
+
}
|
|
1070
|
+
// Block dangerous environment variables
|
|
1071
|
+
if (DANGEROUS_ENV_VARS.has(key)) {
|
|
1072
|
+
logger.warn({ key }, 'Blocked dangerous env variable in buildContainerEnvLines');
|
|
1073
|
+
continue;
|
|
1074
|
+
}
|
|
1075
|
+
// Strip control characters to prevent env injection
|
|
1076
|
+
const sanitized = value.replace(/[\r\n\0]/g, '');
|
|
1077
|
+
lines.push(`${key}=${sanitized}`);
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
return lines;
|
|
1081
|
+
}
|
|
1082
|
+
// ─── OAuth credentials file management ────────────────────────────
|
|
1083
|
+
/**
|
|
1084
|
+
* Write .credentials.json to a Claude session directory.
|
|
1085
|
+
* Format matches what Claude Code CLI/Agent SDK natively reads.
|
|
1086
|
+
*/
|
|
1087
|
+
export function writeCredentialsFile(sessionDir, config) {
|
|
1088
|
+
const creds = config.claudeOAuthCredentials;
|
|
1089
|
+
if (!creds)
|
|
1090
|
+
return;
|
|
1091
|
+
// Claude CLI requires scopes to recognize the token as valid.
|
|
1092
|
+
// Fall back to a sensible default when the stored credentials lack scopes
|
|
1093
|
+
// (e.g. tokens imported before scopes were captured).
|
|
1094
|
+
const scopes = creds.scopes?.length
|
|
1095
|
+
? creds.scopes
|
|
1096
|
+
: DEFAULT_CREDENTIAL_SCOPES;
|
|
1097
|
+
const claudeAiOauth = {
|
|
1098
|
+
accessToken: creds.accessToken,
|
|
1099
|
+
refreshToken: creds.refreshToken,
|
|
1100
|
+
expiresAt: creds.expiresAt,
|
|
1101
|
+
scopes,
|
|
1102
|
+
};
|
|
1103
|
+
// Only include subscriptionType when explicitly configured — avoids
|
|
1104
|
+
// misleading Claude CLI when the actual subscription tier is unknown.
|
|
1105
|
+
if (creds.subscriptionType) {
|
|
1106
|
+
claudeAiOauth.subscriptionType = creds.subscriptionType;
|
|
1107
|
+
}
|
|
1108
|
+
const credentialsData = { claudeAiOauth };
|
|
1109
|
+
const filePath = path.join(sessionDir, '.credentials.json');
|
|
1110
|
+
const tmp = `${filePath}.tmp`;
|
|
1111
|
+
fs.writeFileSync(tmp, JSON.stringify(credentialsData, null, 2) + '\n', {
|
|
1112
|
+
encoding: 'utf-8',
|
|
1113
|
+
mode: 0o644,
|
|
1114
|
+
});
|
|
1115
|
+
fs.renameSync(tmp, filePath);
|
|
1116
|
+
}
|
|
1117
|
+
/**
|
|
1118
|
+
* Update .credentials.json in all existing session directories + host ~/.claude/
|
|
1119
|
+
*/
|
|
1120
|
+
export function updateAllSessionCredentials(config) {
|
|
1121
|
+
if (!config.claudeOAuthCredentials)
|
|
1122
|
+
return;
|
|
1123
|
+
const sessionsDir = path.join(DATA_DIR, 'sessions');
|
|
1124
|
+
try {
|
|
1125
|
+
if (!fs.existsSync(sessionsDir))
|
|
1126
|
+
return;
|
|
1127
|
+
for (const folder of fs.readdirSync(sessionsDir)) {
|
|
1128
|
+
const claudeDir = path.join(sessionsDir, folder, '.claude');
|
|
1129
|
+
if (fs.existsSync(claudeDir) && fs.statSync(claudeDir).isDirectory()) {
|
|
1130
|
+
try {
|
|
1131
|
+
writeCredentialsFile(claudeDir, config);
|
|
1132
|
+
}
|
|
1133
|
+
catch (err) {
|
|
1134
|
+
logger.warn({ err, folder }, 'Failed to write .credentials.json for session');
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
// Also update sub-agent session dirs
|
|
1138
|
+
const agentsDir = path.join(sessionsDir, folder, 'agents');
|
|
1139
|
+
if (fs.existsSync(agentsDir) && fs.statSync(agentsDir).isDirectory()) {
|
|
1140
|
+
for (const agentId of fs.readdirSync(agentsDir)) {
|
|
1141
|
+
const agentClaudeDir = path.join(agentsDir, agentId, '.claude');
|
|
1142
|
+
if (fs.existsSync(agentClaudeDir) &&
|
|
1143
|
+
fs.statSync(agentClaudeDir).isDirectory()) {
|
|
1144
|
+
try {
|
|
1145
|
+
writeCredentialsFile(agentClaudeDir, config);
|
|
1146
|
+
}
|
|
1147
|
+
catch (err) {
|
|
1148
|
+
logger.warn({ err, folder, agentId }, 'Failed to write .credentials.json for agent session');
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
catch (err) {
|
|
1156
|
+
logger.warn({ err }, 'Failed to update session credentials');
|
|
1157
|
+
}
|
|
1158
|
+
// Host mode uses CLAUDE_CONFIG_DIR=~/.cli-claw/sessions/{folder}/.claude for isolation,
|
|
1159
|
+
// so we must NOT touch ~/.claude/.credentials.json to avoid interfering with
|
|
1160
|
+
// the user's local Claude Code installation.
|
|
1161
|
+
}
|
|
1162
|
+
/**
|
|
1163
|
+
* Read and parse OAuth credentials from ~/.claude/.credentials.json.
|
|
1164
|
+
* Returns the raw oauth object with accessToken, refreshToken, expiresAt, scopes,
|
|
1165
|
+
* or null if the file is missing / invalid / incomplete.
|
|
1166
|
+
*/
|
|
1167
|
+
function readLocalOAuthCredentials() {
|
|
1168
|
+
const homeDir = process.env.HOME || '/root';
|
|
1169
|
+
const credFile = path.join(homeDir, '.claude', '.credentials.json');
|
|
1170
|
+
try {
|
|
1171
|
+
if (!fs.existsSync(credFile))
|
|
1172
|
+
return null;
|
|
1173
|
+
const content = JSON.parse(fs.readFileSync(credFile, 'utf-8'));
|
|
1174
|
+
const oauth = content?.claudeAiOauth;
|
|
1175
|
+
if (oauth?.accessToken && oauth?.refreshToken) {
|
|
1176
|
+
return {
|
|
1177
|
+
accessToken: oauth.accessToken,
|
|
1178
|
+
refreshToken: oauth.refreshToken,
|
|
1179
|
+
expiresAt: typeof oauth.expiresAt === 'number' ? oauth.expiresAt : undefined,
|
|
1180
|
+
scopes: Array.isArray(oauth.scopes) ? oauth.scopes : undefined,
|
|
1181
|
+
subscriptionType: typeof oauth.subscriptionType === 'string'
|
|
1182
|
+
? oauth.subscriptionType
|
|
1183
|
+
: undefined,
|
|
1184
|
+
};
|
|
1185
|
+
}
|
|
1186
|
+
return null;
|
|
1187
|
+
}
|
|
1188
|
+
catch {
|
|
1189
|
+
return null;
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
/**
|
|
1193
|
+
* Detect if the host machine has a valid ~/.claude/.credentials.json
|
|
1194
|
+
* (i.e. user has logged into Claude Code locally).
|
|
1195
|
+
*/
|
|
1196
|
+
export function detectLocalClaudeCode() {
|
|
1197
|
+
const oauth = readLocalOAuthCredentials();
|
|
1198
|
+
if (oauth) {
|
|
1199
|
+
return {
|
|
1200
|
+
detected: true,
|
|
1201
|
+
hasCredentials: true,
|
|
1202
|
+
expiresAt: oauth.expiresAt ?? null,
|
|
1203
|
+
accessTokenMasked: maskSecret(oauth.accessToken),
|
|
1204
|
+
};
|
|
1205
|
+
}
|
|
1206
|
+
// Check if the file exists at all (detected but no valid credentials)
|
|
1207
|
+
const homeDir = process.env.HOME || '/root';
|
|
1208
|
+
const credFile = path.join(homeDir, '.claude', '.credentials.json');
|
|
1209
|
+
const fileExists = fs.existsSync(credFile);
|
|
1210
|
+
return {
|
|
1211
|
+
detected: fileExists,
|
|
1212
|
+
hasCredentials: false,
|
|
1213
|
+
expiresAt: null,
|
|
1214
|
+
accessTokenMasked: null,
|
|
1215
|
+
};
|
|
1216
|
+
}
|
|
1217
|
+
/**
|
|
1218
|
+
* Read local ~/.claude/.credentials.json and return parsed OAuth credentials.
|
|
1219
|
+
* Returns null if not found or invalid.
|
|
1220
|
+
*/
|
|
1221
|
+
export function importLocalClaudeCredentials() {
|
|
1222
|
+
const oauth = readLocalOAuthCredentials();
|
|
1223
|
+
if (!oauth)
|
|
1224
|
+
return null;
|
|
1225
|
+
return {
|
|
1226
|
+
accessToken: oauth.accessToken,
|
|
1227
|
+
refreshToken: oauth.refreshToken,
|
|
1228
|
+
expiresAt: oauth.expiresAt ?? Date.now() + 8 * 3600_000,
|
|
1229
|
+
scopes: oauth.scopes ?? [],
|
|
1230
|
+
...(oauth.subscriptionType
|
|
1231
|
+
? { subscriptionType: oauth.subscriptionType }
|
|
1232
|
+
: {}),
|
|
1233
|
+
};
|
|
1234
|
+
}
|
|
1235
|
+
// ─── Appearance config (plain JSON, no encryption) ────────────────
|
|
1236
|
+
const APPEARANCE_CONFIG_FILE = path.join(CLAUDE_CONFIG_DIR, 'appearance.json');
|
|
1237
|
+
const DEFAULT_APPEARANCE_CONFIG = {
|
|
1238
|
+
appName: ASSISTANT_NAME,
|
|
1239
|
+
aiName: ASSISTANT_NAME,
|
|
1240
|
+
aiAvatarEmoji: '\u{1F431}',
|
|
1241
|
+
aiAvatarColor: '#0d9488',
|
|
1242
|
+
};
|
|
1243
|
+
export function getAppearanceConfig() {
|
|
1244
|
+
try {
|
|
1245
|
+
if (!fs.existsSync(APPEARANCE_CONFIG_FILE)) {
|
|
1246
|
+
return { ...DEFAULT_APPEARANCE_CONFIG };
|
|
1247
|
+
}
|
|
1248
|
+
const raw = JSON.parse(fs.readFileSync(APPEARANCE_CONFIG_FILE, 'utf-8'));
|
|
1249
|
+
return {
|
|
1250
|
+
appName: typeof raw.appName === 'string' && raw.appName
|
|
1251
|
+
? raw.appName
|
|
1252
|
+
: DEFAULT_APPEARANCE_CONFIG.appName,
|
|
1253
|
+
aiName: typeof raw.aiName === 'string' && raw.aiName
|
|
1254
|
+
? raw.aiName
|
|
1255
|
+
: DEFAULT_APPEARANCE_CONFIG.aiName,
|
|
1256
|
+
aiAvatarEmoji: typeof raw.aiAvatarEmoji === 'string' && raw.aiAvatarEmoji
|
|
1257
|
+
? raw.aiAvatarEmoji
|
|
1258
|
+
: DEFAULT_APPEARANCE_CONFIG.aiAvatarEmoji,
|
|
1259
|
+
aiAvatarColor: typeof raw.aiAvatarColor === 'string' && raw.aiAvatarColor
|
|
1260
|
+
? raw.aiAvatarColor
|
|
1261
|
+
: DEFAULT_APPEARANCE_CONFIG.aiAvatarColor,
|
|
1262
|
+
};
|
|
1263
|
+
}
|
|
1264
|
+
catch (err) {
|
|
1265
|
+
logger.warn({ err }, 'Failed to read appearance config, returning defaults');
|
|
1266
|
+
return { ...DEFAULT_APPEARANCE_CONFIG };
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
export function saveAppearanceConfig(next) {
|
|
1270
|
+
const existing = getAppearanceConfig();
|
|
1271
|
+
const config = {
|
|
1272
|
+
appName: next.appName || existing.appName,
|
|
1273
|
+
aiName: next.aiName,
|
|
1274
|
+
aiAvatarEmoji: next.aiAvatarEmoji,
|
|
1275
|
+
aiAvatarColor: next.aiAvatarColor,
|
|
1276
|
+
updatedAt: new Date().toISOString(),
|
|
1277
|
+
};
|
|
1278
|
+
fs.mkdirSync(CLAUDE_CONFIG_DIR, { recursive: true });
|
|
1279
|
+
const tmp = `${APPEARANCE_CONFIG_FILE}.tmp`;
|
|
1280
|
+
fs.writeFileSync(tmp, JSON.stringify(config, null, 2) + '\n', 'utf-8');
|
|
1281
|
+
fs.renameSync(tmp, APPEARANCE_CONFIG_FILE);
|
|
1282
|
+
return {
|
|
1283
|
+
appName: config.appName,
|
|
1284
|
+
aiName: config.aiName,
|
|
1285
|
+
aiAvatarEmoji: config.aiAvatarEmoji,
|
|
1286
|
+
aiAvatarColor: config.aiAvatarColor,
|
|
1287
|
+
};
|
|
1288
|
+
}
|
|
1289
|
+
// ─── Per-user IM config (AES-256-GCM encrypted) ─────────────────
|
|
1290
|
+
const USER_IM_CONFIG_DIR = path.join(DATA_DIR, 'config', 'user-im');
|
|
1291
|
+
function userImDir(userId) {
|
|
1292
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(userId)) {
|
|
1293
|
+
throw new Error('Invalid userId');
|
|
1294
|
+
}
|
|
1295
|
+
return path.join(USER_IM_CONFIG_DIR, userId);
|
|
1296
|
+
}
|
|
1297
|
+
export function getUserFeishuConfig(userId) {
|
|
1298
|
+
const filePath = path.join(userImDir(userId), 'feishu.json');
|
|
1299
|
+
try {
|
|
1300
|
+
if (!fs.existsSync(filePath))
|
|
1301
|
+
return null;
|
|
1302
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
1303
|
+
const parsed = JSON.parse(content);
|
|
1304
|
+
if (parsed.version !== 1)
|
|
1305
|
+
return null;
|
|
1306
|
+
const stored = parsed;
|
|
1307
|
+
const secret = decryptChannelSecret(stored.secret);
|
|
1308
|
+
return {
|
|
1309
|
+
appId: normalizeFeishuAppId(stored.appId ?? ''),
|
|
1310
|
+
appSecret: secret.appSecret,
|
|
1311
|
+
enabled: stored.enabled,
|
|
1312
|
+
updatedAt: stored.updatedAt || null,
|
|
1313
|
+
};
|
|
1314
|
+
}
|
|
1315
|
+
catch (err) {
|
|
1316
|
+
logger.warn({ err, userId }, 'Failed to read user Feishu config');
|
|
1317
|
+
return null;
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
export function saveUserFeishuConfig(userId, next) {
|
|
1321
|
+
const normalized = {
|
|
1322
|
+
appId: normalizeFeishuAppId(next.appId),
|
|
1323
|
+
appSecret: normalizeSecret(next.appSecret, 'appSecret'),
|
|
1324
|
+
enabled: next.enabled,
|
|
1325
|
+
updatedAt: new Date().toISOString(),
|
|
1326
|
+
};
|
|
1327
|
+
const payload = {
|
|
1328
|
+
version: 1,
|
|
1329
|
+
appId: normalized.appId,
|
|
1330
|
+
enabled: normalized.enabled,
|
|
1331
|
+
updatedAt: normalized.updatedAt || new Date().toISOString(),
|
|
1332
|
+
secret: encryptChannelSecret({
|
|
1333
|
+
appSecret: normalized.appSecret,
|
|
1334
|
+
}),
|
|
1335
|
+
};
|
|
1336
|
+
const dir = userImDir(userId);
|
|
1337
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1338
|
+
const filePath = path.join(dir, 'feishu.json');
|
|
1339
|
+
const tmp = `${filePath}.tmp`;
|
|
1340
|
+
fs.writeFileSync(tmp, JSON.stringify(payload, null, 2) + '\n', 'utf-8');
|
|
1341
|
+
fs.renameSync(tmp, filePath);
|
|
1342
|
+
return normalized;
|
|
1343
|
+
}
|
|
1344
|
+
export function getUserTelegramConfig(userId) {
|
|
1345
|
+
const filePath = path.join(userImDir(userId), 'telegram.json');
|
|
1346
|
+
try {
|
|
1347
|
+
if (!fs.existsSync(filePath))
|
|
1348
|
+
return null;
|
|
1349
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
1350
|
+
const parsed = JSON.parse(content);
|
|
1351
|
+
if (parsed.version !== 1)
|
|
1352
|
+
return null;
|
|
1353
|
+
const stored = parsed;
|
|
1354
|
+
const secret = decryptChannelSecret(stored.secret);
|
|
1355
|
+
return {
|
|
1356
|
+
botToken: secret.botToken,
|
|
1357
|
+
proxyUrl: normalizeTelegramProxyUrl(stored.proxyUrl ?? ''),
|
|
1358
|
+
enabled: stored.enabled,
|
|
1359
|
+
updatedAt: stored.updatedAt || null,
|
|
1360
|
+
};
|
|
1361
|
+
}
|
|
1362
|
+
catch (err) {
|
|
1363
|
+
logger.warn({ err, userId }, 'Failed to read user Telegram config');
|
|
1364
|
+
return null;
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
export function saveUserTelegramConfig(userId, next) {
|
|
1368
|
+
const normalizedProxyUrl = next.proxyUrl
|
|
1369
|
+
? normalizeTelegramProxyUrl(next.proxyUrl)
|
|
1370
|
+
: '';
|
|
1371
|
+
const normalized = {
|
|
1372
|
+
botToken: normalizeSecret(next.botToken, 'botToken'),
|
|
1373
|
+
proxyUrl: normalizedProxyUrl || undefined,
|
|
1374
|
+
enabled: next.enabled,
|
|
1375
|
+
updatedAt: new Date().toISOString(),
|
|
1376
|
+
};
|
|
1377
|
+
const payload = {
|
|
1378
|
+
version: 1,
|
|
1379
|
+
proxyUrl: normalizedProxyUrl || undefined,
|
|
1380
|
+
enabled: normalized.enabled,
|
|
1381
|
+
updatedAt: normalized.updatedAt || new Date().toISOString(),
|
|
1382
|
+
secret: encryptChannelSecret({
|
|
1383
|
+
botToken: normalized.botToken,
|
|
1384
|
+
}),
|
|
1385
|
+
};
|
|
1386
|
+
const dir = userImDir(userId);
|
|
1387
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1388
|
+
const filePath = path.join(dir, 'telegram.json');
|
|
1389
|
+
const tmp = `${filePath}.tmp`;
|
|
1390
|
+
fs.writeFileSync(tmp, JSON.stringify(payload, null, 2) + '\n', 'utf-8');
|
|
1391
|
+
fs.renameSync(tmp, filePath);
|
|
1392
|
+
return normalized;
|
|
1393
|
+
}
|
|
1394
|
+
// ========== QQ User IM Config ==========
|
|
1395
|
+
export function getUserQQConfig(userId) {
|
|
1396
|
+
const filePath = path.join(userImDir(userId), 'qq.json');
|
|
1397
|
+
try {
|
|
1398
|
+
if (!fs.existsSync(filePath))
|
|
1399
|
+
return null;
|
|
1400
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
1401
|
+
const parsed = JSON.parse(content);
|
|
1402
|
+
if (parsed.version !== 1)
|
|
1403
|
+
return null;
|
|
1404
|
+
const stored = parsed;
|
|
1405
|
+
const secret = decryptChannelSecret(stored.secret);
|
|
1406
|
+
return {
|
|
1407
|
+
appId: normalizeFeishuAppId(stored.appId ?? ''),
|
|
1408
|
+
appSecret: secret.appSecret,
|
|
1409
|
+
enabled: stored.enabled,
|
|
1410
|
+
updatedAt: stored.updatedAt || null,
|
|
1411
|
+
};
|
|
1412
|
+
}
|
|
1413
|
+
catch (err) {
|
|
1414
|
+
logger.warn({ err, userId }, 'Failed to read user QQ config');
|
|
1415
|
+
return null;
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
export function saveUserQQConfig(userId, next) {
|
|
1419
|
+
const normalized = {
|
|
1420
|
+
appId: normalizeFeishuAppId(next.appId),
|
|
1421
|
+
appSecret: normalizeSecret(next.appSecret, 'appSecret'),
|
|
1422
|
+
enabled: next.enabled,
|
|
1423
|
+
updatedAt: new Date().toISOString(),
|
|
1424
|
+
};
|
|
1425
|
+
const payload = {
|
|
1426
|
+
version: 1,
|
|
1427
|
+
appId: normalized.appId,
|
|
1428
|
+
enabled: normalized.enabled,
|
|
1429
|
+
updatedAt: normalized.updatedAt || new Date().toISOString(),
|
|
1430
|
+
secret: encryptChannelSecret({
|
|
1431
|
+
appSecret: normalized.appSecret,
|
|
1432
|
+
}),
|
|
1433
|
+
};
|
|
1434
|
+
const dir = userImDir(userId);
|
|
1435
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1436
|
+
const filePath = path.join(dir, 'qq.json');
|
|
1437
|
+
const tmp = `${filePath}.tmp`;
|
|
1438
|
+
fs.writeFileSync(tmp, JSON.stringify(payload, null, 2) + '\n', 'utf-8');
|
|
1439
|
+
fs.renameSync(tmp, filePath);
|
|
1440
|
+
return normalized;
|
|
1441
|
+
}
|
|
1442
|
+
export function getUserWeChatConfig(userId) {
|
|
1443
|
+
const filePath = path.join(userImDir(userId), 'wechat.json');
|
|
1444
|
+
try {
|
|
1445
|
+
if (!fs.existsSync(filePath))
|
|
1446
|
+
return null;
|
|
1447
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
1448
|
+
const parsed = JSON.parse(content);
|
|
1449
|
+
if (parsed.version !== 1)
|
|
1450
|
+
return null;
|
|
1451
|
+
const stored = parsed;
|
|
1452
|
+
const secret = decryptChannelSecret(stored.secret);
|
|
1453
|
+
return {
|
|
1454
|
+
botToken: secret.botToken,
|
|
1455
|
+
ilinkBotId: (stored.ilinkBotId ?? '').trim(),
|
|
1456
|
+
baseUrl: stored.baseUrl,
|
|
1457
|
+
cdnBaseUrl: stored.cdnBaseUrl,
|
|
1458
|
+
getUpdatesBuf: stored.getUpdatesBuf,
|
|
1459
|
+
bypassProxy: stored.bypassProxy ?? true, // 默认直连
|
|
1460
|
+
enabled: stored.enabled,
|
|
1461
|
+
updatedAt: stored.updatedAt || null,
|
|
1462
|
+
};
|
|
1463
|
+
}
|
|
1464
|
+
catch (err) {
|
|
1465
|
+
logger.warn({ err, userId }, 'Failed to read user WeChat config');
|
|
1466
|
+
return null;
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
export function saveUserWeChatConfig(userId, next) {
|
|
1470
|
+
const normalized = {
|
|
1471
|
+
botToken: normalizeSecret(next.botToken, 'botToken'),
|
|
1472
|
+
ilinkBotId: (next.ilinkBotId ?? '').trim(),
|
|
1473
|
+
baseUrl: next.baseUrl?.trim() || undefined,
|
|
1474
|
+
cdnBaseUrl: next.cdnBaseUrl?.trim() || undefined,
|
|
1475
|
+
getUpdatesBuf: next.getUpdatesBuf,
|
|
1476
|
+
bypassProxy: next.bypassProxy ?? true,
|
|
1477
|
+
enabled: next.enabled,
|
|
1478
|
+
updatedAt: new Date().toISOString(),
|
|
1479
|
+
};
|
|
1480
|
+
const payload = {
|
|
1481
|
+
version: 1,
|
|
1482
|
+
ilinkBotId: normalized.ilinkBotId,
|
|
1483
|
+
baseUrl: normalized.baseUrl,
|
|
1484
|
+
cdnBaseUrl: normalized.cdnBaseUrl,
|
|
1485
|
+
getUpdatesBuf: normalized.getUpdatesBuf,
|
|
1486
|
+
bypassProxy: normalized.bypassProxy,
|
|
1487
|
+
enabled: normalized.enabled,
|
|
1488
|
+
updatedAt: normalized.updatedAt || new Date().toISOString(),
|
|
1489
|
+
secret: encryptChannelSecret({
|
|
1490
|
+
botToken: normalized.botToken,
|
|
1491
|
+
}),
|
|
1492
|
+
};
|
|
1493
|
+
const dir = userImDir(userId);
|
|
1494
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1495
|
+
const filePath = path.join(dir, 'wechat.json');
|
|
1496
|
+
const tmp = `${filePath}.tmp`;
|
|
1497
|
+
fs.writeFileSync(tmp, JSON.stringify(payload, null, 2) + '\n', 'utf-8');
|
|
1498
|
+
fs.renameSync(tmp, filePath);
|
|
1499
|
+
return normalized;
|
|
1500
|
+
}
|
|
1501
|
+
// ========== DingTalk User IM Config ==========
|
|
1502
|
+
export function getUserDingTalkConfig(userId) {
|
|
1503
|
+
const filePath = path.join(userImDir(userId), 'dingtalk.json');
|
|
1504
|
+
try {
|
|
1505
|
+
if (!fs.existsSync(filePath))
|
|
1506
|
+
return null;
|
|
1507
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
1508
|
+
const parsed = JSON.parse(content);
|
|
1509
|
+
if (parsed.version !== 1)
|
|
1510
|
+
return null;
|
|
1511
|
+
const stored = parsed;
|
|
1512
|
+
const secret = decryptChannelSecret(stored.secret);
|
|
1513
|
+
return {
|
|
1514
|
+
clientId: (stored.clientId ?? '').trim(),
|
|
1515
|
+
clientSecret: secret.clientSecret,
|
|
1516
|
+
enabled: stored.enabled,
|
|
1517
|
+
updatedAt: stored.updatedAt || null,
|
|
1518
|
+
};
|
|
1519
|
+
}
|
|
1520
|
+
catch (err) {
|
|
1521
|
+
logger.warn({ err, userId }, 'Failed to read user DingTalk config');
|
|
1522
|
+
return null;
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
export function saveUserDingTalkConfig(userId, next) {
|
|
1526
|
+
const normalized = {
|
|
1527
|
+
clientId: (next.clientId ?? '').trim(),
|
|
1528
|
+
clientSecret: normalizeSecret(next.clientSecret, 'clientSecret'),
|
|
1529
|
+
enabled: next.enabled,
|
|
1530
|
+
updatedAt: new Date().toISOString(),
|
|
1531
|
+
};
|
|
1532
|
+
const payload = {
|
|
1533
|
+
version: 1,
|
|
1534
|
+
clientId: normalized.clientId,
|
|
1535
|
+
enabled: normalized.enabled,
|
|
1536
|
+
updatedAt: normalized.updatedAt || new Date().toISOString(),
|
|
1537
|
+
secret: encryptChannelSecret({
|
|
1538
|
+
clientSecret: normalized.clientSecret,
|
|
1539
|
+
}),
|
|
1540
|
+
};
|
|
1541
|
+
const dir = userImDir(userId);
|
|
1542
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1543
|
+
const filePath = path.join(dir, 'dingtalk.json');
|
|
1544
|
+
const tmp = `${filePath}.tmp`;
|
|
1545
|
+
fs.writeFileSync(tmp, JSON.stringify(payload, null, 2) + '\n', 'utf-8');
|
|
1546
|
+
fs.renameSync(tmp, filePath);
|
|
1547
|
+
return normalized;
|
|
1548
|
+
}
|
|
1549
|
+
// ─── System settings (plain JSON, no encryption) ─────────────────
|
|
1550
|
+
const SYSTEM_SETTINGS_FILE = path.join(CLAUDE_CONFIG_DIR, 'system-settings.json');
|
|
1551
|
+
const DEFAULT_SYSTEM_SETTINGS = {
|
|
1552
|
+
containerTimeout: 1800000,
|
|
1553
|
+
idleTimeout: 1800000,
|
|
1554
|
+
containerMaxOutputSize: 10485760,
|
|
1555
|
+
maxConcurrentContainers: 20,
|
|
1556
|
+
maxConcurrentHostProcesses: 5,
|
|
1557
|
+
maxLoginAttempts: 5,
|
|
1558
|
+
loginLockoutMinutes: 15,
|
|
1559
|
+
maxConcurrentScripts: 10,
|
|
1560
|
+
scriptTimeout: 60000,
|
|
1561
|
+
skillAutoSyncEnabled: false,
|
|
1562
|
+
skillAutoSyncIntervalMinutes: 10,
|
|
1563
|
+
billingEnabled: false,
|
|
1564
|
+
billingMode: 'wallet_first',
|
|
1565
|
+
billingMinStartBalanceUsd: 0.01,
|
|
1566
|
+
billingCurrency: 'USD',
|
|
1567
|
+
billingCurrencyRate: 1,
|
|
1568
|
+
};
|
|
1569
|
+
function parseIntEnv(envVar, fallback) {
|
|
1570
|
+
if (!envVar)
|
|
1571
|
+
return fallback;
|
|
1572
|
+
const parsed = parseInt(envVar, 10);
|
|
1573
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
1574
|
+
}
|
|
1575
|
+
function parseFloatEnv(envVar, fallback) {
|
|
1576
|
+
if (!envVar)
|
|
1577
|
+
return fallback;
|
|
1578
|
+
const parsed = parseFloat(envVar);
|
|
1579
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
1580
|
+
}
|
|
1581
|
+
// In-memory cache: avoid synchronous file I/O on hot paths (stdout data handler, queue capacity check)
|
|
1582
|
+
let _settingsCache = null;
|
|
1583
|
+
let _settingsMtimeMs = 0;
|
|
1584
|
+
function readSystemSettingsFromFile() {
|
|
1585
|
+
if (!fs.existsSync(SYSTEM_SETTINGS_FILE))
|
|
1586
|
+
return null;
|
|
1587
|
+
const raw = JSON.parse(fs.readFileSync(SYSTEM_SETTINGS_FILE, 'utf-8'));
|
|
1588
|
+
return {
|
|
1589
|
+
containerTimeout: typeof raw.containerTimeout === 'number' && raw.containerTimeout > 0
|
|
1590
|
+
? raw.containerTimeout
|
|
1591
|
+
: DEFAULT_SYSTEM_SETTINGS.containerTimeout,
|
|
1592
|
+
idleTimeout: typeof raw.idleTimeout === 'number' && raw.idleTimeout > 0
|
|
1593
|
+
? raw.idleTimeout
|
|
1594
|
+
: DEFAULT_SYSTEM_SETTINGS.idleTimeout,
|
|
1595
|
+
containerMaxOutputSize: typeof raw.containerMaxOutputSize === 'number' &&
|
|
1596
|
+
raw.containerMaxOutputSize > 0
|
|
1597
|
+
? raw.containerMaxOutputSize
|
|
1598
|
+
: DEFAULT_SYSTEM_SETTINGS.containerMaxOutputSize,
|
|
1599
|
+
maxConcurrentContainers: typeof raw.maxConcurrentContainers === 'number' &&
|
|
1600
|
+
raw.maxConcurrentContainers > 0
|
|
1601
|
+
? raw.maxConcurrentContainers
|
|
1602
|
+
: DEFAULT_SYSTEM_SETTINGS.maxConcurrentContainers,
|
|
1603
|
+
maxConcurrentHostProcesses: typeof raw.maxConcurrentHostProcesses === 'number' &&
|
|
1604
|
+
raw.maxConcurrentHostProcesses > 0
|
|
1605
|
+
? raw.maxConcurrentHostProcesses
|
|
1606
|
+
: DEFAULT_SYSTEM_SETTINGS.maxConcurrentHostProcesses,
|
|
1607
|
+
maxLoginAttempts: typeof raw.maxLoginAttempts === 'number' && raw.maxLoginAttempts > 0
|
|
1608
|
+
? raw.maxLoginAttempts
|
|
1609
|
+
: DEFAULT_SYSTEM_SETTINGS.maxLoginAttempts,
|
|
1610
|
+
loginLockoutMinutes: typeof raw.loginLockoutMinutes === 'number' && raw.loginLockoutMinutes > 0
|
|
1611
|
+
? raw.loginLockoutMinutes
|
|
1612
|
+
: DEFAULT_SYSTEM_SETTINGS.loginLockoutMinutes,
|
|
1613
|
+
maxConcurrentScripts: typeof raw.maxConcurrentScripts === 'number' &&
|
|
1614
|
+
raw.maxConcurrentScripts > 0
|
|
1615
|
+
? raw.maxConcurrentScripts
|
|
1616
|
+
: DEFAULT_SYSTEM_SETTINGS.maxConcurrentScripts,
|
|
1617
|
+
scriptTimeout: typeof raw.scriptTimeout === 'number' && raw.scriptTimeout > 0
|
|
1618
|
+
? raw.scriptTimeout
|
|
1619
|
+
: DEFAULT_SYSTEM_SETTINGS.scriptTimeout,
|
|
1620
|
+
skillAutoSyncEnabled: typeof raw.skillAutoSyncEnabled === 'boolean'
|
|
1621
|
+
? raw.skillAutoSyncEnabled
|
|
1622
|
+
: DEFAULT_SYSTEM_SETTINGS.skillAutoSyncEnabled,
|
|
1623
|
+
skillAutoSyncIntervalMinutes: typeof raw.skillAutoSyncIntervalMinutes === 'number' &&
|
|
1624
|
+
raw.skillAutoSyncIntervalMinutes >= 1
|
|
1625
|
+
? raw.skillAutoSyncIntervalMinutes
|
|
1626
|
+
: DEFAULT_SYSTEM_SETTINGS.skillAutoSyncIntervalMinutes,
|
|
1627
|
+
billingEnabled: typeof raw.billingEnabled === 'boolean'
|
|
1628
|
+
? raw.billingEnabled
|
|
1629
|
+
: DEFAULT_SYSTEM_SETTINGS.billingEnabled,
|
|
1630
|
+
billingMode: 'wallet_first',
|
|
1631
|
+
billingMinStartBalanceUsd: typeof raw.billingMinStartBalanceUsd === 'number' &&
|
|
1632
|
+
raw.billingMinStartBalanceUsd >= 0
|
|
1633
|
+
? raw.billingMinStartBalanceUsd
|
|
1634
|
+
: DEFAULT_SYSTEM_SETTINGS.billingMinStartBalanceUsd,
|
|
1635
|
+
billingCurrency: typeof raw.billingCurrency === 'string' && raw.billingCurrency
|
|
1636
|
+
? raw.billingCurrency
|
|
1637
|
+
: DEFAULT_SYSTEM_SETTINGS.billingCurrency,
|
|
1638
|
+
billingCurrencyRate: typeof raw.billingCurrencyRate === 'number' && raw.billingCurrencyRate > 0
|
|
1639
|
+
? raw.billingCurrencyRate
|
|
1640
|
+
: DEFAULT_SYSTEM_SETTINGS.billingCurrencyRate,
|
|
1641
|
+
};
|
|
1642
|
+
}
|
|
1643
|
+
function buildEnvFallbackSettings() {
|
|
1644
|
+
return {
|
|
1645
|
+
containerTimeout: parseIntEnv(process.env.CONTAINER_TIMEOUT, DEFAULT_SYSTEM_SETTINGS.containerTimeout),
|
|
1646
|
+
idleTimeout: parseIntEnv(process.env.IDLE_TIMEOUT, DEFAULT_SYSTEM_SETTINGS.idleTimeout),
|
|
1647
|
+
containerMaxOutputSize: parseIntEnv(process.env.CONTAINER_MAX_OUTPUT_SIZE, DEFAULT_SYSTEM_SETTINGS.containerMaxOutputSize),
|
|
1648
|
+
maxConcurrentContainers: parseIntEnv(process.env.MAX_CONCURRENT_CONTAINERS, DEFAULT_SYSTEM_SETTINGS.maxConcurrentContainers),
|
|
1649
|
+
maxConcurrentHostProcesses: parseIntEnv(process.env.MAX_CONCURRENT_HOST_PROCESSES, DEFAULT_SYSTEM_SETTINGS.maxConcurrentHostProcesses),
|
|
1650
|
+
maxLoginAttempts: parseIntEnv(process.env.MAX_LOGIN_ATTEMPTS, DEFAULT_SYSTEM_SETTINGS.maxLoginAttempts),
|
|
1651
|
+
loginLockoutMinutes: parseIntEnv(process.env.LOGIN_LOCKOUT_MINUTES, DEFAULT_SYSTEM_SETTINGS.loginLockoutMinutes),
|
|
1652
|
+
maxConcurrentScripts: parseIntEnv(process.env.MAX_CONCURRENT_SCRIPTS, DEFAULT_SYSTEM_SETTINGS.maxConcurrentScripts),
|
|
1653
|
+
scriptTimeout: parseIntEnv(process.env.SCRIPT_TIMEOUT, DEFAULT_SYSTEM_SETTINGS.scriptTimeout),
|
|
1654
|
+
skillAutoSyncEnabled: process.env.SKILL_AUTO_SYNC_ENABLED === 'true' ||
|
|
1655
|
+
DEFAULT_SYSTEM_SETTINGS.skillAutoSyncEnabled,
|
|
1656
|
+
skillAutoSyncIntervalMinutes: parseIntEnv(process.env.SKILL_AUTO_SYNC_INTERVAL_MINUTES, DEFAULT_SYSTEM_SETTINGS.skillAutoSyncIntervalMinutes),
|
|
1657
|
+
billingEnabled: process.env.BILLING_ENABLED === 'true' ||
|
|
1658
|
+
DEFAULT_SYSTEM_SETTINGS.billingEnabled,
|
|
1659
|
+
billingMode: 'wallet_first',
|
|
1660
|
+
billingMinStartBalanceUsd: parseFloatEnv(process.env.BILLING_MIN_START_BALANCE_USD, DEFAULT_SYSTEM_SETTINGS.billingMinStartBalanceUsd),
|
|
1661
|
+
billingCurrency: process.env.BILLING_CURRENCY || DEFAULT_SYSTEM_SETTINGS.billingCurrency,
|
|
1662
|
+
billingCurrencyRate: parseFloatEnv(process.env.BILLING_CURRENCY_RATE, DEFAULT_SYSTEM_SETTINGS.billingCurrencyRate),
|
|
1663
|
+
};
|
|
1664
|
+
}
|
|
1665
|
+
export function getSystemSettings() {
|
|
1666
|
+
// Fast path: return cached value if file hasn't changed (single stat)
|
|
1667
|
+
if (_settingsCache) {
|
|
1668
|
+
try {
|
|
1669
|
+
const mtimeMs = fs.statSync(SYSTEM_SETTINGS_FILE).mtimeMs;
|
|
1670
|
+
if (mtimeMs === _settingsMtimeMs)
|
|
1671
|
+
return _settingsCache;
|
|
1672
|
+
}
|
|
1673
|
+
catch {
|
|
1674
|
+
return _settingsCache; // file gone or stat failed — cached value is still valid
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
// 1. Try reading from file
|
|
1678
|
+
try {
|
|
1679
|
+
const settings = readSystemSettingsFromFile();
|
|
1680
|
+
if (settings) {
|
|
1681
|
+
_settingsCache = settings;
|
|
1682
|
+
try {
|
|
1683
|
+
_settingsMtimeMs = fs.statSync(SYSTEM_SETTINGS_FILE).mtimeMs;
|
|
1684
|
+
}
|
|
1685
|
+
catch {
|
|
1686
|
+
/* ignore */
|
|
1687
|
+
}
|
|
1688
|
+
return settings;
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
catch (err) {
|
|
1692
|
+
if (err.code !== 'ENOENT') {
|
|
1693
|
+
logger.warn({ err }, 'Failed to read system settings, falling back to env/defaults');
|
|
1694
|
+
}
|
|
1695
|
+
}
|
|
1696
|
+
// 2. Fall back to env vars, then hardcoded defaults
|
|
1697
|
+
const settings = buildEnvFallbackSettings();
|
|
1698
|
+
_settingsCache = settings;
|
|
1699
|
+
_settingsMtimeMs = 0; // no file — will re-check on next call
|
|
1700
|
+
return settings;
|
|
1701
|
+
}
|
|
1702
|
+
export function saveSystemSettings(partial) {
|
|
1703
|
+
const existing = getSystemSettings();
|
|
1704
|
+
const merged = { ...existing, ...partial };
|
|
1705
|
+
// Range validation
|
|
1706
|
+
if (merged.containerTimeout < 60000)
|
|
1707
|
+
merged.containerTimeout = 60000; // min 1 min
|
|
1708
|
+
if (merged.containerTimeout > 86400000)
|
|
1709
|
+
merged.containerTimeout = 86400000; // max 24 hours
|
|
1710
|
+
if (merged.idleTimeout < 60000)
|
|
1711
|
+
merged.idleTimeout = 60000;
|
|
1712
|
+
if (merged.idleTimeout > 86400000)
|
|
1713
|
+
merged.idleTimeout = 86400000;
|
|
1714
|
+
if (merged.containerMaxOutputSize < 1048576)
|
|
1715
|
+
merged.containerMaxOutputSize = 1048576; // min 1MB
|
|
1716
|
+
if (merged.containerMaxOutputSize > 104857600)
|
|
1717
|
+
merged.containerMaxOutputSize = 104857600; // max 100MB
|
|
1718
|
+
if (merged.maxConcurrentContainers < 1)
|
|
1719
|
+
merged.maxConcurrentContainers = 1;
|
|
1720
|
+
if (merged.maxConcurrentContainers > 100)
|
|
1721
|
+
merged.maxConcurrentContainers = 100;
|
|
1722
|
+
if (merged.maxConcurrentHostProcesses < 1)
|
|
1723
|
+
merged.maxConcurrentHostProcesses = 1;
|
|
1724
|
+
if (merged.maxConcurrentHostProcesses > 50)
|
|
1725
|
+
merged.maxConcurrentHostProcesses = 50;
|
|
1726
|
+
if (merged.maxLoginAttempts < 1)
|
|
1727
|
+
merged.maxLoginAttempts = 1;
|
|
1728
|
+
if (merged.maxLoginAttempts > 100)
|
|
1729
|
+
merged.maxLoginAttempts = 100;
|
|
1730
|
+
if (merged.loginLockoutMinutes < 1)
|
|
1731
|
+
merged.loginLockoutMinutes = 1;
|
|
1732
|
+
if (merged.loginLockoutMinutes > 1440)
|
|
1733
|
+
merged.loginLockoutMinutes = 1440; // max 24 hours
|
|
1734
|
+
if (merged.maxConcurrentScripts < 1)
|
|
1735
|
+
merged.maxConcurrentScripts = 1;
|
|
1736
|
+
if (merged.maxConcurrentScripts > 50)
|
|
1737
|
+
merged.maxConcurrentScripts = 50;
|
|
1738
|
+
if (merged.scriptTimeout < 5000)
|
|
1739
|
+
merged.scriptTimeout = 5000; // min 5s
|
|
1740
|
+
if (merged.scriptTimeout > 600000)
|
|
1741
|
+
merged.scriptTimeout = 600000; // max 10 min
|
|
1742
|
+
if (merged.skillAutoSyncIntervalMinutes < 1)
|
|
1743
|
+
merged.skillAutoSyncIntervalMinutes = 1;
|
|
1744
|
+
if (merged.skillAutoSyncIntervalMinutes > 1440)
|
|
1745
|
+
merged.skillAutoSyncIntervalMinutes = 1440; // max 24h
|
|
1746
|
+
merged.billingMode = 'wallet_first';
|
|
1747
|
+
if (merged.billingMinStartBalanceUsd < 0)
|
|
1748
|
+
merged.billingMinStartBalanceUsd =
|
|
1749
|
+
DEFAULT_SYSTEM_SETTINGS.billingMinStartBalanceUsd;
|
|
1750
|
+
if (merged.billingMinStartBalanceUsd > 1000000)
|
|
1751
|
+
merged.billingMinStartBalanceUsd = 1000000;
|
|
1752
|
+
fs.mkdirSync(CLAUDE_CONFIG_DIR, { recursive: true });
|
|
1753
|
+
const tmp = `${SYSTEM_SETTINGS_FILE}.tmp`;
|
|
1754
|
+
fs.writeFileSync(tmp, JSON.stringify(merged, null, 2) + '\n', 'utf-8');
|
|
1755
|
+
fs.renameSync(tmp, SYSTEM_SETTINGS_FILE);
|
|
1756
|
+
// Update in-memory cache immediately
|
|
1757
|
+
_settingsCache = merged;
|
|
1758
|
+
try {
|
|
1759
|
+
_settingsMtimeMs = fs.statSync(SYSTEM_SETTINGS_FILE).mtimeMs;
|
|
1760
|
+
}
|
|
1761
|
+
catch {
|
|
1762
|
+
/* ignore */
|
|
1763
|
+
}
|
|
1764
|
+
return merged;
|
|
1765
|
+
}
|
|
1766
|
+
/**
|
|
1767
|
+
* 解析 OAuth usage bucket 对象
|
|
1768
|
+
* 运行时类型守卫,验证 API 响应结构
|
|
1769
|
+
*/
|
|
1770
|
+
export function parseOAuthUsageBucket(v) {
|
|
1771
|
+
if (!v || typeof v !== 'object')
|
|
1772
|
+
return null;
|
|
1773
|
+
const obj = v;
|
|
1774
|
+
if (typeof obj.utilization !== 'number' || typeof obj.resets_at !== 'string')
|
|
1775
|
+
return null;
|
|
1776
|
+
return { utilization: obj.utilization, resets_at: obj.resets_at };
|
|
1777
|
+
}
|