refactor(tlon): centralize Urbit request helpers

This commit is contained in:
Peter Steinberger 2026-02-14 19:22:09 +01:00
parent df7464ddf6
commit d0f64c955e
11 changed files with 293 additions and 166 deletions

View File

@ -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(/^~/, ""),

View File

@ -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<v
let api: UrbitSSEClient | null = null;
try {
const ssrfPolicy = account.allowPrivateNetwork ? { allowPrivateNetwork: true } : undefined;
const ssrfPolicy = ssrfPolicyFromAllowPrivateNetwork(account.allowPrivateNetwork);
runtime.log?.(`[tlon] Attempting authentication to ${account.url}...`);
const cookie = await authenticate(account.url, account.code, { ssrfPolicy });
api = new UrbitSSEClient(account.url, cookie, {

View File

@ -1,4 +1,5 @@
import type { LookupFn, SsrFPolicy } from "openclaw/plugin-sdk";
import { UrbitAuthError } from "./errors.js";
import { urbitFetch } from "./fetch.js";
export type UrbitAuthenticateOptions = {
@ -31,14 +32,14 @@ export async function authenticate(
try {
if (!response.ok) {
throw new Error(`Login failed with status ${response.status}`);
throw new UrbitAuthError("auth_failed", `Login failed with status ${response.status}`);
}
// Some Urbit setups require the response body to be read before cookie headers finalize.
await response.text().catch(() => {});
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 {

View File

@ -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");
});
});

View File

@ -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 {

View File

@ -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;
}
}

View File

@ -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<Response>;
};
export async function createUrbitChannel(
deps: UrbitChannelDeps,
params: { body: unknown; auditContext: string },
): Promise<void> {
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<void> {
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<void> {
await createUrbitChannel(deps, {
body: params.createBody,
auditContext: params.createAuditContext,
});
await wakeUrbitChannel(deps);
}

View File

@ -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;
}

View File

@ -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";
}
}

View File

@ -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();

View File

@ -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<void>) | 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;