src/browser.js
Rama main · réplica de lectura del repositorio original.
import { spawn } from "node:child_process";
import { basename, dirname, win32 } from "node:path";
import { fileURLToPath } from "node:url";
const HANDOFF_DIRECTORY_PATTERN = /^relmio-browser-[A-Za-z0-9_-]{6,64}$/u;
const HANDOFF_FILE_PATTERN = /^launch-[a-f0-9]{24}\.html$/u;
function windowsFilePath(url) {
const parsed = new URL(url);
let pathname;
try {
pathname = decodeURIComponent(parsed.pathname);
} catch {
throw new TypeError("Relmio browser handoff URL is invalid.");
}
if (/^\/[A-Za-z]:\//u.test(pathname)) pathname = pathname.slice(1);
return pathname.replaceAll("/", "\\");
}
function windowsSystemRoot(systemRoot) {
if (
typeof systemRoot !== "string" || !win32.isAbsolute(systemRoot) ||
/[\u0000-\u001F\u007F"<>|*?]/u.test(systemRoot)
) {
throw new TypeError("Relmio Windows system root is invalid.");
}
return win32.normalize(systemRoot);
}
function windowsExplorer(systemRoot) {
return win32.join(windowsSystemRoot(systemRoot), "explorer.exe");
}
// explorer.exe opens the handoff file. It does not open an https URL with ? and &.
// cmd.exe expands %NAME% in its command text, so %3A%2F in a URL would pick up a variable named 3A.
// The targets travel in variables read by delayed expansion (!NAME!), which runs after cmd has
// parsed the line; their %, & and ! are never interpreted.
export function windowsUrlLaunch(targets) {
if (!Array.isArray(targets) || targets.length < 1 || targets.length > 4) {
throw new TypeError("Relmio Windows browser target is invalid.");
}
const env = {};
const quoted = targets.map((target, index) => {
if (
typeof target !== "string" || target.length === 0 || target.length > 4096 ||
/[\u0000-\u001F\u007F"]/u.test(target)
) {
throw new TypeError("Relmio Windows browser target is invalid.");
}
env[`RELMIO_BROWSER_TARGET_${index}`] = target;
return `"!RELMIO_BROWSER_TARGET_${index}!"`;
});
return { args: ["/d", "/v:on", "/s", "/c", `start "" ${quoted.join(" ")}`], env };
}
export function isPrivateBrowserLaunchUrl(value) {
if (typeof value !== "string" || /[\u0000-\u001F\u007F]/u.test(value)) return false;
try {
const parsed = new URL(value);
if (
parsed.protocol !== "file:" || parsed.hostname !== "" || parsed.username ||
parsed.password || parsed.search || parsed.hash
) return false;
const path = fileURLToPath(parsed);
return HANDOFF_FILE_PATTERN.test(basename(path)) &&
HANDOFF_DIRECTORY_PATTERN.test(basename(dirname(path)));
} catch {
return false;
}
}
export function isOpenAiAuthorizationUrl(value) {
if (typeof value !== "string" || value.length > 4096 || /[\u0000-\u0020\u007F]/u.test(value)) return false;
try {
const url = new URL(value);
if (url.origin !== "https://auth.openai.com" ||
url.pathname !== "/api/accounts/authorize" || url.hash ||
url.username || url.password || url.href !== value) return false;
const allowed = new Set(["client_id", "agent_name_hint", "ext_agent_host_id",
"response_type", "redirect_uri", "scope", "resource", "state", "nonce",
"code_challenge_method", "code_challenge", "prompt"]);
const keys = [...url.searchParams.keys()];
const required = ["client_id", "ext_agent_host_id", "response_type", "redirect_uri",
"scope", "resource", "state", "nonce", "code_challenge_method", "code_challenge"];
const clientId = url.searchParams.get("client_id");
if (keys.some(key => !allowed.has(key)) || new Set(keys).size !== keys.length ||
required.some(key => !url.searchParams.has(key)) ||
!/^[!-~]{1,256}$/u.test(clientId ?? "") ||
!/^urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu.test(url.searchParams.get("ext_agent_host_id") ?? "") ||
(clientId === "dynamic_agent_client" ? url.searchParams.get("agent_name_hint") !== "Relmio" : url.searchParams.has("agent_name_hint")) ||
(url.searchParams.has("prompt") && url.searchParams.get("prompt") !== "consent") ||
url.searchParams.get("response_type") !== "code" ||
url.searchParams.get("code_challenge_method") !== "S256" ||
url.searchParams.get("resource") !== "https://api.openai.com/v1" ||
url.searchParams.get("scope") !== "openid profile email offline_access resource.invoke chatgpt.tokens.use.direct" ||
!/^[A-Za-z0-9_-]{32,128}$/u.test(url.searchParams.get("state") ?? "") ||
!/^[A-Za-z0-9_-]{32,128}$/u.test(url.searchParams.get("nonce") ?? "") ||
!/^[A-Za-z0-9_-]{43}$/u.test(url.searchParams.get("code_challenge") ?? "")) return false;
const redirectUri = url.searchParams.get("redirect_uri");
const redirect = new URL(redirectUri);
return redirect.protocol === "http:" && redirect.hostname === "127.0.0.1" &&
Number(redirect.port) >= 1 && Number(redirect.port) <= 65535 &&
redirect.pathname === "/auth/callback" && redirect.href === redirectUri &&
!redirect.search && !redirect.hash;
} catch {
return false;
}
}
export async function openOpenAiAuthorization(url, options = {}) {
if (!isOpenAiAuthorizationUrl(url)) return false;
return launchCommand(url, options);
}
export function browserCommand(
launchUrl,
platform = process.platform,
{ systemRoot = process.env.SystemRoot ?? process.env.SYSTEMROOT ?? process.env.WINDIR } = {},
) {
if (!isPrivateBrowserLaunchUrl(launchUrl)) {
throw new TypeError("Relmio browser handoff URL is invalid.");
}
if (platform === "darwin") return { file: "open", args: [launchUrl] };
if (platform === "win32") {
return {
file: windowsExplorer(systemRoot),
args: [windowsFilePath(launchUrl)],
};
}
return { file: "xdg-open", args: [launchUrl] };
}
export async function openBrowser(launchUrl, options = {}) {
if (!isPrivateBrowserLaunchUrl(launchUrl)) return false;
return launchCommand(launchUrl, options);
}
async function launchCommand(launchUrl, {
platform = process.platform,
spawnProcess = spawn,
launchTimeoutMs = 5_000,
systemRoot = process.env.SystemRoot ?? process.env.SYSTEMROOT ?? process.env.WINDIR,
} = {}) {
if (!Number.isSafeInteger(launchTimeoutMs) || launchTimeoutMs < 1 || launchTimeoutMs > 30_000) return false;
let command;
try {
command = isPrivateBrowserLaunchUrl(launchUrl)
? browserCommand(launchUrl, platform, { systemRoot })
: isOpenAiAuthorizationUrl(launchUrl)
? platform === "darwin" ? { file: "open", args: [launchUrl] }
: platform === "win32" ? {
file: win32.join(windowsSystemRoot(systemRoot), "System32", "cmd.exe"),
...windowsUrlLaunch([launchUrl]),
}
: { file: "xdg-open", args: [launchUrl] }
: null;
} catch {
return false;
}
if (!command) return false;
let child;
const spawnOptions = {
detached: true,
stdio: "ignore",
shell: false,
};
if (command.env) {
spawnOptions.env = { ...process.env, ...command.env };
spawnOptions.windowsVerbatimArguments = true;
spawnOptions.windowsHide = true;
}
try {
child = spawnProcess(command.file, command.args, spawnOptions);
} catch {
return false;
}
if (
!child || typeof child.once !== "function" ||
typeof child.removeListener !== "function" || typeof child.unref !== "function"
) return false;
child.unref();
return await new Promise((resolveLaunch) => {
let settled = false;
let spawned = false;
let timer;
const settle = (result) => {
if (settled) return;
settled = true;
clearTimeout(timer);
child.removeListener("spawn", onSpawn);
child.removeListener("error", onError);
child.removeListener("exit", onExit);
resolveLaunch(result);
};
const explorerDispatch = platform === "win32" && /(?:^|\\)explorer\.exe$/iu.test(command.file);
const onSpawn = () => {
spawned = true;
// Explorer dispatches the handoff file; its exit code does not report loading.
if (explorerDispatch) settle(true);
};
const onError = () => settle(false);
const onExit = (code, signal) => settle(signal === null && code === 0);
child.once("spawn", onSpawn);
child.once("error", onError);
child.once("exit", onExit);
timer = setTimeout(
() => settle(platform === "win32" && !explorerDispatch ? false : spawned),
launchTimeoutMs,
);
});
}
export function attachBrowserReopenOnEnter({
input = process.stdin,
prepareLaunch,
open = openBrowser,
write = console.log,
}) {
if (!input.isTTY) return () => {};
if (typeof prepareLaunch !== "function" || typeof open !== "function") {
throw new TypeError("Relmio browser reopen adapter is invalid.");
}
const onData = (data) => {
if (/\r|\n/u.test(String(data))) {
void Promise.resolve()
.then(() => prepareLaunch())
.then((url) => open(url))
.catch(() => {});
}
};
input.setEncoding?.("utf8");
input.resume?.();
input.on("data", onData);
write("If the wizard did not open automatically, press Enter to open it again.");
return () => {
input.off("data", onData);
input.pause?.();
};
}