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/gittensory-miner/lib/laptop-init.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ export function checkCodexCliPresent(options?: {
resolveCodexAuthPath?: () => string;
}): DoctorCheck;

export function resolveCodexAuthPath(env?: Record<string, string | undefined>): string;

export function verifyGithubToken(options?: {
githubToken?: string;
fetchImpl?: typeof fetch;
Expand Down
5 changes: 3 additions & 2 deletions packages/gittensory-miner/lib/laptop-init.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
Expand Down
7 changes: 7 additions & 0 deletions packages/gittensory-miner/lib/status.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@ export function runStatus(args?: string[], env?: Record<string, string | undefin

export function checkConfigContent(cwd: string, readImpl?: (path: string, encoding: "utf8") => string): DoctorCheck;

export function checkGitHubTokenPresent(env?: Record<string, string | undefined>): DoctorCheck;

export function checkCodingAgentCredential(
env?: Record<string, string | undefined>,
resolveAuthPath?: (env: Record<string, string | undefined>) => string,
): DoctorCheck;

export function runDoctorChecks(env?: Record<string, string | undefined>, cwd?: string): DoctorCheck[];

export function runDoctor(args?: string[], env?: Record<string, string | undefined>, cwd?: string): number;
Expand Down
73 changes: 72 additions & 1 deletion packages/gittensory-miner/lib/status.js
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -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()) {
Expand All @@ -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),
];
Expand Down
138 changes: 137 additions & 1 deletion test/unit/miner-cli-doctor-checks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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);
}
});
});
8 changes: 6 additions & 2 deletions test/unit/miner-status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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",
Expand Down Expand Up @@ -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();
Expand Down