diff --git a/src/infra/clawhub-client.test.ts b/src/infra/clawhub-client.test.ts index 7ada3994624b..b893c22f8358 100644 --- a/src/infra/clawhub-client.test.ts +++ b/src/infra/clawhub-client.test.ts @@ -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 { + async function searchAuthorizationHeader(): Promise { + 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 { + 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 () => { diff --git a/src/infra/clawhub-client.ts b/src/infra/clawhub-client.ts index adc0c5ef28cc..7afa36263188 100644 --- a/src/infra/clawhub-client.ts +++ b/src/infra/clawhub-client.ts @@ -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 { @@ -148,12 +160,11 @@ export async function resolveClawHubAuthToken(): Promise { 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; diff --git a/src/infra/dotenv-workspace-blocklist.test.ts b/src/infra/dotenv-workspace-blocklist.test.ts index 8a41234fa1e9..20b387483fb3 100644 --- a/src/infra/dotenv-workspace-blocklist.test.ts +++ b/src/infra/dotenv-workspace-blocklist.test.ts @@ -194,6 +194,7 @@ describe("workspace .env blocklist completeness", () => { "HOMEBREW_BREW_FILE", "HOMEBREW_PREFIX", "IRC_HOST", + "APPDATA", "LOCALAPPDATA", "MATTERMOST_URL", "MATRIX_HOMESERVER", diff --git a/src/infra/dotenv.test.ts b/src/infra/dotenv.test.ts index 1b4eaea7c5fc..c77c47c339f6 100644 --- a/src/infra/dotenv.test.ts +++ b/src/infra/dotenv.test.ts @@ -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", diff --git a/src/infra/dotenv.ts b/src/infra/dotenv.ts index b3c790179aff..b9797bc51881 100644 --- a/src/infra/dotenv.ts +++ b/src/infra/dotenv.ts @@ -112,6 +112,7 @@ const BLOCKED_WORKSPACE_DOTENV_KEYS = new Set([ "HOMEBREW_BREW_FILE", "HOMEBREW_PREFIX", "IRC_HOST", + "APPDATA", "LOCALAPPDATA", "MATTERMOST_URL", "MATRIX_HOMESERVER",