Skip to content

Commit 5b1088a

Browse files
google-genai-botcopybara-github
authored andcommitted
feat: add Cloud Run sandbox option in code executors
PiperOrigin-RevId: 944887432
1 parent 7543be8 commit 5b1088a

5 files changed

Lines changed: 383 additions & 0 deletions

File tree

src/google/adk/cli/cli_deploy.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -798,6 +798,7 @@ def to_cloud_run(
798798
str(port),
799799
'--verbosity',
800800
log_level.lower() if log_level else verbosity,
801+
'--sandbox-launcher',
801802
]
802803

803804
# Handle labels specially - merge user labels with ADK label
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""ADK Cloud Run Integration."""
16+
17+
from ._cloud_run_sandbox_code_executor import CloudRunSandboxCodeExecutor
18+
19+
__all__ = [
20+
'CloudRunSandboxCodeExecutor',
21+
]
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from __future__ import annotations
16+
17+
import logging
18+
import subprocess
19+
import sys
20+
21+
from pydantic import Field
22+
from typing_extensions import override
23+
24+
from ...agents.invocation_context import InvocationContext
25+
from ...code_executors.base_code_executor import BaseCodeExecutor
26+
from ...code_executors.code_execution_utils import CodeExecutionInput
27+
from ...code_executors.code_execution_utils import CodeExecutionResult
28+
29+
logger = logging.getLogger('google_adk.' + __name__)
30+
31+
32+
def _filter_stderr(stderr: str | None) -> str:
33+
"""Filters out harmless sandbox warning messages from stderr."""
34+
if not stderr:
35+
return ''
36+
filtered_lines = []
37+
for line in stderr.splitlines():
38+
# Filter out the harmless netns cleanup warnings
39+
if (
40+
'Failed to cleanup network namespace' in line
41+
or 'failed to unmount netns file' in line
42+
):
43+
continue
44+
filtered_lines.append(line)
45+
return '\n'.join(filtered_lines)
46+
47+
48+
class CloudRunSandboxCodeExecutor(BaseCodeExecutor):
49+
"""Executes Python code inside a Cloud Run sandbox using the `sandbox` CLI tool.
50+
51+
This executor is designed to run from within a Cloud Run container where
52+
sandboxes are enabled. It cannot be used to execute code remotely from a
53+
local machine or other external environments, as it relies on the local guest
54+
`sandbox` binary provided by the Cloud Run container runtime.
55+
56+
It executes the code by passing it via stdin to the Python interpreter
57+
running inside the local sandbox: `sandbox do <python_path>`.
58+
"""
59+
60+
sandbox_bin: str = '/usr/local/gcp/bin/sandbox'
61+
"""The path to the sandbox binary. Defaults to '/usr/local/gcp/bin/sandbox'."""
62+
63+
allow_egress: bool = False
64+
"""Whether to allow egress for the sandbox."""
65+
66+
# Overrides the BaseCodeExecutor attribute: this executor cannot be stateful.
67+
stateful: bool = Field(default=False, frozen=True, exclude=True)
68+
69+
# Overrides the BaseCodeExecutor attribute: this executor cannot optimize_data_file.
70+
optimize_data_file: bool = Field(default=False, frozen=True, exclude=True)
71+
72+
def __init__(self, **data):
73+
if 'stateful' in data and data['stateful']:
74+
raise ValueError(
75+
'Cannot set `stateful=True` in CloudRunSandboxCodeExecutor.'
76+
)
77+
if 'optimize_data_file' in data and data['optimize_data_file']:
78+
raise ValueError(
79+
'Cannot set `optimize_data_file=True` in CloudRunSandboxCodeExecutor.'
80+
)
81+
82+
super().__init__(**data)
83+
84+
@override
85+
def execute_code(
86+
self,
87+
invocation_context: InvocationContext,
88+
code_execution_input: CodeExecutionInput,
89+
) -> CodeExecutionResult:
90+
logger.debug(
91+
'Executing code in Cloud Run Sandbox:\n```\n%s\n```',
92+
code_execution_input.code,
93+
)
94+
95+
# Construct the sandbox command
96+
# We use 'sandbox do' to run the command in a one-shot sandbox.
97+
# By default, 'sandbox do' mounts the host's rootfs as read-only, which is fine
98+
# since we are passing the code via stdin and don't need to read host files,
99+
# but the python3 binary and libraries from the host rootfs are available.
100+
cmd = [self.sandbox_bin, 'do']
101+
if self.allow_egress:
102+
cmd.append('--allow-egress')
103+
104+
# We run the same python binary as the current process, using its absolute path
105+
# to avoid PATH resolution issues inside the sandbox (where PATH might be empty).
106+
cmd.append(sys.executable or 'python3')
107+
108+
logger.debug('Running sandbox command: %s', ' '.join(cmd))
109+
110+
timeout = self.timeout_seconds if self.timeout_seconds is not None else None
111+
112+
try:
113+
# Run the command and capture output, writing the code to stdin
114+
result = subprocess.run(
115+
cmd,
116+
input=code_execution_input.code,
117+
capture_output=True,
118+
text=True,
119+
timeout=timeout,
120+
check=False,
121+
)
122+
123+
logger.debug(
124+
'Sandbox execution finished. Return code: %d, Stdout len: %d, Stderr'
125+
' len: %d',
126+
result.returncode,
127+
len(result.stdout) if result.stdout else 0,
128+
len(result.stderr) if result.stderr else 0,
129+
)
130+
if result.stderr:
131+
logger.warning('Sandbox stderr: %s', result.stderr)
132+
133+
stderr_filtered = _filter_stderr(result.stderr)
134+
return CodeExecutionResult(
135+
stdout=result.stdout,
136+
stderr=stderr_filtered,
137+
output_files=[],
138+
)
139+
140+
except subprocess.TimeoutExpired as e:
141+
logger.error('Sandbox execution timed out: %s', e)
142+
# TimeoutExpired.output/stderr might be bytes or str depending on how it was run,
143+
# but since we passed text=True, they should be str if captured.
144+
# However, they might be None if no output was captured before timeout.
145+
stdout_str = (
146+
e.output
147+
if isinstance(e.output, str)
148+
else (e.output.decode('utf-8') if e.output else '')
149+
)
150+
stderr_str = (
151+
e.stderr
152+
if isinstance(e.stderr, str)
153+
else (e.stderr.decode('utf-8') if e.stderr else '')
154+
)
155+
stderr_filtered = _filter_stderr(stderr_str)
156+
return CodeExecutionResult(
157+
stdout=stdout_str,
158+
stderr=stderr_filtered
159+
or f'Code execution timed out after {self.timeout_seconds} seconds.',
160+
output_files=[],
161+
)
162+
except FileNotFoundError as e:
163+
logger.error('Sandbox binary not found: %s', e)
164+
return CodeExecutionResult(
165+
stdout='',
166+
stderr=(
167+
f'Sandbox binary "{self.sandbox_bin}" not found. Ensure you are'
168+
' running in an environment with the sandbox tool installed.'
169+
),
170+
output_files=[],
171+
)
172+
except Exception as e:
173+
logger.error('Unexpected error running sandbox: %s', e)
174+
return CodeExecutionResult(
175+
stdout='',
176+
stderr=f'Unexpected error running sandbox: {e}',
177+
output_files=[],
178+
)

tests/unittests/cli/utils/test_cli_deploy_to_cloud_run.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@ def test_to_cloud_run_happy_path(
188188
"8080",
189189
"--verbosity",
190190
"info",
191+
"--sandbox-launcher",
191192
"--labels",
192193
"created-by=adk",
193194
]
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import sys
16+
from unittest.mock import MagicMock
17+
from unittest.mock import patch
18+
19+
from google.adk.agents.base_agent import BaseAgent
20+
from google.adk.agents.invocation_context import InvocationContext
21+
from google.adk.code_executors.code_execution_utils import CodeExecutionInput
22+
from google.adk.code_executors.code_execution_utils import CodeExecutionResult
23+
from google.adk.integrations.cloud_run import CloudRunSandboxCodeExecutor
24+
from google.adk.sessions.base_session_service import BaseSessionService
25+
from google.adk.sessions.session import Session
26+
import pytest
27+
28+
29+
@pytest.fixture
30+
def mock_invocation_context() -> InvocationContext:
31+
"""Provides a mock InvocationContext."""
32+
mock_agent = MagicMock(spec=BaseAgent)
33+
mock_session = MagicMock(spec=Session)
34+
mock_session_service = MagicMock(spec=BaseSessionService)
35+
return InvocationContext(
36+
invocation_id="test_invocation",
37+
agent=mock_agent,
38+
session=mock_session,
39+
session_service=mock_session_service,
40+
)
41+
42+
43+
class TestCloudRunSandboxCodeExecutor:
44+
45+
def test_init_default(self):
46+
executor = CloudRunSandboxCodeExecutor()
47+
assert not executor.stateful
48+
assert not executor.optimize_data_file
49+
assert executor.sandbox_bin == "/usr/local/gcp/bin/sandbox"
50+
assert not executor.allow_egress
51+
52+
def test_init_stateful_raises_error(self):
53+
with pytest.raises(
54+
ValueError,
55+
match="Cannot set `stateful=True` in CloudRunSandboxCodeExecutor.",
56+
):
57+
CloudRunSandboxCodeExecutor(stateful=True)
58+
59+
def test_init_optimize_data_file_raises_error(self):
60+
with pytest.raises(
61+
ValueError,
62+
match=(
63+
"Cannot set `optimize_data_file=True` in"
64+
" CloudRunSandboxCodeExecutor."
65+
),
66+
):
67+
CloudRunSandboxCodeExecutor(optimize_data_file=True)
68+
69+
@patch("subprocess.run")
70+
def test_execute_code_success(
71+
self, mock_run, mock_invocation_context: InvocationContext
72+
):
73+
# Setup mock subprocess.run response
74+
mock_response = MagicMock()
75+
mock_response.stdout = "hello world\n"
76+
mock_response.stderr = ""
77+
mock_response.returncode = 0
78+
mock_run.return_value = mock_response
79+
80+
executor = CloudRunSandboxCodeExecutor()
81+
code_input = CodeExecutionInput(code='print("hello world")')
82+
result = executor.execute_code(mock_invocation_context, code_input)
83+
84+
assert isinstance(result, CodeExecutionResult)
85+
assert result.stdout == "hello world\n"
86+
assert result.stderr == ""
87+
assert result.output_files == []
88+
89+
# Verify subprocess.run was called with correct arguments
90+
expected_python = sys.executable or "python3"
91+
mock_run.assert_called_once_with(
92+
["/usr/local/gcp/bin/sandbox", "do", expected_python],
93+
input='print("hello world")',
94+
capture_output=True,
95+
text=True,
96+
timeout=None,
97+
check=False,
98+
)
99+
100+
@patch("subprocess.run")
101+
def test_execute_code_with_egress_and_custom_bin(
102+
self, mock_run, mock_invocation_context: InvocationContext
103+
):
104+
mock_response = MagicMock()
105+
mock_response.stdout = "egress success\n"
106+
mock_response.stderr = ""
107+
mock_response.returncode = 0
108+
mock_run.return_value = mock_response
109+
110+
executor = CloudRunSandboxCodeExecutor(
111+
sandbox_bin="/usr/bin/custom-sandbox",
112+
allow_egress=True,
113+
timeout_seconds=10,
114+
)
115+
code_input = CodeExecutionInput(code="import requests; print('ok')")
116+
result = executor.execute_code(mock_invocation_context, code_input)
117+
118+
assert result.stdout == "egress success\n"
119+
120+
expected_python = sys.executable or "python3"
121+
mock_run.assert_called_once_with(
122+
["/usr/bin/custom-sandbox", "do", "--allow-egress", expected_python],
123+
input="import requests; print('ok')",
124+
capture_output=True,
125+
text=True,
126+
timeout=10,
127+
check=False,
128+
)
129+
130+
@patch("subprocess.run")
131+
def test_execute_code_with_error(
132+
self, mock_run, mock_invocation_context: InvocationContext
133+
):
134+
mock_response = MagicMock()
135+
mock_response.stdout = ""
136+
mock_response.stderr = "Traceback ... ValueError: Test error\n"
137+
mock_response.returncode = 1
138+
mock_run.return_value = mock_response
139+
140+
executor = CloudRunSandboxCodeExecutor()
141+
code_input = CodeExecutionInput(code='raise ValueError("Test error")')
142+
result = executor.execute_code(mock_invocation_context, code_input)
143+
144+
assert result.stdout == ""
145+
assert "ValueError: Test error" in result.stderr
146+
147+
@patch("subprocess.run")
148+
def test_execute_code_timeout(
149+
self, mock_run, mock_invocation_context: InvocationContext
150+
):
151+
import subprocess
152+
153+
mock_run.side_effect = subprocess.TimeoutExpired(
154+
cmd=["sandbox", "do", "python3"],
155+
timeout=5,
156+
output="partial stdout",
157+
stderr="partial stderr",
158+
)
159+
160+
executor = CloudRunSandboxCodeExecutor(timeout_seconds=5)
161+
code_input = CodeExecutionInput(code="import time\ntime.sleep(10)")
162+
result = executor.execute_code(mock_invocation_context, code_input)
163+
164+
assert result.stdout == "partial stdout"
165+
assert result.stderr == "partial stderr"
166+
167+
@patch("subprocess.run")
168+
def test_execute_code_binary_not_found(
169+
self, mock_run, mock_invocation_context: InvocationContext
170+
):
171+
mock_run.side_effect = FileNotFoundError(
172+
"[Errno 2] No such file or directory: 'sandbox'"
173+
)
174+
175+
executor = CloudRunSandboxCodeExecutor()
176+
code_input = CodeExecutionInput(code='print("hello")')
177+
result = executor.execute_code(mock_invocation_context, code_input)
178+
179+
assert result.stdout == ""
180+
assert (
181+
'Sandbox binary "/usr/local/gcp/bin/sandbox" not found' in result.stderr
182+
)

0 commit comments

Comments
 (0)