mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-17 10:46:01 +00:00
refactor: move legacy auth profile imports to doctor
This commit is contained in:
@@ -83,6 +83,10 @@ proceed with these assumptions:
|
||||
- `openclaw doctor --fix` owns the legacy file-to-database migration step.
|
||||
Runtime startup and `openclaw migrate` should not carry legacy OpenClaw
|
||||
database-upgrade paths.
|
||||
- Credential compatibility follows the same rule even though credentials stay
|
||||
file-backed: runtime reads canonical `auth-profiles.json` only. Retired
|
||||
per-agent `auth.json` and shared `credentials/oauth.json` files are doctor
|
||||
migration inputs, then removed after import.
|
||||
- Runtime must not migrate, normalize, or bridge transcript locators. Active
|
||||
transcript identity is `{agentId, sessionId}` in SQLite. File paths are
|
||||
legacy doctor inputs only, and `sqlite-transcript://...` must disappear from
|
||||
@@ -627,6 +631,10 @@ sessionId}` and session key context.
|
||||
- Config health fingerprints now use shared SQLite KV instead of
|
||||
`logs/config-health.json`, keeping the normal config file as the only
|
||||
non-credential configuration document.
|
||||
- Auth profile runtime no longer imports retired credential JSON files. The
|
||||
canonical credential file remains `auth-profiles.json`; per-agent `auth.json`
|
||||
and shared `credentials/oauth.json` are doctor migration inputs that are
|
||||
removed after import.
|
||||
- Voice Wake trigger and routing settings now use shared SQLite KV instead of
|
||||
`settings/voicewake.json` and `settings/voicewake-routing.json`; doctor imports
|
||||
the legacy JSON files and removes them after a successful migration.
|
||||
|
||||
@@ -136,7 +136,9 @@ This plan has started landing in slices:
|
||||
- Auth profile runtime routing state now uses the shared SQLite `kv` store as
|
||||
the primary record path. Older per-agent `auth-state.json` files are imported
|
||||
and removed by `openclaw doctor --fix`; `auth-profiles.json` still owns
|
||||
credentials and stays file-backed.
|
||||
credentials and stays file-backed. Retired per-agent `auth.json` and shared
|
||||
`credentials/oauth.json` credential files are doctor migration inputs only;
|
||||
runtime no longer imports them.
|
||||
- Device identity, local device auth tokens, bootstrap tokens, device/node
|
||||
pairing ledgers, channel pairing requests/allowlists, inferred commitment
|
||||
records, subagent run records, TUI restore pointers, auth routing state,
|
||||
@@ -564,8 +566,9 @@ Add tests before each migration step:
|
||||
imported and removed only by doctor.
|
||||
- TUI last-session restore pointers read from SQLite without JSON exports,
|
||||
import legacy JSON only through doctor, and clear stale pointers from SQLite.
|
||||
- Auth profile runtime state reads from SQLite, imports legacy JSON only through
|
||||
doctor, and deletes SQLite state when runtime state is empty.
|
||||
- Auth profile runtime state reads from SQLite, imports legacy state/credential
|
||||
JSON only through doctor, and deletes SQLite state when runtime state is
|
||||
empty.
|
||||
|
||||
## Rollout Plan
|
||||
|
||||
|
||||
@@ -152,7 +152,7 @@ describe("ensureAuthProfileStore", () => {
|
||||
return profile;
|
||||
}
|
||||
|
||||
it("migrates legacy auth.json and deletes it (PR #368)", () => {
|
||||
it("does not import legacy auth.json at runtime", () => {
|
||||
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-profiles-"));
|
||||
try {
|
||||
const legacyPath = path.join(agentDir, "auth.json");
|
||||
@@ -175,19 +175,9 @@ describe("ensureAuthProfileStore", () => {
|
||||
);
|
||||
|
||||
const store = ensureAuthProfileStore(agentDir);
|
||||
expect(store.profiles["anthropic:default"]).toMatchObject({
|
||||
type: "oauth",
|
||||
provider: "anthropic",
|
||||
});
|
||||
|
||||
const migratedPath = path.join(agentDir, "auth-profiles.json");
|
||||
expect(fs.existsSync(migratedPath)).toBe(true);
|
||||
expect(fs.existsSync(legacyPath)).toBe(false);
|
||||
|
||||
// idempotent
|
||||
const store2 = ensureAuthProfileStore(agentDir);
|
||||
expect(store2.profiles).toHaveProperty("anthropic:default");
|
||||
expect(fs.existsSync(legacyPath)).toBe(false);
|
||||
expect(store.profiles["anthropic:default"]).toBeUndefined();
|
||||
expect(fs.existsSync(path.join(agentDir, "auth-profiles.json"))).toBe(false);
|
||||
expect(fs.existsSync(legacyPath)).toBe(true);
|
||||
} finally {
|
||||
fs.rmSync(agentDir, { recursive: true, force: true });
|
||||
}
|
||||
@@ -714,10 +704,11 @@ describe("ensureAuthProfileStore", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("normalizes mode/apiKey aliases while migrating legacy auth.json", () => {
|
||||
it("does not normalize legacy auth.json aliases at runtime", () => {
|
||||
withTempAgentDir("openclaw-auth-legacy-alias-", (agentDir) => {
|
||||
const legacyPath = path.join(agentDir, "auth.json");
|
||||
fs.writeFileSync(
|
||||
path.join(agentDir, "auth.json"),
|
||||
legacyPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
anthropic: {
|
||||
@@ -733,11 +724,8 @@ describe("ensureAuthProfileStore", () => {
|
||||
);
|
||||
|
||||
const store = ensureAuthProfileStore(agentDir);
|
||||
expect(store.profiles["anthropic:default"]).toMatchObject({
|
||||
type: "api_key",
|
||||
provider: "anthropic",
|
||||
key: "sk-ant-legacy",
|
||||
});
|
||||
expect(store.profiles["anthropic:default"]).toBeUndefined();
|
||||
expect(fs.existsSync(legacyPath)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -762,7 +750,7 @@ describe("ensureAuthProfileStore", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("merges legacy oauth.json into auth-profiles.json", () => {
|
||||
it("does not import legacy oauth.json at runtime", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-oauth-migrate-"));
|
||||
const previousStateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
const previousAgentDir = process.env.OPENCLAW_AGENT_DIR;
|
||||
@@ -795,24 +783,9 @@ describe("ensureAuthProfileStore", () => {
|
||||
clearRuntimeAuthProfileStoreSnapshots();
|
||||
|
||||
const store = ensureAuthProfileStore(agentDir);
|
||||
expect(store.profiles["openai-codex:default"]).toMatchObject({
|
||||
type: "oauth",
|
||||
provider: "openai-codex",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
});
|
||||
|
||||
const persisted = JSON.parse(
|
||||
fs.readFileSync(path.join(agentDir, "auth-profiles.json"), "utf8"),
|
||||
) as {
|
||||
profiles: Record<string, unknown>;
|
||||
};
|
||||
expect(persisted.profiles["openai-codex:default"]).toMatchObject({
|
||||
type: "oauth",
|
||||
provider: "openai-codex",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
});
|
||||
expect(store.profiles["openai-codex:default"]).toBeUndefined();
|
||||
expect(fs.existsSync(path.join(agentDir, "auth-profiles.json"))).toBe(false);
|
||||
expect(fs.existsSync(path.join(oauthDir, "oauth.json"))).toBe(true);
|
||||
} finally {
|
||||
clearRuntimeAuthProfileStoreSnapshots();
|
||||
restoreEnvValue("OPENCLAW_STATE_DIR", previousStateDir);
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||
export {
|
||||
AUTH_PROFILE_FILENAME,
|
||||
AUTH_STATE_FILENAME,
|
||||
LEGACY_AUTH_FILENAME,
|
||||
} from "./path-constants.js";
|
||||
export { AUTH_PROFILE_FILENAME, AUTH_STATE_FILENAME } from "./path-constants.js";
|
||||
|
||||
export const AUTH_STORE_VERSION = 1;
|
||||
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
export const AUTH_PROFILE_FILENAME = "auth-profiles.json";
|
||||
export const AUTH_STATE_FILENAME = "auth-state.json";
|
||||
export const LEGACY_AUTH_FILENAME = "auth.json";
|
||||
|
||||
@@ -3,22 +3,13 @@ import path from "node:path";
|
||||
import { resolveStateDir } from "../../config/paths.js";
|
||||
import { resolveUserPath } from "../../utils.js";
|
||||
import { resolveDefaultAgentDir } from "../agent-scope-config.js";
|
||||
import {
|
||||
AUTH_PROFILE_FILENAME,
|
||||
AUTH_STATE_FILENAME,
|
||||
LEGACY_AUTH_FILENAME,
|
||||
} from "./path-constants.js";
|
||||
import { AUTH_PROFILE_FILENAME, AUTH_STATE_FILENAME } from "./path-constants.js";
|
||||
|
||||
export function resolveAuthStorePath(agentDir?: string): string {
|
||||
const resolved = resolveUserPath(agentDir ?? resolveDefaultAgentDir({}));
|
||||
return path.join(resolved, AUTH_PROFILE_FILENAME);
|
||||
}
|
||||
|
||||
export function resolveLegacyAuthStorePath(agentDir?: string): string {
|
||||
const resolved = resolveUserPath(agentDir ?? resolveDefaultAgentDir({}));
|
||||
return path.join(resolved, LEGACY_AUTH_FILENAME);
|
||||
}
|
||||
|
||||
export function resolveAuthStatePath(agentDir?: string): string {
|
||||
const resolved = resolveUserPath(agentDir ?? resolveDefaultAgentDir({}));
|
||||
return path.join(resolved, AUTH_STATE_FILENAME);
|
||||
|
||||
@@ -7,7 +7,6 @@ export {
|
||||
resolveAuthStatePathForDisplay,
|
||||
resolveAuthStorePath,
|
||||
resolveAuthStorePathForDisplay,
|
||||
resolveLegacyAuthStorePath,
|
||||
resolveOAuthRefreshLockPath,
|
||||
} from "./path-resolve.js";
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { resolveOAuthPath } from "../../config/paths.js";
|
||||
import { coerceSecretRef } from "../../config/types.secrets.js";
|
||||
import { loadJsonFile } from "../../infra/json-file.js";
|
||||
import { normalizeProviderId } from "../provider-id.js";
|
||||
@@ -10,7 +9,7 @@ import {
|
||||
normalizeAuthEmailToken,
|
||||
normalizeAuthIdentityToken,
|
||||
} from "./oauth-shared.js";
|
||||
import { resolveAuthStorePath, resolveLegacyAuthStorePath } from "./paths.js";
|
||||
import { resolveAuthStorePath } from "./paths.js";
|
||||
import {
|
||||
coerceAuthProfileState,
|
||||
loadPersistedAuthProfileState,
|
||||
@@ -22,12 +21,9 @@ import type {
|
||||
AuthProfileSecretsStore,
|
||||
AuthProfileStore,
|
||||
OAuthCredential,
|
||||
OAuthCredentials,
|
||||
ProfileUsageStats,
|
||||
} from "./types.js";
|
||||
|
||||
export type LegacyAuthStore = Record<string, AuthProfileCredential>;
|
||||
|
||||
type CredentialRejectReason = "non_object" | "invalid_type" | "missing_provider";
|
||||
type RejectedCredentialEntry = { key: string; reason: CredentialRejectReason };
|
||||
|
||||
@@ -105,28 +101,6 @@ function warnRejectedCredentialEntries(source: string, rejected: RejectedCredent
|
||||
});
|
||||
}
|
||||
|
||||
function coerceLegacyAuthStore(raw: unknown): LegacyAuthStore | null {
|
||||
if (!raw || typeof raw !== "object") {
|
||||
return null;
|
||||
}
|
||||
const record = raw as Record<string, unknown>;
|
||||
if ("profiles" in record) {
|
||||
return null;
|
||||
}
|
||||
const entries: LegacyAuthStore = {};
|
||||
const rejected: RejectedCredentialEntry[] = [];
|
||||
for (const [key, value] of Object.entries(record)) {
|
||||
const parsed = parseCredentialEntry(value, key);
|
||||
if (!parsed.ok) {
|
||||
rejected.push({ key, reason: parsed.reason });
|
||||
continue;
|
||||
}
|
||||
entries[key] = parsed.credential;
|
||||
}
|
||||
warnRejectedCredentialEntries("auth.json", rejected);
|
||||
return Object.keys(entries).length > 0 ? entries : null;
|
||||
}
|
||||
|
||||
export function coercePersistedAuthProfileStore(raw: unknown): AuthProfileStore | null {
|
||||
if (!raw || typeof raw !== "object") {
|
||||
return null;
|
||||
@@ -526,69 +500,6 @@ export function buildPersistedAuthProfileSecretsStore(
|
||||
};
|
||||
}
|
||||
|
||||
export function applyLegacyAuthStore(store: AuthProfileStore, legacy: LegacyAuthStore): void {
|
||||
for (const [provider, cred] of Object.entries(legacy)) {
|
||||
const profileId = `${provider}:default`;
|
||||
const credentialProvider = cred.provider ?? provider;
|
||||
if (cred.type === "api_key") {
|
||||
store.profiles[profileId] = {
|
||||
type: "api_key",
|
||||
provider: credentialProvider,
|
||||
key: cred.key,
|
||||
...(cred.email ? { email: cred.email } : {}),
|
||||
};
|
||||
continue;
|
||||
}
|
||||
if (cred.type === "token") {
|
||||
store.profiles[profileId] = {
|
||||
type: "token",
|
||||
provider: credentialProvider,
|
||||
token: cred.token,
|
||||
...(typeof cred.expires === "number" ? { expires: cred.expires } : {}),
|
||||
...(cred.email ? { email: cred.email } : {}),
|
||||
};
|
||||
continue;
|
||||
}
|
||||
store.profiles[profileId] = {
|
||||
type: "oauth",
|
||||
provider: credentialProvider,
|
||||
access: cred.access,
|
||||
refresh: cred.refresh,
|
||||
expires: cred.expires,
|
||||
...(cred.enterpriseUrl ? { enterpriseUrl: cred.enterpriseUrl } : {}),
|
||||
...(cred.projectId ? { projectId: cred.projectId } : {}),
|
||||
...(cred.accountId ? { accountId: cred.accountId } : {}),
|
||||
...(cred.email ? { email: cred.email } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function mergeOAuthFileIntoStore(store: AuthProfileStore): boolean {
|
||||
const oauthPath = resolveOAuthPath();
|
||||
const oauthRaw = loadJsonFile(oauthPath);
|
||||
if (!oauthRaw || typeof oauthRaw !== "object") {
|
||||
return false;
|
||||
}
|
||||
const oauthEntries = oauthRaw as Record<string, OAuthCredentials>;
|
||||
let mutated = false;
|
||||
for (const [provider, creds] of Object.entries(oauthEntries)) {
|
||||
if (!creds || typeof creds !== "object") {
|
||||
continue;
|
||||
}
|
||||
const profileId = `${provider}:default`;
|
||||
if (store.profiles[profileId]) {
|
||||
continue;
|
||||
}
|
||||
store.profiles[profileId] = {
|
||||
type: "oauth",
|
||||
provider,
|
||||
...creds,
|
||||
};
|
||||
mutated = true;
|
||||
}
|
||||
return mutated;
|
||||
}
|
||||
|
||||
export function loadPersistedAuthProfileStore(agentDir?: string): AuthProfileStore | null {
|
||||
const authPath = resolveAuthStorePath(agentDir);
|
||||
const raw = loadJsonFile(authPath);
|
||||
@@ -601,7 +512,3 @@ export function loadPersistedAuthProfileStore(agentDir?: string): AuthProfileSto
|
||||
...mergeAuthProfileState(coerceAuthProfileState(raw), loadPersistedAuthProfileState(agentDir)),
|
||||
};
|
||||
}
|
||||
|
||||
export function loadLegacyAuthProfileStore(agentDir?: string): LegacyAuthStore | null {
|
||||
return coerceLegacyAuthStore(loadJsonFile(resolveLegacyAuthStorePath(agentDir)));
|
||||
}
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
import fs from "node:fs";
|
||||
import {
|
||||
resolveAuthStatePath,
|
||||
resolveAuthStorePath,
|
||||
resolveLegacyAuthStorePath,
|
||||
} from "./path-resolve.js";
|
||||
import { resolveAuthStatePath, resolveAuthStorePath } from "./path-resolve.js";
|
||||
import { hasAnyRuntimeAuthProfileStoreSource } from "./runtime-snapshots.js";
|
||||
|
||||
function hasStoredAuthProfileFiles(agentDir?: string): boolean {
|
||||
return (
|
||||
fs.existsSync(resolveAuthStorePath(agentDir)) ||
|
||||
fs.existsSync(resolveAuthStatePath(agentDir)) ||
|
||||
fs.existsSync(resolveLegacyAuthStorePath(agentDir))
|
||||
fs.existsSync(resolveAuthStorePath(agentDir)) || fs.existsSync(resolveAuthStatePath(agentDir))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,24 +8,15 @@ import {
|
||||
AUTH_STORE_LOCK_OPTIONS,
|
||||
AUTH_STORE_VERSION,
|
||||
EXTERNAL_CLI_SYNC_TTL_MS,
|
||||
log,
|
||||
} from "./constants.js";
|
||||
import { overlayExternalAuthProfiles, shouldPersistExternalAuthProfile } from "./external-auth.js";
|
||||
import type { ExternalCliAuthDiscovery } from "./external-cli-discovery.js";
|
||||
import { isSafeToAdoptMainStoreOAuthIdentity } from "./oauth-shared.js";
|
||||
import { ensureAuthStoreFile, resolveAuthStatePath, resolveAuthStorePath } from "./paths.js";
|
||||
import {
|
||||
ensureAuthStoreFile,
|
||||
resolveAuthStatePath,
|
||||
resolveAuthStorePath,
|
||||
resolveLegacyAuthStorePath,
|
||||
} from "./paths.js";
|
||||
import {
|
||||
applyLegacyAuthStore,
|
||||
buildPersistedAuthProfileSecretsStore,
|
||||
loadLegacyAuthProfileStore,
|
||||
loadPersistedAuthProfileStore,
|
||||
mergeAuthProfileStores,
|
||||
mergeOAuthFileIntoStore,
|
||||
} from "./persisted.js";
|
||||
import {
|
||||
clearRuntimeAuthProfileStoreSnapshots as clearRuntimeAuthProfileStoreSnapshotsImpl,
|
||||
@@ -338,15 +329,6 @@ export function loadAuthProfileStore(): AuthProfileStore {
|
||||
if (asStore) {
|
||||
return overlayExternalAuthProfiles(asStore);
|
||||
}
|
||||
const legacy = loadLegacyAuthProfileStore();
|
||||
if (legacy) {
|
||||
const store: AuthProfileStore = {
|
||||
version: AUTH_STORE_VERSION,
|
||||
profiles: {},
|
||||
};
|
||||
applyLegacyAuthStore(store, legacy);
|
||||
return overlayExternalAuthProfiles(store);
|
||||
}
|
||||
|
||||
const store: AuthProfileStore = { version: AUTH_STORE_VERSION, profiles: {} };
|
||||
return overlayExternalAuthProfiles(store);
|
||||
@@ -384,38 +366,10 @@ function loadAuthProfileStoreForAgent(
|
||||
return asStore;
|
||||
}
|
||||
|
||||
const legacy = loadLegacyAuthProfileStore(agentDir);
|
||||
const store: AuthProfileStore = {
|
||||
version: AUTH_STORE_VERSION,
|
||||
profiles: {},
|
||||
};
|
||||
if (legacy) {
|
||||
applyLegacyAuthStore(store, legacy);
|
||||
}
|
||||
|
||||
const mergedOAuth = mergeOAuthFileIntoStore(store);
|
||||
const forceReadOnly = process.env.OPENCLAW_AUTH_STORE_READONLY === "1";
|
||||
const shouldWrite = !readOnly && !forceReadOnly && (legacy !== null || mergedOAuth);
|
||||
if (shouldWrite) {
|
||||
saveAuthProfileStore(store, agentDir);
|
||||
}
|
||||
|
||||
// PR #368: legacy auth.json could get re-migrated from other agent dirs,
|
||||
// overwriting fresh OAuth creds with stale tokens (fixes #363). Delete only
|
||||
// after we've successfully written auth-profiles.json.
|
||||
if (shouldWrite && legacy !== null) {
|
||||
const legacyPath = resolveLegacyAuthStorePath(agentDir);
|
||||
try {
|
||||
fs.unlinkSync(legacyPath);
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException)?.code !== "ENOENT") {
|
||||
log.warn("failed to delete legacy auth.json after migration", {
|
||||
err,
|
||||
legacyPath,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!readOnly) {
|
||||
writeCachedAuthProfileStore({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { clearRuntimeAuthProfileStoreSnapshots } from "../agents/auth-profiles/store.js";
|
||||
import {
|
||||
@@ -81,6 +82,84 @@ describe("maybeRepairLegacyFlatAuthProfileStores", () => {
|
||||
expect(JSON.parse(fs.readFileSync(`${authPath}.legacy-flat.123.bak`, "utf8"))).toEqual(legacy);
|
||||
});
|
||||
|
||||
it("imports retired auth.json stores into auth-profiles.json and removes the source", async () => {
|
||||
const state = await makeTestState();
|
||||
const agentDir = state.agentDir();
|
||||
fs.mkdirSync(agentDir, { recursive: true });
|
||||
const legacyPath = `${agentDir}/auth.json`;
|
||||
const legacy = {
|
||||
anthropic: {
|
||||
mode: "api_key",
|
||||
apiKey: "sk-ant-legacy",
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(legacyPath, `${JSON.stringify(legacy, null, 2)}\n`);
|
||||
|
||||
const result = await maybeRepairLegacyFlatAuthProfileStores({
|
||||
cfg: {},
|
||||
prompter: makePrompter(true),
|
||||
now: () => 234,
|
||||
});
|
||||
|
||||
expect(result.detected).toEqual([legacyPath]);
|
||||
expect(result.warnings).toStrictEqual([]);
|
||||
expect(fs.existsSync(legacyPath)).toBe(false);
|
||||
expect(JSON.parse(fs.readFileSync(`${legacyPath}.legacy-auth.234.bak`, "utf8"))).toEqual(
|
||||
legacy,
|
||||
);
|
||||
expect(JSON.parse(fs.readFileSync(`${agentDir}/auth-profiles.json`, "utf8"))).toEqual({
|
||||
version: 1,
|
||||
profiles: {
|
||||
"anthropic:default": {
|
||||
type: "api_key",
|
||||
provider: "anthropic",
|
||||
key: "sk-ant-legacy",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("imports retired oauth.json into auth-profiles.json and removes the source", async () => {
|
||||
const state = await makeTestState();
|
||||
const legacyPath = state.statePath("credentials", "oauth.json");
|
||||
const legacy = {
|
||||
"openai-codex": {
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: 1_800_000_000_000,
|
||||
accountId: "acct_123",
|
||||
},
|
||||
};
|
||||
fs.mkdirSync(path.dirname(legacyPath), { recursive: true });
|
||||
fs.writeFileSync(legacyPath, `${JSON.stringify(legacy, null, 2)}\n`);
|
||||
|
||||
const result = await maybeRepairLegacyFlatAuthProfileStores({
|
||||
cfg: {},
|
||||
prompter: makePrompter(true),
|
||||
now: () => 345,
|
||||
});
|
||||
|
||||
expect(result.detected).toEqual([legacyPath]);
|
||||
expect(result.warnings).toStrictEqual([]);
|
||||
expect(fs.existsSync(legacyPath)).toBe(false);
|
||||
expect(JSON.parse(fs.readFileSync(`${legacyPath}.legacy-oauth.345.bak`, "utf8"))).toEqual(
|
||||
legacy,
|
||||
);
|
||||
expect(JSON.parse(fs.readFileSync(`${state.agentDir()}/auth-profiles.json`, "utf8"))).toEqual({
|
||||
version: 1,
|
||||
profiles: {
|
||||
"openai-codex:default": {
|
||||
type: "oauth",
|
||||
provider: "openai-codex",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: 1_800_000_000_000,
|
||||
accountId: "acct_123",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("reports legacy flat stores without rewriting when repair is declined", async () => {
|
||||
const state = await makeTestState();
|
||||
const legacy = {
|
||||
|
||||
@@ -3,13 +3,21 @@ import path from "node:path";
|
||||
import { resolveAgentDir, resolveDefaultAgentDir, listAgentIds } from "../agents/agent-scope.js";
|
||||
import { AUTH_STORE_VERSION } from "../agents/auth-profiles/constants.js";
|
||||
import { resolveAuthStorePath } from "../agents/auth-profiles/paths.js";
|
||||
import {
|
||||
loadPersistedAuthProfileStore,
|
||||
mergeAuthProfileStores,
|
||||
} from "../agents/auth-profiles/persisted.js";
|
||||
import {
|
||||
clearRuntimeAuthProfileStoreSnapshots,
|
||||
saveAuthProfileStore,
|
||||
} from "../agents/auth-profiles/store.js";
|
||||
import type { AuthProfileCredential, AuthProfileStore } from "../agents/auth-profiles/types.js";
|
||||
import type {
|
||||
AuthProfileCredential,
|
||||
AuthProfileStore,
|
||||
OAuthCredentials,
|
||||
} from "../agents/auth-profiles/types.js";
|
||||
import { formatCliCommand } from "../cli/command-format.js";
|
||||
import { resolveStateDir } from "../config/paths.js";
|
||||
import { resolveOAuthPath, resolveStateDir } from "../config/paths.js";
|
||||
import type { AuthProfileConfig } from "../config/types.auth.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { loadJsonFile } from "../infra/json-file.js";
|
||||
@@ -28,6 +36,20 @@ type LegacyFlatAuthProfileStore = {
|
||||
store: AuthProfileStore;
|
||||
};
|
||||
|
||||
type LegacyAuthJsonStore = {
|
||||
agentDir?: string;
|
||||
authPath: string;
|
||||
legacyPath: string;
|
||||
store: AuthProfileStore;
|
||||
};
|
||||
|
||||
type LegacyOAuthJsonStore = {
|
||||
agentDir?: string;
|
||||
authPath: string;
|
||||
legacyPath: string;
|
||||
store: AuthProfileStore;
|
||||
};
|
||||
|
||||
type AwsSdkProfileMarker = {
|
||||
profileId: string;
|
||||
provider: string;
|
||||
@@ -228,18 +250,121 @@ function resolveLegacyFlatStore(
|
||||
};
|
||||
}
|
||||
|
||||
function resolveLegacyAuthJsonStore(
|
||||
candidate: AuthProfileRepairCandidate,
|
||||
): LegacyAuthJsonStore | null {
|
||||
const legacyPath = path.join(path.dirname(candidate.authPath), "auth.json");
|
||||
if (!fs.existsSync(legacyPath)) {
|
||||
return null;
|
||||
}
|
||||
const raw = loadJsonFile(legacyPath);
|
||||
const store = coerceLegacyFlatAuthProfileStore(raw);
|
||||
if (!store || Object.keys(store.profiles).length === 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...candidate,
|
||||
legacyPath,
|
||||
store,
|
||||
};
|
||||
}
|
||||
|
||||
function coerceLegacyOAuthJsonStore(raw: unknown): AuthProfileStore | null {
|
||||
if (!isRecord(raw)) {
|
||||
return null;
|
||||
}
|
||||
const store: AuthProfileStore = {
|
||||
version: AUTH_STORE_VERSION,
|
||||
profiles: {},
|
||||
};
|
||||
for (const [provider, value] of Object.entries(raw)) {
|
||||
if (!isRecord(value) || !isSafeLegacyProviderKey(provider)) {
|
||||
continue;
|
||||
}
|
||||
const creds = value as OAuthCredentials;
|
||||
if (
|
||||
!readNonEmptyString(creds.access) ||
|
||||
!readNonEmptyString(creds.refresh) ||
|
||||
typeof creds.expires !== "number"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
store.profiles[`${provider}:default`] = {
|
||||
type: "oauth",
|
||||
provider,
|
||||
access: creds.access,
|
||||
refresh: creds.refresh,
|
||||
expires: creds.expires,
|
||||
...(readNonEmptyString(creds.accountId)
|
||||
? { accountId: readNonEmptyString(creds.accountId) }
|
||||
: {}),
|
||||
...(readNonEmptyString(creds.email) ? { email: readNonEmptyString(creds.email) } : {}),
|
||||
};
|
||||
}
|
||||
return Object.keys(store.profiles).length > 0 ? store : null;
|
||||
}
|
||||
|
||||
function resolveLegacyOAuthJsonStore(cfg: OpenClawConfig): LegacyOAuthJsonStore | null {
|
||||
const legacyPath = resolveOAuthPath();
|
||||
if (!fs.existsSync(legacyPath)) {
|
||||
return null;
|
||||
}
|
||||
const store = coerceLegacyOAuthJsonStore(loadJsonFile(legacyPath));
|
||||
if (!store || Object.keys(store.profiles).length === 0) {
|
||||
return null;
|
||||
}
|
||||
const agentDir = resolveDefaultAgentDir(cfg);
|
||||
return {
|
||||
agentDir,
|
||||
authPath: resolveAuthStorePath(agentDir),
|
||||
legacyPath,
|
||||
store,
|
||||
};
|
||||
}
|
||||
|
||||
function backupAuthProfileStore(authPath: string, now: () => number): string {
|
||||
const backupPath = `${authPath}.legacy-flat.${now()}.bak`;
|
||||
fs.copyFileSync(authPath, backupPath);
|
||||
return backupPath;
|
||||
}
|
||||
|
||||
function backupLegacyAuthJsonStore(legacyPath: string, now: () => number): string {
|
||||
const backupPath = `${legacyPath}.legacy-auth.${now()}.bak`;
|
||||
fs.copyFileSync(legacyPath, backupPath);
|
||||
return backupPath;
|
||||
}
|
||||
|
||||
function backupLegacyOAuthJsonStore(legacyPath: string, now: () => number): string {
|
||||
const backupPath = `${legacyPath}.legacy-oauth.${now()}.bak`;
|
||||
fs.copyFileSync(legacyPath, backupPath);
|
||||
return backupPath;
|
||||
}
|
||||
|
||||
function backupAwsSdkProfileMarkerStore(authPath: string, now: () => number): string {
|
||||
const backupPath = `${authPath}.aws-sdk-profile.${now()}.bak`;
|
||||
fs.copyFileSync(authPath, backupPath);
|
||||
return backupPath;
|
||||
}
|
||||
|
||||
function mergeMissingAuthProfiles(params: {
|
||||
agentDir?: string;
|
||||
imported: AuthProfileStore;
|
||||
}): AuthProfileStore {
|
||||
const existing = loadPersistedAuthProfileStore(params.agentDir);
|
||||
if (!existing) {
|
||||
return params.imported;
|
||||
}
|
||||
const missingOnly: AuthProfileStore = {
|
||||
version: AUTH_STORE_VERSION,
|
||||
profiles: Object.fromEntries(
|
||||
Object.entries(params.imported.profiles).filter(
|
||||
([profileId]) => !existing.profiles[profileId],
|
||||
),
|
||||
),
|
||||
};
|
||||
return mergeAuthProfileStores(existing, missingOnly);
|
||||
}
|
||||
|
||||
function resolveAwsSdkAuthProfileMarkerStore(
|
||||
candidate: AuthProfileRepairCandidate,
|
||||
): AwsSdkAuthProfileMarkerStore | null {
|
||||
@@ -311,6 +436,10 @@ export async function maybeRepairLegacyFlatAuthProfileStores(params: {
|
||||
const legacyStores = listAuthProfileRepairCandidates(params.cfg)
|
||||
.map(resolveLegacyFlatStore)
|
||||
.filter((entry): entry is LegacyFlatAuthProfileStore => entry !== null);
|
||||
const legacyAuthJsonStores = listAuthProfileRepairCandidates(params.cfg)
|
||||
.map(resolveLegacyAuthJsonStore)
|
||||
.filter((entry): entry is LegacyAuthJsonStore => entry !== null);
|
||||
const legacyOAuthJsonStore = resolveLegacyOAuthJsonStore(params.cfg);
|
||||
const awsSdkMarkerStores = listAuthProfileRepairCandidates(params.cfg)
|
||||
.map(resolveAwsSdkAuthProfileMarkerStore)
|
||||
.filter((entry): entry is AwsSdkAuthProfileMarkerStore => entry !== null);
|
||||
@@ -318,12 +447,19 @@ export async function maybeRepairLegacyFlatAuthProfileStores(params: {
|
||||
const result: LegacyFlatAuthProfileRepairResult = {
|
||||
detected: [
|
||||
...legacyStores.map((entry) => entry.authPath),
|
||||
...legacyAuthJsonStores.map((entry) => entry.legacyPath),
|
||||
...(legacyOAuthJsonStore ? [legacyOAuthJsonStore.legacyPath] : []),
|
||||
...awsSdkMarkerStores.map((entry) => entry.authPath),
|
||||
],
|
||||
changes: [],
|
||||
warnings: [],
|
||||
};
|
||||
if (legacyStores.length === 0 && awsSdkMarkerStores.length === 0) {
|
||||
if (
|
||||
legacyStores.length === 0 &&
|
||||
legacyAuthJsonStores.length === 0 &&
|
||||
!legacyOAuthJsonStore &&
|
||||
awsSdkMarkerStores.length === 0
|
||||
) {
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -331,6 +467,15 @@ export async function maybeRepairLegacyFlatAuthProfileStores(params: {
|
||||
...legacyStores.map(
|
||||
(entry) => `- ${shortenHomePath(entry.authPath)} uses the legacy flat auth profile format.`,
|
||||
),
|
||||
...legacyAuthJsonStores.map(
|
||||
(entry) =>
|
||||
`- ${shortenHomePath(entry.legacyPath)} uses the retired auth.json credential format.`,
|
||||
),
|
||||
...(legacyOAuthJsonStore
|
||||
? [
|
||||
`- ${shortenHomePath(legacyOAuthJsonStore.legacyPath)} uses the retired shared OAuth credential format.`,
|
||||
]
|
||||
: []),
|
||||
...awsSdkMarkerStores.map(
|
||||
(entry) =>
|
||||
`- ${shortenHomePath(entry.authPath)} contains aws-sdk profile markers that belong in openclaw.json auth.profiles.`,
|
||||
@@ -341,6 +486,11 @@ export async function maybeRepairLegacyFlatAuthProfileStores(params: {
|
||||
`- The gateway expects the canonical version/profiles store; ${formatCliCommand("openclaw doctor --fix")} rewrites this legacy shape with a backup.`,
|
||||
);
|
||||
}
|
||||
if (legacyAuthJsonStores.length > 0 || legacyOAuthJsonStore) {
|
||||
noteLines.push(
|
||||
`- Runtime no longer imports retired credential JSON files; ${formatCliCommand("openclaw doctor --fix")} imports them into auth-profiles.json and removes the source files.`,
|
||||
);
|
||||
}
|
||||
if (awsSdkMarkerStores.length > 0) {
|
||||
noteLines.push(
|
||||
`- AWS SDK profile markers are routing metadata, not stored credentials; ${formatCliCommand("openclaw doctor --fix")} moves them to config with a backup.`,
|
||||
@@ -367,6 +517,40 @@ export async function maybeRepairLegacyFlatAuthProfileStores(params: {
|
||||
result.warnings.push(`Failed to rewrite ${shortenHomePath(entry.authPath)}: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
for (const entry of legacyAuthJsonStores) {
|
||||
try {
|
||||
const backupPath = backupLegacyAuthJsonStore(entry.legacyPath, now);
|
||||
const merged = mergeMissingAuthProfiles({
|
||||
agentDir: entry.agentDir,
|
||||
imported: entry.store,
|
||||
});
|
||||
saveAuthProfileStore(merged, entry.agentDir, { syncExternalCli: false });
|
||||
fs.unlinkSync(entry.legacyPath);
|
||||
result.changes.push(
|
||||
`Imported ${shortenHomePath(entry.legacyPath)} into ${shortenHomePath(entry.authPath)} (backup: ${shortenHomePath(backupPath)}).`,
|
||||
);
|
||||
} catch (err) {
|
||||
result.warnings.push(`Failed to import ${shortenHomePath(entry.legacyPath)}: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
if (legacyOAuthJsonStore) {
|
||||
try {
|
||||
const backupPath = backupLegacyOAuthJsonStore(legacyOAuthJsonStore.legacyPath, now);
|
||||
const merged = mergeMissingAuthProfiles({
|
||||
agentDir: legacyOAuthJsonStore.agentDir,
|
||||
imported: legacyOAuthJsonStore.store,
|
||||
});
|
||||
saveAuthProfileStore(merged, legacyOAuthJsonStore.agentDir, { syncExternalCli: false });
|
||||
fs.unlinkSync(legacyOAuthJsonStore.legacyPath);
|
||||
result.changes.push(
|
||||
`Imported ${shortenHomePath(legacyOAuthJsonStore.legacyPath)} into ${shortenHomePath(legacyOAuthJsonStore.authPath)} (backup: ${shortenHomePath(backupPath)}).`,
|
||||
);
|
||||
} catch (err) {
|
||||
result.warnings.push(
|
||||
`Failed to import ${shortenHomePath(legacyOAuthJsonStore.legacyPath)}: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const entry of awsSdkMarkerStores) {
|
||||
try {
|
||||
const backupPath = backupAwsSdkProfileMarkerStore(entry.authPath, now);
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
CANONICAL_ROOT_MEMORY_FILENAME,
|
||||
LEGACY_ROOT_MEMORY_FILENAME,
|
||||
resolveCanonicalRootMemoryPath,
|
||||
resolveLegacyRootMemoryPath,
|
||||
resolveRootMemoryRepairDir,
|
||||
} from "../memory/root-memory-files.js";
|
||||
import { note } from "../terminal/note.js";
|
||||
@@ -118,7 +117,7 @@ export async function detectRootMemoryFiles(
|
||||
): Promise<RootMemoryFilesDetection> {
|
||||
const resolvedWorkspace = path.resolve(workspaceDir);
|
||||
const canonicalPath = resolveCanonicalRootMemoryPath(resolvedWorkspace);
|
||||
const legacyPath = resolveLegacyRootMemoryPath(resolvedWorkspace);
|
||||
const legacyPath = path.join(resolvedWorkspace, LEGACY_ROOT_MEMORY_FILENAME);
|
||||
const entries = await listWorkspaceEntries(resolvedWorkspace);
|
||||
const [canonical, legacy] = await Promise.all([
|
||||
entries.has(CANONICAL_ROOT_MEMORY_FILENAME)
|
||||
|
||||
@@ -9,10 +9,6 @@ export function resolveCanonicalRootMemoryPath(workspaceDir: string): string {
|
||||
return path.join(workspaceDir, CANONICAL_ROOT_MEMORY_FILENAME);
|
||||
}
|
||||
|
||||
export function resolveLegacyRootMemoryPath(workspaceDir: string): string {
|
||||
return path.join(workspaceDir, LEGACY_ROOT_MEMORY_FILENAME);
|
||||
}
|
||||
|
||||
export function resolveRootMemoryRepairDir(workspaceDir: string): string {
|
||||
return path.join(workspaceDir, ".openclaw-repair", "root-memory");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user