diff --git a/extensions/matrix/src/account-selection.test.ts b/extensions/matrix/src/account-selection.test.ts new file mode 100644 index 00000000000..e7ee26a3de3 --- /dev/null +++ b/extensions/matrix/src/account-selection.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import { + requiresExplicitMatrixDefaultAccount, + resolveConfiguredMatrixAccountIds, + resolveMatrixDefaultOrOnlyAccountId, +} from "./account-selection.js"; +import type { CoreConfig } from "./types.js"; + +describe("Matrix account selection topology", () => { + it("includes a top-level default account when its auth is actually complete", () => { + const cfg = { + channels: { + matrix: { + accessToken: "default-token", + accounts: { + ops: { + homeserver: "https://matrix.example.org", + accessToken: "ops-token", + }, + }, + }, + }, + } as CoreConfig; + const env = { + MATRIX_HOMESERVER: "https://matrix.example.org", + } as NodeJS.ProcessEnv; + + expect(resolveConfiguredMatrixAccountIds(cfg, env)).toEqual(["default", "ops"]); + expect(resolveMatrixDefaultOrOnlyAccountId(cfg, env)).toBe("default"); + expect(requiresExplicitMatrixDefaultAccount(cfg, env)).toBe(true); + }); + + it("does not materialize a top-level default account from partial shared auth fields", () => { + const cfg = { + channels: { + matrix: { + accessToken: "shared-token", + accounts: { + ops: { + homeserver: "https://matrix.example.org", + accessToken: "ops-token", + }, + }, + }, + }, + } as CoreConfig; + + expect(resolveConfiguredMatrixAccountIds(cfg, {} as NodeJS.ProcessEnv)).toEqual(["ops"]); + expect(resolveMatrixDefaultOrOnlyAccountId(cfg, {} as NodeJS.ProcessEnv)).toBe("ops"); + expect(requiresExplicitMatrixDefaultAccount(cfg, {} as NodeJS.ProcessEnv)).toBe(false); + }); + + it("does not materialize a default env account from partial global auth fields", () => { + const cfg = { + channels: { + matrix: {}, + }, + } as CoreConfig; + const env = { + MATRIX_ACCESS_TOKEN: "shared-token", + MATRIX_OPS_HOMESERVER: "https://matrix.example.org", + MATRIX_OPS_ACCESS_TOKEN: "ops-token", + } as NodeJS.ProcessEnv; + + expect(resolveConfiguredMatrixAccountIds(cfg, env)).toEqual(["ops"]); + expect(resolveMatrixDefaultOrOnlyAccountId(cfg, env)).toBe("ops"); + expect(requiresExplicitMatrixDefaultAccount(cfg, env)).toBe(false); + }); + + it("counts env-backed named accounts when shared homeserver comes from channel config", () => { + const cfg = { + channels: { + matrix: { + homeserver: "https://matrix.example.org", + }, + }, + } as CoreConfig; + const env = { + MATRIX_OPS_ACCESS_TOKEN: "ops-token", + } 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 ddd174e691f..60ab600e1d9 100644 --- a/extensions/matrix/src/account-selection.ts +++ b/extensions/matrix/src/account-selection.ts @@ -11,23 +11,106 @@ import { } from "openclaw/plugin-sdk/account-id"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/secret-input"; -import { listMatrixEnvAccountIds } from "./env-vars.js"; +import { + resolveMatrixAccountStringValues, + type MatrixResolvedStringField, +} from "./auth-precedence.js"; +import { getMatrixScopedEnvVarNames, listMatrixEnvAccountIds } from "./env-vars.js"; function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } -function hasConfiguredDefaultMatrixAccountSource(channel: Record | null): boolean { - if (!channel) { - return false; +type MatrixTopologyStringSources = Partial>; + +function readConfiguredMatrixString(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +function readConfiguredMatrixSecretSource(value: unknown): string { + return hasConfiguredSecretInput(value) ? "configured" : ""; +} + +function resolveMatrixChannelStringSources( + entry: Record | null, +): MatrixTopologyStringSources { + if (!entry) { + return {}; } - // Top-level Matrix config can provide shared defaults for named accounts. Only - // count fields that are actually default-account-specific auth/identity input. - return ( - typeof channel.userId === "string" || - hasConfiguredSecretInput(channel.accessToken) || - hasConfiguredSecretInput(channel.password) - ); + return { + homeserver: readConfiguredMatrixString(entry.homeserver), + userId: readConfiguredMatrixString(entry.userId), + accessToken: readConfiguredMatrixSecretSource(entry.accessToken), + password: readConfiguredMatrixSecretSource(entry.password), + deviceId: readConfiguredMatrixString(entry.deviceId), + deviceName: readConfiguredMatrixString(entry.deviceName), + }; +} + +function readEnvMatrixString(env: NodeJS.ProcessEnv, key: string): string { + const value = env[key]; + return typeof value === "string" ? value.trim() : ""; +} + +function resolveScopedMatrixEnvStringSources( + accountId: string, + env: NodeJS.ProcessEnv, +): MatrixTopologyStringSources { + const keys = getMatrixScopedEnvVarNames(accountId); + return { + homeserver: readEnvMatrixString(env, keys.homeserver), + userId: readEnvMatrixString(env, keys.userId), + accessToken: readEnvMatrixString(env, keys.accessToken), + password: readEnvMatrixString(env, keys.password), + deviceId: readEnvMatrixString(env, keys.deviceId), + deviceName: readEnvMatrixString(env, keys.deviceName), + }; +} + +function resolveGlobalMatrixEnvStringSources(env: NodeJS.ProcessEnv): MatrixTopologyStringSources { + return { + homeserver: readEnvMatrixString(env, "MATRIX_HOMESERVER"), + userId: readEnvMatrixString(env, "MATRIX_USER_ID"), + accessToken: readEnvMatrixString(env, "MATRIX_ACCESS_TOKEN"), + password: readEnvMatrixString(env, "MATRIX_PASSWORD"), + deviceId: readEnvMatrixString(env, "MATRIX_DEVICE_ID"), + deviceName: readEnvMatrixString(env, "MATRIX_DEVICE_NAME"), + }; +} + +function hasReadyResolvedMatrixAuth(values: { + homeserver: string; + userId: string; + accessToken: string; + password: string; +}): boolean { + return Boolean(values.homeserver && (values.accessToken || (values.userId && values.password))); +} + +function hasReadyEffectiveMatrixAccountSource(params: { + channel: Record | null; + accountId: string; + env: NodeJS.ProcessEnv; +}): boolean { + const normalizedAccountId = normalizeAccountId(params.accountId); + const resolved = resolveMatrixAccountStringValues({ + accountId: normalizedAccountId, + scopedEnv: resolveScopedMatrixEnvStringSources(normalizedAccountId, params.env), + channel: resolveMatrixChannelStringSources(params.channel), + globalEnv: resolveGlobalMatrixEnvStringSources(params.env), + }); + return hasReadyResolvedMatrixAuth(resolved); +} + +function hasConfiguredDefaultMatrixAccountSource(params: { + channel: Record | null; + env: NodeJS.ProcessEnv; +}): boolean { + return hasReadyEffectiveMatrixAccountSource({ + channel: params.channel, + accountId: DEFAULT_ACCOUNT_ID, + env: params.env, + }); } export function resolveMatrixChannelConfig(cfg: OpenClawConfig): Record | null { @@ -60,12 +143,15 @@ export function resolveConfiguredMatrixAccountIds( accounts: channel && isRecord(channel.accounts) ? channel.accounts : undefined, normalizeAccountId, }); - if (hasConfiguredDefaultMatrixAccountSource(channel)) { + if (hasConfiguredDefaultMatrixAccountSource({ channel, env })) { configuredAccountIds.push(DEFAULT_ACCOUNT_ID); } + const readyEnvAccountIds = listMatrixEnvAccountIds(env).filter((accountId) => + hasReadyEffectiveMatrixAccountSource({ channel, accountId, env }), + ); return listCombinedAccountIds({ configuredAccountIds, - additionalAccountIds: listMatrixEnvAccountIds(env), + additionalAccountIds: readyEnvAccountIds, fallbackAccountIdWhenEmpty: channel ? DEFAULT_ACCOUNT_ID : undefined, }); } diff --git a/extensions/matrix/src/matrix/client.test.ts b/extensions/matrix/src/matrix/client.test.ts index f08151679d6..b17b9f73194 100644 --- a/extensions/matrix/src/matrix/client.test.ts +++ b/extensions/matrix/src/matrix/client.test.ts @@ -397,6 +397,25 @@ describe("resolveMatrixConfig", () => { expect(resolveMatrixAuthContext({ cfg, env: {} as NodeJS.ProcessEnv }).accountId).toBe("ops"); }); + it("does not materialize a default account from partial top-level auth defaults", () => { + const cfg = { + channels: { + matrix: { + accessToken: "shared-token", + accounts: { + ops: { + homeserver: "https://matrix.ops.example.org", + accessToken: "ops-token", + }, + }, + }, + }, + } as CoreConfig; + + expect(resolveImplicitMatrixAccountId(cfg, {} as NodeJS.ProcessEnv)).toBe("ops"); + expect(resolveMatrixAuthContext({ cfg, env: {} as NodeJS.ProcessEnv }).accountId).toBe("ops"); + }); + it("honors injected env when implicit Matrix account selection becomes ambiguous", () => { const cfg = { channels: { @@ -416,6 +435,22 @@ describe("resolveMatrixConfig", () => { ); }); + it("does not materialize a default env account from partial global auth fields", () => { + const cfg = { + channels: { + matrix: {}, + }, + } as CoreConfig; + const env = { + MATRIX_ACCESS_TOKEN: "shared-token", + MATRIX_OPS_HOMESERVER: "https://matrix.example.org", + MATRIX_OPS_ACCESS_TOKEN: "ops-token", + } 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: {