feat(workspace-runtime): expose universal MCP server to runtime=external operators
Ship the baseline universal MCP path that any external runtime (Claude
Code, hermes, codex, anything that speaks MCP stdio) can use, before
optimizing per-runtime channels. Today the workspace MCP server only
spins up inside the container; external operators have no way to call
the 8 platform tools (delegate_task, list_peers, send_message_to_user,
commit_memory, etc.) from outside.
Three additive changes:
1. **`platform_auth.get_token()` env-var fallback** — adds
`MOLECULE_WORKSPACE_TOKEN` as a fallback when no
`${CONFIGS_DIR}/.auth_token` file exists. File-first preserves
in-container behavior unchanged. External operators (no /configs
volume) now have a way to supply the token without faking the
filesystem layout.
2. **`molecule-mcp` console script** — adds a new entry point in the
published `molecule-ai-workspace-runtime` PyPI wheel. Operators run
`pip install molecule-ai-workspace-runtime`, set 3 env vars
(WORKSPACE_ID, PLATFORM_URL, MOLECULE_WORKSPACE_TOKEN), and register
the binary in their agent's MCP config. `mcp_cli.main` is a thin
validator wrapper — it checks env BEFORE importing the heavy
`a2a_mcp_server` module so a misconfigured first-run gets a friendly
3-line error instead of a 20-line module-level RuntimeError
traceback.
3. **Wheel smoke gate** — extends `scripts/wheel_smoke.py` to assert
`cli_main` and `mcp_cli.main` are importable. Same regression class
as the 0.1.16 main_sync incident: a silent rename or unrewritten
import here would break every external operator on the next wheel
publish (memory: feedback_runtime_publish_pipeline_gates.md).
Test coverage:
- `tests/test_platform_auth.py` — 8 new tests for the env-var fallback:
file-priority, env-fallback, whitespace handling, cache, header
construction, empty-env-as-unset.
- `tests/test_mcp_cli.py` — 8 new tests for the validator: each
required var separately, file-or-env satisfies token requirement,
whitespace-only env treated as missing, help mentions canvas Tokens
tab.
- Full `workspace/tests/` suite green: 1346 passed, 1 skipped.
- Local end-to-end: built wheel, installed in venv, ran `molecule-mcp`
with no env → friendly error; with env → MCP server starts.
Why now / why this shape: user redirect was "support the baseline
first so all runtimes can use, then optimize". A claude-only MCP
channel leaves hermes/codex/third-party operators broken on
runtime=external. This PR ships the runtime-agnostic baseline; per-
runtime polish (claude-channel push delivery, hermes-native
bindings) is a follow-up PR. PR #2412 fixed the partner bug where
canvas Restart silently revoked the operator's token — the two
together unblock the external-runtime story end-to-end.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
36e263a07d
commit
169e284d57
@ -68,6 +68,7 @@ TOP_LEVEL_MODULES = {
|
||||
"internal_chat_uploads",
|
||||
"internal_file_read",
|
||||
"main",
|
||||
"mcp_cli",
|
||||
"molecule_ai_status",
|
||||
"platform_auth",
|
||||
"platform_inbound_auth",
|
||||
@ -217,6 +218,7 @@ dependencies = [
|
||||
|
||||
[project.scripts]
|
||||
molecule-runtime = "molecule_runtime.main:main_sync"
|
||||
molecule-mcp = "molecule_runtime.mcp_cli:main"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
@ -240,6 +242,31 @@ directory** by the `publish-runtime` GitHub Actions workflow on every
|
||||
`runtime-v*` tag push. **Do not edit this package directly** — edit
|
||||
`workspace/` in the monorepo.
|
||||
|
||||
## External-runtime MCP server (`molecule-mcp`)
|
||||
|
||||
Operators running an agent outside the platform's container fleet
|
||||
(any runtime that supports MCP stdio — Claude Code, hermes, codex,
|
||||
etc.) can install this wheel and run the universal MCP server
|
||||
locally:
|
||||
|
||||
```sh
|
||||
pip install molecule-ai-workspace-runtime
|
||||
WORKSPACE_ID=<uuid> \\
|
||||
PLATFORM_URL=https://<tenant>.staging.moleculesai.app \\
|
||||
MOLECULE_WORKSPACE_TOKEN=<bearer> \\
|
||||
molecule-mcp
|
||||
```
|
||||
|
||||
That exposes the same 8 platform tools (`delegate_task`, `list_peers`,
|
||||
`send_message_to_user`, `commit_memory`, etc.) that container-bound
|
||||
runtimes already get via the workspace's auto-spawned MCP. Register
|
||||
the binary in your agent's MCP config (e.g. Claude Code's
|
||||
`claude mcp add molecule -- molecule-mcp` with the env above).
|
||||
|
||||
The token comes from the canvas → Tokens tab. Restarting an external
|
||||
workspace from the canvas no longer revokes the token (PR #2412), so
|
||||
operator tokens persist across status nudges.
|
||||
|
||||
See [`docs/workspace-runtime-package.md`](https://github.com/Molecule-AI/molecule-core/blob/main/docs/workspace-runtime-package.md)
|
||||
for the publish flow and architecture.
|
||||
"""
|
||||
|
||||
@ -32,6 +32,17 @@ def smoke_imports_and_invariants() -> None:
|
||||
from molecule_runtime.builtin_tools import memory # noqa: F401
|
||||
from molecule_runtime.adapters import get_adapter, BaseAdapter, AdapterConfig
|
||||
|
||||
# cli_main + mcp_cli.main are the molecule-mcp console-script entry
|
||||
# points — the external-runtime universal MCP path. Same regression
|
||||
# class as the 0.1.16 main_sync incident: a silent rename or missed
|
||||
# rewrite here would break every external operator's MCP install on
|
||||
# the next wheel publish. Pin both names because pyproject points
|
||||
# at mcp_cli.main, which then imports a2a_mcp_server.cli_main.
|
||||
from molecule_runtime.a2a_mcp_server import cli_main # noqa: F401
|
||||
from molecule_runtime.mcp_cli import main as mcp_cli_main # noqa: F401
|
||||
assert callable(cli_main), "a2a_mcp_server.cli_main must be callable"
|
||||
assert callable(mcp_cli_main), "mcp_cli.main must be callable"
|
||||
|
||||
assert a2a_client._A2A_ERROR_PREFIX, "a2a_client missing error sentinel"
|
||||
assert callable(get_adapter), "adapters.get_adapter must be callable"
|
||||
assert hasattr(BaseAdapter, "name"), "BaseAdapter interface broken"
|
||||
|
||||
@ -201,5 +201,34 @@ async def main(): # pragma: no cover
|
||||
break
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
def cli_main() -> None: # pragma: no cover
|
||||
"""Synchronous entry point for the ``molecule-mcp`` console script.
|
||||
|
||||
Declared in scripts/build_runtime_package.py as the wheel's
|
||||
entry-point target (``molecule-mcp = "molecule_runtime.a2a_mcp_server:cli_main"``).
|
||||
External-runtime operators install ``molecule-ai-workspace-runtime``
|
||||
and register this binary as an MCP server in their agent's config —
|
||||
Claude Code, hermes, codex, anything that speaks MCP stdio.
|
||||
|
||||
Required environment:
|
||||
WORKSPACE_ID — this workspace's UUID (must
|
||||
already be registered on the
|
||||
platform).
|
||||
PLATFORM_URL — base URL of the Molecule
|
||||
platform (e.g. https://your-
|
||||
tenant.staging.moleculesai.app).
|
||||
MOLECULE_WORKSPACE_TOKEN — bearer token for this workspace
|
||||
(issued by the platform on
|
||||
first /registry/register).
|
||||
|
||||
Mirrors the ``main_sync`` pattern in main.py — wheel-smoke gates
|
||||
in scripts/wheel_smoke.py assert this name is importable so a
|
||||
silent rename can't break every external-runtime operator's MCP
|
||||
install (the 0.1.16 main_sync incident is the cautionary
|
||||
precedent).
|
||||
"""
|
||||
asyncio.run(main())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
cli_main()
|
||||
|
||||
74
workspace/mcp_cli.py
Normal file
74
workspace/mcp_cli.py
Normal file
@ -0,0 +1,74 @@
|
||||
"""Console-script entry point for the ``molecule-mcp`` universal MCP server.
|
||||
|
||||
Validates required environment BEFORE importing the heavy
|
||||
``a2a_mcp_server`` module — that module triggers a ``RuntimeError`` at
|
||||
import time when ``WORKSPACE_ID`` is unset (a2a_client.py:22), and
|
||||
console-script entry-point shims surface it as an ugly traceback. This
|
||||
wrapper catches the missing-env case early and prints actionable help
|
||||
to stderr so an operator running ``molecule-mcp`` for the first time
|
||||
gets the right pointer in the first 3 lines of output instead of a
|
||||
20-line traceback.
|
||||
|
||||
Existing in-container usage (``python -m molecule_runtime.a2a_mcp_server``
|
||||
or direct import) is unaffected — those paths bypass this wrapper. Only
|
||||
the external-runtime ``molecule-mcp`` console script routes through here.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _print_missing_env_help(missing: list[str], have_token_file: bool) -> None:
|
||||
print("molecule-mcp: missing required environment.\n", file=sys.stderr)
|
||||
print("Set the following before running molecule-mcp:", file=sys.stderr)
|
||||
print(" WORKSPACE_ID — your workspace UUID (from canvas)", file=sys.stderr)
|
||||
print(
|
||||
" PLATFORM_URL — base URL of your Molecule platform "
|
||||
"(e.g. https://your-tenant.staging.moleculesai.app)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
if not have_token_file:
|
||||
print(
|
||||
" MOLECULE_WORKSPACE_TOKEN — bearer token for this workspace "
|
||||
"(canvas → Tokens tab)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print("", file=sys.stderr)
|
||||
print(f"Currently missing: {', '.join(missing)}", file=sys.stderr)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Entry point for the ``molecule-mcp`` console script.
|
||||
|
||||
Returns nothing — calls ``sys.exit`` on validation failure or on
|
||||
normal completion of the underlying MCP server loop.
|
||||
"""
|
||||
missing: list[str] = []
|
||||
if not os.environ.get("WORKSPACE_ID", "").strip():
|
||||
missing.append("WORKSPACE_ID")
|
||||
if not os.environ.get("PLATFORM_URL", "").strip():
|
||||
missing.append("PLATFORM_URL")
|
||||
# Token can come from env OR file — only flag when both are absent.
|
||||
# Mirrors platform_auth.get_token's resolution order (file-first,
|
||||
# env-fallback).
|
||||
configs_dir = Path(os.environ.get("CONFIGS_DIR", "/configs"))
|
||||
has_token_file = (configs_dir / ".auth_token").is_file()
|
||||
has_token_env = bool(os.environ.get("MOLECULE_WORKSPACE_TOKEN", "").strip())
|
||||
if not has_token_file and not has_token_env:
|
||||
missing.append("MOLECULE_WORKSPACE_TOKEN (or CONFIGS_DIR/.auth_token)")
|
||||
|
||||
if missing:
|
||||
_print_missing_env_help(missing, have_token_file=has_token_file)
|
||||
sys.exit(2)
|
||||
|
||||
# Env is valid — safe to import the heavy module now. Importing
|
||||
# earlier would trigger a2a_client.py:22's module-level RuntimeError
|
||||
# before our friendly help reaches the user.
|
||||
from a2a_mcp_server import cli_main
|
||||
cli_main()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@ -39,22 +39,42 @@ def _token_file() -> Path:
|
||||
|
||||
|
||||
def get_token() -> str | None:
|
||||
"""Return the cached token, reading it from disk on first call."""
|
||||
"""Return the cached token, reading it from disk on first call.
|
||||
|
||||
Resolution order:
|
||||
1. In-process cache (hot path)
|
||||
2. ``${CONFIGS_DIR}/.auth_token`` file (in-container default —
|
||||
the platform writes this on provision and rotates it on
|
||||
restart)
|
||||
3. ``MOLECULE_WORKSPACE_TOKEN`` env var (external-runtime path —
|
||||
operators running the universal MCP server outside a
|
||||
container have no /configs volume to populate, so they pass
|
||||
the token via env)
|
||||
|
||||
File-first preserves in-container behavior unchanged: containers
|
||||
always have /configs/.auth_token on disk, env-var fallback only
|
||||
fires when there's no file. This is additive — no existing caller
|
||||
sees a behavior change.
|
||||
"""
|
||||
global _cached_token
|
||||
if _cached_token is not None:
|
||||
return _cached_token
|
||||
path = _token_file()
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
tok = path.read_text().strip()
|
||||
except OSError as exc:
|
||||
logger.warning("platform_auth: failed to read %s: %s", path, exc)
|
||||
return None
|
||||
if not tok:
|
||||
return None
|
||||
_cached_token = tok
|
||||
return tok
|
||||
if path.exists():
|
||||
try:
|
||||
tok = path.read_text().strip()
|
||||
except OSError as exc:
|
||||
logger.warning("platform_auth: failed to read %s: %s", path, exc)
|
||||
tok = ""
|
||||
if tok:
|
||||
_cached_token = tok
|
||||
return tok
|
||||
# File missing or empty — fall back to env (external-runtime path).
|
||||
env_tok = os.environ.get("MOLECULE_WORKSPACE_TOKEN", "").strip()
|
||||
if env_tok:
|
||||
_cached_token = env_tok
|
||||
return env_tok
|
||||
return None
|
||||
|
||||
|
||||
def save_token(token: str) -> None:
|
||||
|
||||
141
workspace/tests/test_mcp_cli.py
Normal file
141
workspace/tests/test_mcp_cli.py
Normal file
@ -0,0 +1,141 @@
|
||||
"""Tests for workspace/mcp_cli.py — the molecule-mcp console-script
|
||||
entry-point validator.
|
||||
|
||||
The wrapper exists to surface a friendly missing-env error before
|
||||
a2a_client.py:22's module-level RuntimeError fires. Regressions here
|
||||
ship a poor first-run UX to every external-runtime operator.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import mcp_cli
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate(monkeypatch, tmp_path):
|
||||
"""Each test starts with no Molecule env vars set + a fresh
|
||||
CONFIGS_DIR pointing at an empty tmpdir."""
|
||||
for var in ("WORKSPACE_ID", "PLATFORM_URL", "MOLECULE_WORKSPACE_TOKEN"):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
monkeypatch.setenv("CONFIGS_DIR", str(tmp_path))
|
||||
yield
|
||||
|
||||
|
||||
def _run_main_capturing_exit(capsys) -> tuple[int, str]:
|
||||
"""Call mcp_cli.main and return (exit_code, stderr).
|
||||
|
||||
main() is supposed to sys.exit on missing env. Any non-exit return
|
||||
means it tried to run the real MCP loop, which we don't want in a
|
||||
unit test (and which would also fail because we never set the
|
||||
mandatory env).
|
||||
"""
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
mcp_cli.main()
|
||||
captured = capsys.readouterr()
|
||||
code = exc_info.value.code if isinstance(exc_info.value.code, int) else 1
|
||||
return code, captured.err
|
||||
|
||||
|
||||
def test_missing_workspace_id_exits_with_message(capsys):
|
||||
code, err = _run_main_capturing_exit(capsys)
|
||||
assert code == 2, f"expected exit code 2, got {code}"
|
||||
assert "WORKSPACE_ID" in err
|
||||
assert "PLATFORM_URL" in err # also missing
|
||||
assert "MOLECULE_WORKSPACE_TOKEN" in err # also missing
|
||||
|
||||
|
||||
def test_only_workspace_id_missing(capsys, monkeypatch):
|
||||
monkeypatch.setenv("PLATFORM_URL", "http://localhost:8080")
|
||||
monkeypatch.setenv("MOLECULE_WORKSPACE_TOKEN", "tok")
|
||||
code, err = _run_main_capturing_exit(capsys)
|
||||
assert code == 2
|
||||
# Only WORKSPACE_ID should appear in the "currently missing" list.
|
||||
assert "Currently missing: WORKSPACE_ID" in err
|
||||
|
||||
|
||||
def test_only_platform_url_missing(capsys, monkeypatch):
|
||||
monkeypatch.setenv("WORKSPACE_ID", "00000000-0000-0000-0000-000000000000")
|
||||
monkeypatch.setenv("MOLECULE_WORKSPACE_TOKEN", "tok")
|
||||
code, err = _run_main_capturing_exit(capsys)
|
||||
assert code == 2
|
||||
assert "Currently missing: PLATFORM_URL" in err
|
||||
|
||||
|
||||
def test_only_token_missing(capsys, monkeypatch):
|
||||
monkeypatch.setenv("WORKSPACE_ID", "00000000-0000-0000-0000-000000000000")
|
||||
monkeypatch.setenv("PLATFORM_URL", "http://localhost:8080")
|
||||
code, err = _run_main_capturing_exit(capsys)
|
||||
assert code == 2
|
||||
assert "MOLECULE_WORKSPACE_TOKEN" in err
|
||||
|
||||
|
||||
def test_token_file_satisfies_token_requirement(capsys, monkeypatch, tmp_path):
|
||||
"""Token from CONFIGS_DIR/.auth_token must be accepted (in-container
|
||||
path)."""
|
||||
(tmp_path / ".auth_token").write_text("file-token")
|
||||
monkeypatch.setenv("WORKSPACE_ID", "00000000-0000-0000-0000-000000000000")
|
||||
monkeypatch.setenv("PLATFORM_URL", "http://localhost:8080")
|
||||
# No MOLECULE_WORKSPACE_TOKEN — but file exists. Validation should
|
||||
# pass; we then short-circuit before importing the heavy module by
|
||||
# patching the import to a no-op spy.
|
||||
|
||||
spy_called: dict[str, bool] = {"called": False}
|
||||
|
||||
def fake_cli_main():
|
||||
spy_called["called"] = True
|
||||
|
||||
# Patch the heavy import to avoid actually running the MCP server.
|
||||
# mcp_cli does the import lazily inside main(), so we monkeypatch
|
||||
# sys.modules to inject a fake a2a_mcp_server.
|
||||
import types
|
||||
fake_module = types.ModuleType("a2a_mcp_server")
|
||||
fake_module.cli_main = fake_cli_main
|
||||
monkeypatch.setitem(sys.modules, "a2a_mcp_server", fake_module)
|
||||
|
||||
mcp_cli.main() # should NOT exit
|
||||
assert spy_called["called"], "expected cli_main to be invoked when env+file are valid"
|
||||
|
||||
|
||||
def test_env_token_satisfies_token_requirement(capsys, monkeypatch):
|
||||
"""Token from env must be accepted (external-runtime path)."""
|
||||
monkeypatch.setenv("WORKSPACE_ID", "00000000-0000-0000-0000-000000000000")
|
||||
monkeypatch.setenv("PLATFORM_URL", "http://localhost:8080")
|
||||
monkeypatch.setenv("MOLECULE_WORKSPACE_TOKEN", "env-token")
|
||||
|
||||
spy_called: dict[str, bool] = {"called": False}
|
||||
|
||||
def fake_cli_main():
|
||||
spy_called["called"] = True
|
||||
|
||||
import types
|
||||
fake_module = types.ModuleType("a2a_mcp_server")
|
||||
fake_module.cli_main = fake_cli_main
|
||||
monkeypatch.setitem(sys.modules, "a2a_mcp_server", fake_module)
|
||||
|
||||
mcp_cli.main()
|
||||
assert spy_called["called"]
|
||||
|
||||
|
||||
def test_whitespace_only_env_treated_as_missing(capsys, monkeypatch):
|
||||
"""An accidentally-empty env var (WORKSPACE_ID=" ") must NOT be
|
||||
considered set — otherwise the error would surface deep inside an
|
||||
HTTP call instead of in this validator."""
|
||||
monkeypatch.setenv("WORKSPACE_ID", " ")
|
||||
monkeypatch.setenv("PLATFORM_URL", "http://localhost:8080")
|
||||
monkeypatch.setenv("MOLECULE_WORKSPACE_TOKEN", "tok")
|
||||
code, err = _run_main_capturing_exit(capsys)
|
||||
assert code == 2
|
||||
assert "WORKSPACE_ID" in err
|
||||
|
||||
|
||||
def test_help_lists_canvas_tokens_tab_pointer(capsys):
|
||||
"""Operator must know WHERE to get a token. The help mentions the
|
||||
canvas Tokens tab so they can self-recover without asking on
|
||||
Slack."""
|
||||
code, err = _run_main_capturing_exit(capsys)
|
||||
assert code == 2
|
||||
assert "Tokens tab" in err or "canvas" in err.lower()
|
||||
@ -119,3 +119,65 @@ def test_default_configs_dir_fallback(tmp_path, monkeypatch):
|
||||
# We expect _token_file() to resolve under /configs when env is unset.
|
||||
path = platform_auth._token_file()
|
||||
assert str(path).startswith("/configs")
|
||||
|
||||
|
||||
# ==================== MOLECULE_WORKSPACE_TOKEN env-var fallback ====================
|
||||
# External-runtime path: operators running the universal MCP server outside
|
||||
# a container have no /configs volume. They pass the token via env. The
|
||||
# fallback must NOT override the file when both are present (in-container
|
||||
# rotation must keep working) and MUST surface env when the file is absent.
|
||||
|
||||
|
||||
def test_get_token_uses_env_when_file_absent(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("MOLECULE_WORKSPACE_TOKEN", "env-token-xyz")
|
||||
assert not (tmp_path / ".auth_token").exists()
|
||||
assert platform_auth.get_token() == "env-token-xyz"
|
||||
|
||||
|
||||
def test_get_token_file_takes_priority_over_env(tmp_path, monkeypatch):
|
||||
"""In-container rotation must keep working — file overrides env."""
|
||||
(tmp_path / ".auth_token").write_text("file-token")
|
||||
monkeypatch.setenv("MOLECULE_WORKSPACE_TOKEN", "env-token-should-be-ignored")
|
||||
assert platform_auth.get_token() == "file-token"
|
||||
|
||||
|
||||
def test_get_token_falls_back_to_env_when_file_empty(tmp_path, monkeypatch):
|
||||
"""Empty file is equivalent to absent — env still fires."""
|
||||
(tmp_path / ".auth_token").write_text("")
|
||||
monkeypatch.setenv("MOLECULE_WORKSPACE_TOKEN", "env-token-fallback")
|
||||
assert platform_auth.get_token() == "env-token-fallback"
|
||||
|
||||
|
||||
def test_get_token_strips_env_whitespace(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("MOLECULE_WORKSPACE_TOKEN", " padded-env-token \n")
|
||||
assert platform_auth.get_token() == "padded-env-token"
|
||||
|
||||
|
||||
def test_get_token_ignores_empty_env(tmp_path, monkeypatch):
|
||||
"""Empty string env var is the same as unset — no false positive."""
|
||||
monkeypatch.setenv("MOLECULE_WORKSPACE_TOKEN", "")
|
||||
assert platform_auth.get_token() is None
|
||||
|
||||
|
||||
def test_get_token_ignores_whitespace_only_env(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("MOLECULE_WORKSPACE_TOKEN", " \n\n ")
|
||||
assert platform_auth.get_token() is None
|
||||
|
||||
|
||||
def test_env_token_caches_like_file_token(tmp_path, monkeypatch):
|
||||
"""Once env-token is read, mutating env shouldn't affect cached value."""
|
||||
monkeypatch.setenv("MOLECULE_WORKSPACE_TOKEN", "first-env-token")
|
||||
assert platform_auth.get_token() == "first-env-token"
|
||||
monkeypatch.setenv("MOLECULE_WORKSPACE_TOKEN", "second-env-token")
|
||||
# Cache returns first value
|
||||
assert platform_auth.get_token() == "first-env-token"
|
||||
# clear_cache forces re-read of env
|
||||
platform_auth.clear_cache()
|
||||
assert platform_auth.get_token() == "second-env-token"
|
||||
|
||||
|
||||
def test_auth_headers_works_with_env_token(tmp_path, monkeypatch):
|
||||
"""Header construction must use the env-fallback token, not silently
|
||||
return {} when no file exists."""
|
||||
monkeypatch.setenv("MOLECULE_WORKSPACE_TOKEN", "external-bearer")
|
||||
assert platform_auth.auth_headers() == {"Authorization": "Bearer external-bearer"}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user