Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,9 @@ jobs:
- name: CLI config and Docker planning tests
run: uv run pytest tests/unit/test_cli.py tests/unit/test_graph_manifest.py -q

- name: Minimum CLI dependency compatibility
run: uv run python scripts/test_minimum_cli_dependencies.py

embedded-seekdb-smoke:
name: Embedded SeekDB Smoke
runs-on: ubuntu-latest
Expand Down Expand Up @@ -135,6 +138,9 @@ jobs:
- name: CLI Docker smoke
run: make test-cli-docker

- name: Container environment boundary smoke
run: uv run python scripts/test_container_env_boundary.py

sample-graphs:
name: Sample Graphs
runs-on: ubuntu-latest
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ dependencies = [
"pymysql>=1.1.0",
"langchain>=0.3.9",
"mcp>=1.27.1,<2",
"python-dotenv>=1.0,<1.3",
"scalar-fastapi>=1.0.3",
]

Expand Down
285 changes: 285 additions & 0 deletions scripts/dotenv_conformance.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,285 @@
"""Shared dotenv interpolation cases for supported-version and handoff tests."""

from __future__ import annotations

import importlib.metadata
import io
import json
import os
import tempfile
from pathlib import Path

from dotenv.parser import parse_stream
from dotenv.variables import Variable, parse_variables

DOTENV_CONFORMANCE_CASES = (
{
"name": "missing",
"contents": "CONF_MISSING=prefix-${PR69_MISSING}-suffix\n",
"ambient": {},
"container_expected": {"CONF_MISSING": "prefix-${PR69_MISSING}-suffix"},
"container_absent": (),
},
{
"name": "duplicate-order",
"contents": (
"CONF_ORIGIN=https://first.example\n"
"CONF_ORDERED=${CONF_ORIGIN}/v1\n"
"CONF_ORIGIN=https://second.example\n"
),
"ambient": {},
"container_expected": {
"CONF_ORIGIN": "https://second.example",
"CONF_ORDERED": "https://first.example/v1",
},
"container_absent": (),
},
{
"name": "broad-names",
"contents": "A.B=dotted\n1LEADING=digit\nCONF_BROAD=${A.B}-${1LEADING}\n",
"ambient": {},
"container_expected": {"CONF_BROAD": "dotted-digit"},
"container_absent": (),
},
{
"name": "multiline-default",
"contents": 'CONF_MULTILINE="${PR69_MISSING:-first line\nsecond line}"\n',
"ambient": {},
"container_expected": {"CONF_MULTILINE": "first line\nsecond line"},
"container_absent": (),
},
{
"name": "bare-default",
"contents": "CONF_BARE=$PR69_MISSING\nCONF_DEFAULT=${PR69_MISSING:-fallback}\n",
"ambient": {},
"container_expected": {
"CONF_BARE": "$PR69_MISSING",
"CONF_DEFAULT": "fallback",
},
"container_absent": (),
},
{
"name": "empty-valueless",
"contents": (
"CONF_EMPTY=\n"
"CONF_VALUELESS\n"
"CONF_FROM_EMPTY=${CONF_EMPTY:-fallback}\n"
"CONF_FROM_VALUELESS=${CONF_VALUELESS:-fallback}\n"
),
"ambient": {},
"container_expected": {
"CONF_EMPTY": "",
"CONF_FROM_EMPTY": "",
"CONF_FROM_VALUELESS": "",
},
"container_absent": ("CONF_VALUELESS",),
},
{
"name": "allowed-disallowed-ambient",
"contents": (
"CONF_ALLOWED=${OPENAI_ALLOWED_SOURCE}\n"
"CONF_DISALLOWED=${PR69_DISALLOWED_SECRET}\n"
),
"ambient": {
"OPENAI_ALLOWED_SOURCE": "allowlisted-source",
"PR69_DISALLOWED_SECRET": "host-sensitive-value",
},
"container_expected": {
"CONF_ALLOWED": "allowlisted-source",
"CONF_DISALLOWED": "${PR69_DISALLOWED_SECRET}",
},
"container_absent": ("PR69_DISALLOWED_SECRET",),
},
)

CROSS_LAYER_CONFORMANCE_CASES = (
{
"name": "config-dotenv-reference",
"config_env": "./config.env",
"config_dotenv": "CONF_SOURCE=https://dotenv.example\n",
"cli_dotenv": "CONF_RESULT=${CONF_SOURCE}/v1\n",
"shell_env": {},
"expected": {
"CONF_SOURCE": "https://dotenv.example",
"CONF_RESULT": "https://dotenv.example/v1",
},
"absent": (),
},
{
"name": "config-mapping-reference",
"config_env": {"CONF_SOURCE": "https://mapping.example"},
"config_dotenv": None,
"cli_dotenv": "CONF_RESULT=${CONF_SOURCE}/v1\n",
"shell_env": {},
"expected": {
"CONF_SOURCE": "https://mapping.example",
"CONF_RESULT": "https://mapping.example/v1",
},
"absent": (),
},
{
"name": "final-allowlisted-shell-override",
"config_env": {"OPENAI_API_KEY": "from-config"},
"config_dotenv": None,
"cli_dotenv": "CONF_RESULT=${OPENAI_API_KEY}\n",
"shell_env": {"OPENAI_API_KEY": "from-shell"},
"expected": {
"CONF_RESULT": "from-config",
"OPENAI_API_KEY": "from-shell",
},
"absent": (),
},
{
"name": "config-dotenv-valueless",
"config_env": "./config.env",
"config_dotenv": "CONF_TOMBSTONE=from-config-dotenv\n",
"cli_dotenv": "CONF_TOMBSTONE\nCONF_RESULT=${CONF_TOMBSTONE:-fallback}\n",
"shell_env": {},
"expected": {
"CONF_TOMBSTONE": "from-config-dotenv",
"CONF_RESULT": "",
},
"absent": (),
},
{
"name": "config-mapping-valueless",
"config_env": {"CONF_TOMBSTONE": "from-config-mapping"},
"config_dotenv": None,
"cli_dotenv": "CONF_TOMBSTONE\nCONF_RESULT=${CONF_TOMBSTONE:-fallback}\n",
"shell_env": {},
"expected": {
"CONF_TOMBSTONE": "from-config-mapping",
"CONF_RESULT": "",
},
"absent": (),
},
{
"name": "config-dotenv-valueless-does-not-mask-shell-for-next-file",
"config_env": "./config.env",
"config_dotenv": "OPENAI_API_KEY\n",
"cli_dotenv": "CONF_RESULT=${OPENAI_API_KEY}\n",
"shell_env": {"OPENAI_API_KEY": "from-shell"},
"expected": {
"OPENAI_API_KEY": "from-shell",
"CONF_RESULT": "from-shell",
},
"absent": (),
},
)

CONFORMANCE_AMBIENT_MODES = (
("clean", {}),
(
"hostile",
{
"OPENAI_API_KEY": "ambient-provider-key",
"OPENAI_ALLOWED_SOURCE": "ambient-allowed-source",
"PR69_MISSING": "ambient-missing-value",
"PR69_DISALLOWED_SECRET": "host-sensitive-value",
"A.B": "ambient-dotted-value",
"1LEADING": "ambient-digit-value",
},
),
)


def _dotenv_keys(contents: str) -> set[str]:
keys: set[str] = set()
for binding in parse_stream(io.StringIO(contents)):
if binding.key is not None:
keys.add(binding.key)
if binding.value is not None:
keys.update(atom.name for atom in parse_variables(binding.value) if isinstance(atom, Variable))
return keys


def _conformance_env_keys() -> frozenset[str]:
keys: set[str] = set()
for case in DOTENV_CONFORMANCE_CASES:
keys.update(_dotenv_keys(case["contents"]))
keys.update(case["ambient"])
keys.update(case["container_expected"])
keys.update(case["container_absent"])
for case in CROSS_LAYER_CONFORMANCE_CASES:
config_env = case["config_env"]
if isinstance(config_env, dict):
keys.update(config_env)
if case["config_dotenv"] is not None:
keys.update(_dotenv_keys(case["config_dotenv"]))
keys.update(_dotenv_keys(case["cli_dotenv"]))
keys.update(case["shell_env"])
keys.update(case["expected"])
keys.update(case["absent"])
return frozenset(keys)


DOTENV_CONFORMANCE_ENV_KEYS = _conformance_env_keys()


def assert_runtime_conformance(*, expected_dotenv_version: str | None = None) -> None:
"""Compare the runtime loader with the installed python-dotenv version."""
from dotenv import dotenv_values

from agentseek_api.cli import build_runtime_env

if expected_dotenv_version is not None:
assert importlib.metadata.version("python-dotenv") == expected_dotenv_version

previous = {key: os.environ.get(key) for key in DOTENV_CONFORMANCE_ENV_KEYS}
try:
with tempfile.TemporaryDirectory(prefix="agentseek-dotenv-") as directory:
root = Path(directory)
for mode_name, mode_ambient in CONFORMANCE_AMBIENT_MODES:
for index, case in enumerate(DOTENV_CONFORMANCE_CASES):
for key in DOTENV_CONFORMANCE_ENV_KEYS:
os.environ.pop(key, None)
os.environ.update(mode_ambient)
os.environ.update(case["ambient"])
env_file = root / f"{mode_name}-{index}.env"
env_file.write_text(case["contents"], encoding="utf-8")
upstream = dotenv_values(env_file)
expected = {key: value for key, value in upstream.items() if value is not None}
expected.update({key: os.environ[key] for key in upstream.keys() & os.environ.keys()})
actual = build_runtime_env(
config_path=None,
env_file=str(env_file),
cwd=root,
base_env=dict(os.environ),
)
assertion = f"{mode_name}/{case['name']}"
assert {key: actual[key] for key in expected} == expected, assertion
assert all(
key not in actual
for key, value in upstream.items()
if value is None and key not in os.environ
), assertion

