{"version":3,"sources":["../src/cli/utils/governance/config.ts"],"sourcesContent":["/**\n * Persists langwatch CLI governance credentials at\n * ~/.langwatch/config.json. The file is mode 0600 (atomic rename\n * on save). The shape mirrors what `POST /api/auth/cli/exchange`\n * returns plus a few client-side fields.\n */\n\nimport * as fs from \"node:fs\";\nimport * as os from \"node:os\";\nimport * as path from \"node:path\";\n\nimport type { PlatformToolPolicyMap } from \"./platform-tool-policy\";\n\nexport interface GovernanceConfig {\n  /** AI Gateway base URL (e.g. https://gateway.langwatch.ai). */\n  gateway_url: string;\n  /** Control plane base URL (e.g. https://app.langwatch.ai). */\n  control_plane_url: string;\n\n  /** Short-lived bearer for the gateway. */\n  access_token?: string;\n  /** Long-lived token for refreshing access_token. */\n  refresh_token?: string;\n  /** Unix epoch (seconds) when access_token expires. */\n  expires_at?: number;\n\n  user?: { id?: string; email?: string; name?: string };\n  organization?: { id?: string; slug?: string; name?: string };\n  default_personal_vk?: { id?: string; secret?: string; prefix?: string };\n\n  /**\n   * Personal ingest keys (the project-scoped ingest-only ApiKey\n   * `sk-lw-<...>` shape minted by `/api/auth/cli/governance/ingestion-key`),\n   * keyed by the tool's source_type slug (`claude_code` / `codex` /\n   * `gemini` / `opencode`). One key per source so different wrapped\n   * tools surface as their own ingestion source in /me + /messages.\n   *\n   * When the right key is present for a wrapped tool, the\n   * `langwatch <tool>` wrapper injects the standard OTEL_*_EXPORTER\n   * env vars pointing at the OTLP endpoint with this key as the\n   * Authorization bearer (reusing the cache instead of re-minting).\n   *\n   * Unset until the wrapper's first auto-mint for that tool.\n   */\n  default_personal_ingest_keys?: Record<\n    string,\n    { id?: string; secret?: string; prefix?: string }\n  >;\n\n  /**\n   * Persistent answer to the post-login \"save export block to your\n   * shell rc?\" prompt. `skip` = user picked \"never\", stay quiet\n   * forever. `undefined` = ask each login that lands in an\n   * unconfigured shell. The \"not now\" answer doesn't persist — it\n   * lets the next login re-ask on its own.\n   */\n  shell_rc_preference?: \"skip\";\n\n  /**\n   * Per-wrapped-tool routing mode answer.\n   *\n   *   \"gateway\"   — Path A: route the tool's HTTP calls through\n   *                  the AI Gateway via base-URL swap (full server-\n   *                  side I/O + cost capture, no client OTel).\n   *   \"ingestion\" — Path B: enable the tool's native OTel exporter\n   *                  pointed at /api/otel + mint the user's personal\n   *                  ingest key (sk-lw-*). For codex this also writes\n   *                  the [otel] block to ~/.codex/config.toml\n   *                  automatically.\n   *   \"ask\"       — re-prompt on the next `langwatch <tool>`. The\n   *                  default when this key is absent.\n   *\n   * The two modes are mutually exclusive per the no-double-trace\n   * rule — gateway capture + OTel emission on the same call would\n   * double-count both traces and cost. The wrapper picks Path A\n   * by default when a personal VK is configured, and falls back\n   * to Path B when no VK + the user opts in.\n   */\n  tool_mode?: Record<string, \"gateway\" | \"ingestion\" | \"ask\">;\n\n  /**\n   * Per-(org, tool) path policy cached from the login bootstrap\n   * (`/api/auth/cli/bootstrap` → `toolPolicies`). The `langwatch\n   * <tool>` wrapper gates path selection on this map so it only\n   * offers the paths the org admin permits. Absent for a legacy /\n   * offline CLI that never cached it; `resolvePlatformToolPolicy`\n   * then falls back to the hardcoded defaults.\n   */\n  tool_policies?: PlatformToolPolicyMap;\n\n  /**\n   * Most-recent signed `request_increase_url` returned by the\n   * gateway in a 402 budget_exceeded payload — cached so\n   * `langwatch request-increase` opens the exact URL the gateway\n   * produced (with HMAC'd user/limit/spent params).\n   */\n  last_request_increase_url?: string;\n}\n\nfunction defaults(): GovernanceConfig {\n  // Note: the single source of truth for endpoint resolution at command\n  // boundaries is `resolveControlPlaneEndpoint()` in resolveEndpoint.ts.\n  // This function only seeds the *initial* GovernanceConfig shape when\n  // no file exists yet — at boot, before the user has logged in.\n  // `LANGWATCH_URL` legacy alias intentionally NOT read (was undocumented;\n  // dropped per rchaves directive 2026-05-05).\n  const cp = process.env.LANGWATCH_ENDPOINT ?? \"https://app.langwatch.ai\";\n  const explicitGw = process.env.LANGWATCH_GATEWAY_URL;\n  // Self-hosted detection: when the user pointed `LANGWATCH_ENDPOINT` at\n  // localhost (the standard `make dev` shape) and didn't override the\n  // gateway URL, default to the local AI gateway port (5563 per\n  // langwatch/CLAUDE.md `make service svc=aigateway`). Without this,\n  // `langwatch login` + `whoami` printed the production gateway URL on\n  // self-hosted installs and the user's `langwatch claude` calls would\n  // route at the wrong place (Ariana QA — same shape as the /me tile\n  // base-URL bug Sergey c45e69987 / Alexis 30e52a718 fixed on the\n  // control-plane side).\n  const gw =\n    explicitGw ??\n    (/^https?:\\/\\/(localhost|127\\.0\\.0\\.1)/.test(cp)\n      ? \"http://localhost:5563\"\n      : \"https://gateway.langwatch.ai\");\n  return { gateway_url: gw, control_plane_url: cp };\n}\n\n/**\n * Canonical personal-VK secret prefix. Mirrors the control plane's\n * `vk-lw-<ULID>` minting format (langwatch virtualKey.crypto.ts). The\n * gateway rejects anything else as malformed_key before any DB lookup,\n * so a config carrying a legacy-format secret (older `lw_vk_live_*`\n * logins) routes every `langwatch <tool>` call straight to a 401.\n */\nconst VK_SECRET_PREFIX = \"vk-lw-\";\n\n/**\n * Whether a stored secret is in the format the current gateway can\n * parse. Legacy secrets minted before the format change fail this and\n * must be re-issued via a fresh login.\n */\nexport function isCanonicalVkSecret(secret: string | undefined): boolean {\n  return !!secret && secret.startsWith(VK_SECRET_PREFIX);\n}\n\n/**\n * Returns the absolute path to the config file. Override with\n * LANGWATCH_CLI_CONFIG for tests / non-default homes.\n */\nexport function configPath(): string {\n  const env = process.env.LANGWATCH_CLI_CONFIG;\n  if (env) return env;\n  return path.join(os.homedir(), \".langwatch\", \"config.json\");\n}\n\n/** Read the config from disk, merging in defaults for missing keys. */\nexport function loadConfig(): GovernanceConfig {\n  const p = configPath();\n  if (!fs.existsSync(p)) return defaults();\n  try {\n    const text = fs.readFileSync(p, \"utf8\");\n    const parsed = JSON.parse(text) as Partial<GovernanceConfig>;\n    const cfg = { ...defaults(), ...parsed };\n    // Drop a legacy-format personal VK secret on load. Keeping it would\n    // route every `langwatch <tool>` call to a malformed_key 401; once\n    // dropped, the wrapper preflight tells the user to re-login and the\n    // next login persists a fresh `vk-lw-` secret. A valid canonical\n    // secret is never touched, so this won't wipe a working credential.\n    if (\n      cfg.default_personal_vk &&\n      !isCanonicalVkSecret(cfg.default_personal_vk.secret)\n    ) {\n      delete cfg.default_personal_vk;\n    }\n    return cfg;\n  } catch (err) {\n    throw new Error(`Failed to parse ${p}: ${(err as Error).message}`);\n  }\n}\n\n/** Write the config atomically (tmp file + rename) with mode 0600. */\nexport function saveConfig(cfg: GovernanceConfig): void {\n  const p = configPath();\n  const dir = path.dirname(p);\n  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });\n  const tmp = p + \".tmp\";\n  fs.writeFileSync(tmp, JSON.stringify(cfg, null, 2), { mode: 0o600 });\n  fs.renameSync(tmp, p);\n}\n\n/** Delete the config file; idempotent. */\nexport function clearConfig(): void {\n  const p = configPath();\n  try {\n    fs.unlinkSync(p);\n  } catch (err) {\n    if ((err as NodeJS.ErrnoException).code !== \"ENOENT\") throw err;\n  }\n}\n\n/** Whether a loaded config has live credentials. */\nexport function isLoggedIn(cfg: GovernanceConfig | null | undefined): boolean {\n  return !!cfg && !!cfg.access_token;\n}\n"],"mappings":";;;;;AAOA,YAAY,QAAQ;AACpB,YAAY,QAAQ;AACpB,YAAY,UAAU;AA0FtB,SAAS,WAA6B;AAnGtC;AA0GE,QAAM,MAAK,aAAQ,IAAI,uBAAZ,YAAkC;AAC7C,QAAM,aAAa,QAAQ,IAAI;AAU/B,QAAM,KACJ,kCACC,uCAAuC,KAAK,EAAE,IAC3C,0BACA;AACN,SAAO,EAAE,aAAa,IAAI,mBAAmB,GAAG;AAClD;AASA,IAAM,mBAAmB;AAOlB,SAAS,oBAAoB,QAAqC;AACvE,SAAO,CAAC,CAAC,UAAU,OAAO,WAAW,gBAAgB;AACvD;AAMO,SAAS,aAAqB;AACnC,QAAM,MAAM,QAAQ,IAAI;AACxB,MAAI,IAAK,QAAO;AAChB,SAAY,UAAQ,WAAQ,GAAG,cAAc,aAAa;AAC5D;AAGO,SAAS,aAA+B;AAC7C,QAAM,IAAI,WAAW;AACrB,MAAI,CAAI,cAAW,CAAC,EAAG,QAAO,SAAS;AACvC,MAAI;AACF,UAAM,OAAU,gBAAa,GAAG,MAAM;AACtC,UAAM,SAAS,KAAK,MAAM,IAAI;AAC9B,UAAM,MAAM,kCAAK,SAAS,IAAM;AAMhC,QACE,IAAI,uBACJ,CAAC,oBAAoB,IAAI,oBAAoB,MAAM,GACnD;AACA,aAAO,IAAI;AAAA,IACb;AACA,WAAO;AAAA,EACT,SAAS,KAAK;AACZ,UAAM,IAAI,MAAM,mBAAmB,CAAC,KAAM,IAAc,OAAO,EAAE;AAAA,EACnE;AACF;AAGO,SAAS,WAAW,KAA6B;AACtD,QAAM,IAAI,WAAW;AACrB,QAAM,MAAW,aAAQ,CAAC;AAC1B,EAAG,aAAU,KAAK,EAAE,WAAW,MAAM,MAAM,IAAM,CAAC;AAClD,QAAM,MAAM,IAAI;AAChB,EAAG,iBAAc,KAAK,KAAK,UAAU,KAAK,MAAM,CAAC,GAAG,EAAE,MAAM,IAAM,CAAC;AACnE,EAAG,cAAW,KAAK,CAAC;AACtB;AAGO,SAAS,cAAoB;AAClC,QAAM,IAAI,WAAW;AACrB,MAAI;AACF,IAAG,cAAW,CAAC;AAAA,EACjB,SAAS,KAAK;AACZ,QAAK,IAA8B,SAAS,SAAU,OAAM;AAAA,EAC9D;AACF;AAGO,SAAS,WAAW,KAAmD;AAC5E,SAAO,CAAC,CAAC,OAAO,CAAC,CAAC,IAAI;AACxB;","names":[]}