From cbeafa35a5ed7fc280ce2e2b9636bdf77dfb1ba2 Mon Sep 17 00:00:00 2001 From: "Yuyao Huang (Sam)" Date: Sun, 29 Mar 2026 06:46:45 +0800 Subject: [PATCH] =?UTF-8?q?feat(perm):=20=E6=B7=BB=E5=8A=A0=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E6=9D=83=E9=99=90=E6=A8=A1=E5=BC=8F=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现会话权限模式管理功能,包括: 1. 在 pty_process 中定义三种权限模式标志 2. 添加 /perm 命令用于修改会话权限模式 3. 新增 run_command 工具用于执行 bot 控制命令 4. 在会话管理中支持权限模式设置 5. 添加完整的测试用例和文档说明 --- agent/manager.py | 27 ++++- agent/pty_process.py | 17 ++- bot/commands.py | 152 ++++++++++++++++++++------- orchestrator/agent.py | 49 ++++++--- orchestrator/tools.py | 37 +++++++ tests/features/commands/perm.feature | 70 ++++++++++++ tests/step_defs/common_steps.py | 9 ++ tests/step_defs/test_commands.py | 1 + 8 files changed, 302 insertions(+), 60 deletions(-) create mode 100644 tests/features/commands/perm.feature diff --git a/agent/manager.py b/agent/manager.py index 9dcb4fa..ec7a006 100644 --- a/agent/manager.py +++ b/agent/manager.py @@ -10,7 +10,7 @@ from dataclasses import dataclass, field, asdict from pathlib import Path from typing import Dict, List, Optional -from agent.pty_process import run_claude +from agent.pty_process import run_claude, DEFAULT_PERMISSION_MODE, VALID_PERMISSION_MODES from agent.audit import log_interaction logger = logging.getLogger(__name__) @@ -30,6 +30,7 @@ class Session: started: bool = False idle_timeout: int = DEFAULT_IDLE_TIMEOUT cc_timeout: float = DEFAULT_CC_TIMEOUT + permission_mode: str = field(default_factory=lambda: DEFAULT_PERMISSION_MODE) def touch(self) -> None: self.last_activity = asyncio.get_event_loop().time() @@ -70,6 +71,7 @@ class SessionManager: owner_id: str = "", idle_timeout: int = DEFAULT_IDLE_TIMEOUT, cc_timeout: float = DEFAULT_CC_TIMEOUT, + permission_mode: str = DEFAULT_PERMISSION_MODE, ) -> Session: async with self._lock: session = Session( @@ -78,12 +80,14 @@ class SessionManager: owner_id=owner_id, idle_timeout=idle_timeout, cc_timeout=cc_timeout, + permission_mode=permission_mode, ) self._sessions[conv_id] = session self._save() logger.info( - "Created session %s (owner=...%s) in %s (idle=%ds, cc=%.0fs)", - conv_id, owner_id[-8:] if owner_id else "-", working_dir, idle_timeout, cc_timeout, + "Created session %s (owner=...%s) in %s (idle=%ds, cc=%.0fs, perm=%s)", + conv_id, owner_id[-8:] if owner_id else "-", working_dir, + idle_timeout, cc_timeout, permission_mode, ) return session @@ -98,6 +102,7 @@ class SessionManager: cwd = session.cwd cc_session_id = session.cc_session_id cc_timeout = session.cc_timeout + permission_mode = session.permission_mode first_message = not session.started if first_message: session.started = True @@ -116,6 +121,7 @@ class SessionManager: cc_session_id=cc_session_id, resume=not first_message, timeout=cc_timeout, + permission_mode=permission_mode, ) log_interaction( conv_id=conv_id, @@ -158,6 +164,7 @@ class SessionManager: cc_session_id=cc_session_id, resume=not first_message, timeout=cc_timeout, + permission_mode=permission_mode, ) log_interaction( @@ -195,10 +202,24 @@ class SessionManager: "started": s.started, "idle_timeout": s.idle_timeout, "cc_timeout": s.cc_timeout, + "permission_mode": s.permission_mode, } for s in sessions ] + def set_permission_mode(self, conv_id: str, mode: str, user_id: Optional[str] = None) -> None: + """Change the permission mode for an existing session.""" + session = self._sessions.get(conv_id) + if session is None: + raise KeyError(f"No session for conv_id={conv_id!r}") + if session.owner_id and user_id and session.owner_id != user_id: + raise PermissionError(f"Session {conv_id} belongs to another user") + if mode not in VALID_PERMISSION_MODES: + raise ValueError(f"Invalid permission mode {mode!r}. Valid: {VALID_PERMISSION_MODES}") + session.permission_mode = mode + self._save() + logger.info("Set permission_mode=%s for session %s", mode, conv_id) + def _save(self) -> None: try: data = {cid: s.to_dict() for cid, s in self._sessions.items()} diff --git a/agent/pty_process.py b/agent/pty_process.py index ffd132d..9cca00b 100644 --- a/agent/pty_process.py +++ b/agent/pty_process.py @@ -17,12 +17,22 @@ def strip_ansi(text: str) -> str: return ANSI_ESCAPE.sub("", text) +PERMISSION_MODE_FLAGS: dict[str, list[str]] = { + "bypassPermissions": ["--dangerously-skip-permissions"], + "acceptEdits": ["--permission-mode", "acceptEdits"], + "plan": ["--permission-mode", "plan"], +} +VALID_PERMISSION_MODES = list(PERMISSION_MODE_FLAGS) +DEFAULT_PERMISSION_MODE = "bypassPermissions" + + async def run_claude( prompt: str, cwd: str, cc_session_id: str | None = None, resume: bool = False, timeout: float = 300.0, + permission_mode: str = DEFAULT_PERMISSION_MODE, ) -> str: """ Run `claude -p ` in the given directory and return the output. @@ -35,11 +45,10 @@ async def run_claude( - Subsequent calls: passed as --resume so CC has full history. resume: If True, use --resume instead of --session-id. timeout: Maximum seconds to wait before giving up. + permission_mode: One of 'bypassPermissions', 'acceptEdits', 'plan'. """ - base_args = [ - "--dangerously-skip-permissions", - "-p", prompt, - ] + perm_flags = PERMISSION_MODE_FLAGS.get(permission_mode, PERMISSION_MODE_FLAGS[DEFAULT_PERMISSION_MODE]) + base_args = perm_flags + ["-p", prompt] if cc_session_id: if resume: diff --git a/bot/commands.py b/bot/commands.py index 160539d..9441c33 100644 --- a/bot/commands.py +++ b/bot/commands.py @@ -12,11 +12,35 @@ from typing import Optional, Tuple from agent.manager import manager from agent.scheduler import scheduler from agent.task_runner import task_runner +from agent.pty_process import VALID_PERMISSION_MODES, DEFAULT_PERMISSION_MODE from orchestrator.agent import agent from orchestrator.tools import set_current_user, get_current_chat logger = logging.getLogger(__name__) +# Permission mode aliases (user-facing shorthand → internal CC mode) +_PERM_ALIASES: dict[str, str] = { + "bypass": "bypassPermissions", + "skip": "bypassPermissions", + "accept": "acceptEdits", + "plan": "plan", +} +_PERM_LABELS: dict[str, str] = { + "bypassPermissions": "bypass", + "acceptEdits": "accept", + "plan": "plan", +} + + +def _resolve_perm(alias: str) -> str | None: + """Map user-facing alias to internal permission mode, or None if invalid.""" + return _PERM_ALIASES.get(alias.lower().strip()) + + +def _perm_label(mode: str) -> str: + """Return short human-readable label for a permission mode.""" + return _PERM_LABELS.get(mode, mode) + def parse_command(text: str) -> Optional[Tuple[str, str]]: """ @@ -67,6 +91,8 @@ async def handle_command(user_id: str, text: str) -> Optional[str]: return await _cmd_shell(args) elif cmd == "/remind": return await _cmd_remind(args) + elif cmd == "/perm": + return await _cmd_perm(user_id, args) elif cmd in ("/nodes", "/node"): return await _cmd_nodes(user_id, args) else: @@ -76,61 +102,71 @@ async def handle_command(user_id: str, text: str) -> Optional[str]: async def _cmd_new(user_id: str, args: str) -> str: """Create a new session.""" if not args: - return "Usage: /new [initial_message] [--timeout N]\nExample: /new todo_app fix the bug --timeout 600" + return "Usage: /new [initial_message] [--timeout N] [--perm MODE]\nModes: bypass (default), accept, plan" parser = argparse.ArgumentParser() parser.add_argument("working_dir", nargs="?", help="Project directory") parser.add_argument("rest", nargs="*", help="Initial message") parser.add_argument("--timeout", type=int, default=None, help="CC timeout in seconds") parser.add_argument("--idle", type=int, default=None, help="Idle timeout in seconds") + parser.add_argument("--perm", default=None, help="Permission mode: bypass, accept, plan") try: parsed = parser.parse_args(args.split()) except SystemExit: - return "Usage: /new [initial_message] [--timeout N] [--idle N]" + return "Usage: /new [initial_message] [--timeout N] [--idle N] [--perm MODE]" if not parsed.working_dir: return "Error: project_dir is required" + permission_mode = _resolve_perm(parsed.perm) if parsed.perm else DEFAULT_PERMISSION_MODE + if permission_mode is None: + return f"Invalid --perm. Valid modes: bypass, accept, plan" + working_dir = parsed.working_dir initial_msg = " ".join(parsed.rest) if parsed.rest else None - from orchestrator.tools import CreateConversationTool + from orchestrator.tools import _resolve_dir - tool = CreateConversationTool() - result = await tool._arun( - working_dir=working_dir, - initial_message=initial_msg, - cc_timeout=parsed.timeout, - idle_timeout=parsed.idle, - ) try: - data = json.loads(result) - if "error" in data: - return f"Error: {data['error']}" - conv_id = data.get("conv_id", "") - agent._active_conv[user_id] = conv_id - cwd = data.get("working_dir", working_dir) + resolved = _resolve_dir(working_dir) + except ValueError as e: + return f"Error: {e}" - chat_id = get_current_chat() - if chat_id: - from bot.feishu import send_card, send_text, build_sessions_card - sessions = manager.list_sessions(user_id=user_id) - mode = "Direct 🟢" if agent.get_passthrough(user_id) else "Smart ⚪" - card = build_sessions_card(sessions, conv_id, mode) - await send_card(chat_id, "chat_id", card) - if initial_msg and data.get("response"): - await send_text(chat_id, "chat_id", data["response"]) - return "" + import uuid as _uuid + conv_id = str(_uuid.uuid4())[:8] + await manager.create( + conv_id, + str(resolved), + owner_id=user_id, + idle_timeout=parsed.idle or 1800, + cc_timeout=float(parsed.timeout or 300), + permission_mode=permission_mode, + ) + agent._active_conv[user_id] = conv_id - reply = f"✓ Created session `{conv_id}` in `{cwd}`" - if parsed.timeout: - reply += f" (timeout: {parsed.timeout}s)" - if initial_msg: - reply += f"\n\nSent: {initial_msg[:100]}..." - return reply - except Exception: - return result + response = None + if initial_msg: + response = await manager.send(conv_id, initial_msg, user_id=user_id) + + chat_id = get_current_chat() + if chat_id: + from bot.feishu import send_card, send_text, build_sessions_card + sessions = manager.list_sessions(user_id=user_id) + routing_mode = "Direct 🟢" if agent.get_passthrough(user_id) else "Smart ⚪" + card = build_sessions_card(sessions, conv_id, routing_mode) + await send_card(chat_id, "chat_id", card) + if initial_msg and response: + await send_text(chat_id, "chat_id", response) + return "" + + perm_label = _perm_label(permission_mode) + reply = f"✓ Created session `{conv_id}` in `{resolved}` [{perm_label}]" + if parsed.timeout: + reply += f" (timeout: {parsed.timeout}s)" + if initial_msg and response: + reply += f"\n\n{response}" + return reply async def _cmd_status(user_id: str) -> str: @@ -152,7 +188,8 @@ async def _cmd_status(user_id: str) -> str: lines = ["**Your Sessions:**\n"] for i, s in enumerate(sessions, 1): marker = "→ " if s["conv_id"] == active else " " - lines.append(f"{marker}{i}. `{s['conv_id']}` - `{s['cwd']}`") + perm = _perm_label(s.get("permission_mode", DEFAULT_PERMISSION_MODE)) + lines.append(f"{marker}{i}. `{s['conv_id']}` - `{s['cwd']}` [{perm}]") lines.append(f"\n**Mode:** {mode}") lines.append("Use `/switch ` to activate a session.") lines.append("Use `/direct` or `/smart` to change mode.") @@ -234,6 +271,38 @@ async def _cmd_switch(user_id: str, args: str) -> str: return f"Invalid number: {args}" +async def _cmd_perm(user_id: str, args: str) -> str: + """Change the permission mode of the active (or specified) session.""" + parts = args.split() + if not parts: + return ( + "Usage: /perm [conv_id]\n" + "Modes: bypass (default), accept, plan\n" + " bypass — skip all permission checks\n" + " accept — auto-accept file edits, confirm shell commands\n" + " plan — plan only, no writes" + ) + + alias = parts[0] + conv_id = parts[1] if len(parts) > 1 else agent.get_active_conv(user_id) + + permission_mode = _resolve_perm(alias) + if permission_mode is None: + return f"Unknown mode '{alias}'. Valid: bypass, accept, plan" + + if not conv_id: + return "No active session. Use `/perm ` or activate a session first." + + try: + manager.set_permission_mode(conv_id, permission_mode, user_id=user_id) + except KeyError: + return f"Session `{conv_id}` not found." + except PermissionError as e: + return str(e) + + return f"✓ Session `{conv_id}` permission mode set to **{_perm_label(permission_mode)}**" + + async def _cmd_retry(user_id: str) -> str: """Retry the last message (placeholder - needs history tracking).""" return "Retry not yet implemented. Just send your message again." @@ -357,10 +426,11 @@ async def _cmd_nodes(user_id: str, args: str) -> str: def _cmd_help() -> str: """Show help.""" return """**Commands:** -/new [msg] [--timeout N] [--idle N] - Create session +/new [msg] [--timeout N] [--idle N] [--perm MODE] - Create session /status - Show sessions and current mode /close [n] - Close session (active or by number) /switch - Switch to session by number +/perm [conv_id] - Set permission mode (bypass/accept/plan) /direct - Direct mode: messages → Claude Code (no LLM overhead) /smart - Smart mode: messages → LLM routing (default) /shell - Run shell command (bypasses LLM) @@ -369,4 +439,12 @@ def _cmd_help() -> str: /nodes - List connected host nodes /node - Switch active node /retry - Retry last message -/help - Show this help""" +/help - Show this help + +**Permission modes** (used by /perm and /new --perm): + bypass — 跳过所有权限确认,CC 自动执行一切操作(默认) + 适合:受信任的沙盒环境、自动化任务 + accept — 自动接受文件编辑,但 shell 命令仍需手动确认 + 适合:日常开发,需要对命令执行保持控制 + plan — 只规划、不执行任何写操作 + 适合:先预览 CC 的操作计划再决定是否执行""" diff --git a/orchestrator/agent.py b/orchestrator/agent.py index c8b4311..06f293d 100644 --- a/orchestrator/agent.py +++ b/orchestrator/agent.py @@ -40,28 +40,45 @@ Pass these names directly to `create_conversation` — the tool resolves them au {active_session_line} -Your responsibilities: +## Tools — two distinct categories + +### Bot control commands (use `run_command`) +`run_command` executes PhoneWork slash commands. Use it when the user asks to: +- Change permission mode: "切换到只读模式" → run_command("/perm plan") +- Close/switch sessions: "关掉第一个" → run_command("/close 1") +- Change routing mode: "切换到直连模式" → run_command("/direct") +- Set a reminder: "10分钟后提醒我" → run_command("/remind 10m 提醒我") +- Check status: "看看现在有哪些 session" → run_command("/status") +- Any other /command the user would type manually + +Available bot commands (pass verbatim to run_command): + /new [msg] [--perm bypass|accept|plan] — create session + /close [n|conv_id] — close session + /switch — switch active session + /perm [conv_id] — permission mode: bypass (default), accept, plan + /direct — direct mode (bypass LLM for CC messages) + /smart — smart mode (LLM routing, default) + /status — list sessions + /remind — one-shot reminder + /tasks — list background tasks + +### Host shell commands (use `run_shell`) +`run_shell` executes shell commands on the host machine (git, ls, cat, pip, etc.). +NEVER use `run_shell` for bot control. NEVER use `run_command` for shell commands. + +## Session responsibilities 1. NEW session: call `create_conversation` with the project name/path. \ If the user's message also contains a task, pass it as `initial_message` too. 2. Follow-up to ACTIVE session: call `send_to_conversation` with the active conv_id shown above. -3. List sessions: call `list_conversations`. -4. Close session: call `close_conversation`. -5. GENERAL QUESTIONS: If the user asks a general question (not about a specific project or file), \ - answer directly using your own knowledge. Do NOT create a session for simple Q&A. -6. WEB / SEARCH: Use the `web` tool when the user needs current information. \ - Call it ONCE (or at most twice with a refined query). Then synthesize and reply — \ - do NOT keep searching in a loop. If the first search returns results, use them. -7. BACKGROUND TASKS: When `create_conversation` or `send_to_conversation` returns a \ - "Task #... started" message, the task is running in the background. \ - Immediately reply to the user that the task has started and they will be notified. \ - Do NOT call `task_status` in a loop waiting for it — the system sends a notification when done. +3. BOT CONTROL: call `run_command` with the appropriate slash command. +4. GENERAL QUESTIONS: answer directly — do NOT create a session for simple Q&A. +5. WEB / SEARCH: use `web` at most twice, then synthesize and reply. +6. BACKGROUND TASKS: when a task starts, reply immediately — do NOT poll `task_status`. Guidelines: - Relay Claude Code's output verbatim. -- If no active session and the user sends a task without naming a directory, ask them which project. -- For general knowledge questions (e.g., "what is a Python generator?", "explain async/await"), \ - answer directly without creating a session. -- After using any tool, always produce a final text reply to the user. Never end a turn on a tool call. +- If no active session and the user sends a task without naming a directory, ask which project. +- After using any tool, always produce a final text reply. Never end a turn on a tool call. - Keep your own words brief — let Claude Code's output speak. - Reply in the same language the user uses (Chinese or English). """ diff --git a/orchestrator/tools.py b/orchestrator/tools.py index aac0d79..f64632b 100644 --- a/orchestrator/tools.py +++ b/orchestrator/tools.py @@ -716,12 +716,49 @@ class TaskStatusTool(BaseTool): }, ensure_ascii=False) +class RunCommandInput(BaseModel): + command: str = Field( + ..., + description=( + "A bot slash command to execute (e.g. '/perm accept', '/close 1', '/switch 2'). " + "This runs bot control commands — NOT shell commands on the host machine. " + "Use run_shell for host shell commands (git, ls, etc.)." + ), + ) + + +class RunCommandTool(BaseTool): + name: str = "run_command" + description: str = ( + "Execute a PhoneWork bot slash command on behalf of the user. " + "Use this to control sessions, switch modes, change permissions, etc. " + "Examples: '/perm accept', '/close 1', '/switch 2', '/direct', '/smart', '/status'. " + "Do NOT use this for shell commands — use run_shell for those." + ) + args_schema: Type[BaseModel] = RunCommandInput + + def _run(self, command: str) -> str: + raise NotImplementedError("Use async version") + + async def _arun(self, command: str) -> str: + from bot.commands import handle_command + from orchestrator.tools import get_current_user + user_id = get_current_user() + if not user_id: + return "Error: no user context" + result = await handle_command(user_id, command.strip()) + if result is None: + return f"Unknown command: {command!r}. Use /help to see available commands." + return result + + # Module-level tool list for easy import TOOLS = [ CreateConversationTool(), SendToConversationTool(), ListConversationsTool(), CloseConversationTool(), + RunCommandTool(), ShellTool(), FileReadTool(), FileWriteTool(), diff --git a/tests/features/commands/perm.feature b/tests/features/commands/perm.feature new file mode 100644 index 0000000..4882344 --- /dev/null +++ b/tests/features/commands/perm.feature @@ -0,0 +1,70 @@ +Feature: /perm command — change session permission mode + + Background: + Given user "user_abc123" is sending commands + + Scenario: No args shows usage + When user sends "/perm" + Then reply contains "Usage" + And reply contains "bypass" + And reply contains "accept" + And reply contains "plan" + + Scenario: Set active session to accept mode + Given user has session "sess01" in "/tmp/proj1" + And active session is "sess01" + When user sends "/perm accept" + Then reply contains "accept" + And reply contains "sess01" + And session "sess01" has permission mode "acceptEdits" + + Scenario: Set active session to plan mode + Given user has session "sess01" in "/tmp/proj1" + And active session is "sess01" + When user sends "/perm plan" + Then reply contains "plan" + And session "sess01" has permission mode "plan" + + Scenario: Set active session back to bypass + Given user has session "sess01" in "/tmp/proj1" + And active session is "sess01" + When user sends "/perm bypass" + Then reply contains "bypass" + And session "sess01" has permission mode "bypassPermissions" + + Scenario: Unknown mode returns error + Given user has session "sess01" in "/tmp/proj1" + And active session is "sess01" + When user sends "/perm turbo" + Then reply contains "Unknown mode" + + Scenario: No active session returns error + Given no active session for user "user_abc123" + When user sends "/perm accept" + Then reply contains "No active session" + + Scenario: Set permission on specific conv_id + Given user has session "sess01" in "/tmp/proj1" + And user has session "sess02" in "/tmp/proj2" + And active session is "sess01" + When user sends "/perm plan sess02" + Then reply contains "sess02" + And session "sess02" has permission mode "plan" + + Scenario: Cannot change permission of another user's session + Given session "sess01" in "/tmp/proj1" belongs to user "other_user" + When user sends "/perm accept sess01" + Then reply contains "another user" + + Scenario: New session with --perm accept + When user sends "/new myproject --perm accept" + Then reply contains "accept" + And session manager has 1 session for user "user_abc123" + + Scenario: New session with --perm plan + When user sends "/new myproject --perm plan" + Then reply contains "plan" + + Scenario: New session with invalid --perm + When user sends "/new myproject --perm turbo" + Then reply contains "Invalid" diff --git a/tests/step_defs/common_steps.py b/tests/step_defs/common_steps.py index 3c2c8ff..c1d202b 100644 --- a/tests/step_defs/common_steps.py +++ b/tests/step_defs/common_steps.py @@ -143,6 +143,15 @@ def check_no_active_session(user_id): assert agent._active_conv.get(user_id) is None +@then(parsers.parse('session "{conv_id}" has permission mode "{mode}"')) +def check_session_perm_mode(conv_id, mode): + from agent.manager import manager + session = manager._sessions.get(conv_id) + assert session is not None, f"Session {conv_id!r} not found" + assert session.permission_mode == mode, \ + f"Expected permission_mode={mode!r}, got {session.permission_mode!r}" + + # ── Then: mode state ───────────────────────────────────────────────────────── @then(parsers.parse('passthrough mode is enabled for user "{user_id}"')) diff --git a/tests/step_defs/test_commands.py b/tests/step_defs/test_commands.py index d0239ae..c9cc5b0 100644 --- a/tests/step_defs/test_commands.py +++ b/tests/step_defs/test_commands.py @@ -20,6 +20,7 @@ scenarios( "../features/commands/tasks.feature", "../features/commands/nodes.feature", "../features/commands/help.feature", + "../features/commands/perm.feature", )