diff --git a/src/agents/auth-profiles.runtime-snapshot-save.test.ts b/src/agents/auth-profiles.runtime-snapshot-save.test.ts new file mode 100644 index 00000000000..5ab2635d996 --- /dev/null +++ b/src/agents/auth-profiles.runtime-snapshot-save.test.ts @@ -0,0 +1,71 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { + activateSecretsRuntimeSnapshot, + clearSecretsRuntimeSnapshot, + prepareSecretsRuntimeSnapshot, +} from "../secrets/runtime.js"; +import { ensureAuthProfileStore, markAuthProfileUsed } from "./auth-profiles.js"; + +describe("auth profile runtime snapshot persistence", () => { + it("does not write resolved plaintext keys during usage updates", async () => { + const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-runtime-save-")); + const agentDir = path.join(stateDir, "agents", "main", "agent"); + const authPath = path.join(agentDir, "auth-profiles.json"); + try { + await fs.mkdir(agentDir, { recursive: true }); + await fs.writeFile( + authPath, + `${JSON.stringify( + { + version: 1, + profiles: { + "openai:default": { + type: "api_key", + provider: "openai", + keyRef: { source: "env", id: "OPENAI_API_KEY" }, + }, + }, + }, + null, + 2, + )}\n`, + "utf8", + ); + + const snapshot = await prepareSecretsRuntimeSnapshot({ + config: {}, + env: { OPENAI_API_KEY: "sk-runtime-openai" }, + agentDirs: [agentDir], + }); + activateSecretsRuntimeSnapshot(snapshot); + + const runtimeStore = ensureAuthProfileStore(agentDir); + expect(runtimeStore.profiles["openai:default"]).toMatchObject({ + type: "api_key", + key: "sk-runtime-openai", + keyRef: { source: "env", id: "OPENAI_API_KEY" }, + }); + + await markAuthProfileUsed({ + store: runtimeStore, + profileId: "openai:default", + agentDir, + }); + + const persisted = JSON.parse(await fs.readFile(authPath, "utf8")) as { + profiles: Record; + }; + expect(persisted.profiles["openai:default"]?.key).toBeUndefined(); + expect(persisted.profiles["openai:default"]?.keyRef).toEqual({ + source: "env", + id: "OPENAI_API_KEY", + }); + } finally { + clearSecretsRuntimeSnapshot(); + await fs.rm(stateDir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/agents/auth-profiles.store.save.test.ts b/src/agents/auth-profiles.store.save.test.ts new file mode 100644 index 00000000000..20aac07d200 --- /dev/null +++ b/src/agents/auth-profiles.store.save.test.ts @@ -0,0 +1,62 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { resolveAuthStorePath } from "./auth-profiles/paths.js"; +import { saveAuthProfileStore } from "./auth-profiles/store.js"; +import type { AuthProfileStore } from "./auth-profiles/types.js"; + +describe("saveAuthProfileStore", () => { + it("strips plaintext when keyRef/tokenRef are present", async () => { + const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-save-")); + try { + const store: AuthProfileStore = { + version: 1, + profiles: { + "openai:default": { + type: "api_key", + provider: "openai", + key: "sk-runtime-value", + keyRef: { source: "env", id: "OPENAI_API_KEY" }, + }, + "github-copilot:default": { + type: "token", + provider: "github-copilot", + token: "gh-runtime-token", + tokenRef: { source: "env", id: "GITHUB_TOKEN" }, + }, + "anthropic:default": { + type: "api_key", + provider: "anthropic", + key: "sk-anthropic-plain", + }, + }, + }; + + saveAuthProfileStore(store, agentDir); + + const parsed = JSON.parse(await fs.readFile(resolveAuthStorePath(agentDir), "utf8")) as { + profiles: Record< + string, + { key?: string; keyRef?: unknown; token?: string; tokenRef?: unknown } + >; + }; + + expect(parsed.profiles["openai:default"]?.key).toBeUndefined(); + expect(parsed.profiles["openai:default"]?.keyRef).toEqual({ + source: "env", + id: "OPENAI_API_KEY", + }); + + expect(parsed.profiles["github-copilot:default"]?.token).toBeUndefined(); + expect(parsed.profiles["github-copilot:default"]?.tokenRef).toEqual({ + source: "env", + id: "GITHUB_TOKEN", + }); + + expect(parsed.profiles["anthropic:default"]?.key).toBe("sk-anthropic-plain"); + } finally { + await fs.rm(agentDir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/agents/auth-profiles/store.ts b/src/agents/auth-profiles/store.ts index 1253c6b18a9..389a4f81ff6 100644 --- a/src/agents/auth-profiles/store.ts +++ b/src/agents/auth-profiles/store.ts @@ -486,9 +486,24 @@ export function ensureAuthProfileStore( export function saveAuthProfileStore(store: AuthProfileStore, agentDir?: string): void { const authPath = resolveAuthStorePath(agentDir); + const profiles = Object.fromEntries( + Object.entries(store.profiles).map(([profileId, credential]) => { + if (credential.type === "api_key" && credential.keyRef && credential.key !== undefined) { + const sanitized = { ...credential } as Record; + delete sanitized.key; + return [profileId, sanitized]; + } + if (credential.type === "token" && credential.tokenRef && credential.token !== undefined) { + const sanitized = { ...credential } as Record; + delete sanitized.token; + return [profileId, sanitized]; + } + return [profileId, credential]; + }), + ) as AuthProfileStore["profiles"]; const payload = { version: AUTH_STORE_VERSION, - profiles: store.profiles, + profiles, order: store.order ?? undefined, lastGood: store.lastGood ?? undefined, usageStats: store.usageStats ?? undefined, diff --git a/src/agents/models-config.providers.ts b/src/agents/models-config.providers.ts index fd6ee1e25f1..96b0c805493 100644 --- a/src/agents/models-config.providers.ts +++ b/src/agents/models-config.providers.ts @@ -1034,17 +1034,8 @@ export async function resolveImplicitCopilotProvider(params: { } } - // pi-coding-agent's ModelRegistry marks a model "available" only if its - // `AuthStorage` has auth configured for that provider (via auth.json/env/etc). - // Our Copilot auth lives in OpenClaw's auth-profiles store instead, so we also - // write a runtime-only auth.json entry for pi-coding-agent to pick up. - // - // This is safe because it's (1) within OpenClaw's agent dir, (2) contains the - // GitHub token (not the exchanged Copilot token), and (3) matches existing - // patterns for OAuth-like providers in pi-coding-agent. - // Note: we deliberately do not write pi-coding-agent's `auth.json` here. - // OpenClaw uses its own auth store and exchanges tokens at runtime. - // `models list` uses OpenClaw's auth heuristics for availability. + // We deliberately do not write pi-coding-agent auth.json here. + // OpenClaw keeps auth in auth-profiles and resolves runtime availability from that store. // We intentionally do NOT define custom models for Copilot in models.json. // pi-coding-agent treats providers with models as replacements requiring apiKey. diff --git a/src/agents/pi-auth-json.ts b/src/agents/pi-auth-json.ts index 122efb7b9f6..33fb5e4f497 100644 --- a/src/agents/pi-auth-json.ts +++ b/src/agents/pi-auth-json.ts @@ -4,6 +4,10 @@ import { ensureAuthProfileStore } from "./auth-profiles.js"; import type { AuthProfileCredential } from "./auth-profiles/types.js"; import { normalizeProviderId } from "./model-selection.js"; +/** + * @deprecated Legacy bridge for older flows that still expect `agentDir/auth.json`. + * Runtime auth resolution uses auth-profiles directly and should not depend on this module. + */ type AuthJsonCredential = | { type: "api_key"; @@ -110,6 +114,8 @@ function credentialsEqual(a: AuthJsonCredential | undefined, b: AuthJsonCredenti * registry/catalog output. * * Syncs all credential types: api_key, token (as api_key), and oauth. + * + * @deprecated Runtime auth now comes from OpenClaw auth-profiles snapshots. */ export async function ensurePiAuthJsonFromAuthProfiles(agentDir: string): Promise<{ wrote: boolean;