From d0f64c955e03efd8470b4f2b8cfd6d4b6f693cd4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 14 Feb 2026 19:22:09 +0100 Subject: [PATCH] refactor(tlon): centralize Urbit request helpers --- extensions/tlon/src/channel.ts | 5 +- extensions/tlon/src/monitor/index.ts | 3 +- extensions/tlon/src/urbit/auth.ts | 5 +- extensions/tlon/src/urbit/base-url.test.ts | 41 ++++++++ extensions/tlon/src/urbit/base-url.ts | 12 ++- extensions/tlon/src/urbit/channel-client.ts | 107 +++++--------------- extensions/tlon/src/urbit/channel-ops.ts | 92 +++++++++++++++++ extensions/tlon/src/urbit/context.ts | 47 +++++++++ extensions/tlon/src/urbit/errors.ts | 51 ++++++++++ extensions/tlon/src/urbit/fetch.ts | 3 +- extensions/tlon/src/urbit/sse-client.ts | 93 ++++------------- 11 files changed, 293 insertions(+), 166 deletions(-) create mode 100644 extensions/tlon/src/urbit/base-url.test.ts create mode 100644 extensions/tlon/src/urbit/channel-ops.ts create mode 100644 extensions/tlon/src/urbit/context.ts create mode 100644 extensions/tlon/src/urbit/errors.ts diff --git a/extensions/tlon/src/channel.ts b/extensions/tlon/src/channel.ts index 802af8484d5..323d41d0ce6 100644 --- a/extensions/tlon/src/channel.ts +++ b/extensions/tlon/src/channel.ts @@ -17,6 +17,7 @@ import { formatTargetHint, normalizeShip, parseTlonTarget } from "./targets.js"; import { resolveTlonAccount, listTlonAccountIds } from "./types.js"; import { authenticate } from "./urbit/auth.js"; import { UrbitChannelClient } from "./urbit/channel-client.js"; +import { ssrfPolicyFromAllowPrivateNetwork } from "./urbit/context.js"; import { buildMediaText, sendDm, sendGroupMessage } from "./urbit/send.js"; const TLON_CHANNEL_ID = "tlon" as const; @@ -123,7 +124,7 @@ const tlonOutbound: ChannelOutboundAdapter = { throw new Error(`Invalid Tlon target. Use ${formatTargetHint()}`); } - const ssrfPolicy = account.allowPrivateNetwork ? { allowPrivateNetwork: true } : undefined; + const ssrfPolicy = ssrfPolicyFromAllowPrivateNetwork(account.allowPrivateNetwork); const cookie = await authenticate(account.url, account.code, { ssrfPolicy }); const api = new UrbitChannelClient(account.url, cookie, { ship: account.ship.replace(/^~/, ""), @@ -345,7 +346,7 @@ export const tlonPlugin: ChannelPlugin = { return { ok: false, error: "Not configured" }; } try { - const ssrfPolicy = account.allowPrivateNetwork ? { allowPrivateNetwork: true } : undefined; + const ssrfPolicy = ssrfPolicyFromAllowPrivateNetwork(account.allowPrivateNetwork); const cookie = await authenticate(account.url, account.code, { ssrfPolicy }); const api = new UrbitChannelClient(account.url, cookie, { ship: account.ship.replace(/^~/, ""), diff --git a/extensions/tlon/src/monitor/index.ts b/extensions/tlon/src/monitor/index.ts index 2ed726e7579..70e06b08747 100644 --- a/extensions/tlon/src/monitor/index.ts +++ b/extensions/tlon/src/monitor/index.ts @@ -5,6 +5,7 @@ import { getTlonRuntime } from "../runtime.js"; import { normalizeShip, parseChannelNest } from "../targets.js"; import { resolveTlonAccount } from "../types.js"; import { authenticate } from "../urbit/auth.js"; +import { ssrfPolicyFromAllowPrivateNetwork } from "../urbit/context.js"; import { sendDm, sendGroupMessage } from "../urbit/send.js"; import { UrbitSSEClient } from "../urbit/sse-client.js"; import { fetchAllChannels } from "./discovery.js"; @@ -113,7 +114,7 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise {}); const cookie = response.headers.get("set-cookie"); if (!cookie) { - throw new Error("No authentication cookie received"); + throw new UrbitAuthError("missing_cookie", "No authentication cookie received"); } return cookie; } finally { diff --git a/extensions/tlon/src/urbit/base-url.test.ts b/extensions/tlon/src/urbit/base-url.test.ts new file mode 100644 index 00000000000..c61433b6649 --- /dev/null +++ b/extensions/tlon/src/urbit/base-url.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { validateUrbitBaseUrl } from "./base-url.js"; + +describe("validateUrbitBaseUrl", () => { + it("adds https:// when scheme is missing and strips path/query fragments", () => { + const result = validateUrbitBaseUrl("example.com/foo?bar=baz"); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.baseUrl).toBe("https://example.com"); + expect(result.hostname).toBe("example.com"); + }); + + it("rejects non-http schemes", () => { + const result = validateUrbitBaseUrl("file:///etc/passwd"); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toContain("http:// or https://"); + }); + + it("rejects embedded credentials", () => { + const result = validateUrbitBaseUrl("https://user:pass@example.com"); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toContain("credentials"); + }); + + it("normalizes a trailing dot in the hostname for origin construction", () => { + const result = validateUrbitBaseUrl("https://example.com./foo"); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.baseUrl).toBe("https://example.com"); + expect(result.hostname).toBe("example.com"); + }); + + it("preserves port in the normalized origin", () => { + const result = validateUrbitBaseUrl("http://example.com:8080/~/login"); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.baseUrl).toBe("http://example.com:8080"); + }); +}); diff --git a/extensions/tlon/src/urbit/base-url.ts b/extensions/tlon/src/urbit/base-url.ts index 73238dd1d50..7aa85e44cea 100644 --- a/extensions/tlon/src/urbit/base-url.ts +++ b/extensions/tlon/src/urbit/base-url.ts @@ -36,8 +36,16 @@ export function validateUrbitBaseUrl(raw: string): UrbitBaseUrlValidation { return { ok: false, error: "Invalid hostname" }; } - // Normalize to origin so callers can't smuggle paths/query fragments into the base URL. - return { ok: true, baseUrl: parsed.origin, hostname }; + // Normalize to origin so callers can't smuggle paths/query fragments into the base URL, + // and strip a trailing dot from the hostname (DNS root label). + const isIpv6 = hostname.includes(":"); + const host = parsed.port + ? `${isIpv6 ? `[${hostname}]` : hostname}:${parsed.port}` + : isIpv6 + ? `[${hostname}]` + : hostname; + + return { ok: true, baseUrl: `${parsed.protocol}//${host}`, hostname }; } export function isBlockedUrbitHostname(hostname: string): boolean { diff --git a/extensions/tlon/src/urbit/channel-client.ts b/extensions/tlon/src/urbit/channel-client.ts index b857ae54775..1b38f7d7c80 100644 --- a/extensions/tlon/src/urbit/channel-client.ts +++ b/extensions/tlon/src/urbit/channel-client.ts @@ -1,5 +1,6 @@ import type { LookupFn, SsrFPolicy } from "openclaw/plugin-sdk"; -import { validateUrbitBaseUrl } from "./base-url.js"; +import { ensureUrbitChannelOpen } from "./channel-ops.js"; +import { getUrbitContext, normalizeUrbitCookie } from "./context.js"; import { urbitFetch } from "./fetch.js"; export type UrbitChannelClientOptions = { @@ -20,28 +21,15 @@ export class UrbitChannelClient { private channelId: string | null = null; constructor(url: string, cookie: string, options: UrbitChannelClientOptions = {}) { - const validated = validateUrbitBaseUrl(url); - if (!validated.ok) { - throw new Error(validated.error); - } - - this.baseUrl = validated.baseUrl; - this.cookie = cookie.split(";")[0]; - this.ship = ( - options.ship?.replace(/^~/, "") ?? this.resolveShipFromHostname(validated.hostname) - ).trim(); + const ctx = getUrbitContext(url, options.ship); + this.baseUrl = ctx.baseUrl; + this.cookie = normalizeUrbitCookie(cookie); + this.ship = ctx.ship; this.ssrfPolicy = options.ssrfPolicy; this.lookupFn = options.lookupFn; this.fetchImpl = options.fetchImpl; } - private resolveShipFromHostname(hostname: string): string { - if (hostname.includes(".")) { - return hostname.split(".")[0] ?? hostname; - } - return hostname; - } - private get channelPath(): string { const id = this.channelId; if (!id) { @@ -55,73 +43,28 @@ export class UrbitChannelClient { return; } - this.channelId = `${Math.floor(Date.now() / 1000)}-${Math.random().toString(36).substring(2, 8)}`; + const channelId = `${Math.floor(Date.now() / 1000)}-${Math.random().toString(36).substring(2, 8)}`; + this.channelId = channelId; - // Create the channel. - { - const { response, release } = await urbitFetch({ - baseUrl: this.baseUrl, - path: this.channelPath, - init: { - method: "PUT", - headers: { - "Content-Type": "application/json", - Cookie: this.cookie, - }, - body: JSON.stringify([]), + try { + await ensureUrbitChannelOpen( + { + baseUrl: this.baseUrl, + cookie: this.cookie, + ship: this.ship, + channelId, + ssrfPolicy: this.ssrfPolicy, + lookupFn: this.lookupFn, + fetchImpl: this.fetchImpl, }, - ssrfPolicy: this.ssrfPolicy, - lookupFn: this.lookupFn, - fetchImpl: this.fetchImpl, - timeoutMs: 30_000, - auditContext: "tlon-urbit-channel-open", - }); - - try { - if (!response.ok && response.status !== 204) { - throw new Error(`Channel creation failed: ${response.status}`); - } - } finally { - await release(); - } - } - - // Wake the channel (matches urbit/http-api behavior). - { - const { response, release } = await urbitFetch({ - baseUrl: this.baseUrl, - path: this.channelPath, - init: { - method: "PUT", - headers: { - "Content-Type": "application/json", - Cookie: this.cookie, - }, - body: JSON.stringify([ - { - id: Date.now(), - action: "poke", - ship: this.ship, - app: "hood", - mark: "helm-hi", - json: "Opening API channel", - }, - ]), + { + createBody: [], + createAuditContext: "tlon-urbit-channel-open", }, - ssrfPolicy: this.ssrfPolicy, - lookupFn: this.lookupFn, - fetchImpl: this.fetchImpl, - timeoutMs: 30_000, - auditContext: "tlon-urbit-channel-wake", - }); - - try { - if (!response.ok && response.status !== 204) { - throw new Error(`Channel activation failed: ${response.status}`); - } - } finally { - await release(); - } + ); + } catch (error) { + this.channelId = null; + throw error; } } diff --git a/extensions/tlon/src/urbit/channel-ops.ts b/extensions/tlon/src/urbit/channel-ops.ts new file mode 100644 index 00000000000..f62d870fc45 --- /dev/null +++ b/extensions/tlon/src/urbit/channel-ops.ts @@ -0,0 +1,92 @@ +import type { LookupFn, SsrFPolicy } from "openclaw/plugin-sdk"; +import { UrbitHttpError } from "./errors.js"; +import { urbitFetch } from "./fetch.js"; + +export type UrbitChannelDeps = { + baseUrl: string; + cookie: string; + ship: string; + channelId: string; + ssrfPolicy?: SsrFPolicy; + lookupFn?: LookupFn; + fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise; +}; + +export async function createUrbitChannel( + deps: UrbitChannelDeps, + params: { body: unknown; auditContext: string }, +): Promise { + const { response, release } = await urbitFetch({ + baseUrl: deps.baseUrl, + path: `/~/channel/${deps.channelId}`, + init: { + method: "PUT", + headers: { + "Content-Type": "application/json", + Cookie: deps.cookie, + }, + body: JSON.stringify(params.body), + }, + ssrfPolicy: deps.ssrfPolicy, + lookupFn: deps.lookupFn, + fetchImpl: deps.fetchImpl, + timeoutMs: 30_000, + auditContext: params.auditContext, + }); + + try { + if (!response.ok && response.status !== 204) { + throw new UrbitHttpError({ operation: "Channel creation", status: response.status }); + } + } finally { + await release(); + } +} + +export async function wakeUrbitChannel(deps: UrbitChannelDeps): Promise { + const { response, release } = await urbitFetch({ + baseUrl: deps.baseUrl, + path: `/~/channel/${deps.channelId}`, + init: { + method: "PUT", + headers: { + "Content-Type": "application/json", + Cookie: deps.cookie, + }, + body: JSON.stringify([ + { + id: Date.now(), + action: "poke", + ship: deps.ship, + app: "hood", + mark: "helm-hi", + json: "Opening API channel", + }, + ]), + }, + ssrfPolicy: deps.ssrfPolicy, + lookupFn: deps.lookupFn, + fetchImpl: deps.fetchImpl, + timeoutMs: 30_000, + auditContext: "tlon-urbit-channel-wake", + }); + + try { + if (!response.ok && response.status !== 204) { + throw new UrbitHttpError({ operation: "Channel activation", status: response.status }); + } + } finally { + await release(); + } +} + +export async function ensureUrbitChannelOpen( + deps: UrbitChannelDeps, + params: { createBody: unknown; createAuditContext: string }, +): Promise { + await createUrbitChannel(deps, { + body: params.createBody, + auditContext: params.createAuditContext, + }); + await wakeUrbitChannel(deps); +} diff --git a/extensions/tlon/src/urbit/context.ts b/extensions/tlon/src/urbit/context.ts new file mode 100644 index 00000000000..90c2721c7b8 --- /dev/null +++ b/extensions/tlon/src/urbit/context.ts @@ -0,0 +1,47 @@ +import type { SsrFPolicy } from "openclaw/plugin-sdk"; +import { validateUrbitBaseUrl } from "./base-url.js"; +import { UrbitUrlError } from "./errors.js"; + +export type UrbitContext = { + baseUrl: string; + hostname: string; + ship: string; +}; + +export function resolveShipFromHostname(hostname: string): string { + const trimmed = hostname.trim().toLowerCase().replace(/\.$/, ""); + if (!trimmed) { + return ""; + } + if (trimmed.includes(".")) { + return trimmed.split(".")[0] ?? trimmed; + } + return trimmed; +} + +export function normalizeUrbitShip(ship: string | undefined, hostname: string): string { + const raw = ship?.replace(/^~/, "") ?? resolveShipFromHostname(hostname); + return raw.trim(); +} + +export function normalizeUrbitCookie(cookie: string): string { + return cookie.split(";")[0] ?? cookie; +} + +export function getUrbitContext(url: string, ship?: string): UrbitContext { + const validated = validateUrbitBaseUrl(url); + if (!validated.ok) { + throw new UrbitUrlError(validated.error); + } + return { + baseUrl: validated.baseUrl, + hostname: validated.hostname, + ship: normalizeUrbitShip(ship, validated.hostname), + }; +} + +export function ssrfPolicyFromAllowPrivateNetwork( + allowPrivateNetwork: boolean | null | undefined, +): SsrFPolicy | undefined { + return allowPrivateNetwork ? { allowPrivateNetwork: true } : undefined; +} diff --git a/extensions/tlon/src/urbit/errors.ts b/extensions/tlon/src/urbit/errors.ts new file mode 100644 index 00000000000..d39fa7d6c1b --- /dev/null +++ b/extensions/tlon/src/urbit/errors.ts @@ -0,0 +1,51 @@ +export type UrbitErrorCode = + | "invalid_url" + | "http_error" + | "auth_failed" + | "missing_cookie" + | "channel_not_open"; + +export class UrbitError extends Error { + readonly code: UrbitErrorCode; + + constructor(code: UrbitErrorCode, message: string, options?: { cause?: unknown }) { + super(message, options); + this.name = "UrbitError"; + this.code = code; + } +} + +export class UrbitUrlError extends UrbitError { + constructor(message: string, options?: { cause?: unknown }) { + super("invalid_url", message, options); + this.name = "UrbitUrlError"; + } +} + +export class UrbitHttpError extends UrbitError { + readonly status: number; + readonly operation: string; + readonly bodyText?: string; + + constructor(params: { operation: string; status: number; bodyText?: string; cause?: unknown }) { + const suffix = params.bodyText ? ` - ${params.bodyText}` : ""; + super("http_error", `${params.operation} failed: ${params.status}${suffix}`, { + cause: params.cause, + }); + this.name = "UrbitHttpError"; + this.status = params.status; + this.operation = params.operation; + this.bodyText = params.bodyText; + } +} + +export class UrbitAuthError extends UrbitError { + constructor( + code: "auth_failed" | "missing_cookie", + message: string, + options?: { cause?: unknown }, + ) { + super(code, message, options); + this.name = "UrbitAuthError"; + } +} diff --git a/extensions/tlon/src/urbit/fetch.ts b/extensions/tlon/src/urbit/fetch.ts index f941be841a9..08032a028ef 100644 --- a/extensions/tlon/src/urbit/fetch.ts +++ b/extensions/tlon/src/urbit/fetch.ts @@ -1,6 +1,7 @@ import type { LookupFn, SsrFPolicy } from "openclaw/plugin-sdk"; import { fetchWithSsrFGuard } from "openclaw/plugin-sdk"; import { validateUrbitBaseUrl } from "./base-url.js"; +import { UrbitUrlError } from "./errors.js"; export type UrbitFetchOptions = { baseUrl: string; @@ -19,7 +20,7 @@ export type UrbitFetchOptions = { export async function urbitFetch(params: UrbitFetchOptions) { const validated = validateUrbitBaseUrl(params.baseUrl); if (!validated.ok) { - throw new Error(validated.error); + throw new UrbitUrlError(validated.error); } const url = new URL(params.path, validated.baseUrl).toString(); diff --git a/extensions/tlon/src/urbit/sse-client.ts b/extensions/tlon/src/urbit/sse-client.ts index de2cf46a79c..f4a1b8fdf8c 100644 --- a/extensions/tlon/src/urbit/sse-client.ts +++ b/extensions/tlon/src/urbit/sse-client.ts @@ -1,6 +1,7 @@ import type { LookupFn, SsrFPolicy } from "openclaw/plugin-sdk"; import { Readable } from "node:stream"; -import { validateUrbitBaseUrl } from "./base-url.js"; +import { ensureUrbitChannelOpen } from "./channel-ops.js"; +import { getUrbitContext, normalizeUrbitCookie } from "./context.js"; import { urbitFetch } from "./fetch.js"; export type UrbitSseLogger = { @@ -54,14 +55,10 @@ export class UrbitSSEClient { streamRelease: (() => Promise) | null = null; constructor(url: string, cookie: string, options: UrbitSseOptions = {}) { - const validated = validateUrbitBaseUrl(url); - if (!validated.ok) { - throw new Error(validated.error); - } - - this.url = validated.baseUrl; - this.cookie = cookie.split(";")[0]; - this.ship = options.ship?.replace(/^~/, "") ?? this.resolveShipFromHostname(validated.hostname); + const ctx = getUrbitContext(url, options.ship); + this.url = ctx.baseUrl; + this.cookie = normalizeUrbitCookie(cookie); + this.ship = ctx.ship; this.channelId = `${Math.floor(Date.now() / 1000)}-${Math.random().toString(36).substring(2, 8)}`; this.channelUrl = new URL(`/~/channel/${this.channelId}`, this.url).toString(); this.onReconnect = options.onReconnect ?? null; @@ -75,13 +72,6 @@ export class UrbitSSEClient { this.fetchImpl = options.fetchImpl; } - private resolveShipFromHostname(hostname: string): string { - if (hostname.includes(".")) { - return hostname.split(".")[0] ?? hostname; - } - return hostname; - } - async subscribe(params: { app: string; path: string; @@ -150,70 +140,21 @@ export class UrbitSSEClient { } async connect() { - { - const { response, release } = await urbitFetch({ + await ensureUrbitChannelOpen( + { baseUrl: this.url, - path: `/~/channel/${this.channelId}`, - init: { - method: "PUT", - headers: { - "Content-Type": "application/json", - Cookie: this.cookie, - }, - body: JSON.stringify(this.subscriptions), - }, + cookie: this.cookie, + ship: this.ship, + channelId: this.channelId, ssrfPolicy: this.ssrfPolicy, lookupFn: this.lookupFn, fetchImpl: this.fetchImpl, - timeoutMs: 30_000, - auditContext: "tlon-urbit-channel-create", - }); - - try { - if (!response.ok && response.status !== 204) { - throw new Error(`Channel creation failed: ${response.status}`); - } - } finally { - await release(); - } - } - - { - const { response, release } = await urbitFetch({ - baseUrl: this.url, - path: `/~/channel/${this.channelId}`, - init: { - method: "PUT", - headers: { - "Content-Type": "application/json", - Cookie: this.cookie, - }, - body: JSON.stringify([ - { - id: Date.now(), - action: "poke", - ship: this.ship, - app: "hood", - mark: "helm-hi", - json: "Opening API channel", - }, - ]), - }, - ssrfPolicy: this.ssrfPolicy, - lookupFn: this.lookupFn, - fetchImpl: this.fetchImpl, - timeoutMs: 30_000, - auditContext: "tlon-urbit-channel-wake", - }); - - try { - if (!response.ok && response.status !== 204) { - throw new Error(`Channel activation failed: ${response.status}`); - } - } finally { - await release(); - } - } + }, + { + createBody: this.subscriptions, + createAuditContext: "tlon-urbit-channel-create", + }, + ); await this.openStream(); this.isConnected = true;