feat: proto-based RPC approval for Run commands via Discord

Decoded HandleCascadeUserInteractionRequest protobuf schema from AG's
extension.js (message #162, base64 FileDescriptor 78KB).

Working payload (variant PROTO-0):
  cascadeId + interaction.{trajectoryId, stepIndex, runCommand.confirm}

Changes:
- extension.ts: Added Strategy 0-PROTO with decoded proto RPC call
- extension.ts: Fixed processResponseFile to call tryApprovalStrategies()
  instead of direct clickTrigger (was bypassing all strategies)
- extension.ts: Fixed false positive Run detection (sessionStalled reset
  when step_probe confirms no WAITING)
- extension.ts: Moved lastPendingStepIndex to module scope
- extension.ts: Added activeTrajectoryId tracking from session init
- bot.py: Added MERGE detection + Discord message edit for command updates
- bot.py: Added _sent_commands tracking for merge detection

Proto RE methodology:
1. Found schema exports in AG extension.js
2. Located fileDesc() with base64 protobuf descriptor
3. Decoded 58KB raw proto, found message names
4. Extracted CascadeRunCommandInteraction.confirm field
5. Tested camelCase JSON via ConnectRPC = SUCCESS
This commit is contained in:
2026-03-10 07:45:10 +09:00
parent 98646fed27
commit 1f63f60280
4 changed files with 532 additions and 129 deletions

49
bot.py
View File

@@ -165,6 +165,7 @@ class GravityBot(commands.Bot):
self.session_status_messages: dict[str, int] = {} # conv_id → msg_id
self._sent_approval_ids: set[str] = set()
self._deferred_ids: dict[str, int] = {} # request_id → defer count
self._sent_commands: dict[str, str] = {} # request_id → command text (for MERGE edit detection)
self._ready_event = asyncio.Event()
self._channel_lock = asyncio.Lock()
self.bridge = BridgeProtocol()
@@ -542,6 +543,7 @@ class GravityBot(commands.Bot):
channel = await self._get_channel(project)
if channel:
self._sent_approval_ids.add(req.request_id)
self._sent_commands[req.request_id] = req.command
await self._send_approval_request(channel, req)
# ── Check for auto_resolved pendings (approved directly in AG) ──
@@ -567,6 +569,53 @@ class GravityBot(commands.Bot):
pass
f.unlink()
self._deferred_ids.pop(data.get("request_id", ""), None)
self._sent_commands.pop(data.get("request_id", ""), None)
except (json.JSONDecodeError, OSError):
pass
# ── Check for MERGE updates (step_probe updated command in already-sent pending) ──
for f in self.bridge.pending_dir.glob("*.json"):
try:
data = json.loads(f.read_text(encoding="utf-8-sig"))
rid = data.get("request_id", "")
if rid not in self._sent_approval_ids:
continue
if data.get("status") != "pending":
continue
msg_id = data.get("discord_message_id", 0)
if not msg_id:
continue
# Check if command was updated via MERGE
new_cmd = data.get("command", "")
old_cmd = self._sent_commands.get(rid, "")
if new_cmd and new_cmd != old_cmd and len(new_cmd) > len(old_cmd):
# MERGE detected — edit Discord message
self._sent_commands[rid] = new_cmd
project = data.get("project_name", Config.PROJECT_NAME)
channel = await self._get_channel(project)
if channel:
try:
msg = await channel.fetch_message(msg_id)
# Rebuild embed with full command
buttons = data.get("buttons")
desc_parts = [f"**명령어:**\n```\n{new_cmd[:1000]}\n```"]
if buttons and len(buttons) > 1:
btn_names = [b.get("text", "?") for b in buttons]
desc_parts.append(f"**선택지:** {' / '.join(btn_names)}")
desc = data.get("description", "")
if desc:
desc_parts.append(desc[:500])
embed = discord.Embed(
title="⚠️ 승인 요청",
description="\n".join(desc_parts),
color=discord.Color.orange(),
timestamp=datetime.now(timezone.utc),
)
embed.set_footer(text=f"ID: {rid}")
await msg.edit(embed=embed)
logger.info(f"MERGE edit: {rid[:12]} cmd='{new_cmd[:60]}'")
except discord.NotFound:
pass
except (json.JSONDecodeError, OSError):
pass