diff --git a/.memory/worklog.json b/.memory/worklog.json index 135fec6..4541b37 100644 --- a/.memory/worklog.json +++ b/.memory/worklog.json @@ -1,12 +1,5 @@ { "entries": [ - { - "files_changed": 1, - "hash": "a4b2ec6", - "message": "auto-save 2026-05-10 10:13 (~1)", - "ts": "2026-05-10T10:13:19+08:00", - "type": "commit" - }, { "files_changed": 1, "message": "Codex 会话活跃 · 最近命令:codex · 分支 master · 1 项未提交变更 · 最近提交:auto-save 2026-05-10 10:13 (~1)", @@ -3271,6 +3264,13 @@ "type": "session-heartbeat", "message": "Codex 会话活跃 · 最近命令:codex · 分支 master · 1 项未提交变更 · 最近提交:auto-save 2026-05-11 19:04 (~1)", "files_changed": 1 + }, + { + "ts": "2026-05-11T19:10:01+08:00", + "type": "commit", + "message": "auto-save 2026-05-11 19:09 (~1)", + "hash": "77d800d", + "files_changed": 1 } ] } diff --git a/RULES.md b/RULES.md index 1640e10..1d7cd0e 100644 --- a/RULES.md +++ b/RULES.md @@ -21,6 +21,7 @@ - 爱马仕前端「设置 → 连接」可自助维护 API 地址 / API Key 并测试连接;「对话 → 存周报」和「设置 → 周报记录」会在本地保存任务描述、上下文片段和最终周报 - 爱马仕前端「模型」可维护 AI 模型 Profiles,用于给不同 Agent 绑定不同模型、Provider、Base URL 和服务器端 Key 引用 - 爱马仕前端「提供商」可维护 LXC 内 `/opt/hermes-agent/config.yaml` 的 `model` 块,保存后重启 Docker `hermes-agent` +- 爱马仕前端「提供商 → Provider API Keys」可写入 / 清除服务器环境变量里的 AI Provider Key;真实 Key 不回显、不入库 - 爱马仕前端「工具 → MCP 工具接入」可维护 LXC 内 `/opt/hermes-agent/config.yaml` 的 `mcp_servers` 块,保存后重启 Docker `hermes-agent` - 飞书、模型、MCP、共享 Agent/Profile 等 `/feishu/*` 管理接口复用网页登录 cookie,并在前端 401 时走 `/_auth/verify` 静默续期 - 当前前端不再启用 Service Worker 静态壳缓存;`sw.js` 仅用于清理旧 `hermes-ui-*` 缓存并注销旧注册 @@ -68,5 +69,6 @@ - 飞书后台配置所需回调 URL、verification token、notify token 备份:`/root/hermes-feishu-bridge.tokens`,mode 600 - 飞书自助配置接口 `POST /feishu/apps`、`DELETE /feishu/apps/{app_id}` 要求已登录爱马仕 cookie 且同源请求;未登录公网请求返回 401;后台 Origin 白名单默认包含当前请求 Host,可用 `HERMES_ADMIN_ORIGINS` 扩展 - Hermes 运行配置接口 `/feishu/hermes-config` 复用飞书桥接反代,要求已登录爱马仕 cookie 且同源请求;会通过 Incus 写入 LXC 配置并重启 `hermes-agent` +- Provider Key 管理接口 `/feishu/provider-keys` 复用飞书桥接反代,要求已登录爱马仕 cookie 且同源请求;只允许管理形如 `*_API_KEY` 的 Provider 环境变量,禁止管理飞书和部署控制变量 - 当前飞书桥接版本按明文事件回调处理;如果飞书后台开启事件加密,需要先补充解密支持 - 主站有 cookie 门禁;nginx 已对 `/feishu/` 单独放行并反代到飞书桥接服务 diff --git a/server/feishu_bridge.py b/server/feishu_bridge.py index 84f044e..4d32702 100644 --- a/server/feishu_bridge.py +++ b/server/feishu_bridge.py @@ -228,6 +228,9 @@ def reload_config_from_env_file() -> None: Config.feishu_app_secret = _env("FEISHU_APP_SECRET") Config.feishu_apps = _load_feishu_apps() Config.default_feishu_app_id = _env("FEISHU_DEFAULT_APP_ID", Config.feishu_app_id) + Config.hermes_api_base = _env("HERMES_API_BASE", Config.hermes_api_base).rstrip("/") + Config.hermes_api_key = _env("HERMES_API_KEY") + Config.hermes_model = _env("HERMES_MODEL", Config.hermes_model) def sync_tokens_file() -> None: @@ -1140,6 +1143,125 @@ def is_admin_request(headers: dict[str, str]) -> tuple[bool, int, str]: return True, 200, "ok" +COMMON_PROVIDER_KEYS = [ + "HERMES_API_KEY", + "OPENROUTER_API_KEY", + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "GOOGLE_API_KEY", + "GEMINI_API_KEY", + "DEEPSEEK_API_KEY", + "XAI_API_KEY", + "GROQ_API_KEY", + "DASHSCOPE_API_KEY", +] + + +def provider_env_key(raw: Any) -> str: + key = str(raw or "").strip() + if key.startswith("env:"): + key = key[4:].strip() + if not re.match(r"^[A-Z][A-Z0-9_]{1,120}$", key): + return "" + return key + + +def validate_provider_env_key(raw: Any) -> str: + key = provider_env_key(raw) + if not key: + raise ValueError("env key must look like OPENROUTER_API_KEY") + blocked_prefixes = ("FEISHU_", "HERMES_ADMIN_", "FEISHU_BRIDGE_") + blocked_keys = {"PORT", "PATH", "HOME", "SHELL", "PWD"} + if key in blocked_keys or key.startswith(blocked_prefixes): + raise ValueError("this env key is managed by deployment, not by provider settings") + if not key.endswith("_API_KEY"): + raise ValueError("provider env key must end with _API_KEY") + return key + + +def provider_key_profiles() -> dict[str, list[str]]: + result: dict[str, list[str]] = {} + try: + config = read_ui_config_file() + except Exception: + logging.warning("provider key profile map unavailable:\n%s", traceback.format_exc()) + return result + profiles = config.get("modelProfiles") if isinstance(config.get("modelProfiles"), list) else [] + for profile in profiles: + if not isinstance(profile, dict): + continue + key = provider_env_key(profile.get("apiKeyRef")) + if not key: + continue + name = str(profile.get("name") or profile.get("model") or "Profile")[:80] + result.setdefault(key, []).append(name) + return result + + +def handle_provider_keys_get(headers: dict[str, str]) -> tuple[int, dict[str, Any]]: + ok, status, message = is_admin_request(headers) + if not ok: + return status, {"code": status, "msg": message} + try: + env = parse_env_file(Config.env_file) + profile_map = provider_key_profiles() + keys = set(COMMON_PROVIDER_KEYS) | set(profile_map.keys()) + payload = [] + for key in sorted(keys): + present = bool(os.environ.get(key) or env.get(key)) + payload.append({ + "key": key, + "present": present, + "profiles": profile_map.get(key, []), + }) + return 200, {"code": 0, "msg": "ok", "keys": payload} + except Exception as exc: + logging.error("failed to read provider keys:\n%s", traceback.format_exc()) + return 500, {"code": 500, "msg": str(exc)} + + +def handle_provider_keys_post(headers: dict[str, str], body: dict[str, Any]) -> tuple[int, dict[str, Any]]: + ok, status, message = is_admin_request(headers) + if not ok: + return status, {"code": status, "msg": message} + try: + key = validate_provider_env_key(body.get("key")) + value = str(body.get("value") or body.get("api_key") or "").strip() + if not value: + raise ValueError("api key value is required") + if "\n" in value or "\r" in value: + raise ValueError("api key value must be a single line") + if len(value) > 10000: + raise ValueError("api key value is too large") + write_env_updates(Config.env_file, {key: value}) + reload_config_from_env_file() + logging.info("updated provider env key %s", key) + return 200, {"code": 0, "msg": "ok", "key": {"key": key, "present": True}} + except ValueError as exc: + return 400, {"code": 400, "msg": str(exc)} + except Exception as exc: + logging.error("failed to write provider key:\n%s", traceback.format_exc()) + return 500, {"code": 500, "msg": str(exc)} + + +def handle_provider_keys_delete(path: str, headers: dict[str, str]) -> tuple[int, dict[str, Any]]: + ok, status, message = is_admin_request(headers) + if not ok: + return status, {"code": status, "msg": message} + try: + raw_key = urllib.parse.unquote(path[len("/feishu/provider-keys/") :].strip("/")) + key = validate_provider_env_key(raw_key) + write_env_removals(Config.env_file, {key}) + reload_config_from_env_file() + logging.info("removed provider env key %s", key) + return 200, {"code": 0, "msg": "ok", "key": {"key": key, "present": False}} + except ValueError as exc: + return 400, {"code": 400, "msg": str(exc)} + except Exception as exc: + logging.error("failed to remove provider key:\n%s", traceback.format_exc()) + return 500, {"code": 500, "msg": str(exc)} + + def validate_feishu_credentials(app_id: str, app_secret: str) -> None: data = http_json( "POST", @@ -1348,6 +1470,10 @@ class Handler(BaseHTTPRequestHandler): status, payload = handle_ui_config_get(headers) self.send_json(status, payload) return + if self.path == "/feishu/provider-keys": + status, payload = handle_provider_keys_get(headers) + self.send_json(status, payload) + return self.send_json(404, {"code": 404, "msg": "not found"}) def do_POST(self) -> None: @@ -1362,6 +1488,8 @@ class Handler(BaseHTTPRequestHandler): status, payload = handle_hermes_config_post(headers, body) elif self.path == "/feishu/ui-config": status, payload = handle_ui_config_post(headers, body) + elif self.path == "/feishu/provider-keys": + status, payload = handle_provider_keys_post(headers, body) elif self.path == "/feishu/chat/completions": ok, status, message = is_admin_request(headers) if not ok: @@ -1383,6 +1511,8 @@ class Handler(BaseHTTPRequestHandler): headers = {key.lower(): value for key, value in self.headers.items()} if self.path.startswith("/feishu/apps/"): status, payload = handle_apps_delete(self.path, headers) + elif self.path.startswith("/feishu/provider-keys/"): + status, payload = handle_provider_keys_delete(self.path, headers) else: status, payload = 404, {"code": 404, "msg": "not found"} except Exception as exc: diff --git a/src/app.js b/src/app.js index 6dd5a71..b987d77 100644 --- a/src/app.js +++ b/src/app.js @@ -13,6 +13,7 @@ const LS_TAB = "hermes-ui-active-tab-v1"; const LS_WEEKLY_REPORTS = "hermes-ui-weekly-reports-v1"; const LS_MODEL_PROFILES = "hermes-ui-model-profiles-v1"; const UI_CONFIG_ENDPOINT = "/feishu/ui-config"; +const PROVIDER_KEYS_ENDPOINT = "/feishu/provider-keys"; const DEFAULT_MODEL_ID = "google/gemini-3.1-pro-preview"; const LEGACY_DEFAULT_MODEL_ID = "gemini-3-pro-preview"; @@ -666,6 +667,7 @@ function switchTab(name, options = {}) { } if (name === "providers") { refreshHermesConfig(); + refreshProviderKeys(); } if (name === "tools") { refreshTools(); @@ -894,6 +896,8 @@ async function testApiConnection() { let _hermesConfigLoaded = false; let _hermesConfigLoading = false; let _hermesConfigSnapshot = null; +let _providerKeysLoaded = false; +let _providerKeysLoading = false; function setSettingsStatus(id, text, isError = false) { const el = document.getElementById(id); if (!el) return; @@ -910,6 +914,141 @@ function setHermesConfigStatuses(text, isError = false) { setHermesModelStatus(text, isError); setHermesMcpStatus(text, isError); } +function setProviderKeyStatus(text, isError = false) { + setSettingsStatus("providerKeyStatus", text, isError); +} + +function providerKeyBadge(present) { + return present + ? '已配置' + : '未配置'; +} + +function renderProviderKeys(keys) { + const box = document.getElementById("providerKeysList"); + if (!box) return; + if (!Array.isArray(keys) || !keys.length) { + box.innerHTML = '
GET /v1/models + localStorage 聚合GET/POST /feishu/ui-configGET/PUT /feishu/hermes-config 的 model 块GET/POST /feishu/hermes-config + /feishu/provider-keysGET /hermes-skills/ autoindex JSONGET /memory/SOUL.mdGET /memory/sessions.json 入口聚合