File size: 12,545 Bytes
2a2730a
0963758
2a2730a
b149c75
0963758
 
b149c75
2a2730a
bded2d7
651d3fd
 
 
 
2a2730a
 
 
0d9ba62
 
bded2d7
 
2a2730a
 
 
 
 
 
 
 
 
29b5d55
 
 
 
 
 
 
 
b149c75
 
 
 
 
 
651d3fd
 
 
 
 
 
 
 
2a2730a
 
 
 
 
 
 
 
 
 
 
4132204
2a2730a
 
0d9ba62
4132204
4e47b4d
4132204
4e47b4d
4132204
 
 
 
0d9ba62
 
4e47b4d
 
 
 
 
 
 
 
 
 
2a2730a
 
4e47b4d
 
 
 
 
 
 
2a2730a
 
0963758
 
 
 
 
 
 
 
bded2d7
0d9ba62
4e47b4d
 
 
 
 
 
 
 
 
 
db23f5d
bded2d7
 
4e47b4d
bded2d7
 
 
4e47b4d
 
 
bded2d7
 
4e47b4d
 
 
 
 
 
 
bded2d7
 
 
4e47b4d
0d9ba62
4e47b4d
 
2a2730a
 
0d9ba62
 
 
 
 
 
 
 
 
 
 
 
 
2a2730a
db23f5d
2a2730a
 
b149c75
2a2730a
 
 
 
 
 
 
bded2d7
db23f5d
bded2d7
 
db23f5d
651d3fd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bded2d7
651d3fd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0d9ba62
 
651d3fd
 
 
 
 
 
 
 
 
 
 
 
0d9ba62
 
 
 
 
 
 
 
 
 
 
 
 
 
651d3fd
bded2d7
651d3fd
bded2d7
651d3fd
bded2d7
651d3fd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bded2d7
 
b149c75
2a2730a
29b5d55
 
b149c75
29b5d55
b149c75
 
2a2730a
29b5d55
 
 
 
2a2730a
0963758
 
2a2730a
 
b149c75
 
2a2730a
 
b149c75
 
2a2730a
0963758
 
 
 
 
 
 
 
 
 
db23f5d
29b5d55
db23f5d
 
 
 
 
 
b149c75
 
 
 
 
2a2730a
b149c75
 
 
 
 
 
2a2730a
b149c75
 
2a2730a
 
b149c75
 
 
2a2730a
b149c75
 
 
 
 
 
 
 
 
 
 
 
 
 
2a2730a
 
bded2d7
 
 
 
 
 
 
 
 
 
 
 
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
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
/**
 * Manages connectivity to a Headroom proxy (local or remote).
 *
 * Security model:
 * - Local proxies (127.0.0.1 / localhost) can be auto-started via subprocess
 * - Remote proxies are connect-only: probe and use, never launch
 * - No environment variable access
 */
import { spawn } from "node:child_process";
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";

export interface ProxyManagerConfig {
  proxyUrl?: string;
  proxyPort?: number;
  pythonPath?: string;
  autoStart?: boolean;
  startupTimeoutMs?: number;
}

export interface ProxyManagerLogger {
  info(message: string): void;
  warn(message: string): void;
  error(message: string): void;
  debug(message: string): void;
}

/** Default logger that prefixes all messages with `[headroom]`. */
export const defaultLogger: ProxyManagerLogger = {
  info: (m) => console.log(`[headroom] ${m}`),
  warn: (m) => console.warn(`[headroom] ${m}`),
  error: (m) => console.error(`[headroom] ${m}`),
  debug: () => {},
};

export interface ProxyProbeResult {
  reachable: boolean;
  isHeadroom: boolean;
  reason?: string;
}

interface LaunchSpec {
  label: string;
  command: string;
  args: string[];
  checkCommand: string;
  checkArgs: string[];
}

export class ProxyManager {
  private config: ProxyManagerConfig;
  private logger: ProxyManagerLogger;
  private proxyUrl: string | null = null;

