fix(clawhub): read Windows auth config from AppData (#124658)

* fix(clawhub): read Windows auth config from AppData

* fix(infra): block workspace APPDATA overrides

* fix(clawhub): preserve canonical config precedence

---------

Co-authored-by: Josh Lehman <550978+jalehman@users.noreply.github.com>
This commit is contained in:
Mert Başar
2026-08-25 11:43:44 +03:00
committed by GitHub
parent 59f566547b
commit 58111d46c4
5 changed files with 120 additions and 13 deletions
+94 -4
View File
@@ -4,7 +4,7 @@ import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { withTestDir } from "../test-helpers/temp-dir.js";
import { captureEnv, setTestEnvValue } from "../test-utils/env.js";
import { captureEnv, deleteTestEnvValue, setTestEnvValue } from "../test-utils/env.js";
import {
fetchClawHubSkillInstallResolution,
fetchClawHubSkillSecurityVerdicts,
@@ -51,14 +51,15 @@ function malformedUtf8(prefix: string, suffix: string): ArrayBuffer {
}
describe("clawhub client", () => {
const originalEnv = captureEnv(["HOME", "XDG_CONFIG_HOME"]);
const originalEnv = captureEnv(["APPDATA", "HOME", "XDG_CONFIG_HOME"]);
async function expectSearchUsesAuthToken(expectedToken: string): Promise<void> {
async function searchAuthorizationHeader(): Promise<string | null> {
let authorization: string | null = null;
await expect(
searchClawHubSkills({
query: "calendar",
fetchImpl: async (_input, init) => {
expect(new Headers(init?.headers).get("Authorization")).toBe(`Bearer ${expectedToken}`);
authorization = new Headers(init?.headers).get("Authorization");
return new Response(JSON.stringify({ results: [] }), {
status: 200,
headers: { "content-type": "application/json" },
@@ -66,6 +67,11 @@ describe("clawhub client", () => {
},
}),
).resolves.toStrictEqual([]);
return authorization;
}
async function expectSearchUsesAuthToken(expectedToken: string): Promise<void> {
await expect(searchAuthorizationHeader()).resolves.toBe(`Bearer ${expectedToken}`);
}
afterEach(() => {
@@ -104,6 +110,90 @@ describe("clawhub client", () => {
});
});
it.each(["clawhub", "clawdhub"])(
"loads ClawHub request auth from the Windows AppData %s config path",
async (configDirectory) => {
await withTestDir({ prefix: "openclaw-clawhub-appdata-" }, async (appDataRoot) => {
const configPath = path.join(appDataRoot, configDirectory, "config.json");
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
setTestEnvValue("APPDATA", appDataRoot);
deleteTestEnvValue("XDG_CONFIG_HOME");
try {
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(
configPath,
JSON.stringify({ token: "fixture-appdata-token" }),
"utf8",
);
await expectSearchUsesAuthToken("fixture-appdata-token");
} finally {
platformSpy.mockRestore();
}
});
},
);
it("keeps XDG_CONFIG_HOME ahead of AppData on Windows", async () => {
await withTestDir({ prefix: "openclaw-clawhub-appdata-" }, async (appDataRoot) => {
await withTestDir({ prefix: "openclaw-clawhub-xdg-" }, async (xdgRoot) => {
const appDataConfigPath = path.join(appDataRoot, "clawhub", "config.json");
const xdgConfigPath = path.join(xdgRoot, "clawhub", "config.json");
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
setTestEnvValue("APPDATA", appDataRoot);
setTestEnvValue("XDG_CONFIG_HOME", xdgRoot);
try {
await Promise.all([
fs.mkdir(path.dirname(appDataConfigPath), { recursive: true }),
fs.mkdir(path.dirname(xdgConfigPath), { recursive: true }),
]);
await Promise.all([
fs.writeFile(
appDataConfigPath,
JSON.stringify({ token: "stale-appdata-token" }),
"utf8",
),
fs.writeFile(xdgConfigPath, JSON.stringify({ token: "fixture-xdg-token" }), "utf8"),
]);
await expectSearchUsesAuthToken("fixture-xdg-token");
} finally {
platformSpy.mockRestore();
}
});
});
});
it.each([
["without a token", JSON.stringify({})],
["with malformed JSON", "{"],
])(
"does not fall back to a legacy token when the canonical config exists %s",
async (_, contents) => {
await withTestDir({ prefix: "openclaw-clawhub-appdata-" }, async (appDataRoot) => {
const canonicalConfigPath = path.join(appDataRoot, "clawhub", "config.json");
const legacyConfigPath = path.join(appDataRoot, "clawdhub", "config.json");
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
setTestEnvValue("APPDATA", appDataRoot);
deleteTestEnvValue("XDG_CONFIG_HOME");
try {
await Promise.all([
fs.mkdir(path.dirname(canonicalConfigPath), { recursive: true }),
fs.mkdir(path.dirname(legacyConfigPath), { recursive: true }),
]);
await Promise.all([
fs.writeFile(canonicalConfigPath, contents, "utf8"),
fs.writeFile(legacyConfigPath, JSON.stringify({ token: "stale-legacy-token" }), "utf8"),
]);
await expect(searchAuthorizationHeader()).resolves.toBeNull();
} finally {
platformSpy.mockRestore();
}
});
},
);
it.runIf(process.platform === "darwin")(
"loads ClawHub request auth from the macOS Application Support path",
async () => {
+20 -9
View File
@@ -9,6 +9,7 @@ import {
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { retryClawHubRead } from "./clawhub-retry.js";
import { isTruthyEnvValue } from "./env.js";
import { isErrno } from "./errno.js";
import { readResponseTextSnippet, readResponseWithLimit } from "./http-body.js";
const DEFAULT_CLAWHUB_URL = "https://clawhub.ai";
@@ -114,6 +115,12 @@ function extractTokenFromClawHubConfig(value: unknown): string | undefined {
);
}
function resolveClawHubConfigPathsIn(configHome: string): string[] {
return ["clawhub", "clawdhub"].map((directory) =>
path.join(configHome, directory, "config.json"),
);
}
function resolveClawHubConfigPaths(): string[] {
const explicit =
normalizeOptionalString(process.env.CLAWHUB_CONFIG_PATH) ||
@@ -125,16 +132,21 @@ function resolveClawHubConfigPaths(): string[] {
const xdgConfigHome = normalizeOptionalString(process.env.XDG_CONFIG_HOME);
const configHome =
xdgConfigHome && xdgConfigHome.length > 0 ? xdgConfigHome : path.join(os.homedir(), ".config");
const xdgPath = path.join(configHome, "clawhub", "config.json");
const configPaths = resolveClawHubConfigPathsIn(configHome);
if (process.platform === "darwin") {
return [
path.join(os.homedir(), "Library", "Application Support", "clawhub", "config.json"),
xdgPath,
...resolveClawHubConfigPathsIn(path.join(os.homedir(), "Library", "Application Support")),
...configPaths,
];
}
return [xdgPath];
const appData = normalizeOptionalString(process.env.APPDATA);
if (process.platform === "win32" && !xdgConfigHome && appData) {
return [...resolveClawHubConfigPathsIn(appData), ...configPaths];
}
return configPaths;
}
export async function resolveClawHubAuthToken(): Promise<string | undefined> {
@@ -148,12 +160,11 @@ export async function resolveClawHubAuthToken(): Promise<string | undefined> {
for (const configPath of resolveClawHubConfigPaths()) {
try {
const raw = await fs.readFile(configPath, "utf8");
const token = extractTokenFromClawHubConfig(JSON.parse(raw));
if (token) {
return token;
return extractTokenFromClawHubConfig(JSON.parse(raw));
} catch (error) {
if (!isErrno(error) || error.code !== "ENOENT") {
return undefined;
}
} catch {
// Try the next candidate path.
}
}
return undefined;
@@ -194,6 +194,7 @@ describe("workspace .env blocklist completeness", () => {
"HOMEBREW_BREW_FILE",
"HOMEBREW_PREFIX",
"IRC_HOST",
"APPDATA",
"LOCALAPPDATA",
"MATTERMOST_URL",
"MATRIX_HOMESERVER",
+4
View File
@@ -53,6 +53,8 @@ const BUNDLED_TRUST_ROOT_ENV_KEYS = BUNDLED_TRUST_ROOT_ENV_LINES.map(
);
const WINDOWS_SHELL_TRUST_ROOT_ENV_KEYS = [
"AppData",
"APPDATA",
"ComSpec",
"COMSPEC",
"LocalAppData",
@@ -515,6 +517,8 @@ describe("loadDotEnv", () => {
await writeEnvFile(
path.join(cwdDir, ".env"),
[
"AppData=.\\evil-app-data",
"APPDATA=.\\evil-app-data-upper",
"ComSpec=.\\evil-comspec",
"COMSPEC=.\\evil-comspec-upper",
"LocalAppData=.\\evil-local-app-data",
+1
View File
@@ -112,6 +112,7 @@ const BLOCKED_WORKSPACE_DOTENV_KEYS = new Set([
"HOMEBREW_BREW_FILE",
"HOMEBREW_PREFIX",
"IRC_HOST",
"APPDATA",
"LOCALAPPDATA",
"MATTERMOST_URL",
"MATRIX_HOMESERVER",