254 lines
7.3 KiB
JavaScript
254 lines
7.3 KiB
JavaScript
#!/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 match = String(objective || "").match(/https?:\/\/[^\s,。;、))]+/i);
|
||
return match?.[0] || undefined;
|
||
}
|
||
|
||
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 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 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);
|
||
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;
|
||
const automatedTitle =
|
||
targetUrl && !dryRun && automationMode === "playwright"
|
||
? await runBrowserAutomation(targetUrl, currentRequestId)
|
||
: undefined;
|
||
const pageTitle =
|
||
automatedTitle ||
|
||
(automationMode !== "off" ? await inspectPageTitle(targetUrl) : undefined);
|
||
if (targetUrl && !dryRun) {
|
||
if (automationMode === "playwright" && automatedTitle) {
|
||
action = "browser_automation_executed";
|
||
} else {
|
||
const command = String(process.env.BOSS_BROWSER_OPEN_COMMAND || "").trim() || "open";
|
||
const prefixArgs =
|
||
parseArgsJson(process.env.BOSS_BROWSER_OPEN_ARGS_JSON) ??
|
||
parseArgs(process.env.BOSS_BROWSER_OPEN_ARGS);
|
||
await runCommand(command, [...prefixArgs, targetUrl]);
|
||
action = "open_url_executed";
|
||
}
|
||
}
|
||
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})`
|
||
: `${action} completed (risk=${riskLevel})`,
|
||
targetUrl,
|
||
artifacts,
|
||
});
|