refactor(extension): split extension.ts into 3 modules - http-bridge, html-patcher, command-handler (#398)
This commit is contained in:
291
extension/src/html-patcher.ts
Normal file
291
extension/src/html-patcher.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* 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<void> {
|
||||
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;
|
||||
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<string, string> = {
|
||||
'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] !== hash) {
|
||||
logToFile(`[CHECKSUM] updating ${key}: ${product.checksums[key].substring(0, 12)}... → ${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('<!DOCTYPE html>')
|
||||
&& 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('<!DOCTYPE html>');
|
||||
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('<!DOCTYPE html>')
|
||||
&& 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 = '<!-- AG SDK [variet-gravity-bridge] -->';
|
||||
const extMarkerEnd = '<!-- /AG SDK [variet-gravity-bridge] -->';
|
||||
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 = '<!-- AG SDK INLINE [variet-gravity-bridge] -->';
|
||||
const inlineMarkerEnd = '<!-- /AG SDK INLINE [variet-gravity-bridge] -->';
|
||||
|
||||
if (html.includes(inlineMarkerStart)) {
|
||||
const re = new RegExp(
|
||||
inlineMarkerStart.replace(/[[\]]/g, '\\$&') +
|
||||
'[\\s\\S]*?' +
|
||||
inlineMarkerEnd.replace(/[[\]]/g, '\\$&')
|
||||
);
|
||||
html = html.replace(re,
|
||||
`${inlineMarkerStart}\n<script>\n${combinedScript}\n</script>\n${inlineMarkerEnd}`);
|
||||
logToFile(`[OBSERVER] ${spec.name} inline script UPDATED`);
|
||||
} else {
|
||||
html = html.replace('</html>',
|
||||
`\n${inlineMarkerStart}\n<script>\n${combinedScript}\n</script>\n${inlineMarkerEnd}\n</html>`);
|
||||
logToFile(`[OBSERVER] ${spec.name} inline script INSERTED`);
|
||||
}
|
||||
// SAFETY: Final validation before write
|
||||
if (html.length < 500 || !html.includes('<!DOCTYPE html>') || !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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user