  constructor(config: ProxyManagerConfig = {}, logger?: ProxyManagerLogger) {
    this.config = config;
    this.logger = logger ?? defaultLogger;
  }

  /**
   * Ensure a proxy is available. Returns the normalized URL origin.
   */
  async start(): Promise<string> {
    const port = this.getProxyPort();
    const rawExplicitUrl =
      typeof this.config.proxyUrl === "string" && this.config.proxyUrl.trim().length > 0
        ? normalizeAndValidateProxyUrl(this.config.proxyUrl)
        : null;
    // Only apply proxyPort default to local URLs — remote URLs use their protocol default
    const explicitUrl = rawExplicitUrl
      ? isLocalProxyUrl(rawExplicitUrl) ? withDefaultPort(rawExplicitUrl, port) : rawExplicitUrl
      : null;
    const defaultCandidates = this.getDefaultProxyCandidates(port);
    const candidateUrls = explicitUrl ? [explicitUrl] : [...defaultCandidates];
    const probeByUrl = new Map<string, ProxyProbeResult>();

    for (const url of candidateUrls) {
      const probe = await probeHeadroomProxy(url);
      probeByUrl.set(url, probe);
      if (probe.reachable && probe.isHeadroom) {
        this.proxyUrl = url;
        this.logger.info(`Headroom proxy already running at ${url}`);
        return url;
      }
    }

    if (explicitUrl) {
      const explicitProbe = probeByUrl.get(explicitUrl);
      if (explicitProbe?.reachable && !explicitProbe.isHeadroom) {
        throw new Error(
          `Service reachable at ${explicitUrl}, but it does not appear to be a Headroom proxy (${explicitProbe.reason ?? "unknown service"}).`,
        );
      }
    }

    // Remote URLs are connect-only — never auto-start a subprocess for them
    if (explicitUrl && !isLocalProxyUrl(explicitUrl)) {
      throw new Error(
        `Remote Headroom proxy not reachable at ${explicitUrl}. Ensure the proxy is running at that address.`,
      );
    }

    // Auto-start is only available for local proxies
    if (this.config.autoStart !== false) {
      const startupUrl = explicitUrl ?? defaultCandidates[0];
      const startupProbe = probeByUrl.get(startupUrl);
      if (startupProbe?.reachable && !startupProbe.isHeadroom) {
        throw new Error(
          `Cannot auto-start Headroom at ${startupUrl}: port is in use by a non-Headroom service (${startupProbe.reason ?? "unknown service"}).`,
        );
      }

      this.logger.info(
        `No Headroom proxy detected${explicitUrl ? ` at ${startupUrl}` : " on default local endpoints"}; attempting to auto-start...`,
      );
      await this.startHeadroomProxy(startupUrl, port);

      const startedProbe = await waitForHeadroomProxy(
        startupUrl,
        this.config.startupTimeoutMs ?? 20_000,
      );
      if (startedProbe.reachable && startedProbe.isHeadroom) {
        this.proxyUrl = startupUrl;
        this.logger.info(`Headroom proxy started and reachable at ${startupUrl}`);
        return startupUrl;
      }
      throw new Error(
        `Attempted to start Headroom proxy, but it was not reachable at ${startupUrl} (${startedProbe.reason ?? "unknown"}).`,
      );
    }

    if (explicitUrl) {
      throw new Error(
        `Headroom proxy not reachable at ${explicitUrl}. Ensure the proxy is running first.`,
      );
    }

    throw new Error(
      `Headroom proxy not detected on default endpoints (${defaultCandidates.join(", ")}). ` +
        "Set proxyUrl explicitly or enable autoStart.",
    );
  }

  private getProxyPort(): number {
    const rawPort = this.config.proxyPort;
    if (!Number.isInteger(rawPort) || rawPort === undefined) return 8787;
    if (rawPort < 1 || rawPort > 65535) {
      throw new Error("proxyPort must be an integer between 1 and 65535");
    }
    return rawPort;
  }

