fix(doctor): surface GH_CONFIG_DIR hint when gh auth lives at a different HOME

When OpenClaw spawns an agent shell with a different HOME than the user
that ran `gh auth login` (per-agent codex homes, systemd User= services,
sudo'd shells), `gh` looks at $XDG_CONFIG_HOME/gh or $HOME/.config/gh and
reports "not logged into any GitHub hosts" even though the operator HOME
has a valid hosts.yml.

Add `detectGhConfigDirMismatch` in src/agents/skills/gh-config-discovery.ts:
a pure helper that takes process env plus a fileExists probe and returns
either "auth-discoverable", "no-known-auth", "explicit-gh-config-dir-set",
or a "mismatch" with the alternate config dir, the host file path, and a
suggested GH_CONFIG_DIR value to set on the gateway service environment.
The helper checks `/root`, `$SUDO_USER`'s home, and `$USER`'s home as
candidate operator homes on Linux/macOS, and uses platform-specific path
joins so the same logic works on Windows test runners.

Wire the helper into the doctor skills health flow: when the github skill
is reported and the gh binary is present, call the discovery helper and,
on a mismatch, print a "GitHub CLI" note with the operator-actionable
fix instructions before any unavailable-skill repair prompt.

Update skills/github/SKILL.md with a troubleshooting subsection that
documents GH_CONFIG_DIR for service/agent environments where HOME differs
from the user that ran `gh auth login`.

Fixes #78063.
This commit is contained in:
tmimmanuel 2026-05-06 00:04:59 +02:00 committed by Peter Steinberger
parent 0abf14777d
commit 72b5cddbe1
5 changed files with 535 additions and 0 deletions

View file

@ -63,6 +63,25 @@ gh auth login
gh auth status
```
### When the gateway HOME differs from the operator HOME
OpenClaw agent shells often run with a different `HOME` than the user that ran
`gh auth login` (per-agent codex homes, systemd `User=` services, sudo). `gh`
looks up its config under `$GH_CONFIG_DIR`, then `$XDG_CONFIG_HOME/gh`, then
`$HOME/.config/gh`, so the agent shell can report `not logged into any GitHub
hosts` even when the operator login is intact.
To point the gateway at the canonical `gh` config, set `GH_CONFIG_DIR` on the
service environment, e.g.
```bash
# Gateway service env file (example: ~/.openclaw/gateway.systemd.env)
GH_CONFIG_DIR=/path/to/operator/.config/gh
```
then restart the gateway. `openclaw doctor` warns when it detects an authenticated
`hosts.yml` outside the agent process's effective `gh` config dir.
## Common Commands
### Pull Requests

View file

@ -0,0 +1,223 @@
import { describe, expect, it } from "vitest";
import {
detectGhConfigDirMismatch,
formatGhConfigDirMismatchHint,
type GhConfigDirMismatch,
type GhConfigDiscoveryInput,
} from "./gh-config-discovery.js";
function makeInput(overrides: Partial<GhConfigDiscoveryInput>): GhConfigDiscoveryInput {
return {
platform: "linux",
env: {},
fileExists: () => false,
...overrides,
};
}
function fileSet(...paths: readonly string[]): (absolutePath: string) => boolean {
const set = new Set(paths);
return (absolutePath) => set.has(absolutePath);
}
describe("detectGhConfigDirMismatch", () => {
it("returns 'explicit-gh-config-dir-set' when GH_CONFIG_DIR is already set", () => {
const result = detectGhConfigDirMismatch(
makeInput({
env: { HOME: "/agent/home", GH_CONFIG_DIR: "/etc/openclaw/gh" },
}),
);
expect(result).toEqual({ kind: "explicit-gh-config-dir-set", ghConfigDir: "/etc/openclaw/gh" });
});
it("returns 'no-process-home' when HOME and XDG and APPDATA are missing", () => {
const result = detectGhConfigDirMismatch(makeInput({ env: {} }));
expect(result).toEqual({ kind: "no-process-home" });
});
it("returns 'auth-discoverable' when the effective config dir already has hosts.yml", () => {
const result = detectGhConfigDirMismatch(
makeInput({
env: { HOME: "/agent/home" },
fileExists: fileSet("/agent/home/.config/gh/hosts.yml"),
}),
);
expect(result).toEqual({
kind: "auth-discoverable",
effectiveConfigDir: "/agent/home/.config/gh",
});
});
it("flags a mismatch when /root/.config/gh has hosts.yml but the agent HOME does not", () => {
const result = detectGhConfigDirMismatch(
makeInput({
env: { HOME: "/root/.openclaw/agents/main/agent/codex-home/home" },
fileExists: fileSet("/root/.config/gh/hosts.yml"),
}),
);
expect(result).toMatchObject({
kind: "mismatch",
effectiveConfigDir: "/root/.openclaw/agents/main/agent/codex-home/home/.config/gh",
alternateConfigDir: "/root/.config/gh",
alternateHostsFile: "/root/.config/gh/hosts.yml",
alternateHomeHint: "/root",
suggestedEnvValue: "/root/.config/gh",
});
});
it("uses SUDO_USER home as a candidate when set", () => {
const result = detectGhConfigDirMismatch(
makeInput({
env: { HOME: "/var/lib/openclaw/agent", SUDO_USER: "alice" },
fileExists: fileSet("/home/alice/.config/gh/hosts.yml"),
}),
);
expect(result).toMatchObject({
kind: "mismatch",
alternateConfigDir: "/home/alice/.config/gh",
alternateHomeHint: "/home/alice",
});
});
it("uses USER home as a fallback candidate when SUDO_USER is missing", () => {
const result = detectGhConfigDirMismatch(
makeInput({
env: { HOME: "/var/lib/openclaw/agent", USER: "ops" },
fileExists: fileSet("/home/ops/.config/gh/hosts.yml"),
}),
);
expect(result).toMatchObject({
kind: "mismatch",
alternateConfigDir: "/home/ops/.config/gh",
alternateHomeHint: "/home/ops",
});
});
it("ignores USER=root since /root is already part of the default candidate set", () => {
const result = detectGhConfigDirMismatch(
makeInput({
env: { HOME: "/agent/home", USER: "root" },
fileExists: fileSet("/root/.config/gh/hosts.yml"),
}),
);
expect(result.kind).toBe("mismatch");
});
it("returns 'no-known-auth' when no candidate has hosts.yml", () => {
const result = detectGhConfigDirMismatch(
makeInput({
env: { HOME: "/agent/home" },
fileExists: () => false,
}),
);
expect(result).toEqual({
kind: "no-known-auth",
effectiveConfigDir: "/agent/home/.config/gh",
});
});
it("does not flag a mismatch when the agent HOME equals the operator HOME", () => {
const result = detectGhConfigDirMismatch(
makeInput({
env: { HOME: "/root" },
fileExists: fileSet("/root/.config/gh/hosts.yml"),
}),
);
expect(result).toEqual({
kind: "auth-discoverable",
effectiveConfigDir: "/root/.config/gh",
});
});
it("respects XDG_CONFIG_HOME for the effective config dir on Linux", () => {
const result = detectGhConfigDirMismatch(
makeInput({
env: { HOME: "/agent/home", XDG_CONFIG_HOME: "/agent/xdg" },
fileExists: fileSet("/agent/xdg/gh/hosts.yml"),
}),
);
expect(result).toEqual({
kind: "auth-discoverable",
effectiveConfigDir: "/agent/xdg/gh",
});
});
it("uses HOME/.config/gh on darwin (matches gh's documented macOS lookup)", () => {
const result = detectGhConfigDirMismatch(
makeInput({
platform: "darwin",
env: { HOME: "/Users/agent" },
fileExists: fileSet("/Users/operator/.config/gh/hosts.yml"),
candidateOperatorHomes: ["/Users/operator"],
}),
);
expect(result).toMatchObject({
kind: "mismatch",
effectiveConfigDir: "/Users/agent/.config/gh",
alternateConfigDir: "/Users/operator/.config/gh",
alternateHomeHint: "/Users/operator",
});
});
it("uses APPDATA/GitHub CLI on win32", () => {
const result = detectGhConfigDirMismatch(
makeInput({
platform: "win32",
env: { APPDATA: "C:\\Users\\agent\\AppData\\Roaming" },
fileExists: fileSet("C:\\Users\\agent\\AppData\\Roaming\\GitHub CLI\\hosts.yml"),
}),
);
expect(result).toMatchObject({
kind: "auth-discoverable",
effectiveConfigDir: "C:\\Users\\agent\\AppData\\Roaming\\GitHub CLI",
});
});
it("respects an explicit candidateOperatorHomes list", () => {
const result = detectGhConfigDirMismatch(
makeInput({
env: { HOME: "/agent/home" },
fileExists: fileSet("/srv/automation/.config/gh/hosts.yml"),
candidateOperatorHomes: ["/srv/automation"],
}),
);
expect(result).toMatchObject({
kind: "mismatch",
alternateConfigDir: "/srv/automation/.config/gh",
alternateHomeHint: "/srv/automation",
});
});
});
describe("formatGhConfigDirMismatchHint", () => {
it("formats the mismatch into operator-actionable lines", () => {
const mismatch: GhConfigDirMismatch = {
effectiveConfigDir: "/agent/home/.config/gh",
alternateConfigDir: "/root/.config/gh",
alternateHostsFile: "/root/.config/gh/hosts.yml",
alternateHomeHint: "/root",
suggestedEnvValue: "/root/.config/gh",
};
expect(formatGhConfigDirMismatchHint(mismatch)).toEqual([
"GitHub CLI auth was found at a different HOME than the one this OpenClaw process uses.",
" Process gh config dir: /agent/home/.config/gh",
" Authenticated config: /root/.config/gh (contains hosts.yml)",
" Authenticated HOME: /root",
" Fix: set GH_CONFIG_DIR=/root/.config/gh on the OpenClaw service environment, then restart the gateway.",
]);
});
it("omits the home hint line when the alternate has no associated HOME", () => {
const mismatch: GhConfigDirMismatch = {
effectiveConfigDir: "/agent/home/.config/gh",
alternateConfigDir: "/srv/automation/.config/gh",
alternateHostsFile: "/srv/automation/.config/gh/hosts.yml",
suggestedEnvValue: "/srv/automation/.config/gh",
};
const lines = formatGhConfigDirMismatchHint(mismatch);
expect(lines.some((line) => line.includes("Authenticated HOME"))).toBe(false);
expect(lines).toContain(
" Fix: set GH_CONFIG_DIR=/srv/automation/.config/gh on the OpenClaw service environment, then restart the gateway.",
);
});
});

View file

@ -0,0 +1,187 @@
import { posix as posixPath, win32 as win32Path } from "node:path";
function pathFor(platform: NodeJS.Platform) {
return platform === "win32" ? win32Path : posixPath;
}
// Detects the case where `gh` is authenticated under one HOME but the current
// OpenClaw process is running with a different HOME (e.g. the per-agent
// codex-home, a systemd service home, or a sudo'd shell). Without GH_CONFIG_DIR
// the gh CLI looks at $XDG_CONFIG_HOME/gh or $HOME/.config/gh and reports
// "not logged in", even though the operator HOME has a valid hosts.yml.
// See https://github.com/openclaw/openclaw/issues/78063.
export type GhConfigDiscoveryEnv = {
HOME?: string;
XDG_CONFIG_HOME?: string;
GH_CONFIG_DIR?: string;
APPDATA?: string;
SUDO_USER?: string;
USER?: string;
USERPROFILE?: string;
};
export type GhConfigDiscoveryInput = {
platform: NodeJS.Platform;
env: GhConfigDiscoveryEnv;
fileExists: (absolutePath: string) => boolean;
// Optional: well-known operator-home guesses to consider when looking for an
// alternate gh config dir. Defaults to a small Linux/macOS set; tests pass an
// explicit list to keep behavior deterministic.
candidateOperatorHomes?: readonly string[];
};
export type GhConfigDirMismatch = {
// The directory `gh` would actually consult given the current process env.
effectiveConfigDir: string;
// The directory that contains the operator's real `hosts.yml`.
alternateConfigDir: string;
// Absolute path to the alternate hosts.yml that the current process won't see.
alternateHostsFile: string;
// The HOME-like path the alternate dir was derived from, if known.
alternateHomeHint?: string;
// Suggested env value the operator should set on the OpenClaw service to
// surface the alternate config to the agent shell.
suggestedEnvValue: string;
};
export type GhConfigDiscoveryResult =
| { kind: "no-gh-binary" }
| { kind: "explicit-gh-config-dir-set"; ghConfigDir: string }
| { kind: "no-process-home" }
| { kind: "auth-discoverable"; effectiveConfigDir: string }
| { kind: "no-known-auth"; effectiveConfigDir: string }
| ({ kind: "mismatch" } & GhConfigDirMismatch);
const HOSTS_FILE = "hosts.yml";
// gh config-dir lookup order, matching the documented behavior of `gh
// help environment` for each platform. macOS uses Library/Application Support,
// Windows uses %AppData%\GitHub CLI, Linux/other uses XDG_CONFIG_HOME or
// $HOME/.config/gh.
function resolveEffectiveGhConfigDir(input: GhConfigDiscoveryInput): string | undefined {
const env = input.env;
if (env.GH_CONFIG_DIR && env.GH_CONFIG_DIR.trim()) {
return env.GH_CONFIG_DIR.trim();
}
if (input.platform === "win32") {
const appData = env.APPDATA?.trim();
if (appData) {
return pathFor(input.platform).join(appData, "GitHub CLI");
}
const profile = env.USERPROFILE?.trim();
if (profile) {
return pathFor(input.platform).join(profile, "AppData", "Roaming", "GitHub CLI");
}
return undefined;
}
if (input.platform === "darwin") {
const home = env.HOME?.trim();
if (!home) {
return undefined;
}
return pathFor(input.platform).join(home, ".config", "gh");
}
// Linux and POSIX-like default
const xdg = env.XDG_CONFIG_HOME?.trim();
if (xdg) {
return pathFor(input.platform).join(xdg, "gh");
}
const home = env.HOME?.trim();
if (!home) {
return undefined;
}
return pathFor(input.platform).join(home, ".config", "gh");
}
function defaultCandidateOperatorHomes(input: GhConfigDiscoveryInput): string[] {
const env = input.env;
const homes = new Set<string>();
// Common operator HOME on Linux servers running gateway as root.
if (input.platform !== "win32") {
homes.add("/root");
}
// sudo invocation: the original shell user's home is exposed through SUDO_USER.
if (env.SUDO_USER?.trim()) {
const sudoUser = env.SUDO_USER.trim();
homes.add(pathFor(input.platform).join("/home", sudoUser));
if (input.platform === "darwin") {
homes.add(pathFor(input.platform).join("/Users", sudoUser));
}
}
// USER fallback: works when HOME has been redirected but the login user is
// still on the env (e.g. systemd User= with PassEnvironment=USER).
if (env.USER?.trim()) {
const user = env.USER.trim();
if (user !== "root") {
if (input.platform === "darwin") {
homes.add(pathFor(input.platform).join("/Users", user));
} else if (input.platform !== "win32") {
homes.add(pathFor(input.platform).join("/home", user));
}
}
}
// Drop the current process HOME from the candidate set; we want directories
// that are NOT what gh would already consult.
const processHome = env.HOME?.trim();
if (processHome) {
homes.delete(processHome);
}
return [...homes];
}
function ghConfigDirForHome(home: string, platform: NodeJS.Platform): string {
// Linux and macOS both put gh's config under <HOME>/.config/gh. Windows is
// not a realistic mismatch case for the bug this helper detects; we still
// return the POSIX-layout directory so the hint points at a sensible path.
return pathFor(platform).join(home, ".config", "gh");
}
export function detectGhConfigDirMismatch(input: GhConfigDiscoveryInput): GhConfigDiscoveryResult {
const env = input.env;
if (env.GH_CONFIG_DIR && env.GH_CONFIG_DIR.trim()) {
return { kind: "explicit-gh-config-dir-set", ghConfigDir: env.GH_CONFIG_DIR.trim() };
}
const effective = resolveEffectiveGhConfigDir(input);
if (!effective) {
return { kind: "no-process-home" };
}
const effectiveHosts = pathFor(input.platform).join(effective, HOSTS_FILE);
if (input.fileExists(effectiveHosts)) {
return { kind: "auth-discoverable", effectiveConfigDir: effective };
}
const candidates = input.candidateOperatorHomes ?? defaultCandidateOperatorHomes(input);
for (const home of candidates) {
const candidateDir = ghConfigDirForHome(home, input.platform);
if (candidateDir === effective) {
continue;
}
const candidateHosts = pathFor(input.platform).join(candidateDir, HOSTS_FILE);
if (input.fileExists(candidateHosts)) {
return {
kind: "mismatch",
effectiveConfigDir: effective,
alternateConfigDir: candidateDir,
alternateHostsFile: candidateHosts,
alternateHomeHint: home,
suggestedEnvValue: candidateDir,
};
}
}
return { kind: "no-known-auth", effectiveConfigDir: effective };
}
export function formatGhConfigDirMismatchHint(mismatch: GhConfigDirMismatch): string[] {
const lines: string[] = [
"GitHub CLI auth was found at a different HOME than the one this OpenClaw process uses.",
` Process gh config dir: ${mismatch.effectiveConfigDir}`,
` Authenticated config: ${mismatch.alternateConfigDir} (contains ${HOSTS_FILE})`,
];
if (mismatch.alternateHomeHint) {
lines.push(` Authenticated HOME: ${mismatch.alternateHomeHint}`);
}
lines.push(
` Fix: set GH_CONFIG_DIR=${mismatch.suggestedEnvValue} on the OpenClaw service environment, then restart the gateway.`,
);
return lines;
}

View file

@ -1,9 +1,11 @@
import { describe, expect, it } from "vitest";
import type { SkillStatusEntry, SkillStatusReport } from "../agents/skills-status.js";
import type { GhConfigDiscoveryInput } from "../agents/skills/gh-config-discovery.js";
import { createEmptyInstallChecks } from "../cli/requirements-test-fixtures.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import {
collectUnavailableAgentSkills,
describeGhConfigDirHintFromDiscovery,
disableUnavailableSkillsInConfig,
formatUnavailableSkillDoctorLines,
} from "./doctor-skills.js";
@ -87,6 +89,67 @@ describe("doctor skills", () => {
expect(lines.join("\n")).toContain("openclaw doctor --fix");
});
it("surfaces a GH_CONFIG_DIR hint when the github skill is eligible but auth lives at a different HOME", () => {
const githubSkill = createSkill({
name: "github",
skillKey: "github",
eligible: true,
missing: { bins: [], anyBins: [], env: [], config: [], os: [] },
});
const discovery: GhConfigDiscoveryInput = {
platform: "linux",
env: { HOME: "/root/.openclaw/agents/main/agent/codex-home/home" },
fileExists: (p) => p === "/root/.config/gh/hosts.yml",
};
const lines = describeGhConfigDirHintFromDiscovery([githubSkill], discovery);
expect(lines.some((line) => line.includes("/root/.config/gh"))).toBe(true);
expect(lines.join("\n")).toContain("GH_CONFIG_DIR=/root/.config/gh");
});
it("does not surface the GH_CONFIG_DIR hint when the github skill is missing the gh binary", () => {
const githubSkill = createSkill({
name: "github",
skillKey: "github",
eligible: false,
missing: { bins: ["gh"], anyBins: [], env: [], config: [], os: [] },
});
const discovery: GhConfigDiscoveryInput = {
platform: "linux",
env: { HOME: "/agent/home" },
fileExists: (p) => p === "/root/.config/gh/hosts.yml",
};
expect(describeGhConfigDirHintFromDiscovery([githubSkill], discovery)).toEqual([]);
});
it("does not surface the GH_CONFIG_DIR hint when GH_CONFIG_DIR is already set", () => {
const githubSkill = createSkill({
name: "github",
skillKey: "github",
eligible: true,
missing: { bins: [], anyBins: [], env: [], config: [], os: [] },
});
const discovery: GhConfigDiscoveryInput = {
platform: "linux",
env: { HOME: "/agent/home", GH_CONFIG_DIR: "/etc/openclaw/gh" },
fileExists: () => true,
};
expect(describeGhConfigDirHintFromDiscovery([githubSkill], discovery)).toEqual([]);
});
it("does not surface the GH_CONFIG_DIR hint when the github skill is not present in the report", () => {
const discovery: GhConfigDiscoveryInput = {
platform: "linux",
env: { HOME: "/agent/home" },
fileExists: (p) => p === "/root/.config/gh/hosts.yml",
};
expect(describeGhConfigDirHintFromDiscovery([], discovery)).toEqual([]);
});
it("disables unavailable skills through skills.entries without dropping existing config", () => {
const config: OpenClawConfig = {
skills: {

View file

@ -1,6 +1,13 @@
import { existsSync } from "node:fs";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import type { SkillStatusEntry, SkillStatusReport } from "../agents/skills-status.js";
import { buildWorkspaceSkillStatus } from "../agents/skills-status.js";
import {
detectGhConfigDirMismatch,
formatGhConfigDirMismatchHint,
type GhConfigDiscoveryInput,
type GhConfigDiscoveryResult,
} from "../agents/skills/gh-config-discovery.js";
import { formatCliCommand } from "../cli/command-format.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { note } from "../terminal/note.js";
@ -43,6 +50,38 @@ function formatInstallHints(skill: SkillStatusEntry): string[] {
return skill.install.slice(0, 2).map((entry) => ` install option: ${entry.label}`);
}
function defaultGhConfigDiscoveryInput(): GhConfigDiscoveryInput {
return {
platform: process.platform,
env: process.env as GhConfigDiscoveryInput["env"],
fileExists: (absolutePath) => existsSync(absolutePath),
};
}
export function describeGhConfigDirHint(skills: SkillStatusEntry[]): string[] {
return describeGhConfigDirHintFromDiscovery(skills, defaultGhConfigDiscoveryInput());
}
export function describeGhConfigDirHintFromDiscovery(
skills: SkillStatusEntry[],
discoveryInput: GhConfigDiscoveryInput,
): string[] {
const githubSkill = skills.find((skill) => skill.name === "github");
if (!githubSkill) {
return [];
}
// The github skill only requires the `gh` binary; if it is not installed we
// do not surface a config-dir hint (the bin install hint covers it).
if (githubSkill.missing.bins.includes("gh")) {
return [];
}
const result: GhConfigDiscoveryResult = detectGhConfigDirMismatch(discoveryInput);
if (result.kind !== "mismatch") {
return [];
}
return formatGhConfigDirMismatchHint(result);
}
export function formatUnavailableSkillDoctorLines(skills: SkillStatusEntry[]): string[] {
const lines: string[] = [
"Some skills are allowed for this agent but are not usable in the current runtime environment.",
@ -91,6 +130,10 @@ export async function maybeRepairSkillReadiness(params: {
config: params.cfg,
agentId,
});
const githubHint = describeGhConfigDirHint(report.skills);
if (githubHint.length > 0) {
note(githubHint.join("\n"), "GitHub CLI");
}
const unavailable = collectUnavailableAgentSkills(report);
if (unavailable.length === 0) {
return params.cfg;