Auth profiles: never persist plaintext when refs are present
This commit is contained in:
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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user