pi-free 2.0.14 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/README.md +64 -78
  3. package/banner.svg +21 -36
  4. package/config.ts +123 -9
  5. package/constants.ts +3 -9
  6. package/index.ts +14 -15
  7. package/lib/built-in-toggle.ts +29 -16
  8. package/lib/json-persistence.ts +90 -22
  9. package/lib/logger.ts +21 -12
  10. package/lib/model-detection.ts +2 -12
  11. package/lib/model-enhancer.ts +11 -2
  12. package/lib/model-metadata.ts +387 -0
  13. package/lib/open-browser.ts +74 -24
  14. package/lib/paths.ts +90 -0
  15. package/lib/probe-cache.ts +19 -19
  16. package/lib/provider-cache.ts +74 -28
  17. package/lib/provider-compat.ts +58 -9
  18. package/lib/provider-probe.ts +188 -0
  19. package/lib/registry.ts +1 -5
  20. package/lib/session-start-metrics.ts +46 -0
  21. package/lib/telemetry.ts +115 -86
  22. package/lib/types.ts +22 -2
  23. package/lib/util.ts +80 -21
  24. package/package.json +7 -2
  25. package/provider-failover/benchmark-lookup.ts +17 -5
  26. package/provider-helper.ts +11 -2
  27. package/providers/cline/cline-models.ts +12 -2
  28. package/providers/cline/cline-xml-bridge.ts +974 -0
  29. package/providers/cline/cline.ts +67 -176
  30. package/providers/crofai/crofai.ts +6 -1
  31. package/providers/deepinfra/deepinfra.ts +69 -2
  32. package/providers/dynamic-built-in/index.ts +237 -2
  33. package/providers/kilo/kilo-models.ts +3 -1
  34. package/providers/kilo/kilo.ts +268 -41
  35. package/providers/model-fetcher.ts +18 -55
  36. package/providers/novita/novita.ts +69 -2
  37. package/providers/ollama/ollama.ts +48 -24
  38. package/providers/opencode-session.ts +67 -2
  39. package/providers/routeway/routeway.ts +188 -2
  40. package/providers/sambanova/sambanova.ts +67 -1
  41. package/providers/together/together.ts +69 -2
  42. package/providers/tokenrouter/tokenrouter.ts +378 -0
  43. package/providers/zenmux/zenmux.ts +6 -1
  44. package/scripts/check-extensions.mjs +32 -16
  45. package/providers/nvidia/nvidia.ts +0 -504
package/lib/logger.ts CHANGED
@@ -8,8 +8,9 @@
8
8
  * - Disable file logging: PI_FREE_FILE_LOG=false
9
9
  */
10
10
 
