diff --git a/extensions/tlon/src/channel.ts b/extensions/tlon/src/channel.ts index e62983b4504..4eeca78086b 100644 --- a/extensions/tlon/src/channel.ts +++ b/extensions/tlon/src/channel.ts @@ -17,6 +17,7 @@ import { tlonOnboardingAdapter } from "./onboarding.js"; 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 { ensureUrbitConnectPatched, Urbit } from "./urbit/http-api.js"; import { @@ -443,21 +444,20 @@ export const tlonPlugin: ChannelPlugin = { return { ok: false, error: "Not configured" }; } try { - ensureUrbitConnectPatched(); - const api = await Urbit.authenticate({ + 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(/^~/, ""), - url: account.url, - code: account.code, - verbose: false, + ssrfPolicy, }); try { await api.getOurName(); return { ok: true }; } finally { - await api.delete(); + await api.close(); } - } catch (error: any) { - return { ok: false, error: error?.message ?? String(error) }; + } catch (error) { + return { ok: false, error: (error as { message?: string })?.message ?? String(error) }; } }, buildAccountSnapshot: ({ account, runtime, probe }) => { diff --git a/extensions/tlon/src/urbit/channel-client.ts b/extensions/tlon/src/urbit/channel-client.ts new file mode 100644 index 00000000000..fb8af656a6f --- /dev/null +++ b/extensions/tlon/src/urbit/channel-client.ts @@ -0,0 +1,157 @@ +import type { LookupFn, SsrFPolicy } from "openclaw/plugin-sdk"; +import { ensureUrbitChannelOpen, pokeUrbitChannel, scryUrbitPath } from "./channel-ops.js"; +import { getUrbitContext, normalizeUrbitCookie } from "./context.js"; +import { urbitFetch } from "./fetch.js"; + +export type UrbitChannelClientOptions = { + ship?: string; + ssrfPolicy?: SsrFPolicy; + lookupFn?: LookupFn; + fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise; +}; + +export class UrbitChannelClient { + readonly baseUrl: string; + readonly cookie: string; + readonly ship: string; + readonly ssrfPolicy?: SsrFPolicy; + readonly lookupFn?: LookupFn; + readonly fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise; + + private channelId: string | null = null; + + constructor(url: string, cookie: string, options: UrbitChannelClientOptions = {}) { + 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 get channelPath(): string { + const id = this.channelId; + if (!id) { + throw new Error("Channel not opened"); + } + return `/~/channel/${id}`; + } + + async open(): Promise { + if (this.channelId) { + return; + } + + const channelId = `${Math.floor(Date.now() / 1000)}-${Math.random().toString(36).substring(2, 8)}`; + this.channelId = channelId; + + try { + await ensureUrbitChannelOpen( + { + baseUrl: this.baseUrl, + cookie: this.cookie, + ship: this.ship, + channelId, + ssrfPolicy: this.ssrfPolicy, + lookupFn: this.lookupFn, + fetchImpl: this.fetchImpl, + }, + { + createBody: [], + createAuditContext: "tlon-urbit-channel-open", + }, + ); + } catch (error) { + this.channelId = null; + throw error; + } + } + + async poke(params: { app: string; mark: string; json: unknown }): Promise { + await this.open(); + const channelId = this.channelId; + if (!channelId) { + throw new Error("Channel not opened"); + } + return await pokeUrbitChannel( + { + baseUrl: this.baseUrl, + cookie: this.cookie, + ship: this.ship, + channelId, + ssrfPolicy: this.ssrfPolicy, + lookupFn: this.lookupFn, + fetchImpl: this.fetchImpl, + }, + { ...params, auditContext: "tlon-urbit-poke" }, + ); + } + + async scry(path: string): Promise { + return await scryUrbitPath( + { + baseUrl: this.baseUrl, + cookie: this.cookie, + ssrfPolicy: this.ssrfPolicy, + lookupFn: this.lookupFn, + fetchImpl: this.fetchImpl, + }, + { path, auditContext: "tlon-urbit-scry" }, + ); + } + + async getOurName(): Promise { + const { response, release } = await urbitFetch({ + baseUrl: this.baseUrl, + path: "/~/name", + init: { + method: "GET", + headers: { Cookie: this.cookie }, + }, + ssrfPolicy: this.ssrfPolicy, + lookupFn: this.lookupFn, + fetchImpl: this.fetchImpl, + timeoutMs: 30_000, + auditContext: "tlon-urbit-name", + }); + + try { + if (!response.ok) { + throw new Error(`Name request failed: ${response.status}`); + } + const text = await response.text(); + return text.trim(); + } finally { + await release(); + } + } + + async close(): Promise { + if (!this.channelId) { + return; + } + const channelPath = this.channelPath; + this.channelId = null; + + try { + const { response, release } = await urbitFetch({ + baseUrl: this.baseUrl, + path: channelPath, + init: { method: "DELETE", headers: { Cookie: this.cookie } }, + ssrfPolicy: this.ssrfPolicy, + lookupFn: this.lookupFn, + fetchImpl: this.fetchImpl, + timeoutMs: 30_000, + auditContext: "tlon-urbit-channel-close", + }); + try { + void response.body?.cancel(); + } finally { + await release(); + } + } catch { + // ignore cleanup errors + } + } +}