all repos — sz-chrome-remote-cdp @ master

pi-browser-harness, patched to drive a Chrome on another host over CDP, with castle_cdp_search web search

src/cdp/endpoint.ts (view raw)

 1
 2
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
 100
 101
 102
 103
 104
 105
 106
 107
 108
 109
 110
 111
 112
 113
 114
 115
 116
 117
 118
 119
 120
 121
 122
 123
 124
 125
 126
 127
 128
 129
 130
 131
 132
 133
 134
 135
 136
 137
 138
 139
 140
 141
 142
 143
 144
 145
 146
 147
 148
 149
 150
 151
 152
 153
 154
 155
 156
 157
 158
 159
 160
 161
 162
 163
 164
 165
 166
/**
 * Which browser this harness should drive.
 *
 * Upstream only ever drives a browser on localhost. This fork adds a remote
 * target so pi can drive a shared Chrome on another host (see FORK.md).
 *
 * Resolution is pure — no I/O — so the status command can report the configured
 * intent without touching the network, and the daemon can resolve the same way
 * in its own process.
 */

/** Default remote CDP endpoint: castle's Chrome, exposed on the wire network. */
export const DEFAULT_REMOTE_CDP = "10.88.0.25:9223";

/** Values of BU_CDP_HTTP that mean "ignore the default, use this machine". */
const LOCAL_ALIASES = new Set(["local", "localhost", "off", "none", "0", "no"]);

/**
 * Where a target came from. "ws" is upstream's BU_CDP_WS, "env" is BU_CDP_HTTP,
 * "stored" is /browser-target, "default" is this fork's built-in. Only
 * "default" is allowed to fall back.
 */
export type CdpTargetSource = "ws" | "env" | "stored" | "default";

export type CdpTarget =
  | {
      readonly kind: "remote";
      readonly host: string;
      readonly port: number;
      readonly source: CdpTargetSource;
    }
  | { readonly kind: "local"; readonly source: Exclude<CdpTargetSource, "default" | "ws"> }
  | { readonly kind: "invalid"; readonly raw: string; readonly reason: string; readonly source: CdpTargetSource };

/**
 * Precedence: BU_CDP_WS, then BU_CDP_HTTP, then the /browser-target choice,
 * then the built-in default.
 *
 *   BU_CDP_WS    -> upstream's pinned WebSocket URL; discoverEndpoint() returns
 *                   it before any of the rest is consulted, so it is reported
 *                   here only so status, /browser-target and the prompt agree
 *                   with what the daemon actually dials
 *
 *   "host:port"  -> that remote, and a failure to reach it is fatal
 *   local|off|…  -> upstream behaviour, this machine's own browser
 *   neither set  -> DEFAULT_REMOTE_CDP, falling back to local when unreachable
 *
 * The environment wins over the stored choice so that a one-off
 * `BU_CDP_HTTP=… pi` is never silently overridden by a setting made days ago.
 *
 * The fallback asymmetry is deliberate. An explicit endpoint — from either
 * source — is an instruction, so silently driving a different browser instead
 * would be wrong: it would automate the operator's own signed-in Chrome rather
 * than the shared one. The built-in default is only a preference, so a laptop
 * away from the wire network still gets the local browser the extension was
 * originally written for.
 *
 * `stored` is passed in rather than read here so this stays pure and
 * synchronous: /browser-status can describe the target without I/O, and the
 * daemon can resolve identically in its own process.
 */
export const resolveCdpTarget = (
  env: NodeJS.ProcessEnv = process.env,
  stored: string | null = null,
): CdpTarget => {
  // FORK: mirror upstream's endpointFromEnv() in discovery.ts, which wins.
  const pinnedWs = env["BU_CDP_WS"]?.trim();
  if (pinnedWs) {
    const parsed = parseWsUrl(pinnedWs);
    if (!parsed) {
      return { kind: "invalid", raw: redactWsUrl(pinnedWs), source: "ws", reason: "expected a ws:// or wss:// URL" };
    }
    return { kind: "remote", host: parsed.host, port: parsed.port, source: "ws" };
  }

  const fromEnv = env["BU_CDP_HTTP"]?.trim();
  const fromStore = stored?.trim();

  const raw = fromEnv !== undefined && fromEnv !== "" ? fromEnv : fromStore;
  const source: CdpTargetSource = fromEnv !== undefined && fromEnv !== "" ? "env" : "stored";

  if (raw === undefined || raw === "") {
    const parsed = parseHostPort(DEFAULT_REMOTE_CDP);
    // Unreachable in practice: the constant above is validated by its own test.
    if (!parsed) return { kind: "local", source: "env" };
    return { kind: "remote", host: parsed.host, port: parsed.port, source: "default" };
  }

  if (LOCAL_ALIASES.has(raw.toLowerCase())) return { kind: "local", source };

  const parsed = parseHostPort(raw);
  if (!parsed) {
    return {
      kind: "invalid",
      raw,
      source,
      reason: `expected "host:port" or one of ${[...LOCAL_ALIASES].join(", ")}`,
    };
  }
  return { kind: "remote", host: parsed.host, port: parsed.port, source };
};

/** The vocabulary /browser-target accepts, for help text and validation. */
export const localAliases = (): ReadonlyArray<string> => [...LOCAL_ALIASES];

/** True when `value` is something resolveCdpTarget can make sense of. */
export const isValidTargetValue = (value: string): boolean => {
  const v = value.trim();
  if (v === "") return false;
  return LOCAL_ALIASES.has(v.toLowerCase()) || parseHostPort(v) !== null;
};

const parseHostPort = (value: string): { host: string; port: number } | null => {
  const sep = value.lastIndexOf(":");
  if (sep === -1) return null;
  const host = value.slice(0, sep).trim();
  const port = Number(value.slice(sep + 1).trim());
  if (!host) return null;
  if (!Number.isInteger(port) || port <= 0 || port >= 65536) return null;
  return { host, port };
};

/**
 * host:port of a BU_CDP_WS URL. Only that much is ever shown: the path carries
 * the browser's id, and a proxied endpoint may carry a token or credentials.
 */
const parseWsUrl = (value: string): { host: string; port: number } | null => {
  let url: URL;
  try {
    url = new URL(value);
  } catch {
    return null;
  }
  if (url.protocol !== "ws:" && url.protocol !== "wss:") return null;
  const host = url.hostname.replace(/^\[(.*)\]$/, "$1");
  if (!host) return null;
  const port = url.port === "" ? (url.protocol === "wss:" ? 443 : 80) : Number(url.port);
  return { host, port };
};

/** A BU_CDP_WS value safe to print: host:port when parseable, otherwise a placeholder. */
export const redactWsUrl = (value: string): string => {
  const parsed = parseWsUrl(value.trim());
  return parsed ? `${parsed.host}:${parsed.port}` : "<unparseable URL>";
};

const sourceLabel = (source: CdpTargetSource): string =>
  source === "ws"
    ? "BU_CDP_WS, pinned WebSocket"
    : source === "env"
      ? "BU_CDP_HTTP"
      : source === "stored"
        ? "/browser-target"
        : "built-in default";

/** One-line description for /browser-status and error messages. */
export const describeCdpTarget = (target: CdpTarget): string => {
  switch (target.kind) {
    case "remote":
      return `${target.host}:${target.port} (${sourceLabel(target.source)})`;
    case "local":
      return `this machine's own browser (${sourceLabel(target.source)})`;
    case "invalid":
      return `invalid target "${target.raw}" from ${sourceLabel(target.source)} — ${target.reason}`;
  }
};