@cortexkit/aft-opencode 0.15.3 → 0.15.5

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.
@@ -1,4 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { lookup } from "node:dns/promises";
2
3
  import {
3
4
  existsSync,
4
5
  mkdirSync,
@@ -7,6 +8,7 @@ import {
7
8
  unlinkSync,
8
9
  writeFileSync,
9
10
  } from "node:fs";
11
+ import { isIP } from "node:net";
10
12
  import { join } from "node:path";
11
13
  import { log, warn } from "../logger";
12
14
 
@@ -16,6 +18,11 @@ const MAX_RESPONSE_BYTES = 10 * 1024 * 1024;
16
18
  const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
17
19
  /** Fetch timeout: 30 seconds */
18
20
  const FETCH_TIMEOUT_MS = 30_000;
21
+ const MAX_REDIRECTS = 5;
22
+
23
+ interface FetchUrlOptions {
24
+ allowPrivate?: boolean;
25
+ }
19
26
 
20
27
  interface CacheMeta {
21
28
  url: string;
@@ -40,6 +47,198 @@ function contentPath(storageDir: string, hash: string, extension: string): strin
40
47
  return join(cacheDir(storageDir), `${hash}${extension}`);
41
48
  }
42
49
 
50
+ /** Exported for unit tests. */
51
+ export function _isPrivateIpv4(address: string): boolean {
52
+ const parts = address.split(".").map((part) => Number.parseInt(part, 10));
53
+ if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part))) return true;
54
+ const [a, b] = parts;
55
+ return (
56
+ a === 0 || // 0.0.0.0/8 — wildcard, blocked to prevent local-host bypass
57
+ a === 10 ||
58
+ a === 127 ||
59
+ (a === 172 && b >= 16 && b <= 31) ||
60
+ (a === 192 && b === 168) ||
61
+ (a === 169 && b === 254) ||
62
+ a >= 224 // multicast (224/4) and reserved (240/4)
63
+ );
64
+ }
65
+
66
+ /**
67
+ * Expand a possibly-compressed IPv6 address to 8 hextets (numbers).
68
+ * Returns null on parse failure.
69
+ */
70
+ function expandIpv6(addr: string): number[] | null {
71
+ if (addr === "::") return [0, 0, 0, 0, 0, 0, 0, 0];
72
+ // Reject more than one "::"
73
+ const dcMatches = addr.match(/::/g);
74
+ if (dcMatches && dcMatches.length > 1) return null;
75
+
76
+ // Handle dotted-quad tail by converting to two hextets.
77
+ let normalized = addr;
78
+ const lastColon = normalized.lastIndexOf(":");
79
+ if (lastColon !== -1) {
80
+ const tail = normalized.slice(lastColon + 1);
81
+ if (tail.includes(".")) {
82
+ const octets = tail.split(".").map((p) => Number.parseInt(p, 10));
83
+ if (octets.length !== 4 || octets.some((o) => Number.isNaN(o) || o < 0 || o > 255)) {
84
+ return null;
85
+ }
86
+ const h1 = ((octets[0] << 8) | octets[1]).toString(16);
87
+ const h2 = ((octets[2] << 8) | octets[3]).toString(16);
88
+ normalized = `${normalized.slice(0, lastColon)}:${h1}:${h2}`;
89
+ }
90
+ }
91
+
92
+ let parts: string[];
93
+ if (normalized.includes("::")) {
94
+ const [left, right] = normalized.split("::");
95
+ const leftParts = left ? left.split(":") : [];
96
+ const rightParts = right ? right.split(":") : [];
97
+ const fill = 8 - leftParts.length - rightParts.length;
98
+ if (fill < 0) return null;
99
+ parts = [...leftParts, ...Array(fill).fill("0"), ...rightParts];
100
+ } else {
101
+ parts = normalized.split(":");
102
+ }
103
+
104
+ if (parts.length !== 8) return null;
105
+ const hextets = parts.map((p) => {
106
+ const n = Number.parseInt(p, 16);
107
+ return Number.isNaN(n) || n < 0 || n > 0xffff ? -1 : n;
108
+ });
109
+ if (hextets.some((h) => h === -1)) return null;
110
+ return hextets;
111
+ }
112
+
113
+ function isPrivateIp(address: string): boolean {
114
+ if (!address.includes(":")) {
115
+ return _isPrivateIpv4(address);
116
+ }
117
+
118
+ const lower = address.toLowerCase();
119
+ // Strip optional zone identifier (fe80::1%eth0 → fe80::1).
120
+ const noZone = lower.split("%")[0];
121
+
122
+ // Expand to 8 hextets so we can reliably detect IPv4-mapped/compatible
123
+ // forms even after URL canonicalization (e.g. [::ffff:127.0.0.1] →
124
+ // [::ffff:7f00:1]).
125
+ const hextets = expandIpv6(noZone);
126
+ if (hextets) {
127
+ // IPv4-mapped (::ffff:X.X.X.X) and IPv4-compatible (::X.X.X.X) — extract
128
+ // the embedded IPv4 from the last two hextets and check against IPv4 ranges.
129
+ const top6Zero = hextets.slice(0, 6).every((h) => h === 0);
130
+ const isMapped =
131
+ hextets[0] === 0 &&
132
+ hextets[1] === 0 &&
133
+ hextets[2] === 0 &&
134
+ hextets[3] === 0 &&
135
+ hextets[4] === 0 &&
136
+ hextets[5] === 0xffff;
137
+ if (isMapped || top6Zero) {
138
+ const a = (hextets[6] >> 8) & 0xff;
139
+ const b = hextets[6] & 0xff;
140
+ const c = (hextets[7] >> 8) & 0xff;
141
+ const d = hextets[7] & 0xff;
142
+ const ipv4 = `${a}.${b}.${c}.${d}`;
143
+ // ::1 (loopback) and :: (unspecified) both fall into top6Zero and last
144
+ // hextet 0 or 1 — _isPrivateIpv4 already blocks 0.0.0.0 and 127/8.
145
+ if (top6Zero && hextets[6] === 0 && hextets[7] <= 1) {
146
+ return true;
147
+ }
148
+ return _isPrivateIpv4(ipv4);
149
+ }
150
+
151
+ const firstHextet = hextets[0];
152
+ return (
153
+ // fe80::/10 link-local
154
+ (firstHextet >= 0xfe80 && firstHextet <= 0xfebf) ||
155
+ // fc00::/7 unique local
156
+ (firstHextet >= 0xfc00 && firstHextet <= 0xfdff) ||
157
+ // ff00::/8 multicast
158
+ firstHextet >= 0xff00
159
+ );
160
+ }
161
+
162
+ // Could not expand — be conservative and block.
163
+ return true;
164
+ }
165
+
166
+ async function assertPublicUrl(url: URL, allowPrivate: boolean): Promise<void> {
167
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
168
+ throw new Error(`Only http:// and https:// URLs are supported, got: ${url.protocol}`);
169
+ }
170
+ if (allowPrivate) return;
171
+
172
+ // URL.hostname returns IPv6 literals wrapped in brackets ("[::1]") per WHATWG.
173
+ // Strip them before checking the address.
174
+ const hostname = url.hostname.replace(/^\[|\]$/g, "");
175
+
176
+ // If the hostname is already a literal IP, check it directly — skip DNS so we
177
+ // also catch malformed IPv6 forms (::127.0.0.1, ::ffff:127.0.0.1) that some
178
+ // resolvers reject as "not found" instead of returning the embedded IPv4.
179
+ if (isIP(hostname) || hostname.includes(":")) {
180
+ if (isPrivateIp(hostname)) {
181
+ throw new Error(`Blocked private URL host ${url.hostname} (${hostname})`);
182
+ }
183
+ return;
184
+ }
185
+
186
+ const addresses = await lookup(hostname, { all: true, verbatim: true });
187
+ for (const { address } of addresses) {
188
+ if (isPrivateIp(address)) {
189
+ throw new Error(`Blocked private URL host ${url.hostname} (${address})`);
190
+ }
191
+ }
192
+ }
193
+
194
+ function resolveRedirectUrl(currentUrl: URL, location: string | null): URL {
195
+ if (!location) {
196
+ throw new Error(`Redirect from ${currentUrl.href} missing Location header`);
197
+ }
198
+ return new URL(location, currentUrl);
199
+ }
200
+
201
+ async function fetchWithRedirects(startUrl: URL, allowPrivate: boolean): Promise<Response> {
202
+ let currentUrl = startUrl;
203
+
204
+ for (let redirectCount = 0; redirectCount <= MAX_REDIRECTS; redirectCount++) {
205
+ await assertPublicUrl(currentUrl, allowPrivate);
206
+
207
+ const controller = new AbortController();
208
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
209
+ let response: Response;
210
+ try {
211
+ response = await fetch(currentUrl.href, {
212
+ signal: controller.signal,
213
+ redirect: "manual",
214
+ headers: {
215
+ "user-agent": "aft-opencode-plugin",
216
+ // Prioritize markdown for content-negotiating servers (GitHub API, many docs sites).
217
+ // `application/vnd.github.raw` is GitHub's custom type — returns raw markdown from
218
+ // repo file and readme endpoints. Falls back to HTML if markdown is not available.
219
+ accept:
220
+ "application/vnd.github.raw, text/markdown, text/x-markdown, text/html;q=0.9, text/plain;q=0.5",
221
+ },
222
+ });
223
+ } catch (err) {
224
+ throw new Error(`Failed to fetch ${currentUrl.href}: ${(err as Error).message}`);
225
+ } finally {
226
+ clearTimeout(timer);
227
+ }
228
+
229
+ if (response.status < 300 || response.status >= 400) {
230
+ return response;
231
+ }
232
+
233
+ if (redirectCount === MAX_REDIRECTS) {
234
+ throw new Error(`Too many redirects fetching ${startUrl.href}`);
235
+ }
236
+ currentUrl = resolveRedirectUrl(currentUrl, response.headers.get("location"));
237
+ }
238
+
239
+ throw new Error(`Too many redirects fetching ${startUrl.href}`);
240
+ }
241
+
43
242
  /**
44
243
  * Map a Content-Type header to a file extension AFT can parse.
45
244
  * Returns null for unsupported types.
@@ -79,7 +278,11 @@ function resolveExtension(contentType: string): string | null {
79
278
  * Returns the cached file path the Rust outline/zoom command can read.
80
279
  * Throws on errors (invalid URL, network failure, unsupported content type, oversized body).
81
280
  */
