@syengup/friday-channel-next 0.1.27 → 0.1.29

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.
@@ -0,0 +1,234 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ vi.mock("node:dns/promises", () => ({
4
+ default: { lookup: vi.fn() },
5
+ }));
6
+
7
+ import dns from "node:dns/promises";
8
+ import {
9
+ BlockedUrlError,
10
+ assertPublicHttpUrl,
11
+ assertResolvesPublic,
12
+ fetchPublicUrl,
13
+ isPrivateAddress,
14
+ parseHttpUrl,
15
+ } from "./ssrf-guard.js";
16
+
17
+ const lookupMock = vi.mocked(dns.lookup);
18
+
19
+ function mockPublicDns(): void {
20
+ lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }] as never);
21
+ }
22
+
23
+ beforeEach(() => {
24
+ lookupMock.mockReset();
25
+ });
26
+
27
+ afterEach(() => {
28
+ vi.unstubAllGlobals();
29
+ });
30
+
31
+ describe("parseHttpUrl", () => {
32
+ it("accepts absolute http/https URLs", () => {
33
+ expect(parseHttpUrl("https://example.com/a?b=1")?.hostname).toBe("example.com");
34
+ expect(parseHttpUrl(" http://example.com ")?.protocol).toBe("http:");
35
+ });
36
+
37
+ it("rejects non-http schemes and garbage", () => {
38
+ expect(parseHttpUrl("ftp://example.com/a")).toBeNull();
39
+ expect(parseHttpUrl("file:///etc/passwd")).toBeNull();
40
+ expect(parseHttpUrl("javascript:alert(1)")).toBeNull();
41
+ expect(parseHttpUrl("not a url")).toBeNull();
42
+ expect(parseHttpUrl("/relative/path")).toBeNull();
43
+ });
44
+ });
45
+
46
+ describe("assertPublicHttpUrl", () => {
47
+ it("rejects non-default ports", () => {
48
+ expect(() => assertPublicHttpUrl(new URL("http://example.com:8080/"))).toThrow(BlockedUrlError);
49
+ expect(() => assertPublicHttpUrl(new URL("https://example.com:8443/"))).toThrow(BlockedUrlError);
50
+ });
51
+
52
+ it("allows default and explicit 80/443 ports", () => {
53
+ expect(() => assertPublicHttpUrl(new URL("https://example.com/"))).not.toThrow();
54
+ expect(() => assertPublicHttpUrl(new URL("http://example.com:80/"))).not.toThrow();
55
+ expect(() => assertPublicHttpUrl(new URL("https://example.com:443/"))).not.toThrow();
56
+ });
57
+
58
+ it("rejects localhost and internal-suffix hostnames", () => {
59
+ expect(() => assertPublicHttpUrl(new URL("http://localhost/"))).toThrow(BlockedUrlError);
60
+ expect(() => assertPublicHttpUrl(new URL("http://gateway.local/"))).toThrow(BlockedUrlError);
61
+ expect(() => assertPublicHttpUrl(new URL("http://db.internal/"))).toThrow(BlockedUrlError);
62
+ expect(() => assertPublicHttpUrl(new URL("http://nas.home.arpa/"))).toThrow(BlockedUrlError);
63
+ });
64
+
65
+ it("rejects private IP literals including bracketed IPv6", () => {
66
+ expect(() => assertPublicHttpUrl(new URL("http://127.0.0.1/"))).toThrow(BlockedUrlError);
67
+ expect(() => assertPublicHttpUrl(new URL("http://10.0.0.5/"))).toThrow(BlockedUrlError);
68
+ expect(() => assertPublicHttpUrl(new URL("http://[::1]/"))).toThrow(BlockedUrlError);
69
+ expect(() => assertPublicHttpUrl(new URL("http://[fc00::1]/"))).toThrow(BlockedUrlError);
70
+ });
71
+
72
+ it("allows public IP literals", () => {
73
+ expect(() => assertPublicHttpUrl(new URL("http://93.184.216.34/"))).not.toThrow();
74
+ });
75
+ });
76
+
77
+ describe("isPrivateAddress", () => {
78
+ it("flags private/reserved IPv4 ranges", () => {
79
+ for (const ip of [
80
+ "0.0.0.0",
81
+ "10.1.2.3",
82
+ "100.64.0.1",
83
+ "100.127.255.255",
84
+ "127.0.0.1",
85
+ "169.254.1.1",
86
+ "172.16.0.1",
87
+ "172.31.255.255",
88
+ "192.0.0.1",
89
+ "192.168.1.1",
90
+ "224.0.0.1",
91
+ "255.255.255.255",
92
+ ]) {
93
+ expect(isPrivateAddress(ip), ip).toBe(true);
94
+ }
95
+ });
96
+
97
+ it("passes public IPv4", () => {
98
+ for (const ip of ["8.8.8.8", "93.184.216.34", "100.63.0.1", "100.128.0.1", "172.32.0.1", "198.20.0.1"]) {
99
+ expect(isPrivateAddress(ip), ip).toBe(false);
100
+ }
101
+ });
102
+
103
+ it("passes 198.18/15 (fake-IP DNS range used by clash/surge tun mode)", () => {
104
+ expect(isPrivateAddress("198.18.1.156")).toBe(false);
105
+ expect(isPrivateAddress("198.19.255.255")).toBe(false);
106
+ });
107
+
108
+ it("flags private/reserved IPv6 and mapped IPv4", () => {
109
+ for (const ip of ["::1", "::", "fc00::1", "fd12:3456::1", "fe80::1", "::ffff:10.0.0.1", "::ffff:127.0.0.1"]) {
110
+ expect(isPrivateAddress(ip), ip).toBe(true);
111
+ }
112
+ });
113
+
114
+ it("passes public IPv6 and mapped public IPv4", () => {
115
+ expect(isPrivateAddress("2606:2800:220:1:248:1893:25c8:1946")).toBe(false);
116
+ expect(isPrivateAddress("::ffff:93.184.216.34")).toBe(false);
117
+ });
118
+
119
+ it("treats non-IP strings as unsafe", () => {
120
+ expect(isPrivateAddress("not-an-ip")).toBe(true);
121
+ });
122
+ });
123
+
124
+ describe("assertResolvesPublic", () => {
125
+ it("passes when all resolved addresses are public", async () => {
126
+ lookupMock.mockResolvedValue([
127
+ { address: "93.184.216.34", family: 4 },
128
+ { address: "2606:2800:220:1:248:1893:25c8:1946", family: 6 },
129
+ ] as never);
130
+ await expect(assertResolvesPublic(new URL("https://example.com/"))).resolves.toBeUndefined();
131
+ });
132
+
133
+ it("throws BlockedUrlError when any resolved address is private", async () => {
134
+ lookupMock.mockResolvedValue([
135
+ { address: "93.184.216.34", family: 4 },
136
+ { address: "10.0.0.5", family: 4 },
137
+ ] as never);
138
+ await expect(assertResolvesPublic(new URL("https://rebind.example.com/"))).rejects.toThrow(BlockedUrlError);
139
+ });
140
+
141
+ it("validates IP-literal hosts without a DNS lookup", async () => {
142
+ await expect(assertResolvesPublic(new URL("http://127.0.0.1/"))).rejects.toThrow(BlockedUrlError);
143
+ await expect(assertResolvesPublic(new URL("http://93.184.216.34/"))).resolves.toBeUndefined();
144
+ expect(lookupMock).not.toHaveBeenCalled();
145
+ });
146
+
147
+ it("throws a plain error (not BlockedUrlError) on DNS failure", async () => {
148
+ lookupMock.mockRejectedValue(new Error("ENOTFOUND"));
149
+ const err = await assertResolvesPublic(new URL("https://nope.example.com/")).catch((e) => e);
150
+ expect(err).toBeInstanceOf(Error);
151
+ expect(err).not.toBeInstanceOf(BlockedUrlError);
152
+ });
153
+ });
154
+
155
+ describe("fetchPublicUrl", () => {
156
+ const opts = { maxBytes: 1024, timeoutMs: 5000 };
157
+
158
+ it("fetches a public URL and returns body + finalUrl", async () => {
159
+ mockPublicDns();
160
+ vi.stubGlobal(
161
+ "fetch",
162
+ vi.fn(async () => new Response("<html>hi</html>", { status: 200, headers: { "content-type": "text/html; charset=utf-8" } })),
163
+ );
164
+ const result = await fetchPublicUrl("https://example.com/page", opts);
165
+ expect(result?.finalUrl).toBe("https://example.com/page");
166
+ expect(result?.contentType).toContain("text/html");
167
+ expect(result?.body.toString()).toBe("<html>hi</html>");
168
+ });
169
+
170
+ it("follows redirects and re-validates each hop", async () => {
171
+ mockPublicDns();
172
+ const fetchMock = vi
173
+ .fn()
174
+ .mockResolvedValueOnce(new Response(null, { status: 302, headers: { location: "https://other.example.com/final" } }))
175
+ .mockResolvedValueOnce(new Response("ok", { status: 200, headers: { "content-type": "text/html" } }));
176
+ vi.stubGlobal("fetch", fetchMock);
177
+ const result = await fetchPublicUrl("https://example.com/start", opts);
178
+ expect(result?.finalUrl).toBe("https://other.example.com/final");
179
+ expect(fetchMock).toHaveBeenCalledTimes(2);
180
+ expect(lookupMock).toHaveBeenCalledTimes(2);
181
+ });
182
+
183
+ it("throws BlockedUrlError when a redirect targets a private address", async () => {
184
+ mockPublicDns();
185
+ vi.stubGlobal(
186
+ "fetch",
187
+ vi.fn(async () => new Response(null, { status: 302, headers: { location: "http://127.0.0.1/admin" } })),
188
+ );
189
+ await expect(fetchPublicUrl("https://example.com/start", opts)).rejects.toThrow(BlockedUrlError);
190
+ });
191
+
192
+ it("throws BlockedUrlError for a directly-blocked URL", async () => {
193
+ await expect(fetchPublicUrl("http://192.168.1.1/", opts)).rejects.toThrow(BlockedUrlError);
194
+ });
195
+
196
+ it("returns null when the body exceeds maxBytes (ignoring content-length)", async () => {
197
+ mockPublicDns();
198
+ const big = "x".repeat(2048);
199
+ vi.stubGlobal(
200
+ "fetch",
201
+ vi.fn(async () => new Response(big, { status: 200, headers: { "content-type": "text/html", "content-length": "10" } })),
202
+ );
203
+ expect(await fetchPublicUrl("https://example.com/big", opts)).toBeNull();
204
+ });
205
+
206
+ it("returns null when content-type does not match the required prefixes", async () => {
207
+ mockPublicDns();
208
+ vi.stubGlobal(
209
+ "fetch",
210
+ vi.fn(async () => new Response("{}", { status: 200, headers: { "content-type": "application/json" } })),
211
+ );
212
+ expect(
213
+ await fetchPublicUrl("https://example.com/api", { ...opts, requireContentTypePrefixes: ["text/html", "application/xhtml+xml"] }),
214
+ ).toBeNull();
215
+ });
216
+
217
+ it("returns null on non-2xx and on DNS failure", async () => {
218
+ mockPublicDns();
219
+ vi.stubGlobal("fetch", vi.fn(async () => new Response("nope", { status: 404 })));
220
+ expect(await fetchPublicUrl("https://example.com/missing", opts)).toBeNull();
221
+
222
+ lookupMock.mockRejectedValue(new Error("ENOTFOUND"));
223
+ expect(await fetchPublicUrl("https://nope.example.com/", opts)).toBeNull();
224
+ });
225
+
226
+ it("gives up after too many redirects", async () => {
227
+ mockPublicDns();
228
+ vi.stubGlobal(
229
+ "fetch",
230
+ vi.fn(async () => new Response(null, { status: 302, headers: { location: "https://example.com/loop" } })),
231
+ );
232
+ expect(await fetchPublicUrl("https://example.com/loop", opts)).toBeNull();
233
+ });
234
+ });
@@ -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
+ }
@@ -1,5 +1,35 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { semverGreater } from "./plugin-install-info.js";
2
+ import { classifyInstallSourceFromLoadedPath, semverGreater } from "./plugin-install-info.js";
3
+
4
+ describe("classifyInstallSourceFromLoadedPath", () => {
5
+ it("treats paths under the managed npm projects dir as npm", () => {
6
+ expect(
7
+ classifyInstallSourceFromLoadedPath(
8
+ "/Users/me/.openclaw/npm/projects/syengup-friday-channel-next-ef89e139a1/node_modules/@syengup/friday-channel-next/dist/index.js",
9
+ ),
10
+ ).toBe("npm");
11
+ // install-path form (no /dist/index.js suffix) also resolves to npm
12
+ expect(
13
+ classifyInstallSourceFromLoadedPath(
14
+ "/Users/me/.openclaw/npm/projects/syengup-friday-channel-next-ef89e139a1/node_modules/@syengup/friday-channel-next",
15
+ ),
16
+ ).toBe("npm");
17
+ });
18
+
19
+ it("treats a dev/link checkout path as path", () => {
20
+ expect(
21
+ classifyInstallSourceFromLoadedPath(
22
+ "/Users/me/Documents/Project/Friday-Next/openclaw-fridaynext-channel/dist/index.js",
23
+ ),
24
+ ).toBe("path");
25
+ });
26
+
27
+ it("returns unknown for missing/empty input", () => {
28
+ expect(classifyInstallSourceFromLoadedPath(null)).toBe("unknown");
29
+ expect(classifyInstallSourceFromLoadedPath(undefined)).toBe("unknown");
30
+ expect(classifyInstallSourceFromLoadedPath("")).toBe("unknown");
31
+ });
32
+ });
3
33
 