  private getDefaultProxyCandidates(port: number): string[] {
    return [`http://127.0.0.1:${port}`, `http://localhost:${port}`];
  }

  /**
   * Stop manager state. Spawned proxy processes are detached and externally managed.
   */
  async stop(): Promise<void> {
    this.proxyUrl = null;
  }

  getUrl(): string | null {
    return this.proxyUrl;
  }

  // --- Internal ---

  private async startHeadroomProxy(proxyUrl: string, defaultPort: number): Promise<void> {
    const parsed = new URL(proxyUrl);
    const host = parsed.hostname;
    const port = parsed.port || String(defaultPort);
    const specs = this.buildLaunchSpecs(host, port);
    const errors: string[] = [];

    for (const spec of specs) {
      if (!this.canExecute(spec.checkCommand, spec.checkArgs)) {
        this.logger.debug(`Launcher unavailable: ${spec.label}`);
        continue;
      }

      try {
        const child = spawn(spec.command, spec.args, {
          detached: true,
          stdio: "ignore",
        });
        child.unref();
        this.logger.info(`Auto-start launcher selected: ${spec.label}`);
        return;
      } catch (error) {
        errors.push(`${spec.label}: ${String(error)}`);
      }
    }

    throw new Error(
      "No usable Headroom launcher found. Tried PATH, local npm, global npm, and Python. " +
        "Install headroom-ai (npm or pip) and ensure one launcher is available.\n" +
        (errors.length > 0 ? `Launch errors: ${errors.join("; ")}` : ""),
    );
  }

  private buildLaunchSpecs(host: string, port: string): LaunchSpec[] {
    const commonArgs = ["proxy", "--host", host, "--port", port];
    const specs: LaunchSpec[] = [];

    // 1) PATH
    specs.push({
      label: "PATH: headroom",
      command: "headroom",
      args: commonArgs,
      checkCommand: "headroom",
      checkArgs: ["--version"],
    });

    // 2) Local npm install (inside plugin install path)
    const moduleDir = dirname(fileURLToPath(import.meta.url)); // .../dist
    const packageRoot = dirname(moduleDir);
    const localBinDir = join(packageRoot, "node_modules", ".bin");
    const localBins = process.platform === "win32"
      ? [join(localBinDir, "headroom.cmd"), join(localBinDir, "headroom")]
      : [join(localBinDir, "headroom")];
    for (const localBin of localBins) {
      if (!existsSync(localBin)) continue;
      specs.push({
        label: `Local npm: ${localBin}`,
        command: localBin,
        args: commonArgs,
        checkCommand: localBin,
        checkArgs: ["--version"],
      });
    }

    // 3) Global npm install
    const npmPrefix = this.getNpmGlobalPrefix();
    if (npmPrefix) {
      const globalBins = process.platform === "win32"
        ? [join(npmPrefix, "headroom.cmd"), join(npmPrefix, "headroom")]
        : [join(npmPrefix, "bin", "headroom"), join(npmPrefix, "headroom")];

      for (const globalBin of globalBins) {
        if (!existsSync(globalBin)) continue;
        specs.push({
          label: `Global npm: ${globalBin}`,
          command: globalBin,
          args: commonArgs,
          checkCommand: globalBin,
          checkArgs: ["--version"],
        });
      }
    }

    // 4) Python module fallback
    const pythonCommands = this.getPythonCommands();
    for (const pyCmd of pythonCommands) {
      specs.push({
        label: `Python: ${pyCmd} -m headroom.cli`,
        command: pyCmd,
        args: ["-m", "headroom.cli", ...commonArgs],
        checkCommand: pyCmd,
        checkArgs: ["-c", "import headroom"],
      });
    }

    return specs;
  }

