diff --git a/.gitignore b/.gitignore index 6d38ee8c59..b317e84025 100644 --- a/.gitignore +++ b/.gitignore @@ -258,9 +258,12 @@ yarn.lock !.pdd/meta/construct_paths_python.json !.pdd/meta/prompt_lint_python.json !.pdd/meta/core_cloud_python.json +!.pdd/meta/commands_auth_python.json +!.pdd/meta/commands_analysis_python.json !.pdd/meta/fix_code_loop_python.json !.pdd/meta/fix_error_loop_python.json !.pdd/meta/get_comment_python.json +!.pdd/meta/get_jwt_token_python.json !.pdd/meta/get_test_command_python.json !.pdd/meta/git_update_python.json !.pdd/meta/llm_invoke_python.json @@ -268,11 +271,17 @@ yarn.lock !.pdd/meta/summarize_directory_python.json !.pdd/meta/sync_determine_operation_python.json !.pdd/meta/sync_tui_python.json +!.pdd/meta/user_story_tests_python.json # Design-refresh (PR #1561) dev units: track their fingerprints so the auto-heal # drift detector sees committed metadata for the modules this PR changed. !.pdd/meta/cli_theme_python.json !.pdd/meta/sync_animation_python.json .pdd/meta/*_run.json +!.pdd/meta/commands_auth_python_run.json +!.pdd/meta/commands_analysis_python_run.json +!.pdd/meta/get_jwt_token_python_run.json +!.pdd/meta/llm_invoke_python_run.json +!.pdd/meta/user_story_tests_python_run.json # Issue #1006 follow-up: these two modules' tests pass quickly but # `pdd sync` runs >20 minutes on them in CI, hitting the auto-heal # 1200s timeout. Committing run-reports satisfies the released drift diff --git a/.pdd/meta/commands_analysis_python.json b/.pdd/meta/commands_analysis_python.json new file mode 100644 index 0000000000..b041b89874 --- /dev/null +++ b/.pdd/meta/commands_analysis_python.json @@ -0,0 +1,22 @@ +{ + "pdd_version": "0.0.304.dev0", + "timestamp": "2026-07-13T07:58:36.176734+00:00", + "command": "test", + "prompt_hash": "88fa390a51bc483eb85a643b9b782a7afa467fa1be3d4b0c3cd31501d6f3a3d0", + "code_hash": "57f552e4a1cfe55119bddaf94b90569c086c271b00afc6b7dabe9b3adaf5a804", + "example_hash": "01ff82910c50ec8f8cfd22f8e3b7c011f286a4b9f90e7dd5b2eb685c1f339e56", + "test_hash": "eba9c5a01ac883b6887ebf1e9117553ab5b0b94194d9d4e8fe4a24ded08bb280", + "test_files": { + "test_analysis.py": "eba9c5a01ac883b6887ebf1e9117553ab5b0b94194d9d4e8fe4a24ded08bb280" + }, + "include_deps": { + "context/python_preamble.prompt": "0388ed131bf986f8752e1bc4c81e4da0460cfe2908ec8c60b1314edbab768254", + "context/detect_change_main_example.py": "ddc494709d127398004f53de58bede388692b2839a97b26668c55575369d9edd", + "context/conflicts_main_example.py": "36e9f73fd888af26dc73f35ee765715e99e4325881bc0ffdc5d70106eda7e239", + "context/bug_main_example.py": "662ecde39d05a5345533c4ae74c6b1031f016916ec2ff4764d34a26f7ec26172", + "context/agentic_bug_example.py": "96e35df4e8425a9173ff6a30281892c2bd8b81361cff72b732e488172ddf52f5", + "context/crash_main_example.py": "5c9b909107f74f773ccf7013d556a5812643d2a3b086e79fe07da7ea7877458c", + "context/trace_main_example.py": "c1f43ebf55c05af801640f5f8331ee7a674aef9e3f383b0e5ffee7abc7246fe1", + "context/track_cost_example.py": "bd60efaa7d6274b0e4d1743ac05785461af65ec785b254730288d08774f72ed6" + } +} \ No newline at end of file diff --git a/.pdd/meta/commands_analysis_python_run.json b/.pdd/meta/commands_analysis_python_run.json new file mode 100644 index 0000000000..964033e974 --- /dev/null +++ b/.pdd/meta/commands_analysis_python_run.json @@ -0,0 +1,11 @@ +{ + "timestamp": "2026-07-13T17:43:55.846120+00:00", + "exit_code": 0, + "tests_passed": 49, + "tests_failed": 0, + "coverage": 88.0, + "test_hash": "eba9c5a01ac883b6887ebf1e9117553ab5b0b94194d9d4e8fe4a24ded08bb280", + "test_files": { + "test_analysis.py": "eba9c5a01ac883b6887ebf1e9117553ab5b0b94194d9d4e8fe4a24ded08bb280" + } +} diff --git a/.pdd/meta/commands_auth_python.json b/.pdd/meta/commands_auth_python.json new file mode 100644 index 0000000000..77bb6691f6 --- /dev/null +++ b/.pdd/meta/commands_auth_python.json @@ -0,0 +1,20 @@ +{ + "pdd_version": "0.0.304.dev0", + "timestamp": "2026-07-13T08:18:29.698872+00:00", + "command": "test", + "prompt_hash": "4f9edbbe1daf05a4b248c693c84a34a5af524901714930a1a99e88a8577912c1", + "code_hash": "4bd3d81ace06fd311092795bc47e8ba84e25c24da35029adcc4401edcec3afc9", + "example_hash": "d1e77e2ab5a5b6f3c400a8a68a73bb633f6d45e39dd4a90306dcc18c729d9000", + "test_hash": "56d45f4b80dab36fdfac94fc4ae4b2011d02c0b5a30b2de538360a05fcda5454", + "test_files": { + "test_auth.py": "56d45f4b80dab36fdfac94fc4ae4b2011d02c0b5a30b2de538360a05fcda5454" + }, + "include_deps": { + "context/python_preamble.prompt": "0388ed131bf986f8752e1bc4c81e4da0460cfe2908ec8c60b1314edbab768254", + "context/auth_service_example.py": "4b6d2c8c9e13d1edc0d9805ee0c07657494cbf31dffe2bb0029b72975674a8cf", + "context/get_jwt_token_example.py": "76a87c8257cb1f4cfdc2edff6225c752432afb3a5e352fcb016d08d8eae16e01", + "context/core/cloud_example.py": "8a14ae9e69dbccfbbfffd9bde1ea11192334a10aaf9d5b5e6dba5ac23ba0dd90", + "context/commands/__init___example.py": "cc6e0de7d94b1dddda35d9c8ecb800710263d928191f1d4cb2400bae36c31cb4", + "context/_keyring_timeout_example.py": "41f83d5ef117caba86b788f69b519f7e88f3d7dc2a641a017d74a7cee9be6ed1" + } +} \ No newline at end of file diff --git a/.pdd/meta/commands_auth_python_run.json b/.pdd/meta/commands_auth_python_run.json new file mode 100644 index 0000000000..5f5369f549 --- /dev/null +++ b/.pdd/meta/commands_auth_python_run.json @@ -0,0 +1,11 @@ +{ + "timestamp": "2026-07-13T17:44:03.595033+00:00", + "exit_code": 0, + "tests_passed": 23, + "tests_failed": 0, + "coverage": 72.0, + "test_hash": "56d45f4b80dab36fdfac94fc4ae4b2011d02c0b5a30b2de538360a05fcda5454", + "test_files": { + "test_auth.py": "56d45f4b80dab36fdfac94fc4ae4b2011d02c0b5a30b2de538360a05fcda5454" + } +} diff --git a/.pdd/meta/get_jwt_token_python.json b/.pdd/meta/get_jwt_token_python.json new file mode 100644 index 0000000000..e7b7c7fc84 --- /dev/null +++ b/.pdd/meta/get_jwt_token_python.json @@ -0,0 +1,16 @@ +{ + "pdd_version": "0.0.304.dev0", + "timestamp": "2026-07-13T08:16:26.918463+00:00", + "command": "test", + "prompt_hash": "07c3ed43ef1a9f5251d06c9369eb397f2989d812336ce76c4f0f72ba5a54b099", + "code_hash": "ffc5706ccc49f85095bb7a1dcf11bb4aa5aef5b44bc077d922c14f721092e584", + "example_hash": "76a87c8257cb1f4cfdc2edff6225c752432afb3a5e352fcb016d08d8eae16e01", + "test_hash": "d14e6504e35c7fe88715e92ed113dc562ffa8a5608c692b83157317b9052316c", + "test_files": { + "test_get_jwt_token.py": "d14e6504e35c7fe88715e92ed113dc562ffa8a5608c692b83157317b9052316c" + }, + "include_deps": { + "context/python_preamble.prompt": "0388ed131bf986f8752e1bc4c81e4da0460cfe2908ec8c60b1314edbab768254", + "context/_keyring_timeout_example.py": "41f83d5ef117caba86b788f69b519f7e88f3d7dc2a641a017d74a7cee9be6ed1" + } +} \ No newline at end of file diff --git a/.pdd/meta/get_jwt_token_python_run.json b/.pdd/meta/get_jwt_token_python_run.json new file mode 100644 index 0000000000..8688e77ee1 --- /dev/null +++ b/.pdd/meta/get_jwt_token_python_run.json @@ -0,0 +1,11 @@ +{ + "timestamp": "2026-07-13T17:44:11.233398+00:00", + "exit_code": 0, + "tests_passed": 38, + "tests_failed": 0, + "coverage": 62.0, + "test_hash": "d14e6504e35c7fe88715e92ed113dc562ffa8a5608c692b83157317b9052316c", + "test_files": { + "test_get_jwt_token.py": "d14e6504e35c7fe88715e92ed113dc562ffa8a5608c692b83157317b9052316c" + } +} diff --git a/.pdd/meta/llm_invoke_python.json b/.pdd/meta/llm_invoke_python.json index 5dc5650fae..60cf4e701b 100644 --- a/.pdd/meta/llm_invoke_python.json +++ b/.pdd/meta/llm_invoke_python.json @@ -1,25 +1,19 @@ { - "pdd_version": "0.0.263.dev0", - "timestamp": "2026-06-04T20:30:11.770314+00:00", + "pdd_version": "0.0.304.dev0", + "timestamp": "2026-07-13T07:58:36.815052+00:00", "command": "test", - "prompt_hash": "43285448cc8f00ad6509e964509d9ec768914e02eef40f1a958bae69a60823a0", - "code_hash": "127c8cf41cdba1a5ec3865330cfba5ebbb9a582f65c19479a57fe732e33f3f2f", + "prompt_hash": "febab08e932b0056ef8fa9c283e9aa141a3754dd79762584bbec3c43b820f4d4", + "code_hash": "f98cd2bc08343946a549877aff0f536334de4c447823e49de32232b1a5cd1790", "example_hash": "3e66413118ec2f8e8940e7a835932920309aeeb775d23ce728d78299fb99e2ec", - "test_hash": "1979352422b5ea80ee7f2ea14a8a2c20ab880d02fd1d2365d8dd54050d2f5f18", + "test_hash": "4aaae7e8dc472e0fb280e9d773dce17ae806ee959c232badc1fb922011df1fb9", "test_files": { - "test_llm_invoke.py": "1979352422b5ea80ee7f2ea14a8a2c20ab880d02fd1d2365d8dd54050d2f5f18", - "test_llm_invoke_csv_model_registration.py": "1583b5e076fe5227a11f3d1079034ab4a21bc6131a3433f37bd68e35a99ba49e", - "test_llm_invoke_grounding.py": "e219903b03ea56b821cfaaf18b8f279bdddf9d59729daf86a3456bee7bbe2ecb", - "test_llm_invoke_integration.py": "2eb4bd2565761a4148762c6fb73c887cb6e12ff962742e05f5c2d4d62b47aaf9", - "test_llm_invoke_nested_schema.py": "c983a19874abacc3e0ea9d6ca2ec87495960d2dd96d4e93be4039ed1bc995b9b", - "test_llm_invoke_retry_cost.py": "bfdfe7b814b78813f86b8bc6d081831daf531187feff66358260f6282925958c", - "test_llm_invoke_vertex_retry.py": "eafe91ba9376dc7e2da36faf3cd2de3cef50f70cf1c4fc5655e373deb7f50c26" + "test_llm_invoke.py": "4aaae7e8dc472e0fb280e9d773dce17ae806ee959c232badc1fb922011df1fb9" }, "include_deps": { - "context/auth_service_example.py": "4b6d2c8c9e13d1edc0d9805ee0c07657494cbf31dffe2bb0029b72975674a8cf", - "context/path_resolution_example.py": "15ba84fd11c61af8fa12dcb19d63d42f0cb09b747542bf20e69650b8ff4ac137", "context/python_preamble.prompt": "0388ed131bf986f8752e1bc4c81e4da0460cfe2908ec8c60b1314edbab768254", - "pdd/core/cloud.py": "0487c0b989996af144df3af1c818a6eeafe52fd209710e5a54eb43990c89ae97", - "pdd/server/token_counter.py": "f784679da58bbabb9eb9b17c86f13e1ec5d98f2f2bd09407b4e1b044f83ca8a1" + "context/path_resolution_example.py": "15ba84fd11c61af8fa12dcb19d63d42f0cb09b747542bf20e69650b8ff4ac137", + "context/auth_service_example.py": "4b6d2c8c9e13d1edc0d9805ee0c07657494cbf31dffe2bb0029b72975674a8cf", + "pdd/server/token_counter.py": "847ac13e3804c451aca64652877940c06b72c2fc33edc00232f1d4aa16c957e8", + "pdd/core/cloud.py": "0487c0b989996af144df3af1c818a6eeafe52fd209710e5a54eb43990c89ae97" } } \ No newline at end of file diff --git a/.pdd/meta/llm_invoke_python_run.json b/.pdd/meta/llm_invoke_python_run.json index a168344ef1..9c72892dc3 100644 --- a/.pdd/meta/llm_invoke_python_run.json +++ b/.pdd/meta/llm_invoke_python_run.json @@ -1,17 +1,11 @@ { - "timestamp": "2026-06-04T20:30:11.771286+00:00", + "timestamp": "2026-07-13T17:44:40.602335+00:00", "exit_code": 0, - "tests_passed": 375, + "tests_passed": 362, "tests_failed": 0, - "coverage": 90.0, - "test_hash": "1979352422b5ea80ee7f2ea14a8a2c20ab880d02fd1d2365d8dd54050d2f5f18", + "coverage": 71.0, + "test_hash": "4aaae7e8dc472e0fb280e9d773dce17ae806ee959c232badc1fb922011df1fb9", "test_files": { - "test_llm_invoke.py": "1979352422b5ea80ee7f2ea14a8a2c20ab880d02fd1d2365d8dd54050d2f5f18", - "test_llm_invoke_csv_model_registration.py": "1583b5e076fe5227a11f3d1079034ab4a21bc6131a3433f37bd68e35a99ba49e", - "test_llm_invoke_grounding.py": "e219903b03ea56b821cfaaf18b8f279bdddf9d59729daf86a3456bee7bbe2ecb", - "test_llm_invoke_integration.py": "2eb4bd2565761a4148762c6fb73c887cb6e12ff962742e05f5c2d4d62b47aaf9", - "test_llm_invoke_nested_schema.py": "c983a19874abacc3e0ea9d6ca2ec87495960d2dd96d4e93be4039ed1bc995b9b", - "test_llm_invoke_retry_cost.py": "bfdfe7b814b78813f86b8bc6d081831daf531187feff66358260f6282925958c", - "test_llm_invoke_vertex_retry.py": "eafe91ba9376dc7e2da36faf3cd2de3cef50f70cf1c4fc5655e373deb7f50c26" + "test_llm_invoke.py": "4aaae7e8dc472e0fb280e9d773dce17ae806ee959c232badc1fb922011df1fb9" } } diff --git a/.pdd/meta/user_story_tests_python.json b/.pdd/meta/user_story_tests_python.json new file mode 100644 index 0000000000..c41fec039d --- /dev/null +++ b/.pdd/meta/user_story_tests_python.json @@ -0,0 +1,15 @@ +{ + "pdd_version": "0.0.304.dev0", + "timestamp": "2026-07-13T07:58:37.133345+00:00", + "command": "test", + "prompt_hash": "a9ec2be97ab7540dc2c24cdda9a12713ce39206eb905a2863bd69bdce7029a97", + "code_hash": "7b248c457b719c62aa776618583b637081f59f41f2fce511900eb7fa6d4a65ce", + "example_hash": "53214544b7af564ee41f178b9cbabc5162c9c6648638366f609a2ec5a46398d3", + "test_hash": "f44f319f030b32b0803967958100e1d1edaed7f1cde423bc2046957b5c88cdfd", + "test_files": { + "test_user_story_tests.py": "f44f319f030b32b0803967958100e1d1edaed7f1cde423bc2046957b5c88cdfd" + }, + "include_deps": { + "context/python_preamble.prompt": "0388ed131bf986f8752e1bc4c81e4da0460cfe2908ec8c60b1314edbab768254" + } +} \ No newline at end of file diff --git a/.pdd/meta/user_story_tests_python_run.json b/.pdd/meta/user_story_tests_python_run.json new file mode 100644 index 0000000000..786dd0bb15 --- /dev/null +++ b/.pdd/meta/user_story_tests_python_run.json @@ -0,0 +1,11 @@ +{ + "timestamp": "2026-07-13T17:45:01.394903+00:00", + "exit_code": 0, + "tests_passed": 52, + "tests_failed": 0, + "coverage": 75.0, + "test_hash": "f44f319f030b32b0803967958100e1d1edaed7f1cde423bc2046957b5c88cdfd", + "test_files": { + "test_user_story_tests.py": "f44f319f030b32b0803967958100e1d1edaed7f1cde423bc2046957b5c88cdfd" + } +} diff --git a/.pdd/sync-ownership.json b/.pdd/sync-ownership.json index 8010ee6c36..f3783733ff 100644 --- a/.pdd/sync-ownership.json +++ b/.pdd/sync-ownership.json @@ -234,6 +234,30 @@ "pattern": ".pdd/meta/code_generator_main_python.json", "role": "human-maintained" }, + { + "inventory": "HUMAN_OWNED", + "owner": "pdd-maintainers", + "pattern": ".pdd/meta/commands_analysis_python.json", + "role": "human-maintained" + }, + { + "inventory": "HUMAN_OWNED", + "owner": "pdd-maintainers", + "pattern": ".pdd/meta/commands_analysis_python_run.json", + "role": "human-maintained" + }, + { + "inventory": "HUMAN_OWNED", + "owner": "pdd-maintainers", + "pattern": ".pdd/meta/commands_auth_python.json", + "role": "human-maintained" + }, + { + "inventory": "HUMAN_OWNED", + "owner": "pdd-maintainers", + "pattern": ".pdd/meta/commands_auth_python_run.json", + "role": "human-maintained" + }, { "inventory": "HUMAN_OWNED", "owner": "pdd-maintainers", @@ -330,6 +354,18 @@ "pattern": ".pdd/meta/get_comment_python.json", "role": "human-maintained" }, + { + "inventory": "HUMAN_OWNED", + "owner": "pdd-maintainers", + "pattern": ".pdd/meta/get_jwt_token_python.json", + "role": "human-maintained" + }, + { + "inventory": "HUMAN_OWNED", + "owner": "pdd-maintainers", + "pattern": ".pdd/meta/get_jwt_token_python_run.json", + "role": "human-maintained" + }, { "inventory": "HUMAN_OWNED", "owner": "pdd-maintainers", @@ -480,6 +516,18 @@ "pattern": ".pdd/meta/track_cost_python_run.json", "role": "human-maintained" }, + { + "inventory": "HUMAN_OWNED", + "owner": "pdd-maintainers", + "pattern": ".pdd/meta/user_story_tests_python.json", + "role": "human-maintained" + }, + { + "inventory": "HUMAN_OWNED", + "owner": "pdd-maintainers", + "pattern": ".pdd/meta/user_story_tests_python_run.json", + "role": "human-maintained" + }, { "inventory": "HUMAN_OWNED", "owner": "pdd-maintainers", @@ -12417,6 +12465,12 @@ "pattern": "tests/test_issue_1872_reproduction.py", "role": "human-maintained" }, + { + "inventory": "HUMAN_OWNED", + "owner": "pdd-maintainers", + "pattern": "tests/test_issue_1923_detect_stories_noninteractive.py", + "role": "human-maintained" + }, { "inventory": "HUMAN_OWNED", "owner": "pdd-maintainers", diff --git a/.pdd/verification-profiles.json b/.pdd/verification-profiles.json index 0720f5bd37..446bc9ad38 100644 --- a/.pdd/verification-profiles.json +++ b/.pdd/verification-profiles.json @@ -4009,7 +4009,7 @@ "prompt_path": "pdd/prompts/commands/analysis_python.prompt", "language_id": "python", "required_requirement_ids": [ - "CONTRACT-SHA256:0e6824053f24a2230d51000fd998ff01ebeb56a4784660757a811dc86894c1a4" + "CONTRACT-SHA256:84c10997c3f2ffdbd7cc2db031f6a88cca8c3e434f994465187a62a55776cd4f" ], "obligations": [ { @@ -4018,7 +4018,7 @@ "validator_id": "threshold-ed25519", "validator_config_digest": "threshold-ed25519-v1", "requirement_ids": [ - "CONTRACT-SHA256:0e6824053f24a2230d51000fd998ff01ebeb56a4784660757a811dc86894c1a4" + "CONTRACT-SHA256:84c10997c3f2ffdbd7cc2db031f6a88cca8c3e434f994465187a62a55776cd4f" ], "artifact_paths": [ "pdd/prompts/commands/analysis_python.prompt" @@ -4031,7 +4031,7 @@ "prompt_path": "pdd/prompts/commands/auth_python.prompt", "language_id": "python", "required_requirement_ids": [ - "CONTRACT-SHA256:b4c1ac81709096413153612326f009adba9db405b804307c6f6d35712afb9a56" + "CONTRACT-SHA256:da6ce2315683ba6d315125e4e5f072712485e52f555890cf447ca77206a628cb" ], "obligations": [ { @@ -4040,7 +4040,7 @@ "validator_id": "threshold-ed25519", "validator_config_digest": "threshold-ed25519-v1", "requirement_ids": [ - "CONTRACT-SHA256:b4c1ac81709096413153612326f009adba9db405b804307c6f6d35712afb9a56" + "CONTRACT-SHA256:da6ce2315683ba6d315125e4e5f072712485e52f555890cf447ca77206a628cb" ], "artifact_paths": [ "pdd/prompts/commands/auth_python.prompt" @@ -7515,7 +7515,7 @@ "prompt_path": "pdd/prompts/get_jwt_token_python.prompt", "language_id": "python", "required_requirement_ids": [ - "CONTRACT-SHA256:f1fe2cab01019fd81d683c40511bfd09e698bd44458a4a4dd0b2158c82369ca3" + "CONTRACT-SHA256:3ad4ed6d5010e5f4f15cf31dbdd3a0d9b6a9521f070380a3d989cfa834f504c8" ], "obligations": [ { @@ -7524,7 +7524,7 @@ "validator_id": "threshold-ed25519", "validator_config_digest": "threshold-ed25519-v1", "requirement_ids": [ - "CONTRACT-SHA256:f1fe2cab01019fd81d683c40511bfd09e698bd44458a4a4dd0b2158c82369ca3" + "CONTRACT-SHA256:3ad4ed6d5010e5f4f15cf31dbdd3a0d9b6a9521f070380a3d989cfa834f504c8" ], "artifact_paths": [ "pdd/prompts/get_jwt_token_python.prompt" @@ -7933,7 +7933,7 @@ "prompt_path": "pdd/prompts/llm_invoke_python.prompt", "language_id": "python", "required_requirement_ids": [ - "CONTRACT-SHA256:88face96e298219fba7448186eb71f1586a676888a827a04d326882df8e4f41e" + "CONTRACT-SHA256:d2df9c9798249bae1ff7da952d92efa402768e100f793d086522fef336d250e2" ], "obligations": [ { @@ -7942,7 +7942,7 @@ "validator_id": "threshold-ed25519", "validator_config_digest": "threshold-ed25519-v1", "requirement_ids": [ - "CONTRACT-SHA256:88face96e298219fba7448186eb71f1586a676888a827a04d326882df8e4f41e" + "CONTRACT-SHA256:d2df9c9798249bae1ff7da952d92efa402768e100f793d086522fef336d250e2" ], "artifact_paths": [ "pdd/prompts/llm_invoke_python.prompt" @@ -10177,7 +10177,7 @@ "prompt_path": "pdd/prompts/user_story_tests_python.prompt", "language_id": "python", "required_requirement_ids": [ - "CONTRACT-SHA256:81256961dd9cfbfbc998e3b34720a9cb7915cd81484a1857898f45b1585e63b2" + "CONTRACT-SHA256:5bc98cbadfa9fde24baf0524c5fe5537cdac9f36660304e885e0a38cf3a43075" ], "obligations": [ { @@ -10186,7 +10186,7 @@ "validator_id": "threshold-ed25519", "validator_config_digest": "threshold-ed25519-v1", "requirement_ids": [ - "CONTRACT-SHA256:81256961dd9cfbfbc998e3b34720a9cb7915cd81484a1857898f45b1585e63b2" + "CONTRACT-SHA256:5bc98cbadfa9fde24baf0524c5fe5537cdac9f36660304e885e0a38cf3a43075" ], "artifact_paths": [ "pdd/prompts/user_story_tests_python.prompt" diff --git a/architecture.json b/architecture.json index 01cce9d57b..49e9a9c5e6 100644 --- a/architecture.json +++ b/architecture.json @@ -8581,6 +8581,77 @@ "y": 2000 } }, + { + "reason": "Provides explicit PDD Cloud authentication commands, including deliberate interactive login in otherwise non-interactive environments.", + "description": "Defines the auth login, status, logout, token, and clear-cache Click commands while preserving bounded keyring and JWT-cache behavior.", + "dependencies": [], + "priority": 70, + "filename": "commands/auth_python.prompt", + "filepath": "pdd/commands/auth.py", + "tags": [ + "command", + "python" + ], + "interface": { + "type": "command", + "command": { + "commands": [ + {"name": "auth login", "description": "Authenticate explicitly with PDD Cloud"}, + {"name": "auth status", "description": "Display current authentication state"}, + {"name": "auth logout", "description": "Clear cached and stored authentication"}, + {"name": "auth token", "description": "Output the current JWT for scripting"}, + {"name": "auth clear-cache", "description": "Delete the cached JWT token"} + ] + } + }, + "position": { + "x": 13050, + "y": 2000 + } + }, + { + "reason": "Registers analysis-oriented CLI commands, including non-interactive user-story validation through detect --stories.", + "description": "Defines the detect, conflicts, bug, crash, and trace Click commands and delegates their work to the corresponding prompt-owned orchestration modules.", + "dependencies": [], + "priority": 70, + "filename": "commands/analysis_python.prompt", + "filepath": "pdd/commands/analysis.py", + "tags": [ + "command", + "python" + ], + "interface": { + "type": "command", + "command": { + "commands": [ + { + "name": "detect", + "description": "Detect prompt changes or validate user stories" + }, + { + "name": "conflicts", + "description": "Detect conflicts between prompts" + }, + { + "name": "bug", + "description": "Run agentic or manual bug analysis" + }, + { + "name": "crash", + "description": "Analyze and optionally repair a crashing program" + }, + { + "name": "trace", + "description": "Trace a code line back to its prompt source" + } + ] + } + }, + "position": { + "x": 13100, + "y": 2000 + } + }, { "reason": "Registers maintenance CLI commands: sync, sync-architecture, auto-deps, and setup with all options.", "description": "Contains maintenance and setup commands for the PDD CLI, including single-module sync, no-argument project-wide sync, GitHub issue sync dispatch, prompt-to-architecture sync, auto-deps, and setup.", diff --git a/docs/generating_user_stories.md b/docs/generating_user_stories.md index aeb77b78cc..bd75892b21 100644 --- a/docs/generating_user_stories.md +++ b/docs/generating_user_stories.md @@ -277,6 +277,18 @@ Story mode prints PASS/FAIL for each story and exits non-zero if any story fails. `--output` is not supported with `--stories`; use `--evidence` when CI needs a machine-readable run manifest. +In CI or agent workflows, story validation runs in non-interactive mode by +default when stdin is not a TTY, so it does not launch GitHub browser/device +authentication. This applies to every command that runs story validation +(`pdd detect --stories`, `pdd change`, agentic change, and drift fixing). +Provide credentials before running it: set the model provider API key +environment variables used by your `llm_model.csv`, set `PDD_JWT_TOKEN` for PDD +Cloud, or run `pdd auth login` once in an interactive shell so the JWT is +cached. If credentials are missing, story validation fails non-zero with a clear +error instead of waiting for a device-login code. To deliberately allow the +interactive GitHub device-login flow during story validation (for example, to +authenticate from a piped-but-attended shell), set `PDD_ALLOW_INTERACTIVE=1`. + `pdd change` also runs story validation after a prompt modification, so stories act as a regression gate during normal development. diff --git a/pdd/commands/analysis.py b/pdd/commands/analysis.py index 04fc98ba68..4f317ed3a3 100644 --- a/pdd/commands/analysis.py +++ b/pdd/commands/analysis.py @@ -112,6 +112,9 @@ def detect_change( raise click.UsageError("--output is not supported with --stories.") obj = get_context_obj(ctx) + # Non-interactive story validation (issue #1923) is enforced inside + # run_user_story_tests, so every caller of that choke point (detect + # --stories, change, agentic change, drift) is protected uniformly. passed, results, total_cost, model_name = run_user_story_tests( prompts_dir=prompts_dir, stories_dir=stories_dir, diff --git a/pdd/commands/auth.py b/pdd/commands/auth.py index 939cfa6433..f7cda503b2 100644 --- a/pdd/commands/auth.py +++ b/pdd/commands/auth.py @@ -176,7 +176,15 @@ async def run_login(): console.print(f"[red]An unexpected error occurred: {e}[/red]") sys.exit(1) - asyncio.run(run_login()) + previous_allow_interactive = os.environ.get("PDD_ALLOW_INTERACTIVE") + os.environ["PDD_ALLOW_INTERACTIVE"] = "1" + try: + asyncio.run(run_login()) + finally: + if previous_allow_interactive is None: + os.environ.pop("PDD_ALLOW_INTERACTIVE", None) + else: + os.environ["PDD_ALLOW_INTERACTIVE"] = previous_allow_interactive @auth_group.command("status") diff --git a/pdd/get_jwt_token.py b/pdd/get_jwt_token.py index 6783c489a1..06f2d698f9 100644 --- a/pdd/get_jwt_token.py +++ b/pdd/get_jwt_token.py @@ -52,15 +52,20 @@ class UserCancelledError(AuthError): def _is_noninteractive() -> bool: """Return True when the process cannot safely prompt a human. - Used to refuse GitHub device-flow OAuth in CI/Cloud Build/Docker contexts - where no one can enter the verification code. Driven by explicit env vars - only: device flow writes to stdout, so a non-TTY stdin alone is not a - reliable signal. + Used to refuse GitHub device-flow OAuth in unattended CI/Cloud Build/Docker + contexts. ``PDD_ALLOW_INTERACTIVE`` is an explicit opt-in and therefore + takes precedence over the non-interactive flags. Driven by explicit env + vars only: device flow writes to stdout, so a non-TTY stdin alone is not a + reliable signal. Callers that must run non-interactively (e.g. story + validation) set ``PDD_NO_INTERACTIVE=1`` for the duration of the + auth-sensitive work instead of relying on TTY detection here. """ - truthy = ("1", "true", "yes") - if os.environ.get("PDD_NO_INTERACTIVE", "").lower() in truthy: + truthy = ("1", "true", "yes", "on") + if os.environ.get("PDD_ALLOW_INTERACTIVE", "").strip().lower() in truthy: + return False + if os.environ.get("PDD_NO_INTERACTIVE", "").strip().lower() in truthy: return True - if os.environ.get("CI", "").lower() in truthy: + if os.environ.get("CI", "").strip().lower() in truthy: return True return False diff --git a/pdd/llm_invoke.py b/pdd/llm_invoke.py index 5434a159c0..c4fb1b6ec4 100644 --- a/pdd/llm_invoke.py +++ b/pdd/llm_invoke.py @@ -3101,8 +3101,18 @@ def _clean_optional_scalar(value: Any) -> Optional[str]: def _interactive_credential_acquisition_allowed() -> bool: - """Whether API-key setup may prompt interactively in this process.""" - return not (_env_truthy("PDD_FORCE") or _is_cloud_runtime()) + """Whether API-key setup may prompt interactively in this process. + + ``PDD_NO_INTERACTIVE`` (set, for example, by ``pdd detect --stories`` for + the duration of non-interactive story validation) forces this off so a + missing API key fails closed instead of blocking on an interactive prompt. + ``PDD_ALLOW_INTERACTIVE`` is an explicit opt back in. + """ + if _env_truthy("PDD_ALLOW_INTERACTIVE"): + return True + if _env_truthy("PDD_NO_INTERACTIVE") or _env_truthy("PDD_FORCE") or _is_cloud_runtime(): + return False + return True def _vertex_project_value() -> Optional[str]: diff --git a/pdd/prompts/commands/analysis_python.prompt b/pdd/prompts/commands/analysis_python.prompt index 57fba40784..df427331f1 100644 --- a/pdd/prompts/commands/analysis_python.prompt +++ b/pdd/prompts/commands/analysis_python.prompt @@ -20,6 +20,7 @@ - `include_llm_prompts=include_llm` - `fail_fast=fail_fast` - Return result payload as `{"passed": passed, "results": results}` plus cost/model. + - Non-interactive safety for story validation (issue #1923) is enforced inside `run_user_story_tests` itself (it scopes `PDD_NO_INTERACTIVE` around each auth-sensitive `detect_change` call when stdin is not a TTY), so this command does not need to manage that env var; every caller of `run_user_story_tests` (`detect --stories`, `change`, agentic change, drift) is protected uniformly. - If `passed` is false, write any requested evidence first. For direct command invocation with no parent result callback, raise `click.exceptions.Exit(1)`. For top-level CLI invocation, return the result tuple so the summary still includes cost/model; the core result callback must then exit non-zero when it sees `{"passed": False, ...}`. - Options: `--stories`, `--stories-dir`, `--prompts-dir`, `--include-llm`, `--fail-fast/--no-fail-fast`. - For handled non-Click exceptions, mark `ctx.obj["_command_failed"] = True` before calling `handle_error(...)` and returning `None`, so the core result callback exits non-zero even when Click chain mode supplies an empty results list. diff --git a/pdd/prompts/commands/auth_python.prompt b/pdd/prompts/commands/auth_python.prompt index 6aa159e826..c85618edff 100644 --- a/pdd/prompts/commands/auth_python.prompt +++ b/pdd/prompts/commands/auth_python.prompt @@ -12,6 +12,7 @@ 2. **login**: - Launches device flow authentication with GitHub via Firebase. - Logic: Call get_jwt_token() from pdd.get_jwt_token with appropriate parameters. + - This command is an explicit request to authenticate. Scope `PDD_ALLOW_INTERACTIVE=1` around the async login call so GitHub device flow remains available even when `CI` or `PDD_NO_INTERACTIVE` is inherited, and restore the caller's exact prior value (including absence) in a `finally` block. - On success: Print "Successfully authenticated to PDD Cloud." - On failure: Print error and exit with code 1. diff --git a/pdd/prompts/get_jwt_token_python.prompt b/pdd/prompts/get_jwt_token_python.prompt index 51fe2da2c0..524bc8d962 100644 --- a/pdd/prompts/get_jwt_token_python.prompt +++ b/pdd/prompts/get_jwt_token_python.prompt @@ -73,7 +73,7 @@ a. Check keyring for refresh token b. If refresh token exists: call Firebase to get new JWT, cache it, return it c. If no refresh token or refresh fails: - - **Non-interactive guard**: before initiating device flow, check `_is_noninteractive()` (returns True when `PDD_NO_INTERACTIVE` or `CI` is set to a truthy value). If non-interactive, raise `AuthError` with a message instructing the caller to set `PDD_JWT_TOKEN` or run `pdd auth login` to cache credentials. This prevents Cloud Build / Docker runners from printing a device code and hanging on a verification prompt no human can answer; upstream `CloudConfig.get_jwt_token` catches the `AuthError` and falls back to local execution. + - **Non-interactive guard**: before initiating device flow, check `_is_noninteractive()`. Normalize all guard values by stripping whitespace and lowercasing, and recognize `1`/`true`/`yes`/`on`. `PDD_ALLOW_INTERACTIVE` is the explicit opt-in and takes precedence over `PDD_NO_INTERACTIVE` and `CI`; otherwise either `PDD_NO_INTERACTIVE` or `CI` makes the helper return True. A non-TTY stdin alone is intentionally NOT treated as non-interactive here, because device flow writes to stdout and an explicit `pdd auth login` may be run with piped stdin. Callers that must be non-interactive (e.g. `pdd detect --stories` story validation) set `PDD_NO_INTERACTIVE=1` for the duration of the auth-sensitive work, while the explicit login command scopes `PDD_ALLOW_INTERACTIVE=1`. If non-interactive, raise `AuthError` with a message instructing the caller to set `PDD_JWT_TOKEN` or run `pdd auth login` to cache credentials. This prevents unattended Cloud Build / Docker runners from printing a device code and hanging while preserving deliberate authentication; upstream `CloudConfig.get_jwt_token` catches the `AuthError` and falls back to local execution. i. Request device code from GitHub's OAuth device endpoint ii. Display user code and verification URL in terminal iii. Poll GitHub's OAuth endpoint for completion diff --git a/pdd/prompts/llm_invoke_python.prompt b/pdd/prompts/llm_invoke_python.prompt index f731d5bdea..04f04f380e 100644 --- a/pdd/prompts/llm_invoke_python.prompt +++ b/pdd/prompts/llm_invoke_python.prompt @@ -150,7 +150,7 @@ - Multi var (pipe-delimited): check all env vars. Do NOT pass `api_key=` to litellm — it reads them from os.environ automatically. For Vertex rows, resolve project/location through shared helpers so blank CSV values are not treated as usable: prefer a non-blank CSV `location` override, otherwise allow env aliases (`VERTEXAI_LOCATION` / legacy `VERTEX_LOCATION`), and similarly honor `VERTEXAI_PROJECT`, legacy `VERTEX_PROJECT`, or `GOOGLE_CLOUD_PROJECT`. If any required Vertex value is still missing, the row must be treated as unusable. Only pass `vertex_location=` to litellm when the catalog row itself pins a non-blank location such as `global`; blank-location rows should rely on the env vars LiteLLM already reads. - Empty: device flow or local model — no key needed, skip check. Exception: `github_copilot/` models in `PDD_FORCE` mode must check for an existing OAuth token at the path resolved from `GITHUB_COPILOT_TOKEN_DIR` (default `~/.config/litellm/github_copilot`) / `GITHUB_COPILOT_API_KEY_FILE` (default `api-key.json`). If the token file does not exist, skip the model to prevent litellm from hanging on an interactive device flow. - `chatgpt/` models authenticate with a ChatGPT subscription via the `codex login` token (issue #1269), enabling a flat-rate fallback when `ANTHROPIC_API_KEY` is missing or rate-limited. In `_ensure_api_key`, bridge the codex `auth.json` (`$CODEX_HOME/auth.json`, default `~/.codex/auth.json`; its OAuth fields are nested under `tokens` and must be flattened to the top level into a private `CHATGPT_TOKEN_DIR`) and apply the litellm `chatgpt/` empty-output workaround (upstream BerriAI/litellm#25429 / PR #27562) before the call. Treat the model as usable in force/cloud contexts only when the bridge actually succeeds in the current process; mere source-token detection is not enough. In non-interactive force/cloud contexts, skip the model when the bridge/token is unavailable so litellm never blocks on an interactive device-login flow. The subscription backend ignores `response_format`/`json_schema`, so for `chatgpt/` models requesting structured output, drop `response_format` and instead inject the JSON schema as an in-band system-message instruction (mirroring the Groq handling). This auth/patch/coercion logic lives in `pdd/codex_subscription.py`. The ChatGPT subscription is a first-class provider FAMILY (CSV provider `OpenAI ChatGPT`): multiple `chatgpt/*` model rows (e.g. `chatgpt/gpt-5.5`, `chatgpt/gpt-5.4`, `chatgpt/gpt-5.3-codex`, `chatgpt/gpt-5.2`) with empty `api_key`, raw ELOs mirrored to their API-keyed twins, and `model_rank_score` mirrored to the DeepSWE-first rank policy, so `--strength` interpolates within the family like Anthropic's models. These are distinct from the API-keyed `OpenAI` provider rows (which need `OPENAI_API_KEY`) — litellm routes the subscription rows by the `chatgpt/` prefix. Bring-your-own-subscription only — never pool one subscription across users. - - If missing and the run is non-interactive (`PDD_FORCE` or cloud runtime), skip the model instead of calling `input()`. + - If missing and the run is non-interactive, skip the model instead of calling `input()`. Centralize this decision in `_interactive_credential_acquisition_allowed()`: it returns `True` when `PDD_ALLOW_INTERACTIVE` is truthy (explicit opt-in), otherwise `False` when any of `PDD_NO_INTERACTIVE`, `PDD_FORCE`, or a cloud runtime is active, otherwise `True`. `PDD_NO_INTERACTIVE` is honored so callers that scope non-interactivity around a block of work (e.g. `pdd detect --stories`, issue #1923) fail closed on a missing key instead of blocking on an interactive `input()` prompt. Do not treat a non-TTY stdin as non-interactive here on its own — that global change would break explicit interactive setup flows run with piped stdin; the story path opts in via `PDD_NO_INTERACTIVE`. - For single-var: prompt user via `input()`, sanitize key, set env var, save to .env file. - .env save: Replace key in-place (no comment + append). Remove old commented versions of the same key. Preserve all other content. - Mark key as newly acquired for retry logic on auth errors. diff --git a/pdd/prompts/user_story_tests_python.prompt b/pdd/prompts/user_story_tests_python.prompt index 25e8f7eb17..6b52e56c80 100644 --- a/pdd/prompts/user_story_tests_python.prompt +++ b/pdd/prompts/user_story_tests_python.prompt @@ -269,6 +269,8 @@ multiple dev-unit lanes. - Respect `quiet` for console output. - If no stories are found, return success with zero cost. - If no prompt files are found, return failure with a clear message and zero cost. + - **Non-interactive story validation (issue #1923):** story validation calls `detect_change` -> `llm_invoke` -> cloud auth, which can otherwise reach an interactive GitHub device-login flow and block forever in an agent/CI shell. Decide once (via a helper such as `_story_validation_noninteractive()`) whether validation may prompt a human: return `False` when `PDD_ALLOW_INTERACTIVE` is truthy (explicit opt-in); `True` when `PDD_NO_INTERACTIVE` is already truthy; otherwise `True` when `sys.stdin` is not a TTY (the default for agent/CI shells; treat any error reading `isatty()` as non-interactive). Use a single truthy set `1/true/yes/on` (case-insensitive, whitespace-stripped) consistent with the other non-interactive guards. When non-interactive, set `os.environ["PDD_NO_INTERACTIVE"] = "1"` immediately around each auth-sensitive `detect_change(...)` call and restore the caller's prior value (or unset it) in a `finally` block. Force the value to `"1"` rather than skipping when a value is already present, so a falsy preset like `PDD_NO_INTERACTIVE=""`/`"0"` cannot leave the downstream truthiness guard unset. Because this lives in `run_user_story_tests`, EVERY caller (`detect --stories`, `change`, agentic change, drift) is protected; the downstream `get_jwt_token._is_noninteractive()` guard and `llm_invoke` interactive API-key acquisition both honor `PDD_NO_INTERACTIVE`, so no browser/device-login flow starts. It must NOT change global auth: an explicit `pdd auth login` (a different entrypoint) is unaffected. + - **Fail closed on validation errors (issue #1923):** wrap the per-story `detect_change(...)` call so that if it raises for any reason (auth/model/credential/network failure — e.g. a refused non-interactive device-flow), the exception does NOT abort the whole run or bubble up as a crash. Catch it, mark the run failed (`all_passed = False`), and append a failed result for that story (`passed=False`, empty `changes`) whose `error` message includes the story path, the underlying exception text, and conditional guidance ("If this is an authentication/credential error, ...") that non-interactive CI/agent runs must provide model API credentials, set `PDD_JWT_TOKEN` for PDD Cloud, or run `pdd auth login` before validation so no browser/device login is required. Do NOT assert the failure is always a credential problem, since the same catch covers genuine bugs. When not `quiet`, print `FAIL ` followed by the error. Honor `fail_fast` (break) and otherwise continue to the next story. This guarantees `pdd detect --stories` reports the failure and exits non-zero rather than hanging on device auth or crashing with a stack trace, without regressing the non-zero exit behavior of issue #1872. 3. **Story generation (#820, #1356)** - `generate_user_story` requires an `issue` argument (GitHub issue/PR URL, issue diff --git a/pdd/user_story_tests.py b/pdd/user_story_tests.py index ead4738b48..7c903b3e2f 100644 --- a/pdd/user_story_tests.py +++ b/pdd/user_story_tests.py @@ -8,12 +8,14 @@ import os import re import subprocess +import sys import tempfile import warnings from pathlib import Path from typing import Dict, Iterable, List, Optional, Tuple from rich import print as rprint +from rich.markup import escape as _rich_escape from .detect_change import detect_change from .get_extension import get_extension @@ -1471,6 +1473,30 @@ def generate_user_story( # pylint: disable=too-many-arguments,too-many-locals,t ) +_NONINTERACTIVE_TRUTHY = ("1", "true", "yes", "on") + + +def _story_validation_noninteractive() -> bool: + """Return True when story validation must not start browser/device auth. + + Story validation calls ``detect_change`` -> ``llm_invoke`` -> cloud auth, + which can otherwise reach an interactive GitHub device-login flow and block + forever in an agent/CI shell (issue #1923). Returns False when the caller + explicitly opts in via ``PDD_ALLOW_INTERACTIVE``; True when + ``PDD_NO_INTERACTIVE`` is already truthy; otherwise True when stdin is not a + TTY (the default for agent/CI shells). Any error reading ``isatty()`` is + treated as non-interactive. + """ + if os.environ.get("PDD_ALLOW_INTERACTIVE", "").strip().lower() in _NONINTERACTIVE_TRUTHY: + return False + if os.environ.get("PDD_NO_INTERACTIVE", "").strip().lower() in _NONINTERACTIVE_TRUTHY: + return True + try: + return not sys.stdin.isatty() + except Exception: # noqa: BLE001 - a missing/broken stdin means non-interactive + return True + + def run_user_story_tests( # pylint: disable=too-many-arguments,redefined-outer-name,too-many-locals,too-many-branches,too-many-statements *, prompts_dir: Optional[str] = None, @@ -1524,6 +1550,20 @@ def run_user_story_tests( # pylint: disable=too-many-arguments,redefined-outer- results: List[Dict[str, object]] = [] all_passed = True + # Issue #1923: story validation triggers cloud auth via detect_change -> + # llm_invoke. When it should not prompt a human (non-TTY agent/CI shell), + # force PDD_NO_INTERACTIVE around each auth-sensitive call so the device-flow + # guard and interactive API-key acquisition fail closed instead of opening a + # GitHub device-login flow that blocks forever. This protects every caller of + # this function (detect --stories, change, agentic change, drift), not just + # the detect command. Forcing "1" (rather than skipping when a falsy value is + # already present) closes the gap where PDD_NO_INTERACTIVE="0"/"" would leave + # the downstream truthiness guard unset. The set/restore mutates process-wide + # os.environ; that is safe here because story validation runs a single + # command per process (the detect_change worker threads are spawned inside + # the forced window, which is the intended behavior). + scope_noninteractive = _story_validation_noninteractive() + for story_path in story_files: story_content = _read_story(story_path) metadata_prompt_refs = _parse_story_prompt_metadata(story_content) @@ -1565,14 +1605,49 @@ def run_user_story_tests( # pylint: disable=too-many-arguments,redefined-outer- break continue - changes_list, cost, model = detect_change( - [str(p) for p in story_prompt_files], - oracle_content, - strength, - temperature, - time, - verbose=verbose, - ) + if scope_noninteractive: + previous_no_interactive = os.environ.get("PDD_NO_INTERACTIVE") + os.environ["PDD_NO_INTERACTIVE"] = "1" + try: + changes_list, cost, model = detect_change( + [str(p) for p in story_prompt_files], + oracle_content, + strength, + temperature, + time, + verbose=verbose, + ) + except Exception as exc: # noqa: BLE001 - story validation must fail closed + all_passed = False + error_message = ( + f"Story validation failed for {story_path}: {exc}. If this is an " + "authentication/credential error, non-interactive CI/agent runs " + "must provide model API credentials, set PDD_JWT_TOKEN for PDD " + "Cloud, or run `pdd auth login` before validation so no " + "browser/device login is required." + ) + results.append({ + "story": str(story_path), + "passed": False, + "changes": [], + "error": error_message, + }) + if not quiet: + # Escape the error text: the underlying exception string may + # contain rich-markup-like brackets, which must not crash the + # fail-closed path (it would turn a clean non-zero exit into a + # MarkupError traceback). + rprint(f"[bold]FAIL[/bold] {_rich_escape(str(story_path))}") + rprint(f"[red]{_rich_escape(error_message)}[/red]") + if fail_fast: + break + continue + finally: + if scope_noninteractive: + if previous_no_interactive is None: + os.environ.pop("PDD_NO_INTERACTIVE", None) + else: + os.environ["PDD_NO_INTERACTIVE"] = previous_no_interactive total_cost += cost model_name = model or model_name passed = len(changes_list) == 0 diff --git a/tests/commands/test_analysis.py b/tests/commands/test_analysis.py index 2f4063b9ab..bb3dd6cafb 100644 --- a/tests/commands/test_analysis.py +++ b/tests/commands/test_analysis.py @@ -94,6 +94,35 @@ def test_detect_stories_options(runner, mock_context_obj): assert kwargs["cache_story_prompt_links"] is True +def test_detect_stories_delegates_noninteractive_to_runner(runner, mock_context_obj, monkeypatch): + """Issue #1923: the non-interactive device-auth guard now lives inside + ``run_user_story_tests`` (so every caller is protected uniformly), NOT in the + detect command. The command must delegate cleanly and must not itself mutate + ``PDD_NO_INTERACTIVE`` in the process environment. + + The end-to-end "no device flow starts" behavior is covered by + ``tests/test_issue_1923_detect_stories_noninteractive.py`` and + ``tests/test_cloud_noninteractive_auth.py``. + """ + monkeypatch.delenv("PDD_NO_INTERACTIVE", raising=False) + monkeypatch.delenv("PDD_ALLOW_INTERACTIVE", raising=False) + observed = {} + + def fake_runner(**_kwargs): + # The real scoping happens inside run_user_story_tests; when it is mocked + # the command layer leaves the env untouched. + observed["pdd_no_interactive_during"] = os.environ.get("PDD_NO_INTERACTIVE") + return True, [], 0.0, "gpt-4" + + with patch("pdd.commands.analysis.run_user_story_tests", side_effect=fake_runner): + result = runner.invoke(detect_change, ["--stories"], obj=mock_context_obj) + + assert result.exit_code == 0 + # Command layer does not manage PDD_NO_INTERACTIVE itself. + assert observed["pdd_no_interactive_during"] is None + assert os.environ.get("PDD_NO_INTERACTIVE") is None + + def test_detect_stories_rejects_files(runner, mock_context_obj): """Test '--stories' mode rejects prompt/change positional arguments.""" with runner.isolated_filesystem(): diff --git a/tests/commands/test_auth.py b/tests/commands/test_auth.py index 33da5d47a6..dbcc2c2539 100644 --- a/tests/commands/test_auth.py +++ b/tests/commands/test_auth.py @@ -110,6 +110,29 @@ def test_load_firebase_api_key_file(monkeypatch, tmp_path): # --- Login Command Tests --- +def test_login_explicitly_allows_interaction_in_ci_and_restores_env( + runner, mock_dependencies, monkeypatch +): + """`pdd auth login` is itself opt-in, even under inherited CI guards.""" + observed = {} + + async def mock_get_jwt_token(*_args, **_kwargs): + observed["allow"] = os.environ.get("PDD_ALLOW_INTERACTIVE") + raise RuntimeError("stop after observing scoped environment") + + monkeypatch.setenv("NEXT_PUBLIC_FIREBASE_API_KEY", "test-api-key") + monkeypatch.setenv("CI", "1") + monkeypatch.setenv("PDD_NO_INTERACTIVE", "1") + monkeypatch.setenv("PDD_ALLOW_INTERACTIVE", " prior ") + mock_dependencies["get_jwt_token"].side_effect = mock_get_jwt_token + + result = runner.invoke(auth_group, ["login"]) + + assert result.exit_code == 1 + assert observed == {"allow": "1"} + assert os.environ["PDD_ALLOW_INTERACTIVE"] == " prior " + + def test_login_success(runner, mock_dependencies, monkeypatch): """Test successful login flow.""" monkeypatch.setenv("NEXT_PUBLIC_FIREBASE_API_KEY", "test-api-key") @@ -430,4 +453,4 @@ def test_status_shows_expiration_time(runner, mock_dependencies): assert result.exit_code == 0 assert "Token expires in:" in result.output # Allow for timing variance (44-45 minutes due to test execution time) - assert "44 minutes" in result.output or "45 minutes" in result.output \ No newline at end of file + assert "44 minutes" in result.output or "45 minutes" in result.output diff --git a/tests/test_get_jwt_token.py b/tests/test_get_jwt_token.py index 30f9fb2235..87a5fc3774 100644 --- a/tests/test_get_jwt_token.py +++ b/tests/test_get_jwt_token.py @@ -30,8 +30,8 @@ def _isolate_auth_env(monkeypatch): """ monkeypatch.delenv(PDD_JWT_TOKEN_ENV, raising=False) monkeypatch.delenv("PDD_NO_INTERACTIVE", raising=False) + monkeypatch.delenv("PDD_ALLOW_INTERACTIVE", raising=False) monkeypatch.delenv("CI", raising=False) - monkeypatch.setattr("pdd.get_jwt_token._is_noninteractive", lambda: False) @pytest.mark.asyncio @@ -71,9 +71,21 @@ def test_autouse_fixture_clears_pdd_jwt_token_env_leak(): def test_autouse_fixture_clears_noninteractive_env_leak(): """Device-flow tests should not inherit CI runner interactivity settings.""" assert "PDD_NO_INTERACTIVE" not in os.environ + assert "PDD_ALLOW_INTERACTIVE" not in os.environ assert "CI" not in os.environ +def test_explicit_interactive_opt_in_overrides_ci_and_noninteractive(monkeypatch): + """A deliberate login can reach device flow despite inherited CI guards.""" + from pdd.get_jwt_token import _is_noninteractive + + monkeypatch.setenv("CI", " true ") + monkeypatch.setenv("PDD_NO_INTERACTIVE", "YES") + monkeypatch.setenv("PDD_ALLOW_INTERACTIVE", " On ") + + assert _is_noninteractive() is False + + def test_expected_jwt_audience_staging_ignores_generic_project_env(monkeypatch): """PDD_ENV=staging must not be overridden by local ADC project settings.""" from pdd.get_jwt_token import _get_expected_jwt_audience diff --git a/tests/test_issue_1923_detect_stories_noninteractive.py b/tests/test_issue_1923_detect_stories_noninteractive.py new file mode 100644 index 0000000000..5c83e5fc9a --- /dev/null +++ b/tests/test_issue_1923_detect_stories_noninteractive.py @@ -0,0 +1,330 @@ +"""Regression tests for issue #1923. + +`pdd detect --stories` (and `pdd change` / agentic change / drift, which share +the same ``run_user_story_tests`` choke point) must not block on an interactive +GitHub device-login prompt when run from an agent/CI-style non-interactive +shell. Historically story validation reached cloud authentication and printed a +GitHub device URL/code, then waited forever for a human to complete the browser +flow. + +The fix scopes ``PDD_NO_INTERACTIVE`` around each auth-sensitive +``detect_change`` call *inside* ``run_user_story_tests`` so every caller is +protected uniformly, and forces the value to ``"1"`` (rather than skipping when +a falsy value is already present) so a stray ``PDD_NO_INTERACTIVE=""``/``"0"`` +cannot reopen the hang. + +These tests exercise: + * the scoping decision (`_story_validation_noninteractive`) and that the env + is actually forced to "1" at the moment ``detect_change`` runs, then + restored (deterministic, covers all callers); + * the M1 falsy/empty-preset hole and the ``on`` truthy value; + * the interactive opt-in (`PDD_ALLOW_INTERACTIVE`) and real-TTY cases; and + * the REAL device-flow guard end to end: with cloud enabled and no cached + credentials, ``run_user_story_tests`` must never instantiate ``DeviceFlow`` + (red on main, green after fix). +""" +from __future__ import annotations + +import io +import os + +import pytest +from click.testing import CliRunner + +import pdd.user_story_tests as ust +from pdd.cli import cli +from pdd import get_jwt_token as gjt_module +from pdd.core import cloud +from pdd.core.cloud import ( + FIREBASE_API_KEY_ENV, + GITHUB_CLIENT_ID_ENV, + PDD_JWT_TOKEN_ENV, +) + + +PROVIDER_KEY_ENVS = ( + "ANTHROPIC_API_KEY", + "OPENAI_API_KEY", + "GEMINI_API_KEY", + "GOOGLE_API_KEY", + "GROQ_API_KEY", + "MISTRAL_API_KEY", + "DEEPSEEK_API_KEY", + "COHERE_API_KEY", + "TOGETHERAI_API_KEY", + "PERPLEXITYAI_API_KEY", + "VERTEX_CREDENTIALS", + "GOOGLE_APPLICATION_CREDENTIALS", + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", + "PDD_MODEL_DEFAULT", +) + + +@pytest.fixture(autouse=True) +def _isolate_auth_env(monkeypatch): + """Neutralize env vars that influence auth/interactivity for each test.""" + for var in ( + PDD_JWT_TOKEN_ENV, + "PDD_NO_INTERACTIVE", + "PDD_ALLOW_INTERACTIVE", + "CI", + FIREBASE_API_KEY_ENV, + GITHUB_CLIENT_ID_ENV, + "PDD_FORCE_LOCAL", + "PDD_FORCE", + "K_SERVICE", + "FUNCTIONS_EMULATOR", + ): + monkeypatch.delenv(var, raising=False) + + +def _make_story_repo(tmp_path): + """Create a minimal prompts dir + one story so validation reaches detect_change.""" + prompts_dir = tmp_path / "prompts" + stories_dir = tmp_path / "user_stories" + prompts_dir.mkdir() + stories_dir.mkdir() + (prompts_dir / "foo_python.prompt").write_text("Do the thing.", encoding="utf-8") + (stories_dir / "story__example.md").write_text( + "As a user, I want the thing to work.", encoding="utf-8" + ) + return str(prompts_dir), str(stories_dir) + + +# ----------------------------------------------------------------------------- +# Scoping decision + env forcing (deterministic; covers ALL callers) +# ----------------------------------------------------------------------------- + +def test_story_validation_forces_no_interactive_when_non_tty(tmp_path, monkeypatch): + """Under a non-TTY stdin with no env set, run_user_story_tests forces + PDD_NO_INTERACTIVE=1 exactly while detect_change runs, then unsets it.""" + prompts_dir, stories_dir = _make_story_repo(tmp_path) + monkeypatch.setattr(ust, "_story_validation_noninteractive", lambda: True) + + captured = {} + + def spy_detect_change(*_args, **_kwargs): + captured["during"] = os.environ.get("PDD_NO_INTERACTIVE") + return [], 0.0, "gpt-test" + + monkeypatch.setattr(ust, "detect_change", spy_detect_change) + + passed, _results, _cost, _model = ust.run_user_story_tests( + prompts_dir=prompts_dir, stories_dir=stories_dir, quiet=True + ) + + assert passed is True + assert captured["during"] == "1" # forced while auth-sensitive work runs + assert os.environ.get("PDD_NO_INTERACTIVE") is None # restored (was unset) + + +@pytest.mark.parametrize("preset", ["", "0", "false", "on", "1"]) +def test_story_validation_forces_no_interactive_over_presets(tmp_path, monkeypatch, preset): + """M1/M2: a pre-existing PDD_NO_INTERACTIVE value — including the falsy + ""/"0"/"false" that previously reopened the hang — must be forced to a + guard-honored "1" during validation and restored to the caller's value.""" + prompts_dir, stories_dir = _make_story_repo(tmp_path) + monkeypatch.setenv("PDD_NO_INTERACTIVE", preset) + # Non-interactive because either the preset is truthy or stdin is non-TTY. + monkeypatch.setattr(ust, "_story_validation_noninteractive", lambda: True) + + captured = {} + + def spy_detect_change(*_args, **_kwargs): + captured["during"] = os.environ.get("PDD_NO_INTERACTIVE") + return [], 0.0, "gpt-test" + + monkeypatch.setattr(ust, "detect_change", spy_detect_change) + + ust.run_user_story_tests(prompts_dir=prompts_dir, stories_dir=stories_dir, quiet=True) + + assert captured["during"] == "1" # forced regardless of preset + assert os.environ.get("PDD_NO_INTERACTIVE") == preset # caller's value restored + + +def test_story_validation_noninteractive_decision_matrix(monkeypatch): + """The decision helper: opt-in wins; truthy PDD_NO_INTERACTIVE wins; else + non-TTY => True. Uses the shared 1/true/yes/on truthy set.""" + monkeypatch.setattr(ust.sys.stdin, "isatty", lambda: False, raising=False) + monkeypatch.delenv("PDD_NO_INTERACTIVE", raising=False) + monkeypatch.delenv("PDD_ALLOW_INTERACTIVE", raising=False) + assert ust._story_validation_noninteractive() is True # non-TTY default + + monkeypatch.setenv("PDD_ALLOW_INTERACTIVE", "1") + assert ust._story_validation_noninteractive() is False # explicit opt-in + monkeypatch.setenv("CI", "true") + monkeypatch.setenv("PDD_NO_INTERACTIVE", "1") + assert ust._story_validation_noninteractive() is False # opt-in remains explicit + monkeypatch.delenv("PDD_ALLOW_INTERACTIVE", raising=False) + monkeypatch.delenv("CI", raising=False) + monkeypatch.delenv("PDD_NO_INTERACTIVE", raising=False) + + for truthy in ("1", "true", "yes", "on", "ON", " Yes "): + monkeypatch.setenv("PDD_NO_INTERACTIVE", truthy) + assert ust._story_validation_noninteractive() is True + + # Falsy presets fall through to the non-TTY signal (still True here). + for falsy in ("", "0", "false", "no"): + monkeypatch.setenv("PDD_NO_INTERACTIVE", falsy) + assert ust._story_validation_noninteractive() is True + + +def test_get_jwt_token_guard_honors_on_truthy_value(monkeypatch): + """The downstream device-flow guard shares the 1/true/yes/on truthy set + (whitespace-stripped), so a value the story scope may set/forward is honored.""" + for truthy in ("1", "true", "yes", "on", "ON", " on "): + monkeypatch.setenv("PDD_NO_INTERACTIVE", truthy) + assert gjt_module._is_noninteractive() is True + for falsy in ("", "0", "false", "no"): + monkeypatch.setenv("PDD_NO_INTERACTIVE", falsy) + monkeypatch.delenv("CI", raising=False) + assert gjt_module._is_noninteractive() is False + + +def test_story_validation_opt_in_allows_interactive(tmp_path, monkeypatch): + """PDD_ALLOW_INTERACTIVE opts back in: no forcing, env untouched.""" + prompts_dir, stories_dir = _make_story_repo(tmp_path) + monkeypatch.setenv("PDD_ALLOW_INTERACTIVE", "1") + + captured = {} + + def spy_detect_change(*_args, **_kwargs): + captured["during"] = os.environ.get("PDD_NO_INTERACTIVE") + return [], 0.0, "gpt-test" + + monkeypatch.setattr(ust, "detect_change", spy_detect_change) + + ust.run_user_story_tests(prompts_dir=prompts_dir, stories_dir=stories_dir, quiet=True) + + assert captured["during"] is None # device flow remains allowed for the human + + +def test_story_validation_respects_real_tty(tmp_path, monkeypatch): + """A real TTY (interactive terminal) must NOT be forced non-interactive.""" + prompts_dir, stories_dir = _make_story_repo(tmp_path) + monkeypatch.setattr(ust.sys.stdin, "isatty", lambda: True, raising=False) + + captured = {} + + def spy_detect_change(*_args, **_kwargs): + captured["during"] = os.environ.get("PDD_NO_INTERACTIVE") + return [], 0.0, "gpt-test" + + monkeypatch.setattr(ust, "detect_change", spy_detect_change) + + ust.run_user_story_tests(prompts_dir=prompts_dir, stories_dir=stories_dir, quiet=True) + + assert captured["during"] is None # interactive terminal unaffected + + +# ----------------------------------------------------------------------------- +# Real device-flow guard, end to end (the exact #1923 decision point) +# ----------------------------------------------------------------------------- + +def test_detect_stories_cli_non_tty_never_starts_device_flow(tmp_path, monkeypatch): + """Exercise the real top-level ``pdd detect --stories`` auth boundary. + + ``PDD_ISSUE_1923_TEST_PRE_FIX=1`` is a test-only RED-evidence switch: it + simulates the pre-fix missing story-validation scope while retaining every + assertion below. The normal test uses the real non-TTY decision helper. + """ + prompts_dir, stories_dir = _make_story_repo(tmp_path) + + # Enable cloud auth (device-flow credentials present) but no cached JWT. + monkeypatch.setenv(FIREBASE_API_KEY_ENV, "fake_firebase_key") + monkeypatch.setenv(GITHUB_CLIENT_ID_ENV, "fake_github_client_id") + # No usable provider keys, so the local fallback fails fast without network. + for key in PROVIDER_KEY_ENVS: + monkeypatch.delenv(key, raising=False) + + # Controlled baseline for concise TDD evidence. This is intentionally not a + # production switch: it only replaces the owning guard from inside this test. + if os.environ.get("PDD_ISSUE_1923_TEST_PRE_FIX") == "1": + monkeypatch.setattr(ust, "_story_validation_noninteractive", lambda: False) + + # Both JWT caches miss and there is no stored refresh token, so the real + # get_jwt_token reaches the device-flow branch unless the guard stops it. + monkeypatch.setattr(cloud, "_get_cached_jwt", lambda verbose=False: None) + monkeypatch.setattr(gjt_module, "_get_cached_jwt", lambda: None) + monkeypatch.setattr( + gjt_module.FirebaseAuthenticator, "_get_stored_refresh_token", lambda self: None + ) + + device_flow_calls = {"request": 0, "poll": 0} + + async def _trap_request_device_code(_self): + device_flow_calls["request"] += 1 + raise AssertionError( + "device-flow authentication must not request a code for " + "non-interactive story validation" + ) + + async def _trap_poll_for_token(_self, *_args, **_kwargs): + device_flow_calls["poll"] += 1 + raise AssertionError( + "device-flow authentication must not poll in non-interactive " + "story validation" + ) + + monkeypatch.setattr( + gjt_module.DeviceFlow, "request_device_code", _trap_request_device_code + ) + monkeypatch.setattr(gjt_module.DeviceFlow, "poll_for_token", _trap_poll_for_token) + + # Defensive: ensure the local fallback can never make a real model call. + import pdd.llm_invoke as llm_invoke_module + + def _no_completion(*_args, **_kwargs): + raise RuntimeError("no network in tests") + + monkeypatch.setattr( + llm_invoke_module.litellm, "completion", _no_completion, raising=False + ) + + class _NonTTYInput(io.BytesIO): + """Click input stream that records the real ``isatty`` decision.""" + + isatty_checked = False + + def isatty(self): + self.isatty_checked = True + return False + + stdin = _NonTTYInput(b"") + result = CliRunner().invoke( + cli, + [ + "--no-core-dump", + "detect", + "--stories", + "--prompts-dir", + prompts_dir, + "--stories-dir", + stories_dir, + ], + input=stdin, + ) + + # The real Click-isolated stdin, not pytest's capture stream, drove the + # decision. This remains deterministic with ordinary pytest and pytest -s. + assert stdin.isatty_checked is True + assert device_flow_calls == {"request": 0, "poll": 0} + assert result.exit_code != 0 + + output = result.output + assert "PDD_JWT_TOKEN" in output + assert "model API credentials" in output + assert "pdd auth login" in output + for interactive_text in ( + "https://github.com/login/device", + "To authenticate, visit:", + "Enter code:", + "Opening browser for authentication", + "Waiting for authentication", + ): + assert interactive_text not in output + + # The scoped env is restored after the run. + assert os.environ.get("PDD_NO_INTERACTIVE") is None diff --git a/tests/test_user_story_tests.py b/tests/test_user_story_tests.py index 98ce897a82..59a0391515 100644 --- a/tests/test_user_story_tests.py +++ b/tests/test_user_story_tests.py @@ -121,6 +121,38 @@ def test_user_story_tests_detect_fail(tmp_path): assert model == "gpt-test" +def test_user_story_tests_auth_failure_fails_closed(tmp_path): + prompts_dir = tmp_path / "prompts" + stories_dir = tmp_path / "user_stories" + prompts_dir.mkdir() + stories_dir.mkdir() + + (prompts_dir / "foo_python.prompt").write_text("prompt", encoding="utf-8") + story = stories_dir / "story__auth.md" + story.write_text("As a user...", encoding="utf-8") + + with patch("pdd.user_story_tests.detect_change") as mock_detect: + mock_detect.side_effect = RuntimeError("Refusing interactive device-flow auth") + passed, results, cost, model = run_user_story_tests( + prompts_dir=str(prompts_dir), + stories_dir=str(stories_dir), + quiet=True, + ) + + assert passed is False + assert results[0]["passed"] is False + # Assert on the prompt-mandated content only (path, underlying error, and + # conditional credential guidance), not on an exact prefix the prompt does + # not require. + error = results[0]["error"] + assert "Refusing interactive device-flow auth" in error # underlying exc surfaced + assert "PDD_JWT_TOKEN" in error + assert "pdd auth login" in error + assert "device login" in error + assert cost == 0.0 + assert model == "" + + def test_discover_prompt_files_excludes_llm_by_default(tmp_path): prompts_dir = tmp_path / "prompts" prompts_dir.mkdir()