From 5d72b27446478227b8910f80f2cb8df3253bb168 Mon Sep 17 00:00:00 2001 From: Tyler Thomas Date: Sun, 12 Jul 2026 17:48:55 +0000 Subject: [PATCH 1/4] fix(miner): add optional token verification to init --- packages/gittensory-miner/DEPLOYMENT.md | 5 +- packages/gittensory-miner/README.md | 4 +- .../gittensory-miner/bin/gittensory-miner.js | 10 +- packages/gittensory-miner/lib/cli.js | 2 +- .../gittensory-miner/lib/laptop-init.d.ts | 16 +- packages/gittensory-miner/lib/laptop-init.js | 136 ++++++++++++- test/unit/miner-init-verify-token.test.ts | 192 ++++++++++++++++++ test/unit/miner-laptop-init.test.ts | 14 +- 8 files changed, 359 insertions(+), 20 deletions(-) create mode 100644 test/unit/miner-init-verify-token.test.ts diff --git a/packages/gittensory-miner/DEPLOYMENT.md b/packages/gittensory-miner/DEPLOYMENT.md index 6fba10145e..43333ffd9f 100644 --- a/packages/gittensory-miner/DEPLOYMENT.md +++ b/packages/gittensory-miner/DEPLOYMENT.md @@ -20,11 +20,12 @@ Two form factors for running `@jsonbored/gittensory-miner`: **laptop mode** (sin npm install && npm --workspace @jsonbored/gittensory-miner run build ``` -2. Inspect what is installed and where local state will live (no network calls): +2. Inspect what is installed and where local state will live. `status` and `doctor` stay offline; `init --verify-token` is optional and makes one authenticated GitHub call up front: ```sh gittensory-miner status gittensory-miner doctor + gittensory-miner init --verify-token # optional: validate GITHUB_TOKEN once before attempts ``` 3. Expected layout after first use (default paths): @@ -86,7 +87,7 @@ To run the miner continuously on a plain Linux host without Docker, supervise `g ```sh npm install -g @jsonbored/gittensory-miner -gittensory-miner init +gittensory-miner init --verify-token # optional: validate GITHUB_TOKEN before discovery/attempt runs sudo cp systemd/gittensory-miner.service.example /etc/systemd/system/gittensory-miner.service sudo $EDITOR /etc/systemd/system/gittensory-miner.service # set User / WorkingDirectory / ExecStart / secrets sudo systemctl daemon-reload diff --git a/packages/gittensory-miner/README.md b/packages/gittensory-miner/README.md index 1528c4e340..4ddb112361 100644 --- a/packages/gittensory-miner/README.md +++ b/packages/gittensory-miner/README.md @@ -102,7 +102,7 @@ gittensory-miner doctor gittensory-miner status ``` -`init` creates `~/.config/gittensory-miner/` (or `GITTENSORY_MINER_CONFIG_DIR` / `XDG_CONFIG_HOME` overrides) and a local `laptop-state.sqlite3` bootstrap file. Re-running `init` is idempotent. `doctor` reports Node, the state directory, SQLite readiness, and whether Docker is installed (informational only). +`init` creates `~/.config/gittensory-miner/` (or `GITTENSORY_MINER_CONFIG_DIR` / `XDG_CONFIG_HOME` overrides) and a local `laptop-state.sqlite3` bootstrap file. Re-running `init` is idempotent. Pass `--verify-token` to make one authenticated GitHub API call up front and fail fast if `GITHUB_TOKEN` is invalid or missing repository access scopes. `doctor` reports Node, the state directory, SQLite readiness, and whether Docker is installed (informational only). From a local checkout: @@ -119,7 +119,7 @@ gittensory-miner --help gittensory-miner help gittensory-miner --version gittensory-miner version -gittensory-miner init [--json] +gittensory-miner init [--json] [--verify-token] gittensory-miner status [--json] gittensory-miner doctor [--json] gittensory-miner manage status [--json] diff --git a/packages/gittensory-miner/bin/gittensory-miner.js b/packages/gittensory-miner/bin/gittensory-miner.js index 04789f0a87..39c1669c57 100755 --- a/packages/gittensory-miner/bin/gittensory-miner.js +++ b/packages/gittensory-miner/bin/gittensory-miner.js @@ -25,11 +25,13 @@ import { resolveMinerVersion } from "../lib/version.js"; const cliArgs = process.argv.slice(2); -// `init`, `status`, and `doctor` are strictly local, offline commands — their contract is to make NO network calls. -// Dispatch them BEFORE the opportunistic npm-registry update check is even started, so they can never reach that -// network path (the update check runs for the remaining commands below). +// `status` and `doctor` are strictly local, offline commands — their contract is to make NO network calls. +// `init` stays local by default and only makes a network call when the operator explicitly passes +// `--verify-token`. +// Dispatch the local commands BEFORE the opportunistic npm-registry update check is even started, so they can +// never reach that network path (the update check runs for the remaining commands below). if (cliArgs[0] === "init") { - process.exit(runInit(cliArgs.slice(1))); + process.exit(await runInit(cliArgs.slice(1))); } if (cliArgs[0] === "status") { diff --git a/packages/gittensory-miner/lib/cli.js b/packages/gittensory-miner/lib/cli.js index df54c686ab..d4e8c3838c 100644 --- a/packages/gittensory-miner/lib/cli.js +++ b/packages/gittensory-miner/lib/cli.js @@ -14,7 +14,7 @@ export function printHelp(input) { " gittensory-miner --version", " gittensory-miner help", " gittensory-miner version", - " gittensory-miner init [--json] Bootstrap laptop-mode local SQLite state", + " gittensory-miner init [--json] [--verify-token] Bootstrap laptop-mode local SQLite state", " gittensory-miner status [--json] Show installed versions + local state paths", " gittensory-miner doctor [--json] Check this laptop is set up correctly", " gittensory-miner manage status [--json] Show managed PR rows from local portfolio + ledger", diff --git a/packages/gittensory-miner/lib/laptop-init.d.ts b/packages/gittensory-miner/lib/laptop-init.d.ts index d152718493..e76283e618 100644 --- a/packages/gittensory-miner/lib/laptop-init.d.ts +++ b/packages/gittensory-miner/lib/laptop-init.d.ts @@ -10,6 +10,13 @@ export type DoctorCheck = { detail: string; }; +export type GithubTokenVerification = { + ok: boolean; + login: string | null; + scopes: string[]; + detail: string; +}; + export function resolveLaptopStateDbPath(env?: Record): string; export function initLaptopState(env?: Record): LaptopInitResult; @@ -34,4 +41,11 @@ export function checkCodexCliPresent(options?: { resolveCodexAuthPath?: () => string; }): DoctorCheck; -export function runInit(args?: string[], env?: Record): number; +export function verifyGithubToken(options?: { + githubToken?: string; + fetchImpl?: typeof fetch; + apiBaseUrl?: string; + timeoutMs?: number; +}): Promise; + +export function runInit(args?: string[], env?: Record): Promise; diff --git a/packages/gittensory-miner/lib/laptop-init.js b/packages/gittensory-miner/lib/laptop-init.js index b9c47bc557..d3d0d9f9cb 100644 --- a/packages/gittensory-miner/lib/laptop-init.js +++ b/packages/gittensory-miner/lib/laptop-init.js @@ -3,6 +3,9 @@ import { homedir } from "node:os"; import { delimiter, join } from "node:path"; import { DatabaseSync } from "node:sqlite"; +const githubApiBaseUrl = "https://api.github.com"; +const githubApiVersion = "2022-11-28"; +const classicRepoScopes = new Set(["repo", "public_repo"]); const defaultDbFileName = "laptop-state.sqlite3"; /** Local state directory (mirrors `resolveMinerStateDir` in status.js — kept local to avoid import cycles). */ @@ -105,6 +108,113 @@ function resolveCodexAuthPath(env = process.env) { return join(base, "auth.json"); } +function githubHeaders(githubToken) { + const headers = { + accept: "application/vnd.github+json", + "user-agent": "gittensory-miner", + "x-github-api-version": githubApiVersion, + }; + const token = typeof githubToken === "string" ? githubToken.trim() : ""; + if (token) headers.authorization = `Bearer ${token}`; + return headers; +} + +function parseScopesHeader(scopesHeader) { + return typeof scopesHeader === "string" && scopesHeader.trim() + ? scopesHeader.split(",").map((scope) => scope.trim()).filter(Boolean) + : []; +} + +function formatScopes(scopes) { + return scopes.length > 0 ? scopes.join(", ") : "none reported"; +} + +function hasRepoAccessScope(scopes) { + return scopes.some((scope) => classicRepoScopes.has(scope)); +} + +function readGithubErrorMessage(payload, status) { + if (payload && typeof payload === "object" && typeof payload.message === "string" && payload.message.trim()) { + return payload.message.trim(); + } + return `GitHub returned HTTP ${status}`; +} + +/** + * Validate a GitHub token with one authenticated API call. + * + * The classic OAuth scope header is advisory when GitHub reports it: if GitHub returns `repo` or + * `public_repo`, we treat the token as sufficiently scoped for miner setup. If GitHub omits the classic + * scope header altogether, the token is still considered valid and the response is reported as "scopes not + * reported" — that keeps fine-grained tokens usable while still surfacing the scopes GitHub did return. + */ +export async function verifyGithubToken(options = {}) { + const githubToken = typeof options.githubToken === "string" ? options.githubToken.trim() : ""; + const fetchImpl = options.fetchImpl ?? fetch; + const apiBaseUrl = + typeof options.apiBaseUrl === "string" && options.apiBaseUrl.trim() + ? options.apiBaseUrl.trim().replace(/\/+$/, "") || githubApiBaseUrl + : githubApiBaseUrl; + const timeoutMs = Number.isFinite(options.timeoutMs) && options.timeoutMs > 0 ? options.timeoutMs : 5000; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + + let response; + try { + response = await fetchImpl(`${apiBaseUrl}/user`, { + method: "GET", + headers: githubHeaders(githubToken), + signal: controller.signal, + }); + } catch (error) { + const detail = controller.signal.aborted + ? `timed out after ${timeoutMs}ms` + : error instanceof Error + ? error.message + : "request failed"; + return { + ok: false, + login: null, + scopes: [], + detail: `GITHUB_TOKEN verification failed: ${detail}`, + }; + } finally { + clearTimeout(timeout); + } + + const payload = await response.json().catch(() => null); + const scopes = parseScopesHeader(response.headers.get("x-oauth-scopes")); + const login = payload && typeof payload === "object" && typeof payload.login === "string" ? payload.login.trim() : ""; + + if (!response.ok) { + return { + ok: false, + login: null, + scopes, + detail: `GITHUB_TOKEN verification failed: ${readGithubErrorMessage(payload, response.status)}`, + }; + } + + if (scopes.length > 0 && !hasRepoAccessScope(scopes)) { + return { + ok: false, + login: login || null, + scopes, + detail: `GITHUB_TOKEN is valid, but GitHub reported only ${formatScopes(scopes)}; reissue it with repo access for miner setup.`, + }; + } + + return { + ok: true, + login: login || null, + scopes, + detail: + scopes.length > 0 + ? `validated GitHub token for ${login || "unknown user"}; scopes: ${formatScopes(scopes)}` + : `validated GitHub token for ${login || "unknown user"}; GitHub did not report classic OAuth scopes`, + }; +} + /** A coding-agent CLI is only needed once a driver provider is configured (#4289) — gated by * `MINER_CODING_AGENT_PROVIDER` (#5165). When that provider is NOT the CLI being checked, absence is * advisory (`ok: true`), mirroring checkDockerPresent's optional tone. When it IS configured and the CLI is @@ -175,13 +285,33 @@ export function checkCodexCliPresent(options = {}) { return { name: "codex-cli-present", ok: true, detail }; } -export function runInit(args = [], env = process.env) { +export async function runInit(args = [], env = process.env) { + const verifyToken = args.includes("--verify-token"); + const jsonOutput = args.includes("--json"); + let verification = null; + if (verifyToken) { + verification = await verifyGithubToken({ githubToken: env.GITHUB_TOKEN ?? "" }); + if (!verification.ok) { + console.error(verification.detail); + return 1; + } + } + const result = initLaptopState(env); - if (args.includes("--json")) { - console.log(JSON.stringify(result, null, 2)); + if (jsonOutput) { + console.log( + JSON.stringify( + verification ? { ...result, tokenVerification: verification } : result, + null, + 2, + ), + ); } else { console.log(`initialized ${result.stateDir}`); console.log(`sqlite: ${result.dbPath}${result.created ? "" : " (already existed)"}`); + if (verification) { + console.log(`token: ${verification.detail}`); + } } return 0; } diff --git a/test/unit/miner-init-verify-token.test.ts b/test/unit/miner-init-verify-token.test.ts new file mode 100644 index 0000000000..8ae721f68c --- /dev/null +++ b/test/unit/miner-init-verify-token.test.ts @@ -0,0 +1,192 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { runInit, verifyGithubToken } from "../../packages/gittensory-miner/lib/laptop-init.js"; + +const tempDirs = new Set(); + +function makeTempEnv() { + const configDir = mkdtempSync(join(tmpdir(), "gittensory-miner-init-")); + tempDirs.add(configDir); + return { + env: { + ...process.env, + GITTENSORY_MINER_CONFIG_DIR: configDir, + }, + configDir, + dbPath: join(configDir, "laptop-state.sqlite3"), + }; +} + +function mockJsonResponse(body, init = {}) { + return new Response(JSON.stringify(body), { + status: init.status ?? 200, + headers: { + "content-type": "application/json", + ...(init.headers ?? {}), + }, + }); +} + +afterEach(() => { + vi.restoreAllMocks(); + for (const dir of tempDirs) rmSync(dir, { recursive: true, force: true }); + tempDirs.clear(); +}); + +describe("verifyGithubToken", () => { + it("accepts a valid token, returning the reported scopes and login", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( + mockJsonResponse({ login: "octocat" }, { headers: { "x-oauth-scopes": "repo, read:org" } }), + ); + + const result = await verifyGithubToken({ githubToken: "token-value" }); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(result.ok).toBe(true); + expect(result.login).toBe("octocat"); + expect(result.scopes).toEqual(["repo", "read:org"]); + expect(result.detail).toContain("octocat"); + expect(result.detail).toContain("repo, read:org"); + }); + + it("still succeeds when GitHub does not report classic OAuth scopes", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue(mockJsonResponse({ login: "octocat" })); + + const result = await verifyGithubToken({ githubToken: "token-value" }); + + expect(result.ok).toBe(true); + expect(result.login).toBe("octocat"); + expect(result.scopes).toEqual([]); + expect(result.detail).toContain("did not report classic OAuth scopes"); + }); + + it("rejects a token when GitHub reports only non-repository scopes", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + mockJsonResponse({ login: "octocat" }, { headers: { "x-oauth-scopes": "read:org" } }), + ); + + const result = await verifyGithubToken({ githubToken: "token-value" }); + + expect(result.ok).toBe(false); + expect(result.login).toBe("octocat"); + expect(result.scopes).toEqual(["read:org"]); + expect(result.detail).toContain("missing repository access"); + }); + + it("surfaces a rejected token as a clear GitHub error", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + mockJsonResponse({ message: "Bad credentials" }, { status: 401 }), + ); + + const result = await verifyGithubToken({ githubToken: "token-value" }); + + expect(result.ok).toBe(false); + expect(result.login).toBeNull(); + expect(result.detail).toContain("Bad credentials"); + }); + + it("surfaces network errors as a validation failure", async () => { + vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("ECONNRESET")); + + const result = await verifyGithubToken({ githubToken: "token-value" }); + + expect(result.ok).toBe(false); + expect(result.login).toBeNull(); + expect(result.detail).toContain("ECONNRESET"); + }); +}); + +describe("runInit", () => { + it("keeps the default init path offline and byte-stable", async () => { + const { env, configDir, dbPath } = makeTempEnv(); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(mockJsonResponse({ login: "octocat" })); + + const exitCode = await runInit([], env); + + expect(exitCode).toBe(0); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(logSpy.mock.calls.map(([line]) => line)).toEqual([ + `initialized ${configDir}`, + `sqlite: ${dbPath}`, + ]); + }); + + it("preserves the JSON shape when --json is present without token verification", async () => { + const { env, configDir, dbPath } = makeTempEnv(); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(mockJsonResponse({ login: "octocat" })); + + const exitCode = await runInit(["--json"], env); + + expect(exitCode).toBe(0); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(logSpy).toHaveBeenCalledTimes(1); + expect(JSON.parse(String(logSpy.mock.calls[0][0]))).toEqual({ + stateDir: configDir, + dbPath, + created: true, + }); + }); + + it("runs exactly one GitHub API call when --verify-token is requested", async () => { + const { env, configDir, dbPath } = makeTempEnv(); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( + mockJsonResponse({ login: "octocat" }, { headers: { "x-oauth-scopes": "repo, read:org" } }), + ); + + const exitCode = await runInit(["--verify-token"], env); + + expect(exitCode).toBe(0); + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(String(fetchSpy.mock.calls[0][0])).toBe("https://api.github.com/user"); + expect(logSpy.mock.calls.map(([line]) => line)).toEqual([ + `initialized ${configDir}`, + `sqlite: ${dbPath}`, + "token: validated GitHub token for octocat; scopes: repo, read:org", + ]); + }); + + it("includes token verification data in JSON output when --json and --verify-token are both set", async () => { + const { env, configDir, dbPath } = makeTempEnv(); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + vi.spyOn(globalThis, "fetch").mockResolvedValue( + mockJsonResponse({ login: "octocat" }, { headers: { "x-oauth-scopes": "repo, read:org" } }), + ); + + const exitCode = await runInit(["--json", "--verify-token"], env); + + expect(exitCode).toBe(0); + expect(logSpy).toHaveBeenCalledTimes(1); + expect(JSON.parse(String(logSpy.mock.calls[0][0]))).toEqual({ + stateDir: configDir, + dbPath, + created: true, + tokenVerification: { + ok: true, + login: "octocat", + scopes: ["repo", "read:org"], + detail: "validated GitHub token for octocat; scopes: repo, read:org", + }, + }); + }); + + it("stops before init when token verification fails", async () => { + const { env } = makeTempEnv(); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( + mockJsonResponse({ message: "Bad credentials" }, { status: 401 }), + ); + + const exitCode = await runInit(["--verify-token"], env); + + expect(exitCode).toBe(1); + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(errorSpy).toHaveBeenCalledWith( + "GITHUB_TOKEN verification failed: Bad credentials", + ); + }); +}); diff --git a/test/unit/miner-laptop-init.test.ts b/test/unit/miner-laptop-init.test.ts index e3fcab52e3..43a227804d 100644 --- a/test/unit/miner-laptop-init.test.ts +++ b/test/unit/miner-laptop-init.test.ts @@ -60,14 +60,14 @@ describe("gittensory-miner laptop init (#2329)", () => { expect(readFileSync(join(first.stateDir, "marker.txt"), "utf8")).toBe("keep-me"); }); - it("runInit prints human text (0) and machine JSON with --json", () => { + it("runInit prints human text (0) and machine JSON with --json", async () => { const root = tempRoot(); const env = { GITTENSORY_MINER_CONFIG_DIR: join(root, "state") }; const log = vi.spyOn(console, "log").mockImplementation(() => {}); - expect(runInit([], env)).toBe(0); + expect(await runInit([], env)).toBe(0); expect(String(log.mock.calls[0]?.[0])).toContain("initialized"); log.mockClear(); - expect(runInit(["--json"], env)).toBe(0); + expect(await runInit(["--json"], env)).toBe(0); const payload = JSON.parse(String(log.mock.calls[0]?.[0])); expect(payload.created).toBe(false); expect(payload.dbPath).toBe(resolveLaptopStateDbPath(env)); @@ -127,16 +127,16 @@ describe("gittensory-miner laptop init (#2329)", () => { expect(existsSync(marker)).toBe(false); }); - it("runInit notes when sqlite already existed", () => { + it("runInit notes when sqlite already existed", async () => { const root = tempRoot(); const env = { GITTENSORY_MINER_CONFIG_DIR: join(root, "state") }; initLaptopState(env); const log = vi.spyOn(console, "log").mockImplementation(() => {}); - expect(runInit([], env)).toBe(0); + expect(await runInit([], env)).toBe(0); expect(String(log.mock.calls[1]?.[0])).toContain("already existed"); }); - it("makes no network calls", () => { + it("makes no network calls", async () => { const fetchStub = vi.fn(() => { throw new Error("network calls are forbidden"); }); @@ -144,7 +144,7 @@ describe("gittensory-miner laptop init (#2329)", () => { const root = tempRoot(); const env = { GITTENSORY_MINER_CONFIG_DIR: join(root, "state") }; vi.spyOn(console, "log").mockImplementation(() => {}); - runInit([], env); + await runInit([], env); checkDockerPresent(); expect(fetchStub).not.toHaveBeenCalled(); }); From a4ae58755c8536811ce9c686bb31bfa5ea42c954 Mon Sep 17 00:00:00 2001 From: Tyler Thomas Date: Sun, 12 Jul 2026 18:03:30 +0000 Subject: [PATCH 2/4] fix(miner): satisfy init token verification ci --- test/unit/miner-init-verify-token.test.ts | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/test/unit/miner-init-verify-token.test.ts b/test/unit/miner-init-verify-token.test.ts index 8ae721f68c..a0c256225e 100644 --- a/test/unit/miner-init-verify-token.test.ts +++ b/test/unit/miner-init-verify-token.test.ts @@ -19,7 +19,10 @@ function makeTempEnv() { }; } -function mockJsonResponse(body, init = {}) { +function mockJsonResponse( + body: unknown, + init: { status?: number; headers?: Record } = {}, +) { return new Response(JSON.stringify(body), { status: init.status ?? 200, headers: { @@ -72,7 +75,7 @@ describe("verifyGithubToken", () => { expect(result.ok).toBe(false); expect(result.login).toBe("octocat"); expect(result.scopes).toEqual(["read:org"]); - expect(result.detail).toContain("missing repository access"); + expect(result.detail).toContain("reissue it with repo access"); }); it("surfaces a rejected token as a clear GitHub error", async () => { @@ -108,6 +111,9 @@ describe("runInit", () => { expect(exitCode).toBe(0); expect(fetchSpy).not.toHaveBeenCalled(); + const firstLog = logSpy.mock.calls[0]; + const secondLog = logSpy.mock.calls[1]; + if (!firstLog || !secondLog) throw new Error("expected two init log lines"); expect(logSpy.mock.calls.map(([line]) => line)).toEqual([ `initialized ${configDir}`, `sqlite: ${dbPath}`, @@ -124,7 +130,9 @@ describe("runInit", () => { expect(exitCode).toBe(0); expect(fetchSpy).not.toHaveBeenCalled(); expect(logSpy).toHaveBeenCalledTimes(1); - expect(JSON.parse(String(logSpy.mock.calls[0][0]))).toEqual({ + const jsonLog = logSpy.mock.calls[0]; + if (!jsonLog) throw new Error("expected one JSON init log line"); + expect(JSON.parse(String(jsonLog[0]))).toEqual({ stateDir: configDir, dbPath, created: true, @@ -143,6 +151,10 @@ describe("runInit", () => { expect(exitCode).toBe(0); expect(fetchSpy).toHaveBeenCalledTimes(1); expect(String(fetchSpy.mock.calls[0][0])).toBe("https://api.github.com/user"); + const firstInitLog = logSpy.mock.calls[0]; + const secondInitLog = logSpy.mock.calls[1]; + const tokenLog = logSpy.mock.calls[2]; + if (!firstInitLog || !secondInitLog || !tokenLog) throw new Error("expected three init log lines"); expect(logSpy.mock.calls.map(([line]) => line)).toEqual([ `initialized ${configDir}`, `sqlite: ${dbPath}`, @@ -161,7 +173,9 @@ describe("runInit", () => { expect(exitCode).toBe(0); expect(logSpy).toHaveBeenCalledTimes(1); - expect(JSON.parse(String(logSpy.mock.calls[0][0]))).toEqual({ + const jsonLog = logSpy.mock.calls[0]; + if (!jsonLog) throw new Error("expected one JSON init log line"); + expect(JSON.parse(String(jsonLog[0]))).toEqual({ stateDir: configDir, dbPath, created: true, From 510497437cd4ca020c49b76fcbcc3307005fe8a6 Mon Sep 17 00:00:00 2001 From: Tyler Thomas Date: Sun, 12 Jul 2026 18:06:36 +0000 Subject: [PATCH 3/4] fix(miner): finalize init token ci fix --- test/unit/miner-init-verify-token.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/unit/miner-init-verify-token.test.ts b/test/unit/miner-init-verify-token.test.ts index a0c256225e..804367d13a 100644 --- a/test/unit/miner-init-verify-token.test.ts +++ b/test/unit/miner-init-verify-token.test.ts @@ -150,7 +150,9 @@ describe("runInit", () => { expect(exitCode).toBe(0); expect(fetchSpy).toHaveBeenCalledTimes(1); - expect(String(fetchSpy.mock.calls[0][0])).toBe("https://api.github.com/user"); + const firstFetchCall = fetchSpy.mock.calls[0]; + if (!firstFetchCall) throw new Error("expected one GitHub token verification call"); + expect(String(firstFetchCall[0])).toBe("https://api.github.com/user"); const firstInitLog = logSpy.mock.calls[0]; const secondInitLog = logSpy.mock.calls[1]; const tokenLog = logSpy.mock.calls[2]; From ba029ab77dbec27182af64840aab7881540d1cbc Mon Sep 17 00:00:00 2001 From: jakearmstrong59 Date: Sun, 12 Jul 2026 18:17:10 +0000 Subject: [PATCH 4/4] fix(miner): reject empty oauth scope headers --- packages/gittensory-miner/lib/laptop-init.js | 13 +++- test/unit/miner-init-verify-token.test.ts | 73 ++++++++++++++++++++ 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/packages/gittensory-miner/lib/laptop-init.js b/packages/gittensory-miner/lib/laptop-init.js index d3d0d9f9cb..8e996d27a8 100644 --- a/packages/gittensory-miner/lib/laptop-init.js +++ b/packages/gittensory-miner/lib/laptop-init.js @@ -183,7 +183,9 @@ export async function verifyGithubToken(options = {}) { } const payload = await response.json().catch(() => null); - const scopes = parseScopesHeader(response.headers.get("x-oauth-scopes")); + const scopesHeader = response.headers.get("x-oauth-scopes"); + const scopesHeaderPresent = response.headers.has("x-oauth-scopes"); + const scopes = parseScopesHeader(scopesHeader); const login = payload && typeof payload === "object" && typeof payload.login === "string" ? payload.login.trim() : ""; if (!response.ok) { @@ -195,6 +197,15 @@ export async function verifyGithubToken(options = {}) { }; } + if (scopesHeaderPresent && scopes.length === 0) { + return { + ok: false, + login: login || null, + scopes, + detail: "GITHUB_TOKEN is valid, but GitHub returned an empty x-oauth-scopes header; reissue it with repo access for miner setup.", + }; + } + if (scopes.length > 0 && !hasRepoAccessScope(scopes)) { return { ok: false, diff --git a/test/unit/miner-init-verify-token.test.ts b/test/unit/miner-init-verify-token.test.ts index 804367d13a..6204ff333f 100644 --- a/test/unit/miner-init-verify-token.test.ts +++ b/test/unit/miner-init-verify-token.test.ts @@ -39,6 +39,35 @@ afterEach(() => { }); describe("verifyGithubToken", () => { + it("trims the API base URL and omits Authorization when the token is blank", async () => { + const requests: Array<{ url: string; headers: Record }> = []; + const fetchImpl: typeof fetch = async (input, init) => { + requests.push({ + url: String(input), + headers: Object.fromEntries(new Headers(init?.headers).entries()), + }); + return mockJsonResponse({ login: "octocat" }, { headers: { "x-oauth-scopes": "repo" } }); + }; + + const result = await verifyGithubToken({ + githubToken: " ", + apiBaseUrl: "https://example.com/", + fetchImpl, + }); + + expect(result.ok).toBe(true); + expect(requests).toEqual([ + { + url: "https://example.com/user", + headers: { + accept: "application/vnd.github+json", + "user-agent": "gittensory-miner", + "x-github-api-version": "2022-11-28", + }, + }, + ]); + }); + it("accepts a valid token, returning the reported scopes and login", async () => { const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( mockJsonResponse({ login: "octocat" }, { headers: { "x-oauth-scopes": "repo, read:org" } }), @@ -65,6 +94,19 @@ describe("verifyGithubToken", () => { expect(result.detail).toContain("did not report classic OAuth scopes"); }); + it("rejects an explicitly empty x-oauth-scopes header", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + mockJsonResponse({ login: "octocat" }, { headers: { "x-oauth-scopes": "" } }), + ); + + const result = await verifyGithubToken({ githubToken: "token-value" }); + + expect(result.ok).toBe(false); + expect(result.login).toBe("octocat"); + expect(result.scopes).toEqual([]); + expect(result.detail).toContain("empty x-oauth-scopes header"); + }); + it("rejects a token when GitHub reports only non-repository scopes", async () => { vi.spyOn(globalThis, "fetch").mockResolvedValue( mockJsonResponse({ login: "octocat" }, { headers: { "x-oauth-scopes": "read:org" } }), @@ -90,6 +132,16 @@ describe("verifyGithubToken", () => { expect(result.detail).toContain("Bad credentials"); }); + it("falls back to the HTTP status when GitHub returns an error without a message", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue(mockJsonResponse({}, { status: 403 })); + + const result = await verifyGithubToken({ githubToken: "token-value" }); + + expect(result.ok).toBe(false); + expect(result.login).toBeNull(); + expect(result.detail).toContain("GitHub returned HTTP 403"); + }); + it("surfaces network errors as a validation failure", async () => { vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("ECONNRESET")); @@ -99,6 +151,27 @@ describe("verifyGithubToken", () => { expect(result.login).toBeNull(); expect(result.detail).toContain("ECONNRESET"); }); + + it("times out when the GitHub request never settles", async () => { + const fetchImpl: typeof fetch = async (_input, init) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener( + "abort", + () => reject(new Error("aborted")), + { once: true }, + ); + }); + + const result = await verifyGithubToken({ + githubToken: "token-value", + fetchImpl, + timeoutMs: 1, + }); + + expect(result.ok).toBe(false); + expect(result.login).toBeNull(); + expect(result.detail).toContain("timed out after 1ms"); + }); }); describe("runInit", () => {