/**
 * Runtime credential store.
 *
 * Phase 1 scope: consume `credentialBundle.body.tunnel` delivered by the
 * onboarding-service heartbeat and materialize it to the active profile's
 * private tunnel directory.
 *
 * Design notes:
 *   - The store is intentionally thin. It does not persist applied-version
 *     metadata on disk; instead it keeps a lightweight in-memory fingerprint
 *     to short-circuit redundant disk writes within a single process.
 *   - On a cold start the first heartbeat is a bootstrap heartbeat, so the
 *     server will always re-issue the current credential and the store will
 *     re-materialize files idempotently. We don't need a separate durable
 *     "applied version" marker.
 *   - Tunnel credentials ARE written to disk — `cloudflared` consumes them
 *     via `credentials-file`. Other credential kinds (Gemini, SerpAPI,
 *     and future LLM keys) are stored in two places: an in-memory
 *     tier-1 "bundle" variable inside their respective `*-credentials.ts`
 *     module (so resolvers see the new value immediately) AND a
 *     write-through cache in `config.json` (so the agent survives an
 *     offline onboarding service across a restart). Resolvers prefer
 *     the in-memory tier; the on-disk field is only consulted when both
 *     the bundle and the shared-keys tiers are empty.
 *   - Files are written with 0o600 permissions to keep the minimum exposure
 *     surface, and the enclosing directory is 0o700.
 */
import fs from "node:fs";
import { getProfileTunnelDir, getProfileTunnelConfigPath, getProfileTunnelCredentialsPath, getConfigFile, } from "../config/index.js";
import { writeGoogleTokensJson } from "../google/token-file.js";
import { parseCloudflaredConfigYaml, writeProfileTunnelConfig, } from "../obs/cloudflared-config.js";
import { logger } from "../logger.js";
import { setBundleSerpApiKey } from "../serpapi-credentials.js";
import { setBundleGeminiApiKey } from "../gemini-credentials.js";
/**
 * Snapshot of what the store last materialized in this process. Kept as a
 * module-level singleton because the onboarding client is itself a
 * per-process singleton.
 */
let currentTunnel = null;
/**
 * Best-effort fingerprint for the most recently applied tunnel credential,
 * used to skip redundant writes. Covers both an explicit `configVersion`
 * (preferred) and the raw credential contents (fallback).
 */
let lastAppliedTunnelFingerprint = null;
function buildTunnelFingerprint(cred) {
    if (cred.configVersion && cred.configVersion.trim() !== "") {
        return `v:${cred.configVersion.trim()}|id:${cred.tunnelId}|name:${cred.tunnelName}`;
    }
    // Avoid hashing the raw credentialsJson into logs — just use length +
    // tunnelId as a coarse fingerprint. This is not a security primitive;
    // it's only used to decide "did anything change since the last apply?".
    return `len:${cred.credentialsJson.length}|id:${cred.tunnelId}|name:${cred.tunnelName}|host:${cred.baseHostname}`;
}
function buildTunnelHostname(cred) {
    const name = cred.tunnelName.trim();
    const base = cred.baseHostname.trim().replace(/^\.+|\.+$/g, "");
    if (!name)
        return base;
    if (!base)
        return name;
    return `${name}.${base}`;
}
/** Canonical DB slug form. */
function normalizeAgentName(name) {
    return name.trim().toLowerCase().replace(/\s+/g, "-");
}
/**
 * Canonical OBS tunnel-name form. Mirrors onboarding's
 * `normalizeAgentName(...).replace(/-/g, "")`, so agent `holling-pugh`
 * expects tunnel `hollingpugh`.
 */
function canonicalObsTunnelName(name) {
    return normalizeAgentName(name).replace(/-/g, "");
}
/**
 * Best-effort directory creation with restrictive permissions. Chmod is not
 * supported on Windows for directories in the same way as POSIX, so we
 * ignore failures there.
 */
