diff --git a/WorldOS-GUI-RUNBOOK.md b/WorldOS-GUI-RUNBOOK.md index dc3c50f7..3d10c557 100644 --- a/WorldOS-GUI-RUNBOOK.md +++ b/WorldOS-GUI-RUNBOOK.md @@ -237,6 +237,10 @@ release truth still requires `qa/ui_playtest_app.sh` Part A+B and the full RRI s - Target: owner-provided **32GB support VM** (`support-vm-1`); connection/auth details live in local operator-only runbooks/evidence, not tracked repo docs. - Do not assume it is ready for Codex runs until credentials/config are intentionally installed and verified. + The default support-VM persona lane is Codex DM plus Codex UI player; Claude is only required when + the preflight is run with `--provider claude` or `--player-agent claude`. The Codex lane requires + Codex CLI `>=0.120.0` because it uses per-invocation `codex exec -c mcp_servers.*` overrides rather + than mutating `CODEX_HOME` with `codex mcp add`. - Use it for heavy backend/persona release sweeps and parallel QA once configured. - Do **not** use it as proof for Mac-only surfaces: `WorldOS.app` build/launch, native #356, and built-app UI play evidence stay on this Mac or macOS CI. @@ -249,6 +253,8 @@ release truth still requires `qa/ui_playtest_app.sh` Part A+B and the full RRI s python3 qa/support_vm_preflight.py \ --repo /root/worldos-qa/WorldOS \ --expected-sha 9545383 \ + --provider codex \ + --player-agent codex \ --art-root /root/worldos-qa/WorldOS \ --private-art-mode required \ --artifact-dir /tmp/worldos-support-vm-preflight-9545383 \ @@ -256,7 +262,9 @@ release truth still requires `qa/ui_playtest_app.sh` Part A+B and the full RRI s ``` The script is read-only with respect to WorldOS state; it writes `support_vm_preflight.json` and `support_vm_preflight.md`, redacts secrets, and exits non-zero if same-SHA/origin/tool/auth/private-art - blockers would make the RRI sweep untrustworthy. + blockers would make the RRI sweep untrustworthy. Its generated persona commands must include both + `WOS_APP_SELECTED_PROVIDER` and `WOS_APP_PLAYER_AGENT`; otherwise the VM sweep lane is not defined + tightly enough to count toward #466. - Read-only VM scout (2026-06-01): an operator-only endpoint note can reach `evaos-support` without printing the endpoint. Capacity/tooling look suitable for heavy sweeps: ~32 GB RAM, 16 CPUs, ~537 GB free disk, `git`, `python3`, `uv 0.11.17`, Node `v22.22.1`, npm `10.9.4`, `codex-cli 0.120.0`, Playwright modules, and private diff --git a/WorldOS-OPERATING-GOAL.md b/WorldOS-OPERATING-GOAL.md index c23fbc2c..d96817f7 100644 --- a/WorldOS-OPERATING-GOAL.md +++ b/WorldOS-OPERATING-GOAL.md @@ -5,7 +5,7 @@ Post-compaction agents: this 6-line block is ground truth. Do NOT reconstruct state from scattered docs or old plans; trust this, verify the sha, then act. ────────────────────────────────────────────────────────────────────────── - AS OF: 2026-06-01T17:49:00+07:00 support-VM origin-readiness pass; latest handoff build remains 9545383 + AS OF: 2026-06-01T18:02:45+07:00 repo Codex persona-lane patch; latest handoff build remains 9545383 MAIN BASELINE: Latest same-SHA app-proof target is `9545383` (PR #508 merged the repo-owned support-VM preflight artifact gate, and the app handoff gate was rerun on that @@ -21,7 +21,9 @@ SUPPORT VM: 32GB owner-provided support VM (`support-vm-1`). Connection/auth details live in local operator-only evidence/runbooks, not tracked repo docs. Use it for heavy backend/persona sweeps only after Codex/config/credentials are intentionally - installed. Mac-built `.app` smoke/play proof stays on this Mac or macOS CI. + installed. The default VM persona lane is Codex DM plus Codex UI player and requires + Codex CLI `>=0.120.0`; Claude is only required when deliberately selected. Mac-built + `.app` smoke/play proof stays on this Mac or macOS CI. Current local preflight note: a read-only operator-endpoint scout reached `evaos-support` (~32 GB RAM, 16 CPUs) with WorldOS at `/root/worldos-qa/WorldOS`, but that checkout was stale (`4524b3e`) and behind the `9545383` app-proof baseline. @@ -57,7 +59,8 @@ Mac handoff proof through `--handoff-json`. Do not claim release. Run #466 for a trustworthy clean RRI failure list/result. For same-SHA RRI, sync the support VM checkout to `9545383`, run - `python3 qa/support_vm_preflight.py --expected-sha 9545383 ...`, and pair + `python3 qa/support_vm_preflight.py --expected-sha 9545383 --provider codex --player-agent codex ...`, + and pair the VM persona artifacts with the `9545383` Mac handoff JSON above. If a newer release-candidate SHA is used instead, rerun the Mac handoff on that same SHA before rollup. The 32GB support VM runs heavy backend/persona sweeps diff --git a/qa/QA_TOOLS.md b/qa/QA_TOOLS.md index acc57d87..fd4d3527 100644 --- a/qa/QA_TOOLS.md +++ b/qa/QA_TOOLS.md @@ -49,12 +49,15 @@ Stable app failure buckets are: |---|---|---|---| | `qa/release_readiness.py` | The Release Readiness Index rollup and only release verdict | Complete same-SHA app/persona evidence, including optional `--handoff-json` Mac proof | You only need fast GUI wiring confidence | | `qa/release_gate.sh` | Orchestrate the release sweep over the canonical persona set | Built app, persona runs, behavior/UI/image/palette evidence | The support VM or Mac proof preflight is incomplete | -| `qa/support_vm_preflight.py` | Read-only readiness artifact before a support-VM persona sweep | VM identity, repo SHA, `origin/main` queryability, tool/auth/art status, return path, teardown plan | You are trying to fix or sync the VM; get operator approval first | +| `qa/support_vm_preflight.py` | Read-only readiness artifact before a support-VM persona sweep | VM identity, repo SHA, selected provider/player lane, `origin/main` queryability, tool/auth/art status, return path, teardown plan | You are trying to fix or sync the VM; get operator approval first | RRI requires a non-partial five-persona result on one build SHA. A handoff gate can feed the native app proof through `--handoff-json`, but it cannot fill in missing persona artifacts. The support-VM preflight must pass before a VM sweep can count toward #466; if `origin/main` is not queryable from the VM, fix the VM repo credentials/sync lane before running personas. +The default VM lane is Codex DM plus Codex UI player; Claude is only a readiness dependency when +`--provider claude` or `--player-agent claude` is selected. The Codex lane requires Codex CLI +`>=0.120.0` for per-invocation MCP server overrides. ## Browser And Persona Diagnostics diff --git a/qa/support_vm_preflight.py b/qa/support_vm_preflight.py index a999b7ae..bbf94971 100644 --- a/qa/support_vm_preflight.py +++ b/qa/support_vm_preflight.py @@ -28,15 +28,14 @@ SCHEMA = "worldos.support-vm-preflight.v1" CANONICAL_PERSONAS = ["newbie", "veteran", "adversarial", "narrative", "optimizer"] MIN_SHA_MATCH_CHARS = 7 -REQUIRED_TOOLS = [ +MIN_CODEX_MCP_OVERRIDE_VERSION = (0, 120, 0) +BASE_REQUIRED_TOOLS = [ "git", "python3", "uv", "node", "npm", "npx", - "codex", - "claude", "jq", "curl", "lsof", @@ -45,6 +44,8 @@ "pgrep", "ps", ] +PERSONA_PROVIDERS = ("codex", "claude") +PLAYER_AGENTS = ("codex", "claude") INTERESTING_ENV_PREFIXES = ("WORLDOS_", "CLAWDND_", "CODEX_", "OPENAI_", "ANTHROPIC_") SAFE_PATH_ENV_NAMES = { "WORLDOS_ART_REPO_ROOT", @@ -71,6 +72,17 @@ class PreflightConfig: budget: str concurrency: int port: int + provider: str = "codex" + player_agent: str = "codex" + + +def required_tools_for(config: PreflightConfig) -> list[str]: + tools = list(BASE_REQUIRED_TOOLS) + if config.provider == "codex" or config.player_agent == "codex": + tools.append("codex") + if config.provider == "claude" or config.player_agent == "claude": + tools.append("claude") + return tools def utc_timestamp() -> str: @@ -144,6 +156,18 @@ def has_auth_marker(text: str, markers: Sequence[str]) -> bool: return any(re.search(rf"\b{re.escape(marker)}\b", text) for marker in markers) +def parse_semver(text: str) -> tuple[int, int, int] | None: + match = re.search(r"\b(\d+)\.(\d+)\.(\d+)\b", text or "") + if not match: + return None + return tuple(int(part) for part in match.groups()) + + +def supports_codex_mcp_overrides(version_text: str) -> bool: + version = parse_semver(version_text) + return bool(version and version >= MIN_CODEX_MCP_OVERRIDE_VERSION) + + def run_command(cmd: Sequence[str], cwd: Path | None = None, timeout: int = 8) -> dict: try: proc = subprocess.run( @@ -350,7 +374,12 @@ def inspect_tool( return info -def inspect_tools(repo: Path, runner: CommandRunner, which: WhichFn) -> tuple[dict, list[str], list[str]]: +def inspect_tools( + repo: Path, + runner: CommandRunner, + which: WhichFn, + required_tools: Sequence[str], +) -> tuple[dict, list[str], list[str]]: blockers: list[str] = [] warnings: list[str] = [] tool_specs = { @@ -371,7 +400,7 @@ def inspect_tools(repo: Path, runner: CommandRunner, which: WhichFn) -> tuple[di "ps": ("ps", ["--version"]), } tools = {name: inspect_tool(name, exe, args, repo, runner, which) for name, (exe, args) in tool_specs.items()} - for name in REQUIRED_TOOLS: + for name in required_tools: if not tools.get(name, {}).get("available"): blockers.append(f"required VM tool missing: {name}") @@ -421,9 +450,18 @@ def inspect_tools(repo: Path, runner: CommandRunner, which: WhichFn) -> tuple[di blockers.append("Playwright Chromium executable is not installed; run (cd qa/playwright && npx playwright install chromium)") tools["playwright_chromium"] = chromium - codex = {"available": bool(tools.get("codex", {}).get("available")), "auth_status": "not_proven"} + codex_required = "codex" in required_tools + codex = {"available": bool(tools.get("codex", {}).get("available")), "auth_status": "not_required"} codex_path = tools.get("codex", {}).get("path") + codex_version = tools.get("codex", {}).get("version") or "" + codex["mcp_override_min_version"] = ".".join(str(part) for part in MIN_CODEX_MCP_OVERRIDE_VERSION) + codex["mcp_override_supported"] = supports_codex_mcp_overrides(codex_version) if codex_path else False + if codex_required and codex_path and not codex["mcp_override_supported"]: + blockers.append( + "Codex CLI version does not prove support for codex exec -c mcp_servers.* overrides; require >= 0.120.0" + ) if codex_path: + codex["auth_status"] = "not_proven" result = runner([codex_path, "auth", "status"], repo, 10) combined = f"{result.get('stdout') or ''}\n{result.get('stderr') or ''}".strip() lower = combined.lower() @@ -433,17 +471,27 @@ def inspect_tools(repo: Path, runner: CommandRunner, which: WhichFn) -> tuple[di "auth_probe_timed_out": bool(result.get("timed_out")), } ) - negative_auth = ("not authenticated", "unauthenticated", "not logged in", "signed out", "inactive") + negative_auth = ( + "not authenticated", + "unauthenticated", + "not logged in", + "not signed in", + "not signed", + "signed out", + "inactive", + ) positive_auth = ("authenticated", "logged in", "signed in") if has_auth_marker(lower, negative_auth): codex["auth_status"] = "not_proven" - blockers.append("Codex CLI auth/profile status is not proven") + if codex_required: + blockers.append("Codex CLI auth/profile status is not proven") elif result.get("ok") and has_auth_marker(lower, positive_auth): codex["auth_status"] = "proven" elif result.get("ok"): codex["auth_status"] = "command_ok_unclassified" - blockers.append("Codex CLI auth/profile status is not proven") - else: + if codex_required: + blockers.append("Codex CLI auth/profile status is not proven") + elif codex_required: blockers.append("Codex CLI auth/profile status is not proven") tools["codex_auth"] = codex return tools, blockers, warnings @@ -481,7 +529,12 @@ def inspect_private_art(art_root: Path, mode: str) -> tuple[dict, list[str], lis return info, blockers, warnings -def inspect_required_repo_files(repo: Path, personas: list[str]) -> tuple[dict, list[str], list[str]]: +def inspect_required_repo_files( + repo: Path, + personas: list[str], + provider: str, + _player_agent: str, +) -> tuple[dict, list[str], list[str]]: blockers: list[str] = [] warnings: list[str] = [] required = [ @@ -492,9 +545,11 @@ def inspect_required_repo_files(repo: Path, personas: list[str]) -> tuple[dict, "qa/release_readiness.py", "qa/play_player_duo.txt", "qa/playwright/palette_server.js", - "scripts/play.sh", - "scripts/play_party.sh", ] + if provider == "codex": + required.append("scripts/play_codex_dm.sh") + elif provider == "claude": + required.extend(["scripts/play.sh", "scripts/play_party.sh"]) required.extend(f"qa/play_player_browser_{persona}.txt" for persona in personas) files = {} for rel in required: @@ -539,6 +594,8 @@ def build_vm_persona_commands(config: PreflightConfig) -> list[str]: "WOS_APP_PART=B", "WOS_APP_SKIP_BUILD=1", "WOS_APP_NO_GLOBAL_KILL=1", + f"WOS_APP_SELECTED_PROVIDER={q(config.provider)}", + f"WOS_APP_PLAYER_AGENT={q(config.player_agent)}", f"WOS_APP_PREFERRED_PORT={q(config.port)}", "qa/ui_playtest_app.sh", q(f"{run_prefix}-{persona}"), @@ -562,11 +619,21 @@ def build_report( blockers: list[str] = [] warnings: list[str] = [] config.artifact_dir.mkdir(parents=True, exist_ok=True) + if config.provider not in PERSONA_PROVIDERS: + blockers.append(f"unsupported support-VM persona provider: {config.provider}") + if config.player_agent not in PLAYER_AGENTS: + blockers.append(f"unsupported support-VM player agent: {config.player_agent}") + required_tools = required_tools_for(config) repo, repo_blockers, repo_warnings = inspect_repo(config.repo, config.expected_sha, runner) - tools, tool_blockers, tool_warnings = inspect_tools(config.repo, runner, which) + tools, tool_blockers, tool_warnings = inspect_tools(config.repo, runner, which, required_tools) art, art_blockers, art_warnings = inspect_private_art(config.art_root, config.private_art_mode) - repo_files, file_blockers, file_warnings = inspect_required_repo_files(config.repo, config.personas) + repo_files, file_blockers, file_warnings = inspect_required_repo_files( + config.repo, + config.personas, + config.provider, + config.player_agent, + ) blockers.extend(repo_blockers + tool_blockers + art_blockers + file_blockers) warnings.extend(repo_warnings + tool_warnings + art_warnings + file_warnings) @@ -599,6 +666,9 @@ def build_report( "budget": config.budget, "concurrency_cap": config.concurrency, "port": config.port, + "provider": config.provider, + "player_agent": config.player_agent, + "required_tools": required_tools, "same_sha_required": True, "expected_sha": config.expected_sha, "support_vm_scope": "backend/persona artifacts only; Mac built-app/native handoff evidence is supplied separately", @@ -663,6 +733,9 @@ def markdown_report(report: dict) -> str: f"- Personas: `{','.join(report['rri_plan']['expected_personas'])}`", f"- Budget: `{report['rri_plan']['budget']}`", f"- Port: `{report['rri_plan']['port']}`", + f"- Provider: `{report['rri_plan']['provider']}`", + f"- Player agent: `{report['rri_plan']['player_agent']}`", + f"- Required tools: `{','.join(report['rri_plan'].get('required_tools', []))}`", f"- Support VM scope: `{report['rri_plan']['support_vm_scope']}`", f"- Do not run on support VM: `{report['rri_plan']['do_not_run_on_support_vm']}`", f"- First persona command: `{(report['rri_plan'].get('vm_persona_sweep_commands') or [''])[0]}`", @@ -693,6 +766,8 @@ def parse_args(argv: Sequence[str]) -> argparse.Namespace: parser.add_argument("--budget", default="12.00") parser.add_argument("--concurrency", type=int, default=1) parser.add_argument("--port", type=int, default=8785) + parser.add_argument("--provider", choices=PERSONA_PROVIDERS, default="codex") + parser.add_argument("--player-agent", choices=PLAYER_AGENTS, default="codex") parser.add_argument("--no-fail", action="store_true", help="Write the report and exit 0 even if blockers exist") return parser.parse_args(argv) @@ -711,6 +786,8 @@ def main(argv: Sequence[str] | None = None) -> int: budget=args.budget, concurrency=args.concurrency, port=args.port, + provider=args.provider, + player_agent=args.player_agent, ) report = build_report(config) json_path = config.artifact_dir / "support_vm_preflight.json" diff --git a/qa/test_macos_app_static.py b/qa/test_macos_app_static.py index e4137e32..98a4f291 100644 --- a/qa/test_macos_app_static.py +++ b/qa/test_macos_app_static.py @@ -55,6 +55,9 @@ def test_built_app_playtest_can_keep_minted_backend_for_manual_gameplay(self): self.assertIn("WOS_APP_KEEP_MINTED_BACKEND=1", harness) self.assertIn("WOS_APP_SELECTED_PROVIDER=codex|scripted|claude|openclaw", harness) + self.assertIn("WOS_APP_PLAYER_AGENT=claude|codex", harness) + self.assertIn('PLAYER_AGENT="${WOS_APP_PLAYER_AGENT:-claude}"', harness) + self.assertIn('PART_B_PROVIDER="${SELECTED_PROVIDER:-claude}"', harness) self.assertIn('defaults write dev.clawdnd.app selectedProvider "$SELECTED_PROVIDER"', harness) self.assertIn("requires WOS_APP_PART=A", harness) self.assertIn('KEEP_MINTED_BACKEND="${WOS_APP_KEEP_MINTED_BACKEND:-0}"', harness) @@ -73,6 +76,19 @@ def test_built_app_playtest_can_keep_minted_backend_for_manual_gameplay(self): self.assertNotIn('play_party.sh $WORLD $minted_run', harness) self.assertNotIn('play.sh $WORLD $minted_run', harness) + def test_built_app_part_b_supports_codex_provider_and_player_agent(self): + harness = self.read("qa/ui_playtest_app.sh") + + self.assertIn("scripts/play_codex_dm.sh", harness) + self.assertIn("CLAWDND_PROVIDER=codex", harness) + self.assertIn("codex exec", harness) + self.assertIn("codex_supports_mcp_override_config", harness) + self.assertIn("Codex CLI >= 0.120.0", harness) + self.assertIn("mcp_servers.clawdnd-uiplayer.command", harness) + self.assertIn("palette_server.js", harness) + self.assertIn("player_agent", harness) + self.assertIn("provider", harness) + def test_scripted_provider_is_dev_gated_and_model_free(self): models = self.read("macos/WorldOSApp/Sources/WorldOSApp/Models/ProviderModels.swift") providers = self.read("macos/WorldOSApp/Sources/WorldOSApp/Services/ProviderAdapters.swift") diff --git a/qa/test_support_vm_preflight.py b/qa/test_support_vm_preflight.py index f43e7979..c6f4e0ec 100644 --- a/qa/test_support_vm_preflight.py +++ b/qa/test_support_vm_preflight.py @@ -14,6 +14,7 @@ def __init__( head: str = "deadbeefcafebabe1234567890abcdef12345678", status: str = "", codex_auth_output: str = "Authenticated as codex-test", + codex_version: str = "codex-cli 0.120.0", chromium_path: Path | None = None, create_chromium: bool = True, remote_query_ok: bool = True, @@ -24,6 +25,7 @@ def __init__( self.head = head self.status = status self.codex_auth_output = codex_auth_output + self.codex_version = codex_version self.chromium_path = chromium_path or repo / ".fake-chromium" self.remote_query_ok = remote_query_ok self.remote_query_head = remote_query_head or head @@ -63,7 +65,7 @@ def __call__(self, cmd, cwd=None, timeout=8): "node": "v22.22.1", "npm": "10.9.4", "npx": "10.9.4", - "codex": "codex-cli 0.120.0", + "codex": self.codex_version, "claude": "claude 0.0.0-test", "jq": "jq-1.7", "curl": "curl 8.0.0", @@ -117,6 +119,7 @@ def make_config(tmp: Path, *, expected_sha: str = "deadbee") -> preflight.Prefli "qa/playwright/palette_server.js", "scripts/play.sh", "scripts/play_party.sh", + "scripts/play_codex_dm.sh", "qa/play_player_browser_newbie.txt", "qa/play_player_browser_veteran.txt", "qa/play_player_browser_adversarial.txt", @@ -182,6 +185,8 @@ def test_private_art_required_blocks_and_optional_warns(self): def test_cli_defaults_require_private_art(self): args = preflight.parse_args([]) self.assertEqual(args.private_art_mode, "required") + self.assertEqual(args.provider, "codex") + self.assertEqual(args.player_agent, "codex") def test_env_snapshot_redacts_secret_values_but_keeps_safe_paths(self): snapshot = preflight.env_snapshot( @@ -234,7 +239,13 @@ def test_report_contains_required_sections_with_canonical_personas(self): ): self.assertIn(section, report) self.assertEqual(report["rri_plan"]["expected_personas"], preflight.CANONICAL_PERSONAS) + self.assertEqual(report["rri_plan"]["provider"], "codex") + self.assertEqual(report["rri_plan"]["player_agent"], "codex") + self.assertIn("codex", report["rri_plan"]["required_tools"]) + self.assertNotIn("claude", report["rri_plan"]["required_tools"]) self.assertEqual(report["blockers"], []) + markdown = preflight.markdown_report(report) + self.assertIn("- Required tools: `git,python3,uv,node,npm,npx,jq,curl,lsof,timeout,pkill,pgrep,ps,codex`", markdown) def test_report_flags_dirty_repo_and_expected_sha_mismatch(self): with tempfile.TemporaryDirectory() as td: @@ -305,19 +316,48 @@ def test_vm_plan_does_not_suggest_mac_release_gate_command(self): plan_blob = json.dumps(report["rri_plan"]) self.assertIn("qa/ui_playtest_app.sh", plan_blob) self.assertIn("WOS_APP_PART=B", plan_blob) + self.assertIn("WOS_APP_SELECTED_PROVIDER=codex", plan_blob) + self.assertIn("WOS_APP_PLAYER_AGENT=codex", plan_blob) self.assertNotIn("qa/release_gate.sh --personas", plan_blob) self.assertTrue(report["rri_plan"]["mac_handoff_required"]) - def test_missing_persona_lane_tool_blocks_readiness(self): + def test_default_codex_lane_does_not_require_claude(self): with tempfile.TemporaryDirectory() as td: config = make_config(Path(td)) + def which_without_claude(name: str) -> str | None: + return None if name == "claude" else f"/fake/{name}" + + report = preflight.build_report(config, runner=FakeRunner(config.repo), which=which_without_claude, env={}) + self.assertTrue(report["ready_for_rri"]) + self.assertNotIn("required VM tool missing: claude", report["blockers"]) + + def test_codex_lane_blocks_when_codex_missing(self): + with tempfile.TemporaryDirectory() as td: + config = make_config(Path(td)) + + def which_without_codex(name: str) -> str | None: + return None if name == "codex" else f"/fake/{name}" + + report = preflight.build_report(config, runner=FakeRunner(config.repo), which=which_without_codex, env={}) + self.assertFalse(report["ready_for_rri"]) + self.assertIn("required VM tool missing: codex", report["blockers"]) + + def test_explicit_claude_lane_requires_claude_and_marks_command(self): + with tempfile.TemporaryDirectory() as td: + config = make_config(Path(td)) + config.provider = "claude" + config.player_agent = "claude" + def which_without_claude(name: str) -> str | None: return None if name == "claude" else f"/fake/{name}" report = preflight.build_report(config, runner=FakeRunner(config.repo), which=which_without_claude, env={}) self.assertFalse(report["ready_for_rri"]) self.assertIn("required VM tool missing: claude", report["blockers"]) + plan_blob = json.dumps(report["rri_plan"]) + self.assertIn("WOS_APP_SELECTED_PROVIDER=claude", plan_blob) + self.assertIn("WOS_APP_PLAYER_AGENT=claude", plan_blob) def test_missing_playwright_chromium_blocks_readiness(self): with tempfile.TemporaryDirectory() as td: @@ -355,11 +395,44 @@ def test_codex_not_authenticated_output_does_not_false_green(self): self.assertNotIn("auth_probe_excerpt", report["tools"]["codex_auth"]) self.assertNotIn("user@example.com", json.dumps(report)) + def test_codex_not_signed_in_output_does_not_false_green(self): + with tempfile.TemporaryDirectory() as td: + config = make_config(Path(td)) + report = preflight.build_report( + config, + runner=FakeRunner(config.repo, codex_auth_output="Not signed in"), + which=fake_which, + env={}, + ) + self.assertFalse(report["ready_for_rri"]) + self.assertEqual(report["tools"]["codex_auth"]["auth_status"], "not_proven") + self.assertIn("Codex CLI auth/profile status is not proven", report["blockers"]) + def test_codex_auth_classifier_uses_word_boundaries(self): self.assertFalse(preflight.has_auth_marker("inactive", ("active",))) self.assertFalse(preflight.has_auth_marker("notauthenticated", ("authenticated",))) self.assertTrue(preflight.has_auth_marker("authenticated as codex", ("authenticated",))) + def test_codex_mcp_override_version_gate(self): + self.assertTrue(preflight.supports_codex_mcp_overrides("codex-cli 0.120.0")) + self.assertTrue(preflight.supports_codex_mcp_overrides("codex-cli 1.0.0")) + self.assertFalse(preflight.supports_codex_mcp_overrides("codex-cli 0.119.9")) + self.assertFalse(preflight.supports_codex_mcp_overrides("codex-cli unknown")) + + def test_old_codex_version_blocks_codex_lane(self): + with tempfile.TemporaryDirectory() as td: + config = make_config(Path(td)) + report = preflight.build_report( + config, + runner=FakeRunner(config.repo, codex_version="codex-cli 0.119.9"), + which=fake_which, + env={}, + ) + self.assertFalse(report["ready_for_rri"]) + blocker_text = "\n".join(report["blockers"]) + self.assertIn("codex exec -c mcp_servers.* overrides", blocker_text) + self.assertFalse(report["tools"]["codex_auth"]["mcp_override_supported"]) + def test_codex_active_alone_does_not_prove_auth(self): with tempfile.TemporaryDirectory() as td: config = make_config(Path(td)) diff --git a/qa/ui_playtest_app.sh b/qa/ui_playtest_app.sh index b7a7712a..5024f695 100755 --- a/qa/ui_playtest_app.sh +++ b/qa/ui_playtest_app.sh @@ -43,6 +43,8 @@ # WOS_APP_PART=A|B|AB run only part A, only part B, or both (default AB). # WOS_APP_SELECTED_PROVIDER=codex|scripted|claude|openclaw # set the native app's provider preference before minting a session. +# WOS_APP_PLAYER_AGENT=claude|codex +# part B UI-driving agent (default: claude for legacy compatibility). # WOS_APP_KEEP_MINTED_BACKEND=1 # part A only: leave the .app-minted provider backend alive so # an operator can continue a short built-app gameplay playtest. @@ -67,6 +69,7 @@ BUDGET="${5:-4.00}" PART="$(worldos_env APP_PART "${WOS_APP_PART:-AB}")" KEEP_MINTED_BACKEND="${WOS_APP_KEEP_MINTED_BACKEND:-0}" SELECTED_PROVIDER="${WOS_APP_SELECTED_PROVIDER:-}" +PLAYER_AGENT="${WOS_APP_PLAYER_AGENT:-claude}" if [ "$KEEP_MINTED_BACKEND" = "1" ] && [ "$PART" != "A" ]; then printf '[uipt-app] WOS_APP_KEEP_MINTED_BACKEND=1 requires WOS_APP_PART=A; refusing to mix kept native backend with part B.\n' >&2 exit 2 @@ -90,7 +93,14 @@ VERSION="$( ([ -f "$ROOT/VERSION" ] && cat "$ROOT/VERSION") \ || git -C "$ROOT" describe --tags --always 2>/dev/null \ || echo "unknown")" log() { printf '[uipt-app] %s\n' "$*"; } -log "run=$RUN world=$WORLD persona=$PERSONA beats=$BEATS budget=\$$BUDGET part=$PART" +case "$PLAYER_AGENT" in + claude|codex) ;; + *) + printf '[uipt-app] WOS_APP_PLAYER_AGENT must be claude or codex (got %s)\n' "$PLAYER_AGENT" >&2 + exit 2 + ;; +esac +log "run=$RUN world=$WORLD persona=$PERSONA beats=$BEATS budget=\$$BUDGET part=$PART player_agent=$PLAYER_AGENT" log "build_sha=$BUILD_SHA version=$VERSION repo=$ROOT" if [ -n "$SELECTED_PROVIDER" ]; then case "$SELECTED_PROVIDER" in @@ -104,6 +114,19 @@ if [ -n "$SELECTED_PROVIDER" ]; then ;; esac fi +PART_B_PROVIDER="${SELECTED_PROVIDER:-claude}" +case "$PART_B_PROVIDER" in + claude|codex) ;; + scripted|openclaw) + case "$PART" in + A) ;; + *) + printf '[uipt-app] Part B supports WOS_APP_SELECTED_PROVIDER=claude|codex only (got %s)\n' "$PART_B_PROVIDER" >&2 + exit 2 + ;; + esac + ;; +esac # Agent-readable failure buckets for built-app smoke. Keep these crisp and stable; the # detailed shell/native result still travels separately as original_result. @@ -147,6 +170,20 @@ set_bucket_pair() { # $1=A|B $2='bucket|detail' fi } +codex_supports_mcp_override_config() { + local raw major minor patch + raw="$(codex --version 2>/dev/null | head -1 || true)" + if [[ "$raw" =~ ([0-9]+)\.([0-9]+)\.([0-9]+) ]]; then + major="${BASH_REMATCH[1]}" + minor="${BASH_REMATCH[2]}" + patch="${BASH_REMATCH[3]}" + [ "$major" -gt 0 ] && return 0 + [ "$minor" -gt 120 ] && return 0 + [ "$minor" -eq 120 ] && [ "$patch" -ge 0 ] && return 0 + fi + return 1 +} + # --- never let a play script pop a browser on the owner's screen -------------------------- # scripts/play*.sh call `open`/`xdg-open` once the dashboard serves. Shim `open` to a no-op so # the BACKEND runs but no window is forced onto the owner. (The .app's OWN window in part A is @@ -493,34 +530,55 @@ run_part_b() { # The PLAYER agent gets whatever budget remains (it drives /move; the DM cost is the bulk). local player_budget; player_budget="$(awk -v s="${spent:-0}" -v b="$BUDGET" 'BEGIN{r=b-s; if(r<0.5)r=0.5; printf "%.2f", r}')" - # Faithful backend: a SEPARATE play-state run (so it never collides with part A's mint). This - # is the EXACT command the native bridge shells (ProviderAdapters.swift:62). Solo → execs - # scripts/play.sh, which boots a LIVE viewer (CLAWDND_PLAYER_MOVES set) + a DM cold-open + a - # DM resolver loop tailing the move sink. We point the palette persona at THAT viewer. + # Faithful backend: a SEPARATE play-state run (so it never collides with part A's mint). The + # Claude lane still uses the native bridge's legacy play_party.sh/play.sh path; the Codex lane + # uses the Codex DM provider wrapper that owns the same live viewer + /move sink contract. + # We point the palette persona at THAT viewer. local b_run="${RUN}-b" local b_port; b_port="$(pick_free_port $((PREFERRED_PORT+20)))" || { log "[B] no free port"; PART_B_RESULT="no_port"; set_bucket_pair B "$(bucket_pair no_provider "no free localhost port for faithful backend")"; return 1; } local b_url="http://127.0.0.1:$b_port/openworlds/" - log "[B] launching faithful backend: scripts/play_party.sh $WORLD $b_run $b_port (solo → play.sh; DM=$DM_MODEL)" + log "[B] launching faithful backend: provider=$PART_B_PROVIDER run=$b_run port=$b_port DM=$DM_MODEL" # Cap the backend so it can never overshoot the run budget. CLAWDND_PLAY_SESSION_BUDGET is the # aggregate DM ceiling; CLAWDND_PLAY_MAX_TURNS bounds turns; per-turn cap keeps each beat small. local sess_cap; sess_cap="$(awk -v r="$player_budget" 'BEGIN{c=r-0.20; if(c<0.50)c=0.50; printf "%.2f", c}')" - ( - export PATH="$PATH_NOOPEN" - export WORLDOS_DM_MODEL="$DM_MODEL" CLAWDND_DM_MODEL="$DM_MODEL" - export CLAWDND_PLAY_PORT="$b_port" - export CLAWDND_PLAY_BUDGET="${CLAWDND_PLAY_BUDGET:-1.50}" - export CLAWDND_PLAY_SESSION_BUDGET="$sess_cap" - export CLAWDND_PLAY_MAX_TURNS="${CLAWDND_PLAY_MAX_TURNS:-$((BEATS + 4))}" - export CLAWDND_PLAY_MAX_IDLE="${CLAWDND_PLAY_MAX_IDLE:-600}" - "$ROOT/scripts/play_party.sh" "$WORLD" "$b_run" "$b_port" >> "$RUNDIR/backend.log" 2>&1 - ) & + local codex_default_model + codex_default_model="${CLAWDND_CODEX_MODEL:-}" + case "$PART_B_PROVIDER" in + claude) + ( + export PATH="$PATH_NOOPEN" + export WORLDOS_DM_MODEL="$DM_MODEL" CLAWDND_DM_MODEL="$DM_MODEL" + export CLAWDND_PLAY_PORT="$b_port" + export CLAWDND_PLAY_BUDGET="${CLAWDND_PLAY_BUDGET:-1.50}" + export CLAWDND_PLAY_SESSION_BUDGET="$sess_cap" + export CLAWDND_PLAY_MAX_TURNS="${CLAWDND_PLAY_MAX_TURNS:-$((BEATS + 4))}" + export CLAWDND_PLAY_MAX_IDLE="${CLAWDND_PLAY_MAX_IDLE:-600}" + exec "$ROOT/scripts/play_party.sh" "$WORLD" "$b_run" "$b_port" >> "$RUNDIR/backend.log" 2>&1 + ) & + ;; + codex) + ( + export PATH="$PATH_NOOPEN" + export CLAWDND_PROVIDER=codex + export CLAWDND_WORLD="$WORLD" + export CLAWDND_RUN_ID="$b_run" + export CLAWDND_PLAY_PORT="$b_port" + export CLAWDND_PLAY_BUDGET="${CLAWDND_PLAY_BUDGET:-1.50}" + export CLAWDND_PLAY_SESSION_BUDGET="$sess_cap" + export CLAWDND_PLAY_MAX_TURNS="${CLAWDND_PLAY_MAX_TURNS:-$((BEATS + 4))}" + export CLAWDND_CODEX_MODEL="${WOS_APP_CODEX_DM_MODEL:-$codex_default_model}" + exec "$ROOT/scripts/play_codex_dm.sh" >> "$RUNDIR/backend.log" 2>&1 + ) & + ;; + esac B_BACKEND=$! # Clean teardown: kill the backend subshell, THEN the play.sh supervisor + DM-loop bash procs # (matched by the run-id positional — they don't carry the play-state path, and the supervisor # respawns the viewer, so a path-only kill leaves it alive), then the viewer + state dir, belt # and suspenders. The launcher and other runs are untouched (unique run id `${RUN}-b`). b_cleanup() { + pkill -TERM -P "$B_BACKEND" 2>/dev/null || true kill "$B_BACKEND" 2>/dev/null || true pkill -f "play_party.sh $WORLD $b_run" 2>/dev/null || true pkill -f "play.sh $WORLD $b_run" 2>/dev/null || true @@ -588,13 +646,60 @@ PY local persona_brief; persona_brief="$(cat "$PERSONA_FILE")" local psid; psid="$(python3 -c 'import uuid;print(uuid.uuid4())')" local player_out="$PLAYERDIR/player.jsonl" - log "[B] player agent starting (persona=$PERSONA, ~$BEATS actions, budget \$$player_budget)…" - claude -p "$persona_brief - -You have a budget of about $BEATS actions for this whole session. Spend them trying to start and play the story, reporting friction as you go. When you have either (a) genuinely gotten stuck after reporting it, or (b) played a few real turns and seen enough to judge the experience, you may stop — give a final 1-2 sentence verdict and, if you got stuck, call give_up. Start now." \ - --session-id "$psid" --mcp-config "$player_cfg" --strict-mcp-config \ - --model "$PLAYER_MODEL" --permission-mode bypassPermissions --max-budget-usd "$player_budget" \ - --output-format stream-json --verbose > "$player_out" 2>> "$PLAYERDIR/player.err" + local player_prompt="$PLAYERDIR/player.prompt.md" + local player_last="$PLAYERDIR/player.last.txt" + cat > "$player_prompt" < "$player_out" 2>> "$PLAYERDIR/player.err" + ;; + codex) + : > "$player_last" + export CLAWDND_UIPT_URL="$b_url" + export CLAWDND_UIPT_RUNDIR="$RUNDIR" + local uipt_channel + uipt_channel="$(worldos_env UIPT_CHANNEL "")" + export CLAWDND_UIPT_CHANNEL="$uipt_channel" + export CLAWDND_UIPT_PERSONA="$PERSONA" + local codex_player_model + codex_player_model="${WOS_APP_CODEX_PLAYER_MODEL:-$codex_default_model}" + local codex_player_model_args=() + if [ -n "${codex_player_model//[[:space:]]/}" ]; then + codex_player_model_args=(--model "$codex_player_model") + fi + # Keep this lane self-contained: support-VM evidence must not mutate CODEX_HOME + # with `codex mcp add`. Codex CLI 0.120.0 supports the same per-invocation + # TOML dot-notation overrides used by scripts/play_codex_dm.sh and + # scripts/play_codex_actor.sh. + if ! codex_supports_mcp_override_config; then + printf '[uipt-app] WOS_APP_PLAYER_AGENT=codex requires Codex CLI >= 0.120.0 for codex exec -c mcp_servers.* overrides; upgrade Codex CLI or use WOS_APP_PLAYER_AGENT=claude.\n' >&2 + return 1 + fi + codex exec \ + --ignore-user-config \ + --ignore-rules \ + --sandbox read-only \ + --json \ + ${codex_player_model_args[@]+"${codex_player_model_args[@]}"} \ + --cd "$ROOT" \ + --output-last-message "$player_last" \ + -c "mcp_servers.clawdnd-uiplayer.command=\"node\"" \ + -c "mcp_servers.clawdnd-uiplayer.args=[\"$PW_DIR/palette_server.js\"]" \ + -c "mcp_servers.clawdnd-uiplayer.env_vars=[\"CLAWDND_UIPT_URL\",\"CLAWDND_UIPT_RUNDIR\",\"CLAWDND_UIPT_CHANNEL\",\"CLAWDND_UIPT_PERSONA\"]" \ + -c "mcp_servers.clawdnd-uiplayer.required=true" \ + -c "mcp_servers.clawdnd-uiplayer.default_tools_approval_mode=\"approve\"" \ + -c "mcp_servers.clawdnd-uiplayer.enabled_tools=[\"screenshot\",\"a11y_tree\",\"click\",\"type\",\"key\",\"wait\",\"report_bug\",\"give_up\"]" \ + - < "$player_prompt" > "$player_out" 2>> "$PLAYERDIR/player.err" + ;; + esac local player_rc=$? log "[B] player agent finished (rc=$player_rc)." @@ -608,21 +713,27 @@ You have a budget of about $BEATS actions for this whole session. Spend them try fi local player_verdict player_cost - player_verdict="$(jq -rs 'map(select(.type=="result"))[-1].result // ""' "$player_out" 2>/dev/null)" + if [ "$PLAYER_AGENT" = "codex" ]; then + player_verdict="$(cat "$player_last" 2>/dev/null || true)" + else + player_verdict="$(jq -rs 'map(select(.type=="result"))[-1].result // ""' "$player_out" 2>/dev/null)" + fi player_cost="$(jq -rs '[.[]|select(.type=="result")|.total_cost_usd//0]|add // 0' "$player_out" 2>/dev/null)" PART_B_PLAYER_COST="${player_cost:-0}" b_cleanup; trap - RETURN # meta.json (the scorer reads it) + score + summary via the EXISTING scorer (unchanged). - python3 - "$RUNDIR/meta.json" "$RUN" "$WORLD" "$PERSONA" "$b_port" "$BEATS" "$BUDGET" "$player_cost" "$player_rc" "$BUILD_SHA" "$VERSION" <<'PY' + python3 - "$RUNDIR/meta.json" "$RUN" "$WORLD" "$PERSONA" "$b_port" "$BEATS" "$BUDGET" "$player_cost" "$player_rc" "$BUILD_SHA" "$VERSION" "$PART_B_PROVIDER" "$PLAYER_AGENT" <<'PY' import json, sys, datetime -out, run, world, persona, port, beats, budget, cost, rc, sha, ver = sys.argv[1:12] +out, run, world, persona, port, beats, budget, cost, rc, sha, ver, provider, player_agent = sys.argv[1:14] json.dump({ "run": run, "world": world, "persona": persona, "port": int(port), "beats_cap": int(beats), "budget_usd": float(budget), "player_cost_usd": round(float(cost or 0), 4), "player_rc": int(rc), - "build_sha": sha, "version": ver, "surface": "built-app-faithful-backend (play_party.sh)", + "build_sha": sha, "version": ver, + "provider": provider, "player_agent": player_agent, + "surface": f"built-app-faithful-backend ({provider} provider, {player_agent} player)", "session_surface_path": "session_surface.final.json", "finished_at": datetime.datetime.utcnow().replace(microsecond=0).isoformat() + "Z", }, open(out, "w"), indent=2) @@ -738,11 +849,13 @@ python3 - "$RUNDIR/run.json" "$RUN" "$WORLD" "$PERSONA" "$BEATS" "$BUDGET" "$BUI "$PART" "$PART_A_RESULT" "${PART_A_RUNDIR:-}" "${PART_A_MINTED_PORT:-}" \ "$PART_A_KEPT_BACKEND" "$PART_A_FIRST_TURN_READY" "$PART_B_RESULT" "$PART_B_SCORE_PASS" \ "$FINAL_DM_SPEND" "$PART_B_PLAYER_COST" "$TOTAL_SPEND" \ - "$PART_A_FAILURE_BUCKET" "$PART_A_FAILURE_DETAIL" "$PART_B_FAILURE_BUCKET" "$PART_B_FAILURE_DETAIL" <<'PY' + "$PART_A_FAILURE_BUCKET" "$PART_A_FAILURE_DETAIL" "$PART_B_FAILURE_BUCKET" "$PART_B_FAILURE_DETAIL" \ + "$PART_B_PROVIDER" "$PLAYER_AGENT" <<'PY' import json, sys, datetime (out, run, world, persona, beats, budget, sha, ver, part, a_res, a_run, a_port, a_kept, a_first_turn_ready, b_res, b_score_pass, dm_spend, player_cost, total) = sys.argv[1:20] a_bucket, a_detail, b_bucket, b_detail = sys.argv[20:24] +provider, player_agent = sys.argv[24:26] json.dump({ "run": run, "world": world, "persona": persona, "beats_cap": int(beats), "budget_usd": float(budget), "build_sha": sha, "version": ver, "part": part, @@ -754,13 +867,14 @@ json.dump({ "kept_backend_alive": a_kept == "true", "first_turn_ready": a_first_turn_ready == "true"}, "part_b": {"persona_loop": b_res, "score_pass": b_score_pass == "true", + "provider": provider, "player_agent": player_agent, "original_result": b_res, "failure_bucket": b_bucket or None, "failure_detail": b_detail or None}, "spend_usd": {"dm_and_companions": round(float(dm_spend or 0), 4), "player_agent": round(float(player_cost or 0), 4), "total": round(float(total or 0), 4)}, - "surface": "BUILT dist/WorldOS.app (part A) + byte-identical play_party.sh backend (part B)", + "surface": f"BUILT dist/WorldOS.app (part A) + {provider} provider/{player_agent} player backend (part B)", "at": datetime.datetime.utcnow().replace(microsecond=0).isoformat() + "Z", }, open(out, "w"), indent=2) PY