11
- import { appendFileSync, existsSync, mkdirSync } from "node:fs";
12
- import { dirname, join } from "node:path";
11
+ import { appendFileSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { ensureDir, resolveSafeDataFile } from "./paths.ts";
13
14
 
14
15
  export type LogLevel = "debug" | "info" | "warn" | "error";
15
16
 
@@ -29,10 +30,23 @@ const LOG_LEVELS: Record<LogLevel, number> = {
29
30
  };
30
31
 
31
32
  // Console default: error-only. Set LOG_LEVEL=debug or LOG_LEVEL=info to see more.
32
- const currentLevel: LogLevel = (process.env.LOG_LEVEL as LogLevel) || "error";
33
33
  // File default: debug (so we can inspect full behavior in ~/.pi/free.log).
34
- const fileLevel: LogLevel =
35
- (process.env.PI_FREE_LOG_LEVEL as LogLevel) || "debug";
34
+ const VALID_LEVELS = new Set<LogLevel>(["debug", "info", "warn", "error"]);
35
+
36
+ function parseLogLevel(
37
+ envValue: string | undefined,
38
+ defaultLevel: LogLevel,
39
+ ): LogLevel {
40
+ if (!envValue) return defaultLevel;
41
+ const normalized = envValue.toLowerCase() as LogLevel;
42
+ return VALID_LEVELS.has(normalized) ? normalized : defaultLevel;
43
+ }
44
+
45
+ const currentLevel: LogLevel = parseLogLevel(process.env.LOG_LEVEL, "error");
46
+ const fileLevel: LogLevel = parseLogLevel(
47
+ process.env.PI_FREE_LOG_LEVEL,
48
+ "debug",
49
+ );
36
50
 
37
51
  function shouldLog(level: LogLevel, minLevel: LogLevel): boolean {
38
52
  return LOG_LEVELS[level] >= LOG_LEVELS[minLevel];
@@ -50,18 +64,13 @@ function formatMessage(entry: LogEntry): string {
50
64
  return `[${entry.timestamp}] [${entry.level.toUpperCase()}] [${entry.namespace}] ${entry.message}${data}`;
51
65
  }
52
66
 
53
- const HOME_DIR = process.env.HOME || process.env.USERPROFILE || "";
54
- const DEFAULT_LOG_PATH = join(HOME_DIR, ".pi", "free.log");
55
- const LOG_PATH = process.env.PI_FREE_LOG_PATH || DEFAULT_LOG_PATH;
67
+ const LOG_PATH = resolveSafeDataFile(process.env.PI_FREE_LOG_PATH, "free.log");
56
68
  const FILE_LOG_ENABLED = process.env.PI_FREE_FILE_LOG !== "false";
57
69
 
58
70
  function appendToFile(line: string): void {
59
71
  if (!FILE_LOG_ENABLED) return;
60
72
  try {
61
- const dir = dirname(LOG_PATH);
62
- if (!existsSync(dir)) {
63
- mkdirSync(dir, { recursive: true });
64
- }
73
+ ensureDir(join(LOG_PATH, ".."));
65
74
  appendFileSync(LOG_PATH, `${line}\n`, "utf8");
66
75
  } catch (err) {
67
76
  console.error("Failed to write to log file:", err);
@@ -22,16 +22,6 @@ export interface ModelFamily {
22
22
  models: ModelInfo[]; // All models in this family
23
23
  }
24
24
 
25
- /**
26
- * Check if a model is free (zero input and output cost)
27
- */
28
- export function isModelFree(model: {
29
- cost?: { input: number; output: number };
30
- }): boolean {
31
- if (!model.cost) return true;
32
- return model.cost.input === 0 && model.cost.output === 0;
33
- }
34
-
35
25
  /**
36
26
  * Convert Pi's Model type to ModelInfo for internal use
37
27
  */
@@ -40,7 +30,7 @@ export function toModelInfo(model: Model<any>): ModelInfo {
40
30
  id: model.id,
41
31
  name: model.name,
42
32
  provider: model.provider,
43
- isFree: isModelFree(model),
33
+ isFree: !model.cost || (model.cost.input === 0 && model.cost.output === 0),
44
34
  inputCost: model.cost?.input ?? 0,
45
35
  outputCost: model.cost?.output ?? 0,
46
36
  };
@@ -54,7 +44,7 @@ export function toProviderModelInfo(model: ProviderModelConfig): ModelInfo {
54
44
  id: model.id,
55
45
  name: model.name,
56
46
  provider: "", // Will be set by caller
57
- isFree: isModelFree(model),
47
+ isFree: !model.cost || (model.cost.input === 0 && model.cost.output === 0),
58
48
  inputCost: model.cost?.input ?? 0,
59
49
  outputCost: model.cost?.output ?? 0,
60
50
  };
@@ -6,15 +6,24 @@
6
6
  import type { ProviderModelConfig } from "@earendil-works/pi-coding-agent";
7
7
  import { enhanceModelNameWithCodingIndex } from "../provider-failover/benchmark-lookup.ts";
8
8
 
9
+ interface ModelsDevEnrichedMetadata {
10
+ modelsDev?: Parameters<typeof enhanceModelNameWithCodingIndex>[3];
11
+ }
12
+
9
13
  /**
10
14
  * Enhance model names with Coding Index scores
11
15
  * Use this before registering providers to show CI in /model list
12
16
  */
13
17
  export function enhanceModelsWithCodingIndex(
14
- models: ProviderModelConfig[],
18
+ models: Array<ProviderModelConfig & ModelsDevEnrichedMetadata>,
15
19
  ): ProviderModelConfig[] {
16
20
  return models.map((m) => ({
17
21
  ...m,
18
- name: enhanceModelNameWithCodingIndex(m.name, m.id),
22
+ name: enhanceModelNameWithCodingIndex(
23
+ m.name,
24
+ m.id,
25
+ undefined,
26
+ m.modelsDev,
27
+ ),
19
28
  }));
20
29
  }
@@ -0,0 +1,387 @@
1
+ import type { ProviderModelConfig } from "@earendil-works/pi-coding-agent";
2
+ import { DEFAULT_FETCH_TIMEOUT_MS, URL_MODELS_DEV } from "../constants.ts";
3
+ import { createLogger } from "./logger.ts";
4
+ import { getProxyModelCompat } from "./provider-compat.ts";
5
+ import type {
6
+ CostConfig,
7
+ ModelIdentity,
8
+ ModelMatchHints,
9
+ ModelsDevEnrichedMetadata,
10
+ ModelsDevModel,
11
+ ModelsDevProvider,
12
+ } from "./types.ts";
13
+
14
+ const DEFAULT_CONTEXT_WINDOW = 128_000;
15
+ const DEFAULT_MAX_TOKENS = 16_384;
16
+ const MODELS_DEV_CACHE_TTL_MS = 5 * 60 * 1000;
17
+ const MODELS_DEV_RETRIES = 3;
18
+ const MODELS_DEV_RETRY_DELAY_MS = 250;
19
+ const MODELS_DEV_PROVIDER_ALIASES: Record<string, string> = {
20
+ together: "togetherai",
21
+ novita: "novita-ai",
22
+ };
23
+
24
+ const _logger = createLogger("model-metadata");
25
+
26
+ type ThinkingLevelMap = NonNullable<ProviderModelConfig["thinkingLevelMap"]>;
27
+ type ModelCompat = NonNullable<ProviderModelConfig["compat"]>;
28
+
29
+ let catalogCache:
30
+ | {
31
+ expiresAt: number;
32
+ promise: Promise<Record<string, ModelsDevProvider>>;
33
+ }
34
+ | undefined;
35
+
36
+ function sleep(ms: number): Promise<void> {
37
+ return new Promise((resolve) => setTimeout(resolve, ms));
38
+ }
39
+
40
+ export function clearModelsDevMetaCache(): void {
41
+ catalogCache = undefined;
42
+ }
43
+
44
+ function errorMessage(error: unknown): string {
45
+ return error instanceof Error ? error.message : String(error);
46
+ }
47
+
48
+ async function fetchModelsDevCatalog(): Promise<Record<string, ModelsDevProvider>> {
49
+ let lastError: unknown;
50
+
51
+ for (let attempt = 1; attempt <= MODELS_DEV_RETRIES; attempt++) {
52
+ try {
53
+ const response = await fetch(URL_MODELS_DEV, {
54
+ headers: { "User-Agent": "pi-free-providers" },
55
+ signal: AbortSignal.timeout(DEFAULT_FETCH_TIMEOUT_MS),
56
+ });
57
+ if (response.ok) {
58
+ return (await response.json()) as Record<string, ModelsDevProvider>;
59
+ }
60
+
61
+ lastError = new Error(
62
+ `HTTP ${response.status} ${response.statusText}`.trim(),
63
+ );
64
+ } catch (error) {
65
+ lastError = error;
66
+ }
67
+
68
+ if (attempt < MODELS_DEV_RETRIES) {
69
+ await sleep(MODELS_DEV_RETRY_DELAY_MS);
70
+ }
71
+ }
72
+
73
+ _logger.warn("Failed to fetch models.dev metadata", {
74
+ error: errorMessage(lastError),
75
+ });
76
+ return {};
77
+ }
78
+
79
+ function getModelsDevCatalog(): Promise<Record<string, ModelsDevProvider>> {
80
+ const now = Date.now();
81
+ if (catalogCache && catalogCache.expiresAt > now) {
82
+ return catalogCache.promise;
83
+ }
84
+
85
+ const promise = fetchModelsDevCatalog().catch((error) => {
86
+ catalogCache = undefined;
87
+ _logger.warn("Failed to load models.dev metadata", {
88
+ error: errorMessage(error),
89
+ });
90
+ return {};
91
+ });
92
+ catalogCache = {
93
+ expiresAt: now + MODELS_DEV_CACHE_TTL_MS,
94
+ promise,
95
+ };
96
+ return promise;
97
+ }
98
+
99
+ function collectAllModels(
100
+ catalog: Record<string, ModelsDevProvider>,
101
+ ): Record<string, ModelsDevModel> {
102
+ const allModels: Record<string, ModelsDevModel> = {};
103
+ for (const [providerKey, provider] of Object.entries(catalog)) {
104
+ for (const [modelId, model] of Object.entries(provider.models ?? {})) {
105
+ allModels[`${provider.id ?? providerKey}/${modelId}`] = model;
106
+ }
107
+ }
108
+ return allModels;
109
+ }
110
+
111
+ function hasModels(
112
+ models: Record<string, ModelsDevModel> | undefined,
113
+ ): models is Record<string, ModelsDevModel> {
114
+ return models !== undefined && Object.keys(models).length > 0;
115
+ }
116
+
117
+ function findProviderModels(
118
+ catalog: Record<string, ModelsDevProvider>,
119
+ providerId: string,
120
+ ): Record<string, ModelsDevModel> | undefined {
121
+ const ids = new Set(
122
+ [providerId, MODELS_DEV_PROVIDER_ALIASES[providerId]].filter(
123
+ (id): id is string => Boolean(id),
124
+ ),
125
+ );
126
+
127
+ for (const id of ids) {
128
+ const directModels = catalog[id]?.models;
129
+ if (hasModels(directModels)) return directModels;
130
+ }
131
+
132
+ for (const provider of Object.values(catalog)) {
133
+ if (provider?.id && ids.has(provider.id) && hasModels(provider.models)) {
134
+ return provider.models;
135
+ }
136
+ }
137
+
138
+ return undefined;
139
+ }
140
+
141
+ export async function fetchModelsDevMeta(
142
+ providerId?: string,
143
+ ): Promise<Record<string, ModelsDevModel>> {
144
+ const catalog = await getModelsDevCatalog();
145
+ if (providerId) {
146
+ const scopedModels = findProviderModels(catalog, providerId);
147
+ if (scopedModels) return scopedModels;
148
+ }
149
+
150
+ return collectAllModels(catalog);
151
+ }
152
+
153
+ function normalizeModelKey(id: string): string {
154
+ return id
155
+ .toLowerCase()
156
+ .replace(/^~/, "")
157
+ .replace(/:free$/, "")
158
+ .replace(/-free$/, "");
159
+ }
160
+
161
+ function buildModelMetaIndex(
162
+ meta: Record<string, ModelsDevModel>,
163
+ ): Map<string, ModelsDevModel> {
164
+ const index = new Map<string, ModelsDevModel>();
165
+ for (const [key, model] of Object.entries(meta)) {
166
+ for (const value of [key, model.id, key.split("/").pop()]) {
167
+ if (value) index.set(normalizeModelKey(value), model);
168
+ }
169
+ }
170
+ return index;
171
+ }
172
+
173
+ function findModelDevMeta(
174
+ index: Map<string, ModelsDevModel>,
175
+ modelId: string,
176
+ ): ModelsDevModel | undefined {
177
+ const id = normalizeModelKey(modelId);
178
+ return index.get(id) ?? index.get(id.split("/").pop() ?? id);
179
+ }
180
+
181
+ function isTextOnly(input: ProviderModelConfig["input"]): boolean {
182
+ return input.length === 1 && input[0] === "text";
183
+ }
184
+
185
+ function costLooksLikeFallback(cost: CostConfig): boolean {
186
+ return (
187
+ cost.input === 0 &&
188
+ cost.output === 0 &&
189
+ cost.cacheRead === 0 &&
190
+ cost.cacheWrite === 0
191
+ );
192
+ }
193
+
194
+ function costFromModelsDev(
195
+ cost: ModelsDevModel["cost"],
196
+ ): CostConfig | undefined {
197
+ if (!cost) return undefined;
198
+ return {
199
+ input: cost.input / 1_000_000,
200
+ output: cost.output / 1_000_000,
201
+ cacheRead: (cost.cache_read ?? 0) / 1_000_000,
202
+ cacheWrite: (cost.cache_write ?? 0) / 1_000_000,
203
+ };
204
+ }
205
+
206
+ function thinkingMapFromReasoningOptions(
207
+ options: ModelsDevModel["reasoning_options"],
208
+ ): ThinkingLevelMap | undefined {
209
+ const effort = options?.find((option) => option.type === "effort");
210
+ if (!effort?.values?.length) return undefined;
211
+
212
+ const values = new Set(effort.values);
213
+ return {
214
+ off: values.has("none") ? "none" : null,
215
+ minimal: values.has("minimal") ? "minimal" : null,
216
+ low: values.has("low") ? "low" : null,
217
+ medium: values.has("medium") ? "medium" : null,
218
+ high: values.has("high") ? "high" : null,
219
+ xhigh: values.has("xhigh") ? "xhigh" : values.has("max") ? "max" : null,
220
+ };
221
+ }
222
+
223
+ function identityFromMeta(
224
+ model: ProviderModelConfig,
225
+ meta: ModelsDevModel,
226
+ ): ModelIdentity {
227
+ return {
228
+ id: [model.id, meta.id, meta.family, meta.provider]
229
+ .filter(Boolean)
230
+ .join(" "),
231
+ name: [model.name, meta.name].filter(Boolean).join(" "),
232
+ family: meta.family,
233
+ provider: meta.provider,
234
+ };
235
+ }
236
+
237
+ function mergeCompat(
238
+ existing: ProviderModelConfig["compat"],
239
+ derived: ProviderModelConfig["compat"],
240
+ ): ProviderModelConfig["compat"] | undefined {
241
+ if (!existing) return derived;
242
+ if (!derived) return existing;
243
+ return { ...(derived as ModelCompat), ...(existing as ModelCompat) };
244
+ }
245
+
246
+ export interface ModelsDevEnrichmentOptions {
247
+ /** Provider id to scope models.dev lookup. Omit to search all providers. */
248
+ providerId?: string;
249
+ /** Values treated as provider defaults and safe to replace from models.dev. */
250
+ fallbackContextWindows?: number[];
251
+ fallbackMaxTokens?: number[];
252
+ /** Fill image modality when the provider exposed text-only fallback. */
253
+ enrichInput?: boolean;
254
+ /** Fill reasoning flag and effort map from models.dev. */
255
+ enrichReasoning?: boolean;
256
+ /** Fill cost only when explicitly enabled and current cost is all-zero fallback. */
257
+ enrichCost?: "never" | "fallback-only";
258
+ /** Add model/family compat without overwriting existing compat keys. */
259
+ enrichCompat?: boolean;
260
+ }
261
+
262
+ interface EnrichmentContext {
263
+ index: Map<string, ModelsDevModel>;
264
+ fallbackContextWindows: Set<number>;
265
+ fallbackMaxTokens: Set<number>;
266
+ enrichInput: boolean;
267
+ enrichReasoning: boolean;
268
+ enrichCost: "never" | "fallback-only";
269
+ enrichCompat: boolean;
270
+ }
271
+
272
+ function enrichModel<T extends ProviderModelConfig>(
273
+ model: T,
274
+ ctx: EnrichmentContext,
275
+ ): T & ModelsDevEnrichedMetadata {
276
+ const modelMeta = findModelDevMeta(ctx.index, model.id);
277
+ if (!modelMeta) return model;
278
+
279
+ const contextWindow =
280
+ modelMeta.limit && ctx.fallbackContextWindows.has(model.contextWindow)
281
+ ? modelMeta.limit.context
282
+ : model.contextWindow;
283
+ const maxTokens =
284
+ modelMeta.limit && ctx.fallbackMaxTokens.has(model.maxTokens)
285
+ ? modelMeta.limit.output
286
+ : model.maxTokens;
287
+ const input =
288
+ ctx.enrichInput &&
289
+ isTextOnly(model.input) &&
290
+ modelMeta.modalities?.input?.includes("image")
291
+ ? (["text", "image"] as const)
292
+ : model.input;
293
+ const reasoning =
294
+ ctx.enrichReasoning && modelMeta.reasoning === true ? true : model.reasoning;
295
+ const thinkingLevelMap =
296
+ ctx.enrichReasoning && model.thinkingLevelMap === undefined
297
+ ? thinkingMapFromReasoningOptions(modelMeta.reasoning_options)
298
+ : model.thinkingLevelMap;
299
+ const cost =
300
+ ctx.enrichCost === "fallback-only" && costLooksLikeFallback(model.cost)
301
+ ? (costFromModelsDev(modelMeta.cost) ?? model.cost)
302
+ : model.cost;
303
+ const compat = ctx.enrichCompat
304
+ ? mergeCompat(model.compat, getProxyModelCompat(identityFromMeta(model, modelMeta)))
305
+ : model.compat;
306
+
307
+ const modelsDevMetadata: ModelMatchHints = {
308
+ id: modelMeta.id,
309
+ name: modelMeta.name,
310
+ ...(modelMeta.family ? { family: modelMeta.family } : {}),
311
+ ...(modelMeta.provider ? { provider: modelMeta.provider } : {}),
312
+ };
313
+
314
+ return {
315
+ ...model,
316
+ contextWindow,
317
+ maxTokens,
318
+ input,
319
+ reasoning,
320
+ ...(thinkingLevelMap ? { thinkingLevelMap } : {}),
321
+ cost,
322
+ ...(compat ? { compat } : {}),
323
+ modelsDev: modelsDevMetadata,
324
+ };
325
+ }
326
+
327
+ /**
328
+ * Fill Pi-usable model fields from models.dev when provider APIs only expose
329
+ * generic defaults. Fail-open: network/API failures leave models unchanged.
330
+ */
331
+ export async function enrichModelsWithModelsDev<T extends ProviderModelConfig>(
332
+ models: T[],
333
+ options: ModelsDevEnrichmentOptions = {},
334
+ ): Promise<Array<T & ModelsDevEnrichedMetadata>> {
335
+ if (models.length === 0) return models;
336
+
337
+ let meta: Record<string, ModelsDevModel>;
338
+ try {
339
+ meta = await fetchModelsDevMeta(options.providerId);
340
+ } catch (error) {
341
+ _logger.warn("Failed to load models.dev metadata", {
342
+ providerId: options.providerId,
343
+ error: errorMessage(error),
344
+ });
345
+ return models;
346
+ }
347
+ if (Object.keys(meta).length === 0) return models;
348
+
349
+ const ctx: EnrichmentContext = {
350
+ index: buildModelMetaIndex(meta),
351
+ fallbackContextWindows: new Set(
352
+ options.fallbackContextWindows ?? [DEFAULT_CONTEXT_WINDOW, 4096],
353
+ ),
354
+ fallbackMaxTokens: new Set(
355
+ options.fallbackMaxTokens ?? [DEFAULT_MAX_TOKENS, 4096],
356
+ ),
357
+ enrichInput: options.enrichInput ?? true,
358
+ enrichReasoning: options.enrichReasoning ?? true,
359
+ enrichCost: options.enrichCost ?? "never",
360
+ enrichCompat: options.enrichCompat ?? true,
361
+ };
362
+
363
+ return models.map((model) => {
364
+ try {
365
+ return enrichModel(model, ctx);
366
+ } catch (error) {
367
+ _logger.warn("Failed to enrich model from models.dev metadata", {
368
+ modelId: model.id,
369
+ error: errorMessage(error),
370
+ });
371
+ return model;
372
+ }
373
+ });
374
+ }
375
+
376
+ export async function safeEnrichModelsWithModelsDev<
377
+ T extends ProviderModelConfig,
378
+ >(
379
+ models: T[],
380
+ options: ModelsDevEnrichmentOptions = {},
381
+ ): Promise<Array<T & ModelsDevEnrichedMetadata>> {
382
+ try {
383
+ return await enrichModelsWithModelsDev(models, options);
384
+ } catch {
385
+ return models;
386
+ }
387
+ }
@@ -1,16 +1,59 @@
1
1
  /**
2
2
  * Cross-platform browser opener
3
3
  *
4
- * Opens a URL in the user's default browser. Handles URL-unsafe characters
5
- * on Windows by using PowerShell's Start-Process instead of cmd.exe.
4
+ * Opens a URL in the user's default browser.
5
+ *
6
+ * SECURITY:
7
+ * - On Windows, uses `rundll32 url.dll,FileProtocolHandler <url>` — this
8
+ * bypasses cmd.exe's command parser entirely. cmd's `start` builtin
9
+ * interprets shell metacharacters (`&`, `|`, `^`, etc.) BEFORE the URL
10
+ * reaches its target, so `cmd /c start "" <url>` is exploitable even
11
+ * with `shell: false` and discrete args (CodeQL js/uncontrolled-command-line).
12
+ * rundll32 doesn't parse the command line, so the URL is handed to
13
+ * ShellExecute as a literal.
14
+ * - On all platforms, URLs are strictly validated: only http/https, no
15
+ * control characters. This is defense-in-depth.
16
+ * - The URL is always passed as a single argument to the underlying
17
+ * launcher — never interpolated into a command string.
18
+ *
19
+ * Platforms:
20
+ * - Windows: `rundll32 url.dll,FileProtocolHandler` → ShellExecute
21
+ * - macOS: `/usr/bin/open`
22
+ * - Linux: `/usr/bin/xdg-open`
6
23
  */
7
24
 
8
25
  import { execFileSync, spawn } from "node:child_process";
9
26
  import { existsSync } from "node:fs";
27
+ import { createLogger } from "./logger.ts";
28
+
29
+ const _logger = createLogger("open-browser");
10
30
 
11
31
  /**
12
- * Resolve an executable path, preferring the known absolute path if it exists,
13
- * falling back to PATH lookup. This avoids relying on an untrusted PATH variable.
32
+ * Validate that a URL is safe to open.
33
+ * Only http/https protocols are allowed. URLs with control characters
34
+ * are rejected.
35
+ */
36
+ function isSafeUrl(url: string): boolean {
37
+ if (typeof url !== "string" || url.length === 0 || url.length > 2048) {
38
+ return false;
39
+ }
40
+ // Reject any control characters (NUL, CR, LF, etc.) — they have no
41
+ // place in a URL and can confuse shell parsers.
42
+ // eslint-disable-next-line no-control-regex
43
+ if (/[\x00-\x1f\x7f]/.test(url)) return false;
44
+ let parsed: URL;
45
+ try {
46
+ parsed = new URL(url);
47
+ } catch {
48
+ return false;
49
+ }
50
+ return parsed.protocol === "http:" || parsed.protocol === "https:";
51
+ }
52
+
53
+ /**
54
+ * Resolve an executable path, preferring the known absolute path if it
55
+ * exists, falling back to PATH lookup. This avoids relying on an
56
+ * untrusted PATH variable.
14
57
  */
15
58
  function resolveExe(name: string, absolutePath: string): string {
16
59
  if (absolutePath && existsSync(absolutePath)) {
@@ -31,29 +74,32 @@ function resolveExe(name: string, absolutePath: string): string {
31
74
  /**
32
75
  * Open a URL in the user's default browser.
33
76
  *
34
- * - Windows: uses PowerShell Start-Process (cmd.exe interprets & as command separator)
35
- * - macOS: uses `open`
36
- * - Linux/BSD: uses `xdg-open`
77
+ * Returns true if the URL was accepted and the launcher was spawned,
78
+ * false if the URL was rejected by validation.
37
79
  */
38
- export function openBrowser(url: string): void {
80
+ export function openBrowser(url: string): boolean {
81
+ if (!isSafeUrl(url)) {
82
+ _logger.warn("openBrowser: rejected URL", { url });
83
+ return false;
84
+ }
85
+
39
86
  try {
40
87
  if (process.platform === "win32") {
41
- // PowerShell's Start-Process treats the URL as a literal string,
42
- // unlike cmd.exe which interprets & as a command separator.
43
- const powershell = resolveExe(
44
- "powershell.exe",
45
- "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe",
88
+ // rundll32 url.dll,FileProtocolHandler invokes ShellExecute
89
+ // with the URL, which opens it in the user's default browser.
90
+ // Unlike `cmd /c start "" <url>`, rundll32 does NOT parse the
91
+ // command line — the URL is handed to ShellExecute as a
92
+ // literal argument. This is the canonical safe pattern and
93
+ // addresses the CodeQL "Uncontrolled command line" finding.
94
+ const rundll32 = resolveExe(
95
+ "rundll32.exe",
96
+ "C:\\Windows\\System32\\rundll32.exe",
46
97
  );
47
- spawn(
48
- powershell,
49
- [
50
- "-NoProfile",
51
- "-NonInteractive",
52
- "-Command",
53
- `Start-Process "${url.replaceAll(/[\\"]/g, "\\$&")}"`,
54
- ],
55
- { detached: true, shell: false, windowsHide: true },
56
- ).unref();
98
+ spawn(rundll32, ["url.dll,FileProtocolHandler", url], {
99
+ detached: true,
100
+ shell: false,
101
+ windowsHide: true,
102
+ }).unref();
57
103
  } else if (process.platform === "darwin") {
58
104
  const open = resolveExe("open", "/usr/bin/open");
59
105
  spawn(open, [url], { detached: true }).unref();
@@ -61,8 +107,12 @@ export function openBrowser(url: string): void {
61
107
  const xdgOpen = resolveExe("xdg-open", "/usr/bin/xdg-open");
62
108
  spawn(xdgOpen, [url], { detached: true }).unref();
63
109
  }
110
+ return true;
64
111
  } catch (err) {
65
112
  // Best-effort — browser opening is non-critical
66
- console.debug("Failed to open browser:", err);
113
+ _logger.warn("Failed to open browser", {
114
+ error: err instanceof Error ? err.message : String(err),
115
+ });
116
+ return false;
67
117
  }
68
118
  }