function ensureTunnelDir(dir) {
    fs.mkdirSync(dir, { recursive: true });
    if (process.platform !== "win32") {
        try {
            fs.chmodSync(dir, 0o700);
        }
        catch {
            // best-effort: some filesystems (e.g. network mounts) reject chmod
        }
    }
}
function writeCredentialsFile(filePath, contents) {
    fs.writeFileSync(filePath, contents, "utf-8");
    if (process.platform !== "win32") {
        try {
            fs.chmodSync(filePath, 0o600);
        }
        catch {
            // best-effort; credentialsJson is still protected by the 0o700 parent
        }
    }
}
/**
 * When rewriting `config.yml` during a tunnel-UUID rotation, preserve
 * any pre-existing named ingress rules (obs-injected service targets,
 * user-added routes to other local ports, legacy-migrated hostnames).
 * We only need to swap the `tunnel:` + `credentials-file:` lines — the
 * ingress list should carry forward intact so non-obs routes don't
 * silently break on rotation.
 *
 * The canonical obs hostname is always ensured present (added as a
 * placeholder when missing) so a brand-new config.yml still has the
 * agent's own hostname wired up for obs's ensureIngressRule to patch.
 *
 * Returns undefined if the existing config can't be parsed — callers
 * should fall back to writing a fresh single-rule config in that case.
 */
function collectRotationIngressRules(configFile, canonicalHostname) {
    if (!fs.existsSync(configFile))
        return undefined;
    let parsed;
    try {
        parsed = parseCloudflaredConfigYaml(fs.readFileSync(configFile, "utf-8"));
    }
    catch {
        return undefined;
    }
    const named = parsed.ingress
        .filter((r) => r.hostname.trim() !== "" && r.service.trim() !== "")
        .map((r) => ({ hostname: r.hostname, service: r.service }));
    const hasCanonical = named.some((r) => r.hostname.toLowerCase() === canonicalHostname.toLowerCase());
    if (!hasCanonical) {
        named.push({ hostname: canonicalHostname, service: "http_status:404" });
    }
    return named;
}
/**
 * Apply a server-delivered tunnel credential. Idempotent: re-applies with
 * the same fingerprint short-circuit without touching disk.
 *
 * Write strategy, intended to cooperate with the obs server's
 * `ensureIngressRule` which owns the real ingress `service` target:
 *   - Credentials JSON (`{UUID}.json`) — written whenever the on-disk
 *     bytes differ from the bundle (or the file is missing).
 *   - `config.yml` — written ONLY when the incoming tunnel UUID differs
 *     from what's already on disk (or no config.yml exists). When the
 *     UUID matches, we leave `config.yml` untouched so a heartbeat
 *     landing AFTER obs has patched in `service: http://localhost:<port>`
 *     doesn't clobber it back to the `http_status:404` placeholder.
 *
 * `agentName` is the agent's own canonical identifier (same value the
 * heartbeat uses in the onboarding URL path). When provided, the
 * credential is rejected unless its `tunnelName` matches — the tunnel
 * name is interpolated into the public hostname as
 * `<tunnelName>.<baseHostname>`, so a drifted name would cause the agent
 * to advertise a hostname it cannot serve.
 */
