fix(secrets): scope message SecretRef resolution and harden doctor/status paths (#48728)

* fix(secrets): scope message runtime resolution and harden doctor/status

* docs: align message/doctor/status SecretRef behavior notes

* test(cli): accept scoped targetIds wiring in secret-resolution coverage

* fix(secrets): keep scoped allowedPaths isolation and tighten coverage gate

* fix(secrets): avoid default-account coercion in scoped target selection

* test(doctor): cover inactive telegram secretref inspect path

* docs

Signed-off-by: joshavant <830519+joshavant@users.noreply.github.com>

* changelog

Signed-off-by: joshavant <830519+joshavant@users.noreply.github.com>

---------

Signed-off-by: joshavant <830519+joshavant@users.noreply.github.com>
This commit is contained in:
Josh Avant
2026-03-17 00:01:34 -05:00
committed by GitHub
parent 50c3321d2e
commit da34f81ce2
27 changed files with 854 additions and 76 deletions

View File

@@ -155,6 +155,45 @@ describe("resolveCommandSecretRefsViaGateway", () => {
expect(result.resolvedConfig.talk?.apiKey).toBe("sk-live");
});
it("enforces unresolved checks only for allowed paths when provided", async () => {
callGateway.mockResolvedValueOnce({
assignments: [
{
path: "channels.discord.accounts.ops.token",
pathSegments: ["channels", "discord", "accounts", "ops", "token"],
value: "ops-token",
},
],
diagnostics: [],
});
const result = await resolveCommandSecretRefsViaGateway({
config: {
channels: {
discord: {
accounts: {
ops: {
token: { source: "env", provider: "default", id: "DISCORD_OPS_TOKEN" },
},
chat: {
token: { source: "env", provider: "default", id: "DISCORD_CHAT_TOKEN" },
},
},
},
},
} as OpenClawConfig,
commandName: "message",
targetIds: new Set(["channels.discord.accounts.*.token"]),
allowedPaths: new Set(["channels.discord.accounts.ops.token"]),
});
expect(result.resolvedConfig.channels?.discord?.accounts?.ops?.token).toBe("ops-token");
expect(result.targetStatesByPath).toEqual({
"channels.discord.accounts.ops.token": "resolved_gateway",
});
expect(result.hadUnresolvedTargets).toBe(false);
});
it("fails fast when gateway-backed resolution is unavailable", async () => {
const envKey = "TALK_API_KEY_FAILFAST";
const priorValue = process.env[envKey];

View File

@@ -120,10 +120,14 @@ function targetsRuntimeWebResolution(params: {
function collectConfiguredTargetRefPaths(params: {
config: OpenClawConfig;
targetIds: Set<string>;
allowedPaths?: ReadonlySet<string>;
}): Set<string> {
const defaults = params.config.secrets?.defaults;
const configuredTargetRefPaths = new Set<string>();
for (const target of discoverConfigSecretTargetsByIds(params.config, params.targetIds)) {
if (params.allowedPaths && !params.allowedPaths.has(target.path)) {
continue;
}
const { ref } = resolveSecretInputRef({
value: target.value,
refValue: target.refValue,
@@ -449,11 +453,13 @@ export async function resolveCommandSecretRefsViaGateway(params: {
commandName: string;
targetIds: Set<string>;
mode?: CommandSecretResolutionModeInput;
allowedPaths?: ReadonlySet<string>;
}): Promise<ResolveCommandSecretsResult> {
const mode = normalizeCommandSecretResolutionMode(params.mode);
const configuredTargetRefPaths = collectConfiguredTargetRefPaths({
config: params.config,
targetIds: params.targetIds,
allowedPaths: params.allowedPaths,
});
if (configuredTargetRefPaths.size === 0) {
return {
@@ -498,6 +504,7 @@ export async function resolveCommandSecretRefsViaGateway(params: {
targetIds: params.targetIds,
preflightDiagnostics: preflight.diagnostics,
mode,
allowedPaths: params.allowedPaths,
});
const recoveredLocally = Object.values(fallback.targetStatesByPath).some(
(state) => state === "resolved_local",
@@ -556,6 +563,7 @@ export async function resolveCommandSecretRefsViaGateway(params: {
resolvedConfig,
targetIds: params.targetIds,
inactiveRefPaths,
allowedPaths: params.allowedPaths,
});
let diagnostics = dedupeDiagnostics(parsed.diagnostics);
const targetStatesByPath = buildTargetStatesByPath({

View File

@@ -14,6 +14,13 @@ const SECRET_TARGET_CALLSITES = [
"src/commands/status.scan.ts",
] as const;
function hasSupportedTargetIdsWiring(source: string): boolean {
return (
/targetIds:\s*get[A-Za-z0-9_]+\(\)/m.test(source) ||
/targetIds:\s*scopedTargets\.targetIds/m.test(source)
);
}
describe("command secret resolution coverage", () => {
it.each(SECRET_TARGET_CALLSITES)(
"routes target-id command path through shared gateway resolver: %s",
@@ -21,7 +28,7 @@ describe("command secret resolution coverage", () => {
const absolutePath = path.join(process.cwd(), relativePath);
const source = await fs.readFile(absolutePath, "utf8");
expect(source).toContain("resolveCommandSecretRefsViaGateway");
expect(source).toContain("targetIds: get");
expect(hasSupportedTargetIdsWiring(source)).toBe(true);
expect(source).toContain("resolveCommandSecretRefsViaGateway({");
},
);

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
getAgentRuntimeCommandSecretTargetIds,
getMemoryCommandSecretTargetIds,
getScopedChannelsCommandSecretTargets,
getSecurityAuditCommandSecretTargetIds,
} from "./command-secret-targets.js";
@@ -31,4 +32,83 @@ describe("command secret target ids", () => {
expect(ids.has("gateway.remote.token")).toBe(true);
expect(ids.has("gateway.remote.password")).toBe(true);
});
it("scopes channel targets to the requested channel", () => {
const scoped = getScopedChannelsCommandSecretTargets({
config: {} as never,
channel: "discord",
});
expect(scoped.targetIds.size).toBeGreaterThan(0);
expect([...scoped.targetIds].every((id) => id.startsWith("channels.discord."))).toBe(true);
expect([...scoped.targetIds].some((id) => id.startsWith("channels.telegram."))).toBe(false);
});
it("does not coerce missing accountId to default when channel is scoped", () => {
const scoped = getScopedChannelsCommandSecretTargets({
config: {
channels: {
discord: {
defaultAccount: "ops",
accounts: {
ops: {
token: { source: "env", provider: "default", id: "DISCORD_OPS" },
},
},
},
},
} as never,
channel: "discord",
});
expect(scoped.allowedPaths).toBeUndefined();
expect(scoped.targetIds.size).toBeGreaterThan(0);
expect([...scoped.targetIds].every((id) => id.startsWith("channels.discord."))).toBe(true);
});
it("scopes allowed paths to channel globals + selected account", () => {
const scoped = getScopedChannelsCommandSecretTargets({
config: {
channels: {
discord: {
token: { source: "env", provider: "default", id: "DISCORD_DEFAULT" },
accounts: {
ops: {
token: { source: "env", provider: "default", id: "DISCORD_OPS" },
},
chat: {
token: { source: "env", provider: "default", id: "DISCORD_CHAT" },
},
},
},
},
} as never,
channel: "discord",
accountId: "ops",
});
expect(scoped.allowedPaths).toBeDefined();
expect(scoped.allowedPaths?.has("channels.discord.token")).toBe(true);
expect(scoped.allowedPaths?.has("channels.discord.accounts.ops.token")).toBe(true);
expect(scoped.allowedPaths?.has("channels.discord.accounts.chat.token")).toBe(false);
});
it("keeps account-scoped allowedPaths as an empty set when scoped target paths are absent", () => {
const scoped = getScopedChannelsCommandSecretTargets({
config: {
channels: {
discord: {
accounts: {
ops: { enabled: true },
},
},
},
} as never,
channel: "custom-plugin-channel-without-secret-targets",
accountId: "ops",
});
expect(scoped.allowedPaths).toBeDefined();
expect(scoped.allowedPaths?.size).toBe(0);
});
});

View File

@@ -1,4 +1,9 @@
import { listSecretTargetRegistryEntries } from "../secrets/target-registry.js";
import type { OpenClawConfig } from "../config/config.js";
import { normalizeOptionalAccountId } from "../routing/session-key.js";
import {
discoverConfigSecretTargetsByIds,
listSecretTargetRegistryEntries,
} from "../secrets/target-registry.js";
function idsByPrefix(prefixes: readonly string[]): string[] {
return listSecretTargetRegistryEntries()
@@ -37,6 +42,65 @@ function toTargetIdSet(values: readonly string[]): Set<string> {
return new Set(values);
}
function normalizeScopedChannelId(value?: string | null): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}
function selectChannelTargetIds(channel?: string): Set<string> {
if (!channel) {
return toTargetIdSet(COMMAND_SECRET_TARGETS.channels);
}
return toTargetIdSet(
COMMAND_SECRET_TARGETS.channels.filter((id) => id.startsWith(`channels.${channel}.`)),
);
}
function pathTargetsScopedChannelAccount(params: {
pathSegments: readonly string[];
channel: string;
accountId: string;
}): boolean {
const [root, channelId, accountRoot, accountId] = params.pathSegments;
if (root !== "channels" || channelId !== params.channel) {
return false;
}
if (accountRoot !== "accounts") {
return true;
}
return accountId === params.accountId;
}
export function getScopedChannelsCommandSecretTargets(params: {
config: OpenClawConfig;
channel?: string | null;
accountId?: string | null;
}): {
targetIds: Set<string>;
allowedPaths?: Set<string>;
} {
const channel = normalizeScopedChannelId(params.channel);
const targetIds = selectChannelTargetIds(channel);
const normalizedAccountId = normalizeOptionalAccountId(params.accountId);
if (!channel || !normalizedAccountId) {
return { targetIds };
}
const allowedPaths = new Set<string>();
for (const target of discoverConfigSecretTargetsByIds(params.config, targetIds)) {
if (
pathTargetsScopedChannelAccount({
pathSegments: target.pathSegments,
channel,
accountId: normalizedAccountId,
})
) {
allowedPaths.add(target.path);
}
}
return { targetIds, allowedPaths };
}
export function getMemoryCommandSecretTargetIds(): Set<string> {
return toTargetIdSet(COMMAND_SECRET_TARGETS.memory);
}

View File

@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import { resolveMessageSecretScope } from "./message-secret-scope.js";
describe("resolveMessageSecretScope", () => {
it("prefers explicit channel/account inputs", () => {
expect(
resolveMessageSecretScope({
channel: "Discord",
accountId: "Ops",
}),
).toEqual({
channel: "discord",
accountId: "ops",
});
});
it("infers channel from a prefixed target", () => {
expect(
resolveMessageSecretScope({
target: "telegram:12345",
}),
).toEqual({
channel: "telegram",
});
});
it("infers a shared channel from target arrays", () => {
expect(
resolveMessageSecretScope({
targets: ["discord:one", "discord:two"],
}),
).toEqual({
channel: "discord",
});
});
it("does not infer a channel when target arrays mix channels", () => {
expect(
resolveMessageSecretScope({
targets: ["discord:one", "slack:two"],
}),
).toEqual({});
});
it("uses fallback channel/account when direct inputs are missing", () => {
expect(
resolveMessageSecretScope({
fallbackChannel: "Signal",
fallbackAccountId: "Chat",
}),
).toEqual({
channel: "signal",
accountId: "chat",
});
});
});

View File

@@ -0,0 +1,83 @@
import { normalizeAccountId } from "../routing/session-key.js";
import { isDeliverableMessageChannel, normalizeMessageChannel } from "../utils/message-channel.js";
function resolveScopedChannelCandidate(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const normalized = normalizeMessageChannel(value);
if (!normalized || !isDeliverableMessageChannel(normalized)) {
return undefined;
}
return normalized;
}
function resolveChannelFromTargetValue(target: unknown): string | undefined {
if (typeof target !== "string") {
return undefined;
}
const trimmed = target.trim();
if (!trimmed) {
return undefined;
}
const separator = trimmed.indexOf(":");
if (separator <= 0) {
return undefined;
}
return resolveScopedChannelCandidate(trimmed.slice(0, separator));
}
function resolveChannelFromTargets(targets: unknown): string | undefined {
if (!Array.isArray(targets)) {
return undefined;
}
const seen = new Set<string>();
for (const target of targets) {
const channel = resolveChannelFromTargetValue(target);
if (channel) {
seen.add(channel);
}
}
if (seen.size !== 1) {
return undefined;
}
return [...seen][0];
}
function resolveScopedAccountId(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
if (!trimmed) {
return undefined;
}
return normalizeAccountId(trimmed);
}
export function resolveMessageSecretScope(params: {
channel?: unknown;
target?: unknown;
targets?: unknown;
fallbackChannel?: string | null;
accountId?: unknown;
fallbackAccountId?: string | null;
}): {
channel?: string;
accountId?: string;
} {
const channel =
resolveScopedChannelCandidate(params.channel) ??
resolveChannelFromTargetValue(params.target) ??
resolveChannelFromTargets(params.targets) ??
resolveScopedChannelCandidate(params.fallbackChannel);
const accountId =
resolveScopedAccountId(params.accountId) ??
resolveScopedAccountId(params.fallbackAccountId ?? undefined);
return {
...(channel ? { channel } : {}),
...(accountId ? { accountId } : {}),
};
}