From 80fd8cf2c6b04bd1ba123ede4f2683b1de6c2e57 Mon Sep 17 00:00:00 2001 From: maplexu Date: Wed, 12 Aug 2026 12:40:10 -0400 Subject: [PATCH 1/3] AI-382: keep sandbox credentials out of workflow history A sandbox manifest's environment values are serialized into workflow history, which is durable, replayed, and visible in the Web UI. Because the manifest rides inside the session state passed to and returned from every sandbox activity, a literal credential is recorded repeatedly for the life of the session and survives rotation. Add SecretRef, an EnvValue subclass carrying a lookup key rather than a value. Upstream resolves it worker-side at the point the environment is needed and never rewrites the manifest, so only the reference persists. Two related fixes ride along. Host-path bindings in extra_path_grants were written to history in plaintext; they are now refused at the point the manifest crosses into a Temporal payload, which is the only place that sees grants added by a capability. And run_config accepts a dict upstream, which this plugin read attributes off directly, so a dict raised AttributeError before reaching sandbox validation. Requires openai-agents >= 0.19.2 for the EnvValue discriminator, capped below 0.20 where nine tests currently fail. --- .gitignore | 1 + pyproject.toml | 6 +- temporalio/contrib/openai_agents/README.md | 52 +++ temporalio/contrib/openai_agents/__init__.py | 2 + .../contrib/openai_agents/_openai_runner.py | 9 + .../openai_agents/sandbox/_secret_ref.py | 73 ++++ .../sandbox/_temporal_sandbox_client.py | 20 + .../openai_agents/test_openai_sandbox.py | 383 +++++++++++++++++- .../test_openai_sandbox_secrets.py | 218 ++++++++++ uv.lock | 66 +-- 10 files changed, 791 insertions(+), 39 deletions(-) create mode 100644 temporalio/contrib/openai_agents/sandbox/_secret_ref.py create mode 100644 tests/contrib/openai_agents/test_openai_sandbox_secrets.py diff --git a/.gitignore b/.gitignore index 8cd439e05..5522ed255 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .venv +.mypy_cache __pycache__ /build /dist diff --git a/pyproject.toml b/pyproject.toml index ab9f2638e..57f09164c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ classifiers = [ grpc = ["grpcio>=1.48.2,<2"] opentelemetry = ["opentelemetry-api>=1.11.1,<2", "opentelemetry-sdk>=1.11.1,<2"] pydantic = ["pydantic>=2.0.0,<3"] -openai-agents = ["openai-agents>=0.17.5", "mcp>=1.9.4, <2"] +openai-agents = ["openai-agents>=0.19.2,<0.20", "mcp>=1.9.4, <2"] google-adk = ["google-adk>=2.2.0,<3", "mcp>=1.24,<2"] langgraph = ["langgraph>=1.1.0"] langsmith = ["langsmith>=0.7.34,<0.9"] @@ -77,8 +77,8 @@ dev = [ "pytest-cov>=6.1.1", "httpx>=0.28.1", "pytest-pretty>=1.3.0", - "openai-agents>=0.14.0; python_version >= '3.14'", - "openai-agents[litellm]>=0.14.0; python_version < '3.14'", + "openai-agents>=0.19.2,<0.20; python_version >= '3.14'", + "openai-agents[litellm]>=0.19.2,<0.20; python_version < '3.14'", "litellm>=1.83.0", "openinference-instrumentation-google-adk>=0.1.11", "googleapis-common-protos>=1.75.0,<2", diff --git a/temporalio/contrib/openai_agents/README.md b/temporalio/contrib/openai_agents/README.md index 83539044c..50524fccf 100644 --- a/temporalio/contrib/openai_agents/README.md +++ b/temporalio/contrib/openai_agents/README.md @@ -612,6 +612,58 @@ result = await Runner.run( ) ``` +### Environment Variables and Secrets + +Environment variables you give a sandbox are written into workflow history, which is durable and visible in the web UI. A literal value stays there after you rotate it. + +`SecretRef` records the variable's name instead of its value. The worker reads the value from its own environment when the sandbox needs it: + +```python +from agents import RunConfig, Runner +from agents.sandbox import Manifest, SandboxRunConfig +from agents.sandbox.manifest import Environment +from agents.extensions.sandbox.daytona import DaytonaSandboxClientOptions + +from temporalio.contrib.openai_agents import SecretRef +from temporalio.contrib.openai_agents.workflow import temporal_sandbox_client + +manifest = Manifest( + environment=Environment( + value={ + "OPENAI_API_KEY": SecretRef(key="OPENAI_API_KEY"), + "REGION": "us-west-2", + } + ) +) + +result = await Runner.run( + agent, prompt, + run_config=RunConfig(sandbox=SandboxRunConfig( + client=temporal_sandbox_client("daytona"), + options=DaytonaSandboxClientOptions(pause_on_exit=False), + manifest=manifest, + )), +) +``` + +Set the variable on every worker that runs sandbox activities. If it is missing or empty, the run fails with a non-retryable error naming it. + +The two names need not match. `{"OPENAI_API_KEY": SecretRef(key="PROD_OPENAI_KEY")}` reads `PROD_OPENAI_KEY` on the worker and sets `OPENAI_API_KEY` inside the sandbox. + +#### Where secrets still do not belong + +Environment variables are the only place a `SecretRef` fits. Everything else you put in a manifest or in client options is recorded as you wrote it, so keep secrets out of: + +- **Inline file contents.** `File(content=b"...")` appears verbatim in history. Write the file from inside the sandbox, using an environment variable for the secret. +- **Environment fields on client options**, such as `DaytonaSandboxClientOptions.env_vars`. Put the values in the manifest environment instead. +- **Credential fields on client options**, such as `CloudflareSandboxClientOptions.api_key`. + +#### Unsupported inputs + +Path grants that bind a host path are rejected, because the host path itself would be recorded. Grant paths inside the sandbox instead. + +A live sandbox session passed to `SandboxRunConfig(session=...)` is also rejected — the plugin needs to create the session itself so its work runs in activities. + ## Streaming ⚠️ **Experimental** - This functionality is subject to change prior to General Availability. diff --git a/temporalio/contrib/openai_agents/__init__.py b/temporalio/contrib/openai_agents/__init__.py index 3976f633c..284a3c1c9 100644 --- a/temporalio/contrib/openai_agents/__init__.py +++ b/temporalio/contrib/openai_agents/__init__.py @@ -16,6 +16,7 @@ from temporalio.contrib.openai_agents.sandbox._sandbox_client_provider import ( SandboxClientProvider, ) +from temporalio.contrib.openai_agents.sandbox._secret_ref import SecretRef from temporalio.contrib.openai_agents.workflow import AgentsWorkflowError from . import testing, workflow @@ -26,6 +27,7 @@ "OpenAIAgentsPlugin", "OpenAIPayloadConverter", "SandboxClientProvider", + "SecretRef", "StatelessMCPServerProvider", "StatefulMCPServerProvider", "testing", diff --git a/temporalio/contrib/openai_agents/_openai_runner.py b/temporalio/contrib/openai_agents/_openai_runner.py index ea2e6e5df..4dea95275 100644 --- a/temporalio/contrib/openai_agents/_openai_runner.py +++ b/temporalio/contrib/openai_agents/_openai_runner.py @@ -196,6 +196,15 @@ def _prepare_workflow_run( " from temporalio.contrib.openai_agents.workflow import temporal_sandbox_client\n" " run_config = RunConfig(sandbox=SandboxRunConfig(client=temporal_sandbox_client('my-backend')))" ) + # Checked before the client, which upstream never resolves for a live + # session: it configures the session and returns, so no activity -- + # and no manifest check -- ever runs. + elif run_config.sandbox.session is not None: + raise AgentsWorkflowError( + "run_config.sandbox.session is not supported by the Temporal OpenAI Agents " + "plugin. A live sandbox session is not a durable construct in a workflow. " + "Pass run_config.sandbox.client=temporal_sandbox_client(name) instead." + ) elif run_config.sandbox.client is None: raise ValueError( "run_config.sandbox.client must be set to a temporal sandbox client. " diff --git a/temporalio/contrib/openai_agents/sandbox/_secret_ref.py b/temporalio/contrib/openai_agents/sandbox/_secret_ref.py new file mode 100644 index 000000000..c54962b2b --- /dev/null +++ b/temporalio/contrib/openai_agents/sandbox/_secret_ref.py @@ -0,0 +1,73 @@ +"""Reference to a secret held in the worker process environment.""" + +from __future__ import annotations + +import os +from typing import Literal + +from agents.sandbox.manifest import EnvValue + +from temporalio import workflow +from temporalio.exceptions import ApplicationError + + +class SecretRef(EnvValue): + """A sandbox environment variable whose value is read on the worker. + + .. warning:: + This class is experimental and may change in future versions. + Use with caution in production environments. + + Use it in place of the value, so only the variable's name reaches + workflow history:: + + from agents.sandbox import Manifest + from agents.sandbox.manifest import Environment + + from temporalio.contrib.openai_agents import SecretRef + + manifest = Manifest( + environment=Environment( + value={ + "OPENAI_API_KEY": SecretRef(key="OPENAI_API_KEY"), + "REGION": "us-west-2", + } + ) + ) + + Set the variable on every worker that runs sandbox activities. The two + names need not match: ``{"OPENAI_API_KEY": SecretRef(key="PROD_KEY")}`` + reads ``PROD_KEY`` on the worker and sets ``OPENAI_API_KEY`` inside the + sandbox. + """ + + type: Literal["temporal.secret_ref"] = "temporal.secret_ref" # type: ignore[assignment] + """Discriminator for this environment value type.""" + + key: str + """Name of the environment variable to read on the worker.""" + + async def resolve(self) -> str: + """Return the secret read from the worker's environment. + + Raises: + ApplicationError: If :py:attr:`key` is unset or empty, or if called + from workflow code. Non-retryable. + """ + if workflow.in_workflow(): + raise ApplicationError( + "SecretRef.resolve() must run on a worker, not in workflow code: it " + "reads the process environment, which is non-deterministic on replay " + "and would pull the secret into workflow state.", + type="SecretRefUnusable", + non_retryable=True, + ) + value = os.environ.get(self.key) + if not value: + raise ApplicationError( + f"SecretRef environment variable {self.key!r} is not set, or is " + "empty, in the worker process environment.", + type="SecretRefUnusable", + non_retryable=True, + ) + return value diff --git a/temporalio/contrib/openai_agents/sandbox/_temporal_sandbox_client.py b/temporalio/contrib/openai_agents/sandbox/_temporal_sandbox_client.py index 891c65f4b..e0c47d8bf 100644 --- a/temporalio/contrib/openai_agents/sandbox/_temporal_sandbox_client.py +++ b/temporalio/contrib/openai_agents/sandbox/_temporal_sandbox_client.py @@ -68,6 +68,7 @@ async def create( options: BaseSandboxClientOptions, ) -> SandboxSession: """Create a new sandbox session via activity.""" + _reject_host_path_grants(manifest) result: SessionResult = await workflow.execute_activity( f"{self._name}-sandbox_client_create", arg=CreateSessionArgs( @@ -93,6 +94,7 @@ async def create( async def resume(self, state: SandboxSessionState) -> SandboxSession: """Resume an existing sandbox session via activity.""" + _reject_host_path_grants(state.manifest) result: SessionResult = await workflow.execute_activity( f"{self._name}-sandbox_client_resume", arg=ResumeSessionArgs(state=state), @@ -122,3 +124,21 @@ async def delete(self, session: TemporalSandboxSession) -> TemporalSandboxSessio def deserialize_session_state(self, payload: dict[str, Any]) -> SandboxSessionState: """Deserialize a session state from a dict.""" return SandboxSessionState.parse(payload) + + +def _reject_host_path_grants(manifest: Manifest | None) -> None: + """Reject path grants bound to a host path before the manifest reaches a payload.""" + # Imported here because workflow.py imports this module. + from temporalio.contrib.openai_agents.workflow import AgentsWorkflowError + + if manifest is None: + return + # Names the sandbox-side path, never host_path: this message is recorded in + # the WorkflowExecutionFailed event. + bound = [g.path for g in manifest.extra_path_grants if g.host_path is not None] + if bound: + raise AgentsWorkflowError( + "Sandbox path grants with a host_path are not supported by the Temporal OpenAI " + f"Agents plugin (found: {', '.join(bound)}). The host path is recorded in " + "workflow history in plaintext. Remove host_path from these grants." + ) diff --git a/tests/contrib/openai_agents/test_openai_sandbox.py b/tests/contrib/openai_agents/test_openai_sandbox.py index 3338f8d64..ebaa6c84a 100644 --- a/tests/contrib/openai_agents/test_openai_sandbox.py +++ b/tests/contrib/openai_agents/test_openai_sandbox.py @@ -14,6 +14,7 @@ SandboxError, WorkspaceArchiveReadError, ) +from agents.sandbox.manifest import Environment from agents.sandbox.session.base_sandbox_session import BaseSandboxSession from agents.sandbox.session.sandbox_client import ( BaseSandboxClient, @@ -23,15 +24,19 @@ from agents.sandbox.session.sandbox_session_state import SandboxSessionState from agents.sandbox.snapshot import NoopSnapshot from agents.sandbox.types import ExecResult -from pydantic import TypeAdapter +from agents.sandbox.workspace_paths import SandboxPathGrant +from pydantic import BaseModel, TypeAdapter from pydantic_core import to_json from temporalio import workflow -from temporalio.client import Client +from temporalio.client import Client, WorkflowFailureError from temporalio.contrib.openai_agents import ( + AgentsWorkflowError, ModelActivityParameters, OpenAIAgentsPlugin, + OpenAIPayloadConverter, SandboxClientProvider, + SecretRef, ) from temporalio.contrib.openai_agents._openai_runner import _has_sandbox_agent from temporalio.contrib.openai_agents.sandbox._temporal_activity_models import ( @@ -185,6 +190,30 @@ async def run(self) -> str: except ValueError as e: assert "temporal_sandbox_client(name)" in str(e) + # Case 5: run_config as a dict must reach the same validation + try: + agent = SandboxAgent[None](name="sandbox") + await Runner.run( + starting_agent=agent, + input="hello", + run_config={"sandbox": SandboxRunConfig(client=None)}, # type: ignore[typeddict-item] + ) + return "FAIL: dict-run-config should have raised" + except ValueError as e: + assert "run_config.sandbox.client must be set" in str(e) + + # Case 6: fully nested dict, where SandboxRunConfig is itself a dict + try: + agent = SandboxAgent[None](name="sandbox") + await Runner.run( + starting_agent=agent, + input="hello", + run_config={"sandbox": {"client": None}}, # type: ignore[typeddict-item] + ) + return "FAIL: nested-dict-run-config should have raised" + except ValueError as e: + assert "run_config.sandbox.client must be set" in str(e) + return "OK" @@ -195,7 +224,12 @@ async def test_sandbox_validation_errors(client: Client): async with new_worker( client, SandboxValidationWorkflow, - workflow_failure_exception_types=[ValueError, AssertionError], + # Deliberately wider than production, where these types hang the workflow. + workflow_failure_exception_types=[ + ValueError, + AssertionError, + AttributeError, + ], ) as worker: result = await client.execute_workflow( SandboxValidationWorkflow.run, @@ -285,6 +319,7 @@ def __init__(self, session: _MockSandboxSession | None = None) -> None: self.create_calls: int = 0 self.resume_calls: int = 0 self.delete_calls: int = 0 + self.resolved_envs: dict[str, str] | None = None async def create( self, @@ -296,6 +331,7 @@ async def create( self.create_calls += 1 if manifest is not None: self.inner_session.state.manifest = manifest + self.resolved_envs = await manifest.environment.resolve() return self.session async def resume(self, state: SandboxSessionState) -> SandboxSession: @@ -358,6 +394,40 @@ async def test_activities_create_session_delegates( assert isinstance(result.supports_pty, bool) +async def test_create_session_activity_resolves_secret_ref_but_returns_the_reference( + sandbox_activities: SandboxClientProvider, + mock_client: _MockSandboxClient, + monkeypatch: pytest.MonkeyPatch, +): + """Both activity payloads carry the reference while the worker sees the secret.""" + secret = "sk-activity-boundary-secret" + monkeypatch.setenv("WORKER_ACTIVITY_SECRET", secret) + + def payload_bytes(value: BaseModel) -> bytes: + payload = OpenAIPayloadConverter().to_payload(value) + assert payload is not None + return payload.data + + args = CreateSessionArgs( + snapshot_spec=None, + manifest=Manifest( + environment=Environment( + value={"API_KEY": SecretRef(key="WORKER_ACTIVITY_SECRET")} + ) + ), + client_options=None, + ) + assert secret.encode() not in payload_bytes(args) + + acts = _activity_map(sandbox_activities) + result = await acts["mock-sandbox_client_create"](args) + + assert mock_client.resolved_envs == {"API_KEY": secret} + returned = payload_bytes(result) + assert secret.encode() not in returned + assert b"temporal.secret_ref" in returned + + async def test_activities_resume_session_delegates( sandbox_activities: SandboxClientProvider, mock_client: _MockSandboxClient, @@ -818,6 +888,313 @@ async def run(self) -> str: return result.final_output +_HOST_PATH = "/host/private-dir" +_HOST_PATH_GRANT_MANIFEST = Manifest( + extra_path_grants=( + SandboxPathGrant(path="/workspace/shared", host_path=_HOST_PATH), + ) +) +# A clean grant first, so a check that only inspects index 0 fails this. +_TRAILING_GRANT_MANIFEST = Manifest( + extra_path_grants=( + SandboxPathGrant(path="/workspace/clean"), + SandboxPathGrant(path="/workspace/shared", host_path=_HOST_PATH), + ) +) +_TWO_BOUND_GRANTS_MANIFEST = Manifest( + extra_path_grants=( + SandboxPathGrant(path="/workspace/shared", host_path=_HOST_PATH), + SandboxPathGrant(path="/workspace/other", host_path="/host/second-dir"), + ) +) + + +class _GrantInjectingCapability(Capability): + """Adds a bound grant in ``process_manifest``, after the run boundary has looked.""" + + def __init__(self) -> None: + super().__init__(type="grant_injecting") + + def process_manifest(self, manifest: Manifest) -> Manifest: + return manifest.model_copy( + update={ + "extra_path_grants": ( + *manifest.extra_path_grants, + SandboxPathGrant(path="/workspace/injected", host_path=_HOST_PATH), + ) + } + ) + + +@workflow.defn +class HostPathGrantWorkflow: + @workflow.run + async def run(self, route: str) -> str: + agent = SandboxAgent[None](name="sandbox-grant") + client = temporal_sandbox_client("mock") + options = _TestSandboxClientOptions() + expected = "/workspace/shared" + + if route == "run_config_manifest": + sandbox = SandboxRunConfig( + client=client, options=options, manifest=_HOST_PATH_GRANT_MANIFEST + ) + elif route == "default_manifest": + agent = SandboxAgent[None]( + name="sandbox-grant", default_manifest=_HOST_PATH_GRANT_MANIFEST + ) + sandbox = SandboxRunConfig(client=client, options=options) + elif route == "session_state": + sandbox = SandboxRunConfig( + client=client, + options=options, + session_state=TestSessionState( + manifest=_HOST_PATH_GRANT_MANIFEST, + snapshot=NoopSnapshot(id=str(workflow.uuid4())), + ), + ) + elif route == "capability": + # Only the client boundary sees this: it is appended after upstream + # resolves the effective manifest. The manifest is empty but present, + # because upstream skips capabilities entirely when there is none. + agent = SandboxAgent[None]( + name="sandbox-grant", capabilities=[_GrantInjectingCapability()] + ) + sandbox = SandboxRunConfig( + client=client, options=options, manifest=Manifest() + ) + expected = "/workspace/injected" + elif route == "trailing_grant": + sandbox = SandboxRunConfig( + client=client, options=options, manifest=_TRAILING_GRANT_MANIFEST + ) + elif route == "two_bound_grants": + sandbox = SandboxRunConfig( + client=client, options=options, manifest=_TWO_BOUND_GRANTS_MANIFEST + ) + expected = "/workspace/shared, /workspace/other" + else: + raise AssertionError(f"unknown route {route}") + + try: + await Runner.run( + starting_agent=agent, + input="hello", + run_config=RunConfig(sandbox=sandbox), + ) + except AgentsWorkflowError as e: + assert expected in str(e), str(e) + # The guard must not name the host path: this text reaches history. + assert _HOST_PATH not in str(e), str(e) + return "REJECTED" + return "NOT REJECTED" + + +@pytest.mark.parametrize( + "route", + [ + "run_config_manifest", + "default_manifest", + "session_state", + "capability", + "trailing_grant", + "two_bound_grants", + ], +) +async def test_host_path_grants_are_rejected_per_manifest_source( + client: Client, route: str +): + """Each manifest source, including one only the client boundary can see.""" + mock_sandbox_client = _MockSandboxClient(_MockSandboxSession()) + plugin = OpenAIAgentsPlugin( + model_params=ModelActivityParameters( + start_to_close_timeout=timedelta(seconds=30), + ), + model_provider=TestModelProvider( + TestModel.returning_responses([ResponseBuilders.output_message("done")]) + ), + sandbox_clients=[SandboxClientProvider("mock", mock_sandbox_client)], + ) + new_config = client.config() + new_config["plugins"] = [plugin] + test_client = Client(**new_config) + + async with new_worker( + test_client, + HostPathGrantWorkflow, + workflow_failure_exception_types=[Exception], + ) as worker: + result = await test_client.execute_workflow( + HostPathGrantWorkflow.run, + route, + id=f"host-path-grant-{route}-{uuid.uuid4()}", + task_queue=worker.task_queue, + execution_timeout=timedelta(seconds=15), + ) + + assert result == "REJECTED" + assert mock_sandbox_client.create_calls == 0 + assert mock_sandbox_client.resume_calls == 0 + + +@workflow.defn +class UncaughtHostPathGrantWorkflow: + @workflow.run + async def run(self) -> str: + await Runner.run( + starting_agent=SandboxAgent[None](name="sandbox-grant"), + input="hello", + run_config=RunConfig( + sandbox=SandboxRunConfig( + client=temporal_sandbox_client("mock"), + options=_TestSandboxClientOptions(), + manifest=_HOST_PATH_GRANT_MANIFEST, + ), + ), + ) + return "NOT REJECTED" + + +async def test_host_path_grant_fails_the_workflow_on_a_production_like_worker( + client: Client, +): + """Given no ``workflow_failure_exception_types``, so only the plugin's own applies. + + If the error were rewrapped into an unregistered type the workflow task + would retry until ``execution_timeout`` and this would report a timeout. + """ + mock_sandbox_client = _MockSandboxClient(_MockSandboxSession()) + plugin = OpenAIAgentsPlugin( + model_params=ModelActivityParameters( + start_to_close_timeout=timedelta(seconds=30), + ), + model_provider=TestModelProvider( + TestModel.returning_responses([ResponseBuilders.output_message("done")]) + ), + sandbox_clients=[SandboxClientProvider("mock", mock_sandbox_client)], + ) + new_config = client.config() + new_config["plugins"] = [plugin] + test_client = Client(**new_config) + + async with new_worker(test_client, UncaughtHostPathGrantWorkflow) as worker: + with pytest.raises(WorkflowFailureError) as exc_info: + await test_client.execute_workflow( + UncaughtHostPathGrantWorkflow.run, + id=f"host-path-uncaught-{uuid.uuid4()}", + task_queue=worker.task_queue, + execution_timeout=timedelta(seconds=15), + ) + + cause = exc_info.value.cause + assert isinstance(cause, ApplicationError), cause + assert cause.type == "AgentsWorkflowError", cause.type + assert "/workspace/shared" in str(cause) + # The WorkflowExecutionFailed event must not carry the host path. + assert _HOST_PATH not in str(cause) + assert mock_sandbox_client.create_calls == 0 + + +@workflow.defn +class ResolveOnWorkflowThreadWorkflow: + @workflow.run + async def run(self) -> str: + try: + await SecretRef(key="WORKER_THREAD_PROBE_KEY").resolve() + except ApplicationError as e: + return e.type or "" + return "NO RAISE" + + +async def test_secret_ref_resolve_raises_inside_a_real_workflow( + client: Client, monkeypatch: pytest.MonkeyPatch +): + """``in_workflow()`` is genuinely True here, unlike the monkeypatched unit test. + + The variable is set in this process so that removing the guard makes + ``resolve()`` succeed and this fail, rather than raising for being unset. + """ + monkeypatch.setenv("WORKER_THREAD_PROBE_KEY", "value-that-must-not-be-read") + plugin = OpenAIAgentsPlugin( + model_params=ModelActivityParameters( + start_to_close_timeout=timedelta(seconds=30), + ), + model_provider=TestModelProvider( + TestModel.returning_responses([ResponseBuilders.output_message("done")]) + ), + ) + new_config = client.config() + new_config["plugins"] = [plugin] + test_client = Client(**new_config) + + async with new_worker( + test_client, + ResolveOnWorkflowThreadWorkflow, + workflow_failure_exception_types=[Exception], + ) as worker: + result = await test_client.execute_workflow( + ResolveOnWorkflowThreadWorkflow.run, + id=f"resolve-in-workflow-{uuid.uuid4()}", + task_queue=worker.task_queue, + execution_timeout=timedelta(seconds=15), + ) + + assert result == "SecretRefUnusable" + + +@workflow.defn +class LiveSandboxSessionWorkflow: + @workflow.run + async def run(self) -> str: + try: + await Runner.run( + starting_agent=SandboxAgent[None](name="sandbox-live"), + input="hello", + run_config=RunConfig( + sandbox=SandboxRunConfig( + # Rejected on presence, so the value is never used. + session=object(), # type: ignore[arg-type] + ), + ), + ) + except AgentsWorkflowError as e: + assert "run_config.sandbox.session" in str(e), str(e) + return "REJECTED" + return "NOT REJECTED" + + +async def test_live_sandbox_session_is_rejected(client: Client): + """A live session returns before ``_resolve_client()``, so no activity guard runs.""" + mock_sandbox_client = _MockSandboxClient(_MockSandboxSession()) + plugin = OpenAIAgentsPlugin( + model_params=ModelActivityParameters( + start_to_close_timeout=timedelta(seconds=30), + ), + model_provider=TestModelProvider( + TestModel.returning_responses([ResponseBuilders.output_message("done")]) + ), + sandbox_clients=[SandboxClientProvider("mock", mock_sandbox_client)], + ) + new_config = client.config() + new_config["plugins"] = [plugin] + test_client = Client(**new_config) + + async with new_worker( + test_client, + LiveSandboxSessionWorkflow, + workflow_failure_exception_types=[Exception], + ) as worker: + result = await test_client.execute_workflow( + LiveSandboxSessionWorkflow.run, + id=f"live-session-{uuid.uuid4()}", + task_queue=worker.task_queue, + execution_timeout=timedelta(seconds=15), + ) + + assert result == "REJECTED" + assert mock_sandbox_client.create_calls == 0 + + async def test_sandbox_e2e_runner(client: Client): """End-to-end: Runner.run() with SandboxAgent exercises the full sandbox lifecycle (create, start, stop, shutdown, delete) through Temporal activities.""" diff --git a/tests/contrib/openai_agents/test_openai_sandbox_secrets.py b/tests/contrib/openai_agents/test_openai_sandbox_secrets.py new file mode 100644 index 000000000..08f802332 --- /dev/null +++ b/tests/contrib/openai_agents/test_openai_sandbox_secrets.py @@ -0,0 +1,218 @@ +"""Tests for keeping sandbox environment secrets out of workflow history.""" + +from __future__ import annotations + +import uuid +from typing import Any, Literal + +import pytest +from agents.sandbox import Manifest +from agents.sandbox.manifest import EnvEntry, Environment, EnvValue, StrEnvValue +from agents.sandbox.session.sandbox_session_state import SandboxSessionState +from agents.sandbox.snapshot import NoopSnapshot +from agents.sandbox.workspace_paths import SandboxPathGrant +from pydantic import BaseModel, TypeAdapter +from pydantic_core import SchemaSerializer +from pydantic_core.core_schema import any_schema + +from temporalio.contrib.openai_agents import OpenAIPayloadConverter, SecretRef +from temporalio.contrib.openai_agents.sandbox._temporal_activity_models import ExecArgs +from temporalio.exceptions import ApplicationError + +SECRET = "sk-not-in-history-1234567890" +KEY = "TEST_SECRET_REF_KEY" + + +class _SecretRefSessionState(SandboxSessionState): + """Concrete session state so manifests can travel the real activity models.""" + + type: Literal["secret_ref_test"] = "secret_ref_test" # type: ignore[assignment] + + +def _payload_bytes(value: BaseModel) -> bytes: + payload = OpenAIPayloadConverter().to_payload(value) + assert payload is not None + return payload.data + + +def _round_trip(value: BaseModel, type_hint: type) -> Any: + converter = OpenAIPayloadConverter() + payload = converter.to_payload(value) + assert payload is not None + return converter.from_payload(payload, type_hint) + + +def _manifest(env: dict[str, Any]) -> Manifest: + return Manifest(environment=Environment(value=env)) + + +def _state(manifest: Manifest) -> _SecretRefSessionState: + return _SecretRefSessionState( + manifest=manifest, snapshot=NoopSnapshot(id=str(uuid.uuid4())) + ) + + +def test_literal_env_value_is_written_into_the_payload() -> None: + """The motivating behaviour, not a defect: literal values keep working.""" + raw = _payload_bytes(_manifest({KEY: SECRET})) + assert SECRET.encode() in raw + + +def test_secret_ref_round_trips_without_the_secret() -> None: + raw = _payload_bytes(_manifest({KEY: SecretRef(key=KEY)})) + assert SECRET.encode() not in raw + assert b"temporal.secret_ref" in raw + + back = _round_trip(_manifest({KEY: SecretRef(key=KEY)}), Manifest) + value = back.environment.value[KEY] + assert isinstance(value, SecretRef) + assert value.key == KEY + + +def test_secret_ref_round_trips_inside_an_env_entry() -> None: + manifest = _manifest({KEY: EnvEntry(value=SecretRef(key=KEY))}) + raw = _payload_bytes(manifest) + assert SECRET.encode() not in raw + assert b"temporal.secret_ref" in raw + + back = _round_trip(manifest, Manifest) + entry = back.environment.value[KEY] + assert isinstance(entry, EnvEntry) + assert isinstance(entry.value, SecretRef) + assert entry.value.key == KEY + + +def test_secret_ref_survives_the_durable_activity_path() -> None: + args = ExecArgs(state=_state(_manifest({KEY: SecretRef(key=KEY)})), command=["ls"]) + raw = _payload_bytes(args) + assert SECRET.encode() not in raw + + back = _round_trip(args, ExecArgs) + value = back.state.manifest.environment.value[KEY] + assert isinstance(value, SecretRef) + assert value.key == KEY + + +def test_literal_env_values_are_untouched_alongside_a_secret_ref() -> None: + manifest = _manifest({KEY: SecretRef(key=KEY), "REGION": "us-west-2"}) + back = _round_trip(manifest, Manifest) + + assert isinstance(back.environment.value[KEY], SecretRef) + assert back.environment.value["REGION"] == "us-west-2" + + normalized = back.environment.normalized() + assert isinstance(normalized["REGION"].value, StrEnvValue) + assert normalized["REGION"].value.value == "us-west-2" + + +def test_discriminator_survives_exclude_unset() -> None: + """Guards a property split between our ``exclude_unset`` and upstream's wrap serializers.""" + serializer = SchemaSerializer(any_schema()) + raw = serializer.to_json(_manifest({KEY: SecretRef(key=KEY)}), exclude_unset=True) + assert b"temporal.secret_ref" in raw + + back = TypeAdapter(Manifest).validate_json(raw) + assert isinstance(back.environment.value[KEY], SecretRef) + + +def test_a_host_path_grant_would_reach_the_payload_unprotected() -> None: + """Why host-path grants are refused: nothing keeps the host path out of history.""" + manifest = Manifest( + extra_path_grants=( + SandboxPathGrant(path="/workspace/shared", host_path="/host/private-dir"), + ) + ) + assert b"/host/private-dir" in _payload_bytes(manifest) + assert b"/host/private-dir" in _payload_bytes(_state(manifest)) + + +def test_secret_ref_tag_is_namespaced() -> None: + """Upstream raises on a duplicate tag, so the namespace keeps it registrable.""" + tag = SecretRef(key=KEY).type + assert tag.startswith("temporal.") + assert EnvValue._subclass_registry[tag] is SecretRef + + +# ── resolve() ── + + +async def test_resolve_reads_the_worker_process_environment( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv(KEY, SECRET) + assert await SecretRef(key=KEY).resolve() == SECRET + + +async def test_resolve_raises_naming_the_key_when_unset( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv(KEY, raising=False) + with pytest.raises(ApplicationError) as exc_info: + await SecretRef(key=KEY).resolve() + + assert KEY in str(exc_info.value) + assert exc_info.value.non_retryable + + +async def test_resolve_raises_when_the_variable_is_set_but_empty( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv(KEY, "") + with pytest.raises(ApplicationError) as exc_info: + await SecretRef(key=KEY).resolve() + + assert KEY in str(exc_info.value) + assert exc_info.value.non_retryable + + +async def test_resolve_refuses_to_run_on_the_workflow_thread( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv(KEY, SECRET) + monkeypatch.setattr("temporalio.workflow.in_workflow", lambda: True) + with pytest.raises(ApplicationError) as exc_info: + await SecretRef(key=KEY).resolve() + + assert exc_info.value.non_retryable + assert SECRET not in str(exc_info.value) + + +async def test_each_reference_resolves_its_own_variable_under_its_own_name( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Crossed names pin key-to-value pairing, which resolving by mapping key breaks.""" + monkeypatch.setenv("WORKER_PRIMARY", "primary-secret") + monkeypatch.setenv("WORKER_SECONDARY", "secondary-secret") + manifest = _manifest( + { + "REGION": "us-west-2", + "SANDBOX_PRIMARY": SecretRef(key="WORKER_PRIMARY"), + "LOG_LEVEL": "debug", + "SANDBOX_SECONDARY": SecretRef(key="WORKER_SECONDARY"), + } + ) + + assert await manifest.environment.resolve() == { + "REGION": "us-west-2", + "SANDBOX_PRIMARY": "primary-secret", + "LOG_LEVEL": "debug", + "SANDBOX_SECONDARY": "secondary-secret", + } + + raw = _payload_bytes(manifest) + for secret in (b"primary-secret", b"secondary-secret"): + assert secret not in raw + for name in (b"WORKER_PRIMARY", b"WORKER_SECONDARY"): + assert name in raw + + +async def test_environment_resolve_leaves_the_manifest_holding_references( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv(KEY, SECRET) + manifest = _manifest({KEY: SecretRef(key=KEY), "REGION": "us-west-2"}) + + assert await manifest.environment.resolve() == {KEY: SECRET, "REGION": "us-west-2"} + + assert isinstance(manifest.environment.value[KEY], SecretRef) + assert SECRET.encode() not in _payload_bytes(manifest) diff --git a/uv.lock b/uv.lock index f64424dca..a840e8bb9 100644 --- a/uv.lock +++ b/uv.lock @@ -257,14 +257,14 @@ name = "anthropic" version = "0.117.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, - { name = "distro" }, - { name = "docstring-parser" }, - { name = "httpx" }, - { name = "jiter" }, - { name = "pydantic" }, - { name = "sniffio" }, - { name = "typing-extensions" }, + { name = "anyio", marker = "python_full_version >= '3.11'" }, + { name = "distro", marker = "python_full_version >= '3.11'" }, + { name = "docstring-parser", marker = "python_full_version >= '3.11'" }, + { name = "httpx", marker = "python_full_version >= '3.11'" }, + { name = "jiter", marker = "python_full_version >= '3.11'" }, + { name = "pydantic", marker = "python_full_version >= '3.11'" }, + { name = "sniffio", marker = "python_full_version >= '3.11'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/41/0d/8f71d535edb0d438f023bd825fb65f67c14fa88a2bd6b75f292a58a63de4/anthropic-0.117.0.tar.gz", hash = "sha256:98107f2b76439641e0ae2a1754087534b8f178dbab99d6eb1bc4b7bc8c744496", size = 989933, upload-time = "2026-07-16T19:36:13.07Z" } wheels = [ @@ -942,12 +942,12 @@ name = "deepagents" version = "0.6.12" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "langchain" }, - { name = "langchain-anthropic" }, - { name = "langchain-core" }, - { name = "langchain-google-genai" }, - { name = "langsmith" }, - { name = "wcmatch" }, + { name = "langchain", marker = "python_full_version >= '3.11'" }, + { name = "langchain-anthropic", marker = "python_full_version >= '3.11'" }, + { name = "langchain-core", marker = "python_full_version >= '3.11'" }, + { name = "langchain-google-genai", marker = "python_full_version >= '3.11'" }, + { name = "langsmith", marker = "python_full_version >= '3.11'" }, + { name = "wcmatch", marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e0/db/a6acdc72a9e90c3f07ed10de35c951734a02d4facb693bb59684ad368801/deepagents-0.6.12.tar.gz", hash = "sha256:1f281c0bc5a63132f62e2ee345c1dc593b23188da6e23016401f6879fbe54b5f", size = 211364, upload-time = "2026-06-25T17:26:52.775Z" } wheels = [ @@ -1022,7 +1022,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -1971,9 +1971,9 @@ name = "langchain" version = "1.3.14" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "langchain-core" }, - { name = "langgraph" }, - { name = "pydantic" }, + { name = "langchain-core", marker = "python_full_version >= '3.11'" }, + { name = "langgraph", marker = "python_full_version >= '3.11'" }, + { name = "pydantic", marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/29/68/a6dbad9c22df4087a0f9e79ddd46226c442b30128bfeee538d5889492a73/langchain-1.3.14.tar.gz", hash = "sha256:1b6696c72ba3bbbce54d745e0180742c9f6ece8bbc59ed5a46c3e20b9a435929", size = 645181, upload-time = "2026-07-16T13:28:18.29Z" } wheels = [ @@ -1985,9 +1985,9 @@ name = "langchain-anthropic" version = "1.4.8" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anthropic" }, - { name = "langchain-core" }, - { name = "pydantic" }, + { name = "anthropic", marker = "python_full_version >= '3.11'" }, + { name = "langchain-core", marker = "python_full_version >= '3.11'" }, + { name = "pydantic", marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/98/22/40ab129b08329ca295b391aa1d48267692b42594757084c6918e22b655ac/langchain_anthropic-1.4.8.tar.gz", hash = "sha256:c76891b2044d56105ff13c106ed12650637b53bd598a4bdf15b4796eefa2a4ec", size = 708524, upload-time = "2026-06-26T21:28:46.916Z" } wheels = [ @@ -2019,10 +2019,10 @@ name = "langchain-google-genai" version = "4.2.7" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "filetype" }, - { name = "google-genai" }, - { name = "langchain-core" }, - { name = "pydantic" }, + { name = "filetype", marker = "python_full_version >= '3.11'" }, + { name = "google-genai", marker = "python_full_version >= '3.11'" }, + { name = "langchain-core", marker = "python_full_version >= '3.11'" }, + { name = "pydantic", marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1d/0c/bc60dabc362ca7c6ffe8c4bcc2f724c7e566b43eb230cee51419f88f784c/langchain_google_genai-4.2.7.tar.gz", hash = "sha256:03b1463ffe4d42435f43c7870467f2215f684bb46400d2543435d10157c80ac7", size = 281605, upload-time = "2026-07-06T13:51:58.724Z" } wheels = [ @@ -2820,7 +2820,7 @@ wheels = [ [[package]] name = "openai-agents" -version = "0.18.2" +version = "0.19.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "griffelib" }, @@ -2831,14 +2831,14 @@ dependencies = [ { name = "typing-extensions" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/05/0c/52e9aeff5549b225d5666a0eb84a8a22b4c47db08b6f44dbd45876fcfba3/openai_agents-0.18.2.tar.gz", hash = "sha256:9f418bb563eddff1e01f245ae8a4964b7649396f444b569b4113d105e41ca1d3", size = 5546139, upload-time = "2026-07-11T01:08:18.537Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/ea/a8cae2dadf798f369be5f9cb544a169f5f6aecc096a80f1a209dddc4c00f/openai_agents-0.19.4.tar.gz", hash = "sha256:fe21778ee1e8216c9cdb775fa86d11b08be68c0184e14023993088d3f812c0be", size = 5784063, upload-time = "2026-08-05T02:59:12.939Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/23/b5b6b80a3e36f021ca2a8c4637684f0d722d646b19d3616768a68802c302/openai_agents-0.18.2-py3-none-any.whl", hash = "sha256:c7aea341b256a90b87b17b7e444bab29a12655864a2f0094f65561223d867185", size = 874310, upload-time = "2026-07-11T01:08:16.851Z" }, + { url = "https://files.pythonhosted.org/packages/90/d8/98925e1e4888e58d7694ba71af2ba93b94540f481c45ee8b7f7be7e30fcd/openai_agents-0.19.4-py3-none-any.whl", hash = "sha256:12e0372fae9698fe6f78e05aaeb4ccdb229602f7ef99b8195a7d68dc82869f51", size = 968498, upload-time = "2026-08-05T02:59:11.191Z" }, ] [package.optional-dependencies] litellm = [ - { name = "litellm" }, + { name = "litellm", marker = "python_full_version < '3.14'" }, ] [[package]] @@ -4816,7 +4816,7 @@ requires-dist = [ { name = "mcp", marker = "extra == 'google-adk'", specifier = ">=1.24,<2" }, { name = "mcp", marker = "extra == 'openai-agents'", specifier = ">=1.9.4,<2" }, { name = "nexus-rpc", specifier = "==1.4.0" }, - { name = "openai-agents", marker = "extra == 'openai-agents'", specifier = ">=0.17.5" }, + { name = "openai-agents", marker = "extra == 'openai-agents'", specifier = ">=0.19.2,<0.20" }, { name = "opentelemetry-api", marker = "extra == 'lambda-worker-otel'", specifier = ">=1.11.1,<2" }, { name = "opentelemetry-api", marker = "extra == 'opentelemetry'", specifier = ">=1.11.1,<2" }, { name = "opentelemetry-exporter-otlp-proto-grpc", marker = "extra == 'lambda-worker-otel'", specifier = ">=1.11.1,<2" }, @@ -4855,8 +4855,8 @@ dev = [ { name = "moto", extras = ["s3", "server"], specifier = ">=5" }, { name = "mypy", specifier = "==1.18.2" }, { name = "mypy-protobuf", specifier = ">=3.3.0,<4" }, - { name = "openai-agents", marker = "python_full_version >= '3.14'", specifier = ">=0.14.0" }, - { name = "openai-agents", extras = ["litellm"], marker = "python_full_version < '3.14'", specifier = ">=0.14.0" }, + { name = "openai-agents", marker = "python_full_version >= '3.14'", specifier = ">=0.19.2,<0.20" }, + { name = "openai-agents", extras = ["litellm"], marker = "python_full_version < '3.14'", specifier = ">=0.19.2,<0.20" }, { name = "openinference-instrumentation-google-adk", specifier = ">=0.1.11" }, { name = "openinference-instrumentation-openai-agents", specifier = ">=0.1.0" }, { name = "opentelemetry-exporter-otlp-proto-grpc", specifier = ">=1.11.1,<2" }, @@ -5367,7 +5367,7 @@ name = "wcmatch" version = "11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "bracex" }, + { name = "bracex", marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/16/25/1da725838132221e33568973da484ff43813662ccc06ebf7f6e3abddfcd5/wcmatch-11.0.tar.gz", hash = "sha256:55d95c2447789712774b198ceec72939e88b5618f1f8f0a9b605bf7740b63b96", size = 141360, upload-time = "2026-07-10T05:50:24.183Z" } wheels = [ From da712f11e1dc65b58be4df046136383bda1f5f41 Mon Sep 17 00:00:00 2001 From: maplexu Date: Wed, 12 Aug 2026 15:43:42 -0400 Subject: [PATCH 2/3] AI-382: add changelog entries for sandbox secret references Covers the new SecretRef API and the host-path grant rejection, which is breaking for workflows already running against openai-agents 0.19.2 or later -- the release where SandboxPathGrant.host_path was added. --- CHANGELOG.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 614fd8e01..69a2bbacd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,14 @@ to include examples, links to docs, or any other relevant information. workflow sandbox now also restricts the non-deterministic `uuid.uuid7()` added to the standard library in Python 3.14, matching the existing `uuid.uuid1()`/`uuid.uuid4()` restrictions. +- `temporalio.contrib.openai_agents.SecretRef` lets a sandbox environment + variable carry the name of a worker environment variable instead of the + secret itself, so only the name is recorded in workflow history. + `{"OPENAI_API_KEY": SecretRef(key="PROD_KEY")}` reads `PROD_KEY` on the + worker and sets `OPENAI_API_KEY` inside the sandbox. Set the variable on + every worker that runs sandbox activities; a worker without a value for + it fails the sandbox operation with a non-retryable error naming the + variable. Requires `openai-agents >= 0.19.2`. ### Changed @@ -45,6 +53,15 @@ to include examples, links to docs, or any other relevant information. ### :boom: Breaking Changes +- `temporalio.contrib.openai_agents` now rejects sandbox path grants that + are bound to a host path. A `SandboxPathGrant` with `host_path` set wrote + that path into workflow history in plaintext on every sandbox operation, + so such a grant is refused before the manifest is serialized and the + workflow fails with an error naming the sandbox-side paths. Remove + `host_path` from those grants. `SandboxPathGrant.host_path` was added in + `openai-agents` 0.19.2, so this affects only workflows already running + against 0.19.2 or later. + ### Fixed - The `google-adk` extra now depends on `mcp`, so fresh installs of From cb1900ae7a8e792b6ceb50d2d876d73525d37d37 Mon Sep 17 00:00:00 2001 From: maplexu Date: Thu, 13 Aug 2026 12:18:11 -0400 Subject: [PATCH 3/3] AI-382: rename SecretRef to TemporalWorkerEnvValue The class was framed as a secrets feature, which left the reader deciding which sandbox environment values were sensitive enough to wrap. It is better understood as this plugin's EnvValue: reach for it whenever a value should come from the worker's environment rather than being written into the manifest, and a secret is the case where that matters most. The discriminator becomes temporal.worker_env_value and the error type becomes TemporalWorkerEnvValueUnresolved. Nothing has shipped, so neither carries a compatibility constraint. Behaviour is unchanged: the worker reads the named variable when the sandbox environment is needed, and an unset or empty one fails non-retryably naming it. --- CHANGELOG.md | 17 ++-- temporalio/contrib/openai_agents/README.md | 26 ++---- temporalio/contrib/openai_agents/__init__.py | 6 +- .../openai_agents/sandbox/_secret_ref.py | 73 ---------------- .../sandbox/_temporal_worker_env_value.py | 78 +++++++++++++++++ .../openai_agents/test_openai_sandbox.py | 16 ++-- ...> test_openai_sandbox_worker_env_value.py} | 85 ++++++++++--------- 7 files changed, 154 insertions(+), 147 deletions(-) delete mode 100644 temporalio/contrib/openai_agents/sandbox/_secret_ref.py create mode 100644 temporalio/contrib/openai_agents/sandbox/_temporal_worker_env_value.py rename tests/contrib/openai_agents/{test_openai_sandbox_secrets.py => test_openai_sandbox_worker_env_value.py} (66%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 69a2bbacd..6041a25b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,14 +27,15 @@ to include examples, links to docs, or any other relevant information. workflow sandbox now also restricts the non-deterministic `uuid.uuid7()` added to the standard library in Python 3.14, matching the existing `uuid.uuid1()`/`uuid.uuid4()` restrictions. -- `temporalio.contrib.openai_agents.SecretRef` lets a sandbox environment - variable carry the name of a worker environment variable instead of the - secret itself, so only the name is recorded in workflow history. - `{"OPENAI_API_KEY": SecretRef(key="PROD_KEY")}` reads `PROD_KEY` on the - worker and sets `OPENAI_API_KEY` inside the sandbox. Set the variable on - every worker that runs sandbox activities; a worker without a value for - it fails the sandbox operation with a non-retryable error naming the - variable. Requires `openai-agents >= 0.19.2`. +- `temporalio.contrib.openai_agents.TemporalWorkerEnvValue` supplies a sandbox + environment variable from the worker's own environment, so the manifest + carries the variable's name and workflow history never holds its value. + `{"OPENAI_API_KEY": TemporalWorkerEnvValue(key="PROD_KEY")}` reads + `PROD_KEY` on the worker and sets `OPENAI_API_KEY` inside the sandbox. Set + the variable on every worker that runs sandbox activities; a worker without + a value for it fails the sandbox operation with a non-retryable + `TemporalWorkerEnvValueUnresolved` error naming the variable. Requires + `openai-agents >= 0.19.2`. ### Changed diff --git a/temporalio/contrib/openai_agents/README.md b/temporalio/contrib/openai_agents/README.md index 50524fccf..68d749ff6 100644 --- a/temporalio/contrib/openai_agents/README.md +++ b/temporalio/contrib/openai_agents/README.md @@ -612,11 +612,9 @@ result = await Runner.run( ) ``` -### Environment Variables and Secrets +### Environment Variables -Environment variables you give a sandbox are written into workflow history, which is durable and visible in the web UI. A literal value stays there after you rotate it. - -`SecretRef` records the variable's name instead of its value. The worker reads the value from its own environment when the sandbox needs it: +Use `TemporalWorkerEnvValue` for a sandbox environment value that should come from the worker's environment rather than being written into the manifest — a secret especially. It carries the name of a variable, and the worker reads that variable when the sandbox environment is needed: ```python from agents import RunConfig, Runner @@ -624,13 +622,13 @@ from agents.sandbox import Manifest, SandboxRunConfig from agents.sandbox.manifest import Environment from agents.extensions.sandbox.daytona import DaytonaSandboxClientOptions -from temporalio.contrib.openai_agents import SecretRef +from temporalio.contrib.openai_agents import TemporalWorkerEnvValue from temporalio.contrib.openai_agents.workflow import temporal_sandbox_client manifest = Manifest( environment=Environment( value={ - "OPENAI_API_KEY": SecretRef(key="OPENAI_API_KEY"), + "OPENAI_API_KEY": TemporalWorkerEnvValue(key="PROD_OPENAI_KEY"), "REGION": "us-west-2", } ) @@ -646,21 +644,13 @@ result = await Runner.run( ) ``` -Set the variable on every worker that runs sandbox activities. If it is missing or empty, the run fails with a non-retryable error naming it. - -The two names need not match. `{"OPENAI_API_KEY": SecretRef(key="PROD_OPENAI_KEY")}` reads `PROD_OPENAI_KEY` on the worker and sets `OPENAI_API_KEY` inside the sandbox. - -#### Where secrets still do not belong - -Environment variables are the only place a `SecretRef` fits. Everything else you put in a manifest or in client options is recorded as you wrote it, so keep secrets out of: +This reads `PROD_OPENAI_KEY` on the worker and sets `OPENAI_API_KEY` inside the sandbox; the two names need not match. Set the variable on every worker that runs sandbox activities — if it is missing or empty there, the run fails with a non-retryable error naming it. -- **Inline file contents.** `File(content=b"...")` appears verbatim in history. Write the file from inside the sandbox, using an environment variable for the secret. -- **Environment fields on client options**, such as `DaytonaSandboxClientOptions.env_vars`. Put the values in the manifest environment instead. -- **Credential fields on client options**, such as `CloudflareSandboxClientOptions.api_key`. +Keep secrets out of inline file contents (`File(content=...)`), environment fields on client options such as `DaytonaSandboxClientOptions.env_vars`, and credential fields on client options such as `CloudflareSandboxClientOptions.api_key`. -#### Unsupported inputs +### Unsupported Inputs -Path grants that bind a host path are rejected, because the host path itself would be recorded. Grant paths inside the sandbox instead. +Path grants that bind a host path are rejected. Grant paths inside the sandbox instead. A live sandbox session passed to `SandboxRunConfig(session=...)` is also rejected — the plugin needs to create the session itself so its work runs in activities. diff --git a/temporalio/contrib/openai_agents/__init__.py b/temporalio/contrib/openai_agents/__init__.py index 284a3c1c9..4a1f57f0e 100644 --- a/temporalio/contrib/openai_agents/__init__.py +++ b/temporalio/contrib/openai_agents/__init__.py @@ -16,7 +16,9 @@ from temporalio.contrib.openai_agents.sandbox._sandbox_client_provider import ( SandboxClientProvider, ) -from temporalio.contrib.openai_agents.sandbox._secret_ref import SecretRef +from temporalio.contrib.openai_agents.sandbox._temporal_worker_env_value import ( + TemporalWorkerEnvValue, +) from temporalio.contrib.openai_agents.workflow import AgentsWorkflowError from . import testing, workflow @@ -27,9 +29,9 @@ "OpenAIAgentsPlugin", "OpenAIPayloadConverter", "SandboxClientProvider", - "SecretRef", "StatelessMCPServerProvider", "StatefulMCPServerProvider", + "TemporalWorkerEnvValue", "testing", "workflow", ] diff --git a/temporalio/contrib/openai_agents/sandbox/_secret_ref.py b/temporalio/contrib/openai_agents/sandbox/_secret_ref.py deleted file mode 100644 index c54962b2b..000000000 --- a/temporalio/contrib/openai_agents/sandbox/_secret_ref.py +++ /dev/null @@ -1,73 +0,0 @@ -"""Reference to a secret held in the worker process environment.""" - -from __future__ import annotations - -import os -from typing import Literal - -from agents.sandbox.manifest import EnvValue - -from temporalio import workflow -from temporalio.exceptions import ApplicationError - - -class SecretRef(EnvValue): - """A sandbox environment variable whose value is read on the worker. - - .. warning:: - This class is experimental and may change in future versions. - Use with caution in production environments. - - Use it in place of the value, so only the variable's name reaches - workflow history:: - - from agents.sandbox import Manifest - from agents.sandbox.manifest import Environment - - from temporalio.contrib.openai_agents import SecretRef - - manifest = Manifest( - environment=Environment( - value={ - "OPENAI_API_KEY": SecretRef(key="OPENAI_API_KEY"), - "REGION": "us-west-2", - } - ) - ) - - Set the variable on every worker that runs sandbox activities. The two - names need not match: ``{"OPENAI_API_KEY": SecretRef(key="PROD_KEY")}`` - reads ``PROD_KEY`` on the worker and sets ``OPENAI_API_KEY`` inside the - sandbox. - """ - - type: Literal["temporal.secret_ref"] = "temporal.secret_ref" # type: ignore[assignment] - """Discriminator for this environment value type.""" - - key: str - """Name of the environment variable to read on the worker.""" - - async def resolve(self) -> str: - """Return the secret read from the worker's environment. - - Raises: - ApplicationError: If :py:attr:`key` is unset or empty, or if called - from workflow code. Non-retryable. - """ - if workflow.in_workflow(): - raise ApplicationError( - "SecretRef.resolve() must run on a worker, not in workflow code: it " - "reads the process environment, which is non-deterministic on replay " - "and would pull the secret into workflow state.", - type="SecretRefUnusable", - non_retryable=True, - ) - value = os.environ.get(self.key) - if not value: - raise ApplicationError( - f"SecretRef environment variable {self.key!r} is not set, or is " - "empty, in the worker process environment.", - type="SecretRefUnusable", - non_retryable=True, - ) - return value diff --git a/temporalio/contrib/openai_agents/sandbox/_temporal_worker_env_value.py b/temporalio/contrib/openai_agents/sandbox/_temporal_worker_env_value.py new file mode 100644 index 000000000..b37ca0af4 --- /dev/null +++ b/temporalio/contrib/openai_agents/sandbox/_temporal_worker_env_value.py @@ -0,0 +1,78 @@ +"""Sandbox environment value resolved from the Temporal Worker's environment.""" + +from __future__ import annotations + +import os +from typing import Literal + +from agents.sandbox.manifest import EnvValue + +from temporalio import workflow +from temporalio.exceptions import ApplicationError + + +class TemporalWorkerEnvValue(EnvValue): + """A sandbox environment variable whose value is read on the Temporal Worker. + + .. warning:: + This class is experimental and may change in future versions. + Use with caution in production environments. + + Use it wherever a sandbox environment value should come from the worker's + environment rather than being written into the manifest. It carries the name + of the variable, and the worker reads the value when the sandbox environment + is needed. Only the name is recorded in workflow history. + + :: + + from agents.sandbox import Manifest + from agents.sandbox.manifest import Environment + + from temporalio.contrib.openai_agents import TemporalWorkerEnvValue + + manifest = Manifest( + environment=Environment( + value={ + "OPENAI_API_KEY": TemporalWorkerEnvValue(key="OPENAI_API_KEY"), + "REGION": "us-west-2", + } + ) + ) + + Set the variable on every worker that runs sandbox activities. The two + names need not match: ``{"OPENAI_API_KEY": TemporalWorkerEnvValue(key="PROD_KEY")}`` + reads ``PROD_KEY`` on the worker and sets ``OPENAI_API_KEY`` inside the + sandbox. + """ + + type: Literal["temporal.worker_env_value"] = "temporal.worker_env_value" # type: ignore[assignment] + """Discriminator for this environment value type.""" + + key: str + """Name of the environment variable to read on the worker.""" + + async def resolve(self) -> str: + """Return the value read from the worker's environment. + + Raises: + ApplicationError: If :py:attr:`key` is unset or empty, or if called + from workflow code. Non-retryable, with + ``type="TemporalWorkerEnvValueUnresolved"``. + """ + if workflow.in_workflow(): + raise ApplicationError( + "TemporalWorkerEnvValue.resolve() must run on a worker, not in workflow " + "code: it reads the process environment, which is non-deterministic on " + "replay and would pull the value into workflow state.", + type="TemporalWorkerEnvValueUnresolved", + non_retryable=True, + ) + value = os.environ.get(self.key) + if not value: + raise ApplicationError( + f"TemporalWorkerEnvValue environment variable {self.key!r} is not set, " + "or is empty, in the worker process environment.", + type="TemporalWorkerEnvValueUnresolved", + non_retryable=True, + ) + return value diff --git a/tests/contrib/openai_agents/test_openai_sandbox.py b/tests/contrib/openai_agents/test_openai_sandbox.py index ebaa6c84a..41d045ee0 100644 --- a/tests/contrib/openai_agents/test_openai_sandbox.py +++ b/tests/contrib/openai_agents/test_openai_sandbox.py @@ -36,7 +36,7 @@ OpenAIAgentsPlugin, OpenAIPayloadConverter, SandboxClientProvider, - SecretRef, + TemporalWorkerEnvValue, ) from temporalio.contrib.openai_agents._openai_runner import _has_sandbox_agent from temporalio.contrib.openai_agents.sandbox._temporal_activity_models import ( @@ -394,12 +394,12 @@ async def test_activities_create_session_delegates( assert isinstance(result.supports_pty, bool) -async def test_create_session_activity_resolves_secret_ref_but_returns_the_reference( +async def test_create_session_activity_resolves_worker_env_value_but_returns_it_unresolved( sandbox_activities: SandboxClientProvider, mock_client: _MockSandboxClient, monkeypatch: pytest.MonkeyPatch, ): - """Both activity payloads carry the reference while the worker sees the secret.""" + """Both activity payloads carry the variable name while the worker sees the secret.""" secret = "sk-activity-boundary-secret" monkeypatch.setenv("WORKER_ACTIVITY_SECRET", secret) @@ -412,7 +412,7 @@ def payload_bytes(value: BaseModel) -> bytes: snapshot_spec=None, manifest=Manifest( environment=Environment( - value={"API_KEY": SecretRef(key="WORKER_ACTIVITY_SECRET")} + value={"API_KEY": TemporalWorkerEnvValue(key="WORKER_ACTIVITY_SECRET")} ) ), client_options=None, @@ -425,7 +425,7 @@ def payload_bytes(value: BaseModel) -> bytes: assert mock_client.resolved_envs == {"API_KEY": secret} returned = payload_bytes(result) assert secret.encode() not in returned - assert b"temporal.secret_ref" in returned + assert b"temporal.worker_env_value" in returned async def test_activities_resume_session_delegates( @@ -1100,13 +1100,13 @@ class ResolveOnWorkflowThreadWorkflow: @workflow.run async def run(self) -> str: try: - await SecretRef(key="WORKER_THREAD_PROBE_KEY").resolve() + await TemporalWorkerEnvValue(key="WORKER_THREAD_PROBE_KEY").resolve() except ApplicationError as e: return e.type or "" return "NO RAISE" -async def test_secret_ref_resolve_raises_inside_a_real_workflow( +async def test_worker_env_value_resolve_raises_inside_a_real_workflow( client: Client, monkeypatch: pytest.MonkeyPatch ): """``in_workflow()`` is genuinely True here, unlike the monkeypatched unit test. @@ -1139,7 +1139,7 @@ async def test_secret_ref_resolve_raises_inside_a_real_workflow( execution_timeout=timedelta(seconds=15), ) - assert result == "SecretRefUnusable" + assert result == "TemporalWorkerEnvValueUnresolved" @workflow.defn diff --git a/tests/contrib/openai_agents/test_openai_sandbox_secrets.py b/tests/contrib/openai_agents/test_openai_sandbox_worker_env_value.py similarity index 66% rename from tests/contrib/openai_agents/test_openai_sandbox_secrets.py rename to tests/contrib/openai_agents/test_openai_sandbox_worker_env_value.py index 08f802332..50c5e1673 100644 --- a/tests/contrib/openai_agents/test_openai_sandbox_secrets.py +++ b/tests/contrib/openai_agents/test_openai_sandbox_worker_env_value.py @@ -1,4 +1,4 @@ -"""Tests for keeping sandbox environment secrets out of workflow history.""" +"""Tests for ``TemporalWorkerEnvValue`` and what a sandbox manifest carries in its payloads.""" from __future__ import annotations @@ -15,18 +15,21 @@ from pydantic_core import SchemaSerializer from pydantic_core.core_schema import any_schema -from temporalio.contrib.openai_agents import OpenAIPayloadConverter, SecretRef +from temporalio.contrib.openai_agents import ( + OpenAIPayloadConverter, + TemporalWorkerEnvValue, +) from temporalio.contrib.openai_agents.sandbox._temporal_activity_models import ExecArgs from temporalio.exceptions import ApplicationError SECRET = "sk-not-in-history-1234567890" -KEY = "TEST_SECRET_REF_KEY" +KEY = "TEST_WORKER_ENV_VALUE_KEY" -class _SecretRefSessionState(SandboxSessionState): +class _EnvValueSessionState(SandboxSessionState): """Concrete session state so manifests can travel the real activity models.""" - type: Literal["secret_ref_test"] = "secret_ref_test" # type: ignore[assignment] + type: Literal["env_value_test"] = "env_value_test" # type: ignore[assignment] def _payload_bytes(value: BaseModel) -> bytes: @@ -46,8 +49,8 @@ def _manifest(env: dict[str, Any]) -> Manifest: return Manifest(environment=Environment(value=env)) -def _state(manifest: Manifest) -> _SecretRefSessionState: - return _SecretRefSessionState( +def _state(manifest: Manifest) -> _EnvValueSessionState: + return _EnvValueSessionState( manifest=manifest, snapshot=NoopSnapshot(id=str(uuid.uuid4())) ) @@ -58,46 +61,48 @@ def test_literal_env_value_is_written_into_the_payload() -> None: assert SECRET.encode() in raw -def test_secret_ref_round_trips_without_the_secret() -> None: - raw = _payload_bytes(_manifest({KEY: SecretRef(key=KEY)})) +def test_worker_env_value_round_trips_without_the_value() -> None: + raw = _payload_bytes(_manifest({KEY: TemporalWorkerEnvValue(key=KEY)})) assert SECRET.encode() not in raw - assert b"temporal.secret_ref" in raw + assert b"temporal.worker_env_value" in raw - back = _round_trip(_manifest({KEY: SecretRef(key=KEY)}), Manifest) + back = _round_trip(_manifest({KEY: TemporalWorkerEnvValue(key=KEY)}), Manifest) value = back.environment.value[KEY] - assert isinstance(value, SecretRef) + assert isinstance(value, TemporalWorkerEnvValue) assert value.key == KEY -def test_secret_ref_round_trips_inside_an_env_entry() -> None: - manifest = _manifest({KEY: EnvEntry(value=SecretRef(key=KEY))}) +def test_worker_env_value_round_trips_inside_an_env_entry() -> None: + manifest = _manifest({KEY: EnvEntry(value=TemporalWorkerEnvValue(key=KEY))}) raw = _payload_bytes(manifest) assert SECRET.encode() not in raw - assert b"temporal.secret_ref" in raw + assert b"temporal.worker_env_value" in raw back = _round_trip(manifest, Manifest) entry = back.environment.value[KEY] assert isinstance(entry, EnvEntry) - assert isinstance(entry.value, SecretRef) + assert isinstance(entry.value, TemporalWorkerEnvValue) assert entry.value.key == KEY -def test_secret_ref_survives_the_durable_activity_path() -> None: - args = ExecArgs(state=_state(_manifest({KEY: SecretRef(key=KEY)})), command=["ls"]) +def test_worker_env_value_survives_the_durable_activity_path() -> None: + args = ExecArgs( + state=_state(_manifest({KEY: TemporalWorkerEnvValue(key=KEY)})), command=["ls"] + ) raw = _payload_bytes(args) assert SECRET.encode() not in raw back = _round_trip(args, ExecArgs) value = back.state.manifest.environment.value[KEY] - assert isinstance(value, SecretRef) + assert isinstance(value, TemporalWorkerEnvValue) assert value.key == KEY -def test_literal_env_values_are_untouched_alongside_a_secret_ref() -> None: - manifest = _manifest({KEY: SecretRef(key=KEY), "REGION": "us-west-2"}) +def test_literal_env_values_are_untouched_alongside_a_worker_env_value() -> None: + manifest = _manifest({KEY: TemporalWorkerEnvValue(key=KEY), "REGION": "us-west-2"}) back = _round_trip(manifest, Manifest) - assert isinstance(back.environment.value[KEY], SecretRef) + assert isinstance(back.environment.value[KEY], TemporalWorkerEnvValue) assert back.environment.value["REGION"] == "us-west-2" normalized = back.environment.normalized() @@ -108,11 +113,13 @@ def test_literal_env_values_are_untouched_alongside_a_secret_ref() -> None: def test_discriminator_survives_exclude_unset() -> None: """Guards a property split between our ``exclude_unset`` and upstream's wrap serializers.""" serializer = SchemaSerializer(any_schema()) - raw = serializer.to_json(_manifest({KEY: SecretRef(key=KEY)}), exclude_unset=True) - assert b"temporal.secret_ref" in raw + raw = serializer.to_json( + _manifest({KEY: TemporalWorkerEnvValue(key=KEY)}), exclude_unset=True + ) + assert b"temporal.worker_env_value" in raw back = TypeAdapter(Manifest).validate_json(raw) - assert isinstance(back.environment.value[KEY], SecretRef) + assert isinstance(back.environment.value[KEY], TemporalWorkerEnvValue) def test_a_host_path_grant_would_reach_the_payload_unprotected() -> None: @@ -126,11 +133,11 @@ def test_a_host_path_grant_would_reach_the_payload_unprotected() -> None: assert b"/host/private-dir" in _payload_bytes(_state(manifest)) -def test_secret_ref_tag_is_namespaced() -> None: +def test_worker_env_value_tag_is_namespaced() -> None: """Upstream raises on a duplicate tag, so the namespace keeps it registrable.""" - tag = SecretRef(key=KEY).type + tag = TemporalWorkerEnvValue(key=KEY).type assert tag.startswith("temporal.") - assert EnvValue._subclass_registry[tag] is SecretRef + assert EnvValue._subclass_registry[tag] is TemporalWorkerEnvValue # ── resolve() ── @@ -140,7 +147,7 @@ async def test_resolve_reads_the_worker_process_environment( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setenv(KEY, SECRET) - assert await SecretRef(key=KEY).resolve() == SECRET + assert await TemporalWorkerEnvValue(key=KEY).resolve() == SECRET async def test_resolve_raises_naming_the_key_when_unset( @@ -148,9 +155,10 @@ async def test_resolve_raises_naming_the_key_when_unset( ) -> None: monkeypatch.delenv(KEY, raising=False) with pytest.raises(ApplicationError) as exc_info: - await SecretRef(key=KEY).resolve() + await TemporalWorkerEnvValue(key=KEY).resolve() assert KEY in str(exc_info.value) + assert exc_info.value.type == "TemporalWorkerEnvValueUnresolved" assert exc_info.value.non_retryable @@ -159,9 +167,10 @@ async def test_resolve_raises_when_the_variable_is_set_but_empty( ) -> None: monkeypatch.setenv(KEY, "") with pytest.raises(ApplicationError) as exc_info: - await SecretRef(key=KEY).resolve() + await TemporalWorkerEnvValue(key=KEY).resolve() assert KEY in str(exc_info.value) + assert exc_info.value.type == "TemporalWorkerEnvValueUnresolved" assert exc_info.value.non_retryable @@ -171,13 +180,13 @@ async def test_resolve_refuses_to_run_on_the_workflow_thread( monkeypatch.setenv(KEY, SECRET) monkeypatch.setattr("temporalio.workflow.in_workflow", lambda: True) with pytest.raises(ApplicationError) as exc_info: - await SecretRef(key=KEY).resolve() + await TemporalWorkerEnvValue(key=KEY).resolve() assert exc_info.value.non_retryable assert SECRET not in str(exc_info.value) -async def test_each_reference_resolves_its_own_variable_under_its_own_name( +async def test_each_env_value_resolves_its_own_variable_under_its_own_name( monkeypatch: pytest.MonkeyPatch, ) -> None: """Crossed names pin key-to-value pairing, which resolving by mapping key breaks.""" @@ -186,9 +195,9 @@ async def test_each_reference_resolves_its_own_variable_under_its_own_name( manifest = _manifest( { "REGION": "us-west-2", - "SANDBOX_PRIMARY": SecretRef(key="WORKER_PRIMARY"), + "SANDBOX_PRIMARY": TemporalWorkerEnvValue(key="WORKER_PRIMARY"), "LOG_LEVEL": "debug", - "SANDBOX_SECONDARY": SecretRef(key="WORKER_SECONDARY"), + "SANDBOX_SECONDARY": TemporalWorkerEnvValue(key="WORKER_SECONDARY"), } ) @@ -206,13 +215,13 @@ async def test_each_reference_resolves_its_own_variable_under_its_own_name( assert name in raw -async def test_environment_resolve_leaves_the_manifest_holding_references( +async def test_environment_resolve_leaves_the_manifest_unresolved( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setenv(KEY, SECRET) - manifest = _manifest({KEY: SecretRef(key=KEY), "REGION": "us-west-2"}) + manifest = _manifest({KEY: TemporalWorkerEnvValue(key=KEY), "REGION": "us-west-2"}) assert await manifest.environment.resolve() == {KEY: SECRET, "REGION": "us-west-2"} - assert isinstance(manifest.environment.value[KEY], SecretRef) + assert isinstance(manifest.environment.value[KEY], TemporalWorkerEnvValue) assert SECRET.encode() not in _payload_bytes(manifest)