diff --git a/shared/egg_agent/client.py b/shared/egg_agent/client.py index ebe674f958..4ac8bbe063 100644 --- a/shared/egg_agent/client.py +++ b/shared/egg_agent/client.py @@ -95,6 +95,12 @@ async def run_agent_async( """ model = model or DEFAULT_MODEL + # Resolve cwd: explicit arg > EGG_REPO_PATH > SDK default (os.getcwd()). + # Sandbox agents start at HOME (/home/egg) while the repo lives at + # /home/egg/repos/ (EGG_REPO_PATH). Defaulting to EGG_REPO_PATH + # lands the agent in the repo on its first tool call. See #1993. + resolved_cwd: str | None = str(cwd) if cwd else (os.environ.get("EGG_REPO_PATH") or None) + # --- Harness selection (opt-in via EGG_HARNESS env var) --- harness = os.environ.get("EGG_HARNESS", "claude-sdk") if harness == "egg": @@ -106,7 +112,7 @@ async def run_agent_async( model=model, max_turns=max_turns or 200, system_prompt=system_prompt, - cwd=str(cwd) if cwd else None, + cwd=resolved_cwd, timeout=timeout, on_output=on_output, env=env, @@ -194,7 +200,7 @@ async def _check_tool_permission( options = ClaudeAgentOptions( permission_mode="bypassPermissions", model=model, - cwd=str(cwd) if cwd else None, + cwd=resolved_cwd, env=env or {}, # Read CLAUDE.md and settings.json from the filesystem so the agent # picks up sandbox rules (BRC protocol, egg-orch CLI, git safety, etc.). @@ -275,10 +281,11 @@ async def _check_tool_permission( actual_model: str | None = None result_meta: dict[str, Any] = {} - # Log the effective cwd — when the caller did not pass one, the SDK - # inherits os.getcwd(), so log that rather than None. Keeps - # session-init lines diagnostically useful (see #1954). - effective_cwd = str(cwd) if cwd else os.getcwd() + # Log the effective cwd — when the caller did not pass one and + # EGG_REPO_PATH is unset, the SDK inherits os.getcwd(), so log + # that rather than None. Keeps session-init lines diagnostically + # useful (see #1954, #1993). + effective_cwd = resolved_cwd or os.getcwd() logger.info( "Agent session init", event_type="system", diff --git a/shared/egg_harness_integration/harness_factory.py b/shared/egg_harness_integration/harness_factory.py index ad25b298ce..bb829ee587 100644 --- a/shared/egg_harness_integration/harness_factory.py +++ b/shared/egg_harness_integration/harness_factory.py @@ -111,7 +111,9 @@ def create_egg_harness( provider = RetryProvider(inner_provider) # -- Tool registry ----------------------------------------------------- - cwd_str = str(cwd) if cwd is not None else None + # Default cwd to EGG_REPO_PATH so sandbox agents start inside the repo + # rather than at HOME (/home/egg). See #1993. + cwd_str = str(cwd) if cwd is not None else (os.environ.get("EGG_REPO_PATH") or None) registry = ToolRegistry() @@ -163,6 +165,10 @@ def create_egg_harness( if system_prompt is None: # Look for project CLAUDE.md relative to cwd or EGG_REPO_PATH. project_claude_md: str | None = None + # cwd_str already incorporates the EGG_REPO_PATH fallback (line 116), + # so the `or` branch is redundant when called from client.py. It is + # kept for direct callers of create_egg_harness (e.g. validate_harness_parity.py) + # that may pass cwd=None without the env-var resolution layer. repo_path = cwd_str or os.environ.get("EGG_REPO_PATH") if repo_path: candidate = os.path.join(repo_path, "CLAUDE.md") diff --git a/tests/shared/egg_agent/test_client.py b/tests/shared/egg_agent/test_client.py index bf295b9f43..f566f0a313 100644 --- a/tests/shared/egg_agent/test_client.py +++ b/tests/shared/egg_agent/test_client.py @@ -339,8 +339,12 @@ def test_structured_logging_init_and_result(self, mock_query): @patch("claude_agent_sdk.query", side_effect=_mock_query_success) def test_init_log_cwd_fallback(self, mock_query): - """Test that cwd falls back to os.getcwd() when not provided.""" - with patch("egg_agent.client.logger") as mock_logger: + """Test that cwd falls back to os.getcwd() when neither arg nor env is set.""" + with ( + patch("egg_agent.client.logger") as mock_logger, + patch.dict(os.environ, {}, clear=False) as env, + ): + env.pop("EGG_REPO_PATH", None) _run_async(run_agent_async("test prompt")) init_calls = [ @@ -351,6 +355,10 @@ def test_init_log_cwd_fallback(self, mock_query): assert len(init_calls) == 1 assert init_calls[0].kwargs["cwd"] == os.getcwd() + # Verify cwd on the options object actually passed to query() + options = mock_query.call_args.kwargs["options"] + assert options.cwd is None # SDK defaults to os.getcwd() + @patch("claude_agent_sdk.query", side_effect=_mock_query_success) def test_init_log_cwd_explicit(self, mock_query): """Test that cwd uses the provided value when passed explicitly.""" @@ -365,6 +373,75 @@ def test_init_log_cwd_explicit(self, mock_query): assert len(init_calls) == 1 assert init_calls[0].kwargs["cwd"] == "/tmp/test-dir" + # Verify cwd on the options object actually passed to query() + options = mock_query.call_args.kwargs["options"] + assert options.cwd == "/tmp/test-dir" + + @patch("claude_agent_sdk.query", side_effect=_mock_query_success) + def test_init_log_cwd_falls_back_to_egg_repo_path(self, mock_query): + """When no cwd is passed, EGG_REPO_PATH should be used (see #1993).""" + with ( + patch("egg_agent.client.logger") as mock_logger, + patch.dict(os.environ, {"EGG_REPO_PATH": "/home/egg/repos/myrepo"}), + ): + _run_async(run_agent_async("test prompt")) + + init_calls = [ + c + for c in mock_logger.info.call_args_list + if c.args and c.args[0] == "Agent session init" + ] + assert len(init_calls) == 1 + assert init_calls[0].kwargs["cwd"] == "/home/egg/repos/myrepo" + + # Verify cwd on the options object actually passed to query() + options = mock_query.call_args.kwargs["options"] + assert options.cwd == "/home/egg/repos/myrepo" + + @patch("claude_agent_sdk.query", side_effect=_mock_query_success) + def test_explicit_cwd_wins_over_egg_repo_path(self, mock_query): + """Explicit cwd argument must take precedence over EGG_REPO_PATH.""" + with ( + patch("egg_agent.client.logger") as mock_logger, + patch.dict(os.environ, {"EGG_REPO_PATH": "/home/egg/repos/myrepo"}), + ): + _run_async(run_agent_async("test prompt", cwd="/tmp/explicit")) + + init_calls = [ + c + for c in mock_logger.info.call_args_list + if c.args and c.args[0] == "Agent session init" + ] + assert len(init_calls) == 1 + assert init_calls[0].kwargs["cwd"] == "/tmp/explicit" + + # Verify cwd on the options object actually passed to query() + options = mock_query.call_args.kwargs["options"] + assert options.cwd == "/tmp/explicit" + + @patch("claude_agent_sdk.query", side_effect=_mock_query_success) + def test_empty_egg_repo_path_treated_as_unset(self, mock_query): + """EGG_REPO_PATH='' (set but empty) should behave like unset.""" + with ( + patch("egg_agent.client.logger") as mock_logger, + patch.dict(os.environ, {"EGG_REPO_PATH": ""}), + ): + _run_async(run_agent_async("test prompt")) + + init_calls = [ + c + for c in mock_logger.info.call_args_list + if c.args and c.args[0] == "Agent session init" + ] + assert len(init_calls) == 1 + # Empty EGG_REPO_PATH should fall back to os.getcwd() + assert init_calls[0].kwargs["cwd"] == os.getcwd() + + # Verify cwd on the options object — should be None so SDK + # defaults to os.getcwd(), not "" + options = mock_query.call_args.kwargs["options"] + assert options.cwd is None + @patch("claude_agent_sdk.query", side_effect=_mock_query_error) def test_structured_logging_on_error(self, mock_query): """Test that system/result log is emitted on error paths."""