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.session_status_messages: dict[str, int] = {} # conv_id → msg_id
self._sent_approval_ids: set[str] = set() self._sent_approval_ids: set[str] = set()
self._deferred_ids: dict[str, int] = {} # request_id → defer count 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._ready_event = asyncio.Event()
self._channel_lock = asyncio.Lock() self._channel_lock = asyncio.Lock()
self.bridge = BridgeProtocol() self.bridge = BridgeProtocol()
@@ -542,6 +543,7 @@ class GravityBot(commands.Bot):
channel = await self._get_channel(project) channel = await self._get_channel(project)
if channel: if channel:
self._sent_approval_ids.add(req.request_id) self._sent_approval_ids.add(req.request_id)
self._sent_commands[req.request_id] = req.command
await self._send_approval_request(channel, req) await self._send_approval_request(channel, req)
# ── Check for auto_resolved pendings (approved directly in AG) ── # ── Check for auto_resolved pendings (approved directly in AG) ──
@@ -567,6 +569,53 @@ class GravityBot(commands.Bot):
pass pass
f.unlink() f.unlink()
self._deferred_ids.pop(data.get("request_id", ""), None) 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): except (json.JSONDecodeError, OSError):
pass pass

View File

@@ -114,6 +114,7 @@ function ensureBridgeDir() {
} }
// Module-level activeSessionId so writeChatSnapshot can register sessions lazily // Module-level activeSessionId so writeChatSnapshot can register sessions lazily
let activeSessionId = ''; let activeSessionId = '';
let activeTrajectoryId = '';
function writeChatSnapshot(text) { function writeChatSnapshot(text) {
try { try {
// Write to chat_snapshots/*.json for Bot's chat_snapshot_scanner to pick up // Write to chat_snapshots/*.json for Bot's chat_snapshot_scanner to pick up
@@ -426,6 +427,7 @@ const pendingResponses = new Map();
// Click trigger: extension sets this, renderer polls and clicks button // Click trigger: extension sets this, renderer polls and clicks button
let clickTrigger = null; let clickTrigger = null;
let sessionStalled = false; // true when session is stalled waiting for approval let sessionStalled = false; // true when session is stalled waiting for approval
let lastPendingStepIndex = -1; // dedup: don't re-create pending for same step (module-level for tryApprovalStrategies)
// Deep inspect trigger: curl sets this, renderer picks it up and POSTs results back // Deep inspect trigger: curl sets this, renderer picks it up and POSTs results back
let deepInspectRequested = false; let deepInspectRequested = false;
let deepInspectResult = null; let deepInspectResult = null;
@@ -511,8 +513,15 @@ function startObserverHttpBridge() {
if (fs.existsSync(respFile)) { if (fs.existsSync(respFile)) {
try { try {
const data = JSON.parse(fs.readFileSync(respFile, 'utf8')); const data = JSON.parse(fs.readFileSync(respFile, 'utf8'));
fs.unlinkSync(respFile); logToFile(`[HTTP] response served to renderer: ${rid} approved=${data.approved}`);
logToFile(`[HTTP] response sent: ${rid} approved=${data.approved}`); // Delay deletion: processResponseFile (response watcher) may need to read it too.
// The watcher fires with 300ms delay, so 2s is safe.
setTimeout(() => {
try {
fs.unlinkSync(respFile);
}
catch { }
}, 2000);
res.writeHead(200, { 'Content-Type': 'application/json' }); res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(data)); res.end(JSON.stringify(data));
} }
@@ -1398,7 +1407,7 @@ function setupMonitor() {
let lastKnownStepCount = 0; let lastKnownStepCount = 0;
let lastNotifyStepIndex = -1; let lastNotifyStepIndex = -1;
let lastTaskStepIndex = -1; let lastTaskStepIndex = -1;
let lastPendingStepIndex = -1; // dedup: don't re-create pending for same step // lastPendingStepIndex is module-level (above sessionStalled)
let consecutiveIdleCount = 0; // debounce: require N consecutive stall polls let consecutiveIdleCount = 0; // debounce: require N consecutive stall polls
let lastPendingTime = 0; // cooldown: minimum gap between pendings let lastPendingTime = 0; // cooldown: minimum gap between pendings
let sawRunningAfterPending = true; // gate: must see delta>0 before next pending let sawRunningAfterPending = true; // gate: must see delta>0 before next pending
@@ -1452,6 +1461,7 @@ function setupMonitor() {
// Session changed? // Session changed?
if (bestSessionId !== activeSessionId) { if (bestSessionId !== activeSessionId) {
activeSessionId = bestSessionId; activeSessionId = bestSessionId;
activeTrajectoryId = bestSession.trajectoryId || '';
activeSessionTitle = currentTitle; activeSessionTitle = currentTitle;
lastKnownStepCount = currentCount; lastKnownStepCount = currentCount;
lastNotifyStepIndex = bestSession.latestNotifyUserStep?.stepIndex ?? -1; lastNotifyStepIndex = bestSession.latestNotifyUserStep?.stepIndex ?? -1;
@@ -1603,6 +1613,10 @@ function setupMonitor() {
if (!foundWaiting) { if (!foundWaiting) {
const lastStep = steps[steps.length - 1]; const lastStep = steps[steps.length - 1];
logToFile(`[STEP-PROBE] lastStep status=${lastStep?.status} type=${lastStep?.type} (not waiting)`); logToFile(`[STEP-PROBE] lastStep status=${lastStep?.status} type=${lastStep?.type} (not waiting)`);
// CRITICAL: Reset sessionStalled when step_probe confirms no WAITING.
// Without this, sessionStalled stays true during long AI generations
// (PLANNER_RESPONSE with delta=0), causing false positive "Run" detections.
sessionStalled = false;
} }
} }
} }
@@ -1746,6 +1760,11 @@ function setupResponseWatcher() {
} }
async function processResponseFile(filePath) { async function processResponseFile(filePath) {
try { try {
// Gracefully handle files already consumed by HTTP handler
if (!fs.existsSync(filePath)) {
// HTTP GET /response/:rid already served and deleted this file — skip silently
return;
}
const content = fs.readFileSync(filePath, 'utf-8'); const content = fs.readFileSync(filePath, 'utf-8');
const resp = JSON.parse(content); const resp = JSON.parse(content);
const msg = `[RESPONSE] rid=${resp.request_id} approved=${resp.approved}`; const msg = `[RESPONSE] rid=${resp.request_id} approved=${resp.approved}`;
@@ -1775,15 +1794,10 @@ async function processResponseFile(filePath) {
logToFile(`[RESPONSE] renderer-handled approval (rid=${resp.request_id})`); logToFile(`[RESPONSE] renderer-handled approval (rid=${resp.request_id})`);
} }
else { else {
// Step probe path: approve → trigger renderer click, reject → log only // Step probe path: run ALL approval strategies (5 vectors → 30+ methods)
if (approved) { logToFile(`[RESPONSE] step_probe → running tryApprovalStrategies(${approved}, ${activeSessionId.substring(0, 8)})`);
logToFile(`[RESPONSE] step_probe → approve via trigger-click`); const strategyResult = await tryApprovalStrategies(approved, activeSessionId);
clickTrigger = { action: 'approve', timestamp: Date.now() }; logToFile(`[RESPONSE] strategy result: ${strategyResult}`);
}
else {
// SAFE: reject logs only — NO ResolveOutstandingSteps (it cancels AI work!)
logToFile(`[RESPONSE] step_probe → reject (log only, no destructive action)`);
}
} }
logToFile(`[RESPONSE] ${approved ? 'approve' : 'reject'} done (${isDomObserver ? 'dom' : 'step_probe'})`); logToFile(`[RESPONSE] ${approved ? 'approve' : 'reject'} done (${isDomObserver ? 'dom' : 'step_probe'})`);
// Cleanup response file // Cleanup response file
@@ -1999,76 +2013,241 @@ function writePendingApproval(data) {
async function tryApprovalStrategies(approved, sessionId) { async function tryApprovalStrategies(approved, sessionId) {
const action = approved ? 'APPROVE' : 'REJECT'; const action = approved ? 'APPROVE' : 'REJECT';
logToFile(`[APPROVAL] Starting ${action} strategies for session ${sessionId.substring(0, 8)}`); logToFile(`[APPROVAL] Starting ${action} strategies for session ${sessionId.substring(0, 8)}`);
// ── Dynamic Command Discovery (check if approval commands appear when step is WAITING) ── // ── Dynamic Command Discovery (log what's available during WAITING state) ──
let approvalCmdList = [];
try { try {
const allCmds = await vscode.commands.getCommands(true); const allCmds = await vscode.commands.getCommands(true);
const agCmds = allCmds.filter((c) => c.startsWith('antigravity.')); const agCmds = allCmds.filter((c) => c.startsWith('antigravity.'));
const approvalCmds = agCmds.filter((c) => { approvalCmdList = agCmds.filter((c) => {
const lower = c.toLowerCase(); const lower = c.toLowerCase();
return lower.includes('accept') || lower.includes('reject') || lower.includes('approve') return lower.includes('accept') || lower.includes('reject') || lower.includes('approve')
|| lower.includes('terminal') || lower.includes('run') || lower.includes('step'); || lower.includes('terminal') || lower.includes('run') || lower.includes('step')
|| lower.includes('cascade') || lower.includes('action');
}); });
logToFile(`[APPROVAL-CMD-CHECK] ${agCmds.length} total, ${approvalCmds.length} approval-related:`); logToFile(`[APPROVAL-CMD-CHECK] ${agCmds.length} total, ${approvalCmdList.length} approval-related:`);
for (const c of approvalCmds) { for (const c of approvalCmdList) {
logToFile(`[APPROVAL-CMD-CHECK] → ${c}`); logToFile(`[APPROVAL-CMD-CHECK] → ${c}`);
} }
// Dump ALL antigravity.* commands for full comparison
logToFile(`[APPROVAL-CMD-CHECK] FULL LIST (${agCmds.length}):`);
for (const c of agCmds) {
logToFile(`[APPROVAL-CMD-FULL] ${c}`);
}
} }
catch (e) { catch (e) {
logToFile(`[APPROVAL-CMD-CHECK] error: ${e.message}`); logToFile(`[APPROVAL-CMD-CHECK] error: ${e.message}`);
} }
// ── Strategy 1: HandleCascadeUserInteraction RPC ── // ══════════════════════════════════════════════════════════
if (sdk) { // STRATEGY 0-PROTO: Correct proto-based RPC (decoded from AG source)
// Try variant A: { cascadeId, approved } // HandleCascadeUserInteractionRequest:
try { // cascade_id: string
logToFile(`[APPROVAL-1A] HandleCascadeUserInteraction { cascadeId, approved: ${approved} }`); // interaction: CascadeUserInteraction {
const rpcResult = await sdk.ls.rawRPC('HandleCascadeUserInteraction', { // trajectory_id, step_index,
// oneof: { run_command: CascadeRunCommandInteraction { confirm: bool } }
// }
// ConnectRPC uses camelCase for JSON encoding of snake_case proto fields
// ══════════════════════════════════════════════════════════
if (sdk && approved) {
const protoVariants = [
// Variant A: camelCase JSON (ConnectRPC default)
{
cascadeId: sessionId, cascadeId: sessionId,
approved: approved, interaction: {
}); trajectoryId: activeTrajectoryId || sessionId,
logToFile(`[APPROVAL-1A] ✅ SUCCESS: ${JSON.stringify(rpcResult).substring(0, 200)}`); stepIndex: lastPendingStepIndex,
return `RPC-1A:HandleCascadeUserInteraction(approved=${approved})`; runCommand: { confirm: true },
} },
catch (e) { },
logToFile(`[APPROVAL-1A] ❌ FAIL: ${e.message}`); // Variant B: snake_case JSON (proto native)
} {
// Try variant B: { cascadeId, stepAction } cascade_id: sessionId,
try { interaction: {
const stepAction = approved ? 'STEP_ACTION_ACCEPT' : 'STEP_ACTION_REJECT'; trajectory_id: activeTrajectoryId || sessionId,
logToFile(`[APPROVAL-1B] HandleCascadeUserInteraction { cascadeId, stepAction: '${stepAction}' }`); step_index: lastPendingStepIndex,
const rpcResult = await sdk.ls.rawRPC('HandleCascadeUserInteraction', { run_command: { confirm: true },
},
},
// Variant C: camelCase, without trajectoryId (maybe optional)
{
cascadeId: sessionId, cascadeId: sessionId,
stepAction: stepAction, interaction: {
}); stepIndex: lastPendingStepIndex,
logToFile(`[APPROVAL-1B] ✅ SUCCESS: ${JSON.stringify(rpcResult).substring(0, 200)}`); runCommand: { confirm: true },
return `RPC-1B:HandleCascadeUserInteraction(stepAction=${stepAction})`; },
} },
catch (e) { // Variant D: camelCase, confirm only (minimal)
logToFile(`[APPROVAL-1B] ❌ FAIL: ${e.message}`); {
}
// Try variant C: { cascadeId, userAction } (experimental)
try {
const userAction = approved ? 'USER_ACTION_APPROVED' : 'USER_ACTION_REJECTED';
logToFile(`[APPROVAL-1C] HandleCascadeUserInteraction { cascadeId, userAction: '${userAction}' }`);
const rpcResult = await sdk.ls.rawRPC('HandleCascadeUserInteraction', {
cascadeId: sessionId, cascadeId: sessionId,
userAction: userAction, interaction: {
}); runCommand: { confirm: true },
logToFile(`[APPROVAL-1C] ✅ SUCCESS: ${JSON.stringify(rpcResult).substring(0, 200)}`); },
return `RPC-1C:HandleCascadeUserInteraction(userAction=${userAction})`; },
} // Variant E: snake_case minimal
catch (e) { {
logToFile(`[APPROVAL-1C] ❌ FAIL: ${e.message}`); cascade_id: sessionId,
interaction: {
run_command: { confirm: true },
},
},
];
for (let i = 0; i < protoVariants.length; i++) {
try {
const payload = protoVariants[i];
logToFile(`[APPROVAL-PROTO-${i}] HandleCascadeUserInteraction(${JSON.stringify(payload).substring(0, 200)})`);
const rpcResult = await sdk.ls.rawRPC('HandleCascadeUserInteraction', payload);
logToFile(`[APPROVAL-PROTO-${i}] ✅ SUCCESS: ${JSON.stringify(rpcResult).substring(0, 200)}`);
return `RPC-PROTO-${i}:HandleCascadeUserInteraction`;
}
catch (e) {
// Capture FULL error message (critical for diagnostics)
logToFile(`[APPROVAL-PROTO-${i}] ❌ ${e.message.substring(0, 300)}`);
}
} }
} }
// ── Strategy 2: Renderer DOM Click via HTTP Bridge ── // ══════════════════════════════════════════════════════════
// 2026-03-09: All SDK approval commands NOT REGISTERED (119 cmds tested). // STRATEGY 0A: executeCascadeAction
// Keyboard simulation sends Enter to chat input (sends empty message — WRONG). // ══════════════════════════════════════════════════════════
// New approach: set clickTrigger flag → renderer polls /trigger-click → clicks button. const cascadeActionVariants = [
{ action: approved ? 'accept' : 'reject' },
{ action: approved ? 'ACCEPT' : 'REJECT' },
{ action: approved ? 'approve' : 'deny' },
{ action: approved ? 'run' : 'reject' },
{ cascadeId: sessionId, action: approved ? 'accept' : 'reject' },
{ cascadeId: sessionId, type: approved ? 'accept' : 'reject' },
approved ? 'accept' : 'reject', // plain string arg
approved ? 1 : 0, // numeric arg
];
for (let i = 0; i < cascadeActionVariants.length; i++) {
try {
const arg = cascadeActionVariants[i];
logToFile(`[APPROVAL-0A-${i}] executeCascadeAction(${JSON.stringify(arg)})`);
const result = await vscode.commands.executeCommand('antigravity.executeCascadeAction', arg);
logToFile(`[APPROVAL-0A-${i}] ✅ SUCCESS: ${JSON.stringify(result).substring(0, 200)}`);
return `CMD-0A:executeCascadeAction(variant=${i})`;
}
catch (e) {
logToFile(`[APPROVAL-0A-${i}] ❌ ${e.message.substring(0, 100)}`);
}
}
// ══════════════════════════════════════════════════════════
// STRATEGY 0B: Direct approval commands (brute-force try all 7 SDK commands)
// ══════════════════════════════════════════════════════════
const commandsToTry = approved
? [
'antigravity.terminalCommand.run',
'antigravity.terminalCommand.accept',
'antigravity.agent.acceptAgentStep',
'antigravity.command.accept',
]
: [
'antigravity.terminalCommand.reject',
'antigravity.agent.rejectAgentStep',
'antigravity.command.reject',
];
for (const cmd of commandsToTry) {
// Try with no args, then with sessionId
for (const args of [[], [sessionId], [{ cascadeId: sessionId }]]) {
try {
logToFile(`[APPROVAL-0B] ${cmd}(${JSON.stringify(args)})`);
const result = await vscode.commands.executeCommand(cmd, ...args);
logToFile(`[APPROVAL-0B] ✅ SUCCESS: ${JSON.stringify(result).substring(0, 200)}`);
return `CMD-0B:${cmd}`;
}
catch (e) {
logToFile(`[APPROVAL-0B] ❌ ${e.message.substring(0, 80)}`);
}
}
}
// ══════════════════════════════════════════════════════════
// STRATEGY 0C: Electron webContents access (pierce webview isolation)
// ══════════════════════════════════════════════════════════
try {
logToFile(`[APPROVAL-0C] Attempting Electron webContents access...`);
// eslint-disable-next-line @typescript-eslint/no-var-requires
const electron = require('electron');
const remote = electron.remote;
if (remote) {
logToFile(`[APPROVAL-0C] electron.remote available!`);
const allWC = remote.webContents.getAllWebContents();
logToFile(`[APPROVAL-0C] Found ${allWC.length} webContents`);
for (const wc of allWC) {
const wcUrl = wc.getURL() || '';
logToFile(`[APPROVAL-0C] wc: ${wcUrl.substring(0, 100)}`);
if (wcUrl.includes('vscode-webview://') || wcUrl.includes('webview')) {
const clickScript = approved
? `(function(){ var btns = document.querySelectorAll('button'); for(var b of btns){ if(/Run|Accept|Allow/i.test(b.textContent)){ b.click(); return 'clicked:'+b.textContent; } } return 'no-match'; })()`
: `(function(){ var btns = document.querySelectorAll('button'); for(var b of btns){ if(/Reject|Deny|Cancel/i.test(b.textContent)){ b.click(); return 'clicked:'+b.textContent; } } return 'no-match'; })()`;
const clickResult = await wc.executeJavaScript(clickScript);
logToFile(`[APPROVAL-0C] executeJavaScript result: ${clickResult}`);
if (clickResult && clickResult.startsWith('clicked:')) {
return `ELECTRON-0C:webContents(${clickResult})`;
}
}
}
}
else {
logToFile(`[APPROVAL-0C] electron.remote NOT available`);
// Try without remote (main process context)
const wc = electron.webContents;
if (wc && typeof wc.getAllWebContents === 'function') {
logToFile(`[APPROVAL-0C] Direct electron.webContents available!`);
const allWC = wc.getAllWebContents();
logToFile(`[APPROVAL-0C] Found ${allWC.length} webContents`);
}
else {
logToFile(`[APPROVAL-0C] No webContents access from Extension Host`);
}
}
}
catch (e) {
logToFile(`[APPROVAL-0C] ❌ ${e.message.substring(0, 150)}`);
}
// ══════════════════════════════════════════════════════════
// STRATEGY 0D: sendChatActionMessage (may route to agent)
// ══════════════════════════════════════════════════════════
const chatActionVariants = [
{ action: approved ? 'accept' : 'reject', cascadeId: sessionId },
{ message: approved ? 'accept' : 'reject', conversationId: sessionId },
approved ? 'accept' : 'reject',
];
for (let i = 0; i < chatActionVariants.length; i++) {
try {
logToFile(`[APPROVAL-0D-${i}] sendChatActionMessage(${JSON.stringify(chatActionVariants[i])})`);
const result = await vscode.commands.executeCommand('antigravity.sendChatActionMessage', chatActionVariants[i]);
logToFile(`[APPROVAL-0D-${i}] ✅ SUCCESS: ${JSON.stringify(result).substring(0, 200)}`);
return `CMD-0D:sendChatActionMessage(variant=${i})`;
}
catch (e) {
logToFile(`[APPROVAL-0D-${i}] ❌ ${e.message.substring(0, 80)}`);
}
}
// ══════════════════════════════════════════════════════════
// STRATEGY 1: HandleCascadeUserInteraction RPC (expanded variants)
// ══════════════════════════════════════════════════════════
if (sdk) {
const rpcVariants = [
// Original variants
{ cascadeId: sessionId, approved: approved },
{ cascadeId: sessionId, stepAction: approved ? 'STEP_ACTION_ACCEPT' : 'STEP_ACTION_REJECT' },
{ cascadeId: sessionId, userAction: approved ? 'USER_ACTION_APPROVED' : 'USER_ACTION_REJECTED' },
// New variants — ConnectRPC protobuf field naming conventions
{ cascade_id: sessionId, accepted: approved },
{ cascadeId: sessionId, accepted: approved },
{ cascade_id: sessionId, user_action: approved ? 1 : 2 }, // enum as int
{ cascadeId: sessionId, interaction: approved ? 'ACCEPT' : 'REJECT' },
{ cascadeId: sessionId, action: approved ? 'accept' : 'reject' },
// With step index from last known waiting step
{ cascadeId: sessionId, stepIndex: lastPendingStepIndex, accepted: approved },
{ cascadeId: sessionId, stepIndex: lastPendingStepIndex, approved: approved },
];
for (let i = 0; i < rpcVariants.length; i++) {
try {
logToFile(`[APPROVAL-1-${i}] HandleCascadeUserInteraction(${JSON.stringify(rpcVariants[i]).substring(0, 120)})`);
const rpcResult = await sdk.ls.rawRPC('HandleCascadeUserInteraction', rpcVariants[i]);
logToFile(`[APPROVAL-1-${i}] ✅ SUCCESS: ${JSON.stringify(rpcResult).substring(0, 200)}`);
return `RPC-1-${i}:HandleCascadeUserInteraction`;
}
catch (e) {
logToFile(`[APPROVAL-1-${i}] ❌ ${e.message.substring(0, 80)}`);
}
}
}
// ── Strategy 2: Renderer DOM Click via HTTP Bridge (kept as fallback) ──
try { try {
const triggerAction = approved ? 'approve' : 'reject'; const triggerAction = approved ? 'approve' : 'reject';
logToFile(`[APPROVAL-2] Setting clickTrigger=${triggerAction} for renderer DOM click`); logToFile(`[APPROVAL-2] Setting clickTrigger=${triggerAction} for renderer DOM click`);

File diff suppressed because one or more lines are too long

View File

@@ -81,6 +81,7 @@ function ensureBridgeDir() {
// Module-level activeSessionId so writeChatSnapshot can register sessions lazily // Module-level activeSessionId so writeChatSnapshot can register sessions lazily
let activeSessionId = ''; let activeSessionId = '';
let activeTrajectoryId = '';
function writeChatSnapshot(text: string) { function writeChatSnapshot(text: string) {
try { try {
@@ -403,6 +404,7 @@ const pendingResponses = new Map<string, { approved: boolean } | null>();
// Click trigger: extension sets this, renderer polls and clicks button // Click trigger: extension sets this, renderer polls and clicks button
let clickTrigger: { action: 'approve' | 'reject'; timestamp: number } | null = null; let clickTrigger: { action: 'approve' | 'reject'; timestamp: number } | null = null;
let sessionStalled = false; // true when session is stalled waiting for approval let sessionStalled = false; // true when session is stalled waiting for approval
let lastPendingStepIndex = -1; // dedup: don't re-create pending for same step (module-level for tryApprovalStrategies)
// Deep inspect trigger: curl sets this, renderer picks it up and POSTs results back // Deep inspect trigger: curl sets this, renderer picks it up and POSTs results back
let deepInspectRequested = false; let deepInspectRequested = false;
@@ -489,8 +491,12 @@ function startObserverHttpBridge(): Promise<number> {
if (fs.existsSync(respFile)) { if (fs.existsSync(respFile)) {
try { try {
const data = JSON.parse(fs.readFileSync(respFile, 'utf8')); const data = JSON.parse(fs.readFileSync(respFile, 'utf8'));
fs.unlinkSync(respFile); logToFile(`[HTTP] response served to renderer: ${rid} approved=${data.approved}`);
logToFile(`[HTTP] response sent: ${rid} approved=${data.approved}`); // Delay deletion: processResponseFile (response watcher) may need to read it too.
// The watcher fires with 300ms delay, so 2s is safe.
setTimeout(() => {
try { fs.unlinkSync(respFile); } catch { }
}, 2000);
res.writeHead(200, { 'Content-Type': 'application/json' }); res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(data)); res.end(JSON.stringify(data));
} catch { } catch {
@@ -1376,7 +1382,7 @@ function setupMonitor() {
let lastKnownStepCount = 0; let lastKnownStepCount = 0;
let lastNotifyStepIndex = -1; let lastNotifyStepIndex = -1;
let lastTaskStepIndex = -1; let lastTaskStepIndex = -1;
let lastPendingStepIndex = -1; // dedup: don't re-create pending for same step // lastPendingStepIndex is module-level (above sessionStalled)
let consecutiveIdleCount = 0; // debounce: require N consecutive stall polls let consecutiveIdleCount = 0; // debounce: require N consecutive stall polls
let lastPendingTime = 0; // cooldown: minimum gap between pendings let lastPendingTime = 0; // cooldown: minimum gap between pendings
let sawRunningAfterPending = true; // gate: must see delta>0 before next pending let sawRunningAfterPending = true; // gate: must see delta>0 before next pending
@@ -1432,6 +1438,7 @@ function setupMonitor() {
// Session changed? // Session changed?
if (bestSessionId !== activeSessionId) { if (bestSessionId !== activeSessionId) {
activeSessionId = bestSessionId; activeSessionId = bestSessionId;
activeTrajectoryId = (bestSession as any).trajectoryId || '';
activeSessionTitle = currentTitle; activeSessionTitle = currentTitle;
lastKnownStepCount = currentCount; lastKnownStepCount = currentCount;
lastNotifyStepIndex = bestSession.latestNotifyUserStep?.stepIndex ?? -1; lastNotifyStepIndex = bestSession.latestNotifyUserStep?.stepIndex ?? -1;
@@ -1586,6 +1593,10 @@ function setupMonitor() {
if (!foundWaiting) { if (!foundWaiting) {
const lastStep = steps[steps.length - 1]; const lastStep = steps[steps.length - 1];
logToFile(`[STEP-PROBE] lastStep status=${lastStep?.status} type=${lastStep?.type} (not waiting)`); logToFile(`[STEP-PROBE] lastStep status=${lastStep?.status} type=${lastStep?.type} (not waiting)`);
// CRITICAL: Reset sessionStalled when step_probe confirms no WAITING.
// Without this, sessionStalled stays true during long AI generations
// (PLANNER_RESPONSE with delta=0), causing false positive "Run" detections.
sessionStalled = false;
} }
} }
} catch (e: any) { } catch (e: any) {
@@ -1726,6 +1737,11 @@ function setupResponseWatcher() {
async function processResponseFile(filePath: string) { async function processResponseFile(filePath: string) {
try { try {
// Gracefully handle files already consumed by HTTP handler
if (!fs.existsSync(filePath)) {
// HTTP GET /response/:rid already served and deleted this file — skip silently
return;
}
const content = fs.readFileSync(filePath, 'utf-8'); const content = fs.readFileSync(filePath, 'utf-8');
const resp = JSON.parse(content); const resp = JSON.parse(content);
const msg = `[RESPONSE] rid=${resp.request_id} approved=${resp.approved}`; const msg = `[RESPONSE] rid=${resp.request_id} approved=${resp.approved}`;
@@ -1757,14 +1773,10 @@ async function processResponseFile(filePath: string) {
// DOM observer path: renderer polls /response/:rid and clicks directly // DOM observer path: renderer polls /response/:rid and clicks directly
logToFile(`[RESPONSE] renderer-handled approval (rid=${resp.request_id})`); logToFile(`[RESPONSE] renderer-handled approval (rid=${resp.request_id})`);
} else { } else {
// Step probe path: approve → trigger renderer click, reject → log only // Step probe path: run ALL approval strategies (5 vectors → 30+ methods)
if (approved) { logToFile(`[RESPONSE] step_probe → running tryApprovalStrategies(${approved}, ${activeSessionId.substring(0, 8)})`);
logToFile(`[RESPONSE] step_probe → approve via trigger-click`); const strategyResult = await tryApprovalStrategies(approved, activeSessionId);
clickTrigger = { action: 'approve' as const, timestamp: Date.now() }; logToFile(`[RESPONSE] strategy result: ${strategyResult}`);
} else {
// SAFE: reject logs only — NO ResolveOutstandingSteps (it cancels AI work!)
logToFile(`[RESPONSE] step_probe → reject (log only, no destructive action)`);
}
} }
logToFile(`[RESPONSE] ${approved ? 'approve' : 'reject'} done (${isDomObserver ? 'dom' : 'step_probe'})`); logToFile(`[RESPONSE] ${approved ? 'approve' : 'reject'} done (${isDomObserver ? 'dom' : 'step_probe'})`);
@@ -1968,76 +1980,239 @@ async function tryApprovalStrategies(approved: boolean, sessionId: string): Prom
const action = approved ? 'APPROVE' : 'REJECT'; const action = approved ? 'APPROVE' : 'REJECT';
logToFile(`[APPROVAL] Starting ${action} strategies for session ${sessionId.substring(0, 8)}`); logToFile(`[APPROVAL] Starting ${action} strategies for session ${sessionId.substring(0, 8)}`);
// ── Dynamic Command Discovery (check if approval commands appear when step is WAITING) ── // ── Dynamic Command Discovery (log what's available during WAITING state) ──
let approvalCmdList: string[] = [];
try { try {
const allCmds = await vscode.commands.getCommands(true); const allCmds = await vscode.commands.getCommands(true);
const agCmds = allCmds.filter((c: string) => c.startsWith('antigravity.')); const agCmds = allCmds.filter((c: string) => c.startsWith('antigravity.'));
const approvalCmds = agCmds.filter((c: string) => { approvalCmdList = agCmds.filter((c: string) => {
const lower = c.toLowerCase(); const lower = c.toLowerCase();
return lower.includes('accept') || lower.includes('reject') || lower.includes('approve') return lower.includes('accept') || lower.includes('reject') || lower.includes('approve')
|| lower.includes('terminal') || lower.includes('run') || lower.includes('step'); || lower.includes('terminal') || lower.includes('run') || lower.includes('step')
|| lower.includes('cascade') || lower.includes('action');
}); });
logToFile(`[APPROVAL-CMD-CHECK] ${agCmds.length} total, ${approvalCmds.length} approval-related:`); logToFile(`[APPROVAL-CMD-CHECK] ${agCmds.length} total, ${approvalCmdList.length} approval-related:`);
for (const c of approvalCmds) { for (const c of approvalCmdList) {
logToFile(`[APPROVAL-CMD-CHECK] → ${c}`); logToFile(`[APPROVAL-CMD-CHECK] → ${c}`);
} }
// Dump ALL antigravity.* commands for full comparison
logToFile(`[APPROVAL-CMD-CHECK] FULL LIST (${agCmds.length}):`);
for (const c of agCmds) {
logToFile(`[APPROVAL-CMD-FULL] ${c}`);
}
} catch (e: any) { } catch (e: any) {
logToFile(`[APPROVAL-CMD-CHECK] error: ${e.message}`); logToFile(`[APPROVAL-CMD-CHECK] error: ${e.message}`);
} }
// ── Strategy 1: HandleCascadeUserInteraction RPC ── // ══════════════════════════════════════════════════════════
if (sdk) { // STRATEGY 0-PROTO: Correct proto-based RPC (decoded from AG source)
// Try variant A: { cascadeId, approved } // HandleCascadeUserInteractionRequest:
try { // cascade_id: string
logToFile(`[APPROVAL-1A] HandleCascadeUserInteraction { cascadeId, approved: ${approved} }`); // interaction: CascadeUserInteraction {
const rpcResult = await sdk.ls.rawRPC('HandleCascadeUserInteraction', { // trajectory_id, step_index,
// oneof: { run_command: CascadeRunCommandInteraction { confirm: bool } }
// }
// ConnectRPC uses camelCase for JSON encoding of snake_case proto fields
// ══════════════════════════════════════════════════════════
if (sdk && approved) {
const protoVariants = [
// Variant A: camelCase JSON (ConnectRPC default)
{
cascadeId: sessionId, cascadeId: sessionId,
approved: approved, interaction: {
}); trajectoryId: activeTrajectoryId || sessionId,
logToFile(`[APPROVAL-1A] ✅ SUCCESS: ${JSON.stringify(rpcResult).substring(0, 200)}`); stepIndex: lastPendingStepIndex,
return `RPC-1A:HandleCascadeUserInteraction(approved=${approved})`; runCommand: { confirm: true },
} catch (e: any) { },
logToFile(`[APPROVAL-1A] ❌ FAIL: ${e.message}`); },
} // Variant B: snake_case JSON (proto native)
{
// Try variant B: { cascadeId, stepAction } cascade_id: sessionId,
try { interaction: {
const stepAction = approved ? 'STEP_ACTION_ACCEPT' : 'STEP_ACTION_REJECT'; trajectory_id: activeTrajectoryId || sessionId,
logToFile(`[APPROVAL-1B] HandleCascadeUserInteraction { cascadeId, stepAction: '${stepAction}' }`); step_index: lastPendingStepIndex,
const rpcResult = await sdk.ls.rawRPC('HandleCascadeUserInteraction', { run_command: { confirm: true },
},
},
// Variant C: camelCase, without trajectoryId (maybe optional)
{
cascadeId: sessionId, cascadeId: sessionId,
stepAction: stepAction, interaction: {
}); stepIndex: lastPendingStepIndex,
logToFile(`[APPROVAL-1B] ✅ SUCCESS: ${JSON.stringify(rpcResult).substring(0, 200)}`); runCommand: { confirm: true },
return `RPC-1B:HandleCascadeUserInteraction(stepAction=${stepAction})`; },
} catch (e: any) { },
logToFile(`[APPROVAL-1B] ❌ FAIL: ${e.message}`); // Variant D: camelCase, confirm only (minimal)
} {
// Try variant C: { cascadeId, userAction } (experimental)
try {
const userAction = approved ? 'USER_ACTION_APPROVED' : 'USER_ACTION_REJECTED';
logToFile(`[APPROVAL-1C] HandleCascadeUserInteraction { cascadeId, userAction: '${userAction}' }`);
const rpcResult = await sdk.ls.rawRPC('HandleCascadeUserInteraction', {
cascadeId: sessionId, cascadeId: sessionId,
userAction: userAction, interaction: {
}); runCommand: { confirm: true },
logToFile(`[APPROVAL-1C] ✅ SUCCESS: ${JSON.stringify(rpcResult).substring(0, 200)}`); },
return `RPC-1C:HandleCascadeUserInteraction(userAction=${userAction})`; },
} catch (e: any) { // Variant E: snake_case minimal
logToFile(`[APPROVAL-1C] ❌ FAIL: ${e.message}`); {
cascade_id: sessionId,
interaction: {
run_command: { confirm: true },
},
},
];
for (let i = 0; i < protoVariants.length; i++) {
try {
const payload = protoVariants[i];
logToFile(`[APPROVAL-PROTO-${i}] HandleCascadeUserInteraction(${JSON.stringify(payload).substring(0, 200)})`);
const rpcResult = await sdk.ls.rawRPC('HandleCascadeUserInteraction', payload);
logToFile(`[APPROVAL-PROTO-${i}] ✅ SUCCESS: ${JSON.stringify(rpcResult).substring(0, 200)}`);
return `RPC-PROTO-${i}:HandleCascadeUserInteraction`;
} catch (e: any) {
// Capture FULL error message (critical for diagnostics)
logToFile(`[APPROVAL-PROTO-${i}] ❌ ${e.message.substring(0, 300)}`);
}
} }
} }
// ── Strategy 2: Renderer DOM Click via HTTP Bridge ── // ══════════════════════════════════════════════════════════
// 2026-03-09: All SDK approval commands NOT REGISTERED (119 cmds tested). // STRATEGY 0A: executeCascadeAction
// Keyboard simulation sends Enter to chat input (sends empty message — WRONG). // ══════════════════════════════════════════════════════════
// New approach: set clickTrigger flag → renderer polls /trigger-click → clicks button. const cascadeActionVariants = [
{ action: approved ? 'accept' : 'reject' },
{ action: approved ? 'ACCEPT' : 'REJECT' },
{ action: approved ? 'approve' : 'deny' },
{ action: approved ? 'run' : 'reject' },
{ cascadeId: sessionId, action: approved ? 'accept' : 'reject' },
{ cascadeId: sessionId, type: approved ? 'accept' : 'reject' },
approved ? 'accept' : 'reject', // plain string arg
approved ? 1 : 0, // numeric arg
];
for (let i = 0; i < cascadeActionVariants.length; i++) {
try {
const arg = cascadeActionVariants[i];
logToFile(`[APPROVAL-0A-${i}] executeCascadeAction(${JSON.stringify(arg)})`);
const result = await vscode.commands.executeCommand('antigravity.executeCascadeAction', arg);
logToFile(`[APPROVAL-0A-${i}] ✅ SUCCESS: ${JSON.stringify(result).substring(0, 200)}`);
return `CMD-0A:executeCascadeAction(variant=${i})`;
} catch (e: any) {
logToFile(`[APPROVAL-0A-${i}] ❌ ${e.message.substring(0, 100)}`);
}
}
// ══════════════════════════════════════════════════════════
// STRATEGY 0B: Direct approval commands (brute-force try all 7 SDK commands)
// ══════════════════════════════════════════════════════════
const commandsToTry = approved
? [
'antigravity.terminalCommand.run',
'antigravity.terminalCommand.accept',
'antigravity.agent.acceptAgentStep',
'antigravity.command.accept',
]
: [
'antigravity.terminalCommand.reject',
'antigravity.agent.rejectAgentStep',
'antigravity.command.reject',
];
for (const cmd of commandsToTry) {
// Try with no args, then with sessionId
for (const args of [[], [sessionId], [{ cascadeId: sessionId }]]) {
try {
logToFile(`[APPROVAL-0B] ${cmd}(${JSON.stringify(args)})`);
const result = await vscode.commands.executeCommand(cmd, ...args);
logToFile(`[APPROVAL-0B] ✅ SUCCESS: ${JSON.stringify(result).substring(0, 200)}`);
return `CMD-0B:${cmd}`;
} catch (e: any) {
logToFile(`[APPROVAL-0B] ❌ ${e.message.substring(0, 80)}`);
}
}
}
// ══════════════════════════════════════════════════════════
// STRATEGY 0C: Electron webContents access (pierce webview isolation)
// ══════════════════════════════════════════════════════════
try {
logToFile(`[APPROVAL-0C] Attempting Electron webContents access...`);
// eslint-disable-next-line @typescript-eslint/no-var-requires
const electron = require('electron');
const remote = (electron as any).remote;
if (remote) {
logToFile(`[APPROVAL-0C] electron.remote available!`);
const allWC = remote.webContents.getAllWebContents();
logToFile(`[APPROVAL-0C] Found ${allWC.length} webContents`);
for (const wc of allWC) {
const wcUrl = wc.getURL() || '';
logToFile(`[APPROVAL-0C] wc: ${wcUrl.substring(0, 100)}`);
if (wcUrl.includes('vscode-webview://') || wcUrl.includes('webview')) {
const clickScript = approved
? `(function(){ var btns = document.querySelectorAll('button'); for(var b of btns){ if(/Run|Accept|Allow/i.test(b.textContent)){ b.click(); return 'clicked:'+b.textContent; } } return 'no-match'; })()`
: `(function(){ var btns = document.querySelectorAll('button'); for(var b of btns){ if(/Reject|Deny|Cancel/i.test(b.textContent)){ b.click(); return 'clicked:'+b.textContent; } } return 'no-match'; })()`;
const clickResult = await wc.executeJavaScript(clickScript);
logToFile(`[APPROVAL-0C] executeJavaScript result: ${clickResult}`);
if (clickResult && clickResult.startsWith('clicked:')) {
return `ELECTRON-0C:webContents(${clickResult})`;
}
}
}
} else {
logToFile(`[APPROVAL-0C] electron.remote NOT available`);
// Try without remote (main process context)
const wc = (electron as any).webContents;
if (wc && typeof wc.getAllWebContents === 'function') {
logToFile(`[APPROVAL-0C] Direct electron.webContents available!`);
const allWC = wc.getAllWebContents();
logToFile(`[APPROVAL-0C] Found ${allWC.length} webContents`);
} else {
logToFile(`[APPROVAL-0C] No webContents access from Extension Host`);
}
}
} catch (e: any) {
logToFile(`[APPROVAL-0C] ❌ ${e.message.substring(0, 150)}`);
}
// ══════════════════════════════════════════════════════════
// STRATEGY 0D: sendChatActionMessage (may route to agent)
// ══════════════════════════════════════════════════════════
const chatActionVariants = [
{ action: approved ? 'accept' : 'reject', cascadeId: sessionId },
{ message: approved ? 'accept' : 'reject', conversationId: sessionId },
approved ? 'accept' : 'reject',
];
for (let i = 0; i < chatActionVariants.length; i++) {
try {
logToFile(`[APPROVAL-0D-${i}] sendChatActionMessage(${JSON.stringify(chatActionVariants[i])})`);
const result = await vscode.commands.executeCommand('antigravity.sendChatActionMessage', chatActionVariants[i]);
logToFile(`[APPROVAL-0D-${i}] ✅ SUCCESS: ${JSON.stringify(result).substring(0, 200)}`);
return `CMD-0D:sendChatActionMessage(variant=${i})`;
} catch (e: any) {
logToFile(`[APPROVAL-0D-${i}] ❌ ${e.message.substring(0, 80)}`);
}
}
// ══════════════════════════════════════════════════════════
// STRATEGY 1: HandleCascadeUserInteraction RPC (expanded variants)
// ══════════════════════════════════════════════════════════
if (sdk) {
const rpcVariants = [
// Original variants
{ cascadeId: sessionId, approved: approved },
{ cascadeId: sessionId, stepAction: approved ? 'STEP_ACTION_ACCEPT' : 'STEP_ACTION_REJECT' },
{ cascadeId: sessionId, userAction: approved ? 'USER_ACTION_APPROVED' : 'USER_ACTION_REJECTED' },
// New variants — ConnectRPC protobuf field naming conventions
{ cascade_id: sessionId, accepted: approved },
{ cascadeId: sessionId, accepted: approved },
{ cascade_id: sessionId, user_action: approved ? 1 : 2 }, // enum as int
{ cascadeId: sessionId, interaction: approved ? 'ACCEPT' : 'REJECT' },
{ cascadeId: sessionId, action: approved ? 'accept' : 'reject' },
// With step index from last known waiting step
{ cascadeId: sessionId, stepIndex: lastPendingStepIndex, accepted: approved },
{ cascadeId: sessionId, stepIndex: lastPendingStepIndex, approved: approved },
];
for (let i = 0; i < rpcVariants.length; i++) {
try {
logToFile(`[APPROVAL-1-${i}] HandleCascadeUserInteraction(${JSON.stringify(rpcVariants[i]).substring(0, 120)})`);
const rpcResult = await sdk.ls.rawRPC('HandleCascadeUserInteraction', rpcVariants[i]);
logToFile(`[APPROVAL-1-${i}] ✅ SUCCESS: ${JSON.stringify(rpcResult).substring(0, 200)}`);
return `RPC-1-${i}:HandleCascadeUserInteraction`;
} catch (e: any) {
logToFile(`[APPROVAL-1-${i}] ❌ ${e.message.substring(0, 80)}`);
}
}
}
// ── Strategy 2: Renderer DOM Click via HTTP Bridge (kept as fallback) ──
try { try {
const triggerAction = approved ? 'approve' : 'reject'; const triggerAction = approved ? 'approve' : 'reject';
logToFile(`[APPROVAL-2] Setting clickTrigger=${triggerAction} for renderer DOM click`); logToFile(`[APPROVAL-2] Setting clickTrigger=${triggerAction} for renderer DOM click`);