mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-10 00:11:19 +00:00
fix(doctor): import default-agent auth profiles into sqlite (#93156)
* fix(doctor): import default-agent auth profiles into sqlite * fix(doctor): type legacy config auth imports * fix(doctor): preserve auth json precedence * fix(doctor): import config auth with state-only stores Signed-off-by: sallyom <somalley@redhat.com> --------- Signed-off-by: sallyom <somalley@redhat.com> Co-authored-by: sallyom <somalley@redhat.com>
This commit is contained in:
parent
8b0eac7927
commit
f018945eff
2 changed files with 721 additions and 9 deletions
|
|
@ -442,6 +442,413 @@ describe("maybeMigrateAuthProfileJsonStoresToSqlite", () => {
|
|||
expect(fs.existsSync(authPath)).toBe(true);
|
||||
expect(fs.existsSync(`${authPath}.sqlite-import.464.bak`)).toBe(false);
|
||||
});
|
||||
|
||||
it("imports default-agent config auth profiles into sqlite when no legacy files exist", async () => {
|
||||
const state = await makeTestState();
|
||||
const cfg = {
|
||||
auth: {
|
||||
profiles: {
|
||||
"openai:default": {
|
||||
provider: "openai",
|
||||
mode: "api_key",
|
||||
key: "sk-config",
|
||||
},
|
||||
"anthropic:default": {
|
||||
provider: "anthropic",
|
||||
mode: "token",
|
||||
token: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "ANTHROPIC_TOKEN",
|
||||
},
|
||||
},
|
||||
"router:default": {
|
||||
provider: "router",
|
||||
mode: "api_key",
|
||||
displayName: "routing only",
|
||||
},
|
||||
},
|
||||
order: {
|
||||
openai: ["openai:default"],
|
||||
anthropic: ["anthropic:default"],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const result = await maybeMigrateAuthProfileJsonStoresToSqlite({
|
||||
cfg,
|
||||
prompter: makePrompter(true),
|
||||
now: () => 465,
|
||||
});
|
||||
|
||||
const authPath = `${state.agentDir()}/auth-profiles.json`;
|
||||
expect(result.detected).toEqual([authPath]);
|
||||
expect(result.configChanged).toBe(true);
|
||||
expect(result.warnings).toStrictEqual([]);
|
||||
expect(cfg.auth?.profiles?.["openai:default"]).toEqual({
|
||||
provider: "openai",
|
||||
mode: "api_key",
|
||||
});
|
||||
expect(cfg.auth?.profiles?.["anthropic:default"]).toEqual({
|
||||
provider: "anthropic",
|
||||
mode: "token",
|
||||
});
|
||||
expect(cfg.auth?.profiles?.["router:default"]).toEqual({
|
||||
provider: "router",
|
||||
mode: "api_key",
|
||||
displayName: "routing only",
|
||||
});
|
||||
expect(cfg.auth?.order).toEqual({
|
||||
openai: ["openai:default"],
|
||||
anthropic: ["anthropic:default"],
|
||||
});
|
||||
expect(loadPersistedAuthProfileStore(state.agentDir())).toMatchObject({
|
||||
profiles: {
|
||||
"openai:default": {
|
||||
type: "api_key",
|
||||
provider: "openai",
|
||||
key: "sk-config",
|
||||
},
|
||||
"anthropic:default": {
|
||||
type: "token",
|
||||
provider: "anthropic",
|
||||
tokenRef: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "ANTHROPIC_TOKEN",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(
|
||||
loadPersistedAuthProfileStore(state.agentDir())?.profiles["router:default"],
|
||||
).toBeUndefined();
|
||||
expect(loadPersistedAuthProfileStore(state.agentDir())?.order).toBeUndefined();
|
||||
expect(fs.existsSync(authPath)).toBe(false);
|
||||
expect(fs.existsSync(`${authPath}.sqlite-import.465.bak`)).toBe(false);
|
||||
});
|
||||
|
||||
it("imports default-agent config auth profiles when only legacy state exists", async () => {
|
||||
const state = await makeTestState();
|
||||
const statePath = await state.writeText(
|
||||
"agents/main/agent/auth-state.json",
|
||||
`${JSON.stringify({
|
||||
version: 1,
|
||||
order: { openai: ["openai:default"] },
|
||||
lastGood: { openai: "openai:default" },
|
||||
})}\n`,
|
||||
);
|
||||
const cfg = {
|
||||
auth: {
|
||||
profiles: {
|
||||
"openai:default": {
|
||||
provider: "openai",
|
||||
mode: "api_key",
|
||||
key: "sk-config",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const result = await maybeMigrateAuthProfileJsonStoresToSqlite({
|
||||
cfg,
|
||||
prompter: makePrompter(true),
|
||||
now: () => 467,
|
||||
});
|
||||
|
||||
const authPath = `${state.agentDir()}/auth-profiles.json`;
|
||||
expect(result.detected.toSorted()).toEqual([authPath, statePath].toSorted());
|
||||
expect(result.configChanged).toBe(true);
|
||||
expect(result.warnings).toStrictEqual([]);
|
||||
expect(cfg.auth?.profiles?.["openai:default"]).toEqual({
|
||||
provider: "openai",
|
||||
mode: "api_key",
|
||||
});
|
||||
expect(loadPersistedAuthProfileStore(state.agentDir())).toMatchObject({
|
||||
profiles: {
|
||||
"openai:default": {
|
||||
type: "api_key",
|
||||
provider: "openai",
|
||||
key: "sk-config",
|
||||
},
|
||||
},
|
||||
order: { openai: ["openai:default"] },
|
||||
lastGood: { openai: "openai:default" },
|
||||
});
|
||||
expect(fs.existsSync(statePath)).toBe(false);
|
||||
expect(fs.existsSync(`${statePath}.sqlite-import.467.bak`)).toBe(true);
|
||||
expect(fs.existsSync(authPath)).toBe(false);
|
||||
expect(fs.existsSync(`${authPath}.sqlite-import.467.bak`)).toBe(false);
|
||||
});
|
||||
|
||||
it("infers config credential provider and mode before stripping config", async () => {
|
||||
const cases: Array<{ profileId: string; cfg: OpenClawConfig; now: number }> = [
|
||||
{
|
||||
profileId: "openai:default",
|
||||
cfg: {
|
||||
auth: { profiles: { "openai:default": { key: "sk-config" } } },
|
||||
} as unknown as OpenClawConfig,
|
||||
now: 468,
|
||||
},
|
||||
{
|
||||
profileId: "work",
|
||||
cfg: {
|
||||
auth: { profiles: { work: { key: "sk-config" } } },
|
||||
agents: { defaults: { model: { primary: "openai/gpt-5.5@work" } } },
|
||||
} as unknown as OpenClawConfig,
|
||||
now: 470,
|
||||
},
|
||||
{
|
||||
profileId: "ordered",
|
||||
cfg: {
|
||||
auth: {
|
||||
profiles: { ordered: { key: "sk-config" } },
|
||||
order: { openai: ["ordered"] },
|
||||
},
|
||||
} as unknown as OpenClawConfig,
|
||||
now: 474,
|
||||
},
|
||||
];
|
||||
|
||||
for (const entry of cases) {
|
||||
const state = await makeTestState();
|
||||
const result = await maybeMigrateAuthProfileJsonStoresToSqlite({
|
||||
cfg: entry.cfg,
|
||||
prompter: makePrompter(true),
|
||||
now: () => entry.now,
|
||||
});
|
||||
|
||||
const authPath = `${state.agentDir()}/auth-profiles.json`;
|
||||
expect(result.detected).toEqual([authPath]);
|
||||
expect(result.configChanged).toBe(true);
|
||||
expect(result.warnings).toStrictEqual([]);
|
||||
expect(entry.cfg.auth?.profiles?.[entry.profileId]).toEqual({
|
||||
provider: "openai",
|
||||
mode: "api_key",
|
||||
});
|
||||
expect(loadPersistedAuthProfileStore(state.agentDir())?.profiles[entry.profileId]).toEqual({
|
||||
type: "api_key",
|
||||
provider: "openai",
|
||||
key: "sk-config",
|
||||
});
|
||||
expect(fs.existsSync(authPath)).toBe(false);
|
||||
expect(fs.existsSync(`${authPath}.sqlite-import.${entry.now}.bak`)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("imports missing config credentials while preserving legacy JSON precedence", async () => {
|
||||
const state = await makeTestState();
|
||||
const authPath = await writeLegacyAuthProfilesJson(state, {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"openai:default": {
|
||||
type: "api_key",
|
||||
provider: "openai",
|
||||
key: "sk-json",
|
||||
},
|
||||
"openai:work": {
|
||||
type: "api_key",
|
||||
provider: "openai",
|
||||
key: "sk-work",
|
||||
},
|
||||
},
|
||||
order: {
|
||||
openai: ["openai:work"],
|
||||
},
|
||||
});
|
||||
const cfg = {
|
||||
auth: {
|
||||
profiles: {
|
||||
"openai:default": {
|
||||
provider: "openai",
|
||||
mode: "api_key",
|
||||
key: "sk-config",
|
||||
},
|
||||
"anthropic:default": {
|
||||
provider: "anthropic",
|
||||
mode: "api_key",
|
||||
key: "sk-anthropic",
|
||||
},
|
||||
},
|
||||
order: {
|
||||
openai: ["openai:default"],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const result = await maybeMigrateAuthProfileJsonStoresToSqlite({
|
||||
cfg,
|
||||
prompter: makePrompter(true),
|
||||
now: () => 471,
|
||||
});
|
||||
|
||||
expect(result.detected).toEqual([authPath]);
|
||||
expect(result.configChanged).toBe(true);
|
||||
expect(result.warnings).toStrictEqual([]);
|
||||
expect(loadPersistedAuthProfileStore(state.agentDir())?.profiles).toMatchObject({
|
||||
"openai:default": {
|
||||
type: "api_key",
|
||||
provider: "openai",
|
||||
key: "sk-json",
|
||||
},
|
||||
"openai:work": {
|
||||
type: "api_key",
|
||||
provider: "openai",
|
||||
key: "sk-work",
|
||||
},
|
||||
"anthropic:default": {
|
||||
type: "api_key",
|
||||
provider: "anthropic",
|
||||
key: "sk-anthropic",
|
||||
},
|
||||
});
|
||||
expect(cfg.auth?.profiles?.["openai:default"]).toEqual({
|
||||
provider: "openai",
|
||||
mode: "api_key",
|
||||
});
|
||||
expect(cfg.auth?.profiles?.["anthropic:default"]).toEqual({
|
||||
provider: "anthropic",
|
||||
mode: "api_key",
|
||||
});
|
||||
expect(loadPersistedAuthProfileStore(state.agentDir())).toMatchObject({
|
||||
order: {
|
||||
openai: ["openai:work"],
|
||||
},
|
||||
});
|
||||
expect(fs.existsSync(authPath)).toBe(false);
|
||||
expect(fs.existsSync(`${authPath}.sqlite-import.471.bak`)).toBe(true);
|
||||
});
|
||||
|
||||
it("imports default-agent config api key alias SecretRefs as key refs", async () => {
|
||||
const cases = [
|
||||
{
|
||||
profileId: "openai:api-key-object",
|
||||
profile: {
|
||||
provider: "openai",
|
||||
apiKey: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "OPENAI_API_KEY",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
profileId: "openai:api-key-template",
|
||||
profile: {
|
||||
provider: "openai",
|
||||
mode: "api_key",
|
||||
apiKey: "${OPENAI_API_KEY}",
|
||||
},
|
||||
},
|
||||
{
|
||||
profileId: "openai:api-key-legacy-field",
|
||||
profile: {
|
||||
provider: "openai",
|
||||
api_key: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "OPENAI_API_KEY",
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
for (const entry of cases) {
|
||||
const state = await makeTestState();
|
||||
const cfg = {
|
||||
auth: {
|
||||
profiles: {
|
||||
[entry.profileId]: entry.profile,
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
const result = await maybeMigrateAuthProfileJsonStoresToSqlite({
|
||||
cfg,
|
||||
prompter: makePrompter(true),
|
||||
now: () => 473,
|
||||
});
|
||||
|
||||
expect(result.configChanged).toBe(true);
|
||||
expect(result.warnings).toStrictEqual([]);
|
||||
expect(cfg.auth?.profiles?.[entry.profileId]).toEqual({
|
||||
provider: "openai",
|
||||
mode: "api_key",
|
||||
});
|
||||
expect(loadPersistedAuthProfileStore(state.agentDir())?.profiles[entry.profileId]).toEqual({
|
||||
type: "api_key",
|
||||
provider: "openai",
|
||||
keyRef: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "OPENAI_API_KEY",
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("uses config credentials only when same-id sqlite credentials are incomplete", async () => {
|
||||
const cases = [
|
||||
{
|
||||
existing: {
|
||||
type: "api_key" as const,
|
||||
provider: "openai",
|
||||
key: "sk-sqlite",
|
||||
},
|
||||
expectedKey: "sk-sqlite",
|
||||
},
|
||||
{
|
||||
existing: {
|
||||
type: "api_key" as const,
|
||||
provider: "openai",
|
||||
},
|
||||
expectedKey: "sk-config",
|
||||
},
|
||||
];
|
||||
|
||||
for (const entry of cases) {
|
||||
const state = await makeTestState();
|
||||
saveAuthProfileStore(
|
||||
{
|
||||
version: 1,
|
||||
profiles: {
|
||||
"openai:default": entry.existing,
|
||||
},
|
||||
},
|
||||
state.agentDir(),
|
||||
{ syncExternalCli: false },
|
||||
);
|
||||
const cfg = {
|
||||
auth: {
|
||||
profiles: {
|
||||
"openai:default": {
|
||||
provider: "openai",
|
||||
mode: "api_key",
|
||||
key: "sk-config",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const result = await maybeMigrateAuthProfileJsonStoresToSqlite({
|
||||
cfg,
|
||||
prompter: makePrompter(true),
|
||||
now: () => 469,
|
||||
});
|
||||
|
||||
expect(result.configChanged).toBe(true);
|
||||
expect(result.warnings).toStrictEqual([]);
|
||||
expect(cfg.auth?.profiles?.["openai:default"]).toEqual({
|
||||
provider: "openai",
|
||||
mode: "api_key",
|
||||
});
|
||||
expect(loadPersistedAuthProfileStore(state.agentDir())?.profiles["openai:default"]).toEqual({
|
||||
type: "api_key",
|
||||
provider: "openai",
|
||||
key: entry.expectedKey,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("maybeRepairLegacyFlatAuthProfileStores", () => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
/** Doctor repairs for legacy auth profile JSON stores and OpenAI provider-id migrations. */
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { collectConfiguredModelRefs } from "@openclaw/model-catalog-core/configured-model-refs";
|
||||
import { isRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import { note } from "../../packages/terminal-core/src/note.js";
|
||||
import { resolveAgentDir, resolveDefaultAgentDir, listAgentIds } from "../agents/agent-scope.js";
|
||||
|
|
@ -26,6 +27,7 @@ import type {
|
|||
AuthProfileState,
|
||||
AuthProfileStore,
|
||||
} from "../agents/auth-profiles/types.js";
|
||||
import { splitTrailingAuthProfile } from "../agents/model-ref-profile.js";
|
||||
import { formatCliCommand } from "../cli/command-format.js";
|
||||
import { resolveStateDir } from "../config/paths.js";
|
||||
import type { AuthProfileConfig } from "../config/types.auth.js";
|
||||
|
|
@ -65,6 +67,12 @@ type AwsSdkAuthProfileMarkerStore = {
|
|||
profiles: AwsSdkProfileMarker[];
|
||||
};
|
||||
|
||||
type RawAuthProfileImportStore = {
|
||||
version: number;
|
||||
profiles: Record<string, Record<string, unknown>>;
|
||||
order?: Record<string, string[]>;
|
||||
};
|
||||
|
||||
export type LegacyFlatAuthProfileRepairResult = {
|
||||
detected: string[];
|
||||
changes: string[];
|
||||
|
|
@ -90,6 +98,86 @@ function extractProviderFromProfileId(profileId: string): string | undefined {
|
|||
return readNonEmptyString(profileId.slice(0, colon));
|
||||
}
|
||||
|
||||
function extractProviderFromModelRef(modelRef: string): string | undefined {
|
||||
const { model } = splitTrailingAuthProfile(modelRef);
|
||||
const slash = model.indexOf("/");
|
||||
if (slash <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
return readNonEmptyString(model.slice(0, slash));
|
||||
}
|
||||
|
||||
function collectLegacyConfigAuthProfileProviderHints(
|
||||
cfg: OpenClawConfig,
|
||||
): ReadonlyMap<string, string> {
|
||||
const hints = new Map<string, string>();
|
||||
const conflicted = new Set<string>();
|
||||
const addHint = (profileId: string, provider: string): void => {
|
||||
const existing = hints.get(profileId);
|
||||
if (existing && existing !== provider) {
|
||||
hints.delete(profileId);
|
||||
conflicted.add(profileId);
|
||||
return;
|
||||
}
|
||||
if (!conflicted.has(profileId)) {
|
||||
hints.set(profileId, provider);
|
||||
}
|
||||
};
|
||||
const addModelHints = (models: unknown): void => {
|
||||
if (!isRecord(models)) {
|
||||
return;
|
||||
}
|
||||
for (const [modelRef, rawModel] of Object.entries(models)) {
|
||||
const provider = extractProviderFromModelRef(modelRef);
|
||||
if (!provider || !isSafeLegacyProviderKey(provider) || !isRecord(rawModel)) {
|
||||
continue;
|
||||
}
|
||||
const agentRuntime = isRecord(rawModel.agentRuntime) ? rawModel.agentRuntime : null;
|
||||
const authProfileId = agentRuntime
|
||||
? readNonEmptyString(agentRuntime.authProfileId)
|
||||
: undefined;
|
||||
if (authProfileId) {
|
||||
addHint(authProfileId, provider);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (const { value } of collectConfiguredModelRefs(cfg)) {
|
||||
const { profile } = splitTrailingAuthProfile(value);
|
||||
const provider = extractProviderFromModelRef(value);
|
||||
if (profile && provider && isSafeLegacyProviderKey(provider)) {
|
||||
addHint(profile, provider);
|
||||
}
|
||||
}
|
||||
|
||||
const root: Record<string, unknown> = cfg;
|
||||
const auth = isRecord(root.auth) ? root.auth : null;
|
||||
const order = auth && isRecord(auth.order) ? auth.order : null;
|
||||
if (order) {
|
||||
for (const [provider, profileIds] of Object.entries(order)) {
|
||||
if (!isSafeLegacyProviderKey(provider) || !Array.isArray(profileIds)) {
|
||||
continue;
|
||||
}
|
||||
for (const profileId of profileIds) {
|
||||
const normalizedProfileId = readNonEmptyString(profileId);
|
||||
if (normalizedProfileId) {
|
||||
addHint(normalizedProfileId, provider);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const agents = isRecord(root.agents) ? root.agents : null;
|
||||
const defaults = agents && isRecord(agents.defaults) ? agents.defaults : null;
|
||||
addModelHints(defaults?.models);
|
||||
const agentList = agents && Array.isArray(agents.list) ? agents.list : [];
|
||||
for (const agent of agentList) {
|
||||
if (isRecord(agent)) {
|
||||
addModelHints(agent.models);
|
||||
}
|
||||
}
|
||||
return hints;
|
||||
}
|
||||
|
||||
function inferLegacyCredentialType(
|
||||
record: Record<string, unknown>,
|
||||
): AuthProfileCredential["type"] | undefined {
|
||||
|
|
@ -293,15 +381,177 @@ function collectAuthProfileStateProfileIds(state: AuthProfileState): string[] {
|
|||
return [...profileIds];
|
||||
}
|
||||
|
||||
function inferLegacyConfigAuthProfileMode(
|
||||
raw: Record<string, unknown>,
|
||||
): AuthProfileCredential["type"] | undefined {
|
||||
const explicit = readNonEmptyString(raw.mode) ?? readNonEmptyString(raw.type);
|
||||
if (explicit === "api_key" || explicit === "token" || explicit === "oauth") {
|
||||
return explicit;
|
||||
}
|
||||
if (
|
||||
readNonEmptyString(raw.key) ||
|
||||
readNonEmptyString(raw.apiKey) ||
|
||||
readNonEmptyString(raw["api_key"]) ||
|
||||
coerceSecretRef(raw.keyRef) ||
|
||||
coerceSecretRef(raw.key) ||
|
||||
coerceSecretRef(raw.apiKey) ||
|
||||
coerceSecretRef(raw["api_key"])
|
||||
) {
|
||||
return "api_key";
|
||||
}
|
||||
if (
|
||||
readNonEmptyString(raw.token) ||
|
||||
coerceSecretRef(raw.tokenRef) ||
|
||||
coerceSecretRef(raw.token)
|
||||
) {
|
||||
return "token";
|
||||
}
|
||||
if (
|
||||
readNonEmptyString(raw.access) &&
|
||||
readNonEmptyString(raw.refresh) &&
|
||||
typeof raw.expires === "number"
|
||||
) {
|
||||
return "oauth";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function coerceLegacyConfigAuthProfileStore(cfg: OpenClawConfig): AuthProfileStore | null {
|
||||
const cfgRecord: Record<string, unknown> = cfg;
|
||||
const auth = isRecord(cfgRecord.auth) ? cfgRecord.auth : null;
|
||||
const profiles = auth && isRecord(auth.profiles) ? auth.profiles : null;
|
||||
if (!profiles) {
|
||||
return null;
|
||||
}
|
||||
const providerHints = collectLegacyConfigAuthProfileProviderHints(cfg);
|
||||
const store: RawAuthProfileImportStore = { version: AUTH_STORE_VERSION, profiles: {} };
|
||||
for (const [profileId, raw] of Object.entries(profiles)) {
|
||||
if (!isRecord(raw)) {
|
||||
continue;
|
||||
}
|
||||
const mode = inferLegacyConfigAuthProfileMode(raw);
|
||||
if (mode !== "api_key" && mode !== "token" && mode !== "oauth") {
|
||||
continue;
|
||||
}
|
||||
const provider =
|
||||
readNonEmptyString(raw.provider) ??
|
||||
extractProviderFromProfileId(profileId) ??
|
||||
providerHints.get(profileId);
|
||||
if (!provider || !isSafeLegacyProviderKey(provider)) {
|
||||
continue;
|
||||
}
|
||||
const next: Record<string, unknown> = { ...raw, provider, mode };
|
||||
if (mode === "api_key") {
|
||||
const keyRef =
|
||||
coerceSecretRef(raw.keyRef) ??
|
||||
coerceSecretRef(raw.key) ??
|
||||
coerceSecretRef(raw.apiKey) ??
|
||||
coerceSecretRef(raw["api_key"]);
|
||||
const key =
|
||||
readNonEmptyString(raw.key) ??
|
||||
readNonEmptyString(raw.apiKey) ??
|
||||
readNonEmptyString(raw["api_key"]);
|
||||
if (keyRef) {
|
||||
next.keyRef = keyRef;
|
||||
delete next.key;
|
||||
delete next.apiKey;
|
||||
delete next["api_key"];
|
||||
} else if (key) {
|
||||
next.key = key;
|
||||
delete next.keyRef;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
} else if (mode === "token") {
|
||||
const tokenRef = coerceSecretRef(raw.tokenRef) ?? coerceSecretRef(raw.token);
|
||||
const token = readNonEmptyString(raw.token);
|
||||
if (tokenRef) {
|
||||
next.tokenRef = tokenRef;
|
||||
delete next.token;
|
||||
} else if (token) {
|
||||
next.token = token;
|
||||
delete next.tokenRef;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
} else if (
|
||||
!readNonEmptyString(raw.access) ||
|
||||
!readNonEmptyString(raw.refresh) ||
|
||||
typeof raw.expires !== "number"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
store.profiles[profileId] = next;
|
||||
}
|
||||
const canonicalStore = coercePersistedAuthProfileStore(store);
|
||||
return canonicalStore && Object.keys(canonicalStore.profiles).length > 0 ? canonicalStore : null;
|
||||
}
|
||||
|
||||
function isDefaultAgentCandidate(
|
||||
candidate: AuthProfileSqliteMigrationCandidate,
|
||||
cfg: OpenClawConfig,
|
||||
env: NodeJS.ProcessEnv,
|
||||
): boolean {
|
||||
return path.resolve(candidate.agentDir ?? "") === path.resolve(resolveDefaultAgentDir(cfg, env));
|
||||
}
|
||||
|
||||
function stripImportedConfigAuthProfileCredentials(
|
||||
cfg: OpenClawConfig,
|
||||
store: AuthProfileStore,
|
||||
): boolean {
|
||||
const profiles = ensureConfigAuthProfiles(cfg);
|
||||
let changed = false;
|
||||
for (const [profileId, credential] of Object.entries(store.profiles)) {
|
||||
const current = profiles[profileId];
|
||||
if (!current) {
|
||||
continue;
|
||||
}
|
||||
const metadata: AuthProfileConfig = {
|
||||
provider: current.provider || credential.provider,
|
||||
mode: credential.type,
|
||||
...(current.email ? { email: current.email } : {}),
|
||||
...(current.displayName ? { displayName: current.displayName } : {}),
|
||||
};
|
||||
profiles[profileId] = metadata;
|
||||
changed = true;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
function hasUsableAuthProfileCredential(credential: AuthProfileCredential): boolean {
|
||||
if (credential.type === "api_key") {
|
||||
return Boolean(readNonEmptyString(credential.key) || credential.keyRef);
|
||||
}
|
||||
if (credential.type === "token") {
|
||||
return Boolean(readNonEmptyString(credential.token) || credential.tokenRef);
|
||||
}
|
||||
return (
|
||||
Boolean(readNonEmptyString(credential.access)) &&
|
||||
Boolean(readNonEmptyString(credential.refresh)) &&
|
||||
typeof credential.expires === "number"
|
||||
);
|
||||
}
|
||||
|
||||
function mergeImportedAuthProfiles(params: {
|
||||
store: AuthProfileStore;
|
||||
profiles: AuthProfileStore["profiles"];
|
||||
existingProfileIds: ReadonlySet<string>;
|
||||
replaceExistingWithoutCredential?: boolean;
|
||||
}): AuthProfileStore {
|
||||
const profiles = { ...params.store.profiles };
|
||||
for (const [profileId, credential] of Object.entries(params.profiles)) {
|
||||
if (!params.existingProfileIds.has(profileId)) {
|
||||
profiles[profileId] = credential;
|
||||
continue;
|
||||
}
|
||||
const existing = profiles[profileId];
|
||||
if (
|
||||
params.replaceExistingWithoutCredential &&
|
||||
existing &&
|
||||
!hasUsableAuthProfileCredential(existing) &&
|
||||
hasUsableAuthProfileCredential(credential)
|
||||
) {
|
||||
profiles[profileId] = credential;
|
||||
}
|
||||
}
|
||||
return { ...params.store, profiles };
|
||||
|
|
@ -492,6 +742,14 @@ function hasImportableAuthProfileStore(store: AuthProfileStore | null): store is
|
|||
return Boolean(store && (Object.keys(store.profiles).length > 0 || hasAuthProfileState(store)));
|
||||
}
|
||||
|
||||
function hasLegacyAuthProfileSource(candidate: AuthProfileSqliteMigrationCandidate): boolean {
|
||||
return (
|
||||
fs.existsSync(candidate.authPath) ||
|
||||
fs.existsSync(candidate.statePath) ||
|
||||
fs.existsSync(candidate.legacyPath)
|
||||
);
|
||||
}
|
||||
|
||||
function backupAuthProfileJson(pathname: string, suffix: string, now: () => number): string {
|
||||
const backupPath = `${pathname}.${suffix}.${now()}.bak`;
|
||||
fs.copyFileSync(pathname, backupPath);
|
||||
|
|
@ -532,17 +790,30 @@ export async function maybeMigrateAuthProfileJsonStoresToSqlite(params: {
|
|||
const loadMigratedStore =
|
||||
params.deps?.loadPersistedAuthProfileStore ?? loadPersistedAuthProfileStore;
|
||||
const candidates = listAuthProfileSqliteMigrationCandidates(params.cfg, env);
|
||||
const configStore = coerceLegacyConfigAuthProfileStore(params.cfg);
|
||||
const detected = candidates.filter(
|
||||
(candidate) =>
|
||||
fs.existsSync(candidate.authPath) ||
|
||||
fs.existsSync(candidate.statePath) ||
|
||||
fs.existsSync(candidate.legacyPath),
|
||||
hasLegacyAuthProfileSource(candidate) ||
|
||||
(configStore && isDefaultAgentCandidate(candidate, params.cfg, env)),
|
||||
);
|
||||
const result: LegacyFlatAuthProfileRepairResult = {
|
||||
detected: detected.flatMap((candidate) =>
|
||||
[candidate.authPath, candidate.statePath, candidate.legacyPath].filter((pathname) =>
|
||||
fs.existsSync(pathname),
|
||||
),
|
||||
[
|
||||
candidate.authPath,
|
||||
candidate.statePath,
|
||||
candidate.legacyPath,
|
||||
...(configStore && isDefaultAgentCandidate(candidate, params.cfg, env)
|
||||
? [candidate.authPath]
|
||||
: []),
|
||||
]
|
||||
.filter((pathname, index, entries) => entries.indexOf(pathname) === index)
|
||||
.filter(
|
||||
(pathname) =>
|
||||
fs.existsSync(pathname) ||
|
||||
(configStore &&
|
||||
isDefaultAgentCandidate(candidate, params.cfg, env) &&
|
||||
pathname === candidate.authPath),
|
||||
),
|
||||
),
|
||||
changes: [],
|
||||
warnings: [],
|
||||
|
|
@ -615,12 +886,20 @@ export async function maybeMigrateAuthProfileJsonStoresToSqlite(params: {
|
|||
const canonicalStore = hasImportableAuthProfileStore(maybeCanonicalStore)
|
||||
? maybeCanonicalStore
|
||||
: null;
|
||||
const configCanonicalStore =
|
||||
configStore && isDefaultAgentCandidate(candidate, params.cfg, env) ? configStore : null;
|
||||
const legacyStore = loadLegacyAuthProfileStore(candidate.agentDir);
|
||||
const rawState = fs.existsSync(candidate.statePath)
|
||||
? loadJsonFile(candidate.statePath)
|
||||
: null;
|
||||
const state = coerceAuthProfileState(rawState);
|
||||
if (!canonicalStore && !legacyStore && !hasAuthProfileState(state) && !awsSdkMarkerStore) {
|
||||
if (
|
||||
!canonicalStore &&
|
||||
!configCanonicalStore &&
|
||||
!legacyStore &&
|
||||
!hasAuthProfileState(state) &&
|
||||
!awsSdkMarkerStore
|
||||
) {
|
||||
result.warnings.push(
|
||||
`Left auth profile JSON in place for ${shortenHomePath(candidate.authPath)} because no importable auth profiles or state were found.`,
|
||||
);
|
||||
|
|
@ -666,16 +945,32 @@ export async function maybeMigrateAuthProfileJsonStoresToSqlite(params: {
|
|||
existingState,
|
||||
});
|
||||
}
|
||||
if (configCanonicalStore) {
|
||||
for (const profileId of Object.keys(configCanonicalStore.profiles)) {
|
||||
importedProfileIds.add(profileId);
|
||||
}
|
||||
// Config imports fill missing SQLite credentials only; when both exist,
|
||||
// the canonical per-agent SQLite store wins over legacy config secrets.
|
||||
next = mergeImportedAuthProfiles({
|
||||
store: next,
|
||||
profiles: configCanonicalStore.profiles,
|
||||
existingProfileIds: new Set(Object.keys(next.profiles)),
|
||||
replaceExistingWithoutCredential: true,
|
||||
});
|
||||
}
|
||||
if (hasAuthProfileState(state)) {
|
||||
next = mergeImportedAuthProfileState({ store: next, state, existingState });
|
||||
}
|
||||
|
||||
if (canonicalStore || legacyStore || hasAuthProfileState(state)) {
|
||||
if (canonicalStore || configCanonicalStore || legacyStore || hasAuthProfileState(state)) {
|
||||
const stateProfileIds = [
|
||||
...collectAuthProfileStateProfileIds(state),
|
||||
...(canonicalStore
|
||||
? collectAuthProfileStateProfileIds(coerceAuthProfileState(canonicalStore))
|
||||
: []),
|
||||
...(configCanonicalStore
|
||||
? collectAuthProfileStateProfileIds(coerceAuthProfileState(configCanonicalStore))
|
||||
: []),
|
||||
];
|
||||
saveAuthProfileStore(next, candidate.agentDir, {
|
||||
filterExternalAuthProfiles: false,
|
||||
|
|
@ -694,6 +989,12 @@ export async function maybeMigrateAuthProfileJsonStoresToSqlite(params: {
|
|||
);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
configCanonicalStore &&
|
||||
stripImportedConfigAuthProfileCredentials(params.cfg, configCanonicalStore)
|
||||
) {
|
||||
result.configChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
const backups: string[] = [];
|
||||
|
|
@ -711,8 +1012,12 @@ export async function maybeMigrateAuthProfileJsonStoresToSqlite(params: {
|
|||
if (fs.existsSync(candidate.legacyPath)) {
|
||||
backups.push(backupAndRemoveAuthProfileJson(candidate.legacyPath, "sqlite-import", now));
|
||||
}
|
||||
const backupText =
|
||||
backups.length > 0
|
||||
? `backup${backups.length === 1 ? "" : "s"}: ${backups.map(shortenHomePath).join(", ")}`
|
||||
: "no legacy JSON backup needed";
|
||||
result.changes.push(
|
||||
`Migrated auth profile JSON for ${shortenHomePath(candidate.authPath)} into SQLite (backup${backups.length === 1 ? "" : "s"}: ${backups.map(shortenHomePath).join(", ")}).`,
|
||||
`Migrated auth profile JSON for ${shortenHomePath(candidate.authPath)} into SQLite (${backupText}).`,
|
||||
);
|
||||
if (awsSdkMarkerStore) {
|
||||
result.changes.push(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue