diff --git a/extensions/tlon/src/channel.ts b/extensions/tlon/src/channel.ts index eb37c8d7f74..7a460a6adb8 100644 --- a/extensions/tlon/src/channel.ts +++ b/extensions/tlon/src/channel.ts @@ -3,18 +3,11 @@ import { configureClient } from "@tloncorp/api"; import type { ChannelOutboundAdapter, ChannelPlugin, - ChannelSetupInput, OpenClawConfig, } from "openclaw/plugin-sdk/tlon"; -import { - applyAccountNameToChannelSection, - DEFAULT_ACCOUNT_ID, - normalizeAccountId, -} from "openclaw/plugin-sdk/tlon"; -import { buildTlonAccountFields } from "./account-fields.js"; import { tlonChannelConfigSchema } from "./config-schema.js"; import { monitorTlonProvider } from "./monitor/index.js"; -import { tlonOnboardingAdapter } from "./onboarding.js"; +import { tlonSetupAdapter, tlonSetupWizard } from "./setup-surface.js"; import { formatTargetHint, normalizeShip, parseTlonTarget } from "./targets.js"; import { resolveTlonAccount, listTlonAccountIds } from "./types.js"; import { authenticate } from "./urbit/auth.js"; @@ -89,70 +82,6 @@ async function createHttpPokeApi(params: { const TLON_CHANNEL_ID = "tlon" as const; -type TlonSetupInput = ChannelSetupInput & { - ship?: string; - url?: string; - code?: string; - allowPrivateNetwork?: boolean; - groupChannels?: string[]; - dmAllowlist?: string[]; - autoDiscoverChannels?: boolean; - ownerShip?: string; -}; - -function applyTlonSetupConfig(params: { - cfg: OpenClawConfig; - accountId: string; - input: TlonSetupInput; -}): OpenClawConfig { - const { cfg, accountId, input } = params; - const useDefault = accountId === DEFAULT_ACCOUNT_ID; - const namedConfig = applyAccountNameToChannelSection({ - cfg, - channelKey: "tlon", - accountId, - name: input.name, - }); - const base = namedConfig.channels?.tlon ?? {}; - - const payload = buildTlonAccountFields(input); - - if (useDefault) { - return { - ...namedConfig, - channels: { - ...namedConfig.channels, - tlon: { - ...base, - enabled: true, - ...payload, - }, - }, - }; - } - - return { - ...namedConfig, - channels: { - ...namedConfig.channels, - tlon: { - ...base, - enabled: base.enabled ?? true, - accounts: { - ...(base as { accounts?: Record }).accounts, - [accountId]: { - ...(base as { accounts?: Record> }).accounts?.[ - accountId - ], - enabled: true, - ...payload, - }, - }, - }, - }, - }; -} - type ResolvedTlonAccount = ReturnType; type ConfiguredTlonAccount = ResolvedTlonAccount & { ship: string; @@ -296,7 +225,8 @@ export const tlonPlugin: ChannelPlugin = { reply: true, threads: true, }, - onboarding: tlonOnboardingAdapter, + setup: tlonSetupAdapter, + setupWizard: tlonSetupWizard, reload: { configPrefixes: ["channels.tlon"] }, configSchema: tlonChannelConfigSchema, config: { @@ -374,39 +304,6 @@ export const tlonPlugin: ChannelPlugin = { url: account.url, }), }, - setup: { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg: cfg, - channelKey: "tlon", - accountId, - name, - }), - validateInput: ({ cfg, accountId, input }) => { - const setupInput = input as TlonSetupInput; - const resolved = resolveTlonAccount(cfg, accountId ?? undefined); - const ship = setupInput.ship?.trim() || resolved.ship; - const url = setupInput.url?.trim() || resolved.url; - const code = setupInput.code?.trim() || resolved.code; - if (!ship) { - return "Tlon requires --ship."; - } - if (!url) { - return "Tlon requires --url."; - } - if (!code) { - return "Tlon requires --code."; - } - return null; - }, - applyAccountConfig: ({ cfg, accountId, input }) => - applyTlonSetupConfig({ - cfg: cfg, - accountId, - input: input as TlonSetupInput, - }), - }, messaging: { normalizeTarget: (target) => { const parsed = parseTlonTarget(target); diff --git a/extensions/tlon/src/onboarding.ts b/extensions/tlon/src/onboarding.ts deleted file mode 100644 index 8207b190628..00000000000 --- a/extensions/tlon/src/onboarding.ts +++ /dev/null @@ -1,209 +0,0 @@ -import type { OpenClawConfig } from "openclaw/plugin-sdk/tlon"; -import { - formatDocsLink, - patchScopedAccountConfig, - resolveAccountIdForConfigure, - DEFAULT_ACCOUNT_ID, - type ChannelOnboardingAdapter, - type WizardPrompter, -} from "openclaw/plugin-sdk/tlon"; -import { buildTlonAccountFields } from "./account-fields.js"; -import type { TlonResolvedAccount } from "./types.js"; -import { listTlonAccountIds, resolveTlonAccount } from "./types.js"; -import { isBlockedUrbitHostname, validateUrbitBaseUrl } from "./urbit/base-url.js"; - -const channel = "tlon" as const; - -function isConfigured(account: TlonResolvedAccount): boolean { - return Boolean(account.ship && account.url && account.code); -} - -function applyAccountConfig(params: { - cfg: OpenClawConfig; - accountId: string; - input: { - name?: string; - ship?: string; - url?: string; - code?: string; - allowPrivateNetwork?: boolean; - groupChannels?: string[]; - dmAllowlist?: string[]; - autoDiscoverChannels?: boolean; - }; -}): OpenClawConfig { - const { cfg, accountId, input } = params; - const nextValues = { - enabled: true, - ...(input.name ? { name: input.name } : {}), - ...buildTlonAccountFields(input), - }; - if (accountId === DEFAULT_ACCOUNT_ID) { - return patchScopedAccountConfig({ - cfg, - channelKey: channel, - accountId, - patch: nextValues, - ensureChannelEnabled: false, - ensureAccountEnabled: false, - }); - } - return patchScopedAccountConfig({ - cfg, - channelKey: channel, - accountId, - patch: { enabled: cfg.channels?.tlon?.enabled ?? true }, - accountPatch: nextValues, - ensureChannelEnabled: false, - ensureAccountEnabled: false, - }); -} - -async function noteTlonHelp(prompter: WizardPrompter): Promise { - await prompter.note( - [ - "You need your Urbit ship URL and login code.", - "Example URL: https://your-ship-host", - "Example ship: ~sampel-palnet", - "If your ship URL is on a private network (LAN/localhost), you must explicitly allow it during setup.", - `Docs: ${formatDocsLink("/channels/tlon", "channels/tlon")}`, - ].join("\n"), - "Tlon setup", - ); -} - -function parseList(value: string): string[] { - return value - .split(/[\n,;]+/g) - .map((entry) => entry.trim()) - .filter(Boolean); -} - -export const tlonOnboardingAdapter: ChannelOnboardingAdapter = { - channel, - getStatus: async ({ cfg }) => { - const accountIds = listTlonAccountIds(cfg); - const configured = - accountIds.length > 0 - ? accountIds.some((accountId) => isConfigured(resolveTlonAccount(cfg, accountId))) - : isConfigured(resolveTlonAccount(cfg, DEFAULT_ACCOUNT_ID)); - - return { - channel, - configured, - statusLines: [`Tlon: ${configured ? "configured" : "needs setup"}`], - selectionHint: configured ? "configured" : "urbit messenger", - quickstartScore: configured ? 1 : 4, - }; - }, - configure: async ({ cfg, prompter, accountOverrides, shouldPromptAccountIds }) => { - const defaultAccountId = DEFAULT_ACCOUNT_ID; - const accountId = await resolveAccountIdForConfigure({ - cfg, - prompter, - label: "Tlon", - accountOverride: accountOverrides[channel], - shouldPromptAccountIds, - listAccountIds: listTlonAccountIds, - defaultAccountId, - }); - - const resolved = resolveTlonAccount(cfg, accountId); - await noteTlonHelp(prompter); - - const ship = await prompter.text({ - message: "Ship name", - placeholder: "~sampel-palnet", - initialValue: resolved.ship ?? undefined, - validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), - }); - - const url = await prompter.text({ - message: "Ship URL", - placeholder: "https://your-ship-host", - initialValue: resolved.url ?? undefined, - validate: (value) => { - const next = validateUrbitBaseUrl(String(value ?? "")); - if (!next.ok) { - return next.error; - } - return undefined; - }, - }); - - const validatedUrl = validateUrbitBaseUrl(String(url).trim()); - if (!validatedUrl.ok) { - throw new Error(`Invalid URL: ${validatedUrl.error}`); - } - - let allowPrivateNetwork = resolved.allowPrivateNetwork ?? false; - if (isBlockedUrbitHostname(validatedUrl.hostname)) { - allowPrivateNetwork = await prompter.confirm({ - message: - "Ship URL looks like a private/internal host. Allow private network access? (SSRF risk)", - initialValue: allowPrivateNetwork, - }); - if (!allowPrivateNetwork) { - throw new Error("Refusing private/internal Ship URL without explicit approval"); - } - } - - const code = await prompter.text({ - message: "Login code", - placeholder: "lidlut-tabwed-pillex-ridrup", - initialValue: resolved.code ?? undefined, - validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), - }); - - const wantsGroupChannels = await prompter.confirm({ - message: "Add group channels manually? (optional)", - initialValue: false, - }); - - let groupChannels: string[] | undefined; - if (wantsGroupChannels) { - const entry = await prompter.text({ - message: "Group channels (comma-separated)", - placeholder: "chat/~host-ship/general, chat/~host-ship/support", - }); - const parsed = parseList(String(entry ?? "")); - groupChannels = parsed.length > 0 ? parsed : undefined; - } - - const wantsAllowlist = await prompter.confirm({ - message: "Restrict DMs with an allowlist?", - initialValue: false, - }); - - let dmAllowlist: string[] | undefined; - if (wantsAllowlist) { - const entry = await prompter.text({ - message: "DM allowlist (comma-separated ship names)", - placeholder: "~zod, ~nec", - }); - const parsed = parseList(String(entry ?? "")); - dmAllowlist = parsed.length > 0 ? parsed : undefined; - } - - const autoDiscoverChannels = await prompter.confirm({ - message: "Enable auto-discovery of group channels?", - initialValue: resolved.autoDiscoverChannels ?? true, - }); - - const next = applyAccountConfig({ - cfg, - accountId, - input: { - ship: String(ship).trim(), - url: String(url).trim(), - code: String(code).trim(), - allowPrivateNetwork, - groupChannels, - dmAllowlist, - autoDiscoverChannels, - }, - }); - - return { cfg: next, accountId }; - }, -}; diff --git a/extensions/tlon/src/setup-surface.test.ts b/extensions/tlon/src/setup-surface.test.ts new file mode 100644 index 00000000000..bb638fc3018 --- /dev/null +++ b/extensions/tlon/src/setup-surface.test.ts @@ -0,0 +1,94 @@ +import type { OpenClawConfig, RuntimeEnv, WizardPrompter } from "openclaw/plugin-sdk/tlon"; +import { describe, expect, it, vi } from "vitest"; +import { buildChannelOnboardingAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; +import { tlonPlugin } from "./channel.js"; + +const selectFirstOption = async (params: { options: Array<{ value: T }> }): Promise => { + const first = params.options[0]; + if (!first) { + throw new Error("no options"); + } + return first.value; +}; + +function createPrompter(overrides: Partial): WizardPrompter { + return { + intro: vi.fn(async () => {}), + outro: vi.fn(async () => {}), + note: vi.fn(async () => {}), + select: selectFirstOption as WizardPrompter["select"], + multiselect: vi.fn(async () => []), + text: vi.fn(async () => "") as WizardPrompter["text"], + confirm: vi.fn(async () => false), + progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), + ...overrides, + }; +} + +const tlonConfigureAdapter = buildChannelOnboardingAdapterFromSetupWizard({ + plugin: tlonPlugin, + wizard: tlonPlugin.setupWizard!, +}); + +describe("tlon setup wizard", () => { + it("configures ship, auth, and discovery settings", async () => { + const prompter = createPrompter({ + text: vi.fn(async ({ message }: { message: string }) => { + if (message === "Ship name") { + return "sampel-palnet"; + } + if (message === "Ship URL") { + return "https://urbit.example.com"; + } + if (message === "Login code") { + return "lidlut-tabwed-pillex-ridrup"; + } + if (message === "Group channels (comma-separated)") { + return "chat/~host-ship/general, chat/~host-ship/support"; + } + if (message === "DM allowlist (comma-separated ship names)") { + return "~zod, nec"; + } + throw new Error(`Unexpected prompt: ${message}`); + }) as WizardPrompter["text"], + confirm: vi.fn(async ({ message }: { message: string }) => { + if (message === "Add group channels manually? (optional)") { + return true; + } + if (message === "Restrict DMs with an allowlist?") { + return true; + } + if (message === "Enable auto-discovery of group channels?") { + return true; + } + return false; + }), + }); + + const runtime: RuntimeEnv = createRuntimeEnv(); + + const result = await tlonConfigureAdapter.configure({ + cfg: {} as OpenClawConfig, + runtime, + prompter, + options: {}, + accountOverrides: {}, + shouldPromptAccountIds: false, + forceAllowFrom: false, + }); + + expect(result.accountId).toBe("default"); + expect(result.cfg.channels?.tlon?.enabled).toBe(true); + expect(result.cfg.channels?.tlon?.ship).toBe("~sampel-palnet"); + expect(result.cfg.channels?.tlon?.url).toBe("https://urbit.example.com"); + expect(result.cfg.channels?.tlon?.code).toBe("lidlut-tabwed-pillex-ridrup"); + expect(result.cfg.channels?.tlon?.groupChannels).toEqual([ + "chat/~host-ship/general", + "chat/~host-ship/support", + ]); + expect(result.cfg.channels?.tlon?.dmAllowlist).toEqual(["~zod", "~nec"]); + expect(result.cfg.channels?.tlon?.autoDiscoverChannels).toBe(true); + expect(result.cfg.channels?.tlon?.allowPrivateNetwork).toBe(false); + }); +}); diff --git a/extensions/tlon/src/setup-surface.ts b/extensions/tlon/src/setup-surface.ts new file mode 100644 index 00000000000..4cf0d006ebd --- /dev/null +++ b/extensions/tlon/src/setup-surface.ts @@ -0,0 +1,278 @@ +import { + applyAccountNameToChannelSection, + patchScopedAccountConfig, +} from "../../../src/channels/plugins/setup-helpers.js"; +import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import type { ChannelSetupInput } from "../../../src/channels/plugins/types.core.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; +import { buildTlonAccountFields } from "./account-fields.js"; +import { normalizeShip } from "./targets.js"; +import { listTlonAccountIds, resolveTlonAccount, type TlonResolvedAccount } from "./types.js"; +import { isBlockedUrbitHostname, validateUrbitBaseUrl } from "./urbit/base-url.js"; + +const channel = "tlon" as const; + +type TlonSetupInput = ChannelSetupInput & { + ship?: string; + url?: string; + code?: string; + allowPrivateNetwork?: boolean; + groupChannels?: string[]; + dmAllowlist?: string[]; + autoDiscoverChannels?: boolean; + ownerShip?: string; +}; + +function isConfigured(account: TlonResolvedAccount): boolean { + return Boolean(account.ship && account.url && account.code); +} + +function parseList(value: string): string[] { + return value + .split(/[\n,;]+/g) + .map((entry) => entry.trim()) + .filter(Boolean); +} + +function applyTlonSetupConfig(params: { + cfg: OpenClawConfig; + accountId: string; + input: TlonSetupInput; +}): OpenClawConfig { + const { cfg, accountId, input } = params; + const useDefault = accountId === DEFAULT_ACCOUNT_ID; + const namedConfig = applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name: input.name, + }); + const base = namedConfig.channels?.tlon ?? {}; + const payload = buildTlonAccountFields(input); + + if (useDefault) { + return { + ...namedConfig, + channels: { + ...namedConfig.channels, + tlon: { + ...base, + enabled: true, + ...payload, + }, + }, + }; + } + + return patchScopedAccountConfig({ + cfg: namedConfig, + channelKey: channel, + accountId, + patch: { enabled: base.enabled ?? true }, + accountPatch: { + enabled: true, + ...payload, + }, + ensureChannelEnabled: false, + ensureAccountEnabled: false, + }); +} + +export const tlonSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name, + }), + validateInput: ({ cfg, accountId, input }) => { + const setupInput = input as TlonSetupInput; + const resolved = resolveTlonAccount(cfg, accountId ?? undefined); + const ship = setupInput.ship?.trim() || resolved.ship; + const url = setupInput.url?.trim() || resolved.url; + const code = setupInput.code?.trim() || resolved.code; + if (!ship) { + return "Tlon requires --ship."; + } + if (!url) { + return "Tlon requires --url."; + } + if (!code) { + return "Tlon requires --code."; + } + return null; + }, + applyAccountConfig: ({ cfg, accountId, input }) => + applyTlonSetupConfig({ + cfg, + accountId, + input: input as TlonSetupInput, + }), +}; + +export const tlonSetupWizard: ChannelSetupWizard = { + channel, + status: { + configuredLabel: "configured", + unconfiguredLabel: "needs setup", + configuredHint: "configured", + unconfiguredHint: "urbit messenger", + configuredScore: 1, + unconfiguredScore: 4, + resolveConfigured: ({ cfg }) => { + const accountIds = listTlonAccountIds(cfg); + return accountIds.length > 0 + ? accountIds.some((accountId) => isConfigured(resolveTlonAccount(cfg, accountId))) + : isConfigured(resolveTlonAccount(cfg, DEFAULT_ACCOUNT_ID)); + }, + resolveStatusLines: ({ cfg }) => { + const accountIds = listTlonAccountIds(cfg); + const configured = + accountIds.length > 0 + ? accountIds.some((accountId) => isConfigured(resolveTlonAccount(cfg, accountId))) + : isConfigured(resolveTlonAccount(cfg, DEFAULT_ACCOUNT_ID)); + return [`Tlon: ${configured ? "configured" : "needs setup"}`]; + }, + }, + introNote: { + title: "Tlon setup", + lines: [ + "You need your Urbit ship URL and login code.", + "Example URL: https://your-ship-host", + "Example ship: ~sampel-palnet", + "If your ship URL is on a private network (LAN/localhost), you must explicitly allow it during setup.", + `Docs: ${formatDocsLink("/channels/tlon", "channels/tlon")}`, + ], + }, + credentials: [], + textInputs: [ + { + inputKey: "ship", + message: "Ship name", + placeholder: "~sampel-palnet", + currentValue: ({ cfg, accountId }) => resolveTlonAccount(cfg, accountId).ship ?? undefined, + validate: ({ value }) => (String(value ?? "").trim() ? undefined : "Required"), + normalizeValue: ({ value }) => normalizeShip(String(value).trim()), + applySet: async ({ cfg, accountId, value }) => + applyTlonSetupConfig({ + cfg, + accountId, + input: { ship: value }, + }), + }, + { + inputKey: "url", + message: "Ship URL", + placeholder: "https://your-ship-host", + currentValue: ({ cfg, accountId }) => resolveTlonAccount(cfg, accountId).url ?? undefined, + validate: ({ value }) => { + const next = validateUrbitBaseUrl(String(value ?? "")); + if (!next.ok) { + return next.error; + } + return undefined; + }, + normalizeValue: ({ value }) => String(value).trim(), + applySet: async ({ cfg, accountId, value }) => + applyTlonSetupConfig({ + cfg, + accountId, + input: { url: value }, + }), + }, + { + inputKey: "code", + message: "Login code", + placeholder: "lidlut-tabwed-pillex-ridrup", + currentValue: ({ cfg, accountId }) => resolveTlonAccount(cfg, accountId).code ?? undefined, + validate: ({ value }) => (String(value ?? "").trim() ? undefined : "Required"), + normalizeValue: ({ value }) => String(value).trim(), + applySet: async ({ cfg, accountId, value }) => + applyTlonSetupConfig({ + cfg, + accountId, + input: { code: value }, + }), + }, + ], + finalize: async ({ cfg, accountId, prompter }) => { + let next = cfg; + const resolved = resolveTlonAccount(next, accountId); + const validatedUrl = validateUrbitBaseUrl(resolved.url ?? ""); + if (!validatedUrl.ok) { + throw new Error(`Invalid URL: ${validatedUrl.error}`); + } + + let allowPrivateNetwork = resolved.allowPrivateNetwork ?? false; + if (isBlockedUrbitHostname(validatedUrl.hostname)) { + allowPrivateNetwork = await prompter.confirm({ + message: + "Ship URL looks like a private/internal host. Allow private network access? (SSRF risk)", + initialValue: allowPrivateNetwork, + }); + if (!allowPrivateNetwork) { + throw new Error("Refusing private/internal Ship URL without explicit approval"); + } + } + next = applyTlonSetupConfig({ + cfg: next, + accountId, + input: { allowPrivateNetwork }, + }); + + const currentGroups = resolved.groupChannels; + const wantsGroupChannels = await prompter.confirm({ + message: "Add group channels manually? (optional)", + initialValue: currentGroups.length > 0, + }); + if (wantsGroupChannels) { + const entry = await prompter.text({ + message: "Group channels (comma-separated)", + placeholder: "chat/~host-ship/general, chat/~host-ship/support", + initialValue: currentGroups.join(", ") || undefined, + }); + next = applyTlonSetupConfig({ + cfg: next, + accountId, + input: { groupChannels: parseList(String(entry ?? "")) }, + }); + } + + const currentAllowlist = resolved.dmAllowlist; + const wantsAllowlist = await prompter.confirm({ + message: "Restrict DMs with an allowlist?", + initialValue: currentAllowlist.length > 0, + }); + if (wantsAllowlist) { + const entry = await prompter.text({ + message: "DM allowlist (comma-separated ship names)", + placeholder: "~zod, ~nec", + initialValue: currentAllowlist.join(", ") || undefined, + }); + next = applyTlonSetupConfig({ + cfg: next, + accountId, + input: { + dmAllowlist: parseList(String(entry ?? "")).map((ship) => normalizeShip(ship)), + }, + }); + } + + const autoDiscoverChannels = await prompter.confirm({ + message: "Enable auto-discovery of group channels?", + initialValue: resolved.autoDiscoverChannels ?? true, + }); + next = applyTlonSetupConfig({ + cfg: next, + accountId, + input: { autoDiscoverChannels }, + }); + + return { cfg: next }; + }, +};