insightfactory-cli 1.1.2.dev31__tar.gz → 1.2.0.dev34__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. {insightfactory_cli-1.1.2.dev31 → insightfactory_cli-1.2.0.dev34}/CHANGELOG.md +14 -0
  2. {insightfactory_cli-1.1.2.dev31 → insightfactory_cli-1.2.0.dev34}/PKG-INFO +9 -1
  3. {insightfactory_cli-1.1.2.dev31 → insightfactory_cli-1.2.0.dev34}/README.md +8 -0
  4. {insightfactory_cli-1.1.2.dev31 → insightfactory_cli-1.2.0.dev34}/pyproject.toml +1 -1
  5. insightfactory_cli-1.2.0.dev34/src/if_cli/attribution.py +51 -0
  6. {insightfactory_cli-1.1.2.dev31 → insightfactory_cli-1.2.0.dev34}/src/if_cli/constants.py +1 -0
  7. {insightfactory_cli-1.1.2.dev31 → insightfactory_cli-1.2.0.dev34}/src/if_cli/http.py +11 -1
  8. {insightfactory_cli-1.1.2.dev31 → insightfactory_cli-1.2.0.dev34}/src/if_cli/oauth.py +2 -0
  9. {insightfactory_cli-1.1.2.dev31 → insightfactory_cli-1.2.0.dev34}/src/if_cli/router/upstream.py +6 -1
  10. {insightfactory_cli-1.1.2.dev31 → insightfactory_cli-1.2.0.dev34}/tests/conftest.py +2 -0
  11. insightfactory_cli-1.2.0.dev34/tests/test_attribution.py +160 -0
  12. {insightfactory_cli-1.1.2.dev31 → insightfactory_cli-1.2.0.dev34}/tests/test_router_upstream.py +26 -1
  13. {insightfactory_cli-1.1.2.dev31 → insightfactory_cli-1.2.0.dev34}/uv.lock +1 -1
  14. {insightfactory_cli-1.1.2.dev31 → insightfactory_cli-1.2.0.dev34}/.github/workflows/ci.yml +0 -0
  15. {insightfactory_cli-1.1.2.dev31 → insightfactory_cli-1.2.0.dev34}/.github/workflows/claude.yml +0 -0
  16. {insightfactory_cli-1.1.2.dev31 → insightfactory_cli-1.2.0.dev34}/.github/workflows/release.yml +0 -0
  17. {insightfactory_cli-1.1.2.dev31 → insightfactory_cli-1.2.0.dev34}/.gitignore +0 -0
  18. {insightfactory_cli-1.1.2.dev31 → insightfactory_cli-1.2.0.dev34}/.python-version +0 -0
  19. {insightfactory_cli-1.1.2.dev31 → insightfactory_cli-1.2.0.dev34}/AGENTS.md +0 -0
  20. {insightfactory_cli-1.1.2.dev31 → insightfactory_cli-1.2.0.dev34}/CLAUDE.md +0 -0
  21. {insightfactory_cli-1.1.2.dev31 → insightfactory_cli-1.2.0.dev34}/LICENSE +0 -0
  22. {insightfactory_cli-1.1.2.dev31 → insightfactory_cli-1.2.0.dev34}/src/if_cli/__init__.py +0 -0
  23. {insightfactory_cli-1.1.2.dev31 → insightfactory_cli-1.2.0.dev34}/src/if_cli/__main__.py +0 -0
  24. {insightfactory_cli-1.1.2.dev31 → insightfactory_cli-1.2.0.dev34}/src/if_cli/assets/__init__.py +0 -0
  25. {insightfactory_cli-1.1.2.dev31 → insightfactory_cli-1.2.0.dev34}/src/if_cli/assets/insightfactoryai-logo.svg +0 -0
  26. {insightfactory_cli-1.1.2.dev31 → insightfactory_cli-1.2.0.dev34}/src/if_cli/cache.py +0 -0
  27. {insightfactory_cli-1.1.2.dev31 → insightfactory_cli-1.2.0.dev34}/src/if_cli/callback_page.py +0 -0
  28. {insightfactory_cli-1.1.2.dev31 → insightfactory_cli-1.2.0.dev34}/src/if_cli/cli.py +0 -0
  29. {insightfactory_cli-1.1.2.dev31 → insightfactory_cli-1.2.0.dev34}/src/if_cli/colour.py +0 -0
  30. {insightfactory_cli-1.1.2.dev31 → insightfactory_cli-1.2.0.dev34}/src/if_cli/commands/__init__.py +0 -0
  31. {insightfactory_cli-1.1.2.dev31 → insightfactory_cli-1.2.0.dev34}/src/if_cli/commands/api.py +0 -0
  32. {insightfactory_cli-1.1.2.dev31 → insightfactory_cli-1.2.0.dev34}/src/if_cli/commands/config.py +0 -0
  33. {insightfactory_cli-1.1.2.dev31 → insightfactory_cli-1.2.0.dev34}/src/if_cli/commands/login.py +0 -0
  34. {insightfactory_cli-1.1.2.dev31 → insightfactory_cli-1.2.0.dev34}/src/if_cli/commands/logout.py +0 -0
  35. {insightfactory_cli-1.1.2.dev31 → insightfactory_cli-1.2.0.dev34}/src/if_cli/commands/mcp.py +0 -0
  36. {insightfactory_cli-1.1.2.dev31 → insightfactory_cli-1.2.0.dev34}/src/if_cli/commands/profiles.py +0 -0
  37. {insightfactory_cli-1.1.2.dev31 → insightfactory_cli-1.2.0.dev34}/src/if_cli/commands/set_token.py +0 -0
  38. {insightfactory_cli-1.1.2.dev31 → insightfactory_cli-1.2.0.dev34}/src/if_cli/commands/token.py +0 -0
  39. {insightfactory_cli-1.1.2.dev31 → insightfactory_cli-1.2.0.dev34}/src/if_cli/config.py +0 -0
  40. {insightfactory_cli-1.1.2.dev31 → insightfactory_cli-1.2.0.dev34}/src/if_cli/main.py +0 -0
  41. {insightfactory_cli-1.1.2.dev31 → insightfactory_cli-1.2.0.dev34}/src/if_cli/router/__init__.py +0 -0
  42. {insightfactory_cli-1.1.2.dev31 → insightfactory_cli-1.2.0.dev34}/src/if_cli/router/catalog.py +0 -0
  43. {insightfactory_cli-1.1.2.dev31 → insightfactory_cli-1.2.0.dev34}/src/if_cli/router/content.py +0 -0
  44. {insightfactory_cli-1.1.2.dev31 → insightfactory_cli-1.2.0.dev34}/src/if_cli/router/log.py +0 -0
  45. {insightfactory_cli-1.1.2.dev31 → insightfactory_cli-1.2.0.dev34}/src/if_cli/router/policy.py +0 -0
  46. {insightfactory_cli-1.1.2.dev31 → insightfactory_cli-1.2.0.dev34}/src/if_cli/router/server.py +0 -0
  47. {insightfactory_cli-1.1.2.dev31 → insightfactory_cli-1.2.0.dev34}/src/if_cli/runtime.py +0 -0
  48. {insightfactory_cli-1.1.2.dev31 → insightfactory_cli-1.2.0.dev34}/tests/__init__.py +0 -0
  49. {insightfactory_cli-1.1.2.dev31 → insightfactory_cli-1.2.0.dev34}/tests/cache_writer.py +0 -0
  50. {insightfactory_cli-1.1.2.dev31 → insightfactory_cli-1.2.0.dev34}/tests/helpers.py +0 -0
  51. {insightfactory_cli-1.1.2.dev31 → insightfactory_cli-1.2.0.dev34}/tests/servers.py +0 -0
  52. {insightfactory_cli-1.1.2.dev31 → insightfactory_cli-1.2.0.dev34}/tests/test_api.py +0 -0
  53. {insightfactory_cli-1.1.2.dev31 → insightfactory_cli-1.2.0.dev34}/tests/test_api_command.py +0 -0
  54. {insightfactory_cli-1.1.2.dev31 → insightfactory_cli-1.2.0.dev34}/tests/test_cache.py +0 -0
  55. {insightfactory_cli-1.1.2.dev31 → insightfactory_cli-1.2.0.dev34}/tests/test_cli.py +0 -0
  56. {insightfactory_cli-1.1.2.dev31 → insightfactory_cli-1.2.0.dev34}/tests/test_config.py +0 -0
  57. {insightfactory_cli-1.1.2.dev31 → insightfactory_cli-1.2.0.dev34}/tests/test_config_command.py +0 -0
  58. {insightfactory_cli-1.1.2.dev31 → insightfactory_cli-1.2.0.dev34}/tests/test_login.py +0 -0
  59. {insightfactory_cli-1.1.2.dev31 → insightfactory_cli-1.2.0.dev34}/tests/test_mcp_command.py +0 -0
  60. {insightfactory_cli-1.1.2.dev31 → insightfactory_cli-1.2.0.dev34}/tests/test_oauth.py +0 -0
  61. {insightfactory_cli-1.1.2.dev31 → insightfactory_cli-1.2.0.dev34}/tests/test_oauth_flow.py +0 -0
  62. {insightfactory_cli-1.1.2.dev31 → insightfactory_cli-1.2.0.dev34}/tests/test_oauth_force_refresh.py +0 -0
  63. {insightfactory_cli-1.1.2.dev31 → insightfactory_cli-1.2.0.dev34}/tests/test_profiles.py +0 -0
  64. {insightfactory_cli-1.1.2.dev31 → insightfactory_cli-1.2.0.dev34}/tests/test_programmatic_api.py +0 -0
  65. {insightfactory_cli-1.1.2.dev31 → insightfactory_cli-1.2.0.dev34}/tests/test_release_tools.py +0 -0
  66. {insightfactory_cli-1.1.2.dev31 → insightfactory_cli-1.2.0.dev34}/tests/test_router_catalog.py +0 -0
  67. {insightfactory_cli-1.1.2.dev31 → insightfactory_cli-1.2.0.dev34}/tests/test_router_content.py +0 -0
  68. {insightfactory_cli-1.1.2.dev31 → insightfactory_cli-1.2.0.dev34}/tests/test_router_log.py +0 -0
  69. {insightfactory_cli-1.1.2.dev31 → insightfactory_cli-1.2.0.dev34}/tests/test_router_policy.py +0 -0
  70. {insightfactory_cli-1.1.2.dev31 → insightfactory_cli-1.2.0.dev34}/tests/test_router_resources.py +0 -0
  71. {insightfactory_cli-1.1.2.dev31 → insightfactory_cli-1.2.0.dev34}/tests/test_router_server.py +0 -0
  72. {insightfactory_cli-1.1.2.dev31 → insightfactory_cli-1.2.0.dev34}/tests/test_runtime.py +0 -0
  73. {insightfactory_cli-1.1.2.dev31 → insightfactory_cli-1.2.0.dev34}/tests/test_set_token.py +0 -0
  74. {insightfactory_cli-1.1.2.dev31 → insightfactory_cli-1.2.0.dev34}/tests/test_token.py +0 -0
  75. {insightfactory_cli-1.1.2.dev31 → insightfactory_cli-1.2.0.dev34}/tools/check_release.py +0 -0
  76. {insightfactory_cli-1.1.2.dev31 → insightfactory_cli-1.2.0.dev34}/tools/release_notes.py +0 -0
