Files
pi-firefox-bidi/extensions/firefox-bidi.ts
T

572 lines
20 KiB
TypeScript

/**
* firefox-bidi.ts — Estensione pi: controllo bidirezionale di Firefox
* via WebDriver BiDi, con screenshot e delegazione verso Antigravity CLI.
*
* Installazione (solo se autorizzata):
* cp firefox-bidi.ts ~/.pi/agent/extensions/ # poi /reload in pi
*
* Architettura:
* pi (questa estensione) ──HTTP JSON──► Browser Bridge (python)
* └──WebSocket BiDi──► Firefox (ws://127.0.0.1:9222)
*
* Il Bridge mantiene la sessione BiDi (id→Future, event loop) ed espone
* API HTTP semplici; questa estensione registra tool pi che le chiamano.
* La parte browser del bridge è in bridge/bridge.py (vedi DESIGN.md).
*
* Gestione errori (v0.2):
* - timeout su ogni chiamata HTTP (AbortController)
* - retry con backoff esponenziale per errori transitori (ECONNREFUSED, 5xx, timeout)
* - riavvio automatico di Firefox su sessione BiDi orfana
* ("Maximum number of active sessions" dopo un kill senza session.end)
* - chiusura pulita della sessione in browser_stop (evita sessioni orfane)
* - errori tipizzati (BridgeError) e logging con prefisso [firefox-bidi]
*
* Zero dipendenze npm: si usa solo fetch di Node 22 + child_process.
*/
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
import { spawn, execFile } from "node:child_process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
const BRIDGE_URL = process.env.FIREFOX_BIDI_BRIDGE ?? "http://127.0.0.1:8787";
const BRIDGE_TOKEN = process.env.FIREFOX_BIDI_TOKEN ?? "firefox-bidi-local";
const SCRIPT =
process.env.FIREFOX_BIDI_SCRIPT ?? "/home/enne2/Dev/firefox-bidi/start-firefox-bidi.sh";
const BRIDGE_PY =
process.env.FIREFOX_BIDI_BRIDGE_PY ?? "/home/enne2/Dev/firefox-bidi/bridge/bridge.py";
const DEFAULT_TIMEOUT_MS = 120_000; // comandi BiDi lunghi (navigate wait:"complete")
const STATUS_TIMEOUT_MS = 5_000;
const EVAL_TIMEOUT_MS = 30_000; // fail-fast per operazioni veloci
const SCREENSHOT_TIMEOUT_MS = 30_000;
const EVENTS_TIMEOUT_MS = 10_000;
const SEND_TIMEOUT_MS = 60_000;
const MAX_BRIDGE_CYCLES = 2; // cicli di riavvio bridge+firefox su sessione orfana
let bridgeProcess: ReturnType<typeof spawn> | null = null;
const log = (...a: unknown[]) => console.log("[firefox-bidi]", ...a);
// ---------------------------------------------------------------------------
// Trasporto HTTP con timeout, errori tipizzati, retry
// ---------------------------------------------------------------------------
class BridgeError extends Error {
constructor(
public status: number,
path: string,
body: unknown,
) {
super(`bridge ${path}: HTTP ${status} ${JSON.stringify(body).slice(0, 300)}`);
this.name = "BridgeError";
}
}
async function fetchWithTimeout(
url: string,
init: RequestInit,
timeoutMs: number,
): Promise<Response> {
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), timeoutMs);
try {
return await fetch(url, { ...init, signal: ac.signal });
} catch (err) {
if (ac.signal.aborted) {
throw new Error(
`timeout dopo ${timeoutMs}ms: ${url} non ha risposto (bridge bloccato o Firefox non risponde). ` +
`Verifica lo stato con browser_status.`,
);
}
throw err;
} finally {
clearTimeout(timer);
}
}
async function bridge(
path: string,
init?: RequestInit,
timeoutMs: number = DEFAULT_TIMEOUT_MS,
): Promise<any> {
let res: Response;
try {
res = await fetchWithTimeout(
`${BRIDGE_URL}${path}`,
{
headers: {
"content-type": "application/json",
authorization: `Bearer ${BRIDGE_TOKEN}`,
},
...init,
},
timeoutMs,
);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
throw new Error(
`bridge non raggiungibile (${BRIDGE_URL}): ${msg}. ` +
`Avvia il bridge con browser_start prima di usare i comandi browser_*.`,
);
}
const body = await res.json().catch(() => ({}));
if (!res.ok) throw new BridgeError(res.status, path, body);
return body;
}
function isTransient(err: unknown): boolean {
if (err instanceof BridgeError) return err.status >= 500;
const msg = err instanceof Error ? err.message : String(err);
// NOTA: "session not created" NON è transitorio: richiede il riavvio
// della sessione (gestito da withSessionRecovery), non un semplice retry.
return /ECONNREFUSED|fetch failed|abort|timeout|socket hang up/i.test(msg);
}
function isSessionError(err: unknown): boolean {
const msg = err instanceof Error ? err.message : String(err);
return /session not created|sessione BiDi chiusa|session\.end/i.test(msg);
}
async function withRetry<T>(fn: () => Promise<T>, label: string, retries = 2): Promise<T> {
let lastErr: unknown;
for (let attempt = 0; attempt <= retries; attempt++) {
try {
return await fn();
} catch (err) {
lastErr = err;
if (!isTransient(err) || attempt === retries) break;
// backoff esponenziale con jitter (±50%) per evitare thundering herd
const base = 500 * 2 ** attempt;
const delay = Math.round(base * (0.5 + Math.random() * 0.5));
log(`${label}: tentativo ${attempt + 1} fallito (${(err as Error).message}), retry tra ${delay}ms`);
await new Promise((r) => setTimeout(r, delay));
}
}
throw new Error(
`${label} fallito dopo ${retries + 1} tentativi: ${(lastErr as Error).message}`,
);
}
// ---------------------------------------------------------------------------
// Ciclo di vita: Firefox + bridge, con recupero da sessione orfana
// ---------------------------------------------------------------------------
async function bridgeAlive(): Promise<boolean> {
try {
await bridge("/status", undefined, STATUS_TIMEOUT_MS);
return true;
} catch {
return false;
}
}
async function startFirefox(profile: "dedicated" | "main"): Promise<void> {
const cmd = profile === "main" ? "start-main" : "start";
await execFileAsync(SCRIPT, [cmd], { timeout: 90_000 });
}
function startBridgeProcess(): void {
bridgeProcess = spawn("python3", [BRIDGE_PY], { stdio: "ignore", detached: true });
bridgeProcess.unref();
}
async function waitBridgeReady(timeoutMs = 15_000): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await bridgeAlive()) return true;
await new Promise((r) => setTimeout(r, 300));
}
return false;
}
/**
* Garantisce bridge attivo. Se non risponde: avvia Firefox (profilo scelto) +
* bridge. Se il bridge parte ma muore (es. sessione BiDi orfana dopo un kill
* senza session.end), riavvia Firefox e riprova fino a MAX_BRIDGE_CYCLES.
* Con forceRestart=true riavvia comunque (usato dopo errori di sessione).
*/
async function ensureBridge(
profile: "dedicated" | "main" = "dedicated",
opts: { forceRestart?: boolean } = {},
): Promise<void> {
if (!opts.forceRestart && (await bridgeAlive())) return;
for (let cycle = 0; cycle <= MAX_BRIDGE_CYCLES; cycle++) {
if (cycle > 0 || opts.forceRestart) {
log(`ciclo ${cycle}: riavvio Firefox (possibile sessione BiDi orfana)`);
await execFileAsync(SCRIPT, ["stop"]).catch(() => {});
await new Promise((r) => setTimeout(r, 1_500));
}
await startFirefox(profile);
startBridgeProcess();
if (await waitBridgeReady()) return;
log(`ciclo ${cycle}: bridge non pronto, kill processo`);
bridgeProcess?.kill("SIGKILL");
bridgeProcess = null;
}
throw new Error(
"Bridge BiDi non raggiungibile dopo riavvii. Verifica: Firefox attivo? Porta 9222 libera? " +
"Se persiste, chiudi Firefox manualmente e riprova (sessione BiDi orfana).",
);
}
/**
* Esegue fn; se fallisce per sessione BiDi non valida (es. "session not
* created", "sessione BiDi chiusa"), riavvia Firefox+bridge e riprova una
* volta. Evita di lasciare l'utente con errori criptici dopo un crash.
*/
async function withSessionRecovery<T>(fn: () => Promise<T>, label: string): Promise<T> {
try {
return await fn();
} catch (err) {
if (!isSessionError(err)) throw err;
log(`${label}: sessione BiDi non valida (${(err as Error).message}), riavvio Firefox+bridge`);
await ensureBridge("dedicated", { forceRestart: true });
return await fn();
}
}
export default function (pi: ExtensionAPI) {
// -------------------------------------------------------------------------
// Ciclo di vita del browser
// -------------------------------------------------------------------------
pi.registerTool({
name: "browser_start",
label: "Browser Start",
description:
"Avvia Firefox con WebDriver BiDi (porta 9222) e il Browser Bridge. " +
"profile=main usa il profilo principale (login/cookie intatti, chiude l'istanza attiva); " +
"profile=dedicated (default) usa il profilo di automazione bidi-profile.",
parameters: Type.Object({
profile: Type.Optional(
Type.Enum(
{ dedicated: "dedicated", main: "main" },
{ description: "Profilo Firefox: dedicated (automazione) o main (login reali)", default: "dedicated" },
),
),
}),
async execute(_id, params) {
await ensureBridge(params.profile ?? "dedicated");
const st = await bridge("/status");
return {
content: [{ type: "text", text: `Firefox BiDi attivo: ${JSON.stringify(st)}` }],
details: st,
};
},
});
pi.registerTool({
name: "browser_stop",
label: "Browser Stop",
description:
"Termina l'istanza Firefox BiDi e il bridge. Chiude prima la sessione BiDi " +
"(session.end) per evitare sessioni orfane sul Remote Agent.",
parameters: Type.Object({}),
async execute() {
// 1) chiusura pulita della sessione BiDi (evita "Maximum number of active sessions")
try {
await bridge("/session/close", { method: "POST" }, 5_000);
} catch {
/* bridge già giù o sessione assente: ok */
}
// 2) SIGINT al bridge → il finally di bridge.py esegue close_session
if (bridgeProcess) {
bridgeProcess.kill("SIGINT");
bridgeProcess = null;
}
// 3) stop Firefox
await execFileAsync(SCRIPT, ["stop"]).catch(() => {});
return {
content: [{ type: "text", text: "Firefox BiDi fermato (sessione chiusa)." }],
details: {},
};
},
});
pi.registerTool({
name: "browser_status",
label: "Browser Status",
description: "Stato di Firefox BiDi e del bridge (sessioni, contesti aperti).",
parameters: Type.Object({}),
async execute() {
const st = await bridge("/status");
return { content: [{ type: "text", text: JSON.stringify(st, null, 2) }], details: st };
},
});
// -------------------------------------------------------------------------
// Navigazione e comandi BiDi
// -------------------------------------------------------------------------
pi.registerTool({
name: "browser_navigate",
label: "Browser Navigate",
description: "Naviga il contesto principale verso un URL e attende il carico.",
parameters: Type.Object({
url: Type.String({ description: "URL completo (es. https://example.com)" }),
}),
async execute(_id, params) {
await ensureBridge();
const r = await withSessionRecovery(
() =>
withRetry(
() =>
bridge(
"/navigate",
{
method: "POST",
body: JSON.stringify({ url: params.url }),
},
DEFAULT_TIMEOUT_MS,
),
"navigate",
),
"navigate",
);
return {
content: [{ type: "text", text: `Navigazione OK: ${r.url} (context ${r.context})` }],
details: r,
};
},
});
pi.registerTool({
name: "browser_screenshot",
label: "Browser Screenshot",
description:
"Cattura uno screenshot (PNG) della pagina corrente e lo salva su disco. " +
"Il percorso viene restituito e l'immagine è allegata al contesto.",
parameters: Type.Object({
path: Type.Optional(
Type.String({ description: "Percorso di salvataggio (default: /tmp/firefox-bidi/shot-<ts>.png)" }),
),
fullPage: Type.Optional(Type.Boolean({ description: "Screenshot dell'intera pagina" })),
}),
async execute(_id, params) {
await ensureBridge();
const r = await withSessionRecovery(
() =>
withRetry(
() =>
bridge(
"/screenshot",
{
method: "POST",
body: JSON.stringify({ out: params.path, fullPage: params.fullPage ?? false }),
},
SCREENSHOT_TIMEOUT_MS,
),
"screenshot",
),
"screenshot",
);
return {
content: [
{ type: "text", text: `Screenshot salvato in ${r.path} (${r.bytes} bytes)` },
{ type: "image", image: r.path, mediaType: "image/png" },
],
details: r,
};
},
});
pi.registerTool({
name: "browser_eval",
label: "Browser Eval",
description:
"Esegue JavaScript nella pagina (script.evaluate BiDi). Restituisce il valore JSON.",
parameters: Type.Object({
expression: Type.String({ description: "Espressione JS da valutare" }),
awaitPromise: Type.Optional(Type.Boolean({ default: true })),
}),
async execute(_id, params) {
await ensureBridge();
const r = await withSessionRecovery(
() =>
withRetry(
() =>
bridge(
"/eval",
{
method: "POST",
body: JSON.stringify(params),
},
EVAL_TIMEOUT_MS,
),
"eval",
),
"eval",
);
return {
content: [{ type: "text", text: `Result: ${JSON.stringify(r.result ?? r, null, 2)}` }],
details: r,
};
},
});
pi.registerTool({
name: "browser_send",
label: "Browser BiDi Send",
description:
"Invia un comando WebDriver BiDi grezzo (power users), es. browsingContext.captureScreenshot, " +
"session.subscribe, network.getCookies, log.read.",
parameters: Type.Object({
method: Type.String({ description: "Nome metodo BiDi, es. browsingContext.captureScreenshot" }),
params: Type.Optional(Type.Record(Type.String(), Type.Any(), { default: {} })),
}),
async execute(_id, params) {
await ensureBridge();
const r = await withSessionRecovery(
() =>
bridge(
"/cmd",
{
method: "POST",
body: JSON.stringify(params),
},
SEND_TIMEOUT_MS,
),
"browser_send",
);
return {
content: [{ type: "text", text: JSON.stringify(r, null, 2) }],
details: r,
};
},
});
// -------------------------------------------------------------------------
// Eventi asincroni (push browser → pi)
// -------------------------------------------------------------------------
pi.registerTool({
name: "browser_events",
label: "Browser Events",
description:
"Legge gli eventi BiDi accumulati dal bridge (log.entryAdded, network.responseCompleted, " +
"browsingContext.*, script.*). Opzionalmente sottoscrive un modulo prima.",
parameters: Type.Object({
action: Type.Enum(
{ list: "list", subscribe: "subscribe", drain: "drain" },
{ description: "list=eventi in coda, subscribe=iscrivi un modulo, drain=svuota la coda" },
),
module: Type.Optional(
Type.String({ description: "Modulo BiDi da sottoscrivere, es. log, network, browsingContext" }),
),
clear: Type.Optional(Type.Boolean({ default: false })),
}),
async execute(_id, params) {
await ensureBridge();
const r = await bridge(
"/events",
{
method: "POST",
body: JSON.stringify(params),
},
EVENTS_TIMEOUT_MS,
);
return {
content: [{ type: "text", text: JSON.stringify(r, null, 2) }],
details: r,
};
},
});
// -------------------------------------------------------------------------
// Delegazione asincrona a Antigravity CLI (pi → agy)
// -------------------------------------------------------------------------
pi.registerTool({
name: "agy_delegate",
label: "Antigravity Delegate",
description:
"Delega un task a Antigravity CLI in modalità headless (stream-json NDJSON). " +
"Usa --conversation per continuare una conversazione agy esistente. " +
"L'output (init/step_update/result) viene restituito come testo.",
parameters: Type.Object({
prompt: Type.String({ description: "Task per l'agente agy" }),
conversation: Type.Optional(Type.String({ description: "ID conversazione agy da riprendere" })),
model: Type.Optional(Type.String({ description: "Modello agy (es. Gemini 3.1 Pro)" })),
timeout: Type.Optional(Type.Number({ default: 300, description: "Timeout secondi" })),
}),
async execute(_id, params) {
const args = [
"-p", params.prompt,
"--output-format", "stream-json",
"--print-timeout", `${params.timeout ?? 300}m`,
];
if (params.conversation) args.push("--conversation", params.conversation);
if (params.model) args.push("--model", params.model);
let stdout: string, stderr: string;
try {
({ stdout, stderr } = await execFileAsync("agy", args, {
timeout: ((params.timeout ?? 300) + 30) * 1000,
maxBuffer: 20 * 1024 * 1024,
}));
} catch (err) {
const e = err as NodeJS.ErrnoException & { stderr?: string };
if (e.code === "ENOENT") throw new Error("agy non trovato in PATH (Antigravity CLI non installato?)");
throw new Error(`agy fallito (exit ${e.code ?? "?"}): ${(e.stderr || e.message || "").slice(0, 500)}`);
}
// NDJSON → riepilogo compatto per il contesto pi
const lines = stdout
.trim()
.split("\n")
.filter(Boolean)
.map((l) => {
try {
return JSON.parse(l);
} catch {
return null;
}
});
const init = lines.find((e) => e?.type === "init");
const result = lines.find((e) => e?.type === "result");
const summary = {
conversation_id: init?.conversation_id ?? result?.conversation_id ?? params.conversation,
status: result?.status,
response: result?.response,
duration: result?.duration,
stderr: stderr.slice(0, 500),
};
return {
content: [
{
type: "text",
text: `agy → ${summary.status ?? "?"} | conversation=${summary.conversation_id}\n${summary.response ?? ""}${summary.stderr ? `\n[stderr] ${summary.stderr}` : ""}`,
},
],
details: summary,
};
},
});
// -------------------------------------------------------------------------
// Notifica asincrona (agy/bridge → pi): webhook locale
// -------------------------------------------------------------------------
pi.registerTool({
name: "bridge_notify",
label: "Bridge Notify (webhook)",
description:
"Invia un evento al webhook locale di pi (http://127.0.0.1:8790/webhook): " +
"usato da agy o dal bridge per segnalare task completi, errori o eventi browser.",
parameters: Type.Object({
event: Type.String({ description: "Nome evento, es. task.completed, browser.event" }),
payload: Type.Optional(Type.Record(Type.String(), Type.Any(), { default: {} })),
}),
async execute(_id, params) {
await fetchWithTimeout(
"http://127.0.0.1:8790/webhook",
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ event: params.event, payload: params.payload ?? {} }),
},
5_000,
).catch((e) => {
throw new Error(`webhook: ${e.message}`);
});
return {
content: [{ type: "text", text: "Evento inoltrato al webhook pi." }],
details: {},
};
},
});
}