Files
boss/scripts/browser-control-smoke.mjs
2026-05-17 02:20:08 +08:00

357 lines
11 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
import { mkdir, writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { spawn } from "node:child_process";
import path from "node:path";
function writeJson(payload) {
process.stdout.write(`${JSON.stringify(payload)}\n`);
}
async function readStdin() {
const chunks = [];
for await (const chunk of process.stdin) {
chunks.push(typeof chunk === "string" ? chunk : chunk.toString("utf8"));
}
return chunks.join("").trim();
}
function normalizePayload(raw) {
try {
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return {
ok: false,
error: "INVALID_BROWSER_CONTROL_PAYLOAD: expected object",
};
}
return {
ok: true,
payload: parsed,
};
} catch {
return {
ok: false,
error: "INVALID_BROWSER_CONTROL_PAYLOAD: invalid json",
};
}
}
function extractTargetUrl(objective) {
const text = String(objective || "");
const fullUrl = text.match(/https?:\/\/[^\s、)]+/i)?.[0];
if (fullUrl) {
return fullUrl;
}
const bareDomain = text.match(
/(?:访问|打开|进入|看一下|visit|open)?\s*((?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}(?:\/[^\s、)]*)?)/i,
)?.[1];
return bareDomain ? `https://${bareDomain}` : undefined;
}
function cleanupSearchQuery(value) {
return String(value || "")
.replace(/打开\s*(?:youtube|油管)/gi, " ")
.replace(/(?:youtube|油管)/gi, " ")
.replace(/用浏览器打开/gi, " ")
.replace(/打开浏览器/gi, " ")
.replace(/找一个|找一下|搜索|搜一下|搜|播放/gi, " ")
.replace(/的\s*mv/gi, " MV")
.replace(/mv/gi, " MV")
.replace(/[,。;;、]/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function deriveYouTubeSearchUrl(objective) {
const text = String(objective || "").trim();
if (!/(youtube|油管)/i.test(text)) {
return undefined;
}
const queryPatterns = [
/(?:找一个|找一下|搜索|搜一下|搜|播放)\s*([^,。;;]+)/i,
/(?:youtube|油管)\s*([^,。;;]+)/i,
];
for (const pattern of queryPatterns) {
const query = cleanupSearchQuery(text.match(pattern)?.[1]);
if (query) {
return `https://www.youtube.com/results?search_query=${encodeURIComponent(query)}`;
}
}
const fallbackQuery = cleanupSearchQuery(text);
return fallbackQuery
? `https://www.youtube.com/results?search_query=${encodeURIComponent(fallbackQuery)}`
: "https://www.youtube.com";
}
async function writeArtifact(payload) {
const artifactDir = String(process.env.BOSS_CONTROL_ARTIFACT_DIR || "").trim();
if (!artifactDir) {
return [];
}
await mkdir(artifactDir, { recursive: true });
const requestId =
typeof payload.requestId === "string" && payload.requestId.trim()
? payload.requestId.trim()
: `browser-${Date.now()}`;
const artifactPath = path.join(artifactDir, `${requestId}.json`);
await writeFile(artifactPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
return [
{
kind: "json",
path: artifactPath,
},
];
}
function parseArgs(value) {
return String(value || "")
.trim()
.split(/\s+/)
.filter(Boolean);
}
function parseArgsJson(value) {
const raw = String(value || "").trim();
if (!raw) {
return undefined;
}
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed.map((item) => String(item)).filter(Boolean) : undefined;
} catch {
return undefined;
}
}
function detectRequestedBrowserApp(objective) {
const text = String(objective || "").toLowerCase();
if (text.includes("chrome") || text.includes("谷歌浏览器")) {
return "Google Chrome";
}
if (text.includes("safari")) {
return "Safari";
}
return undefined;
}
function resolveBrowserOpenArgs(command, objective) {
const configured =
parseArgsJson(process.env.BOSS_BROWSER_OPEN_ARGS_JSON) ??
parseArgs(process.env.BOSS_BROWSER_OPEN_ARGS);
if (configured.length > 0) {
return configured;
}
const requestedApp = detectRequestedBrowserApp(objective);
const commandName = path.basename(command || "").toLowerCase();
return requestedApp && commandName === "open" ? ["-a", requestedApp] : [];
}
function escapeAppleScriptString(value) {
return String(value || "").replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}
async function openTargetUrl(targetUrl, objective) {
const commandOverride = String(process.env.BOSS_BROWSER_OPEN_COMMAND || "").trim();
const command = commandOverride || "open";
const requestedApp = detectRequestedBrowserApp(objective);
if (!commandOverride && process.platform === "darwin" && requestedApp) {
const app = escapeAppleScriptString(requestedApp);
const url = escapeAppleScriptString(targetUrl);
const script = [
`tell application "${app}" to activate`,
`tell application "${app}" to open location "${url}"`,
].join("\n");
await runCommand("osascript", ["-e", script]);
return "osascript_open_url_executed";
}
const prefixArgs = resolveBrowserOpenArgs(command, objective);
await runCommand(command, [...prefixArgs, targetUrl]);
return "open_url_executed";
}
function getBrowserAutomationMode() {
const raw = String(process.env.BOSS_BROWSER_AUTOMATION_MODE || "").trim().toLowerCase();
if (raw === "off" || raw === "fetch" || raw === "playwright" || raw === "auto") {
return raw;
}
return "auto";
}
function shouldOpenVisibleBrowserAfterAutomation() {
const raw = String(process.env.BOSS_BROWSER_VISIBLE_OPEN_AFTER_AUTOMATION || "").trim().toLowerCase();
return !["0", "false", "off", "no"].includes(raw);
}
function resolveCodexHome() {
return String(process.env.CODEX_HOME || "").trim() || path.join(process.env.HOME || "", ".codex");
}
function resolveBundledPlaywrightCommand() {
const wrapper = path.join(resolveCodexHome(), "skills", "playwright", "scripts", "playwright_cli.sh");
return existsSync(wrapper) ? wrapper : undefined;
}
function resolveBrowserAutomationArgs(command, commandArgs, targetUrl, requestId) {
const args = [...commandArgs];
const session =
String(process.env.BOSS_BROWSER_AUTOMATION_SESSION || "").trim() ||
String(requestId || "").trim();
if (session) {
args.push("--session", session);
}
args.push(command, targetUrl);
return args;
}
async function runCommand(command, args) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk) => {
stdout += chunk;
});
child.stderr.on("data", (chunk) => {
stderr += chunk;
});
child.on("error", reject);
child.on("close", (code) => {
if (code !== 0) {
reject(new Error(stderr.trim() || `browser open exit code ${code}`));
return;
}
resolve({ stdout: stdout.trim(), stderr: stderr.trim() });
});
});
}
async function runBrowserAutomation(targetUrl, requestId) {
const command =
String(process.env.BOSS_BROWSER_AUTOMATION_COMMAND || "").trim() || resolveBundledPlaywrightCommand();
if (!command || !targetUrl) {
return undefined;
}
const prefixArgs =
parseArgsJson(process.env.BOSS_BROWSER_AUTOMATION_ARGS_JSON) ??
parseArgs(process.env.BOSS_BROWSER_AUTOMATION_ARGS);
await runCommand(command, resolveBrowserAutomationArgs("open", prefixArgs, targetUrl, requestId));
const title = await runCommand(
command,
resolveBrowserAutomationArgs("eval", prefixArgs, "document.title", requestId),
);
return title.stdout || undefined;
}
async function inspectPageTitle(targetUrl) {
if (!targetUrl) {
return undefined;
}
try {
const response = await fetch(targetUrl, {
redirect: "follow",
signal: AbortSignal.timeout(8000),
});
const contentType = response.headers.get("content-type") || "";
if (!contentType.includes("text/html")) {
return undefined;
}
const html = await response.text();
const title = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1]?.replace(/\s+/g, " ").trim();
return title || undefined;
} catch {
return undefined;
}
}
const raw = await readStdin();
const normalized = normalizePayload(raw);
if (!normalized.ok) {
writeJson({
status: "failed",
error: normalized.error,
});
process.exit(0);
}
const payload = normalized.payload;
const currentRequestId = typeof payload.requestId === "string" ? payload.requestId.trim() : "";
const objective =
typeof payload.objective === "string" && payload.objective.trim()
? payload.objective.trim()
: "浏览器控制 smoke 链路正常";
const targetUrl = extractTargetUrl(objective) || deriveYouTubeSearchUrl(objective);
const riskLevel =
typeof payload.context?.riskLevel === "string" && payload.context.riskLevel.trim()
? payload.context.riskLevel.trim()
: "unknown";
const dryRun = payload.context?.dryRun === true;
let action = targetUrl ? "open_url" : "browser_smoke";
const configuredAutomationMode = getBrowserAutomationMode();
const automationMode =
configuredAutomationMode === "auto"
? String(process.env.BOSS_BROWSER_AUTOMATION_COMMAND || "").trim() || resolveBundledPlaywrightCommand()
? "playwright"
: "fetch"
: configuredAutomationMode;
let automationError;
let automatedTitle;
if (targetUrl && !dryRun && automationMode === "playwright") {
try {
automatedTitle = await runBrowserAutomation(targetUrl, currentRequestId);
} catch (error) {
automationError = error instanceof Error ? error.message : String(error);
}
}
const pageTitle =
automatedTitle ||
(automationMode !== "off" ? await inspectPageTitle(targetUrl) : undefined);
if (targetUrl && !dryRun) {
if (automationMode === "playwright" && automatedTitle) {
action = "browser_automation_executed";
if (shouldOpenVisibleBrowserAfterAutomation()) {
const visibleAction = await openTargetUrl(targetUrl, objective);
action = `${action}+${visibleAction}`;
}
} else {
action = await openTargetUrl(targetUrl, objective);
}
}
const artifacts = await writeArtifact({
requestKind: payload.requestKind,
requestId: payload.requestId,
action,
objective,
targetUrl,
dryRun,
riskLevel,
capturedAt: new Date().toISOString(),
});
writeJson({
status: "completed",
requestId: typeof payload.requestId === "string" ? payload.requestId : undefined,
replyBody: pageTitle
? `浏览器控制已完成:${objective}。页面标题:${pageTitle}`
: `浏览器控制已完成:${objective}`,
executionSummary: pageTitle
? `${action} completed (risk=${riskLevel}, title=${pageTitle}${automationError ? ", automationFallback=true" : ""})`
: `${action} completed (risk=${riskLevel}${automationError ? ", automationFallback=true" : ""})`,
targetUrl,
artifacts,
});