mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-13 10:59:39 +00:00
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:
parent
0abf14777d
commit
72b5cddbe1
5 changed files with 535 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
223
src/agents/skills/gh-config-discovery.test.ts
Normal file
223
src/agents/skills/gh-config-discovery.test.ts
Normal 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.",
|
||||
);
|
||||
});
|
||||
});
|
||||
187
src/agents/skills/gh-config-discovery.ts
Normal file
187
src/agents/skills/gh-config-discovery.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue