mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
feat(secrets): expand SecretRef coverage across user-supplied credentials (#29580)
* feat(secrets): expand secret target coverage and gateway tooling * docs(secrets): align gateway and CLI secret docs * chore(protocol): regenerate swift gateway models for secrets methods * fix(config): restore talk apiKey fallback and stabilize runner test * ci(windows): reduce test worker count for shard stability * ci(windows): raise node heap for test shard stability * test(feishu): make proxy env precedence assertion windows-safe * fix(gateway): resolve auth password SecretInput refs for clients * fix(gateway): resolve remote SecretInput credentials for clients * fix(secrets): skip inactive refs in command snapshot assignments * fix(secrets): scope gateway.remote refs to effective auth surfaces * fix(secrets): ignore memory defaults when enabled agents disable search * fix(secrets): honor Google Chat serviceAccountRef inheritance * fix(secrets): address tsgo errors in command and gateway collectors * fix(secrets): avoid auth-store load in providers-only configure * fix(gateway): defer local password ref resolution by precedence * fix(secrets): gate telegram webhook secret refs by webhook mode * fix(secrets): gate slack signing secret refs to http mode * fix(secrets): skip telegram botToken refs when tokenFile is set * fix(secrets): gate discord pluralkit refs by enabled flag * fix(secrets): gate discord voice tts refs by voice enabled * test(secrets): make runtime fixture modes explicit * fix(cli): resolve local qr password secret refs * fix(cli): fail when gateway leaves command refs unresolved * fix(gateway): fail when local password SecretRef is unresolved * fix(gateway): fail when required remote SecretRefs are unresolved * fix(gateway): resolve local password refs only when password can win * fix(cli): skip local password SecretRef resolution on qr token override * test(gateway): cast SecretRef fixtures to OpenClawConfig * test(secrets): activate mode-gated targets in runtime coverage fixture * fix(cron): support SecretInput webhook tokens safely * fix(bluebubbles): support SecretInput passwords across config paths * fix(msteams): make appPassword SecretInput-safe in onboarding/token paths * fix(bluebubbles): align SecretInput schema helper typing * fix(cli): clarify secrets.resolve version-skew errors * refactor(secrets): return structured inactive paths from secrets.resolve * refactor(gateway): type onboarding secret writes as SecretInput * chore(protocol): regenerate swift models for secrets.resolve * feat(secrets): expand extension credential secretref support * fix(secrets): gate web-search refs by active provider * fix(onboarding): detect SecretRef credentials in extension status * fix(onboarding): allow keeping existing ref in secret prompt * fix(onboarding): resolve gateway password SecretRefs for probe and tui * fix(onboarding): honor secret-input-mode for local gateway auth * fix(acp): resolve gateway SecretInput credentials * fix(secrets): gate gateway.remote refs to remote surfaces * test(secrets): cover pattern matching and inactive array refs * docs(secrets): clarify secrets.resolve and remote active surfaces * fix(bluebubbles): keep existing SecretRef during onboarding * fix(tests): resolve CI type errors in new SecretRef coverage * fix(extensions): replace raw fetch with SSRF-guarded fetch * test(secrets): mark gateway remote targets active in runtime coverage * test(infra): normalize home-prefix expectation across platforms * fix(cli): only resolve local qr password refs in password mode * test(cli): cover local qr token mode with unresolved password ref * docs(cli): clarify local qr password ref resolution behavior * refactor(extensions): reuse sdk SecretInput helpers * fix(wizard): resolve onboarding env-template secrets before plaintext * fix(cli): surface secrets.resolve diagnostics in memory and qr * test(secrets): repair post-rebase runtime and fixtures * fix(gateway): skip remote password ref resolution when token wins * fix(secrets): treat tailscale remote gateway refs as active * fix(gateway): allow remote password fallback when token ref is unresolved * fix(gateway): ignore stale local password refs for none and trusted-proxy * fix(gateway): skip remote secret ref resolution on local call paths * test(cli): cover qr remote tailscale secret ref resolution * fix(secrets): align gateway password active-surface with auth inference * fix(cli): resolve inferred local gateway password refs in qr * fix(gateway): prefer resolvable remote password over token ref pre-resolution * test(gateway): cover none and trusted-proxy stale password refs * docs(secrets): sync qr and gateway active-surface behavior * fix: restore stability blockers from pre-release audit * Secrets: fix collector/runtime precedence contradictions * docs: align secrets and web credential docs * fix(rebase): resolve integration regressions after main rebase * fix(node-host): resolve gateway secret refs for auth * fix(secrets): harden secretinput runtime readers * gateway: skip inactive auth secretref resolution * cli: avoid gateway preflight for inactive secret refs * extensions: allow unresolved refs in onboarding status * tests: fix qr-cli module mock hoist ordering * Security: align audit checks with SecretInput resolution * Gateway: resolve local-mode remote fallback secret refs * Node host: avoid resolving inactive password secret refs * Secrets runtime: mark Slack appToken inactive for HTTP mode * secrets: keep inactive gateway remote refs non-blocking * cli: include agent memory secret targets in runtime resolution * docs(secrets): sync docs with active-surface and web search behavior * fix(secrets): keep telegram top-level token refs active for blank account tokens * fix(daemon): resolve gateway password secret refs for probe auth * fix(secrets): skip IRC NickServ ref resolution when NickServ is disabled * fix(secrets): align token inheritance and exec timeout defaults * docs(secrets): clarify active-surface notes in cli docs * cli: require secrets.resolve gateway capability * gateway: log auth secret surface diagnostics * secrets: remove dead provider resolver module * fix(secrets): restore gateway auth precedence and fallback resolution * fix(tests): align plugin runtime mock typings --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
import { resolveBlueBubblesAccount } from "./accounts.js";
|
||||
import { normalizeResolvedSecretInputString } from "./secret-input.js";
|
||||
|
||||
export type BlueBubblesAccountResolveOpts = {
|
||||
serverUrl?: string;
|
||||
@@ -18,8 +19,24 @@ export function resolveBlueBubblesServerAccount(params: BlueBubblesAccountResolv
|
||||
cfg: params.cfg ?? {},
|
||||
accountId: params.accountId,
|
||||
});
|
||||
const baseUrl = params.serverUrl?.trim() || account.config.serverUrl?.trim();
|
||||
const password = params.password?.trim() || account.config.password?.trim();
|
||||
const baseUrl =
|
||||
normalizeResolvedSecretInputString({
|
||||
value: params.serverUrl,
|
||||
path: "channels.bluebubbles.serverUrl",
|
||||
}) ||
|
||||
normalizeResolvedSecretInputString({
|
||||
value: account.config.serverUrl,
|
||||
path: `channels.bluebubbles.accounts.${account.accountId}.serverUrl`,
|
||||
});
|
||||
const password =
|
||||
normalizeResolvedSecretInputString({
|
||||
value: params.password,
|
||||
path: "channels.bluebubbles.password",
|
||||
}) ||
|
||||
normalizeResolvedSecretInputString({
|
||||
value: account.config.password,
|
||||
path: `channels.bluebubbles.accounts.${account.accountId}.password`,
|
||||
});
|
||||
if (!baseUrl) {
|
||||
throw new Error("BlueBubbles serverUrl is required");
|
||||
}
|
||||
|
||||
25
extensions/bluebubbles/src/accounts.test.ts
Normal file
25
extensions/bluebubbles/src/accounts.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveBlueBubblesAccount } from "./accounts.js";
|
||||
|
||||
describe("resolveBlueBubblesAccount", () => {
|
||||
it("treats SecretRef passwords as configured when serverUrl exists", () => {
|
||||
const resolved = resolveBlueBubblesAccount({
|
||||
cfg: {
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
enabled: true,
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "BLUEBUBBLES_PASSWORD",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolved.configured).toBe(true);
|
||||
expect(resolved.baseUrl).toBe("http://localhost:1234");
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
normalizeAccountId,
|
||||
normalizeOptionalAccountId,
|
||||
} from "openclaw/plugin-sdk/account-id";
|
||||
import { hasConfiguredSecretInput, normalizeSecretInputString } from "./secret-input.js";
|
||||
import { normalizeBlueBubblesServerUrl, type BlueBubblesAccountConfig } from "./types.js";
|
||||
|
||||
export type ResolvedBlueBubblesAccount = {
|
||||
@@ -79,9 +80,9 @@ export function resolveBlueBubblesAccount(params: {
|
||||
const baseEnabled = params.cfg.channels?.bluebubbles?.enabled;
|
||||
const merged = mergeBlueBubblesAccountConfig(params.cfg, accountId);
|
||||
const accountEnabled = merged.enabled !== false;
|
||||
const serverUrl = merged.serverUrl?.trim();
|
||||
const password = merged.password?.trim();
|
||||
const configured = Boolean(serverUrl && password);
|
||||
const serverUrl = normalizeSecretInputString(merged.serverUrl);
|
||||
const password = normalizeSecretInputString(merged.password);
|
||||
const configured = Boolean(serverUrl && hasConfiguredSecretInput(merged.password));
|
||||
const baseUrl = serverUrl ? normalizeBlueBubblesServerUrl(serverUrl) : undefined;
|
||||
return {
|
||||
accountId,
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
import { resolveBlueBubblesMessageId } from "./monitor.js";
|
||||
import { getCachedBlueBubblesPrivateApiStatus, isMacOS26OrHigher } from "./probe.js";
|
||||
import { sendBlueBubblesReaction } from "./reactions.js";
|
||||
import { normalizeSecretInputString } from "./secret-input.js";
|
||||
import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
|
||||
import { normalizeBlueBubblesHandle, parseBlueBubblesTarget } from "./targets.js";
|
||||
import type { BlueBubblesSendTarget } from "./types.js";
|
||||
@@ -102,8 +103,8 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
||||
cfg: cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
const baseUrl = account.config.serverUrl?.trim();
|
||||
const password = account.config.password?.trim();
|
||||
const baseUrl = normalizeSecretInputString(account.config.serverUrl);
|
||||
const password = normalizeSecretInputString(account.config.password);
|
||||
const opts = { cfg: cfg, accountId: accountId ?? undefined };
|
||||
const assertPrivateApiEnabled = () => {
|
||||
if (getCachedBlueBubblesPrivateApiStatus(account.accountId) === false) {
|
||||
|
||||
@@ -10,6 +10,18 @@ describe("BlueBubblesConfigSchema", () => {
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts SecretRef password when serverUrl is set", () => {
|
||||
const parsed = BlueBubblesConfigSchema.safeParse({
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "BLUEBUBBLES_PASSWORD",
|
||||
},
|
||||
});
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
it("requires password when top-level serverUrl is configured", () => {
|
||||
const parsed = BlueBubblesConfigSchema.safeParse({
|
||||
serverUrl: "http://localhost:1234",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { MarkdownConfigSchema, ToolPolicySchema } from "openclaw/plugin-sdk";
|
||||
import { z } from "zod";
|
||||
import { buildSecretInputSchema, hasConfiguredSecretInput } from "./secret-input.js";
|
||||
|
||||
const allowFromEntry = z.union([z.string(), z.number()]);
|
||||
|
||||
@@ -30,7 +31,7 @@ const bluebubblesAccountSchema = z
|
||||
enabled: z.boolean().optional(),
|
||||
markdown: MarkdownConfigSchema,
|
||||
serverUrl: z.string().optional(),
|
||||
password: z.string().optional(),
|
||||
password: buildSecretInputSchema().optional(),
|
||||
webhookPath: z.string().optional(),
|
||||
dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
|
||||
allowFrom: z.array(allowFromEntry).optional(),
|
||||
@@ -49,8 +50,8 @@ const bluebubblesAccountSchema = z
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
const serverUrl = value.serverUrl?.trim() ?? "";
|
||||
const password = value.password?.trim() ?? "";
|
||||
if (serverUrl && !password) {
|
||||
const passwordConfigured = hasConfiguredSecretInput(value.password);
|
||||
if (serverUrl && !passwordConfigured) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["password"],
|
||||
|
||||
@@ -43,6 +43,7 @@ import type {
|
||||
} from "./monitor-shared.js";
|
||||
import { isBlueBubblesPrivateApiEnabled } from "./probe.js";
|
||||
import { normalizeBlueBubblesReactionInput, sendBlueBubblesReaction } from "./reactions.js";
|
||||
import { normalizeSecretInputString } from "./secret-input.js";
|
||||
import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
|
||||
import { formatBlueBubblesChatTarget, isAllowedBlueBubblesSender } from "./targets.js";
|
||||
|
||||
@@ -731,8 +732,8 @@ export async function processMessage(
|
||||
// surfacing dropped content (allowlist/mention/command gating).
|
||||
cacheInboundMessage();
|
||||
|
||||
const baseUrl = account.config.serverUrl?.trim();
|
||||
const password = account.config.password?.trim();
|
||||
const baseUrl = normalizeSecretInputString(account.config.serverUrl);
|
||||
const password = normalizeSecretInputString(account.config.password);
|
||||
const maxBytes =
|
||||
account.config.mediaMaxMb && account.config.mediaMaxMb > 0
|
||||
? account.config.mediaMaxMb * 1024 * 1024
|
||||
|
||||
@@ -50,8 +50,11 @@ const mockReadAllowFromStore = vi.fn().mockResolvedValue([]);
|
||||
const mockUpsertPairingRequest = vi.fn().mockResolvedValue({ code: "TESTCODE", created: true });
|
||||
const mockResolveAgentRoute = vi.fn(() => ({
|
||||
agentId: "main",
|
||||
channel: "bluebubbles",
|
||||
accountId: "default",
|
||||
sessionKey: "agent:main:bluebubbles:dm:+15551234567",
|
||||
mainSessionKey: "agent:main:main",
|
||||
matchedBy: "default",
|
||||
}));
|
||||
const mockBuildMentionRegexes = vi.fn(() => [/\bbert\b/i]);
|
||||
const mockMatchesMentionPatterns = vi.fn((text: string, regexes: RegExp[]) =>
|
||||
@@ -76,7 +79,9 @@ const mockDispatchReplyWithBufferedBlockDispatcher = vi.fn(
|
||||
const mockHasControlCommand = vi.fn(() => false);
|
||||
const mockResolveCommandAuthorizedFromAuthorizers = vi.fn(() => false);
|
||||
const mockSaveMediaBuffer = vi.fn().mockResolvedValue({
|
||||
id: "test-media.jpg",
|
||||
path: "/tmp/test-media.jpg",
|
||||
size: Buffer.byteLength("test"),
|
||||
contentType: "image/jpeg",
|
||||
});
|
||||
const mockResolveStorePath = vi.fn(() => "/tmp/sessions.json");
|
||||
@@ -104,17 +109,21 @@ function createMockRuntime(): PluginRuntime {
|
||||
chunkByNewline: mockChunkByNewline,
|
||||
chunkMarkdownTextWithMode: mockChunkMarkdownTextWithMode,
|
||||
chunkTextWithMode: mockChunkTextWithMode,
|
||||
resolveChunkMode: mockResolveChunkMode,
|
||||
resolveChunkMode:
|
||||
mockResolveChunkMode as unknown as PluginRuntime["channel"]["text"]["resolveChunkMode"],
|
||||
hasControlCommand: mockHasControlCommand,
|
||||
},
|
||||
reply: {
|
||||
dispatchReplyWithBufferedBlockDispatcher: mockDispatchReplyWithBufferedBlockDispatcher,
|
||||
dispatchReplyWithBufferedBlockDispatcher:
|
||||
mockDispatchReplyWithBufferedBlockDispatcher as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"],
|
||||
formatAgentEnvelope: mockFormatAgentEnvelope,
|
||||
formatInboundEnvelope: mockFormatInboundEnvelope,
|
||||
resolveEnvelopeFormatOptions: mockResolveEnvelopeFormatOptions,
|
||||
resolveEnvelopeFormatOptions:
|
||||
mockResolveEnvelopeFormatOptions as unknown as PluginRuntime["channel"]["reply"]["resolveEnvelopeFormatOptions"],
|
||||
},
|
||||
routing: {
|
||||
resolveAgentRoute: mockResolveAgentRoute,
|
||||
resolveAgentRoute:
|
||||
mockResolveAgentRoute as unknown as PluginRuntime["channel"]["routing"]["resolveAgentRoute"],
|
||||
},
|
||||
pairing: {
|
||||
buildPairingReply: mockBuildPairingReply,
|
||||
@@ -122,7 +131,8 @@ function createMockRuntime(): PluginRuntime {
|
||||
upsertPairingRequest: mockUpsertPairingRequest,
|
||||
},
|
||||
media: {
|
||||
saveMediaBuffer: mockSaveMediaBuffer,
|
||||
saveMediaBuffer:
|
||||
mockSaveMediaBuffer as unknown as PluginRuntime["channel"]["media"]["saveMediaBuffer"],
|
||||
},
|
||||
session: {
|
||||
resolveStorePath: mockResolveStorePath,
|
||||
@@ -134,7 +144,8 @@ function createMockRuntime(): PluginRuntime {
|
||||
matchesMentionWithExplicit: mockMatchesMentionWithExplicit,
|
||||
},
|
||||
groups: {
|
||||
resolveGroupPolicy: mockResolveGroupPolicy,
|
||||
resolveGroupPolicy:
|
||||
mockResolveGroupPolicy as unknown as PluginRuntime["channel"]["groups"]["resolveGroupPolicy"],
|
||||
resolveRequireMention: mockResolveRequireMention,
|
||||
},
|
||||
commands: {
|
||||
|
||||
@@ -50,8 +50,11 @@ const mockReadAllowFromStore = vi.fn().mockResolvedValue([]);
|
||||
const mockUpsertPairingRequest = vi.fn().mockResolvedValue({ code: "TESTCODE", created: true });
|
||||
const mockResolveAgentRoute = vi.fn(() => ({
|
||||
agentId: "main",
|
||||
channel: "bluebubbles",
|
||||
accountId: "default",
|
||||
sessionKey: "agent:main:bluebubbles:dm:+15551234567",
|
||||
mainSessionKey: "agent:main:main",
|
||||
matchedBy: "default",
|
||||
}));
|
||||
const mockBuildMentionRegexes = vi.fn(() => [/\bbert\b/i]);
|
||||
const mockMatchesMentionPatterns = vi.fn((text: string, regexes: RegExp[]) =>
|
||||
@@ -76,7 +79,9 @@ const mockDispatchReplyWithBufferedBlockDispatcher = vi.fn(
|
||||
const mockHasControlCommand = vi.fn(() => false);
|
||||
const mockResolveCommandAuthorizedFromAuthorizers = vi.fn(() => false);
|
||||
const mockSaveMediaBuffer = vi.fn().mockResolvedValue({
|
||||
id: "test-media.jpg",
|
||||
path: "/tmp/test-media.jpg",
|
||||
size: Buffer.byteLength("test"),
|
||||
contentType: "image/jpeg",
|
||||
});
|
||||
const mockResolveStorePath = vi.fn(() => "/tmp/sessions.json");
|
||||
@@ -104,17 +109,21 @@ function createMockRuntime(): PluginRuntime {
|
||||
chunkByNewline: mockChunkByNewline,
|
||||
chunkMarkdownTextWithMode: mockChunkMarkdownTextWithMode,
|
||||
chunkTextWithMode: mockChunkTextWithMode,
|
||||
resolveChunkMode: mockResolveChunkMode,
|
||||
resolveChunkMode:
|
||||
mockResolveChunkMode as unknown as PluginRuntime["channel"]["text"]["resolveChunkMode"],
|
||||
hasControlCommand: mockHasControlCommand,
|
||||
},
|
||||
reply: {
|
||||
dispatchReplyWithBufferedBlockDispatcher: mockDispatchReplyWithBufferedBlockDispatcher,
|
||||
dispatchReplyWithBufferedBlockDispatcher:
|
||||
mockDispatchReplyWithBufferedBlockDispatcher as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"],
|
||||
formatAgentEnvelope: mockFormatAgentEnvelope,
|
||||
formatInboundEnvelope: mockFormatInboundEnvelope,
|
||||
resolveEnvelopeFormatOptions: mockResolveEnvelopeFormatOptions,
|
||||
resolveEnvelopeFormatOptions:
|
||||
mockResolveEnvelopeFormatOptions as unknown as PluginRuntime["channel"]["reply"]["resolveEnvelopeFormatOptions"],
|
||||
},
|
||||
routing: {
|
||||
resolveAgentRoute: mockResolveAgentRoute,
|
||||
resolveAgentRoute:
|
||||
mockResolveAgentRoute as unknown as PluginRuntime["channel"]["routing"]["resolveAgentRoute"],
|
||||
},
|
||||
pairing: {
|
||||
buildPairingReply: mockBuildPairingReply,
|
||||
@@ -122,7 +131,8 @@ function createMockRuntime(): PluginRuntime {
|
||||
upsertPairingRequest: mockUpsertPairingRequest,
|
||||
},
|
||||
media: {
|
||||
saveMediaBuffer: mockSaveMediaBuffer,
|
||||
saveMediaBuffer:
|
||||
mockSaveMediaBuffer as unknown as PluginRuntime["channel"]["media"]["saveMediaBuffer"],
|
||||
},
|
||||
session: {
|
||||
resolveStorePath: mockResolveStorePath,
|
||||
@@ -134,7 +144,8 @@ function createMockRuntime(): PluginRuntime {
|
||||
matchesMentionWithExplicit: mockMatchesMentionWithExplicit,
|
||||
},
|
||||
groups: {
|
||||
resolveGroupPolicy: mockResolveGroupPolicy,
|
||||
resolveGroupPolicy:
|
||||
mockResolveGroupPolicy as unknown as PluginRuntime["channel"]["groups"]["resolveGroupPolicy"],
|
||||
resolveRequireMention: mockResolveRequireMention,
|
||||
},
|
||||
commands: {
|
||||
|
||||
81
extensions/bluebubbles/src/onboarding.secret-input.test.ts
Normal file
81
extensions/bluebubbles/src/onboarding.secret-input.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import type { WizardPrompter } from "openclaw/plugin-sdk";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("openclaw/plugin-sdk", () => ({
|
||||
DEFAULT_ACCOUNT_ID: "default",
|
||||
addWildcardAllowFrom: vi.fn(),
|
||||
formatDocsLink: (_url: string, fallback: string) => fallback,
|
||||
hasConfiguredSecretInput: (value: unknown) => {
|
||||
if (typeof value === "string") {
|
||||
return value.trim().length > 0;
|
||||
}
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return false;
|
||||
}
|
||||
const ref = value as { source?: unknown; provider?: unknown; id?: unknown };
|
||||
const validSource = ref.source === "env" || ref.source === "file" || ref.source === "exec";
|
||||
return (
|
||||
validSource &&
|
||||
typeof ref.provider === "string" &&
|
||||
ref.provider.trim().length > 0 &&
|
||||
typeof ref.id === "string" &&
|
||||
ref.id.trim().length > 0
|
||||
);
|
||||
},
|
||||
mergeAllowFromEntries: (_existing: unknown, entries: string[]) => entries,
|
||||
normalizeSecretInputString: (value: unknown) => {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
},
|
||||
normalizeAccountId: (value?: string | null) =>
|
||||
value && value.trim().length > 0 ? value : "default",
|
||||
promptAccountId: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("bluebubbles onboarding SecretInput", () => {
|
||||
it("preserves existing password SecretRef when user keeps current credential", async () => {
|
||||
const { blueBubblesOnboardingAdapter } = await import("./onboarding.js");
|
||||
type ConfigureContext = Parameters<
|
||||
NonNullable<typeof blueBubblesOnboardingAdapter.configure>
|
||||
>[0];
|
||||
const passwordRef = { source: "env", provider: "default", id: "BLUEBUBBLES_PASSWORD" };
|
||||
const confirm = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(true) // keep server URL
|
||||
.mockResolvedValueOnce(true) // keep password SecretRef
|
||||
.mockResolvedValueOnce(false); // keep default webhook path
|
||||
const text = vi.fn();
|
||||
const note = vi.fn();
|
||||
|
||||
const prompter = {
|
||||
confirm,
|
||||
text,
|
||||
note,
|
||||
} as unknown as WizardPrompter;
|
||||
|
||||
const context = {
|
||||
cfg: {
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
enabled: true,
|
||||
serverUrl: "http://127.0.0.1:1234",
|
||||
password: passwordRef,
|
||||
},
|
||||
},
|
||||
},
|
||||
prompter,
|
||||
runtime: { ...console, exit: vi.fn() } as ConfigureContext["runtime"],
|
||||
forceAllowFrom: false,
|
||||
accountOverrides: {},
|
||||
shouldPromptAccountIds: false,
|
||||
} satisfies ConfigureContext;
|
||||
|
||||
const result = await blueBubblesOnboardingAdapter.configure(context);
|
||||
|
||||
expect(result.cfg.channels?.bluebubbles?.password).toEqual(passwordRef);
|
||||
expect(text).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
resolveBlueBubblesAccount,
|
||||
resolveDefaultBlueBubblesAccountId,
|
||||
} from "./accounts.js";
|
||||
import { hasConfiguredSecretInput, normalizeSecretInputString } from "./secret-input.js";
|
||||
import { parseBlueBubblesAllowTarget } from "./targets.js";
|
||||
import { normalizeBlueBubblesServerUrl } from "./types.js";
|
||||
|
||||
@@ -222,8 +223,11 @@ export const blueBubblesOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
}
|
||||
|
||||
// Prompt for password
|
||||
let password = resolvedAccount.config.password?.trim();
|
||||
if (!password) {
|
||||
const existingPassword = resolvedAccount.config.password;
|
||||
const existingPasswordText = normalizeSecretInputString(existingPassword);
|
||||
const hasConfiguredPassword = hasConfiguredSecretInput(existingPassword);
|
||||
let password: unknown = existingPasswordText;
|
||||
if (!hasConfiguredPassword) {
|
||||
await prompter.note(
|
||||
[
|
||||
"Enter the BlueBubbles server password.",
|
||||
@@ -247,6 +251,8 @@ export const blueBubblesOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
||||
});
|
||||
password = String(entered).trim();
|
||||
} else if (!existingPasswordText) {
|
||||
password = existingPassword;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { BaseProbeResult } from "openclaw/plugin-sdk";
|
||||
import { normalizeSecretInputString } from "./secret-input.js";
|
||||
import { buildBlueBubblesApiUrl, blueBubblesFetchWithTimeout } from "./types.js";
|
||||
|
||||
export type BlueBubblesProbe = BaseProbeResult & {
|
||||
@@ -35,8 +36,8 @@ export async function fetchBlueBubblesServerInfo(params: {
|
||||
accountId?: string;
|
||||
timeoutMs?: number;
|
||||
}): Promise<BlueBubblesServerInfo | null> {
|
||||
const baseUrl = params.baseUrl?.trim();
|
||||
const password = params.password?.trim();
|
||||
const baseUrl = normalizeSecretInputString(params.baseUrl);
|
||||
const password = normalizeSecretInputString(params.password);
|
||||
if (!baseUrl || !password) {
|
||||
return null;
|
||||
}
|
||||
@@ -138,8 +139,8 @@ export async function probeBlueBubbles(params: {
|
||||
password?: string | null;
|
||||
timeoutMs?: number;
|
||||
}): Promise<BlueBubblesProbe> {
|
||||
const baseUrl = params.baseUrl?.trim();
|
||||
const password = params.password?.trim();
|
||||
const baseUrl = normalizeSecretInputString(params.baseUrl);
|
||||
const password = normalizeSecretInputString(params.password);
|
||||
if (!baseUrl) {
|
||||
return { ok: false, error: "serverUrl not configured" };
|
||||
}
|
||||
|
||||
19
extensions/bluebubbles/src/secret-input.ts
Normal file
19
extensions/bluebubbles/src/secret-input.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import {
|
||||
hasConfiguredSecretInput,
|
||||
normalizeResolvedSecretInputString,
|
||||
normalizeSecretInputString,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import { z } from "zod";
|
||||
|
||||
export { hasConfiguredSecretInput, normalizeResolvedSecretInputString, normalizeSecretInputString };
|
||||
|
||||
export function buildSecretInputSchema() {
|
||||
return z.union([
|
||||
z.string(),
|
||||
z.object({
|
||||
source: z.enum(["env", "file", "exec"]),
|
||||
provider: z.string().min(1),
|
||||
id: z.string().min(1),
|
||||
}),
|
||||
]);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
isBlueBubblesPrivateApiStatusEnabled,
|
||||
} from "./probe.js";
|
||||
import { warnBlueBubbles } from "./runtime.js";
|
||||
import { normalizeSecretInputString } from "./secret-input.js";
|
||||
import { extractBlueBubblesMessageId, resolveBlueBubblesSendTarget } from "./send-helpers.js";
|
||||
import { extractHandleFromChatGuid, normalizeBlueBubblesHandle } from "./targets.js";
|
||||
import {
|
||||
@@ -372,8 +373,12 @@ export async function sendMessageBlueBubbles(
|
||||
cfg: opts.cfg ?? {},
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
const baseUrl = opts.serverUrl?.trim() || account.config.serverUrl?.trim();
|
||||
const password = opts.password?.trim() || account.config.password?.trim();
|
||||
const baseUrl =
|
||||
normalizeSecretInputString(opts.serverUrl) ||
|
||||
normalizeSecretInputString(account.config.serverUrl);
|
||||
const password =
|
||||
normalizeSecretInputString(opts.password) ||
|
||||
normalizeSecretInputString(account.config.password);
|
||||
if (!baseUrl) {
|
||||
throw new Error("BlueBubbles serverUrl is required");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user