import { mkdir, writeFile } from "node:fs/promises";
import { isIP } from "node:net";
import { pathToFileURL } from "node:url";
import { RetryAfterOutOfRange, RetryDeferred, retryDelayMs, waitBeforeRetry } from "./retry_after.mjs";
export const MAX_ATTEMPTS = 2;
export const MAX_WAIT_MS = 10_000;
const TRANSIENT_STATUSES = new Set([429, 500, 502, 503, 504]);
class TaskFailed extends Error {
constructor(reason) { super(reason); this.reason = reason; }
}
function redactIp(value) {
if (isIP(value) === 4) return value.replace(/\.\d+$/, ".xxx");
if (isIP(value) === 6) return `${value.split(":").slice(0, 4).join(":")}::`;
throw new TaskFailed("invalid_exit_ip");
}
export async function navigate(page, url, state) {
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt += 1) {
let response;
try {
response = await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30_000 });
} catch (error) {
if (error.name !== "TimeoutError") throw new TaskFailed("navigation_failed");
if (attempt === MAX_ATTEMPTS - 1) throw new TaskFailed("timeout_attempts_exhausted");
await waitBeforeRetry(2 ** attempt * 1_000, state.budget,
(delay) => page.waitForTimeout(delay));
state.retries += 1;
continue;
}
state.status = response?.status() ?? 0;
if (state.status === 407) throw new TaskFailed("proxy_authentication_failed");
if (TRANSIENT_STATUSES.has(state.status)) {
const delay = retryDelayMs(response.headers()["retry-after"], 2 ** attempt * 1_000);
if (attempt === MAX_ATTEMPTS - 1) throw new RetryDeferred(delay, "maximum_attempts_reached");
await waitBeforeRetry(delay, state.budget, (ms) => page.waitForTimeout(ms));
state.retries += 1;
continue;
}
if (state.status < 200 || state.status >= 400) {
throw new TaskFailed(`http_status_${state.status}`);
}
return response;
}
throw new TaskFailed("maximum_attempts_reached");
}
export async function runAgent({ chromium, env = process.env, log = (record) => console.log(JSON.stringify(record)) } = {}) {
const startedAt = performance.now();
const state = { status: 0, retries: 0, steps: 0, budget: { remainingMs: MAX_WAIT_MS } };
let browser;
let context;
let exitCode = 1;
try {
for (const name of ["PROXY_SERVER", "PROXY_USERNAME", "PROXY_PASSWORD", "TARGET_URL"]) {
if (!env[name]) throw new TaskFailed(`missing_${name}`);
}
const proxy = new URL(env.PROXY_SERVER);
if (!["http:", "https:"].includes(proxy.protocol) || proxy.username || proxy.password) {
throw new TaskFailed("HTTP_proxy_and_separate_credentials_required");
}
const target = new URL(env.TARGET_URL);
const allowedHosts = new Set((env.ALLOWED_HOSTS || "example.com").split(","));
allowedHosts.add("api.ipify.org");
const allowed = (url) => url.protocol === "https:" && allowedHosts.has(url.hostname)
&& ["", "443"].includes(url.port) && !url.username && !url.password;
if (!allowed(target)) throw new TaskFailed("target_not_allowlisted");
chromium ??= (await import("playwright")).chromium;
browser = await chromium.launch({ headless: true, timeout: 30_000, proxy: {
server: env.PROXY_SERVER, username: env.PROXY_USERNAME, password: env.PROXY_PASSWORD,
} });
context = await browser.newContext({ serviceWorkers: "block" });
await context.route("**/*", async (route) => {
let permitted = false;
try { permitted = allowed(new URL(route.request().url())); } catch {}
if (permitted) await route.continue(); else await route.abort();
});
const page = await context.newPage();
page.setDefaultTimeout(15_000);
const readExitIp = async () => {
const response = await navigate(page, "https://api.ipify.org?format=json", state);
const value = String((await response.json()).ip ?? "");
redactIp(value);
state.steps += 1;
return value;
};
const firstExitIp = await readExitIp();
const targetResponse = await navigate(page, target.href, state);
const targetStatus = targetResponse.status();
const result = {
title: await page.title(),
heading: (await page.locator("h1").first().textContent() ?? "").trim(),
};
if (!result.title || !result.heading) throw new TaskFailed("required_fields_missing");
result.titleMatchesHeading = result.title === result.heading;
state.steps += 1;
const secondExitIp = await readExitIp();
const sameExitAcrossTask = firstExitIp === secondExitIp;
const outputDir = env.OUTPUT_DIR || "output";
await mkdir(outputDir, { recursive: true });
await writeFile(`${outputDir}/agent-result.json`, JSON.stringify(result, null, 2), "utf8");
log({ outcome: sameExitAcrossTask ? "success" : "failed", status: targetStatus,
steps: state.steps, retries: state.retries,
durationMs: Math.round(performance.now() - startedAt), exitIp: redactIp(firstExitIp),
sameExitAcrossTask, titleMatchesHeading: result.titleMatchesHeading,
resultPath: `${outputDir}/agent-result.json` });
exitCode = sameExitAcrossTask ? 0 : 1;
} catch (error) {
const deferred = error instanceof RetryDeferred;
const reason = error instanceof TaskFailed ? error.reason
: error instanceof RetryAfterOutOfRange ? "retry_after_out_of_range" : "task_failed";
log({ outcome: deferred ? "deferred" : "failed", status: state.status,
steps: state.steps, retries: state.retries,
durationMs: Math.round(performance.now() - startedAt),
...(deferred ? { retry_at: error.retryAt, reason: error.reason } : { reason }) });
exitCode = deferred ? 2 : 1;
} finally {
await context?.close().catch(() => {});
await browser?.close().catch(() => {});
}
return exitCode;
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
process.exitCode = await runAgent();
}