From 1f9371dc3e987614db533ff42f450a7b7b212f8d Mon Sep 17 00:00:00 2001 From: "lusendong.6789" Date: Fri, 13 Nov 2026 18:50:44 +0800 Subject: [PATCH] fix(core): keep generated commands on the selected installation Signed-off-by: lusendong.6789 Co-authored-by: TRAE CLI --- .../rfcs/monorepo-distribution-split-v0.md | 29 +++++ .../monorepo-distribution-split-v0.zh-CN.md | 21 ++++ loopx/bootstrap_command_pack.py | 4 +- loopx/cli_runtime.py | 6 +- loopx/command_invocation.py | 20 +++- loopx/doctor.py | 14 ++- loopx/release_candidate.py | 26 ++++- packages/loopx-core/README.md | 7 +- tests/test_core_distribution.py | 107 ++++++++++++++++++ 9 files changed, 219 insertions(+), 15 deletions(-) diff --git a/docs/architecture/rfcs/monorepo-distribution-split-v0.md b/docs/architecture/rfcs/monorepo-distribution-split-v0.md index 1c3ef3a647..0b0c5c198f 100644 --- a/docs/architecture/rfcs/monorepo-distribution-split-v0.md +++ b/docs/architecture/rfcs/monorepo-distribution-split-v0.md @@ -3780,3 +3780,32 @@ Unicode readback through the real LoopX reader with a synthetic Desktop peer. This qualifies optional record reading, not model-context retention, managed V4/R1, split-package M3, installed-App adoption or complete costs. The original Goal regression, acceptance gaps, authority and frozen failures remain intact. + +### 2026-11-13 — Standalone continuation retains its installation + +Standalone startup previously returned an ambient `loopx` command even when +the caller selected an isolated core interpreter. Executing that complete +command could import another installation through PATH/PYTHONPATH. Earlier +installed tests discarded the executable and replayed its arguments, hiding +the failure. Core-generated commands now retain the same environment's Python +with `-I -m loopx.entrypoint` through the existing invocation owner. An explicit +global `--cli-bin` and ordinary-distribution defaults retain their behavior; +invalid arguments retain complete parser diagnostics. Multi-Goal selection +uses the same command renderer. + +Core module diagnosis verifies its actual imported distribution, recorded +module and present recorded console entrypoints. Version/catalog/help probes +use the same isolated module invocation, so a foreign PYTHONPATH cannot +silently change the artifact being checked. Missing or redirected module +records, absent console records/files, unrecorded launchers and missing runtime +files remain failures. The installed qualification executes whole returned +activation and diagnosis commands, with a foreign-package sentinel and an +explicit-route control, alongside existing SQLite/File recovery and context +checks. This repairs command provenance without adding a lifecycle, authority, +configuration or protocol owner. + +Full R1/V1–V5/V10, real model recovery/adoption, progressive product control, +minimal dependency closure, M3/Standard/workspace/meta packaging and complete +cost acceptance remain open. This does not upgrade an installed host, start a +model service or establish effectiveness from protocol fixtures. Preserve all +nine original requirements, the original-Goal regression and earlier failures. diff --git a/docs/architecture/rfcs/monorepo-distribution-split-v0.zh-CN.md b/docs/architecture/rfcs/monorepo-distribution-split-v0.zh-CN.md index d52a84ed32..018725cfd1 100644 --- a/docs/architecture/rfcs/monorepo-distribution-split-v0.zh-CN.md +++ b/docs/architecture/rfcs/monorepo-distribution-split-v0.zh-CN.md @@ -2949,3 +2949,24 @@ Python 3.11 wheel 现已通过真实 LoopX reader 和合成 Desktop peer,验 成功响应断流恢复、拒绝响应断流不重试及完整 Unicode 回读。这只资格化可选记录 读取,不证明模型上下文保留、受管理 V4/R1、拆包 M3、已安装 App 采用或完整成本。 原目标回归、未完成验收、权限边界与冻结失败全部保持。 + +### 2026-11-13 — 独立 core 续接保留原安装 + +此前即使调用者选择隔离的 core interpreter,独立启动仍返回环境中的 `loopx` +命令;执行完整命令可能通过 PATH/PYTHONPATH 导入其他安装。旧安装测试丢弃 +executable 后重放参数,掩盖了失败。Core 生成的命令现在通过既有 invocation +owner 保留同一环境的 Python,并使用 `-I -m loopx.entrypoint`。显式全局 +`--cli-bin` 与普通发行默认行为保持;非法参数保留完整 parser 诊断。 +多 Goal 选择也复用同一命令 renderer。 + +Core module 诊断核验实际导入的 distribution、记录中的 module,以及真实存在 +且有记录的 console 入口。Version/catalog/help probe 使用同一隔离 module +调用,foreign PYTHONPATH 无法静默改变被检查的 artifact。缺失或重定向的 module +记录、缺失 console 记录/文件、未记录 launcher 与缺失 runtime 文件仍拒绝。 +安装资格测试执行完整的返回激活与诊断命令,使用 foreign-package sentinel 和 +显式 route 对照,并覆盖既有 SQLite/File 恢复与 context 检查。本轮修复命令来源, +不新增生命周期、权限、配置或 protocol owner。 + +完整 R1/V1–V5/V10、真实模型恢复/采用、渐进产品控制、最小依赖闭包、 +M3/Standard/workspace/meta 拆包与完整成本验收仍开放。本轮不升级已安装 Host、 +启动模型服务或从协议夹具推断效果。原九项要求、原 Goal 回归及此前失败全部保留。 diff --git a/loopx/bootstrap_command_pack.py b/loopx/bootstrap_command_pack.py index f1be728107..1c92b1409a 100644 --- a/loopx/bootstrap_command_pack.py +++ b/loopx/bootstrap_command_pack.py @@ -1299,8 +1299,8 @@ def _build_multi_goal_start_selection_packet( fine_grained=fine_grained, ), "commands": { - "doctor": f"{shell_arg_for_host(cli_bin)} doctor", - "status": _project_command(resolved_project, f"{shell_arg_for_host(cli_bin)} status"), + "doctor": f"{render_cli_command_prefix(cli_bin=cli_bin)} doctor", + "status": _project_command(resolved_project, f"{render_cli_command_prefix(cli_bin=cli_bin)} status"), "goal_selection_choices": choices, }, "safety_contract": { diff --git a/loopx/cli_runtime.py b/loopx/cli_runtime.py index fd810a5edd..70edc94748 100644 --- a/loopx/cli_runtime.py +++ b/loopx/cli_runtime.py @@ -323,7 +323,11 @@ def cli_invocation_route(argv: list[str]): if option == "--cli-bin": program = argument index += 1 - with loopx_cli_route(program): + # A standalone core may be invoked without activating its environment. Its + # returned commands must stay on this artifact, even when PATH/PYTHONPATH + # names another LoopX installation. Explicit routes remain caller-owned. + default_argv = [sys.executable, "-I", "-m", "loopx.entrypoint"] if _standalone_core() else None + with loopx_cli_route(program, default_argv=default_argv): yield diff --git a/loopx/command_invocation.py b/loopx/command_invocation.py index 7e202a9a76..53ef8e5028 100644 --- a/loopx/command_invocation.py +++ b/loopx/command_invocation.py @@ -9,7 +9,7 @@ from typing import Iterator, Mapping, Sequence -_loopx_cli_bin: ContextVar[str] = ContextVar("loopx_cli_bin", default="loopx") +_loopx_cli_bin: ContextVar[tuple[str, ...]] = ContextVar("loopx_cli_bin", default=("loopx",)) def validate_cli_bin(value: str) -> str: @@ -20,9 +20,17 @@ def validate_cli_bin(value: str) -> str: @contextmanager -def loopx_cli_route(cli_bin: str | None) -> Iterator[None]: +def loopx_cli_route( + cli_bin: str | None, *, default_argv: Sequence[str] | None = None, +) -> Iterator[None]: """Bind only this invocation's command rendering; retain no host policy.""" - token = _loopx_cli_bin.set(cli_bin if cli_bin is not None else "loopx") + program = cli_bin if cli_bin is not None else "loopx" + route = tuple(default_argv) if cli_bin is None and default_argv is not None else ( + (program,) if program == "loopx" else (program, "--cli-bin", program) + ) + if not route: + raise ValueError("the default CLI invocation must not be empty") + token = _loopx_cli_bin.set(route) try: yield finally: @@ -30,7 +38,11 @@ def loopx_cli_route(cli_bin: str | None) -> Iterator[None]: def loopx_cli_argv(cli_bin: str | None = None) -> list[str]: - program = validate_cli_bin(cli_bin if cli_bin is not None else _loopx_cli_bin.get()) + if cli_bin is None: + route = _loopx_cli_bin.get() + validate_cli_bin(route[0]) + return list(route) + program = validate_cli_bin(cli_bin) # The executable selects this process; the option preserves that selection # when its returned commands start another process. Neither grants authority. return [program] if program == "loopx" else [program, "--cli-bin", program] diff --git a/loopx/doctor.py b/loopx/doctor.py index c187b57265..405a5342fe 100644 --- a/loopx/doctor.py +++ b/loopx/doctor.py @@ -124,7 +124,7 @@ def command_release_root(command_realpath: Path | None) -> Path | None: def current_script_invocation_path() -> Path | None: - """Return the active LoopX executable when invoked through its console script.""" + """Return the active console script or installed standalone-core module.""" if not sys.argv or not sys.argv[0]: return None path = Path(sys.argv[0]).expanduser() @@ -136,6 +136,12 @@ def current_script_invocation_path() -> Path | None: path = path.absolute() if path.exists() and path.name.lower() in {"loopx", "loopx.exe"}: return path + if path == Path(__file__).with_name("entrypoint.py").resolve(): + # Module invocation is the core's isolated continuation route. Verify + # the imported distribution before replacing ambient PATH discovery. + installed = python_distribution_install(Path(__file__).resolve()) + if installed.get("distribution_name") == "loopx-core": + return path return None @@ -731,7 +737,11 @@ def collect_doctor( loopx_path = resolve_command_path("loopx") invocation_path = current_script_invocation_path() loopx_canary_path = resolve_command_path("loopx-canary") - command_path_primary = loopx_path or invocation_path + command_path_primary = ( + invocation_path + if invocation_path == Path(__file__).with_name("entrypoint.py").resolve() + else loopx_path or invocation_path + ) canary_path = loopx_canary_path command_path = command_path_primary command_realpath = command_path.resolve() if command_path else None diff --git a/loopx/release_candidate.py b/loopx/release_candidate.py index b324511884..34200592fe 100644 --- a/loopx/release_candidate.py +++ b/loopx/release_candidate.py @@ -64,7 +64,9 @@ def _import_summary(module_names: Sequence[str] = REPRESENTATIVE_CLI_IMPORTS) -> } -def _command_summary(command_path: Path | None) -> dict[str, Any]: +def _command_summary( + command_path: Path | None, *, invocation_argv: Sequence[str] | None = None, +) -> dict[str, Any]: if command_path is None: return {"ok": False, "results": {}, "failed": ["command_missing"]} @@ -72,7 +74,7 @@ def probe(item: tuple[str, tuple[str, ...]]) -> tuple[str, dict[str, Any]]: probe_name, args = item try: result = subprocess.run( - command_argv(command_path, args), + [*invocation_argv, *args] if invocation_argv is not None else command_argv(command_path, args), check=False, capture_output=True, text=True, encoding="utf-8", errors="replace", @@ -130,7 +132,15 @@ def _distribution_command_summary(command_path: Path | None) -> dict[str, Any]: } recorded_commands: list[Path] = [] + recorded_module = False + resolved_command = command_path.resolve() + standalone_core = getattr(installed, "name", None) == "loopx-core" for item in installed.files or (): + if standalone_core and str(item) == "loopx/entrypoint.py": + recorded_module = ( + Path(item.locate()).resolve() == resolved_command + == Path(__file__).with_name("entrypoint.py").resolve() + ) if Path(str(item)).name.lower() not in { "loopx", "loopx.exe", @@ -144,9 +154,10 @@ def _distribution_command_summary(command_path: Path | None) -> dict[str, Any]: if resolved_path not in recorded_commands: recorded_commands.append(resolved_path) recorded_commands.sort(key=str) - resolved_command = command_path.resolve() return { - "ok": resolved_command in recorded_commands, + "ok": resolved_command in recorded_commands or ( + recorded_module and any(path.is_file() for path in recorded_commands) + ), **({"distribution_name": "loopx-core"} if getattr(installed, "name", None) == "loopx-core" else {}), "command": str(resolved_command), "recorded_commands": [str(path) for path in recorded_commands], @@ -227,7 +238,12 @@ def collect_python_distribution_checks( distribution_command = _distribution_command_summary(command_path) standalone_core = distribution_command.get("distribution_name") == "loopx-core" imports = _import_summary(CORE_CLI_IMPORTS) if standalone_core else _import_summary() - commands = _command_summary(command_path) + if standalone_core and command_path == Path(__file__).with_name("entrypoint.py").resolve(): + commands = _command_summary( + command_path, invocation_argv=[sys.executable, "-I", "-m", "loopx.entrypoint"], + ) + else: + commands = _command_summary(command_path) package_paths = _package_path_summary( package_root, CORE_DISTRIBUTION_PATHS if standalone_core else REPRESENTATIVE_DISTRIBUTION_PATHS, diff --git a/packages/loopx-core/README.md b/packages/loopx-core/README.md index ab24bf78b2..ae5ef9df5d 100644 --- a/packages/loopx-core/README.md +++ b/packages/loopx-core/README.md @@ -30,7 +30,12 @@ loopx start-goal --guided --project . --goal-text "Create and check an inventory Follow its returned host choice and ordered transaction. The preview preserves the exact goal text and writes no state. Its project bootstrap, fresh identity registration, session binding and subsequent Todo/guard commands execute in -this standalone distribution through their existing owners. The host supplies +this standalone distribution through their existing owners. Generated commands +use this environment's Python with `-I -m loopx.entrypoint`, so continuation and +installation diagnosis keep using the same core even when PATH or PYTHONPATH +points to another installation. An explicit global `--cli-bin` retains the +caller's selected executable. Deep diagnosis still requires recorded module and +console entrypoints; redirected or missing records fail. The host supplies the model, tools and scheduler; the wheel does not provide them or activate an optional capability from goal wording. `connect` and `configure-goal` remain available for explicit setup. Create and claim work through the existing `todo` diff --git a/tests/test_core_distribution.py b/tests/test_core_distribution.py index afd9894cd6..ba505ae37b 100644 --- a/tests/test_core_distribution.py +++ b/tests/test_core_distribution.py @@ -7,6 +7,7 @@ from __future__ import annotations import importlib.util +import csv import json import os import re @@ -184,6 +185,70 @@ def command(*arguments, expected=0): assert pack["goal_start_contract"]["explicit_invocation_confirms_project_local_state_writes"] is True +@pytest.mark.parametrize("explicit_route", [False, True]) +def test_installed_core_generated_commands_keep_their_distribution( + core_python, tmp_path, monkeypatch, explicit_route, +): + from tests.control_plane.canonical_authority_fixture import isolate_sqlite_runtime + + isolate_sqlite_runtime(tmp_path, monkeypatch) + project, runtime = tmp_path / "project", tmp_path / "runtime" + project.mkdir() + prefix = ["--runtime-root", str(runtime), "--format", "json"] + environment = {"TRAECLI_THREAD_ID": "core-session"} + + def command(*arguments): + return json.loads(_run(core_python, list(arguments), project, + environment=environment).stdout) + + command(*prefix, "bootstrap", "--project", str(project), "--goal-id", GOAL, + "--objective", "Verify the inventory report.", "--write-scope", "docs/**") + command(*prefix, "register-agent", "--goal-id", GOAL, + "--agent-id", AGENT, "--require-new", "--execute") + command(*prefix, "bind-agent-thread", "--goal-id", GOAL, "--agent-id", AGENT, + "--thread-id", "core-session", "--host-surface", "traex-cli", "--execute") + route = ["--cli-bin", "loopx"] if explicit_route else [] + pack = command(*prefix, *route, "bootstrap-command-pack", "--project", str(project), + "--goal-id", GOAL, "--host-surface", "traex-cli") + assert pack["agent_id"] == AGENT + # Execute the complete returned command, rather than discarding its + # executable and replaying only the arguments through the test's Python. + arguments = shlex.split(pack["host_loop_activation"]["activation_input_command"]) + if os.name == "nt" and arguments[0] == "&": + arguments = arguments[1:] + foreign = tmp_path / "foreign" / "loopx" + foreign.mkdir(parents=True) + (foreign / "__init__.py").write_text("", encoding="utf-8") + (foreign / "entrypoint.py").write_text( + "from pathlib import Path\n" + "Path('foreign-cli-used').write_text('foreign package', encoding='utf-8')\n" + "def main(argv=None): return 91\n", + encoding="utf-8", + ) + inherited = _environment(core_python, environment) + inherited["PYTHONPATH"] = str(foreign.parent) + result = subprocess.run(arguments, cwd=project, env=inherited, + capture_output=True, text=True, timeout=45) + marker = project / "foreign-cli-used" + if explicit_route: + assert result.returncode == 91 and marker.exists(), (result.stdout, result.stderr) + else: + assert result.returncode == 0, (result.stdout, result.stderr) + assert not marker.exists() + payload = json.loads(result.stdout) + assert payload["ok"] and payload["goal_id"] == GOAL and payload["agent_id"] == AGENT + doctor_command = shlex.split(pack["commands"]["doctor"]) + if os.name == "nt" and doctor_command[0] == "&": + doctor_command = doctor_command[1:] + diagnosed = subprocess.run( + [*doctor_command, "--installation-only", "--deep", "--format", "json"], + cwd=project, env=inherited, capture_output=True, text=True, timeout=90, + ) + assert diagnosed.returncode == 0, (diagnosed.stdout, diagnosed.stderr) + assert json.loads(diagnosed.stdout)["ok"] + assert not marker.exists() + + @pytest.mark.parametrize("route", ["help", "preview", "catalog"]) def test_installed_core_start_metadata_defers_issue_fix_execution(core_python, tmp_path, route): arguments = ["start-goal", "--help"] if route == "help" else [ @@ -268,6 +333,48 @@ def test_installed_core_deep_doctor_rejects_missing_runtime_file(core_python, tm backup.rename(original) +@pytest.mark.parametrize("damage", ["missing_module", "redirected_module", "missing_console", "missing_console_file"]) +def test_installed_core_module_doctor_requires_recorded_entrypoints(core_python, tmp_path, monkeypatch, damage): + from tests.control_plane.canonical_authority_fixture import isolate_sqlite_runtime + + isolate_sqlite_runtime(tmp_path, monkeypatch) + probe = subprocess.run([core_python, "-I", "-c", + "from importlib.metadata import distribution;" + "print(next(str(p.locate()) for p in distribution('loopx-core').files " + "if str(p).endswith('.dist-info/RECORD')))"], + capture_output=True, text=True, check=True) + record = Path(probe.stdout.strip()) + original = record.read_bytes() + rows = list(csv.reader(original.decode("utf-8").splitlines())) + changed = [] + for row in rows: + if damage == "missing_console" and Path(row[0]).name.lower() in {"loopx", "loopx.exe", "loopx-script.py"}: + continue + if row[0] == "loopx/entrypoint.py" and damage in {"missing_module", "redirected_module"}: + if damage == "missing_module": + continue + row[0] = "loopx/unrecorded-entrypoint.py" + changed.append(row) + try: + console = Path(core_python).parent / ("loopx.exe" if os.name == "nt" else "loopx") + backup = console.with_suffix(".core-test-backup") + if damage == "missing_console_file": + console.rename(backup) + with record.open("w", encoding="utf-8", newline="") as stream: + csv.writer(stream).writerows(changed) + result = subprocess.run( + [core_python, "-I", "-m", "loopx.entrypoint", "doctor", "--installation-only", "--deep", "--format", "json"], + cwd=tmp_path, env=_environment(core_python), capture_output=True, text=True, timeout=90, + ) + assert result.returncode == 1, (result.stdout, result.stderr) + checks = {check["id"]: check for check in json.loads(result.stdout)["checks"]} + assert checks["command_package_same_distribution"]["ok"] is False + finally: + record.write_bytes(original) + if damage == "missing_console_file" and backup.exists(): + backup.rename(console) + + @pytest.fixture(params=["sqlite", "file"]) def task(tmp_path, monkeypatch, core_python, request): from tests.control_plane.canonical_authority_fixture import isolate_sqlite_runtime