function applyTunnelCredential(cred, agentName) {
    const tunnelId = cred.tunnelId.trim();
    const tunnelName = cred.tunnelName.trim();
    if (!tunnelId || !tunnelName) {
        throw new Error("Runtime tunnel credential is missing tunnelId or tunnelName");
    }
    if (typeof cred.credentialsJson !== "string" || cred.credentialsJson === "") {
        throw new Error("Runtime tunnel credential has empty credentialsJson");
    }
    if (agentName !== undefined && agentName.trim() !== "") {
        const expected = canonicalObsTunnelName(agentName);
        const actual = canonicalObsTunnelName(tunnelName);
        if (expected !== actual) {
            throw new Error(`Runtime tunnel credential tunnelName "${tunnelName}" does not match ` +
                `agent name "${agentName}"; refusing to materialize`);
        }
    }
    const tunnelDir = getProfileTunnelDir();
    ensureTunnelDir(tunnelDir);
    const credentialsFile = getProfileTunnelCredentialsPath(tunnelId);
    const configFile = getProfileTunnelConfigPath();
    const tunnelHostname = buildTunnelHostname(cred);
    // Snapshot whatever is currently on disk BEFORE we write, so we can
    // report the previous tunnel UUID (null if this is the first apply or
    // the profile config was wiped).
    const preSnapshot = probeLocalTunnelCredential();
    const previousTunnelId = preSnapshot?.tunnelId ?? null;
    const applied = {
        tunnelId,
        tunnelName,
        tunnelHostname,
        credentialsFile,
        configFile,
        ...(cred.configVersion && cred.configVersion.trim() !== ""
            ? { configVersion: cred.configVersion.trim() }
            : {}),
    };
    const fingerprint = buildTunnelFingerprint(cred);
    // Fast-path: fingerprint unchanged AND the on-disk config still points
    // at the incoming UUID AND the creds JSON file still exists. Skip all
    // I/O and report no change so the caller doesn't bounce cloudflared.
    if (lastAppliedTunnelFingerprint === fingerprint &&
        previousTunnelId === tunnelId &&
        fs.existsSync(credentialsFile)) {
        currentTunnel = applied;
        return { applied, changed: false, previousTunnelId };
    }
    // Credentials JSON: write when content differs or file is missing.
    let credsChanged = true;
    if (fs.existsSync(credentialsFile)) {
        try {
            credsChanged = fs.readFileSync(credentialsFile, "utf-8") !== cred.credentialsJson;
        }
        catch {
            credsChanged = true;
        }
    }
    if (credsChanged) {
        writeCredentialsFile(credentialsFile, cred.credentialsJson);
    }
    // config.yml: only rewrite when the UUID changed (or the file is
    // missing). Leaving it alone on a same-UUID refresh preserves any
    // ingress `service` targets that obs's `ensureIngressRule` has already
    // injected — clobbering them back to `http_status:404` would leave
    // cloudflared's in-memory state correct (it doesn't re-read on its
    // own) but the on-disk file stale and confusing.
    //
    // On a UUID rotation we still need to rewrite `tunnel:` and
    // `credentials-file:`, but the ingress list must carry forward —
    // operators or obs may have stamped non-obs routes (e.g. a user's
    // local dev server, legacy-migrated hostnames) that would silently
    // break if we collapsed back to just the canonical placeholder.
    const configMissingOrDrifted = previousTunnelId !== tunnelId || !fs.existsSync(configFile);
    let configChanged = false;
    if (configMissingOrDrifted) {
        const preservedRules = collectRotationIngressRules(configFile, tunnelHostname);
        writeProfileTunnelConfig({
            configPath: configFile,
            tunnelId,
            credentialsFile,
            // Fresh-install path (no parseable config.yml): only the canonical
            // obs hostname is known. The service target is injected later by
            // the obs server when it knows its own port, so a safe placeholder
            // catch-all is written until then.
            rules: preservedRules ?? [
                { hostname: tunnelHostname, service: "http_status:404" },
            ],
        });
        configChanged = true;
    }
    const changed = credsChanged || configChanged;
    if (changed) {
        lastAppliedTunnelFingerprint = fingerprint;
        logger.system(`Runtime credential: materialized tunnel "${tunnelName}" (${tunnelId}) to ${tunnelDir}` +
            (configChanged ? "" : " (config.yml preserved)"));
    }
    currentTunnel = applied;
    return { applied, changed, previousTunnelId };
}
// ---------------------------------------------------------------------------
// Google credential materialization
// ---------------------------------------------------------------------------
/**
 * Apply a server-delivered Google credential to disk.
 * Writes gmail password to config.json (gmailPassword field) and
 * atomically writes google-tokens.json if oauthTokensJson is provided.
 * Both writes are idempotent - only writes if content has changed.
 */
export function applyGoogleRuntimeCredential(cred) {
    let changed = false;
    // Update gmailPassword in config.json
    const configFile = getConfigFile();
    if (fs.existsSync(configFile)) {
        try {
            const raw = fs.readFileSync(configFile, "utf-8");
            const config = JSON.parse(raw);
            if (config.gmailPassword !== cred.password) {
                config.gmailPassword = cred.password;
                fs.writeFileSync(configFile, JSON.stringify(config, null, 2) + "\n", "utf-8");
                if (process.platform !== "win32") {
                    try {
                        fs.chmodSync(configFile, 0o600);
                    }
                    catch { /* best-effort */ }
                }
                logger.system(`Runtime credential: updated gmailPassword in config.json`);
                changed = true;
            }
        }
        catch (err) {
            logger.warn(`Runtime credential: failed to update gmailPassword in config.json: ${err instanceof Error ? err.message : String(err)}`);
        }
    }
    else {
        logger.warn(`Runtime credential: config.json not found at ${configFile}; skipping gmailPassword update`);
    }
    // Atomically write google-tokens.json if oauthTokensJson was delivered
    if (cred.oauthTokensJson) {
        try {
            const result = writeGoogleTokensJson(cred.oauthTokensJson);
            if (result.changed) {
                logger.system(`Runtime credential: updated google-tokens.json`);
                changed = true;
            }
        }
        catch (err) {
            logger.warn(`Runtime credential: failed to write google-tokens.json: ${err instanceof Error ? err.message : String(err)}`);
        }
    }
    return changed;
}
/**
 * Atomic read-mutate-write of a single top-level field in config.json.
 *
 * This is the single chokepoint for "authoritative server-issued credentials"
 * writes — currently per-agent bundle credentials (SerpAPI / Gemini / Bedrock,
 * future LLM keys). These overwrite whatever's on disk because the bundle is
 * the source of truth for that agent.
 *
 * Shared keys take the softer `cacheConfigFieldIfMissing` path below: they
 * only seed an empty slot and never overwrite an existing value, so admin or
 * per-agent configuration on disk cannot be silently flipped by a global
 * shared key.
 *
 * Routing all writers through these helpers avoids the previous race window
 * where heartbeat-manager re-serialised its in-memory `this.config`
 * snapshot while runtime-credentials was independently re-reading and
 * rewriting the same file. Each writer now touches only its own field.
 *
 * Empty/undefined values are ignored by client policy: server-issued null or
 * whitespace must never clear a local value.
 */
