Skip to content

Commit ab9781e

Browse files
yjwyjw
authored andcommitted
fix: harden persisted auth and terminal editing
1 parent 77b9ba6 commit ab9781e

9 files changed

Lines changed: 145 additions & 16 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Changed
1111
- `young chat` now auto-uses the existing `reach` bridge for explicit finance/news lookup requests, but only feeds compact evidence excerpts to the model instead of exposing raw search captures in the terminal.
12-
- `young chat` input now uses a fixed `young ` prompt prefix so line editing no longer eats the visible prompt label.
12+
- `young chat` input now uses `prompt_toolkit` with a fixed, non-editable `young ` prompt prefix for reliable backspace and cursor movement on macOS terminals.
1313
- Help text now makes the command boundary clearer: `young daily --llm` is the strict stock-analysis M1-M6 Markdown replay, `young replay` is only a deprecated alias, and `young report` is PDF export only.
1414

1515
### Fixed
1616
- Fixed chat search flows that previously surfaced raw `/reach` output or still nudged users to run `/reach` manually instead of returning a summarized answer directly.
1717
- Hardened LLM replay prompts so `young daily --llm` stays on the stock-analysis six-module framework and does not drift into persona-style investment templates.
1818
- Fixed LLM config persistence so `young config llm --api-key-env ...` also stores a local fallback key, which keeps `young chat` working across fresh terminal sessions without forcing users to re-export the secret every time.
19+
- Fixed stale shell environment variables overriding the saved API key after terminal restarts; saved credentials now take precedence and accidental outer quotes or whitespace are normalized.
1920

2021
## [0.2.8] - 2026-06-19
2122

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ dependencies = [
2929
"requests>=2.31",
3030
"rich>=13.0",
3131
"click>=8.1",
32+
"prompt-toolkit>=3.0,<4",
3233
"weasyprint>=62",
3334
]
3435

src/young_stock/chat.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,10 @@
1717
from rich.console import Console
1818
from rich.markdown import Markdown
1919

20-
try: # ponytail: best-effort line editing on local terminals; falls back harmlessly if unavailable.
21-
import readline # noqa: F401
22-
except Exception: # pragma: no cover - platform dependent
23-
readline = None
20+
try: # pragma: no cover - optional dependency may be absent in some envs.
21+
from prompt_toolkit import prompt as _prompt_toolkit_prompt
22+
except Exception: # pragma: no cover - dependency missing or broken
23+
_prompt_toolkit_prompt = None
2424

2525
try: # pragma: no cover - import path differs across Python builds
2626
from zoneinfo import ZoneInfo
@@ -297,6 +297,17 @@ def _current_time_system_note() -> str:
297297
)
298298

299299

300+
def _read_chat_input(prompt_text: str = "young ") -> str:
301+
if callable(_prompt_toolkit_prompt):
302+
try:
303+
return _prompt_toolkit_prompt(prompt_text)
304+
except (EOFError, KeyboardInterrupt):
305+
raise
306+
except Exception:
307+
pass
308+
return builtins.input(prompt_text)
309+
310+
300311
def _is_time_query(text: str) -> bool:
301312
stripped = text.strip()
302313
if not stripped or stripped.startswith("/"):
@@ -788,7 +799,7 @@ def run_chat() -> None:
788799
)
789800
while True:
790801
try:
791-
text = builtins.input("young ").strip()
802+
text = _read_chat_input().strip()
792803
except (EOFError, KeyboardInterrupt):
793804
console.print("\n再见。")
794805
return

src/young_stock/config.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,13 @@ def config_path() -> Path:
2424
return young_home() / "config.json"
2525

2626

27+
def normalize_api_key(value: Any) -> str:
28+
text = str(value or "").strip()
29+
if len(text) >= 2 and text[0] == text[-1] and text[0] in {'"', "'"}:
30+
text = text[1:-1].strip()
31+
return text
32+
33+
2734
def _with_defaults(data: dict[str, Any] | None) -> dict[str, Any]:
2835
result = copy.deepcopy(DEFAULT_CONFIG)
2936
if not isinstance(data, dict):
@@ -91,10 +98,19 @@ def update_llm_config(**values: Any) -> dict[str, Any]:
9198
if env_name and values.get("api_key") is None:
9299
resolved = os.environ.get(env_name)
93100
if resolved:
94-
values["api_key"] = resolved
101+
values["api_key"] = normalize_api_key(resolved)
95102
for key, value in values.items():
96103
if value is not None:
97-
llm[key] = value
104+
if key == "api_key":
105+
normalized = normalize_api_key(value)
106+
if normalized:
107+
llm[key] = normalized
108+
elif key == "api_key_env":
109+
env_value = str(value).strip()
110+
if env_value:
111+
llm[key] = env_value
112+
else:
113+
llm[key] = value
98114
return save_config(config)
99115

