Ctrl K
ComunidadGuías…

server/cli-phone-setup.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// Phone onboarding only chooses a route; the caller starts, verifies and stops it, and mints or cancels its pairing code.
import type { CliOptions } from "./cli.ts";
import { SetupCancelled, type SetupIo } from "./cli-prompts.ts";

export type PhoneKind = "ios" | "android";
export interface PhoneSetupResult {
  options: CliOptions;
  phone?: PhoneKind;
}
export interface PhoneSetupDependencies {
  /** May read saved tunnel credentials, but must not sign in or start a tunnel. */
  accountReady?(options: CliOptions): boolean | Promise<boolean>;
  login(options: CliOptions, io: SetupIo): Promise<number>;
}

/** An HTTPS origin, not proof a phone can reach it; never a pasted invitation, password, query token, localhost or unspecified bind. */
export function normalizePhoneOrigin(raw: string): string | null {
  const value = raw.trim();
  // Reject control characters before URL parsing can silently remove them.
  // eslint-disable-next-line no-control-regex
  if (!/^https:\/\/[^/]/i.test(value) || /[\s\u0000-\u001f\u007f\\?#@]/.test(value)) return null;
  try {
    const url = new URL(value);
    const host = url.hostname.toLowerCase().replace(/\.$/, "");
    if (url.protocol !== "https:" || url.username || url.password || url.pathname !== "/") return null;
    if (!host || host === "localhost" || host.endsWith(".localhost") || host === "0.0.0.0" || host.startsWith("127.")) return null;
    if (host === "[::]" || host === "[::1]" || /^\[::ffff:(?:0:0|7f[0-9a-f]{2}:[0-9a-f]+)\]$/.test(host)) return null;
    return url.origin;
  } catch {
    return null;
  }
}

export async function runPhoneSetup(
  options: CliOptions,
  io: SetupIo,
  deps: PhoneSetupDependencies,
): Promise<PhoneSetupResult> {
  const skip = (): PhoneSetupResult => ({ options });
  const selected = (phone: PhoneKind, route: Partial<CliOptions> = {}): PhoneSetupResult => ({
    options: { ...options, ...route, client: true, pair: true }, phone,
  });
  const signIn = async (): Promise<boolean> => {
    try {
      if (await deps.accountReady?.(options)) {
        io.log("Your saved OpenMausBot account can be reused. The connection will be checked when the server starts.");
        return true;
      }
      io.log("Sign in to an OpenMausBot account using an emailed code. This is separate from your AI provider account.");
      if (await deps.login(options, io) === 0) return true;
    } catch (error) {
      if (error instanceof SetupCancelled) throw error;
      // Account errors can carry credentials or URLs: never echo them into terminal logs.
    }
    io.log("Phone access could not be prepared. Your AI provider setup is still saved; you can try phone setup later.");
    return false;
  };
  for (;;) {
    const device = await io.choose("Use OpenMausBot on your phone?", [
      "Skip for now",
      "iPhone / iPad — native app or Safari",
      "Android — app or browser",
    ], 0);
    if (device === 0) return skip();
    const phone: PhoneKind = device === 1 ? "ios" : "android";
    if (options.tunnel) return await signIn() ? selected(phone) : skip();
    if (options.tailscale) {
      io.log("Keep Tailscale connected on both this computer and your phone, on the same tailnet.");
      return selected(phone);
    }
    const existing = options.publicUrl ? normalizePhoneOrigin(options.publicUrl) : null;
    if (existing) {
      io.log("Your configured HTTPS address will be checked before a phone pairing link is shown.");
      return selected(phone, { publicUrl: existing });
    }
    io.log("This server listens only on this computer. A localhost or LAN-IP link will not connect your phone.");
    let back = false;
    while (!back) {
      const route = await io.choose("How should your phone reach this computer?", [
        "Managed HTTPS address — protected by pairing",
        "Existing Tailscale — only your tailnet",
        "Existing HTTPS address — advanced",
        "Back to phone choice",
        "Skip for now",
      ], 4);
      if (route === 4) return skip();
      if (route === 3) { back = true; continue; }
      if (route === 0) {
        io.log("This creates a public HTTPS endpoint through Cloudflare. Chat and settings require device pairing; the sign-in page and basic server identity are public.");
        io.log("Starting it may download the Cloudflare connector. It stays active while OpenMausBot runs; stop OpenMausBot to close the connection.");
        if (!await io.confirm("Allow this managed public endpoint and connector download?", false)) continue;
        if (!await signIn()) return skip();
        return selected(phone, { tunnel: true, tailscale: false, publicUrl: undefined });
      }
      if (route === 1) {
        io.log("Tailscale must already be installed and signed in on this computer and phone, with HTTPS certificates enabled for the tailnet.");
        if (!await io.confirm("Allow OpenMausBot to serve HTTPS to your tailnet while it runs?", false)) continue;
        return selected(phone, { tailscale: true, tunnel: false, publicUrl: undefined });
      }
      io.log("Use an HTTPS reverse proxy you already configured for this server. Enter only its origin, without a password, pairing code, path, query or fragment.");
      const answer = await io.ask("Existing HTTPS address (Enter goes back): ");
      if (!answer.trim()) continue;
      const origin = normalizePhoneOrigin(answer);
      if (!origin) {
        io.log("Enter a non-localhost HTTPS origin, such as https://maus.example.com. The address was not saved.");
        continue;
      }
      io.log("This does not create a proxy or open a LAN listener. Its connection will be checked before pairing.");
      return selected(phone, { publicUrl: origin, tunnel: false, tailscale: false });
    }
  }
}

/** The URL is an origin, never the invitation carrying its one-time credential. */
export function phonePairingInstructions(
  phone: PhoneKind,
  input: { origin: string | null; ready: boolean },
): string[] {
  const origin = input.origin ? normalizePhoneOrigin(input.origin) : null;
  if (!input.ready || !origin) {
    return ["Phone access is not ready yet. No phone QR should be shown until the HTTPS connection is verified.",
      "Your local OpenMausBot can still be used on this computer."];
  }
  return [
    phone === "ios"
      ? "On iPhone or iPad, scan the QR with Camera to open Safari. If you already have the OpenMausBot iOS app, use its pairing scanner or paste the full pairing link there."
      : "On Android, open the OpenMausBot app and scan the QR with its pairing scanner. The QR is an app link, so Camera will not open it in a browser.",
    `Or open ${origin}/pair on your phone and enter the code.`,
    "Choose Connect on the phone. Scanning a QR does not mean the phone is paired.",
    "The code works once and expires after five minutes. This phone receives client access: chat and approvals, not settings or pairing administration.",
  ];
}