fix(bridge): v0.3.5 — inline script + deterministic port + auto-checksum

- vscode-file:// refuses custom .js files → inline script into HTML
- Random port → deterministic port from project name hash (gravity_control=34332)
- Hardcoded port in renderer script for immediate discovery
- Auto-update product.json SHA256 checksums after HTML modification
- Bump version 0.2.0 → 0.3.5
This commit is contained in:
2026-03-08 18:37:09 +09:00
parent afb1a1d6e6
commit 43f023c87e
7 changed files with 493 additions and 102 deletions

View File

@@ -0,0 +1,101 @@
# Known Issues & Lessons Learned
> **이 파일은 SSOT(Single Source of Truth)입니다.**
> 디버깅이나 구현 전에 **반드시** 이 파일을 확인하세요.
> 세션 종료 시 새로 발견된 이슈를 이 파일에 추가합니다.
---
## 포맷
각 항목은 아래 형식을 따릅니다:
```markdown
### [날짜] [키워드] — 한줄 요약
- **증상**: 무엇이 잘못되었는가
- **원인**: 근본 원인
- **해결**: 올바른 해결 방법
- **주의**: 재발 방지를 위한 교훈
```
---
## 공통 이슈
### [2026-03-08] PowerShell curl — Invoke-WebRequest 충돌
- **증상**: `curl` 명령이 예상과 다른 응답 형식을 반환
- **원인**: PowerShell에서 `curl``Invoke-WebRequest`의 별칭
- **해결**: **`curl.exe`**를 명시적으로 사용
- **주의**: HTTP 관련 모든 명령에서 `curl.exe` 사용 필수
### [2026-03-08] PowerShell npm — 실행 정책 오류
- **증상**: `npm run` 명령이 `실행 정책` 관련 오류로 실패
- **원인**: PowerShell 스크립트 실행 정책이 제한적으로 설정됨
- **해결**: `cmd /c npm run dev` 형식으로 cmd를 통해 실행
- **주의**: npm 관련 명령은 항상 `cmd /c` 접두어 사용 권장
---
## 프로젝트별 이슈
> 아래에 프로젝트 특화 이슈를 추가하세요.
### [2026-03-08] Antigravity Renderer Injection — Electron 캐시 차단
- **증상**: workbench.html, workbench-jetski-agent.html에 `<script>` 태그 추가 후 리로드해도 실행되지 않음
- **원인**: Electron의 V8 코드 캐시가 수정된 HTML을 무시하고 캐시된 버전을 서빙
- **해결**: 렌더러 인젝션 방식 **포기**. Extension Host에서 RPC 폴링 방식으로 전환
- **주의**: Antigravity는 `workbench-jetski-agent.html`을 사용 (Jetski = 내부 코드네임)
### [2026-03-08] Antigravity 승인 대기 = RUNNING (NOT IDLE)
- **증상**: IDLE 기반 승인 감지가 실제 승인 대기를 놓침
- **원인**: 승인 대기 시 세션 상태가 `CASCADE_RUN_STATUS_RUNNING` (IDLE 아님), `IDLE`은 대화 대기(notify_user 후)
- **해결**: `RUNNING + delta=0` (stall) 기반 감지로 전환. 6 polls (30초) 이상 FROZEN 시 pending 생성
- **주의**: Thinking/생성 중에도 `RUNNING + delta=0`이 발생 → `lastModifiedTime`으로 구분 시도했으나 불완전
### [2026-03-08] ResolveOutstandingSteps RPC — 승인이 아닌 취소!
- **증상**: Discord 승인 → `ResolveOutstandingSteps` 호출 → step이 취소됨
- **원인**: `ResolveOutstandingSteps`는 blocking steps를 "resolve" = **REJECT/CANCEL**, approve가 아님
- **해결**: `ResolveOutstandingSteps` 제거. `HandleCascadeUserInteraction``socket hang up`
- **주의**: KI에 "more reliable"로 기록되어 있으나 실제 동작은 cancel임. KI 업데이트 필요
### [2026-03-08] VS Code Accept Commands — Silent Success 문제
- **증상**: 4개 accept command 모두 OK(undefined) 반환하나 실제 승인 안 됨
- **원인**: webview에 활성 포커스가 필요. `panel.focus()`로는 충분하지 않음
- **해결**: **미해결**. Windows UI Automation 등 OS 레벨 접근 필요
- **주의**: reject commands는 동작함. accept만 focus 의존성 있음
### [2026-03-08] Multi-Window 세션 등록 경쟁 조건
- **증상**: 이 창(gravity_control)의 대화가 `#ag-variet_agent` 채널로 메시지 전달
- **원인**: `writeRegistration()`이 폴링 루프에서 호출 → 먼저 폴링한 확장이 세션을 자기 프로젝트로 등록
- **해결**: `writeRegistration`을 폴링에서 제거, `writeChatSnapshot`/`writePendingApproval`에서만 지연 호출
- **주의**: `GetAllCascadeTrajectories`는 모든 창의 세션을 반환하므로 세션→창 매핑은 불가능. 활동 기반 등록만 신뢰 가능
### [2026-03-08] 공유 렌더러 스크립트 파일 덮어쓰기 문제
- **증상**: DOM Observer 렌더러 스크립트가 잘못된 HTTP bridge 포트에 연결
- **원인**: 두 확장이 동일한 `ag-sdk-variet-gravity-bridge.js` 파일에 각자 포트를 씀 → 마지막 확장 것만 남음
- **해결**: `ag-bridge-ports.json`에 모든 확장의 port를 JSON으로 기록, 렌더러가 all ports를 순회하며 ping
- **주의**: 렌더러 스크립트 파일 경로는 SDK patcher namespace에 의해 고정 — 변경 불가
### [2026-03-08] workbench.html vs workbench-jetski-agent.html
- **증상**: 렌더러에서 `[GB Observer]` 로그가 전혀 안 나옴
- **원인**: DevTools가 `workbench.html`을 로드 — 스크립트 태그는 `workbench-jetski-agent.html`에만 패치됨
- **해결**: `workbench.html`에도 스크립트 태그 필요. Antigravity 재설치 후 SDK patcher가 올바르게 패치하도록 함
- **주의**: SDK patcher는 `both` HTML 파일을 패치하지만, 수동 수정은 Antigravity integrity check에 의해 되돌려질 수 있음
### [2026-03-08] product.json 체크섬 불일치 → 렌더러 스크립트 미로딩
- **증상**: `<script>` 태그가 HTML에 존재하고 .js 파일도 디스크에 있으나, 렌더러 콘솔에 스크립트 로그가 전혀 없음
- **원인**: Antigravity 재설치 시 `product.json`의 SHA256 체크섬이 원본으로 리셋됨. Extension이 HTML을 패치하지만 `IntegrityManager.suppressCheck()`를 호출하지 않아 체크섬 불일치. `vscode-file://` 프로토콜이 체크섬 불일치 파일을 무시하고 **원본 캐시 HTML**을 서빙
- **해결**: `product.json``checksums` 항목에서 수정된 파일(workbench.html, workbench-jetski-agent.html)의 SHA256 해시를 실제 파일 기준으로 업데이트. SDK `IntegrityManager.suppressCheck()` 호출 또는 수동 스크립트로 해결
- **주의**: Extension `setupApprovalObserver()``suppressCheck()` 호출을 영구 추가해야 재설치마다 반복 안 됨. 해시 = `base64(sha256(file)).replace(/=+$/, '')`
### [2026-03-08] vscode-file:// 프로토콜 — 커스텀 .js 파일 서빙 불가
- **증상**: `<script src="./ag-sdk-variet-gravity-bridge.js">` 태그가 HTML에 있으나 `net::ERR_FILE_NOT_FOUND` 발생, GB Observer 로그 전혀 없음
- **원인**: `vscode-file://` 프로토콜은 원본 배포에 포함된 파일만 서빙. Extension이 디스크에 쓴 커스텀 `.js` 파일은 프로토콜 레벨에서 차단됨
- **해결**: 외부 `<script src>` 참조 대신 **인라인 `<script>...코드...</script>`** 방식으로 HTML에 직접 삽입
- **주의**: `ag-bridge-ports.json`도 같은 이유로 XHR 로딩 불가. 모든 렌더러 스크립트/데이터는 HTML 인라인으로 전달해야 함
### [2026-03-08] Renderer 포트 디스커버리 — ag-bridge-ports.json XHR 실패
- **증상**: `[GB Observer] Port discovery timeout after 2min` — 렌더러가 bridge 포트를 찾지 못함
- **원인**: 렌더러 스크립트가 `./ag-bridge-ports.json`을 동기 XHR로 읽으려 하나, `vscode-file://` 프로토콜이 `.json` 파일 서빙 거부
- **해결**: (1) 프로젝트명 해시 기반 **결정론적 포트** 사용 (`gravity_control→34332`), (2) 스크립트 생성 시 포트를 `HARDCODED_PORT=${port}`로 직접 삽입
- **주의**: `server.listen(0)` 랜덤 포트 → 매 재시작마다 변경되어 렌더러와 불일치. 결정론적 포트는 `EADDRINUSE` 시 랜덤 폴백 필요

