diff --git a/CHANGELOG.md b/CHANGELOG.md index bf59b0659..17d90a43d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,14 @@ to include examples, links to docs, or any other relevant information. `uuid.uuid1()`/`uuid.uuid4()` restrictions. - **Experimental**: `TemporalOperationHandler` can now use Standalone Activities as asynchronous Nexus Operation backing executions through `TemporalNexusClient.start_activity`. +- **Experimental**: `temporalio.contrib.openai_agents.temporal_worker_env_ref` names an environment + variable the worker reads for a hosted tool credential, keeping it out of workflow history. +- **Experimental**: `temporalio.contrib.openai_agents.TemporalWorkerEnvValue` names an environment + variable the worker reads for a sandbox environment value, keeping it out of workflow history. +- **Experimental**: `OpenAIAgentsPlugin(resolvable_worker_env_vars=...)` allowlists the environment + variable names a worker will read. +- **Experimental**: `temporalio.contrib.openai_agents.AllowAllWorkerEnvVars` allowlists every + environment variable name on the worker. ### Changed @@ -70,6 +78,12 @@ to include examples, links to docs, or any other relevant information. ### :boom: Breaking Changes +- The `openai-agents` extra now requires `openai-agents>=0.19.2,<0.20`, up from `>=0.17.5` with no + upper bound. +- `temporalio.contrib.openai_agents` now rejects a sandbox `SandboxPathGrant` bound to a + `host_path`. +- `temporalio.contrib.openai_agents` now rejects `run_config.sandbox.session`. + ### Fixed - `temporalio.contrib.opentelemetry` replay-safe spans now delegate diff --git a/pyproject.toml b/pyproject.toml index 6f16278cf..d6397a7b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ classifiers = [ grpc = ["grpcio>=1.48.2,<2"] opentelemetry = ["opentelemetry-api>=1.26,<2", "opentelemetry-sdk>=1.26,<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 211c7a522..2266e38a7 100644 --- a/temporalio/contrib/openai_agents/README.md +++ b/temporalio/contrib/openai_agents/README.md @@ -487,11 +487,36 @@ A stateless factory that declares no parameters — like the `lambda: MCPServerS For network-accessible MCP servers, you can also use `HostedMCPTool` from the OpenAI Agents SDK, which uses an MCP client hosted by OpenAI. -## Secrets for Hosted Tools +## Secrets from the Worker's Environment ⚠️ **Experimental** - This functionality is subject to change prior to General Availability. -Use `temporal_worker_env_ref()` for a hosted tool credential that should come from the worker's environment rather than being written into your workflow. Pass it the *name of an environment variable*, in place of the credential itself: +A credential an agent needs can stay in the worker process's environment instead of being written into your workflow. Where the value would otherwise go, you name the environment variable that holds it, and the worker reads that variable when the value is actually needed. + +There are two forms, and which one you use follows from where the value goes: + +- For a hosted tool credential, use `temporal_worker_env_ref()`. It is substituted only in the fields listed under [Hosted Tool Credentials](#hosted-tool-credentials). +- For a sandbox environment variable, use `TemporalWorkerEnvValue`. + +Both are gated by `resolvable_worker_env_vars`, an allowlist of the variable names a worker is willing to read. On every worker that runs model or sandbox activities, set the variable and add its name to that list: + +```python +plugin = OpenAIAgentsPlugin(resolvable_worker_env_vars=["MY_MCP_TOKEN"]) +``` + +Names are matched exactly, with no globbing. Passing `AllowAllWorkerEnvVars()` in place of the list makes every environment variable on the worker resolvable, so a workflow-authored sandbox manifest can name any variable on the worker and have its value land inside the container. + +```python +from temporalio.contrib.openai_agents import AllowAllWorkerEnvVars + +plugin = OpenAIAgentsPlugin(resolvable_worker_env_vars=AllowAllWorkerEnvVars()) +``` + +The reference form never raises. A name the worker does not allow is sent on as the reference string, and a name it allows resolves to whatever the variable holds — an empty string when that variable is unset or empty. + +### Hosted Tool Credentials + +Pass `temporal_worker_env_ref()` the *name of an environment variable*, in place of the credential itself: ```python from agents import HostedMCPTool @@ -507,14 +532,6 @@ tool = HostedMCPTool( ) ``` -Every worker that runs model activities must both set `MY_MCP_TOKEN` and name it as resolvable: - -```python -plugin = OpenAIAgentsPlugin(resolvable_worker_env_vars=["MY_MCP_TOKEN"]) -``` - -Names are matched exactly, with no globbing, and `"*"` anywhere in the list allows every environment variable on the worker. - A reference can sit inside a larger value: in `"Bearer " + temporal_worker_env_ref("MY_MCP_TOKEN")`, the reference is replaced in place and the rest of the string is sent unchanged. The environment variable's value is substituted in these fields and no others: @@ -523,6 +540,28 @@ The environment variable's value is substituted in these fields and no others: - `value` in each entry of `network_policy.domain_secrets` under a hosted `ShellTool`'s `environment` - `value` in each entry of `network_policy.domain_secrets` under the `container` in a `CodeInterpreterTool`'s `tool_config` +### Sandbox Environment Variables + +Put a `TemporalWorkerEnvValue` in the environment of a [sandbox](#sandbox-support) manifest, in place of the value itself: + +```python +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(name="PROD_OPENAI_KEY"), + "REGION": "us-west-2", + } + ) +) +``` + +Pass that manifest to `SandboxRunConfig(manifest=...)`. This reads `PROD_OPENAI_KEY` on the worker and sets `OPENAI_API_KEY` inside the sandbox, so the two names need not match. + ## Sandbox Support ⚠️ **Pre-release** - 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 3305eaf95..b00620fb5 100644 --- a/temporalio/contrib/openai_agents/__init__.py +++ b/temporalio/contrib/openai_agents/__init__.py @@ -4,6 +4,7 @@ `OpenAI Agents SDK `_ and Temporal workflows. """ +from temporalio.contrib.openai_agents._errors import AgentsWorkflowError from temporalio.contrib.openai_agents._mcp import ( StatefulMCPServerProvider, StatelessMCPServerProvider, @@ -14,23 +15,28 @@ OpenAIPayloadConverter, ) from temporalio.contrib.openai_agents._temporal_worker_env_ref import ( + AllowAllWorkerEnvVars, temporal_worker_env_ref, ) from temporalio.contrib.openai_agents.sandbox._sandbox_client_provider import ( SandboxClientProvider, ) -from temporalio.contrib.openai_agents.workflow import AgentsWorkflowError +from temporalio.contrib.openai_agents.sandbox._temporal_worker_env_value import ( + TemporalWorkerEnvValue, +) from . import testing, workflow __all__ = [ "AgentsWorkflowError", + "AllowAllWorkerEnvVars", "ModelActivityParameters", "OpenAIAgentsPlugin", "OpenAIPayloadConverter", "SandboxClientProvider", "StatelessMCPServerProvider", "StatefulMCPServerProvider", + "TemporalWorkerEnvValue", "temporal_worker_env_ref", "testing", "workflow", diff --git a/temporalio/contrib/openai_agents/_errors.py b/temporalio/contrib/openai_agents/_errors.py new file mode 100644 index 000000000..5ca502376 --- /dev/null +++ b/temporalio/contrib/openai_agents/_errors.py @@ -0,0 +1,11 @@ +"""Error types for the OpenAI Agents SDK Temporal integration.""" + +from temporalio.exceptions import TemporalError + + +class AgentsWorkflowError(TemporalError): + """Error that terminates the calling workflow or update. + + Raised when the agents SDK raises an error which should terminate, or when + the plugin rejects an unsupported configuration. + """ diff --git a/temporalio/contrib/openai_agents/_invoke_model_activity.py b/temporalio/contrib/openai_agents/_invoke_model_activity.py index b94d48beb..8cf735f9f 100644 --- a/temporalio/contrib/openai_agents/_invoke_model_activity.py +++ b/temporalio/contrib/openai_agents/_invoke_model_activity.py @@ -48,6 +48,7 @@ from temporalio import activity from temporalio.contrib.openai_agents._heartbeat_decorator import auto_heartbeater from temporalio.contrib.openai_agents._temporal_worker_env_ref import ( + AllowAllWorkerEnvVars, _WorkerEnvRefResolver, ) from temporalio.contrib.workflow_streams import WorkflowStreamClient @@ -342,7 +343,7 @@ class ModelActivity: def __init__( self, model_provider: ModelProvider | None = None, - resolvable_worker_env_vars: Collection[str] = (), + resolvable_worker_env_vars: Collection[str] | AllowAllWorkerEnvVars = (), ): """Initialize the activity with a model provider.""" self._model_provider = model_provider or OpenAIProvider( diff --git a/temporalio/contrib/openai_agents/_openai_runner.py b/temporalio/contrib/openai_agents/_openai_runner.py index ea2e6e5df..369b1d345 100644 --- a/temporalio/contrib/openai_agents/_openai_runner.py +++ b/temporalio/contrib/openai_agents/_openai_runner.py @@ -20,12 +20,12 @@ from typing_extensions import Unpack from temporalio import workflow +from temporalio.contrib.openai_agents._errors import AgentsWorkflowError from temporalio.contrib.openai_agents._model_parameters import ModelActivityParameters from temporalio.contrib.openai_agents._temporal_model_stub import _TemporalModelStub from temporalio.contrib.openai_agents.sandbox._temporal_sandbox_client import ( TemporalSandboxClient, ) -from temporalio.contrib.openai_agents.workflow import AgentsWorkflowError # Recursively replace models in all agents @@ -196,6 +196,12 @@ 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')))" ) + 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/_temporal_openai_agents.py b/temporalio/contrib/openai_agents/_temporal_openai_agents.py index 3fd5e4ea0..6023ad090 100644 --- a/temporalio/contrib/openai_agents/_temporal_openai_agents.py +++ b/temporalio/contrib/openai_agents/_temporal_openai_agents.py @@ -20,6 +20,7 @@ from openai._models import construct_type import temporalio.api.common.v1 +from temporalio.contrib.openai_agents._errors import AgentsWorkflowError from temporalio.contrib.openai_agents._invoke_model_activity import ModelActivity from temporalio.contrib.openai_agents._model_parameters import ModelActivityParameters from temporalio.contrib.openai_agents._openai_runner import ( @@ -28,10 +29,13 @@ from temporalio.contrib.openai_agents._temporal_trace_provider import ( TemporalTraceProvider, ) +from temporalio.contrib.openai_agents._temporal_worker_env_ref import ( + AllowAllWorkerEnvVars, + _snapshot_resolvable_env_vars, +) from temporalio.contrib.openai_agents._trace_interceptor import ( OpenAIAgentsContextPropagationInterceptor, ) -from temporalio.contrib.openai_agents.workflow import AgentsWorkflowError from temporalio.contrib.opentelemetry._tracer_provider import ReplaySafeTracerProvider from temporalio.contrib.pydantic import ( PydanticJSONPlainPayloadConverter, @@ -295,7 +299,7 @@ def __init__( register_activities: bool = True, add_temporal_spans: bool = True, use_otel_instrumentation: bool = False, - resolvable_worker_env_vars: Collection[str] = (), + resolvable_worker_env_vars: Collection[str] | AllowAllWorkerEnvVars = (), ) -> None: """Initialize the OpenAI agents plugin. @@ -324,9 +328,10 @@ def __init__( Warning: use_otel_instrumentation is experimental and behavior may change in future versions. Use with caution in production environments. resolvable_worker_env_vars: Names of the environment variables that - ``temporal_worker_env_ref()`` may read on this worker. Names are - matched exactly, with no globbing; ``"*"`` - anywhere in the collection allows every name. + ``temporal_worker_env_ref()`` and ``TemporalWorkerEnvValue`` may + read on this worker. Names are matched exactly, with no globbing; + pass ``AllowAllWorkerEnvVars()`` in place of the names to allow + every variable. Warning: resolvable_worker_env_vars is experimental and behavior may change in future versions. Use with caution in production environments. @@ -349,6 +354,8 @@ def __init__( self._use_otel_instrumentation = use_otel_instrumentation + resolvable_env_vars = _snapshot_resolvable_env_vars(resolvable_worker_env_vars) + # Delay activity construction until they are actually needed def add_activities( activities: Sequence[Callable] | None, @@ -357,7 +364,7 @@ def add_activities( return activities or [] model_activity = ModelActivity( - model_provider, resolvable_worker_env_vars=resolvable_worker_env_vars + model_provider, resolvable_worker_env_vars=resolvable_env_vars ) new_activities = [ model_activity.invoke_model_activity, @@ -380,7 +387,9 @@ def add_activities( ) for sandbox_provider in sandbox_clients: - new_activities.extend(sandbox_provider._get_activities()) + new_activities.extend( + sandbox_provider._get_activities(resolvable_env_vars) + ) return list(activities or []) + new_activities diff --git a/temporalio/contrib/openai_agents/_temporal_worker_env_ref.py b/temporalio/contrib/openai_agents/_temporal_worker_env_ref.py index 503db3587..ab8e82625 100644 --- a/temporalio/contrib/openai_agents/_temporal_worker_env_ref.py +++ b/temporalio/contrib/openai_agents/_temporal_worker_env_ref.py @@ -1,7 +1,8 @@ -"""References to secrets held in the Temporal Worker's environment.""" +"""Worker-environment secrets: the reference form, and the allowlist both forms share.""" from __future__ import annotations +import dataclasses import os import re from collections.abc import Collection, Mapping, MutableMapping @@ -14,7 +15,50 @@ _REF_PATTERN = re.compile(re.escape(_REF_PREFIX) + r"\{([^}{]*)\}") -_ANY_ENV_VAR = "*" + +@dataclasses.dataclass(frozen=True) +class AllowAllWorkerEnvVars: + """Make every environment variable on the worker resolvable. + + .. warning:: + This class is experimental and may change in future versions. + Use with caution in production environments. + + Pass an instance in place of a list of names:: + + OpenAIAgentsPlugin(resolvable_worker_env_vars=AllowAllWorkerEnvVars()) + + This grants far more on the sandbox form than on the hosted tool form. A + sandbox manifest is written in workflow code, so allowing every name lets a + workflow name any variable on the worker and have its value set inside the + sandbox container, where a shell command the model composes can read it. + """ + + +def _snapshot_resolvable_env_vars( + resolvable_worker_env_vars: Collection[str] | AllowAllWorkerEnvVars, +) -> frozenset[str] | AllowAllWorkerEnvVars: + if isinstance(resolvable_worker_env_vars, AllowAllWorkerEnvVars): + return resolvable_worker_env_vars + if isinstance(resolvable_worker_env_vars, str): + raise TypeError( + "resolvable_worker_env_vars takes a collection of environment variable " + 'names, such as ["MY_MCP_TOKEN"], or AllowAllWorkerEnvVars(). A single ' + "string is read as the collection of its characters, so pass a list even " + "for one name." + ) + if cast(object, resolvable_worker_env_vars) is AllowAllWorkerEnvVars: + raise TypeError( + "resolvable_worker_env_vars takes an AllowAllWorkerEnvVars instance, not " + "the class itself. Pass AllowAllWorkerEnvVars()." + ) + return frozenset(resolvable_worker_env_vars) + + +def _is_resolvable( + resolvable: frozenset[str] | AllowAllWorkerEnvVars, name: str +) -> bool: + return isinstance(resolvable, AllowAllWorkerEnvVars) or name in resolvable def temporal_worker_env_ref(name: str) -> str: @@ -45,19 +89,16 @@ def temporal_worker_env_ref(name: str) -> str: class _WorkerEnvRefResolver: # type:ignore[reportUnusedClass] - def __init__(self, resolvable_worker_env_vars: Collection[str]) -> None: - if isinstance(resolvable_worker_env_vars, str): - raise TypeError( - "resolvable_worker_env_vars takes a collection of environment variable " - 'names, such as ["MY_MCP_TOKEN"]. A single string is read as the ' - "collection of its characters, so pass a list even for one name." - ) - self._allowed = frozenset(resolvable_worker_env_vars) + def __init__( + self, + resolvable_worker_env_vars: Collection[str] | AllowAllWorkerEnvVars, + ) -> None: + self._allowed = _snapshot_resolvable_env_vars(resolvable_worker_env_vars) def _resolve_ref(self, value: str) -> str: def substitute(match: re.Match[str]) -> str: name = match.group(1) - if _ANY_ENV_VAR not in self._allowed and name not in self._allowed: + if not _is_resolvable(self._allowed, name): return match.group(0) return os.environ.get(name, "") diff --git a/temporalio/contrib/openai_agents/sandbox/_sandbox_client_provider.py b/temporalio/contrib/openai_agents/sandbox/_sandbox_client_provider.py index 4aa6fd38e..f16895255 100644 --- a/temporalio/contrib/openai_agents/sandbox/_sandbox_client_provider.py +++ b/temporalio/contrib/openai_agents/sandbox/_sandbox_client_provider.py @@ -3,7 +3,7 @@ from __future__ import annotations import io -from collections.abc import Callable, Iterator, Sequence +from collections.abc import Callable, Collection, Iterator, Sequence from contextlib import contextmanager from pathlib import Path from typing import Any @@ -13,6 +13,10 @@ from agents.sandbox.session.sandbox_session import SandboxSession from temporalio import activity +from temporalio.contrib.openai_agents._temporal_worker_env_ref import ( + AllowAllWorkerEnvVars, + _snapshot_resolvable_env_vars, +) from temporalio.contrib.openai_agents.sandbox._temporal_activity_models import ( CreateSessionArgs, ExecArgs, @@ -36,6 +40,9 @@ from temporalio.contrib.openai_agents.sandbox._temporal_activity_models import ( ExecResult as ExecResultModel, ) +from temporalio.contrib.openai_agents.sandbox._temporal_worker_env_value import ( + _resolvable_worker_env_vars_scope, +) from temporalio.exceptions import ApplicationError @@ -54,6 +61,15 @@ def _translate_sandbox_errors() -> Iterator[None]: raise +@contextmanager +def _sandbox_activity_scope( + resolvable_worker_env_vars: frozenset[str] | AllowAllWorkerEnvVars, +) -> Iterator[None]: + with _resolvable_worker_env_vars_scope(resolvable_worker_env_vars): + with _translate_sandbox_errors(): + yield + + class SandboxClientProvider: """A named sandbox client provider for Temporal workflows. @@ -109,15 +125,18 @@ async def _session(self, args: _HasState) -> SandboxSession: self._sessions[key] = await self._client.resume(args.state) return self._sessions[key] - def _get_activities(self) -> Sequence[Callable[..., Any]]: + def _get_activities( + self, resolvable_worker_env_vars: Collection[str] | AllowAllWorkerEnvVars + ) -> Sequence[Callable[..., Any]]: """Return all activity callables for registration with a Temporal Worker.""" prefix = self._name + resolvable = _snapshot_resolvable_env_vars(resolvable_worker_env_vars) # -- Client-level operations (lifecycle) -- @activity.defn(name=f"{prefix}-sandbox_client_create") async def create_session(args: CreateSessionArgs) -> SessionResult: - with _translate_sandbox_errors(): + with _sandbox_activity_scope(resolvable): session = await self._client.create( snapshot=args.snapshot_spec, manifest=args.manifest, @@ -130,7 +149,7 @@ async def create_session(args: CreateSessionArgs) -> SessionResult: @activity.defn(name=f"{prefix}-sandbox_client_resume") async def resume_session(args: ResumeSessionArgs) -> SessionResult: - with _translate_sandbox_errors(): + with _sandbox_activity_scope(resolvable): session = await self._client.resume(args.state) self._sessions[str(session.state.session_id)] = session return SessionResult( @@ -139,7 +158,7 @@ async def resume_session(args: ResumeSessionArgs) -> SessionResult: @activity.defn(name=f"{prefix}-sandbox_client_delete") async def delete_session(args: StopArgs) -> None: - with _translate_sandbox_errors(): + with _sandbox_activity_scope(resolvable): session = await self._session(args) await self._client.delete(session) return None @@ -148,7 +167,7 @@ async def delete_session(args: StopArgs) -> None: @activity.defn(name=f"{prefix}-sandbox_session_exec") async def exec_(args: ExecArgs) -> ExecResultModel: - with _translate_sandbox_errors(): + with _sandbox_activity_scope(resolvable): session = await self._session(args) result = await session.exec( *args.command, @@ -164,21 +183,21 @@ async def exec_(args: ExecArgs) -> ExecResultModel: @activity.defn(name=f"{prefix}-sandbox_session_read") async def read(args: ReadArgs) -> ReadResult: - with _translate_sandbox_errors(): + with _sandbox_activity_scope(resolvable): session = await self._session(args) handle = await session.read(Path(args.path)) return ReadResult(data=handle.read()) @activity.defn(name=f"{prefix}-sandbox_session_write") async def write(args: WriteArgs) -> None: - with _translate_sandbox_errors(): + with _sandbox_activity_scope(resolvable): session = await self._session(args) await session.write(Path(args.path), io.BytesIO(args.data)) return None @activity.defn(name=f"{prefix}-sandbox_session_running") async def running(args: RunningArgs) -> RunningResult: - with _translate_sandbox_errors(): + with _sandbox_activity_scope(resolvable): session = await self._session(args) return RunningResult(is_running=await session.running()) @@ -186,21 +205,21 @@ async def running(args: RunningArgs) -> RunningResult: async def persist_workspace( args: PersistWorkspaceArgs, ) -> PersistWorkspaceResult: - with _translate_sandbox_errors(): + with _sandbox_activity_scope(resolvable): session = await self._session(args) stream = await session.persist_workspace() return PersistWorkspaceResult(data=stream.read()) @activity.defn(name=f"{prefix}-sandbox_session_hydrate_workspace") async def hydrate_workspace(args: HydrateWorkspaceArgs) -> None: - with _translate_sandbox_errors(): + with _sandbox_activity_scope(resolvable): session = await self._session(args) await session.hydrate_workspace(io.BytesIO(args.data)) return None @activity.defn(name=f"{prefix}-sandbox_session_pty_exec_start") async def pty_exec_start(args: PtyExecStartArgs) -> PtyExecUpdateResult: - with _translate_sandbox_errors(): + with _sandbox_activity_scope(resolvable): session = await self._session(args) update = await session.pty_exec_start( *args.command, @@ -220,7 +239,7 @@ async def pty_exec_start(args: PtyExecStartArgs) -> PtyExecUpdateResult: @activity.defn(name=f"{prefix}-sandbox_session_pty_write_stdin") async def pty_write_stdin(args: PtyWriteStdinArgs) -> PtyExecUpdateResult: - with _translate_sandbox_errors(): + with _sandbox_activity_scope(resolvable): session = await self._session(args) update = await session.pty_write_stdin( session_id=args.session_id, @@ -237,14 +256,14 @@ async def pty_write_stdin(args: PtyWriteStdinArgs) -> PtyExecUpdateResult: @activity.defn(name=f"{prefix}-sandbox_session_start") async def start(args: StartArgs) -> None: - with _translate_sandbox_errors(): + with _sandbox_activity_scope(resolvable): session = await self._session(args) await session.start() return None @activity.defn(name=f"{prefix}-sandbox_session_stop") async def session_stop(args: StopArgs) -> None: - with _translate_sandbox_errors(): + with _sandbox_activity_scope(resolvable): session = await self._session(args) await session.stop() return None @@ -256,7 +275,7 @@ async def session_shutdown(args: StopArgs) -> None: if session is None: return None try: - with _translate_sandbox_errors(): + with _sandbox_activity_scope(resolvable): await session.shutdown() except ApplicationError: # Terminal failure: the session is dead, so evict it before diff --git a/temporalio/contrib/openai_agents/sandbox/_temporal_sandbox_client.py b/temporalio/contrib/openai_agents/sandbox/_temporal_sandbox_client.py index 891c65f4b..4df0d9405 100644 --- a/temporalio/contrib/openai_agents/sandbox/_temporal_sandbox_client.py +++ b/temporalio/contrib/openai_agents/sandbox/_temporal_sandbox_client.py @@ -16,6 +16,7 @@ from pydantic.type_adapter import TypeAdapter from temporalio import workflow +from temporalio.contrib.openai_agents._errors import AgentsWorkflowError from temporalio.contrib.openai_agents.sandbox._temporal_activity_models import ( CreateSessionArgs, ResumeSessionArgs, @@ -68,6 +69,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 +95,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 +125,16 @@ 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: + if manifest is None: + return + # Sandbox-side paths only: this message reaches the workflow failure 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)}). A grant's host_path is written " + "into the activity argument 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..a9efe240e --- /dev/null +++ b/temporalio/contrib/openai_agents/sandbox/_temporal_worker_env_value.py @@ -0,0 +1,88 @@ +"""Sandbox environment value resolved from the Temporal Worker's environment.""" + +from __future__ import annotations + +import os +from collections.abc import Collection, Iterator +from contextlib import contextmanager +from contextvars import ContextVar +from typing import Literal + +from agents.sandbox.manifest import EnvValue + +from temporalio import workflow +from temporalio.contrib.openai_agents._temporal_worker_env_ref import ( + AllowAllWorkerEnvVars, + _is_resolvable, + _snapshot_resolvable_env_vars, +) +from temporalio.exceptions import ApplicationError + +_resolvable_worker_env_vars: ContextVar[frozenset[str] | AllowAllWorkerEnvVars] = ( + ContextVar("temporal_resolvable_worker_env_vars") +) + + +@contextmanager +def _resolvable_worker_env_vars_scope( # type:ignore[reportUnusedFunction] + names: Collection[str] | AllowAllWorkerEnvVars, +) -> Iterator[None]: + token = _resolvable_worker_env_vars.set(_snapshot_resolvable_env_vars(names)) + try: + yield + finally: + _resolvable_worker_env_vars.reset(token) + + +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. + + Put one in a sandbox manifest's ``Environment`` in place of the value + itself. Only the name travels in the manifest, and the worker reads the + value when the sandbox environment is needed. Every worker that runs sandbox + activities must set the variable and name it in + ``OpenAIAgentsPlugin(resolvable_worker_env_vars=[...])``. + """ + + type: Literal["temporal.worker_env_value"] = "temporal.worker_env_value" # type: ignore[assignment] + + name: 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 the variable is not resolvable on this worker, + is unset or empty, or if called from workflow code. + """ + if workflow.in_workflow(): + raise ApplicationError( + "TemporalWorkerEnvValue.resolve() must run in an activity: it reads the " + "process environment, which is non-deterministic on replay and would " + "pull the value into workflow state.", + type="TemporalWorkerEnvValueUnresolved", + non_retryable=True, + ) + resolvable = _resolvable_worker_env_vars.get(frozenset()) + if not _is_resolvable(resolvable, self.name): + raise ApplicationError( + f"TemporalWorkerEnvValue environment variable {self.name!r} is not " + "resolvable on this worker. Name it in " + "OpenAIAgentsPlugin(resolvable_worker_env_vars=[...]).", + type="TemporalWorkerEnvValueUnresolved", + non_retryable=True, + ) + value = os.environ.get(self.name) + if not value: + raise ApplicationError( + f"TemporalWorkerEnvValue environment variable {self.name!r} is not set, " + "or is empty, in the worker process environment.", + type="TemporalWorkerEnvValueUnresolved", + non_retryable=True, + ) + return value diff --git a/temporalio/contrib/openai_agents/testing.py b/temporalio/contrib/openai_agents/testing.py index 05d7864d5..0c6b149c3 100644 --- a/temporalio/contrib/openai_agents/testing.py +++ b/temporalio/contrib/openai_agents/testing.py @@ -29,6 +29,10 @@ ) from temporalio.contrib.openai_agents._model_parameters import ModelActivityParameters from temporalio.contrib.openai_agents._temporal_openai_agents import OpenAIAgentsPlugin +from temporalio.contrib.openai_agents._temporal_worker_env_ref import ( + AllowAllWorkerEnvVars, + _snapshot_resolvable_env_vars, +) __all__ = [ "AgentEnvironment", @@ -181,7 +185,7 @@ def __init__( register_activities: bool = True, add_temporal_spans: bool = True, use_otel_instrumentation: bool = False, - resolvable_worker_env_vars: Collection[str] = (), + resolvable_worker_env_vars: Collection[str] | AllowAllWorkerEnvVars = (), ) -> None: """Initialize the AgentEnvironment. @@ -203,7 +207,9 @@ def __init__( Warning: use_otel_instrumentation is experimental and behavior may change in future versions. Use with caution in production environments. resolvable_worker_env_vars: Names of the environment variables that - ``temporal_worker_env_ref()`` may read on this environment's workers. + ``temporal_worker_env_ref()`` may read on this environment's workers; + pass ``AllowAllWorkerEnvVars()`` in place of the names to allow every + variable. Warning: resolvable_worker_env_vars is experimental and behavior may change in future versions. Use with caution in production environments. """ @@ -218,7 +224,9 @@ def __init__( self._plugin: OpenAIAgentsPlugin | None = None self._add_temporal_spans = add_temporal_spans self._use_otel_instrumentation = use_otel_instrumentation - self._resolvable_worker_env_vars = resolvable_worker_env_vars + self._resolvable_worker_env_vars = _snapshot_resolvable_env_vars( + resolvable_worker_env_vars + ) async def __aenter__(self) -> "AgentEnvironment": """Enter the async context manager.""" diff --git a/temporalio/contrib/openai_agents/workflow.py b/temporalio/contrib/openai_agents/workflow.py index d99028d68..70f54629f 100644 --- a/temporalio/contrib/openai_agents/workflow.py +++ b/temporalio/contrib/openai_agents/workflow.py @@ -22,6 +22,9 @@ from temporalio import activity from temporalio import workflow as temporal_workflow from temporalio.common import Priority, RetryPolicy +from temporalio.contrib.openai_agents._errors import ( + AgentsWorkflowError as AgentsWorkflowError, +) from temporalio.contrib.openai_agents.sandbox._temporal_sandbox_client import ( TemporalSandboxClient, ) @@ -377,7 +380,3 @@ class ToolSerializationError(TemporalError): To fix this error, ensure your tool returns string-convertible values or modify the tool to return a string representation of the result. """ - - -class AgentsWorkflowError(TemporalError): - """Error that occurs when the agents SDK raises an error which should terminate the calling workflow or update.""" diff --git a/tests/contrib/openai_agents/test_openai_sandbox.py b/tests/contrib/openai_agents/test_openai_sandbox.py index 3338f8d64..4b9bf2f2b 100644 --- a/tests/contrib/openai_agents/test_openai_sandbox.py +++ b/tests/contrib/openai_agents/test_openai_sandbox.py @@ -2,6 +2,7 @@ import io import uuid +from collections.abc import Collection from datetime import timedelta from pathlib import Path from typing import Any, Literal @@ -14,7 +15,9 @@ SandboxError, WorkspaceArchiveReadError, ) +from agents.sandbox.manifest import Environment from agents.sandbox.session.base_sandbox_session import BaseSandboxSession +from agents.sandbox.session.pty_types import PtyExecUpdate from agents.sandbox.session.sandbox_client import ( BaseSandboxClient, BaseSandboxClientOptions, @@ -23,27 +26,38 @@ 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._temporal_worker_env_ref import ( + AllowAllWorkerEnvVars, +) from temporalio.contrib.openai_agents.sandbox._temporal_activity_models import ( CreateSessionArgs, ExecArgs, HydrateWorkspaceArgs, + PersistWorkspaceArgs, PersistWorkspaceResult, + PtyExecStartArgs, PtyExecUpdateResult, + PtyWriteStdinArgs, ReadArgs, ReadResult, ResumeSessionArgs, RunningArgs, + StartArgs, StopArgs, WriteArgs, ) @@ -53,6 +67,9 @@ from temporalio.contrib.openai_agents.sandbox._temporal_sandbox_client import ( TemporalSandboxClient, ) +from temporalio.contrib.openai_agents.sandbox._temporal_worker_env_value import ( + _resolvable_worker_env_vars, +) from temporalio.contrib.openai_agents.testing import ( AgentEnvironment, ResponseBuilders, @@ -60,7 +77,7 @@ TestModelProvider, ) from temporalio.contrib.openai_agents.workflow import temporal_sandbox_client -from temporalio.exceptions import ApplicationError +from temporalio.exceptions import ActivityError, ApplicationError from temporalio.workflow import ActivityConfig from tests.helpers import new_worker @@ -285,6 +302,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 +314,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: @@ -333,11 +352,12 @@ def _make_state(manifest: Manifest | None = None) -> TestSessionState: def _activity_map( sandbox_activities: SandboxClientProvider, + resolvable_worker_env_vars: Collection[str] = (), ) -> dict[str, Any]: """Build a short-name → callable dict from all() for easy test dispatch.""" return { act.__temporal_activity_definition.name: act # type: ignore[attr-defined, union-attr] - for act in sandbox_activities._get_activities() + for act in sandbox_activities._get_activities(resolvable_worker_env_vars) } @@ -358,6 +378,252 @@ 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, +): + 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(name="WORKER_ACTIVITY_SECRET")} + ) + ), + client_options=None, + ) + assert secret.encode() not in payload_bytes(args) + + acts = _activity_map(sandbox_activities, ["WORKER_ACTIVITY_SECRET"]) + 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_create_session_activity_refuses_an_unlisted_worker_env_value( + sandbox_activities: SandboxClientProvider, + monkeypatch: pytest.MonkeyPatch, +): + secret = "sk-unlisted-secret" + monkeypatch.setenv("WORKER_ACTIVITY_SECRET", secret) + + args = CreateSessionArgs( + snapshot_spec=None, + manifest=Manifest( + environment=Environment( + value={"API_KEY": TemporalWorkerEnvValue(name="WORKER_ACTIVITY_SECRET")} + ) + ), + client_options=None, + ) + + acts = _activity_map(sandbox_activities, ["SOMETHING_ELSE"]) + with pytest.raises(ApplicationError) as exc_info: + await acts["mock-sandbox_client_create"](args) + + assert exc_info.value.type == "TemporalWorkerEnvValueUnresolved" + assert exc_info.value.non_retryable + assert "WORKER_ACTIVITY_SECRET" in str(exc_info.value) + assert "resolvable_worker_env_vars" in str(exc_info.value) + assert secret not in str(exc_info.value) + + +async def test_the_resolvable_names_are_snapshotted_when_the_activities_are_built( + sandbox_activities: SandboxClientProvider, + mock_client: _MockSandboxClient, + monkeypatch: pytest.MonkeyPatch, +): + secret = "sk-snapshot-secret" + monkeypatch.setenv("WORKER_ACTIVITY_SECRET", secret) + + args = CreateSessionArgs( + snapshot_spec=None, + manifest=Manifest( + environment=Environment( + value={"API_KEY": TemporalWorkerEnvValue(name="WORKER_ACTIVITY_SECRET")} + ) + ), + client_options=None, + ) + + resolvable = ["WORKER_ACTIVITY_SECRET"] + acts = _activity_map(sandbox_activities, resolvable) + resolvable.clear() + + await acts["mock-sandbox_client_create"](args) + assert mock_client.resolved_envs == {"API_KEY": secret} + + +class _ScopeRecordingSession(_MockSandboxSession): + def __init__(self, scopes: list[frozenset[str] | AllowAllWorkerEnvVars]) -> None: + super().__init__() + self._scopes = scopes + + def supports_pty(self) -> bool: + return True + + async def shutdown(self) -> None: + self._scopes.append(_resolvable_worker_env_vars.get(frozenset())) + await super().shutdown() + + async def pty_exec_start( + self, *command: str | Path, **kwargs: Any + ) -> PtyExecUpdate: + return PtyExecUpdate( + process_id=1, output=b"", exit_code=None, original_token_count=None + ) + + async def pty_write_stdin(self, **kwargs: Any) -> PtyExecUpdate: + return PtyExecUpdate( + process_id=1, output=b"", exit_code=None, original_token_count=None + ) + + +class _ScopeRecordingClient(_MockSandboxClient): + def __init__(self) -> None: + self.scopes: list[frozenset[str] | AllowAllWorkerEnvVars] = [] + super().__init__(_ScopeRecordingSession(self.scopes)) + + async def create(self, **kwargs: Any) -> SandboxSession: + self.scopes.append(_resolvable_worker_env_vars.get(frozenset())) + return await super().create(**kwargs) + + async def resume(self, state: SandboxSessionState) -> SandboxSession: + self.scopes.append(_resolvable_worker_env_vars.get(frozenset())) + return await super().resume(state) + + +async def test_every_activity_runs_its_body_inside_the_resolvable_names_scope(): + recording_client = _ScopeRecordingClient() + provider = SandboxClientProvider("mock", recording_client) + acts = _activity_map(provider, ["A_RESOLVABLE_NAME"]) + state = _make_state() + args_by_activity: dict[str, Any] = { + "mock-sandbox_client_create": CreateSessionArgs( + snapshot_spec=None, manifest=Manifest(), client_options=None + ), + "mock-sandbox_client_resume": ResumeSessionArgs(state=state), + "mock-sandbox_client_delete": StopArgs(state=state), + "mock-sandbox_session_exec": ExecArgs(state=state, command=["ls"], shell=True), + "mock-sandbox_session_read": ReadArgs(state=state, path="/tmp/f"), + "mock-sandbox_session_write": WriteArgs(state=state, path="/tmp/f", data=b"d"), + "mock-sandbox_session_running": RunningArgs(state=state), + "mock-sandbox_session_persist_workspace": PersistWorkspaceArgs(state=state), + "mock-sandbox_session_hydrate_workspace": HydrateWorkspaceArgs( + state=state, data=b"d" + ), + "mock-sandbox_session_pty_exec_start": PtyExecStartArgs( + state=state, command=["ls"] + ), + "mock-sandbox_session_pty_write_stdin": PtyWriteStdinArgs( + state=state, session_id=1, chars="x" + ), + "mock-sandbox_session_start": StartArgs(state=state), + "mock-sandbox_session_stop": StopArgs(state=state), + "mock-sandbox_session_shutdown": StopArgs(state=state), + } + assert set(args_by_activity) == set(acts) + + for name, args in args_by_activity.items(): + # Shutdown is the one activity that does not resume on a cache miss, so + # it alone needs a cached session to reach the client. + cached = name == "mock-sandbox_session_shutdown" + provider._sessions = ( + {str(state.session_id): recording_client.session} if cached else {} + ) + recording_client.scopes.clear() + await acts[name](args) + assert recording_client.scopes == [frozenset({"A_RESOLVABLE_NAME"})], name + + +def _plugin_with_one_shot_names( + *sandbox_clients: SandboxClientProvider, +) -> OpenAIAgentsPlugin: + return OpenAIAgentsPlugin( + model_params=ModelActivityParameters( + start_to_close_timeout=timedelta(seconds=30), + ), + model_provider=TestModelProvider( + TestModel.returning_responses([ResponseBuilders.output_message("done")]) + ), + sandbox_clients=list(sandbox_clients), + resolvable_worker_env_vars=(name for name in ["A_RESOLVABLE_NAME"]), # type: ignore[arg-type] + ) + + +def _plugin_activity_map(plugin: OpenAIAgentsPlugin) -> dict[str, Any]: + build_activities = plugin.activities + assert callable(build_activities) + return { + act.__temporal_activity_definition.name: act # type: ignore[attr-defined, union-attr] + for act in build_activities([]) + } + + +def _hosted_resolvable_names( + acts: dict[str, Any], +) -> frozenset[str] | AllowAllWorkerEnvVars: + return acts["invoke_model_activity"].__self__._env_refs._allowed + + +async def test_a_one_shot_names_iterable_reaches_the_hosted_and_the_sandbox_activities(): + recording_client = _ScopeRecordingClient() + acts = _plugin_activity_map( + _plugin_with_one_shot_names(SandboxClientProvider("mock", recording_client)) + ) + + assert _hosted_resolvable_names(acts) == frozenset({"A_RESOLVABLE_NAME"}) + await acts["mock-sandbox_client_create"]( + CreateSessionArgs(snapshot_spec=None, manifest=Manifest(), client_options=None) + ) + assert recording_client.scopes == [frozenset({"A_RESOLVABLE_NAME"})] + + +async def test_every_sandbox_provider_on_a_plugin_gets_the_names(): + first = _ScopeRecordingClient() + second = _ScopeRecordingClient() + acts = _plugin_activity_map( + _plugin_with_one_shot_names( + SandboxClientProvider("first", first), + SandboxClientProvider("second", second), + ) + ) + + args = CreateSessionArgs( + snapshot_spec=None, manifest=Manifest(), client_options=None + ) + await acts["first-sandbox_client_create"](args) + await acts["second-sandbox_client_create"](args) + assert first.scopes == [frozenset({"A_RESOLVABLE_NAME"})] + assert second.scopes == [frozenset({"A_RESOLVABLE_NAME"})] + + +async def test_a_second_worker_built_from_one_plugin_gets_the_names(): + recording_client = _ScopeRecordingClient() + plugin = _plugin_with_one_shot_names( + SandboxClientProvider("mock", recording_client) + ) + _plugin_activity_map(plugin) + acts = _plugin_activity_map(plugin) + + assert _hosted_resolvable_names(acts) == frozenset({"A_RESOLVABLE_NAME"}) + await acts["mock-sandbox_client_create"]( + CreateSessionArgs(snapshot_spec=None, manifest=Manifest(), client_options=None) + ) + assert recording_client.scopes == [frozenset({"A_RESOLVABLE_NAME"})] + + async def test_activities_resume_session_delegates( sandbox_activities: SandboxClientProvider, mock_client: _MockSandboxClient, @@ -519,7 +785,7 @@ async def test_activities_all_returns_all_activity_methods( sandbox_activities: SandboxClientProvider, ): """all() should return all 14 activity callables with prefixed names.""" - activities = sandbox_activities._get_activities() + activities = sandbox_activities._get_activities(()) assert len(activities) == 14 # Verify they are all activity-decorated callables with prefixed names activity_names = set() @@ -552,8 +818,8 @@ async def test_multiple_providers_register_distinct_activities(): provider1 = SandboxClientProvider("daytona", client1) provider2 = SandboxClientProvider("local", client2) - activities1 = provider1._get_activities() - activities2 = provider2._get_activities() + activities1 = provider1._get_activities(()) + activities2 = provider2._get_activities(()) names1 = {a.__temporal_activity_definition.name for a in activities1} # type: ignore names2 = {a.__temporal_activity_definition.name for a in activities2} # type: ignore @@ -818,6 +1084,374 @@ 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): + 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": + # The manifest must be present but empty: upstream skips capabilities + # when there is no manifest at all. + 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 +): + 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 test-only ``workflow_failure_exception_types``, so only the plugin's own applies.""" + 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) + assert _HOST_PATH not in str(cause) + assert mock_sandbox_client.create_calls == 0 + + +_PLUGIN_ENV_NAME = "WORKER_ENV_VALUE_THROUGH_THE_PLUGIN" + + +@workflow.defn +class SandboxWorkerEnvValueWorkflow: + @workflow.run + async def run(self) -> str: + await Runner.run( + starting_agent=SandboxAgent[None](name="sandbox-env"), + input="hello", + run_config=RunConfig( + sandbox=SandboxRunConfig( + client=temporal_sandbox_client("mock"), + options=_TestSandboxClientOptions(), + manifest=Manifest( + environment=Environment( + value={ + "API_KEY": TemporalWorkerEnvValue(name=_PLUGIN_ENV_NAME) + } + ) + ), + ), + ), + ) + return "RAN" + + +@pytest.mark.parametrize( + ("resolvable", "resolves"), + [([_PLUGIN_ENV_NAME], True), (["SOMETHING_ELSE"], False)], +) +async def test_a_sandbox_activity_resolves_only_the_variables_its_plugin_names( + client: Client, + monkeypatch: pytest.MonkeyPatch, + resolvable: list[str], + resolves: bool, +): + secret = "sk-through-the-plugin" + monkeypatch.setenv(_PLUGIN_ENV_NAME, secret) + 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)], + resolvable_worker_env_vars=resolvable, + ) + new_config = client.config() + new_config["plugins"] = [plugin] + test_client = Client(**new_config) + + async def execute(worker: Any) -> str: + return await test_client.execute_workflow( + SandboxWorkerEnvValueWorkflow.run, + id=f"sandbox-env-value-{uuid.uuid4()}", + task_queue=worker.task_queue, + execution_timeout=timedelta(seconds=15), + ) + + async with new_worker(test_client, SandboxWorkerEnvValueWorkflow) as worker: + if resolves: + assert await execute(worker) == "RAN" + assert mock_sandbox_client.resolved_envs == {"API_KEY": secret} + else: + with pytest.raises(WorkflowFailureError) as exc_info: + await execute(worker) + activity_error = exc_info.value.cause + assert isinstance(activity_error, ActivityError), activity_error + cause = activity_error.cause + assert isinstance(cause, ApplicationError), cause + assert cause.type == "TemporalWorkerEnvValueUnresolved" + assert _PLUGIN_ENV_NAME in str(cause) + assert secret not in str(cause) + + +@workflow.defn +class ResolveOnWorkflowThreadWorkflow: + @workflow.run + async def run(self) -> str: + try: + await TemporalWorkerEnvValue(name="WORKER_THREAD_PROBE_NAME").resolve() + except ApplicationError as e: + return e.message + return "NO RAISE" + + +async def test_worker_env_value_resolve_raises_inside_a_real_workflow(client: Client): + """``in_workflow()`` is genuinely True here, unlike the monkeypatched unit test.""" + 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 "must run in an activity" in result + + +@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): + 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..bfeffef0e --- /dev/null +++ b/tests/contrib/openai_agents/test_openai_sandbox_worker_env_value.py @@ -0,0 +1,295 @@ +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 ( + AllowAllWorkerEnvVars, + OpenAIPayloadConverter, + TemporalWorkerEnvValue, +) +from temporalio.contrib.openai_agents.sandbox._temporal_activity_models import ExecArgs +from temporalio.contrib.openai_agents.sandbox._temporal_worker_env_value import ( + _resolvable_worker_env_vars_scope, +) +from temporalio.exceptions import ApplicationError + +SECRET = "sk-not-in-history-1234567890" +NAME = "TEST_WORKER_ENV_VALUE_NAME" + + +class _EnvValueSessionState(SandboxSessionState): + 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: + raw = _payload_bytes(_manifest({NAME: SECRET})) + assert SECRET.encode() in raw + + +def test_worker_env_value_round_trips_without_the_value() -> None: + raw = _payload_bytes(_manifest({NAME: TemporalWorkerEnvValue(name=NAME)})) + assert SECRET.encode() not in raw + assert b"temporal.worker_env_value" in raw + + back = _round_trip(_manifest({NAME: TemporalWorkerEnvValue(name=NAME)}), Manifest) + value = back.environment.value[NAME] + assert isinstance(value, TemporalWorkerEnvValue) + assert value.name == NAME + + +def test_worker_env_value_round_trips_inside_an_env_entry() -> None: + manifest = _manifest({NAME: EnvEntry(value=TemporalWorkerEnvValue(name=NAME))}) + 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[NAME] + assert isinstance(entry, EnvEntry) + assert isinstance(entry.value, TemporalWorkerEnvValue) + assert entry.value.name == NAME + + +def test_worker_env_value_survives_the_durable_activity_path() -> None: + args = ExecArgs( + state=_state(_manifest({NAME: TemporalWorkerEnvValue(name=NAME)})), + command=["ls"], + ) + raw = _payload_bytes(args) + assert SECRET.encode() not in raw + + back = _round_trip(args, ExecArgs) + value = back.state.manifest.environment.value[NAME] + assert isinstance(value, TemporalWorkerEnvValue) + assert value.name == NAME + + +def test_literal_env_values_are_untouched_alongside_a_worker_env_value() -> None: + manifest = _manifest( + {NAME: TemporalWorkerEnvValue(name=NAME), "REGION": "us-west-2"} + ) + back = _round_trip(manifest, Manifest) + + assert isinstance(back.environment.value[NAME], 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: + """``type`` is a class default, so ``exclude_unset=True`` must not drop it.""" + serializer = SchemaSerializer(any_schema()) + raw = serializer.to_json( + _manifest({NAME: TemporalWorkerEnvValue(name=NAME)}), exclude_unset=True + ) + assert b"temporal.worker_env_value" in raw + + back = TypeAdapter(Manifest).validate_json(raw) + assert isinstance(back.environment.value[NAME], 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(name=NAME).type + assert tag.startswith("temporal.") + assert EnvValue._subclass_registry[tag] is TemporalWorkerEnvValue + + +async def test_resolve_reads_the_worker_process_environment( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv(NAME, SECRET) + with _resolvable_worker_env_vars_scope([NAME]): + assert await TemporalWorkerEnvValue(name=NAME).resolve() == SECRET + + +async def test_resolve_raises_naming_the_variable_when_unset( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv(NAME, raising=False) + with pytest.raises(ApplicationError) as exc_info: + with _resolvable_worker_env_vars_scope([NAME]): + await TemporalWorkerEnvValue(name=NAME).resolve() + + assert NAME in str(exc_info.value) + # An allowed-but-unset variable must not read as a denial. + assert "resolvable_worker_env_vars" not 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(NAME, "") + with pytest.raises(ApplicationError) as exc_info: + with _resolvable_worker_env_vars_scope([NAME]): + await TemporalWorkerEnvValue(name=NAME).resolve() + + assert NAME 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(NAME, SECRET) + monkeypatch.setattr("temporalio.workflow.in_workflow", lambda: True) + with pytest.raises(ApplicationError) as exc_info: + with _resolvable_worker_env_vars_scope([NAME]): + await TemporalWorkerEnvValue(name=NAME).resolve() + + assert exc_info.value.non_retryable + assert SECRET not in str(exc_info.value) + + +async def test_resolve_refuses_a_name_the_worker_does_not_list( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv(NAME, SECRET) + with pytest.raises(ApplicationError) as exc_info: + with _resolvable_worker_env_vars_scope(["SOMETHING_ELSE"]): + await TemporalWorkerEnvValue(name=NAME).resolve() + + assert NAME in str(exc_info.value) + assert "resolvable_worker_env_vars" in str(exc_info.value) + assert SECRET not in str(exc_info.value) + assert exc_info.value.type == "TemporalWorkerEnvValueUnresolved" + assert exc_info.value.non_retryable + + +async def test_resolve_refuses_outside_a_sandbox_activity( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv(NAME, SECRET) + with pytest.raises(ApplicationError) as exc_info: + await TemporalWorkerEnvValue(name=NAME).resolve() + + assert "resolvable_worker_env_vars" in str(exc_info.value) + + +async def test_allow_all_makes_an_unlisted_name_resolvable( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("A_NAME_LISTED_NOWHERE", SECRET) + value = TemporalWorkerEnvValue(name="A_NAME_LISTED_NOWHERE") + + with _resolvable_worker_env_vars_scope(AllowAllWorkerEnvVars()): + assert await value.resolve() == SECRET + + +async def test_a_literal_star_in_the_resolvable_names_matches_no_name( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("A_NAME_LISTED_NOWHERE", SECRET) + value = TemporalWorkerEnvValue(name="A_NAME_LISTED_NOWHERE") + + with pytest.raises(ApplicationError): + with _resolvable_worker_env_vars_scope(["*"]): + await value.resolve() + + +async def test_a_glob_in_the_resolvable_names_matches_no_name( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv(NAME, SECRET) + with pytest.raises(ApplicationError) as exc_info: + with _resolvable_worker_env_vars_scope(["TEST_WORKER_ENV_VALUE_*"]): + await TemporalWorkerEnvValue(name=NAME).resolve() + + assert "resolvable_worker_env_vars" in str(exc_info.value) + + +async def test_each_env_value_resolves_its_own_variable_under_its_own_name( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("WORKER_PRIMARY", "primary-secret") + monkeypatch.setenv("WORKER_SECONDARY", "secondary-secret") + manifest = _manifest( + { + "REGION": "us-west-2", + "SANDBOX_PRIMARY": TemporalWorkerEnvValue(name="WORKER_PRIMARY"), + "LOG_LEVEL": "debug", + "SANDBOX_SECONDARY": TemporalWorkerEnvValue(name="WORKER_SECONDARY"), + } + ) + + with _resolvable_worker_env_vars_scope(["WORKER_PRIMARY", "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(NAME, SECRET) + manifest = _manifest( + {NAME: TemporalWorkerEnvValue(name=NAME), "REGION": "us-west-2"} + ) + + with _resolvable_worker_env_vars_scope([NAME]): + assert await manifest.environment.resolve() == { + NAME: SECRET, + "REGION": "us-west-2", + } + + assert isinstance(manifest.environment.value[NAME], TemporalWorkerEnvValue) + assert SECRET.encode() not in _payload_bytes(manifest) diff --git a/tests/contrib/openai_agents/test_openai_tool_secrets.py b/tests/contrib/openai_agents/test_openai_tool_secrets.py index de4a3e0c4..949b232e4 100644 --- a/tests/contrib/openai_agents/test_openai_tool_secrets.py +++ b/tests/contrib/openai_agents/test_openai_tool_secrets.py @@ -40,6 +40,7 @@ ) from temporalio.contrib.openai_agents._temporal_model_stub import _TemporalModelStub from temporalio.contrib.openai_agents._temporal_worker_env_ref import ( + AllowAllWorkerEnvVars, _WorkerEnvRefResolver, ) from temporalio.contrib.openai_agents.testing import ( @@ -217,11 +218,17 @@ def test_hosted_mcp_secrets_resolve_for_the_model_call( @pytest.mark.parametrize( ("resolvable", "resolves"), - [([ENV_NAME], True), (["*"], True), ([OTHER_ENV_NAME], False)], - ids=["the_name", "star", "another_name"], + [ + ([ENV_NAME], True), + (AllowAllWorkerEnvVars(), True), + ([OTHER_ENV_NAME], False), + ], + ids=["the_name", "allow_all", "another_name"], ) def test_a_reference_resolves_only_from_a_variable_the_worker_allows( - monkeypatch: pytest.MonkeyPatch, resolvable: Collection[str], resolves: bool + monkeypatch: pytest.MonkeyPatch, + resolvable: Collection[str] | AllowAllWorkerEnvVars, + resolves: bool, ): monkeypatch.setenv(ENV_NAME, SENTINEL) ref = temporal_worker_env_ref(ENV_NAME) @@ -235,7 +242,7 @@ def test_a_reference_resolves_only_from_a_variable_the_worker_allows( assert _as_dict(built.tool_config)["headers"]["X-Token"] == expected -def test_star_anywhere_in_the_resolvable_names_resolves_every_name( +def test_a_star_in_the_resolvable_names_is_an_ordinary_name( monkeypatch: pytest.MonkeyPatch, ): monkeypatch.setenv(ENV_NAME, SENTINEL) @@ -245,8 +252,8 @@ def test_star_anywhere_in_the_resolvable_names_resolves_every_name( built = _build_tool(received, _WorkerEnvRefResolver([OTHER_ENV_NAME, "*"])) assert isinstance(built, HostedMCPTool) - assert _as_dict(built.tool_config)["authorization"] == SENTINEL - assert _as_dict(built.tool_config)["headers"]["X-Token"] == SENTINEL + assert _as_dict(built.tool_config)["authorization"] == ref + assert _as_dict(built.tool_config)["headers"]["X-Token"] == ref def test_a_glob_in_the_resolvable_names_matches_no_name( @@ -657,7 +664,7 @@ def test_a_worker_env_ref_with_no_closing_brace_is_passed_through( _hosted_mcp_tool(truncated, truncated) ) - built = _build_tool(received, _WorkerEnvRefResolver(["*"])) + built = _build_tool(received, _WorkerEnvRefResolver(AllowAllWorkerEnvVars())) assert isinstance(built, HostedMCPTool) assert _as_dict(built.tool_config)["authorization"] == truncated @@ -685,6 +692,16 @@ def test_a_bare_string_is_rejected_as_the_resolvable_variable_names(): _WorkerEnvRefResolver(ENV_NAME) +def test_the_allow_all_class_itself_is_rejected_as_the_resolvable_variable_names(): + with pytest.raises(TypeError, match=r"AllowAllWorkerEnvVars\(\)"): + _WorkerEnvRefResolver(AllowAllWorkerEnvVars) # type: ignore[arg-type] + + +def test_the_plugin_rejects_a_bare_string_as_the_resolvable_variable_names(): + with pytest.raises(TypeError, match="resolvable_worker_env_vars"): + OpenAIAgentsPlugin(resolvable_worker_env_vars=ENV_NAME) + + async def _no_stream_events() -> AsyncIterator[TResponseStreamEvent]: """Publishing an event here makes the flusher retry for ten minutes against a workflow that does not exist.""" events: list[TResponseStreamEvent] = [] @@ -865,3 +882,27 @@ async def test_an_agent_environment_forwards_the_variables_it_names_to_the_worke assert tool_config["headers"] == { "X-Token": temporal_worker_env_ref(OTHER_ENV_NAME) } + + +async def test_re_entering_an_agent_environment_keeps_the_variables_it_names( + monkeypatch: pytest.MonkeyPatch, client: Client +): + monkeypatch.setenv(ENV_NAME, SENTINEL) + model = _ToolRecordingModel() + names = [ENV_NAME] + env = AgentEnvironment(model=model, resolvable_worker_env_vars=names) + + async with env: + pass + names.clear() + + async with env: + client = env.applied_on_client(client) + async with new_worker(client, WorkerEnvRefAgentWorkflow) as worker: + await client.execute_workflow( + WorkerEnvRefAgentWorkflow.run, + id=f"agent-environment-re-entry-{uuid.uuid4()}", + task_queue=worker.task_queue, + ) + + assert _hosted_mcp_config_the_model_received(model)["authorization"] == SENTINEL diff --git a/uv.lock b/uv.lock index 31d094ed0..22d2244c6 100644 --- a/uv.lock +++ b/uv.lock @@ -9,7 +9,7 @@ resolution-markers = [ ] [options] -exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer = "2026-08-05T18:42:17.193687502Z" exclude-newer-span = "P2W" [[package]] @@ -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.26,<2" }, { name = "opentelemetry-api", marker = "extra == 'opentelemetry'", specifier = ">=1.26,<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 = [