Skip to content
Closed
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 @@ -18,6 +18,8 @@ export function checkLaptopStateSqlite(env?: Record<string, string | undefined>)

export function findExecutableOnPath(name: string, env?: Record<string, string | undefined>): string | null;

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

export function checkDockerPresent(options?: {
env?: Record<string, string | undefined>;
resolveDockerPath?: () => string | null;
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 @@ -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");
}
Expand Down
61 changes: 60 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";

Expand Down Expand Up @@ -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) {
Expand All @@ -294,6 +351,8 @@ export function runDoctorChecks(env = process.env) {
checkDockerPresent(),
checkClaudeCliPresent({ env }),
checkCodexCliPresent({ env }),
checkGithubTokenPresent(env),
checkCodingAgentCredentialPresent(env),
];
}

Expand Down
110 changes: 107 additions & 3 deletions test/unit/miner-status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
});
});
});