v0.2: gestione errori robusta (timeout, retry, recupero sessione orfana, auth token, profile main)
This commit is contained in:
+220
-74
@@ -13,6 +13,14 @@
|
||||
* 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";
|
||||
@@ -23,46 +31,146 @@ 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 MAX_BRIDGE_CYCLES = 2; // cicli di riavvio bridge+firefox su sessione orfana
|
||||
|
||||
let bridgeProcess: ReturnType<typeof spawn> | null = null;
|
||||
|
||||
async function bridge(path: string, init?: RequestInit): Promise<any> {
|
||||
const res = await fetch(`${BRIDGE_URL}${path}`, {
|
||||
headers: { "content-type": "application/json" },
|
||||
...init,
|
||||
});
|
||||
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 });
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function bridge(
|
||||
path: string,
|
||||
init?: RequestInit,
|
||||
timeoutMs: number = DEFAULT_TIMEOUT_MS,
|
||||
): Promise<any> {
|
||||
const res = await fetchWithTimeout(
|
||||
`${BRIDGE_URL}${path}`,
|
||||
{
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
authorization: `Bearer ${BRIDGE_TOKEN}`,
|
||||
},
|
||||
...init,
|
||||
},
|
||||
timeoutMs,
|
||||
);
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(`bridge ${path}: ${res.status} ${JSON.stringify(body)}`);
|
||||
if (!res.ok) throw new BridgeError(res.status, path, body);
|
||||
return body;
|
||||
}
|
||||
|
||||
async function ensureBridge() {
|
||||
try {
|
||||
await bridge("/status");
|
||||
return;
|
||||
} catch {
|
||||
/* bridge non attivo: avvio */
|
||||
}
|
||||
await execFileAsync(SCRIPT, ["start"]);
|
||||
bridgeProcess = spawn("python3", [BRIDGE_PY], {
|
||||
stdio: "ignore",
|
||||
detached: true,
|
||||
});
|
||||
bridgeProcess.unref();
|
||||
// attende che il bridge risponda
|
||||
for (let i = 0; i < 40; i++) {
|
||||
function isTransient(err: unknown): boolean {
|
||||
if (err instanceof BridgeError) return err.status >= 500;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return /ECONNREFUSED|fetch failed|abort|timeout|socket hang up|session not created/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 {
|
||||
await bridge("/status");
|
||||
return;
|
||||
} catch {
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
if (!isTransient(err) || attempt === retries) break;
|
||||
const delay = 500 * 2 ** attempt;
|
||||
log(`${label}: tentativo ${attempt + 1} fallito (${(err as Error).message}), retry tra ${delay}ms`);
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
}
|
||||
}
|
||||
throw new Error("Bridge non raggiungibile dopo 10s");
|
||||
throw lastErr;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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.
|
||||
*/
|
||||
async function ensureBridge(profile: "dedicated" | "main" = "dedicated"): Promise<void> {
|
||||
if (await bridgeAlive()) return;
|
||||
for (let cycle = 0; cycle <= MAX_BRIDGE_CYCLES; cycle++) {
|
||||
if (cycle > 0) {
|
||||
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).",
|
||||
);
|
||||
}
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
@@ -73,16 +181,23 @@ export default function (pi: ExtensionAPI) {
|
||||
name: "browser_start",
|
||||
label: "Browser Start",
|
||||
description:
|
||||
"Avvia Firefox con WebDriver BiDi (profilo dedicato, porta 9222) e il Browser Bridge.",
|
||||
parameters: Type.Object({}),
|
||||
async execute() {
|
||||
await ensureBridge();
|
||||
"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: {},
|
||||
content: [{ type: "text", text: `Firefox BiDi attivo: ${JSON.stringify(st)}` }],
|
||||
details: st,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -90,16 +205,26 @@ export default function (pi: ExtensionAPI) {
|
||||
pi.registerTool({
|
||||
name: "browser_stop",
|
||||
label: "Browser Stop",
|
||||
description: "Termina l'istanza Firefox BiDi (e il bridge).",
|
||||
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();
|
||||
bridgeProcess.kill("SIGINT");
|
||||
bridgeProcess = null;
|
||||
}
|
||||
// 3) stop Firefox
|
||||
await execFileAsync(SCRIPT, ["stop"]).catch(() => {});
|
||||
return {
|
||||
content: [{ type: "text", text: "Firefox BiDi fermato." }],
|
||||
content: [{ type: "text", text: "Firefox BiDi fermato (sessione chiusa)." }],
|
||||
details: {},
|
||||
};
|
||||
},
|
||||
@@ -112,7 +237,7 @@ export default function (pi: ExtensionAPI) {
|
||||
parameters: Type.Object({}),
|
||||
async execute() {
|
||||
const st = await bridge("/status");
|
||||
return { content: [{ type: "text", text: JSON.stringify(st, null, 2) }], details: {} };
|
||||
return { content: [{ type: "text", text: JSON.stringify(st, null, 2) }], details: st };
|
||||
},
|
||||
});
|
||||
|
||||
@@ -128,17 +253,16 @@ export default function (pi: ExtensionAPI) {
|
||||
}),
|
||||
async execute(_id, params) {
|
||||
await ensureBridge();
|
||||
const r = await bridge("/navigate", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ url: params.url }),
|
||||
});
|
||||
const r = await withRetry(
|
||||
() =>
|
||||
bridge("/navigate", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ url: params.url }),
|
||||
}),
|
||||
"navigate",
|
||||
);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Navigazione OK: ${r.url} (context ${r.context})`,
|
||||
},
|
||||
],
|
||||
content: [{ type: "text", text: `Navigazione OK: ${r.url} (context ${r.context})` }],
|
||||
details: r,
|
||||
};
|
||||
},
|
||||
@@ -158,10 +282,14 @@ export default function (pi: ExtensionAPI) {
|
||||
}),
|
||||
async execute(_id, params) {
|
||||
await ensureBridge();
|
||||
const r = await bridge("/screenshot", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ out: params.path, fullPage: params.fullPage ?? false }),
|
||||
});
|
||||
const r = await withRetry(
|
||||
() =>
|
||||
bridge("/screenshot", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ out: params.path, fullPage: params.fullPage ?? false }),
|
||||
}),
|
||||
"screenshot",
|
||||
);
|
||||
return {
|
||||
content: [
|
||||
{ type: "text", text: `Screenshot salvato in ${r.path} (${r.bytes} bytes)` },
|
||||
@@ -183,17 +311,16 @@ export default function (pi: ExtensionAPI) {
|
||||
}),
|
||||
async execute(_id, params) {
|
||||
await ensureBridge();
|
||||
const r = await bridge("/eval", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
const r = await withRetry(
|
||||
() =>
|
||||
bridge("/eval", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(params),
|
||||
}),
|
||||
"eval",
|
||||
);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Result: ${JSON.stringify(r.result ?? r, null, 2)}`,
|
||||
},
|
||||
],
|
||||
content: [{ type: "text", text: `Result: ${JSON.stringify(r.result ?? r, null, 2)}` }],
|
||||
details: r,
|
||||
};
|
||||
},
|
||||
@@ -278,14 +405,29 @@ export default function (pi: ExtensionAPI) {
|
||||
];
|
||||
if (params.conversation) args.push("--conversation", params.conversation);
|
||||
if (params.model) args.push("--model", params.model);
|
||||
const { stdout, stderr } = await execFileAsync("agy", args, {
|
||||
timeout: ((params.timeout ?? 300) + 30) * 1000,
|
||||
maxBuffer: 20 * 1024 * 1024,
|
||||
});
|
||||
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 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 = {
|
||||
@@ -321,11 +463,15 @@ export default function (pi: ExtensionAPI) {
|
||||
payload: Type.Optional(Type.Record(Type.String(), Type.Any(), { default: {} })),
|
||||
}),
|
||||
async execute(_id, params) {
|
||||
await fetch("http://127.0.0.1:8790/webhook", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ event: params.event, payload: params.payload ?? {} }),
|
||||
}).catch((e) => {
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user