diff --git a/packages/gittensory-miner/lib/laptop-init.d.ts b/packages/gittensory-miner/lib/laptop-init.d.ts index d152718493..db709e7b42 100644 --- a/packages/gittensory-miner/lib/laptop-init.d.ts +++ b/packages/gittensory-miner/lib/laptop-init.d.ts @@ -18,6 +18,8 @@ export function checkLaptopStateSqlite(env?: Record) export function findExecutableOnPath(name: string, env?: Record): string | null; +export function resolveCodexAuthPath(env?: Record): string; + export function checkDockerPresent(options?: { env?: Record; resolveDockerPath?: () => string | null; diff --git a/packages/gittensory-miner/lib/laptop-init.js b/packages/gittensory-miner/lib/laptop-init.js index b9c47bc557..398c9c7f55 100644 --- a/packages/gittensory-miner/lib/laptop-init.js +++ b/packages/gittensory-miner/lib/laptop-init.js @@ -99,8 +99,9 @@ export function checkDockerPresent(options = {}) { // Codex stores credentials at `$CODEX_HOME/auth.json`, else `$HOME/.codex/auth.json` — mirrors // resolveCodexAuthPath in src/selfhost/ai.ts, kept local so the offline miner package never imports the -// Worker AI module. -function resolveCodexAuthPath(env = process.env) { +// Worker AI module. Exported so status.js's credential-presence doctor check (#5170) reuses this exact path +// resolution instead of re-deriving it. +export function resolveCodexAuthPath(env = process.env) { const base = env.CODEX_HOME ?? join(env.HOME ?? homedir(), ".codex"); return join(base, "auth.json"); } diff --git a/packages/gittensory-miner/lib/status.js b/packages/gittensory-miner/lib/status.js index 7d50754b4a..0899e0e36e 100644 --- a/packages/gittensory-miner/lib/status.js +++ b/packages/gittensory-miner/lib/status.js @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { accessSync, constants, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { createRequire } from "node:module"; import { homedir } from "node:os"; import { join } from "node:path"; @@ -9,6 +9,7 @@ import { checkDockerPresent, checkLaptopStateSqlite, findExecutableOnPath, + resolveCodexAuthPath, } from "./laptop-init.js"; import { resolveMinerVersion } from "./version.js"; @@ -271,6 +272,62 @@ function checkStateDirWritable(stateDir) { } } +/** Offline (no network call) presence check for GITHUB_TOKEN -- string presence/length only, never its value. + * Missing this is a common cause of a doctor-clean setup still failing on the first real attempt (#5170). */ +function checkGithubTokenPresent(env) { + const present = typeof env.GITHUB_TOKEN === "string" && env.GITHUB_TOKEN.trim().length > 0; + return { + name: "github-token-present", + ok: present, + detail: present ? "GITHUB_TOKEN is set" : "GITHUB_TOKEN is not set -- required for attempts to open/update PRs", + }; +} + +/** Offline presence check for whichever credential the CONFIGURED coding-agent provider actually needs (#5170). + * `noop` and `agent-sdk` are locally-authenticated with no separate env-var/file credential this repo tracks + * (driver-factory.ts's own comment: "All are locally-authenticated (no API-key env requirement)"), so they -- + * and an unconfigured provider -- report `ok: true` advisory. `claude-cli`/`codex-cli` reuse the exact + * credential conditions `checkClaudeCliPresent`/`checkCodexCliPresent` already probe (CLAUDE_CODE_OAUTH_TOKEN / + * the codex auth.json path) rather than re-deriving them, and fail doctor when missing since every attempt + * would otherwise fail mid-run on it. Never prints the credential's actual value -- only presence/paths/names. */ +function checkCodingAgentCredentialPresent(env) { + const provider = resolveFirstConfiguredCodingAgentDriverName(env) ?? null; + if (provider === "claude-cli") { + const present = typeof env.CLAUDE_CODE_OAUTH_TOKEN === "string" && env.CLAUDE_CODE_OAUTH_TOKEN.trim().length > 0; + return { + name: "coding-agent-credential-present", + ok: present, + detail: present + ? "CLAUDE_CODE_OAUTH_TOKEN is set" + : "CLAUDE_CODE_OAUTH_TOKEN is not set -- every claude-cli attempt will fail without it", + }; + } + if (provider === "codex-cli") { + const authPath = resolveCodexAuthPath(env); + let present = false; + try { + accessSync(authPath, constants.R_OK); + present = true; + } catch { + // missing or unreadable -- codex would fail for lack of credentials at call time. + } + return { + name: "coding-agent-credential-present", + ok: present, + detail: present + ? `${authPath} is readable` + : `${authPath} is missing or unreadable -- run \`codex auth\`; every codex-cli attempt will fail without it`, + }; + } + return { + name: "coding-agent-credential-present", + ok: true, + detail: provider + ? `${provider} requires no separate credential file/env var` + : "no coding-agent provider configured", + }; +} + /** Run the doctor checks. Returns an array of { name, ok, detail }; only writes a transient probe in the state dir, * never touches the network. */ export function runDoctorChecks(env = process.env) { @@ -294,6 +351,8 @@ export function runDoctorChecks(env = process.env) { checkDockerPresent(), checkClaudeCliPresent({ env }), checkCodexCliPresent({ env }), + checkGithubTokenPresent(env), + checkCodingAgentCredentialPresent(env), ]; } diff --git a/test/unit/miner-status.test.ts b/test/unit/miner-status.test.ts index 1c562fd3d3..4be39829ce 100644 --- a/test/unit/miner-status.test.ts +++ b/test/unit/miner-status.test.ts @@ -77,9 +77,9 @@ describe("gittensory-miner status/doctor (#2288)", () => { expect(JSON.parse(String(log.mock.calls[0]?.[0])).stateDir).toBe("/s"); }); - it("doctor passes on a healthy setup (writable state dir, initialized sqlite, optional Docker)", () => { + it("doctor passes on a healthy setup (writable state dir, initialized sqlite, optional Docker, GITHUB_TOKEN set)", () => { const log = vi.spyOn(console, "log").mockImplementation(() => {}); - const env = { GITTENSORY_MINER_CONFIG_DIR: join(tempRoot(), "state") }; + const env = { GITTENSORY_MINER_CONFIG_DIR: join(tempRoot(), "state"), GITHUB_TOKEN: "ghp_faketoken" }; initLaptopState(env); const checks = runDoctorChecks(env); expect(checks.every((check) => check.ok)).toBe(true); @@ -92,6 +92,8 @@ describe("gittensory-miner status/doctor (#2288)", () => { "docker-present", "claude-cli-present", "codex-cli-present", + "github-token-present", + "coding-agent-credential-present", ]); expect(runDoctor([], env)).toBe(0); expect(log).toHaveBeenCalled(); @@ -154,7 +156,7 @@ describe("gittensory-miner status/doctor (#2288)", () => { it("runDoctor supports --json output", () => { const log = vi.spyOn(console, "log").mockImplementation(() => {}); - const env = { GITTENSORY_MINER_CONFIG_DIR: join(tempRoot(), "state") }; + const env = { GITTENSORY_MINER_CONFIG_DIR: join(tempRoot(), "state"), GITHUB_TOKEN: "ghp_faketoken" }; initLaptopState(env); expect(runDoctor(["--json"], env)).toBe(0); expect(JSON.parse(String(log.mock.calls[0]?.[0])).checks).toBeDefined(); @@ -313,4 +315,106 @@ describe("gittensory-miner status/doctor (#2288)", () => { } }); }); + + describe("doctor credential-presence checks (#5170)", () => { + it("github-token-present: ok true when GITHUB_TOKEN is a non-empty string", () => { + const check = runDoctorChecks({ GITHUB_TOKEN: "ghp_faketoken" }).find( + (c) => c.name === "github-token-present", + ); + expect(check?.ok).toBe(true); + expect(check?.detail).toBe("GITHUB_TOKEN is set"); + }); + + it("github-token-present: ok false (regression guard) when GITHUB_TOKEN is unset or blank", () => { + const unset = runDoctorChecks({}).find((c) => c.name === "github-token-present"); + expect(unset?.ok).toBe(false); + const blank = runDoctorChecks({ GITHUB_TOKEN: " " }).find((c) => c.name === "github-token-present"); + expect(blank?.ok).toBe(false); + }); + + it("coding-agent-credential-present: no provider configured is advisory (ok true)", () => { + const check = runDoctorChecks({}).find((c) => c.name === "coding-agent-credential-present"); + expect(check?.ok).toBe(true); + expect(check?.detail).toBe("no coding-agent provider configured"); + }); + + it("coding-agent-credential-present: noop/agent-sdk require no separate credential (ok true, no secret in message)", () => { + const noop = runDoctorChecks({ MINER_CODING_AGENT_PROVIDER: "noop" }).find( + (c) => c.name === "coding-agent-credential-present", + ); + expect(noop?.ok).toBe(true); + expect(noop?.detail).toBe("noop requires no separate credential file/env var"); + + const agentSdk = runDoctorChecks({ MINER_CODING_AGENT_PROVIDER: "agent-sdk" }).find( + (c) => c.name === "coding-agent-credential-present", + ); + expect(agentSdk?.ok).toBe(true); + expect(agentSdk?.detail).toBe("agent-sdk requires no separate credential file/env var"); + }); + + it("coding-agent-credential-present: claude-cli configured + token set is ok true", () => { + const check = runDoctorChecks({ + MINER_CODING_AGENT_PROVIDER: "claude-cli", + CLAUDE_CODE_OAUTH_TOKEN: "fake-oauth-token", + }).find((c) => c.name === "coding-agent-credential-present"); + expect(check?.ok).toBe(true); + expect(check?.detail).toBe("CLAUDE_CODE_OAUTH_TOKEN is set"); + }); + + it("coding-agent-credential-present: claude-cli configured + token missing fails with an actionable message", () => { + const check = runDoctorChecks({ MINER_CODING_AGENT_PROVIDER: "claude-cli" }).find( + (c) => c.name === "coding-agent-credential-present", + ); + expect(check?.ok).toBe(false); + expect(check?.detail).toBe( + "CLAUDE_CODE_OAUTH_TOKEN is not set -- every claude-cli attempt will fail without it", + ); + }); + + it("coding-agent-credential-present: codex-cli configured + auth.json readable is ok true", () => { + const root = tempRoot(); + const authFile = join(root, "auth.json"); + writeFileSync(authFile, "{}"); + const check = runDoctorChecks({ MINER_CODING_AGENT_PROVIDER: "codex-cli", CODEX_HOME: root }).find( + (c) => c.name === "coding-agent-credential-present", + ); + expect(check?.ok).toBe(true); + expect(check?.detail).toBe(`${authFile} is readable`); + }); + + it("coding-agent-credential-present: codex-cli configured + auth.json missing fails with an actionable message", () => { + const root = tempRoot(); + const check = runDoctorChecks({ MINER_CODING_AGENT_PROVIDER: "codex-cli", CODEX_HOME: root }).find( + (c) => c.name === "coding-agent-credential-present", + ); + expect(check?.ok).toBe(false); + expect(check?.detail).toBe( + `${join(root, "auth.json")} is missing or unreadable -- run \`codex auth\`; every codex-cli attempt will fail without it`, + ); + }); + + it("makes zero network calls (preserves doctor's documented invariant)", () => { + const fetchStub = vi.fn(() => { + throw new Error("network calls are forbidden"); + }); + vi.stubGlobal("fetch", fetchStub); + runDoctorChecks({ GITHUB_TOKEN: "ghp_faketoken", MINER_CODING_AGENT_PROVIDER: "codex-cli" }); + expect(fetchStub).not.toHaveBeenCalled(); + }); + + it("invariant: doctor's output never contains an actual credential value, only presence booleans/names/paths", () => { + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const secretToken = "ghp_should-never-appear-in-doctor-output"; + const secretOauth = "oauth-should-never-appear-in-doctor-output"; + runDoctor(["--json"], { + GITTENSORY_MINER_CONFIG_DIR: tempRoot(), + GITHUB_TOKEN: secretToken, + MINER_CODING_AGENT_PROVIDER: "claude-cli", + CLAUDE_CODE_OAUTH_TOKEN: secretOauth, + }); + const output = String(log.mock.calls[0]?.[0]); + expect(output).not.toContain(secretToken); + expect(output).not.toContain(secretOauth); + }); + }); });