4
34
  describe("semverGreater", () => {
5
35
  it("returns true when a is a higher patch/minor/major", () => {
@@ -10,10 +10,33 @@ import { PLUGIN_ID, PLUGIN_PACKAGE_NAME } from "./version.js";
10
10
  export type InstallSource = "npm" | "path" | "archive" | "clawhub" | "git" | "marketplace" | "unknown";
11
11
 
12
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).
13
+ * Infer the install source from the loaded plugin's filesystem path (`api.source`).
14
+ *
15
+ * OpenClaw copies non-dev installs (npm/archive/clawhub/git) into
16
+ * `~/.openclaw/npm/projects/<hash>/node_modules/...`, whereas a dev install
17
+ * (`load.paths` / `plugins install --link`) is loaded directly from the source
18
+ * checkout. For this plugin's purposes the only distinction that matters is
19
+ * npm-managed (auto-upgradable) vs dev (must never npm-upgrade — see
20
+ * dev-no-duplicate-plugin-install), so anything under the managed projects dir
21
+ * is treated as "npm".
22
+ */
23
+ export function classifyInstallSourceFromLoadedPath(loadedPath: string | null | undefined): InstallSource {
24
+ if (!loadedPath) return "unknown";
25
+ return loadedPath.includes("/.openclaw/npm/projects/") ? "npm" : "path";
26
+ }
27
+
28
+ /**
29
+ * Resolve the install source for this plugin (npm vs dev path).
30
+ * Returns "unknown" when it can't be resolved (e.g. runtime not captured).
31
+ * Only "npm" is auto-upgradable; "path" means a dev (load.paths / --link) install
32
+ * which must never be npm-upgraded (would duplicate-install and break agent media sends).
33
+ *
34
+ * Resolution order:
35
+ * 1. The explicit `plugins.installs[<id>].source` config record — present on older
36
+ * OpenClaw builds that surface install records in the runtime config snapshot.
37
+ * 2. Fallback: OpenClaw 2026.6.x moved install records out of the config snapshot
38
+ * into a separate registry (~/.openclaw/state.db), leaving `plugins.installs`
39
+ * unset — so infer from the loaded plugin path (`api.source`) instead.
17
40
  */
18
41
  export function getInstallSource(): InstallSource {
19
42
  const rt = getUpgradeRuntime();
@@ -33,10 +56,10 @@ export function getInstallSource(): InstallSource {
33
56
  ) {
34
57
  return source;
35
58
  }
36
- return "unknown";
37
59
  } catch {
38
- return "unknown";
60
+ // fall through to the path-based heuristic
39
61
  }
62
+ return classifyInstallSourceFromLoadedPath(rt.pluginSource);
40
63
  }
41
64
 
42
65
  /** Compare dotted numeric versions. Returns true if `a` is strictly greater than `b`. */
@@ -30,6 +30,12 @@ export type UpgradeRuntime = {
30
30
  afterWrite: ConfigAfterWrite;
31
31
  mutate: (draft: unknown) => unknown | void;
32
32
  }) => Promise<unknown>;
33
+ /**
34
+ * Filesystem path of THIS loaded plugin (`api.source`). Used to infer the install
35
+ * source (npm vs dev) on OpenClaw builds (2026.6.x+) that no longer surface
36
+ * `plugins.installs` in the config snapshot. See `getInstallSource`.
37
+ */
38
+ pluginSource: string | undefined;
33
39
  };
34
40
 
35
41
  let upgradeRuntime: UpgradeRuntime | null = null;
@@ -56,6 +62,7 @@ export function setUpgradeRuntime(api: OpenClawPluginApi): void {
56
62
  if (!mutate) throw new Error("runtime.config.mutateConfigFile unavailable");
57
63
  return mutate(params);
58
64
  },
65
+ pluginSource: typeof api.source === "string" ? api.source : undefined,
59
66
  };
60
67
  }
61
68
 
package/src/version.ts CHANGED
@@ -11,7 +11,7 @@ import { readFileSync } from "node:fs";
11
11
  import { fileURLToPath } from "node:url";
12
12
 
13
13
  /** Keep in sync with package.json "version" as a last-resort fallback. */
14
- const FALLBACK_VERSION = "0.1.27";
14
+ const FALLBACK_VERSION = "0.1.29";
15
15
 
16
16
  function resolvePluginVersion(): string {
17
17
  // dist layout: <root>/dist/src/version.js → ../../package.json = <root>/package.json