Ctrl K
ComunidadGuías…

utils/normalizeEnv.ts

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

Archivo original ↗Preguntar a KiBot
import { parseCustomHeaders } from "../models.ts";
import { log } from "./cli.ts";
import { maskSecret } from "./secretCommands.ts";
import { isSensitiveEnvName } from "./secrets.ts";

/**
 * Trim surrounding whitespace from a sensitive value and register it as a
 * GitHub Actions log mask. Trailing newlines from terminal-copy paste are a
 * common footgun: the value travels through GH Actions logs and any tool
 * that re-emits parts of it leaks the unmasked tail. Trimming canonicalises
 * the value so the mask matches exactly what downstream tools will print.
 *
 * Masking is delegated to `maskSecret` (not raw `console.log`) so the
 * toolkit percent-encodes `\r`/`\n`; the runner V2 parser decodes them and
 * registers the full value plus every non-empty line as separate masks. That
 * keeps us safe for embedded-newline values (PEMs, kubeconfigs, JSON blobs)
 * even though they aren't currently used.
 *
 * Returns the trimmed value, or `null` when the input was whitespace-only —
 * callers must leave `process.env` untouched in that case so a misconfigured
 * value surfaces as a clear "missing key" downstream rather than silently
 * mutating to the empty string.
 */
/** C0 controls + DEL — the bytes an HTTP header value cannot carry. */
export function hasControlCharacter(value: string): boolean {
  return [...value].some((ch) => {
    const code = ch.charCodeAt(0);
    return code < 0x20 || code === 0x7f;
  });
}

export function sanitizeSecret(key: string, value: string): string | null {
  const trimmed = value.trim();
  if (trimmed.length === 0) {
    log.warning(
      `» ${key} is whitespace-only — leaving env var unchanged. check your secret value.`
    );
    return null;
  }
  // an interior control character can never be sent in an HTTP header, so the
  // provider rejects the request with `Header 'N' has invalid value` and the
  // run dies ~1s in — we already know here that it cannot succeed (#1162).
  // almost always a line-wrapped paste, and silently stripping would ship half
  // a key with a worse error, so refuse and let the missing-key path name the
  // fix. scoped to `*_API_KEY` because JSON-blob credentials (CODEX_AUTH_JSON)
  // legitimately carry newlines.
  if (key.endsWith("_API_KEY") && hasControlCharacter(trimmed)) {
    log.warning(
      `» ${key} contains a line break or control character and cannot be sent as an HTTP header — ` +
        "leaving env var unchanged. re-save the secret as a single line."
    );
    return null;
  }
  if (trimmed !== value) {
    log.warning(
      `» stripped whitespace from ${key} (whitespace in secret values breaks GitHub Actions log masking)`
    );
  }
  maskSecret(trimmed);
  return trimmed;
}

/**
 * The request headers `envVar` holds, for a harness config. Each value is masked on its own: the
 * runner masks the variable whole and line by line, and neither matches a value printed apart from
 * its name. Under 8 characters is no credential, and masking `X-Tenant: 1` would blank every 1.
 */
export function readCustomHeaders(envVar: string): Record<string, string> {
  const parsed = parseCustomHeaders(process.env[envVar] ?? "");
  if (parsed.badLine) {
    throw new Error(
      `Line ${parsed.badLine} of \`${envVar}\` is not a header. Write one \`Name: Value\` per line, in ASCII. ` +
        "[Custom headers guide](https://docs.pullfrog.com/keys#custom-headers)"
    );
  }
  for (const value of Object.values(parsed.headers)) {
    if (value.length >= 8) maskSecret(value);
  }
  return parsed.headers;
}

/**
 * Normalize environment variables to uppercase.
 * This handles case-insensitive env var names (e.g., `anthropic_api_key` -> `ANTHROPIC_API_KEY`).
 *
 * If there are conflicts (same key with different capitalizations but different values),
 * logs a warning and keeps the uppercase version.
 *
 * Also trims and masks sensitive values so accidental trailing whitespace
 * doesn't defeat GitHub Actions log masking.
 */
export function normalizeEnv(): void {
  const upperKeys = new Map<string, string[]>();

  // group keys by their uppercase form
  for (const key of Object.keys(process.env)) {
    const upper = key.toUpperCase();
    const existing = upperKeys.get(upper) || [];
    existing.push(key);
    upperKeys.set(upper, existing);
  }

  // process each group
  for (const [upperKey, keys] of upperKeys) {
    if (keys.length === 1) {
      const key = keys[0];
      if (key !== upperKey) {
        // single key, just needs uppercasing
        process.env[upperKey] = process.env[key];
        delete process.env[key];
      }
      continue;
    }

    // multiple keys with different capitalizations
    const values = keys.map((k) => process.env[k]);
    const uniqueValues = new Set(values);

    if (uniqueValues.size > 1) {
      // conflict: different values for different capitalizations
      log.warning(
        `env var conflict: ${keys.join(", ")} have different values. using uppercase ${upperKey}.`
      );
    }

    // prefer the uppercase version if it exists, otherwise use the first one
    const preferredKey = keys.find((k) => k === upperKey) || keys[0];
    const preferredValue = process.env[preferredKey];

    // delete all variants
    for (const key of keys) {
      delete process.env[key];
    }

    // set the uppercase version
    process.env[upperKey] = preferredValue;
  }

  // trim + mask sensitive values after case normalisation so each key is
  // visited exactly once with its final, canonical value
  for (const key of Object.keys(process.env)) {
    if (!isSensitiveEnvName(key)) continue;
    const value = process.env[key];
    if (typeof value !== "string" || value.length === 0) continue;
    const sanitized = sanitizeSecret(key, value);
    if (sanitized !== null) process.env[key] = sanitized;
  }
}