← Files DataARCHIVED FILE

templates/data-app/base/src/runtime-environment.js

11 KB · Sep 30, 2026 · 23:19 UTC

↓ Download file

import { canonicalDashboardPath } from "./chart-permalink.js";
import { dashboardViewSearchParams } from "./dashboard-url-state.js";
import { safeSourceHref } from "./source-provenance.js";

function isLocalDataApp(location) {
  if (location?.protocol === "file:") return Boolean(currentDataAppReference(location).htmlPath);
  return ["localhost", "127.0.0.1", "[::1]", "terminal.local"].includes(location?.hostname)
    || location?.hostname?.endsWith(".localhost");
}

// Keep report identity and task working directories on the same local-path
// boundary. Drive-relative paths and UNC shares are not absolute local paths.
export function isSafeLocalDataAppPath(value) {
  return typeof value === "string" && value.length <= 2048
    && /^(?:\/(?![\\/])|[A-Za-z]:[\\/])/u.test(value)
    && !/[\u0000-\u001f\u007f]/u.test(value);
}

// Older packaged Codex builds share ChatGPTBrowser with Atlas. Accept both during the client rollout.
const CODEX_BROWSER_USER_AGENT_PATTERN = /^(?:CodexBrowser|ChatGPTBrowser)(?:[ /]|$)/u;

export function isCodexBrowser(userAgent = globalThis.navigator?.userAgent) {
  // Honor an explicit host marker when its user agent is unbranded.
  return globalThis.window?.__codexBrowser === true
    || (typeof userAgent === "string" && CODEX_BROWSER_USER_AGENT_PATTERN.test(userAgent));
}

export function dataAppPromptDestination(
  location = globalThis.window?.location,
  userAgent = globalThis.navigator?.userAgent,
  destination,
) {
  if (destination !== undefined && destination !== "desktop" && destination !== "web") {
    throw new Error("Choose desktop or web for the ChatGPT destination.");
  }
  if (destination) return destination;
  return location?.protocol === "file:" || isLocalDevelopmentHost(location?.hostname) || isCodexBrowser(userAgent)
    ? "desktop"
    : "web";
}

function isLocalDevelopmentHost(hostname = "") {
  return (
    typeof hostname === "string" &&
    (["localhost", "127.0.0.1", "[::1]", "terminal.local"].includes(hostname) || hostname.endsWith(".localhost"))
  );
}

