diff --git a/src/agents/model-catalog.test-harness.ts b/src/agents/model-catalog.test-harness.ts index 26b8bb10736..0c4633d6748 100644 --- a/src/agents/model-catalog.test-harness.ts +++ b/src/agents/model-catalog.test-harness.ts @@ -31,6 +31,7 @@ export function mockCatalogImportFailThenRecover() { throw new Error("boom"); } return { + discoverAuthStorage: () => ({}), AuthStorage: class {}, ModelRegistry: class { getAll() { diff --git a/src/agents/model-catalog.test.ts b/src/agents/model-catalog.test.ts index ada47c86126..1cacc286306 100644 --- a/src/agents/model-catalog.test.ts +++ b/src/agents/model-catalog.test.ts @@ -38,6 +38,7 @@ describe("loadModelCatalog", () => { __setModelCatalogImportForTest( async () => ({ + discoverAuthStorage: () => ({}), AuthStorage: class {}, ModelRegistry: class { getAll() { @@ -69,6 +70,7 @@ describe("loadModelCatalog", () => { __setModelCatalogImportForTest( async () => ({ + discoverAuthStorage: () => ({}), AuthStorage: class {}, ModelRegistry: class { getAll() { diff --git a/src/agents/model-catalog.ts b/src/agents/model-catalog.ts index 82ca5686493..ccae3baa18a 100644 --- a/src/agents/model-catalog.ts +++ b/src/agents/model-catalog.ts @@ -154,14 +154,6 @@ export function __setModelCatalogImportForTest(loader?: () => Promise unknown }; - if (typeof withFactory.create === "function") { - return withFactory.create(path); - } - return new (AuthStorageLike as { new (path: string): unknown })(path); -} - export async function loadModelCatalog(params?: { config?: OpenClawConfig; useCache?: boolean; @@ -186,9 +178,6 @@ export async function loadModelCatalog(params?: { try { const cfg = params?.config ?? loadConfig(); await ensureOpenClawModelsJson(cfg); - await ( - await import("./pi-auth-json.js") - ).ensurePiAuthJsonFromAuthProfiles(resolveOpenClawAgentDir()); // IMPORTANT: keep the dynamic import *inside* the try/catch. // If this fails once (e.g. during a pnpm install that temporarily swaps node_modules), // we must not poison the cache with a rejected promise (otherwise all channel handlers @@ -196,7 +185,7 @@ export async function loadModelCatalog(params?: { const piSdk = await importPiSdk(); const agentDir = resolveOpenClawAgentDir(); const { join } = await import("node:path"); - const authStorage = createAuthStorage(piSdk.AuthStorage, join(agentDir, "auth.json")); + const authStorage = piSdk.discoverAuthStorage(agentDir); const registry = new (piSdk.ModelRegistry as unknown as { new ( authStorage: unknown, diff --git a/src/agents/pi-model-discovery.auth.test.ts b/src/agents/pi-model-discovery.auth.test.ts new file mode 100644 index 00000000000..68f207db135 --- /dev/null +++ b/src/agents/pi-model-discovery.auth.test.ts @@ -0,0 +1,68 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { saveAuthProfileStore } from "./auth-profiles.js"; +import { discoverAuthStorage } from "./pi-model-discovery.js"; + +async function createAgentDir(): Promise { + return await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-pi-auth-storage-")); +} + +async function pathExists(pathname: string): Promise { + try { + await fs.stat(pathname); + return true; + } catch { + return false; + } +} + +describe("discoverAuthStorage", () => { + it("loads runtime credentials from auth-profiles without writing auth.json", async () => { + const agentDir = await createAgentDir(); + try { + saveAuthProfileStore( + { + version: 1, + profiles: { + "openrouter:default": { + type: "api_key", + provider: "openrouter", + key: "sk-or-v1-runtime", + }, + "anthropic:default": { + type: "token", + provider: "anthropic", + token: "sk-ant-runtime", + }, + "openai-codex:default": { + type: "oauth", + provider: "openai-codex", + access: "oauth-access", + refresh: "oauth-refresh", + expires: Date.now() + 60_000, + }, + }, + }, + agentDir, + ); + + const authStorage = discoverAuthStorage(agentDir); + + expect(authStorage.hasAuth("openrouter")).toBe(true); + expect(authStorage.hasAuth("anthropic")).toBe(true); + expect(authStorage.hasAuth("openai-codex")).toBe(true); + await expect(authStorage.getApiKey("openrouter")).resolves.toBe("sk-or-v1-runtime"); + await expect(authStorage.getApiKey("anthropic")).resolves.toBe("sk-ant-runtime"); + expect(authStorage.get("openai-codex")).toMatchObject({ + type: "oauth", + access: "oauth-access", + }); + + expect(await pathExists(path.join(agentDir, "auth.json"))).toBe(false); + } finally { + await fs.rm(agentDir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/agents/pi-model-discovery.ts b/src/agents/pi-model-discovery.ts index 51ac1aeb8e5..9415cd9d83b 100644 --- a/src/agents/pi-model-discovery.ts +++ b/src/agents/pi-model-discovery.ts @@ -1,19 +1,125 @@ import path from "node:path"; -import { AuthStorage, ModelRegistry } from "@mariozechner/pi-coding-agent"; +import { + AuthStorage, + InMemoryAuthStorageBackend, + ModelRegistry, +} from "@mariozechner/pi-coding-agent"; +import { ensureAuthProfileStore } from "./auth-profiles.js"; +import type { AuthProfileCredential } from "./auth-profiles.js"; +import { normalizeProviderId } from "./model-selection.js"; export { AuthStorage, ModelRegistry } from "@mariozechner/pi-coding-agent"; -function createAuthStorage(AuthStorageLike: unknown, path: string) { - const withFactory = AuthStorageLike as { create?: (path: string) => unknown }; - if (typeof withFactory.create === "function") { - return withFactory.create(path) as AuthStorage; +type PiApiKeyCredential = { type: "api_key"; key: string }; +type PiOAuthCredential = { + type: "oauth"; + access: string; + refresh: string; + expires: number; +}; + +type PiCredential = PiApiKeyCredential | PiOAuthCredential; +type PiCredentialMap = Record; + +function createAuthStorage(AuthStorageLike: unknown, path: string, creds: PiCredentialMap) { + const withInMemory = AuthStorageLike as { inMemory?: (data?: unknown) => unknown }; + if (typeof withInMemory.inMemory === "function") { + return withInMemory.inMemory(creds) as AuthStorage; } - return new (AuthStorageLike as { new (path: string): unknown })(path) as AuthStorage; + + const withFromStorage = AuthStorageLike as { + fromStorage?: (storage: unknown) => unknown; + }; + if (typeof withFromStorage.fromStorage === "function") { + const backend = new InMemoryAuthStorageBackend(); + backend.withLock(() => ({ + result: undefined, + next: JSON.stringify(creds, null, 2), + })); + return withFromStorage.fromStorage(backend) as AuthStorage; + } + + const withFactory = AuthStorageLike as { create?: (path: string) => unknown }; + const withRuntimeOverride = ( + typeof withFactory.create === "function" + ? withFactory.create(path) + : new (AuthStorageLike as { new (path: string): unknown })(path) + ) as AuthStorage & { + setRuntimeApiKey?: (provider: string, apiKey: string) => void; + }; + if (typeof withRuntimeOverride.setRuntimeApiKey === "function") { + for (const [provider, credential] of Object.entries(creds)) { + if (credential.type === "api_key") { + withRuntimeOverride.setRuntimeApiKey(provider, credential.key); + continue; + } + withRuntimeOverride.setRuntimeApiKey(provider, credential.access); + } + } + return withRuntimeOverride; +} + +function convertAuthProfileCredential(cred: AuthProfileCredential): PiCredential | null { + if (cred.type === "api_key") { + const key = typeof cred.key === "string" ? cred.key.trim() : ""; + if (!key) { + return null; + } + return { type: "api_key", key }; + } + + if (cred.type === "token") { + const token = typeof cred.token === "string" ? cred.token.trim() : ""; + if (!token) { + return null; + } + if ( + typeof cred.expires === "number" && + Number.isFinite(cred.expires) && + Date.now() >= cred.expires + ) { + return null; + } + return { type: "api_key", key: token }; + } + + if (cred.type === "oauth") { + const access = typeof cred.access === "string" ? cred.access.trim() : ""; + const refresh = typeof cred.refresh === "string" ? cred.refresh.trim() : ""; + if (!access || !refresh || !Number.isFinite(cred.expires) || cred.expires <= 0) { + return null; + } + return { + type: "oauth", + access, + refresh, + expires: cred.expires, + }; + } + + return null; +} + +function resolvePiCredentials(agentDir: string): PiCredentialMap { + const store = ensureAuthProfileStore(agentDir, { allowKeychainPrompt: false }); + const credentials: PiCredentialMap = {}; + for (const credential of Object.values(store.profiles)) { + const provider = normalizeProviderId(String(credential.provider ?? "")).trim(); + if (!provider || credentials[provider]) { + continue; + } + const converted = convertAuthProfileCredential(credential); + if (converted) { + credentials[provider] = converted; + } + } + return credentials; } // Compatibility helpers for pi-coding-agent 0.50+ (discover* helpers removed). export function discoverAuthStorage(agentDir: string): AuthStorage { - return createAuthStorage(AuthStorage, path.join(agentDir, "auth.json")); + const credentials = resolvePiCredentials(agentDir); + return createAuthStorage(AuthStorage, path.join(agentDir, "auth.json"), credentials); } export function discoverModels(authStorage: AuthStorage, agentDir: string): ModelRegistry { diff --git a/src/commands/models.list.auth-sync.test.ts b/src/commands/models.list.auth-sync.test.ts index 75eb98cc09d..b97de4eba1d 100644 --- a/src/commands/models.list.auth-sync.test.ts +++ b/src/commands/models.list.auth-sync.test.ts @@ -100,7 +100,7 @@ describe("models list auth-profile sync", () => { const openrouter = await runModelsListAndGetProvider("openrouter/"); expect(openrouter?.available).toBe(true); - expect(await pathExists(authPath)).toBe(true); + expect(await pathExists(authPath)).toBe(false); }); }); diff --git a/src/commands/models.list.test.ts b/src/commands/models.list.test.ts index da64561de3f..1469effeff1 100644 --- a/src/commands/models.list.test.ts +++ b/src/commands/models.list.test.ts @@ -6,9 +6,6 @@ let toModelRow: typeof import("./models/list.registry.js").toModelRow; const loadConfig = vi.fn(); const ensureOpenClawModelsJson = vi.fn().mockResolvedValue(undefined); -const ensurePiAuthJsonFromAuthProfiles = vi - .fn() - .mockResolvedValue({ wrote: false, authPath: "/tmp/openclaw-agent/auth.json" }); const resolveOpenClawAgentDir = vi.fn().mockReturnValue("/tmp/openclaw-agent"); const ensureAuthProfileStore = vi.fn().mockReturnValue({ version: 1, profiles: {} }); const listProfilesForProvider = vi.fn().mockReturnValue([]); @@ -38,10 +35,6 @@ vi.mock("../agents/models-config.js", () => ({ ensureOpenClawModelsJson, })); -vi.mock("../agents/pi-auth-json.js", () => ({ - ensurePiAuthJsonFromAuthProfiles, -})); - vi.mock("../agents/agent-paths.js", () => ({ resolveOpenClawAgentDir, })); @@ -121,7 +114,6 @@ beforeEach(() => { modelRegistryState.getAllError = undefined; modelRegistryState.getAvailableError = undefined; listProfilesForProvider.mockReturnValue([]); - ensurePiAuthJsonFromAuthProfiles.mockClear(); }); afterEach(() => { @@ -223,13 +215,12 @@ describe("models list/status", () => { ({ loadModelRegistry, toModelRow } = await import("./models/list.registry.js")); }); - it("models list syncs auth-profiles into auth.json before availability checks", async () => { + it("models list runs model discovery without auth.json sync", async () => { setDefaultZaiRegistry(); const runtime = makeRuntime(); await modelsListCommand({ all: true, json: true }, runtime); - - expect(ensurePiAuthJsonFromAuthProfiles).toHaveBeenCalledWith("/tmp/openclaw-agent"); + expect(runtime.error).not.toHaveBeenCalled(); }); it("models list outputs canonical zai key for configured z.ai model", async () => { diff --git a/src/commands/models/list.registry.ts b/src/commands/models/list.registry.ts index 23cef29485c..b86c236e61f 100644 --- a/src/commands/models/list.registry.ts +++ b/src/commands/models/list.registry.ts @@ -8,7 +8,6 @@ import { resolveEnvApiKey, } from "../../agents/model-auth.js"; import { ensureOpenClawModelsJson } from "../../agents/models-config.js"; -import { ensurePiAuthJsonFromAuthProfiles } from "../../agents/pi-auth-json.js"; import type { ModelRegistry } from "../../agents/pi-model-discovery.js"; import { discoverAuthStorage, discoverModels } from "../../agents/pi-model-discovery.js"; import type { OpenClawConfig } from "../../config/config.js"; @@ -98,7 +97,6 @@ function loadAvailableModels(registry: ModelRegistry): Model[] { export async function loadModelRegistry(cfg: OpenClawConfig) { await ensureOpenClawModelsJson(cfg); const agentDir = resolveOpenClawAgentDir(); - await ensurePiAuthJsonFromAuthProfiles(agentDir); const authStorage = discoverAuthStorage(agentDir); const registry = discoverModels(authStorage, agentDir); const models = registry.getAll();