Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,25 @@ def _resolve_checkpoint_root(is_hosted: bool) -> str:

return "/home/session/.checkpoints"

@staticmethod
def _resolve_approval_storage_path(is_hosted: bool) -> str:
"""Resolve function approval storage path.

Hosted: $HOME/.function_approvals/approval_requests.json.
Local: {cwd}/.function_approvals/approval_requests.json.
"""
if not is_hosted:
return os.path.join(os.getcwd(), ".function_approvals", "approval_requests.json")
home = os.environ.get("HOME", "").strip()
if home and home != "/":
try:
resolved = Path(home).resolve()
if str(resolved) != str(resolved.root):
return str(resolved / ".function_approvals" / "approval_requests.json")
except (OSError, ValueError):
pass
return "/home/session/.function_approvals/approval_requests.json"

def __init__(
self,
agent: SupportsAgentRun,
Expand Down Expand Up @@ -451,6 +470,7 @@ def __init__(

self._is_workflow_agent = False
self._checkpoint_storage_path = None
self._approval_storage_path = self._resolve_approval_storage_path(self.config.is_hosted)
if isinstance(agent, WorkflowAgent):
if agent.workflow._runner_context.has_checkpointing(): # pyright: ignore[reportPrivateUsage]
Comment thread
PratikWayase marked this conversation as resolved.
raise RuntimeError(
Expand Down Expand Up @@ -489,7 +509,7 @@ def __init__(
else None
)
self._approval_storage: ApprovalStorage = (
FileBasedFunctionApprovalStorage(self.FUNCTION_APPROVAL_STORAGE_PATH)
FileBasedFunctionApprovalStorage(self._approval_storage_path)
if self.config.is_hosted
else InMemoryFunctionApprovalStorage()
)
Expand Down Expand Up @@ -540,7 +560,7 @@ def _approval_storage_for_request(self) -> ApprovalStorage:
storage = self._approval_storages_by_user.get(user_id)
if storage is None:
storage = FileBasedFunctionApprovalStorage(
_approval_storage_path_for_user(self.FUNCTION_APPROVAL_STORAGE_PATH, user_id)
_approval_storage_path_for_user(self._approval_storage_path, user_id)
)
self._approval_storages_by_user[user_id] = storage
return storage
Expand Down
65 changes: 64 additions & 1 deletion python/packages/foundry_hosting/tests/test_responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import asyncio
import json
import os
import pathlib
import uuid
from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable, Iterator, Mapping, Sequence
from contextlib import contextmanager
Expand Down Expand Up @@ -74,6 +75,7 @@
ConsentError,
FileBasedFunctionApprovalStorage, # pyright: ignore[reportPrivateUsage]
InMemoryFunctionApprovalStorage, # pyright: ignore[reportPrivateUsage]
_approval_storage_path_for_user,
_item_to_message, # pyright: ignore[reportPrivateUsage]
_output_item_to_message, # pyright: ignore[reportPrivateUsage]
consent_url_from_error,
Expand Down Expand Up @@ -4222,7 +4224,6 @@ def _helper() -> Callable[..., str]:
return _approval_storage_path_for_user

def test_user_id_scopes_path_under_base_directory(self, tmp_path: Any) -> None:
from pathlib import Path

helper = self._helper()
base = tmp_path / "approvals" / "requests.json"
Expand Down Expand Up @@ -5190,3 +5191,65 @@ def test_hosted_with_unusable_home_falls_back_to_default(


# endregion


@pytest.mark.filterwarnings("ignore::DeprecationWarning")
class TestApprovalStoragePath:
"""
In hosted mode, function approval storage must be stored under
$HOME/.function_approvals (durable across compute recreation), not
/.function_approvals (ephemeral root path that is wiped on idle).
"""

def test_local_approval_path_uses_cwd(self) -> None:
"""In local mode, approval storage should be under cwd, NOT root `/`."""
server = _make_server(MagicMock())
expected = os.path.join(os.getcwd(), ".function_approvals", "approval_requests.json")
assert server._approval_storage_path == expected

def test_hosted_approval_path_uses_home(self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> None:
"""In hosted mode with valid HOME, approvals must be under $HOME/.function_approvals/."""
monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "true")
monkeypatch.setenv("HOME", "/home/testuser")

monkeypatch.setattr(pathlib.Path, "home", lambda *args, **kwargs: tmp_path)

server = ResponsesHostServer(MagicMock(context_providers=[]), store=InMemoryResponseProvider())
actual_normalized = server._approval_storage_path.replace("\\", "/")
assert actual_normalized.endswith("/home/testuser/.function_approvals/approval_requests.json")
assert not actual_normalized.startswith("/.function_approvals")
assert isinstance(server._approval_storage, FileBasedFunctionApprovalStorage)
storage_normalized = server._approval_storage._storage_path.replace("\\", "/")
assert storage_normalized.endswith("/home/testuser/.function_approvals/approval_requests.json")
user_path = _approval_storage_path_for_user(server._approval_storage_path, "test-user")
user_normalized = str(user_path).replace("\\", "/")
assert user_normalized.endswith("/home/testuser/.function_approvals/test-user/approval_requests.json")

def test_hosted_without_home_env_uses_default_session_dir(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
) -> None:
"""When HOME is unset in hosted mode, fall back to /home/session/.function_approvals/."""
monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "true")
monkeypatch.delenv("HOME", raising=False)

monkeypatch.setattr(pathlib.Path, "home", lambda *args, **kwargs: tmp_path)

server = ResponsesHostServer(MagicMock(), store=InMemoryResponseProvider())
expected = "/home/session/.function_approvals/approval_requests.json"
assert server._approval_storage_path == expected

@pytest.mark.parametrize("bad_home", ["/", "", " "])
def test_hosted_with_unusable_home_falls_back_to_default(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path, bad_home: str
) -> None:
"""Filesystem-root or empty HOME must NOT produce /.function_approvals/."""
monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "true")
monkeypatch.setenv("HOME", bad_home)

# Patch Path.home to use a writable temp directory for SessionStore initialization
monkeypatch.setattr(pathlib.Path, "home", lambda *args, **kwargs: tmp_path)

server = ResponsesHostServer(MagicMock(), store=InMemoryResponseProvider())
expected = "/home/session/.function_approvals/approval_requests.json"
assert server._approval_storage_path == expected
assert not server._approval_storage_path.startswith("/.function_approvals")
Loading