feat(perm): 添加会话权限模式管理功能

实现会话权限模式管理功能,包括:
1. 在 pty_process 中定义三种权限模式标志
2. 添加 /perm 命令用于修改会话权限模式
3. 新增 run_command 工具用于执行 bot 控制命令
4. 在会话管理中支持权限模式设置
5. 添加完整的测试用例和文档说明
This commit is contained in:
Yuyao Huang (Sam)
2026-03-29 06:46:45 +08:00
parent 8dab229aaf
commit cbeafa35a5
8 changed files with 302 additions and 60 deletions
+115 -37
View File
@@ -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 <project_dir> [initial_message] [--timeout N]\nExample: /new todo_app fix the bug --timeout 600"
return "Usage: /new <project_dir> [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 <project_dir> [initial_message] [--timeout N] [--idle N]"
return "Usage: /new <project_dir> [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 <n>` 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 <mode> [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 <mode> <conv_id>` 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 <dir> [msg] [--timeout N] [--idle N] - Create session
/new <dir> [msg] [--timeout N] [--idle N] [--perm MODE] - Create session
/status - Show sessions and current mode
/close [n] - Close session (active or by number)
/switch <n> - Switch to session by number
/perm <mode> [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 <cmd> - Run shell command (bypasses LLM)
@@ -369,4 +439,12 @@ def _cmd_help() -> str:
/nodes - List connected host nodes
/node <name> - 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 的操作计划再决定是否执行"""