export function cacheConfigField(field, value, options) {
    return writeConfigField(field, value, { mode: "overwrite", ...options });
}
/**
 * "Persist only when missing" variant used by the shared-keys path.
 *
 * Writes `value` to `config.json[field]` only if the existing value is
 * absent or empty. Useful as a first-time seed for offline survival without
 * letting later heartbeats clobber values set by `reconfigure`, by a
 * per-agent bundle apply, or by an operator editing config.json directly.
 *
 * `value === undefined` is a no-op (we never clear an existing field via
 * this path; shared-key deletion is currently observation-only).
 */
export function cacheConfigFieldIfMissing(field, value, options) {
    return writeConfigField(field, value, { mode: "if-missing", ...options });
}
function writeConfigField(field, value, options) {
    const configFile = getConfigFile();
    if (!fs.existsSync(configFile)) {
        logger.warn(`Runtime credential: config.json not found at ${configFile}; skipping ${field} update`);
        return false;
    }
    try {
        const raw = fs.readFileSync(configFile, "utf-8");
        const config = JSON.parse(raw);
        const nextValue = value?.trim() ? value.trim() : undefined;
        const existing = config[field];
        const currentValue = typeof existing === "string" && existing.trim() !== "" ? existing : undefined;
        // Client policy: server-issued null/empty values are ignored. They
        // must not clear or overwrite local config, even on the authoritative
        // bundle path.
        if (nextValue === undefined)
            return false;
        if (options.mode === "if-missing") {
            // Only seed an empty slot. Do not overwrite — that's what
            // `cacheConfigField` is for.
            if (currentValue !== undefined)
                return false;
        }
        else {
            if (currentValue === nextValue)
                return false;
        }
        config[field] = nextValue;
        const tmpFile = `${configFile}.tmp-${process.pid}-${Date.now()}`;
        fs.writeFileSync(tmpFile, JSON.stringify(config, null, 2) + "\n", "utf-8");
        if (process.platform !== "win32") {
            try {
                fs.chmodSync(tmpFile, 0o600);
            }
            catch { /* best-effort */ }
        }
        fs.renameSync(tmpFile, configFile);
        if (options.logLabel) {
            logger.system(`Runtime credential: ${options.logLabel}`);
        }
        return true;
    }
    catch (err) {
        logger.warn(`Runtime credential: failed to update ${field} in config.json: ${err instanceof Error ? err.message : String(err)}`);
        return false;
    }
}
/**
 * Apply a SerpAPI credential from the bundle. Updates the in-memory
 * tier-1 store (so resolvers see it immediately) and the on-disk
 * config.json cache (so the agent survives a restart with an offline
 * server). Empty/null server values are ignored and never clear local
 * credential state.
 */
function applySerpApiCredential(cred) {
    const next = cred?.apiKey.trim();
    if (!next)
        return false;
    setBundleSerpApiKey(next);
    return cacheConfigField("serpApiKey", next, {
        logLabel: "updated serpApiKey in config.json",
    });
}
/**
 * Apply a Gemini credential from the bundle. Same semantics as the
 * SerpAPI apply above.
 */
