From 6f68fa2a5c7f1c65612f02f9ed099db3efadf351 Mon Sep 17 00:00:00 2001 From: DCHA Agent <259406208+dcha-agent@users.noreply.github.com> Date: Thu, 20 Aug 2026 01:30:44 +0800 Subject: [PATCH] Prevent claude code auto creating branch when using /new creating new sessions --- src/coding_agent_telegram/agent_runner.py | 19 ++++++- .../router/session_lifecycle_commands.py | 12 +++- src/coding_agent_telegram/session_runtime.py | 3 + tests/test_agent_runner.py | 57 +++++++++++++++++++ tests/test_command_router.py | 11 +++- 5 files changed, 95 insertions(+), 7 deletions(-) diff --git a/src/coding_agent_telegram/agent_runner.py b/src/coding_agent_telegram/agent_runner.py index d5d94ac..824100a 100644 --- a/src/coding_agent_telegram/agent_runner.py +++ b/src/coding_agent_telegram/agent_runner.py @@ -835,11 +835,17 @@ def _copilot_base( ) return args - def _claude_base(self, user_message: str) -> list[str]: + def _claude_base(self, user_message: str, *, for_session_creation: bool = False) -> list[str]: args = [] if self.claude_model: args.extend(["--model", self.claude_model]) - if self.claude_permission_mode: + # Session creation only primes the CLI to hand back a session ID, so it runs + # read-only. Without this the priming prompt inherits full autopilot + # permissions and the agent may act on it (e.g. creating a git branch named + # after the session) behind the bot's back. + if for_session_creation: + args.extend(["--permission-mode", "plan"]) + elif self.claude_permission_mode: args.extend(["--permission-mode", self.claude_permission_mode]) if self.claude_allowed_tools: args.extend(["--allowedTools", ",".join(self.claude_allowed_tools)]) @@ -864,9 +870,16 @@ def create_session( *, skip_git_repo_check: bool = False, image_paths: Sequence[Path] = (), + priming_only: bool = False, on_stall: Optional[Callable[[AgentStallInfo], None]] = None, on_progress: Optional[Callable[[AgentProgressInfo], None]] = None, ) -> AgentRunResult: + """Create a session. + + ``priming_only`` marks calls whose prompt exists solely to make the CLI hand + back a session ID; those run read-only so the throwaway prompt cannot be acted + on. Callers that pass a real user request must leave it False. + """ if provider == "codex": args = [ self.codex_bin, @@ -894,7 +907,7 @@ def create_session( on_progress=on_progress, ) elif provider == "claude": - args = [self.claude_bin, *self._claude_base(user_message)] + args = [self.claude_bin, *self._claude_base(user_message, for_session_creation=priming_only)] return self._run( args, provider="claude", diff --git a/src/coding_agent_telegram/router/session_lifecycle_commands.py b/src/coding_agent_telegram/router/session_lifecycle_commands.py index 73275be..76a78e5 100644 --- a/src/coding_agent_telegram/router/session_lifecycle_commands.py +++ b/src/coding_agent_telegram/router/session_lifecycle_commands.py @@ -11,6 +11,15 @@ from .base import logger, require_allowed_chat +# Sent only to make the CLI open a session and return its ID. Kept explicitly inert: +# an instruction like "Create session: " reads as a real task to an autonomous +# agent, which would then act on it (e.g. creating a git branch named after the +# session) without the bot knowing. +SESSION_PRIMING_PROMPT = ( + "Reply with exactly: ready. Do not make any changes, run any commands, or use any tools." +) + + class SessionLifecycleCommandMixin: _CREATE_SESSION_TEXT_RE = re.compile(r"^\s*create\s+session\s*:\s*(.*?)\s*$", re.IGNORECASE) @@ -137,9 +146,10 @@ async def _create_session_for_context( self.deps.agent_runner.create_session, provider, project_path, - f"Create session: {creation_label}", + SESSION_PRIMING_PROMPT, workspace_lock_key=project_folder, skip_git_repo_check=self.runtime.should_skip_git_repo_check(project_folder), + priming_only=True, stall_message=self._t(update, "runtime.replacement_session_stall"), ) diff --git a/src/coding_agent_telegram/session_runtime.py b/src/coding_agent_telegram/session_runtime.py index 7beb383..f409277 100644 --- a/src/coding_agent_telegram/session_runtime.py +++ b/src/coding_agent_telegram/session_runtime.py @@ -510,6 +510,9 @@ async def compact_active_session( COMPACT_BOOTSTRAP_TEMPLATE.format(summary=compact_summary), workspace_lock_key=project_folder, skip_git_repo_check=self.should_skip_git_repo_check(project_folder), + # Seeds context and returns a session ID only. The summary lists "next + # steps", which an autopilot agent would otherwise start executing here. + priming_only=True, stall_message=self._t(update, "runtime.replacement_session_stall"), progress_label=self._t(update, "runtime.live_agent_output"), ) diff --git a/tests/test_agent_runner.py b/tests/test_agent_runner.py index 7364d39..d1a37a2 100644 --- a/tests/test_agent_runner.py +++ b/tests/test_agent_runner.py @@ -762,6 +762,63 @@ def test_claude_runner_ignores_image_paths_without_error(monkeypatch): assert result.success is True +def test_claude_priming_session_creation_runs_read_only(monkeypatch): + calls = [] + monkeypatch.setattr("coding_agent_telegram.agent_runner.subprocess.Popen", make_fake_popen(calls)) + + runner = MultiAgentRunner( + codex_bin="codex", + copilot_bin="copilot", + approval_policy="never", + sandbox_mode="workspace-write", + claude_permission_mode="bypassPermissions", + ) + + runner.create_session("claude", Path("/tmp/project"), "prime me", priming_only=True) + + args = calls[0][0] + assert args[args.index("--permission-mode") + 1] == "plan" + assert "bypassPermissions" not in args + + +def test_claude_session_creation_with_real_prompt_keeps_configured_permission_mode(monkeypatch): + calls = [] + monkeypatch.setattr("coding_agent_telegram.agent_runner.subprocess.Popen", make_fake_popen(calls)) + + runner = MultiAgentRunner( + codex_bin="codex", + copilot_bin="copilot", + approval_policy="never", + sandbox_mode="workspace-write", + claude_permission_mode="bypassPermissions", + ) + + # The replacement-session path passes the real user request here, so it must not + # be downgraded to read-only. + runner.create_session("claude", Path("/tmp/project"), "fix the bug") + + args = calls[0][0] + assert args[args.index("--permission-mode") + 1] == "bypassPermissions" + + +def test_claude_resume_keeps_configured_permission_mode(monkeypatch): + calls = [] + monkeypatch.setattr("coding_agent_telegram.agent_runner.subprocess.Popen", make_fake_popen(calls)) + + runner = MultiAgentRunner( + codex_bin="codex", + copilot_bin="copilot", + approval_policy="never", + sandbox_mode="workspace-write", + claude_permission_mode="bypassPermissions", + ) + + runner.resume_session("claude", "sess_abc", Path("/tmp/project"), "keep working") + + args = calls[0][0] + assert args[args.index("--permission-mode") + 1] == "bypassPermissions" + + # --------------------------------------------------------------------------- # _validate_session_id # --------------------------------------------------------------------------- diff --git a/tests/test_command_router.py b/tests/test_command_router.py index 262f0e2..47d181c 100644 --- a/tests/test_command_router.py +++ b/tests/test_command_router.py @@ -13,6 +13,7 @@ import pytest from coding_agent_telegram.agent_runner import AgentProgressInfo, AgentRunResult, AgentStallInfo from coding_agent_telegram.command_router import CommandRouter, RouterDeps +from coding_agent_telegram.router.session_lifecycle_commands import SESSION_PRIMING_PROMPT from coding_agent_telegram.config import AppConfig from coding_agent_telegram.session_store import SessionStore from coding_agent_telegram.speech_to_text import SpeechToTextError @@ -32,6 +33,7 @@ def create_session( *, skip_git_repo_check=False, image_paths=(), + priming_only=False, on_stall=None, on_progress=None, ): @@ -128,6 +130,7 @@ def create_session( *, skip_git_repo_check=False, image_paths=(), + priming_only=False, on_stall=None, on_progress=None, ): @@ -1187,6 +1190,7 @@ def create_session( *, skip_git_repo_check=False, image_paths=(), + priming_only=False, on_stall=None, on_progress=None, ): @@ -1565,7 +1569,7 @@ def test_new_without_name_uses_new_session_as_default_name(tmp_path: Path): state = store.get_chat_state("bot-a", 123) assert state["sessions"]["sess_abc123"]["name"] == "sess_abc123" assert "Session created successfully: sess_abc123" in bot.messages[-1][1] - assert runner.create_calls[-1]["user_message"] == "Create session: new session" + assert runner.create_calls[-1]["user_message"] == SESSION_PRIMING_PROMPT def test_new_without_name_ignores_existing_new_session_labels(tmp_path: Path): @@ -1610,7 +1614,7 @@ def test_plain_text_create_session_new_session_uses_unnamed_flow(tmp_path: Path) state = store.get_chat_state("bot-a", 123) assert state["sessions"]["sess_abc123"]["name"] == "sess_abc123" - assert runner.create_calls[-1]["user_message"] == "Create session: new session" + assert runner.create_calls[-1]["user_message"] == SESSION_PRIMING_PROMPT def test_plain_text_create_session_with_name_matches_new_command(tmp_path: Path): @@ -1632,7 +1636,7 @@ def test_plain_text_create_session_with_name_matches_new_command(tmp_path: Path) state = store.get_chat_state("bot-a", 123) assert state["sessions"]["sess_abc123"]["name"] == "release prep" - assert runner.create_calls[-1]["user_message"] == "Create session: release prep" + assert runner.create_calls[-1]["user_message"] == SESSION_PRIMING_PROMPT def test_provider_command_sends_inline_buttons(tmp_path: Path): @@ -8147,6 +8151,7 @@ def create_session( *, skip_git_repo_check=False, image_paths=(), + priming_only=False, on_stall=None, on_progress=None, ):