Files
openclaw/extensions/nextcloud-talk/src/onboarding.ts
Josh Avant 806803b7ef 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>
2026-03-03 02:58:20 +00:00

392 lines
12 KiB
TypeScript

import {
addWildcardAllowFrom,
formatDocsLink,
hasConfiguredSecretInput,
mergeAllowFromEntries,
promptSingleChannelSecretInput,
promptAccountId,
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
type SecretInput,
type ChannelOnboardingAdapter,
type ChannelOnboardingDmPolicy,
type OpenClawConfig,
type WizardPrompter,
} from "openclaw/plugin-sdk";
import {
listNextcloudTalkAccountIds,
resolveDefaultNextcloudTalkAccountId,
resolveNextcloudTalkAccount,
} from "./accounts.js";
import type { CoreConfig, DmPolicy } from "./types.js";
const channel = "nextcloud-talk" as const;
function setNextcloudTalkDmPolicy(cfg: CoreConfig, dmPolicy: DmPolicy): CoreConfig {
const existingConfig = cfg.channels?.["nextcloud-talk"];
const existingAllowFrom: string[] = (existingConfig?.allowFrom ?? []).map((x) => String(x));
const allowFrom: string[] =
dmPolicy === "open" ? (addWildcardAllowFrom(existingAllowFrom) as string[]) : existingAllowFrom;
const newNextcloudTalkConfig = {
...existingConfig,
dmPolicy,
allowFrom,
};
return {
...cfg,
channels: {
...cfg.channels,
"nextcloud-talk": newNextcloudTalkConfig,
},
} as CoreConfig;
}
async function noteNextcloudTalkSecretHelp(prompter: WizardPrompter): Promise<void> {
await prompter.note(
[
"1) SSH into your Nextcloud server",
'2) Run: ./occ talk:bot:install "OpenClaw" "<shared-secret>" "<webhook-url>" --feature reaction',
"3) Copy the shared secret you used in the command",
"4) Enable the bot in your Nextcloud Talk room settings",
"Tip: you can also set NEXTCLOUD_TALK_BOT_SECRET in your env.",
`Docs: ${formatDocsLink("/channels/nextcloud-talk", "channels/nextcloud-talk")}`,
].join("\n"),
"Nextcloud Talk bot setup",
);
}
async function noteNextcloudTalkUserIdHelp(prompter: WizardPrompter): Promise<void> {
await prompter.note(
[
"1) Check the Nextcloud admin panel for user IDs",
"2) Or look at the webhook payload logs when someone messages",
"3) User IDs are typically lowercase usernames in Nextcloud",
`Docs: ${formatDocsLink("/channels/nextcloud-talk", "channels/nextcloud-talk")}`,
].join("\n"),
"Nextcloud Talk user id",
);
}
async function promptNextcloudTalkAllowFrom(params: {
cfg: CoreConfig;
prompter: WizardPrompter;
accountId: string;
}): Promise<CoreConfig> {
const { cfg, prompter, accountId } = params;
const resolved = resolveNextcloudTalkAccount({ cfg, accountId });
const existingAllowFrom = resolved.config.allowFrom ?? [];
await noteNextcloudTalkUserIdHelp(prompter);
const parseInput = (value: string) =>
value
.split(/[\n,;]+/g)
.map((entry) => entry.trim().toLowerCase())
.filter(Boolean);
let resolvedIds: string[] = [];
while (resolvedIds.length === 0) {
const entry = await prompter.text({
message: "Nextcloud Talk allowFrom (user id)",
placeholder: "username",
initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined,
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
});
resolvedIds = parseInput(String(entry));
if (resolvedIds.length === 0) {
await prompter.note("Please enter at least one valid user ID.", "Nextcloud Talk allowlist");
}
}
const merged = [
...existingAllowFrom.map((item) => String(item).trim().toLowerCase()).filter(Boolean),
...resolvedIds,
];
const unique = mergeAllowFromEntries(undefined, merged);
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...cfg,
channels: {
...cfg.channels,
"nextcloud-talk": {
...cfg.channels?.["nextcloud-talk"],
enabled: true,
dmPolicy: "allowlist",
allowFrom: unique,
},
},
};
}
return {
...cfg,
channels: {
...cfg.channels,
"nextcloud-talk": {
...cfg.channels?.["nextcloud-talk"],
enabled: true,
accounts: {
...cfg.channels?.["nextcloud-talk"]?.accounts,
[accountId]: {
...cfg.channels?.["nextcloud-talk"]?.accounts?.[accountId],
enabled: cfg.channels?.["nextcloud-talk"]?.accounts?.[accountId]?.enabled ?? true,
dmPolicy: "allowlist",
allowFrom: unique,
},
},
},
},
};
}
async function promptNextcloudTalkAllowFromForAccount(params: {
cfg: CoreConfig;
prompter: WizardPrompter;
accountId?: string;
}): Promise<CoreConfig> {
const accountId =
params.accountId && normalizeAccountId(params.accountId)
? (normalizeAccountId(params.accountId) ?? DEFAULT_ACCOUNT_ID)
: resolveDefaultNextcloudTalkAccountId(params.cfg);
return promptNextcloudTalkAllowFrom({
cfg: params.cfg,
prompter: params.prompter,
accountId,
});
}
const dmPolicy: ChannelOnboardingDmPolicy = {
label: "Nextcloud Talk",
channel,
policyKey: "channels.nextcloud-talk.dmPolicy",
allowFromKey: "channels.nextcloud-talk.allowFrom",
getCurrent: (cfg) => cfg.channels?.["nextcloud-talk"]?.dmPolicy ?? "pairing",
setPolicy: (cfg, policy) => setNextcloudTalkDmPolicy(cfg as CoreConfig, policy as DmPolicy),
promptAllowFrom: promptNextcloudTalkAllowFromForAccount as (params: {
cfg: OpenClawConfig;
prompter: WizardPrompter;
accountId?: string | undefined;
}) => Promise<OpenClawConfig>,
};
export const nextcloudTalkOnboardingAdapter: ChannelOnboardingAdapter = {
channel,
getStatus: async ({ cfg }) => {
const configured = listNextcloudTalkAccountIds(cfg as CoreConfig).some((accountId) => {
const account = resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId });
return Boolean(account.secret && account.baseUrl);
});
return {
channel,
configured,
statusLines: [`Nextcloud Talk: ${configured ? "configured" : "needs setup"}`],
selectionHint: configured ? "configured" : "self-hosted chat",
quickstartScore: configured ? 1 : 5,
};
},
configure: async ({
cfg,
prompter,
accountOverrides,
shouldPromptAccountIds,
forceAllowFrom,
}) => {
const nextcloudTalkOverride = accountOverrides["nextcloud-talk"]?.trim();
const defaultAccountId = resolveDefaultNextcloudTalkAccountId(cfg as CoreConfig);
let accountId = nextcloudTalkOverride
? normalizeAccountId(nextcloudTalkOverride)
: defaultAccountId;
if (shouldPromptAccountIds && !nextcloudTalkOverride) {
accountId = await promptAccountId({
cfg: cfg as CoreConfig,
prompter,
label: "Nextcloud Talk",
currentId: accountId,
listAccountIds: listNextcloudTalkAccountIds as (cfg: OpenClawConfig) => string[],
defaultAccountId,
});
}
let next = cfg as CoreConfig;
const resolvedAccount = resolveNextcloudTalkAccount({
cfg: next,
accountId,
});
const accountConfigured = Boolean(resolvedAccount.secret && resolvedAccount.baseUrl);
const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
const canUseEnv = allowEnv && Boolean(process.env.NEXTCLOUD_TALK_BOT_SECRET?.trim());
const hasConfigSecret = Boolean(
hasConfiguredSecretInput(resolvedAccount.config.botSecret) ||
resolvedAccount.config.botSecretFile,
);
let baseUrl = resolvedAccount.baseUrl;
if (!baseUrl) {
baseUrl = String(
await prompter.text({
message: "Enter Nextcloud instance URL (e.g., https://cloud.example.com)",
validate: (value) => {
const v = String(value ?? "").trim();
if (!v) {
return "Required";
}
if (!v.startsWith("http://") && !v.startsWith("https://")) {
return "URL must start with http:// or https://";
}
return undefined;
},
}),
).trim();
}
let secret: SecretInput | null = null;
if (!accountConfigured) {
await noteNextcloudTalkSecretHelp(prompter);
}
const secretResult = await promptSingleChannelSecretInput({
cfg: next,
prompter,
providerHint: "nextcloud-talk",
credentialLabel: "bot secret",
accountConfigured,
canUseEnv: canUseEnv && !hasConfigSecret,
hasConfigToken: hasConfigSecret,
envPrompt: "NEXTCLOUD_TALK_BOT_SECRET detected. Use env var?",
keepPrompt: "Nextcloud Talk bot secret already configured. Keep it?",
inputPrompt: "Enter Nextcloud Talk bot secret",
preferredEnvVar: "NEXTCLOUD_TALK_BOT_SECRET",
});
if (secretResult.action === "set") {
secret = secretResult.value;
}
if (secretResult.action === "use-env" || secret || baseUrl !== resolvedAccount.baseUrl) {
if (accountId === DEFAULT_ACCOUNT_ID) {
next = {
...next,
channels: {
...next.channels,
"nextcloud-talk": {
...next.channels?.["nextcloud-talk"],
enabled: true,
baseUrl,
...(secret ? { botSecret: secret } : {}),
},
},
};
} else {
next = {
...next,
channels: {
...next.channels,
"nextcloud-talk": {
...next.channels?.["nextcloud-talk"],
enabled: true,
accounts: {
...next.channels?.["nextcloud-talk"]?.accounts,
[accountId]: {
...next.channels?.["nextcloud-talk"]?.accounts?.[accountId],
enabled:
next.channels?.["nextcloud-talk"]?.accounts?.[accountId]?.enabled ?? true,
baseUrl,
...(secret ? { botSecret: secret } : {}),
},
},
},
},
};
}
}
const existingApiUser = resolvedAccount.config.apiUser?.trim();
const existingApiPasswordConfigured = Boolean(
hasConfiguredSecretInput(resolvedAccount.config.apiPassword) ||
resolvedAccount.config.apiPasswordFile,
);
const configureApiCredentials = await prompter.confirm({
message: "Configure optional Nextcloud Talk API credentials for room lookups?",
initialValue: Boolean(existingApiUser && existingApiPasswordConfigured),
});
if (configureApiCredentials) {
const apiUser = String(
await prompter.text({
message: "Nextcloud Talk API user",
initialValue: existingApiUser,
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
}),
).trim();
const apiPasswordResult = await promptSingleChannelSecretInput({
cfg: next,
prompter,
providerHint: "nextcloud-talk-api",
credentialLabel: "API password",
accountConfigured: Boolean(existingApiUser && existingApiPasswordConfigured),
canUseEnv: false,
hasConfigToken: existingApiPasswordConfigured,
envPrompt: "",
keepPrompt: "Nextcloud Talk API password already configured. Keep it?",
inputPrompt: "Enter Nextcloud Talk API password",
preferredEnvVar: "NEXTCLOUD_TALK_API_PASSWORD",
});
const apiPassword = apiPasswordResult.action === "set" ? apiPasswordResult.value : undefined;
if (accountId === DEFAULT_ACCOUNT_ID) {
next = {
...next,
channels: {
...next.channels,
"nextcloud-talk": {
...next.channels?.["nextcloud-talk"],
enabled: true,
apiUser,
...(apiPassword ? { apiPassword } : {}),
},
},
};
} else {
next = {
...next,
channels: {
...next.channels,
"nextcloud-talk": {
...next.channels?.["nextcloud-talk"],
enabled: true,
accounts: {
...next.channels?.["nextcloud-talk"]?.accounts,
[accountId]: {
...next.channels?.["nextcloud-talk"]?.accounts?.[accountId],
enabled:
next.channels?.["nextcloud-talk"]?.accounts?.[accountId]?.enabled ?? true,
apiUser,
...(apiPassword ? { apiPassword } : {}),
},
},
},
},
};
}
}
if (forceAllowFrom) {
next = await promptNextcloudTalkAllowFrom({
cfg: next,
prompter,
accountId,
});
}
return { cfg: next, accountId };
},
dmPolicy,
disable: (cfg) => ({
...cfg,
channels: {
...cfg.channels,
"nextcloud-talk": { ...cfg.channels?.["nextcloud-talk"], enabled: false },
},
}),
};