Compare commits

...

6 Commits

Author SHA1 Message Date
Gustavo Madeira Santana
897f9cccf8 fix(matrix): harden SecretRef startup fallback openclaw#54980 thanks @kakahu2015 2026-03-27 22:58:53 -04:00
Gustavo Madeira Santana
f30efad1f9 fix(matrix): narrow SecretRef type fallout openclaw#54980 thanks @kakahu2015 2026-03-27 21:38:45 -04:00
Gustavo Madeira Santana
5f82088bc6 fix(matrix): align SecretRef env fallback openclaw#54980 thanks @kakahu2015 2026-03-27 21:38:45 -04:00
Gustavo Madeira Santana
65136e2288 fix(matrix): align SecretRef env fallback 2026-03-27 21:38:44 -04:00
kakahu2015
8db998bf66 tighten SecretRef detection: verify key count and env var name pattern 2026-03-27 21:38:44 -04:00
kakahu2015
c1698b17bf fix(matrix): resolve env SecretRef fallback in clean() for channel startup
When the gateway starts channels before the secrets runtime snapshot is
activated, matrix config fields (accessToken, password) arrive as
unresolved SecretRef objects like {source: 'env', id: 'MATRIX_ACCESS_TOKEN'}.

The clean() function calls normalizeResolvedSecretInputString() which
throws on unresolved refs, causing 'channel startup failed'.

This patch adds an env fallback: if the value is an unresolved env
SecretRef, attempt to resolve it directly from process.env before
validation. This is consistent with how env vars are injected via
systemd Environment directives.
2026-03-27 21:38:43 -04:00
9 changed files with 293 additions and 39 deletions

View File

@@ -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

View File

@@ -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);
});
});

View File

@@ -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(),

View File

@@ -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: {

View File

@@ -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,
});

View File

@@ -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: {

View File

@@ -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);

View File

@@ -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 ?? "";

View File

@@ -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;
};