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:
parent
50c3321d2e
commit
da34f81ce2
@ -31,6 +31,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Browser/existing-session: support `browser.profiles.<name>.userDataDir` so Chrome DevTools MCP can attach to Brave, Edge, and other Chromium-based browsers through their own user data directories. (#48170) Thanks @velvet-shark.
|
||||
- Skills/prompt budget: preserve all registered skills via a compact catalog fallback before dropping entries when the full prompt format exceeds `maxSkillsPromptChars`. (#47553) Thanks @snese.
|
||||
- Plugins/bundles: make enabled bundle MCP servers expose runnable tools in embedded Pi, and default relative bundle MCP launches to the bundle root so marketplace bundles like Context7 work through Pi instead of stopping at config import.
|
||||
- Scope message SecretRef resolution and harden doctor/status paths. (#48728) Thanks @joshavant.
|
||||
|
||||
### Breaking
|
||||
|
||||
|
||||
@ -168,7 +168,7 @@ openclaw pairing approve discord <CODE>
|
||||
|
||||
<Note>
|
||||
Token resolution is account-aware. Config token values win over env fallback. `DISCORD_BOT_TOKEN` is only used for the default account.
|
||||
For advanced outbound calls (message tool/channel actions), an explicit per-call `token` is used for that call. Account policy/retry settings still come from the selected account in the active runtime snapshot.
|
||||
For advanced outbound calls (message tool/channel actions), an explicit per-call `token` is used for that call. This applies to send and read/probe-style actions (for example read/search/fetch/thread/pins/permissions). Account policy/retry settings still come from the selected account in the active runtime snapshot.
|
||||
</Note>
|
||||
|
||||
## Recommended: Set up a guild workspace
|
||||
|
||||
@ -32,6 +32,8 @@ Notes:
|
||||
- Doctor includes a memory-search readiness check and can recommend `openclaw configure --section model` when embedding credentials are missing.
|
||||
- If sandbox mode is enabled but Docker is unavailable, doctor reports a high-signal warning with remediation (`install Docker` or `openclaw config set agents.defaults.sandbox.mode off`).
|
||||
- If `gateway.auth.token`/`gateway.auth.password` are SecretRef-managed and unavailable in the current command path, doctor reports a read-only warning and does not write plaintext fallback credentials.
|
||||
- If channel SecretRef inspection fails in a fix path, doctor continues and reports a warning instead of exiting early.
|
||||
- Telegram `allowFrom` username auto-resolution (`doctor --fix`) requires a resolvable Telegram token in the current command path. If token inspection is unavailable, doctor reports a warning and skips auto-resolution for that pass.
|
||||
|
||||
## macOS: `launchctl` env overrides
|
||||
|
||||
|
||||
@ -50,6 +50,16 @@ Name lookup:
|
||||
- `--dry-run`
|
||||
- `--verbose`
|
||||
|
||||
## SecretRef behavior
|
||||
|
||||
- `openclaw message` resolves supported channel SecretRefs before running the selected action.
|
||||
- Resolution is scoped to the active action target when possible:
|
||||
- channel-scoped when `--channel` is set (or inferred from prefixed targets like `discord:...`)
|
||||
- account-scoped when `--account` is set (channel globals + selected account surfaces)
|
||||
- when `--account` is omitted, OpenClaw does not force a `default` account SecretRef scope
|
||||
- Unresolved SecretRefs on unrelated channels do not block a targeted message action.
|
||||
- If the selected channel/account SecretRef is unresolved, the command fails closed for that action.
|
||||
|
||||
## Actions
|
||||
|
||||
### Core
|
||||
|
||||
@ -27,3 +27,4 @@ Notes:
|
||||
- Read-only status surfaces (`status`, `status --json`, `status --all`) resolve supported SecretRefs for their targeted config paths when possible.
|
||||
- If a supported channel SecretRef is configured but unavailable in the current command path, status stays read-only and reports degraded output instead of crashing. Human output shows warnings such as “configured token unavailable in this command path”, and JSON output includes `secretDiagnostics`.
|
||||
- When command-local SecretRef resolution succeeds, status prefers the resolved snapshot and clears transient “secret unavailable” channel markers from the final output.
|
||||
- `status --all` includes a Secrets overview row and a diagnosis section that summarizes secret diagnostics (truncated for readability) without stopping report generation.
|
||||
|
||||
@ -182,8 +182,8 @@ export async function handleDiscordMessagingAction(
|
||||
}
|
||||
const channelId = resolveChannelId();
|
||||
const permissions = accountId
|
||||
? await fetchChannelPermissionsDiscord(channelId, { accountId })
|
||||
: await fetchChannelPermissionsDiscord(channelId);
|
||||
? await fetchChannelPermissionsDiscord(channelId, { ...cfgOptions, accountId })
|
||||
: await fetchChannelPermissionsDiscord(channelId, cfgOptions);
|
||||
return jsonResult({ ok: true, permissions });
|
||||
}
|
||||
case "fetchMessage": {
|
||||
@ -206,8 +206,8 @@ export async function handleDiscordMessagingAction(
|
||||
);
|
||||
}
|
||||
const message = accountId
|
||||
? await fetchMessageDiscord(channelId, messageId, { accountId })
|
||||
: await fetchMessageDiscord(channelId, messageId);
|
||||
? await fetchMessageDiscord(channelId, messageId, { ...cfgOptions, accountId })
|
||||
: await fetchMessageDiscord(channelId, messageId, cfgOptions);
|
||||
return jsonResult({
|
||||
ok: true,
|
||||
message: normalizeMessage(message),
|
||||
@ -228,8 +228,8 @@ export async function handleDiscordMessagingAction(
|
||||
around: readStringParam(params, "around"),
|
||||
};
|
||||
const messages = accountId
|
||||
? await readMessagesDiscord(channelId, query, { accountId })
|
||||
: await readMessagesDiscord(channelId, query);
|
||||
? await readMessagesDiscord(channelId, query, { ...cfgOptions, accountId })
|
||||
: await readMessagesDiscord(channelId, query, cfgOptions);
|
||||
return jsonResult({
|
||||
ok: true,
|
||||
messages: messages.map((message) => normalizeMessage(message)),
|
||||
@ -338,8 +338,8 @@ export async function handleDiscordMessagingAction(
|
||||
required: true,
|
||||
});
|
||||
const message = accountId
|
||||
? await editMessageDiscord(channelId, messageId, { content }, { accountId })
|
||||
: await editMessageDiscord(channelId, messageId, { content });
|
||||
? await editMessageDiscord(channelId, messageId, { content }, { ...cfgOptions, accountId })
|
||||
: await editMessageDiscord(channelId, messageId, { content }, cfgOptions);
|
||||
return jsonResult({ ok: true, message });
|
||||
}
|
||||
case "deleteMessage": {
|
||||
@ -351,9 +351,9 @@ export async function handleDiscordMessagingAction(
|
||||
required: true,
|
||||
});
|
||||
if (accountId) {
|
||||
await deleteMessageDiscord(channelId, messageId, { accountId });
|
||||
await deleteMessageDiscord(channelId, messageId, { ...cfgOptions, accountId });
|
||||
} else {
|
||||
await deleteMessageDiscord(channelId, messageId);
|
||||
await deleteMessageDiscord(channelId, messageId, cfgOptions);
|
||||
}
|
||||
return jsonResult({ ok: true });
|
||||
}
|
||||
@ -375,8 +375,8 @@ export async function handleDiscordMessagingAction(
|
||||
appliedTags: appliedTags ?? undefined,
|
||||
};
|
||||
const thread = accountId
|
||||
? await createThreadDiscord(channelId, payload, { accountId })
|
||||
: await createThreadDiscord(channelId, payload);
|
||||
? await createThreadDiscord(channelId, payload, { ...cfgOptions, accountId })
|
||||
: await createThreadDiscord(channelId, payload, cfgOptions);
|
||||
return jsonResult({ ok: true, thread });
|
||||
}
|
||||
case "threadList": {
|
||||
@ -399,15 +399,18 @@ export async function handleDiscordMessagingAction(
|
||||
before,
|
||||
limit,
|
||||
},
|
||||
{ accountId },
|
||||
{ ...cfgOptions, accountId },
|
||||
)
|
||||
: await listThreadsDiscord({
|
||||
guildId,
|
||||
channelId,
|
||||
includeArchived,
|
||||
before,
|
||||
limit,
|
||||
});
|
||||
: await listThreadsDiscord(
|
||||
{
|
||||
guildId,
|
||||
channelId,
|
||||
includeArchived,
|
||||
before,
|
||||
limit,
|
||||
},
|
||||
cfgOptions,
|
||||
);
|
||||
return jsonResult({ ok: true, threads });
|
||||
}
|
||||
case "threadReply": {
|
||||
@ -438,9 +441,9 @@ export async function handleDiscordMessagingAction(
|
||||
required: true,
|
||||
});
|
||||
if (accountId) {
|
||||
await pinMessageDiscord(channelId, messageId, { accountId });
|
||||
await pinMessageDiscord(channelId, messageId, { ...cfgOptions, accountId });
|
||||
} else {
|
||||
await pinMessageDiscord(channelId, messageId);
|
||||
await pinMessageDiscord(channelId, messageId, cfgOptions);
|
||||
}
|
||||
return jsonResult({ ok: true });
|
||||
}
|
||||
@ -453,9 +456,9 @@ export async function handleDiscordMessagingAction(
|
||||
required: true,
|
||||
});
|
||||
if (accountId) {
|
||||
await unpinMessageDiscord(channelId, messageId, { accountId });
|
||||
await unpinMessageDiscord(channelId, messageId, { ...cfgOptions, accountId });
|
||||
} else {
|
||||
await unpinMessageDiscord(channelId, messageId);
|
||||
await unpinMessageDiscord(channelId, messageId, cfgOptions);
|
||||
}
|
||||
return jsonResult({ ok: true });
|
||||
}
|
||||
@ -465,8 +468,8 @@ export async function handleDiscordMessagingAction(
|
||||
}
|
||||
const channelId = resolveChannelId();
|
||||
const pins = accountId
|
||||
? await listPinsDiscord(channelId, { accountId })
|
||||
: await listPinsDiscord(channelId);
|
||||
? await listPinsDiscord(channelId, { ...cfgOptions, accountId })
|
||||
: await listPinsDiscord(channelId, cfgOptions);
|
||||
return jsonResult({ ok: true, pins: pins.map((pin) => normalizeMessage(pin)) });
|
||||
}
|
||||
case "searchMessages": {
|
||||
@ -495,15 +498,18 @@ export async function handleDiscordMessagingAction(
|
||||
authorIds: authorIdList.length ? authorIdList : undefined,
|
||||
limit,
|
||||
},
|
||||
{ accountId },
|
||||
{ ...cfgOptions, accountId },
|
||||
)
|
||||
: await searchMessagesDiscord({
|
||||
guildId,
|
||||
content,
|
||||
channelIds: channelIdList.length ? channelIdList : undefined,
|
||||
authorIds: authorIdList.length ? authorIdList : undefined,
|
||||
limit,
|
||||
});
|
||||
: await searchMessagesDiscord(
|
||||
{
|
||||
guildId,
|
||||
content,
|
||||
channelIds: channelIdList.length ? channelIdList : undefined,
|
||||
authorIds: authorIdList.length ? authorIdList : undefined,
|
||||
limit,
|
||||
},
|
||||
cfgOptions,
|
||||
);
|
||||
if (!results || typeof results !== "object") {
|
||||
return jsonResult({ ok: true, results });
|
||||
}
|
||||
|
||||
@ -211,6 +211,24 @@ describe("handleDiscordMessagingAction", () => {
|
||||
expect(payload.messages[0].timestampUtc).toBe(new Date(expectedMs).toISOString());
|
||||
});
|
||||
|
||||
it("threads provided cfg into readMessages calls", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
discord: {
|
||||
token: "token",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
await handleDiscordMessagingAction(
|
||||
"readMessages",
|
||||
{ channelId: "C1" },
|
||||
enableAllActions,
|
||||
{},
|
||||
cfg,
|
||||
);
|
||||
expect(readMessagesDiscord).toHaveBeenCalledWith("C1", expect.any(Object), { cfg });
|
||||
});
|
||||
|
||||
it("adds normalized timestamps to fetchMessage payloads", async () => {
|
||||
fetchMessageDiscord.mockResolvedValueOnce({
|
||||
id: "1",
|
||||
@ -229,6 +247,24 @@ describe("handleDiscordMessagingAction", () => {
|
||||
expect(payload.message?.timestampUtc).toBe(new Date(expectedMs).toISOString());
|
||||
});
|
||||
|
||||
it("threads provided cfg into fetchMessage calls", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
discord: {
|
||||
token: "token",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
await handleDiscordMessagingAction(
|
||||
"fetchMessage",
|
||||
{ guildId: "G1", channelId: "C1", messageId: "M1" },
|
||||
enableAllActions,
|
||||
{},
|
||||
cfg,
|
||||
);
|
||||
expect(fetchMessageDiscord).toHaveBeenCalledWith("C1", "M1", { cfg });
|
||||
});
|
||||
|
||||
it("adds normalized timestamps to listPins payloads", async () => {
|
||||
listPinsDiscord.mockResolvedValueOnce([{ id: "1", timestamp: "2026-01-15T12:00:00.000Z" }]);
|
||||
|
||||
@ -338,12 +374,17 @@ describe("handleDiscordMessagingAction", () => {
|
||||
},
|
||||
enableAllActions,
|
||||
);
|
||||
expect(createThreadDiscord).toHaveBeenCalledWith("C1", {
|
||||
name: "Forum thread",
|
||||
messageId: undefined,
|
||||
autoArchiveMinutes: undefined,
|
||||
content: "Initial forum post body",
|
||||
});
|
||||
expect(createThreadDiscord).toHaveBeenCalledWith(
|
||||
"C1",
|
||||
{
|
||||
name: "Forum thread",
|
||||
messageId: undefined,
|
||||
autoArchiveMinutes: undefined,
|
||||
content: "Initial forum post body",
|
||||
appliedTags: undefined,
|
||||
},
|
||||
{},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ChannelMessageCapability } from "../../channels/plugins/message-capabilities.js";
|
||||
import type { ChannelMessageActionName, ChannelPlugin } from "../../channels/plugins/types.js";
|
||||
import type { MessageActionRunResult } from "../../infra/outbound/message-action-runner.js";
|
||||
@ -8,6 +8,11 @@ import { createMessageTool } from "./message-tool.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
runMessageAction: vi.fn(),
|
||||
loadConfig: vi.fn(() => ({})),
|
||||
resolveCommandSecretRefsViaGateway: vi.fn(async ({ config }: { config: unknown }) => ({
|
||||
resolvedConfig: config,
|
||||
diagnostics: [],
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("../../infra/outbound/message-action-runner.js", async () => {
|
||||
@ -20,6 +25,18 @@ vi.mock("../../infra/outbound/message-action-runner.js", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../config/config.js")>();
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: mocks.loadConfig,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../cli/command-secret-gateway.js", () => ({
|
||||
resolveCommandSecretRefsViaGateway: mocks.resolveCommandSecretRefsViaGateway,
|
||||
}));
|
||||
|
||||
function mockSendResult(overrides: { channel?: string; to?: string } = {}) {
|
||||
mocks.runMessageAction.mockClear();
|
||||
mocks.runMessageAction.mockResolvedValue({
|
||||
@ -41,6 +58,15 @@ function getActionEnum(properties: Record<string, unknown>) {
|
||||
return (properties.action as { enum?: string[] } | undefined)?.enum ?? [];
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.runMessageAction.mockReset();
|
||||
mocks.loadConfig.mockReset().mockReturnValue({});
|
||||
mocks.resolveCommandSecretRefsViaGateway.mockReset().mockImplementation(async ({ config }) => ({
|
||||
resolvedConfig: config,
|
||||
diagnostics: [],
|
||||
}));
|
||||
});
|
||||
|
||||
function createChannelPlugin(params: {
|
||||
id: string;
|
||||
label: string;
|
||||
@ -101,6 +127,49 @@ async function executeSend(params: {
|
||||
| undefined;
|
||||
}
|
||||
|
||||
describe("message tool secret scoping", () => {
|
||||
it("scopes command-time secret resolution to the selected channel/account", async () => {
|
||||
mockSendResult({ channel: "discord", to: "discord:123" });
|
||||
mocks.loadConfig.mockReturnValue({
|
||||
channels: {
|
||||
discord: {
|
||||
token: { source: "env", provider: "default", id: "DISCORD_TOKEN" },
|
||||
accounts: {
|
||||
ops: { token: { source: "env", provider: "default", id: "DISCORD_OPS_TOKEN" } },
|
||||
chat: { token: { source: "env", provider: "default", id: "DISCORD_CHAT_TOKEN" } },
|
||||
},
|
||||
},
|
||||
slack: {
|
||||
botToken: { source: "env", provider: "default", id: "SLACK_BOT_TOKEN" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const tool = createMessageTool({
|
||||
currentChannelProvider: "discord",
|
||||
agentAccountId: "ops",
|
||||
});
|
||||
|
||||
await tool.execute("1", {
|
||||
action: "send",
|
||||
target: "channel:123",
|
||||
message: "hi",
|
||||
});
|
||||
|
||||
const secretResolveCall = mocks.resolveCommandSecretRefsViaGateway.mock.calls[0]?.[0] as {
|
||||
targetIds?: Set<string>;
|
||||
allowedPaths?: Set<string>;
|
||||
};
|
||||
expect(secretResolveCall.targetIds).toBeInstanceOf(Set);
|
||||
expect(
|
||||
[...(secretResolveCall.targetIds ?? [])].every((id) => id.startsWith("channels.discord.")),
|
||||
).toBe(true);
|
||||
expect(secretResolveCall.allowedPaths).toEqual(
|
||||
new Set(["channels.discord.token", "channels.discord.accounts.ops.token"]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("message tool agent routing", () => {
|
||||
it("derives agentId from the session key", async () => {
|
||||
mockSendResult();
|
||||
|
||||
@ -12,7 +12,8 @@ import {
|
||||
type ChannelMessageActionName,
|
||||
} from "../../channels/plugins/types.js";
|
||||
import { resolveCommandSecretRefsViaGateway } from "../../cli/command-secret-gateway.js";
|
||||
import { getChannelsCommandSecretTargetIds } from "../../cli/command-secret-targets.js";
|
||||
import { getScopedChannelsCommandSecretTargets } from "../../cli/command-secret-targets.js";
|
||||
import { resolveMessageSecretScope } from "../../cli/message-secret-scope.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { loadConfig } from "../../config/config.js";
|
||||
import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "../../gateway/protocol/client-info.js";
|
||||
@ -820,19 +821,35 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
|
||||
}
|
||||
}
|
||||
|
||||
const cfg = options?.config
|
||||
? options.config
|
||||
: (
|
||||
await resolveCommandSecretRefsViaGateway({
|
||||
config: loadConfig(),
|
||||
commandName: "tools.message",
|
||||
targetIds: getChannelsCommandSecretTargetIds(),
|
||||
mode: "enforce_resolved",
|
||||
})
|
||||
).resolvedConfig;
|
||||
const action = readStringParam(params, "action", {
|
||||
required: true,
|
||||
}) as ChannelMessageActionName;
|
||||
let cfg = options?.config;
|
||||
if (!cfg) {
|
||||
const loadedRaw = loadConfig();
|
||||
const scope = resolveMessageSecretScope({
|
||||
channel: params.channel,
|
||||
target: params.target,
|
||||
targets: params.targets,
|
||||
fallbackChannel: options?.currentChannelProvider,
|
||||
accountId: params.accountId,
|
||||
fallbackAccountId: agentAccountId,
|
||||
});
|
||||
const scopedTargets = getScopedChannelsCommandSecretTargets({
|
||||
config: loadedRaw,
|
||||
channel: scope.channel,
|
||||
accountId: scope.accountId,
|
||||
});
|
||||
cfg = (
|
||||
await resolveCommandSecretRefsViaGateway({
|
||||
config: loadedRaw,
|
||||
commandName: "tools.message",
|
||||
targetIds: scopedTargets.targetIds,
|
||||
...(scopedTargets.allowedPaths ? { allowedPaths: scopedTargets.allowedPaths } : {}),
|
||||
mode: "enforce_resolved",
|
||||
})
|
||||
).resolvedConfig;
|
||||
}
|
||||
const requireExplicitTarget = options?.requireExplicitTarget === true;
|
||||
if (requireExplicitTarget && actionNeedsExplicitTarget(action)) {
|
||||
const explicitTarget =
|
||||
|
||||
@ -1,13 +1,16 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { setActivePluginRegistry } from "../../plugins/runtime.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import {
|
||||
createChannelTestPluginBase,
|
||||
createTestRegistry,
|
||||
} from "../../test-utils/channel-plugins.js";
|
||||
import {
|
||||
__testing,
|
||||
channelSupportsMessageCapability,
|
||||
channelSupportsMessageCapabilityForChannel,
|
||||
listChannelMessageActions,
|
||||
listChannelMessageCapabilities,
|
||||
listChannelMessageCapabilitiesForChannel,
|
||||
} from "./message-actions.js";
|
||||
@ -56,8 +59,12 @@ function activateMessageActionTestRegistry() {
|
||||
}
|
||||
|
||||
describe("message action capability checks", () => {
|
||||
const errorSpy = vi.spyOn(defaultRuntime, "error").mockImplementation(() => undefined);
|
||||
|
||||
afterEach(() => {
|
||||
setActivePluginRegistry(emptyRegistry);
|
||||
__testing.resetLoggedMessageActionErrors();
|
||||
errorSpy.mockClear();
|
||||
});
|
||||
|
||||
it("aggregates capabilities across plugins", () => {
|
||||
@ -122,4 +129,36 @@ describe("message action capability checks", () => {
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("skips crashing action/capability discovery paths and logs once", () => {
|
||||
const crashingPlugin: ChannelPlugin = {
|
||||
...createChannelTestPluginBase({
|
||||
id: "discord",
|
||||
label: "Discord",
|
||||
capabilities: { chatTypes: ["direct", "group"] },
|
||||
config: {
|
||||
listAccountIds: () => ["default"],
|
||||
},
|
||||
}),
|
||||
actions: {
|
||||
listActions: () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
getCapabilities: () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
},
|
||||
};
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([{ pluginId: "discord", source: "test", plugin: crashingPlugin }]),
|
||||
);
|
||||
|
||||
expect(listChannelMessageActions({} as OpenClawConfig)).toEqual(["send", "broadcast"]);
|
||||
expect(listChannelMessageCapabilities({} as OpenClawConfig)).toEqual([]);
|
||||
expect(errorSpy).toHaveBeenCalledTimes(2);
|
||||
|
||||
expect(listChannelMessageActions({} as OpenClawConfig)).toEqual(["send", "broadcast"]);
|
||||
expect(listChannelMessageCapabilities({} as OpenClawConfig)).toEqual([]);
|
||||
expect(errorSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { getChannelPlugin, listChannelPlugins } from "./index.js";
|
||||
import type { ChannelMessageCapability } from "./message-capabilities.js";
|
||||
import type { ChannelMessageActionContext, ChannelMessageActionName } from "./types.js";
|
||||
@ -16,13 +17,54 @@ function requiresTrustedRequesterSender(ctx: ChannelMessageActionContext): boole
|
||||
);
|
||||
}
|
||||
|
||||
const loggedMessageActionErrors = new Set<string>();
|
||||
|
||||
function logMessageActionError(params: {
|
||||
pluginId: string;
|
||||
operation: "listActions" | "getCapabilities";
|
||||
error: unknown;
|
||||
}) {
|
||||
const message = params.error instanceof Error ? params.error.message : String(params.error);
|
||||
const key = `${params.pluginId}:${params.operation}:${message}`;
|
||||
if (loggedMessageActionErrors.has(key)) {
|
||||
return;
|
||||
}
|
||||
loggedMessageActionErrors.add(key);
|
||||
const stack = params.error instanceof Error && params.error.stack ? params.error.stack : null;
|
||||
defaultRuntime.error?.(
|
||||
`[message-actions] ${params.pluginId}.actions.${params.operation} failed: ${stack ?? message}`,
|
||||
);
|
||||
}
|
||||
|
||||
function runListActionsSafely(params: {
|
||||
pluginId: string;
|
||||
cfg: OpenClawConfig;
|
||||
listActions: NonNullable<ChannelActions["listActions"]>;
|
||||
}): ChannelMessageActionName[] {
|
||||
try {
|
||||
const listed = params.listActions({ cfg: params.cfg });
|
||||
return Array.isArray(listed) ? listed : [];
|
||||
} catch (error) {
|
||||
logMessageActionError({
|
||||
pluginId: params.pluginId,
|
||||
operation: "listActions",
|
||||
error,
|
||||
});
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function listChannelMessageActions(cfg: OpenClawConfig): ChannelMessageActionName[] {
|
||||
const actions = new Set<ChannelMessageActionName>(["send", "broadcast"]);
|
||||
for (const plugin of listChannelPlugins()) {
|
||||
const list = plugin.actions?.listActions?.({ cfg });
|
||||
if (!list) {
|
||||
if (!plugin.actions?.listActions) {
|
||||
continue;
|
||||
}
|
||||
const list = runListActionsSafely({
|
||||
pluginId: plugin.id,
|
||||
cfg,
|
||||
listActions: plugin.actions.listActions,
|
||||
});
|
||||
for (const action of list) {
|
||||
actions.add(action);
|
||||
}
|
||||
@ -30,11 +72,21 @@ export function listChannelMessageActions(cfg: OpenClawConfig): ChannelMessageAc
|
||||
return Array.from(actions);
|
||||
}
|
||||
|
||||
function listCapabilities(
|
||||
actions: ChannelActions,
|
||||
cfg: OpenClawConfig,
|
||||
): readonly ChannelMessageCapability[] {
|
||||
return actions.getCapabilities?.({ cfg }) ?? [];
|
||||
function listCapabilities(params: {
|
||||
pluginId: string;
|
||||
actions: ChannelActions;
|
||||
cfg: OpenClawConfig;
|
||||
}): readonly ChannelMessageCapability[] {
|
||||
try {
|
||||
return params.actions.getCapabilities?.({ cfg: params.cfg }) ?? [];
|
||||
} catch (error) {
|
||||
logMessageActionError({
|
||||
pluginId: params.pluginId,
|
||||
operation: "getCapabilities",
|
||||
error,
|
||||
});
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function listChannelMessageCapabilities(cfg: OpenClawConfig): ChannelMessageCapability[] {
|
||||
@ -43,7 +95,11 @@ export function listChannelMessageCapabilities(cfg: OpenClawConfig): ChannelMess
|
||||
if (!plugin.actions) {
|
||||
continue;
|
||||
}
|
||||
for (const capability of listCapabilities(plugin.actions, cfg)) {
|
||||
for (const capability of listCapabilities({
|
||||
pluginId: plugin.id,
|
||||
actions: plugin.actions,
|
||||
cfg,
|
||||
})) {
|
||||
capabilities.add(capability);
|
||||
}
|
||||
}
|
||||
@ -58,7 +114,15 @@ export function listChannelMessageCapabilitiesForChannel(params: {
|
||||
return [];
|
||||
}
|
||||
const plugin = getChannelPlugin(params.channel as Parameters<typeof getChannelPlugin>[0]);
|
||||
return plugin?.actions ? Array.from(listCapabilities(plugin.actions, params.cfg)) : [];
|
||||
return plugin?.actions
|
||||
? Array.from(
|
||||
listCapabilities({
|
||||
pluginId: plugin.id,
|
||||
actions: plugin.actions,
|
||||
cfg: params.cfg,
|
||||
}),
|
||||
)
|
||||
: [];
|
||||
}
|
||||
|
||||
export function channelSupportsMessageCapability(
|
||||
@ -95,3 +159,9 @@ export async function dispatchChannelMessageAction(
|
||||
}
|
||||
return await plugin.actions.handleAction(ctx);
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
resetLoggedMessageActionErrors() {
|
||||
loggedMessageActionErrors.clear();
|
||||
},
|
||||
};
|
||||
|
||||
@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
@ -387,6 +387,61 @@ describe("doctor config flow", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("warns and continues when Telegram account inspection hits inactive SecretRef surfaces", async () => {
|
||||
const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {});
|
||||
const fetchSpy = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
try {
|
||||
const result = await runDoctorConfigWithInput({
|
||||
repair: true,
|
||||
config: {
|
||||
secrets: {
|
||||
providers: {
|
||||
default: { source: "env" },
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
telegram: {
|
||||
accounts: {
|
||||
inactive: {
|
||||
enabled: false,
|
||||
botToken: { source: "env", provider: "default", id: "TELEGRAM_BOT_TOKEN" },
|
||||
allowFrom: ["@testuser"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
run: loadAndMaybeMigrateDoctorConfig,
|
||||
});
|
||||
|
||||
const cfg = result.cfg as {
|
||||
channels?: {
|
||||
telegram?: {
|
||||
accounts?: Record<string, { allowFrom?: string[] }>;
|
||||
};
|
||||
};
|
||||
};
|
||||
expect(cfg.channels?.telegram?.accounts?.inactive?.allowFrom).toEqual(["@testuser"]);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(
|
||||
noteSpy.mock.calls.some((call) =>
|
||||
String(call[0]).includes("Telegram account inactive: failed to inspect bot token"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
noteSpy.mock.calls.some((call) =>
|
||||
String(call[0]).includes(
|
||||
"Telegram allowFrom contains @username entries, but no Telegram bot token is configured",
|
||||
),
|
||||
),
|
||||
).toBe(true);
|
||||
} finally {
|
||||
noteSpy.mockRestore();
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it("converts numeric discord ids to strings on repair", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const configDir = path.join(home, ".openclaw");
|
||||
|
||||
@ -40,6 +40,7 @@ import {
|
||||
normalizeAccountId,
|
||||
normalizeOptionalAccountId,
|
||||
} from "../routing/session-key.js";
|
||||
import { describeUnknownError } from "../secrets/shared.js";
|
||||
import {
|
||||
isDiscordMutableAllowEntry,
|
||||
isGoogleChatMutableAllowEntry,
|
||||
@ -334,10 +335,23 @@ async function maybeRepairTelegramAllowFromUsernames(cfg: OpenClawConfig): Promi
|
||||
const inspected = inspectTelegramAccount({ cfg, accountId });
|
||||
return inspected.enabled && inspected.tokenStatus === "configured_unavailable";
|
||||
});
|
||||
const tokenResolutionWarnings: string[] = [];
|
||||
const tokens = Array.from(
|
||||
new Set(
|
||||
listTelegramAccountIds(resolvedConfig)
|
||||
.map((accountId) => resolveTelegramAccount({ cfg: resolvedConfig, accountId }))
|
||||
.map((accountId) => {
|
||||
try {
|
||||
return resolveTelegramAccount({ cfg: resolvedConfig, accountId });
|
||||
} catch (error) {
|
||||
tokenResolutionWarnings.push(
|
||||
`- Telegram account ${accountId}: failed to inspect bot token (${describeUnknownError(error)}).`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((account): account is NonNullable<ReturnType<typeof resolveTelegramAccount>> =>
|
||||
Boolean(account),
|
||||
)
|
||||
.map((account) => (account.tokenSource === "none" ? "" : account.token))
|
||||
.map((token) => token.trim())
|
||||
.filter(Boolean),
|
||||
@ -348,6 +362,7 @@ async function maybeRepairTelegramAllowFromUsernames(cfg: OpenClawConfig): Promi
|
||||
return {
|
||||
config: cfg,
|
||||
changes: [
|
||||
...tokenResolutionWarnings,
|
||||
hasConfiguredUnavailableToken
|
||||
? `- Telegram allowFrom contains @username entries, but configured Telegram bot credentials are unavailable in this command path; cannot auto-resolve (start the gateway or make the secret source available, then rerun doctor --fix).`
|
||||
: `- Telegram allowFrom contains @username entries, but no Telegram bot token is configured; cannot auto-resolve (run setup or replace with numeric sender IDs).`,
|
||||
|
||||
@ -301,6 +301,13 @@ describe("messageCommand", () => {
|
||||
commandName: "message",
|
||||
}),
|
||||
);
|
||||
const secretResolveCall = resolveCommandSecretRefsViaGateway.mock.calls[0]?.[0] as {
|
||||
targetIds?: Set<string>;
|
||||
};
|
||||
expect(secretResolveCall.targetIds).toBeInstanceOf(Set);
|
||||
expect(
|
||||
[...(secretResolveCall.targetIds ?? [])].every((id) => id.startsWith("channels.telegram.")),
|
||||
).toBe(true);
|
||||
expect(handleTelegramAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ action: "send", to: "123456", accountId: undefined }),
|
||||
resolvedConfig,
|
||||
|
||||
@ -3,7 +3,8 @@ import {
|
||||
type ChannelMessageActionName,
|
||||
} from "../channels/plugins/types.js";
|
||||
import { resolveCommandSecretRefsViaGateway } from "../cli/command-secret-gateway.js";
|
||||
import { getChannelsCommandSecretTargetIds } from "../cli/command-secret-targets.js";
|
||||
import { getScopedChannelsCommandSecretTargets } from "../cli/command-secret-targets.js";
|
||||
import { resolveMessageSecretScope } from "../cli/message-secret-scope.js";
|
||||
import { createOutboundSendDeps, type CliDeps } from "../cli/outbound-send-deps.js";
|
||||
import { withProgress } from "../cli/progress.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
@ -19,10 +20,22 @@ export async function messageCommand(
|
||||
runtime: RuntimeEnv,
|
||||
) {
|
||||
const loadedRaw = loadConfig();
|
||||
const scope = resolveMessageSecretScope({
|
||||
channel: opts.channel,
|
||||
target: opts.target,
|
||||
targets: opts.targets,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
const scopedTargets = getScopedChannelsCommandSecretTargets({
|
||||
config: loadedRaw,
|
||||
channel: scope.channel,
|
||||
accountId: scope.accountId,
|
||||
});
|
||||
const { resolvedConfig: cfg, diagnostics } = await resolveCommandSecretRefsViaGateway({
|
||||
config: loadedRaw,
|
||||
commandName: "message",
|
||||
targetIds: getChannelsCommandSecretTargetIds(),
|
||||
targetIds: scopedTargets.targetIds,
|
||||
...(scopedTargets.allowedPaths ? { allowedPaths: scopedTargets.allowedPaths } : {}),
|
||||
});
|
||||
for (const entry of diagnostics) {
|
||||
runtime.log(`[secrets] ${entry}`);
|
||||
|
||||
@ -44,12 +44,13 @@ export async function statusAllCommand(
|
||||
await withProgress({ label: "Scanning status --all…", total: 11 }, async (progress) => {
|
||||
progress.setLabel("Loading config…");
|
||||
const loadedRaw = await readBestEffortConfig();
|
||||
const { resolvedConfig: cfg } = await resolveCommandSecretRefsViaGateway({
|
||||
config: loadedRaw,
|
||||
commandName: "status --all",
|
||||
targetIds: getStatusCommandSecretTargetIds(),
|
||||
mode: "read_only_status",
|
||||
});
|
||||
const { resolvedConfig: cfg, diagnostics: secretDiagnostics } =
|
||||
await resolveCommandSecretRefsViaGateway({
|
||||
config: loadedRaw,
|
||||
commandName: "status --all",
|
||||
targetIds: getStatusCommandSecretTargetIds(),
|
||||
mode: "read_only_status",
|
||||
});
|
||||
const osSummary = resolveOsSummary();
|
||||
const snap = await readConfigFileSnapshot().catch(() => null);
|
||||
progress.tick();
|
||||
@ -328,6 +329,13 @@ export async function statusAllCommand(
|
||||
Item: "Agents",
|
||||
Value: `${agentStatus.agents.length} total · ${agentStatus.bootstrapPendingCount} bootstrapping · ${aliveAgents} active · ${agentStatus.totalSessions} sessions`,
|
||||
},
|
||||
{
|
||||
Item: "Secrets",
|
||||
Value:
|
||||
secretDiagnostics.length > 0
|
||||
? `${secretDiagnostics.length} diagnostic${secretDiagnostics.length === 1 ? "" : "s"}`
|
||||
: "none",
|
||||
},
|
||||
];
|
||||
|
||||
const lines = await buildStatusAllReportLines({
|
||||
@ -343,6 +351,7 @@ export async function statusAllCommand(
|
||||
diagnosis: {
|
||||
snap,
|
||||
remoteUrlMissing,
|
||||
secretDiagnostics,
|
||||
sentinel,
|
||||
lastErr,
|
||||
port,
|
||||
|
||||
@ -50,6 +50,7 @@ export async function appendStatusAllDiagnosis(params: {
|
||||
connectionDetailsForReport: string;
|
||||
snap: ConfigSnapshotLike | null;
|
||||
remoteUrlMissing: boolean;
|
||||
secretDiagnostics: string[];
|
||||
sentinel: { payload?: RestartSentinelPayload | null } | null;
|
||||
lastErr: string | null;
|
||||
port: number;
|
||||
@ -104,6 +105,17 @@ export async function appendStatusAllDiagnosis(params: {
|
||||
lines.push(` ${muted("Fix: set gateway.remote.url, or set gateway.mode=local.")}`);
|
||||
}
|
||||
|
||||
emitCheck(
|
||||
`Secret diagnostics (${params.secretDiagnostics.length})`,
|
||||
params.secretDiagnostics.length === 0 ? "ok" : "warn",
|
||||
);
|
||||
for (const diagnostic of params.secretDiagnostics.slice(0, 10)) {
|
||||
lines.push(` - ${muted(redactSecrets(diagnostic))}`);
|
||||
}
|
||||
if (params.secretDiagnostics.length > 10) {
|
||||
lines.push(` ${muted(`… +${params.secretDiagnostics.length - 10} more`)}`);
|
||||
}
|
||||
|
||||
if (params.sentinel?.payload) {
|
||||
emitCheck("Restart sentinel present", "warn");
|
||||
lines.push(
|
||||
|
||||
@ -46,6 +46,7 @@ describe("buildStatusAllReportLines", () => {
|
||||
diagnosis: {
|
||||
snap: null,
|
||||
remoteUrlMissing: false,
|
||||
secretDiagnostics: [],
|
||||
sentinel: null,
|
||||
lastErr: null,
|
||||
port: 18789,
|
||||
@ -70,5 +71,10 @@ describe("buildStatusAllReportLines", () => {
|
||||
expect(output).toContain("Bootstrap file");
|
||||
expect(output).toContain("PRESENT");
|
||||
expect(output).toContain("ABSENT");
|
||||
expect(diagnosisSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
secretDiagnostics: [],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
listChannelPlugins: vi.fn(),
|
||||
@ -14,6 +15,7 @@ vi.mock("./channel-resolution.js", () => ({
|
||||
}));
|
||||
|
||||
import {
|
||||
__testing,
|
||||
listConfiguredMessageChannels,
|
||||
resolveMessageChannelSelection,
|
||||
} from "./channel-selection.js";
|
||||
@ -38,6 +40,8 @@ function makePlugin(params: {
|
||||
}
|
||||
|
||||
describe("listConfiguredMessageChannels", () => {
|
||||
const errorSpy = vi.spyOn(defaultRuntime, "error").mockImplementation(() => undefined);
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.listChannelPlugins.mockReset();
|
||||
mocks.listChannelPlugins.mockReturnValue([]);
|
||||
@ -45,6 +49,8 @@ describe("listConfiguredMessageChannels", () => {
|
||||
mocks.resolveOutboundChannelPlugin.mockImplementation(({ channel }: { channel: string }) => ({
|
||||
id: channel,
|
||||
}));
|
||||
__testing.resetLoggedChannelSelectionErrors();
|
||||
errorSpy.mockClear();
|
||||
});
|
||||
|
||||
it("skips unknown plugin ids and plugins without accounts", async () => {
|
||||
@ -93,6 +99,20 @@ describe("listConfiguredMessageChannels", () => {
|
||||
|
||||
await expect(listConfiguredMessageChannels({} as never)).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("skips plugin accounts whose resolveAccount throws", async () => {
|
||||
mocks.listChannelPlugins.mockReturnValue([
|
||||
makePlugin({
|
||||
id: "discord",
|
||||
resolveAccount: () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
await expect(listConfiguredMessageChannels({} as never)).resolves.toEqual([]);
|
||||
expect(errorSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveMessageChannelSelection", () => {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { listChannelPlugins } from "../../channels/plugins/index.js";
|
||||
import type { ChannelPlugin } from "../../channels/plugins/types.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import {
|
||||
listDeliverableMessageChannels,
|
||||
type DeliverableMessageChannel,
|
||||
@ -59,6 +60,25 @@ function isAccountEnabled(account: unknown): boolean {
|
||||
return enabled !== false;
|
||||
}
|
||||
|
||||
const loggedChannelSelectionErrors = new Set<string>();
|
||||
|
||||
function logChannelSelectionError(params: {
|
||||
pluginId: string;
|
||||
accountId: string;
|
||||
operation: "resolveAccount" | "isConfigured";
|
||||
error: unknown;
|
||||
}) {
|
||||
const message = params.error instanceof Error ? params.error.message : String(params.error);
|
||||
const key = `${params.pluginId}:${params.accountId}:${params.operation}:${message}`;
|
||||
if (loggedChannelSelectionErrors.has(key)) {
|
||||
return;
|
||||
}
|
||||
loggedChannelSelectionErrors.add(key);
|
||||
defaultRuntime.error?.(
|
||||
`[channel-selection] ${params.pluginId}(${params.accountId}) ${params.operation} failed: ${message}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function isPluginConfigured(plugin: ChannelPlugin, cfg: OpenClawConfig): Promise<boolean> {
|
||||
const accountIds = plugin.config.listAccountIds(cfg);
|
||||
if (accountIds.length === 0) {
|
||||
@ -66,7 +86,18 @@ async function isPluginConfigured(plugin: ChannelPlugin, cfg: OpenClawConfig): P
|
||||
}
|
||||
|
||||
for (const accountId of accountIds) {
|
||||
const account = plugin.config.resolveAccount(cfg, accountId);
|
||||
let account: unknown;
|
||||
try {
|
||||
account = plugin.config.resolveAccount(cfg, accountId);
|
||||
} catch (error) {
|
||||
logChannelSelectionError({
|
||||
pluginId: plugin.id,
|
||||
accountId,
|
||||
operation: "resolveAccount",
|
||||
error,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const enabled = plugin.config.isEnabled
|
||||
? plugin.config.isEnabled(account, cfg)
|
||||
: isAccountEnabled(account);
|
||||
@ -76,7 +107,18 @@ async function isPluginConfigured(plugin: ChannelPlugin, cfg: OpenClawConfig): P
|
||||
if (!plugin.config.isConfigured) {
|
||||
return true;
|
||||
}
|
||||
const configured = await plugin.config.isConfigured(account, cfg);
|
||||
let configured = false;
|
||||
try {
|
||||
configured = await plugin.config.isConfigured(account, cfg);
|
||||
} catch (error) {
|
||||
logChannelSelectionError({
|
||||
pluginId: plugin.id,
|
||||
accountId,
|
||||
operation: "isConfigured",
|
||||
error,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (configured) {
|
||||
return true;
|
||||
}
|
||||
@ -162,3 +204,9 @@ export async function resolveMessageChannelSelection(params: {
|
||||
`Channel is required when multiple channels are configured: ${configured.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
resetLoggedChannelSelectionErrors() {
|
||||
loggedChannelSelectionErrors.clear();
|
||||
},
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user