mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-21 05:32:53 +00:00
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:
@@ -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];
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({");
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
56
src/cli/message-secret-scope.test.ts
Normal file
56
src/cli/message-secret-scope.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
83
src/cli/message-secret-scope.ts
Normal file
83
src/cli/message-secret-scope.ts
Normal 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 } : {}),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user