82
- export async function fetchUrlToTempFile(url: string, storageDir: string): Promise<string> {
281
+ export async function fetchUrlToTempFile(
282
+ url: string,
283
+ storageDir: string,
284
+ options: FetchUrlOptions = {},
285
+ ): Promise<string> {
83
286
  let parsed: URL;
84
287
  try {
85
288
  parsed = new URL(url);
@@ -89,6 +292,7 @@ export async function fetchUrlToTempFile(url: string, storageDir: string): Promi
89
292
  if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
90
293
  throw new Error(`Only http:// and https:// URLs are supported, got: ${parsed.protocol}`);
91
294
  }
295
+ const allowPrivate = options.allowPrivate === true;
92
296
 
93
297
  const dir = cacheDir(storageDir);
94
298
  mkdirSync(dir, { recursive: true });
@@ -112,28 +316,7 @@ export async function fetchUrlToTempFile(url: string, storageDir: string): Promi
112
316
  }
113
317
 
114
318
  log(`Fetching URL: ${url}`);
115
-
116
- const controller = new AbortController();
117
- const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
118
- let response: Response;
119
- try {
120
- response = await fetch(url, {
121
- signal: controller.signal,
122
- redirect: "follow",
123
- headers: {
124
- "user-agent": "aft-opencode-plugin",
125
- // Prioritize markdown for content-negotiating servers (GitHub API, many docs sites).
126
- // `application/vnd.github.raw` is GitHub's custom type — returns raw markdown from
127
- // repo file and readme endpoints. Falls back to HTML if markdown is not available.
128
- accept:
129
- "application/vnd.github.raw, text/markdown, text/x-markdown, text/html;q=0.9, text/plain;q=0.5",
130
- },
131
- });
132
- } catch (err) {
133
- throw new Error(`Failed to fetch ${url}: ${(err as Error).message}`);
134
- } finally {
135
- clearTimeout(timer);
136
- }
319
+ const response = await fetchWithRedirects(parsed, allowPrivate);
137
320
 
138
321
  if (!response.ok) {
139
322
  throw new Error(`HTTP ${response.status} ${response.statusText} fetching ${url}`);
package/src/tui/index.tsx CHANGED
@@ -5,6 +5,11 @@ import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui";
5
5
  import { AftRpcClient } from "../shared/rpc-client";
6
6
  import { coerceAftStatus, formatStatusDialogMessage } from "../shared/status";
7
7
 
8
+ // The TUI talks to the server plugin via AftRpcClient. The client reads the
9
+ // JSON port file written by AftRpcServer ({ port, token }) and includes that
10
+ // per-server token on every RPC request; legacy integer port files are still
11
+ // tolerated for already-running older server plugins.
12
+
8
13
  const STATUS_COMMAND = "aft-status";
9
14
 
10
15
  // RPC clients keyed by directory — one per project