diff --git a/docs/reference/protocols/host-mode-plan-v0.md b/docs/reference/protocols/host-mode-plan-v0.md index b1cb25e3b..ef0a2d1fa 100644 --- a/docs/reference/protocols/host-mode-plan-v0.md +++ b/docs/reference/protocols/host-mode-plan-v0.md @@ -147,8 +147,10 @@ quota guard command, and required proofs. - `host` is the mode's **declared** host: the scheduler context the readiness statement and capability requirements are written against. It is not a claim that this concrete host has already been resolved for the run; the runtime - resolves the concrete host (including a credential- or configuration-resolved - default) when `plan_command` runs. + resolves the concrete host from its own explicit product default + (`loopx/control_plane/turn_driver/host_binding.py`) when `plan_command` runs, + and `LOOPX_TURN_HOST` or an explicit `--host` re-points that default. An + operator credential authenticates the selected host; it does not select one. - `host_selection` is `resolved_default` when the command deliberately leaves host resolution to `loopx turn plan`/`run-once`, and `pinned` when the command carries an explicit `--host`. diff --git a/examples/host-mode-plan-smoke.py b/examples/host-mode-plan-smoke.py index 8c3f4b223..f4efce344 100755 --- a/examples/host-mode-plan-smoke.py +++ b/examples/host-mode-plan-smoke.py @@ -13,6 +13,7 @@ from __future__ import annotations +import os import re import sys from pathlib import Path @@ -113,8 +114,8 @@ def test_headless_maps_to_loopx_turn_plan_not_parallel_runner() -> None: assert selected["scheduler_owner"] == "outer_controller", selected command = selected["plan_command"] assert "loopx turn plan" in command, command - # The preview keeps the shipped host resolution, so a lane without an - # operator credential does not land on the compatibility adapter path. + # The preview keeps the shipped host resolution, so it does not freeze the + # compatibility adapter path as the product default. assert "--host" not in command, command assert "--execution-mode isolated-headless" in command, command assert "--scheduler-owner outer_controller" in command, command @@ -127,6 +128,28 @@ def test_headless_maps_to_loopx_turn_plan_not_parallel_runner() -> None: assert plan["turn_contract"]["writeback_before_quota_spend"] is True, plan +def test_headless_preview_ignores_operator_credential() -> None: + credential_env = "DEEPSEEK_API_KEY" + previous = os.environ.pop(credential_env, None) + try: + without_credential = build_full_plan("continue_without_ui")["selected_turn_mapping"] + os.environ[credential_env] = "host-mode-plan-smoke-not-a-credential" + with_credential = build_full_plan("continue_without_ui")["selected_turn_mapping"] + finally: + if previous is None: + os.environ.pop(credential_env, None) + else: + os.environ[credential_env] = previous + # `loopx turn plan`/`run-once` ship one explicit product default that + # `LOOPX_TURN_HOST` or an explicit `--host` re-points, and an operator + # credential only authenticates the host that was already selected. A + # preview whose shape changed when the credential appeared would re-introduce + # a credential-selected Turn host, so the shape is pinned here instead. + assert with_credential == without_credential, (without_credential, with_credential) + assert without_credential["host_selection"] == "resolved_default", without_credential + assert "--host" not in without_credential["plan_command"], without_credential + + def test_visible_mode_stays_visible_and_scoped() -> None: plan = build_workflow_identity_plan("watch_each_turn", host_identity="codex-cli") selected = plan["selected_turn_mapping"] @@ -469,6 +492,7 @@ def test_markdown_and_docs_are_wired() -> None: def main() -> int: test_intent_selects_distinct_host_modes() test_headless_maps_to_loopx_turn_plan_not_parallel_runner() + test_headless_preview_ignores_operator_credential() test_visible_mode_stays_visible_and_scoped() test_visible_mode_preserves_distinct_host_identities() test_visible_mode_fails_closed_without_host_identity() diff --git a/loopx/host_mode_planner.py b/loopx/host_mode_planner.py index ae2f1720f..e5050bab0 100644 --- a/loopx/host_mode_planner.py +++ b/loopx/host_mode_planner.py @@ -121,11 +121,14 @@ _INTENT_PRIMARY_MODE = {meta["intent"]: mode for mode, meta in _MODE_METADATA.items()} # The headless Turn modes preview the *shipped* host resolution instead of -# pinning one host. `loopx turn plan`/`run-once` resolve their default from the -# operator credential, so a preview that pinned `generic-cli` would quietly ask -# every operator for the compatibility adapter path. The declared host stays in -# the mapping and in the rollback command, because the mode's scheduler context -# and capability requirements are still stated for it. +# pinning one host. `loopx turn plan`/`run-once` ship one explicit product +# default, owned by `control_plane.turn_driver.host_binding.selected_turn_host`, +# which `LOOPX_TURN_HOST` or an explicit `--host` re-points; an operator +# credential authenticates that host and never selects it, so a preview that +# pinned `generic-cli` would quietly ask every operator for the compatibility +# adapter path. The declared host stays in the mapping and in the rollback +# command, because the mode's scheduler context and capability requirements are +# still stated for it. RESOLVED_DEFAULT_TURN_HOST_MODES = frozenset( {MODE_ISOLATED_HEADLESS_TURN, MODE_SHELL_SERVICE} )