fix: scope auth profile keys to env

This commit is contained in:
Peter Steinberger
2026-05-17 03:29:08 +01:00
parent 80314d7a2f
commit 045882e24b
6 changed files with 173 additions and 37 deletions

View File

@@ -9,24 +9,39 @@ const LEGACY_AUTH_PROFILE_FILENAME = "auth-profiles.json";
const LEGACY_AUTH_STATE_FILENAME = "auth-state.json";
const LEGACY_AUTH_FILENAME = "auth.json";
export function resolveAuthProfileStoreAgentDir(agentDir?: string): string {
return resolveUserPath(agentDir ?? resolveDefaultAgentDir({}));
export function resolveAuthProfileStoreAgentDir(
agentDir?: string,
env: NodeJS.ProcessEnv = process.env,
): string {
return resolveUserPath(agentDir ?? resolveDefaultAgentDir({}, env), env);
}
export function resolveAuthProfileStoreKey(agentDir?: string): string {
return resolveAuthProfileStoreAgentDir(agentDir);
export function resolveAuthProfileStoreKey(
agentDir?: string,
env: NodeJS.ProcessEnv = process.env,
): string {
return resolveAuthProfileStoreAgentDir(agentDir, env);
}
export function resolveAuthStorePath(agentDir?: string): string {
return path.join(resolveAuthProfileStoreAgentDir(agentDir), LEGACY_AUTH_PROFILE_FILENAME);
export function resolveAuthStorePath(
agentDir?: string,
env: NodeJS.ProcessEnv = process.env,
): string {
return path.join(resolveAuthProfileStoreAgentDir(agentDir, env), LEGACY_AUTH_PROFILE_FILENAME);
}
export function resolveLegacyAuthStorePath(agentDir?: string): string {
return path.join(resolveAuthProfileStoreAgentDir(agentDir), LEGACY_AUTH_FILENAME);
export function resolveLegacyAuthStorePath(
agentDir?: string,
env: NodeJS.ProcessEnv = process.env,
): string {
return path.join(resolveAuthProfileStoreAgentDir(agentDir, env), LEGACY_AUTH_FILENAME);
}
export function resolveAuthStatePath(agentDir?: string): string {
return path.join(resolveAuthProfileStoreAgentDir(agentDir), LEGACY_AUTH_STATE_FILENAME);
export function resolveAuthStatePath(
agentDir?: string,
env: NodeJS.ProcessEnv = process.env,
): string {
return path.join(resolveAuthProfileStoreAgentDir(agentDir, env), LEGACY_AUTH_STATE_FILENAME);
}
export function resolveAuthStorePathForDisplay(agentDir?: string): string {
@@ -43,7 +58,7 @@ export function resolveAuthProfileStoreLocationForDisplay(
agentDir?: string,
env: NodeJS.ProcessEnv = process.env,
): string {
return `${resolveOpenClawStateSqlitePath(env)}#table/auth_profile_stores/${resolveAuthProfileStoreKey(agentDir)}`;
return `${resolveOpenClawStateSqlitePath(env)}#table/auth_profile_stores/${resolveAuthProfileStoreKey(agentDir, env)}`;
}
export const OAUTH_REFRESH_LOCK_SCOPE = "auth.oauth-refresh";

View File

@@ -44,6 +44,20 @@ describe("auth profile path helpers (direct-import coverage attribution)", () =>
expect(resolved.endsWith(path.join("agents", "main", "agent"))).toBe(true);
});
it("resolves the default auth profile store key from an env override", async () => {
const otherStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-path-direct-env-"));
try {
const resolved = resolveAuthProfileStoreKey(undefined, {
...process.env,
OPENCLAW_STATE_DIR: otherStateDir,
});
expect(resolved.startsWith(otherStateDir)).toBe(true);
expect(resolved.endsWith(path.join("agents", "main", "agent"))).toBe(true);
} finally {
await fs.rm(otherStateDir, { recursive: true, force: true });
}
});
it("resolves the display location as a SQLite table target", () => {
const agentDir = path.join(stateDir, "agents", "main", "agent");
const resolved = resolveAuthProfileStoreLocationForDisplay(agentDir, {

View File

@@ -38,8 +38,11 @@ import type {
ProfileUsageStats,
} from "./types.js";
export function authProfileStoreKey(agentDir?: string): string {
return resolveAuthProfileStoreKey(agentDir);
export function authProfileStoreKey(
agentDir?: string,
env: NodeJS.ProcessEnv = process.env,
): string {
return resolveAuthProfileStoreKey(agentDir, env);
}
export type PersistedAuthProfileStoreEntry = {
@@ -889,11 +892,11 @@ function preserveLegacyOAuthRefsForDoctorMigration(
export function loadPersistedAuthProfileStoreEntryFromDatabase(
database: OpenClawStateDatabase,
agentDir?: string,
_options: Pick<OpenClawStateDatabaseOptions, "env"> = {},
options: Pick<OpenClawStateDatabaseOptions, "env"> = {},
): PersistedAuthProfileStoreEntry | null {
const result = readAuthProfileStorePayloadResultFromDatabase(
database,
authProfileStoreKey(agentDir),
authProfileStoreKey(agentDir, options.env),
);
if (!result.exists || result.value === undefined) {
return null;
@@ -907,7 +910,7 @@ export function loadPersistedAuthProfileStoreEntryFromDatabase(
...store,
...mergeAuthProfileState(
coerceAuthProfileState(raw),
loadPersistedAuthProfileStateFromDatabase(database, agentDir),
loadPersistedAuthProfileStateFromDatabase(database, agentDir, options),
),
};
return {
@@ -920,7 +923,10 @@ export function loadPersistedAuthProfileStoreEntry(
agentDir?: string,
options: OpenClawStateDatabaseOptions = {},
): PersistedAuthProfileStoreEntry | null {
const result = readAuthProfileStorePayloadResult(authProfileStoreKey(agentDir), options);
const result = readAuthProfileStorePayloadResult(
authProfileStoreKey(agentDir, options.env),
options,
);
if (!result.exists || result.value === undefined) {
return null;
}
@@ -944,10 +950,10 @@ export function loadPersistedAuthProfileStoreEntry(
export function loadLegacyAuthProfileStoreEntry(
agentDir?: string,
_options: OpenClawStateDatabaseOptions = {},
options: OpenClawStateDatabaseOptions = {},
): PersistedAuthProfileStoreEntry | null {
const authPath = path.join(
resolveAuthProfileStoreAgentDir(agentDir),
resolveAuthProfileStoreAgentDir(agentDir, options.env),
LEGACY_AUTH_PROFILE_FILENAME,
);
const raw = loadJsonFile(authPath);
@@ -984,7 +990,7 @@ export function savePersistedAuthProfileSecretsStore(
env: options.env,
});
writeAuthProfileStorePayload(
authProfileStoreKey(agentDir),
authProfileStoreKey(agentDir, options.env),
payload as unknown as AuthProfilePayloadValue,
options,
);
@@ -995,10 +1001,11 @@ export function savePersistedAuthProfileSecretsStoreInTransaction(
store: AuthProfileSecretsStore,
agentDir?: string,
updatedAt: number = Date.now(),
options: Pick<OpenClawStateDatabaseOptions, "env"> = {},
): void {
writeAuthProfileStorePayloadInTransaction(
database,
authProfileStoreKey(agentDir),
authProfileStoreKey(agentDir, options.env),
store as unknown as AuthProfilePayloadValue,
updatedAt,
);
@@ -1008,5 +1015,6 @@ export function hasPersistedAuthProfileSecretsStore(
agentDir?: string,
options: OpenClawStateDatabaseOptions = {},
): boolean {
return readAuthProfileStorePayloadResult(authProfileStoreKey(agentDir), options).exists;
return readAuthProfileStorePayloadResult(authProfileStoreKey(agentDir, options.env), options)
.exists;
}

View File

@@ -9,12 +9,101 @@ import {
} from "./profiles.js";
import {
clearRuntimeAuthProfileStoreSnapshots,
loadAuthProfileStoreWithoutExternalProfiles,
loadAuthProfileStoreForRuntime,
saveAuthProfileStore,
} from "./store.js";
import type { AuthProfileStore } from "./types.js";
describe("promoteAuthProfileInOrder", () => {
it("uses env-scoped default agent keys when agentDir is omitted", async () => {
const processStateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-process-state-"));
const envStateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-env-state-"));
const previousStateDir = process.env.OPENCLAW_STATE_DIR;
process.env.OPENCLAW_STATE_DIR = processStateDir;
const env = { ...process.env, OPENCLAW_STATE_DIR: envStateDir };
const profileId = "openai-codex:env-default";
try {
saveAuthProfileStore(
{
version: AUTH_STORE_VERSION,
profiles: {
[profileId]: {
type: "token",
provider: "openai-codex",
token: "env-token",
},
},
},
undefined,
{ env },
);
clearRuntimeAuthProfileStoreSnapshots();
const loaded = loadAuthProfileStoreWithoutExternalProfiles(undefined, { env });
expect(loaded.profiles[profileId]).toMatchObject({
type: "token",
provider: "openai-codex",
token: "env-token",
});
expect(fs.existsSync(path.join(envStateDir, "state", "openclaw.sqlite"))).toBe(true);
expect(fs.existsSync(path.join(processStateDir, "state", "openclaw.sqlite"))).toBe(false);
} finally {
if (previousStateDir === undefined) {
delete process.env.OPENCLAW_STATE_DIR;
} else {
process.env.OPENCLAW_STATE_DIR = previousStateDir;
}
fs.rmSync(processStateDir, { recursive: true, force: true });
fs.rmSync(envStateDir, { recursive: true, force: true });
clearRuntimeAuthProfileStoreSnapshots();
}
});
it("reads env-scoped legacy default auth profiles before migration", async () => {
const processStateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-legacy-process-"));
const envStateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-legacy-env-"));
const previousStateDir = process.env.OPENCLAW_STATE_DIR;
process.env.OPENCLAW_STATE_DIR = processStateDir;
const env = { ...process.env, OPENCLAW_STATE_DIR: envStateDir };
const agentDir = path.join(envStateDir, "agents", "main", "agent");
const profileId = "openai-codex:legacy-default";
try {
fs.mkdirSync(agentDir, { recursive: true });
fs.writeFileSync(
path.join(agentDir, "auth-profiles.json"),
`${JSON.stringify({
version: AUTH_STORE_VERSION,
profiles: {
[profileId]: {
type: "token",
provider: "openai-codex",
token: "legacy-env-token",
},
},
})}\n`,
);
const loaded = loadAuthProfileStoreWithoutExternalProfiles(undefined, { env });
expect(loaded.profiles[profileId]).toMatchObject({
type: "token",
provider: "openai-codex",
token: "legacy-env-token",
});
} finally {
if (previousStateDir === undefined) {
delete process.env.OPENCLAW_STATE_DIR;
} else {
process.env.OPENCLAW_STATE_DIR = previousStateDir;
}
fs.rmSync(processStateDir, { recursive: true, force: true });
fs.rmSync(envStateDir, { recursive: true, force: true });
clearRuntimeAuthProfileStoreSnapshots();
}
});
it("moves a relogin profile to the front of an existing per-agent provider order", async () => {
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-order-promote-"));
const agentDir = path.join(stateDir, "agents", "main", "agent");

View File

@@ -42,8 +42,11 @@ const AUTH_FAILURE_REASONS = new Set<AuthProfileFailureReason>([
const AUTH_BLOCKED_REASONS = new Set<AuthProfileBlockedReason>(["subscription_limit"]);
const AUTH_BLOCKED_SOURCES = new Set<AuthProfileBlockedSource>(["codex_rate_limits", "wham"]);
export function authProfileStateKey(agentDir?: string): string {
return resolveAuthProfileStoreKey(agentDir);
export function authProfileStateKey(
agentDir?: string,
env: NodeJS.ProcessEnv = process.env,
): string {
return resolveAuthProfileStoreKey(agentDir, env);
}
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -208,7 +211,7 @@ export function loadPersistedAuthProfileState(
agentDir?: string,
options: OpenClawStateDatabaseOptions = {},
): AuthProfileState {
const key = authProfileStateKey(agentDir);
const key = authProfileStateKey(agentDir, options.env);
const sqliteState = readAuthProfileStatePayloadResult(key, options);
if (sqliteState.exists && sqliteState.value !== undefined) {
return coerceAuthProfileState(sqliteState.value);
@@ -220,8 +223,9 @@ export function loadPersistedAuthProfileState(
export function loadPersistedAuthProfileStateFromDatabase(
database: OpenClawStateDatabase,
agentDir?: string,
options: Pick<OpenClawStateDatabaseOptions, "env"> = {},
): AuthProfileState {
const key = authProfileStateKey(agentDir);
const key = authProfileStateKey(agentDir, options.env);
const sqliteState = readAuthProfileStatePayloadResultFromDatabase(database, key);
if (sqliteState.exists && sqliteState.value !== undefined) {
return coerceAuthProfileState(sqliteState.value);
@@ -262,10 +266,11 @@ export function savePersistedAuthProfileStateInTransaction(
store: AuthProfileState,
agentDir?: string,
updatedAt: number = Date.now(),
options: Pick<OpenClawStateDatabaseOptions, "env"> = {},
): AuthProfileStateStore | null {
return savePersistedAuthProfileStatePayload({
store,
key: authProfileStateKey(agentDir),
key: authProfileStateKey(agentDir, options.env),
write: (key, payload) =>
writeAuthProfileStatePayloadInTransaction(
database,

View File

@@ -346,15 +346,20 @@ function saveAuthProfileStoreInTransaction(
const localStore = buildLocalAuthProfileStoreForSave({ store, agentDir, options });
const previousRaw = readAuthProfileStorePayloadResultFromDatabase(
database,
resolveAuthProfileStoreKey(agentDir),
resolveAuthProfileStoreKey(agentDir, options?.env),
);
const payload = buildPersistedAuthProfileSecretsStore(localStore, undefined, {
agentDir,
env: options?.env,
existingRaw: previousRaw.exists ? previousRaw.value : undefined,
});
savePersistedAuthProfileSecretsStoreInTransaction(database, payload, agentDir);
savePersistedAuthProfileStateInTransaction(database, localStore, agentDir);
const updatedAt = Date.now();
savePersistedAuthProfileSecretsStoreInTransaction(database, payload, agentDir, updatedAt, {
env: options?.env,
});
savePersistedAuthProfileStateInTransaction(database, localStore, agentDir, updatedAt, {
env: options?.env,
});
return localStore;
}
@@ -395,7 +400,7 @@ export async function updateAuthProfileStoreWithLock(params: {
);
if (savedStore) {
writeCachedAuthProfileStore({
storeKey: resolveAuthProfileStoreKey(params.agentDir),
storeKey: resolveAuthProfileStoreKey(params.agentDir, params.env),
authMtimeMs: Date.now(),
store: savedStore,
});
@@ -426,7 +431,7 @@ function loadAuthProfileStoreForAgent(
options?: LoadAuthProfileStoreOptions,
): AuthProfileStore {
const readOnly = options?.readOnly === true;
const storeKey = resolveAuthProfileStoreKey(agentDir);
const storeKey = resolveAuthProfileStoreKey(agentDir, options?.env);
let persisted = loadPersistedAuthProfileStoreEntry(agentDir, { env: options?.env });
let authMtimeMs = persisted?.updatedAt ?? null;
if (!persisted) {
@@ -482,8 +487,8 @@ export function loadAuthProfileStoreForRuntime(
options?: LoadAuthProfileStoreOptions,
): AuthProfileStore {
const store = loadAuthProfileStoreForAgent(agentDir, options);
const storeKey = resolveAuthProfileStoreKey(agentDir);
const mainStoreKey = resolveAuthProfileStoreKey();
const storeKey = resolveAuthProfileStoreKey(agentDir, options?.env);
const mainStoreKey = resolveAuthProfileStoreKey(undefined, options?.env);
const externalCli = resolveExternalCliOverlayOptions(options);
if (!agentDir || storeKey === mainStoreKey) {
return overlayExternalAuthProfiles(store, {
@@ -518,8 +523,8 @@ export function loadAuthProfileStoreWithoutExternalProfiles(
...(options?.env ? { env: options.env } : {}),
};
const store = loadAuthProfileStoreForAgent(agentDir, loadOptions);
const storeKey = resolveAuthProfileStoreKey(agentDir);
const mainStoreKey = resolveAuthProfileStoreKey();
const storeKey = resolveAuthProfileStoreKey(agentDir, options?.env);
const mainStoreKey = resolveAuthProfileStoreKey(undefined, options?.env);
if (!agentDir || storeKey === mainStoreKey) {
return store;
}
@@ -662,7 +667,7 @@ export function saveAuthProfileStore(
agentDir?: string,
options?: SaveAuthProfileStoreOptions,
): void {
const storeKey = resolveAuthProfileStoreKey(agentDir);
const storeKey = resolveAuthProfileStoreKey(agentDir, options?.env);
let updatedAt: number | null = null;
let savedStore = store;
runOpenClawStateWriteTransaction(