@@ -11,6 +11,20 @@ that: every PR into `develop` must add an entry here, and a tag whose version ha
11
11
  section fails rather than releasing an empty page. The version only moves when the
12
12
  last one has been released, so a cycle's PRs share a section.
13
13
 
14
+ ## 1.2.0
15
+
16
+ ### Added
17
+
18
+ - Factory requests carry an `X-IF-UA` header of RFC 9110 product tokens, so the
19
+ Interfaces API can attribute work to the client that made it. The value is
20
+ `if-cli/<version>`, with the contents of `INSIGHTFACTORY_UA` in front when that
21
+ variable holds valid product tokens, for example
22
+ `if-fde-harness/5.10.0 if-cli/1.2.0`. The CLI drops a value that fails the
23
+ grammar or exceeds 256 characters and sends its own token alone. `api`, OAuth
24
+ discovery and the `if-cli mcp` router's calls to each factory send the header.
25
+ Token requests to the identity provider do not, and a header the caller sets
26
+ itself replaces the built one.
27
+
14
28
  ## 1.1.2
15
29
 
16
30
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: insightfactory-cli
3
- Version: 1.1.2.dev31
3
+ Version: 1.2.0.dev34
4
4
  Summary: Profile-based authentication CLI for the InsightFactory Interfaces API
5
5
  Project-URL: Homepage, https://insightfactory.ai
6
6
  Author-email: "insightfactory.ai Support" <support@insightfactory.ai>
@@ -128,6 +128,14 @@ accepted and completed the request after `if-cli` gave up, so re-issuing a
128
128
  timeout message tells you to confirm the current state before retrying rather
129
129
  than repeat the call.
130
130
 
131
+ Every request to the factory carries an `X-IF-UA` header naming the client,
132
+ `if-cli/<version>` by default. A tool that runs `if-cli` can name itself first
133
+ by setting `INSIGHTFACTORY_UA` to one or more RFC 9110 product tokens, such as
134
+ `if-fde-harness/5.10.0`, and the header becomes
135
+ `if-fde-harness/5.10.0 if-cli/<version>`. The CLI ignores a value that is not
136
+ valid product-token syntax or is longer than 256 characters. Token requests to
137
+ the identity provider never carry the header.
138
+
131
139
  Override the config directory with `INSIGHTFACTORY_CONFIG_DIR`, which is useful
132
140
  in tests and CI. The default is `~/.insightfactory`.
133
141
 
@@ -104,6 +104,14 @@ accepted and completed the request after `if-cli` gave up, so re-issuing a
104
104
  timeout message tells you to confirm the current state before retrying rather
