Auth profiles: never persist plaintext when refs are present

This commit is contained in:
joshavant
2026-02-21 17:08:20 -08:00
parent 16b7d4bbed
commit 6eb674d6d0
5 changed files with 157 additions and 12 deletions

View File

@@ -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<string, { key?: string; keyRef?: unknown }>;
};
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 });
}
});
});

View File

@@ -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 });
}
});
});

View File

@@ -414,9 +414,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<string, unknown>;
delete sanitized.key;
return [profileId, sanitized];
}
if (credential.type === "token" && credential.tokenRef && credential.token !== undefined) {
const sanitized = { ...credential } as Record<string, unknown>;
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,

View File

@@ -961,17 +961,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.

View File

@@ -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;