From e450b14b28a8f4592b3192f5a3ff80227ea116f0 Mon Sep 17 00:00:00 2001 From: "prompt-driven-github[bot]" Date: Thu, 9 Jul 2026 17:42:07 +0000 Subject: [PATCH 1/7] fix: prevent story detection device auth in non-tty --- docs/generating_user_stories.md | 8 ++++++ pdd/commands/analysis.py | 47 +++++++++++++++++++++++++-------- pdd/get_jwt_token.py | 10 ++++--- pdd/llm_invoke.py | 9 ++++++- pdd/user_story_tests.py | 38 ++++++++++++++++++++------ tests/commands/test_analysis.py | 18 +++++++++++++ tests/test_user_story_tests.py | 27 +++++++++++++++++++ 7 files changed, 134 insertions(+), 23 deletions(-) diff --git a/docs/generating_user_stories.md b/docs/generating_user_stories.md index ae388b04e4..f5ebcb6c6a 100644 --- a/docs/generating_user_stories.md +++ b/docs/generating_user_stories.md @@ -311,6 +311,14 @@ 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, `pdd detect --stories` runs in non-interactive mode +when stdin is not a TTY. It will not launch GitHub browser/device +authentication. 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 +fatal error instead of waiting for a device-login code. + `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..788e41677d 100644 --- a/pdd/commands/analysis.py +++ b/pdd/commands/analysis.py @@ -5,6 +5,7 @@ """ import os import re +import sys import click from typing import Optional, Tuple, List, Dict, Any @@ -41,6 +42,19 @@ def _mark_command_failed(ctx: click.Context) -> None: ctx.obj["_command_failed"] = True +def _story_detection_noninteractive() -> bool: + """Return True when story validation must not start browser/device auth.""" + 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 + try: + return not sys.stdin.isatty() + except Exception: + return True + + @click.command("detect") @click.argument("files", nargs=-1, type=click.Path(exists=True, dir_okay=False)) @click.option( @@ -112,18 +126,29 @@ def detect_change( raise click.UsageError("--output is not supported with --stories.") obj = get_context_obj(ctx) - passed, results, total_cost, model_name = run_user_story_tests( - prompts_dir=prompts_dir, - stories_dir=stories_dir, - strength=obj.get("strength", 0.2), - temperature=obj.get("temperature", 0.0), - time=obj.get("time", 0.25), - verbose=obj.get("verbose", False), - quiet=obj.get("quiet", False), - fail_fast=fail_fast, - include_llm_prompts=include_llm, - cache_story_prompt_links=True, + previous_no_interactive = os.environ.get("PDD_NO_INTERACTIVE") + set_no_interactive = ( + _story_detection_noninteractive() + and previous_no_interactive is None ) + if set_no_interactive: + os.environ["PDD_NO_INTERACTIVE"] = "1" + try: + passed, results, total_cost, model_name = run_user_story_tests( + prompts_dir=prompts_dir, + stories_dir=stories_dir, + strength=obj.get("strength", 0.2), + temperature=obj.get("temperature", 0.0), + time=obj.get("time", 0.25), + verbose=obj.get("verbose", False), + quiet=obj.get("quiet", False), + fail_fast=fail_fast, + include_llm_prompts=include_llm, + cache_story_prompt_links=True, + ) + finally: + if set_no_interactive: + os.environ.pop("PDD_NO_INTERACTIVE", None) if evidence: write_evidence_manifest( command="pdd detect --stories", diff --git a/pdd/get_jwt_token.py b/pdd/get_jwt_token.py index 6783c489a1..3979b3deae 100644 --- a/pdd/get_jwt_token.py +++ b/pdd/get_jwt_token.py @@ -53,15 +53,19 @@ 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. + where no one can enter the verification code. A non-TTY stdin is also + treated as non-interactive unless the caller explicitly opts in with + PDD_ALLOW_INTERACTIVE. """ truthy = ("1", "true", "yes") if os.environ.get("PDD_NO_INTERACTIVE", "").lower() in truthy: return True if os.environ.get("CI", "").lower() in truthy: return True + if os.environ.get("PDD_ALLOW_INTERACTIVE", "").lower() in truthy: + return False + if not sys.stdin.isatty(): + return True return False diff --git a/pdd/llm_invoke.py b/pdd/llm_invoke.py index 5434a159c0..04c59adcae 100644 --- a/pdd/llm_invoke.py +++ b/pdd/llm_invoke.py @@ -3102,7 +3102,14 @@ 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()) + 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 + try: + return sys.stdin.isatty() + except Exception: + return False def _vertex_project_value() -> Optional[str]: diff --git a/pdd/user_story_tests.py b/pdd/user_story_tests.py index 21f4cd84f9..0c655e5994 100644 --- a/pdd/user_story_tests.py +++ b/pdd/user_story_tests.py @@ -1539,14 +1539,36 @@ 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, - ) + 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 = ( + "Fatal story validation error: " + f"{exc}. In non-interactive CI/agent runs, 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: + rprint(f"[bold]FAIL[/bold] {story_path}") + rprint(f"[red]{error_message}[/red]") + if fail_fast: + break + continue 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..b765a1252c 100644 --- a/tests/commands/test_analysis.py +++ b/tests/commands/test_analysis.py @@ -94,6 +94,24 @@ def test_detect_stories_options(runner, mock_context_obj): assert kwargs["cache_story_prompt_links"] is True +def test_detect_stories_non_tty_disables_interactive_auth(runner, mock_context_obj, monkeypatch): + """Non-TTY story validation must not allow browser/device auth to start.""" + monkeypatch.delenv("PDD_NO_INTERACTIVE", raising=False) + monkeypatch.delenv("PDD_ALLOW_INTERACTIVE", raising=False) + observed = {} + + def fake_runner(**_kwargs): + observed["pdd_no_interactive"] = 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 + assert observed["pdd_no_interactive"] == "1" + 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/test_user_story_tests.py b/tests/test_user_story_tests.py index 98ce897a82..92a74d32ba 100644 --- a/tests/test_user_story_tests.py +++ b/tests/test_user_story_tests.py @@ -121,6 +121,33 @@ 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 "Fatal story validation error" in results[0]["error"] + assert "PDD_JWT_TOKEN" in results[0]["error"] + assert "device login" in results[0]["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() From 1c8e17b76f8bd4ed948464eab81eb4c654ec43f8 Mon Sep 17 00:00:00 2001 From: niti-go Date: Thu, 9 Jul 2026 10:55:45 -0700 Subject: [PATCH 2/7] fix(detect --stories): scope non-interactive auth in prompt space Re-align the issue #1923 fix with the prompt-first source of truth and keep it scoped to story validation instead of globally changing device-flow auth. - Prompts (source of truth) now describe the behavior: analysis (scope PDD_NO_INTERACTIVE around story validation when non-TTY), llm_invoke (_interactive_credential_acquisition_allowed honors PDD_NO_INTERACTIVE), and user_story_tests (fail closed with credential guidance on validation errors). - Revert the global _is_noninteractive() isatty change: it broke 'pdd auth login' with piped stdin and the existing non-interactive-auth test. The scoped PDD_NO_INTERACTIVE env set by 'detect --stories' is sufficient. - Add tests/test_issue_1923_detect_stories_noninteractive.py: end-to-end regression that drives the real command -> real cloud-auth guard and asserts GitHub device-flow is never started in a non-TTY run (red on main, green now). Co-Authored-By: Claude Opus 4.8 --- pdd/get_jwt_token.py | 10 +- pdd/llm_invoke.py | 13 +- pdd/prompts/commands/analysis_python.prompt | 1 + pdd/prompts/llm_invoke_python.prompt | 2 +- pdd/prompts/user_story_tests_python.prompt | 1 + ...ssue_1923_detect_stories_noninteractive.py | 195 ++++++++++++++++++ 6 files changed, 209 insertions(+), 13 deletions(-) create mode 100644 tests/test_issue_1923_detect_stories_noninteractive.py diff --git a/pdd/get_jwt_token.py b/pdd/get_jwt_token.py index 3979b3deae..6783c489a1 100644 --- a/pdd/get_jwt_token.py +++ b/pdd/get_jwt_token.py @@ -53,19 +53,15 @@ 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. A non-TTY stdin is also - treated as non-interactive unless the caller explicitly opts in with - PDD_ALLOW_INTERACTIVE. + 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. """ truthy = ("1", "true", "yes") if os.environ.get("PDD_NO_INTERACTIVE", "").lower() in truthy: return True if os.environ.get("CI", "").lower() in truthy: return True - if os.environ.get("PDD_ALLOW_INTERACTIVE", "").lower() in truthy: - return False - if not sys.stdin.isatty(): - return True return False diff --git a/pdd/llm_invoke.py b/pdd/llm_invoke.py index 04c59adcae..c4fb1b6ec4 100644 --- a/pdd/llm_invoke.py +++ b/pdd/llm_invoke.py @@ -3101,15 +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.""" + """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 - try: - return sys.stdin.isatty() - except Exception: - 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..28dfbc9b2a 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 story validation (issue #1923):** `pdd detect --stories` must be safe to run from agents/CI without blocking on a GitHub device-login prompt. Provide a helper `_story_detection_noninteractive()` that decides whether story validation may prompt a human: return `False` when `PDD_ALLOW_INTERACTIVE` is truthy (explicit opt-in), `True` when `PDD_NO_INTERACTIVE` is truthy, otherwise `True` when `sys.stdin` is not a TTY (the default for agent/CI shells; treat any error reading `isatty()` as non-interactive). When it returns `True` and `PDD_NO_INTERACTIVE` is not already set in the environment, set `os.environ["PDD_NO_INTERACTIVE"] = "1"` only for the duration of the `run_user_story_tests(...)` call and restore/unset it in a `finally` block. This scopes non-interactivity to story validation; it must NOT change global auth behavior, so an explicit `pdd auth login` (even with piped stdin) is unaffected. The downstream device-flow guard (`get_jwt_token`) and interactive API-key acquisition (`llm_invoke`) both honor `PDD_NO_INTERACTIVE`, so no browser/device-login flow starts and missing credentials fail closed with actionable guidance instead of waiting for a device code. - 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/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 08cacc09dd..63f8cfef88 100644 --- a/pdd/prompts/user_story_tests_python.prompt +++ b/pdd/prompts/user_story_tests_python.prompt @@ -265,6 +265,7 @@ 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. + - **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 states 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. 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/tests/test_issue_1923_detect_stories_noninteractive.py b/tests/test_issue_1923_detect_stories_noninteractive.py new file mode 100644 index 0000000000..a257533ffb --- /dev/null +++ b/tests/test_issue_1923_detect_stories_noninteractive.py @@ -0,0 +1,195 @@ +"""Regression tests for issue #1923. + +`pdd detect --stories` must not block on an interactive GitHub device-login +prompt when run from an agent/CI-style non-interactive shell. Historically the +story-validation LLM path reached cloud authentication and printed a GitHub +device URL/code, then waited forever for a human to complete the browser flow. + +These tests reproduce the exact auth/device-login decision point: + + detect --stories -> _story_detection_noninteractive() scopes + PDD_NO_INTERACTIVE -> run_user_story_tests -> (real) cloud auth + CloudConfig.get_jwt_token -> get_jwt_token._is_noninteractive() guard + -> DeviceFlow is NEVER instantiated. + +The story/LLM machinery is stubbed (out of scope for the auth bug and to avoid +real model cost), but the env-scoping in the command and the REAL device-flow +guard are exercised end to end. ``DeviceFlow`` is trapped so any attempt to +start device authentication fails the test loudly. + +Red/green: on ``main`` the command does not scope PDD_NO_INTERACTIVE, so the +real guard reaches the device-flow branch and the trap fires (fails). With the +fix the guard refuses device flow, cloud auth returns ``None``, story +validation fails closed, and the command exits non-zero. +""" +from __future__ import annotations + +import os + +import pytest +from click.testing import CliRunner + +from pdd import get_jwt_token as gjt_module +from pdd.core import cloud +from pdd.core.cloud import ( + CloudConfig, + FIREBASE_API_KEY_ENV, + GITHUB_CLIENT_ID_ENV, + PDD_JWT_TOKEN_ENV, +) +from pdd.commands.analysis import detect_change + + +@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 _trap_device_flow(monkeypatch): + """Make the credential caches miss and trap DeviceFlow instantiation. + + Returns a mutable dict whose ``started`` flag flips True (and raises) the + moment anything tries to begin GitHub device authentication. + """ + started = {"value": 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, + ) + + class _TrapDeviceFlow: + def __init__(self, *_args, **_kwargs): + started["value"] = True + raise AssertionError( + "device-flow authentication must not start for " + "`pdd detect --stories` in a non-interactive shell" + ) + + monkeypatch.setattr(gjt_module, "DeviceFlow", _TrapDeviceFlow) + return started + + +def test_detect_stories_non_tty_never_starts_device_flow(monkeypatch): + """Exact #1923 decision point: no device auth in a non-TTY story run. + + CliRunner drives the command with a non-TTY stdin. Cloud is enabled (real + FIREBASE_API_KEY + GITHUB_CLIENT_ID) with no cached/keyring credentials, so + only the PDD_NO_INTERACTIVE scoping set by the command keeps the real auth + path from starting device flow. + """ + monkeypatch.setenv(FIREBASE_API_KEY_ENV, "fake_firebase_key") + monkeypatch.setenv(GITHUB_CLIENT_ID_ENV, "fake_github_client_id") + started = _trap_device_flow(monkeypatch) + + captured = {} + + def fake_story_runner(**_kwargs): + # Runs INSIDE the command's PDD_NO_INTERACTIVE scope. Exercise the REAL + # cloud-auth path the story LLM call would trigger. + captured["no_interactive"] = os.environ.get("PDD_NO_INTERACTIVE") + token = CloudConfig.get_jwt_token(verbose=False) + captured["token"] = token + if token is None: + # Mirror user_story_tests' fail-closed behavior on missing creds. + return ( + False, + [{ + "story": "story__example.md", + "passed": False, + "changes": [], + "error": ( + "Fatal story validation error: cloud auth unavailable. " + "Set PDD_JWT_TOKEN or run `pdd auth login`; no device " + "login is started in non-interactive runs." + ), + }], + 0.0, + "", + ) + return True, [], 0.0, "gpt-test" + + monkeypatch.setattr( + "pdd.commands.analysis.run_user_story_tests", fake_story_runner + ) + + runner = CliRunner() + result = runner.invoke( + detect_change, ["--stories"], obj={"verbose": False, "quiet": True} + ) + + # The core assertion: device authentication was never started. + assert started["value"] is False + # The command scoped non-interactivity around story validation... + assert captured["no_interactive"] == "1" + # ...so the real guard refused device flow and cloud auth returned None... + assert captured["token"] is None + # ...and the command failed closed with a non-zero exit (issue #1872 too). + assert result.exit_code != 0 + # The scoped env var is restored after the command completes. + assert os.environ.get("PDD_NO_INTERACTIVE") is None + + +def test_detect_stories_explicit_interactive_opt_in_allows_auth(monkeypatch): + """PDD_ALLOW_INTERACTIVE opts back in: the command must NOT scope + PDD_NO_INTERACTIVE, so an interactive user can still authenticate.""" + monkeypatch.setenv("PDD_ALLOW_INTERACTIVE", "1") + + captured = {} + + def fake_story_runner(**_kwargs): + captured["no_interactive"] = os.environ.get("PDD_NO_INTERACTIVE") + return True, [], 0.0, "gpt-test" + + monkeypatch.setattr( + "pdd.commands.analysis.run_user_story_tests", fake_story_runner + ) + + runner = CliRunner() + result = runner.invoke( + detect_change, ["--stories"], obj={"verbose": False, "quiet": True} + ) + + assert result.exit_code == 0 + # Explicit opt-in => command did not force non-interactive mode. + assert captured["no_interactive"] is None + + +def test_detect_stories_preserves_preexisting_no_interactive(monkeypatch): + """A caller-provided PDD_NO_INTERACTIVE must be preserved (not popped) by + the command's scoping so outer non-interactive contexts stay intact.""" + monkeypatch.setenv("PDD_NO_INTERACTIVE", "1") + + def fake_story_runner(**_kwargs): + return True, [], 0.0, "gpt-test" + + monkeypatch.setattr( + "pdd.commands.analysis.run_user_story_tests", fake_story_runner + ) + + runner = CliRunner() + result = runner.invoke( + detect_change, ["--stories"], obj={"verbose": False, "quiet": True} + ) + + assert result.exit_code == 0 + # The command must not clobber a pre-existing value on exit. + assert os.environ.get("PDD_NO_INTERACTIVE") == "1" From 45aaaa8d63cc588f48ee14134ab9f74430d22013 Mon Sep 17 00:00:00 2001 From: niti-go Date: Thu, 9 Jul 2026 11:19:12 -0700 Subject: [PATCH 3/7] fix(story-validation): move non-interactive auth guard to shared choke point Address adversarial review of the #1923 fix: - Move the non-interactive scoping from the detect command into run_user_story_tests so ALL callers (detect --stories, change, agentic change, drift) are protected, not just detect. Force PDD_NO_INTERACTIVE=1 around each auth-sensitive detect_change call and restore the caller's prior value, closing the gap where a falsy preset (PDD_NO_INTERACTIVE=""/"0") left the downstream truthiness guard unset and reopened the device-login hang. - Unify the non-interactive truthy set to 1/true/yes/on across the guards (get_jwt_token._is_noninteractive now includes 'on'). - Make the fail-closed story error conditional ("If this is an auth/credential error ...") so genuine bugs are not misattributed to missing credentials. - Sync prompts (analysis, get_jwt_token, user_story_tests) with the above. - Rewrite the #1923 regression tests to drive the REAL run_user_story_tests and real get_jwt_token guard (device-flow trapped), covering falsy/on presets, the opt-in and real-TTY cases, and the end-to-end no-device-flow path. - docs: soften the unconditional claim and document PDD_ALLOW_INTERACTIVE. Co-Authored-By: Claude Opus 4.8 --- docs/generating_user_stories.md | 18 +- pdd/commands/analysis.py | 50 +-- pdd/get_jwt_token.py | 6 +- pdd/prompts/commands/analysis_python.prompt | 2 +- pdd/prompts/get_jwt_token_python.prompt | 2 +- pdd/prompts/user_story_tests_python.prompt | 3 +- pdd/user_story_tests.py | 55 ++- tests/commands/test_analysis.py | 19 +- ...ssue_1923_detect_stories_noninteractive.py | 314 +++++++++++------- tests/test_user_story_tests.py | 11 +- 10 files changed, 295 insertions(+), 185 deletions(-) diff --git a/docs/generating_user_stories.md b/docs/generating_user_stories.md index f5ebcb6c6a..ee8749baa0 100644 --- a/docs/generating_user_stories.md +++ b/docs/generating_user_stories.md @@ -311,13 +311,17 @@ 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, `pdd detect --stories` runs in non-interactive mode -when stdin is not a TTY. It will not launch GitHub browser/device -authentication. 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 -fatal error instead of waiting for a device-login code. +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 788e41677d..4f317ed3a3 100644 --- a/pdd/commands/analysis.py +++ b/pdd/commands/analysis.py @@ -5,7 +5,6 @@ """ import os import re -import sys import click from typing import Optional, Tuple, List, Dict, Any @@ -42,19 +41,6 @@ def _mark_command_failed(ctx: click.Context) -> None: ctx.obj["_command_failed"] = True -def _story_detection_noninteractive() -> bool: - """Return True when story validation must not start browser/device auth.""" - 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 - try: - return not sys.stdin.isatty() - except Exception: - return True - - @click.command("detect") @click.argument("files", nargs=-1, type=click.Path(exists=True, dir_okay=False)) @click.option( @@ -126,29 +112,21 @@ def detect_change( raise click.UsageError("--output is not supported with --stories.") obj = get_context_obj(ctx) - previous_no_interactive = os.environ.get("PDD_NO_INTERACTIVE") - set_no_interactive = ( - _story_detection_noninteractive() - and previous_no_interactive is None + # 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, + strength=obj.get("strength", 0.2), + temperature=obj.get("temperature", 0.0), + time=obj.get("time", 0.25), + verbose=obj.get("verbose", False), + quiet=obj.get("quiet", False), + fail_fast=fail_fast, + include_llm_prompts=include_llm, + cache_story_prompt_links=True, ) - if set_no_interactive: - os.environ["PDD_NO_INTERACTIVE"] = "1" - try: - passed, results, total_cost, model_name = run_user_story_tests( - prompts_dir=prompts_dir, - stories_dir=stories_dir, - strength=obj.get("strength", 0.2), - temperature=obj.get("temperature", 0.0), - time=obj.get("time", 0.25), - verbose=obj.get("verbose", False), - quiet=obj.get("quiet", False), - fail_fast=fail_fast, - include_llm_prompts=include_llm, - cache_story_prompt_links=True, - ) - finally: - if set_no_interactive: - os.environ.pop("PDD_NO_INTERACTIVE", None) if evidence: write_evidence_manifest( command="pdd detect --stories", diff --git a/pdd/get_jwt_token.py b/pdd/get_jwt_token.py index 6783c489a1..ad63ea9024 100644 --- a/pdd/get_jwt_token.py +++ b/pdd/get_jwt_token.py @@ -55,9 +55,11 @@ def _is_noninteractive() -> bool: 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. + 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") + truthy = ("1", "true", "yes", "on") if os.environ.get("PDD_NO_INTERACTIVE", "").lower() in truthy: return True if os.environ.get("CI", "").lower() in truthy: diff --git a/pdd/prompts/commands/analysis_python.prompt b/pdd/prompts/commands/analysis_python.prompt index 28dfbc9b2a..df427331f1 100644 --- a/pdd/prompts/commands/analysis_python.prompt +++ b/pdd/prompts/commands/analysis_python.prompt @@ -20,7 +20,7 @@ - `include_llm_prompts=include_llm` - `fail_fast=fail_fast` - Return result payload as `{"passed": passed, "results": results}` plus cost/model. - - **Non-interactive story validation (issue #1923):** `pdd detect --stories` must be safe to run from agents/CI without blocking on a GitHub device-login prompt. Provide a helper `_story_detection_noninteractive()` that decides whether story validation may prompt a human: return `False` when `PDD_ALLOW_INTERACTIVE` is truthy (explicit opt-in), `True` when `PDD_NO_INTERACTIVE` is truthy, otherwise `True` when `sys.stdin` is not a TTY (the default for agent/CI shells; treat any error reading `isatty()` as non-interactive). When it returns `True` and `PDD_NO_INTERACTIVE` is not already set in the environment, set `os.environ["PDD_NO_INTERACTIVE"] = "1"` only for the duration of the `run_user_story_tests(...)` call and restore/unset it in a `finally` block. This scopes non-interactivity to story validation; it must NOT change global auth behavior, so an explicit `pdd auth login` (even with piped stdin) is unaffected. The downstream device-flow guard (`get_jwt_token`) and interactive API-key acquisition (`llm_invoke`) both honor `PDD_NO_INTERACTIVE`, so no browser/device-login flow starts and missing credentials fail closed with actionable guidance instead of waiting for a device code. + - 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/get_jwt_token_python.prompt b/pdd/prompts/get_jwt_token_python.prompt index 51fe2da2c0..f5ccb8e8ff 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()` (returns True when `PDD_NO_INTERACTIVE` or `CI` is set to a truthy value — case-insensitive, one of `1`/`true`/`yes`/`on`; keep this truthy set consistent with the other non-interactive guards in the codebase). 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. 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. 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/user_story_tests_python.prompt b/pdd/prompts/user_story_tests_python.prompt index 63f8cfef88..dbf9106edf 100644 --- a/pdd/prompts/user_story_tests_python.prompt +++ b/pdd/prompts/user_story_tests_python.prompt @@ -265,7 +265,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. - - **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 states 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. 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. + - **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 0c655e5994..62ea6e6f19 100644 --- a/pdd/user_story_tests.py +++ b/pdd/user_story_tests.py @@ -8,6 +8,7 @@ import os import re import subprocess +import sys import tempfile import warnings from pathlib import Path @@ -1445,6 +1446,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, @@ -1498,6 +1523,17 @@ 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. + 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) @@ -1539,6 +1575,9 @@ def run_user_story_tests( # pylint: disable=too-many-arguments,redefined-outer- break continue + 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], @@ -1551,11 +1590,11 @@ def run_user_story_tests( # pylint: disable=too-many-arguments,redefined-outer- except Exception as exc: # noqa: BLE001 - story validation must fail closed all_passed = False error_message = ( - "Fatal story validation error: " - f"{exc}. In non-interactive CI/agent runs, 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." + 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), @@ -1569,6 +1608,12 @@ def run_user_story_tests( # pylint: disable=too-many-arguments,redefined-outer- 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 b765a1252c..bb3dd6cafb 100644 --- a/tests/commands/test_analysis.py +++ b/tests/commands/test_analysis.py @@ -94,21 +94,32 @@ def test_detect_stories_options(runner, mock_context_obj): assert kwargs["cache_story_prompt_links"] is True -def test_detect_stories_non_tty_disables_interactive_auth(runner, mock_context_obj, monkeypatch): - """Non-TTY story validation must not allow browser/device auth to start.""" +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): - observed["pdd_no_interactive"] = os.environ.get("PDD_NO_INTERACTIVE") + # 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 - assert observed["pdd_no_interactive"] == "1" + # 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 diff --git a/tests/test_issue_1923_detect_stories_noninteractive.py b/tests/test_issue_1923_detect_stories_noninteractive.py index a257533ffb..d9020409a6 100644 --- a/tests/test_issue_1923_detect_stories_noninteractive.py +++ b/tests/test_issue_1923_detect_stories_noninteractive.py @@ -1,34 +1,35 @@ """Regression tests for issue #1923. -`pdd detect --stories` must not block on an interactive GitHub device-login -prompt when run from an agent/CI-style non-interactive shell. Historically the -story-validation LLM path reached cloud authentication and printed a GitHub -device URL/code, then waited forever for a human to complete the browser flow. - -These tests reproduce the exact auth/device-login decision point: - - detect --stories -> _story_detection_noninteractive() scopes - PDD_NO_INTERACTIVE -> run_user_story_tests -> (real) cloud auth - CloudConfig.get_jwt_token -> get_jwt_token._is_noninteractive() guard - -> DeviceFlow is NEVER instantiated. - -The story/LLM machinery is stubbed (out of scope for the auth bug and to avoid -real model cost), but the env-scoping in the command and the REAL device-flow -guard are exercised end to end. ``DeviceFlow`` is trapped so any attempt to -start device authentication fails the test loudly. - -Red/green: on ``main`` the command does not scope PDD_NO_INTERACTIVE, so the -real guard reaches the device-flow branch and the trap fires (fails). With the -fix the guard refuses device flow, cloud auth returns ``None``, story -validation fails closed, and the command exits non-zero. +`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 os import pytest -from click.testing import CliRunner +import pdd.user_story_tests as ust from pdd import get_jwt_token as gjt_module from pdd.core import cloud from pdd.core.cloud import ( @@ -37,7 +38,17 @@ GITHUB_CLIENT_ID_ENV, PDD_JWT_TOKEN_ENV, ) -from pdd.commands.analysis import detect_change + + +PROVIDER_KEY_ENVS = ( + "ANTHROPIC_API_KEY", + "OPENAI_API_KEY", + "GEMINI_API_KEY", + "GOOGLE_API_KEY", + "GROQ_API_KEY", + "MISTRAL_API_KEY", + "PDD_MODEL_DEFAULT", +) @pytest.fixture(autouse=True) @@ -58,138 +69,191 @@ def _isolate_auth_env(monkeypatch): monkeypatch.delenv(var, raising=False) -def _trap_device_flow(monkeypatch): - """Make the credential caches miss and trap DeviceFlow instantiation. +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) - Returns a mutable dict whose ``started`` flag flips True (and raises) the - moment anything tries to begin GitHub device authentication. - """ - started = {"value": 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, - ) +# ----------------------------------------------------------------------------- +# Scoping decision + env forcing (deterministic; covers ALL callers) +# ----------------------------------------------------------------------------- - class _TrapDeviceFlow: - def __init__(self, *_args, **_kwargs): - started["value"] = True - raise AssertionError( - "device-flow authentication must not start for " - "`pdd detect --stories` in a non-interactive shell" - ) +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) - monkeypatch.setattr(gjt_module, "DeviceFlow", _TrapDeviceFlow) - return started + captured = {} + def spy_detect_change(*_args, **_kwargs): + captured["during"] = os.environ.get("PDD_NO_INTERACTIVE") + return [], 0.0, "gpt-test" -def test_detect_stories_non_tty_never_starts_device_flow(monkeypatch): - """Exact #1923 decision point: no device auth in a non-TTY story run. + monkeypatch.setattr(ust, "detect_change", spy_detect_change) - CliRunner drives the command with a non-TTY stdin. Cloud is enabled (real - FIREBASE_API_KEY + GITHUB_CLIENT_ID) with no cached/keyring credentials, so - only the PDD_NO_INTERACTIVE scoping set by the command keeps the real auth - path from starting device flow. - """ - monkeypatch.setenv(FIREBASE_API_KEY_ENV, "fake_firebase_key") - monkeypatch.setenv(GITHUB_CLIENT_ID_ENV, "fake_github_client_id") - started = _trap_device_flow(monkeypatch) + 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 fake_story_runner(**_kwargs): - # Runs INSIDE the command's PDD_NO_INTERACTIVE scope. Exercise the REAL - # cloud-auth path the story LLM call would trigger. - captured["no_interactive"] = os.environ.get("PDD_NO_INTERACTIVE") - token = CloudConfig.get_jwt_token(verbose=False) - captured["token"] = token - if token is None: - # Mirror user_story_tests' fail-closed behavior on missing creds. - return ( - False, - [{ - "story": "story__example.md", - "passed": False, - "changes": [], - "error": ( - "Fatal story validation error: cloud auth unavailable. " - "Set PDD_JWT_TOKEN or run `pdd auth login`; no device " - "login is started in non-interactive runs." - ), - }], - 0.0, - "", - ) - return True, [], 0.0, "gpt-test" + def spy_detect_change(*_args, **_kwargs): + captured["during"] = os.environ.get("PDD_NO_INTERACTIVE") + return [], 0.0, "gpt-test" - monkeypatch.setattr( - "pdd.commands.analysis.run_user_story_tests", fake_story_runner - ) + monkeypatch.setattr(ust, "detect_change", spy_detect_change) - runner = CliRunner() - result = runner.invoke( - detect_change, ["--stories"], obj={"verbose": False, "quiet": True} - ) + ust.run_user_story_tests(prompts_dir=prompts_dir, stories_dir=stories_dir, quiet=True) - # The core assertion: device authentication was never started. - assert started["value"] is False - # The command scoped non-interactivity around story validation... - assert captured["no_interactive"] == "1" - # ...so the real guard refused device flow and cloud auth returned None... - assert captured["token"] is None - # ...and the command failed closed with a non-zero exit (issue #1872 too). - assert result.exit_code != 0 - # The scoped env var is restored after the command completes. - assert os.environ.get("PDD_NO_INTERACTIVE") is None + 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.delenv("PDD_ALLOW_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 -def test_detect_stories_explicit_interactive_opt_in_allows_auth(monkeypatch): - """PDD_ALLOW_INTERACTIVE opts back in: the command must NOT scope - PDD_NO_INTERACTIVE, so an interactive user can still authenticate.""" + # 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_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 fake_story_runner(**_kwargs): - captured["no_interactive"] = os.environ.get("PDD_NO_INTERACTIVE") - return True, [], 0.0, "gpt-test" + 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_story_validation_non_tty_never_starts_device_flow(tmp_path, monkeypatch): + """End-to-end: real run_user_story_tests -> real detect_change/llm_invoke + cloud auth -> real get_jwt_token guard. With cloud enabled and no cached + credentials in a non-TTY run, GitHub device authentication must never start. + + Red on main (env-only guard reaches the trapped DeviceFlow); green after the + fix (scoped PDD_NO_INTERACTIVE makes the guard refuse device flow, cloud auth + returns None, and validation fails closed). + """ + 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) + + # Force non-interactive decision deterministically (don't depend on runner TTY). + monkeypatch.setattr(ust, "_story_validation_noninteractive", lambda: True) + + # 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( - "pdd.commands.analysis.run_user_story_tests", fake_story_runner + gjt_module.FirebaseAuthenticator, "_get_stored_refresh_token", lambda self: None ) - runner = CliRunner() - result = runner.invoke( - detect_change, ["--stories"], obj={"verbose": False, "quiet": True} - ) + device_flow_started = {"value": False} - assert result.exit_code == 0 - # Explicit opt-in => command did not force non-interactive mode. - assert captured["no_interactive"] is None + class _TrapDeviceFlow: + def __init__(self, *_args, **_kwargs): + device_flow_started["value"] = True + raise AssertionError( + "device-flow authentication must not start for non-interactive " + "story validation" + ) + monkeypatch.setattr(gjt_module, "DeviceFlow", _TrapDeviceFlow) -def test_detect_stories_preserves_preexisting_no_interactive(monkeypatch): - """A caller-provided PDD_NO_INTERACTIVE must be preserved (not popped) by - the command's scoping so outer non-interactive contexts stay intact.""" - monkeypatch.setenv("PDD_NO_INTERACTIVE", "1") + # Defensive: ensure the local fallback can never make a real model call. + import pdd.llm_invoke as llm_invoke_module - def fake_story_runner(**_kwargs): - return True, [], 0.0, "gpt-test" + def _no_completion(*_args, **_kwargs): + raise RuntimeError("no network in tests") monkeypatch.setattr( - "pdd.commands.analysis.run_user_story_tests", fake_story_runner + llm_invoke_module.litellm, "completion", _no_completion, raising=False ) - runner = CliRunner() - result = runner.invoke( - detect_change, ["--stories"], obj={"verbose": False, "quiet": True} + passed, results, _cost, _model = ust.run_user_story_tests( + prompts_dir=prompts_dir, stories_dir=stories_dir, quiet=True ) - assert result.exit_code == 0 - # The command must not clobber a pre-existing value on exit. - assert os.environ.get("PDD_NO_INTERACTIVE") == "1" + # Core assertion: no device authentication was started. + assert device_flow_started["value"] is False + # And validation failed closed (missing creds) rather than passing/hanging. + assert passed is False + assert results and results[0]["passed"] is False + # 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 92a74d32ba..59a0391515 100644 --- a/tests/test_user_story_tests.py +++ b/tests/test_user_story_tests.py @@ -141,9 +141,14 @@ def test_user_story_tests_auth_failure_fails_closed(tmp_path): assert passed is False assert results[0]["passed"] is False - assert "Fatal story validation error" in results[0]["error"] - assert "PDD_JWT_TOKEN" in results[0]["error"] - assert "device login" in results[0]["error"] + # 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 == "" From d0381e0e9fd923d80a1a26ec39819aa6fb5ccb02 Mon Sep 17 00:00:00 2001 From: niti-go Date: Thu, 9 Jul 2026 11:34:04 -0700 Subject: [PATCH 4/7] harden: strip env whitespace, escape fail-closed markup, cover 'on' guard Round-2 review follow-ups (non-blocking hardening): - get_jwt_token._is_noninteractive: strip() env values for whitespace consistency with the other guards (prompt updated to match). - user_story_tests: escape rich markup in the fail-closed error print so a malformed exception string can't crash the fail-closed path. - Add a thread-safety note on the scoped os.environ mutation. - Tests: add a direct 'on'/whitespace assertion for the get_jwt_token guard and correct the e2e test docstring about the regression condition. Co-Authored-By: Claude Opus 4.8 --- pdd/get_jwt_token.py | 4 ++-- pdd/prompts/get_jwt_token_python.prompt | 2 +- pdd/user_story_tests.py | 14 ++++++++++--- ...ssue_1923_detect_stories_noninteractive.py | 21 ++++++++++++++++--- 4 files changed, 32 insertions(+), 9 deletions(-) diff --git a/pdd/get_jwt_token.py b/pdd/get_jwt_token.py index ad63ea9024..c17aaec38c 100644 --- a/pdd/get_jwt_token.py +++ b/pdd/get_jwt_token.py @@ -60,9 +60,9 @@ def _is_noninteractive() -> bool: auth-sensitive work instead of relying on TTY detection here. """ truthy = ("1", "true", "yes", "on") - if os.environ.get("PDD_NO_INTERACTIVE", "").lower() in truthy: + 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/prompts/get_jwt_token_python.prompt b/pdd/prompts/get_jwt_token_python.prompt index f5ccb8e8ff..20b106c08e 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 — case-insensitive, one of `1`/`true`/`yes`/`on`; keep this truthy set consistent with the other non-interactive guards in the codebase). 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. 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()` (returns True when `PDD_NO_INTERACTIVE` or `CI` is set to a truthy value — whitespace-stripped and case-insensitive, one of `1`/`true`/`yes`/`on`; keep this truthy set and the strip/lower normalization consistent with the other non-interactive guards in the codebase). 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. 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. 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/user_story_tests.py b/pdd/user_story_tests.py index 62ea6e6f19..5f4c533914 100644 --- a/pdd/user_story_tests.py +++ b/pdd/user_story_tests.py @@ -15,6 +15,7 @@ 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 @@ -1531,7 +1532,10 @@ def run_user_story_tests( # pylint: disable=too-many-arguments,redefined-outer- # 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 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: @@ -1603,8 +1607,12 @@ def run_user_story_tests( # pylint: disable=too-many-arguments,redefined-outer- "error": error_message, }) if not quiet: - rprint(f"[bold]FAIL[/bold] {story_path}") - rprint(f"[red]{error_message}[/red]") + # 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 diff --git a/tests/test_issue_1923_detect_stories_noninteractive.py b/tests/test_issue_1923_detect_stories_noninteractive.py index d9020409a6..3d007405ec 100644 --- a/tests/test_issue_1923_detect_stories_noninteractive.py +++ b/tests/test_issue_1923_detect_stories_noninteractive.py @@ -155,6 +155,18 @@ def test_story_validation_noninteractive_decision_matrix(monkeypatch): 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) @@ -200,9 +212,12 @@ def test_story_validation_non_tty_never_starts_device_flow(tmp_path, monkeypatch cloud auth -> real get_jwt_token guard. With cloud enabled and no cached credentials in a non-TTY run, GitHub device authentication must never start. - Red on main (env-only guard reaches the trapped DeviceFlow); green after the - fix (scoped PDD_NO_INTERACTIVE makes the guard refuse device flow, cloud auth - returns None, and validation fails closed). + This is a genuine regression guard: if the forcing of PDD_NO_INTERACTIVE is + removed from run_user_story_tests (reintroducing the bug), the env-only + device-flow guard is reached and the trapped ``DeviceFlow`` flips + ``device_flow_started`` to True, failing the assertion below. With the fix, + the guard refuses device flow, cloud auth returns None, the local fallback + also stays non-interactive, and validation fails closed. """ prompts_dir, stories_dir = _make_story_repo(tmp_path) From bf32c0846a5ebcd3555e7f12625d1f33705b30d0 Mon Sep 17 00:00:00 2001 From: Niti Goyal Date: Mon, 13 Jul 2026 08:11:47 -0700 Subject: [PATCH 5/7] fix(auth): preserve explicit story login opt-in --- .gitignore | 9 ++ .pdd/meta/commands_analysis_python.json | 22 ++++ .pdd/meta/commands_analysis_python_run.json | 11 ++ .pdd/meta/commands_auth_python.json | 20 +++ .pdd/meta/commands_auth_python_run.json | 11 ++ .pdd/meta/get_jwt_token_python.json | 16 +++ .pdd/meta/get_jwt_token_python_run.json | 11 ++ .pdd/meta/llm_invoke_python.json | 26 ++-- .pdd/meta/llm_invoke_python_run.json | 16 +-- .pdd/meta/user_story_tests_python.json | 15 +++ .pdd/meta/user_story_tests_python_run.json | 11 ++ architecture.json | 71 +++++++++++ pdd/commands/auth.py | 10 +- pdd/get_jwt_token.py | 9 +- pdd/prompts/commands/auth_python.prompt | 1 + pdd/prompts/get_jwt_token_python.prompt | 2 +- tests/commands/test_auth.py | 25 +++- tests/test_get_jwt_token.py | 14 ++- ...ssue_1923_detect_stories_noninteractive.py | 116 +++++++++++++----- 19 files changed, 352 insertions(+), 64 deletions(-) create mode 100644 .pdd/meta/commands_analysis_python.json create mode 100644 .pdd/meta/commands_analysis_python_run.json create mode 100644 .pdd/meta/commands_auth_python.json create mode 100644 .pdd/meta/commands_auth_python_run.json create mode 100644 .pdd/meta/get_jwt_token_python.json create mode 100644 .pdd/meta/get_jwt_token_python_run.json create mode 100644 .pdd/meta/user_story_tests_python.json create mode 100644 .pdd/meta/user_story_tests_python_run.json 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..50f6664a41 --- /dev/null +++ b/.pdd/meta/commands_analysis_python_run.json @@ -0,0 +1,11 @@ +{ + "timestamp": "2026-07-13T07:59:04.601734+00:00", + "exit_code": 0, + "tests_passed": 49, + "tests_failed": 0, + "coverage": 0.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..c029a5d0a0 --- /dev/null +++ b/.pdd/meta/commands_auth_python_run.json @@ -0,0 +1,11 @@ +{ + "timestamp": "2026-07-13T08:18:29.699271+00:00", + "exit_code": 0, + "tests_passed": 22, + "tests_failed": 0, + "coverage": 0.0, + "test_hash": "56d45f4b80dab36fdfac94fc4ae4b2011d02c0b5a30b2de538360a05fcda5454", + "test_files": { + "test_auth.py": "56d45f4b80dab36fdfac94fc4ae4b2011d02c0b5a30b2de538360a05fcda5454" + } +} \ No newline at end of file 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..607a91af4e --- /dev/null +++ b/.pdd/meta/get_jwt_token_python_run.json @@ -0,0 +1,11 @@ +{ + "timestamp": "2026-07-13T08:16:26.919016+00:00", + "exit_code": 0, + "tests_passed": 38, + "tests_failed": 0, + "coverage": 0.0, + "test_hash": "d14e6504e35c7fe88715e92ed113dc562ffa8a5608c692b83157317b9052316c", + "test_files": { + "test_get_jwt_token.py": "d14e6504e35c7fe88715e92ed113dc562ffa8a5608c692b83157317b9052316c" + } +} \ No newline at end of file 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..d305878c99 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-13T07:59:05.132035+00:00", "exit_code": 0, - "tests_passed": 375, + "tests_passed": 362, "tests_failed": 0, - "coverage": 90.0, - "test_hash": "1979352422b5ea80ee7f2ea14a8a2c20ab880d02fd1d2365d8dd54050d2f5f18", + "coverage": 0.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..dd0334e46b --- /dev/null +++ b/.pdd/meta/user_story_tests_python_run.json @@ -0,0 +1,11 @@ +{ + "timestamp": "2026-07-13T07:59:05.378711+00:00", + "exit_code": 0, + "tests_passed": 52, + "tests_failed": 0, + "coverage": 0.0, + "test_hash": "f44f319f030b32b0803967958100e1d1edaed7f1cde423bc2046957b5c88cdfd", + "test_files": { + "test_user_story_tests.py": "f44f319f030b32b0803967958100e1d1edaed7f1cde423bc2046957b5c88cdfd" + } +} diff --git a/architecture.json b/architecture.json index 0466fbced2..cdea737d19 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/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 c17aaec38c..06f2d698f9 100644 --- a/pdd/get_jwt_token.py +++ b/pdd/get_jwt_token.py @@ -52,14 +52,17 @@ 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 + 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", "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", "").strip().lower() in truthy: 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 20b106c08e..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 — whitespace-stripped and case-insensitive, one of `1`/`true`/`yes`/`on`; keep this truthy set and the strip/lower normalization consistent with the other non-interactive guards in the codebase). 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. 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/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 index 3d007405ec..5c83e5fc9a 100644 --- a/tests/test_issue_1923_detect_stories_noninteractive.py +++ b/tests/test_issue_1923_detect_stories_noninteractive.py @@ -25,15 +25,17 @@ """ 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 ( - CloudConfig, FIREBASE_API_KEY_ENV, GITHUB_CLIENT_ID_ENV, PDD_JWT_TOKEN_ENV, @@ -47,6 +49,15 @@ "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", ) @@ -143,7 +154,12 @@ def test_story_validation_noninteractive_decision_matrix(monkeypatch): 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) @@ -207,17 +223,12 @@ def spy_detect_change(*_args, **_kwargs): # Real device-flow guard, end to end (the exact #1923 decision point) # ----------------------------------------------------------------------------- -def test_story_validation_non_tty_never_starts_device_flow(tmp_path, monkeypatch): - """End-to-end: real run_user_story_tests -> real detect_change/llm_invoke - cloud auth -> real get_jwt_token guard. With cloud enabled and no cached - credentials in a non-TTY run, GitHub device authentication must never start. - - This is a genuine regression guard: if the forcing of PDD_NO_INTERACTIVE is - removed from run_user_story_tests (reintroducing the bug), the env-only - device-flow guard is reached and the trapped ``DeviceFlow`` flips - ``device_flow_started`` to True, failing the assertion below. With the fix, - the guard refuses device flow, cloud auth returns None, the local fallback - also stays non-interactive, and validation fails closed. +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) @@ -228,8 +239,10 @@ def test_story_validation_non_tty_never_starts_device_flow(tmp_path, monkeypatch for key in PROVIDER_KEY_ENVS: monkeypatch.delenv(key, raising=False) - # Force non-interactive decision deterministically (don't depend on runner TTY). - monkeypatch.setattr(ust, "_story_validation_noninteractive", lambda: True) + # 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. @@ -239,17 +252,26 @@ def test_story_validation_non_tty_never_starts_device_flow(tmp_path, monkeypatch gjt_module.FirebaseAuthenticator, "_get_stored_refresh_token", lambda self: None ) - device_flow_started = {"value": False} + device_flow_calls = {"request": 0, "poll": 0} - class _TrapDeviceFlow: - def __init__(self, *_args, **_kwargs): - device_flow_started["value"] = True - raise AssertionError( - "device-flow authentication must not start for non-interactive " - "story validation" - ) + 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" + ) - monkeypatch.setattr(gjt_module, "DeviceFlow", _TrapDeviceFlow) + 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 @@ -261,14 +283,48 @@ def _no_completion(*_args, **_kwargs): llm_invoke_module.litellm, "completion", _no_completion, raising=False ) - passed, results, _cost, _model = ust.run_user_story_tests( - prompts_dir=prompts_dir, stories_dir=stories_dir, quiet=True + 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, ) - # Core assertion: no device authentication was started. - assert device_flow_started["value"] is False - # And validation failed closed (missing creds) rather than passing/hanging. - assert passed is False - assert results and results[0]["passed"] is False + # 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 From 4e6b33bd91eb4513571b3914c245ade370c5dc4b Mon Sep 17 00:00:00 2001 From: Niti Goyal Date: Mon, 13 Jul 2026 09:06:27 -0700 Subject: [PATCH 6/7] chore(sync): register auth regression artifacts --- .pdd/sync-ownership.json | 54 +++++++++++++++++++++++++++++++++ .pdd/verification-profiles.json | 20 ++++++------ 2 files changed, 64 insertions(+), 10 deletions(-) diff --git a/.pdd/sync-ownership.json b/.pdd/sync-ownership.json index 14847c4a6e..e5b1182f6f 100644 --- a/.pdd/sync-ownership.json +++ b/.pdd/sync-ownership.json @@ -216,6 +216,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", @@ -312,6 +336,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", @@ -462,6 +498,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", @@ -12399,6 +12447,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 b9b9c0c939..acb50a4f7b 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" From f89185f8cbed405e71451f050926009f5305905f Mon Sep 17 00:00:00 2001 From: Niti Goyal Date: Mon, 13 Jul 2026 10:48:56 -0700 Subject: [PATCH 7/7] chore(sync): record measured auth coverage --- .pdd/meta/commands_analysis_python_run.json | 4 ++-- .pdd/meta/commands_auth_python_run.json | 8 ++++---- .pdd/meta/get_jwt_token_python_run.json | 6 +++--- .pdd/meta/llm_invoke_python_run.json | 4 ++-- .pdd/meta/user_story_tests_python_run.json | 4 ++-- 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.pdd/meta/commands_analysis_python_run.json b/.pdd/meta/commands_analysis_python_run.json index 50f6664a41..964033e974 100644 --- a/.pdd/meta/commands_analysis_python_run.json +++ b/.pdd/meta/commands_analysis_python_run.json @@ -1,9 +1,9 @@ { - "timestamp": "2026-07-13T07:59:04.601734+00:00", + "timestamp": "2026-07-13T17:43:55.846120+00:00", "exit_code": 0, "tests_passed": 49, "tests_failed": 0, - "coverage": 0.0, + "coverage": 88.0, "test_hash": "eba9c5a01ac883b6887ebf1e9117553ab5b0b94194d9d4e8fe4a24ded08bb280", "test_files": { "test_analysis.py": "eba9c5a01ac883b6887ebf1e9117553ab5b0b94194d9d4e8fe4a24ded08bb280" diff --git a/.pdd/meta/commands_auth_python_run.json b/.pdd/meta/commands_auth_python_run.json index c029a5d0a0..5f5369f549 100644 --- a/.pdd/meta/commands_auth_python_run.json +++ b/.pdd/meta/commands_auth_python_run.json @@ -1,11 +1,11 @@ { - "timestamp": "2026-07-13T08:18:29.699271+00:00", + "timestamp": "2026-07-13T17:44:03.595033+00:00", "exit_code": 0, - "tests_passed": 22, + "tests_passed": 23, "tests_failed": 0, - "coverage": 0.0, + "coverage": 72.0, "test_hash": "56d45f4b80dab36fdfac94fc4ae4b2011d02c0b5a30b2de538360a05fcda5454", "test_files": { "test_auth.py": "56d45f4b80dab36fdfac94fc4ae4b2011d02c0b5a30b2de538360a05fcda5454" } -} \ 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 index 607a91af4e..8688e77ee1 100644 --- a/.pdd/meta/get_jwt_token_python_run.json +++ b/.pdd/meta/get_jwt_token_python_run.json @@ -1,11 +1,11 @@ { - "timestamp": "2026-07-13T08:16:26.919016+00:00", + "timestamp": "2026-07-13T17:44:11.233398+00:00", "exit_code": 0, "tests_passed": 38, "tests_failed": 0, - "coverage": 0.0, + "coverage": 62.0, "test_hash": "d14e6504e35c7fe88715e92ed113dc562ffa8a5608c692b83157317b9052316c", "test_files": { "test_get_jwt_token.py": "d14e6504e35c7fe88715e92ed113dc562ffa8a5608c692b83157317b9052316c" } -} \ 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 d305878c99..9c72892dc3 100644 --- a/.pdd/meta/llm_invoke_python_run.json +++ b/.pdd/meta/llm_invoke_python_run.json @@ -1,9 +1,9 @@ { - "timestamp": "2026-07-13T07:59:05.132035+00:00", + "timestamp": "2026-07-13T17:44:40.602335+00:00", "exit_code": 0, "tests_passed": 362, "tests_failed": 0, - "coverage": 0.0, + "coverage": 71.0, "test_hash": "4aaae7e8dc472e0fb280e9d773dce17ae806ee959c232badc1fb922011df1fb9", "test_files": { "test_llm_invoke.py": "4aaae7e8dc472e0fb280e9d773dce17ae806ee959c232badc1fb922011df1fb9" diff --git a/.pdd/meta/user_story_tests_python_run.json b/.pdd/meta/user_story_tests_python_run.json index dd0334e46b..786dd0bb15 100644 --- a/.pdd/meta/user_story_tests_python_run.json +++ b/.pdd/meta/user_story_tests_python_run.json @@ -1,9 +1,9 @@ { - "timestamp": "2026-07-13T07:59:05.378711+00:00", + "timestamp": "2026-07-13T17:45:01.394903+00:00", "exit_code": 0, "tests_passed": 52, "tests_failed": 0, - "coverage": 0.0, + "coverage": 75.0, "test_hash": "f44f319f030b32b0803967958100e1d1edaed7f1cde423bc2046957b5c88cdfd", "test_files": { "test_user_story_tests.py": "f44f319f030b32b0803967958100e1d1edaed7f1cde423bc2046957b5c88cdfd"