mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-10 11:24:58 +00:00
Auth profiles: never persist plaintext when refs are present
This commit is contained in:
committed by
Peter Steinberger
parent
4c5a2c3c6d
commit
e1301c31e7
71
src/agents/auth-profiles.runtime-snapshot-save.test.ts
Normal file
71
src/agents/auth-profiles.runtime-snapshot-save.test.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
62
src/agents/auth-profiles.store.save.test.ts
Normal file
62
src/agents/auth-profiles.store.save.test.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -486,9 +486,24 @@ export function ensureAuthProfileStore(
|
|||||||
|
|
||||||
export function saveAuthProfileStore(store: AuthProfileStore, agentDir?: string): void {
|
export function saveAuthProfileStore(store: AuthProfileStore, agentDir?: string): void {
|
||||||
const authPath = resolveAuthStorePath(agentDir);
|
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 = {
|
const payload = {
|
||||||
version: AUTH_STORE_VERSION,
|
version: AUTH_STORE_VERSION,
|
||||||
profiles: store.profiles,
|
profiles,
|
||||||
order: store.order ?? undefined,
|
order: store.order ?? undefined,
|
||||||
lastGood: store.lastGood ?? undefined,
|
lastGood: store.lastGood ?? undefined,
|
||||||
usageStats: store.usageStats ?? undefined,
|
usageStats: store.usageStats ?? undefined,
|
||||||
|
|||||||
@@ -1034,17 +1034,8 @@ export async function resolveImplicitCopilotProvider(params: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// pi-coding-agent's ModelRegistry marks a model "available" only if its
|
// We deliberately do not write pi-coding-agent auth.json here.
|
||||||
// `AuthStorage` has auth configured for that provider (via auth.json/env/etc).
|
// OpenClaw keeps auth in auth-profiles and resolves runtime availability from that store.
|
||||||
// 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 intentionally do NOT define custom models for Copilot in models.json.
|
// We intentionally do NOT define custom models for Copilot in models.json.
|
||||||
// pi-coding-agent treats providers with models as replacements requiring apiKey.
|
// pi-coding-agent treats providers with models as replacements requiring apiKey.
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import { ensureAuthProfileStore } from "./auth-profiles.js";
|
|||||||
import type { AuthProfileCredential } from "./auth-profiles/types.js";
|
import type { AuthProfileCredential } from "./auth-profiles/types.js";
|
||||||
import { normalizeProviderId } from "./model-selection.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 AuthJsonCredential =
|
||||||
| {
|
| {
|
||||||
type: "api_key";
|
type: "api_key";
|
||||||
@@ -110,6 +114,8 @@ function credentialsEqual(a: AuthJsonCredential | undefined, b: AuthJsonCredenti
|
|||||||
* registry/catalog output.
|
* registry/catalog output.
|
||||||
*
|
*
|
||||||
* Syncs all credential types: api_key, token (as api_key), and oauth.
|
* 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<{
|
export async function ensurePiAuthJsonFromAuthProfiles(agentDir: string): Promise<{
|
||||||
wrote: boolean;
|
wrote: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user