mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-19 18:39:20 -05:00
fix(security): harden tlon Urbit requests against SSRF
This commit is contained in:
@@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Security/Agents: scope CLI process cleanup to owned child PIDs to avoid killing unrelated processes on shared hosts. Thanks @aether-ai-agent.
|
||||
- Security/Agents (macOS): prevent shell injection when writing Claude CLI keychain credentials. (#15924) Thanks @aether-ai-agent.
|
||||
- Security: fix Chutes manual OAuth login state validation (thanks @aether-ai-agent). (#16058)
|
||||
- Security/Tlon: harden Urbit URL fetching against SSRF by blocking private/internal hosts by default (opt-in: `channels.tlon.allowPrivateNetwork`). Thanks @p80n-sec.
|
||||
- Security/Voice Call (Telnyx): require webhook signature verification when receiving inbound events; configs without `telnyx.publicKey` are now rejected unless `skipSignatureVerification` is enabled. Thanks @p80n-sec.
|
||||
- Security/Discovery: stop treating Bonjour TXT records as authoritative routing (prefer resolved service endpoints) and prevent discovery from overriding stored TLS pins; autoconnect now requires a previously trusted gateway. Thanks @simecek.
|
||||
- macOS: hard-limit unkeyed `openclaw://agent` deep links and ignore `deliver` / `to` / `channel` unless a valid unattended key is provided. Thanks @Cillian-Collins.
|
||||
|
||||
@@ -55,6 +55,22 @@ Minimal config (single account):
|
||||
}
|
||||
```
|
||||
|
||||
Private/LAN ship URLs (advanced):
|
||||
|
||||
By default, OpenClaw blocks private/internal hostnames and IP ranges for this plugin (SSRF hardening).
|
||||
If your ship URL is on a private network (for example `http://192.168.1.50:8080` or `http://localhost:8080`),
|
||||
you must explicitly opt in:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
tlon: {
|
||||
allowPrivateNetwork: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Group channels
|
||||
|
||||
Auto-discovery is enabled by default. You can also pin channels manually:
|
||||
|
||||
@@ -5,8 +5,7 @@
|
||||
"description": "OpenClaw Tlon/Urbit channel plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@urbit/aura": "^3.0.0",
|
||||
"@urbit/http-api": "^3.0.0"
|
||||
"@urbit/aura": "^3.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"openclaw": "workspace:*"
|
||||
|
||||
@@ -15,7 +15,8 @@ import { monitorTlonProvider } from "./monitor/index.js";
|
||||
import { tlonOnboardingAdapter } from "./onboarding.js";
|
||||
import { formatTargetHint, normalizeShip, parseTlonTarget } from "./targets.js";
|
||||
import { resolveTlonAccount, listTlonAccountIds } from "./types.js";
|
||||
import { ensureUrbitConnectPatched, Urbit } from "./urbit/http-api.js";
|
||||
import { authenticate } from "./urbit/auth.js";
|
||||
import { UrbitChannelClient } from "./urbit/channel-client.js";
|
||||
import { buildMediaText, sendDm, sendGroupMessage } from "./urbit/send.js";
|
||||
|
||||
const TLON_CHANNEL_ID = "tlon" as const;
|
||||
@@ -24,6 +25,7 @@ type TlonSetupInput = ChannelSetupInput & {
|
||||
ship?: string;
|
||||
url?: string;
|
||||
code?: string;
|
||||
allowPrivateNetwork?: boolean;
|
||||
groupChannels?: string[];
|
||||
dmAllowlist?: string[];
|
||||
autoDiscoverChannels?: boolean;
|
||||
@@ -48,6 +50,9 @@ function applyTlonSetupConfig(params: {
|
||||
...(input.ship ? { ship: input.ship } : {}),
|
||||
...(input.url ? { url: input.url } : {}),
|
||||
...(input.code ? { code: input.code } : {}),
|
||||
...(typeof input.allowPrivateNetwork === "boolean"
|
||||
? { allowPrivateNetwork: input.allowPrivateNetwork }
|
||||
: {}),
|
||||
...(input.groupChannels ? { groupChannels: input.groupChannels } : {}),
|
||||
...(input.dmAllowlist ? { dmAllowlist: input.dmAllowlist } : {}),
|
||||
...(typeof input.autoDiscoverChannels === "boolean"
|
||||
@@ -118,12 +123,11 @@ const tlonOutbound: ChannelOutboundAdapter = {
|
||||
throw new Error(`Invalid Tlon target. Use ${formatTargetHint()}`);
|
||||
}
|
||||
|
||||
ensureUrbitConnectPatched();
|
||||
const api = await Urbit.authenticate({
|
||||
const ssrfPolicy = account.allowPrivateNetwork ? { allowPrivateNetwork: true } : undefined;
|
||||
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 {
|
||||
@@ -146,11 +150,7 @@ const tlonOutbound: ChannelOutboundAdapter = {
|
||||
replyToId: replyId,
|
||||
});
|
||||
} finally {
|
||||
try {
|
||||
await api.delete();
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
await api.close();
|
||||
}
|
||||
},
|
||||
sendMedia: async ({ cfg, to, text, mediaUrl, accountId, replyToId, threadId }) => {
|
||||
@@ -345,18 +345,17 @@ export const tlonPlugin: ChannelPlugin = {
|
||||
return { ok: false, error: "Not configured" };
|
||||
}
|
||||
try {
|
||||
ensureUrbitConnectPatched();
|
||||
const api = await Urbit.authenticate({
|
||||
const ssrfPolicy = account.allowPrivateNetwork ? { allowPrivateNetwork: true } : undefined;
|
||||
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) {
|
||||
return { ok: false, error: (error as { message?: string })?.message ?? String(error) };
|
||||
|
||||
@@ -19,6 +19,7 @@ export const TlonAccountSchema = z.object({
|
||||
ship: ShipSchema.optional(),
|
||||
url: z.string().optional(),
|
||||
code: z.string().optional(),
|
||||
allowPrivateNetwork: z.boolean().optional(),
|
||||
groupChannels: z.array(ChannelNestSchema).optional(),
|
||||
dmAllowlist: z.array(ShipSchema).optional(),
|
||||
autoDiscoverChannels: z.boolean().optional(),
|
||||
@@ -32,6 +33,7 @@ export const TlonConfigSchema = z.object({
|
||||
ship: ShipSchema.optional(),
|
||||
url: z.string().optional(),
|
||||
code: z.string().optional(),
|
||||
allowPrivateNetwork: z.boolean().optional(),
|
||||
groupChannels: z.array(ChannelNestSchema).optional(),
|
||||
dmAllowlist: z.array(ShipSchema).optional(),
|
||||
autoDiscoverChannels: z.boolean().optional(),
|
||||
|
||||
@@ -113,10 +113,12 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
|
||||
|
||||
let api: UrbitSSEClient | null = null;
|
||||
try {
|
||||
const ssrfPolicy = account.allowPrivateNetwork ? { allowPrivateNetwork: true } : undefined;
|
||||
runtime.log?.(`[tlon] Attempting authentication to ${account.url}...`);
|
||||
const cookie = await authenticate(account.url, account.code);
|
||||
const cookie = await authenticate(account.url, account.code, { ssrfPolicy });
|
||||
api = new UrbitSSEClient(account.url, cookie, {
|
||||
ship: botShipName,
|
||||
ssrfPolicy,
|
||||
logger: {
|
||||
log: (message) => runtime.log?.(message),
|
||||
error: (message) => runtime.error?.(message),
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "openclaw/plugin-sdk";
|
||||
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;
|
||||
|
||||
@@ -24,6 +25,7 @@ function applyAccountConfig(params: {
|
||||
ship?: string;
|
||||
url?: string;
|
||||
code?: string;
|
||||
allowPrivateNetwork?: boolean;
|
||||
groupChannels?: string[];
|
||||
dmAllowlist?: string[];
|
||||
autoDiscoverChannels?: boolean;
|
||||
@@ -45,6 +47,9 @@ function applyAccountConfig(params: {
|
||||
...(input.ship ? { ship: input.ship } : {}),
|
||||
...(input.url ? { url: input.url } : {}),
|
||||
...(input.code ? { code: input.code } : {}),
|
||||
...(typeof input.allowPrivateNetwork === "boolean"
|
||||
? { allowPrivateNetwork: input.allowPrivateNetwork }
|
||||
: {}),
|
||||
...(input.groupChannels ? { groupChannels: input.groupChannels } : {}),
|
||||
...(input.dmAllowlist ? { dmAllowlist: input.dmAllowlist } : {}),
|
||||
...(typeof input.autoDiscoverChannels === "boolean"
|
||||
@@ -73,6 +78,9 @@ function applyAccountConfig(params: {
|
||||
...(input.ship ? { ship: input.ship } : {}),
|
||||
...(input.url ? { url: input.url } : {}),
|
||||
...(input.code ? { code: input.code } : {}),
|
||||
...(typeof input.allowPrivateNetwork === "boolean"
|
||||
? { allowPrivateNetwork: input.allowPrivateNetwork }
|
||||
: {}),
|
||||
...(input.groupChannels ? { groupChannels: input.groupChannels } : {}),
|
||||
...(input.dmAllowlist ? { dmAllowlist: input.dmAllowlist } : {}),
|
||||
...(typeof input.autoDiscoverChannels === "boolean"
|
||||
@@ -91,6 +99,7 @@ async function noteTlonHelp(prompter: WizardPrompter): Promise<void> {
|
||||
"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",
|
||||
@@ -151,9 +160,32 @@ export const tlonOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
message: "Ship URL",
|
||||
placeholder: "https://your-ship-host",
|
||||
initialValue: resolved.url ?? undefined,
|
||||
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
||||
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",
|
||||
@@ -203,6 +235,7 @@ export const tlonOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
ship: String(ship).trim(),
|
||||
url: String(url).trim(),
|
||||
code: String(code).trim(),
|
||||
allowPrivateNetwork,
|
||||
groupChannels,
|
||||
dmAllowlist,
|
||||
autoDiscoverChannels,
|
||||
|
||||
@@ -8,6 +8,7 @@ export type TlonResolvedAccount = {
|
||||
ship: string | null;
|
||||
url: string | null;
|
||||
code: string | null;
|
||||
allowPrivateNetwork: boolean | null;
|
||||
groupChannels: string[];
|
||||
dmAllowlist: string[];
|
||||
autoDiscoverChannels: boolean | null;
|
||||
@@ -25,6 +26,7 @@ export function resolveTlonAccount(
|
||||
ship?: string;
|
||||
url?: string;
|
||||
code?: string;
|
||||
allowPrivateNetwork?: boolean;
|
||||
groupChannels?: string[];
|
||||
dmAllowlist?: string[];
|
||||
autoDiscoverChannels?: boolean;
|
||||
@@ -42,6 +44,7 @@ export function resolveTlonAccount(
|
||||
ship: null,
|
||||
url: null,
|
||||
code: null,
|
||||
allowPrivateNetwork: null,
|
||||
groupChannels: [],
|
||||
dmAllowlist: [],
|
||||
autoDiscoverChannels: null,
|
||||
@@ -55,6 +58,9 @@ export function resolveTlonAccount(
|
||||
const ship = (account?.ship ?? base.ship ?? null) as string | null;
|
||||
const url = (account?.url ?? base.url ?? null) as string | null;
|
||||
const code = (account?.code ?? base.code ?? null) as string | null;
|
||||
const allowPrivateNetwork = (account?.allowPrivateNetwork ?? base.allowPrivateNetwork ?? null) as
|
||||
| boolean
|
||||
| null;
|
||||
const groupChannels = (account?.groupChannels ?? base.groupChannels ?? []) as string[];
|
||||
const dmAllowlist = (account?.dmAllowlist ?? base.dmAllowlist ?? []) as string[];
|
||||
const autoDiscoverChannels = (account?.autoDiscoverChannels ??
|
||||
@@ -73,6 +79,7 @@ export function resolveTlonAccount(
|
||||
ship,
|
||||
url,
|
||||
code,
|
||||
allowPrivateNetwork,
|
||||
groupChannels,
|
||||
dmAllowlist,
|
||||
autoDiscoverChannels,
|
||||
|
||||
42
extensions/tlon/src/urbit/auth.ssrf.test.ts
Normal file
42
extensions/tlon/src/urbit/auth.ssrf.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { SsrFBlockedError } from "openclaw/plugin-sdk";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { authenticate } from "./auth.js";
|
||||
|
||||
describe("tlon urbit auth ssrf", () => {
|
||||
beforeEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("blocks private IPs by default", async () => {
|
||||
const mockFetch = vi.fn();
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
await expect(authenticate("http://127.0.0.1:8080", "code")).rejects.toBeInstanceOf(
|
||||
SsrFBlockedError,
|
||||
);
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows private IPs when allowPrivateNetwork is enabled", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => "ok",
|
||||
headers: new Headers({
|
||||
"set-cookie": "urbauth-~zod=123; Path=/; HttpOnly",
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
const cookie = await authenticate("http://127.0.0.1:8080", "code", {
|
||||
ssrfPolicy: { allowPrivateNetwork: true },
|
||||
lookupFn: async () => [{ address: "127.0.0.1", family: 4 }],
|
||||
});
|
||||
expect(cookie).toContain("urbauth-~zod=123");
|
||||
expect(mockFetch).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,18 +1,47 @@
|
||||
export async function authenticate(url: string, code: string): Promise<string> {
|
||||
const resp = await fetch(`${url}/~/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: `password=${code}`,
|
||||
import type { LookupFn, SsrFPolicy } from "openclaw/plugin-sdk";
|
||||
import { urbitFetch } from "./fetch.js";
|
||||
|
||||
export type UrbitAuthenticateOptions = {
|
||||
ssrfPolicy?: SsrFPolicy;
|
||||
lookupFn?: LookupFn;
|
||||
fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
export async function authenticate(
|
||||
url: string,
|
||||
code: string,
|
||||
options: UrbitAuthenticateOptions = {},
|
||||
): Promise<string> {
|
||||
const { response, release } = await urbitFetch({
|
||||
baseUrl: url,
|
||||
path: "/~/login",
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({ password: code }).toString(),
|
||||
},
|
||||
ssrfPolicy: options.ssrfPolicy,
|
||||
lookupFn: options.lookupFn,
|
||||
fetchImpl: options.fetchImpl,
|
||||
timeoutMs: options.timeoutMs ?? 15_000,
|
||||
maxRedirects: 3,
|
||||
auditContext: "tlon-urbit-login",
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Login failed with status ${resp.status}`);
|
||||
}
|
||||
try {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Login failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
await resp.text();
|
||||
const cookie = resp.headers.get("set-cookie");
|
||||
if (!cookie) {
|
||||
throw new Error("No authentication cookie received");
|
||||
// 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");
|
||||
}
|
||||
return cookie;
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
return cookie;
|
||||
}
|
||||
|
||||
49
extensions/tlon/src/urbit/base-url.ts
Normal file
49
extensions/tlon/src/urbit/base-url.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { isBlockedHostname, isPrivateIpAddress } from "openclaw/plugin-sdk";
|
||||
|
||||
export type UrbitBaseUrlValidation =
|
||||
| { ok: true; baseUrl: string; hostname: string }
|
||||
| { ok: false; error: string };
|
||||
|
||||
function hasScheme(value: string): boolean {
|
||||
return /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(value);
|
||||
}
|
||||
|
||||
export function validateUrbitBaseUrl(raw: string): UrbitBaseUrlValidation {
|
||||
const trimmed = String(raw ?? "").trim();
|
||||
if (!trimmed) {
|
||||
return { ok: false, error: "Required" };
|
||||
}
|
||||
|
||||
const candidate = hasScheme(trimmed) ? trimmed : `https://${trimmed}`;
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(candidate);
|
||||
} catch {
|
||||
return { ok: false, error: "Invalid URL" };
|
||||
}
|
||||
|
||||
if (!["http:", "https:"].includes(parsed.protocol)) {
|
||||
return { ok: false, error: "URL must use http:// or https://" };
|
||||
}
|
||||
|
||||
if (parsed.username || parsed.password) {
|
||||
return { ok: false, error: "URL must not include credentials" };
|
||||
}
|
||||
|
||||
const hostname = parsed.hostname.trim().toLowerCase().replace(/\.$/, "");
|
||||
if (!hostname) {
|
||||
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 };
|
||||
}
|
||||
|
||||
export function isBlockedUrbitHostname(hostname: string): boolean {
|
||||
const normalized = hostname.trim().toLowerCase().replace(/\.$/, "");
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
return isBlockedHostname(normalized) || isPrivateIpAddress(normalized);
|
||||
}
|
||||
248
extensions/tlon/src/urbit/channel-client.ts
Normal file
248
extensions/tlon/src/urbit/channel-client.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import type { LookupFn, SsrFPolicy } from "openclaw/plugin-sdk";
|
||||
import { validateUrbitBaseUrl } from "./base-url.js";
|
||||
import { urbitFetch } from "./fetch.js";
|
||||
|
||||
export type UrbitChannelClientOptions = {
|
||||
ship?: string;
|
||||
ssrfPolicy?: SsrFPolicy;
|
||||
lookupFn?: LookupFn;
|
||||
fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
};
|
||||
|
||||
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<Response>;
|
||||
|
||||
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();
|
||||
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) {
|
||||
throw new Error("Channel not opened");
|
||||
}
|
||||
return `/~/channel/${id}`;
|
||||
}
|
||||
|
||||
async open(): Promise<void> {
|
||||
if (this.channelId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.channelId = `${Math.floor(Date.now() / 1000)}-${Math.random().toString(36).substring(2, 8)}`;
|
||||
|
||||
// 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([]),
|
||||
},
|
||||
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",
|
||||
},
|
||||
]),
|
||||
},
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async poke(params: { app: string; mark: string; json: unknown }): Promise<number> {
|
||||
await this.open();
|
||||
const pokeId = Date.now();
|
||||
const pokeData = {
|
||||
id: pokeId,
|
||||
action: "poke",
|
||||
ship: this.ship,
|
||||
app: params.app,
|
||||
mark: params.mark,
|
||||
json: params.json,
|
||||
};
|
||||
|
||||
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([pokeData]),
|
||||
},
|
||||
ssrfPolicy: this.ssrfPolicy,
|
||||
lookupFn: this.lookupFn,
|
||||
fetchImpl: this.fetchImpl,
|
||||
timeoutMs: 30_000,
|
||||
auditContext: "tlon-urbit-poke",
|
||||
});
|
||||
|
||||
try {
|
||||
if (!response.ok && response.status !== 204) {
|
||||
const errorText = await response.text().catch(() => "");
|
||||
throw new Error(`Poke failed: ${response.status}${errorText ? ` - ${errorText}` : ""}`);
|
||||
}
|
||||
return pokeId;
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}
|
||||
|
||||
async scry(path: string): Promise<unknown> {
|
||||
const scryPath = `/~/scry${path}`;
|
||||
const { response, release } = await urbitFetch({
|
||||
baseUrl: this.baseUrl,
|
||||
path: scryPath,
|
||||
init: {
|
||||
method: "GET",
|
||||
headers: { Cookie: this.cookie },
|
||||
},
|
||||
ssrfPolicy: this.ssrfPolicy,
|
||||
lookupFn: this.lookupFn,
|
||||
fetchImpl: this.fetchImpl,
|
||||
timeoutMs: 30_000,
|
||||
auditContext: "tlon-urbit-scry",
|
||||
});
|
||||
|
||||
try {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Scry failed: ${response.status} for path ${path}`);
|
||||
}
|
||||
return await response.json();
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}
|
||||
|
||||
async getOurName(): Promise<string> {
|
||||
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<void> {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
38
extensions/tlon/src/urbit/fetch.ts
Normal file
38
extensions/tlon/src/urbit/fetch.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { LookupFn, SsrFPolicy } from "openclaw/plugin-sdk";
|
||||
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk";
|
||||
import { validateUrbitBaseUrl } from "./base-url.js";
|
||||
|
||||
export type UrbitFetchOptions = {
|
||||
baseUrl: string;
|
||||
path: string;
|
||||
init?: RequestInit;
|
||||
ssrfPolicy?: SsrFPolicy;
|
||||
lookupFn?: LookupFn;
|
||||
fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
timeoutMs?: number;
|
||||
maxRedirects?: number;
|
||||
signal?: AbortSignal;
|
||||
auditContext?: string;
|
||||
pinDns?: boolean;
|
||||
};
|
||||
|
||||
export async function urbitFetch(params: UrbitFetchOptions) {
|
||||
const validated = validateUrbitBaseUrl(params.baseUrl);
|
||||
if (!validated.ok) {
|
||||
throw new Error(validated.error);
|
||||
}
|
||||
|
||||
const url = new URL(params.path, validated.baseUrl).toString();
|
||||
return await fetchWithSsrFGuard({
|
||||
url,
|
||||
fetchImpl: params.fetchImpl,
|
||||
init: params.init,
|
||||
timeoutMs: params.timeoutMs,
|
||||
maxRedirects: params.maxRedirects,
|
||||
signal: params.signal,
|
||||
policy: params.ssrfPolicy,
|
||||
lookupFn: params.lookupFn,
|
||||
auditContext: params.auditContext,
|
||||
pinDns: params.pinDns,
|
||||
});
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { Urbit } from "@urbit/http-api";
|
||||
|
||||
let patched = false;
|
||||
|
||||
export function ensureUrbitConnectPatched() {
|
||||
if (patched) {
|
||||
return;
|
||||
}
|
||||
patched = true;
|
||||
Urbit.prototype.connect = async function patchedConnect() {
|
||||
const resp = await fetch(`${this.url}/~/login`, {
|
||||
method: "POST",
|
||||
body: `password=${this.code}`,
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (resp.status >= 400) {
|
||||
throw new Error(`Login failed with status ${resp.status}`);
|
||||
}
|
||||
|
||||
const cookie = resp.headers.get("set-cookie");
|
||||
if (cookie) {
|
||||
const match = /urbauth-~([\w-]+)/.exec(cookie);
|
||||
if (match) {
|
||||
if (!(this as unknown as { ship?: string | null }).ship) {
|
||||
(this as unknown as { ship?: string | null }).ship = match[1];
|
||||
}
|
||||
(this as unknown as { nodeId?: string }).nodeId = match[1];
|
||||
}
|
||||
(this as unknown as { cookie?: string }).cookie = cookie;
|
||||
}
|
||||
|
||||
await (this as typeof Urbit.prototype).getShipName();
|
||||
await (this as typeof Urbit.prototype).getOurName();
|
||||
};
|
||||
}
|
||||
|
||||
export { Urbit };
|
||||
@@ -16,7 +16,9 @@ describe("UrbitSSEClient", () => {
|
||||
it("sends subscriptions added after connect", async () => {
|
||||
mockFetch.mockResolvedValue({ ok: true, status: 200, text: async () => "" });
|
||||
|
||||
const client = new UrbitSSEClient("https://example.com", "urbauth-~zod=123");
|
||||
const client = new UrbitSSEClient("https://example.com", "urbauth-~zod=123", {
|
||||
lookupFn: async () => [{ address: "1.1.1.1", family: 4 }],
|
||||
});
|
||||
(client as { isConnected: boolean }).isConnected = true;
|
||||
|
||||
await client.subscribe({
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { LookupFn, SsrFPolicy } from "openclaw/plugin-sdk";
|
||||
import { Readable } from "node:stream";
|
||||
import { validateUrbitBaseUrl } from "./base-url.js";
|
||||
import { urbitFetch } from "./fetch.js";
|
||||
|
||||
export type UrbitSseLogger = {
|
||||
log?: (message: string) => void;
|
||||
@@ -7,6 +10,9 @@ export type UrbitSseLogger = {
|
||||
|
||||
type UrbitSseOptions = {
|
||||
ship?: string;
|
||||
ssrfPolicy?: SsrFPolicy;
|
||||
lookupFn?: LookupFn;
|
||||
fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
onReconnect?: (client: UrbitSSEClient) => Promise<void> | void;
|
||||
autoReconnect?: boolean;
|
||||
maxReconnectAttempts?: number;
|
||||
@@ -42,32 +48,38 @@ export class UrbitSSEClient {
|
||||
maxReconnectDelay: number;
|
||||
isConnected = false;
|
||||
logger: UrbitSseLogger;
|
||||
ssrfPolicy?: SsrFPolicy;
|
||||
lookupFn?: LookupFn;
|
||||
fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
streamRelease: (() => Promise<void>) | null = null;
|
||||
|
||||
constructor(url: string, cookie: string, options: UrbitSseOptions = {}) {
|
||||
this.url = url;
|
||||
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.resolveShipFromUrl(url);
|
||||
this.ship = options.ship?.replace(/^~/, "") ?? this.resolveShipFromHostname(validated.hostname);
|
||||
this.channelId = `${Math.floor(Date.now() / 1000)}-${Math.random().toString(36).substring(2, 8)}`;
|
||||
this.channelUrl = `${url}/~/channel/${this.channelId}`;
|
||||
this.channelUrl = new URL(`/~/channel/${this.channelId}`, this.url).toString();
|
||||
this.onReconnect = options.onReconnect ?? null;
|
||||
this.autoReconnect = options.autoReconnect !== false;
|
||||
this.maxReconnectAttempts = options.maxReconnectAttempts ?? 10;
|
||||
this.reconnectDelay = options.reconnectDelay ?? 1000;
|
||||
this.maxReconnectDelay = options.maxReconnectDelay ?? 30000;
|
||||
this.logger = options.logger ?? {};
|
||||
this.ssrfPolicy = options.ssrfPolicy;
|
||||
this.lookupFn = options.lookupFn;
|
||||
this.fetchImpl = options.fetchImpl;
|
||||
}
|
||||
|
||||
private resolveShipFromUrl(url: string): string {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const host = parsed.hostname;
|
||||
if (host.includes(".")) {
|
||||
return host.split(".")[0] ?? host;
|
||||
}
|
||||
return host;
|
||||
} catch {
|
||||
return "";
|
||||
private resolveShipFromHostname(hostname: string): string {
|
||||
if (hostname.includes(".")) {
|
||||
return hostname.split(".")[0] ?? hostname;
|
||||
}
|
||||
return hostname;
|
||||
}
|
||||
|
||||
async subscribe(params: {
|
||||
@@ -107,58 +119,100 @@ export class UrbitSSEClient {
|
||||
app: string;
|
||||
path: string;
|
||||
}) {
|
||||
const response = await fetch(this.channelUrl, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Cookie: this.cookie,
|
||||
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([subscription]),
|
||||
},
|
||||
body: JSON.stringify([subscription]),
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
ssrfPolicy: this.ssrfPolicy,
|
||||
lookupFn: this.lookupFn,
|
||||
fetchImpl: this.fetchImpl,
|
||||
timeoutMs: 30_000,
|
||||
auditContext: "tlon-urbit-subscribe",
|
||||
});
|
||||
|
||||
if (!response.ok && response.status !== 204) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Subscribe failed: ${response.status} - ${errorText}`);
|
||||
try {
|
||||
if (!response.ok && response.status !== 204) {
|
||||
const errorText = await response.text().catch(() => "");
|
||||
throw new Error(
|
||||
`Subscribe failed: ${response.status}${errorText ? ` - ${errorText}` : ""}`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}
|
||||
|
||||
async connect() {
|
||||
const createResp = await fetch(this.channelUrl, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Cookie: this.cookie,
|
||||
},
|
||||
body: JSON.stringify(this.subscriptions),
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
{
|
||||
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(this.subscriptions),
|
||||
},
|
||||
ssrfPolicy: this.ssrfPolicy,
|
||||
lookupFn: this.lookupFn,
|
||||
fetchImpl: this.fetchImpl,
|
||||
timeoutMs: 30_000,
|
||||
auditContext: "tlon-urbit-channel-create",
|
||||
});
|
||||
|
||||
if (!createResp.ok && createResp.status !== 204) {
|
||||
throw new Error(`Channel creation failed: ${createResp.status}`);
|
||||
try {
|
||||
if (!response.ok && response.status !== 204) {
|
||||
throw new Error(`Channel creation failed: ${response.status}`);
|
||||
}
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}
|
||||
|
||||
const pokeResp = await fetch(this.channelUrl, {
|
||||
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",
|
||||
{
|
||||
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",
|
||||
},
|
||||
]),
|
||||
},
|
||||
]),
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
ssrfPolicy: this.ssrfPolicy,
|
||||
lookupFn: this.lookupFn,
|
||||
fetchImpl: this.fetchImpl,
|
||||
timeoutMs: 30_000,
|
||||
auditContext: "tlon-urbit-channel-wake",
|
||||
});
|
||||
|
||||
if (!pokeResp.ok && pokeResp.status !== 204) {
|
||||
throw new Error(`Channel activation failed: ${pokeResp.status}`);
|
||||
try {
|
||||
if (!response.ok && response.status !== 204) {
|
||||
throw new Error(`Channel activation failed: ${response.status}`);
|
||||
}
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}
|
||||
|
||||
await this.openStream();
|
||||
@@ -172,19 +226,33 @@ export class UrbitSSEClient {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 60_000);
|
||||
|
||||
const response = await fetch(this.channelUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "text/event-stream",
|
||||
Cookie: this.cookie,
|
||||
this.streamController = controller;
|
||||
|
||||
const { response, release } = await urbitFetch({
|
||||
baseUrl: this.url,
|
||||
path: `/~/channel/${this.channelId}`,
|
||||
init: {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "text/event-stream",
|
||||
Cookie: this.cookie,
|
||||
},
|
||||
},
|
||||
ssrfPolicy: this.ssrfPolicy,
|
||||
lookupFn: this.lookupFn,
|
||||
fetchImpl: this.fetchImpl,
|
||||
signal: controller.signal,
|
||||
auditContext: "tlon-urbit-sse-stream",
|
||||
});
|
||||
|
||||
// Clear timeout once connection established (headers received)
|
||||
this.streamRelease = release;
|
||||
|
||||
// Clear timeout once connection established (headers received).
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
await release();
|
||||
this.streamRelease = null;
|
||||
throw new Error(`Stream connection failed: ${response.status}`);
|
||||
}
|
||||
|
||||
@@ -222,6 +290,12 @@ export class UrbitSSEClient {
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (this.streamRelease) {
|
||||
const release = this.streamRelease;
|
||||
this.streamRelease = null;
|
||||
await release();
|
||||
}
|
||||
this.streamController = null;
|
||||
if (!this.aborted && this.autoReconnect) {
|
||||
this.isConnected = false;
|
||||
this.logger.log?.("[SSE] Stream ended, attempting reconnection...");
|
||||
@@ -285,39 +359,61 @@ export class UrbitSSEClient {
|
||||
json: params.json,
|
||||
};
|
||||
|
||||
const response = await fetch(this.channelUrl, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Cookie: this.cookie,
|
||||
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([pokeData]),
|
||||
},
|
||||
body: JSON.stringify([pokeData]),
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
ssrfPolicy: this.ssrfPolicy,
|
||||
lookupFn: this.lookupFn,
|
||||
fetchImpl: this.fetchImpl,
|
||||
timeoutMs: 30_000,
|
||||
auditContext: "tlon-urbit-poke",
|
||||
});
|
||||
|
||||
if (!response.ok && response.status !== 204) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Poke failed: ${response.status} - ${errorText}`);
|
||||
try {
|
||||
if (!response.ok && response.status !== 204) {
|
||||
const errorText = await response.text().catch(() => "");
|
||||
throw new Error(`Poke failed: ${response.status}${errorText ? ` - ${errorText}` : ""}`);
|
||||
}
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
|
||||
return pokeId;
|
||||
}
|
||||
|
||||
async scry(path: string) {
|
||||
const scryUrl = `${this.url}/~/scry${path}`;
|
||||
const response = await fetch(scryUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Cookie: this.cookie,
|
||||
const { response, release } = await urbitFetch({
|
||||
baseUrl: this.url,
|
||||
path: `/~/scry${path}`,
|
||||
init: {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Cookie: this.cookie,
|
||||
},
|
||||
},
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
ssrfPolicy: this.ssrfPolicy,
|
||||
lookupFn: this.lookupFn,
|
||||
fetchImpl: this.fetchImpl,
|
||||
timeoutMs: 30_000,
|
||||
auditContext: "tlon-urbit-scry",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Scry failed: ${response.status} for path ${path}`);
|
||||
try {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Scry failed: ${response.status} for path ${path}`);
|
||||
}
|
||||
return await response.json();
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
async attemptReconnect() {
|
||||
@@ -347,7 +443,7 @@ export class UrbitSSEClient {
|
||||
|
||||
try {
|
||||
this.channelId = `${Math.floor(Date.now() / 1000)}-${Math.random().toString(36).substring(2, 8)}`;
|
||||
this.channelUrl = `${this.url}/~/channel/${this.channelId}`;
|
||||
this.channelUrl = new URL(`/~/channel/${this.channelId}`, this.url).toString();
|
||||
|
||||
if (this.onReconnect) {
|
||||
await this.onReconnect(this);
|
||||
@@ -364,6 +460,7 @@ export class UrbitSSEClient {
|
||||
async close() {
|
||||
this.aborted = true;
|
||||
this.isConnected = false;
|
||||
this.streamController?.abort();
|
||||
|
||||
try {
|
||||
const unsubscribes = this.subscriptions.map((sub) => ({
|
||||
@@ -372,25 +469,61 @@ export class UrbitSSEClient {
|
||||
subscription: sub.id,
|
||||
}));
|
||||
|
||||
await fetch(this.channelUrl, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Cookie: this.cookie,
|
||||
},
|
||||
body: JSON.stringify(unsubscribes),
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
{
|
||||
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(unsubscribes),
|
||||
},
|
||||
ssrfPolicy: this.ssrfPolicy,
|
||||
lookupFn: this.lookupFn,
|
||||
fetchImpl: this.fetchImpl,
|
||||
timeoutMs: 30_000,
|
||||
auditContext: "tlon-urbit-unsubscribe",
|
||||
});
|
||||
try {
|
||||
void response.body?.cancel();
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}
|
||||
|
||||
await fetch(this.channelUrl, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
Cookie: this.cookie,
|
||||
},
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
{
|
||||
const { response, release } = await urbitFetch({
|
||||
baseUrl: this.url,
|
||||
path: `/~/channel/${this.channelId}`,
|
||||
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 (error) {
|
||||
this.logger.error?.(`Error closing channel: ${String(error)}`);
|
||||
}
|
||||
|
||||
if (this.streamRelease) {
|
||||
const release = this.streamRelease;
|
||||
this.streamRelease = null;
|
||||
await release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
22
pnpm-lock.yaml
generated
22
pnpm-lock.yaml
generated
@@ -495,9 +495,6 @@ importers:
|
||||
'@urbit/aura':
|
||||
specifier: ^3.0.0
|
||||
version: 3.0.0
|
||||
'@urbit/http-api':
|
||||
specifier: ^3.0.0
|
||||
version: 3.0.0
|
||||
devDependencies:
|
||||
openclaw:
|
||||
specifier: workspace:*
|
||||
@@ -3118,9 +3115,6 @@ packages:
|
||||
resolution: {integrity: sha512-N8/FHc/lmlMDCumMuTXyRHCxlov5KZY6unmJ9QR2GOw+OpROZMBsXYGwE+ZMtvN21ql9+Xb8KhGNBj08IrG3Wg==}
|
||||
engines: {node: '>=16', npm: '>=8'}
|
||||
|
||||
'@urbit/http-api@3.0.0':
|
||||
resolution: {integrity: sha512-EmyPbWHWXhfYQ/9wWFcLT53VvCn8ct9ljd6QEe+UBjNPEhUPOFBLpDsDp3iPLQgg8ykSU8JMMHxp95LHCorExA==}
|
||||
|
||||
'@vector-im/matrix-bot-sdk@0.8.0-element.3':
|
||||
resolution: {integrity: sha512-2FFo/Kz2vTnOZDv59Q0s803LHf7KzuQ2EwOYYAtO0zUKJ8pV5CPsVC/IHyFb+Fsxl3R9XWFiX529yhslb4v9cQ==}
|
||||
engines: {node: '>=22.0.0'}
|
||||
@@ -3416,9 +3410,6 @@ packages:
|
||||
resolution: {integrity: sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
browser-or-node@1.3.0:
|
||||
resolution: {integrity: sha512-0F2z/VSnLbmEeBcUrSuDH5l0HxTXdQQzLjkmBR4cYfvg1zJrKSlmIZFqyFR8oX0NrwPhy3c3HQ6i3OxMbew4Tg==}
|
||||
|
||||
buffer-equal-constant-time@1.0.1:
|
||||
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
|
||||
|
||||
@@ -3569,9 +3560,6 @@ packages:
|
||||
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
core-js@3.48.0:
|
||||
resolution: {integrity: sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==}
|
||||
|
||||
core-util-is@1.0.2:
|
||||
resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==}
|
||||
|
||||
@@ -8647,12 +8635,6 @@ snapshots:
|
||||
|
||||
'@urbit/aura@3.0.0': {}
|
||||
|
||||
'@urbit/http-api@3.0.0':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.6
|
||||
browser-or-node: 1.3.0
|
||||
core-js: 3.48.0
|
||||
|
||||
'@vector-im/matrix-bot-sdk@0.8.0-element.3':
|
||||
dependencies:
|
||||
'@matrix-org/matrix-sdk-crypto-nodejs': 0.4.0
|
||||
@@ -9034,8 +9016,6 @@ snapshots:
|
||||
dependencies:
|
||||
balanced-match: 4.0.2
|
||||
|
||||
browser-or-node@1.3.0: {}
|
||||
|
||||
buffer-equal-constant-time@1.0.1: {}
|
||||
|
||||
buffer-from@1.1.2: {}
|
||||
@@ -9187,8 +9167,6 @@ snapshots:
|
||||
|
||||
cookie@0.7.2: {}
|
||||
|
||||
core-js@3.48.0: {}
|
||||
|
||||
core-util-is@1.0.2: {}
|
||||
|
||||
core-util-is@1.0.3: {}
|
||||
|
||||
@@ -146,6 +146,10 @@ export {
|
||||
readRequestBodyWithLimit,
|
||||
requestBodyErrorToText,
|
||||
} from "../infra/http-body.js";
|
||||
|
||||
export { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js";
|
||||
export { SsrFBlockedError, isBlockedHostname, isPrivateIpAddress } from "../infra/net/ssrf.js";
|
||||
export type { LookupFn, SsrFPolicy } from "../infra/net/ssrf.js";
|
||||
export { isWSLSync, isWSL2Sync, isWSLEnv } from "../infra/wsl.js";
|
||||
export { isTruthyEnvValue } from "../infra/env.js";
|
||||
export { resolveToolsBySender } from "../config/group-policy.js";
|
||||
|
||||
Reference in New Issue
Block a user