105
105
  than repeat the call.
106
106
 
107
+ Every request to the factory carries an `X-IF-UA` header naming the client,
108
+ `if-cli/<version>` by default. A tool that runs `if-cli` can name itself first
109
+ by setting `INSIGHTFACTORY_UA` to one or more RFC 9110 product tokens, such as
110
+ `if-fde-harness/5.10.0`, and the header becomes
111
+ `if-fde-harness/5.10.0 if-cli/<version>`. The CLI ignores a value that is not
112
+ valid product-token syntax or is longer than 256 characters. Token requests to
113
+ the identity provider never carry the header.
114
+
107
115
  Override the config directory with `INSIGHTFACTORY_CONFIG_DIR`, which is useful
108
116
  in tests and CI. The default is `~/.insightfactory`.
109
117
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "insightfactory-cli"
3
- version = "1.1.2.dev31"
3
+ version = "1.2.0.dev34"
4
4
  description = "Profile-based authentication CLI for the InsightFactory Interfaces API"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -0,0 +1,51 @@
1
+ """The `X-IF-UA` client-attribution header.
2
+
3
+ The Interfaces API attributes work to the client that made the request by reading
4
+ `X-IF-UA`, a list of RFC 9110 product tokens. It takes the first token, splits it at
5
+ `/` into client and version, and ignores the rest. So a tool that drives `if-cli`
6
+ names itself first through `INSIGHTFACTORY_UA` and `if-cli` appends its own token:
7
+
8
+ X-IF-UA: if-cli/1.2.0 # INSIGHTFACTORY_UA unset
9
+ X-IF-UA: if-fde-harness/5.10.0 if-cli/1.2.0 # INSIGHTFACTORY_UA=if-fde-harness/5.10.0
10
+
11
+ The environment value is only ever sent after it matches the product-token grammar,
12
+ so a stray newline or shell fragment in it cannot add a header or reach the factory.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ import re
19
+ from functools import cache
20
+ from importlib.metadata import version
21
+
22
+ from if_cli.constants import ENV_UA
23
+
24
+ HEADER_NAME = "X-IF-UA"
25
+ MAX_CALLER_PRODUCTS_LENGTH = 256
26
+
27
+ # RFC 9110 section 5.6.2: token = 1*tchar. Section 10.1.5: product = token ["/" product-version].
28
+ _TCHAR = r"[!#$%&'*+\-.^_`|~0-9A-Za-z]"
29
+ _PRODUCT = rf"{_TCHAR}+(?:/{_TCHAR}+)?"
30
+ _PRODUCTS = re.compile(rf"{_PRODUCT}(?: {_PRODUCT})*")
31
+
32
+
33
+ @cache
34
+ def _own_product() -> str:
35
+ return f"if-cli/{version('insightfactory-cli')}"
36
+
37
+
38
+ def is_valid_products(value: str) -> bool:
39
+ """Whether `value` is one or more space-separated product tokens, within the length cap."""
40
+ return len(value) <= MAX_CALLER_PRODUCTS_LENGTH and _PRODUCTS.fullmatch(value) is not None
41
+
42
+
43
+ def build_x_if_ua() -> str:
44
+ """The `X-IF-UA` value: the caller's products from `INSIGHTFACTORY_UA`, then `if-cli`'s own.
45
+
46
+ An unset, empty or invalid `INSIGHTFACTORY_UA` leaves `if-cli`'s token alone.
47
+ """
48
+ caller = os.environ.get(ENV_UA)
49
+ if caller and is_valid_products(caller):
50
+ return f"{caller} {_own_product()}"
51
+ return _own_product()
@@ -1,5 +1,6 @@
1
1
  ENV_PROFILE: str = "INSIGHTFACTORY_CONFIG_PROFILE"
2
2
  ENV_REQUEST_TIMEOUT: str = "INSIGHTFACTORY_REQUEST_TIMEOUT"
3
+ ENV_UA: str = "INSIGHTFACTORY_UA"
3
4
  DEFAULT_SCOPE: str = "openid profile email offline_access"
4
5
  LOOPBACK_HOST: str = "127.0.0.1"
5
6
  DEFAULT_CALLBACK_PORT: int = 53682
@@ -10,6 +10,7 @@ from email.message import Message
10
10
  from typing import NoReturn
11
11
  from urllib.parse import ParseResult, urljoin, urlparse
12
12
 
13
+ from if_cli.attribution import HEADER_NAME, build_x_if_ua
13
14
  from if_cli.constants import ENV_REQUEST_TIMEOUT
14
15
  from if_cli.runtime import die
15
16
 
@@ -170,6 +171,7 @@ def fetch_with_timeout(
170
171
  body: str | bytes | None = None,
171
172
  timeout: float | None = None,
172
173
  warn_unsafe_retry: bool = False,
174
+ attribute: bool = True,
173
175
  ) -> HttpResponse:
174
176
  """Issue one request, dying on timeout.