View File

@@ -16,3 +16,4 @@
| 12 | 11:30~14:35 | 승인 로직 정밀 디버깅: IDLE→stall 전환, lastModifiedTime 구분, RPC/Commands 전수 테스트, ResolveOutstandingSteps cancel 발견 | - | 🔧 |
| 13 | 15:00~16:52 | Multi-window 격리 (v0.3.1→0.3.4): 세션 필터, per-project 포트, 등록 경쟁 조건 수정, DOM Observer 렌더러 디버깅 | - | 🔧 |
| 14 | 17:01~17:38 | **근본 원인 발견**: product.json 체크섬 불일치 → vscode-file:// 원본 캐시 서빙. 체크섬 수동 업데이트로 수정 | - | 🔧 |
| 15 | 17:50~18:30 | **v0.3.5**: 포트 디스커버리 수정 (결정론적 포트 + 하드코딩), 인라인 스크립트 전환 (`<script src>``<script>inline</script>`), product.json 자동 체크섬 업데이트 | - | 🔧 |

Binary file not shown.

View File

@@ -51,6 +51,7 @@ const fs = __importStar(require("fs"));
const path = __importStar(require("path"));
const os = __importStar(require("os"));
const cp = __importStar(require("child_process"));
const crypto = __importStar(require("crypto"));
// ─── File-based logging (AI can read directly) ───
function logToFile(msg) {
const ts = new Date().toISOString().replace('T', ' ').substring(0, 19);
@@ -73,6 +74,7 @@ let statusBar;
let bridgePath;
let projectName;
let isActive = false;
let deterministicPort = 0; // derived from projectName, consistent across restarts
let watcher = null;
let commandsWatcher = null;
const sentPendingIds = new Set();
@@ -287,26 +289,44 @@ async function setupApprovalObserver() {
logToFile('[OBSERVER] workbench.html patched (needs reload)');
}
// Also patch workbench-jetski-agent.html (Antigravity's actual entry point!)
// IMPORTANT: vscode-file:// protocol does NOT serve custom .js files,
// so we INLINE the script content directly into the HTML.
const scriptDir = path.dirname(scriptPath);
const jetskiHtml = path.join(scriptDir, 'workbench-jetski-agent.html');
const scriptBasename = path.basename(scriptPath);
try {
if (fs.existsSync(jetskiHtml)) {
let html = fs.readFileSync(jetskiHtml, 'utf8');
if (!html.includes(scriptBasename)) {
html = html.replace('</html>', `\n<!-- AG SDK [variet-gravity-bridge] -->\n<script src="./${scriptBasename}"></script>\n<!-- /AG SDK [variet-gravity-bridge] -->\n</html>`);
fs.writeFileSync(jetskiHtml, html, 'utf8');
logToFile('[OBSERVER] workbench-jetski-agent.html PATCHED');
// Remove old external script tag if present (from previous versions)
const oldScriptBasename = path.basename(scriptPath);
if (html.includes(`src="./${oldScriptBasename}"`)) {
html = html.replace(/\n?<!-- AG SDK \[variet-gravity-bridge\] -->\n?<script src="[^"]*"><\/script>\n?<!-- \/AG SDK \[variet-gravity-bridge\] -->\n?/, '');
logToFile('[OBSERVER] removed old external <script src> tag from jetski HTML');
}
// 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)) {
// Replace existing inline script with updated content
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] jetski HTML inline script UPDATED');
}
else {
logToFile('[OBSERVER] workbench-jetski-agent.html already has script tag');
// First time: insert before </html>
html = html.replace('</html>', `\n${inlineMarkerStart}\n<script>\n${combinedScript}\n</script>\n${inlineMarkerEnd}\n</html>`);
logToFile('[OBSERVER] jetski HTML inline script INSERTED');
}
fs.writeFileSync(jetskiHtml, html, 'utf8');
}
}
catch (e) {
logToFile(`[OBSERVER] jetski patch error: ${e.message}`);
}
}
// 4. Update product.json checksums so vscode-file:// serves our patched files
updateProductChecksums();
try {
integration.enableAutoRepair();
}
@@ -322,9 +342,78 @@ async function setupApprovalObserver() {
logToFile(`[OBSERVER] setup error: ${err.message}`);
}
}
// ─── Product.json Checksum Auto-Update ───
// vscode-file:// protocol validates SHA256 checksums in product.json.
// If a file's checksum doesn't match, Electron serves the ORIGINAL cached version.
// This function recalculates checksums for files we modify (HTML files with <script> tags).
function updateProductChecksums() {
try {
// Find product.json (2 levels up from workbench dir: resources/app/product.json)
const patcher = sdk?.integration?._patcher;
if (!patcher || typeof patcher.getWorkbenchDir !== 'function') {
logToFile('[CHECKSUM] no patcher/workbenchDir — skipping');
return;
}
const workbenchDir = patcher.getWorkbenchDir();
// workbenchDir = .../resources/app/out/vs/code/electron-browser/workbench
// product.json = .../resources/app/product.json (5 levels up from workbench)
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 (relative key in product.json → absolute path)
const filesToCheck = {
'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'),
};
let updated = false;
for (const [key, filePath] of Object.entries(filesToCheck)) {
if (!product.checksums[key])
continue; // not in checksums, skip
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) {
logToFile(`[CHECKSUM] error: ${e.message}`);
}
}
// ─── HTTP Bridge Server (Extension Host → Renderer communication) ───
let observerHttpServer = null;
const pendingResponses = new Map();
/** Derive a deterministic port from project name (range 10000-60000) */
function getDeterministicPort(name) {
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = ((hash << 5) - hash + name.charCodeAt(i)) | 0;
}
return 10000 + (Math.abs(hash) % 50000);
}
function startObserverHttpBridge() {
return new Promise((resolve) => {
try {
@@ -406,33 +495,53 @@ function startObserverHttpBridge() {
res.writeHead(404);
res.end('not found');
});
// Listen on random port
server.listen(0, '127.0.0.1', () => {
const port = server.address().port;
observerHttpServer = server;
logToFile(`[HTTP] bridge server started on port ${port}`);
// Write port to shared ports JSON (multi-bridge support)
const patcher = sdk.integration?._patcher;
if (patcher && typeof patcher.getWorkbenchDir === 'function') {
const workbenchDir = patcher.getWorkbenchDir();
const portsFile = path.join(workbenchDir, 'ag-bridge-ports.json');
let portsData = {};
try {
if (fs.existsSync(portsFile)) {
portsData = JSON.parse(fs.readFileSync(portsFile, 'utf-8'));
// Listen on deterministic port (derived from projectName), fallback to random
deterministicPort = getDeterministicPort(projectName);
const tryListen = (targetPort) => {
server.listen(targetPort, '127.0.0.1', () => {
const port = server.address().port;
observerHttpServer = server;
logToFile(`[HTTP] bridge server started on port ${port}`);
// Write port to shared ports JSON (multi-bridge support)
const patcher = sdk.integration?._patcher;
if (patcher && typeof patcher.getWorkbenchDir === 'function') {
const workbenchDir = patcher.getWorkbenchDir();
const portsFile = path.join(workbenchDir, 'ag-bridge-ports.json');
let portsData = {};
try {
if (fs.existsSync(portsFile)) {
portsData = JSON.parse(fs.readFileSync(portsFile, 'utf-8'));
}
}
catch { }
portsData[projectName] = port;
fs.writeFileSync(portsFile, JSON.stringify(portsData), 'utf-8');
logToFile(`[HTTP] ports JSON updated → ${portsFile} (${projectName}=${port})`);
}
catch { }
portsData[projectName] = port;
fs.writeFileSync(portsFile, JSON.stringify(portsData), 'utf-8');
logToFile(`[HTTP] ports JSON updated → ${portsFile} (${projectName}=${port})`);
}
resolve(port);
});
resolve(port);
});
};
server.on('error', (e) => {
if (e.code === 'EADDRINUSE' && deterministicPort > 0) {
logToFile(`[HTTP] deterministic port ${deterministicPort} in use, trying random...`);
deterministicPort = 0;
const server2 = require('http').createServer(server._events.request);
observerHttpServer = server2;
server2.on('error', (e2) => {
logToFile(`[HTTP] random port also failed: ${e2.message}`);
resolve(0);
});
server2.listen(0, '127.0.0.1', () => {
const port = server2.address().port;
logToFile(`[HTTP] bridge server started on RANDOM port ${port}`);
resolve(port);
});
return;
}
logToFile(`[HTTP] server error: ${e.message}`);
resolve(0);
});
tryListen(deterministicPort);
}
catch (e) {
logToFile(`[HTTP] server failed: ${e.message}`);
@@ -455,40 +564,67 @@ function generateApprovalObserverScript(_port) {
function log(m){console.log('[GB Observer] '+m);}
log('v2 Script loaded — discovering bridge port...');
// ── Multi-Port Discovery: reads ag-bridge-ports.json, tries ALL bridges ──
// ── Port Discovery: try hardcoded port FIRST, then JSON fallback ──
// vscode-file:// protocol blocks .json files, so XHR to ag-bridge-ports.json
// returns 404. The extension embeds the known port at script generation time.
var HARDCODED_PORT=${_port};
function tryPing(port,cb){
try{
var xhr=new XMLHttpRequest();
xhr.open('GET','http://127.0.0.1:'+port+'/ping?t='+Date.now(),false);
xhr.timeout=2000;
xhr.send();
if(xhr.status===200&&xhr.responseText==='pong'){cb(true);return;}
}catch(e){}
cb(false);
}
function discoverPort(cb){
var attempts=0;
var timer=setInterval(function(){
attempts++;
if(attempts>60){clearInterval(timer);log('Port discovery timeout after 2min');return;}
try{
var xhr=new XMLHttpRequest();
xhr.open('GET','./ag-bridge-ports.json?t='+Date.now(),false);
xhr.send();
if(xhr.status===200){
var ports=JSON.parse(xhr.responseText);
var keys=Object.keys(ports);
for(var i=0;i<keys.length;i++){
var port=ports[keys[i]];
if(port>0&&port<65536){
// Try ping on each port
try{
var xhr2=new XMLHttpRequest();
xhr2.open('GET','http://127.0.0.1:'+port+'/ping?t='+Date.now(),false);
xhr2.timeout=1000;
xhr2.send();
if(xhr2.status===200&&xhr2.responseText==='pong'){
clearInterval(timer);
log('Port discovered: '+port+' (project='+keys[i]+')');
cb(port);
return;
}
}catch(e2){}
// Strategy 1: Try hardcoded port immediately
log('Trying hardcoded port '+HARDCODED_PORT+'...');
tryPing(HARDCODED_PORT,function(ok){
if(ok){log('Port discovered (hardcoded): '+HARDCODED_PORT);cb(HARDCODED_PORT);return;}
log('Hardcoded port failed, falling back to JSON + port scan...');
// Strategy 2: JSON file fallback + port scan
var attempts=0;
var timer=setInterval(function(){
attempts++;
if(attempts>60){clearInterval(timer);log('Port discovery timeout after 2min');return;}
// Try hardcoded port again (server may start late)
tryPing(HARDCODED_PORT,function(ok2){
if(ok2){clearInterval(timer);log('Port discovered (hardcoded retry): '+HARDCODED_PORT);cb(HARDCODED_PORT);return;}
});
// Try JSON file (may work in future Electron versions)
try{
var xhr=new XMLHttpRequest();
xhr.open('GET','./ag-bridge-ports.json?t='+Date.now(),false);
xhr.send();
if(xhr.status===200){
var ports=JSON.parse(xhr.responseText);
var keys=Object.keys(ports);
for(var i=0;i<keys.length;i++){
var port=ports[keys[i]];
if(port>0&&port<65536){
try{
var xhr2=new XMLHttpRequest();
xhr2.open('GET','http://127.0.0.1:'+port+'/ping?t='+Date.now(),false);
xhr2.timeout=1000;
xhr2.send();
if(xhr2.status===200&&xhr2.responseText==='pong'){
clearInterval(timer);
log('Port discovered (JSON): '+port+' (project='+keys[i]+')');
cb(port);
return;
}
}catch(e2){}
}
}
}
}
}catch(e){}
},2000);
}catch(e){}
},2000);
});
}
discoverPort(function(port){

File diff suppressed because one or more lines are too long

View File

@@ -2,7 +2,7 @@
"name": "gravity-bridge",
"displayName": "Gravity Bridge",
"description": "Antigravity ↔ Discord 브리지 연동 확장",
"version": "0.2.0",
"version": "0.3.5",
"publisher": "variet",
"engines": {
"vscode": "^1.100.0"

View File

@@ -15,6 +15,7 @@ import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import * as cp from 'child_process';
import * as crypto from 'crypto';
// ─── File-based logging (AI can read directly) ───
function logToFile(msg: string) {
@@ -38,6 +39,7 @@ let statusBar: vscode.StatusBarItem;
let bridgePath: string;
let projectName: string;
let isActive = false;
let deterministicPort = 0; // derived from projectName, consistent across restarts
let watcher: fs.FSWatcher | null = null;
let commandsWatcher: fs.FSWatcher | null = null;
@@ -248,26 +250,54 @@ async function setupApprovalObserver() {
}
// Also patch workbench-jetski-agent.html (Antigravity's actual entry point!)
// IMPORTANT: vscode-file:// protocol does NOT serve custom .js files,
// so we INLINE the script content directly into the HTML.
const scriptDir = path.dirname(scriptPath);
const jetskiHtml = path.join(scriptDir, 'workbench-jetski-agent.html');
const scriptBasename = path.basename(scriptPath);
try {
if (fs.existsSync(jetskiHtml)) {
let html = fs.readFileSync(jetskiHtml, 'utf8');
if (!html.includes(scriptBasename)) {
html = html.replace('</html>',
`\n<!-- AG SDK [variet-gravity-bridge] -->\n<script src="./${scriptBasename}"></script>\n<!-- /AG SDK [variet-gravity-bridge] -->\n</html>`);
fs.writeFileSync(jetskiHtml, html, 'utf8');
logToFile('[OBSERVER] workbench-jetski-agent.html PATCHED');
} else {
logToFile('[OBSERVER] workbench-jetski-agent.html already has script tag');
// Remove old external script tag if present (from previous versions)
const oldScriptBasename = path.basename(scriptPath);
if (html.includes(`src="./${oldScriptBasename}"`)) {
html = html.replace(
/\n?<!-- AG SDK \[variet-gravity-bridge\] -->\n?<script src="[^"]*"><\/script>\n?<!-- \/AG SDK \[variet-gravity-bridge\] -->\n?/,
''
);
logToFile('[OBSERVER] removed old external <script src> tag from jetski HTML');
}
// 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)) {
// Replace existing inline script with updated content
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] jetski HTML inline script UPDATED');
} else {
// First time: insert before </html>
html = html.replace('</html>',
`\n${inlineMarkerStart}\n<script>\n${combinedScript}\n</script>\n${inlineMarkerEnd}\n</html>`);
logToFile('[OBSERVER] jetski HTML inline script INSERTED');
}
fs.writeFileSync(jetskiHtml, html, 'utf8');
}
} catch (e: any) {
logToFile(`[OBSERVER] jetski patch error: ${e.message}`);
}
}
// 4. Update product.json checksums so vscode-file:// serves our patched files
updateProductChecksums();
try { integration.enableAutoRepair(); } catch { }
setInterval(() => { try { integration.signalActive(); } catch { } }, 30_000);
@@ -278,11 +308,86 @@ async function setupApprovalObserver() {
}
}
// ─── Product.json Checksum Auto-Update ───
// vscode-file:// protocol validates SHA256 checksums in product.json.
// If a file's checksum doesn't match, Electron serves the ORIGINAL cached version.
// This function recalculates checksums for files we modify (HTML files with <script> tags).
function updateProductChecksums() {
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();
// workbenchDir = .../resources/app/out/vs/code/electron-browser/workbench
// product.json = .../resources/app/product.json (5 levels up from workbench)
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 (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'),
};
let updated = false;
for (const [key, filePath] of Object.entries(filesToCheck)) {
if (!product.checksums[key]) continue; // not in checksums, skip
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}`);
}
}
// ─── HTTP Bridge Server (Extension Host → Renderer communication) ───
let observerHttpServer: any = null;
const pendingResponses = new Map<string, { approved: boolean } | null>();
/** Derive a deterministic port from project name (range 10000-60000) */
function getDeterministicPort(name: string): number {
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = ((hash << 5) - hash + name.charCodeAt(i)) | 0;
}
return 10000 + (Math.abs(hash) % 50000);
}
function startObserverHttpBridge(): Promise<number> {
return new Promise((resolve) => {
try {
@@ -357,8 +462,10 @@ function startObserverHttpBridge(): Promise<number> {
res.writeHead(404); res.end('not found');
});
// Listen on random port
server.listen(0, '127.0.0.1', () => {
// Listen on deterministic port (derived from projectName), fallback to random
deterministicPort = getDeterministicPort(projectName);
const tryListen = (targetPort: number) => {
server.listen(targetPort, '127.0.0.1', () => {
const port = server.address().port;
observerHttpServer = server;
logToFile(`[HTTP] bridge server started on port ${port}`);
@@ -380,12 +487,31 @@ function startObserverHttpBridge(): Promise<number> {
}
resolve(port);
});
});
};
server.on('error', (e: any) => {
if (e.code === 'EADDRINUSE' && deterministicPort > 0) {
logToFile(`[HTTP] deterministic port ${deterministicPort} in use, trying random...`);
deterministicPort = 0;
const server2 = require('http').createServer(server._events.request);
observerHttpServer = server2;
server2.on('error', (e2: any) => {
logToFile(`[HTTP] random port also failed: ${e2.message}`);
resolve(0);
});
server2.listen(0, '127.0.0.1', () => {
const port = server2.address().port;
logToFile(`[HTTP] bridge server started on RANDOM port ${port}`);
resolve(port);
});
return;
}
logToFile(`[HTTP] server error: ${e.message}`);
resolve(0);
});
tryListen(deterministicPort);
} catch (e: any) {
logToFile(`[HTTP] server failed: ${e.message}`);
resolve(0);
@@ -409,40 +535,67 @@ function generateApprovalObserverScript(_port: number): string {
function log(m){console.log('[GB Observer] '+m);}
log('v2 Script loaded — discovering bridge port...');
// ── Multi-Port Discovery: reads ag-bridge-ports.json, tries ALL bridges ──
// ── Port Discovery: try hardcoded port FIRST, then JSON fallback ──
// vscode-file:// protocol blocks .json files, so XHR to ag-bridge-ports.json
// returns 404. The extension embeds the known port at script generation time.
var HARDCODED_PORT=${_port};
function tryPing(port,cb){
try{
var xhr=new XMLHttpRequest();
xhr.open('GET','http://127.0.0.1:'+port+'/ping?t='+Date.now(),false);
xhr.timeout=2000;
xhr.send();
if(xhr.status===200&&xhr.responseText==='pong'){cb(true);return;}
}catch(e){}
cb(false);
}
function discoverPort(cb){
var attempts=0;
var timer=setInterval(function(){
attempts++;
if(attempts>60){clearInterval(timer);log('Port discovery timeout after 2min');return;}
try{
var xhr=new XMLHttpRequest();
xhr.open('GET','./ag-bridge-ports.json?t='+Date.now(),false);
xhr.send();
if(xhr.status===200){
var ports=JSON.parse(xhr.responseText);
var keys=Object.keys(ports);
for(var i=0;i<keys.length;i++){
var port=ports[keys[i]];
if(port>0&&port<65536){
// Try ping on each port
try{
var xhr2=new XMLHttpRequest();
xhr2.open('GET','http://127.0.0.1:'+port+'/ping?t='+Date.now(),false);
xhr2.timeout=1000;
xhr2.send();
if(xhr2.status===200&&xhr2.responseText==='pong'){
clearInterval(timer);
log('Port discovered: '+port+' (project='+keys[i]+')');
cb(port);
return;
}
}catch(e2){}
// Strategy 1: Try hardcoded port immediately
log('Trying hardcoded port '+HARDCODED_PORT+'...');
tryPing(HARDCODED_PORT,function(ok){
if(ok){log('Port discovered (hardcoded): '+HARDCODED_PORT);cb(HARDCODED_PORT);return;}
log('Hardcoded port failed, falling back to JSON + port scan...');
// Strategy 2: JSON file fallback + port scan
var attempts=0;
var timer=setInterval(function(){
attempts++;
if(attempts>60){clearInterval(timer);log('Port discovery timeout after 2min');return;}
// Try hardcoded port again (server may start late)
tryPing(HARDCODED_PORT,function(ok2){
if(ok2){clearInterval(timer);log('Port discovered (hardcoded retry): '+HARDCODED_PORT);cb(HARDCODED_PORT);return;}
});
// Try JSON file (may work in future Electron versions)
try{
var xhr=new XMLHttpRequest();
xhr.open('GET','./ag-bridge-ports.json?t='+Date.now(),false);
xhr.send();
if(xhr.status===200){
var ports=JSON.parse(xhr.responseText);
var keys=Object.keys(ports);
for(var i=0;i<keys.length;i++){
var port=ports[keys[i]];
if(port>0&&port<65536){
try{
var xhr2=new XMLHttpRequest();
xhr2.open('GET','http://127.0.0.1:'+port+'/ping?t='+Date.now(),false);
xhr2.timeout=1000;
xhr2.send();
if(xhr2.status===200&&xhr2.responseText==='pong'){
clearInterval(timer);
log('Port discovered (JSON): '+port+' (project='+keys[i]+')');
cb(port);
return;
}
}catch(e2){}
}
}
}
}
}catch(e){}
},2000);
}catch(e){}
},2000);
});
}
discoverPort(function(port){