diff --git a/packages/gittensory-miner/lib/laptop-init.d.ts b/packages/gittensory-miner/lib/laptop-init.d.ts index e76283e618..8cf942b591 100644 --- a/packages/gittensory-miner/lib/laptop-init.d.ts +++ b/packages/gittensory-miner/lib/laptop-init.d.ts @@ -41,6 +41,8 @@ export function checkCodexCliPresent(options?: { resolveCodexAuthPath?: () => string; }): DoctorCheck; +export function resolveCodexAuthPath(env?: Record): string; + export function verifyGithubToken(options?: { githubToken?: string; fetchImpl?: typeof fetch; diff --git a/packages/gittensory-miner/lib/laptop-init.js b/packages/gittensory-miner/lib/laptop-init.js index b416e577ad..57f8cded6d 100644 --- a/packages/gittensory-miner/lib/laptop-init.js +++ b/packages/gittensory-miner/lib/laptop-init.js @@ -105,8 +105,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 `doctor`'s provider-credential check (status.js, #5170) resolves the SAME +// path this file's own codex auth probe uses, instead of duplicating the location logic. +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.d.ts b/packages/gittensory-miner/lib/status.d.ts index 3cc8206040..15da98e7a6 100644 --- a/packages/gittensory-miner/lib/status.d.ts +++ b/packages/gittensory-miner/lib/status.d.ts @@ -27,6 +27,13 @@ export function runStatus(args?: string[], env?: Record string): DoctorCheck; +export function checkGitHubTokenPresent(env?: Record): DoctorCheck; + +export function checkCodingAgentCredential( + env?: Record, + resolveAuthPath?: (env: Record) => string, +): DoctorCheck; + export function runDoctorChecks(env?: Record, cwd?: string): DoctorCheck[]; export function runDoctor(args?: string[], env?: Record, cwd?: string): number; diff --git a/packages/gittensory-miner/lib/status.js b/packages/gittensory-miner/lib/status.js index a6bda0f126..9835cc9952 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"; import { checkStoreIntegrity, describeError } from "./store-maintenance.js"; @@ -329,6 +330,74 @@ export function checkConfigContent(cwd, readImpl = readFileSync) { : { name: "config-content", ok: false, detail: `${configPath}: ${warnings.join("; ")}` }; } +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. */ +export function checkGitHubTokenPresent(env = process.env) { + const present = nonEmptyEnv(env.GITHUB_TOKEN); + 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", + }; +} + +/** Credential presence for the CONFIGURED coding-agent provider (#5170). Distinct from the CLI-present checks, + * which by design keep `ok: true` when only the credential is missing (#5165): this FAILS `doctor` when the + * resolved provider's credential is absent, so an operator learns before an attempt fails partway through. + * Fully offline — an env-var string check for the Claude backends, a file-readability check for codex — and it + * never prints the credential value, only the env-var names / file path. `resolveAuthPath` is injectable for + * tests, mirroring `checkCodexCliPresent`. */ +export function checkCodingAgentCredential(env = process.env, resolveAuthPath = resolveCodexAuthPath) { + const provider = resolveFirstConfiguredCodingAgentDriverName(env) ?? null; + if (provider === null || provider === "noop") { + return { + name: "coding-agent-credential", + ok: true, + detail: + provider === "noop" + ? "noop driver needs no credential" + : "no coding-agent provider configured (skipped)", + }; + } + if (provider === "claude-cli" || provider === "agent-sdk") { + // Both run the Claude backend (a `claude` subprocess vs the in-process Agent SDK) off the same subscription + // OAuth token the rest of the tree reads (CLAUDE_CODE_OAUTH_TOKEN; see createClaudeCodeAi in + // src/selfhost/ai.ts). The SDK additionally accepts a raw ANTHROPIC_API_KEY, so either satisfies the credential. + const present = nonEmptyEnv(env.CLAUDE_CODE_OAUTH_TOKEN) || nonEmptyEnv(env.ANTHROPIC_API_KEY); + return { + name: "coding-agent-credential", + ok: present, + detail: present + ? `${provider}: Claude credential is set` + : `${provider}: no Claude credential — set CLAUDE_CODE_OAUTH_TOKEN (or ANTHROPIC_API_KEY)`, + }; + } + // codex-cli: the only remaining configured provider — its credential is a readable auth.json, the same + // read-only condition checkCodexCliPresent probes (reusing resolveCodexAuthPath so the location never drifts). + const authPath = resolveAuthPath(env); + let readable = false; + try { + accessSync(authPath, constants.R_OK); + readable = true; + } catch { + // missing or unreadable — codex would fail for lack of credentials at attempt time. + } + return { + name: "coding-agent-credential", + ok: readable, + detail: readable + ? `codex-cli: auth.json is readable at ${authPath}` + : `codex-cli: auth.json missing or unreadable at ${authPath} — run \`codex auth\``, + }; +} + /** 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, cwd = process.cwd()) { @@ -352,6 +421,8 @@ export function runDoctorChecks(env = process.env, cwd = process.cwd()) { checkDockerPresent(), checkClaudeCliPresent({ env }), checkCodexCliPresent({ env }), + checkGitHubTokenPresent(env), + checkCodingAgentCredential(env), checkConfigContent(cwd), ...storeIntegrityChecks(env), ]; diff --git a/test/unit/miner-cli-doctor-checks.test.ts b/test/unit/miner-cli-doctor-checks.test.ts index 38820979b8..beeb2c9e5d 100644 --- a/test/unit/miner-cli-doctor-checks.test.ts +++ b/test/unit/miner-cli-doctor-checks.test.ts @@ -3,7 +3,11 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { checkClaudeCliPresent, checkCodexCliPresent } from "../../packages/gittensory-miner/lib/laptop-init.js"; -import { runDoctorChecks } from "../../packages/gittensory-miner/lib/status.js"; +import { + checkCodingAgentCredential, + checkGitHubTokenPresent, + runDoctorChecks, +} from "../../packages/gittensory-miner/lib/status.js"; const roots: string[] = []; function tempRoot() { @@ -341,3 +345,135 @@ describe("gittensory-miner doctor — coding-agent CLI checks (#4304)", () => { }); }); }); + +describe("gittensory-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" }); + }); + + it("fails with an actionable message when GITHUB_TOKEN is unset", () => { + const check = checkGitHubTokenPresent({}); + 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"); + }); + + it("fails when GITHUB_TOKEN is present but empty (the length>0 branch)", () => { + expect(checkGitHubTokenPresent({ GITHUB_TOKEN: "" }).ok).toBe(false); + }); + }); + + describe("checkCodingAgentCredential", () => { + const readableAuth = () => { + const authFile = join(tempRoot(), "auth.json"); + writeFileSync(authFile, "{}"); + return authFile; + }; + const missingAuth = () => join(tempRoot(), "does-not-exist.json"); + + it("skips (ok) when no provider is configured", () => { + const check = checkCodingAgentCredential({}); + expect(check).toMatchObject({ name: "coding-agent-credential", ok: true }); + expect(check.detail).toBe("no coding-agent provider configured (skipped)"); + }); + + it("skips (ok) for the noop driver", () => { + const check = checkCodingAgentCredential({ MINER_CODING_AGENT_PROVIDER: "noop" }); + expect(check.ok).toBe(true); + expect(check.detail).toBe("noop driver needs no credential"); + }); + + it("claude-cli: passes when CLAUDE_CODE_OAUTH_TOKEN is set", () => { + const check = checkCodingAgentCredential({ + MINER_CODING_AGENT_PROVIDER: "claude-cli", + CLAUDE_CODE_OAUTH_TOKEN: "tok", + }); + expect(check.ok).toBe(true); + expect(check.detail).toBe("claude-cli: Claude credential is set"); + }); + + it("claude-cli: fails with a specific remediation when no Claude credential is set", () => { + const check = checkCodingAgentCredential({ MINER_CODING_AGENT_PROVIDER: "claude-cli" }); + expect(check.ok).toBe(false); + expect(check.detail).toBe( + "claude-cli: no Claude credential — set CLAUDE_CODE_OAUTH_TOKEN (or ANTHROPIC_API_KEY)", + ); + }); + + it("agent-sdk: accepts a raw ANTHROPIC_API_KEY as the credential (the API-key branch)", () => { + const check = checkCodingAgentCredential({ + MINER_CODING_AGENT_PROVIDER: "agent-sdk", + ANTHROPIC_API_KEY: "sk-ant-xyz", + }); + expect(check.ok).toBe(true); + expect(check.detail).toBe("agent-sdk: Claude credential is set"); + }); + + it("agent-sdk: fails when neither Claude credential is present", () => { + expect(checkCodingAgentCredential({ MINER_CODING_AGENT_PROVIDER: "agent-sdk" }).ok).toBe(false); + }); + + it("codex-cli: passes when auth.json is readable", () => { + const authPath = readableAuth(); + const check = checkCodingAgentCredential({ MINER_CODING_AGENT_PROVIDER: "codex-cli" }, () => authPath); + expect(check.ok).toBe(true); + expect(check.detail).toBe(`codex-cli: auth.json is readable at ${authPath}`); + }); + + it("codex-cli: fails with the `codex auth` remediation when auth.json is missing", () => { + const authPath = missingAuth(); + const check = checkCodingAgentCredential({ MINER_CODING_AGENT_PROVIDER: "codex-cli" }, () => authPath); + expect(check.ok).toBe(false); + expect(check.detail).toBe( + `codex-cli: auth.json missing or unreadable at ${authPath} — run \`codex auth\``, + ); + }); + + it("resolves the credential for the FIRST configured provider in a fallback chain", () => { + // "agent-sdk,codex-cli" resolves to agent-sdk first, so its Claude credential is what's checked. + const check = checkCodingAgentCredential({ + MINER_CODING_AGENT_PROVIDER: "agent-sdk,codex-cli", + CLAUDE_CODE_OAUTH_TOKEN: "tok", + }); + expect(check.ok).toBe(true); + expect(check.detail).toBe("agent-sdk: Claude credential is set"); + }); + }); + + it("runDoctorChecks now includes the github-token and coding-agent-credential checks", () => { + const names = runDoctorChecks({ GITTENSORY_MINER_CONFIG_DIR: tempRoot() }).map((check) => check.name); + expect(names).toContain("github-token"); + expect(names).toContain("coding-agent-credential"); + }); + + it("REGRESSION (#5170): claude-cli configured + CLI present but no token now FAILS doctor (the gap the CLI-present advisory left open)", () => { + // Before this check, checkClaudeCliPresent stayed ok:true when only the credential was missing (#5165), so + // doctor passed cleanly and the operator only learned of the missing token when a live attempt failed. + const checks = runDoctorChecks({ + MINER_CODING_AGENT_PROVIDER: "claude-cli", + GITHUB_TOKEN: "ghp_present", + GITTENSORY_MINER_CONFIG_DIR: tempRoot(), + }); + const credential = checks.find((check) => check.name === "coding-agent-credential"); + expect(credential?.ok).toBe(false); + }); + + it("invariant: doctor never prints a credential VALUE, only presence + env-var/file names", () => { + const SECRET_GH = "ghp_super_secret_token_value"; + const SECRET_CLAUDE = "oauth_super_secret_value"; + const SECRET_ANTHROPIC = "sk-ant-super-secret-value"; + const checks = runDoctorChecks({ + MINER_CODING_AGENT_PROVIDER: "agent-sdk", + GITHUB_TOKEN: SECRET_GH, + CLAUDE_CODE_OAUTH_TOKEN: SECRET_CLAUDE, + ANTHROPIC_API_KEY: SECRET_ANTHROPIC, + GITTENSORY_MINER_CONFIG_DIR: tempRoot(), + }); + for (const check of checks) { + expect(check.detail).not.toContain(SECRET_GH); + expect(check.detail).not.toContain(SECRET_CLAUDE); + expect(check.detail).not.toContain(SECRET_ANTHROPIC); + } + }); +}); diff --git a/test/unit/miner-status.test.ts b/test/unit/miner-status.test.ts index e2cb6c0b50..2de30c49d7 100644 --- a/test/unit/miner-status.test.ts +++ b/test/unit/miner-status.test.ts @@ -100,7 +100,9 @@ describe("gittensory-miner status/doctor (#2288)", () => { it("doctor passes on a healthy setup (writable state dir, initialized sqlite, optional Docker)", () => { const log = vi.spyOn(console, "log").mockImplementation(() => {}); - const env = { GITTENSORY_MINER_CONFIG_DIR: join(tempRoot(), "state") }; + // A healthy setup now also requires GITHUB_TOKEN (#5170); no coding-agent provider is configured, so the + // provider-credential check is a clean skip. + const env = { GITTENSORY_MINER_CONFIG_DIR: join(tempRoot(), "state"), GITHUB_TOKEN: "ghp_present" }; initLaptopState(env); const cwd = tempRoot(); // a config-less working dir ⇒ config-content check is a clean pass const checks = runDoctorChecks(env, cwd); @@ -114,6 +116,8 @@ describe("gittensory-miner status/doctor (#2288)", () => { "docker-present", "claude-cli-present", "codex-cli-present", + "github-token", + "coding-agent-credential", "config-content", "store-integrity:event-ledger", "store-integrity:governor-ledger", @@ -238,7 +242,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_present" }; initLaptopState(env); expect(runDoctor(["--json"], env)).toBe(0); expect(JSON.parse(String(log.mock.calls[0]?.[0])).checks).toBeDefined();