fix(matrix): keep cached env accounts discoverable

This commit is contained in:
Gustavo Madeira Santana
2026-04-01 13:02:50 -04:00
parent cd5a499f0a
commit 9aa378355f
5 changed files with 111 additions and 4 deletions

View File

@@ -658,6 +658,7 @@ See [Pairing](/channels/pairing) for the shared DM pairing flow and storage layo
Top-level `channels.matrix` values act as defaults for named accounts unless an account overrides them.
You can scope inherited room entries to one Matrix account with `groups.<room>.account` (or legacy `rooms.<room>.account`).
Entries without `account` stay shared across all Matrix accounts, and entries with `account: "default"` still work when the default account is configured directly on top-level `channels.matrix.*`.
Partial shared auth defaults do not create a separate implicit default account by themselves. OpenClaw only treats Matrix accounts with a usable homeserver plus access-token or user-ID-based auth shape as selectable for implicit routing.
Set `defaultAccount` when you want OpenClaw to prefer one named Matrix account for implicit routing, probing, and CLI operations.
If you configure multiple named accounts, set `defaultAccount` or pass `--account <id>` for CLI commands that rely on implicit account selection.
Pass `--account <id>` to `openclaw matrix verify ...` and `openclaw matrix devices ...` when you want to override that implicit selection for one command.

View File

@@ -83,4 +83,21 @@ describe("Matrix account selection topology", () => {
expect(resolveMatrixDefaultOrOnlyAccountId(cfg, env)).toBe("ops");
expect(requiresExplicitMatrixDefaultAccount(cfg, env)).toBe(false);
});
it("keeps env-backed named accounts that rely on cached credentials", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
},
},
} as CoreConfig;
const env = {
MATRIX_OPS_USER_ID: "@ops:example.org",
} as NodeJS.ProcessEnv;
expect(resolveConfiguredMatrixAccountIds(cfg, env)).toEqual(["ops"]);
expect(resolveMatrixDefaultOrOnlyAccountId(cfg, env)).toBe("ops");
expect(requiresExplicitMatrixDefaultAccount(cfg, env)).toBe(false);
});
});

View File

@@ -78,13 +78,15 @@ function resolveGlobalMatrixEnvStringSources(env: NodeJS.ProcessEnv): MatrixTopo
};
}
function hasReadyResolvedMatrixAuth(values: {
function hasUsableResolvedMatrixAuth(values: {
homeserver: string;
userId: string;
accessToken: string;
password: string;
}): boolean {
return Boolean(values.homeserver && (values.accessToken || (values.userId && values.password)));
// Account discovery must keep homeserver+userId shapes because auth can still
// resolve through cached Matrix credentials even when no fresh token/password
// is present in config or env.
return Boolean(values.homeserver && (values.accessToken || values.userId));
}
function hasReadyEffectiveMatrixAccountSource(params: {
@@ -99,7 +101,7 @@ function hasReadyEffectiveMatrixAccountSource(params: {
channel: resolveMatrixChannelStringSources(params.channel),
globalEnv: resolveGlobalMatrixEnvStringSources(params.env),
});
return hasReadyResolvedMatrixAuth(resolved);
return hasUsableResolvedMatrixAuth(resolved);
}
function hasConfiguredDefaultMatrixAccountSource(params: {

View File

@@ -451,6 +451,22 @@ describe("resolveMatrixConfig", () => {
expect(resolveMatrixAuthContext({ cfg, env }).accountId).toBe("ops");
});
it("keeps implicit selection for env-backed accounts that can use cached credentials", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
},
},
} as CoreConfig;
const env = {
MATRIX_OPS_USER_ID: "@ops:example.org",
} as NodeJS.ProcessEnv;
expect(resolveImplicitMatrixAccountId(cfg, env)).toBe("ops");
expect(resolveMatrixAuthContext({ cfg, env }).accountId).toBe("ops");
});
it("rejects explicit non-default account ids that are neither configured nor scoped in env", () => {
const cfg = {
channels: {
@@ -800,6 +816,43 @@ describe("resolveMatrixAuth", () => {
expect(saveMatrixCredentialsMock).not.toHaveBeenCalled();
});
it("uses cached matching credentials for env-backed named accounts without fresh auth", async () => {
vi.mocked(credentialsReadModule!.loadMatrixCredentials).mockReturnValue({
homeserver: "https://matrix.example.org",
userId: "@ops:example.org",
accessToken: "cached-token",
deviceId: "CACHEDDEVICE",
createdAt: "2026-01-01T00:00:00.000Z",
});
vi.mocked(credentialsReadModule!.credentialsMatchConfig).mockReturnValue(true);
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
},
},
} as CoreConfig;
const env = {
MATRIX_OPS_USER_ID: "@ops:example.org",
} as NodeJS.ProcessEnv;
const auth = await resolveMatrixAuth({
cfg,
env,
accountId: "ops",
});
expect(auth).toMatchObject({
accountId: "ops",
homeserver: "https://matrix.example.org",
userId: "@ops:example.org",
accessToken: "cached-token",
deviceId: "CACHEDDEVICE",
});
expect(saveMatrixCredentialsMock).not.toHaveBeenCalled();
});
it("rejects embedded credentials in Matrix homeserver URLs", async () => {
const cfg = {
channels: {

View File

@@ -148,6 +148,40 @@ describe("matrix credentials storage", () => {
expect(fs.existsSync(currentPath)).toBe(false);
});
it("migrates legacy credentials to the named account when top-level auth is only a shared default", () => {
const stateDir = setupStateDir({
channels: {
matrix: {
accessToken: "shared-token",
accounts: {
ops: {
homeserver: "https://matrix.example.org",
accessToken: "ops-token",
},
},
},
},
});
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: "@ops:example.org",
accessToken: "legacy-token",
createdAt: "2026-03-01T10:00:00.000Z",
}),
);
const loaded = loadMatrixCredentials({}, "ops");
expect(loaded?.accessToken).toBe("legacy-token");
expect(fs.existsSync(legacyPath)).toBe(false);
expect(fs.existsSync(currentPath)).toBe(true);
});
it("clears both current and legacy credential paths", () => {
const stateDir = setupStateDir({
channels: {