diff --git a/packages/loopover-miner/README.md b/packages/loopover-miner/README.md index 8d2ee00d41..7c2a78874e 100644 --- a/packages/loopover-miner/README.md +++ b/packages/loopover-miner/README.md @@ -185,6 +185,8 @@ loopover-miner status First-time operators can instead run `loopover-miner init --interactive` (#5176): a guided prompt for `GITHUB_TOKEN` (input hidden, never echoed or written to any log) and an optional coding-agent provider — plus that provider's model/timeout companion vars, each individually skippable with Enter — writes a starter `.env` to the state dir, then automatically reruns `doctor` against the collected values so setup problems surface immediately. `--interactive` makes no network calls of its own beyond what `doctor` already makes (none); non-interactive `init` invocations are unaffected. +The **primary** way to authenticate AMS's git operations (#6116) is `loopover-mcp login` (the GitHub App device flow, `npm install -g @loopover/mcp@latest` then `loopover-mcp login`) — the same one command that already authenticates ORB's MCP session. AMS resolves a live token from that session automatically; no `GITHUB_TOKEN` setup is required for the common case. A manually-created `GITHUB_TOKEN` PAT remains supported as an explicit fallback (it always wins over a session token when set) for self-host operators who already have one configured, or who need a token with different scopes than the App grants. `doctor`'s `github-token` check reports either source as satisfying the requirement. + From a local checkout: ```sh diff --git a/packages/loopover-miner/docs/config-precedence.md b/packages/loopover-miner/docs/config-precedence.md index b3615ff5ce..8f562e7ae6 100644 --- a/packages/loopover-miner/docs/config-precedence.md +++ b/packages/loopover-miner/docs/config-precedence.md @@ -74,6 +74,19 @@ There is **no `.loopover-miner.yml` field** for coding-agent mode today. There is **no `.loopover-miner.yml` forge block** today; `--api-base-url` follows the same CLI → programmatic → default shape for the API host. +### GitHub token value (git operations, #6116) + +**Sources:** `GITHUB_TOKEN` (operator env), a `loopover-mcp login` session recorded in the `loopover-mcp` config file (`~/.config/loopover/config.json` by default), programmatic `options.githubToken`. + +**Order (`lib/github-token-resolution.js`'s `resolveGitHubToken`, called once at the top of each CLI entrypoint — `loop`, `attempt`, `init --verify-token`, `manage poll` — then threaded down explicitly to every real GitHub caller):** + +1. Caller-supplied `options.githubToken` (an explicit override passed programmatically) wins outright. +2. Else `GITHUB_TOKEN` env — an existing self-host operator's PAT setup keeps working unchanged, no filesystem or network access. +3. Else a live token fetched from the authenticated `loopover-mcp login` session (`POST /v1/auth/github/token`, cached in memory for the process's lifetime; a failed fetch is not cached, so a later call retries rather than staying stuck). +4. Else `null` — the caller's own existing "no token" failure mode applies (git operations requiring auth fail the same way they did before this feature existed). + +This is a distinct concern from "Discover forge credential env var name" above, which resolves the *name* of an env var to read, not the token *value* itself; `discover --token-env` is unaffected by this section. + ### Local SQLite store paths **Sources:** per-store `LOOPOVER_MINER_*_DB` env var, then `LOOPOVER_MINER_CONFIG_DIR`, then XDG default (`lib/local-store.js`). diff --git a/packages/loopover-miner/lib/attempt-cli.js b/packages/loopover-miner/lib/attempt-cli.js index ec7ac74c81..248d09e37a 100644 --- a/packages/loopover-miner/lib/attempt-cli.js +++ b/packages/loopover-miner/lib/attempt-cli.js @@ -39,6 +39,7 @@ import { buildAttemptGovernorContext, buildAttemptLoopInput } from "./attempt-in import { getAttemptHistory } from "./portfolio-queue.js"; import { loadReputationHistory, recordOwnSubmission } from "./governor-state.js"; import { runMinerAttempt } from "./attempt-runner.js"; +import { resolveGitHubToken } from "./github-token-resolution.js"; const ATTEMPT_USAGE = "Usage: loopover-miner attempt --miner-login [--base ] [--live] [--dry-run] [--json]"; @@ -137,7 +138,10 @@ export function buildAttemptDeps(env, ledgers) { runSlopAssessment: (input) => runSlopAssessment(input), appendAttemptLogEvent: (event) => ledgers.attemptLog.appendAttemptLogEvent(event), claimLedger: ledgers.claimLedger, - fetchLiveIssueSnapshot: (repoFullName, issueNumber) => fetchLiveIssueSnapshot(repoFullName, issueNumber, { githubToken: env.GITHUB_TOKEN }), + // resolveGitHubToken (#6116): GITHUB_TOKEN env override wins outright, else a live token from the + // authenticated `loopover-mcp login` session -- cached in memory, so repeat calls within this process + // don't repeatedly hit the session-fetch endpoint after the first successful resolution. + fetchLiveIssueSnapshot: async (repoFullName, issueNumber) => fetchLiveIssueSnapshot(repoFullName, issueNumber, { githubToken: await resolveGitHubToken(env) }), eventLedger: ledgers.eventLedger, governorLedgerAppend: (event) => ledgers.governorLedger.appendGovernorEvent(event), nowMs: ledgers.nowMs, @@ -317,7 +321,7 @@ export async function runAttempt(args, options = {}) { // Real SelfReviewContext (#5145): issue/PR/manifest data at live-gate fidelity for the target repo. const fetchReviewContext = options.fetchSelfReviewContext ?? fetchSelfReviewContext; const reviewContext = await fetchReviewContext(parsed.repoFullName, { - githubToken: env.GITHUB_TOKEN, + githubToken: await resolveGitHubToken(env), contributorLogin: parsed.minerLogin, linkedIssues: [parsed.issueNumber], }); diff --git a/packages/loopover-miner/lib/github-token-resolution.d.ts b/packages/loopover-miner/lib/github-token-resolution.d.ts new file mode 100644 index 0000000000..2debd5f67d --- /dev/null +++ b/packages/loopover-miner/lib/github-token-resolution.d.ts @@ -0,0 +1,17 @@ +// A narrower shape than `typeof fetch` on purpose: this module only ever calls it with a string URL and a +// plain init object, and the ambient `fetch` type in this repo's TS program is Cloudflare-Workers-flavored +// (RequestInfo | URL), which is both irrelevant here (this package runs under plain Node) and +// stricter than any real caller needs -- same rationale as live-issue-snapshot.js's own LiveIssueSnapshotFetch. +export type GitHubTokenResolutionFetch = ( + url: string, + init?: { method?: string; headers?: Record; signal?: AbortSignal }, +) => Promise; + +export function resolveGitHubToken( + env?: NodeJS.ProcessEnv, + options?: { fetchImpl?: GitHubTokenResolutionFetch }, +): Promise; + +export function resetGitHubTokenResolutionForTesting(): void; + +export function hasGitHubTokenSource(env?: NodeJS.ProcessEnv): boolean; diff --git a/packages/loopover-miner/lib/github-token-resolution.js b/packages/loopover-miner/lib/github-token-resolution.js new file mode 100644 index 0000000000..dacf1f8185 --- /dev/null +++ b/packages/loopover-miner/lib/github-token-resolution.js @@ -0,0 +1,131 @@ +// GitHub-token resolution for AMS's git operations (#6116). Precedence: an explicit GITHUB_TOKEN env +// override always wins (a self-host operator's existing PAT setup keeps working, unchanged) -- otherwise, +// fetch a live token from the authenticated loopover-mcp session (POST /v1/auth/github/token, #6114/#6115), +// so `loopover-mcp login` alone becomes sufficient to run AMS against a repo the user has access to. +// +// Deliberately reimplements loopover-mcp's own config-file read here rather than depending on @loopover/mcp +// as a package: @loopover/miner and @loopover/mcp are separately-installable CLIs (the whole point of this +// milestone is that installing the GitHub App doesn't require BOTH), and a hard runtime dependency between +// them would mean installing one always pulls in the other just to read a config file format neither +// package publishes as a stable API. This mirrors loopover-mcp/bin/loopover-mcp.js's own configPath/ +// selectProfileName/apiUrl resolution logic (kept in sync by hand -- there is no shared module to import). +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +const DEFAULT_API_URL = "https://gittensory-api.aethereal.dev"; +const LEGACY_DEFAULT_API_URLS = new Set(["https://gittensory-api.zeronode.workers.dev"]); +const DEFAULT_PROFILE_NAME = "default"; +const GITHUB_TOKEN_FETCH_TIMEOUT_MS = 10_000; + +function loopoverConfigPath(env) { + if (env.LOOPOVER_CONFIG_PATH) return env.LOOPOVER_CONFIG_PATH; + if (env.LOOPOVER_CONFIG_DIR) return join(env.LOOPOVER_CONFIG_DIR, "config.json"); + return join(env.XDG_CONFIG_HOME || join(homedir(), ".config"), "loopover", "config.json"); +} + +function loadLoopoverConfig(env) { + const configPath = loopoverConfigPath(env); + if (!existsSync(configPath)) return {}; + try { + const parsed = JSON.parse(readFileSync(configPath, "utf8")); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {}; + } catch { + return {}; + } +} + +// Only ever called with an already-truthy candidate name (see selectProfileName below) -- no nullish +// fallback needed here, since a nullish/empty `value` never reaches this function in the first place. +function normalizeProfileName(value) { + const name = String(value).trim().toLowerCase(); + return /^[a-z0-9][a-z0-9._-]{0,63}$/.test(name) ? name : DEFAULT_PROFILE_NAME; +} + +// Mirrors loopover-mcp's own selectProfileName: an explicit request wins, else the config's own +// activeProfile (only if it names a real profile entry), else "default". +function selectProfileName(config, requestedName) { + if (requestedName) return normalizeProfileName(requestedName); + const configured = config.activeProfile ? normalizeProfileName(config.activeProfile) : DEFAULT_PROFILE_NAME; + return config.profiles?.[configured] ? configured : DEFAULT_PROFILE_NAME; +} + +function activeLoopoverProfile(env) { + const config = loadLoopoverConfig(env); + const profileName = selectProfileName(config, env.LOOPOVER_PROFILE); + return config.profiles?.[profileName] ?? {}; +} + +function loopoverSessionToken(env) { + const token = activeLoopoverProfile(env).session?.token; + return typeof token === "string" && token ? token : null; +} + +function loopoverApiUrl(env) { + if (env.LOOPOVER_API_URL) return env.LOOPOVER_API_URL.replace(/\/+$/, ""); + const profileApiUrl = activeLoopoverProfile(env).apiUrl; + if (typeof profileApiUrl === "string" && profileApiUrl.trim()) { + const normalized = profileApiUrl.replace(/\/+$/, ""); + if (!LEGACY_DEFAULT_API_URLS.has(normalized)) return normalized; + } + return DEFAULT_API_URL; +} + +async function fetchLiveGitHubTokenFromSession(sessionToken, apiUrl, fetchImpl) { + try { + const response = await fetchImpl(`${apiUrl}/v1/auth/github/token`, { + method: "POST", + headers: { authorization: `Bearer ${sessionToken}`, accept: "application/json" }, + signal: AbortSignal.timeout(GITHUB_TOKEN_FETCH_TIMEOUT_MS), + }); + if (!response.ok) return null; + const payload = await response.json().catch(() => null); + return typeof payload?.token === "string" && payload.token ? payload.token : null; + } catch { + return null; + } +} + +// Process-lifetime cache of a SUCCESSFUL resolution only. A failure (no session, expired session, transient +// network error) is deliberately NOT cached -- it's retried on the next call instead, so a long-running AMS +// process can self-heal from a transient blip rather than being stuck treating the token as permanently +// unavailable for its entire remaining lifetime. +let cachedToken; + +/** + * Resolve a GitHub token for AMS's git operations (#6116). Returns null when nothing is available: no + * GITHUB_TOKEN override, no loopover-mcp session on disk, or the session-token fetch fails for any reason -- + * callers already treat a missing token as "git operations requiring auth will fail," the same failure mode + * as before this feature existed. + * @param {NodeJS.ProcessEnv} [env] + * @param {{ fetchImpl?: import("./github-token-resolution.d.ts").GitHubTokenResolutionFetch }} [options] + * @returns {Promise} + */ +export async function resolveGitHubToken(env = process.env, options = {}) { + if (env.GITHUB_TOKEN) return env.GITHUB_TOKEN; + if (cachedToken) return cachedToken; + const sessionToken = loopoverSessionToken(env); + if (!sessionToken) return null; + const fetchImpl = options.fetchImpl ?? fetch; + const fetched = await fetchLiveGitHubTokenFromSession(sessionToken, loopoverApiUrl(env), fetchImpl); + if (fetched) cachedToken = fetched; + return fetched; +} + +/** Test-only: clear the process-lifetime cache so one test's resolution can't leak into the next. */ +export function resetGitHubTokenResolutionForTesting() { + cachedToken = undefined; +} + +/** + * Offline-only check: does resolveGitHubToken have ANYTHING to try (a GITHUB_TOKEN override, or a + * loopover-mcp session recorded on disk), without making the network call resolveGitHubToken itself would + * make to actually verify it still works. For `doctor`/`status`-style diagnostics (status.js's + * checkGitHubTokenPresent), which are deliberately offline-only -- a genuinely expired or revoked session + * still reports "present" here; only an actual attempt (or resolveGitHubToken itself) discovers that. + * @param {NodeJS.ProcessEnv} [env] + * @returns {boolean} + */ +export function hasGitHubTokenSource(env = process.env) { + return Boolean(env.GITHUB_TOKEN) || Boolean(loopoverSessionToken(env)); +} diff --git a/packages/loopover-miner/lib/laptop-init.js b/packages/loopover-miner/lib/laptop-init.js index 285b9db1b9..0d86249b75 100644 --- a/packages/loopover-miner/lib/laptop-init.js +++ b/packages/loopover-miner/lib/laptop-init.js @@ -4,6 +4,7 @@ import { delimiter, join } from "node:path"; import { DatabaseSync } from "node:sqlite"; import { applySchemaMigrations } from "./schema-version.js"; import { reportCliFailure } from "./cli-error.js"; +import { resolveGitHubToken } from "./github-token-resolution.js"; const githubApiBaseUrl = "https://api.github.com"; const githubApiVersion = "2022-11-28"; @@ -306,7 +307,7 @@ export async function runInit(args = [], env = process.env) { const jsonOutput = args.includes("--json"); let verification = null; if (verifyToken) { - verification = await verifyGithubToken({ githubToken: env.GITHUB_TOKEN ?? "" }); + verification = await verifyGithubToken({ githubToken: (await resolveGitHubToken(env)) ?? "" }); if (!verification.ok) { return reportCliFailure(jsonOutput, verification.detail, 1); } diff --git a/packages/loopover-miner/lib/loop-cli.js b/packages/loopover-miner/lib/loop-cli.js index 8666661348..03df2a06e6 100644 --- a/packages/loopover-miner/lib/loop-cli.js +++ b/packages/loopover-miner/lib/loop-cli.js @@ -40,6 +40,7 @@ import { isRejectedPr } from "./rejection-state-machine.js"; import { buildLoopClosureSummary } from "./loop-closure.js"; import { attemptLoopReentry } from "./loop-reentry.js"; import { parsePrNumberFromExecResult } from "./pr-number-parse.js"; +import { resolveGitHubToken } from "./github-token-resolution.js"; import { DEFAULT_AMS_POLICY_SPEC } from "@loopover/engine"; const LOOP_USAGE = @@ -264,10 +265,12 @@ export async function runLoop(args, options = {}) { // Resolved ONCE, at the CLI-entrypoint layer, mirroring manage-poll.js's own runManagePoll (its // recordManagePollSnapshot callee has no env fallback of its own either -- the top-level CLI function is - // where `process.env.GITHUB_TOKEN` gets read, then threaded down explicitly to every real GitHub caller). + // where the GitHub token gets resolved, then threaded down explicitly to every real GitHub caller). // pollPrDisposition (unlike runDiscover, which falls back to process.env.GITHUB_TOKEN internally) has NO // such fallback -- an unresolved githubToken here would silently poll unauthenticated. - const githubToken = options.githubToken ?? env.GITHUB_TOKEN ?? ""; + // resolveGitHubToken (#6116): GITHUB_TOKEN env override wins outright, else a live token from the + // authenticated `loopover-mcp login` session -- cached in memory for this process's lifetime. + const githubToken = options.githubToken ?? (await resolveGitHubToken(env)) ?? ""; async function runDiscoveryOnce() { await runDiscoverFn(discoverArgv(parsed), { diff --git a/packages/loopover-miner/lib/manage-poll.js b/packages/loopover-miner/lib/manage-poll.js index 7eeac642b0..0bde3714ba 100644 --- a/packages/loopover-miner/lib/manage-poll.js +++ b/packages/loopover-miner/lib/manage-poll.js @@ -6,6 +6,7 @@ import { } from "./manage-status.js"; import { initPortfolioQueueStore } from "./portfolio-queue.js"; import { argsWantJson, describeCliError, reportCliFailure } from "./cli-error.js"; +import { resolveGitHubToken } from "./github-token-resolution.js"; const MANAGE_POLL_USAGE = "Usage: loopover-miner manage poll [--branch ] [--dry-run] [--json]"; @@ -187,7 +188,7 @@ export async function runManagePoll(args = [], options = {}) { ensurePortfolioRow: false, pollCheckRuns: options.pollCheckRuns, fetchFn: options.fetchFn, - githubToken: options.githubToken ?? process.env.GITHUB_TOKEN ?? "", + githubToken: options.githubToken ?? (await resolveGitHubToken(process.env)) ?? "", apiBaseUrl: options.apiBaseUrl, maxAttempts: options.maxAttempts, minIntervalMs: options.minIntervalMs, @@ -228,7 +229,7 @@ export async function runManagePoll(args = [], options = {}) { ensurePortfolioRow: options.ensurePortfolioRow ?? true, pollCheckRuns: options.pollCheckRuns, fetchFn: options.fetchFn, - githubToken: options.githubToken ?? process.env.GITHUB_TOKEN ?? "", + githubToken: options.githubToken ?? (await resolveGitHubToken(process.env)) ?? "", apiBaseUrl: options.apiBaseUrl, maxAttempts: options.maxAttempts, minIntervalMs: options.minIntervalMs, diff --git a/packages/loopover-miner/lib/status.js b/packages/loopover-miner/lib/status.js index 3108099a1f..bdb4a4cd63 100644 --- a/packages/loopover-miner/lib/status.js +++ b/packages/loopover-miner/lib/status.js @@ -15,6 +15,7 @@ import { resolveMinerVersion } from "./version.js"; import { checkStoreIntegrity, describeError } from "./store-maintenance.js"; import { resolveEventLedgerDbPath } from "./event-ledger.js"; import { resolveGovernorLedgerDbPath } from "./governor-ledger.js"; +import { hasGitHubTokenSource } from "./github-token-resolution.js"; import { resolvePredictionLedgerDbPath } from "./prediction-ledger.js"; import { resolvePortfolioQueueDbPath } from "./portfolio-queue.js"; import { resolveClaimLedgerDbPath } from "./claim-ledger.js"; @@ -334,17 +335,22 @@ function nonEmptyEnv(value) { return typeof value === "string" && value.length > 0; } -/** `GITHUB_TOKEN` presence (#5170). A purely offline string check — `doctor` never calls GitHub — but a missing - * token fails every real attempt the moment it tries to push a branch or open a PR, so surface it up front - * rather than mid-run. Reports presence only; the token value itself is never included in the detail. */ +/** GitHub token presence (#5170, extended by #6116). A purely offline check — `doctor` never calls GitHub — but + * a missing token fails every real attempt the moment it tries to push a branch or open a PR, so surface it up + * front rather than mid-run. Checks BOTH a GITHUB_TOKEN env override AND a recorded `loopover-mcp login` + * session (hasGitHubTokenSource, offline: reads the local config file, makes no network call) -- otherwise a + * user who only ran `loopover-mcp login` (the new primary flow) would see a spurious "not set" warning even + * though AMS would resolve a live token from that session at attempt time. A session recorded here is not + * re-verified as still valid/unexpired -- only an actual attempt (or resolveGitHubToken itself) discovers + * that. Reports presence only; no token value is ever included in the detail. */ export function checkGitHubTokenPresent(env = process.env) { - const present = nonEmptyEnv(env.GITHUB_TOKEN); + const present = hasGitHubTokenSource(env); return { name: "github-token", ok: present, detail: present - ? "GITHUB_TOKEN is set" - : "GITHUB_TOKEN is not set — attempts that push a branch or open a PR will fail", + ? "A GitHub token is available (GITHUB_TOKEN or a loopover-mcp login session)" + : "No GitHub token available — run `loopover-mcp login`, or set GITHUB_TOKEN, before attempts that push a branch or open a PR", }; } diff --git a/test/unit/miner-cli-doctor-checks.test.ts b/test/unit/miner-cli-doctor-checks.test.ts index 833288a687..f77d4a229b 100644 --- a/test/unit/miner-cli-doctor-checks.test.ts +++ b/test/unit/miner-cli-doctor-checks.test.ts @@ -350,17 +350,39 @@ describe("loopover-miner doctor — credential presence checks (#5170)", () => { describe("checkGitHubTokenPresent", () => { it("passes when GITHUB_TOKEN is set and non-empty", () => { const check = checkGitHubTokenPresent({ GITHUB_TOKEN: "ghp_present" }); - expect(check).toMatchObject({ name: "github-token", ok: true, detail: "GITHUB_TOKEN is set" }); + expect(check).toMatchObject({ + name: "github-token", + ok: true, + detail: "A GitHub token is available (GITHUB_TOKEN or a loopover-mcp login session)", + }); }); - it("fails with an actionable message when GITHUB_TOKEN is unset", () => { - const check = checkGitHubTokenPresent({}); + it("fails with an actionable message when GITHUB_TOKEN is unset and no loopover-mcp session exists", () => { + const root = tempRoot(); + const check = checkGitHubTokenPresent({ LOOPOVER_CONFIG_DIR: root }); expect(check.ok).toBe(false); - expect(check.detail).toBe("GITHUB_TOKEN is not set — attempts that push a branch or open a PR will fail"); + expect(check.detail).toBe( + "No GitHub token available — run `loopover-mcp login`, or set GITHUB_TOKEN, before attempts that push a branch or open a PR", + ); }); it("fails when GITHUB_TOKEN is present but empty (the length>0 branch)", () => { - expect(checkGitHubTokenPresent({ GITHUB_TOKEN: "" }).ok).toBe(false); + const root = tempRoot(); + expect(checkGitHubTokenPresent({ GITHUB_TOKEN: "", LOOPOVER_CONFIG_DIR: root }).ok).toBe(false); + }); + + it("passes (#6116) when no GITHUB_TOKEN is set but a loopover-mcp login session is recorded on disk", () => { + const root = tempRoot(); + writeFileSync( + join(root, "config.json"), + JSON.stringify({ profiles: { default: { session: { token: "session-token" } } } }), + ); + const check = checkGitHubTokenPresent({ LOOPOVER_CONFIG_DIR: root }); + expect(check).toMatchObject({ + name: "github-token", + ok: true, + detail: "A GitHub token is available (GITHUB_TOKEN or a loopover-mcp login session)", + }); }); }); diff --git a/test/unit/miner-github-token-resolution.test.ts b/test/unit/miner-github-token-resolution.test.ts new file mode 100644 index 0000000000..1b63fb00d0 --- /dev/null +++ b/test/unit/miner-github-token-resolution.test.ts @@ -0,0 +1,287 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + hasGitHubTokenSource, + resetGitHubTokenResolutionForTesting, + resolveGitHubToken, +} from "../../packages/loopover-miner/lib/github-token-resolution.js"; + +function writeConfig(dir: string, config: unknown) { + writeFileSync(join(dir, "config.json"), JSON.stringify(config), { mode: 0o600 }); +} + +function configuredEnv(dir: string, overrides: Record = {}): NodeJS.ProcessEnv { + return { LOOPOVER_CONFIG_DIR: dir, ...overrides } as unknown as NodeJS.ProcessEnv; +} + +describe("resolveGitHubToken (#6116)", () => { + let dir: string; + + afterEach(() => { + resetGitHubTokenResolutionForTesting(); + vi.unstubAllGlobals(); + if (dir) rmSync(dir, { recursive: true, force: true }); + }); + + it("uses the real global fetch when no fetchImpl is injected", async () => { + dir = mkdtempSync(join(tmpdir(), "loopover-miner-github-token-realfetch-")); + writeConfig(dir, { profiles: { default: { session: { token: "session-token" } } } }); + let capturedUrl: string | undefined; + vi.stubGlobal("fetch", async (url: string) => { + capturedUrl = url; + return Response.json({ token: "live-token" }); + }); + await expect(resolveGitHubToken(configuredEnv(dir))).resolves.toBe("live-token"); + expect(capturedUrl).toBe("https://gittensory-api.aethereal.dev/v1/auth/github/token"); + }); + + it("an explicit GITHUB_TOKEN env override wins outright, no filesystem or network access", async () => { + const fetchImpl = () => { + throw new Error("should never be called"); + }; + await expect(resolveGitHubToken({ GITHUB_TOKEN: "explicit-pat-token" } as unknown as NodeJS.ProcessEnv, { fetchImpl })).resolves.toBe("explicit-pat-token"); + }); + + it("returns null when no config file exists at all", async () => { + dir = mkdtempSync(join(tmpdir(), "loopover-miner-github-token-none-")); + await expect(resolveGitHubToken(configuredEnv(dir))).resolves.toBeNull(); + }); + + it("returns null when the config exists but the active profile has no session token", async () => { + dir = mkdtempSync(join(tmpdir(), "loopover-miner-github-token-nosession-")); + writeConfig(dir, { profiles: { default: { apiUrl: "https://api.example" } } }); + await expect(resolveGitHubToken(configuredEnv(dir))).resolves.toBeNull(); + }); + + it("fetches a live token from the authenticated loopover-mcp session", async () => { + dir = mkdtempSync(join(tmpdir(), "loopover-miner-github-token-fetch-")); + writeConfig(dir, { profiles: { default: { apiUrl: "https://api.example", session: { token: "loopover-session-token" } } } }); + let capturedUrl: string | undefined; + let capturedAuth: string | undefined; + const fetchImpl = async (url: string, init?: { headers?: Record }) => { + capturedUrl = url; + capturedAuth = init?.headers?.authorization; + return Response.json({ token: "live-github-token" }); + }; + await expect(resolveGitHubToken(configuredEnv(dir), { fetchImpl })).resolves.toBe("live-github-token"); + expect(capturedUrl).toBe("https://api.example/v1/auth/github/token"); + expect(capturedAuth).toBe("Bearer loopover-session-token"); + }); + + it("selects a named profile via LOOPOVER_PROFILE", async () => { + dir = mkdtempSync(join(tmpdir(), "loopover-miner-github-token-profile-")); + writeConfig(dir, { + activeProfile: "default", + profiles: { + default: { apiUrl: "https://default.example", session: { token: "default-session" } }, + work: { apiUrl: "https://work.example", session: { token: "work-session" } }, + }, + }); + let capturedUrl: string | undefined; + let capturedAuth: string | undefined; + const fetchImpl = async (url: string, init?: { headers?: Record }) => { + capturedUrl = url; + capturedAuth = init?.headers?.authorization; + return Response.json({ token: "live-token" }); + }; + await resolveGitHubToken(configuredEnv(dir, { LOOPOVER_PROFILE: "work" }), { fetchImpl }); + expect(capturedUrl).toBe("https://work.example/v1/auth/github/token"); + expect(capturedAuth).toBe("Bearer work-session"); + }); + + it("respects the config's own activeProfile when LOOPOVER_PROFILE is not set", async () => { + dir = mkdtempSync(join(tmpdir(), "loopover-miner-github-token-active-")); + writeConfig(dir, { + activeProfile: "work", + profiles: { + default: { session: { token: "default-session" } }, + work: { apiUrl: "https://work.example", session: { token: "work-session" } }, + }, + }); + let capturedAuth: string | undefined; + const fetchImpl = async (_url: string, init?: { headers?: Record }) => { + capturedAuth = init?.headers?.authorization; + return Response.json({ token: "live-token" }); + }; + await resolveGitHubToken(configuredEnv(dir), { fetchImpl }); + expect(capturedAuth).toBe("Bearer work-session"); + }); + + it("LOOPOVER_API_URL env override wins over the profile's own apiUrl", async () => { + dir = mkdtempSync(join(tmpdir(), "loopover-miner-github-token-apiurl-env-")); + writeConfig(dir, { profiles: { default: { apiUrl: "https://profile.example", session: { token: "session-token" } } } }); + let capturedUrl: string | undefined; + const fetchImpl = async (url: string) => { + capturedUrl = url; + return Response.json({ token: "live-token" }); + }; + await resolveGitHubToken(configuredEnv(dir, { LOOPOVER_API_URL: "https://env-override.example/" }), { fetchImpl }); + expect(capturedUrl).toBe("https://env-override.example/v1/auth/github/token"); + }); + + it("falls back to the default API URL when neither an env override nor a profile apiUrl is set", async () => { + dir = mkdtempSync(join(tmpdir(), "loopover-miner-github-token-default-url-")); + writeConfig(dir, { profiles: { default: { session: { token: "session-token" } } } }); + let capturedUrl: string | undefined; + const fetchImpl = async (url: string) => { + capturedUrl = url; + return Response.json({ token: "live-token" }); + }; + await resolveGitHubToken(configuredEnv(dir), { fetchImpl }); + expect(capturedUrl).toBe("https://gittensory-api.aethereal.dev/v1/auth/github/token"); + }); + + it("treats a legacy default API URL stored in the profile as absent, falling through to the current default", async () => { + dir = mkdtempSync(join(tmpdir(), "loopover-miner-github-token-legacy-url-")); + writeConfig(dir, { profiles: { default: { apiUrl: "https://gittensory-api.zeronode.workers.dev", session: { token: "session-token" } } } }); + let capturedUrl: string | undefined; + const fetchImpl = async (url: string) => { + capturedUrl = url; + return Response.json({ token: "live-token" }); + }; + await resolveGitHubToken(configuredEnv(dir), { fetchImpl }); + expect(capturedUrl).toBe("https://gittensory-api.aethereal.dev/v1/auth/github/token"); + }); + + it("returns null (not throw) when the fetch itself rejects", async () => { + dir = mkdtempSync(join(tmpdir(), "loopover-miner-github-token-neterror-")); + writeConfig(dir, { profiles: { default: { session: { token: "session-token" } } } }); + const fetchImpl = async () => { + throw new Error("network down"); + }; + await expect(resolveGitHubToken(configuredEnv(dir), { fetchImpl })).resolves.toBeNull(); + }); + + it("returns null when the response is not ok", async () => { + dir = mkdtempSync(join(tmpdir(), "loopover-miner-github-token-notok-")); + writeConfig(dir, { profiles: { default: { session: { token: "session-token" } } } }); + const fetchImpl = async () => Response.json({ error: "github_token_unavailable" }, { status: 404 }); + await expect(resolveGitHubToken(configuredEnv(dir), { fetchImpl })).resolves.toBeNull(); + }); + + it("returns null when the response body is not valid JSON", async () => { + dir = mkdtempSync(join(tmpdir(), "loopover-miner-github-token-badjson-")); + writeConfig(dir, { profiles: { default: { session: { token: "session-token" } } } }); + const fetchImpl = async () => new Response("{", { status: 200 }); + await expect(resolveGitHubToken(configuredEnv(dir), { fetchImpl })).resolves.toBeNull(); + }); + + it("returns null when the response is ok but has no token field", async () => { + dir = mkdtempSync(join(tmpdir(), "loopover-miner-github-token-notoken-")); + writeConfig(dir, { profiles: { default: { session: { token: "session-token" } } } }); + const fetchImpl = async () => Response.json({}); + await expect(resolveGitHubToken(configuredEnv(dir), { fetchImpl })).resolves.toBeNull(); + }); + + it("caches a SUCCESSFUL resolution for the process lifetime -- a second call does not re-fetch", async () => { + dir = mkdtempSync(join(tmpdir(), "loopover-miner-github-token-cache-")); + writeConfig(dir, { profiles: { default: { session: { token: "session-token" } } } }); + let calls = 0; + const fetchImpl = async () => { + calls += 1; + return Response.json({ token: "live-token" }); + }; + const env = configuredEnv(dir); + await expect(resolveGitHubToken(env, { fetchImpl })).resolves.toBe("live-token"); + await expect(resolveGitHubToken(env, { fetchImpl })).resolves.toBe("live-token"); + expect(calls).toBe(1); + }); + + it("does NOT cache a failed resolution -- the next call retries (self-heals from a transient failure)", async () => { + dir = mkdtempSync(join(tmpdir(), "loopover-miner-github-token-retry-")); + writeConfig(dir, { profiles: { default: { session: { token: "session-token" } } } }); + let calls = 0; + const fetchImpl = async () => { + calls += 1; + return calls === 1 ? Response.json({}, { status: 502 }) : Response.json({ token: "recovered-token" }); + }; + const env = configuredEnv(dir); + await expect(resolveGitHubToken(env, { fetchImpl })).resolves.toBeNull(); + await expect(resolveGitHubToken(env, { fetchImpl })).resolves.toBe("recovered-token"); + expect(calls).toBe(2); + }); + + it("resetGitHubTokenResolutionForTesting clears a cached successful resolution", async () => { + dir = mkdtempSync(join(tmpdir(), "loopover-miner-github-token-reset-")); + writeConfig(dir, { profiles: { default: { session: { token: "session-token" } } } }); + let calls = 0; + const fetchImpl = async () => { + calls += 1; + return Response.json({ token: "live-token" }); + }; + const env = configuredEnv(dir); + await resolveGitHubToken(env, { fetchImpl }); + resetGitHubTokenResolutionForTesting(); + await resolveGitHubToken(env, { fetchImpl }); + expect(calls).toBe(2); + }); + + it("degrades to an empty config (no crash) when the config file contains malformed JSON", async () => { + dir = mkdtempSync(join(tmpdir(), "loopover-miner-github-token-malformed-")); + writeFileSync(join(dir, "config.json"), "{not valid json", { mode: 0o600 }); + await expect(resolveGitHubToken(configuredEnv(dir))).resolves.toBeNull(); + }); + + it("degrades to an empty config when the config file's top-level JSON is not an object", async () => { + dir = mkdtempSync(join(tmpdir(), "loopover-miner-github-token-arrayjson-")); + writeFileSync(join(dir, "config.json"), "[1,2,3]", { mode: 0o600 }); + await expect(resolveGitHubToken(configuredEnv(dir))).resolves.toBeNull(); + }); + + it("falls back to XDG_CONFIG_HOME when neither LOOPOVER_CONFIG_PATH nor LOOPOVER_CONFIG_DIR is set", async () => { + dir = mkdtempSync(join(tmpdir(), "loopover-miner-github-token-xdg-")); + const loopoverConfigDir = join(dir, "loopover"); + mkdirSync(loopoverConfigDir, { recursive: true }); + writeConfig(loopoverConfigDir, { profiles: { default: { session: { token: "session-token" } } } }); + const fetchImpl = async () => Response.json({ token: "live-token" }); + await expect( + resolveGitHubToken({ XDG_CONFIG_HOME: dir } as unknown as NodeJS.ProcessEnv, { fetchImpl }), + ).resolves.toBe("live-token"); + }); + + it("LOOPOVER_CONFIG_PATH reaches a specific file directly, independent of LOOPOVER_CONFIG_DIR", async () => { + dir = mkdtempSync(join(tmpdir(), "loopover-miner-github-token-configpath-")); + const file = join(dir, "custom-config.json"); + writeFileSync(file, JSON.stringify({ profiles: { default: { session: { token: "session-token" } } } }), { mode: 0o600 }); + const fetchImpl = async () => Response.json({ token: "live-token" }); + await expect(resolveGitHubToken({ LOOPOVER_CONFIG_PATH: file, LOOPOVER_CONFIG_DIR: "" } as unknown as NodeJS.ProcessEnv, { fetchImpl })).resolves.toBe("live-token"); + }); + + it("an invalid LOOPOVER_PROFILE name falls back to the default profile rather than throwing", async () => { + dir = mkdtempSync(join(tmpdir(), "loopover-miner-github-token-badprofile-")); + writeConfig(dir, { profiles: { default: { session: { token: "default-session" } } } }); + let capturedAuth: string | undefined; + const fetchImpl = async (_url: string, init?: { headers?: Record }) => { + capturedAuth = init?.headers?.authorization; + return Response.json({ token: "live-token" }); + }; + await resolveGitHubToken(configuredEnv(dir, { LOOPOVER_PROFILE: "Not A Valid Name!!" }), { fetchImpl }); + expect(capturedAuth).toBe("Bearer default-session"); + }); +}); + +describe("hasGitHubTokenSource (#6116)", () => { + let dir: string; + + afterEach(() => { + if (dir) rmSync(dir, { recursive: true, force: true }); + }); + + it("is true when GITHUB_TOKEN is set, even with no config file on disk", () => { + dir = mkdtempSync(join(tmpdir(), "loopover-miner-github-token-source-envonly-")); + expect(hasGitHubTokenSource(configuredEnv(dir, { GITHUB_TOKEN: "explicit-pat-token" }))).toBe(true); + }); + + it("is true when no GITHUB_TOKEN is set but a loopover-mcp session token is recorded", () => { + dir = mkdtempSync(join(tmpdir(), "loopover-miner-github-token-source-session-")); + writeConfig(dir, { profiles: { default: { session: { token: "session-token" } } } }); + expect(hasGitHubTokenSource(configuredEnv(dir))).toBe(true); + }); + + it("is false when neither GITHUB_TOKEN nor a loopover-mcp session is available", () => { + dir = mkdtempSync(join(tmpdir(), "loopover-miner-github-token-source-none-")); + expect(hasGitHubTokenSource(configuredEnv(dir))).toBe(false); + }); +});