@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.
- package/dist/bridge.d.ts +5 -0
- package/dist/bridge.d.ts.map +1 -1
- package/dist/config.d.ts +1 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/downloader.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +424 -103
- package/dist/onnx-runtime.d.ts.map +1 -1
- package/dist/pool.d.ts.map +1 -1
- package/dist/resolver.d.ts.map +1 -1
- package/dist/shared/rpc-client.d.ts +2 -0
- package/dist/shared/rpc-client.d.ts.map +1 -1
- package/dist/shared/rpc-server.d.ts +1 -0
- package/dist/shared/rpc-server.d.ts.map +1 -1
- package/dist/shared/url-fetch.d.ts +7 -1
- package/dist/shared/url-fetch.d.ts.map +1 -1
- package/dist/tools/hoisted-internals.d.ts +9 -0
- package/dist/tools/hoisted-internals.d.ts.map +1 -0
- package/dist/tools/hoisted.d.ts +2 -0
- package/dist/tools/hoisted.d.ts.map +1 -1
- package/dist/tools/reading.d.ts.map +1 -1
- package/dist/tui.js +89 -15
- package/package.json +6 -6
- package/src/shared/rpc-client.ts +33 -14
- package/src/shared/rpc-server.ts +15 -3
- package/src/shared/url-fetch.ts +206 -23
- package/src/tui/index.tsx +5 -0
package/src/shared/url-fetch.ts
CHANGED
|
@@ -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(
|
|
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
|