function normalizedLocalPreviewPath(value, directory = false) {
  if (typeof value !== "string" || !value || /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/u.test(value)) return null;

  const windowsDrive = /^[A-Za-z]:[\\/]/u.test(value);
  const windowsShare = /^(?:\\\\|\/\/)/u.test(value);
  const windows = windowsDrive || windowsShare;
  if (!windows && value.includes("\\")) return null;
  let path = windows ? value.replaceAll("\\", "/") : value;
  if (!directory && path.endsWith("/")) return null;

  let anchor;
  let remainder;
  if (windowsDrive) {
    anchor = path.slice(0, 3);
    remainder = path.slice(3);
  } else if (windowsShare) {
    const share = /^\/\/([^/]+)\/([^/]+)(?:\/|$)/u.exec(path);
    if (!share) return null;
    anchor = `//${share[1]}/${share[2]}`;
    remainder = path.slice(share[0].length);
  } else if (path.startsWith("/")) {
    anchor = "/";
    remainder = path.slice(1);
  } else {
    return null;
  }

  if (directory) remainder = remainder.replace(/\/+$/u, "");
  const parts = remainder ? remainder.split("/") : [];
  if (parts.some((part) => !part || part === "." || part === "..")) return null;
  if (windows) {
    const windowsParts = windowsShare ? [...anchor.slice(2).split("/"), ...parts] : parts;
    // Win32 removes trailing dots/spaces and gives colons/device prefixes special
    // meaning. Do not accept paths whose filesystem meaning differs from this
    // lexical containment check.
    if (windowsParts.some((part) => /[<>:"|?*]|[. ]$/u.test(part) || part === "." || part === "..")) return null;
  }

  path = parts.length ? `${anchor.endsWith("/") ? anchor : `${anchor}/`}${parts.join("/")}` : anchor;
  return { path, key: windows ? path.toLowerCase() : path, windows };
}

function localPreviewReference(source) {
  if (!["http:", "https:"].includes(source.protocol) || !isLocalDevelopmentHost(source.hostname)) return null;
  try {
    const entries = globalThis.document?.querySelectorAll?.('meta[name="data-app-local-reference"]');
    if (entries?.length !== 1) return null;
    const value = JSON.parse(entries[0].getAttribute("content"));
    if (!value || typeof value !== "object" || Array.isArray(value)) return null;
    if (Object.keys(value).length !== 2 || !Object.hasOwn(value, "root") || !Object.hasOwn(value, "htmlPath")) {
      return null;
    }
    const root = normalizedLocalPreviewPath(value.root, true);
    const html = normalizedLocalPreviewPath(value.htmlPath);
    if (!root || !html || root.windows !== html.windows || !/\.html?$/iu.test(html.path)) return null;
    const prefix = root.key.endsWith("/") ? root.key : `${root.key}/`;
    if (!html.key.startsWith(prefix)) return null;
    return { root: root.path, htmlPath: html.path };
  } catch {
    return null;
  }
}

function hostedSitesProjectId(source) {
  if (
    source.protocol !== "https:" ||
    ![".chatgpt.site", ".chatgpt-team.site"].some((suffix) => source.hostname.endsWith(suffix))
  ) return null;
  const entries = globalThis.document?.querySelectorAll?.('meta[name="data-app-sites-project"]');
  if (entries?.length !== 1) return null;
  const projectId = entries[0].getAttribute("content");
  return typeof projectId === "string" && /^[A-Za-z0-9][A-Za-z0-9_-]*$/u.test(projectId)
    ? projectId
    : null;
}

// Dashboard navigation state is the only query data that may accompany an
// otherwise safe artifact URL. Keep source-link credential/path checks intact.
export function safeDataAppSourceHref(value) {
  if (typeof value !== "string") return null;
  try {
    const source = new URL(value);
    const view = dashboardViewSearchParams(source);
    if ([...source.searchParams].length !== [...view].length
      || [...source.searchParams.keys()].some(key => !view.has(key))) return null;
    source.search = "";
    const safe = safeSourceHref(source.href);
    if (!safe) return null;
    const result = new URL(safe);
    result.search = view.toString();
    return result.href;
  } catch {
    return null;
  }
}

// Read the current page without transferring credentials or unrelated routing
// parameters. Unlike the artifact identity, this also retains local preview URLs.
export function currentDataAppViewUrl(location = globalThis.window?.location) {
  if (!location?.href) return null;
  try {
    const source = new URL(location.href);
    if (!["http:", "https:"].includes(source.protocol)) return null;
    source.username = "";
    source.password = "";
    source.search = dashboardViewSearchParams(source).toString();
    source.hash = "";
    source.pathname = canonicalDashboardPath(source.pathname);
    return safeDataAppSourceHref(source.href);
  } catch {
    return null;
  }
}

export function codexDataAppPromptUrl(
  prompt,
  dataAppReference,
  location = globalThis.window?.location,
  userAgent = globalThis.navigator?.userAgent,
  destination,
  requiredViewUrl,
) {
  if (typeof prompt !== "string" || !prompt.trim()) {
    throw new Error("A Data app handoff requires a prompt.");
  }
  const local = isLocalDataApp(location);
  const desktop = dataAppPromptDestination(location, userAgent, destination) === "desktop";
  const viewUrl = requiredViewUrl === undefined
    ? currentDataAppViewUrl(location)
    : safeDataAppSourceHref(requiredViewUrl);
  if (requiredViewUrl !== undefined && !viewUrl) {
    throw new Error("The dashboard view link is unavailable. Reopen the dashboard before continuing.");
  }
  if (!desktop && requiredViewUrl !== undefined && isLocalDevelopmentHost(new URL(viewUrl).hostname)) {
    throw new Error("This preview is local to this task. Open it in desktop; ChatGPT on the web cannot read this local preview link.");
  }
  const threadId = local
    ? [
        globalThis.document?.querySelector?.('meta[name="data-app-local-thread"]')?.getAttribute("content"),
        new URLSearchParams(location?.hash?.slice(1)).get("codexThreadId"),
      ].find((value) => /^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/iu.test(value ?? ""))
    : null;
  const url = new URL(desktop
    ? threadId ? `codex://threads/${threadId}` : "codex://new"
    : "https://chatgpt.com/");
  url.searchParams.set(desktop ? "prompt" : "q", prompt);
  if (!desktop) {
    // Match the native handoff: prepare the composer without submitting it.
    url.searchParams.set("disable_auto_send", "1");
  } else if (local && !threadId) {
    const root = (dataAppReference ?? currentDataAppReference(location)).root;
    if (isSafeLocalDataAppPath(root)) url.searchParams.set("path", root);
  }
  if (desktop && viewUrl) url.searchParams.set("browserUrl", viewUrl);
  return url;
}

export function dataAppPromptTarget(url) {
  return url && new URL(url).protocol === "codex:" ? "_self" : "_blank";
}

export function currentDataAppReference(
  location = globalThis.window?.location,
  projectRoot = typeof __DATA_APP_PROJECT_ROOT__ === "string" ? __DATA_APP_PROJECT_ROOT__ : "",
) {
  if (!location?.href) return {};

  let source;
  try {
    source = new URL(location.href);
  } catch {
    return {};
  }
  source.username = "";
  source.password = "";
  source.search = ["http:", "https:"].includes(source.protocol)
    ? dashboardViewSearchParams(source).toString()
    : "";
  source.hash = "";
  // URL normalizes file://localhost to an empty authority. Any remaining
  // authority denotes a network path, not a local report working directory.
  if (source.protocol === "file:" && source.hostname) return {};
  if (isLocalDevelopmentHost(source.hostname)) {
    return (
      localPreviewReference(source) ??
      (projectRoot ? { root: projectRoot, htmlPath: `${projectRoot}/dist/index.html` } : {})
    );
  }
  if (["http:", "https:"].includes(source.protocol)) {
    source.pathname = canonicalDashboardPath(source.pathname);
  }

  if (source.protocol !== "file:") {
    const projectId = hostedSitesProjectId(source);
    return { sourceUrl: source.toString(), ...(projectId ? { projectId } : {}) };
  }

  // Encoded separators must not change the filesystem hierarchy on decoding.
  if (/%2f|%5c/iu.test(source.pathname)) return {};
  let htmlPath;
  try {
    htmlPath = decodeURIComponent(source.pathname);
  } catch {
    return {};
  }
  // File URLs retain a leading slash before a Windows drive, unlike local
  // filesystem paths passed to the native task's working-directory parameter.
  if (/^\/[A-Za-z]:\//u.test(htmlPath)) htmlPath = htmlPath.slice(1);
  if (!isSafeLocalDataAppPath(htmlPath)) return {};
  const directory = htmlPath.replace(/\/[^/]*$/u, "");
  const projectDirectory = directory.endsWith("/dist") ? directory.slice(0, -5) : directory;
  const root = /^[A-Za-z]:$/u.test(projectDirectory) ? `${projectDirectory}/` : projectDirectory || "/";
  return { htmlPath, root, sourceUrl: source.toString() };
}

// Compatibility exports for copied source consumers share the same policy.
export function chatGPTPromptUrl(
  prompt,
  location = globalThis.window?.location,
  userAgent = globalThis.navigator?.userAgent,
  destination,
) {
  return codexDataAppPromptUrl(prompt, undefined, location, userAgent, destination);
}

export const codexDataAppActionUrl = chatGPTPromptUrl;
export const codexDataAppNewTaskUrl = codexDataAppPromptUrl;
export const codexDataAppAskUrl = codexDataAppPromptUrl;

SHA-256: 6f568c79bff45a67571681c0d8ed5f2e6f7450e1af6a01f0dfeaa75ef00d3e4d