Files
c020627 2fd55b34f3 fix: read user-edited config files as utf-8-sig (BOM tolerance)
Windows Notepad / PowerShell write UTF-8 with a BOM by default, and the JSON /
txt files CowAgent asks users to edit by hand are exactly the ones that get
re-saved that way. Plain `utf-8` turns the leading BOM into a `\ufeff`
character, and what happens next depends on the reader:

- `plugins/*/config.json` (godcmd's is created on first run for the operator
  to fill in): `json.load` raises "Unexpected UTF-8 BOM", the plugin's
  `__init__` fails, and `PluginManager.activate_plugins` answers a failed init
  by disabling the plugin and **persisting** `enabled=false` -- so it stayed
  off across restarts even after the file was put right.
- `banwords.txt`: the first line is read as `\ufeffword`, `str.strip()` does
  not remove U+FEFF (it is not whitespace), so the word is registered with the
  BOM glued to it and can never match. No error -- the first word of the list
  is silently unfiltered.
- `skills_config.json`: the read helper swallows the error and answers `{}`,
  and `cow skill list` rebuilds the file assuming `enabled: True` -- a skill
  the user had turned off comes back on, silently.
- `config.json` in `cow backup`: same swallow, so every caller treats the
  install as unconfigured and `cow backup` archives `~/cow` instead of the
  configured `agent_workspace`. `restore` hits the same read on the copy inside
  the archive, where it is not caught and aborts the restore.
- `mcp.json`: `_read_mcp_file` wraps it as `McpConfigError("mcp.json is not
  valid JSON: ...")`, which names the JSON rather than the BOM, so the file
  looks fine in an editor.

Apply the convention `agent/admin.py` already documents -- "utf-8-sig tolerates
a UTF-8 BOM (e.g. config.json edited with Windows Notepad / PowerShell)" -- at
every one of those read sites, replacing what were five separate PRs (#3419,
#3421, #3422, #3423) with one change.

`Plugin.load_config` also restores the `if os.path.exists(plugin_config_path)`
check inside the `if not plugin_conf` block that assigns `plugin_config_path`:
pulled out of it, the check ran on a name that was never assigned whenever
`pconf()` already returned a config, i.e. for every plugin with an entry in
`plugins/config.json`, and `load_config` raised `UnboundLocalError` -- the same
"plugin gets disabled" outcome the change was meant to avoid.
`tests/test_plugin_config_defaults.py` covers that case; it fails on the
previous revision with `UnboundLocalError: cannot access local variable
'plugin_config_path' where it is not associated with a value`.
2026-10-01 15:26:12 +08:00

50 lines
1.7 KiB
Python

import os
import json
from config import pconf, plugin_config, conf, write_plugin_config # noqa: F401 # re-exported via `from .plugin import *`
from common.atomic_write import write_json_atomic
from common.log import logger
class Plugin:
def __init__(self):
self.handlers = {}
def load_config(self) -> dict:
"""
加载当前插件配置
:return: 插件配置字典
"""
# 优先获取 plugins/config.json 中的全局配置
plugin_conf = pconf(self.name)
if not plugin_conf:
# 全局配置不存在,则获取插件目录下的配置
plugin_config_path = os.path.join(self.path, "config.json")
if os.path.exists(plugin_config_path):
with open(plugin_config_path, "r", encoding="utf-8-sig") as f:
plugin_conf = json.load(f)
# 写入全局配置内存
write_plugin_config({self.name: plugin_conf})
return plugin_conf
def save_config(self, config: dict):
try:
write_plugin_config({self.name: config})
# 写入全局配置
global_config_path = "./plugins/config.json"
if os.path.exists(global_config_path):
write_json_atomic(global_config_path, plugin_config)
# 写入插件配置
plugin_config_path = os.path.join(self.path, "config.json")
if os.path.exists(plugin_config_path):
write_json_atomic(plugin_config_path, config)
except Exception as e:
logger.warn("save plugin config failed: {}".format(e))
def get_help_text(self, **kwargs):
return "暂无帮助信息"
def reload(self):
pass