diff --git a/omnigent/runner/tool_dispatch.py b/omnigent/runner/tool_dispatch.py index c4490deb19..05441a68f1 100644 --- a/omnigent/runner/tool_dispatch.py +++ b/omnigent/runner/tool_dispatch.py @@ -2241,6 +2241,7 @@ def _build_session_create_body( title: object, message: object, model: object = None, + terminal_launch_args: object = None, ) -> _JsonObject: """ Build the JSON ``POST /v1/sessions`` body for ``sys_session_create``. @@ -2269,6 +2270,12 @@ def _build_session_create_body( body["title"] = title if isinstance(model, str) and model: body["model_override"] = model + if isinstance(terminal_launch_args, list) and terminal_launch_args: + # Forward verbatim; the server bounds-checks via + # _validate_terminal_launch_args (flat list, no shell metachars). + body["terminal_launch_args"] = [ + str(a) for a in terminal_launch_args if isinstance(a, str) + ] if isinstance(message, str) and message: body["initial_items"] = [ { @@ -2423,6 +2430,7 @@ async def _execute_session_create( args.get("title"), args.get("message"), model=args.get("model"), + terminal_launch_args=args.get("terminal_launch_args"), ) try: resp = await server_client.post("/v1/sessions", json=body, timeout=30.0) @@ -2589,6 +2597,14 @@ async def _upload_config_bundle( title = args.get("title") if isinstance(title, str) and title: metadata["title"] = title + terminal_launch_args = args.get("terminal_launch_args") + if isinstance(terminal_launch_args, list) and terminal_launch_args: + # Forward into the multipart metadata JSON; the server parses it + # via _parse_session_create_metadata -> SessionCreateMetadata, which + # already owns _validate_terminal_launch_args (bounds-checked). + metadata["terminal_launch_args"] = [ + str(a) for a in terminal_launch_args if isinstance(a, str) + ] try: resp = await server_client.post( "/v1/sessions", diff --git a/omnigent/tools/builtins/spawn.py b/omnigent/tools/builtins/spawn.py index 0216a22f14..872684b883 100644 --- a/omnigent/tools/builtins/spawn.py +++ b/omnigent/tools/builtins/spawn.py @@ -936,6 +936,25 @@ def get_schema(self) -> dict[str, Any]: "agent's default." ), }, + "terminal_launch_args": { + "type": "array", + "items": {"type": "string"}, + "description": ( + "Optional native-terminal pass-through args " + "for the child session, e.g. " + "[\"--yolo\"] for a headless cursor-native " + "worker (cursor-agent's full-bypass flag) so it " + "does not stall on in-terminal approval prompts " + "that no human can answer. Forwarded verbatim " + "into the server request body's " + "terminal_launch_args (already validated and " + "bounds-checked server-side); only meaningful " + "for terminal-native harnesses " + "(cursor/codex/claude-native). Top-level " + "sessions keep these explicit args rather than " + "deriving them from the bundle's yolo flag." + ), + }, }, # Only the always-optional fields are listed in # ``required`` (none): the agent_id-vs-config_path diff --git a/tests/runner/test_runner_dispatch.py b/tests/runner/test_runner_dispatch.py index 6601f68d0d..70730f5b87 100644 --- a/tests/runner/test_runner_dispatch.py +++ b/tests/runner/test_runner_dispatch.py @@ -6179,6 +6179,50 @@ async def _server_handler(request: httpx.Request) -> httpx.Response: assert handle["agent_name"] == "researcher" +@pytest.mark.asyncio +async def test_sys_session_create_forwards_terminal_launch_args() -> None: + """ + ``terminal_launch_args`` reaches the JSON create body verbatim. + + Headless native-terminal workers (cursor/codex/claude-native) stall + on in-terminal approval prompts no human can answer; the caller + declares the bypass stance (e.g. ``["--yolo"]``) at launch, and the + server — which already validates + bounds-checks the list — threads + it into the child's terminal_launch_args. If the tool dropped the + field, a top-level agent_id/config_path cursor worker would silently + run WITHOUT ``--yolo`` and stall on every tool call. + """ + from omnigent.runner.tool_dispatch import execute_tool + + captured: dict[str, Any] = {} + + async def _server_handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/v1/sessions": + captured.update(json.loads(request.content)) + return httpx.Response( + 201, + json={"id": "conv_child_tla", "agent_id": "ag_x", "status": "idle"}, + ) + return httpx.Response(404, json={"error": str(request.url)}) + + async with httpx.AsyncClient( + transport=httpx.MockTransport(_server_handler), + base_url="http://server", + ) as server_client: + await execute_tool( + tool_name="sys_session_create", + arguments=json.dumps( + {"agent_id": "ag_x", "terminal_launch_args": ["--yolo"]} + ), + server_client=server_client, + conversation_id="conv_caller", + ) + + # Forwarded verbatim into the create body the server validates. + assert captured["terminal_launch_args"] == ["--yolo"] + assert captured["parent_session_id"] == "conv_caller" + + @pytest.mark.asyncio @pytest.mark.parametrize( "arguments",