@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.
- package/dist/index.js +2 -0
- package/dist/src/http/handlers/files.js +1 -0
- package/dist/src/http/handlers/health.d.ts +1 -0
- package/dist/src/http/handlers/health.js +2 -0
- package/dist/src/http/handlers/link-preview.d.ts +9 -0
- package/dist/src/http/handlers/link-preview.js +41 -0
- package/dist/src/http/handlers/messages.d.ts +5 -0
- package/dist/src/http/handlers/messages.js +19 -8
- package/dist/src/http/handlers/plugin-info.d.ts +11 -0
- package/dist/src/http/handlers/plugin-info.js +32 -0
- package/dist/src/http/handlers/plugin-upgrade.d.ts +11 -0
- package/dist/src/http/handlers/plugin-upgrade.js +94 -0
- package/dist/src/http/handlers/sse.js +2 -0
- package/dist/src/http/handlers/status.js +2 -0
- package/dist/src/http/server.js +15 -0
- package/dist/src/link-preview/og-parse.d.ts +21 -0
- package/dist/src/link-preview/og-parse.js +232 -0
- package/dist/src/link-preview/preview-service.d.ts +31 -0
- package/dist/src/link-preview/preview-service.js +216 -0
- package/dist/src/link-preview/ssrf-guard.d.ts +43 -0
- package/dist/src/link-preview/ssrf-guard.js +223 -0
- package/dist/src/plugin-install-info.d.ts +15 -0
- package/dist/src/plugin-install-info.js +87 -0
- package/dist/src/upgrade-runtime.d.ts +39 -0
- package/dist/src/upgrade-runtime.js +27 -0
- package/dist/src/version.d.ts +5 -0
- package/dist/src/version.js +37 -0
- package/index.ts +2 -0
- package/package.json +1 -1
- package/src/http/handlers/files.ts +1 -0
- package/src/http/handlers/health.ts +3 -0
- package/src/http/handlers/link-preview.test.ts +242 -0
- package/src/http/handlers/link-preview.ts +47 -0
- package/src/http/handlers/messages.test.ts +75 -1
- package/src/http/handlers/messages.ts +19 -7
- package/src/http/handlers/plugin-info.ts +51 -0
- package/src/http/handlers/plugin-upgrade.ts +112 -0
- package/src/http/handlers/sse.ts +2 -0
- package/src/http/handlers/status.ts +2 -0
- package/src/http/server.ts +18 -0
- package/src/link-preview/og-parse.test.ts +168 -0
- package/src/link-preview/og-parse.ts +249 -0
- package/src/link-preview/preview-service.ts +247 -0
- package/src/link-preview/ssrf-guard.test.ts +234 -0
- package/src/link-preview/ssrf-guard.ts +229 -0
- package/src/plugin-install-info.test.ts +28 -0
- package/src/plugin-install-info.ts +95 -0
- package/src/upgrade-runtime.ts +69 -0
- package/src/version.ts +41 -0
|
@@ -0,0 +1,229 @@
|
|
|
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
|
+
|
|
14
|
+
import dns from "node:dns/promises";
|
|
15
|
+
import net from "node:net";
|
|
16
|
+
|
|
17
|
+
const MAX_REDIRECTS = 5;
|
|
18
|
+
|
|
19
|
+
export class BlockedUrlError extends Error {
|
|
20
|
+
readonly reason: string;
|
|
21
|
+
constructor(reason: string) {
|
|
22
|
+
super(`Blocked URL: ${reason}`);
|
|
23
|
+
this.name = "BlockedUrlError";
|
|
24
|
+
this.reason = reason;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Parse an absolute http/https URL. Returns null for anything else (caller maps to invalid_url). */
|
|
29
|
+
export function parseHttpUrl(raw: string): URL | null {
|
|
30
|
+
let url: URL;
|
|
31
|
+
try {
|
|
32
|
+
url = new URL(raw.trim());
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
|
37
|
+
return url;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const BLOCKED_HOST_SUFFIXES = [".local", ".internal", ".home.arpa", ".localhost"];
|
|
41
|
+
|
|
42
|
+
/** Synchronous literal checks: port, hostname blocklist, IP-literal hosts. Throws BlockedUrlError. */
|
|
43
|
+
export function assertPublicHttpUrl(url: URL): void {
|
|
44
|
+
if (url.port && url.port !== "80" && url.port !== "443") {
|
|
45
|
+
throw new BlockedUrlError(`port ${url.port} not allowed`);
|
|
46
|
+
}
|
|
47
|
+
const host = url.hostname.toLowerCase().replace(/\.$/, "");
|
|
48
|
+
if (!host) throw new BlockedUrlError("empty host");
|
|
49
|
+
if (host === "localhost" || BLOCKED_HOST_SUFFIXES.some((s) => host.endsWith(s))) {
|
|
50
|
+
throw new BlockedUrlError(`host "${host}" is not public`);
|
|
51
|
+
}
|
|
52
|
+
// IPv6 literal in a URL comes bracketed: strip for net.isIP.
|
|
53
|
+
const bareHost = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
|
|
54
|
+
if (net.isIP(bareHost) && isPrivateAddress(bareHost)) {
|
|
55
|
+
throw new BlockedUrlError(`address ${bareHost} is private/reserved`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** True when the IP (v4 or v6, including ::ffff: mapped v4) is private, loopback, or reserved. */
|
|
60
|
+
export function isPrivateAddress(ip: string): boolean {
|
|
61
|
+
const version = net.isIP(ip);
|
|
62
|
+
if (version === 4) return isPrivateIPv4(ip);
|
|
63
|
+
if (version === 6) return isPrivateIPv6(ip);
|
|
64
|
+
return true; // not an IP at all — treat as unsafe
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isPrivateIPv4(ip: string): boolean {
|
|
68
|
+
const parts = ip.split(".").map(Number);
|
|
69
|
+
if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return true;
|
|
70
|
+
const [a, b] = parts;
|
|
71
|
+
if (a === 0) return true; // 0.0.0.0/8
|
|
72
|
+
if (a === 10) return true; // 10/8
|
|
73
|
+
if (a === 100 && b >= 64 && b <= 127) return true; // 100.64/10 CGNAT
|
|
74
|
+
if (a === 127) return true; // loopback
|
|
75
|
+
if (a === 169 && b === 254) return true; // link-local
|
|
76
|
+
if (a === 172 && b >= 16 && b <= 31) return true; // 172.16/12
|
|
77
|
+
if (a === 192 && b === 0 && parts[2] === 0) return true; // 192.0.0/24
|
|
78
|
+
if (a === 192 && b === 168) return true; // 192.168/16
|
|
79
|
+
// 198.18/15 (benchmarking) intentionally NOT blocked: fake-IP DNS setups (clash/surge tun
|
|
80
|
+
// mode, common on gateway hosts) resolve EVERY domain into this range, so blocking it kills
|
|
81
|
+
// all lookups. The range is not used for real LAN services, so the SSRF exposure is nil.
|
|
82
|
+
if (a >= 224) return true; // multicast 224/4 + reserved 240/4 + broadcast
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function isPrivateIPv6(ip: string): boolean {
|
|
87
|
+
const lower = ip.toLowerCase();
|
|
88
|
+
// ::ffff:a.b.c.d mapped IPv4 → validate the embedded v4.
|
|
89
|
+
const mapped = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
|
|
90
|
+
if (mapped) return isPrivateIPv4(mapped[1]);
|
|
91
|
+
if (lower === "::" || lower === "::1") return true; // unspecified / loopback
|
|
92
|
+
const head = lower.split(":")[0];
|
|
93
|
+
if (head.startsWith("fc") || head.startsWith("fd")) return true; // fc00::/7 ULA
|
|
94
|
+
if (/^fe[89ab]/.test(head)) return true; // fe80::/10 link-local
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Resolve the hostname and require every returned address to be public. Throws BlockedUrlError. */
|
|
99
|
+
export async function assertResolvesPublic(url: URL): Promise<void> {
|
|
100
|
+
const host = url.hostname.replace(/^\[|\]$/g, "");
|
|
101
|
+
if (net.isIP(host)) {
|
|
102
|
+
if (isPrivateAddress(host)) throw new BlockedUrlError(`address ${host} is private/reserved`);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
let addresses: { address: string }[];
|
|
106
|
+
try {
|
|
107
|
+
addresses = await dns.lookup(host, { all: true, verbatim: true });
|
|
108
|
+
} catch {
|
|
109
|
+
throw new Error(`DNS lookup failed for ${host}`);
|
|
110
|
+
}
|
|
111
|
+
if (!addresses.length) throw new Error(`DNS lookup returned no addresses for ${host}`);
|
|
112
|
+
for (const { address } of addresses) {
|
|
113
|
+
if (isPrivateAddress(address)) {
|
|
114
|
+
throw new BlockedUrlError(`${host} resolves to private/reserved address ${address}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface FetchPublicUrlOptions {
|
|
120
|
+
maxBytes: number;
|
|
121
|
+
timeoutMs: number;
|
|
122
|
+
/** Sent as the Accept header. */
|
|
123
|
+
accept?: string;
|
|
124
|
+
/** When set, the response Content-Type must start with one of these prefixes. */
|
|
125
|
+
requireContentTypePrefixes?: string[];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface FetchPublicUrlResult {
|
|
129
|
+
finalUrl: string;
|
|
130
|
+
contentType: string;
|
|
131
|
+
body: Buffer;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const PREVIEW_USER_AGENT = "Mozilla/5.0 (compatible; OpenClawLinkPreview/1.0)";
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Fetch a public http/https URL with manual redirects (≤5 hops, each hop re-validated) and a
|
|
138
|
+
* streamed size cap (Content-Length is not trusted). Returns null on ordinary failures (non-2xx,
|
|
139
|
+
* oversize, timeout, bad content type, DNS error); throws BlockedUrlError on SSRF rejection.
|
|
140
|
+
*/
|
|
141
|
+
export async function fetchPublicUrl(
|
|
142
|
+
rawUrl: string,
|
|
143
|
+
opts: FetchPublicUrlOptions,
|
|
144
|
+
): Promise<FetchPublicUrlResult | null> {
|
|
145
|
+
const controller = new AbortController();
|
|
146
|
+
const timer = setTimeout(() => controller.abort(), opts.timeoutMs);
|
|
147
|
+
try {
|
|
148
|
+
let current = rawUrl;
|
|
149
|
+
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
|
150
|
+
const url = parseHttpUrl(current);
|
|
151
|
+
if (!url) return null;
|
|
152
|
+
assertPublicHttpUrl(url);
|
|
153
|
+
await assertResolvesPublic(url);
|
|
154
|
+
|
|
155
|
+
const res = await fetch(url, {
|
|
156
|
+
redirect: "manual",
|
|
157
|
+
signal: controller.signal,
|
|
158
|
+
headers: {
|
|
159
|
+
"User-Agent": PREVIEW_USER_AGENT,
|
|
160
|
+
...(opts.accept ? { Accept: opts.accept } : {}),
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
if (res.status >= 300 && res.status < 400) {
|
|
165
|
+
const location = res.headers.get("location");
|
|
166
|
+
await res.body?.cancel().catch(() => {});
|
|
167
|
+
if (!location) return null;
|
|
168
|
+
try {
|
|
169
|
+
current = new URL(location, url).toString();
|
|
170
|
+
} catch {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (!res.ok) {
|
|
176
|
+
await res.body?.cancel().catch(() => {});
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const contentType = res.headers.get("content-type")?.trim().toLowerCase() ?? "";
|
|
181
|
+
if (
|
|
182
|
+
opts.requireContentTypePrefixes &&
|
|
183
|
+
!opts.requireContentTypePrefixes.some((p) => contentType.startsWith(p))
|
|
184
|
+
) {
|
|
185
|
+
await res.body?.cancel().catch(() => {});
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const body = await readBodyCapped(res, opts.maxBytes);
|
|
190
|
+
if (body === null) return null;
|
|
191
|
+
return { finalUrl: url.toString(), contentType, body };
|
|
192
|
+
}
|
|
193
|
+
return null; // too many redirects
|
|
194
|
+
} catch (err) {
|
|
195
|
+
if (err instanceof BlockedUrlError) throw err;
|
|
196
|
+
return null; // timeout / network / DNS failure
|
|
197
|
+
} finally {
|
|
198
|
+
clearTimeout(timer);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Stream the response body, aborting once it exceeds maxBytes. */
|
|
203
|
+
async function readBodyCapped(res: Response, maxBytes: number): Promise<Buffer | null> {
|
|
204
|
+
if (!res.body) {
|
|
205
|
+
const buffer = Buffer.from(await res.arrayBuffer());
|
|
206
|
+
return buffer.length <= maxBytes ? buffer : null;
|
|
207
|
+
}
|
|
208
|
+
const reader = res.body.getReader();
|
|
209
|
+
const chunks: Buffer[] = [];
|
|
210
|
+
let total = 0;
|
|
211
|
+
try {
|
|
212
|
+
for (;;) {
|
|
213
|
+
const { done, value } = await reader.read();
|
|
214
|
+
if (done) break;
|
|
215
|
+
if (value) {
|
|
216
|
+
total += value.byteLength;
|
|
217
|
+
if (total > maxBytes) {
|
|
218
|
+
await reader.cancel().catch(() => {});
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
chunks.push(Buffer.from(value));
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
} catch {
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
const buffer = Buffer.concat(chunks);
|
|
228
|
+
return buffer.length ? buffer : null;
|
|
229
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { semverGreater } from "./plugin-install-info.js";
|
|
3
|
+
|
|
4
|
+
describe("semverGreater", () => {
|
|
5
|
+
it("returns true when a is a higher patch/minor/major", () => {
|
|
6
|
+
expect(semverGreater("0.1.27", "0.1.26")).toBe(true);
|
|
7
|
+
expect(semverGreater("0.2.0", "0.1.99")).toBe(true);
|
|
8
|
+
expect(semverGreater("1.0.0", "0.9.9")).toBe(true);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("returns false when equal or lower", () => {
|
|
12
|
+
expect(semverGreater("0.1.27", "0.1.27")).toBe(false);
|
|
13
|
+
expect(semverGreater("0.1.26", "0.1.27")).toBe(false);
|
|
14
|
+
expect(semverGreater("0.1.0", "0.1.0")).toBe(false);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("tolerates a leading v and pre-release/build suffixes", () => {
|
|
18
|
+
expect(semverGreater("v0.1.27", "0.1.26")).toBe(true);
|
|
19
|
+
expect(semverGreater("0.1.27-beta.1", "0.1.26")).toBe(true);
|
|
20
|
+
expect(semverGreater("0.1.27", "0.1.27-rc.1")).toBe(false);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("returns false for null/undefined inputs", () => {
|
|
24
|
+
expect(semverGreater(null, "0.1.0")).toBe(false);
|
|
25
|
+
expect(semverGreater("0.1.0", null)).toBe(false);
|
|
26
|
+
expect(semverGreater(undefined, undefined)).toBe(false);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
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
|
+
/** Install source from the OpenClaw config `plugins.installs[<id>].source`. */
|
|
10
|
+
export type InstallSource = "npm" | "path" | "archive" | "clawhub" | "git" | "marketplace" | "unknown";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Read the install source for this plugin from the live config snapshot.
|
|
14
|
+
* Returns "unknown" when the record can't be resolved (e.g. runtime not captured).
|
|
15
|
+
* Only "npm" is auto-upgradable; "path" means a dev (load.paths) install which must
|
|
16
|
+
* never be npm-upgraded (would duplicate-install and break agent media sends).
|
|
17
|
+
*/
|
|
18
|
+
export function getInstallSource(): InstallSource {
|
|
19
|
+
const rt = getUpgradeRuntime();
|
|
20
|
+
if (!rt) return "unknown";
|
|
21
|
+
try {
|
|
22
|
+
const cfg = rt.currentConfig() as {
|
|
23
|
+
plugins?: { installs?: Record<string, { source?: string } | undefined> };
|
|
24
|
+
} | undefined;
|
|
25
|
+
const source = cfg?.plugins?.installs?.[PLUGIN_ID]?.source;
|
|
26
|
+
if (
|
|
27
|
+
source === "npm" ||
|
|
28
|
+
source === "path" ||
|
|
29
|
+
source === "archive" ||
|
|
30
|
+
source === "clawhub" ||
|
|
31
|
+
source === "git" ||
|
|
32
|
+
source === "marketplace"
|
|
33
|
+
) {
|
|
34
|
+
return source;
|
|
35
|
+
}
|
|
36
|
+
return "unknown";
|
|
37
|
+
} catch {
|
|
38
|
+
return "unknown";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Compare dotted numeric versions. Returns true if `a` is strictly greater than `b`. */
|
|
43
|
+
export function semverGreater(a: string | null | undefined, b: string | null | undefined): boolean {
|
|
44
|
+
if (!a || !b) return false;
|
|
45
|
+
const pa = parseSemver(a);
|
|
46
|
+
const pb = parseSemver(b);
|
|
47
|
+
for (let i = 0; i < 3; i++) {
|
|
48
|
+
if (pa[i] > pb[i]) return true;
|
|
49
|
+
if (pa[i] < pb[i]) return false;
|
|
50
|
+
}
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function parseSemver(v: string): [number, number, number] {
|
|
55
|
+
// Strip a leading "v" and any pre-release/build suffix, keep major.minor.patch.
|
|
56
|
+
const core = v.trim().replace(/^v/i, "").split(/[-+]/)[0];
|
|
57
|
+
const parts = core.split(".").map((p) => Number.parseInt(p, 10));
|
|
58
|
+
return [parts[0] || 0, parts[1] || 0, parts[2] || 0];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let cachedLatest: { version: string | null; fetchedAt: number } | null = null;
|
|
62
|
+
const LATEST_TTL_MS = 10 * 60 * 1000;
|
|
63
|
+
|
|
64
|
+
/** Fetch the latest published version from the npm registry (cached ~10min). Null on failure. */
|
|
65
|
+
export async function fetchLatestVersion(nowMs: number): Promise<string | null> {
|
|
66
|
+
if (cachedLatest && nowMs - cachedLatest.fetchedAt < LATEST_TTL_MS) {
|
|
67
|
+
return cachedLatest.version;
|
|
68
|
+
}
|
|
69
|
+
let version: string | null = null;
|
|
70
|
+
try {
|
|
71
|
+
const controller = new AbortController();
|
|
72
|
+
const timer = setTimeout(() => controller.abort(), 5000);
|
|
73
|
+
try {
|
|
74
|
+
const res = await fetch(
|
|
75
|
+
`https://registry.npmjs.org/${PLUGIN_PACKAGE_NAME}/latest`,
|
|
76
|
+
{ signal: controller.signal, headers: { Accept: "application/json" } },
|
|
77
|
+
);
|
|
78
|
+
if (res.ok) {
|
|
79
|
+
const body = (await res.json()) as { version?: string };
|
|
80
|
+
if (typeof body.version === "string" && body.version) version = body.version;
|
|
81
|
+
}
|
|
82
|
+
} finally {
|
|
83
|
+
clearTimeout(timer);
|
|
84
|
+
}
|
|
85
|
+
} catch {
|
|
86
|
+
version = null;
|
|
87
|
+
}
|
|
88
|
+
cachedLatest = { version, fetchedAt: nowMs };
|
|
89
|
+
return version;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Vitest-only */
|
|
93
|
+
export function resetLatestVersionCacheForTest(): void {
|
|
94
|
+
cachedLatest = null;
|
|
95
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
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
|
+
|
|
11
|
+
export type SpawnResultLike = {
|
|
12
|
+
code: number | null;
|
|
13
|
+
stdout: string;
|
|
14
|
+
stderr: string;
|
|
15
|
+
[key: string]: unknown;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type ConfigAfterWrite =
|
|
19
|
+
| { mode: "auto" }
|
|
20
|
+
| { mode: "restart"; reason: string }
|
|
21
|
+
| { mode: "none"; reason: string };
|
|
22
|
+
|
|
23
|
+
export type UpgradeRuntime = {
|
|
24
|
+
/** Run a command (argv) with a timeout in ms; resolves with stdout/stderr/code. */
|
|
25
|
+
runCommandWithTimeout: (argv: string[], timeoutMs: number) => Promise<SpawnResultLike>;
|
|
26
|
+
/** Read the current (deep-readonly) OpenClaw config snapshot. */
|
|
27
|
+
currentConfig: () => unknown;
|
|
28
|
+
/** Mutate the config file; `afterWrite: { mode: "restart" }` triggers a safe gateway restart. */
|
|
29
|
+
mutateConfigFile: (params: {
|
|
30
|
+
afterWrite: ConfigAfterWrite;
|
|
31
|
+
mutate: (draft: unknown) => unknown | void;
|
|
32
|
+
}) => Promise<unknown>;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
let upgradeRuntime: UpgradeRuntime | null = null;
|
|
36
|
+
|
|
37
|
+
export function setUpgradeRuntime(api: OpenClawPluginApi): void {
|
|
38
|
+
const runtime = api.runtime as unknown as {
|
|
39
|
+
system?: { runCommandWithTimeout?: (argv: string[], opts: unknown) => Promise<SpawnResultLike> };
|
|
40
|
+
config: {
|
|
41
|
+
current: () => unknown;
|
|
42
|
+
mutateConfigFile?: (params: unknown) => Promise<unknown>;
|
|
43
|
+
};
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
upgradeRuntime = {
|
|
47
|
+
runCommandWithTimeout: async (argv, timeoutMs) => {
|
|
48
|
+
const run = runtime.system?.runCommandWithTimeout;
|
|
49
|
+
if (!run) throw new Error("runtime.system.runCommandWithTimeout unavailable");
|
|
50
|
+
// `runCommandWithTimeout(argv, number | CommandOptions)` — pass the bare ms.
|
|
51
|
+
return run(argv, timeoutMs);
|
|
52
|
+
},
|
|
53
|
+
currentConfig: () => runtime.config.current(),
|
|
54
|
+
mutateConfigFile: async (params) => {
|
|
55
|
+
const mutate = runtime.config.mutateConfigFile;
|
|
56
|
+
if (!mutate) throw new Error("runtime.config.mutateConfigFile unavailable");
|
|
57
|
+
return mutate(params);
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function getUpgradeRuntime(): UpgradeRuntime | null {
|
|
63
|
+
return upgradeRuntime;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Vitest-only */
|
|
67
|
+
export function resetUpgradeRuntimeForTest(): void {
|
|
68
|
+
upgradeRuntime = null;
|
|
69
|
+
}
|
package/src/version.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin self-version.
|
|
3
|
+
*
|
|
4
|
+
* Resolved at module load by reading this package's own package.json relative to
|
|
5
|
+
* the compiled module URL. `tsc` does NOT copy package.json into `dist/`, and a
|
|
6
|
+
* JSON `import` would rewrite to a non-existent `dist/package.json`, so we read
|
|
7
|
+
* the real file from disk and walk a couple of candidate paths. Falls back to a
|
|
8
|
+
* hardcoded constant if the file can't be located (keep in sync with package.json).
|
|
9
|
+
*/
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
|
|
13
|
+
/** Keep in sync with package.json "version" as a last-resort fallback. */
|
|
14
|
+
const FALLBACK_VERSION = "0.1.28";
|
|
15
|
+
|
|
16
|
+
function resolvePluginVersion(): string {
|
|
17
|
+
// dist layout: <root>/dist/src/version.js → ../../package.json = <root>/package.json
|
|
18
|
+
// source layout (vitest/jiti): <root>/src/version.ts → ../package.json = <root>/package.json
|
|
19
|
+
const candidates = ["../../package.json", "../package.json"];
|
|
20
|
+
for (const rel of candidates) {
|
|
21
|
+
try {
|
|
22
|
+
const path = fileURLToPath(new URL(rel, import.meta.url));
|
|
23
|
+
const raw = readFileSync(path, "utf8");
|
|
24
|
+
const pkg = JSON.parse(raw) as { name?: string; version?: string };
|
|
25
|
+
if (pkg.name === "@syengup/friday-channel-next" && typeof pkg.version === "string" && pkg.version) {
|
|
26
|
+
return pkg.version;
|
|
27
|
+
}
|
|
28
|
+
} catch {
|
|
29
|
+
// try next candidate
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return FALLBACK_VERSION;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const PLUGIN_VERSION: string = resolvePluginVersion();
|
|
36
|
+
|
|
37
|
+
/** npm package name, used for the upgrade spec and registry lookup. */
|
|
38
|
+
export const PLUGIN_PACKAGE_NAME = "@syengup/friday-channel-next";
|
|
39
|
+
|
|
40
|
+
/** Plugin id as registered with OpenClaw (used to read the install record). */
|
|
41
|
+
export const PLUGIN_ID = "friday-next";
|