175
177
 
@@ -177,7 +179,15 @@ def fetch_with_timeout(
177
179
  sense for a request the caller issued on the user's behalf against the factory.
178
180
  It is wrong advice for an OAuth round trip, where retrying is the correct response
179
181
  and there is no factory-side state to confirm.
182
+
183
+ `attribute` adds the `X-IF-UA` header (see `if_cli.attribution`) unless the
184
+ caller already set one. It is on for factory requests and off for the token
185
+ endpoint, which belongs to the identity provider: that is a third party, and the
186
+ header is for the factory's own client metrics.
180
187
  """
188
+ request_headers = dict(headers or {})
189
+ if attribute and not any(name.lower() == HEADER_NAME.lower() for name in request_headers):
190
+ request_headers[HEADER_NAME] = build_x_if_ua()
181
191
  data: bytes | None
182
192
  if body is None:
183
193
  data = None
@@ -185,7 +195,7 @@ def fetch_with_timeout(
185
195
  data = body
186
196
  else:
187
197
  data = body.encode("utf-8")
188
- request = urllib.request.Request(url, data=data, headers=headers or {}, method=method)
198
+ request = urllib.request.Request(url, data=data, headers=request_headers, method=method)
189
199
  effective_timeout = resolve_request_timeout(timeout)
190
200
  try:
191
201
  # Callers pass endpoints validated by parse_http_url or derived from a
@@ -128,6 +128,8 @@ def _token_request(token_endpoint: str, form: dict[str, str]) -> dict[str, objec
128
128
  headers={"Content-Type": "application/x-www-form-urlencoded"},
129
129
  body=urlencode(form),
130
130
  timeout=DEFAULT_REQUEST_TIMEOUT_S,
131
+ # The token endpoint is the identity provider's, not the factory's.
132
+ attribute=False,
131
133
  )
132
134
  if not response.ok:
133
135
  body = response.text()
@@ -9,6 +9,7 @@ import mcp_types as types
9
9
  from mcp.client import Client
10
10
  from mcp.client.streamable_http import streamable_http_client
11
11
 
12
+ from if_cli.attribution import HEADER_NAME, build_x_if_ua
12
13
  from if_cli.config import Profile
13
14
  from if_cli.http import parse_url, url_origin
14
15
  from if_cli.oauth import get_valid_token
@@ -200,7 +201,11 @@ class Upstream:
200
201
  async def _run(self, action, *, elicitation_callback=None):
201
202
  url = f"{self.profile['host']}/mcp"
202
203
  try:
203
- async with httpx2.AsyncClient(auth=ProfileBearerAuth(self.profile), timeout=self.timeout) as http_client:
204
+ async with httpx2.AsyncClient(
205
+ auth=ProfileBearerAuth(self.profile),
206
+ timeout=self.timeout,
207
+ headers={HEADER_NAME: build_x_if_ua()},
208
+ ) as http_client:
204
209
  async with Client(
205
210
  streamable_http_client(url, http_client=http_client),
206
211
  elicitation_callback=elicitation_callback,
@@ -7,5 +7,7 @@ import pytest
7
7
  def isolate_insightfactory_home(tmp_path, monkeypatch):
8
8
  monkeypatch.setenv("INSIGHTFACTORY_CONFIG_DIR", str(tmp_path))
9
9
  monkeypatch.delenv("INSIGHTFACTORY_CONFIG_PROFILE", raising=False)
10
+ # The FDE harness sets this in the shells it runs, tests included.
11
+ monkeypatch.delenv("INSIGHTFACTORY_UA", raising=False)
10
12
  monkeypatch.setenv("NO_COLOR", "1")
11
13
  return tmp_path
@@ -0,0 +1,160 @@
1
+ from __future__ import annotations
2
+
3
+ from importlib.metadata import version
4
+
5
+ import pytest
6
+
7
+ from if_cli import oauth
8
+ from if_cli.attribution import MAX_CALLER_PRODUCTS_LENGTH, build_x_if_ua
9
+ from if_cli.cache import store_tokens
10
+ from if_cli.config import Profile
11
+ from if_cli.http import fetch_with_timeout
12
+ from tests.servers import Request, Response, ThreadedServer
13
+
14
+ OWN = f"if-cli/{version('insightfactory-cli')}"
15
+
16
+
17
+ def _header(headers: dict[str, str], name: str = "X-IF-UA") -> str | None:
18
+ matches = [value for key, value in headers.items() if key.lower() == name.lower()]
19
+ assert len(matches) <= 1, f"{name} sent more than once: {matches}"
20
+ return matches[0] if matches else None
21
+
22
+
23
+ def _recording_server(response: Response | None = None) -> tuple[ThreadedServer, list[dict[str, str]]]:
24
+ seen: list[dict[str, str]] = []
25
+
26
+ def handler(request: Request) -> Response:
27
+ seen.append(request.headers)
28
+ return response or Response(body={"ok": True})
29
+
30
+ return ThreadedServer(handler).start(), seen
31
+
32
+
33
+ def _profile(host: str) -> Profile:
34
+ return {
35
+ "name": "a",
36
+ "host": host,
37
+ "audience": f"{host}/api",
38
+ "callback_port": 0,
39
+ "client_id": None,
40
+ "organization": None,
41
+ }
42
+
43
+
44
+ def test_unset_sends_only_the_cli_token():
45
+ assert build_x_if_ua() == OWN
46
+
47
+
48
+ @pytest.mark.parametrize(
49
+ "caller",
50
+ [
51
+ "if-fde-harness/5.10.0",
52
+ "if-fde-harness-mcp",
53
+ "if-fde-harness/5.10.0 other/1",
54
+ "a!#$%&'*+-.^_`|~z/9",
55
+ ],
56
+ )
57
+ def test_a_valid_value_goes_first(monkeypatch, caller: str):
58
+ monkeypatch.setenv("INSIGHTFACTORY_UA", caller)
59
+ assert build_x_if_ua() == f"{caller} {OWN}"
60
+
61
+
62
+ @pytest.mark.parametrize(
63
+ "caller",
64
+ [
65
+ "",
66
+ " ",
67
+ "\t",
68
+ "if-fde-harness/5.10.0\nX-Injected: 1",
69
+ "if-fde-harness/5.10.0\r\n",
70
+ "if-fde-harness/5.10.0\n",
71
+ "$(whoami)",
72
+ "if-fde-harness/$(whoami)",
73
+ " if-fde-harness/5.10.0",
74
+ "if-fde-harness/5.10.0 ",
75
+ "if-fde-harness/5.10.0 other/1",
76
+ "if-fde-harness/",
77
+ "/5.10.0",
78
+ "a/b/c",
79
+ "if-fde-harness/5.10.0 (comment)",
80
+ "harnessé/1",
81
+ "x" * (MAX_CALLER_PRODUCTS_LENGTH + 1),
82
+ ],
83
+ )
84
+ def test_an_invalid_value_is_dropped(monkeypatch, caller: str):
85
+ monkeypatch.setenv("INSIGHTFACTORY_UA", caller)
86
+ assert build_x_if_ua() == OWN
87
+
88
+
89
+ def test_a_value_at_the_cap_is_kept(monkeypatch):
90
+ caller = "x" * MAX_CALLER_PRODUCTS_LENGTH
91
+ monkeypatch.setenv("INSIGHTFACTORY_UA", caller)
92
+ assert build_x_if_ua() == f"{caller} {OWN}"
93
+
94
+
95
+ def test_fetch_with_timeout_sends_the_header(monkeypatch):
96
+ monkeypatch.setenv("INSIGHTFACTORY_UA", "if-fde-harness/5.10.0")
97
+ server, seen = _recording_server()
98
+ try:
99
+ fetch_with_timeout(f"{server.origin}/api/health")
100
+ finally:
101
+ server.close()
102
+ assert _header(seen[0]) == f"if-fde-harness/5.10.0 {OWN}"
103
+
104
+
105
+ def test_fetch_with_timeout_never_puts_an_invalid_value_on_the_wire(monkeypatch):
106
+ monkeypatch.setenv("INSIGHTFACTORY_UA", "if-fde-harness/5.10.0\r\nX-Injected: 1")
107
+ server, seen = _recording_server()
108
+ try:
109
+ fetch_with_timeout(f"{server.origin}/api/health")
110
+ finally:
111
+ server.close()
112
+ assert _header(seen[0]) == OWN
113
+ assert _header(seen[0], "X-Injected") is None
114
+
115
+
116
+ @pytest.mark.parametrize("name", ["X-IF-UA", "x-if-ua"])
117
+ def test_a_caller_supplied_header_wins(monkeypatch, name: str):
118
+ monkeypatch.setenv("INSIGHTFACTORY_UA", "if-fde-harness/5.10.0")
119
+ server, seen = _recording_server()
120
+ try:
121
+ fetch_with_timeout(f"{server.origin}/api/health", headers={name: "custom/1"})
122
+ finally:
123
+ server.close()
124
+ assert _header(seen[0]) == "custom/1"
125
+
126
+
127
+ def test_oauth_discovery_on_the_factory_carries_the_header(monkeypatch):
128
+ monkeypatch.setenv("INSIGHTFACTORY_UA", "if-fde-harness/5.10.0")
129
+ server, seen = _recording_server(
130
+ Response(
131
+ body={
132
+ "authorization_endpoint": "https://factory.example/authorize",
133
+ "token_endpoint": "https://factory.example/oauth/token",
134
+ }
135
+ )
136
+ )
137
+ try:
138
+ oauth._discover(server.origin)
139
+ finally:
140
+ server.close()
141
+ assert _header(seen[0]) == f"if-fde-harness/5.10.0 {OWN}"
142
+
143
+
144
+ def test_the_identity_provider_token_request_carries_no_header(monkeypatch):
145
+ monkeypatch.setenv("INSIGHTFACTORY_UA", "if-fde-harness/5.10.0")
146
+ idp, seen = _recording_server(Response(body={"access_token": "refreshed-token", "expires_in": 3600}))
147
+ try:
148
+ profile = _profile("https://factory.example")
149
+ store_tokens(
150
+ profile["host"],
151
+ {"access_token": "stale-token", "refresh_token": "refresh-tok", "expires_in": 0},
152
+ f"{idp.origin}/token",
153
+ "test-client",
154
+ replace=True,
155
+ )
156
+ assert oauth.get_valid_token(profile, force_refresh=True) == "refreshed-token"
157
+ finally:
158
+ idp.close()
159
+ assert len(seen) == 1
160
+ assert _header(seen[0]) is None
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import inspect
4
4
  import re
5
+ from importlib.metadata import version
5
6
  from typing import Any
6
7
 
7
8
  import pytest
@@ -15,7 +16,7 @@ import mcp_types as types # noqa: E402
15
16
  from if_cli.cache import store_tokens # noqa: E402
16
17
  from if_cli.config import Profile # noqa: E402
17
18
  from if_cli.router.upstream import ConnectUrlRecorder, ProfileBearerAuth, Upstream # noqa: E402
18
- from tests.servers import Response, ThreadedServer # noqa: E402
19
+ from tests.servers import Request, Response, ThreadedServer # noqa: E402
19
20
 
20
21
 
21
22
  def _profile(host: str) -> Profile:
@@ -85,6 +86,30 @@ def test_auth_remints_the_token_once_on_a_401_and_retries():
85
86
  idp.close()
86
87
 
87
88
 
89
+ def test_the_upstream_client_names_the_router_and_the_cli(monkeypatch):
90
+ """The router runs with its MCP registration env, so the harness token goes first."""
91
+ monkeypatch.setenv("INSIGHTFACTORY_UA", "if-fde-harness-mcp")
92
+ seen: list[dict[str, str]] = []
93
+
94
+ def handler(request: Request) -> Response:
95
+ seen.append(request.headers)
96
+ # Any error ends the session; only the first request's headers matter.
97
+ return Response(status=500, body="")
98
+
99
+ server = ThreadedServer(handler).start()
100
+ try:
101
+ profile = _profile(server.origin)
102
+ store_tokens(profile["host"], {"access_token": "test-token", "expires_in": 3600}, "", "c", replace=True)
103
+ with pytest.raises(Exception):
104
+ anyio.run(Upstream("prd", profile, timeout=5).list_tools)
105
+ finally:
106
+ server.close()
107
+ assert seen, "the router never reached the factory"
108
+ headers = {name.lower(): value for name, value in seen[0].items()}
109
+ assert headers["x-if-ua"] == f"if-fde-harness-mcp if-cli/{version('insightfactory-cli')}"
110
+ assert headers["authorization"] == "Bearer test-token"
111
+
112
+
88
113
  def _url_request(url: str, message: str = "Connect your Databricks account.") -> types.ElicitRequest:
89
114
  return types.ElicitRequest(params=types.ElicitRequestURLParams(message=message, url=url))
90
115
 
@@ -305,7 +305,7 @@ wheels = [
305
305
 
306
306
  [[package]]
307
307
  name = "insightfactory-cli"
308
- version = "1.1.2"
308
+ version = "1.2.0"
309
309
  source = { editable = "." }
310
310
 
311
311
  [package.optional-dependencies]