  private getPythonCommands(): string[] {
    const commands: string[] = [];
    const configured = typeof this.config.pythonPath === "string"
      ? this.config.pythonPath.trim()
      : "";
    if (configured.length > 0) {
      commands.push(configured);
    }
    for (const fallback of ["python", "python3", "py"]) {
      if (!commands.includes(fallback)) commands.push(fallback);
    }
    return commands;
  }

  private canExecute(command: string, args: string[]): boolean {
    try {
      const result = spawnSync(command, args, {
        stdio: "ignore",
        timeout: 5000,
      });
      if (result.error) return false;
      return result.status === 0;
    } catch {
      return false;
    }
  }

  private getNpmGlobalPrefix(): string | null {
    try {
      const result = spawnSync("npm", ["prefix", "-g"], {
        encoding: "utf8",
        stdio: ["ignore", "pipe", "ignore"],
        timeout: 5000,
      });
      if (result.error || result.status !== 0) return null;
      const prefix = (result.stdout ?? "").trim();
      return prefix.length > 0 ? prefix : null;
    } catch {
      return null;
    }
  }
}

/** Parse a URL, returning the parsed object or throwing a descriptive error. */
function parseProxyUrl(proxyUrl: string): URL {
  try {
    return new URL(proxyUrl);
  } catch {
    throw new Error(`Invalid proxyUrl: "${proxyUrl}"`);
  }
}

export function normalizeAndValidateProxyUrl(proxyUrl: string): string {
  const parsed = parseProxyUrl(proxyUrl);

  if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
    throw new Error("proxyUrl must use http:// or https://");
  }

  if (parsed.pathname !== "/" || parsed.search || parsed.hash) {
    throw new Error("proxyUrl must not include a path, query, or hash");
  }

  return parsed.origin;
}

/** Returns true if the URL points to a local address (localhost or 127.0.0.1). */
export function isLocalProxyUrl(proxyUrl: string): boolean {
  try {
    const parsed = new URL(proxyUrl);
    return parsed.hostname === "127.0.0.1" || parsed.hostname === "localhost";
  } catch {
    return false;
  }
}

function withDefaultPort(proxyUrl: string, defaultPort: number): string {
  const parsed = parseProxyUrl(proxyUrl);
  if (!parsed.port) {
    parsed.port = String(defaultPort);
  }
  return parsed.origin;
}

/**
 * Probe a configured URL and verify whether it is a running Headroom proxy.
 */
export async function probeHeadroomProxy(proxyUrl: string): Promise<ProxyProbeResult> {
  const origin = normalizeAndValidateProxyUrl(proxyUrl);

  try {
    const health = await fetch(`${origin}/health`, {
      signal: AbortSignal.timeout(3_000),
    });
    if (!health.ok) {
      return { reachable: false, isHeadroom: false, reason: `health HTTP ${health.status}` };
    }
  } catch {
    return { reachable: false, isHeadroom: false, reason: "health check failed" };
  }

  try {
    const retrieveStats = await fetch(`${origin}/v1/retrieve/stats`, {
      signal: AbortSignal.timeout(3_000),
    });
    if (retrieveStats.ok) {
      return { reachable: true, isHeadroom: true };
    }
    return {
      reachable: true,
      isHeadroom: false,
      reason: `retrieve stats HTTP ${retrieveStats.status}`,
    };
  } catch {
    return {
      reachable: true,
      isHeadroom: false,
      reason: "retrieve stats endpoint unavailable",
    };
  }
}

async function waitForHeadroomProxy(proxyUrl: string, timeoutMs: number): Promise<ProxyProbeResult> {
  const start = Date.now();
  while (Date.now() - start < timeoutMs) {
    const result = await probeHeadroomProxy(proxyUrl);
    if (result.reachable && result.isHeadroom) {
      return result;
    }
    await new Promise((resolve) => setTimeout(resolve, 500));
  }
  return probeHeadroomProxy(proxyUrl);
}