100116

src/young_stock/llm.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99

1010
import requests
1111

12+
from .config import normalize_api_key
13+
1214
PROVIDER_BASES = {
1315
"openai": "https://api.openai.com/v1",
1416
"ark": "https://ark.cn-beijing.volces.com/api/v3",
@@ -84,10 +86,13 @@ def list_models(self) -> list[str]:
8486
return sorted(dict.fromkeys(models))
8587

8688
def _api_key(self) -> str:
87-
env_name = str(self.config.get("api_key_env") or "")
89+
saved = normalize_api_key(self.config.get("api_key"))
90+
if saved:
91+
return saved
92+
env_name = str(self.config.get("api_key_env") or "").strip()
8893
if env_name and os.environ.get(env_name):
89-
return str(os.environ[env_name])
90-
return str(self.config.get("api_key") or "")
94+
return normalize_api_key(os.environ[env_name])
95+
return ""
9196

9297
def _post(self, url: str, **kwargs: Any) -> Any:
9398
timeout = float(self.config.get("timeout") or 60)

tests/test_chat.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,7 @@ def test_run_chat_banner_shows_style_options(monkeypatch, tmp_path, capsys):
337337
def raise_eof(*args, **kwargs):
338338
raise EOFError
339339

340+
monkeypatch.setattr(chat_module, "_prompt_toolkit_prompt", None)
340341
monkeypatch.setattr("builtins.input", raise_eof)
341342

342343
run_chat()
@@ -349,14 +350,30 @@ def raise_eof(*args, **kwargs):
349350
assert "对话风格、自称口吻和分析框架" in captured
350351

351352

352-
def test_run_chat_uses_fixed_plain_prompt(monkeypatch, tmp_path):
353+
def test_run_chat_uses_prompt_toolkit_with_fixed_prompt(monkeypatch, tmp_path):
354+
monkeypatch.setenv("YOUNG_STOCK_HOME", str(tmp_path))
355+
prompts = []
356+
357+
def fake_prompt(prompt):
358+
prompts.append(prompt)
359+
raise EOFError
360+
361+
monkeypatch.setattr(chat_module, "_prompt_toolkit_prompt", fake_prompt)
362+
363+
run_chat()
364+
365+
assert prompts == ["young "]
366+
367+
368+
def test_run_chat_falls_back_to_builtins_input_when_prompt_toolkit_unavailable(monkeypatch, tmp_path):
353369
monkeypatch.setenv("YOUNG_STOCK_HOME", str(tmp_path))
354370
prompts = []
355371

356372
def fake_input(prompt):
357373
prompts.append(prompt)
358374
raise EOFError
359375

376+
monkeypatch.setattr(chat_module, "_prompt_toolkit_prompt", None)
360377
monkeypatch.setattr("builtins.input", fake_input)
361378

362379
run_chat()

tests/test_cli.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -725,6 +725,53 @@ def test_cli_config_llm_with_api_key_env_persists_fallback(monkeypatch, tmp_path
725725
assert config["llm"]["api_key"] == "env-secret"
726726

727727

728+
def test_cli_config_llm_uses_saved_key_after_env_changes(monkeypatch, tmp_path):
729+
from click.testing import CliRunner
730+
731+
from young_stock.llm import LLMClient
732+
733+
class FakeSession:
734+
def __init__(self, responses):
735+
self.responses = list(responses)
736+
self.calls = []
737+
738+
def post(self, url, **kwargs):
739+
self.calls.append((url, kwargs))
740+
return self.responses.pop(0)
741+
742+
def response(status, payload):
743+
return SimpleNamespace(status_code=status, json=lambda: payload, text=str(payload), headers={})
744+
745+
monkeypatch.setenv("YOUNG_STOCK_HOME", str(tmp_path))
746+
monkeypatch.setenv("MODEL_KEY", " 'saved-secret' ")
747+
runner = CliRunner()
748+
749+
saved = runner.invoke(
750+
cli,
751+
[
752+
"config",
753+
"llm",
754+
"--provider",
755+
"deepseek",
756+
"--model",
757+
"deepseek-chat",
758+
"--api-key-env",
759+
"MODEL_KEY",
760+
],
761+
)
762+
assert saved.exit_code == 0
763+
764+
monkeypatch.setenv("MODEL_KEY", "wrong-secret")
765+
client = LLMClient(
766+
json.loads((tmp_path / "config.json").read_text(encoding="utf-8"))["llm"],
767+
session=FakeSession([response(200, {"choices": [{"message": {"content": "ok"}}]})]),
768+
)
769+
770+
client.chat([{"role": "user", "content": "hi"}])
771+
772+
assert client.session.calls[0][1]["headers"]["Authorization"] == "Bearer saved-secret"
773+
774+
728775
def test_cli_config_channel_add_persists_app_delivery_fields(monkeypatch, tmp_path):
729776
from click.testing import CliRunner
730777

tests/test_config.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ def test_config_round_trip_uses_young_home(monkeypatch, tmp_path):
3030

3131
def test_config_persists_api_key_env_fallback(monkeypatch, tmp_path):
3232
monkeypatch.setenv("YOUNG_STOCK_HOME", str(tmp_path))
33-
monkeypatch.setenv("MODEL_KEY", "env-secret")
33+
monkeypatch.setenv("MODEL_KEY", ' "env-secret" ')
3434

3535
config = update_llm_config(
3636
provider="deepseek",
@@ -43,6 +43,20 @@ def test_config_persists_api_key_env_fallback(monkeypatch, tmp_path):
4343
assert load_config()["llm"]["api_key"] == "env-secret"
4444

4545

46+
def test_config_normalizes_direct_api_key_before_persisting(monkeypatch, tmp_path):
47+
monkeypatch.setenv("YOUNG_STOCK_HOME", str(tmp_path))
48+
49+
config = update_llm_config(
50+
provider="deepseek",
51+
model="deepseek-chat",
52+
api_key=' "secret-value" ',
53+
api_base="https://api.deepseek.com",
54+
)
55+
56+
assert config["llm"]["api_key"] == "secret-value"
57+
assert load_config()["llm"]["api_key"] == "secret-value"
58+
59+
4660
def test_config_masks_secrets_and_webhook_tokens():
4761
masked = mask_config(
4862
{

tests/test_llm.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,22 +88,22 @@ def test_anthropic_provider_maps_response():
8888
assert result.content == "谨慎看多"
8989

9090

91-
def test_api_key_env_takes_precedence(monkeypatch):
91+
def test_saved_api_key_takes_precedence_over_env(monkeypatch):
9292
monkeypatch.setenv("MODEL_KEY", "env-secret")
9393
session = FakeSession([response(200, {"choices": [{"message": {"content": "ok"}}]})])
9494
client = LLMClient(
9595
{
9696
"provider": "openai",
9797
"model": "gpt-test",
98-
"api_key": "inline-secret",
98+
"api_key": ' "saved-secret" ',
9999
"api_key_env": "MODEL_KEY",
100100
},
101101
session=session,
102102
)
103103

104104
client.chat([{"role": "user", "content": "hi"}])
105105

106-
assert session.calls[0][1]["headers"]["Authorization"] == "Bearer env-secret"
106+
assert session.calls[0][1]["headers"]["Authorization"] == "Bearer saved-secret"
107107

108108

109109
def test_api_key_falls_back_to_saved_secret_when_env_missing(monkeypatch):
@@ -124,6 +124,23 @@ def test_api_key_falls_back_to_saved_secret_when_env_missing(monkeypatch):
124124
assert session.calls[0][1]["headers"]["Authorization"] == "Bearer saved-secret"
125125

126126

127+
def test_api_key_env_falls_back_when_saved_secret_missing(monkeypatch):
128+
monkeypatch.setenv("MODEL_KEY", " 'env-secret' ")
129+
session = FakeSession([response(200, {"choices": [{"message": {"content": "ok"}}]})])
130+
client = LLMClient(
131+
{
132+
"provider": "openai",
133+
"model": "gpt-test",
134+
"api_key_env": "MODEL_KEY",
135+
},
136+
session=session,
137+
)
138+
139+
client.chat([{"role": "user", "content": "hi"}])
140+
141+
assert session.calls[0][1]["headers"]["Authorization"] == "Bearer env-secret"
142+
143+
127144
def test_openai_compatible_model_discovery():
128145
session = FakeSession(
129146
[

0 commit comments

Comments
 (0)