@syengup/friday-channel-next 0.1.26 → 0.1.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/index.js +2 -0
  2. package/dist/src/http/handlers/files.js +1 -0
  3. package/dist/src/http/handlers/health.d.ts +1 -0
  4. package/dist/src/http/handlers/health.js +2 -0
  5. package/dist/src/http/handlers/link-preview.d.ts +9 -0
  6. package/dist/src/http/handlers/link-preview.js +41 -0
  7. package/dist/src/http/handlers/messages.d.ts +5 -0
  8. package/dist/src/http/handlers/messages.js +19 -8
  9. package/dist/src/http/handlers/plugin-info.d.ts +11 -0
  10. package/dist/src/http/handlers/plugin-info.js +32 -0
  11. package/dist/src/http/handlers/plugin-upgrade.d.ts +11 -0
  12. package/dist/src/http/handlers/plugin-upgrade.js +94 -0
  13. package/dist/src/http/handlers/sse.js +2 -0
  14. package/dist/src/http/handlers/status.js +2 -0
  15. package/dist/src/http/server.js +15 -0
  16. package/dist/src/link-preview/og-parse.d.ts +21 -0
  17. package/dist/src/link-preview/og-parse.js +232 -0
  18. package/dist/src/link-preview/preview-service.d.ts +31 -0
  19. package/dist/src/link-preview/preview-service.js +216 -0
  20. package/dist/src/link-preview/ssrf-guard.d.ts +43 -0
  21. package/dist/src/link-preview/ssrf-guard.js +223 -0
  22. package/dist/src/plugin-install-info.d.ts +15 -0
  23. package/dist/src/plugin-install-info.js +87 -0
  24. package/dist/src/upgrade-runtime.d.ts +39 -0
  25. package/dist/src/upgrade-runtime.js +27 -0
  26. package/dist/src/version.d.ts +5 -0
  27. package/dist/src/version.js +37 -0
  28. package/index.ts +2 -0
  29. package/package.json +1 -1
  30. package/src/http/handlers/files.ts +1 -0
  31. package/src/http/handlers/health.ts +3 -0
  32. package/src/http/handlers/link-preview.test.ts +242 -0
  33. package/src/http/handlers/link-preview.ts +47 -0
  34. package/src/http/handlers/messages.test.ts +75 -1
  35. package/src/http/handlers/messages.ts +19 -7
  36. package/src/http/handlers/plugin-info.ts +51 -0
  37. package/src/http/handlers/plugin-upgrade.ts +112 -0
  38. package/src/http/handlers/sse.ts +2 -0
  39. package/src/http/handlers/status.ts +2 -0
  40. package/src/http/server.ts +18 -0
  41. package/src/link-preview/og-parse.test.ts +168 -0
  42. package/src/link-preview/og-parse.ts +249 -0
  43. package/src/link-preview/preview-service.ts +247 -0
  44. package/src/link-preview/ssrf-guard.test.ts +234 -0
  45. package/src/link-preview/ssrf-guard.ts +229 -0
  46. package/src/plugin-install-info.test.ts +28 -0
  47. package/src/plugin-install-info.ts +95 -0
  48. package/src/upgrade-runtime.ts +69 -0
  49. package/src/version.ts +41 -0
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Link-preview orchestration: fetch page → parse Open Graph → re-host the cover image through
3
+ * the gateway's stored files → cache.
4
+ *
5
+ * The cover image is downloaded server-side and served from /friday-next/files/ so the app only
6
+ * ever talks to the trusted gateway host (same rationale as `downloadRemoteMedia` for outbound
7
+ * media). Failures degrade to "no card" on the app side, so every error path returns a typed
8
+ * error instead of throwing.
9
+ */
10
+ export interface LinkPreviewPayload {
11
+ url: string;
12
+ finalUrl: string;
13
+ siteName: string | null;
14
+ title: string;
15
+ description: string | null;
16
+ /** Gateway-relative cover URL ("/friday-next/files/{token}") or null. */
17
+ imageUrl: string | null;
18
+ /** Gateway-relative favicon URL ("/friday-next/files/{token}") or null. */
19
+ iconUrl: string | null;
20
+ fetchedAt: number;
21
+ }
22
+ export type LinkPreviewError = "invalid_url" | "blocked_url" | "fetch_failed" | "no_metadata";
23
+ export type LinkPreviewResult = {
24
+ ok: true;
25
+ preview: LinkPreviewPayload;
26
+ } | {
27
+ ok: false;
28
+ error: LinkPreviewError;
29
+ };
30
+ export declare function resetLinkPreviewCacheForTest(): void;
31
+ export declare function getLinkPreview(rawUrl: string): Promise<LinkPreviewResult>;
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Link-preview orchestration: fetch page → parse Open Graph → re-host the cover image through
3
+ * the gateway's stored files → cache.
4
+ *
5
+ * The cover image is downloaded server-side and served from /friday-next/files/ so the app only
6
+ * ever talks to the trusted gateway host (same rationale as `downloadRemoteMedia` for outbound
7
+ * media). Failures degrade to "no card" on the app side, so every error path returns a typed
8
+ * error instead of throwing.
9
+ */
10
+ import { createFridayNextLogger } from "../logging.js";
11
+ import { storeFile } from "../http/handlers/files.js";
12
+ import { parseOpenGraph } from "./og-parse.js";
13
+ import { BlockedUrlError, fetchPublicUrl, parseHttpUrl } from "./ssrf-guard.js";
14
+ const HTML_MAX_BYTES = 2 * 1024 * 1024;
15
+ const HTML_TIMEOUT_MS = 10_000;
16
+ const IMAGE_MAX_BYTES = 8 * 1024 * 1024;
17
+ const IMAGE_TIMEOUT_MS = 10_000;
18
+ const SUCCESS_TTL_MS = 24 * 60 * 60 * 1000;
19
+ const FAILURE_TTL_MS = 10 * 60 * 1000;
20
+ const MAX_CACHE_ENTRIES = 1000;
21
+ const logger = createFridayNextLogger("link-preview");
22
+ const cache = new Map();
23
+ const inFlight = new Map();
24
+ export function resetLinkPreviewCacheForTest() {
25
+ cache.clear();
26
+ inFlight.clear();
27
+ }
28
+ export async function getLinkPreview(rawUrl) {
29
+ const parsed = parseHttpUrl(rawUrl);
30
+ if (!parsed)
31
+ return { ok: false, error: "invalid_url" };
32
+ const key = parsed.toString();
33
+ const cached = cache.get(key);
34
+ if (cached) {
35
+ const ttl = cached.result.ok ? SUCCESS_TTL_MS : FAILURE_TTL_MS;
36
+ if (Date.now() - cached.cachedAt < ttl)
37
+ return cached.result;
38
+ cache.delete(key);
39
+ }
40
+ const pending = inFlight.get(key);
41
+ if (pending)
42
+ return pending;
43
+ const task = buildPreview(key)
44
+ .then((result) => {
45
+ writeCache(key, result);
46
+ return result;
47
+ })
48
+ .finally(() => {
49
+ inFlight.delete(key);
50
+ });
51
+ inFlight.set(key, task);
52
+ return task;
53
+ }
54
+ function writeCache(key, result) {
55
+ if (cache.size >= MAX_CACHE_ENTRIES) {
56
+ let oldestKey = null;
57
+ let oldestAt = Infinity;
58
+ for (const [k, entry] of cache) {
59
+ if (entry.cachedAt < oldestAt) {
60
+ oldestAt = entry.cachedAt;
61
+ oldestKey = k;
62
+ }
63
+ }
64
+ if (oldestKey)
65
+ cache.delete(oldestKey);
66
+ }
67
+ cache.set(key, { result, cachedAt: Date.now() });
68
+ }
69
+ async function buildPreview(pageUrl) {
70
+ let page;
71
+ try {
72
+ page = await fetchPublicUrl(pageUrl, {
73
+ maxBytes: HTML_MAX_BYTES,
74
+ timeoutMs: HTML_TIMEOUT_MS,
75
+ accept: "text/html,application/xhtml+xml",
76
+ requireContentTypePrefixes: ["text/html", "application/xhtml+xml"],
77
+ });
78
+ }
79
+ catch (err) {
80
+ if (err instanceof BlockedUrlError) {
81
+ logger.warn(`link-preview blocked: ${pageUrl} (${err.reason})`);
82
+ return { ok: false, error: "blocked_url" };
83
+ }
84
+ page = null; // network/timeout — fall through to a favicon-only minimal card
85
+ }
86
+ const finalUrl = page?.finalUrl ?? pageUrl;
87
+ const og = page ? parseOpenGraph(page.body.toString("utf8"), finalUrl) : null;
88
+ const hostname = (() => {
89
+ try {
90
+ return new URL(finalUrl).hostname;
91
+ }
92
+ catch {
93
+ return null;
94
+ }
95
+ })();
96
+ // Favicon: parsed <link rel icon> first, then the conventional /favicon.ico (which is reachable
97
+ // even for pages that block bots, e.g. zhihu → redirects to its CDN icon).
98
+ const iconUrl = await resolveFavicon(og?.iconUrl ?? null, finalUrl);
99
+ // A failed page fetch only yields a (minimal) card when the favicon resolved — that proves the
100
+ // domain is real/reachable (e.g. bot-blocked zhihu). A dead domain (favicon also fails) collapses.
101
+ const reachable = page !== null || iconUrl !== null;
102
+ const title = og?.title ?? hostname;
103
+ if (!reachable || !title) {
104
+ return { ok: false, error: page ? "no_metadata" : "fetch_failed" };
105
+ }
106
+ const imageUrl = og?.imageUrl ? await rehostCoverImage(og.imageUrl) : null;
107
+ return {
108
+ ok: true,
109
+ preview: {
110
+ url: pageUrl,
111
+ finalUrl,
112
+ siteName: og?.siteName ?? hostname,
113
+ title: title ?? hostname ?? pageUrl,
114
+ description: og?.description ?? null,
115
+ imageUrl,
116
+ iconUrl,
117
+ fetchedAt: Date.now(),
118
+ },
119
+ };
120
+ }
121
+ /** Re-host a favicon: try the parsed `<link rel icon>`, then `<origin>/favicon.ico`. */
122
+ async function resolveFavicon(parsedIconUrl, finalUrl) {
123
+ const candidates = [];
124
+ if (parsedIconUrl)
125
+ candidates.push(parsedIconUrl);
126
+ try {
127
+ candidates.push(new URL("/favicon.ico", finalUrl).toString());
128
+ }
129
+ catch {
130
+ // finalUrl unparseable — skip the conventional fallback
131
+ }
132
+ for (const candidate of candidates) {
133
+ const rehosted = await rehostIconImage(candidate);
134
+ if (rehosted)
135
+ return rehosted;
136
+ }
137
+ return null;
138
+ }
139
+ /** Download a favicon (full SSRF checks) and re-publish via stored files. Null on any failure. */
140
+ async function rehostIconImage(iconUrl) {
141
+ let image;
142
+ try {
143
+ image = await fetchPublicUrl(iconUrl, {
144
+ maxBytes: 1024 * 1024,
145
+ timeoutMs: IMAGE_TIMEOUT_MS,
146
+ accept: "image/*",
147
+ requireContentTypePrefixes: ["image/"],
148
+ });
149
+ }
150
+ catch {
151
+ return null;
152
+ }
153
+ if (!image)
154
+ return null;
155
+ const sniffed = sniffImageType(image.body);
156
+ if (!sniffed)
157
+ return null;
158
+ try {
159
+ const stored = storeFile(image.body, `link-preview-icon.${sniffed.ext}`, sniffed.mime);
160
+ return `/friday-next/files/${encodeURIComponent(stored.urlToken)}`;
161
+ }
162
+ catch {
163
+ return null;
164
+ }
165
+ }
166
+ /** Download og:image (full SSRF checks) and re-publish via stored files. Null on any failure. */
167
+ async function rehostCoverImage(imageUrl) {
168
+ let image;
169
+ try {
170
+ image = await fetchPublicUrl(imageUrl, {
171
+ maxBytes: IMAGE_MAX_BYTES,
172
+ timeoutMs: IMAGE_TIMEOUT_MS,
173
+ accept: "image/*",
174
+ requireContentTypePrefixes: ["image/"],
175
+ });
176
+ }
177
+ catch {
178
+ return null; // blocked og:image just means no cover
179
+ }
180
+ if (!image)
181
+ return null;
182
+ const sniffed = sniffImageType(image.body);
183
+ if (!sniffed)
184
+ return null;
185
+ try {
186
+ const stored = storeFile(image.body, `link-preview-cover.${sniffed.ext}`, sniffed.mime);
187
+ return `/friday-next/files/${encodeURIComponent(stored.urlToken)}`;
188
+ }
189
+ catch (err) {
190
+ logger.warn(`link-preview cover store failed for ${imageUrl}: ${String(err)}`);
191
+ return null;
192
+ }
193
+ }
194
+ /** Magic-byte sniff — second line of defense after the Content-Type check. */
195
+ function sniffImageType(buffer) {
196
+ if (buffer.length < 12)
197
+ return null;
198
+ // ICO: 00 00 01 00 (favicons are commonly .ico; iOS ImageIO decodes them).
199
+ if (buffer[0] === 0x00 && buffer[1] === 0x00 && buffer[2] === 0x01 && buffer[3] === 0x00) {
200
+ return { ext: "ico", mime: "image/x-icon" };
201
+ }
202
+ if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) {
203
+ return { ext: "png", mime: "image/png" };
204
+ }
205
+ if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
206
+ return { ext: "jpg", mime: "image/jpeg" };
207
+ }
208
+ if (buffer.subarray(0, 4).toString("latin1") === "GIF8") {
209
+ return { ext: "gif", mime: "image/gif" };
210
+ }
211
+ if (buffer.subarray(0, 4).toString("latin1") === "RIFF" &&
212
+ buffer.subarray(8, 12).toString("latin1") === "WEBP") {
213
+ return { ext: "webp", mime: "image/webp" };
214
+ }
215
+ return null;
216
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * SSRF guard + restricted fetch for the link-preview endpoint.
3
+ *
4
+ * Unlike `downloadRemoteMedia` (agent-supplied URLs for outbound media), link-preview fetches
5
+ * URLs that originate from arbitrary message text, so every hop must be validated: protocol,
6
+ * port, hostname literals, and the full set of DNS-resolved addresses. Redirects are followed
7
+ * manually so each target is re-checked before the next request.
8
+ *
9
+ * Known residual risk: DNS rebinding TOCTOU — we validate resolved addresses, then `fetch`
10
+ * resolves again. Closing that gap requires dialing by IP with SNI/Host rewriting, which is
11
+ * disproportionate for preview metadata; accepted at this threat level.
12
+ */
13
+ export declare class BlockedUrlError extends Error {
14
+ readonly reason: string;
15
+ constructor(reason: string);
16
+ }
17
+ /** Parse an absolute http/https URL. Returns null for anything else (caller maps to invalid_url). */
18
+ export declare function parseHttpUrl(raw: string): URL | null;
19
+ /** Synchronous literal checks: port, hostname blocklist, IP-literal hosts. Throws BlockedUrlError. */
20
+ export declare function assertPublicHttpUrl(url: URL): void;
21
+ /** True when the IP (v4 or v6, including ::ffff: mapped v4) is private, loopback, or reserved. */
22
+ export declare function isPrivateAddress(ip: string): boolean;
23
+ /** Resolve the hostname and require every returned address to be public. Throws BlockedUrlError. */
24
+ export declare function assertResolvesPublic(url: URL): Promise<void>;
25
+ export interface FetchPublicUrlOptions {
26
+ maxBytes: number;
27
+ timeoutMs: number;
28
+ /** Sent as the Accept header. */
29
+ accept?: string;
30
+ /** When set, the response Content-Type must start with one of these prefixes. */
31
+ requireContentTypePrefixes?: string[];
32
+ }
33
+ export interface FetchPublicUrlResult {
34
+ finalUrl: string;
35
+ contentType: string;
36
+ body: Buffer;
37
+ }
38
+ /**
39
+ * Fetch a public http/https URL with manual redirects (≤5 hops, each hop re-validated) and a
40
+ * streamed size cap (Content-Length is not trusted). Returns null on ordinary failures (non-2xx,
41
+ * oversize, timeout, bad content type, DNS error); throws BlockedUrlError on SSRF rejection.
42
+ */
43
+ export declare function fetchPublicUrl(rawUrl: string, opts: FetchPublicUrlOptions): Promise<FetchPublicUrlResult | null>;
@@ -0,0 +1,223 @@
1
+ /**
2
+ * SSRF guard + restricted fetch for the link-preview endpoint.
3
+ *
4
+ * Unlike `downloadRemoteMedia` (agent-supplied URLs for outbound media), link-preview fetches
5
+ * URLs that originate from arbitrary message text, so every hop must be validated: protocol,
6
+ * port, hostname literals, and the full set of DNS-resolved addresses. Redirects are followed
7
+ * manually so each target is re-checked before the next request.
8
+ *
9
+ * Known residual risk: DNS rebinding TOCTOU — we validate resolved addresses, then `fetch`
10
+ * resolves again. Closing that gap requires dialing by IP with SNI/Host rewriting, which is
11
+ * disproportionate for preview metadata; accepted at this threat level.
12
+ */
13
+ import dns from "node:dns/promises";
14
+ import net from "node:net";
15
+ const MAX_REDIRECTS = 5;
16
+ export class BlockedUrlError extends Error {
17
+ reason;
18
+ constructor(reason) {
19
+ super(`Blocked URL: ${reason}`);
20
+ this.name = "BlockedUrlError";
21
+ this.reason = reason;
22
+ }
23
+ }
24
+ /** Parse an absolute http/https URL. Returns null for anything else (caller maps to invalid_url). */
25
+ export function parseHttpUrl(raw) {
26
+ let url;
27
+ try {
28
+ url = new URL(raw.trim());
29
+ }
30
+ catch {
31
+ return null;
32
+ }
33
+ if (url.protocol !== "http:" && url.protocol !== "https:")
34
+ return null;
35
+ return url;
36
+ }
37
+ const BLOCKED_HOST_SUFFIXES = [".local", ".internal", ".home.arpa", ".localhost"];
38
+ /** Synchronous literal checks: port, hostname blocklist, IP-literal hosts. Throws BlockedUrlError. */
39
+ export function assertPublicHttpUrl(url) {
40
+ if (url.port && url.port !== "80" && url.port !== "443") {
41
+ throw new BlockedUrlError(`port ${url.port} not allowed`);
42
+ }
43
+ const host = url.hostname.toLowerCase().replace(/\.$/, "");
44
+ if (!host)
45
+ throw new BlockedUrlError("empty host");
46
+ if (host === "localhost" || BLOCKED_HOST_SUFFIXES.some((s) => host.endsWith(s))) {
47
+ throw new BlockedUrlError(`host "${host}" is not public`);
48
+ }
49
+ // IPv6 literal in a URL comes bracketed: strip for net.isIP.
50
+ const bareHost = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
51
+ if (net.isIP(bareHost) && isPrivateAddress(bareHost)) {
52
+ throw new BlockedUrlError(`address ${bareHost} is private/reserved`);
53
+ }
54
+ }
55
+ /** True when the IP (v4 or v6, including ::ffff: mapped v4) is private, loopback, or reserved. */
56
+ export function isPrivateAddress(ip) {
57
+ const version = net.isIP(ip);
58
+ if (version === 4)
59
+ return isPrivateIPv4(ip);
60
+ if (version === 6)
61
+ return isPrivateIPv6(ip);
62
+ return true; // not an IP at all — treat as unsafe
63
+ }
64
+ function isPrivateIPv4(ip) {
65
+ const parts = ip.split(".").map(Number);
66
+ if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255))
67
+ return true;
68
+ const [a, b] = parts;
69
+ if (a === 0)
70
+ return true; // 0.0.0.0/8
71
+ if (a === 10)
72
+ return true; // 10/8
73
+ if (a === 100 && b >= 64 && b <= 127)
74
+ return true; // 100.64/10 CGNAT
75
+ if (a === 127)
76
+ return true; // loopback
77
+ if (a === 169 && b === 254)
78
+ return true; // link-local
79
+ if (a === 172 && b >= 16 && b <= 31)
80
+ return true; // 172.16/12
81
+ if (a === 192 && b === 0 && parts[2] === 0)
82
+ return true; // 192.0.0/24
83
+ if (a === 192 && b === 168)
84
+ return true; // 192.168/16
85
+ // 198.18/15 (benchmarking) intentionally NOT blocked: fake-IP DNS setups (clash/surge tun
86
+ // mode, common on gateway hosts) resolve EVERY domain into this range, so blocking it kills
87
+ // all lookups. The range is not used for real LAN services, so the SSRF exposure is nil.
88
+ if (a >= 224)
89
+ return true; // multicast 224/4 + reserved 240/4 + broadcast
90
+ return false;
91
+ }
92
+ function isPrivateIPv6(ip) {
93
+ const lower = ip.toLowerCase();
94
+ // ::ffff:a.b.c.d mapped IPv4 → validate the embedded v4.
95
+ const mapped = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
96
+ if (mapped)
97
+ return isPrivateIPv4(mapped[1]);
98
+ if (lower === "::" || lower === "::1")
99
+ return true; // unspecified / loopback
100
+ const head = lower.split(":")[0];
101
+ if (head.startsWith("fc") || head.startsWith("fd"))
102
+ return true; // fc00::/7 ULA
103
+ if (/^fe[89ab]/.test(head))
104
+ return true; // fe80::/10 link-local
105
+ return false;
106
+ }
107
+ /** Resolve the hostname and require every returned address to be public. Throws BlockedUrlError. */
108
+ export async function assertResolvesPublic(url) {
109
+ const host = url.hostname.replace(/^\[|\]$/g, "");
110
+ if (net.isIP(host)) {
111
+ if (isPrivateAddress(host))
112
+ throw new BlockedUrlError(`address ${host} is private/reserved`);
113
+ return;
114
+ }
115
+ let addresses;
116
+ try {
117
+ addresses = await dns.lookup(host, { all: true, verbatim: true });
118
+ }
119
+ catch {
120
+ throw new Error(`DNS lookup failed for ${host}`);
121
+ }
122
+ if (!addresses.length)
123
+ throw new Error(`DNS lookup returned no addresses for ${host}`);
124
+ for (const { address } of addresses) {
125
+ if (isPrivateAddress(address)) {
126
+ throw new BlockedUrlError(`${host} resolves to private/reserved address ${address}`);
127
+ }
128
+ }
129
+ }
130
+ const PREVIEW_USER_AGENT = "Mozilla/5.0 (compatible; OpenClawLinkPreview/1.0)";
131
+ /**
132
+ * Fetch a public http/https URL with manual redirects (≤5 hops, each hop re-validated) and a
133
+ * streamed size cap (Content-Length is not trusted). Returns null on ordinary failures (non-2xx,
134
+ * oversize, timeout, bad content type, DNS error); throws BlockedUrlError on SSRF rejection.
135
+ */
136
+ export async function fetchPublicUrl(rawUrl, opts) {
137
+ const controller = new AbortController();
138
+ const timer = setTimeout(() => controller.abort(), opts.timeoutMs);
139
+ try {
140
+ let current = rawUrl;
141
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
142
+ const url = parseHttpUrl(current);
143
+ if (!url)
144
+ return null;
145
+ assertPublicHttpUrl(url);
146
+ await assertResolvesPublic(url);
147
+ const res = await fetch(url, {
148
+ redirect: "manual",
149
+ signal: controller.signal,
150
+ headers: {
151
+ "User-Agent": PREVIEW_USER_AGENT,
152
+ ...(opts.accept ? { Accept: opts.accept } : {}),
153
+ },
154
+ });
155
+ if (res.status >= 300 && res.status < 400) {
156
+ const location = res.headers.get("location");
157
+ await res.body?.cancel().catch(() => { });
158
+ if (!location)
159
+ return null;
160
+ try {
161
+ current = new URL(location, url).toString();
162
+ }
163
+ catch {
164
+ return null;
165
+ }
166
+ continue;
167
+ }
168
+ if (!res.ok) {
169
+ await res.body?.cancel().catch(() => { });
170
+ return null;
171
+ }
172
+ const contentType = res.headers.get("content-type")?.trim().toLowerCase() ?? "";
173
+ if (opts.requireContentTypePrefixes &&
174
+ !opts.requireContentTypePrefixes.some((p) => contentType.startsWith(p))) {
175
+ await res.body?.cancel().catch(() => { });
176
+ return null;
177
+ }
178
+ const body = await readBodyCapped(res, opts.maxBytes);
179
+ if (body === null)
180
+ return null;
181
+ return { finalUrl: url.toString(), contentType, body };
182
+ }
183
+ return null; // too many redirects
184
+ }
185
+ catch (err) {
186
+ if (err instanceof BlockedUrlError)
187
+ throw err;
188
+ return null; // timeout / network / DNS failure
189
+ }
190
+ finally {
191
+ clearTimeout(timer);
192
+ }
193
+ }
194
+ /** Stream the response body, aborting once it exceeds maxBytes. */
195
+ async function readBodyCapped(res, maxBytes) {
196
+ if (!res.body) {
197
+ const buffer = Buffer.from(await res.arrayBuffer());
198
+ return buffer.length <= maxBytes ? buffer : null;
199
+ }
200
+ const reader = res.body.getReader();
201
+ const chunks = [];
202
+ let total = 0;
203
+ try {
204
+ for (;;) {
205
+ const { done, value } = await reader.read();
206
+ if (done)
207
+ break;
208
+ if (value) {
209
+ total += value.byteLength;
210
+ if (total > maxBytes) {
211
+ await reader.cancel().catch(() => { });
212
+ return null;
213
+ }
214
+ chunks.push(Buffer.from(value));
215
+ }
216
+ }
217
+ }
218
+ catch {
219
+ return null;
220
+ }
221
+ const buffer = Buffer.concat(chunks);
222
+ return buffer.length ? buffer : null;
223
+ }
@@ -0,0 +1,15 @@
1
+ /** Install source from the OpenClaw config `plugins.installs[<id>].source`. */
2
+ export type InstallSource = "npm" | "path" | "archive" | "clawhub" | "git" | "marketplace" | "unknown";
3
+ /**
4
+ * Read the install source for this plugin from the live config snapshot.
5
+ * Returns "unknown" when the record can't be resolved (e.g. runtime not captured).
6
+ * Only "npm" is auto-upgradable; "path" means a dev (load.paths) install which must
7
+ * never be npm-upgraded (would duplicate-install and break agent media sends).
8
+ */
9
+ export declare function getInstallSource(): InstallSource;
10
+ /** Compare dotted numeric versions. Returns true if `a` is strictly greater than `b`. */
11
+ export declare function semverGreater(a: string | null | undefined, b: string | null | undefined): boolean;
12
+ /** Fetch the latest published version from the npm registry (cached ~10min). Null on failure. */
13
+ export declare function fetchLatestVersion(nowMs: number): Promise<string | null>;
14
+ /** Vitest-only */
15
+ export declare function resetLatestVersionCacheForTest(): void;
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Helpers for the plugin upgrade feature: read this plugin's install record
3
+ * (source: "npm" vs "path"/dev), compare semver, and look up the latest version
4
+ * published to the npm registry (cached).
5
+ */
6
+ import { getUpgradeRuntime } from "./upgrade-runtime.js";
7
+ import { PLUGIN_ID, PLUGIN_PACKAGE_NAME } from "./version.js";
8
+ /**
9
+ * Read the install source for this plugin from the live config snapshot.
10
+ * Returns "unknown" when the record can't be resolved (e.g. runtime not captured).
11
+ * Only "npm" is auto-upgradable; "path" means a dev (load.paths) install which must
12
+ * never be npm-upgraded (would duplicate-install and break agent media sends).
13
+ */
14
+ export function getInstallSource() {
15
+ const rt = getUpgradeRuntime();
16
+ if (!rt)
17
+ return "unknown";
18
+ try {
19
+ const cfg = rt.currentConfig();
20
+ const source = cfg?.plugins?.installs?.[PLUGIN_ID]?.source;
21
+ if (source === "npm" ||
22
+ source === "path" ||
23
+ source === "archive" ||
24
+ source === "clawhub" ||
25
+ source === "git" ||
26
+ source === "marketplace") {
27
+ return source;
28
+ }
29
+ return "unknown";
30
+ }
31
+ catch {
32
+ return "unknown";
33
+ }
34
+ }
35
+ /** Compare dotted numeric versions. Returns true if `a` is strictly greater than `b`. */
36
+ export function semverGreater(a, b) {
37
+ if (!a || !b)
38
+ return false;
39
+ const pa = parseSemver(a);
40
+ const pb = parseSemver(b);
41
+ for (let i = 0; i < 3; i++) {
42
+ if (pa[i] > pb[i])
43
+ return true;
44
+ if (pa[i] < pb[i])
45
+ return false;
46
+ }
47
+ return false;
48
+ }
49
+ function parseSemver(v) {
50
+ // Strip a leading "v" and any pre-release/build suffix, keep major.minor.patch.
51
+ const core = v.trim().replace(/^v/i, "").split(/[-+]/)[0];
52
+ const parts = core.split(".").map((p) => Number.parseInt(p, 10));
53
+ return [parts[0] || 0, parts[1] || 0, parts[2] || 0];
54
+ }
55
+ let cachedLatest = null;
56
+ const LATEST_TTL_MS = 10 * 60 * 1000;
57
+ /** Fetch the latest published version from the npm registry (cached ~10min). Null on failure. */
58
+ export async function fetchLatestVersion(nowMs) {
59
+ if (cachedLatest && nowMs - cachedLatest.fetchedAt < LATEST_TTL_MS) {
60
+ return cachedLatest.version;
61
+ }
62
+ let version = null;
63
+ try {
64
+ const controller = new AbortController();
65
+ const timer = setTimeout(() => controller.abort(), 5000);
66
+ try {
67
+ const res = await fetch(`https://registry.npmjs.org/${PLUGIN_PACKAGE_NAME}/latest`, { signal: controller.signal, headers: { Accept: "application/json" } });
68
+ if (res.ok) {
69
+ const body = (await res.json());
70
+ if (typeof body.version === "string" && body.version)
71
+ version = body.version;
72
+ }
73
+ }
74
+ finally {
75
+ clearTimeout(timer);
76
+ }
77
+ }
78
+ catch {
79
+ version = null;
80
+ }
81
+ cachedLatest = { version, fetchedAt: nowMs };
82
+ return version;
83
+ }
84
+ /** Vitest-only */
85
+ export function resetLatestVersionCacheForTest() {
86
+ cachedLatest = null;
87
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Captures the full plugin runtime (`api.runtime`) at `registerFull` time so the
3
+ * plugin-info / plugin-upgrade HTTP handlers can reach `system.runCommandWithTimeout`
4
+ * and `config.mutateConfigFile` — capabilities the narrow `getFridayNextRuntime()`
5
+ * store does NOT expose (it only carries `config`/`logger`).
6
+ *
7
+ * Mirrors the `setFridayAgentForwardRuntime` capture pattern in agent-forward-runtime.ts.
8
+ */
9
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
10
+ export type SpawnResultLike = {
11
+ code: number | null;
12
+ stdout: string;
13
+ stderr: string;
14
+ [key: string]: unknown;
15
+ };
16
+ export type ConfigAfterWrite = {
17
+ mode: "auto";
18
+ } | {
19
+ mode: "restart";
20
+ reason: string;
21
+ } | {
22
+ mode: "none";
23
+ reason: string;
24
+ };
25
+ export type UpgradeRuntime = {
26
+ /** Run a command (argv) with a timeout in ms; resolves with stdout/stderr/code. */
27
+ runCommandWithTimeout: (argv: string[], timeoutMs: number) => Promise<SpawnResultLike>;
28
+ /** Read the current (deep-readonly) OpenClaw config snapshot. */
29
+ currentConfig: () => unknown;
30
+ /** Mutate the config file; `afterWrite: { mode: "restart" }` triggers a safe gateway restart. */
31
+ mutateConfigFile: (params: {
32
+ afterWrite: ConfigAfterWrite;
33
+ mutate: (draft: unknown) => unknown | void;
34
+ }) => Promise<unknown>;
35
+ };
36
+ export declare function setUpgradeRuntime(api: OpenClawPluginApi): void;
37
+ export declare function getUpgradeRuntime(): UpgradeRuntime | null;
38
+ /** Vitest-only */
39
+ export declare function resetUpgradeRuntimeForTest(): void;
@@ -0,0 +1,27 @@
1
+ let upgradeRuntime = null;
2
+ export function setUpgradeRuntime(api) {
3
+ const runtime = api.runtime;
4
+ upgradeRuntime = {
5
+ runCommandWithTimeout: async (argv, timeoutMs) => {
6
+ const run = runtime.system?.runCommandWithTimeout;
7
+ if (!run)
8
+ throw new Error("runtime.system.runCommandWithTimeout unavailable");
9
+ // `runCommandWithTimeout(argv, number | CommandOptions)` — pass the bare ms.
10
+ return run(argv, timeoutMs);
11
+ },
12
+ currentConfig: () => runtime.config.current(),
13
+ mutateConfigFile: async (params) => {
14
+ const mutate = runtime.config.mutateConfigFile;
15
+ if (!mutate)
16
+ throw new Error("runtime.config.mutateConfigFile unavailable");
17
+ return mutate(params);
18
+ },
19
+ };
20
+ }
21
+ export function getUpgradeRuntime() {
22
+ return upgradeRuntime;
23
+ }
24
+ /** Vitest-only */
25
+ export function resetUpgradeRuntimeForTest() {
26
+ upgradeRuntime = null;
27
+ }