-
Notifications
You must be signed in to change notification settings - Fork 147
DRAFT: For PR #2035: add /hooks endpoint #2088
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
enyst
wants to merge
8
commits into
support-openhands-hooks-json-in-agent-server
from
stacked/hooks-endpoint-alternative
Closed
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
96da40a
feat(agent-server): add /hooks endpoint for project hooks
enyst 6b7857b
refactor(agent-server): hooks service respects load_project/load_user…
enyst 598d54d
test(agent-server): cover hooks flags
enyst 0c721c2
fix(agent-server): validate project_dir and harden hooks loading
enyst aeaf9fe
test(agent-server): isolate HOME for user hooks merge
enyst b959db0
chore: document how to reply to GitHub inline review threads
enyst 30fbbf9
refactor(agent-server): simplify hooks validation and error handling
enyst bdb54f9
test(agent-server): cover malformed hooks and reject relative project…
enyst File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
76 changes: 76 additions & 0 deletions
76
openhands-agent-server/openhands/agent_server/hooks_router.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,76 @@ | ||
| """Hooks router for OpenHands Agent Server. | ||
|
|
||
| This module defines the HTTP API endpoints for hook operations. | ||
| Business logic is delegated to hooks_service.py. | ||
| """ | ||
|
|
||
| from pathlib import Path | ||
|
|
||
| from fastapi import APIRouter | ||
| from pydantic import BaseModel, Field | ||
|
|
||
| from openhands.agent_server.hooks_service import load_hooks | ||
| from openhands.sdk.hooks import HookConfig | ||
|
|
||
|
|
||
| hooks_router = APIRouter(prefix="/hooks", tags=["Hooks"]) | ||
|
|
||
|
|
||
| def _validate_project_dir(project_dir: str) -> str | None: | ||
| expanded = Path(project_dir).expanduser() | ||
| if not expanded.is_absolute(): | ||
| return None | ||
|
|
||
| try: | ||
| resolved = expanded.resolve(strict=False) | ||
| except Exception: | ||
| return None | ||
|
|
||
| return str(resolved) | ||
enyst marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
|
|
||
| class HooksRequest(BaseModel): | ||
| """Request body for loading hooks.""" | ||
|
|
||
| load_project: bool = Field( | ||
| default=True, | ||
| description=( | ||
| "Whether to load project hooks from {project_dir}/.openhands/hooks.json" | ||
| ), | ||
| ) | ||
| load_user: bool = Field( | ||
| default=False, | ||
| description="Whether to load user hooks from ~/.openhands/hooks.json", | ||
| ) | ||
| project_dir: str | None = Field( | ||
| default=None, description="Workspace directory path for project hooks" | ||
enyst marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| ) | ||
|
|
||
|
|
||
| class HooksResponse(BaseModel): | ||
| """Response containing hooks configuration.""" | ||
|
|
||
| hook_config: HookConfig | None = Field( | ||
| default=None, | ||
| description=( | ||
| "Hook configuration loaded from the workspace, or None if not found" | ||
| ), | ||
enyst marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| ) | ||
|
|
||
|
|
||
| @hooks_router.post("", response_model=HooksResponse) | ||
| def get_hooks(request: HooksRequest) -> HooksResponse: | ||
| """Load hooks from the workspace .openhands/hooks.json file.""" | ||
|
|
||
| project_dir = None | ||
| if request.project_dir is not None: | ||
| project_dir = _validate_project_dir(request.project_dir) | ||
| if project_dir is None: | ||
enyst marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| return HooksResponse(hook_config=None) | ||
|
|
||
| hook_config = load_hooks( | ||
| load_project=request.load_project, | ||
| load_user=request.load_user, | ||
| project_dir=project_dir, | ||
| ) | ||
| return HooksResponse(hook_config=hook_config) | ||
53 changes: 53 additions & 0 deletions
53
openhands-agent-server/openhands/agent_server/hooks_service.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| """Hooks service for OpenHands Agent Server. | ||
|
|
||
| This module contains the business logic for loading hooks from project and user | ||
| locations. | ||
|
|
||
| Sources: | ||
| - Project hooks: {project_dir}/.openhands/hooks.json | ||
| - User hooks: ~/.openhands/hooks.json | ||
|
|
||
| The agent-server does not own policy; it only respects request flags. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from openhands.sdk.hooks import HookConfig, load_project_hooks, load_user_hooks | ||
| from openhands.sdk.logger import get_logger | ||
|
|
||
|
|
||
| logger = get_logger(__name__) | ||
|
|
||
|
|
||
| def load_hooks( | ||
| *, | ||
| load_project: bool, | ||
| load_user: bool, | ||
| project_dir: str | None, | ||
| ) -> HookConfig | None: | ||
| hook_configs: list[HookConfig] = [] | ||
|
|
||
| def _safe_load(loader, *args, log_message: str): | ||
| try: | ||
| return loader(*args) | ||
| except Exception: | ||
| logger.exception(log_message) | ||
enyst marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| return None | ||
|
|
||
| if load_project and project_dir: | ||
| project_hooks = _safe_load( | ||
| load_project_hooks, | ||
| project_dir, | ||
| log_message="Failed to load project hooks", | ||
| ) | ||
| if project_hooks is not None: | ||
| hook_configs.append(project_hooks) | ||
|
|
||
| if load_user: | ||
| user_hooks = _safe_load( | ||
| load_user_hooks, log_message="Failed to load user hooks" | ||
| ) | ||
| if user_hooks is not None: | ||
enyst marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| hook_configs.append(user_hooks) | ||
|
|
||
| return HookConfig.merge(hook_configs) if hook_configs else None | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,126 @@ | ||
| from pathlib import Path | ||
|
|
||
| from fastapi.testclient import TestClient | ||
|
|
||
| from openhands.agent_server.api import create_app | ||
| from openhands.agent_server.config import Config | ||
|
|
||
|
|
||
| def test_hooks_endpoint_returns_none_when_not_found(tmp_path): | ||
| app = create_app(Config(session_api_keys=[])) | ||
| client = TestClient(app) | ||
|
|
||
| resp = client.post( | ||
| "/api/hooks", | ||
| json={"load_project": True, "load_user": False, "project_dir": str(tmp_path)}, | ||
| ) | ||
| assert resp.status_code == 200 | ||
| data = resp.json() | ||
| assert data["hook_config"] is None | ||
|
|
||
|
|
||
| def test_hooks_endpoint_returns_hook_config_when_present(tmp_path): | ||
| hooks_dir = tmp_path / ".openhands" | ||
| hooks_dir.mkdir(parents=True) | ||
| hooks_file = hooks_dir / "hooks.json" | ||
| hooks_file.write_text( | ||
| '{"session_start":[{"matcher":"*","hooks":[{"command":"echo hi"}]}]}' | ||
| ) | ||
|
|
||
| app = create_app(Config(session_api_keys=[])) | ||
| client = TestClient(app) | ||
|
|
||
| resp = client.post( | ||
| "/api/hooks", | ||
| json={"load_project": True, "load_user": False, "project_dir": str(tmp_path)}, | ||
| ) | ||
| assert resp.status_code == 200 | ||
| data = resp.json() | ||
| assert data["hook_config"] is not None | ||
| assert data["hook_config"]["session_start"][0]["hooks"][0]["command"] == "echo hi" | ||
|
|
||
|
|
||
| def test_hooks_endpoint_respects_load_project_false(tmp_path): | ||
| hooks_dir = tmp_path / ".openhands" | ||
| hooks_dir.mkdir(parents=True) | ||
| (hooks_dir / "hooks.json").write_text( | ||
| '{"session_start":[{"matcher":"*","hooks":[{"command":"echo hi"}]}]}' | ||
| ) | ||
|
|
||
| app = create_app(Config(session_api_keys=[])) | ||
| client = TestClient(app) | ||
|
|
||
| resp = client.post( | ||
| "/api/hooks", | ||
| json={"load_project": False, "load_user": False, "project_dir": str(tmp_path)}, | ||
| ) | ||
| assert resp.status_code == 200 | ||
| assert resp.json()["hook_config"] is None | ||
enyst marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
|
|
||
| def test_hooks_endpoint_accepts_relative_project_dir_and_returns_none(tmp_path): | ||
| app = create_app(Config(session_api_keys=[])) | ||
| client = TestClient(app) | ||
|
|
||
| resp = client.post( | ||
| "/api/hooks", | ||
| json={ | ||
| "load_project": True, | ||
| "load_user": False, | ||
| "project_dir": "relative/path", | ||
| }, | ||
| ) | ||
|
|
||
| assert resp.status_code == 200 | ||
| assert resp.json()["hook_config"] is None | ||
enyst marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
|
|
||
| def test_hooks_endpoint_returns_none_on_malformed_project_hooks_json(tmp_path): | ||
| hooks_dir = tmp_path / ".openhands" | ||
| hooks_dir.mkdir(parents=True) | ||
| (hooks_dir / "hooks.json").write_text("not json") | ||
|
|
||
| app = create_app(Config(session_api_keys=[])) | ||
| client = TestClient(app) | ||
|
|
||
| resp = client.post( | ||
| "/api/hooks", | ||
| json={"load_project": True, "load_user": False, "project_dir": str(tmp_path)}, | ||
| ) | ||
|
|
||
| assert resp.status_code == 200 | ||
| assert resp.json()["hook_config"] is None | ||
|
|
||
|
|
||
| def test_hooks_endpoint_merges_project_and_user_hooks(tmp_path, monkeypatch): | ||
| app = create_app(Config(session_api_keys=[])) | ||
| client = TestClient(app) | ||
|
|
||
| hooks_dir = tmp_path / ".openhands" | ||
| hooks_dir.mkdir(parents=True) | ||
| (hooks_dir / "hooks.json").write_text( | ||
| '{"session_start":[{"matcher":"*","hooks":[{"command":"echo project"}]}]}' | ||
| ) | ||
|
|
||
| fake_home = tmp_path / "fake_home" | ||
| (fake_home / ".openhands").mkdir(parents=True) | ||
| (fake_home / ".openhands" / "hooks.json").write_text( | ||
| '{"session_start":[{"matcher":"*","hooks":[{"command":"echo user"}]}]}' | ||
| ) | ||
|
|
||
| monkeypatch.setattr(Path, "home", lambda: fake_home) | ||
|
|
||
| resp = client.post( | ||
| "/api/hooks", | ||
| json={"load_project": True, "load_user": True, "project_dir": str(tmp_path)}, | ||
| ) | ||
|
|
||
| assert resp.status_code == 200 | ||
| hook_config = resp.json()["hook_config"] | ||
| assert hook_config is not None | ||
|
|
||
| session_start = hook_config["session_start"] | ||
| commands = [ | ||
| hook["command"] for matcher in session_start for hook in matcher["hooks"] | ||
| ] | ||
| assert commands == ["echo project", "echo user"] | ||
enyst marked this conversation as resolved.
Show resolved
Hide resolved
|
||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.