function applyGeminiCredential(cred) {
    const next = cred?.apiKey.trim();
    if (!next)
        return false;
    setBundleGeminiApiKey(next);
    return cacheConfigField("geminiApiKey", next, {
        logLabel: "updated geminiApiKey in config.json",
    });
}
function applyBedrockCredential(cred, config) {
    if (!cred)
        return false;
    const awsRegion = cred.awsRegion.trim();
    const awsAccessKeyId = cred.awsAccessKeyId.trim();
    const awsSecretAccessKey = cred.awsSecretAccessKey.trim();
    if (!awsRegion || !awsAccessKeyId || !awsSecretAccessKey) {
        logger.warn("Runtime credential: Bedrock credential missing region/access key/secret; ignoring");
        return false;
    }
    let changed = false;
    changed = cacheConfigField("awsRegion", awsRegion, {
        logLabel: "updated awsRegion in config.json",
    }) || changed;
    changed = cacheConfigField("awsAccessKeyId", awsAccessKeyId, {
        logLabel: "updated awsAccessKeyId in config.json",
    }) || changed;
    changed = cacheConfigField("awsSecretAccessKey", awsSecretAccessKey, {
        logLabel: "updated awsSecretAccessKey in config.json",
    }) || changed;
    if (config) {
        config.awsRegion = awsRegion;
        config.awsAccessKeyId = awsAccessKeyId;
        config.awsSecretAccessKey = awsSecretAccessKey;
    }
    const awsSessionToken = cred.awsSessionToken?.trim();
    if (awsSessionToken) {
        changed = cacheConfigField("awsSessionToken", awsSessionToken, {
            logLabel: "updated awsSessionToken in config.json",
        }) || changed;
        if (config)
            config.awsSessionToken = awsSessionToken;
    }
    const awsBearerToken = cred.awsBearerToken?.trim();
    if (awsBearerToken) {
        changed = cacheConfigField("awsBearerToken", awsBearerToken, {
            logLabel: "updated awsBearerToken in config.json",
        }) || changed;
        if (config)
            config.awsBearerToken = awsBearerToken;
    }
    return changed;
}
function applyNinerouterCredential(cred, config) {
    if (!cred)
        return false;
    const apiKey = cred.apiKey.trim();
    if (!apiKey)
        return false;
    let changed = cacheConfigField("ninerouterApiKey", apiKey, {
        logLabel: "updated ninerouterApiKey in config.json",
    });
    if (config)
        config.ninerouterApiKey = apiKey;
    const baseUrl = cred.baseUrl?.trim();
    if (baseUrl) {
        changed = cacheConfigField("ninerouterBaseUrl", baseUrl, {
            logLabel: "updated ninerouterBaseUrl in config.json",
        }) || changed;
        if (config)
            config.ninerouterBaseUrl = baseUrl;
    }
    return changed;
}
/**
 * Apply a server-delivered credential bundle body. Tolerates a missing
 * or undefined body (returns a no-op result). Never throws on IO errors
 * — callers use this from the heartbeat critical path and a failed
 * materialisation must not block heartbeats or shut the agent down.
 */
export function applyRuntimeCredentials(bundle, options = {}) {
    if (!bundle) {
        return {
            tunnelApplied: false,
            tunnel: currentTunnel,
            tunnelChanged: false,
            previousTunnelId: null,
            googleApplied: false,
            googleChanged: false,
            serpApiApplied: false,
            serpApiChanged: false,
            geminiApplied: false,
            geminiChanged: false,
            bedrockApplied: false,
            bedrockChanged: false,
            ninerouterApplied: false,
            ninerouterChanged: false,
        };
    }
    let tunnelApplied = false;
    let tunnelChanged = false;
    let previousTunnelId = null;
    if (bundle.tunnel) {
        try {
            const decision = applyTunnelCredential(bundle.tunnel, options.agentName);
            tunnelApplied = true;
            tunnelChanged = decision.changed;
            previousTunnelId = decision.previousTunnelId;
        }
        catch (err) {
            logger.warn(`Runtime credential: failed to materialize tunnel credential: ${err instanceof Error ? err.message : String(err)}`);
        }
    }
    let googleApplied = false;
    let googleChanged = false;
    if (bundle.google) {
        try {
            googleChanged = applyGoogleRuntimeCredential(bundle.google);
            googleApplied = true;
        }
        catch (err) {
            logger.warn(`Runtime credential: failed to materialize Google credential: ${err instanceof Error ? err.message : String(err)}`);
        }
    }
    let serpApiApplied = false;
    let serpApiChanged = false;
    if (bundle.serpapi !== undefined) {
        try {
            serpApiChanged = applySerpApiCredential(bundle.serpapi);
            serpApiApplied = true;
        }
        catch (err) {
            logger.warn(`Runtime credential: failed to materialize SerpAPI credential: ${err instanceof Error ? err.message : String(err)}`);
        }
    }
    let geminiApplied = false;
    let geminiChanged = false;
    if (bundle.gemini !== undefined) {
        try {
            geminiChanged = applyGeminiCredential(bundle.gemini);
            geminiApplied = true;
        }
        catch (err) {
            logger.warn(`Runtime credential: failed to materialize Gemini credential: ${err instanceof Error ? err.message : String(err)}`);
        }
    }
    let bedrockApplied = false;
    let bedrockChanged = false;
    if (bundle.bedrock !== undefined) {
        try {
            bedrockChanged = applyBedrockCredential(bundle.bedrock, options.config);
            bedrockApplied = true;
        }
        catch (err) {
            logger.warn(`Runtime credential: failed to materialize Bedrock credential: ${err instanceof Error ? err.message : String(err)}`);
        }
    }
    let ninerouterApplied = false;
    let ninerouterChanged = false;
    if (bundle.ninerouter !== undefined) {
        try {
            ninerouterChanged = applyNinerouterCredential(bundle.ninerouter, options.config);
            ninerouterApplied = true;
        }
        catch (err) {
            logger.warn(`Runtime credential: failed to materialize 9Router credential: ${err instanceof Error ? err.message : String(err)}`);
        }
    }
    return {
        tunnelApplied,
        tunnel: currentTunnel,
        tunnelChanged,
        previousTunnelId,
        googleApplied,
        googleChanged,
        serpApiApplied,
        serpApiChanged,
        geminiApplied,
        geminiChanged,
        bedrockApplied,
        bedrockChanged,
        ninerouterApplied,
        ninerouterChanged,
    };
}
/**
 * Load the current in-memory snapshot of the applied tunnel credential,
 * if any. Returns `null` when the agent has not yet received or
 * materialized a tunnel credential in this process.
 */
