From f9d3c4ddfcfd0e781e6e04d63bd11cafd1d88f41 Mon Sep 17 00:00:00 2001 From: BigDataDZ <76271875+BigDataDZ@users.noreply.github.com> Date: Mon, 14 Sep 2026 23:30:41 +0800 Subject: [PATCH 1/2] fix(runtime): pin UTF-8 decoding for every remaining text-mode subprocess read subprocess.run(..., text=True) without an explicit encoding decodes the child's stdout with locale.getpreferredencoding(False) - gbk on a zh-CN Windows host - while gh, git, and the wrapped LoopX CLI all emit UTF-8. A single non-ASCII byte then kills the pipe reader thread, result.stdout becomes None with returncode 0, and the caller reports a misleading secondary error instead of the real one. The PyInstaller-frozen sidecar overrides PYTHONUTF8, so an explicit codec is the only robust fix. Mechanical: every remaining text-mode child read in the shipped loopx/ package now pins encoding="utf-8", errors="replace" - the issue-fix, pr-review, and doctor slices already merged covered their own paths. change_quality/scope.py funnels its git calls through _git(..., text=...), so the codec is pinned only when the caller requested text mode: a non-None encoding would silently flip its bytes-mode callers into text mode because CPython treats any of text/encoding/errors as a text-mode request. Contract: a static test walks the shipped package and fails when any text-mode subprocess call omits an explicit codec, so the class of bug cannot silently return. Behavioral regressions drive the goal-mode CLI JSON probe under a simulated cp936 host and exercise the _git helper in both modes with non-ASCII content. Fixes #4155 Signed-off-by: BigDataDZ <76271875+BigDataDZ@users.noreply.github.com> --- loopx/canary/maintainability_ratchet.py | 2 +- loopx/canary/premerge.py | 2 +- loopx/canary/runner.py | 8 +- .../benchmark_toolkit/container_binding.py | 2 +- .../benchmark_toolkit/native_codex_profile.py | 12 +-- .../source_revision_fence.py | 2 +- loopx/capabilities/change_quality/scope.py | 6 ++ .../tests/test_connector_registry.py | 6 +- loopx/capabilities/integration_branch/core.py | 2 +- .../repository_change_window/git_hook.py | 2 +- .../reward_memory/codex_app_outcome.py | 2 +- .../semantic_preference/contract.py | 2 +- loopx/chat_acp.py | 2 +- loopx/chat_agent.py | 2 +- loopx/chat_lark_api.py | 2 +- loopx/chat_providers.py | 2 +- loopx/claude_goal_mode/hooks/goal_policy.py | 4 +- .../claude_goal_mode/scripts/goalmode_cmd.py | 4 +- loopx/claude_goal_mode/scripts/install.py | 16 +-- .../statusline/goal_status.py | 2 +- loopx/cli_commands/canary.py | 4 +- loopx/cli_commands/opencode2_goal_worker.py | 2 +- loopx/codex_cli_runtime_probe.py | 4 +- loopx/codex_cli_scheduler.py | 2 +- loopx/control_plane/agents/workspace_guard.py | 2 +- loopx/control_plane/effect_runtime.py | 2 +- .../goals/ssh_lifecycle_transport.py | 2 +- .../heartbeat/installed_prompt_update.py | 2 +- .../runtime/validation_command.py | 2 +- loopx/control_plane/status/ssh_tunnel.py | 4 +- .../testing/authority_e2e_fixtures.py | 8 +- .../testing/authority_e2e_ladder.py | 6 +- .../testing/authority_e2e_rows_stage2c2.py | 4 +- loopx/control_plane/testing/canary_harness.py | 4 +- ...capability_monitor_repair_tool_behavior.py | 2 +- .../testing/model_tool_behavior.py | 2 +- .../testing/release_commit_qualification.py | 2 +- .../replan_semantic_action_behavior.py | 2 +- .../scoped_gate_successor_tool_behavior.py | 2 +- .../testing/selected_todo_tool_behavior.py | 6 +- .../terminal_settlement_tool_behavior.py | 10 +- .../testing/turn_journal_characterization.py | 2 +- .../todos/completion_validation.py | 2 +- loopx/control_plane/turn_driver/codex_cli.py | 2 +- .../turn_driver/command_validation.py | 2 +- loopx/control_plane/turn_driver/executor.py | 2 +- loopx/dashboard_launcher.py | 4 +- loopx/experiments/planner_worker/traex.py | 4 +- loopx/extensions/lark/app_setup.py | 2 +- .../lark/document_comment_provider.py | 2 +- .../lark/event_collector_runtime.py | 4 +- loopx/extensions/lark/goal_topic_runtime.py | 4 +- loopx/extensions/lark/inbox_reactions.py | 2 +- loopx/extensions/lark/inbox_reply.py | 2 +- loopx/extensions/lark/miaoda_report.py | 2 +- loopx/extensions/lark/presentation/kanban.py | 2 +- .../provider.py | 2 +- loopx/goal_mode_mcp.py | 4 +- loopx/kunluncode_goal_mode/app_server.py | 2 +- loopx/kunluncode_goal_mode/cli.py | 2 +- loopx/kunluncode_goal_mode/control_plane.py | 2 +- loopx/project_alias.py | 2 +- loopx/release_candidate.py | 2 +- loopx/release_manifest.py | 2 +- loopx/repository_identity.py | 2 +- loopx/self_update.py | 16 +-- loopx/self_update_download.py | 4 +- loopx/skill_install_readback.py | 2 +- loopx/windows_install.py | 2 +- tests/test_runtime_subprocess_utf8.py | 101 ++++++++++++++++++ 70 files changed, 223 insertions(+), 116 deletions(-) create mode 100644 tests/test_runtime_subprocess_utf8.py diff --git a/loopx/canary/maintainability_ratchet.py b/loopx/canary/maintainability_ratchet.py index 38f054ea89..b6a79ca04b 100644 --- a/loopx/canary/maintainability_ratchet.py +++ b/loopx/canary/maintainability_ratchet.py @@ -208,7 +208,7 @@ def tracked_python_paths(repository_root: Path) -> set[Path]: ["git", "ls-files", "*.py"], cwd=repository_root, check=True, - text=True, + text=True, encoding="utf-8", errors="replace", stdout=subprocess.PIPE, ) return { diff --git a/loopx/canary/premerge.py b/loopx/canary/premerge.py index 3ed1ac058b..030bac6f02 100644 --- a/loopx/canary/premerge.py +++ b/loopx/canary/premerge.py @@ -397,7 +397,7 @@ def _run_gate_check( completed = subprocess.run( argv, cwd=repo_root, - text=True, + text=True, encoding="utf-8", errors="replace", stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=max(1.0, timeout_seconds), diff --git a/loopx/canary/runner.py b/loopx/canary/runner.py index 61b41a416a..46c754a0fd 100644 --- a/loopx/canary/runner.py +++ b/loopx/canary/runner.py @@ -170,7 +170,7 @@ def _tracked_change_paths() -> tuple[bool, list[str], str]: completed = subprocess.run( ["git", "-C", str(REPO_ROOT), *args], check=False, - text=True, + text=True, encoding="utf-8", errors="replace", stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) @@ -186,7 +186,7 @@ def _git_worktree_probe(root: Path) -> tuple[bool, str]: worktree_probe = subprocess.run( ["git", "-C", str(root), "rev-parse", "--is-inside-work-tree"], check=False, - text=True, + text=True, encoding="utf-8", errors="replace", stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) @@ -232,7 +232,7 @@ def _restore_tracked_paths(paths: list[str]) -> dict[str, Any]: completed = subprocess.run( ["git", "-C", str(REPO_ROOT), "restore", "--staged", "--worktree", "--", *paths], check=False, - text=True, + text=True, encoding="utf-8", errors="replace", stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) @@ -268,7 +268,7 @@ def _run_check( completed = subprocess.run( normalized["argv"], cwd=REPO_ROOT, - text=True, + text=True, encoding="utf-8", errors="replace", stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout_seconds, diff --git a/loopx/capabilities/benchmark_toolkit/container_binding.py b/loopx/capabilities/benchmark_toolkit/container_binding.py index fcfae33b95..b1f2994ba4 100644 --- a/loopx/capabilities/benchmark_toolkit/container_binding.py +++ b/loopx/capabilities/benchmark_toolkit/container_binding.py @@ -29,7 +29,7 @@ class DockerContainerBinding: def _run_command(argv: Sequence[str]) -> subprocess.CompletedProcess[str]: return subprocess.run( argv, - text=True, + text=True, encoding="utf-8", errors="replace", capture_output=True, check=False, ) diff --git a/loopx/capabilities/benchmark_toolkit/native_codex_profile.py b/loopx/capabilities/benchmark_toolkit/native_codex_profile.py index 261645fa4b..d853e50a5d 100644 --- a/loopx/capabilities/benchmark_toolkit/native_codex_profile.py +++ b/loopx/capabilities/benchmark_toolkit/native_codex_profile.py @@ -138,7 +138,7 @@ def _source_clean_preflight(source_root: Path) -> bool | None: ["git", "-C", str(source_root), "rev-parse", "--show-toplevel"], check=False, capture_output=True, - text=True, + text=True, encoding="utf-8", errors="replace", ) except OSError: top_level = None @@ -153,7 +153,7 @@ def _source_clean_preflight(source_root: Path) -> bool | None: ["git", "-C", str(source_root), "status", "--porcelain"], check=False, capture_output=True, - text=True, + text=True, encoding="utf-8", errors="replace", ) if status.returncode != 0: return None @@ -347,7 +347,7 @@ def render_native_codex_goal_prompt( env=native_codex_profile_environment(profile, base_env=base_env), check=False, capture_output=True, - text=True, + text=True, encoding="utf-8", errors="replace", timeout=timeout_sec, ) except subprocess.TimeoutExpired as exc: @@ -416,7 +416,7 @@ def _doctor_payload( env=doctor_env, check=False, capture_output=True, - text=True, + text=True, encoding="utf-8", errors="replace", ) if completed.returncode: raise NativeCodexProfileError( @@ -475,7 +475,7 @@ def inspect_native_codex_profile( version_readback = subprocess.run( [str(cli_bin), "--version"], cwd=paths["root"], env=env, check=False, capture_output=True, - text=True, timeout=30, + text=True, encoding="utf-8", errors="replace", timeout=30, ) except (OSError, subprocess.TimeoutExpired) as exc: raise NativeCodexProfileError("profile_cli_version_unavailable") from exc @@ -587,7 +587,7 @@ def install_native_codex_profile( env=env, check=False, capture_output=True, - text=True, + text=True, encoding="utf-8", errors="replace", ) if completed.returncode: raise NativeCodexProfileError( diff --git a/loopx/capabilities/benchmark_toolkit/source_revision_fence.py b/loopx/capabilities/benchmark_toolkit/source_revision_fence.py index 751a15c385..50d7f86d29 100644 --- a/loopx/capabilities/benchmark_toolkit/source_revision_fence.py +++ b/loopx/capabilities/benchmark_toolkit/source_revision_fence.py @@ -50,7 +50,7 @@ def _git_text(source: Path, *args: str) -> str: ["git", "--no-optional-locks", "-C", str(source), *args], check=False, capture_output=True, - text=True, + text=True, encoding="utf-8", errors="replace", ) except OSError as exc: raise BenchmarkSourceRevisionFenceError( diff --git a/loopx/capabilities/change_quality/scope.py b/loopx/capabilities/change_quality/scope.py index 36c9e08ddf..e312315752 100644 --- a/loopx/capabilities/change_quality/scope.py +++ b/loopx/capabilities/change_quality/scope.py @@ -14,10 +14,16 @@ def _git( *args: str, text: bool = False, ) -> subprocess.CompletedProcess[Any]: + # git emits UTF-8 regardless of the host locale. Pin the codec only in + # text mode: a non-None encoding would silently flip bytes-mode callers + # into text mode because CPython treats any of text/encoding/errors as + # text-mode requests. return subprocess.run( ["git", "-C", str(repo_root), *args], check=False, text=text, + encoding="utf-8" if text else None, + errors="replace" if text else None, stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) diff --git a/loopx/capabilities/connector_registry/tests/test_connector_registry.py b/loopx/capabilities/connector_registry/tests/test_connector_registry.py index 4f1a61f77c..26bf95fff4 100644 --- a/loopx/capabilities/connector_registry/tests/test_connector_registry.py +++ b/loopx/capabilities/connector_registry/tests/test_connector_registry.py @@ -93,7 +93,7 @@ def test_cli_path_override_reads_the_registry_it_writes(tmp_path: Path) -> None: check=True, cwd=Path.cwd(), capture_output=True, - text=True, + text=True, encoding="utf-8", errors="replace", ) assert json.loads(register.stdout)["ok"] is True @@ -112,7 +112,7 @@ def test_cli_path_override_reads_the_registry_it_writes(tmp_path: Path) -> None: check=True, cwd=Path.cwd(), capture_output=True, - text=True, + text=True, encoding="utf-8", errors="replace", ) payload = json.loads(listed.stdout) assert any(row["id"] == "probe-cli" for row in payload["ranked"]) @@ -132,6 +132,6 @@ def test_cli_path_override_reads_the_registry_it_writes(tmp_path: Path) -> None: check=True, cwd=Path.cwd(), capture_output=True, - text=True, + text=True, encoding="utf-8", errors="replace", ) assert any(row["id"] == "probe-cli" for row in json.loads(ranked.stdout)["ranked"]) diff --git a/loopx/capabilities/integration_branch/core.py b/loopx/capabilities/integration_branch/core.py index cda5d02ece..f16e12061b 100644 --- a/loopx/capabilities/integration_branch/core.py +++ b/loopx/capabilities/integration_branch/core.py @@ -29,7 +29,7 @@ def _git( ["git", "-C", str(repo), *args], check=False, capture_output=True, - text=True, + text=True, encoding="utf-8", errors="replace", ) if check and result.returncode != 0: detail = result.stderr.strip() or result.stdout.strip() or "git command failed" diff --git a/loopx/capabilities/repository_change_window/git_hook.py b/loopx/capabilities/repository_change_window/git_hook.py index 3a9fdd7efd..43762a696e 100644 --- a/loopx/capabilities/repository_change_window/git_hook.py +++ b/loopx/capabilities/repository_change_window/git_hook.py @@ -98,7 +98,7 @@ def _hook_runtime_check(level: EnforcementLevel) -> dict[str, object]: ], check=False, capture_output=True, - text=True, + text=True, encoding="utf-8", errors="replace", timeout=10, ) except (OSError, subprocess.TimeoutExpired) as exc: diff --git a/loopx/capabilities/reward_memory/codex_app_outcome.py b/loopx/capabilities/reward_memory/codex_app_outcome.py index 0875fece70..730acd4d9d 100644 --- a/loopx/capabilities/reward_memory/codex_app_outcome.py +++ b/loopx/capabilities/reward_memory/codex_app_outcome.py @@ -207,7 +207,7 @@ def _run_reflection_validator( sort_keys=True, separators=(",", ":"), ), - text=True, + text=True, encoding="utf-8", errors="replace", stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=timeout_seconds, diff --git a/loopx/capabilities/semantic_preference/contract.py b/loopx/capabilities/semantic_preference/contract.py index c5dbaea009..b3f04f0dd8 100644 --- a/loopx/capabilities/semantic_preference/contract.py +++ b/loopx/capabilities/semantic_preference/contract.py @@ -482,7 +482,7 @@ def recall( argv, input=json.dumps(request, ensure_ascii=False), capture_output=True, - text=True, + text=True, encoding="utf-8", errors="replace", timeout=timeout, check=False, ) diff --git a/loopx/chat_acp.py b/loopx/chat_acp.py index 8a9ac4ead3..ba346b1b64 100644 --- a/loopx/chat_acp.py +++ b/loopx/chat_acp.py @@ -106,7 +106,7 @@ def start( stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, - text=True, + text=True, encoding="utf-8", errors="replace", bufsize=1, ) except OSError as exc: diff --git a/loopx/chat_agent.py b/loopx/chat_agent.py index 49452df238..f85b3975a7 100644 --- a/loopx/chat_agent.py +++ b/loopx/chat_agent.py @@ -172,7 +172,7 @@ def _current_builtin_model_catalog(codex_bin: str) -> Iterator[Path]: env=env, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, - text=True, + text=True, encoding="utf-8", errors="replace", timeout=15, check=False, ) diff --git a/loopx/chat_lark_api.py b/loopx/chat_lark_api.py index da8a4ab635..41363afa0f 100644 --- a/loopx/chat_lark_api.py +++ b/loopx/chat_lark_api.py @@ -90,7 +90,7 @@ def _default_git_runner(args: list[str]) -> dict[str, Any]: args, capture_output=True, check=False, - text=True, + text=True, encoding="utf-8", errors="replace", timeout=10, ) except (OSError, subprocess.TimeoutExpired): diff --git a/loopx/chat_providers.py b/loopx/chat_providers.py index ad6d11116b..eca154ca12 100644 --- a/loopx/chat_providers.py +++ b/loopx/chat_providers.py @@ -192,7 +192,7 @@ def start_turn(self, message: str, event_sink: EventSink) -> dict[str, Any]: cwd=str(self.work_dir), stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, - text=True, + text=True, encoding="utf-8", errors="replace", bufsize=1, ) except OSError as exc: diff --git a/loopx/claude_goal_mode/hooks/goal_policy.py b/loopx/claude_goal_mode/hooks/goal_policy.py index 3f38f9e902..618d43578e 100644 --- a/loopx/claude_goal_mode/hooks/goal_policy.py +++ b/loopx/claude_goal_mode/hooks/goal_policy.py @@ -126,14 +126,14 @@ def should_run(registry, goal_id, agent_id=None) -> bool | None: out = subprocess.run( [*cmd, *CLAUDE_RUNTIME_PROFILE_ARGS], capture_output=True, - text=True, + text=True, encoding="utf-8", errors="replace", timeout=10, ) if _runtime_profile_flag_is_unsupported(out): out = subprocess.run( [*cmd, *CLAUDE_LEGACY_SCHEDULER_ARGS], capture_output=True, - text=True, + text=True, encoding="utf-8", errors="replace", timeout=10, ) except Exception: diff --git a/loopx/claude_goal_mode/scripts/goalmode_cmd.py b/loopx/claude_goal_mode/scripts/goalmode_cmd.py index dfc58ee369..026ff02390 100644 --- a/loopx/claude_goal_mode/scripts/goalmode_cmd.py +++ b/loopx/claude_goal_mode/scripts/goalmode_cmd.py @@ -50,7 +50,7 @@ def gh_prefix(): def gh(args, cwd=None): - return subprocess.run(gh_prefix() + args, cwd=cwd, capture_output=True, text=True, timeout=120) + return subprocess.run(gh_prefix() + args, cwd=cwd, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=120) def slug(name: str) -> str: @@ -144,7 +144,7 @@ def goal_detail(ctx): ["--format", "json", "quota", "should-run", "--goal-id", gid] if agent: cmd += ["--agent-id", agent] - out = subprocess.run(cmd, capture_output=True, text=True, timeout=15) + out = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=15) payload = json.loads(out.stdout or "{}") except Exception: pass diff --git a/loopx/claude_goal_mode/scripts/install.py b/loopx/claude_goal_mode/scripts/install.py index 9ef0fef971..928639944d 100644 --- a/loopx/claude_goal_mode/scripts/install.py +++ b/loopx/claude_goal_mode/scripts/install.py @@ -109,10 +109,10 @@ def provision_mcp_python(dry: bool, allow_system_pip: bool = False) -> str: return str(vpy) print(f"[deps] creating mcp venv {MCP_VENV} …") MCP_VENV.parent.mkdir(parents=True, exist_ok=True) - r = subprocess.run([sys.executable, "-m", "venv", str(MCP_VENV)], capture_output=True, text=True) + r = subprocess.run([sys.executable, "-m", "venv", str(MCP_VENV)], capture_output=True, text=True, encoding="utf-8", errors="replace",) if r.returncode == 0: - subprocess.run([str(vpy), "-m", "pip", "install", "-q", "--upgrade", "pip"], capture_output=True, text=True) - pip = subprocess.run([str(vpy), "-m", "pip", "install", "-q", MCP_REQUIREMENT], capture_output=True, text=True) + subprocess.run([str(vpy), "-m", "pip", "install", "-q", "--upgrade", "pip"], capture_output=True, text=True, encoding="utf-8", errors="replace",) + pip = subprocess.run([str(vpy), "-m", "pip", "install", "-q", MCP_REQUIREMENT], capture_output=True, text=True, encoding="utf-8", errors="replace",) if pip.returncode == 0 and _has_mcp(vpy): print(f"[deps] mcp installed into {MCP_VENV}") return str(vpy) @@ -124,7 +124,7 @@ def provision_mcp_python(dry: bool, allow_system_pip: bool = False) -> str: if allow_system_pip: print("[deps] --allow-system-pip: pip install --break-system-packages mcp") subprocess.run([sys.executable, "-m", "pip", "install", "-q", "--break-system-packages", MCP_REQUIREMENT], - capture_output=True, text=True) + capture_output=True, text=True, encoding="utf-8", errors="replace",) if _has_mcp(sys.executable): return sys.executable print("[deps] WARNING: could not provision `mcp` into a dedicated venv, and we will\n" @@ -162,13 +162,13 @@ def install_mcp( subprocess.run( [claude, "mcp", "remove", "--scope", scope, "loopx"], capture_output=True, - text=True, + text=True, encoding="utf-8", errors="replace", cwd=cwd_run, ) has_gh = subprocess.run( [claude, "mcp", "get", "goal-harness"], capture_output=True, - text=True, + text=True, encoding="utf-8", errors="replace", cwd=cwd_run, ).returncode == 0 if has_gh: @@ -177,7 +177,7 @@ def install_mcp( subprocess.run( [claude, "mcp", "remove", "--scope", scope, "goal-harness"], capture_output=True, - text=True, + text=True, encoding="utf-8", errors="replace", cwd=cwd_run, ) else: @@ -187,7 +187,7 @@ def install_mcp( r = subprocess.run( [claude, *add], capture_output=True, - text=True, + text=True, encoding="utf-8", errors="replace", cwd=cwd_run, ) if r.returncode != 0: diff --git a/loopx/claude_goal_mode/statusline/goal_status.py b/loopx/claude_goal_mode/statusline/goal_status.py index a51e3f5486..9914c1f658 100644 --- a/loopx/claude_goal_mode/statusline/goal_status.py +++ b/loopx/claude_goal_mode/statusline/goal_status.py @@ -85,7 +85,7 @@ def main(): cmd += ["--format", "json", "quota", "should-run", "--goal-id", gid] if st.get("agent_id"): cmd += ["--agent-id", st["agent_id"]] - out = subprocess.run(cmd, capture_output=True, text=True, timeout=8) + out = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=8) d = json.loads(out.stdout or "{}") print(_render(gid, d)) except Exception: diff --git a/loopx/cli_commands/canary.py b/loopx/cli_commands/canary.py index c954edd556..3a224b2f7b 100644 --- a/loopx/cli_commands/canary.py +++ b/loopx/cli_commands/canary.py @@ -73,7 +73,7 @@ def _run_git_name_only(repo_root: Path, args: list[str]) -> dict[str, object]: completed = subprocess.run( command, check=False, - text=True, + text=True, encoding="utf-8", errors="replace", stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) @@ -95,7 +95,7 @@ def _resolve_git_repo_root(candidate: Path) -> Path: completed = subprocess.run( ["git", "-C", str(candidate), "rev-parse", "--show-toplevel"], check=False, - text=True, + text=True, encoding="utf-8", errors="replace", stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) diff --git a/loopx/cli_commands/opencode2_goal_worker.py b/loopx/cli_commands/opencode2_goal_worker.py index 920acc0133..5038898574 100644 --- a/loopx/cli_commands/opencode2_goal_worker.py +++ b/loopx/cli_commands/opencode2_goal_worker.py @@ -117,7 +117,7 @@ def handle_opencode2_goal_worker_command( node_args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, - text=True, + text=True, encoding="utf-8", errors="replace", ) except FileNotFoundError: payload = { diff --git a/loopx/codex_cli_runtime_probe.py b/loopx/codex_cli_runtime_probe.py index b2495b39ef..7e326459dc 100644 --- a/loopx/codex_cli_runtime_probe.py +++ b/loopx/codex_cli_runtime_probe.py @@ -222,7 +222,7 @@ def probe_human_input_idle_seconds(*, timeout_seconds: float = DEFAULT_TIMEOUT_S ["ioreg", "-c", "IOHIDSystem"], check=False, capture_output=True, - text=True, + text=True, encoding="utf-8", errors="replace", timeout=timeout_seconds, ) except FileNotFoundError: @@ -275,7 +275,7 @@ def run_codex_cli_session_probe( [codex_bin, *extra_args], check=False, capture_output=True, - text=True, + text=True, encoding="utf-8", errors="replace", timeout=timeout_seconds, ) except FileNotFoundError: diff --git a/loopx/codex_cli_scheduler.py b/loopx/codex_cli_scheduler.py index 31327549f4..65b2b80213 100644 --- a/loopx/codex_cli_scheduler.py +++ b/loopx/codex_cli_scheduler.py @@ -354,7 +354,7 @@ def _run_scheduler_executor_shell_command( completed = subprocess.run( argv, check=False, - text=True, + text=True, encoding="utf-8", errors="replace", timeout=timeout_seconds, stdout=stdout, stderr=stderr, diff --git a/loopx/control_plane/agents/workspace_guard.py b/loopx/control_plane/agents/workspace_guard.py index b3722a2999..e96edf722a 100644 --- a/loopx/control_plane/agents/workspace_guard.py +++ b/loopx/control_plane/agents/workspace_guard.py @@ -41,7 +41,7 @@ def _git_command_output(path: Path, *args: str) -> str | None: check=False, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, - text=True, + text=True, encoding="utf-8", errors="replace", timeout=1.5, ) except (OSError, subprocess.TimeoutExpired): diff --git a/loopx/control_plane/effect_runtime.py b/loopx/control_plane/effect_runtime.py index 497bc5000f..92524ed552 100644 --- a/loopx/control_plane/effect_runtime.py +++ b/loopx/control_plane/effect_runtime.py @@ -269,7 +269,7 @@ def _probe_node() -> tuple[str, str | None, str | None]: [executable, "--version"], check=False, capture_output=True, - text=True, + text=True, encoding="utf-8", errors="replace", timeout=2, ) except (OSError, subprocess.TimeoutExpired): diff --git a/loopx/control_plane/goals/ssh_lifecycle_transport.py b/loopx/control_plane/goals/ssh_lifecycle_transport.py index 34c7106818..dde243afe8 100644 --- a/loopx/control_plane/goals/ssh_lifecycle_transport.py +++ b/loopx/control_plane/goals/ssh_lifecycle_transport.py @@ -76,7 +76,7 @@ def apply_ssh_goal_lifecycle( completed = subprocess.run( ["ssh", "-o", "ConnectTimeout=5", alias, command], capture_output=True, - text=True, + text=True, encoding="utf-8", errors="replace", timeout=timeout_seconds, ) except (OSError, subprocess.TimeoutExpired) as exc: diff --git a/loopx/control_plane/heartbeat/installed_prompt_update.py b/loopx/control_plane/heartbeat/installed_prompt_update.py index a0304d186c..847e9d7e87 100644 --- a/loopx/control_plane/heartbeat/installed_prompt_update.py +++ b/loopx/control_plane/heartbeat/installed_prompt_update.py @@ -187,7 +187,7 @@ def update_with_prompts(payload: dict, *, registry: Path, runtime_root: str | No try: # Do not accidentally import a checkout through the parent's PYTHONPATH. env = {key: value for key, value in os.environ.items() if key != "PYTHONPATH"} - result = subprocess.run(command, capture_output=True, text=True, env=env, + result = subprocess.run(command, capture_output=True, text=True, encoding="utf-8", errors="replace", env=env, timeout=timeout_seconds, cwd=directory) report = json.loads(result.stdout) if not isinstance(report, dict) or "results" not in report: diff --git a/loopx/control_plane/runtime/validation_command.py b/loopx/control_plane/runtime/validation_command.py index eb1a02b53d..f354a444f1 100644 --- a/loopx/control_plane/runtime/validation_command.py +++ b/loopx/control_plane/runtime/validation_command.py @@ -45,7 +45,7 @@ def run_caller_validation( argv, cwd=workspace, env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"}, - text=True, + text=True, encoding="utf-8", errors="replace", stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout_seconds, diff --git a/loopx/control_plane/status/ssh_tunnel.py b/loopx/control_plane/status/ssh_tunnel.py index ea7447995e..f469fbc182 100644 --- a/loopx/control_plane/status/ssh_tunnel.py +++ b/loopx/control_plane/status/ssh_tunnel.py @@ -57,7 +57,7 @@ def _remote_status_ok(alias: str, *, timeout: float = 5.0) -> bool: "curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8766/status.json", ], capture_output=True, - text=True, + text=True, encoding="utf-8", errors="replace", timeout=timeout, ) except (OSError, subprocess.TimeoutExpired): @@ -70,7 +70,7 @@ def _start_remote_status(alias: str) -> None: ["ssh", "-o", "ConnectTimeout=5", alias, _REMOTE_BOOTSTRAP], check=True, capture_output=True, - text=True, + text=True, encoding="utf-8", errors="replace", timeout=25, ) diff --git a/loopx/control_plane/testing/authority_e2e_fixtures.py b/loopx/control_plane/testing/authority_e2e_fixtures.py index c647377736..4bc996b0c2 100644 --- a/loopx/control_plane/testing/authority_e2e_fixtures.py +++ b/loopx/control_plane/testing/authority_e2e_fixtures.py @@ -421,7 +421,7 @@ def run_cli( cwd=REPO_ROOT, env=cli_env(workspace), capture_output=True, - text=True, + text=True, encoding="utf-8", errors="replace", timeout=timeout, check=False, ) @@ -444,7 +444,7 @@ def spawn_cli(workspace: CliWorkspace, *args: str) -> subprocess.Popen[str]: env=cli_env(workspace), stdout=subprocess.PIPE, stderr=subprocess.PIPE, - text=True, + text=True, encoding="utf-8", errors="replace", ) @@ -532,7 +532,7 @@ def ts_readback( command, cwd=REPO_ROOT, capture_output=True, - text=True, + text=True, encoding="utf-8", errors="replace", timeout=60, check=False, ) @@ -591,7 +591,7 @@ def tap_summary( cwd=cwd, env=dict(env) if env is not None else None, capture_output=True, - text=True, + text=True, encoding="utf-8", errors="replace", timeout=timeout, check=False, ) diff --git a/loopx/control_plane/testing/authority_e2e_ladder.py b/loopx/control_plane/testing/authority_e2e_ladder.py index 559cc02fd2..688683c820 100644 --- a/loopx/control_plane/testing/authority_e2e_ladder.py +++ b/loopx/control_plane/testing/authority_e2e_ladder.py @@ -246,7 +246,7 @@ def _run_live_matrix_script(environ: Mapping[str, str], *, live: bool) -> JsonOb cwd=REPO_ROOT, env=env, capture_output=True, - text=True, + text=True, encoding="utf-8", errors="replace", timeout=900, check=False, ) @@ -437,7 +437,7 @@ def _row_nokv_live_qualification(context: RowContext) -> RowOutcome: cwd=REPO_ROOT, env=dict(context.environ), capture_output=True, - text=True, + text=True, encoding="utf-8", errors="replace", timeout=600, check=False, ) @@ -916,7 +916,7 @@ def _git_output(*arguments: str) -> str | None: ["git", *arguments], cwd=REPO_ROOT, capture_output=True, - text=True, + text=True, encoding="utf-8", errors="replace", timeout=30, check=False, ) diff --git a/loopx/control_plane/testing/authority_e2e_rows_stage2c2.py b/loopx/control_plane/testing/authority_e2e_rows_stage2c2.py index b9e0f8cb0d..2892f00c1b 100644 --- a/loopx/control_plane/testing/authority_e2e_rows_stage2c2.py +++ b/loopx/control_plane/testing/authority_e2e_rows_stage2c2.py @@ -356,7 +356,7 @@ def history(workspace: GoalWorkspace) -> list[JsonObject]: [node, "--no-warnings", "--experimental-strip-types", "--input-type=module", "-e", script, str(request_path)], cwd=REPO_ROOT, capture_output=True, - text=True, + text=True, encoding="utf-8", errors="replace", timeout=60, check=False, ) @@ -428,7 +428,7 @@ def crash_cli(workspace: GoalWorkspace, window: str, *args: str) -> None: env=cli_env(workspace), stdout=subprocess.PIPE, stderr=subprocess.PIPE, - text=True, + text=True, encoding="utf-8", errors="replace", ) line = "" try: diff --git a/loopx/control_plane/testing/canary_harness.py b/loopx/control_plane/testing/canary_harness.py index dee1f12ebf..fc7e97a1a4 100644 --- a/loopx/control_plane/testing/canary_harness.py +++ b/loopx/control_plane/testing/canary_harness.py @@ -41,7 +41,7 @@ def run_json_cli( command, cwd=cwd or REPO_ROOT, check=False, - text=True, + text=True, encoding="utf-8", errors="replace", capture_output=True, ) if result.returncode != 0: @@ -78,7 +78,7 @@ def run_json_cli_result( command, cwd=cwd or REPO_ROOT, check=False, - text=True, + text=True, encoding="utf-8", errors="replace", capture_output=True, ) if not result.stdout.strip(): diff --git a/loopx/control_plane/testing/capability_monitor_repair_tool_behavior.py b/loopx/control_plane/testing/capability_monitor_repair_tool_behavior.py index a00a8a7f6d..f38ec73b11 100644 --- a/loopx/control_plane/testing/capability_monitor_repair_tool_behavior.py +++ b/loopx/control_plane/testing/capability_monitor_repair_tool_behavior.py @@ -92,7 +92,7 @@ def _build_capability_repair_fixture(root: Path) -> _SelectedTodoToolFixture: cwd=project_root, check=True, capture_output=True, - text=True, + text=True, encoding="utf-8", errors="replace", timeout=10, ) state_path.write_text( diff --git a/loopx/control_plane/testing/model_tool_behavior.py b/loopx/control_plane/testing/model_tool_behavior.py index ee5a700479..031d210145 100644 --- a/loopx/control_plane/testing/model_tool_behavior.py +++ b/loopx/control_plane/testing/model_tool_behavior.py @@ -532,7 +532,7 @@ def execute_loopx_cli( env=env, check=False, capture_output=True, - text=True, + text=True, encoding="utf-8", errors="replace", timeout=timeout_seconds, ) if completed.returncode not in accepted_return_codes: diff --git a/loopx/control_plane/testing/release_commit_qualification.py b/loopx/control_plane/testing/release_commit_qualification.py index 0a81b5e97a..994369fad3 100644 --- a/loopx/control_plane/testing/release_commit_qualification.py +++ b/loopx/control_plane/testing/release_commit_qualification.py @@ -503,7 +503,7 @@ def _git(repo_root: Path, *args: str) -> str: ["git", "-C", str(repo_root), *args], check=False, capture_output=True, - text=True, + text=True, encoding="utf-8", errors="replace", ) if completed.returncode != 0: raise ValueError("release source identity is unavailable from Git") diff --git a/loopx/control_plane/testing/replan_semantic_action_behavior.py b/loopx/control_plane/testing/replan_semantic_action_behavior.py index 20b60c75ab..9d30666335 100644 --- a/loopx/control_plane/testing/replan_semantic_action_behavior.py +++ b/loopx/control_plane/testing/replan_semantic_action_behavior.py @@ -724,7 +724,7 @@ def _execute_workspace_read( cwd=fixture.project_root, check=False, capture_output=True, - text=True, + text=True, encoding="utf-8", errors="replace", timeout=10, ) status = completed.returncode diff --git a/loopx/control_plane/testing/scoped_gate_successor_tool_behavior.py b/loopx/control_plane/testing/scoped_gate_successor_tool_behavior.py index cddac6ba00..b275d42eca 100644 --- a/loopx/control_plane/testing/scoped_gate_successor_tool_behavior.py +++ b/loopx/control_plane/testing/scoped_gate_successor_tool_behavior.py @@ -99,7 +99,7 @@ def _build_scoped_gate_fixture(root: Path) -> _SelectedTodoToolFixture: cwd=project_root, check=True, capture_output=True, - text=True, + text=True, encoding="utf-8", errors="replace", timeout=10, ) state_path.write_text( diff --git a/loopx/control_plane/testing/selected_todo_tool_behavior.py b/loopx/control_plane/testing/selected_todo_tool_behavior.py index dd62909dc1..0545425f39 100644 --- a/loopx/control_plane/testing/selected_todo_tool_behavior.py +++ b/loopx/control_plane/testing/selected_todo_tool_behavior.py @@ -133,7 +133,7 @@ def _build_fixture( cwd=project_root, check=True, capture_output=True, - text=True, + text=True, encoding="utf-8", errors="replace", timeout=10, ) decoy_action = ( @@ -774,7 +774,7 @@ def _execute_read_plan( cwd=fixture.project_root, check=False, capture_output=True, - text=True, + text=True, encoding="utf-8", errors="replace", timeout=10, ) status = completed.returncode @@ -818,7 +818,7 @@ def _execute_workspace_read( cwd=fixture.project_root, check=False, capture_output=True, - text=True, + text=True, encoding="utf-8", errors="replace", timeout=10, ) if completed.returncode != 0: diff --git a/loopx/control_plane/testing/terminal_settlement_tool_behavior.py b/loopx/control_plane/testing/terminal_settlement_tool_behavior.py index 2895f9c3fb..153cb401d1 100644 --- a/loopx/control_plane/testing/terminal_settlement_tool_behavior.py +++ b/loopx/control_plane/testing/terminal_settlement_tool_behavior.py @@ -144,7 +144,7 @@ def _build_fixture(root: Path) -> _TerminalSettlementFixture: cwd=project_root, check=True, capture_output=True, - text=True, + text=True, encoding="utf-8", errors="replace", timeout=10, ) subprocess.run( @@ -158,7 +158,7 @@ def _build_fixture(root: Path) -> _TerminalSettlementFixture: cwd=project_root, check=True, capture_output=True, - text=True, + text=True, encoding="utf-8", errors="replace", timeout=10, ) state_path.write_text( @@ -334,7 +334,7 @@ def _build_reentry_fixture(root: Path) -> _TerminalSettlementReentryFixture: cwd=project_root, check=True, capture_output=True, - text=True, + text=True, encoding="utf-8", errors="replace", timeout=10, ) subprocess.run( @@ -348,7 +348,7 @@ def _build_reentry_fixture(root: Path) -> _TerminalSettlementReentryFixture: cwd=project_root, check=True, capture_output=True, - text=True, + text=True, encoding="utf-8", errors="replace", timeout=10, ) state_path.write_text( @@ -656,7 +656,7 @@ def _execute_workspace_read( cwd=state.fixture.project_root, check=False, capture_output=True, - text=True, + text=True, encoding="utf-8", errors="replace", timeout=10, ) status = completed.returncode diff --git a/loopx/control_plane/testing/turn_journal_characterization.py b/loopx/control_plane/testing/turn_journal_characterization.py index de77fd61f4..2960b730e2 100644 --- a/loopx/control_plane/testing/turn_journal_characterization.py +++ b/loopx/control_plane/testing/turn_journal_characterization.py @@ -158,7 +158,7 @@ def run_turn_journal_probe_command( check=False, capture_output=True, input=json.dumps(request, sort_keys=True), - text=True, + text=True, encoding="utf-8", errors="replace", timeout=30, ) if completed.returncode != 0: diff --git a/loopx/control_plane/todos/completion_validation.py b/loopx/control_plane/todos/completion_validation.py index aa0d793885..7aea8a5aad 100644 --- a/loopx/control_plane/todos/completion_validation.py +++ b/loopx/control_plane/todos/completion_validation.py @@ -102,7 +102,7 @@ def _git_workspace_is_clean(path: Path) -> bool | None: check=False, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, - text=True, + text=True, encoding="utf-8", errors="replace", timeout=2, ) except (OSError, subprocess.TimeoutExpired): diff --git a/loopx/control_plane/turn_driver/codex_cli.py b/loopx/control_plane/turn_driver/codex_cli.py index dd612788be..0ba85c65ed 100644 --- a/loopx/control_plane/turn_driver/codex_cli.py +++ b/loopx/control_plane/turn_driver/codex_cli.py @@ -761,7 +761,7 @@ def run_codex_cli_host( stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, - text=True, + text=True, encoding="utf-8", errors="replace", start_new_session=True, ) observed_session: list[str] = [] diff --git a/loopx/control_plane/turn_driver/command_validation.py b/loopx/control_plane/turn_driver/command_validation.py index 8f1a1b8cdd..1a765025c4 100644 --- a/loopx/control_plane/turn_driver/command_validation.py +++ b/loopx/control_plane/turn_driver/command_validation.py @@ -122,7 +122,7 @@ def validate( normalized, cwd=project, input=json.dumps(result, ensure_ascii=False, separators=(",", ":")), - text=True, + text=True, encoding="utf-8", errors="replace", stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=max(1.0, timeout_seconds), diff --git a/loopx/control_plane/turn_driver/executor.py b/loopx/control_plane/turn_driver/executor.py index 854586f32d..fdd3e477b2 100644 --- a/loopx/control_plane/turn_driver/executor.py +++ b/loopx/control_plane/turn_driver/executor.py @@ -655,7 +655,7 @@ def _run_host( list(argv), cwd=project, input=json.dumps(request, ensure_ascii=False, separators=(",", ":")), - text=True, + text=True, encoding="utf-8", errors="replace", capture_output=True, timeout=max(1.0, timeout_seconds), check=False, diff --git a/loopx/dashboard_launcher.py b/loopx/dashboard_launcher.py index 860f6a3cb7..95ec4320ed 100644 --- a/loopx/dashboard_launcher.py +++ b/loopx/dashboard_launcher.py @@ -100,7 +100,7 @@ def _listener_pids(port: int) -> list[int]: result = subprocess.run( ["lsof", "-nP", f"-iTCP:{port}", "-sTCP:LISTEN", "-t"], capture_output=True, - text=True, + text=True, encoding="utf-8", errors="replace", timeout=5, ) except (OSError, subprocess.SubprocessError) as exc: @@ -125,7 +125,7 @@ def _is_same_user_loopx_chat_process(pid: int) -> bool: result = subprocess.run( ["ps", "-ww", "-p", str(pid), "-o", "uid=", "-o", "command="], capture_output=True, - text=True, + text=True, encoding="utf-8", errors="replace", timeout=5, ) except (OSError, subprocess.SubprocessError): diff --git a/loopx/experiments/planner_worker/traex.py b/loopx/experiments/planner_worker/traex.py index ce408ad1ad..1d5316f0aa 100644 --- a/loopx/experiments/planner_worker/traex.py +++ b/loopx/experiments/planner_worker/traex.py @@ -89,7 +89,7 @@ def _run_traex_exec( cwd=cwd, check=False, capture_output=True, - text=True, + text=True, encoding="utf-8", errors="replace", timeout=timeout_seconds, ) if result.returncode != 0: @@ -282,7 +282,7 @@ def assert_clean(self, cwd: Path) -> None: cwd=cwd, check=False, capture_output=True, - text=True, + text=True, encoding="utf-8", errors="replace", ) if probe.returncode != 0 or probe.stdout.strip() != "true": raise TraexPlannerWorkerError("planner-worker workspace must be a git worktree") diff --git a/loopx/extensions/lark/app_setup.py b/loopx/extensions/lark/app_setup.py index 72f38194a7..d72ec98899 100644 --- a/loopx/extensions/lark/app_setup.py +++ b/loopx/extensions/lark/app_setup.py @@ -50,7 +50,7 @@ def _default_process_factory(args: list[str], env: dict[str, str]) -> SetupProce env=env, stderr=subprocess.STDOUT, stdout=subprocess.PIPE, - text=True, + text=True, encoding="utf-8", errors="replace", ) diff --git a/loopx/extensions/lark/document_comment_provider.py b/loopx/extensions/lark/document_comment_provider.py index 078c443420..670354edf0 100644 --- a/loopx/extensions/lark/document_comment_provider.py +++ b/loopx/extensions/lark/document_comment_provider.py @@ -126,7 +126,7 @@ def _default_runner( cwd=str(cwd) if cwd else None, timeout=timeout, capture_output=True, - text=True, + text=True, encoding="utf-8", errors="replace", check=False, ) return { diff --git a/loopx/extensions/lark/event_collector_runtime.py b/loopx/extensions/lark/event_collector_runtime.py index 2350d0b661..826e5e2cd2 100644 --- a/loopx/extensions/lark/event_collector_runtime.py +++ b/loopx/extensions/lark/event_collector_runtime.py @@ -754,7 +754,7 @@ def run_lark_event_collector( process = subprocess.Popen( _consume_argv(config, command_prefix), stdout=subprocess.PIPE, - text=True, + text=True, encoding="utf-8", errors="replace", bufsize=1, ) callback_process: subprocess.Popen[str] | None = None @@ -783,7 +783,7 @@ def run_lark_event_collector( stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, - text=True, + text=True, encoding="utf-8", errors="replace", bufsize=1, ) _write_operation_callback_status( diff --git a/loopx/extensions/lark/goal_topic_runtime.py b/loopx/extensions/lark/goal_topic_runtime.py index a33302e8a9..d9349f3f39 100644 --- a/loopx/extensions/lark/goal_topic_runtime.py +++ b/loopx/extensions/lark/goal_topic_runtime.py @@ -135,7 +135,7 @@ def _default_simple_runner(args: list[str]) -> Mapping[str, Any]: completed = subprocess.run( args, capture_output=True, - text=True, + text=True, encoding="utf-8", errors="replace", check=False, timeout=15, ) @@ -154,7 +154,7 @@ def _default_process_factory(args: list[str]) -> subprocess.Popen[str]: stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, - text=True, + text=True, encoding="utf-8", errors="replace", bufsize=1, ) diff --git a/loopx/extensions/lark/inbox_reactions.py b/loopx/extensions/lark/inbox_reactions.py index 66fdcf491a..ec692e36f5 100644 --- a/loopx/extensions/lark/inbox_reactions.py +++ b/loopx/extensions/lark/inbox_reactions.py @@ -35,7 +35,7 @@ def _default_runner(args: Sequence[str]) -> Mapping[str, Any]: result = subprocess.run( list(args), - text=True, + text=True, encoding="utf-8", errors="replace", capture_output=True, check=False, timeout=10, diff --git a/loopx/extensions/lark/inbox_reply.py b/loopx/extensions/lark/inbox_reply.py index 771e0bd552..e0737dc7ed 100644 --- a/loopx/extensions/lark/inbox_reply.py +++ b/loopx/extensions/lark/inbox_reply.py @@ -40,7 +40,7 @@ class BotIdentityVerification(str, Enum): def _default_runner(args: Sequence[str]) -> Mapping[str, Any]: result = subprocess.run( list(args), - text=True, + text=True, encoding="utf-8", errors="replace", capture_output=True, timeout=30, check=False, diff --git a/loopx/extensions/lark/miaoda_report.py b/loopx/extensions/lark/miaoda_report.py index aa8c7e1d1d..3791652014 100644 --- a/loopx/extensions/lark/miaoda_report.py +++ b/loopx/extensions/lark/miaoda_report.py @@ -107,7 +107,7 @@ def _default_runner( cwd=str(cwd) if cwd else None, timeout=timeout, capture_output=True, - text=True, + text=True, encoding="utf-8", errors="replace", check=False, ) return { diff --git a/loopx/extensions/lark/presentation/kanban.py b/loopx/extensions/lark/presentation/kanban.py index 166f22c6f7..0f4f026e35 100644 --- a/loopx/extensions/lark/presentation/kanban.py +++ b/loopx/extensions/lark/presentation/kanban.py @@ -465,7 +465,7 @@ def default_subprocess_runner( cwd=str(cwd) if cwd else None, timeout=timeout, capture_output=True, - text=True, + text=True, encoding="utf-8", errors="replace", ) return { "returncode": completed.returncode, diff --git a/loopx/extensions/openviking_semantic_preference/provider.py b/loopx/extensions/openviking_semantic_preference/provider.py index 04bf84428e..62cdf255f4 100644 --- a/loopx/extensions/openviking_semantic_preference/provider.py +++ b/loopx/extensions/openviking_semantic_preference/provider.py @@ -57,7 +57,7 @@ def _run_ov( capture_output=True, check=False, env=_environment(cli_config), - text=True, + text=True, encoding="utf-8", errors="replace", timeout=timeout_seconds, ) except (OSError, subprocess.TimeoutExpired) as exc: diff --git a/loopx/goal_mode_mcp.py b/loopx/goal_mode_mcp.py index 98d21327b7..813704d46d 100644 --- a/loopx/goal_mode_mcp.py +++ b/loopx/goal_mode_mcp.py @@ -92,11 +92,11 @@ def run_cli( command += ["--registry", registry] command += ["--format", "json"] result = subprocess.run( - [*command, *args], capture_output=True, text=True, timeout=30 + [*command, *args], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=30 ) if legacy_args is not None and self._runtime_profile_flag_is_unsupported(result): result = subprocess.run( - [*command, *legacy_args], capture_output=True, text=True, timeout=30 + [*command, *legacy_args], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=30 ) return (result.stdout or "") + ( ("\n" + result.stderr) if result.returncode else "" diff --git a/loopx/kunluncode_goal_mode/app_server.py b/loopx/kunluncode_goal_mode/app_server.py index b7a410a796..1ea6988d23 100644 --- a/loopx/kunluncode_goal_mode/app_server.py +++ b/loopx/kunluncode_goal_mode/app_server.py @@ -188,7 +188,7 @@ def __init__( stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, - text=True, + text=True, encoding="utf-8", errors="replace", bufsize=1, ) if self.process.stdout is None or self.process.stderr is None: diff --git a/loopx/kunluncode_goal_mode/cli.py b/loopx/kunluncode_goal_mode/cli.py index f2b2bed8be..29ba7fbadd 100644 --- a/loopx/kunluncode_goal_mode/cli.py +++ b/loopx/kunluncode_goal_mode/cli.py @@ -42,7 +42,7 @@ def _run( command, cwd=str(cwd) if cwd else None, capture_output=True, - text=True, + text=True, encoding="utf-8", errors="replace", timeout=timeout, ) diff --git a/loopx/kunluncode_goal_mode/control_plane.py b/loopx/kunluncode_goal_mode/control_plane.py index 3495b07411..6bdf21f560 100644 --- a/loopx/kunluncode_goal_mode/control_plane.py +++ b/loopx/kunluncode_goal_mode/control_plane.py @@ -42,7 +42,7 @@ def _default_command_runner( command, cwd=str(cwd), capture_output=True, - text=True, + text=True, encoding="utf-8", errors="replace", timeout=timeout, ) diff --git a/loopx/project_alias.py b/loopx/project_alias.py index c62e4d76b3..55ca47d8da 100644 --- a/loopx/project_alias.py +++ b/loopx/project_alias.py @@ -21,7 +21,7 @@ def _run_git(project: Path, *args: str) -> subprocess.CompletedProcess[str] | No try: return subprocess.run( ["git", "-C", str(project), *args], - text=True, + text=True, encoding="utf-8", errors="replace", stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False, diff --git a/loopx/release_candidate.py b/loopx/release_candidate.py index 897280f494..807a39ff99 100644 --- a/loopx/release_candidate.py +++ b/loopx/release_candidate.py @@ -68,7 +68,7 @@ def probe(item: tuple[str, tuple[str, ...]]) -> tuple[str, dict[str, Any]]: command_argv(command_path, args), check=False, capture_output=True, - text=True, + text=True, encoding="utf-8", errors="replace", timeout=30, ) except (OSError, subprocess.TimeoutExpired) as exc: diff --git a/loopx/release_manifest.py b/loopx/release_manifest.py index 4e68d32a0f..ec5a4320ef 100644 --- a/loopx/release_manifest.py +++ b/loopx/release_manifest.py @@ -76,7 +76,7 @@ def _run_git(source_root: Path, args: list[str]) -> str | None: ["git", "-C", str(source_root), *args], check=False, capture_output=True, - text=True, + text=True, encoding="utf-8", errors="replace", ) except OSError: return None diff --git a/loopx/repository_identity.py b/loopx/repository_identity.py index 52d452d5c4..e1c8f9dea8 100644 --- a/loopx/repository_identity.py +++ b/loopx/repository_identity.py @@ -60,7 +60,7 @@ def _origin_remote(project: Path, git_bin: str) -> str: [git_bin, "-C", str(project), "config", "--get", "remote.origin.url"], capture_output=True, check=False, - text=True, + text=True, encoding="utf-8", errors="replace", timeout=10, ) except (OSError, subprocess.TimeoutExpired) as exc: diff --git a/loopx/self_update.py b/loopx/self_update.py index ae16504896..162b74322c 100644 --- a/loopx/self_update.py +++ b/loopx/self_update.py @@ -884,7 +884,7 @@ def restart_managed_loopx_services() -> list[str]: result = subprocess.run( ["launchctl", "kickstart", "-k", f"gui/{uid}/{label}"], capture_output=True, - text=True, + text=True, encoding="utf-8", errors="replace", timeout=30, ) if result.returncode == 0: @@ -952,7 +952,7 @@ def _execute_python_distribution_update( results: dict[str, subprocess.CompletedProcess[str]] = {} results["install"] = subprocess.run( commands["install"], - text=True, + text=True, encoding="utf-8", errors="replace", capture_output=True, timeout=timeout_seconds, ) @@ -960,7 +960,7 @@ def _execute_python_distribution_update( for step in ("workflow_skills", "slash_commands", "doctor"): results[step] = subprocess.run( commands[step], - text=True, + text=True, encoding="utf-8", errors="replace", capture_output=True, timeout=timeout_seconds, ) @@ -970,7 +970,7 @@ def _execute_python_distribution_update( ): results["extension_doctor"] = subprocess.run( commands["extension_doctor"], - text=True, + text=True, encoding="utf-8", errors="replace", capture_output=True, timeout=timeout_seconds, ) @@ -1100,7 +1100,7 @@ def execute_update_plan( loopx_bin = Path.home() / ".local" / "bin" / "loopx" doctor_result = subprocess.run( [str(loopx_bin), "--format", "json", "doctor"], - text=True, + text=True, encoding="utf-8", errors="replace", capture_output=True, env=env, timeout=timeout_seconds, @@ -1126,7 +1126,7 @@ def execute_update_plan( "--all-enabled", "--execute", ], - text=True, + text=True, encoding="utf-8", errors="replace", capture_output=True, env=env, timeout=timeout_seconds, @@ -1218,7 +1218,7 @@ def execute_rollback_plan( os.replace(temp_link, loopx_bin) doctor_result = subprocess.run( [str(loopx_bin), "--format", "json", "doctor"], - text=True, + text=True, encoding="utf-8", errors="replace", capture_output=True, timeout=timeout_seconds, ) @@ -1240,7 +1240,7 @@ def execute_rollback_plan( "--all-enabled", "--execute", ], - text=True, + text=True, encoding="utf-8", errors="replace", capture_output=True, timeout=timeout_seconds, ) diff --git a/loopx/self_update_download.py b/loopx/self_update_download.py index e145701ef8..f67207349e 100644 --- a/loopx/self_update_download.py +++ b/loopx/self_update_download.py @@ -44,7 +44,7 @@ def run_archive_installer( try: result = subprocess.run( args, - text=True, + text=True, encoding="utf-8", errors="replace", capture_output=True, env=env, timeout=remaining, @@ -71,7 +71,7 @@ def run_archive_installer( return subprocess.run( ["bash", str(script)], check=False, - text=True, + text=True, encoding="utf-8", errors="replace", capture_output=True, env=env, timeout=remaining, diff --git a/loopx/skill_install_readback.py b/loopx/skill_install_readback.py index 55936cf439..e965fa2e3e 100644 --- a/loopx/skill_install_readback.py +++ b/loopx/skill_install_readback.py @@ -292,7 +292,7 @@ def _git_value(root: Path, *args: str) -> str | None: ["git", "-C", str(root), *args], check=False, capture_output=True, - text=True, + text=True, encoding="utf-8", errors="replace", ) except OSError: return None diff --git a/loopx/windows_install.py b/loopx/windows_install.py index 07cf83927e..e311904b2c 100644 --- a/loopx/windows_install.py +++ b/loopx/windows_install.py @@ -38,7 +38,7 @@ def _resolve_python(requested: str) -> Path: [requested, "-c", "import sys; print(sys.executable); raise SystemExit(sys.version_info < (3, 11))"], check=False, capture_output=True, - text=True, + text=True, encoding="utf-8", errors="replace", ) if result.returncode != 0: raise RuntimeError( diff --git a/tests/test_runtime_subprocess_utf8.py b/tests/test_runtime_subprocess_utf8.py new file mode 100644 index 0000000000..3fe6b19e70 --- /dev/null +++ b/tests/test_runtime_subprocess_utf8.py @@ -0,0 +1,101 @@ +"""Text-mode subprocess reads in the shipped runtime must pin UTF-8 explicitly. + +`subprocess.run(..., text=True)` without `encoding=` decodes the child's output +with `locale.getpreferredencoding(False)` - gbk on a zh-CN Windows host - while +`gh`, `git`, and the wrapped LoopX CLI all emit UTF-8. On such a host a single +non-ASCII byte kills the pipe reader thread, `result.stdout` becomes `None` +with `returncode == 0`, and the caller reports a misleading secondary error. +""" + +from __future__ import annotations + +import ast +import json +import subprocess +import sys +from pathlib import Path + +from loopx.capabilities.change_quality import scope +from loopx.claude_goal_mode.scripts import goalmode_cmd + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +def _text_mode_calls_without_encoding() -> list[str]: + offenders: list[str] = [] + for path in sorted((REPO_ROOT / "loopx").rglob("*.py")): + tree = ast.parse(path.read_text(encoding="utf-8")) + for node in ast.walk(tree): + if not ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr in ("run", "Popen", "check_output") + and isinstance(node.func.value, ast.Name) + and node.func.value.id == "subprocess" + ): + continue + if "encoding" in [kw.arg for kw in node.keywords]: + continue + for kw in node.keywords: + if ( + kw.arg == "text" + and isinstance(kw.value, ast.Constant) + and kw.value.value is True + ): + offenders.append(f"{path.relative_to(REPO_ROOT)}:{node.lineno}") + return offenders + + +def test_shipped_runtime_pins_utf8_for_every_text_mode_subprocess_call() -> None: + """A text-mode child read without an explicit codec reopens the #4155 bug.""" + + assert _text_mode_calls_without_encoding() == [] + + +def test_goal_mode_cli_probe_survives_gbk_host_locale(monkeypatch) -> None: + """The goal-mode loopx JSON probe decodes UTF-8 payload on a cp936 host.""" + + monkeypatch.setattr(subprocess, "_text_encoding", lambda: "gbk") + payload = '{"goal_id": "gbk-probe", "title": "这条issue什么都不用改"}' + script = ( + "import sys; sys.stdout.buffer.write(" + + repr(payload.encode("utf-8")) + + ")" + ) + monkeypatch.setattr( + goalmode_cmd, + "gh_prefix", + lambda: [sys.executable, "-c", script], + ) + + result = goalmode_cmd.gh(["--format", "json", "quota", "should-run"]) + + assert result.returncode == 0 + assert json.loads(result.stdout)["title"] == "这条issue什么都不用改" + + +def test_change_quality_scope_git_helper_pins_codec_only_in_text_mode( + tmp_path: Path, +) -> None: + """Bytes-mode `_git` callers must stay bytes while text callers get UTF-8.""" + + env_repo = tmp_path / "repo" + env_repo.mkdir() + (env_repo / "中文笔记.txt").write_bytes("内容\n".encode("utf-8")) + for args in ( + ["git", "init", "-q"], + ["git", "add", "."], + ["git", "-c", "user.name=t", "-c", "user.email=t@example.com", "commit", "-qm", "init"], + ): + subprocess.run(args, cwd=env_repo, check=True, capture_output=True) + + listed = scope._git( + env_repo, "-c", "core.quotepath=false", "ls-files", text=True + ) + assert listed.returncode == 0 + assert "中文笔记.txt" in listed.stdout + + raw = scope._git(env_repo, "-c", "core.quotepath=false", "ls-files") + assert raw.returncode == 0 + assert isinstance(raw.stdout, bytes) + assert "中文笔记.txt".encode("utf-8") in raw.stdout From c83fcdfe562a5c386871bfe6ea9c710a5920de7b Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Tue, 15 Sep 2026 19:00:59 +0800 Subject: [PATCH 2/2] test(runtime): require utf-8 as the pinned text-mode codec Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- tests/test_runtime_subprocess_utf8.py | 62 ++++++++++++++++++++++----- 1 file changed, 52 insertions(+), 10 deletions(-) diff --git a/tests/test_runtime_subprocess_utf8.py b/tests/test_runtime_subprocess_utf8.py index 3fe6b19e70..992bdfcfc0 100644 --- a/tests/test_runtime_subprocess_utf8.py +++ b/tests/test_runtime_subprocess_utf8.py @@ -21,9 +21,17 @@ REPO_ROOT = Path(__file__).resolve().parents[1] -def _text_mode_calls_without_encoding() -> list[str]: +def _text_mode_calls_without_utf8_encoding(package_root: Path | None = None) -> list[str]: + """Locate text-mode subprocess reads that do not pin UTF-8. + + Reporting only a missing `encoding` keyword is too weak: `encoding="latin-1"` + reproduces the same locale bug with an explicit codec, so the guard must + check the pinned value as well. + """ + + root = package_root or (REPO_ROOT / "loopx") offenders: list[str] = [] - for path in sorted((REPO_ROOT / "loopx").rglob("*.py")): + for path in sorted(root.rglob("*.py")): tree = ast.parse(path.read_text(encoding="utf-8")) for node in ast.walk(tree): if not ( @@ -34,22 +42,56 @@ def _text_mode_calls_without_encoding() -> list[str]: and node.func.value.id == "subprocess" ): continue - if "encoding" in [kw.arg for kw in node.keywords]: + text_mode = any( + kw.arg == "text" + and isinstance(kw.value, ast.Constant) + and kw.value.value is True + for kw in node.keywords + ) + if not text_mode: continue - for kw in node.keywords: - if ( - kw.arg == "text" + pinned = next( + ( + kw.value.value + for kw in node.keywords + if kw.arg == "encoding" and isinstance(kw.value, ast.Constant) - and kw.value.value is True - ): - offenders.append(f"{path.relative_to(REPO_ROOT)}:{node.lineno}") + and isinstance(kw.value.value, str) + ), + None, + ) + if pinned == "utf-8": + continue + offenders.append(f"{path.relative_to(root.parent)}:{node.lineno}") return offenders def test_shipped_runtime_pins_utf8_for_every_text_mode_subprocess_call() -> None: """A text-mode child read without an explicit codec reopens the #4155 bug.""" - assert _text_mode_calls_without_encoding() == [] + assert _text_mode_calls_without_utf8_encoding() == [] + + +def test_guard_reports_missing_and_non_utf8_codecs(tmp_path: Path) -> None: + """The guard covers a wrong explicit codec, not only a missing keyword.""" + + package = tmp_path / "loopx" + package.mkdir() + (package / "sample.py").write_text( + "import subprocess\n" + "\n" + "\n" + "def reads() -> None:\n" + " subprocess.run(['a'], text=True)\n" + " subprocess.run(['b'], text=True, encoding='latin-1')\n" + " subprocess.run(['c'], text=True, encoding='utf-8')\n" + " subprocess.run(['d'], capture_output=True)\n", + encoding="utf-8", + ) + + offenders = _text_mode_calls_without_utf8_encoding(package) + + assert [entry.rsplit(":", 1)[-1] for entry in offenders] == ["5", "6"] def test_goal_mode_cli_probe_survives_gbk_host_locale(monkeypatch) -> None: