diff --git a/console/README.md b/console/README.md index 8db0c0b0..b1273560 100644 --- a/console/README.md +++ b/console/README.md @@ -69,8 +69,10 @@ For another model provider, export its native credential variable and name it with `--api-key-env`; for example, use `--model anthropic/... --api-key-env ANTHROPIC_API_KEY`. The launcher does not rename one provider's credential into another provider's variable. The selected -name is shown in Settings and its value is removed from CLI, cloud CLI, -Terraform, Ansible, and helper subprocesses. +name is shown in Settings. Registered OpenRouter, Anthropic, and OpenAI +credentials, plus any custom selected provider credential, are removed from +dependency, build, CLI, cloud CLI, Terraform, Ansible, and helper subprocesses. +Only the selected provider credential is restored to the LLM backend. The state root and session/config directories are restricted to the console user (`0700`), and the SQLite database and sidecars use `0600`. Startup repairs modes diff --git a/console/SECURITY.md b/console/SECURITY.md index 6b448db2..8f74f3cc 100644 --- a/console/SECURITY.md +++ b/console/SECURITY.md @@ -82,8 +82,12 @@ The provider-native LLM API key selected by the launcher or entered in Settings is kept in the backend process environment and is not returned by the API or written to the console database. `OPENROUTER_API_KEY` remains the default for the default OpenRouter model; other providers retain their own variable names. -Every registered LLM credential is removed from the environment passed to CLI, -cloud CLI, Terraform, Ansible, and helper subprocesses. +The launcher explicitly registers the supported OpenRouter, Anthropic, and +OpenAI credential variables plus any custom selected provider variable, then +removes their values before dependency, build, and frontend helpers run. Only +the selected credential is restored to the LLM backend. Every registered LLM +credential is also removed from the environment passed to CLI, cloud CLI, +Terraform, Ansible, and other backend subprocesses. Cloud credentials are not collected or managed by the console. Provider tooling uses the ambient credentials available to the launcher through environment diff --git a/console/backend/subprocess_env.py b/console/backend/subprocess_env.py index fdb7b3dd..21a68691 100644 --- a/console/backend/subprocess_env.py +++ b/console/backend/subprocess_env.py @@ -13,6 +13,7 @@ DEFAULT_LLM_SECRET_ENV = "OPENROUTER_API_KEY" LLM_SECRET_ENV_SETTING = "DREADGOAD_CONSOLE_LLM_SECRET_ENV" +LLM_SECRET_ENVS_SETTING = "DREADGOAD_CONSOLE_LLM_SECRET_ENVS" # These names pass the Settings route's credential-shaped-name validation, but # they belong to infrastructure commands rather than the LLM. Treating one as @@ -20,25 +21,35 @@ _INFRASTRUCTURE_CREDENTIAL_ENV_NAMES = frozenset( { "AWS_ACCESS_KEY_ID", + "AWS_CONTAINER_AUTHORIZATION_TOKEN", "AWS_SECRET_ACCESS_KEY", "AWS_SECURITY_TOKEN", "AWS_SESSION_TOKEN", + "ARM_OIDC_TOKEN", + "LUDUS_API_KEY", } ) _configured_secret_env = ( os.environ.get(LLM_SECRET_ENV_SETTING, "").strip() or DEFAULT_LLM_SECRET_ENV ) -if _configured_secret_env in _INFRASTRUCTURE_CREDENTIAL_ENV_NAMES: - raise RuntimeError( - f"{LLM_SECRET_ENV_SETTING} cannot name infrastructure credential " - f"{_configured_secret_env}" - ) +_configured_secret_env_names = {DEFAULT_LLM_SECRET_ENV, _configured_secret_env} +_configured_secret_env_names.update( + name.strip() + for name in os.environ.get(LLM_SECRET_ENVS_SETTING, "").split(",") + if name.strip() +) +for _secret_env_name in _configured_secret_env_names: + if _secret_env_name in _INFRASTRUCTURE_CREDENTIAL_ENV_NAMES: + raise RuntimeError( + f"LLM secret metadata cannot name infrastructure credential " + f"{_secret_env_name}" + ) -# Start with only the configured provider credential. Rebinding an immutable -# snapshot lets child_env() iterate safely if a Settings request registers -# another key name at the same time as a command is starting. -_llm_secret_env_names = frozenset({_configured_secret_env}) +# Start with every credential name registered by the launcher. Rebinding an +# immutable snapshot lets child_env() iterate safely if a Settings request +# registers another key name at the same time as a command is starting. +_llm_secret_env_names = frozenset(_configured_secret_env_names) _active_llm_secret_env = _configured_secret_env @@ -52,6 +63,7 @@ def register_llm_secret_env(name: str) -> None: _llm_secret_env_names = _llm_secret_env_names | {name} _active_llm_secret_env = name os.environ[LLM_SECRET_ENV_SETTING] = name + os.environ[LLM_SECRET_ENVS_SETTING] = ",".join(sorted(_llm_secret_env_names)) def active_llm_secret_env() -> str: @@ -70,4 +82,5 @@ def child_env() -> dict[str, str]: for name in _llm_secret_env_names: env.pop(name, None) env.pop(LLM_SECRET_ENV_SETTING, None) + env.pop(LLM_SECRET_ENVS_SETTING, None) return env diff --git a/console/backend/tests/test_subprocess_env.py b/console/backend/tests/test_subprocess_env.py index e752f2de..b44f084f 100644 --- a/console/backend/tests/test_subprocess_env.py +++ b/console/backend/tests/test_subprocess_env.py @@ -9,20 +9,24 @@ import json import os import pathlib +import shlex import subprocess import sys +import tempfile sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[3])) from console.backend.cli import capture, run_command # noqa: E402 from console.backend.subprocess_env import ( # noqa: E402 LLM_SECRET_ENV_SETTING, + LLM_SECRET_ENVS_SETTING, child_env, register_llm_secret_env, ) _CUSTOM_LLM_KEY = "DREADGOAD_TEST_LLM_API_KEY" _CLOUD_KEY = "AWS_SESSION_TOKEN" +_DEPLOYMENT_API_KEY = "LUDUS_API_KEY" def _set_test_environment() -> dict[str, str | None]: @@ -30,6 +34,7 @@ def _set_test_environment() -> dict[str, str | None]: "OPENROUTER_API_KEY": "openrouter-secret", _CUSTOM_LLM_KEY: "custom-secret", _CLOUD_KEY: "cloud-session-token", + _DEPLOYMENT_API_KEY: "deployment-api-key", } previous = {name: os.environ.get(name) for name in values} os.environ.update(values) @@ -46,7 +51,12 @@ def _restore_environment(previous: dict[str, str | None]) -> None: def _probe_argv() -> list[str]: - names = ["OPENROUTER_API_KEY", _CUSTOM_LLM_KEY, _CLOUD_KEY] + names = [ + "OPENROUTER_API_KEY", + _CUSTOM_LLM_KEY, + _CLOUD_KEY, + _DEPLOYMENT_API_KEY, + ] code = ( "import json, os; " f"print(json.dumps({{name: os.environ.get(name) for name in {names!r}}}))" @@ -61,27 +71,35 @@ def test_child_env_scrubs_only_registered_llm_credentials() -> None: assert "OPENROUTER_API_KEY" not in env assert _CUSTOM_LLM_KEY not in env assert env[_CLOUD_KEY] == "cloud-session-token" + assert env[_DEPLOYMENT_API_KEY] == "deployment-api-key" finally: _restore_environment(previous) -def test_launcher_scrubs_only_the_selected_provider_key() -> None: - """Startup metadata scrubs the selected provider's native variable only.""" +def test_backend_scrubs_all_launcher_registered_provider_keys() -> None: + """Startup metadata registers known and custom selected provider keys.""" provider_key = "DREADGOAD_TEST_PROVIDER_API_KEY" + registered_keys = ( + "OPENROUTER_API_KEY", + "ANTHROPIC_API_KEY", + "OPENAI_API_KEY", + provider_key, + ) env = os.environ.copy() env[LLM_SECRET_ENV_SETTING] = provider_key - env[provider_key] = "provider-secret" - env["OPENROUTER_API_KEY"] = "openrouter-secret" + env[LLM_SECRET_ENVS_SETTING] = ",".join(registered_keys) + env.update({name: f"{name.lower()}-secret" for name in registered_keys}) code = ( "import json, os; " "from console.backend.subprocess_env import " "active_llm_secret_env, child_env; " "child = child_env(); " - f"print(json.dumps({{'active': active_llm_secret_env(), " - f"'provider_value': os.environ.get({provider_key!r}), " - f"'provider_in_child': {provider_key!r} in child, " - "'openrouter_in_child': 'OPENROUTER_API_KEY' in child, " - f"'metadata_in_child': {LLM_SECRET_ENV_SETTING!r} in child}}))" + f"names = {registered_keys!r}; " + "print(json.dumps({'active': active_llm_secret_env(), " + "'backend_values': {name: os.environ.get(name) for name in names}, " + "'child_values': {name: child.get(name) for name in names}, " + f"'metadata_in_child': {LLM_SECRET_ENV_SETTING!r} in child, " + f"'metadata_list_in_child': {LLM_SECRET_ENVS_SETTING!r} in child}}))" ) result = subprocess.run( # noqa: S603 [sys.executable, "-c", code], @@ -92,13 +110,11 @@ def test_launcher_scrubs_only_the_selected_provider_key() -> None: text=True, ) values = json.loads(result.stdout) - assert values == { - "active": provider_key, - "provider_value": "provider-secret", - "provider_in_child": False, - "openrouter_in_child": True, - "metadata_in_child": False, - } + assert values["active"] == provider_key + assert all(values["backend_values"].values()) + assert values["child_values"] == dict.fromkeys(registered_keys) + assert values["metadata_in_child"] is False + assert values["metadata_list_in_child"] is False def test_launcher_preserves_provider_native_key_name() -> None: @@ -106,18 +122,130 @@ def test_launcher_preserves_provider_native_key_name() -> None: launcher = pathlib.Path(__file__).resolve().parents[3] / "dreadgoad-console" source = launcher.read_text(encoding="utf-8") assert 'export DREADGOAD_CONSOLE_LLM_SECRET_ENV="$API_KEY_ENV"' in source + assert 'export DREADGOAD_CONSOLE_LLM_SECRET_ENVS="$LLM_SECRET_ENV_LIST"' in source assert 'export OPENROUTER_API_KEY="$API_KEY_VALUE"' not in source - assert source.count('export "$API_KEY_ENV=$LLM_API_KEY_VALUE"') == 1 - assert source.index('unset "$API_KEY_ENV"') < source.index( + assert '"OPENROUTER_API_KEY"' in source + assert '"ANTHROPIC_API_KEY"' in source + assert '"OPENAI_API_KEY"' in source + assert 'export "$API_KEY_ENV=$LLM_API_KEY_VALUE"' in source + assert source.index('unset "$secret_name"') < source.index( "# --- Python venv + backend deps" ) +def test_launcher_help_runs_without_external_tools() -> None: + """Even early help rendering must not expose credentials to a child tool.""" + repo_root = pathlib.Path(__file__).resolve().parents[3] + launcher = repo_root / "dreadgoad-console" + env = os.environ.copy() + env.update( + { + "PATH": "", + "OPENROUTER_API_KEY": "openrouter-secret", + "ANTHROPIC_API_KEY": "anthropic-secret", + } + ) + result = subprocess.run( # noqa: S603 + ["/bin/bash", str(launcher), "--api-key-env", "ANTHROPIC_API_KEY", "--help"], + cwd=repo_root, + env=env, + check=True, + capture_output=True, + text=True, + ) + assert "USAGE" in result.stdout + + +def test_launcher_restores_only_selected_key_for_backend() -> None: + """The real start_backend function restores only the active provider key.""" + repo_root = pathlib.Path(__file__).resolve().parents[3] + source = (repo_root / "dreadgoad-console").read_text(encoding="utf-8") + start = source.index("start_backend() {") + end = source.index('\n}\n\nif [ "$DEV"', start) + 2 + function_source = source[start:end] + + with tempfile.TemporaryDirectory() as temp_dir: + venv_bin = pathlib.Path(temp_dir) / "bin" + venv_bin.mkdir() + fake_uvicorn = venv_bin / "uvicorn" + fake_uvicorn.write_text( + "#!/usr/bin/env python3\n" + "import json, os\n" + 'names = ["OPENROUTER_API_KEY", "ANTHROPIC_API_KEY", ' + '"OPENAI_API_KEY", ' + '"DREADGOAD_TEST_PROVIDER_API_KEY", ' + '"DREADGOAD_CONSOLE_AUTH_TOKEN", ' + '"LLM_API_KEY_VALUE"]\n' + "print(json.dumps({name: os.environ.get(name) for name in names}))\n", + encoding="utf-8", + ) + fake_uvicorn.chmod(0o755) + cases = ( + ( + "default selected", + "OPENROUTER_API_KEY", + "openrouter-secret", + ), + ( + "custom selected", + "DREADGOAD_TEST_PROVIDER_API_KEY", + "provider-secret", + ), + ) + for label, selected_name, selected_value in cases: + script = ( + f"{function_source}\n" + f"API_KEY_ENV={shlex.quote(selected_name)}\n" + f"LLM_API_KEY_VALUE={shlex.quote(selected_value)}\n" + f"VENV={shlex.quote(temp_dir)}\n" + 'AUTH_TOKEN="browser-token"\n' + "start_backend console.backend.server:app\n" + ) + env = os.environ.copy() + for name in ( + "OPENROUTER_API_KEY", + "ANTHROPIC_API_KEY", + "OPENAI_API_KEY", + "DREADGOAD_TEST_PROVIDER_API_KEY", + ): + env.pop(name, None) + env.update( + { + "LLM_API_KEY_VALUE": "attacker-seed", + } + ) + result = subprocess.run( # noqa: S603 + ["bash", "-c", script], + cwd=repo_root, + env=env, + check=True, + capture_output=True, + text=True, + ) + values = json.loads(result.stdout) + expected = { + "OPENROUTER_API_KEY": None, + "ANTHROPIC_API_KEY": None, + "OPENAI_API_KEY": None, + "DREADGOAD_TEST_PROVIDER_API_KEY": None, + "DREADGOAD_CONSOLE_AUTH_TOKEN": "browser-token", + "LLM_API_KEY_VALUE": None, + } + expected[selected_name] = selected_value + assert values == expected, label + + def test_launcher_rejects_infrastructure_credential_names() -> None: - """AWS session-token aliases cannot be selected as LLM credentials.""" + """Deployment credentials cannot be selected as LLM credentials.""" repo_root = pathlib.Path(__file__).resolve().parents[3] launcher = repo_root / "dreadgoad-console" - for name in ("AWS_SECURITY_TOKEN", "AWS_SESSION_TOKEN"): + for name in ( + "ARM_OIDC_TOKEN", + "AWS_CONTAINER_AUTHORIZATION_TOKEN", + "AWS_SECURITY_TOKEN", + "AWS_SESSION_TOKEN", + "LUDUS_API_KEY", + ): result = subprocess.run( # noqa: S603 ["bash", str(launcher), "--api-key-env", name], cwd=repo_root, @@ -129,6 +257,24 @@ def test_launcher_rejects_infrastructure_credential_names() -> None: assert f"cannot use infrastructure credential {name}" in result.stderr +def test_backend_rejects_infrastructure_credential_metadata() -> None: + """Launcher metadata cannot classify cloud credentials as LLM secrets.""" + repo_root = pathlib.Path(__file__).resolve().parents[3] + for name in ("ARM_OIDC_TOKEN", "AWS_SESSION_TOKEN", "LUDUS_API_KEY"): + env = os.environ.copy() + env[LLM_SECRET_ENVS_SETTING] = f"OPENROUTER_API_KEY,{name}" + result = subprocess.run( # noqa: S603 + [sys.executable, "-c", "import console.backend.subprocess_env"], + cwd=repo_root, + env=env, + check=False, + capture_output=True, + text=True, + ) + assert result.returncode != 0 + assert f"cannot name infrastructure credential {name}" in result.stderr + + def test_launcher_scrubs_provider_key_before_build_helpers() -> None: """The real launcher prefix removes the key before dependency setup.""" repo_root = pathlib.Path(__file__).resolve().parents[3] @@ -136,16 +282,26 @@ def test_launcher_scrubs_provider_key_before_build_helpers() -> None: source = launcher.read_text(encoding="utf-8") prefix = source.split("# --- Python venv + backend deps", maxsplit=1)[0] probe = ( - "\nprintf 'VERIFY:%s:%s:%s\\n' " - '"${ANTHROPIC_API_KEY-unset}" "$LLM_API_KEY_VALUE" ' - '"$DREADGOAD_CONSOLE_LLM_SECRET_ENV"\n' - 'python3 -c \'import os; print("CHILD:" + ' - 'os.environ.get("LLM_API_KEY_VALUE", "unset"))\'\n' + "\nprintf 'VERIFY:%s:%s:%s:%s:%s:%s:%s\\n' " + '"${DREADGOAD_TEST_PROVIDER_API_KEY-unset}" ' + '"${ANTHROPIC_API_KEY-unset}" "${OPENROUTER_API_KEY-unset}" ' + '"${OPENAI_API_KEY-unset}" ' + '"$LLM_API_KEY_VALUE" "$DREADGOAD_CONSOLE_LLM_SECRET_ENV" ' + '"$DREADGOAD_CONSOLE_LLM_SECRET_ENVS"\n' + 'python3 -c \'import os; print("CHILD:%s:%s:%s:%s:%s" % ' + '(os.environ.get("DREADGOAD_TEST_PROVIDER_API_KEY", "unset"), ' + 'os.environ.get("ANTHROPIC_API_KEY", "unset"), ' + 'os.environ.get("OPENROUTER_API_KEY", "unset"), ' + 'os.environ.get("OPENAI_API_KEY", "unset"), ' + 'os.environ.get("LLM_API_KEY_VALUE", "unset")))\'\n' ) env = os.environ.copy() env.update( { "ANTHROPIC_API_KEY": "anthropic-secret", + "DREADGOAD_TEST_PROVIDER_API_KEY": "provider-secret", + "OPENAI_API_KEY": "openai-secret", + "OPENROUTER_API_KEY": "openrouter-secret", "DREADGOAD_CONSOLE_PORT": "0", "LLM_API_KEY_VALUE": "attacker-seed", "NO_COLOR": "1", @@ -158,7 +314,7 @@ def test_launcher_scrubs_provider_key_before_build_helpers() -> None: prefix + probe, "./dreadgoad-console", "--api-key-env", - "ANTHROPIC_API_KEY", + "DREADGOAD_TEST_PROVIDER_API_KEY", ], cwd=repo_root, env=env, @@ -167,8 +323,11 @@ def test_launcher_scrubs_provider_key_before_build_helpers() -> None: text=True, ) assert result.stdout.splitlines()[-2:] == [ - "VERIFY:unset:anthropic-secret:ANTHROPIC_API_KEY", - "CHILD:unset", + "VERIFY:unset:unset:unset:unset:provider-secret:" + "DREADGOAD_TEST_PROVIDER_API_KEY:" + "OPENROUTER_API_KEY,ANTHROPIC_API_KEY,OPENAI_API_KEY," + "DREADGOAD_TEST_PROVIDER_API_KEY", + "CHILD:unset:unset:unset:unset:unset", ] @@ -200,9 +359,12 @@ async def test_captured_command_uses_scrubbed_environment() -> None: def main() -> None: test_child_env_scrubs_only_registered_llm_credentials() - test_launcher_scrubs_only_the_selected_provider_key() + test_backend_scrubs_all_launcher_registered_provider_keys() test_launcher_preserves_provider_native_key_name() + test_launcher_help_runs_without_external_tools() + test_launcher_restores_only_selected_key_for_backend() test_launcher_rejects_infrastructure_credential_names() + test_backend_rejects_infrastructure_credential_metadata() test_launcher_scrubs_provider_key_before_build_helpers() asyncio.run(test_streaming_command_uses_scrubbed_environment()) asyncio.run(test_captured_command_uses_scrubbed_environment()) diff --git a/dreadgoad-console b/dreadgoad-console index be557669..b1f474a9 100755 --- a/dreadgoad-console +++ b/dreadgoad-console @@ -9,7 +9,12 @@ # (or at cli/dreadgoad) — the backend shells out to it. See design §10. set -euo pipefail -ROOT="$(cd "$(dirname "$0")" && pwd)" +# Resolve the repository root with Bash built-ins only. LLM credentials are +# still ambient at this point, so no external executable may run yet. +LAUNCHER_DIR="${0%/*}" +if [ "$LAUNCHER_DIR" = "$0" ]; then LAUNCHER_DIR="."; fi +ROOT="$(cd "$LAUNCHER_DIR" && pwd)" +unset LAUNCHER_DIR BACKEND="$ROOT/console/backend" FRONTEND="$ROOT/console/frontend" VENV="$ROOT/.venv" @@ -54,8 +59,6 @@ else RESET='' fi -COLS="$(tput cols 2>/dev/null || echo 80)" - # resolved_model — what a new session will actually run on. # # Asks the backend rather than repeating its fallback: paths.py is stdlib-only @@ -86,7 +89,7 @@ help_cont() { printf ' %-18s %s%s%s\n' "" "$FAINT" "$1" "$RESET"; } usage() { local self - self="$(basename "$0")" + self="${0##*/}" # The compact wordmark, matching banner()'s narrow-terminal form — help is # reference material, not a launch, so it does not get the full six-line # ASCII lockup. @@ -103,7 +106,11 @@ usage() { help_opt "(none)" "build the frontend and serve it on :$PORT" help_opt "--dev" "vite dev server + backend, with hot reload" help_opt "--model NAME" "agent model for new sessions" - help_cont "default: $(resolved_model)" + if [ -n "$MODEL" ]; then + help_cont "default: $MODEL" + else + help_cont "default: backend default" + fi help_opt "--api-key-env VAR" "use the provider-native LLM key in VAR" help_cont "default: $API_KEY_ENV" help_opt "-h, --help" "show this message and exit" @@ -189,7 +196,7 @@ if ! [[ "$API_KEY_ENV" =~ ^[A-Z][A-Z0-9_]*_(API_KEY|KEY|TOKEN)$ ]]; then exit 2 fi case "$API_KEY_ENV" in -AWS_ACCESS_KEY_ID | AWS_SECRET_ACCESS_KEY | AWS_SESSION_TOKEN | AWS_SECURITY_TOKEN) +ARM_OIDC_TOKEN | AWS_ACCESS_KEY_ID | AWS_CONTAINER_AUTHORIZATION_TOKEN | AWS_SECRET_ACCESS_KEY | AWS_SESSION_TOKEN | AWS_SECURITY_TOKEN | LUDUS_API_KEY) printf 'dreadgoad-console: --api-key-env cannot use infrastructure credential %s\n' \ "$API_KEY_ENV" >&2 exit 2 @@ -218,6 +225,49 @@ step() { printf '\n %s▸%s %s%s%s\n' "$BRAND" "$RESET" "$BOLD" "$1" "$RESET"; warn() { printf ' %s!%s %s%s%s\n' "$AMBER" "$RESET" "$AMBER" "$1" "$RESET"; } fail() { printf ' %s✕%s %s%s%s\n' "$RED" "$RESET" "$RED" "$1" "$RESET"; } +# Register supported LLM provider credentials explicitly. Any custom selected +# provider is appended below; infrastructure credentials are never inferred +# from broad *_KEY or *_TOKEN patterns. +LLM_SECRET_ENV_NAMES=( + "OPENROUTER_API_KEY" + "ANTHROPIC_API_KEY" + "OPENAI_API_KEY" +) +if [ "$API_KEY_ENV" != "OPENROUTER_API_KEY" ] && [ -z "${!API_KEY_ENV:-}" ]; then + printf '\n' + fail "--api-key-env named $API_KEY_ENV, but that variable is empty or unset." + fail "export it first, or drop the flag to use OPENROUTER_API_KEY." + exit 1 +fi +LLM_API_KEY_VALUE="${!API_KEY_ENV:-}" +# Assignment preserves an inherited export attribute in Bash. Force this +# holding variable to remain shell-private before any build helper starts. +export -n LLM_API_KEY_VALUE +LLM_SECRET_ENV_SELECTED=0 +for secret_name in "${LLM_SECRET_ENV_NAMES[@]}"; do + if [ "$secret_name" = "$API_KEY_ENV" ]; then + LLM_SECRET_ENV_SELECTED=1 + break + fi +done +if [ "$LLM_SECRET_ENV_SELECTED" = 0 ]; then + LLM_SECRET_ENV_NAMES+=("$API_KEY_ENV") +fi +# Arrays are not placed in a child environment by Bash, but clear any inherited +# export attribute before the registry is used by helper commands. +export -n LLM_SECRET_ENV_NAMES +for secret_name in "${LLM_SECRET_ENV_NAMES[@]}"; do + unset "$secret_name" +done +unset LLM_SECRET_ENV_SELECTED secret_name +LLM_SECRET_ENV_LIST="$( + IFS=, + printf '%s' "${LLM_SECRET_ENV_NAMES[*]}" +)" +export DREADGOAD_CONSOLE_LLM_SECRET_ENV="$API_KEY_ENV" +export DREADGOAD_CONSOLE_LLM_SECRET_ENVS="$LLM_SECRET_ENV_LIST" + +COLS="$(tput cols 2>/dev/null || echo 80)" banner printf ' %sActive Directory range control plane%s %s·%s %s%s%s\n\n' \ "$DIM" "$RESET" "$FAINT" "$RESET" "$CORAL" \ @@ -269,36 +319,15 @@ else CLI_PATH="" fi -# --- credentials and model reach the backend through the environment -------- +# --- model reaches the backend through the environment ---------------------- # Uvicorn is exec'd (or backgrounded) from this shell. The model can remain -# exported, while the credential is held privately until start_backend restores -# it. Previously MODEL was computed for the banner and never exported, so the -# summary reported a model the backend had no idea about unless the caller had -# already exported it themselves. +# exported. Previously MODEL was computed for the banner and never exported, +# so the summary reported a model the backend had no idea about unless the +# caller had already exported it themselves. if [ -n "$MODEL" ]; then export DREADGOAD_CONSOLE_MODEL="$MODEL" fi -# The selected name is provider-native: ANTHROPIC_API_KEY stays Anthropic, -# OPENAI_API_KEY stays OpenAI, and the default remains OpenRouter. Keep its -# value in an unexported shell variable while dependencies and frontend assets -# are built, then restore it only in the Uvicorn process. The backend receives -# the name as scrub-policy metadata; the secret value is never renamed. -if [ "$API_KEY_ENV" != "OPENROUTER_API_KEY" ]; then - if [ -z "${!API_KEY_ENV:-}" ]; then - printf '\n' - fail "--api-key-env named $API_KEY_ENV, but that variable is empty or unset." - fail "export it first, or drop the flag to use OPENROUTER_API_KEY." - exit 1 - fi -fi -LLM_API_KEY_VALUE="${!API_KEY_ENV:-}" -# Assignment preserves an inherited export attribute in Bash. Force this -# holding variable to remain shell-private before any build helper starts. -export -n LLM_API_KEY_VALUE -unset "$API_KEY_ENV" -export DREADGOAD_CONSOLE_LLM_SECRET_ENV="$API_KEY_ENV" - # One credential per launch protects the loopback API from other local users # and unrelated processes. It is carried to the browser in a URL fragment (not # sent in HTTP access logs); the frontend removes it from the address bar after @@ -397,10 +426,11 @@ ready() { printf ' %s╰%s╯%s\n\n' "$BRAND" "$bar" "$RESET" } -# Give the selected provider credential only to the backend. This function runs +# Give only the selected provider credential to the backend. This function runs # in a background subshell in development and replaces the launcher in normal # mode, so the export cannot leak back into npm, Vite, Go, or package installers. start_backend() { + export -n LLM_API_KEY_VALUE if [ -n "$LLM_API_KEY_VALUE" ]; then export "$API_KEY_ENV=$LLM_API_KEY_VALUE" else