mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-28 22:20:35 +00:00
* fix(scripts): read the checkout .env as data in serve.sh
serve.sh sourced the root .env under set -a, so bash ran it as a script:
$$ became the shell's PID, backticks and $(...) ran commands, and an
unquoted value with a space ran its second word as a command (exit 127
under set -e, stopping make dev). The exports also beat the Gateway's
own load_dotenv(override=False), so e.g. a $$ in AUTH_JWT_SECRET changed
the signing secret on every launch.
Parse .env with a stdlib-only scripts/dotenv_exports.py on the host
Python and eval its shell-quoted export lines. It tokenizes like
python-dotenv, so values match what the Gateway reads, and keeps the
launcher's precedence over inherited variables and its $NAME /
${NAME:-x} / leading-~ expansion that support_bundle.py mirrors. $$ is
now one token in both. Unparseable lines and invalid names are skipped
with warnings that never echo values; a non-UTF-8 file stops the
launcher.
Tests run serve.sh's real .env block: no execution, parity with
python-dotenv, precedence, warnings, the non-UTF-8 stop and a stdlib-only
run; the support-bundle launcher cases now exercise the parser.
* docs(changelog): reference #7606 for serve.sh reading .env as data
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
147 lines
6.1 KiB
Python
147 lines
6.1 KiB
Python
"""serve.sh reads the checkout .env as data, never as a shell script.
|
|
|
|
It used to ``source`` the file under ``set -a``: ``$$`` became the shell's PID,
|
|
backticks and ``$(...)`` ran commands, and an unquoted value with a space ran
|
|
its second word as a command (exit 127 under ``set -e``, so ``make dev`` died).
|
|
The exported values then beat the Gateway's own python-dotenv read, which never
|
|
overrides an existing variable.
|
|
|
|
These tests run serve.sh's actual ``.env`` block (and the ``_pick_python`` it
|
|
calls) in bash against a scratch checkout, then read back what it exported.
|
|
The launcher's expansion rules and precedence are pinned against
|
|
``support_bundle.py`` in ``test_support_bundle.py``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from dotenv import dotenv_values
|
|
from support.shell import require_script_bash
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
SCRIPTS = REPO_ROOT / "scripts"
|
|
|
|
_DUMP_ENVIRONMENT = "\"$TEST_PYTHON\" -c 'import json, os, sys; sys.stdout.write(json.dumps(dict(os.environ)))'"
|
|
|
|
|
|
def _dotenv_block() -> str:
|
|
serve = (SCRIPTS / "serve.sh").read_text(encoding="utf-8")
|
|
pick_python = serve[serve.index("_pick_python() {") :].split("\n}\n", 1)[0] + "\n}\n"
|
|
block = serve.split("# ── Load .env", 1)[1].split("\n\n", 1)[1].split("# ── Argument parsing", 1)[0]
|
|
return pick_python + block
|
|
|
|
|
|
def _run_dotenv_block(project: Path, dotenv: bytes, **environment: str) -> subprocess.CompletedProcess[str]:
|
|
(project / "scripts").mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(SCRIPTS / "dotenv_exports.py", project / "scripts" / "dotenv_exports.py")
|
|
(project / ".env").write_bytes(dotenv)
|
|
env = {key: value for key, value in os.environ.items() if key not in {"BASH_ENV", "ENV"}}
|
|
env.update(environment, REPO_ROOT=str(project), TEST_PYTHON=Path(sys.executable).as_posix())
|
|
return subprocess.run(
|
|
[require_script_bash(), "-c", 'set -e\ncd "$REPO_ROOT"\n' + _dotenv_block() + _DUMP_ENVIRONMENT],
|
|
cwd=project,
|
|
env=env,
|
|
capture_output=True,
|
|
text=True,
|
|
encoding="utf-8",
|
|
check=False,
|
|
)
|
|
|
|
|
|
def _exported(project: Path, dotenv: bytes, **environment: str) -> dict[str, str]:
|
|
result = _run_dotenv_block(project, dotenv, **environment)
|
|
assert result.returncode == 0, result.stderr
|
|
return json.loads(result.stdout)
|
|
|
|
|
|
def test_dotenv_values_are_data_not_shell_code(tmp_path: Path) -> None:
|
|
dotenv = 'PASSWORD=pa$$word\nQUOTED_PASSWORD="pa$$word"\nBACKTICKS=`touch backtick-ran`kept\nSUBSTITUTION=$(touch substitution-ran)\nSPACED=hello world\nCHAINED=first; touch chained-ran\nAFTER=still-loaded\n'
|
|
|
|
exported = _exported(tmp_path, dotenv.encode("utf-8"))
|
|
|
|
assert exported["PASSWORD"] == "pa$$word"
|
|
assert exported["QUOTED_PASSWORD"] == "pa$$word"
|
|
assert exported["BACKTICKS"] == "`touch backtick-ran`kept"
|
|
assert exported["SUBSTITUTION"] == "$(touch substitution-ran)"
|
|
assert exported["SPACED"] == "hello world"
|
|
assert exported["CHAINED"] == "first; touch chained-ran"
|
|
assert exported["AFTER"] == "still-loaded"
|
|
assert sorted(path.name for path in tmp_path.glob("*-ran")) == []
|
|
|
|
|
|
def test_literal_values_match_what_the_gateway_reads(tmp_path: Path) -> None:
|
|
"""Without references or tildes, every value equals python-dotenv's reading."""
|
|
dotenv = (
|
|
"FIRST=after-bom\r\n"
|
|
"# a comment line\r\n"
|
|
"export EXPORTED=prefixed\r\n"
|
|
"INLINE=value # trailing comment\r\n"
|
|
"HASH=no#comment\r\n"
|
|
"SPACED_EQUALS = padded\r\n"
|
|
"EMPTY=\r\n"
|
|
"SINGLE='single \\'quoted\\' # not a comment'\r\n"
|
|
'DOUBLE="tab\\there \\"quoted\\""\r\n'
|
|
'MULTILINE="line one\r\nline two"\r\n'
|
|
"'QUOTED_KEY'=quoted key\r\n"
|
|
"UNICODE=café ✓\r\n"
|
|
"BARE_NAME\r\n"
|
|
)
|
|
path = tmp_path / "gateway.env"
|
|
path.write_text(dotenv, encoding="utf-8", newline="")
|
|
gateway = {key: value for key, value in dotenv_values(path, encoding="utf-8-sig").items() if value is not None}
|
|
|
|
exported = _exported(tmp_path, dotenv.encode("utf-8"))
|
|
|
|
assert {key: exported.get(key) for key in gateway} == gateway
|
|
assert "BARE_NAME" not in exported
|
|
|
|
|
|
def test_dotenv_still_overrides_inherited_variables(tmp_path: Path) -> None:
|
|
exported = _exported(tmp_path, b"FROM_SHELL=from-dotenv\nEMPTIED=\n", FROM_SHELL="from-shell", EMPTIED="from-shell")
|
|
|
|
assert exported["FROM_SHELL"] == "from-dotenv"
|
|
assert exported["EMPTIED"] == ""
|
|
|
|
|
|
def test_unparseable_statements_and_names_are_skipped_without_echoing_values(tmp_path: Path) -> None:
|
|
dotenv = b'BEFORE=kept\nNOT-A-NAME=secret-one\nBROKEN="unterminated secret-two\nAFTER=kept\n'
|
|
|
|
result = _run_dotenv_block(tmp_path, dotenv)
|
|
|
|
assert result.returncode == 0, result.stderr
|
|
exported = json.loads(result.stdout)
|
|
assert exported["BEFORE"] == "kept"
|
|
assert exported["AFTER"] == "kept"
|
|
assert "dotenv_exports: skipping 'NOT-A-NAME' at line 2: not a shell variable name" in result.stderr
|
|
assert "dotenv_exports: skipping a statement that does not parse at line 3" in result.stderr
|
|
assert "secret-one" not in result.stderr
|
|
assert "secret-two" not in result.stderr
|
|
|
|
|
|
def test_non_utf8_dotenv_stops_the_launcher_before_exporting(tmp_path: Path) -> None:
|
|
result = _run_dotenv_block(tmp_path, b"FIRST=kept\nLATIN1=caf\xe9\n")
|
|
|
|
assert result.returncode != 0
|
|
assert "is not valid UTF-8" in result.stderr
|
|
assert result.stdout == ""
|
|
|
|
|
|
@pytest.mark.parametrize("source", ["`touch {marker}`", "$(touch {marker})"])
|
|
def test_parser_runs_on_the_standard_library_and_never_executes(tmp_path: Path, source: str) -> None:
|
|
"""serve.sh runs it with the host Python, before ``uv sync`` installs anything."""
|
|
marker = tmp_path / "ran"
|
|
dotenv = tmp_path / ".env"
|
|
dotenv.write_text(f"VALUE={source.format(marker=marker.as_posix())}\n", encoding="utf-8")
|
|
|
|
result = subprocess.run([sys.executable, "-S", str(SCRIPTS / "dotenv_exports.py"), str(dotenv)], capture_output=True, check=True)
|
|
|
|
assert result.stdout.decode("utf-8") == f"export VALUE='{source.format(marker=marker.as_posix())}'\n"
|
|
assert not marker.exists()
|