diff --git a/workspace/platform_auth.py b/workspace/platform_auth.py index 85d1b210..da4e4bd9 100644 --- a/workspace/platform_auth.py +++ b/workspace/platform_auth.py @@ -111,11 +111,26 @@ def auth_headers() -> dict[str, str]: """Return a header dict to merge into httpx calls. Empty if no token is available yet — callers send the request as-is and the platform's heartbeat handler grandfathers pre-token workspaces through until - their next /registry/register issues one.""" + their next /registry/register issues one. + + Always sets ``Origin`` to ``PLATFORM_URL`` when that env var is set. + On hosted SaaS deployments the tenant's edge WAF requires a same- + origin header — without it ``/workspaces/*`` and ``/registry/*/peers`` + requests get silently rewritten to the canvas Next.js app, which has + no such routes and returns an empty 404. Inside-container calls are + unaffected (Docker-internal PLATFORM_URLs aren't behind the WAF). + Discovered while smoke-testing the molecule-mcp external-runtime + path against a live tenant — every tool call returned "not found" + because the WAF was eating them. + """ + headers: dict[str, str] = {} + platform_url = os.environ.get("PLATFORM_URL", "").strip() + if platform_url: + headers["Origin"] = platform_url tok = get_token() - if not tok: - return {} - return {"Authorization": f"Bearer {tok}"} + if tok: + headers["Authorization"] = f"Bearer {tok}" + return headers def self_source_headers(workspace_id: str) -> dict[str, str]: diff --git a/workspace/tests/test_platform_auth.py b/workspace/tests/test_platform_auth.py index bff0a20a..38480393 100644 --- a/workspace/tests/test_platform_auth.py +++ b/workspace/tests/test_platform_auth.py @@ -65,15 +65,36 @@ def test_save_token_rotation_overwrites(tmp_path): assert platform_auth.get_token() == "token-v2" -def test_auth_headers_when_no_token_is_empty(): +def test_auth_headers_when_no_token_and_no_platform_is_empty(monkeypatch): + monkeypatch.delenv("PLATFORM_URL", raising=False) assert platform_auth.auth_headers() == {} -def test_auth_headers_format(): +def test_auth_headers_when_no_token_includes_origin(monkeypatch): + """Origin must be set even without a token — the WAF gates ALL + requests to /workspaces and /registry, including pre-token bootstrap + register calls. Without Origin those would silently 404 from Next.js.""" + monkeypatch.setenv("PLATFORM_URL", "https://tenant.moleculesai.app") + assert platform_auth.auth_headers() == {"Origin": "https://tenant.moleculesai.app"} + + +def test_auth_headers_format(monkeypatch): + monkeypatch.delenv("PLATFORM_URL", raising=False) platform_auth.save_token("hello-world") assert platform_auth.auth_headers() == {"Authorization": "Bearer hello-world"} +def test_auth_headers_includes_origin_when_platform_url_set(monkeypatch): + """Both Authorization and Origin land on the same dict so the + SaaS edge WAF accepts every workspace-runtime request.""" + monkeypatch.setenv("PLATFORM_URL", "https://hongmingwang.moleculesai.app") + platform_auth.save_token("tok") + assert platform_auth.auth_headers() == { + "Authorization": "Bearer tok", + "Origin": "https://hongmingwang.moleculesai.app", + } + + def test_get_token_caches_after_first_disk_read(tmp_path, monkeypatch): path = tmp_path / ".auth_token" path.write_text("disk-token") @@ -179,5 +200,6 @@ def test_env_token_caches_like_file_token(tmp_path, monkeypatch): 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.delenv("PLATFORM_URL", raising=False) monkeypatch.setenv("MOLECULE_WORKSPACE_TOKEN", "external-bearer") assert platform_auth.auth_headers() == {"Authorization": "Bearer external-bearer"}