SPB Git

Repository Public Research preview

safari.mjs 20.8 KB 345 lines javascript

1import { execFile } from "node:child_process";2import { randomBytes } from "node:crypto";3import { existsSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";4import { basename, dirname, resolve, sep } from "node:path";5import { SafariError, osascript, pageCall, sleep } from "./osa.mjs";67/**8 * Safari controller. Design rules:9 *  - The server works in ITS OWN Safari window. Tabs the user already had open are invisible to it10 *    unless SAFARI_MCP_SCOPE=all is set by the operator.11 *  - Every page interaction re-checks the tab's current URL against the domain policy: pages redirect.12 *  - Nothing here reads cookies, saved passwords or other browser storage.13 */14const SCRIPTS = {15  status: `tell application "System Events" to set isRunning to (exists process "Safari")16if not isRunning then return "not-running"17tell application "Safari" to return version`,18  newWindow: `on run argv19  tell application "Safari"20    make new document with properties {URL:(item 1 of argv)}21    return id of window 122  end tell23end run`,24  newTab: `on run argv25  set w to (item 1 of argv) as integer26  tell application "Safari"27    tell window id w28      set t to make new tab with properties {URL:(item 2 of argv)}29      set current tab to t30      return index of t31    end tell32  end tell33end run`,34  setUrl: `on run argv35  tell application "Safari" to set URL of tab ((item 2 of argv) as integer) of window id ((item 1 of argv) as integer) to (item 3 of argv)36end run`,37  tabInfo: `on run argv38  set w to (item 1 of argv) as integer39  set t to (item 2 of argv) as integer40  tell application "Safari"41    set u to URL of tab t of window id w42    if u is missing value then set u to ""43    set n to name of tab t of window id w44    if n is missing value then set n to ""45    return u & (ASCII character 9) & n46  end tell47end run`,48  listTabs: `on run argv49  set w to (item 1 of argv) as integer50  set out to ""51  tell application "Safari"52    set cur to index of current tab of window id w53    repeat with t in tabs of window id w54      set u to URL of t55      if u is missing value then set u to ""56      set n to name of t57      if n is missing value then set n to ""58      set out to out & (index of t) & (ASCII character 9) & u & (ASCII character 9) & n & (ASCII character 9) & ((index of t) is cur) & (ASCII character 10)59    end repeat60  end tell61  return out62end run`,63  listWindows: `set out to ""64tell application "Safari"65  repeat with w in windows66    if visible of w and (count of tabs of w) > 0 then set out to out & (id of w) & (ASCII character 9) & (count of tabs of w) & (ASCII character 9) & (name of w) & (ASCII character 10)67  end repeat68end tell69return out`,70  windowExists: `on run argv71  tell application "Safari"72    try73      return (count of tabs of window id ((item 1 of argv) as integer))74    on error75      return 076    end try77  end tell78end run`,79  selectTab: `on run argv80  set w to (item 1 of argv) as integer81  tell application "Safari" to tell window id w to set current tab to tab ((item 2 of argv) as integer)82end run`,83  closeTab: `on run argv84  tell application "Safari" to close tab ((item 2 of argv) as integer) of window id ((item 1 of argv) as integer)85end run`,86  closeWindow: `on run argv87  tell application "Safari" to close window id ((item 1 of argv) as integer)88end run`,89  activate: `on run argv90  tell application "Safari"91    activate92    set index of window id ((item 1 of argv) as integer) to 193  end tell94end run`,95  bounds: `on run argv96  tell application "Safari" to set bounds of window id ((item 1 of argv) as integer) to {(item 2 of argv) as integer, (item 3 of argv) as integer, (item 4 of argv) as integer, (item 5 of argv) as integer}97end run`,98};99100const list = (v) => (v ?? "").split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);101const hostMatches = (host, rule) => host === rule || host.endsWith("." + rule);102103export class Safari {104  constructor(env = process.env) {105    this.win = null;106    this.scope = env.SAFARI_MCP_SCOPE === "all" ? "all" : "owned";107    this.allow = list(env.SAFARI_MCP_ALLOW_DOMAINS);108    this.block = list(env.SAFARI_MCP_BLOCK_DOMAINS);109    this.allowEval = env.SAFARI_MCP_ALLOW_EVAL === "1";110    this.allowFileUrls = env.SAFARI_MCP_ALLOW_FILE_URLS === "1";111    this.fileRoot = resolve(env.SAFARI_MCP_FILE_ROOT ?? process.cwd());112    this.dialogMode = env.SAFARI_MCP_DIALOGS === "dismiss" ? "dismiss" : "accept";113  }114115  /* ---------- policy ---------- */116  checkUrl(url, { navigating = false } = {}) {117    if (url === "" || url === "about:blank" || /^(favorites|bookmarks|history):/.test(url)) { if (navigating && url !== "about:blank") throw new SafariError("url_forbidden", "Only http(s) URLs can be opened."); return; }118    let u;119    try { u = new URL(url); } catch { throw new SafariError("url_invalid", `"${String(url).slice(0, 100)}" is not a valid URL.`); }120    if (u.protocol === "file:") { if (!this.allowFileUrls) throw new SafariError("url_forbidden", "file:// URLs are disabled (set SAFARI_MCP_ALLOW_FILE_URLS=1 to allow them)."); return; }121    if (u.protocol !== "http:" && u.protocol !== "https:") throw new SafariError("url_forbidden", `${u.protocol} URLs are not allowed.`);122    if (u.username || u.password) throw new SafariError("url_forbidden", "URLs with embedded credentials are refused.");123    const host = u.hostname.toLowerCase();124    if (this.block.some((r) => hostMatches(host, r))) throw new SafariError("domain_blocked", `${host} is on this server's block list (SAFARI_MCP_BLOCK_DOMAINS).`);125    if (this.allow.length && !this.allow.some((r) => hostMatches(host, r))) throw new SafariError("domain_not_allowed", `${host} is not on this server's allow list (SAFARI_MCP_ALLOW_DOMAINS: ${this.allow.join(", ")}).`);126  }127  insideRoot(p, mustExist) {128    const root = realpathSync(this.fileRoot), target = resolve(root, p);129    const real = mustExist ? realpathSync(target) : resolve(existsSync(dirname(target)) ? realpathSync(dirname(target)) : dirname(target), basename(target));130    if (real !== root && !real.startsWith(root + sep)) throw new SafariError("path_forbidden", `Path is outside the allowed root (${root}). Set SAFARI_MCP_FILE_ROOT to change it.`);131    return real;132  }133134  /* ---------- windows and tabs ---------- */135  async status() {136    const v = await osascript(SCRIPTS.status, [], { timeoutMs: 10000 });137    const running = v !== "not-running";138    const jsFlag = await new Promise((r) => execFile("/usr/bin/defaults", ["read", "com.apple.Safari", "AllowJavaScriptFromAppleEvents"], (e, out) => r(e ? null : out.trim() === "1")));139    return { running, version: running ? v : null, javascript_from_apple_events: jsFlag, owned_window: await this.ownedAlive() ? this.win : null, scope: this.scope, allow_domains: this.allow, block_domains: this.block, evaluate_js_enabled: this.allowEval, file_root: this.fileRoot, dialogs: this.dialogMode };140  }141  async ownedAlive() {142    if (this.win == null) return false;143    const n = Number(await osascript(SCRIPTS.windowExists, [this.win], { timeoutMs: 10000 }));144    if (n > 0) return true;145    this.win = null; return false;146  }147  /** Parse "t3" (owned window) or "w123:t3" (any window; needs scope "all"). Default: current tab of the owned window. */148  async locate(tab) {149    if (typeof tab === "string" && /^w\d+:t\d+$/.test(tab)) {150      const [w, t] = tab.slice(1).split(":t").map(Number);151      if (w !== this.win && this.scope !== "all") throw new SafariError("tab_out_of_scope", "That tab was not opened by this server. The operator must set SAFARI_MCP_SCOPE=all to act on the user's own tabs.");152      return { win: w, tab: t };153    }154    if (!(await this.ownedAlive())) throw new SafariError("no_window", "No Safari window is open for this session. Call open_url first.");155    if (tab == null || tab === "") { const cur = (await this.tabs()).find((x) => x.current); return { win: this.win, tab: cur ? cur.index : 1 }; }156    const m = /^t?(\d+)$/.exec(String(tab));157    if (!m) throw new SafariError("tab_invalid", 'tab must look like "t2" (or "w123:t2" with scope all).');158    return { win: this.win, tab: Number(m[1]) };159  }160  async tabs(win = this.win) {161    const raw = await osascript(SCRIPTS.listTabs, [win], { timeoutMs: 15000 });162    return raw.split("\n").filter(Boolean).map((l) => { const [i, url, title, cur] = l.split("\t"); return { tab: win === this.win ? `t${i}` : `w${win}:t${i}`, index: Number(i), url, title, current: cur === "true" }; });163  }164  async listTabs({ all = false } = {}) {165    const out = { owned_window: (await this.ownedAlive()) ? this.win : null, scope: this.scope, tabs: this.win ? await this.tabs() : [] };166    if (all) {167      if (this.scope !== "all") throw new SafariError("tab_out_of_scope", "Listing the user's own tabs needs SAFARI_MCP_SCOPE=all (set by the operator, not by a tool call).");168      const wins = (await osascript(SCRIPTS.listWindows, [], { timeoutMs: 15000 })).split("\n").filter(Boolean).map((l) => Number(l.split("\t")[0])).filter((w) => w !== this.win);169      out.other_windows = [];170      for (const w of wins) out.other_windows.push({ window: w, tabs: await this.tabs(w) });171    }172    return out;173  }174  async info(loc) {175    const [url, title] = (await osascript(SCRIPTS.tabInfo, [loc.win, loc.tab], { timeoutMs: 10000 })).split("\t");176    return { url: url ?? "", title: title ?? "" };177  }178  async open(url, { new_tab = true, wait = true, timeoutMs = 30000 } = {}) {179    this.checkUrl(url, { navigating: true });180    let loc;181    if (!(await this.ownedAlive())) { this.win = Number(await osascript(SCRIPTS.newWindow, [url], { timeoutMs: 20000 })); loc = { win: this.win, tab: 1 }; }182    else if (new_tab) loc = { win: this.win, tab: Number(await osascript(SCRIPTS.newTab, [this.win, url], { timeoutMs: 20000 })) };183    else { loc = await this.locate(); await osascript(SCRIPTS.setUrl, [loc.win, loc.tab, url], { timeoutMs: 20000 }); }184    return this.afterNavigation(loc, { wait, timeoutMs });185  }186  async navigate(tab, url, opts = {}) {187    this.checkUrl(url, { navigating: true });188    const loc = await this.locate(tab);189    await osascript(SCRIPTS.setUrl, [loc.win, loc.tab, url], { timeoutMs: 20000 });190    return this.afterNavigation(loc, opts);191  }192  async afterNavigation(loc, { wait = true, timeoutMs = 30000 } = {}) {193    if (!wait) return { tab: this.handle(loc), ...(await this.info(loc)), waited: false };194    const r = await this.waitLoad(loc, { timeoutMs });195    return { tab: this.handle(loc), ...r };196  }197  handle(loc) { return loc.win === this.win ? `t${loc.tab}` : `w${loc.win}:t${loc.tab}`; }198199  /**200   * Wait until the tab has finished navigating. Safari reports a tab's destination URL as soon as a201   * navigation starts, while the old document is still scriptable; the page is settled when the202   * document's own URL has caught up with it, twice in a row, with readyState "complete".203   */204  async waitLoad(loc, { timeoutMs = 30000, settleMs = 200 } = {}) {205    const start = Date.now(), same = (a, b) => { try { return new URL(a).href === new URL(b).href; } catch { return a === b; } };206    let stable = 0, info = null, pending = true;207    while (Date.now() - start < timeoutMs) {208      try {209        info = await pageCall(loc.win, loc.tab, "info", {}, { timeoutMs: 8000, dialogMode: this.dialogMode, withTabUrl: true });210        pending = !same(info.tab_url, info.url);211        if (info.ready_state === "complete" && !pending) { if (++stable >= 2) break; } else stable = 0;212      } catch (e) { stable = 0; if (e.code === "js_from_apple_events_disabled" || e.code === "automation_denied" || e.code === "no_such_tab") throw e; }213      await sleep(settleMs);214    }215    if (!info) { const basic = await this.info(loc); return { ...basic, loaded: false, waited_ms: Date.now() - start, note: "The page did not become scriptable (not an HTML document, or still loading)." }; }216    this.checkUrl(info.url);217    const loaded = info.ready_state === "complete" && !pending;218    return { url: info.url, title: info.title, loaded, waited_ms: Date.now() - start, dialogs: info.dialogs, cross_origin_frames: info.cross_origin_frames, ...(loaded ? {} : { note: pending ? `Still navigating to ${String(info.tab_url).slice(0, 200)} (a download, or a slow server).` : "The page is still loading." }) };219  }220221  /** Run a helper call in a tab after re-checking where that tab currently is. */222  async call(tab, fn, args = {}, opts = {}) {223    const loc = await this.locate(tab);224    const { url } = await this.info(loc);225    this.checkUrl(url);226    const v = await pageCall(loc.win, loc.tab, fn, args, { dialogMode: this.dialogMode, ...opts });227    return { loc, value: v };228  }229230  /** After an action that may navigate: give the page a moment, then wait for it to settle if a navigation is under way. */231  async settle(loc, urlBefore, { timeoutMs = 20000 } = {}) {232    await sleep(300);233    let now = null;234    try { now = await pageCall(loc.win, loc.tab, "info", {}, { timeoutMs: 6000, dialogMode: this.dialogMode, withTabUrl: true }); } catch (e) { if (e.code === "no_such_tab") throw e; }235    const idle = now && now.ready_state === "complete" && now.url === urlBefore && (() => { try { return new URL(now.tab_url).href === new URL(now.url).href; } catch { return now.tab_url === now.url; } })();236    if (idle) return { navigated: false, url: now.url, title: now.title, dialogs: now.dialogs };237    const r = await this.waitLoad(loc, { timeoutMs });238    return { navigated: r.url !== urlBefore, url: r.url, title: r.title, loaded: r.loaded, dialogs: [...(now?.dialogs ?? []), ...(r.dialogs ?? [])], ...(r.note ? { note: r.note } : {}) };239  }240241  async waitFor(tab, cond, { timeoutMs = 15000, intervalMs = 250 } = {}) {242    const loc = await this.locate(tab);243    const start = Date.now();244    let last = null;245    while (Date.now() - start < timeoutMs) {246      try {247        last = await pageCall(loc.win, loc.tab, "check_condition", cond, { timeoutMs: 6000, dialogMode: this.dialogMode });248        const ok = (cond.selector == null || (cond.selector_gone ? last.selector_count === 0 : cond.visible === false ? last.selector_count > 0 : last.selector_visible > 0)) && (cond.text == null || last.text_found) && (cond.text_gone == null || last.text_gone) && (cond.url_contains == null || last.url_matches) && (!cond.loaded || last.ready_state === "complete");249        if (ok) return { met: true, waited_ms: Date.now() - start, state: last };250      } catch (e) { if (e.code === "js_from_apple_events_disabled" || e.code === "automation_denied") throw e; }251      await sleep(intervalMs);252    }253    return { met: false, waited_ms: Date.now() - start, state: last };254  }255256  async selectTab(tab, { bring_to_front = false } = {}) {257    const loc = await this.locate(tab);258    await osascript(SCRIPTS.selectTab, [loc.win, loc.tab], { timeoutMs: 10000 });259    if (bring_to_front) await osascript(SCRIPTS.activate, [loc.win], { timeoutMs: 10000 });260    return { tab: this.handle(loc), ...(await this.info(loc)) };261  }262  async closeTab(tab) {263    const loc = await this.locate(tab);264    if (loc.win !== this.win) throw new SafariError("tab_out_of_scope", "This server only closes tabs it opened.");265    const n = (await this.tabs()).length;266    if (n <= 1) { await osascript(SCRIPTS.closeWindow, [this.win], { timeoutMs: 10000 }); this.win = null; return { closed: this.handle(loc), window_closed: true }; }267    await osascript(SCRIPTS.closeTab, [loc.win, loc.tab], { timeoutMs: 10000 });268    return { closed: this.handle(loc), window_closed: false, remaining_tabs: n - 1 };269  }270  async closeWindow() {271    if (!(await this.ownedAlive())) return { closed: false, reason: "no window is open for this session" };272    await osascript(SCRIPTS.closeWindow, [this.win], { timeoutMs: 10000 });273    this.win = null;274    return { closed: true };275  }276  async resize(width, height) {277    if (!(await this.ownedAlive())) throw new SafariError("no_window", "No Safari window is open for this session.");278    await osascript(SCRIPTS.bounds, [this.win, 60, 60, 60 + width, 60 + height], { timeoutMs: 10000 });279    return { width, height };280  }281282  /* ---------- screenshot, files ---------- */283  async screenshot(tab, outPath, { max_dimension = 1400 } = {}) {284    const loc = await this.locate(tab);285    if (loc.win !== this.win) throw new SafariError("tab_out_of_scope", "Screenshots are limited to this session's window.");286    const { url } = await this.info(loc);287    this.checkUrl(url);288    const file = this.insideRoot(outPath, false);289    if (existsSync(file)) throw new SafariError("file_exists", `${outPath} already exists; refusing to overwrite.`);290    mkdirSync(dirname(file), { recursive: true });291    await osascript(SCRIPTS.selectTab, [loc.win, loc.tab], { timeoutMs: 10000 });292    await sleep(250);293    // The AppleScript window id is the CoreGraphics window number, so the window is captured even when it is not frontmost.294    await new Promise((res, rej) => execFile("/usr/sbin/screencapture", ["-x", "-o", "-l", String(loc.win), file], { timeout: 20000 }, (e, _o, se) => (e ? rej(new SafariError("screenshot_failed", `screencapture failed: ${String(se || e.message).trim().slice(0, 200)}. Grant Screen Recording permission to the app running this server (System Settings → Privacy & Security).`)) : res())));295    if (!existsSync(file) || statSync(file).size < 200) throw new SafariError("screenshot_failed", "No image was produced. Grant Screen Recording permission to the app running this server, and keep the window on screen (not minimised).");296    const preview = file.replace(/\.png$/i, "") + ".preview.jpg";297    await new Promise((res) => execFile("/usr/bin/sips", ["-Z", String(max_dimension), "-s", "format", "jpeg", "-s", "formatOptions", "70", file, "--out", preview], { timeout: 20000 }, () => res()));298    const previewData = existsSync(preview) ? readFileSync(preview) : null;299    return { path: file, bytes: statSync(file).size, url, preview_path: previewData ? preview : null, previewData };300  }301  async upload(tab, target, path, { append = false } = {}) {302    const real = this.insideRoot(path, true);303    const st = statSync(real);304    if (!st.isFile()) throw new SafariError("not_a_file", "Not a regular file.");305    if (st.size > 20 * 1024 * 1024) throw new SafariError("file_too_large", "Uploads through the page are limited to 20 MB.");306    const b64 = readFileSync(real).toString("base64"), id = randomBytes(6).toString("hex");307    const loc = await this.locate(tab);308    this.checkUrl((await this.info(loc)).url);309    for (let i = 0; i < b64.length || i === 0; i += 180000) { await pageCall(loc.win, loc.tab, "buf_append", { id, chunk: b64.slice(i, i + 180000) }, { dialogMode: this.dialogMode }); if (b64.length === 0) break; }310    const EXT = { pdf: "application/pdf", txt: "text/plain", csv: "text/csv", json: "application/json", png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", html: "text/html", md: "text/markdown", zip: "application/zip", docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" };311    const name = basename(real);312    return pageCall(loc.win, loc.tab, "upload_commit", { id, target, name, type: EXT[name.split(".").pop().toLowerCase()] ?? "application/octet-stream", append }, { dialogMode: this.dialogMode });313  }314  async fetchInPage(tab, { url, as = "binary", allow_cross_origin = false, method, headers, body, timeoutMs = 60000, max_bytes }) {315    const loc = await this.locate(tab);316    const page = (await this.info(loc)).url;317    this.checkUrl(page);318    this.checkUrl(new URL(url, page).href);319    const id = randomBytes(6).toString("hex");320    await pageCall(loc.win, loc.tab, "fetch_start", { id, url, as: as === "text" ? "text" : "binary", allow_cross_origin, method, headers, body, max_bytes }, { dialogMode: this.dialogMode });321    const start = Date.now();322    let first;323    for (;;) {324      first = await pageCall(loc.win, loc.tab, "job_poll", { id, offset: 0 }, { dialogMode: this.dialogMode });325      if (first.done) break;326      if (Date.now() - start > timeoutMs) throw new SafariError("timeout", "The request did not finish in time.");327      await sleep(200);328    }329    if (first.error) throw new SafariError("fetch_failed", `The page's fetch failed: ${first.error}`);330    if (as === "text") return first;331    let b64 = first.chunk, part = first;332    while (!part.last) { part = await pageCall(loc.win, loc.tab, "job_poll", { id, offset: part.next_offset }, { dialogMode: this.dialogMode }); b64 += part.chunk; }333    return { meta: first.meta, size: first.size, data: Buffer.from(b64, "base64") };334  }335  async download(tab, url, outPath, opts = {}) {336    const file = this.insideRoot(outPath, false);337    if (existsSync(file)) throw new SafariError("file_exists", `${outPath} already exists; refusing to overwrite.`);338    const r = await this.fetchInPage(tab, { url, as: "binary", ...opts });339    if (!r.meta.ok) throw new SafariError("http_error", `The server answered HTTP ${r.meta.status}; nothing was saved.`);340    mkdirSync(dirname(file), { recursive: true });341    writeFileSync(file, r.data, { flag: "wx" });342    return { path: file, bytes: r.data.length, status: r.meta.status, content_type: r.meta.type, final_url: r.meta.url };343  }344}345
Files main
Menu

Theme