From 9a9fcd8660b6d8f77fc0c18c25a04621bac6e8ce Mon Sep 17 00:00:00 2001 From: TheCheetah Date: Mon, 31 Aug 2026 12:51:59 +0100 Subject: [PATCH 1/2] fix(openclaw): stop injecting legacy auth-profiles.json beside SQLite stores MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plugin wrote a placeholder auth-profiles.json into every agent directory, including the shared auth-owner (main). Since OpenClaw 2026.8.1 that leftover legacy file is a failed-closed migration trigger: when main's store is empty (its credentials live in shared SQLite state), auth profile convergence fails, the prepared model runtime is never published, and dispatch goes down for the whole agent fleet. The provider's real auth comes from the x402 proxy and the apiKey injectModelsConfig writes into openclaw.json, so the legacy JSON write is at best redundant — and the placeholder is now removed where the SQLite store is authoritative. The legacy bootstrap is kept only for installs with no SQLite store at all. --- src/auth.injection.test.ts | 112 +++++++++++++++++++++++++++++++++++++ src/index.ts | 70 ++++++++++++++++++++++- 2 files changed, 180 insertions(+), 2 deletions(-) create mode 100644 src/auth.injection.test.ts diff --git a/src/auth.injection.test.ts b/src/auth.injection.test.ts new file mode 100644 index 00000000..f2de80ef --- /dev/null +++ b/src/auth.injection.test.ts @@ -0,0 +1,112 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +// The legacy auth-profiles.json write is now sqlite-aware: beside an +// openclaw-agent.sqlite store the file is obsolete — and since OpenClaw +// 2026.8.1 a leftover legacy file beside an empty store fails auth migration +// closed, bricking dispatch for the whole agent fleet. These pin the new +// behavior. +describe("injectAuthProfile", () => { + let homeDir: string | undefined; + + afterEach(() => { + vi.resetModules(); + vi.doUnmock("node:os"); + if (homeDir) { + rmSync(homeDir, { recursive: true, force: true }); + homeDir = undefined; + } + }); + + async function withHome() { + homeDir = mkdtempSync(join(tmpdir(), "clawrouter-auth-profile-")); + vi.doMock("node:os", async () => ({ + ...(await vi.importActual("node:os")), + homedir: () => homeDir, + })); + const mod = await import("./index.js"); + return { mod, homeDir }; + } + + const agentDir = (home: string, agent: string) => join(home, ".openclaw", "agents", agent, "agent"); + const authPath = (home: string, agent: string) => join(agentDir(home, agent), "auth-profiles.json"); + + it("still bootstraps the legacy JSON when no SQLite store exists", async () => { + const { mod, homeDir } = await withHome(); + mkdirSync(agentDir(homeDir, "main"), { recursive: true }); + mkdirSync(agentDir(homeDir, "mike"), { recursive: true }); + + mod.injectAuthProfile({ info: vi.fn() }); + + expect(existsSync(authPath(homeDir, "mike"))).toBe(true); + const store = JSON.parse(readFileSync(authPath(homeDir, "mike"), "utf8")); + expect(store.profiles["blockrun:default"]?.key).toBe("x402-proxy-handles-auth"); + }); + + it("never writes into the shared auth-owner directory, even without a store", async () => { + const { mod, homeDir } = await withHome(); + mkdirSync(agentDir(homeDir, "main"), { recursive: true }); + + mod.injectAuthProfile({ info: vi.fn() }); + + expect(existsSync(authPath(homeDir, "main"))).toBe(false); + }); + + it("does not write the legacy JSON beside an existing SQLite store", async () => { + const { mod, homeDir } = await withHome(); + const dir = agentDir(homeDir, "mike"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "openclaw-agent.sqlite"), "placeholder bytes"); + + mod.injectAuthProfile({ info: vi.fn() }); + + expect(existsSync(authPath(homeDir, "mike"))).toBe(false); + }); + + it("removes our own placeholder beside a SQLite store", async () => { + const { mod, homeDir } = await withHome(); + const dir = agentDir(homeDir, "mike"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "openclaw-agent.sqlite"), "placeholder bytes"); + writeFileSync( + authPath(homeDir, "mike"), + JSON.stringify({ + version: 1, + profiles: { + "blockrun:default": { + type: "api_key", + provider: "blockrun", + key: "x402-proxy-handles-auth", + }, + }, + }), + ); + + mod.injectAuthProfile({ info: vi.fn() }); + + expect(existsSync(authPath(homeDir, "mike"))).toBe(false); + }); + + it("never removes a JSON file that carries real credentials", async () => { + const { mod, homeDir } = await withHome(); + const dir = agentDir(homeDir, "mike"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "openclaw-agent.sqlite"), "placeholder bytes"); + writeFileSync( + authPath(homeDir, "mike"), + JSON.stringify({ + version: 1, + profiles: { + "blockrun:default": { type: "api_key", provider: "blockrun", key: "x402-proxy-handles-auth" }, + "anthropic:default": { type: "api_key", provider: "anthropic", key: "sk-real-key" }, + }, + }), + ); + + mod.injectAuthProfile({ info: vi.fn() }); + + expect(existsSync(authPath(homeDir, "mike"))).toBe(true); + }); +}); diff --git a/src/index.ts b/src/index.ts index a919ffa0..3fa40881 100644 --- a/src/index.ts +++ b/src/index.ts @@ -72,6 +72,7 @@ import { mkdirSync, copyFileSync, renameSync, + unlinkSync, } from "node:fs"; import { readFile as readFileAsync } from "node:fs/promises"; import { readTextFileSync } from "./fs-read.js"; @@ -617,8 +618,21 @@ function syncAgentModelCache( /** * Inject dummy auth profile for BlockRun into agent auth stores. - * OpenClaw's agent system looks for auth credentials even if provider has auth: []. - * We inject a placeholder so the lookup succeeds (proxy handles real auth internally). + * + * The legacy ``auth-profiles.json`` write is now deliberately narrow: + * + * - Wherever ``openclaw-agent.sqlite`` exists, the SQLite auth store is + * authoritative. Writing the legacy JSON beside it is at best ignored, at + * worst a failed-closed migration trigger: since OpenClaw 2026.8.1 a + * leftover legacy file beside a store that holds no profiles fails auth + * migration for the whole agent fleet. So we never write there, and we + * clean up the placeholder we previously injected. + * - The shared auth-owner directory (``main``) is managed by OpenClaw + * itself; a placeholder written there can shadow that state. The + * provider's real auth comes from the x402 proxy (and the apiKey + * injectModelsConfig writes into openclaw.json), so nothing is lost. + * - Only on very old installs with no SQLite store at all do we keep the + * original JSON bootstrap, which those releases import. */ function injectAuthProfile(logger: { info: (msg: string) => void }): void { const agentsDir = join(homedir(), ".openclaw", "agents"); @@ -649,6 +663,22 @@ function injectAuthProfile(logger: { info: (msg: string) => void }): void { for (const agentId of agents) { const authDir = join(agentsDir, agentId, "agent"); const authPath = join(authDir, "auth-profiles.json"); + const sqlitePath = join(authDir, "openclaw-agent.sqlite"); + + // SQLite store exists: it is authoritative, and the legacy JSON is + // obsolete. Remove our own placeholder and never rewrite it. + if (existsSync(sqlitePath)) { + removeInjectedAuthPlaceholder(authPath, logger, agentId); + continue; + } + + // Never write into the shared auth-owner directory. OpenClaw manages + // its credentials centrally, and a leftover legacy file there is what + // fails dispatch closed on 2026.8.1+ when that store is empty. + if (agentId === "main") { + removeInjectedAuthPlaceholder(authPath, logger, agentId); + continue; + } // Create agent dir if needed if (!existsSync(authDir)) { @@ -706,6 +736,42 @@ function injectAuthProfile(logger: { info: (msg: string) => void }): void { } } +/** + * Remove a legacy ``auth-profiles.json`` — but only when it contains nothing + * but the exact placeholder this plugin injects. A real user credential file + * is never touched. + */ +function removeInjectedAuthPlaceholder( + authPath: string, + logger: { info: (msg: string) => void }, + agentId: string, +): void { + try { + if (!existsSync(authPath)) return; + const parsed = JSON.parse(readTextFileSync(authPath)) as { + profiles?: Record; + }; + const profiles = parsed?.profiles; + if (!profiles || typeof profiles !== "object" || Array.isArray(profiles)) return; + const keys = Object.keys(profiles); + if (keys.length !== 1 || keys[0] !== "blockrun:default") return; + const entry = profiles["blockrun:default"]; + if (!entry || typeof entry !== "object") return; + const profile = entry as { type?: unknown; provider?: unknown; key?: unknown }; + if ( + profile.type !== "api_key" || + profile.provider !== "blockrun" || + profile.key !== "x402-proxy-handles-auth" + ) { + return; + } + unlinkSync(authPath); + logger.info(`Removed legacy BlockRun auth placeholder for agent: ${agentId}`); + } catch { + // Unreadable or not our file — leave it alone. + } +} + // Store active proxy handle for cleanup on gateway_stop let activeProxyHandle: Awaited> | null = null; let pendingConfiguredStartupApi: OpenClawPluginApi | null = null; From 758ea825ba087dfc1231e155f15dacc87cc3cd19 Mon Sep 17 00:00:00 2001 From: TheCheetah Date: Mon, 31 Aug 2026 13:00:02 +0100 Subject: [PATCH 2/2] fix(test): prettier formatting for auth injection tests --- src/auth.injection.test.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/auth.injection.test.ts b/src/auth.injection.test.ts index f2de80ef..565d8ae0 100644 --- a/src/auth.injection.test.ts +++ b/src/auth.injection.test.ts @@ -30,8 +30,10 @@ describe("injectAuthProfile", () => { return { mod, homeDir }; } - const agentDir = (home: string, agent: string) => join(home, ".openclaw", "agents", agent, "agent"); - const authPath = (home: string, agent: string) => join(agentDir(home, agent), "auth-profiles.json"); + const agentDir = (home: string, agent: string) => + join(home, ".openclaw", "agents", agent, "agent"); + const authPath = (home: string, agent: string) => + join(agentDir(home, agent), "auth-profiles.json"); it("still bootstraps the legacy JSON when no SQLite store exists", async () => { const { mod, homeDir } = await withHome(); @@ -99,7 +101,11 @@ describe("injectAuthProfile", () => { JSON.stringify({ version: 1, profiles: { - "blockrun:default": { type: "api_key", provider: "blockrun", key: "x402-proxy-handles-auth" }, + "blockrun:default": { + type: "api_key", + provider: "blockrun", + key: "x402-proxy-handles-auth", + }, "anthropic:default": { type: "api_key", provider: "anthropic", key: "sk-real-key" }, }, }),