for index, case in enumerate(CROSS_LAYER_CONFORMANCE_CASES):
for key in DOTENV_CONFORMANCE_ENV_KEYS:
os.environ.pop(key, None)
os.environ.update(mode_ambient)
os.environ.update(case["shell_env"])
config_path = root / f"cross-layer-{mode_name}-{index}.json"
config_path.write_text(
json.dumps({"graphs": {"chat": "chat.graph:graph"}, "env": case["config_env"]}),
encoding="utf-8",
)
if case["config_dotenv"] is not None:
(root / "config.env").write_text(case["config_dotenv"], encoding="utf-8")
env_file = root / f"cross-layer-{mode_name}-{index}.env"
env_file.write_text(case["cli_dotenv"], encoding="utf-8")
actual = build_runtime_env(
config_path=config_path,
env_file=str(env_file),
cwd=root,
base_env=dict(os.environ),
)
assertion = f"{mode_name}/{case['name']}"
assert {key: actual[key] for key in case["expected"]} == case["expected"], assertion
assert all(key not in actual for key in case["absent"]), assertion
finally:
for key, value in previous.items():
if value is None:
os.environ.pop(key, None)
else:
os.environ[key] = value
31 changes: 31 additions & 0 deletions scripts/test-cli-docker.sh
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,22 @@ IMAGE_TAG="${IMAGE_TAG:-agentseek-api-cli-smoke:latest}"
DB_CONTAINER="${DB_CONTAINER:-agentseek-cli-mysql}"
APP_CONTAINER="${APP_CONTAINER:-agentseek-up-8123}"
APP_CONTAINER_AUTOBUILD="${APP_CONTAINER_AUTOBUILD:-agentseek-up-8124}"
APP_CONTAINER_SECURITY="${APP_CONTAINER_SECURITY:-agentseek-up-8125}"
PG_CONTAINER="${PG_CONTAINER:-agentseek-cli-postgres}"
TMP_DIR="${TMP_DIR:-$ROOT_DIR/.tmp/cli-docker}"

cleanup() {
docker rm -f "$APP_CONTAINER" >/dev/null 2>&1 || true
docker rm -f "$APP_CONTAINER_AUTOBUILD" >/dev/null 2>&1 || true
docker rm -f "$APP_CONTAINER_SECURITY" >/dev/null 2>&1 || true
docker rm -f "$DB_CONTAINER" >/dev/null 2>&1 || true
docker rm -f "$PG_CONTAINER" >/dev/null 2>&1 || true
}

print_logs() {
docker logs "$APP_CONTAINER" || true
docker logs "$APP_CONTAINER_AUTOBUILD" || true
docker logs "$APP_CONTAINER_SECURITY" || true
docker logs "$DB_CONTAINER" || true
docker logs "$PG_CONTAINER" || true
}
Expand Down Expand Up @@ -116,6 +119,34 @@ if ! uv run python scripts/verify_docker_api.py --base-url http://127.0.0.1:8123
exit 1
fi

cat >"$TMP_DIR/disallowed.env" <<'EOF'
OPENAI_API_KEY=${PR69_DISALLOWED_SECRET}
EOF

if ! env -u OPENAI_API_KEY PR69_DISALLOWED_SECRET=host-sensitive-value uv run agentseek-api up \
--config "$CONFIG_PATH" \
--image "$IMAGE_TAG" \
--port 8125 \
--env-file "$TMP_DIR/disallowed.env" \
--recreate; then
print_logs
exit 1
fi

for _ in $(seq 1 60); do
if docker inspect "$APP_CONTAINER_SECURITY" --format '{{.State.Running}}' 2>/dev/null | grep -q true; then
break
fi
sleep 1
done

CONTAINER_SECRET="$(docker exec "$APP_CONTAINER_SECURITY" python -c 'import os; print(os.environ["OPENAI_API_KEY"])')"
if [[ "$CONTAINER_SECRET" != '${PR69_DISALLOWED_SECRET}' || "$CONTAINER_SECRET" == 'host-sensitive-value' ]]; then
print_logs
echo "Container environment expanded a host-only secret." >&2
exit 1
fi

DUPLICATE_STDERR="$TMP_DIR/up-duplicate.stderr"
set +e
uv run agentseek-api up \
Expand Down
Loading
Loading