mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 14:01:24 +08:00
Compare commits
6 Commits
v2026.5.3
...
fix/matrix
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
897f9cccf8 | ||
|
|
f30efad1f9 | ||
|
|
5f82088bc6 | ||
|
|
65136e2288 | ||
|
|
8db998bf66 | ||
|
|
c1698b17bf |
@@ -103,6 +103,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Plugins/diffs: load bundled Pierre themes without JSON module imports so diff rendering keeps working on newer Node builds. (#45869) thanks @NickHood1984.
|
||||
- Plugins/uninstall: remove owned `channels.<id>` config when uninstalling channel plugins, and keep the uninstall preview aligned with explicit channel ownership so built-in channels and shared keys stay intact. (#35915) Thanks @wbxl2000.
|
||||
- Plugins/Matrix: prefer explicit DM signals when choosing outbound direct rooms and routing unmapped verification summaries, so strict 2-person fallback rooms do not outrank the real DM. (#56076) thanks @gumadeiras
|
||||
- Plugins/Matrix: resolve env-backed `accessToken` and `password` SecretRefs against the active Matrix config env path during startup, and officially accept SecretRef `accessToken` config values. (#54980) thanks @kakahu2015.
|
||||
|
||||
## 2026.3.24
|
||||
|
||||
|
||||
@@ -2,6 +2,14 @@ import { describe, expect, it } from "vitest";
|
||||
import { MatrixConfigSchema } from "./config-schema.js";
|
||||
|
||||
describe("MatrixConfigSchema SecretInput", () => {
|
||||
it("accepts SecretRef accessToken at top-level", () => {
|
||||
const result = MatrixConfigSchema.safeParse({
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: { source: "env", provider: "default", id: "MATRIX_ACCESS_TOKEN" },
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts SecretRef password at top-level", () => {
|
||||
const result = MatrixConfigSchema.safeParse({
|
||||
homeserver: "https://matrix.example.org",
|
||||
@@ -10,17 +18,4 @@ describe("MatrixConfigSchema SecretInput", () => {
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts SecretRef password on account", () => {
|
||||
const result = MatrixConfigSchema.safeParse({
|
||||
accounts: {
|
||||
work: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
password: { source: "env", provider: "default", id: "MATRIX_WORK_PASSWORD" },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,7 +54,7 @@ export const MatrixConfigSchema = z.object({
|
||||
homeserver: z.string().optional(),
|
||||
allowPrivateNetwork: z.boolean().optional(),
|
||||
userId: z.string().optional(),
|
||||
accessToken: z.string().optional(),
|
||||
accessToken: buildSecretInputSchema().optional(),
|
||||
password: buildSecretInputSchema().optional(),
|
||||
deviceId: z.string().optional(),
|
||||
deviceName: z.string().optional(),
|
||||
|
||||
@@ -91,6 +91,122 @@ describe("resolveMatrixConfig", () => {
|
||||
expect(resolved.encryption).toBe(false);
|
||||
});
|
||||
|
||||
it("resolves accessToken SecretRef against the provided env", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://cfg.example.org",
|
||||
accessToken: { source: "env", provider: "default", id: "MATRIX_ACCESS_TOKEN" },
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
defaults: {
|
||||
env: "default",
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_ACCESS_TOKEN: "env-token",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const resolved = resolveMatrixConfig(cfg, env);
|
||||
expect(resolved.accessToken).toBe("env-token");
|
||||
});
|
||||
|
||||
it("resolves password SecretRef against the provided env", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://cfg.example.org",
|
||||
userId: "@cfg:example.org",
|
||||
password: { source: "env", provider: "default", id: "MATRIX_PASSWORD" },
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
defaults: {
|
||||
env: "default",
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_PASSWORD: "env-pass",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const resolved = resolveMatrixConfig(cfg, env);
|
||||
expect(resolved.password).toBe("env-pass");
|
||||
});
|
||||
|
||||
it("resolves account accessToken SecretRef against the provided env", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://ops.example.org",
|
||||
accessToken: { source: "env", provider: "default", id: "MATRIX_OPS_ACCESS_TOKEN" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
defaults: {
|
||||
env: "default",
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const resolved = resolveMatrixConfigForAccount(cfg, "ops", env);
|
||||
expect(resolved.accessToken).toBe("ops-token");
|
||||
});
|
||||
|
||||
it("keeps unresolved accessToken SecretRef errors when env fallback is missing", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://cfg.example.org",
|
||||
accessToken: { source: "env", provider: "default", id: "MATRIX_ACCESS_TOKEN" },
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
defaults: {
|
||||
env: "default",
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
expect(() => resolveMatrixConfig(cfg, {} as NodeJS.ProcessEnv)).toThrow(
|
||||
/channels\.matrix\.accessToken: unresolved SecretRef "env:default:MATRIX_ACCESS_TOKEN"/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not bypass env provider allowlists during startup fallback", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://cfg.example.org",
|
||||
accessToken: { source: "env", provider: "matrix-env", id: "MATRIX_ACCESS_TOKEN" },
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
providers: {
|
||||
"matrix-env": {
|
||||
source: "env",
|
||||
allowlist: ["OTHER_MATRIX_ACCESS_TOKEN"],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
expect(() =>
|
||||
resolveMatrixConfig(cfg, {
|
||||
MATRIX_ACCESS_TOKEN: "env-token",
|
||||
} as NodeJS.ProcessEnv),
|
||||
).toThrow(/not allowlisted in secrets\.providers\.matrix-env\.allowlist/i);
|
||||
});
|
||||
|
||||
it("uses account-scoped env vars for non-default accounts before global env", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { coerceSecretRef } from "openclaw/plugin-sdk/config-runtime";
|
||||
import {
|
||||
requiresExplicitMatrixDefaultAccount,
|
||||
resolveMatrixDefaultOrOnlyAccountId,
|
||||
@@ -27,8 +28,66 @@ import { MatrixClient } from "../sdk.js";
|
||||
import { ensureMatrixSdkLoggingConfigured } from "./logging.js";
|
||||
import type { MatrixAuth, MatrixResolvedConfig } from "./types.js";
|
||||
|
||||
function clean(value: unknown, path: string): string {
|
||||
return normalizeResolvedSecretInputString({ value, path }) ?? "";
|
||||
function readEnvSecretRefFallback(params: {
|
||||
value: unknown;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
config?: Pick<CoreConfig, "secrets">;
|
||||
}): string | undefined {
|
||||
const ref = coerceSecretRef(params.value, params.config?.secrets?.defaults);
|
||||
if (!ref || ref.source !== "env" || !params.env) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const providerConfig = params.config?.secrets?.providers?.[ref.provider];
|
||||
if (providerConfig) {
|
||||
if (providerConfig.source !== "env") {
|
||||
throw new Error(
|
||||
`Secret provider "${ref.provider}" has source "${providerConfig.source}" but ref requests "env".`,
|
||||
);
|
||||
}
|
||||
if (providerConfig.allowlist && !providerConfig.allowlist.includes(ref.id)) {
|
||||
throw new Error(
|
||||
`Environment variable "${ref.id}" is not allowlisted in secrets.providers.${ref.provider}.allowlist.`,
|
||||
);
|
||||
}
|
||||
} else if (ref.provider !== (params.config?.secrets?.defaults?.env?.trim() || "default")) {
|
||||
throw new Error(
|
||||
`Secret provider "${ref.provider}" is not configured (ref: ${ref.source}:${ref.provider}:${ref.id}).`,
|
||||
);
|
||||
}
|
||||
|
||||
const resolved = params.env[ref.id];
|
||||
if (typeof resolved !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const trimmed = resolved.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function clean(
|
||||
value: unknown,
|
||||
path: string,
|
||||
opts?: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
config?: Pick<CoreConfig, "secrets">;
|
||||
allowEnvSecretRefFallback?: boolean;
|
||||
},
|
||||
): string {
|
||||
const normalizedValue = opts?.allowEnvSecretRefFallback
|
||||
? (readEnvSecretRefFallback({
|
||||
value,
|
||||
env: opts.env,
|
||||
config: opts.config,
|
||||
}) ?? value)
|
||||
: value;
|
||||
return (
|
||||
normalizeResolvedSecretInputString({
|
||||
value: normalizedValue,
|
||||
path,
|
||||
defaults: opts?.config?.secrets?.defaults,
|
||||
}) ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
type MatrixEnvConfig = {
|
||||
@@ -52,11 +111,23 @@ function resolveMatrixBaseConfigFieldPath(field: MatrixConfigStringField): strin
|
||||
return `channels.matrix.${field}`;
|
||||
}
|
||||
|
||||
function shouldAllowEnvSecretRefFallback(field: MatrixConfigStringField): boolean {
|
||||
return field === "accessToken" || field === "password";
|
||||
}
|
||||
|
||||
function readMatrixBaseConfigField(
|
||||
matrix: ReturnType<typeof resolveMatrixBaseConfig>,
|
||||
field: MatrixConfigStringField,
|
||||
opts?: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
config?: Pick<CoreConfig, "secrets">;
|
||||
},
|
||||
): string {
|
||||
return clean(matrix[field], resolveMatrixBaseConfigFieldPath(field));
|
||||
return clean(matrix[field], resolveMatrixBaseConfigFieldPath(field), {
|
||||
env: opts?.env,
|
||||
config: opts?.config,
|
||||
allowEnvSecretRefFallback: shouldAllowEnvSecretRefFallback(field),
|
||||
});
|
||||
}
|
||||
|
||||
function readMatrixAccountConfigField(
|
||||
@@ -64,8 +135,16 @@ function readMatrixAccountConfigField(
|
||||
accountId: string,
|
||||
account: Partial<Record<MatrixConfigStringField, unknown>>,
|
||||
field: MatrixConfigStringField,
|
||||
opts?: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
config?: Pick<CoreConfig, "secrets">;
|
||||
},
|
||||
): string {
|
||||
return clean(account[field], resolveMatrixConfigFieldPath(cfg, accountId, field));
|
||||
return clean(account[field], resolveMatrixConfigFieldPath(cfg, accountId, field), {
|
||||
env: opts?.env,
|
||||
config: opts?.config,
|
||||
allowEnvSecretRefFallback: shouldAllowEnvSecretRefFallback(field),
|
||||
});
|
||||
}
|
||||
|
||||
function clampMatrixInitialSyncLimit(value: unknown): number | undefined {
|
||||
@@ -238,18 +317,22 @@ export function resolveMatrixConfig(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): MatrixResolvedConfig {
|
||||
const matrix = resolveMatrixBaseConfig(cfg);
|
||||
const fieldReadOptions = {
|
||||
env,
|
||||
config: cfg,
|
||||
};
|
||||
const defaultScopedEnv = resolveScopedMatrixEnvConfig(DEFAULT_ACCOUNT_ID, env);
|
||||
const globalEnv = resolveGlobalMatrixEnvConfig(env);
|
||||
const resolvedStrings = resolveMatrixAccountStringValues({
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
scopedEnv: defaultScopedEnv,
|
||||
channel: {
|
||||
homeserver: readMatrixBaseConfigField(matrix, "homeserver"),
|
||||
userId: readMatrixBaseConfigField(matrix, "userId"),
|
||||
accessToken: readMatrixBaseConfigField(matrix, "accessToken"),
|
||||
password: readMatrixBaseConfigField(matrix, "password"),
|
||||
deviceId: readMatrixBaseConfigField(matrix, "deviceId"),
|
||||
deviceName: readMatrixBaseConfigField(matrix, "deviceName"),
|
||||
homeserver: readMatrixBaseConfigField(matrix, "homeserver", fieldReadOptions),
|
||||
userId: readMatrixBaseConfigField(matrix, "userId", fieldReadOptions),
|
||||
accessToken: readMatrixBaseConfigField(matrix, "accessToken", fieldReadOptions),
|
||||
password: readMatrixBaseConfigField(matrix, "password", fieldReadOptions),
|
||||
deviceId: readMatrixBaseConfigField(matrix, "deviceId", fieldReadOptions),
|
||||
deviceName: readMatrixBaseConfigField(matrix, "deviceName", fieldReadOptions),
|
||||
},
|
||||
globalEnv,
|
||||
});
|
||||
@@ -277,10 +360,14 @@ export function resolveMatrixConfigForAccount(
|
||||
const matrix = resolveMatrixBaseConfig(cfg);
|
||||
const account = findMatrixAccountConfig(cfg, accountId) ?? {};
|
||||
const normalizedAccountId = normalizeAccountId(accountId);
|
||||
const fieldReadOptions = {
|
||||
env,
|
||||
config: cfg,
|
||||
};
|
||||
const scopedEnv = resolveScopedMatrixEnvConfig(normalizedAccountId, env);
|
||||
const globalEnv = resolveGlobalMatrixEnvConfig(env);
|
||||
const accountField = (field: MatrixConfigStringField) =>
|
||||
readMatrixAccountConfigField(cfg, normalizedAccountId, account, field);
|
||||
readMatrixAccountConfigField(cfg, normalizedAccountId, account, field, fieldReadOptions);
|
||||
const resolvedStrings = resolveMatrixAccountStringValues({
|
||||
accountId: normalizedAccountId,
|
||||
account: {
|
||||
@@ -293,12 +380,12 @@ export function resolveMatrixConfigForAccount(
|
||||
},
|
||||
scopedEnv,
|
||||
channel: {
|
||||
homeserver: readMatrixBaseConfigField(matrix, "homeserver"),
|
||||
userId: readMatrixBaseConfigField(matrix, "userId"),
|
||||
accessToken: readMatrixBaseConfigField(matrix, "accessToken"),
|
||||
password: readMatrixBaseConfigField(matrix, "password"),
|
||||
deviceId: readMatrixBaseConfigField(matrix, "deviceId"),
|
||||
deviceName: readMatrixBaseConfigField(matrix, "deviceName"),
|
||||
homeserver: readMatrixBaseConfigField(matrix, "homeserver", fieldReadOptions),
|
||||
userId: readMatrixBaseConfigField(matrix, "userId", fieldReadOptions),
|
||||
accessToken: readMatrixBaseConfigField(matrix, "accessToken", fieldReadOptions),
|
||||
password: readMatrixBaseConfigField(matrix, "password", fieldReadOptions),
|
||||
deviceId: readMatrixBaseConfigField(matrix, "deviceId", fieldReadOptions),
|
||||
deviceName: readMatrixBaseConfigField(matrix, "deviceName", fieldReadOptions),
|
||||
},
|
||||
globalEnv,
|
||||
});
|
||||
|
||||
@@ -55,6 +55,24 @@ describe("updateMatrixAccountConfig", () => {
|
||||
expect(updated.channels?.["matrix"]?.accounts?.default?.userId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves SecretRef auth inputs when updating config", () => {
|
||||
const updated = updateMatrixAccountConfig({} as CoreConfig, "default", {
|
||||
accessToken: { source: "env", provider: "default", id: "MATRIX_ACCESS_TOKEN" },
|
||||
password: { source: "env", provider: "default", id: "MATRIX_PASSWORD" },
|
||||
});
|
||||
|
||||
expect(updated.channels?.matrix?.accessToken).toEqual({
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "MATRIX_ACCESS_TOKEN",
|
||||
});
|
||||
expect(updated.channels?.matrix?.password).toEqual({
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "MATRIX_PASSWORD",
|
||||
});
|
||||
});
|
||||
|
||||
it("stores and clears Matrix allowBots and allowPrivateNetwork settings", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
|
||||
import { normalizeAccountId } from "../runtime-api.js";
|
||||
import { coerceSecretRef } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { normalizeAccountId, normalizeSecretInputString } from "../runtime-api.js";
|
||||
import type { CoreConfig, MatrixConfig } from "../types.js";
|
||||
import { findMatrixAccountConfig } from "./account-config.js";
|
||||
|
||||
@@ -9,8 +10,8 @@ export type MatrixAccountPatch = {
|
||||
homeserver?: string | null;
|
||||
allowPrivateNetwork?: boolean | null;
|
||||
userId?: string | null;
|
||||
accessToken?: string | null;
|
||||
password?: string | null;
|
||||
accessToken?: MatrixConfig["accessToken"] | null;
|
||||
password?: MatrixConfig["password"] | null;
|
||||
deviceId?: string | null;
|
||||
deviceName?: string | null;
|
||||
avatarUrl?: string | null;
|
||||
@@ -44,6 +45,36 @@ function applyNullableStringField(
|
||||
target[key] = trimmed;
|
||||
}
|
||||
|
||||
function applyNullableSecretInputField(
|
||||
target: Record<string, unknown>,
|
||||
key: "accessToken" | "password",
|
||||
value: MatrixConfig["accessToken"] | MatrixConfig["password"] | null | undefined,
|
||||
defaults?: NonNullable<CoreConfig["secrets"]>["defaults"],
|
||||
): void {
|
||||
if (value === undefined) {
|
||||
return;
|
||||
}
|
||||
if (value === null) {
|
||||
delete target[key];
|
||||
return;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
const normalized = normalizeSecretInputString(value);
|
||||
if (normalized) {
|
||||
target[key] = normalized;
|
||||
} else {
|
||||
delete target[key];
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const ref = coerceSecretRef(value, defaults);
|
||||
if (!ref) {
|
||||
throw new Error(`Invalid Matrix ${key} SecretInput.`);
|
||||
}
|
||||
target[key] = ref;
|
||||
}
|
||||
|
||||
function cloneMatrixDmConfig(dm: MatrixConfig["dm"]): MatrixConfig["dm"] {
|
||||
if (!dm) {
|
||||
return dm;
|
||||
@@ -140,8 +171,13 @@ export function updateMatrixAccountConfig(
|
||||
|
||||
applyNullableStringField(nextAccount, "homeserver", patch.homeserver);
|
||||
applyNullableStringField(nextAccount, "userId", patch.userId);
|
||||
applyNullableStringField(nextAccount, "accessToken", patch.accessToken);
|
||||
applyNullableStringField(nextAccount, "password", patch.password);
|
||||
applyNullableSecretInputField(
|
||||
nextAccount,
|
||||
"accessToken",
|
||||
patch.accessToken,
|
||||
cfg.secrets?.defaults,
|
||||
);
|
||||
applyNullableSecretInputField(nextAccount, "password", patch.password, cfg.secrets?.defaults);
|
||||
applyNullableStringField(nextAccount, "deviceId", patch.deviceId);
|
||||
applyNullableStringField(nextAccount, "deviceName", patch.deviceName);
|
||||
applyNullableStringField(nextAccount, "avatarUrl", patch.avatarUrl);
|
||||
|
||||
@@ -385,7 +385,7 @@ async function runMatrixConfigure(params: {
|
||||
allowPrivateNetwork,
|
||||
});
|
||||
|
||||
let accessToken = existing.accessToken ?? "";
|
||||
let accessToken = typeof existing.accessToken === "string" ? existing.accessToken : "";
|
||||
let password = typeof existing.password === "string" ? existing.password : "";
|
||||
let userId = existing.userId ?? "";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { DmPolicy, GroupPolicy, SecretInput } from "./runtime-api.js";
|
||||
import type { DmPolicy, GroupPolicy, OpenClawConfig, SecretInput } from "./runtime-api.js";
|
||||
export type { DmPolicy, GroupPolicy };
|
||||
|
||||
export type ReplyToMode = "off" | "first" | "all";
|
||||
@@ -73,7 +73,7 @@ export type MatrixConfig = {
|
||||
/** Matrix user id (@user:server). */
|
||||
userId?: string;
|
||||
/** Matrix access token. */
|
||||
accessToken?: string;
|
||||
accessToken?: SecretInput;
|
||||
/** Matrix password (used only to fetch access token). */
|
||||
password?: SecretInput;
|
||||
/** Optional Matrix device id (recommended when using access tokens + E2EE). */
|
||||
@@ -152,5 +152,6 @@ export type CoreConfig = {
|
||||
ackReaction?: string;
|
||||
ackReactionScope?: "group-mentions" | "group-all" | "direct" | "all" | "none" | "off";
|
||||
};
|
||||
secrets?: OpenClawConfig["secrets"];
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user