/** * HTML Patcher — AG workbench HTML patching + product.json checksum update. * * Extracted from extension.ts to reduce file size. * Handles: * - Injecting the approval observer script into AG's workbench HTML files * - Patching CSP to allow inline scripts * - Managing .orig backups and .patch-lock * - Updating product.json checksums for vscode-file:// protocol */ import * as fs from 'fs'; import * as path from 'path'; import * as crypto from 'crypto'; import { generateApprovalObserverScript } from './observer-script'; // ─── Types ─── interface HtmlFileSpec { name: string; requiredMarker: string; requiredScript: string; } // ─── Public API ─── /** * Set up the approval observer by patching AG's workbench HTML files * with an inline script that monitors DOM for approval buttons. */ export async function setupApprovalObserver( sdk: any, bridgePort: number, logToFile: (msg: string) => void, ): Promise { if (!sdk) { logToFile('[OBSERVER] no SDK'); return; } try { const integration = sdk.integration; if (!integration) { logToFile('[OBSERVER] sdk.integration unavailable'); return; } // 1. Register a TOP_BAR button so build() works try { integration.register({ id: 'gravity_bridge_status', point: 'topBar', icon: '🌉', tooltip: 'Gravity Bridge Active', }); } catch { /* already registered */ } // 2. Write renderer script with HTTP fetch() approach const observerJS = generateApprovalObserverScript(bridgePort); const patcher = (integration as any)._patcher; logToFile(`[OBSERVER-DEBUG] patcher type: ${typeof patcher}, has getScriptPath: ${patcher && typeof patcher.getScriptPath === 'function'}`); if (patcher && typeof patcher.getScriptPath === 'function') { let baseScript = ''; try { baseScript = integration.build(); } catch { baseScript = ''; } const combinedScript = baseScript + '\n' + observerJS; const scriptPath = patcher.getScriptPath(); fs.writeFileSync(scriptPath, combinedScript, 'utf8'); logToFile(`[OBSERVER] script written → ${scriptPath} (port=${bridgePort})`); if (!integration.isInstalled()) { patcher.install(combinedScript); logToFile('[OBSERVER] patcher.install() called (needs reload)'); } // Patch BOTH HTML files with inline script injection. const scriptDir = path.dirname(scriptPath); _patchHtmlFiles(scriptDir, combinedScript, logToFile); } // 3. Update product.json checksums updateProductChecksums(sdk, logToFile); try { integration.enableAutoRepair(); } catch { } setInterval(() => { try { integration.signalActive(); } catch { } }, 30_000); logToFile(`[OBSERVER] setup complete (HTTP bridge on port ${bridgePort})`); console.log(`Gravity Bridge: ✅ Approval observer installed (port ${bridgePort})`); } catch (err: any) { logToFile(`[OBSERVER] setup error: ${err.message}`); } } /** * Update product.json checksums so vscode-file:// serves our patched files. * Without valid checksums, Electron serves the ORIGINAL cached version. */ export function updateProductChecksums(sdk: any, logToFile: (msg: string) => void): void { try { // Find product.json (2 levels up from workbench dir: resources/app/product.json) const patcher = (sdk?.integration as any)?._patcher; if (!patcher || typeof patcher.getWorkbenchDir !== 'function') { logToFile('[CHECKSUM] no patcher/workbenchDir — skipping'); return; } const workbenchDir = patcher.getWorkbenchDir(); const appDir = path.resolve(workbenchDir, '..', '..', '..', '..', '..'); const productJsonPath = path.join(appDir, 'product.json'); if (!fs.existsSync(productJsonPath)) { logToFile(`[CHECKSUM] product.json not found at ${productJsonPath}`); return; } // Read product.json (may have BOM) let raw = fs.readFileSync(productJsonPath, 'utf8'); if (raw.charCodeAt(0) === 0xFEFF) raw = raw.substring(1); const product = JSON.parse(raw); if (!product.checksums) { logToFile('[CHECKSUM] no checksums section in product.json'); return; } // Files we may modify or create (relative key in product.json → absolute path) const filesToCheck: Record = { 'vs/code/electron-browser/workbench/workbench.html': path.join(workbenchDir, 'workbench.html'), 'vs/code/electron-browser/workbench/workbench-jetski-agent.html': path.join(workbenchDir, 'workbench-jetski-agent.html'), 'vs/code/electron-browser/workbench/ag-sdk-variet-gravity-bridge.js': path.join(workbenchDir, 'ag-sdk-variet-gravity-bridge.js'), }; let updated = false; for (const [key, filePath] of Object.entries(filesToCheck)) { if (!fs.existsSync(filePath)) continue; const fileBytes = fs.readFileSync(filePath); const hash = crypto.createHash('sha256').update(fileBytes).digest('base64').replace(/=+$/, ''); if (product.checksums[key] && product.checksums[key] !== hash) { logToFile(`[CHECKSUM] updating ${key}: ${product.checksums[key].substring(0, 12)}... → ${hash.substring(0, 12)}...`); product.checksums[key] = hash; updated = true; } else if (!product.checksums[key]) { logToFile(`[CHECKSUM] adding ${key}: → ${hash.substring(0, 12)}...`); product.checksums[key] = hash; updated = true; } } if (updated) { fs.writeFileSync(productJsonPath, JSON.stringify(product, null, '\t'), 'utf8'); logToFile('[CHECKSUM] product.json updated ✅'); } else { logToFile('[CHECKSUM] all checksums already match ✅'); } } catch (e: any) { logToFile(`[CHECKSUM] error: ${e.message}`); } } // ─── Private ─── /** * Patch both AG workbench HTML files with inline observer script. * CRITICAL: vscode-file:// does NOT serve custom .js files (silent 404), * so we MUST inline the script directly into BOTH HTML files. */ function _patchHtmlFiles(scriptDir: string, combinedScript: string, logToFile: (msg: string) => void): void { // Each HTML file has DIFFERENT CSS/JS entry points — they are NOT interchangeable const htmlFileSpecs: HtmlFileSpec[] = [ { name: 'workbench.html', requiredMarker: 'workbench.desktop.main.css', requiredScript: 'workbench.js', }, { name: 'workbench-jetski-agent.html', requiredMarker: 'jetskiMain.tailwind.css', requiredScript: 'jetskiAgent.js', }, ]; // ── File lock to prevent multi-instance HTML patching race ── const lockFile = path.join(scriptDir, '.patch-lock'); let lockAcquired = false; try { if (fs.existsSync(lockFile)) { const lockAge = Date.now() - fs.statSync(lockFile).mtimeMs; if (lockAge < 30_000) { logToFile(`[OBSERVER] another instance is patching (lock age=${Math.round(lockAge / 1000)}s) — skipping`); return; } logToFile(`[OBSERVER] stale lock (age=${Math.round(lockAge / 1000)}s) — force-acquiring`); } fs.writeFileSync(lockFile, JSON.stringify({ pid: process.pid, ts: Date.now() }), 'utf-8'); lockAcquired = true; } catch (lockErr: any) { logToFile(`[OBSERVER] lock acquire error: ${lockErr.message} — proceeding anyway`); } for (const spec of htmlFileSpecs) { const htmlPath = path.join(scriptDir, spec.name); const backupPath = htmlPath + '.orig'; try { if (!fs.existsSync(htmlPath)) { logToFile(`[OBSERVER] ${spec.name} not found — skipping`); continue; } let html = fs.readFileSync(htmlPath, 'utf8'); // ── BACKUP: Save original before first-ever patch ── if (!fs.existsSync(backupPath) && html.length >= 500 && html.includes('') && html.includes(spec.requiredMarker)) { fs.writeFileSync(backupPath, html, 'utf8'); logToFile(`[OBSERVER] ${spec.name} backed up to .orig (${html.length} bytes)`); } // ── SAFETY: Refuse to patch if file is corrupt, empty, or wrong type ── const isCorrupt = html.length < 500 || !html.includes(''); const isWrongType = !isCorrupt && !html.includes(spec.requiredMarker); if (isCorrupt || isWrongType) { const reason = isCorrupt ? `corrupt/empty (${html.length} bytes)` : `wrong type (missing ${spec.requiredMarker})`; logToFile(`[OBSERVER] ${spec.name} detected ${reason}`); // Try to restore from backup if (fs.existsSync(backupPath)) { const backup = fs.readFileSync(backupPath, 'utf8'); if (backup.length >= 500 && backup.includes('') && backup.includes(spec.requiredMarker)) { fs.writeFileSync(htmlPath, backup, 'utf8'); html = backup; logToFile(`[OBSERVER] ${spec.name} RESTORED from .orig backup (${backup.length} bytes) ✅`); } else { logToFile(`[OBSERVER] ${spec.name} .orig backup also invalid — SKIPPING`); continue; } } else { logToFile(`[OBSERVER] ${spec.name} no .orig backup available — SKIPPING to prevent further damage`); continue; } } // CRITICAL: Patch CSP to allow inline scripts. if (html.includes('script-src') && !html.match(/script-src[^;]*'unsafe-inline'/)) { html = html.replace( /(script-src\s[^;]*?)('self')/, "$1$2\n\t\t\t\t\t'unsafe-inline'" ); logToFile(`[OBSERVER] ${spec.name} CSP patched: added 'unsafe-inline' to script-src`); } // Remove old external script tag if present (legacy, cannot be served) const extMarkerStart = ''; const extMarkerEnd = ''; if (html.includes(extMarkerStart)) { const extRe = new RegExp( '\\n?' + extMarkerStart.replace(/[[\]]/g, '\\$&') + '[\\s\\S]*?' + extMarkerEnd.replace(/[[\]]/g, '\\$&') + '\\n?' ); html = html.replace(extRe, ''); logToFile(`[OBSERVER] removed external script tag from ${spec.name}`); } // Insert or update inline script const inlineMarkerStart = ''; const inlineMarkerEnd = ''; if (html.includes(inlineMarkerStart)) { const re = new RegExp( inlineMarkerStart.replace(/[[\]]/g, '\\$&') + '[\\s\\S]*?' + inlineMarkerEnd.replace(/[[\]]/g, '\\$&') ); html = html.replace(re, `${inlineMarkerStart}\n\n${inlineMarkerEnd}`); logToFile(`[OBSERVER] ${spec.name} inline script UPDATED`); } else { html = html.replace('', `\n${inlineMarkerStart}\n\n${inlineMarkerEnd}\n`); logToFile(`[OBSERVER] ${spec.name} inline script INSERTED`); } // SAFETY: Final validation before write if (html.length < 500 || !html.includes('') || !html.includes(spec.requiredMarker)) { logToFile(`[OBSERVER] ${spec.name} WOULD BE CORRUPT after patching (${html.length} bytes) — ABORTING write`); continue; } fs.writeFileSync(htmlPath, html, 'utf8'); } catch (e: any) { logToFile(`[OBSERVER] ${spec.name} patch error: ${e.message}`); } } // Release patch lock if (lockAcquired) { try { fs.unlinkSync(lockFile); } catch { } logToFile('[OBSERVER] patch lock released'); } }