mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-04 16:30:30 +00:00
secrets: harden read-only SecretRef command paths and diagnostics (#47794)
* secrets: harden read-only SecretRef resolution for status and audit * CLI: add SecretRef degrade-safe regression coverage * Docs: align SecretRef status and daemon probe semantics * Security audit: close SecretRef review gaps * Security audit: preserve source auth SecretRef configuredness * 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:
172
src/commands/channels.status.command-flow.test.ts
Normal file
172
src/commands/channels.status.command-flow.test.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const callGateway = vi.fn();
|
||||
const resolveCommandSecretRefsViaGateway = vi.fn();
|
||||
const requireValidConfigSnapshot = vi.fn();
|
||||
const listChannelPlugins = vi.fn();
|
||||
const withProgress = vi.fn(async (_opts: unknown, run: () => Promise<unknown>) => await run());
|
||||
|
||||
vi.mock("../gateway/call.js", () => ({
|
||||
callGateway: (opts: unknown) => callGateway(opts),
|
||||
}));
|
||||
|
||||
vi.mock("../cli/command-secret-gateway.js", () => ({
|
||||
resolveCommandSecretRefsViaGateway: (opts: unknown) => resolveCommandSecretRefsViaGateway(opts),
|
||||
}));
|
||||
|
||||
vi.mock("./shared.js", () => ({
|
||||
requireValidConfigSnapshot: (runtime: unknown) => requireValidConfigSnapshot(runtime),
|
||||
formatChannelAccountLabel: ({
|
||||
channel,
|
||||
accountId,
|
||||
}: {
|
||||
channel: string;
|
||||
accountId: string;
|
||||
name?: string;
|
||||
}) => `${channel} ${accountId}`,
|
||||
}));
|
||||
|
||||
vi.mock("../channels/plugins/index.js", () => ({
|
||||
listChannelPlugins: () => listChannelPlugins(),
|
||||
getChannelPlugin: (channel: string) =>
|
||||
(listChannelPlugins() as Array<{ id: string }>).find((plugin) => plugin.id === channel),
|
||||
}));
|
||||
|
||||
vi.mock("../cli/progress.js", () => ({
|
||||
withProgress: (opts: unknown, run: () => Promise<unknown>) => withProgress(opts, run),
|
||||
}));
|
||||
|
||||
const { channelsStatusCommand } = await import("./channels/status.js");
|
||||
|
||||
function createTokenOnlyPlugin() {
|
||||
return {
|
||||
id: "discord",
|
||||
meta: {
|
||||
id: "discord",
|
||||
label: "Discord",
|
||||
selectionLabel: "Discord",
|
||||
docsPath: "/channels/discord",
|
||||
blurb: "test",
|
||||
},
|
||||
capabilities: { chatTypes: ["direct"] },
|
||||
config: {
|
||||
listAccountIds: () => ["default"],
|
||||
defaultAccountId: () => "default",
|
||||
inspectAccount: (cfg: { secretResolved?: boolean }) =>
|
||||
cfg.secretResolved
|
||||
? {
|
||||
name: "Primary",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
token: "resolved-discord-token",
|
||||
tokenSource: "config",
|
||||
tokenStatus: "available",
|
||||
}
|
||||
: {
|
||||
name: "Primary",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
token: "",
|
||||
tokenSource: "config",
|
||||
tokenStatus: "configured_unavailable",
|
||||
},
|
||||
resolveAccount: (cfg: { secretResolved?: boolean }) =>
|
||||
cfg.secretResolved
|
||||
? {
|
||||
name: "Primary",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
token: "resolved-discord-token",
|
||||
tokenSource: "config",
|
||||
tokenStatus: "available",
|
||||
}
|
||||
: {
|
||||
name: "Primary",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
token: "",
|
||||
tokenSource: "config",
|
||||
tokenStatus: "configured_unavailable",
|
||||
},
|
||||
isConfigured: () => true,
|
||||
isEnabled: () => true,
|
||||
},
|
||||
actions: {
|
||||
listActions: () => ["send"],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createRuntimeCapture() {
|
||||
const logs: string[] = [];
|
||||
const errors: string[] = [];
|
||||
const runtime = {
|
||||
log: (message: unknown) => logs.push(String(message)),
|
||||
error: (message: unknown) => errors.push(String(message)),
|
||||
exit: (_code?: number) => undefined,
|
||||
};
|
||||
return { runtime, logs, errors };
|
||||
}
|
||||
|
||||
describe("channelsStatusCommand SecretRef fallback flow", () => {
|
||||
beforeEach(() => {
|
||||
callGateway.mockReset();
|
||||
resolveCommandSecretRefsViaGateway.mockReset();
|
||||
requireValidConfigSnapshot.mockReset();
|
||||
listChannelPlugins.mockReset();
|
||||
withProgress.mockClear();
|
||||
listChannelPlugins.mockReturnValue([createTokenOnlyPlugin()]);
|
||||
});
|
||||
|
||||
it("keeps read-only fallback output when SecretRefs are unresolved", async () => {
|
||||
callGateway.mockRejectedValue(new Error("gateway closed"));
|
||||
requireValidConfigSnapshot.mockResolvedValue({ secretResolved: false, channels: {} });
|
||||
resolveCommandSecretRefsViaGateway.mockResolvedValue({
|
||||
resolvedConfig: { secretResolved: false, channels: {} },
|
||||
diagnostics: [
|
||||
"channels status: channels.discord.token is unavailable in this command path; continuing with degraded read-only config.",
|
||||
],
|
||||
targetStatesByPath: {},
|
||||
hadUnresolvedTargets: true,
|
||||
});
|
||||
const { runtime, logs, errors } = createRuntimeCapture();
|
||||
|
||||
await channelsStatusCommand({ probe: false }, runtime as never);
|
||||
|
||||
expect(errors.some((line) => line.includes("Gateway not reachable"))).toBe(true);
|
||||
expect(resolveCommandSecretRefsViaGateway).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
commandName: "channels status",
|
||||
mode: "read_only_status",
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
logs.some((line) =>
|
||||
line.includes("[secrets] channels status: channels.discord.token is unavailable"),
|
||||
),
|
||||
).toBe(true);
|
||||
const joined = logs.join("\n");
|
||||
expect(joined).toContain("configured, secret unavailable in this command path");
|
||||
expect(joined).toContain("token:config (unavailable)");
|
||||
});
|
||||
|
||||
it("prefers resolved snapshots when command-local SecretRef resolution succeeds", async () => {
|
||||
callGateway.mockRejectedValue(new Error("gateway closed"));
|
||||
requireValidConfigSnapshot.mockResolvedValue({ secretResolved: false, channels: {} });
|
||||
resolveCommandSecretRefsViaGateway.mockResolvedValue({
|
||||
resolvedConfig: { secretResolved: true, channels: {} },
|
||||
diagnostics: [],
|
||||
targetStatesByPath: {},
|
||||
hadUnresolvedTargets: false,
|
||||
});
|
||||
const { runtime, logs } = createRuntimeCapture();
|
||||
|
||||
await channelsStatusCommand({ probe: false }, runtime as never);
|
||||
|
||||
const joined = logs.join("\n");
|
||||
expect(joined).toContain("configured");
|
||||
expect(joined).toContain("token:config");
|
||||
expect(joined).not.toContain("secret unavailable in this command path");
|
||||
expect(joined).not.toContain("token:config (unavailable)");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user