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/CHANGELOG.md b/CHANGELOG.md index f5e22ce98..d37cd090f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,9 @@ 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.TemporalWorkerEnvValue` supplies a sandbox + environment variable from the worker's own environment, so the secret value + is never stored in workflow history. - **Experimental**: `TemporalOperationHandler` can now use Standalone Activities as asynchronous Nexus Operation backing executions through `TemporalNexusClient.start_activity`. @@ -50,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 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..68d749ff6 100644 --- a/temporalio/contrib/openai_agents/README.md +++ b/temporalio/contrib/openai_agents/README.md @@ -612,6 +612,48 @@ result = await Runner.run( ) ``` +### Environment Variables + +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 +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 TemporalWorkerEnvValue +from temporalio.contrib.openai_agents.workflow import temporal_sandbox_client + +manifest = Manifest( + environment=Environment( + value={ + "OPENAI_API_KEY": TemporalWorkerEnvValue(key="PROD_OPENAI_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, + )), +) +``` + +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. + +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 + +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. + ## 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..4a1f57f0e 100644 --- a/temporalio/contrib/openai_agents/__init__.py +++ b/temporalio/contrib/openai_agents/__init__.py @@ -16,6 +16,9 @@ from temporalio.contrib.openai_agents.sandbox._sandbox_client_provider import ( SandboxClientProvider, ) +from temporalio.contrib.openai_agents.sandbox._temporal_worker_env_value import ( + TemporalWorkerEnvValue, +) from temporalio.contrib.openai_agents.workflow import AgentsWorkflowError from . import testing, workflow @@ -28,6 +31,7 @@ "SandboxClientProvider", "StatelessMCPServerProvider", "StatefulMCPServerProvider", + "TemporalWorkerEnvValue", "testing", "workflow", ] 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/_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/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 3338f8d64..41d045ee0 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, + TemporalWorkerEnvValue, ) 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_worker_env_value_but_returns_it_unresolved( + sandbox_activities: SandboxClientProvider, + mock_client: _MockSandboxClient, + monkeypatch: pytest.MonkeyPatch, +): + """Both activity payloads carry the variable name 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": TemporalWorkerEnvValue(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.worker_env_value" 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 TemporalWorkerEnvValue(key="WORKER_THREAD_PROBE_KEY").resolve() + except ApplicationError as e: + return e.type or "" + return "NO RAISE" + + +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. + + 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 == "TemporalWorkerEnvValueUnresolved" + + +@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_worker_env_value.py b/tests/contrib/openai_agents/test_openai_sandbox_worker_env_value.py new file mode 100644 index 000000000..50c5e1673 --- /dev/null +++ b/tests/contrib/openai_agents/test_openai_sandbox_worker_env_value.py @@ -0,0 +1,227 @@ +"""Tests for ``TemporalWorkerEnvValue`` and what a sandbox manifest carries in its payloads.""" + +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, + 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_WORKER_ENV_VALUE_KEY" + + +class _EnvValueSessionState(SandboxSessionState): + """Concrete session state so manifests can travel the real activity models.""" + + type: Literal["env_value_test"] = "env_value_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) -> _EnvValueSessionState: + return _EnvValueSessionState( + 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_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.worker_env_value" in raw + + back = _round_trip(_manifest({KEY: TemporalWorkerEnvValue(key=KEY)}), Manifest) + value = back.environment.value[KEY] + assert isinstance(value, TemporalWorkerEnvValue) + assert value.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.worker_env_value" in raw + + back = _round_trip(manifest, Manifest) + entry = back.environment.value[KEY] + assert isinstance(entry, EnvEntry) + assert isinstance(entry.value, TemporalWorkerEnvValue) + assert entry.value.key == KEY + + +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, TemporalWorkerEnvValue) + assert value.key == KEY + + +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], TemporalWorkerEnvValue) + 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: 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], TemporalWorkerEnvValue) + + +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_worker_env_value_tag_is_namespaced() -> None: + """Upstream raises on a duplicate tag, so the namespace keeps it registrable.""" + tag = TemporalWorkerEnvValue(key=KEY).type + assert tag.startswith("temporal.") + assert EnvValue._subclass_registry[tag] is TemporalWorkerEnvValue + + +# ── resolve() ── + + +async def test_resolve_reads_the_worker_process_environment( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv(KEY, SECRET) + assert await TemporalWorkerEnvValue(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 TemporalWorkerEnvValue(key=KEY).resolve() + + assert KEY in str(exc_info.value) + assert exc_info.value.type == "TemporalWorkerEnvValueUnresolved" + 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 TemporalWorkerEnvValue(key=KEY).resolve() + + assert KEY in str(exc_info.value) + assert exc_info.value.type == "TemporalWorkerEnvValueUnresolved" + 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 TemporalWorkerEnvValue(key=KEY).resolve() + + assert exc_info.value.non_retryable + assert SECRET not in str(exc_info.value) + + +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.""" + monkeypatch.setenv("WORKER_PRIMARY", "primary-secret") + monkeypatch.setenv("WORKER_SECONDARY", "secondary-secret") + manifest = _manifest( + { + "REGION": "us-west-2", + "SANDBOX_PRIMARY": TemporalWorkerEnvValue(key="WORKER_PRIMARY"), + "LOG_LEVEL": "debug", + "SANDBOX_SECONDARY": TemporalWorkerEnvValue(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_unresolved( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv(KEY, SECRET) + 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], TemporalWorkerEnvValue) + 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 = [