diff --git a/examples/codex-cli-bootstrap-message-smoke.py b/examples/codex-cli-bootstrap-message-smoke.py index 42e2ba27c5..1a69c41e14 100644 --- a/examples/codex-cli-bootstrap-message-smoke.py +++ b/examples/codex-cli-bootstrap-message-smoke.py @@ -4,6 +4,7 @@ from __future__ import annotations import json +import os import subprocess import sys from pathlib import Path @@ -51,9 +52,15 @@ "--bootstrap", "--codex-app", "Do not spend quota for a setup-only turn", - "python3 -m pip install --upgrade loopx", - "workflow-skills --install", - "loopx-project.github.io/loopx/install.sh", + *( + ("loopx doctor",) + if os.name == "nt" + else ( + "python3 -m pip install --upgrade loopx", + "workflow-skills --install", + "loopx-project.github.io/loopx/install.sh", + ) + ), ) @@ -63,9 +70,17 @@ def assert_message_contract(payload: dict[str, object]) -> None: assert payload["invocation_mode"] == "codex_cli_setup_then_goal_mode", payload assert payload["goal_id"] == GOAL_ID, payload assert payload["agent_id"] == AGENT_ID, payload - assert "python3 -m pip install --upgrade loopx" in str(payload["install_repair_command"]), payload - assert "workflow-skills --install" in str(payload["install_repair_command"]), payload - assert "loopx-project.github.io/loopx/install.sh" in str(payload["install_repair_command"]), payload + repair = str(payload["install_repair_command"]) + assert "loopx doctor" in repair, payload + if os.name == "nt": + # Native Windows has no python3 or Bash; the owner prints the checkout + # installer or the running interpreter's pip repair instead. + assert "| bash" not in repair, payload + assert "install-windows.ps1" in repair or "-m pip install --upgrade loopx" in repair, payload + else: + assert "python3 -m pip install --upgrade loopx" in repair, payload + assert "workflow-skills --install" in repair, payload + assert "loopx-project.github.io/loopx/install.sh" in repair, payload assert payload["existing_goal_probe_command"] == payload["quota_guard_command"], payload assert "heartbeat-prompt --thin" in str(payload["heartbeat_prompt_command"]), payload assert "heartbeat-prompt --thin" in str(payload["heartbeat_prompt_json_command"]), payload @@ -229,8 +244,12 @@ def main() -> int: assert "Fresh Repo Install Repair" in cli_markdown, cli_markdown assert "Post-Bootstrap Loop Loader" in cli_markdown, cli_markdown assert "Transcript-Free Validation Checklist" in cli_markdown, cli_markdown - assert "loopx-project.github.io/loopx/install.sh" in cli_markdown, cli_markdown - assert "python3 -m pip install --upgrade loopx" in cli_markdown, cli_markdown + if os.name == "nt": + assert "loopx doctor" in cli_markdown, cli_markdown + assert "| bash" not in cli_markdown, cli_markdown + else: + assert "loopx-project.github.io/loopx/install.sh" in cli_markdown, cli_markdown + assert "python3 -m pip install --upgrade loopx" in cli_markdown, cli_markdown cli_message_only = run_cli( "codex-cli-bootstrap-message", diff --git a/examples/codex-cli-first-run-rehearsal-smoke.py b/examples/codex-cli-first-run-rehearsal-smoke.py index fdd83e879d..ac64e15fb8 100644 --- a/examples/codex-cli-first-run-rehearsal-smoke.py +++ b/examples/codex-cli-first-run-rehearsal-smoke.py @@ -3,6 +3,7 @@ from __future__ import annotations +import os import subprocess import sys from pathlib import Path @@ -95,8 +96,16 @@ def assert_cli_surfaces_align() -> None: assert not message.startswith("/goal "), message assert "setup/bootstrap instruction" in normalized, message assert "/goal " in normalized, message - assert "python3 -m pip install --upgrade loopx" in normalized, message - assert "workflow-skills --install" in normalized, message + assert "loopx doctor" in normalized, message + if os.name == "nt": + assert "| bash" not in normalized, message + assert ( + "install-windows.ps1" in normalized + or "-m pip install --upgrade loopx" in normalized + ), message + else: + assert "python3 -m pip install --upgrade loopx" in normalized, message + assert "workflow-skills --install" in normalized, message assert "Codex CLI TUI" in normalized, message assert "quota should-run" in normalized, message assert "settlement_plan.ordered_steps" in normalized, message diff --git a/examples/codex-cli-tui-bootstrap-smoke-bundle-smoke.py b/examples/codex-cli-tui-bootstrap-smoke-bundle-smoke.py index 78200a9ad6..2b59e7a1dd 100644 --- a/examples/codex-cli-tui-bootstrap-smoke-bundle-smoke.py +++ b/examples/codex-cli-tui-bootstrap-smoke-bundle-smoke.py @@ -137,9 +137,18 @@ def main() -> None: assert bundle["agent_id"] == AGENT_ID, bundle assert "--message-only" in bundle["message_only_command"], bundle assert str(fresh_repo) in bundle["message_only_command"], bundle - assert "loopx-project.github.io/loopx/install.sh" in bundle["install_repair_command"], bundle - assert "python3 -m pip install --upgrade loopx" in bundle["install_repair_command"], bundle - assert "workflow-skills --install" in bundle["install_repair_command"], bundle + repair = bundle["install_repair_command"] + assert "loopx doctor" in repair, bundle + if os.name == "nt": + assert "| bash" not in repair, bundle + assert ( + "install-windows.ps1" in repair + or "-m pip install --upgrade loopx" in repair + ), bundle + else: + assert "loopx-project.github.io/loopx/install.sh" in repair, bundle + assert "python3 -m pip install --upgrade loopx" in repair, bundle + assert "workflow-skills --install" in repair, bundle assert "quota should-run" in bundle["quota_guard_command"], bundle assert "--agent-id codex-side-bypass" in bundle["quota_guard_command"], bundle assert "refresh-state --goal-id public-fresh-codex-cli-goal" in bundle["refresh_command"], bundle @@ -183,8 +192,10 @@ def main() -> None: assert not message_only.startswith("/goal "), message_only assert "# Codex CLI LoopX Bootstrap Message" not in message_only, message_only assert "Fresh Repo Install Repair" not in message_only, message_only - assert "loopx-project.github.io/loopx/install.sh" in message_only, message_only - assert "python3 -m pip install --upgrade loopx" in message_only, message_only + assert "loopx doctor" in message_only, message_only + if os.name != "nt": + assert "loopx-project.github.io/loopx/install.sh" in message_only, message_only + assert "python3 -m pip install --upgrade loopx" in message_only, message_only assert "/goal " in message_only, message_only assert "quota should-run" in message_only, message_only assert "refresh-state --goal-id public-fresh-codex-cli-goal" in message_only, message_only diff --git a/examples/release/codex-cli-no-clone-release-verification-smoke.py b/examples/release/codex-cli-no-clone-release-verification-smoke.py index a18db94bcb..20671572b6 100644 --- a/examples/release/codex-cli-no-clone-release-verification-smoke.py +++ b/examples/release/codex-cli-no-clone-release-verification-smoke.py @@ -161,8 +161,16 @@ def assert_installed_release( assert not message.startswith("/goal "), message assert "setup/bootstrap instruction" in normalized_message, message assert "/goal " in normalized_message, message - assert "python3 -m pip install --upgrade loopx" in normalized_message, message - assert "workflow-skills --install" in normalized_message, message + assert "loopx doctor" in normalized_message, message + if os.name == "nt": + assert "| bash" not in normalized_message, message + assert ( + "install-windows.ps1" in normalized_message + or "-m pip install --upgrade loopx" in normalized_message + ), message + else: + assert "python3 -m pip install --upgrade loopx" in normalized_message, message + assert "workflow-skills --install" in normalized_message, message assert "Codex CLI TUI" in normalized_message, message assert "quota should-run" in normalized_message, message assert "settlement_plan.ordered_steps" in normalized_message, message diff --git a/loopx/agent_onboarding.py b/loopx/agent_onboarding.py index a3ae62998b..b63b4bfbe6 100644 --- a/loopx/agent_onboarding.py +++ b/loopx/agent_onboarding.py @@ -26,10 +26,12 @@ SKILLS_ROOT_LABEL as KIRO_CLI_SKILLS_ROOT_LABEL, ) from .project_prompt import ( + host_command, render_available_capability_args, render_codex_cli_install_preflight, render_quota_guard_command, shell_arg, + shell_arg_for_host, ) from .registry import registry_goals from .skill_install_readback import ( @@ -46,31 +48,31 @@ def _surface_install_command(agent_type: str, cli_bin: str, project: str) -> str | None: if agent_type in {"codex-app", "codex-app-ssh", "codex-ide-plugin", "codex-cli"}: - return f"{shell_arg(cli_bin)} slash-commands --install --surface codex" + return f"{host_command(cli_bin)} slash-commands --install --surface codex" if agent_type == "claude-code": - return f"{shell_arg(cli_bin)} slash-commands --install --surface claude-code" + return f"{host_command(cli_bin)} slash-commands --install --surface claude-code" if agent_type == "opencode": return ( - f"{shell_arg(cli_bin)} slash-commands --install --surface opencode " + f"{host_command(cli_bin)} slash-commands --install --surface opencode " "--with-goal-bridge" ) if agent_type == "gemini-cli": - return f"{shell_arg(cli_bin)} slash-commands --install --surface gemini" + return f"{host_command(cli_bin)} slash-commands --install --surface gemini" if agent_type == "cursor-agent": - return f"{shell_arg(cli_bin)} slash-commands --install --surface cursor" + return f"{host_command(cli_bin)} slash-commands --install --surface cursor" if agent_type == "zcode": - return f"{shell_arg(cli_bin)} slash-commands --install --surface zcode" + return f"{host_command(cli_bin)} slash-commands --install --surface zcode" if agent_type == "agy": - return f"{shell_arg(cli_bin)} slash-commands --install --surface agy" + return f"{host_command(cli_bin)} slash-commands --install --surface agy" if agent_type == "kiro-cli": - return f"{shell_arg(cli_bin)} slash-commands --install --surface kiro-cli" + return f"{host_command(cli_bin)} slash-commands --install --surface kiro-cli" if agent_type == "pi": # The slash-commands installer resolves the Pi extension target through # --pi-project; pass the resolved project so the command stays correct # when agent-onboard runs from any cwd. return ( - f"{shell_arg(cli_bin)} slash-commands --install --surface pi " - f"--pi-project {shell_arg(project)}" + f"{host_command(cli_bin)} slash-commands --install --surface pi " + f"--pi-project {shell_arg_for_host(project)}" ) return None @@ -102,15 +104,15 @@ def _project_skill_command( execute: bool = False, ) -> str: parts = [ - shell_arg(cli_bin), + host_command(cli_bin), "project-skill", command, "--project", - shell_arg(project), + shell_arg_for_host(project), "--skill", - shell_arg(skill_id), + shell_arg_for_host(skill_id), "--surface", - shell_arg(surface), + shell_arg_for_host(surface), ] if execute: parts.append("--execute") @@ -303,21 +305,21 @@ def _bootstrap_pack_command( "other-agent": "other-agent", } parts = [ - shell_arg(cli_bin), + host_command(cli_bin), "bootstrap-command-pack", "--project", - shell_arg(project), + shell_arg_for_host(project), "--goal-id", - shell_arg(goal_id), + shell_arg_for_host(goal_id), "--host-surface", - shell_arg(surface_by_type[agent_type]), + shell_arg_for_host(surface_by_type[agent_type]), ] if agent_id: - parts.extend(["--agent-id", shell_arg(agent_id)]) + parts.extend(["--agent-id", shell_arg_for_host(agent_id)]) if task_text: - parts.extend(["--goal-text", shell_arg(task_text)]) + parts.extend(["--goal-text", shell_arg_for_host(task_text)]) for capability in available_capabilities or []: - parts.extend(["--available-capability", shell_arg(capability)]) + parts.extend(["--available-capability", shell_arg_for_host(capability)]) return " ".join(parts) @@ -514,12 +516,12 @@ def build_agent_onboarding_packet( else None ), "agent_onboard_recheck": ( - f"{shell_arg(cli_bin)} agent-onboard " - f"--agent-type {shell_arg(canonical_agent_type)} " - f"--project {shell_arg(resolved_project)} " - f"--goal-id {shell_arg(resolved_goal_id)}" + f"{host_command(cli_bin)} agent-onboard " + f"--agent-type {shell_arg_for_host(canonical_agent_type)} " + f"--project {shell_arg_for_host(resolved_project)} " + f"--goal-id {shell_arg_for_host(resolved_goal_id)}" + ( - f" --agent-id {shell_arg(str(selected_agent_id))}" + f" --agent-id {shell_arg_for_host(str(selected_agent_id))}" if selected_agent_id else "" ) @@ -530,11 +532,11 @@ def build_agent_onboarding_packet( commands["install_command_facade"] = install_command if canonical_agent_type == "codex-cli": commands["codex_cli_bootstrap_message"] = ( - f"{shell_arg(cli_bin)} codex-cli-bootstrap-message " - f"--project {shell_arg(resolved_project)} " - f"--goal-id {shell_arg(resolved_goal_id)}" + f"{host_command(cli_bin)} codex-cli-bootstrap-message " + f"--project {shell_arg_for_host(resolved_project)} " + f"--goal-id {shell_arg_for_host(resolved_goal_id)}" + ( - f" --agent-id {shell_arg(str(selected_agent_id))}" + f" --agent-id {shell_arg_for_host(str(selected_agent_id))}" if selected_agent_id else "" ) diff --git a/loopx/control_plane/heartbeat/task_body.py b/loopx/control_plane/heartbeat/task_body.py index bde85c4628..15d1181f69 100644 --- a/loopx/control_plane/heartbeat/task_body.py +++ b/loopx/control_plane/heartbeat/task_body.py @@ -4,6 +4,7 @@ from typing import Any +from ...project_prompt import render_turn_bootstrap from .rules import ( CODEX_NATIVE_GOAL_UNCHANGED_WAIT_RULE, DEFAULT_MATERIAL_QUEUE_RULE, @@ -53,10 +54,15 @@ def bind_exact_turn_settlement_task_body( ) -> str: if turn_instance_id is None: return task_body - bound = task_body.replace( + bound = task_body + for placeholder in ( "LOOPX_TURN=", - f"LOOPX_TURN={turn_instance_id}", - ) + '$LOOPX_TURN = ""', + ): + bound = bound.replace( + placeholder, + render_turn_bootstrap(value=turn_instance_id), + ) plan_path = ("writeback.settlement_plan.ordered_steps" if captured_envelope else "interaction_contract.cli_channel.settlement_plan.ordered_steps") return ( @@ -103,7 +109,7 @@ def render_heartbeat_task_body( ```bash {cli_preflight} -LOOPX_TURN= +{render_turn_bootstrap()} {pr_review_pre_quota_block}{quota_guard_command} ``` @@ -273,7 +279,7 @@ def render_brief_heartbeat_task_body( ```bash {cli_preflight} -LOOPX_TURN= +{render_turn_bootstrap()} {pr_review_pre_quota_block}{quota_guard_command} ``` @@ -341,7 +347,7 @@ def render_compact_heartbeat_task_body( ```bash {cli_preflight} -LOOPX_TURN= +{render_turn_bootstrap()} {pr_review_pre_quota_block}{quota_guard_command} ``` @@ -638,7 +644,7 @@ def render_thin_heartbeat_task_body( After a workspace or state change, re-enter the guard before continuing work. ```sh -LOOPX_TURN= +{render_turn_bootstrap()} {pr_review_pre_quota_instruction}{quota_guard_command} ``` {SCOPE_BOUNDED_WORK_RULE} @@ -665,7 +671,7 @@ def render_thin_heartbeat_task_body( {HOST_LOOP_QUOTA_DISPATCH_RULE} {HEARTBEAT_TURN_BOOTSTRAP_RULE} ```sh -LOOPX_TURN= +{render_turn_bootstrap()} {pr_review_pre_quota_instruction}{quota_guard_command} ``` {HEARTBEAT_NOTIFICATION_RULE_SHORT} diff --git a/loopx/install_contract.py b/loopx/install_contract.py index ea7469587d..4fd0703532 100644 --- a/loopx/install_contract.py +++ b/loopx/install_contract.py @@ -1,15 +1,16 @@ """Install, repair, and fallback command text printed to LoopX operators. -One platform-aware owner keeps the bootstrap/connect hint, the doctor freshness -command, and the archive fallback consistent. POSIX keeps the documented public -defaults; native Windows has neither a ``python3`` launcher nor Bash, so it -names the interpreter that runs here and prints the snapshot installer only for a -checkout that actually ships it. +One platform-aware owner keeps the bootstrap/connect hint, the preflight prompt, +the doctor freshness command, and the archive fallback consistent. POSIX keeps +the documented public defaults; native Windows has neither a ``python3`` launcher +nor Bash, so it names the interpreter that runs here and prints the snapshot +installer only for a checkout that actually ships it. """ from __future__ import annotations import os +import re import shlex import sys from pathlib import Path @@ -32,10 +33,29 @@ ) +_POWERSHELL_SAFE_COMMAND = re.compile(r"^[A-Za-z0-9_./-]+$") + + def _powershell_literal(value: str | Path) -> str: return "'" + str(value).replace("'", "''") + "'" +def _powershell_command(value: str) -> str: + """A command name or path PowerShell can invoke from the start of a pipeline.""" + + if _POWERSHELL_SAFE_COMMAND.fullmatch(value): + return value + return f"& {_powershell_literal(value)}" + + +def host_command_name(value: str) -> str: + """Render an executable as the first token of a host-shell command.""" + + if _is_windows_host(): + return _powershell_command(value) + return shlex.quote(value) + + def _is_windows_host() -> bool: """Whether printed commands target PowerShell instead of a POSIX shell.""" @@ -137,3 +157,89 @@ def archive_fallback_install_command() -> str: if _is_windows_host(): return no_clone_upgrade_command() return ARCHIVE_FALLBACK_INSTALL_COMMAND + + +def doctor_preflight_command( + *, + cli_bin: str = "loopx", + doctor_agent_type: str | None = None, + install_skills: bool = False, +) -> str: + """Ensure the CLI is reachable without upgrading on every scheduled turn.""" + + if _is_windows_host(): + doctor_arg = ( + f" --agent-type {_powershell_literal(doctor_agent_type)}" + if doctor_agent_type + else "" + ) + install_lines = [ + f"if (-not (Get-Command {_powershell_literal(cli_bin)} -ErrorAction SilentlyContinue)) {{", + f" & {_powershell_literal(sys.executable)} -m pip install --upgrade loopx", + ] + if install_skills: + install_lines.append( + f" {_powershell_command(cli_bin)} workflow-skills --install" + ) + install_lines.append("}") + install_lines.append( + f"{_powershell_command(cli_bin)} doctor{doctor_arg} | Out-Null" + ) + return "\n".join(install_lines) + cli_bin_arg = shlex.quote(cli_bin) + doctor_arg = ( + f" --agent-type {shlex.quote(doctor_agent_type)}" + if doctor_agent_type + else "" + ) + return f"""export PATH="$HOME/.local/bin:$PATH" +install_script="$HOME/loopx/scripts/install-local.sh" +if ! command -v {cli_bin_arg} >/dev/null 2>&1; then + if [ -x "$install_script" ]; then + "$install_script" + export PATH="$HOME/.local/bin:$PATH" + else + echo "loopx is not on PATH; clone the LoopX repo and run scripts/install-local.sh" >&2 + exit 1 + fi +fi +{cli_bin_arg} doctor{doctor_arg} >/dev/null""" + + +def codex_cli_install_preflight( + *, + cli_bin: str = "loopx", + doctor_agent_type: str | None = None, +) -> str: + """Install-or-doctor guidance a Codex CLI operator can paste on this host.""" + + if _is_windows_host(): + return doctor_preflight_command( + cli_bin=cli_bin, + doctor_agent_type=doctor_agent_type, + install_skills=True, + ) + cli_bin_arg = shlex.quote(cli_bin) + doctor_agent_arg = ( + f" --agent-type {shlex.quote(doctor_agent_type)}" + if doctor_agent_type + else "" + ) + return f"""export PATH="$HOME/.local/bin:$PATH" +if ! command -v {cli_bin_arg} >/dev/null 2>&1; then + if command -v python3 >/dev/null 2>&1 && python3 -m pip --version >/dev/null 2>&1; then + python3 -m pip install --upgrade loopx + if command -v {cli_bin_arg} >/dev/null 2>&1; then + {cli_bin_arg} workflow-skills --install --cli-bin {cli_bin_arg} + fi + fi + if ! command -v {cli_bin_arg} >/dev/null 2>&1 && command -v curl >/dev/null 2>&1; then + curl -fsSL {NO_CLONE_INSTALL_URL} | bash + export PATH="$HOME/.local/bin:$PATH" + fi + if ! command -v {cli_bin_arg} >/dev/null 2>&1; then + echo "loopx is not on PATH; use a Python 3.11+ environment, the archive fallback, or a contributor checkout" >&2 + exit 1 + fi +fi +{cli_bin_arg} doctor{doctor_agent_arg} >/dev/null""" diff --git a/loopx/project_prompt.py b/loopx/project_prompt.py index 9d7435c3ed..026f00b135 100644 --- a/loopx/project_prompt.py +++ b/loopx/project_prompt.py @@ -23,7 +23,7 @@ scheduler_runtime_profile_for_execution_context, ) from .control_plane.todos.contract import normalize_required_capabilities -from .install_contract import NO_CLONE_INSTALL_URL +from . import install_contract DEFAULT_HANDOFF_OBJECTIVE = "" DEFAULT_HANDOFF_DOMAIN = "" @@ -62,14 +62,30 @@ def render_optional_cli_arg(flag: str, value: str | None) -> str: return f" {flag} {shell_arg_for_host(value)}" +def host_command(value: str) -> str: + """Render an executable as the first token of a host-shell command.""" + + return install_contract.host_command_name(value) + + +def render_turn_bootstrap(*, value: str = "") -> str: + """Assign the per-wake Turn identity with the host shell's syntax.""" + + if _is_windows_host(): + return f'$LOOPX_TURN = "{value}"' + return f"LOOPX_TURN={value}" + + def render_cli_command_prefix( *, cli_bin: str = "loopx", runtime_root: str | Path | None = None, registry_path: str | Path | None = None, ) -> str: - prefix = " ".join(shell_arg_for_host(arg) for arg in loopx_cli_argv( - None if cli_bin == "loopx" else cli_bin)) + argv = loopx_cli_argv(None if cli_bin == "loopx" else cli_bin) + prefix = " ".join( + [host_command(argv[0]), *(shell_arg_for_host(arg) for arg in argv[1:])] + ) if runtime_root is not None: prefix += f" --runtime-root {shell_arg_for_host(str(runtime_root))}" if registry_path is not None: @@ -141,19 +157,7 @@ def render_available_capability_args(values: Any) -> str: def render_cli_preflight(*, cli_bin: str = "loopx") -> str: - cli_bin_arg = shell_arg(cli_bin) - return f"""export PATH="$HOME/.local/bin:$PATH" -install_script="$HOME/loopx/scripts/install-local.sh" -if ! command -v {cli_bin_arg} >/dev/null 2>&1; then - if [ -x "$install_script" ]; then - "$install_script" - export PATH="$HOME/.local/bin:$PATH" - else - echo "loopx is not on PATH; clone the LoopX repo and run scripts/install-local.sh" >&2 - exit 1 - fi -fi -{cli_bin_arg} doctor >/dev/null""" + return install_contract.doctor_preflight_command(cli_bin=cli_bin) def render_codex_cli_install_preflight( @@ -161,30 +165,10 @@ def render_codex_cli_install_preflight( cli_bin: str = "loopx", doctor_agent_type: str | None = None, ) -> str: - cli_bin_arg = shell_arg(cli_bin) - doctor_agent_arg = ( - f" --agent-type {shell_arg(doctor_agent_type)}" - if doctor_agent_type - else "" + return install_contract.codex_cli_install_preflight( + cli_bin=cli_bin, + doctor_agent_type=doctor_agent_type, ) - return f"""export PATH="$HOME/.local/bin:$PATH" -if ! command -v {cli_bin_arg} >/dev/null 2>&1; then - if command -v python3 >/dev/null 2>&1 && python3 -m pip --version >/dev/null 2>&1; then - python3 -m pip install --upgrade loopx - if command -v {cli_bin_arg} >/dev/null 2>&1; then - {cli_bin_arg} workflow-skills --install --cli-bin {cli_bin_arg} - fi - fi - if ! command -v {cli_bin_arg} >/dev/null 2>&1 && command -v curl >/dev/null 2>&1; then - curl -fsSL {NO_CLONE_INSTALL_URL} | bash - export PATH="$HOME/.local/bin:$PATH" - fi - if ! command -v {cli_bin_arg} >/dev/null 2>&1; then - echo "loopx is not on PATH; use a Python 3.11+ environment, the archive fallback, or a contributor checkout" >&2 - exit 1 - fi -fi -{cli_bin_arg} doctor{doctor_agent_arg} >/dev/null""" def render_quota_guard_command( @@ -223,7 +207,11 @@ def render_quota_guard_command( scheduler_execution_context=scheduler_execution_context, ) if heartbeat_turn_receipt: - turn_arg = ' --turn-instance-id "${LOOPX_TURN:?}"' + turn_arg = ( + " --turn-instance-id $LOOPX_TURN" + if _is_windows_host() + else ' --turn-instance-id "${LOOPX_TURN:?}"' + ) elif begin_turn: turn_arg = " --begin-turn" else: @@ -407,29 +395,24 @@ def render_connect_command( cli_bin: str = "loopx", runtime_root: str | Path | None = None, ) -> str: - lines = [ - f"cd {shell_arg(project)}", - f"{render_cli_command_prefix(cli_bin=cli_bin, runtime_root=runtime_root)} connect \\", - f" --goal-id {shell_arg(goal_id)} \\", - f" --objective {shell_arg(objective)} \\", - f" --domain {shell_arg(domain)} \\", - f" --goal-doc {shell_arg(goal_doc)} \\", - f" --adapter-kind {shell_arg(adapter_kind)} \\", - f" --adapter-status {shell_arg(adapter_status)}", - ] + command = ( + f"{render_cli_command_prefix(cli_bin=cli_bin, runtime_root=runtime_root)} connect" + f" --goal-id {shell_arg_for_host(goal_id)}" + f" --objective {shell_arg_for_host(objective)}" + f" --domain {shell_arg_for_host(domain)}" + f" --goal-doc {shell_arg_for_host(goal_doc)}" + f" --adapter-kind {shell_arg_for_host(adapter_kind)}" + f" --adapter-status {shell_arg_for_host(adapter_status)}" + ) if next_probe: - lines[-1] += " \\" - lines.append(f" --next-probe {shell_arg(next_probe)}") + command += f" --next-probe {shell_arg_for_host(next_probe)}" if spawn_allowed: - lines[-1] += " \\" - lines.append(" --spawn-allowed") + command += " --spawn-allowed" for allowed_domain in allowed_domains: - lines[-1] += " \\" - lines.append(f" --allowed-domain {shell_arg(allowed_domain)}") + command += f" --allowed-domain {shell_arg_for_host(allowed_domain)}" for scope in write_scope: - lines[-1] += " \\" - lines.append(f" --write-scope {shell_arg(scope)}") - return "\n".join(lines) + command += f" --write-scope {shell_arg_for_host(scope)}" + return "\n".join([f"cd {shell_arg_for_host(project)}", command]) def build_new_project_prompt( @@ -546,16 +529,14 @@ def render_codex_cli_bootstrap_connect_command( goal_id: str, cli_bin: str, ) -> str: - return "\n".join( - [ - f"cd {shell_arg(project)}", - f"{shell_arg(cli_bin)} bootstrap \\", - " --project . \\", - f" --goal-id {shell_arg(goal_id)} \\", - f" --adapter-kind {shell_arg(DEFAULT_HANDOFF_ADAPTER_KIND)} \\", - f" --adapter-status {shell_arg(DEFAULT_HANDOFF_ADAPTER_STATUS)}", - ] + command = ( + f"{host_command(cli_bin)} bootstrap" + " --project ." + f" --goal-id {shell_arg_for_host(goal_id)}" + f" --adapter-kind {shell_arg_for_host(DEFAULT_HANDOFF_ADAPTER_KIND)}" + f" --adapter-status {shell_arg_for_host(DEFAULT_HANDOFF_ADAPTER_STATUS)}" ) + return "\n".join([f"cd {shell_arg_for_host(project)}", command]) def build_codex_cli_bootstrap_message( diff --git a/tests/control_plane/test_cli_output_budget.py b/tests/control_plane/test_cli_output_budget.py index e928757102..c33ac0f49b 100644 --- a/tests/control_plane/test_cli_output_budget.py +++ b/tests/control_plane/test_cli_output_budget.py @@ -1776,8 +1776,10 @@ def capture_output(command): ) else: # The frozen 94-line body/envelope loses only two diagnostic - # lines: one duplicate route and one absent pre-quota command. - assert len(text.splitlines()) == 92 + # lines. The host shell owns the preflight length: native Windows + # prints a shorter PowerShell block, so the portable invariant is + # the ceiling; the depth equality below still pins semantics. + assert len(text.splitlines()) <= 92 normalized = text.replace(root, "") outputs.append({"format": output_format, "normalized": normalized}) return exit_code, text diff --git a/tests/test_install_hint_platform.py b/tests/test_install_hint_platform.py index e39bd4541f..df5cccdea2 100644 --- a/tests/test_install_hint_platform.py +++ b/tests/test_install_hint_platform.py @@ -227,6 +227,59 @@ def test_windows_installed_fallback_keeps_doctor_options_and_skips_owned_skills( ] +@pytest.mark.parametrize("os_name", ["posix", "nt"]) +def test_preflight_commands_follow_the_host_platform( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, os_name: str +) -> None: + monkeypatch.setattr(install_contract, "_is_windows_host", lambda: os_name == "nt") + if os_name == "nt": + monkeypatch.setattr(sys, "executable", PLAIN_WINDOWS_INTERPRETER) + doctor = install_contract.doctor_preflight_command(cli_bin="loopx-canary") + codex = install_contract.codex_cli_install_preflight( + cli_bin="loopx-canary", doctor_agent_type="codex-cli" + ) + + if os_name == "nt": + for command in (doctor, codex): + for token in POSIX_ONLY_TOKENS: + assert token not in command, command + assert "Get-Command" in command + assert "pwsh" not in command + assert powershell_literal(PLAIN_WINDOWS_INTERPRETER) in command + assert doctor.splitlines()[-1] == "loopx-canary doctor | Out-Null" + assert "loopx-canary workflow-skills --install" in codex + assert ( + f"loopx-canary doctor --agent-type {powershell_literal('codex-cli')} | Out-Null" + in codex + ) + else: + assert doctor.startswith('export PATH="$HOME/.local/bin:$PATH"') + assert "loopx-canary doctor >/dev/null" in doctor + assert "python3 -m pip install --upgrade loopx" in codex + assert "loopx-canary doctor --agent-type codex-cli >/dev/null" in codex + + +@pytest.mark.skipif(PWSH is None, reason="requires pwsh to parse the printed commands") +def test_preflight_commands_parse_as_powershell( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + pwsh = PWSH + assert pwsh is not None + pretend_windows_host(monkeypatch, PLAIN_WINDOWS_INTERPRETER) + commands = { + "doctor": install_contract.doctor_preflight_command(), + "codex": install_contract.codex_cli_install_preflight(doctor_agent_type="codex-cli"), + } + + for name, command in commands.items(): + script = tmp_path / f"{name}-preflight.ps1" + script.write_text(command, encoding="utf-8") + + errors = powershell_parse_errors(pwsh, script) + + assert errors == "", (name, errors) + + @pytest.mark.skipif(PWSH is None, reason="requires pwsh to parse the printed commands") @pytest.mark.parametrize("layout", ["checkout", "installed"]) @pytest.mark.parametrize("interpreter", WINDOWS_INTERPRETERS) diff --git a/tests/test_onboarding_command_platform.py b/tests/test_onboarding_command_platform.py new file mode 100644 index 0000000000..1274d636da --- /dev/null +++ b/tests/test_onboarding_command_platform.py @@ -0,0 +1,308 @@ +"""Onboarding and heartbeat packets must print commands the host can run.""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +import loopx.project_prompt as project_prompt +from loopx import install_contract +from loopx.agent_onboarding import build_agent_onboarding_packet +from loopx.control_plane.heartbeat.task_body import ( + bind_exact_turn_settlement_task_body, +) +from loopx.project_prompt import ( + build_codex_cli_bootstrap_message, + build_new_project_prompt, + render_cli_preflight, + render_quota_guard_command, + render_turn_bootstrap, +) + +PWSH = shutil.which("pwsh") +SH = shutil.which("sh") +GOAL_ID = "windows-onboarding-host" +POSIX_ONLY_TOKENS = ( + "python3 ", + "| bash", + "export PATH=", + "command -v ", + ">/dev/null", +) + + +def pretend_windows_host( + monkeypatch: pytest.MonkeyPatch, + interpreter: str = r"C:\Python311\python.exe", +) -> None: + """Exercise every Windows branch without switching ``os.name``.""" + + monkeypatch.setattr(install_contract, "_is_windows_host", lambda: True) + monkeypatch.setattr(project_prompt, "_is_windows_host", lambda: True) + monkeypatch.setattr(sys, "executable", interpreter) + + +def assert_powershell_ready(command: str) -> None: + assert command.strip(), "empty command" + assert "\\\n" not in command, command + for token in POSIX_ONLY_TOKENS: + assert token not in command, (token, command) + + +def project_with_apostrophe(tmp_path: Path) -> Path: + project = tmp_path / "project's" + project.mkdir() + return project + + +def test_windows_codex_bootstrap_message_prints_powershell_commands( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + pretend_windows_host(monkeypatch) + project = project_with_apostrophe(tmp_path) + payload = build_codex_cli_bootstrap_message( + project=project, + goal_id=GOAL_ID, + agent_id="host-agent", + cli_bin="loopx", + ) + + assert_powershell_ready(str(payload["install_repair_command"])) + connect = str(payload["connect_command"]) + assert_powershell_ready(connect) + assert len(connect.splitlines()) == 2 + assert connect.splitlines()[0].startswith("cd ") + assert connect.splitlines()[1].startswith("loopx bootstrap --project .") + assert "project''s" in connect + assert "'\\''" not in connect + + +def test_windows_agent_onboard_prints_powershell_commands( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + pretend_windows_host(monkeypatch) + project = project_with_apostrophe(tmp_path) + payload = build_agent_onboarding_packet( + project=project, + agent_type="codex-cli", + goal_id=GOAL_ID, + agent_id="host-agent", + ) + commands = payload["commands"] + assert isinstance(commands, dict) + + for key in ("doctor_or_install", "bootstrap_command_pack", "agent_onboard_recheck"): + assert_powershell_ready(str(commands[key])) + assert "Get-Command" in str(commands["doctor_or_install"]) + assert "project''s" in str(commands["bootstrap_command_pack"]) + assert "project''s" in str(commands["agent_onboard_recheck"]) + + +def test_windows_new_project_prompt_prints_powershell_commands( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + pretend_windows_host(monkeypatch) + project = project_with_apostrophe(tmp_path) + payload = build_new_project_prompt( + project=project, + goal_doc=project / "GOAL.md", + goal_id=GOAL_ID, + objective="Verify host-runnable onboarding", + domain="example", + adapter_kind="read_only_project_map_v0", + adapter_status="connected-read-only", + next_probe=None, + spawn_allowed=True, + allowed_domains=["example.com"], + write_scope=["docs/**"], + ) + + assert_powershell_ready(str(payload["cli_preflight"])) + connect = str(payload["connect_command"]) + assert_powershell_ready(connect) + assert len(connect.splitlines()) == 2 + assert "project''s" in connect + assert "--spawn-allowed" in connect + assert "--allowed-domain example.com" in connect + assert "--write-scope 'docs/**'" in connect + + +def test_windows_heartbeat_preflight_does_not_upgrade_every_turn( + monkeypatch: pytest.MonkeyPatch, +) -> None: + pretend_windows_host(monkeypatch) + command = render_cli_preflight() + + assert_powershell_ready(command) + assert "Get-Command" in command + assert command.count("-m pip install --upgrade loopx") == 1 + assert "doctor | Out-Null" in command + + +def test_posix_preflight_keeps_the_documented_shell( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(install_contract, "_is_windows_host", lambda: False) + monkeypatch.setattr(project_prompt, "_is_windows_host", lambda: False) + + command = render_cli_preflight() + + assert command.startswith('export PATH="$HOME/.local/bin:$PATH"') + assert "install-local.sh" in command + assert "doctor >/dev/null" in command + + +def run_powershell(pwsh: str, script: Path, *, env: dict[str, str], cwd: Path) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [pwsh, "-NoLogo", "-NoProfile", "-File", str(script)], + capture_output=True, + text=True, + timeout=180, + env=env, + cwd=cwd, + ) + + +@pytest.mark.skipif( + os.name != "nt" or PWSH is None, + reason="requires native Windows and PowerShell 7", +) +def test_generated_windows_onboarding_commands_run_in_powershell(tmp_path: Path) -> None: + pwsh = PWSH + assert pwsh is not None + project = tmp_path / "project's" + project.mkdir() + home = tmp_path / "home" + home.mkdir() + environment = os.environ.copy() + environment["PATH"] = ( + f"{Path(sys.executable).parent}{os.pathsep}{environment.get('PATH', '')}" + ) + environment["HOME"] = str(home) + environment["USERPROFILE"] = str(home) + + payload = build_new_project_prompt( + project=project, + goal_doc=project / "GOAL.md", + goal_id=GOAL_ID, + objective="Verify host-runnable onboarding", + domain="example", + adapter_kind="read_only_project_map_v0", + adapter_status="connected-read-only", + next_probe=None, + spawn_allowed=False, + allowed_domains=None, + write_scope=None, + ) + + connect_script = tmp_path / "connect.ps1" + connect_script.write_text(str(payload["connect_command"]), encoding="utf-8") + connect = run_powershell(pwsh, connect_script, env=environment, cwd=project) + assert connect.returncode == 0, connect.stdout + connect.stderr + assert (project / ".loopx" / "registry.json").is_file() + assert (project / ".loopx" / "goals" / GOAL_ID / "ACTIVE_GOAL_STATE.md").is_file() + + preflight_script = tmp_path / "preflight.ps1" + preflight_script.write_text(str(payload["cli_preflight"]), encoding="utf-8") + preflight = run_powershell(pwsh, preflight_script, env=environment, cwd=project) + assert preflight.returncode == 0, preflight.stdout + preflight.stderr + + +def test_windows_executable_path_uses_powershell_call_operator( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + pretend_windows_host(monkeypatch) + cli = r"C:\Program Files\LoopX\loopx.exe" + project = tmp_path / "project" + project.mkdir() + + message = build_codex_cli_bootstrap_message( + project=project, + goal_id=GOAL_ID, + agent_id="host-agent", + cli_bin=cli, + ) + assert message["connect_command"].splitlines()[1].startswith( + f"& '{cli}' bootstrap" + ) + packet = build_agent_onboarding_packet( + project=project, + agent_type="codex-cli", + goal_id=GOAL_ID, + agent_id="host-agent", + cli_bin=cli, + ) + commands = packet["commands"] + assert isinstance(commands, dict) + assert commands["agent_onboard_recheck"].startswith(f"& '{cli}' agent-onboard") + assert f"& '{cli}'" in str(commands["bootstrap_command_pack"]) + assert f"& '{cli}'" in str(commands["doctor_or_install"]) + + +def test_windows_heartbeat_turn_identity_uses_powershell( + monkeypatch: pytest.MonkeyPatch, +) -> None: + pretend_windows_host(monkeypatch) + + assert render_turn_bootstrap() == '$LOOPX_TURN = ""' + guard = render_quota_guard_command("goal", heartbeat_turn_receipt=True) + assert "--turn-instance-id $LOOPX_TURN" in guard + assert "${LOOPX_TURN" not in guard + body = f"```sh\n{render_turn_bootstrap()}\n{guard}\n```" + bound = bind_exact_turn_settlement_task_body( + body, + turn_instance_id="2026-10-23T00:00:00Z", + ) + assert '$LOOPX_TURN = "2026-10-23T00:00:00Z"' in bound + assert "${LOOPX_TURN" not in bound + + +def test_posix_heartbeat_turn_identity_keeps_shell_assignment( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(install_contract, "_is_windows_host", lambda: False) + monkeypatch.setattr(project_prompt, "_is_windows_host", lambda: False) + + assert render_turn_bootstrap() == "LOOPX_TURN=" + guard = render_quota_guard_command("goal", heartbeat_turn_receipt=True) + assert '--turn-instance-id "${LOOPX_TURN:?}"' in guard + + +@pytest.mark.skipif( + os.name == "nt" or SH is None, + reason="requires a POSIX shell to parse the printed command", +) +def test_posix_connect_commands_are_valid_shell(tmp_path: Path) -> None: + sh = SH + assert sh is not None + project = tmp_path / "project's" + project.mkdir() + payload = build_new_project_prompt( + project=project, + goal_doc=project / "GOAL.md", + goal_id=GOAL_ID, + objective="Verify host-runnable onboarding", + domain="example", + adapter_kind="read_only_project_map_v0", + adapter_status="connected-read-only", + next_probe=None, + spawn_allowed=False, + allowed_domains=None, + write_scope=None, + ) + for command in (payload["connect_command"], payload["cli_preflight"]): + text = str(command) + assert "\\\n" not in text + completed = subprocess.run( + [sh, "-n"], + input=text, + text=True, + capture_output=True, + timeout=60, + ) + assert completed.returncode == 0, completed.stderr