export function getAppliedTunnelCredential() {
    return currentTunnel;
}
/**
 * Detect whether the active profile currently has valid tunnel credential
 * files on disk (written by a previous heartbeat or process). Used to
 * decide whether the agent should request a bootstrap refresh on startup.
 *
 * Returns a snapshot that callers can feed directly into the tunnel
 * runtime, or `null` if the files are missing or unreadable.
 */
export function probeLocalTunnelCredential() {
    const configFile = getProfileTunnelConfigPath();
    if (!fs.existsSync(configFile))
        return null;
    let configText;
    try {
        configText = fs.readFileSync(configFile, "utf-8");
    }
    catch {
        return null;
    }
    const tunnelMatch = /(?:^|\n)\s*tunnel:\s*([^\s#]+)/.exec(configText);
    const credsMatch = /(?:^|\n)\s*credentials-file:\s*(\S.*?)\s*(?:\n|$)/.exec(configText);
    const tunnelId = tunnelMatch?.[1]?.trim();
    const credentialsFile = credsMatch?.[1]?.trim();
    if (!tunnelId || !credentialsFile)
        return null;
    if (!fs.existsSync(credentialsFile))
        return null;
    // Recover the tunnel name from the credentials JSON. The cloudflared-
    // created file carries `TunnelName` and `TunnelID`; fall back to the
    // bare UUID when the shape differs.
    //
    // Notably we do NOT surface any hostname from `config.yml` here:
    // profile configs may carry multiple ingress rules (canonical obs
    // hostname + user-configured routes to other local services), and
    // picking "first rule in yaml" is an order-dependent guess that can
    // cause obs startup to rewrite the wrong rule's service target. The
    // canonical hostname is always `config.obs.tunnelHostname`, which the
    // caller already has — this snapshot stays authoritative only about
    // tunnel identity and on-disk paths.
    let tunnelName = tunnelId;
    try {
        const blob = JSON.parse(fs.readFileSync(credentialsFile, "utf-8"));
        const raw = typeof blob.TunnelName === "string"
            ? blob.TunnelName
            : typeof blob.tunnelName === "string"
                ? blob.tunnelName
                : undefined;
        if (raw && raw.trim() !== "") {
            tunnelName = raw.trim();
        }
    }
    catch {
        // best-effort; keep tunnelId as the fallback name
    }
    return {
        tunnelId,
        tunnelName,
        credentialsFile,
        configFile,
    };
}
/**
 * Reset all in-memory state. Exposed for tests only.
 */
export function __resetRuntimeCredentialStoreForTests() {
    currentTunnel = null;
    lastAppliedTunnelFingerprint = null;
}
//# sourceMappingURL=runtime-credentials.js.map