diff --git a/docs/channels/matrix.md b/docs/channels/matrix.md index fd5f99f4945..34965359316 100644 --- a/docs/channels/matrix.md +++ b/docs/channels/matrix.md @@ -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..account` (or legacy `rooms..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 ` for CLI commands that rely on implicit account selection. Pass `--account ` to `openclaw matrix verify ...` and `openclaw matrix devices ...` when you want to override that implicit selection for one command. diff --git a/extensions/matrix/src/account-selection.test.ts b/extensions/matrix/src/account-selection.test.ts index e7ee26a3de3..78317dc2f05 100644 --- a/extensions/matrix/src/account-selection.test.ts +++ b/extensions/matrix/src/account-selection.test.ts @@ -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); + }); }); diff --git a/extensions/matrix/src/account-selection.ts b/extensions/matrix/src/account-selection.ts index 60ab600e1d9..393f97ffe31 100644 --- a/extensions/matrix/src/account-selection.ts +++ b/extensions/matrix/src/account-selection.ts @@ -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: { diff --git a/extensions/matrix/src/matrix/client.test.ts b/extensions/matrix/src/matrix/client.test.ts index b17b9f73194..3efcd6993e3 100644 --- a/extensions/matrix/src/matrix/client.test.ts +++ b/extensions/matrix/src/matrix/client.test.ts @@ -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: { diff --git a/extensions/matrix/src/matrix/credentials.test.ts b/extensions/matrix/src/matrix/credentials.test.ts index 34adac370a6..dbd69125f82 100644 --- a/extensions/matrix/src/matrix/credentials.test.ts +++ b/extensions/matrix/src/matrix/credentials.test.ts @@ -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: {