From c9e25bc32ac2e3dabb06d35b6b01a5e49c4e877c Mon Sep 17 00:00:00 2001 From: Jason HONG <136784169+hongzexin@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:12:19 +0800 Subject: [PATCH] fix(runtime): harden mcodex config and daemon startup --- README.md | 11 +++++-- corp_codex_pool/cli.py | 16 +++++----- corp_codex_pool/codex_config.py | 34 ++++++++++++++++++++ corp_codex_pool/multica.py | 55 +++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- tests/test_codex_config.py | 36 +++++++++++++++++++++ tests/test_multica.py | 39 +++++++++++++++++++++++ 7 files changed, 182 insertions(+), 11 deletions(-) create mode 100644 tests/test_multica.py diff --git a/README.md b/README.md index 1704c09..696288d 100644 --- a/README.md +++ b/README.md @@ -323,9 +323,14 @@ poolctl daemon restart poolctl daemon status ``` -multica 只能通过 `MULTICA_CODEX_PATH` 环境变量指定 codex 路径(它的 `config.json` 只支持 `server_url` / `app_url` / `workspace_id` 三个键)。直接 `multica daemon start` 拿不到这个变量,daemon 会**悄悄退回系统 codex、绕开号池**。 - -> ⚠️ **别用 `multica daemon restart` 代替 `poolctl daemon restart`。** 前者由已在运行的 daemon 自己拉起新进程,会继承旧环境,新设的变量传不进去。`poolctl daemon restart` 是 stop 再 start。 +`poolctl daemon start/restart` 会将 `mcodex` 绝对路径持久化到 +`~/.multica/config.json` 的 `backends.codex.binary_path`,同时传入 +`MULTICA_CODEX_PATH` 以兼容旧版 Multica。持久化后,直接运行 +`multica daemon start` 也会继续使用 `mcodex`。 + +> ⚠️ **旧版 Multica 仍请使用 `poolctl daemon restart`。** 旧版不读取 +> `backends.codex.binary_path`,而 `multica daemon restart` 只会继承旧进程环境。 +> 待 Multica 支持该持久化配置后,两种启动方式都会继续使用 `mcodex`。 --- diff --git a/corp_codex_pool/cli.py b/corp_codex_pool/cli.py index 253d9c2..3c505d6 100644 --- a/corp_codex_pool/cli.py +++ b/corp_codex_pool/cli.py @@ -33,7 +33,7 @@ summarize_by_key, summarize_by_session, ) -from .multica import MulticaClient, MulticaError +from .multica import MulticaClient, MulticaError, persist_codex_binary_path CONTEXT = {"help_option_names": ["-h", "--help"]} @@ -681,11 +681,11 @@ def enroll(ctx, inherit, home, real_codex, restart_daemon): click.echo(f" mcodex:{info['config']}") click.echo(" 凭证:任务启动时自动申请,任务外无法使用") + env = _daemon_env(settings) if not restart_daemon: click.echo("\n下一步:poolctl daemon restart") return - env = _daemon_env(settings) subprocess.run(["multica", "daemon", "stop"], env=env, check=False) result = subprocess.run(["multica", "daemon", "start"], env=env, check=False) if result.returncode != 0: @@ -697,11 +697,9 @@ def enroll(ctx, inherit, home, real_codex, restart_daemon): def daemon(): """带号池配置启停 multica daemon。 - multica 只能通过 MULTICA_CODEX_PATH 环境变量指定 codex 可执行文件, - 它的 config.json 不支持这个键。直接 `multica daemon start` 拿不到这个 - 变量,daemon 就会退回系统 codex、绕开号池。 - - 这组命令在启动前把变量设好,避免依赖 shell profile 或人工记忆。 + 启动前同时持久化 backends.codex.binary_path 并设置 + MULTICA_CODEX_PATH。前者保证之后直接 `multica daemon start` + 仍使用 mcodex,后者兼容尚未支持持久化路径的旧版 Multica。 """ @@ -712,6 +710,10 @@ def _daemon_env(settings) -> dict[str, str]: found = _shutil.which("mcodex") if not found: _fail("PATH 里找不到 mcodex", "先 pip install -e .") + try: + persist_codex_binary_path(found) + except MulticaError as exc: + _fail(str(exc), "先运行 multica login 并检查 ~/.multica/config.json") return {**os.environ, "MULTICA_CODEX_PATH": found} diff --git a/corp_codex_pool/codex_config.py b/corp_codex_pool/codex_config.py index a734a43..2b81b3a 100644 --- a/corp_codex_pool/codex_config.py +++ b/corp_codex_pool/codex_config.py @@ -33,6 +33,13 @@ # 行首的 [table] 或 [[array-of-table]] 头 _TABLE_RE = re.compile(r"^\s*\[\[?[^\[\]]", re.MULTILINE) +_TABLE_HEADER_RE = re.compile( + r"^\s*\[([^\[\]]+)\]\s*(?:#.*)?(?:\r?\n)?$" +) +_TOP_LEVEL_MODEL_PROVIDER_RE = re.compile( + r"^[ \t]*model_provider[ \t]*=[^\n]*(?:\n|$)", + re.MULTILINE, +) class ConfigInjectionError(RuntimeError): @@ -149,9 +156,36 @@ def _insert_position(text: str) -> int: return match.start() if match else len(text) +def _strip_conflicting_entries(text: str, spec: ProviderSpec) -> str: + """Remove entries owned by the managed provider block. + + A per-task config is copied from the host before mcodex injects its block. + The host may already select another provider (for example the official GUI + integration) or contain an older unmanaged definition of this provider. + Keeping either entry would make the resulting TOML redefine a key/table. + """ + if spec.set_default: + pos = _insert_position(text) + head = _TOP_LEVEL_MODEL_PROVIDER_RE.sub("", text[:pos]) + text = head + text[pos:] + + provider_table = f"model_providers.{spec.provider_id}" + output: list[str] = [] + skipping = False + for line in text.splitlines(keepends=True): + match = _TABLE_HEADER_RE.match(line) + if match: + table = match.group(1).strip() + skipping = table == provider_table or table.startswith(provider_table + ".") + if not skipping: + output.append(line) + return "".join(output) + + def build_new_text(original: str, spec: ProviderSpec) -> str: """算出注入后的完整文件内容(纯函数,便于测试)。""" body = strip_block(original) + body = _strip_conflicting_entries(body, spec) block = render_block(spec) pos = _insert_position(body) diff --git a/corp_codex_pool/multica.py b/corp_codex_pool/multica.py index fa287ec..5873d33 100644 --- a/corp_codex_pool/multica.py +++ b/corp_codex_pool/multica.py @@ -17,6 +17,8 @@ from __future__ import annotations import json +import os +import tempfile from dataclasses import dataclass from pathlib import Path from typing import Any @@ -43,6 +45,59 @@ def load_local_config(path: Path | None = None) -> dict[str, Any]: return json.loads(path.read_text(encoding="utf-8")) +def persist_codex_binary_path( + binary_path: str | Path, + config_path: Path | None = None, +) -> bool: + """Persist the daemon's Codex executable without dropping other config. + + Newer Multica daemons read ``backends.codex.binary_path`` directly. The + pool wrapper still exports ``MULTICA_CODEX_PATH`` for compatibility with + older daemons, but persisting the same path makes a later plain + ``multica daemon start`` retain mcodex. + """ + binary = Path(binary_path).expanduser() + if not binary.is_absolute(): + raise MulticaError(f"mcodex 路径必须是绝对路径:{binary}") + + path = config_path or Path.home() / ".multica" / "config.json" + config = load_local_config(path) + backends = config.setdefault("backends", {}) + if not isinstance(backends, dict): + raise MulticaError("multica 配置中 backends 必须是对象") + codex = backends.setdefault("codex", {}) + if not isinstance(codex, dict): + raise MulticaError("multica 配置中 backends.codex 必须是对象") + + normalized = str(binary.resolve(strict=False)) + if codex.get("binary_path") == normalized: + return False + codex["binary_path"] = normalized + + temp_path: Path | None = None + try: + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + dir=path.parent, + prefix=".config-", + suffix=".json.tmp", + delete=False, + ) as temp: + temp_path = Path(temp.name) + json.dump(config, temp, ensure_ascii=False, indent=2) + temp.write("\n") + temp.flush() + os.fsync(temp.fileno()) + temp_path.chmod(0o600) + os.replace(temp_path, path) + except OSError as exc: + if temp_path is not None: + temp_path.unlink(missing_ok=True) + raise MulticaError(f"写入 multica 配置失败:{exc}") from exc + return True + + class MulticaClient: def __init__(self, server_url: str, token: str, workspace_id: str, timeout: float = 30.0): if not token: diff --git a/pyproject.toml b/pyproject.toml index ccc6f19..514fa72 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "corp-codex-pool" -version = "0.3.0" +version = "0.3.1" description = "Codex 号池与 multica 运行时的集成层:provider 注入、密钥下发、用量对账" requires-python = ">=3.11" dependencies = [ diff --git a/tests/test_codex_config.py b/tests/test_codex_config.py index 73b86af..ab6cfa7 100644 --- a/tests/test_codex_config.py +++ b/tests/test_codex_config.py @@ -141,6 +141,42 @@ def test_strip_restores_original(self): injected = build_new_text(REAL_CONFIG, spec()) assert tomllib.loads(strip_block(injected)) == tomllib.loads(REAL_CONFIG) + def test_existing_default_provider_is_replaced_without_duplicate_key(self): + src = '''\ +model_provider = "chek" + +[model_providers.chek] +name = "Official managed access" +base_url = "https://official.example.com/v1" +wire_api = "responses" +requires_openai_auth = true +''' + + data = tomllib.loads(build_new_text(src, spec())) + + assert data["model_provider"] == "gw" + assert data["model_providers"]["chek"]["requires_openai_auth"] is True + assert data["model_providers"]["gw"]["base_url"] == "https://gw.example.com/v1" + + def test_existing_unmanaged_same_provider_is_replaced(self): + src = '''\ +model_provider = "gw" + +[model_providers.gw] +name = "Old pool" +base_url = "https://old.example.com/v1" +wire_api = "responses" +env_key = "OLD_POOL_KEY" +''' + + out = build_new_text(src, spec()) + data = tomllib.loads(out) + + assert out.count("model_provider =") == 1 + assert out.count("[model_providers.gw]") == 1 + assert data["model_providers"]["gw"]["base_url"] == "https://gw.example.com/v1" + assert data["model_providers"]["gw"]["env_key"] == "GW_API_KEY" + class TestValidation: def test_wire_api_chat_rejected(self): diff --git a/tests/test_multica.py b/tests/test_multica.py new file mode 100644 index 0000000..e3ef98c --- /dev/null +++ b/tests/test_multica.py @@ -0,0 +1,39 @@ +import json + +import pytest + +from corp_codex_pool.multica import MulticaError, persist_codex_binary_path + + +def test_persist_codex_binary_path_preserves_existing_config(tmp_path): + config_path = tmp_path / "config.json" + config_path.write_text( + json.dumps( + { + "server_url": "https://multica.example.com", + "token": "secret-sentinel", + "future_field": {"keep": True}, + "backends": {"openclaw": {"state_dir": "/var/lib/openclaw"}}, + } + ), + encoding="utf-8", + ) + + changed = persist_codex_binary_path("/opt/company/bin/mcodex", config_path) + + assert changed is True + saved = json.loads(config_path.read_text(encoding="utf-8")) + assert saved["backends"]["codex"]["binary_path"] == "/opt/company/bin/mcodex" + assert saved["backends"]["openclaw"]["state_dir"] == "/var/lib/openclaw" + assert saved["future_field"] == {"keep": True} + assert saved["token"] == "secret-sentinel" + assert config_path.stat().st_mode & 0o777 == 0o600 + assert persist_codex_binary_path("/opt/company/bin/mcodex", config_path) is False + + +def test_persist_codex_binary_path_rejects_relative_path(tmp_path): + config_path = tmp_path / "config.json" + config_path.write_text("{}", encoding="utf-8") + + with pytest.raises(MulticaError, match="绝对路径"): + persist_codex_binary_path("relative/mcodex", config_path)