openhands-agent-server 1.46.0__tar.gz → 1.48.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/PKG-INFO +2 -2
  2. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands/agent_server/api.py +4 -1
  3. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands/agent_server/bash_router.py +14 -3
  4. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands/agent_server/bash_service.py +18 -2
  5. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands/agent_server/conversation_router.py +26 -3
  6. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands/agent_server/conversation_service.py +69 -7
  7. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands/agent_server/dependencies.py +9 -1
  8. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands/agent_server/docker/Dockerfile +114 -33
  9. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands/agent_server/docker/build.py +2 -6
  10. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands/agent_server/event_router.py +1 -3
  11. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands/agent_server/event_service.py +53 -0
  12. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands/agent_server/file_router.py +27 -9
  13. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands/agent_server/mcp_router.py +14 -42
  14. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands/agent_server/models.py +25 -0
  15. openhands_agent_server-1.48.0/openhands/agent_server/openai/models.py +133 -0
  16. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands/agent_server/openai/router.py +37 -0
  17. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands/agent_server/openai/service.py +327 -58
  18. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands/agent_server/persistence/models.py +7 -2
  19. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands/agent_server/profiles_router.py +1 -1
  20. openhands_agent_server-1.48.0/openhands/agent_server/runtime_router.py +107 -0
  21. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands/agent_server/server_details_router.py +4 -0
  22. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands/agent_server/session_protocol.py +10 -0
  23. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands/agent_server/session_socket.py +64 -7
  24. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands_agent_server.egg-info/PKG-INFO +2 -2
  25. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands_agent_server.egg-info/SOURCES.txt +2 -0
  26. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands_agent_server.egg-info/requires.txt +1 -1
  27. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/pyproject.toml +3 -3
  28. openhands_agent_server-1.46.0/openhands/agent_server/openai/models.py +0 -59
  29. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands/agent_server/__init__.py +0 -0
  30. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands/agent_server/__main__.py +0 -0
  31. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands/agent_server/_secret_redaction.py +0 -0
  32. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands/agent_server/_secrets_exposure.py +0 -0
  33. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands/agent_server/agent_profiles_router.py +0 -0
  34. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands/agent_server/auth_router.py +0 -0
  35. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands/agent_server/canvas_extensions/__init__.py +0 -0
  36. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands/agent_server/canvas_extensions/installed.py +0 -0
  37. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands/agent_server/canvas_extensions/manifest.py +0 -0
  38. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands/agent_server/canvas_extensions_router.py +0 -0
  39. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands/agent_server/config.py +0 -0
  40. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands/agent_server/conversation_lease.py +0 -0
  41. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands/agent_server/credential_binding.py +0 -0
  42. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands/agent_server/desktop_router.py +0 -0
  43. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands/agent_server/env_parser.py +0 -0
  44. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands/agent_server/git_router.py +0 -0
  45. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands/agent_server/hooks_router.py +0 -0
  46. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands/agent_server/hooks_service.py +0 -0
  47. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands/agent_server/init_router.py +0 -0
  48. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands/agent_server/llm_router.py +0 -0
  49. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands/agent_server/logging_config.py +0 -0
  50. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands/agent_server/mcp_oauth_store.py +0 -0
  51. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands/agent_server/middleware.py +0 -0
  52. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands/agent_server/openai/__init__.py +0 -0
  53. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands/agent_server/openapi.py +0 -0
  54. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands/agent_server/persistence/__init__.py +0 -0
  55. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands/agent_server/persistence/store.py +0 -0
  56. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands/agent_server/plugins_router.py +0 -0
  57. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands/agent_server/plugins_service.py +0 -0
  58. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands/agent_server/provider_connections_router.py +0 -0
  59. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands/agent_server/pub_sub.py +0 -0
  60. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands/agent_server/py.typed +0 -0
  61. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands/agent_server/settings_router.py +0 -0
  62. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands/agent_server/skills_router.py +0 -0
  63. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands/agent_server/skills_service.py +0 -0
  64. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands/agent_server/sockets.py +0 -0
  65. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands/agent_server/sub_agents_router.py +0 -0
  66. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands/agent_server/telemetry/__init__.py +0 -0
  67. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands/agent_server/telemetry/factory.py +0 -0
  68. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands/agent_server/telemetry/http_exporter.py +0 -0
  69. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands/agent_server/telemetry/models.py +0 -0
  70. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands/agent_server/telemetry/policy.py +0 -0
  71. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands/agent_server/telemetry/posthog_exporter.py +0 -0
  72. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands/agent_server/telemetry/sanitizer.py +0 -0
  73. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands/agent_server/telemetry/service.py +0 -0
  74. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands/agent_server/telemetry/sink.py +0 -0
  75. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands/agent_server/telemetry/subscriber.py +0 -0
  76. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands/agent_server/telemetry_types.py +0 -0
  77. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands/agent_server/tool_preload_service.py +0 -0
  78. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands/agent_server/tool_router.py +0 -0
  79. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands/agent_server/utils.py +0 -0
  80. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands/agent_server/vscode_extensions/openhands-settings/extension.js +0 -0
  81. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands/agent_server/vscode_extensions/openhands-settings/package.json +0 -0
  82. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands/agent_server/vscode_router.py +0 -0
  83. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands/agent_server/vscode_service.py +0 -0
  84. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands/agent_server/workspace_router.py +0 -0
  85. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands/agent_server/workspaces_router.py +0 -0
  86. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands_agent_server.egg-info/dependency_links.txt +0 -0
  87. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands_agent_server.egg-info/entry_points.txt +0 -0
  88. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/openhands_agent_server.egg-info/top_level.txt +0 -0
  89. {openhands_agent_server-1.46.0 → openhands_agent_server-1.48.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openhands-agent-server
3
- Version: 1.46.0
3
+ Version: 1.48.0
4
4
  Summary: OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent
5
5
  Project-URL: Source, https://github.com/OpenHands/software-agent-sdk
6
6
  Project-URL: Homepage, https://github.com/OpenHands/software-agent-sdk
@@ -15,7 +15,7 @@ Requires-Dist: openhands-sdk
15
15
  Requires-Dist: openai<3,>=2.33.0
16
16
  Requires-Dist: pydantic>=2
17
17
  Requires-Dist: sqlalchemy>=2
18
- Requires-Dist: uvicorn>=0.31.1
18
+ Requires-Dist: uvicorn>=0.52.4
19
19
  Requires-Dist: websockets>=12
20
20
  Requires-Dist: wsproto>=1.2.0
21
21
  Provides-Extra: posthog
@@ -40,7 +40,7 @@ from openhands.agent_server.dependencies import (
40
40
  )
41
41
  from openhands.agent_server.desktop_router import desktop_router
42
42
  from openhands.agent_server.event_router import event_router
43
- from openhands.agent_server.file_router import file_router
43
+ from openhands.agent_server.file_router import file_discovery_router, file_router
44
44
  from openhands.agent_server.git_router import git_router
45
45
  from openhands.agent_server.hooks_router import hooks_router
46
46
  from openhands.agent_server.init_router import (
@@ -60,6 +60,7 @@ from openhands.agent_server.profiles_router import profiles_router
60
60
  from openhands.agent_server.provider_connections_router import (
61
61
  provider_connections_router,
62
62
  )
63
+ from openhands.agent_server.runtime_router import create_runtime_router
63
64
  from openhands.agent_server.server_details_router import (
64
65
  get_server_info,
65
66
  mark_initialization_complete,
@@ -411,6 +412,8 @@ def _add_api_routes(app: FastAPI) -> None:
411
412
  ]
412
413
 
413
414
  api_router = APIRouter(prefix="/api", dependencies=dependencies)
415
+ api_router.include_router(file_discovery_router)
416
+ api_router.include_router(create_runtime_router())
414
417
  api_router.include_router(event_router)
415
418
  api_router.include_router(conversation_router)
416
419
  api_router.include_router(credential_binding_router)
@@ -2,6 +2,7 @@
2
2
 
3
3
  import logging
4
4
  from datetime import datetime
5
+ from pathlib import Path
5
6
  from typing import Annotated, Literal, cast
6
7
  from uuid import UUID
7
8
 
@@ -52,13 +53,11 @@ async def search_bash_events(
52
53
  ] = None,
53
54
  limit: Annotated[
54
55
  int,
55
- Query(title="The max number of results in the page", gt=0, lte=100),
56
+ Query(title="The max number of results in the page", gt=0, le=100),
56
57
  ] = 100,
57
58
  bash_event_service: BashEventService = Depends(get_bash_event_service),
58
59
  ) -> BashEventPage:
59
60
  """Search / List bash event events"""
60
- assert limit > 0
61
- assert limit <= 100
62
61
 
63
62
  return await bash_event_service.search_bash_events(
64
63
  kind__eq=kind__eq,
@@ -104,6 +103,7 @@ async def start_bash_command(
104
103
  ) -> BashCommand:
105
104
  """Execute a bash command in the background"""
106
105
  update_last_execution_time()
106
+ _validate_cwd(request, bash_event_service)
107
107
  command, _ = await bash_event_service.start_bash_command(request)
108
108
  return command
109
109
 
@@ -115,6 +115,7 @@ async def execute_bash_command(
115
115
  ) -> BashOutput:
116
116
  """Execute a bash command and wait for a result"""
117
117
  update_last_execution_time()
118
+ _validate_cwd(request, bash_event_service)
118
119
  command, task = await bash_event_service.start_bash_command(request)
119
120
  await task
120
121
  page = await bash_event_service.search_bash_events(command_id__eq=command.id)
@@ -129,3 +130,13 @@ async def clear_all_bash_events(
129
130
  """Clear all bash events from storage"""
130
131
  count = await bash_event_service.clear_all_events()
131
132
  return {"cleared_count": count}
133
+
134
+
135
+ def _validate_cwd(request: ExecuteBashRequest, service: BashEventService) -> None:
136
+ if service.default_cwd is not None and request.cwd is not None:
137
+ if (
138
+ not Path(request.cwd)
139
+ .resolve()
140
+ .is_relative_to(Path(service.default_cwd).resolve())
141
+ ):
142
+ raise HTTPException(422, "cwd must be inside the conversation workspace")
@@ -31,6 +31,9 @@ class BashEventService:
31
31
  will not be visible to the agent."""
32
32
 
33
33
  bash_events_dir: Path = field()
34
+ default_cwd: str | None = None
35
+ _tasks: set[asyncio.Task] = field(default_factory=set, init=False)
36
+ _closed: bool = field(default=False, init=False)
34
37
  _pub_sub: PubSub[BashEventBase] = field(
35
38
  default_factory=lambda: PubSub[BashEventBase](max_subscribers=50),
36
39
  init=False,
@@ -255,13 +258,17 @@ class BashEventService:
255
258
  self, request: ExecuteBashRequest
256
259
  ) -> tuple[BashCommand, asyncio.Task]:
257
260
  """Execute a bash command. The output will be published separately."""
258
- command = BashCommand(**request.model_dump())
261
+ if self._closed:
262
+ raise RuntimeError("Bash event service is closed")
263
+ cwd = request.cwd or self.default_cwd
264
+ command = BashCommand(**{**request.model_dump(), "cwd": cwd})
259
265
  self._save_event_to_file(command)
260
266
  await self._pub_sub(command)
261
267
 
262
268
  # Execute the bash command in a background task
263
269
  task = asyncio.create_task(self._execute_bash_command(command))
264
-
270
+ self._tasks.add(task)
271
+ task.add_done_callback(self._tasks.discard)
265
272
  return command, task
266
273
 
267
274
  async def _execute_bash_command(self, command: BashCommand) -> None:
@@ -347,6 +354,10 @@ class BashEventService:
347
354
  timeout=command.timeout,
348
355
  )
349
356
  exit_code = process.returncode
357
+ except asyncio.CancelledError:
358
+ self._signal_process_group(process, signal.SIGKILL)
359
+ await process.wait()
360
+ raise
350
361
  except TimeoutError:
351
362
  # Send SIGTERM to the whole process group so user-installed
352
363
  # cleanup traps can run, then escalate to SIGKILL if needed.
@@ -509,6 +520,11 @@ class BashEventService:
509
520
 
510
521
  async def close(self):
511
522
  """Close the bash event service and clean up resources."""
523
+ self._closed = True
524
+ tasks = list(self._tasks)
525
+ for task in tasks:
526
+ task.cancel()
527
+ await asyncio.gather(*tasks, return_exceptions=True)
512
528
  await self._pub_sub.close()
513
529
 
514
530
  async def __aenter__(self):
@@ -31,6 +31,8 @@ from openhands.agent_server.models import (
31
31
  AskAgentResponse,
32
32
  ConversationInfo,
33
33
  ConversationPage,
34
+ ConversationRuntimeInfo,
35
+ ConversationRuntimeStatus,
34
36
  ConversationSortOrder,
35
37
  ForkConversationRequest,
36
38
  NavigateConversationRequest,
@@ -94,7 +96,7 @@ async def search_conversations(
94
96
  ] = None,
95
97
  limit: Annotated[
96
98
  int,
97
- Query(title="The max number of results in the page", gt=0, lte=100),
99
+ Query(title="The max number of results in the page", gt=0, le=100),
98
100
  ] = 100,
99
101
  status: Annotated[
100
102
  ConversationExecutionStatus | None,
@@ -108,8 +110,6 @@ async def search_conversations(
108
110
  conversation_service: ConversationService = Depends(get_conversation_service),
109
111
  ) -> ConversationPage:
110
112
  """Search / List conversations"""
111
- assert limit > 0
112
- assert limit <= 100
113
113
  page = await conversation_service.search_conversations(
114
114
  page_id, limit, status, sort_order
115
115
  )
@@ -158,6 +158,29 @@ async def get_conversation(
158
158
  return conversation
159
159
 
160
160
 
161
+ @conversation_router.get("/{conversation_id}/runtime")
162
+ async def get_local_conversation_runtime(
163
+ conversation_id: UUID,
164
+ conversation_service: ConversationService = Depends(get_conversation_service),
165
+ ) -> ConversationRuntimeInfo:
166
+ """Inspect the always-available in-process runtime."""
167
+ if await conversation_service.get_conversation(conversation_id) is None:
168
+ raise HTTPException(status.HTTP_404_NOT_FOUND)
169
+ return ConversationRuntimeInfo(
170
+ runtime_status=ConversationRuntimeStatus.AVAILABLE,
171
+ can_resume=True,
172
+ )
173
+
174
+
175
+ @conversation_router.post("/{conversation_id}/runtime/reprovision")
176
+ async def reprovision_local_conversation_runtime(
177
+ conversation_id: UUID,
178
+ conversation_service: ConversationService = Depends(get_conversation_service),
179
+ ) -> ConversationRuntimeInfo:
180
+ """Return local runtime state; local mode has no infrastructure to provision."""
181
+ return await get_local_conversation_runtime(conversation_id, conversation_service)
182
+
183
+
161
184
  @conversation_router.get(
162
185
  "/{conversation_id}/agent_final_response",
163
186
  responses={404: {"description": "Conversation not found"}},
@@ -61,7 +61,11 @@ from openhands.sdk.conversation.title_utils import (
61
61
  extract_message_text,
62
62
  generate_title_from_message,
63
63
  )
64
- from openhands.sdk.credential import CredentialBindingError, VersionedCredentialBinding
64
+ from openhands.sdk.credential import (
65
+ CredentialAuthorizationRejected,
66
+ CredentialBindingError,
67
+ VersionedCredentialBinding,
68
+ )
65
69
  from openhands.sdk.event import MessageEvent
66
70
  from openhands.sdk.event.conversation_state import ConversationStateUpdateEvent
67
71
  from openhands.sdk.git.exceptions import GitCommandError, GitRepositoryError
@@ -352,9 +356,13 @@ def _resolve_agent_from_profile(
352
356
  cipher: "Cipher | None",
353
357
  mcp_config: "dict[str, MCPServer]",
354
358
  acp_skill_sourcing: ACPSkillSourcing = "native",
355
- ) -> "tuple[AgentBase, LaunchedAgentProfile]":
359
+ ) -> "tuple[AgentBase, LaunchedAgentProfile, set[str] | None]":
356
360
  """Load and resolve an agent profile by id, returning the built agent + provenance.
357
361
 
362
+ The third element is the profile's secret allow-list (``None`` = unrestricted)
363
+ — strictly ``secret_refs``, with nothing added back. It is returned rather
364
+ than applied here because the secrets ride the start request, not the agent.
365
+
358
366
  Runs synchronously (call via ``asyncio.to_thread`` from async context).
359
367
 
360
368
  Args:
@@ -449,8 +457,10 @@ def _resolve_agent_from_profile(
449
457
  launched = LaunchedAgentProfile(
450
458
  agent_profile_id=profile.id,
451
459
  revision=profile.revision,
460
+ secret_refs=profile.secret_refs,
452
461
  )
453
- return agent, launched
462
+ allowed_secrets = None if profile.secret_refs is None else set(profile.secret_refs)
463
+ return agent, launched, allowed_secrets
454
464
 
455
465
 
456
466
  def _compose_conversation_info(
@@ -819,6 +829,18 @@ class ConversationService:
819
829
  if event_services is not None
820
830
  else None
821
831
  )
832
+ record = self._conversation_records.get(conversation_id)
833
+ stored = (
834
+ event_service.stored
835
+ if event_service is not None
836
+ else (record.stored if record is not None else None)
837
+ )
838
+ if stored is not None and not self._profile_allows_secret(
839
+ stored, secret_name
840
+ ):
841
+ raise CredentialAuthorizationRejected(
842
+ "The launched agent profile excludes this credential"
843
+ )
822
844
  if event_service is not None and event_service.is_open():
823
845
  await event_service.activate_credential_binding(secret_name, binding)
824
846
  record = self._conversation_records.get(conversation_id)
@@ -860,6 +882,11 @@ class ConversationService:
860
882
  raise first_error
861
883
  self._credential_bindings = {}
862
884
 
885
+ @staticmethod
886
+ def _profile_allows_secret(stored: StoredConversation, name: str) -> bool:
887
+ profile = stored.launched_agent_profile
888
+ return profile is None or profile.allows_secret(name)
889
+
863
890
  @staticmethod
864
891
  def _is_codex_agent(agent: AgentBase | None) -> bool:
865
892
  return isinstance(agent, ACPAgent) and agent.acp_server == "codex"
@@ -885,9 +912,14 @@ class ConversationService:
885
912
  # ``_load_persisted_state_sync`` usage elsewhere.
886
913
  if agent is None:
887
914
  agent = await asyncio.to_thread(self._agent_from_base_state, stored.id)
888
- bindings = self._credential_bindings.pop(stored.id, {})
915
+ bindings = {
916
+ name: binding
917
+ for name, binding in self._credential_bindings.pop(stored.id, {}).items()
918
+ if self._profile_allows_secret(stored, name)
919
+ }
889
920
  if (
890
- CODEX_AUTH_SECRET_NAME not in bindings
921
+ self._profile_allows_secret(stored, CODEX_AUTH_SECRET_NAME)
922
+ and CODEX_AUTH_SECRET_NAME not in bindings
891
923
  and self._is_codex_agent(agent)
892
924
  and await self._has_local_codex_credential()
893
925
  ):
@@ -1427,6 +1459,23 @@ class ConversationService:
1427
1459
  ):
1428
1460
  async with self._conversation_lifecycle(conversation_id):
1429
1461
  existing_event_service = self._event_services.get(conversation_id)
1462
+ stored = (
1463
+ existing_event_service.stored
1464
+ if existing_event_service is not None
1465
+ else existing_record.stored
1466
+ if existing_record is not None
1467
+ else None
1468
+ )
1469
+ if stored is not None:
1470
+ request = request.model_copy(
1471
+ update={
1472
+ "secrets": {
1473
+ name: value
1474
+ for name, value in request.secrets.items()
1475
+ if self._profile_allows_secret(stored, name)
1476
+ }
1477
+ }
1478
+ )
1430
1479
  if (
1431
1480
  existing_event_service is not None
1432
1481
  and existing_event_service.is_open()
@@ -1595,14 +1644,27 @@ class ConversationService:
1595
1644
 
1596
1645
  if request.agent_profile_id is not None:
1597
1646
  mcp_config = settings.agent_settings.mcp_config
1598
- resolved_agent, launched_agent_profile = await asyncio.to_thread(
1647
+ (
1648
+ resolved_agent,
1649
+ launched_agent_profile,
1650
+ allowed_secrets,
1651
+ ) = await asyncio.to_thread(
1599
1652
  _resolve_agent_from_profile,
1600
1653
  request.agent_profile_id,
1601
1654
  self.cipher,
1602
1655
  mcp_config,
1603
1656
  acp_skill_sourcing=self.acp_skill_sourcing,
1604
1657
  )
1605
- request = request.model_copy(update={"agent": resolved_agent})
1658
+ updates: dict[str, Any] = {"agent": resolved_agent}
1659
+ # Enforced here, not client-side: a caller that sends more secrets
1660
+ # than the profile allows must not widen the agent's scope.
1661
+ if allowed_secrets is not None:
1662
+ updates["secrets"] = {
1663
+ name: value
1664
+ for name, value in request.secrets.items()
1665
+ if name in allowed_secrets
1666
+ }
1667
+ request = request.model_copy(update=updates)
1606
1668
 
1607
1669
  # Applied unconditionally: a serialized agent always carries
1608
1670
  # ``load_memory`` (model_dump emits defaults), so there is no way to
@@ -70,7 +70,15 @@ def get_conversation_service(request: Request) -> ConversationService:
70
70
  return service
71
71
 
72
72
 
73
- def get_bash_event_service(request: Request) -> BashEventService:
73
+ async def get_bash_event_service(request: Request) -> BashEventService:
74
+ if "runtime_conversation_id" in request.path_params:
75
+ event_service: EventService = request.state.runtime_event_service
76
+ if event_service.bash_event_service is None:
77
+ event_service.bash_event_service = BashEventService(
78
+ bash_events_dir=event_service.conversation_dir / "bash_events",
79
+ default_cwd=event_service.get_conversation().workspace.working_dir,
80
+ )
81
+ return event_service.bash_event_service
74
82
  service = getattr(request.app.state, "bash_event_service", None)
75
83
  if service is None:
76
84
  raise HTTPException(
@@ -3,7 +3,7 @@
3
3
  # NOTE: LC_ALL/LANG must be set to C.UTF-8 for libtmux to work correctly with
4
4
  # PyInstaller builds. Without proper locale, tmux converts UTF-8 separator
5
5
  # characters to underscores, breaking libtmux's format parsing.
6
- ARG BASE_IMAGE=nikolaik/python-nodejs:python3.13-nodejs22-slim
6
+ ARG BASE_IMAGE=python-node-runtime
7
7
  ARG USERNAME=openhands
8
8
  ARG UID=10001
9
9
  ARG GID=10001
@@ -26,6 +26,70 @@ ARG INSTALL_ACP_PROVIDERS=claude-code,codex,gemini-cli
26
26
  # below is written so that's a one-case addition, not a signature change.
27
27
  ARG INSTALL_CAPABILITIES=vscode,browser,docker
28
28
 
29
+ ####################################################################################
30
+ # Canonical Python + Node runtime
31
+ #
32
+ # Start from Debian itself and copy only the language runtimes from their official
33
+ # images. The final stage installs only the shared libraries required by Python
34
+ # and Node; developer and package-manager tooling is added by the agent-server
35
+ # stages below when needed. Package upgrades resolve against a dated snapshot so
36
+ # newly published Debian packages have a seven-day observation period.
37
+ ####################################################################################
38
+ FROM python:3.13.15-slim-trixie AS python-runtime
39
+ FROM node:24.21.0-trixie-slim AS node-runtime
40
+ FROM debian:trixie-slim AS python-node-runtime
41
+ # Temporary exception to pick up Debian 13.7 before the normal observation period.
42
+ ARG DEBIAN_SNAPSHOT=20260913T000000Z
43
+
44
+ COPY --from=python-runtime /usr/local /usr/local
45
+ COPY --from=node-runtime /usr/local/bin/node /usr/local/bin/node
46
+ COPY --from=node-runtime /usr/local/lib/node_modules /usr/local/lib/node_modules
47
+
48
+ RUN set -eux; \
49
+ ln -s ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm; \
50
+ ln -s ../lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx; \
51
+ ln -s ../lib/node_modules/corepack/dist/corepack.js /usr/local/bin/corepack; \
52
+ printf '%s\n' \
53
+ 'Types: deb' \
54
+ "URIs: http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}/" \
55
+ 'Suites: trixie' \
56
+ 'Components: main' \
57
+ 'Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg' \
58
+ 'Check-Valid-Until: no' \
59
+ '' \
60
+ 'Types: deb' \
61
+ "URIs: http://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT}/" \
62
+ 'Suites: trixie-security' \
63
+ 'Components: main' \
64
+ 'Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg' \
65
+ 'Check-Valid-Until: no' \
66
+ > /etc/apt/sources.list.d/debian.sources; \
67
+ apt-get update; \
68
+ apt-get upgrade -y; \
69
+ apt-get install -y --no-install-recommends \
70
+ ca-certificates \
71
+ libbz2-1.0 \
72
+ libdb5.3t64 \
73
+ libffi8 \
74
+ libgcc-s1 \
75
+ libgdbm6t64 \
76
+ liblzma5 \
77
+ libncursesw6 \
78
+ libreadline8t64 \
79
+ libsqlite3-0 \
80
+ libssl3t64 \
81
+ libstdc++6 \
82
+ libtinfo6 \
83
+ libuuid1 \
84
+ libzstd1 \
85
+ netbase \
86
+ tzdata \
87
+ zlib1g; \
88
+ rm -rf /var/lib/apt/lists/*; \
89
+ python3 --version; \
90
+ node --version; \
91
+ npm --version
92
+
29
93
  ####################################################################################
30
94
  # Builder (source mode)
31
95
  # We copy source + build a venv here for local dev and debugging.
@@ -168,9 +232,8 @@ RUN set -eux; \
168
232
 
169
233
  ####################################################################################
170
234
  # Base image (minimal)
171
- # It includes only basic packages and the UV runtime.
172
- # No Docker, no browser, no VSCode Web.
173
- # Suitable for running in headless/evaluation mode.
235
+ # It includes only the packages required to run the agent server and its Bash
236
+ # and Git APIs. Development tools and optional capabilities live in base-image.
174
237
  ####################################################################################
175
238
  FROM ${BASE_IMAGE} AS base-image-minimal
176
239
  ARG USERNAME UID GID PORT
@@ -192,36 +255,22 @@ RUN set -eux; \
192
255
  if command -v apt-get >/dev/null 2>&1; then \
193
256
  apt-get -o Acquire::Retries=5 update; \
194
257
  apt-get -o Acquire::Retries=5 install -y --no-install-recommends \
195
- bash ca-certificates curl wget sudo apt-utils git jq tmux tar \
196
- build-essential coreutils util-linux procps findutils grep sed \
197
- tini apt-transport-https gnupg lsb-release xz-utils; \
258
+ bash ca-certificates git tini tmux; \
198
259
  rm -rf /var/lib/apt/lists/*; \
199
260
  elif command -v apk >/dev/null 2>&1; then \
200
- apk add --no-cache \
201
- bash ca-certificates curl wget sudo git jq tmux tar build-base \
202
- coreutils util-linux procps findutils grep sed tini gnupg shadow xz; \
261
+ apk add --no-cache bash ca-certificates git shadow tini tmux; \
203
262
  elif command -v microdnf >/dev/null 2>&1; then \
204
- microdnf install -y \
205
- bash ca-certificates curl wget sudo git jq tmux tar make gcc gcc-c++ \
206
- coreutils util-linux procps-ng findutils grep sed shadow-utils \
207
- gnupg2 xz; \
263
+ microdnf install -y bash ca-certificates git shadow-utils tmux; \
208
264
  microdnf clean all; \
209
265
  elif command -v dnf >/dev/null 2>&1; then \
210
- dnf install -y \
211
- bash ca-certificates curl wget sudo git jq tmux tar make gcc gcc-c++ \
212
- coreutils util-linux procps-ng findutils grep sed shadow-utils \
213
- gnupg2 xz; \
266
+ dnf install -y bash ca-certificates git shadow-utils tmux; \
214
267
  dnf clean all; \
215
268
  elif command -v yum >/dev/null 2>&1; then \
216
- yum install -y \
217
- bash ca-certificates curl wget sudo git jq tmux tar make gcc gcc-c++ \
218
- coreutils util-linux procps-ng findutils grep sed shadow-utils \
219
- gnupg2 xz; \
269
+ yum install -y bash ca-certificates git shadow-utils tmux; \
220
270
  yum clean all; \
221
271
  elif command -v zypper >/dev/null 2>&1; then \
222
272
  zypper --non-interactive install --no-recommends \
223
- bash ca-certificates curl wget sudo git jq tmux tar make gcc gcc-c++ \
224
- coreutils util-linux procps findutils grep sed shadow gpg2 xz; \
273
+ bash ca-certificates git shadow tmux; \
225
274
  zypper clean --all; \
226
275
  else \
227
276
  echo "Unsupported base image: no known package manager found" >&2; \
@@ -230,11 +279,6 @@ RUN set -eux; \
230
279
  grep -Eq "^[^:]*:[^:]*:${GID}:" /etc/group || groupadd -g "${GID}" "${USERNAME}"; \
231
280
  grep -Eq "^${USERNAME}:" /etc/passwd || \
232
281
  useradd -m -u "${UID}" -g "${GID}" -s /bin/bash "${USERNAME}"; \
233
- # Best-effort: add user to a sudo group when one exists (Debian-style
234
- # `sudo` group). On Alpine/RHEL/SUSE there is no `sudo` group by default,
235
- # and the NOPASSWD sudoers line below grants sudo regardless of group.
236
- usermod -aG sudo "${USERNAME}" 2>/dev/null || true; \
237
- echo "${USERNAME} ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers; \
238
282
  mkdir -p /workspace/project; \
239
283
  chown -R "${USERNAME}:${USERNAME}" /workspace
240
284
 
@@ -274,10 +318,6 @@ RUN if ! "$ACP_NODE_DIR/bin/node" --version >/dev/null 2>&1; then \
274
318
  RUN mkdir -p /etc/claude-code && \
275
319
  echo '{"permissions":{"allow":["Edit","Read","Bash"]}}' > /etc/claude-code/managed-settings.json
276
320
 
277
- # NOTE: we should NOT include UV_PROJECT_ENVIRONMENT here,
278
- # since the agent might use it to perform other work (e.g. tools that use Python)
279
- COPY --from=ghcr.io/astral-sh/uv:0.11.6 /uv /uvx /bin/
280
-
281
321
  USER ${USERNAME}
282
322
  WORKDIR /
283
323
  # Locale settings required for libtmux to work with PyInstaller builds
@@ -299,6 +339,47 @@ ARG INSTALL_CAPABILITIES
299
339
 
300
340
  USER root
301
341
 
342
+ # Full images are general-purpose development environments. Keep compilers,
343
+ # package/repository helpers, and command-line utilities out of binary-minimal.
344
+ RUN set -eux; \
345
+ if command -v apt-get >/dev/null 2>&1; then \
346
+ apt-get -o Acquire::Retries=5 update; \
347
+ apt-get -o Acquire::Retries=5 install -y --no-install-recommends \
348
+ apt-transport-https apt-utils build-essential coreutils curl \
349
+ findutils gnupg grep jq lsb-release procps sed sudo tar util-linux \
350
+ wget xz-utils; \
351
+ rm -rf /var/lib/apt/lists/*; \
352
+ elif command -v apk >/dev/null 2>&1; then \
353
+ apk add --no-cache \
354
+ build-base coreutils curl findutils gnupg grep jq procps sudo tar \
355
+ util-linux wget xz; \
356
+ elif command -v microdnf >/dev/null 2>&1; then \
357
+ microdnf install -y \
358
+ coreutils curl findutils gcc gcc-c++ gnupg2 grep jq make procps-ng \
359
+ sed sudo tar util-linux wget xz; \
360
+ microdnf clean all; \
361
+ elif command -v dnf >/dev/null 2>&1; then \
362
+ dnf install -y \
363
+ coreutils curl findutils gcc gcc-c++ gnupg2 grep jq make procps-ng \
364
+ sed sudo tar util-linux wget xz; \
365
+ dnf clean all; \
366
+ elif command -v yum >/dev/null 2>&1; then \
367
+ yum install -y \
368
+ coreutils curl findutils gcc gcc-c++ gnupg2 grep jq make procps-ng \
369
+ sed sudo tar util-linux wget xz; \
370
+ yum clean all; \
371
+ elif command -v zypper >/dev/null 2>&1; then \
372
+ zypper --non-interactive install --no-recommends \
373
+ coreutils curl findutils gcc gcc-c++ gpg2 grep jq make procps sed \
374
+ sudo tar util-linux wget xz; \
375
+ zypper clean --all; \
376
+ fi; \
377
+ usermod -aG sudo "${USERNAME}" 2>/dev/null || true; \
378
+ echo "${USERNAME} ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers
379
+
380
+ # Keep uv as part of the full development environment, but not binary-minimal.
381
+ COPY --from=ghcr.io/astral-sh/uv:0.11.6 /uv /uvx /bin/
382
+
302
383
  # Fail fast on a typo'd capability name before spending any time on the
303
384
  # installs below (each block only checks for ITS OWN keyword's presence, so
304
385
  # an unrecognized one would otherwise be silently ignored rather than erroring).
@@ -393,9 +393,7 @@ _DEFAULT_PACKAGE_VERSION = _package_version()
393
393
 
394
394
 
395
395
  class BuildOptions(BaseModel):
396
- # NOTE: Using Python 3.12 due to PyInstaller+libtmux compatibility issue
397
- # with Python 3.13. See issue #1886 for details.
398
- base_image: str = Field(default="nikolaik/python-nodejs:python3.12-nodejs22-slim")
396
+ base_image: str = Field(default="python-node-runtime")
399
397
  custom_tags: str = Field(
400
398
  default="", description="Comma-separated list of custom tags."
401
399
  )
@@ -1069,9 +1067,7 @@ def main(argv: list[str]) -> int:
1069
1067
  )
1070
1068
  parser.add_argument(
1071
1069
  "--base-image",
1072
- # NOTE: Using Python 3.12 due to PyInstaller+libtmux compatibility issue
1073
- # with Python 3.13. See issue #1886.
1074
- default=_env("BASE_IMAGE", "nikolaik/python-nodejs:python3.12-nodejs22-slim"),
1070
+ default=_env("BASE_IMAGE", "python-node-runtime"),
1075
1071
  help="Base image to use (default from $BASE_IMAGE).",
1076
1072
  )
1077
1073
  parser.add_argument(
@@ -70,7 +70,7 @@ async def search_conversation_events(
70
70
  ] = None,
71
71
  limit: Annotated[
72
72
  int,
73
- Query(title="The max number of results in the page", gt=0, lte=100),
73
+ Query(title="The max number of results in the page", gt=0, le=100),
74
74
  ] = 100,
75
75
  kind: Annotated[
76
76
  str | None,
@@ -101,8 +101,6 @@ async def search_conversation_events(
101
101
  event_service: EventService = Depends(get_event_service),
102
102
  ) -> JSONResponse:
103
103
  """Search / List local events"""
104
- assert limit > 0
105
- assert limit <= 100
106
104
 
107
105
  # Normalize timezone-aware datetimes to server timezone
108
106
  normalized_gte = (