mirror of https://github.com/openclaw/openclaw.git
matrix: retry credentials after legacy migration race (#60591)
Merged via squash.
Prepared head SHA: e050b39de0
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
parent
af81c437fa
commit
f6f7609b66
|
|
@ -105,6 +105,7 @@ Docs: https://docs.openclaw.ai
|
|||
- Agents/fallback: persist selected fallback overrides before retry attempts start, prefer persisted overrides during live-session reconciliation, and keep provider-scoped auth-profile failover from snapping retries back to stale primary selections.
|
||||
- Agents/MCP: sort MCP tools deterministically by name so the tools block in API requests is stable across turns, preventing unnecessary prompt-cache busting from non-deterministic `listTools()` order. (#58037) Thanks @bcherny.
|
||||
- Infra/json-file: preserve symlink-backed JSON stores and Windows overwrite fallback when atomically saving small sync JSON state files. (#60589) Thanks @gumadeiras.
|
||||
- Matrix/credentials: read the current and legacy credential files directly during migration fallback so concurrent legacy rename races still resolve to the stored credentials. (#60591) Thanks @gumadeiras.
|
||||
|
||||
## 2026.4.2
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,18 @@ export type MatrixStoredCredentials = {
|
|||
lastUsedAt?: string;
|
||||
};
|
||||
|
||||
type MatrixCredentialsSource = "current" | "legacy";
|
||||
|
||||
type MatrixCredentialsFileLoadResult =
|
||||
| {
|
||||
kind: "loaded";
|
||||
source: MatrixCredentialsSource;
|
||||
credentials: MatrixStoredCredentials | null;
|
||||
}
|
||||
| {
|
||||
kind: "missing";
|
||||
};
|
||||
|
||||
function resolveStateDir(env: NodeJS.ProcessEnv): string {
|
||||
try {
|
||||
return getMatrixRuntime().state.resolveStateDir(env, os.homedir);
|
||||
|
|
@ -36,7 +48,7 @@ function resolveStateDir(env: NodeJS.ProcessEnv): string {
|
|||
}
|
||||
}
|
||||
|
||||
function resolveLegacyMatrixCredentialsPath(env: NodeJS.ProcessEnv): string | null {
|
||||
function resolveLegacyMatrixCredentialsPath(env: NodeJS.ProcessEnv): string {
|
||||
return path.join(resolveMatrixCredentialsDir(env), "credentials.json");
|
||||
}
|
||||
|
||||
|
|
@ -76,6 +88,35 @@ function parseMatrixCredentialsFile(filePath: string): MatrixStoredCredentials |
|
|||
return parsed as MatrixStoredCredentials;
|
||||
}
|
||||
|
||||
function loadMatrixCredentialsFile(
|
||||
filePath: string,
|
||||
source: MatrixCredentialsSource,
|
||||
): MatrixCredentialsFileLoadResult {
|
||||
try {
|
||||
return {
|
||||
kind: "loaded",
|
||||
source,
|
||||
credentials: parseMatrixCredentialsFile(filePath),
|
||||
};
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
|
||||
return { kind: "missing" };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function loadLegacyMatrixCredentialsWithCurrentFallback(params: {
|
||||
legacyPath: string;
|
||||
currentPath: string;
|
||||
}): MatrixCredentialsFileLoadResult {
|
||||
const legacy = loadMatrixCredentialsFile(params.legacyPath, "legacy");
|
||||
if (legacy.kind === "loaded") {
|
||||
return legacy;
|
||||
}
|
||||
return loadMatrixCredentialsFile(params.currentPath, "current");
|
||||
}
|
||||
|
||||
export function resolveMatrixCredentialsDir(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
stateDir?: string,
|
||||
|
|
@ -96,30 +137,36 @@ export function loadMatrixCredentials(
|
|||
env: NodeJS.ProcessEnv = process.env,
|
||||
accountId?: string | null,
|
||||
): MatrixStoredCredentials | null {
|
||||
const credPath = resolveMatrixCredentialsPath(env, accountId);
|
||||
const currentPath = resolveMatrixCredentialsPath(env, accountId);
|
||||
try {
|
||||
if (fs.existsSync(credPath)) {
|
||||
return parseMatrixCredentialsFile(credPath);
|
||||
const current = loadMatrixCredentialsFile(currentPath, "current");
|
||||
if (current.kind === "loaded") {
|
||||
return current.credentials;
|
||||
}
|
||||
|
||||
const legacyPath = resolveLegacyMigrationSourcePath(env, accountId);
|
||||
if (!legacyPath || !fs.existsSync(legacyPath)) {
|
||||
if (!legacyPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = parseMatrixCredentialsFile(legacyPath);
|
||||
if (!parsed) {
|
||||
const loaded = loadLegacyMatrixCredentialsWithCurrentFallback({
|
||||
legacyPath,
|
||||
currentPath,
|
||||
});
|
||||
if (loaded.kind !== "loaded" || !loaded.credentials) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(credPath), { recursive: true });
|
||||
fs.renameSync(legacyPath, credPath);
|
||||
} catch {
|
||||
// Keep returning the legacy credentials even if migration fails.
|
||||
if (loaded.source === "legacy") {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(currentPath), { recursive: true });
|
||||
fs.renameSync(legacyPath, currentPath);
|
||||
} catch {
|
||||
// Keep returning the legacy credentials even if migration fails.
|
||||
}
|
||||
}
|
||||
|
||||
return parsed;
|
||||
return loaded.credentials;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -138,9 +185,7 @@ export function clearMatrixCredentials(
|
|||
continue;
|
||||
}
|
||||
try {
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
}
|
||||
fs.unlinkSync(filePath);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ describe("matrix credentials storage", () => {
|
|||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
|
@ -113,6 +114,134 @@ describe("matrix credentials storage", () => {
|
|||
expect(fs.existsSync(currentPath)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns migrated credentials when another process moves the legacy file mid-read", () => {
|
||||
const stateDir = setupStateDir({
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
ops: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const legacyPath = path.join(stateDir, "credentials", "matrix", "credentials.json");
|
||||
const currentPath = resolveMatrixCredentialsPath({}, "ops");
|
||||
fs.mkdirSync(path.dirname(legacyPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
legacyPath,
|
||||
JSON.stringify({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "legacy-token",
|
||||
createdAt: "2026-03-01T10:00:00.000Z",
|
||||
}),
|
||||
);
|
||||
|
||||
const originalReadFileSync = fs.readFileSync.bind(fs);
|
||||
let moved = false;
|
||||
const readFileSpy = vi.spyOn(fs, "readFileSync").mockImplementation(((
|
||||
filePath: fs.PathOrFileDescriptor,
|
||||
options?: fs.ObjectEncodingOptions | BufferEncoding | null,
|
||||
) => {
|
||||
if (!moved && filePath === legacyPath) {
|
||||
fs.renameSync(legacyPath, currentPath);
|
||||
moved = true;
|
||||
}
|
||||
return originalReadFileSync(filePath, options as never);
|
||||
}) as typeof fs.readFileSync);
|
||||
try {
|
||||
const loaded = loadMatrixCredentials({}, "ops");
|
||||
|
||||
expect(loaded?.accessToken).toBe("legacy-token");
|
||||
expect(moved).toBe(true);
|
||||
expect(fs.existsSync(legacyPath)).toBe(false);
|
||||
expect(fs.existsSync(currentPath)).toBe(true);
|
||||
} finally {
|
||||
readFileSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not rename the legacy path after falling back to already-migrated current credentials", () => {
|
||||
const stateDir = setupStateDir({
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
ops: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const legacyPath = path.join(stateDir, "credentials", "matrix", "credentials.json");
|
||||
const currentPath = resolveMatrixCredentialsPath({}, "ops");
|
||||
fs.mkdirSync(path.dirname(legacyPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
legacyPath,
|
||||
JSON.stringify({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "legacy-token",
|
||||
createdAt: "2026-03-01T10:00:00.000Z",
|
||||
}),
|
||||
);
|
||||
|
||||
const originalReadFileSync = fs.readFileSync.bind(fs);
|
||||
const originalRenameSync = fs.renameSync.bind(fs);
|
||||
const renameSpy = vi.spyOn(fs, "renameSync");
|
||||
let migrated = false;
|
||||
const readFileSpy = vi.spyOn(fs, "readFileSync").mockImplementation(((
|
||||
filePath: fs.PathOrFileDescriptor,
|
||||
options?: fs.ObjectEncodingOptions | BufferEncoding | null,
|
||||
) => {
|
||||
if (!migrated && filePath === legacyPath && fs.existsSync(legacyPath)) {
|
||||
originalRenameSync(legacyPath, currentPath);
|
||||
fs.writeFileSync(
|
||||
currentPath,
|
||||
JSON.stringify({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "current-token",
|
||||
createdAt: "2026-03-01T10:00:00.000Z",
|
||||
}),
|
||||
);
|
||||
migrated = true;
|
||||
try {
|
||||
return originalReadFileSync(filePath, options as never);
|
||||
} finally {
|
||||
fs.writeFileSync(
|
||||
legacyPath,
|
||||
JSON.stringify({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "recreated-stale-legacy-token",
|
||||
createdAt: "2026-03-01T10:00:00.000Z",
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
return originalReadFileSync(filePath, options as never);
|
||||
}) as typeof fs.readFileSync);
|
||||
|
||||
try {
|
||||
const loaded = loadMatrixCredentials({}, "ops");
|
||||
|
||||
expect(loaded?.accessToken).toBe("current-token");
|
||||
expect(renameSpy).not.toHaveBeenCalled();
|
||||
expect(
|
||||
JSON.parse(fs.readFileSync(currentPath, "utf8")) as { accessToken: string },
|
||||
).toMatchObject({
|
||||
accessToken: "current-token",
|
||||
});
|
||||
expect(
|
||||
JSON.parse(fs.readFileSync(legacyPath, "utf8")) as { accessToken: string },
|
||||
).toMatchObject({
|
||||
accessToken: "recreated-stale-legacy-token",
|
||||
});
|
||||
} finally {
|
||||
readFileSpy.mockRestore();
|
||||
renameSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not migrate legacy default credentials during a non-selected account read", () => {
|
||||
const stateDir = setupStateDir({
|
||||
channels: {
|
||||
|
|
|
|||
Loading…
Reference in New Issue