203 lines
5.6 KiB
JavaScript
Executable File
203 lines
5.6 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
||
|
||
import { spawn } from "node:child_process";
|
||
|
||
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 parsePayload(raw) {
|
||
try {
|
||
const parsed = JSON.parse(raw || "{}");
|
||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||
throw new Error("expected object");
|
||
}
|
||
return parsed;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function envString(name) {
|
||
return String(process.env[name] || "").trim();
|
||
}
|
||
|
||
function envBoolean(name) {
|
||
return envString(name).toLowerCase() === "true";
|
||
}
|
||
|
||
function detectTargetApp(objective) {
|
||
const text = String(objective || "").toLowerCase();
|
||
const candidates = [
|
||
["Chrome", ["chrome", "谷歌浏览器"]],
|
||
["Safari", ["safari"]],
|
||
["Finder", ["finder", "访达"]],
|
||
["System Settings", ["system settings", "系统设置", "设置"]],
|
||
["QQ", ["qq"]],
|
||
["WeChat", ["wechat", "微信"]],
|
||
["Telegram", ["telegram"]],
|
||
];
|
||
for (const [name, aliases] of candidates) {
|
||
if (aliases.some((alias) => text.includes(alias.toLowerCase()))) {
|
||
return name;
|
||
}
|
||
}
|
||
return "Finder";
|
||
}
|
||
|
||
function extractQuotedText(objective) {
|
||
const text = String(objective || "");
|
||
const patterns = [
|
||
/[“"]([^“”"]+)[”"]/,
|
||
/[「『]([^」』]+)[」』]/,
|
||
/输入[::]\s*([^\n。;;]+)/,
|
||
/打字[::]\s*([^\n。;;]+)/,
|
||
];
|
||
for (const pattern of patterns) {
|
||
const match = text.match(pattern);
|
||
const value = match?.[1]?.trim();
|
||
if (value) return value;
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
function shouldSubmitAfterTyping(objective) {
|
||
const text = String(objective || "").toLowerCase();
|
||
return text.includes("发送") || text.includes("提交") || text.includes("回车") || text.includes("enter");
|
||
}
|
||
|
||
function escapeAppleScriptString(value) {
|
||
return String(value || "").replaceAll("\\", "\\\\").replaceAll('"', '\\"');
|
||
}
|
||
|
||
function buildAppleScript(targetApp, objective) {
|
||
const lines = [
|
||
`tell application "${escapeAppleScriptString(targetApp)}"`,
|
||
"activate",
|
||
"end tell",
|
||
];
|
||
const typedText = extractQuotedText(objective);
|
||
if (typedText) {
|
||
lines.push("delay 0.2");
|
||
lines.push('tell application "System Events"');
|
||
lines.push(`keystroke "${escapeAppleScriptString(typedText)}"`);
|
||
if (shouldSubmitAfterTyping(objective)) {
|
||
lines.push("key code 36");
|
||
}
|
||
lines.push("end tell");
|
||
}
|
||
return lines.join("\n");
|
||
}
|
||
|
||
function runCommand(command, args, env = {}) {
|
||
return new Promise((resolve, reject) => {
|
||
const child = spawn(command, args, {
|
||
env: {
|
||
...process.env,
|
||
...env,
|
||
},
|
||
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() || `ssh computer use exited with ${code}`));
|
||
return;
|
||
}
|
||
resolve({ stdout: stdout.trim(), stderr: stderr.trim() });
|
||
});
|
||
});
|
||
}
|
||
|
||
async function runRemoteAppleScript(script) {
|
||
const host = envString("BOSS_SSH_CONTROL_HOST");
|
||
const user = envString("BOSS_SSH_CONTROL_USER");
|
||
const port = envString("BOSS_SSH_CONTROL_PORT") || "22";
|
||
const password = envString("BOSS_SSH_CONTROL_PASSWORD");
|
||
if (!host) throw new Error("SSH_CONTROL_HOST_REQUIRED");
|
||
if (!user) throw new Error("SSH_CONTROL_USER_REQUIRED");
|
||
|
||
const sshTarget = `${user}@${host}`;
|
||
const encodedScript = Buffer.from(script, "utf8").toString("base64");
|
||
const remoteCommand = `printf '%s' '${encodedScript}' | base64 -D | osascript`;
|
||
const sshArgs = [
|
||
"-o",
|
||
"StrictHostKeyChecking=no",
|
||
"-o",
|
||
"ConnectTimeout=8",
|
||
"-o",
|
||
"PreferredAuthentications=password",
|
||
"-o",
|
||
"PubkeyAuthentication=no",
|
||
"-o",
|
||
"NumberOfPasswordPrompts=1",
|
||
"-p",
|
||
port,
|
||
sshTarget,
|
||
remoteCommand,
|
||
];
|
||
if (password) {
|
||
await runCommand("sshpass", ["-e", "ssh", ...sshArgs], { SSHPASS: password });
|
||
return;
|
||
}
|
||
await runCommand("ssh", sshArgs);
|
||
}
|
||
|
||
const payload = parsePayload(await readStdin());
|
||
if (!payload) {
|
||
writeJson({ status: "failed", error: "INVALID_SSH_COMPUTER_USE_PAYLOAD" });
|
||
process.exit(0);
|
||
}
|
||
|
||
const requestId = typeof payload.requestId === "string" ? payload.requestId : undefined;
|
||
const objective = typeof payload.objective === "string" && payload.objective.trim()
|
||
? payload.objective.trim()
|
||
: "远程桌面控制 smoke 链路测试";
|
||
const targetApp = detectTargetApp(objective);
|
||
const typedText = extractQuotedText(objective);
|
||
|
||
try {
|
||
if (!envString("BOSS_SSH_CONTROL_HOST")) {
|
||
throw new Error("SSH_CONTROL_HOST_REQUIRED");
|
||
}
|
||
if (!envString("BOSS_SSH_CONTROL_USER")) {
|
||
throw new Error("SSH_CONTROL_USER_REQUIRED");
|
||
}
|
||
const appleScript = buildAppleScript(targetApp, objective);
|
||
if (!envBoolean("BOSS_SSH_CONTROL_DRY_RUN")) {
|
||
await runRemoteAppleScript(appleScript);
|
||
}
|
||
writeJson({
|
||
status: "completed",
|
||
requestId,
|
||
replyBody: `SSH 桌面控制已完成:${objective}`,
|
||
executionSummary: `ssh osascript ${envBoolean("BOSS_SSH_CONTROL_DRY_RUN") ? "dry-run" : "executed"} (${targetApp})`,
|
||
targetApp,
|
||
typedText,
|
||
});
|
||
} catch (error) {
|
||
writeJson({
|
||
status: "failed",
|
||
requestId,
|
||
error: error instanceof Error ? error.message : "SSH_COMPUTER_USE_FAILED",
|
||
});
|
||
}
|