Repository navigation
fix(env-loaders): strip matching surrounding quotes from .env values - #989
Conversation
## Why
Three independent .env loaders (`src/telegram-bridge.py`,
`skills/x-twitter/x-post.py`, `skills/image-generation/scripts/generate.py`)
parse .env lines with the same pattern:
for line in env_path.read_text().splitlines():
if "=" in line and not line.startswith("#"):
key, _, val = line.partition("=")
os.environ[key.strip()] = val.strip()
This handles unquoted lines correctly (`KEY=value`). It does NOT
handle quoted lines: `KEY="value"` -> `os.environ["KEY"]` becomes
`'"value"'` (with literal quotes). The quoted form is a common .env
convention.
Each loader's downstream consumer then uses the quoted string
verbatim:
- x-post.py: OAuth1 signature base string includes the quotes ->
X returns 401.
- telegram-bridge.py: URL becomes
`https://api.telegram.org/bot"abc"/getUpdates` -> Telegram
returns 404.
- generate.py: Gemini Authorization header gets a malformed key.
## Fix
Mirror python-dotenv's behavior: strip ONLY matching surrounding
quotes. A 3-line check after the existing `val.strip()`:
if len(val) >= 2 and val[0] == val[-1] and val[0] in ('"', "'"):
val = val[1:-1]
Handles every common .env shape:
- KEY="value" -> value
- KEY='value' -> value
- KEY=value -> value (unchanged)
- KEY="abc -> "abc (mismatched — left intact)
- KEY="a"b" -> a"b (outer pair stripped; inner preserved)
Alternative considered: extract to a shared `src/env_loader.py`.
Rejected for now because the call sites span `src/` (core) and
`skills/` (which by design avoid importing from `src/` for
self-containment). A shared module would require sys.path tweaks in
each skill that adopts it, undoing the skill-isolation contract.
## Tests
`tests/env-loader.test.py` — extended with the quote-strip invariant.
Greps each loader for the matching-quote-strip pattern (`val[0] ==
val[-1]` or `v[0] == v[-1]`). All 3 loaders pass against the new
check.
Pre-fix: structural grep of any of the 3 files would NOT match the
pattern -> test fails.
Post-fix: all 3 contain the pattern -> test passes.
Co-Authored-By: Vasiliy's Sutando <noreply@anthropic.com>
|
LGTM. The mismatched-quote behavior table is the part that makes this correct: The 3-line check ( The visible symptoms were upstream errors with zero surface clue (X 401, Telegram 404, Gemini malformed-auth-header) — exactly the kind of bug that wastes hours when it first hits. Worth landing. |
…onichi#989) ## Why Three independent .env loaders (`src/telegram-bridge.py`, `skills/x-twitter/x-post.py`, `skills/image-generation/scripts/generate.py`) parse .env lines with the same pattern: for line in env_path.read_text().splitlines(): if "=" in line and not line.startswith("#"): key, _, val = line.partition("=") os.environ[key.strip()] = val.strip() This handles unquoted lines correctly (`KEY=value`). It does NOT handle quoted lines: `KEY="value"` -> `os.environ["KEY"]` becomes `'"value"'` (with literal quotes). The quoted form is a common .env convention. Each loader's downstream consumer then uses the quoted string verbatim: - x-post.py: OAuth1 signature base string includes the quotes -> X returns 401. - telegram-bridge.py: URL becomes `https://api.telegram.org/bot"abc"/getUpdates` -> Telegram returns 404. - generate.py: Gemini Authorization header gets a malformed key. ## Fix Mirror python-dotenv's behavior: strip ONLY matching surrounding quotes. A 3-line check after the existing `val.strip()`: if len(val) >= 2 and val[0] == val[-1] and val[0] in ('"', "'"): val = val[1:-1] Handles every common .env shape: - KEY="value" -> value - KEY='value' -> value - KEY=value -> value (unchanged) - KEY="abc -> "abc (mismatched — left intact) - KEY="a"b" -> a"b (outer pair stripped; inner preserved) Alternative considered: extract to a shared `src/env_loader.py`. Rejected for now because the call sites span `src/` (core) and `skills/` (which by design avoid importing from `src/` for self-containment). A shared module would require sys.path tweaks in each skill that adopts it, undoing the skill-isolation contract. ## Tests `tests/env-loader.test.py` — extended with the quote-strip invariant. Greps each loader for the matching-quote-strip pattern (`val[0] == val[-1]` or `v[0] == v[-1]`). All 3 loaders pass against the new check. Pre-fix: structural grep of any of the 3 files would NOT match the pattern -> test fails. Post-fix: all 3 contain the pattern -> test passes. Co-authored-by: Vasiliy's Sutando <noreply@anthropic.com> Co-authored-by: Chi Wang <4250911+sonichi@users.noreply.github.com>
Why
Three independent .env loaders share the same naive
val.strip()-only pattern, so a properly-formed line likeX_API_KEY=\"abc\"ends up as'\"abc\"'(with literal quotes) inos.environ. Then:x-post.py: OAuth1 signs with the quoted key → X returns 401.telegram-bridge.py: URL becomeshttps://api.telegram.org/bot\"abc\"/getUpdates→ Telegram returns 404.generate.py: Gemini Authorization header gets a malformed key.Cryptic upstream errors with no surface clue that quoting was the cause. Quoted .env values are a standard convention (python-dotenv handles them); these naive loaders don't.
Fix
Mirror python-dotenv exactly: strip ONLY matching surrounding quotes. A 3-line check after the existing
val.strip():```python
if len(val) >= 2 and val[0] == val[-1] and val[0] in ('"', "'"):
val = val[1:-1]
```
Handles every common .env shape:
KEY=\"value\"valueKEY='value'valueKEY=valuevalue(unchanged)KEY=\"abc\"abc(mismatched — left intact)KEY=\"a\"b\"a\"b(outer pair stripped; inner preserved)Alternative considered: extract to a shared
src/env_loader.pymodule. Rejected for now because the call sites spansrc/(core) andskills/(which by design avoid importing fromsrc/for self-containment). The existing structural test catches drift between the 3 copies.Tests
tests/env-loader.test.py— extended with the quote-strip invariant. Greps each loader for a matching-quote-strip pattern (val[0] == val[-1]orv[0] == v[-1]). All 3 loaders pass against the new check.Pre-fix: a structural grep of any of the 3 files would NOT match → test fails.
Post-fix: all 3 contain the pattern → test passes.
Test plan
python3 tests/env-loader.test.py— all 3 loaders pass extended invariantX_API_KEY=\"abc\"in .env runspython3 skills/x-twitter/x-post.py search foo→ verify X returns results, not 401🤖 Generated with Claude Code