Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/loopover-miner/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions packages/loopover-miner/docs/config-precedence.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down
8 changes: 6 additions & 2 deletions packages/loopover-miner/lib/attempt-cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <owner/repo> <issue#> --miner-login <login> [--base <branch>] [--live] [--dry-run] [--json]";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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],
});
Expand Down
17 changes: 17 additions & 0 deletions packages/loopover-miner/lib/github-token-resolution.d.ts
Original file line number Diff line number Diff line change
@@ -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<CfProperties> | 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<string, string>; signal?: AbortSignal },
) => Promise<Response>;

export function resolveGitHubToken(
env?: NodeJS.ProcessEnv,
options?: { fetchImpl?: GitHubTokenResolutionFetch },
): Promise<string | null>;

export function resetGitHubTokenResolutionForTesting(): void;

export function hasGitHubTokenSource(env?: NodeJS.ProcessEnv): boolean;
131 changes: 131 additions & 0 deletions packages/loopover-miner/lib/github-token-resolution.js
Original file line number Diff line number Diff line change
@@ -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<string | null>}
*/
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));
}
3 changes: 2 additions & 1 deletion packages/loopover-miner/lib/laptop-init.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
}
Expand Down
7 changes: 5 additions & 2 deletions packages/loopover-miner/lib/loop-cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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), {
Expand Down
5 changes: 3 additions & 2 deletions packages/loopover-miner/lib/manage-poll.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <owner/repo> <pr#> [--branch <name>] [--dry-run] [--json]";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
18 changes: 12 additions & 6 deletions packages/loopover-miner/lib/status.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 availablerun `loopover-mcp login`, or set GITHUB_TOKEN, before attempts that push a branch or open a PR",
};
}

Expand Down
Loading