/**
* 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('