@openlines/opl-pi-sht 0.2.12 → 0.3.1
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/README.md +101 -85
- package/configs/opl-browser.json.sample +3 -2
- package/configs/opl-footer.json.sample +3 -2
- package/configs/opl-guardian.json.sample +8 -2
- package/configs/opl-modes.json.sample +5 -0
- package/configs/opl-webaccess.json.sample +7 -1
- package/extensions/opl-browser/README.md +81 -55
- package/extensions/opl-browser/browser.ts +244 -50
- package/extensions/opl-browser/config.ts +15 -7
- package/extensions/opl-browser/index.ts +5 -4
- package/extensions/opl-browser/package-lock.json +3 -3
- package/extensions/opl-browser/package.json +4 -4
- package/extensions/opl-browser/validate.ts +313 -60
- package/extensions/opl-configurator/README.md +40 -0
- package/extensions/opl-configurator/configurator.ts +151 -0
- package/extensions/opl-configurator/index.ts +12 -0
- package/extensions/opl-ctxtrim/README.md +45 -14
- package/extensions/opl-ctxtrim/index.ts +33 -13
- package/extensions/opl-footer/README.md +31 -6
- package/extensions/opl-footer/config.ts +11 -10
- package/extensions/opl-footer/configure.ts +159 -115
- package/extensions/opl-footer/git-status.ts +58 -1
- package/extensions/opl-footer/icons.ts +3 -0
- package/extensions/opl-footer/index.ts +209 -105
- package/extensions/opl-footer/segments/caveman.ts +9 -1
- package/extensions/opl-footer/segments/chat-mode.ts +8 -1
- package/extensions/opl-footer/segments/compactions.ts +13 -0
- package/extensions/opl-footer/segments/context.ts +6 -5
- package/extensions/opl-footer/segments/index.ts +2 -0
- package/extensions/opl-footer/segments/mode-switcher.ts +14 -1
- package/extensions/opl-footer/segments/path.ts +1 -1
- package/extensions/opl-footer/segments/plan-mode.ts +8 -1
- package/extensions/opl-footer/theme.ts +5 -2
- package/extensions/opl-footer/types.ts +4 -0
- package/extensions/opl-guardian/README.md +17 -5
- package/extensions/opl-guardian/config.ts +72 -6
- package/extensions/opl-guardian/guardian.ts +42 -10
- package/extensions/opl-guardian/index.ts +52 -17
- package/extensions/opl-guardian/policies.ts +52 -16
- package/extensions/opl-init/README.md +12 -4
- package/extensions/opl-init/index.ts +228 -27
- package/extensions/opl-input/README.md +17 -3
- package/extensions/opl-input/config.ts +59 -5
- package/extensions/opl-input/configure.ts +146 -0
- package/extensions/opl-input/index.ts +38 -30
- package/extensions/opl-input/mode-style.ts +6 -2
- package/extensions/opl-modes/README.md +22 -8
- package/extensions/opl-modes/config.ts +62 -8
- package/extensions/opl-modes/index.ts +87 -28
- package/extensions/opl-modes/state.ts +50 -2
- package/extensions/opl-modes/types.ts +6 -0
- package/extensions/opl-modes/utils.ts +18 -8
- package/extensions/opl-questionnaire/index.ts +10 -4
- package/extensions/opl-questionnaire/types.ts +4 -0
- package/extensions/opl-simplebench/README.md +21 -15
- package/extensions/opl-simplebench/artifact.ts +7 -4
- package/extensions/opl-simplebench/benchmark.ts +96 -35
- package/extensions/opl-simplebench/coding.ts +9 -4
- package/extensions/opl-simplebench/index.ts +19 -19
- package/extensions/opl-simplebench/llama-server.ts +3 -2
- package/extensions/opl-simplebench/report.ts +0 -6
- package/extensions/opl-simplebench/research.ts +5 -4
- package/extensions/opl-simplebench/scoring.ts +4 -2
- package/extensions/opl-simplebench/util/config.ts +15 -8
- package/extensions/opl-simplebench/util/exec-env.ts +36 -0
- package/extensions/opl-simplebench/util/format.ts +2 -2
- package/extensions/opl-simplebench/util/providers.ts +23 -194
- package/extensions/opl-todo/README.md +5 -5
- package/extensions/opl-todo/config.ts +7 -2
- package/extensions/opl-todo/index.ts +50 -9
- package/extensions/opl-webaccess/README.md +31 -5
- package/extensions/opl-webaccess/config.ts +36 -7
- package/extensions/opl-webaccess/extract.ts +37 -47
- package/extensions/opl-webaccess/http.ts +259 -0
- package/extensions/opl-webaccess/index.ts +12 -6
- package/extensions/opl-webaccess/package-lock.json +3 -3
- package/extensions/opl-webaccess/package.json +4 -9
- package/extensions/opl-webaccess/pdf.ts +4 -4
- package/extensions/opl-webaccess/search.ts +19 -8
- package/extensions/opl-webaccess/storage.ts +134 -2
- package/extensions/opl-webaccess/types.ts +4 -0
- package/extensions/opl-webaccess/utils.ts +221 -33
- package/package.json +19 -12
|
@@ -1,8 +1,20 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
1
4
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
5
|
import type { StoredData } from "./types.js";
|
|
3
6
|
|
|
4
7
|
const CUSTOM_TYPE = "web-access-results";
|
|
5
|
-
const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
|
|
8
|
+
export const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Bytes of body text kept per item in the session copy. The body itself is
|
|
12
|
+
* spilled to `cacheDir()`; a session used to hold every fetched byte forever
|
|
13
|
+
* (up to 10 MB x 20 URLs per call) while the in-memory cache only lives an hour.
|
|
14
|
+
*/
|
|
15
|
+
export const PREVIEW_ITEM_BYTES = 512;
|
|
16
|
+
/** Ceiling for the spill directory; the oldest bodies are dropped past it. */
|
|
17
|
+
export const MAX_CACHE_BYTES = 32 * 1024 * 1024;
|
|
6
18
|
|
|
7
19
|
const store = new Map<string, StoredData>();
|
|
8
20
|
|
|
@@ -18,11 +30,119 @@ export function generateId(): string {
|
|
|
18
30
|
return Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
|
|
19
31
|
}
|
|
20
32
|
|
|
33
|
+
/** Where full bodies are spilled: outside the session file, inside pi's agent dir. */
|
|
34
|
+
export function cacheDir(): string {
|
|
35
|
+
return join(getAgentDir(), "web-access-cache");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Only our own ids are ever turned into a path. */
|
|
39
|
+
function cacheFileFor(id: string): string | null {
|
|
40
|
+
if (!/^[a-z0-9]+$/i.test(id)) return null;
|
|
41
|
+
return join(cacheDir(), `${id}.json`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function capItem(text: string): string {
|
|
45
|
+
const bytes = Buffer.byteLength(text, "utf8");
|
|
46
|
+
if (bytes <= PREVIEW_ITEM_BYTES) return text;
|
|
47
|
+
let head = Buffer.from(text, "utf8").subarray(0, PREVIEW_ITEM_BYTES).toString("utf8");
|
|
48
|
+
if (head.endsWith("\uFFFD")) head = head.slice(0, -1); // never cut mid-codepoint
|
|
49
|
+
const omitted = bytes - Buffer.byteLength(head, "utf8");
|
|
50
|
+
return `${head}\n\n[… ${omitted} bytes omitted from the session copy — the full body stays in the disk cache for ${Math.round(CACHE_TTL_MS / 60000)} minutes]`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A bounded copy for the session entry: identity plus the head of each body. */
|
|
54
|
+
export function previewize(data: StoredData): StoredData {
|
|
55
|
+
const out: StoredData = { id: data.id, type: data.type, timestamp: data.timestamp };
|
|
56
|
+
let truncated = false;
|
|
57
|
+
if (data.queries) {
|
|
58
|
+
out.queries = data.queries.map((q) => {
|
|
59
|
+
const answer = capItem(q.answer ?? "");
|
|
60
|
+
truncated ||= answer !== (q.answer ?? "");
|
|
61
|
+
return { ...q, answer };
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
if (data.urls) {
|
|
65
|
+
out.urls = data.urls.map((u) => {
|
|
66
|
+
const content = capItem(u.content ?? "");
|
|
67
|
+
truncated ||= content !== (u.content ?? "");
|
|
68
|
+
return { ...u, content };
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
if (truncated) out.truncated = true;
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Write the full body to the disk cache. A cache we cannot write must not fail the call. */
|
|
76
|
+
function spill(data: StoredData): string | null {
|
|
77
|
+
const file = cacheFileFor(data.id);
|
|
78
|
+
if (!file) return null;
|
|
79
|
+
try {
|
|
80
|
+
mkdirSync(cacheDir(), { recursive: true });
|
|
81
|
+
writeFileSync(file, JSON.stringify(data), "utf8");
|
|
82
|
+
return file;
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function readSpill(data: StoredData): StoredData | null {
|
|
89
|
+
if (!data.file) return null;
|
|
90
|
+
try {
|
|
91
|
+
const parsed = JSON.parse(readFileSync(data.file, "utf8")) as StoredData;
|
|
92
|
+
return parsed && parsed.id === data.id && parsed.type === data.type ? parsed : null;
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Drop expired bodies, then trim the oldest ones until the directory fits
|
|
100
|
+
* MAX_CACHE_BYTES. Returns how many files were removed.
|
|
101
|
+
*/
|
|
102
|
+
export function pruneCache(now: number = Date.now(), maxBytes: number = MAX_CACHE_BYTES): number {
|
|
103
|
+
const dir = cacheDir();
|
|
104
|
+
if (!existsSync(dir)) return 0;
|
|
105
|
+
const cutoff = now - CACHE_TTL_MS;
|
|
106
|
+
let removed = 0;
|
|
107
|
+
const alive: { path: string; mtimeMs: number; size: number }[] = [];
|
|
108
|
+
for (const name of readdirSync(dir)) {
|
|
109
|
+
if (!name.endsWith(".json")) continue;
|
|
110
|
+
const path = join(dir, name);
|
|
111
|
+
try {
|
|
112
|
+
const stat = statSync(path);
|
|
113
|
+
if (stat.mtimeMs < cutoff) {
|
|
114
|
+
rmSync(path, { force: true });
|
|
115
|
+
removed++;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
alive.push({ path, mtimeMs: stat.mtimeMs, size: stat.size });
|
|
119
|
+
} catch {
|
|
120
|
+
// Raced away between readdir and stat: nothing to keep.
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
let total = alive.reduce((sum, f) => sum + f.size, 0);
|
|
124
|
+
if (total > maxBytes) {
|
|
125
|
+
alive.sort((a, b) => a.mtimeMs - b.mtimeMs); // oldest first
|
|
126
|
+
for (const f of alive) {
|
|
127
|
+
if (total <= maxBytes) break;
|
|
128
|
+
rmSync(f.path, { force: true });
|
|
129
|
+
total -= f.size;
|
|
130
|
+
removed++;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return removed;
|
|
134
|
+
}
|
|
135
|
+
|
|
21
136
|
export function storeResult(id: string, data: StoredData): void {
|
|
22
137
|
store.set(id, data);
|
|
23
138
|
evictExpired();
|
|
24
139
|
}
|
|
25
140
|
|
|
141
|
+
/**
|
|
142
|
+
* Read a result. Entries restored from the session hold a preview plus a pointer;
|
|
143
|
+
* the full body is hydrated from disk on first read and cached in memory, so
|
|
144
|
+
* session_start never has to open every file.
|
|
145
|
+
*/
|
|
26
146
|
export function getResult(id: string, now: number = Date.now()): StoredData | null {
|
|
27
147
|
const data = store.get(id);
|
|
28
148
|
if (!data) return null;
|
|
@@ -30,6 +150,13 @@ export function getResult(id: string, now: number = Date.now()): StoredData | nu
|
|
|
30
150
|
store.delete(id);
|
|
31
151
|
return null;
|
|
32
152
|
}
|
|
153
|
+
if (data.truncated && data.file) {
|
|
154
|
+
const full = readSpill(data);
|
|
155
|
+
if (full) {
|
|
156
|
+
store.set(id, full);
|
|
157
|
+
return full;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
33
160
|
return data;
|
|
34
161
|
}
|
|
35
162
|
|
|
@@ -37,8 +164,13 @@ export function clearStore(): void {
|
|
|
37
164
|
store.clear();
|
|
38
165
|
}
|
|
39
166
|
|
|
167
|
+
/** Persist a pointer plus a bounded preview; the body goes to the disk cache. */
|
|
40
168
|
export function persistResult(pi: ExtensionAPI, data: StoredData): void {
|
|
41
|
-
|
|
169
|
+
const entry = previewize(data);
|
|
170
|
+
const file = spill(data);
|
|
171
|
+
if (file) entry.file = file;
|
|
172
|
+
pi.appendEntry(CUSTOM_TYPE, entry);
|
|
173
|
+
pruneCache();
|
|
42
174
|
}
|
|
43
175
|
|
|
44
176
|
export function restoreFromSession(ctx: ExtensionContext): void {
|
|
@@ -23,4 +23,8 @@ export interface StoredData {
|
|
|
23
23
|
timestamp: number;
|
|
24
24
|
queries?: SearchQueryResult[];
|
|
25
25
|
urls?: ExtractedContent[];
|
|
26
|
+
/** True when this is the session copy: body text was capped and lives in `file`. */
|
|
27
|
+
truncated?: boolean;
|
|
28
|
+
/** Path of the spilled full body in the disk cache, when one was written. */
|
|
29
|
+
file?: string;
|
|
26
30
|
}
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { promises as dns } from "node:dns";
|
|
2
|
+
import type { PinnedFetchOptions, PinnedResponse } from "./http.js";
|
|
3
|
+
|
|
1
4
|
export function errorMessage(err: unknown): string {
|
|
2
5
|
return err instanceof Error ? err.message : String(err);
|
|
3
6
|
}
|
|
@@ -55,10 +58,31 @@ export function isPdfContentType(contentType: string): boolean {
|
|
|
55
58
|
}
|
|
56
59
|
|
|
57
60
|
export interface HttpUrlOptions {
|
|
58
|
-
/** Allow private, link-local, and reserved network hosts (default false).
|
|
61
|
+
/** Allow private, link-local, and reserved network hosts (default false). Cloud metadata is always blocked. */
|
|
59
62
|
allowPrivateNetwork?: boolean;
|
|
63
|
+
/** Allow loopback hosts — localhost, 127.0.0.0/8, ::1 (default false: opt in). */
|
|
64
|
+
allowLoopback?: boolean;
|
|
65
|
+
/** Deadline for one request (ms), combined with the caller's signal so the shorter
|
|
66
|
+
* of the two wins. Used by the fetch path; the guard itself ignores it. */
|
|
67
|
+
timeoutMs?: number;
|
|
68
|
+
/** Ceiling on one response body (bytes). The transport stops reading and drops the socket
|
|
69
|
+
* past it, so an oversized page cannot cost memory before truncation gets a say. */
|
|
70
|
+
maxResponseBytes?: number;
|
|
71
|
+
/** Test seam for host resolution; production callers leave it unset and use node:dns. */
|
|
72
|
+
resolveHost?: (host: string) => Promise<string[]>;
|
|
73
|
+
/** Test seam for the socket layer; production callers leave it unset and use pinnedFetch.
|
|
74
|
+
* The guard still runs - only the connection is replaced. */
|
|
75
|
+
transport?: (target: SafeHttpTarget, opts: PinnedFetchOptions) => Promise<PinnedResponse>;
|
|
76
|
+
/** Caller's abort signal. The lookup itself cannot be cancelled, so this only bounds how long
|
|
77
|
+
* the caller waits; without it a stalled getaddrinfo outlives the caller's own timeout. */
|
|
78
|
+
signal?: AbortSignal;
|
|
79
|
+
/** Self-bound for the DNS round-trip when the caller passes no signal (ms). */
|
|
80
|
+
dnsTimeoutMs?: number;
|
|
60
81
|
}
|
|
61
82
|
|
|
83
|
+
// BEGIN SHARED HOST GUARD — duplicated verbatim in extensions/opl-browser/validate.ts
|
|
84
|
+
// (install.sh installs one extension directory at a time, so this cannot be a shared import;
|
|
85
|
+
// tests/net-guard-parity.test.mjs fails if the two copies drift)
|
|
62
86
|
/** Well-known cloud metadata hostnames that resolve to instance-credential endpoints. */
|
|
63
87
|
const BLOCKED_METADATA_HOSTS = new Set([
|
|
64
88
|
"metadata",
|
|
@@ -68,29 +92,55 @@ const BLOCKED_METADATA_HOSTS = new Set([
|
|
|
68
92
|
]);
|
|
69
93
|
|
|
70
94
|
/** Cloud metadata IPv6 literals (e.g. AWS IMDS over IPv6). Always blocked. */
|
|
71
|
-
const BLOCKED_METADATA_V6 = new Set(["fd00:ec2
|
|
95
|
+
const BLOCKED_METADATA_V6 = new Set(["fd00:ec2:0:0:0:0:0:254"]);
|
|
96
|
+
|
|
97
|
+
type HostClass = "loopback" | "metadata" | "unspecified" | "translation" | "private" | "public";
|
|
72
98
|
|
|
73
|
-
|
|
99
|
+
/** The four octets of an IPv4 address. The arity is the whole point of the classifier, so it is a
|
|
100
|
+
* tuple rather than a list whose elements TypeScript must assume could be missing. */
|
|
101
|
+
type Ipv4 = readonly [number, number, number, number];
|
|
102
|
+
/** The eight groups of an IPv6 literal, with any `::` already expanded. */
|
|
103
|
+
type Ipv6Groups = readonly [number, number, number, number, number, number, number, number];
|
|
104
|
+
|
|
105
|
+
/** Build a tuple from a list whose arity a length check established but `split`/`map` cannot show
|
|
106
|
+
* to TypeScript. The length test keeps the previous rejection of over-long quads; the per-element
|
|
107
|
+
* tests are what the compiler needs. These are the only two widening points in the guard. */
|
|
108
|
+
function asIpv4(values: number[]): Ipv4 | null {
|
|
109
|
+
const [a, b, c, d] = values;
|
|
110
|
+
if (values.length !== 4 || a === undefined || b === undefined || c === undefined || d === undefined) return null;
|
|
111
|
+
return [a, b, c, d];
|
|
112
|
+
}
|
|
113
|
+
function asIpv6Groups(values: number[]): Ipv6Groups | null {
|
|
114
|
+
const [a, b, c, d, e, f, g, h] = values;
|
|
115
|
+
if (
|
|
116
|
+
values.length !== 8 ||
|
|
117
|
+
a === undefined || b === undefined || c === undefined || d === undefined ||
|
|
118
|
+
e === undefined || f === undefined || g === undefined || h === undefined
|
|
119
|
+
) {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
return [a, b, c, d, e, f, g, h];
|
|
123
|
+
}
|
|
74
124
|
|
|
75
125
|
/** Decode a bare unsigned 32-bit integer hostname (e.g. 2130706433) into octets. */
|
|
76
|
-
function ipv4FromInteger(n: number):
|
|
126
|
+
function ipv4FromInteger(n: number): Ipv4 | null {
|
|
77
127
|
if (!Number.isInteger(n) || n < 0 || n > 0xffffffff) return null;
|
|
78
128
|
return [(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff];
|
|
79
129
|
}
|
|
80
130
|
|
|
81
131
|
/** Parse a host into IPv4 octets (dotted-quad or integer form), or null when not an IPv4 literal. */
|
|
82
|
-
function ipv4Octets(host: string):
|
|
132
|
+
function ipv4Octets(host: string): Ipv4 | null {
|
|
83
133
|
if (/^\d+$/.test(host)) return ipv4FromInteger(Number(host));
|
|
84
134
|
const octets = host.split(".").map((part) => (/^\d+$/.test(part) ? Number(part) : NaN));
|
|
85
|
-
if (octets.
|
|
86
|
-
return octets;
|
|
135
|
+
if (octets.some((o) => !Number.isInteger(o) || o < 0 || o > 255)) return null;
|
|
136
|
+
return asIpv4(octets);
|
|
87
137
|
}
|
|
88
138
|
|
|
89
|
-
function isLoopbackIpv4(o:
|
|
139
|
+
function isLoopbackIpv4(o: Ipv4): boolean {
|
|
90
140
|
return o[0] === 127;
|
|
91
141
|
}
|
|
92
142
|
|
|
93
|
-
function isMetadataIpv4(o:
|
|
143
|
+
function isMetadataIpv4(o: Ipv4): boolean {
|
|
94
144
|
return (
|
|
95
145
|
(o[0] === 169 && o[1] === 254 && o[2] === 169 && o[3] === 254) || // AWS/Azure/GCP/Oracle
|
|
96
146
|
(o[0] === 100 && o[1] === 100 && o[2] === 100 && o[3] === 200) // Alibaba
|
|
@@ -98,10 +148,10 @@ function isMetadataIpv4(o: number[]): boolean {
|
|
|
98
148
|
}
|
|
99
149
|
|
|
100
150
|
/** Non-globally-routable (blocked by default, opt-in via allowPrivateNetwork). */
|
|
101
|
-
function isPrivateIpv4(o:
|
|
151
|
+
function isPrivateIpv4(o: Ipv4): boolean {
|
|
102
152
|
const [a, b, c] = o;
|
|
103
153
|
return (
|
|
104
|
-
a ===
|
|
154
|
+
a === 10 || // 10/8
|
|
105
155
|
(a === 100 && b >= 64 && b <= 127) || // 100.64/10 carrier-grade NAT
|
|
106
156
|
(a === 169 && b === 254) || // 169.254/16 link-local
|
|
107
157
|
(a === 172 && b >= 16 && b <= 31) || // 172.16/12
|
|
@@ -114,32 +164,84 @@ function isPrivateIpv4(o: number[]): boolean {
|
|
|
114
164
|
);
|
|
115
165
|
}
|
|
116
166
|
|
|
117
|
-
/**
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
167
|
+
/** "This host on this network" (0.0.0.0/8, ::) — reaching it means reaching local listeners,
|
|
168
|
+
* so it is never toggleable the way cloud metadata is not. */
|
|
169
|
+
function isUnspecifiedIpv4(o: Ipv4): boolean {
|
|
170
|
+
return o[0] === 0;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Expand a compressed, dotted-suffix or full IPv6 literal into its eight 16-bit groups. */
|
|
174
|
+
function ipv6Groups(ip: string): Ipv6Groups | null {
|
|
175
|
+
const sides = ip.split("::");
|
|
176
|
+
if (sides.length > 2) return null;
|
|
177
|
+
const parse = (side: string): number[] | null => {
|
|
178
|
+
if (!side) return [];
|
|
179
|
+
const out: number[] = [];
|
|
180
|
+
for (const part of side.split(":")) {
|
|
181
|
+
const dotted = part.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
182
|
+
if (dotted) {
|
|
183
|
+
const o = asIpv4(dotted.slice(1, 5).map(Number));
|
|
184
|
+
if (!o || o.some((n) => n > 255)) return null;
|
|
185
|
+
const [q0, q1, q2, q3] = o;
|
|
186
|
+
out.push((q0 << 8) | q1, (q2 << 8) | q3);
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (!part || !/^[0-9a-f]{1,4}$/.test(part)) return null;
|
|
190
|
+
out.push(parseInt(part, 16));
|
|
191
|
+
}
|
|
192
|
+
return out;
|
|
193
|
+
};
|
|
194
|
+
const head = parse(sides[0] ?? "");
|
|
195
|
+
if (!head) return null;
|
|
196
|
+
if (sides.length === 1) return asIpv6Groups(head);
|
|
197
|
+
const tail = parse(sides[1] ?? "");
|
|
198
|
+
if (!tail) return null;
|
|
199
|
+
const fill = 8 - head.length - tail.length;
|
|
200
|
+
if (fill < 1) return null; // `::` must stand for at least one zero group
|
|
201
|
+
return asIpv6Groups([...head, ...Array(fill).fill(0), ...tail]);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** IPv4 that an IPv6 literal still routes to: mapped (`::ffff:`), NAT64 (`64:ff9b::/96`),
|
|
205
|
+
* 6to4 (`2002::/16`) and the deprecated IPv4-compatible form (`::a.b`).
|
|
206
|
+
* Wherever those prefixes are still routable they reach the embedded address, so the embedded
|
|
207
|
+
* address is what has to be classified — otherwise `http://[64:ff9b::a9fe:a9fe]/` walks past the
|
|
208
|
+
* metadata block one literal away from 169.254.169.254, and a DNS64 answer can do the same. */
|
|
209
|
+
function embeddedIpv4Octets(ip: string): Ipv4 | null {
|
|
210
|
+
const g = ipv6Groups(ip);
|
|
211
|
+
if (!g) return null;
|
|
212
|
+
const zeros = (from: number, to: number) => g.slice(from, to).every((x) => x === 0);
|
|
213
|
+
const low32 = (): Ipv4 => [(g[6] >> 8) & 0xff, g[6] & 0xff, (g[7] >> 8) & 0xff, g[7] & 0xff];
|
|
214
|
+
if (g[5] === 0xffff && zeros(0, 5)) return low32();
|
|
215
|
+
if (g[0] === 0x2002) {
|
|
216
|
+
return [(g[1] >> 8) & 0xff, g[1] & 0xff, (g[2] >> 8) & 0xff, g[2] & 0xff]; // 6to4 carries v4 after the prefix (tuple by return type)
|
|
217
|
+
}
|
|
218
|
+
if (g[0] === 0x0064 && g[1] === 0xff9b) {
|
|
219
|
+
if (zeros(2, 6)) return low32(); // NAT64 well-known prefix
|
|
125
220
|
}
|
|
221
|
+
if (zeros(0, 6) && (g[6] !== 0 || g[7] !== 0)) return low32(); // IPv4-compatible (deprecated)
|
|
126
222
|
return null;
|
|
127
223
|
}
|
|
128
224
|
|
|
129
|
-
function classifyIpv4Octets(o:
|
|
225
|
+
function classifyIpv4Octets(o: Ipv4): HostClass {
|
|
130
226
|
if (isLoopbackIpv4(o)) return "loopback";
|
|
131
227
|
if (isMetadataIpv4(o)) return "metadata";
|
|
228
|
+
if (isUnspecifiedIpv4(o)) return "unspecified";
|
|
132
229
|
if (isPrivateIpv4(o)) return "private";
|
|
133
230
|
return "public";
|
|
134
231
|
}
|
|
135
232
|
|
|
136
233
|
function classifyIpv6Host(host: string): HostClass {
|
|
137
234
|
const ip = host.replace(/^\[/, "").replace(/\]$/, "").replace(/%.*$/, "").toLowerCase();
|
|
138
|
-
|
|
139
|
-
if (
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
235
|
+
const groups = ipv6Groups(ip);
|
|
236
|
+
if (groups) {
|
|
237
|
+
if (BLOCKED_METADATA_V6.has(groups.map((g) => g.toString(16)).join(":"))) return "metadata";
|
|
238
|
+
if (groups.every((g) => g === 0)) return "unspecified";
|
|
239
|
+
if (groups.slice(0, 7).every((g) => g === 0) && groups[7] === 1) return "loopback";
|
|
240
|
+
// Local-use translation layouts vary; no opt-in may bypass this prefix block.
|
|
241
|
+
if (groups[0] === 0x0064 && groups[1] === 0xff9b && groups[2] === 1) return "translation";
|
|
242
|
+
}
|
|
243
|
+
const embedded = embeddedIpv4Octets(ip);
|
|
244
|
+
if (embedded) return classifyIpv4Octets(embedded);
|
|
143
245
|
if (/^f[cd]/.test(ip)) return "private"; // fc00::/7 unique-local
|
|
144
246
|
if (/^fe[89ab]/.test(ip)) return "private"; // fe80::/10 link-local
|
|
145
247
|
if (/^ff/.test(ip)) return "private"; // ff00::/8 multicast
|
|
@@ -155,17 +257,18 @@ function classifyHost(host: string): HostClass {
|
|
|
155
257
|
return octets ? classifyIpv4Octets(octets) : "public";
|
|
156
258
|
}
|
|
157
259
|
|
|
158
|
-
function isBlockedNetworkHost(host: string, allowPrivateNetwork: boolean): boolean {
|
|
260
|
+
function isBlockedNetworkHost(host: string, allowPrivateNetwork: boolean, allowLoopback: boolean): boolean {
|
|
159
261
|
const cls = classifyHost(host);
|
|
160
|
-
if (cls === "metadata") return true; // never toggleable
|
|
161
|
-
if (cls === "loopback"
|
|
262
|
+
if (cls === "metadata" || cls === "unspecified" || cls === "translation") return true; // never toggleable
|
|
263
|
+
if (cls === "loopback") return !allowLoopback;
|
|
264
|
+
if (cls === "public") return false;
|
|
162
265
|
return !allowPrivateNetwork; // private / link-local / reserved
|
|
163
266
|
}
|
|
164
267
|
|
|
165
268
|
/**
|
|
166
269
|
* Normalize a URL, allowing only http/https. Loopback (localhost/127.0.0.0/8/::1) is
|
|
167
|
-
*
|
|
168
|
-
*
|
|
270
|
+
* opt-in through allowLoopback; private/link-local/reserved ranges are blocked unless
|
|
271
|
+
* allowPrivateNetwork is set; cloud metadata, unspecified addresses and 64:ff9b:1::/48 are always blocked.
|
|
169
272
|
*/
|
|
170
273
|
export function assertHttpUrl(url: string, opts: HttpUrlOptions = {}): string {
|
|
171
274
|
let parsed: URL;
|
|
@@ -175,10 +278,95 @@ export function assertHttpUrl(url: string, opts: HttpUrlOptions = {}): string {
|
|
|
175
278
|
throw new Error(`Invalid URL: ${url}`);
|
|
176
279
|
}
|
|
177
280
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
178
|
-
throw new Error(`
|
|
281
|
+
throw new Error(`Only http/https URLs are allowed, "${parsed.protocol}"`);
|
|
179
282
|
}
|
|
180
|
-
if (isBlockedNetworkHost(parsed.hostname, opts.allowPrivateNetwork === true)) {
|
|
181
|
-
throw new Error(`Blocked network host "${parsed.hostname}" — private, link-local,
|
|
283
|
+
if (isBlockedNetworkHost(parsed.hostname, opts.allowPrivateNetwork === true, opts.allowLoopback === true)) {
|
|
284
|
+
throw new Error(`Blocked network host "${parsed.hostname}" — private, link-local, and reserved URLs are not allowed (set allowPrivateNetwork to opt in to private ranges, or allowLoopback for localhost and 127.0.0.0/8; cloud-metadata, unspecified addresses such as 0.0.0.0, and the 64:ff9b:1::/48 translation prefix are always blocked)`);
|
|
182
285
|
}
|
|
183
286
|
return parsed.href;
|
|
184
287
|
}
|
|
288
|
+
|
|
289
|
+
export function isIpLiteralHost(host: string): boolean {
|
|
290
|
+
const lowered = host.toLowerCase().replace(/^\[|\]$/g, "");
|
|
291
|
+
return lowered.includes(":") || /^\d+$/.test(lowered) || /^\d{1,3}(\.\d{1,3}){3}$/.test(lowered);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** In-flight lookups by host. A page with hundreds of subresources on one host would otherwise
|
|
295
|
+
* serialize hundreds of getaddrinfo calls on libuv's small thread pool. Entries are dropped as
|
|
296
|
+
* soon as the lookup settles, so this deduplicates concurrency only: no decision is reused, and
|
|
297
|
+
* a hostname that changes between two calls still gets two independent answers. */
|
|
298
|
+
const inflightLookups = new Map<string, Promise<string[]>>();
|
|
299
|
+
|
|
300
|
+
/** Default resolver. Rethrows the original error so `.code` survives for callers. */
|
|
301
|
+
export function resolveHostAddresses(host: string): Promise<string[]> {
|
|
302
|
+
const running = inflightLookups.get(host);
|
|
303
|
+
if (running) return running;
|
|
304
|
+
const pending = dns
|
|
305
|
+
.lookup(host, { all: true, verbatim: true })
|
|
306
|
+
.then((hits) => hits.map((hit) => hit.address));
|
|
307
|
+
inflightLookups.set(host, pending);
|
|
308
|
+
const stop = () => {
|
|
309
|
+
if (inflightLookups.get(host) === pending) inflightLookups.delete(host);
|
|
310
|
+
};
|
|
311
|
+
pending.then(stop, stop);
|
|
312
|
+
return pending;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Reject as soon as the caller gives up. `dns.lookup` cannot be cancelled, so without this a
|
|
316
|
+
* stalled getaddrinfo would hold the tool past its own timeout. */
|
|
317
|
+
function untilAborted<T>(pending: Promise<T>, signal?: AbortSignal): Promise<T> {
|
|
318
|
+
if (!signal) return pending;
|
|
319
|
+
const aborted = new Promise<never>((_, reject) => {
|
|
320
|
+
const fail = () => reject(Object.assign(new Error("Aborted"), { name: "AbortError" }));
|
|
321
|
+
if (signal.aborted) fail();
|
|
322
|
+
else signal.addEventListener("abort", fail, { once: true });
|
|
323
|
+
});
|
|
324
|
+
return Promise.race([pending, aborted]);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** A URL that passed the policy check, together with the addresses that decision used. */
|
|
328
|
+
export interface SafeHttpTarget {
|
|
329
|
+
/** Normalized absolute URL. */
|
|
330
|
+
url: string;
|
|
331
|
+
/** Hostname with any IPv6 brackets stripped. */
|
|
332
|
+
host: string;
|
|
333
|
+
/** The classified answers. A literal-IP host contributes that literal. */
|
|
334
|
+
addresses: string[];
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Same policy as assertHttpUrl, plus a DNS round-trip: a hostname that answers with a
|
|
339
|
+
* loopback, private, link-local, reserved, or metadata address is rejected before any
|
|
340
|
+
* request is made. Literal-IP hosts skip the lookup. Text-only classification is not enough
|
|
341
|
+
* on its own — `http://169.254.169.254.nip.io/` reads as a public hostname.
|
|
342
|
+
*
|
|
343
|
+
* The answers are returned so a caller can pin its socket to one of them: validating a name
|
|
344
|
+
* and then letting the transport resolve it again leaves a window where a short-TTL record
|
|
345
|
+
* answers a public address at check time and an internal one at connect time.
|
|
346
|
+
*/
|
|
347
|
+
export async function resolveSafeHostUrl(url: string, opts: HttpUrlOptions = {}): Promise<SafeHttpTarget> {
|
|
348
|
+
const normalized = assertHttpUrl(url, opts);
|
|
349
|
+
const host = new URL(normalized).hostname.replace(/^\[|\]$/g, "");
|
|
350
|
+
if (isIpLiteralHost(host)) return { url: normalized, host, addresses: [host] }; // already classified; no DNS round-trip
|
|
351
|
+
// A stalled getaddrinfo must not outlive the caller's own budget, and with no caller signal
|
|
352
|
+
// (a browser route handler, a frame re-check) the guard bounds itself instead of hanging.
|
|
353
|
+
const dnsSignal = opts.signal ?? (opts.dnsTimeoutMs ? AbortSignal.timeout(opts.dnsTimeoutMs) : undefined);
|
|
354
|
+
const answers = opts.resolveHost
|
|
355
|
+
? await untilAborted(Promise.resolve(opts.resolveHost(host)), dnsSignal)
|
|
356
|
+
: await untilAborted(resolveHostAddresses(host), dnsSignal);
|
|
357
|
+
if (!answers.length) throw new Error(`Could not resolve host "${host}"`);
|
|
358
|
+
const allowPrivate = opts.allowPrivateNetwork === true;
|
|
359
|
+
const allowLoopback = opts.allowLoopback === true;
|
|
360
|
+
for (const answer of answers) {
|
|
361
|
+
if (isBlockedNetworkHost(answer, allowPrivate, allowLoopback)) {
|
|
362
|
+
throw new Error(`Blocked network host "${host}" — it resolves to ${answer}, and private, link-local, and reserved addresses are not allowed (set allowPrivateNetwork to opt in to private ranges, or allowLoopback for localhost and 127.0.0.0/8; cloud-metadata, unspecified addresses such as 0.0.0.0, and the 64:ff9b:1::/48 translation prefix are always blocked)`);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
return { url: normalized, host, addresses: answers };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** `resolveSafeHostUrl` for callers that do not open a socket. */
|
|
369
|
+
export async function assertSafeHttpUrl(url: string, opts: HttpUrlOptions = {}): Promise<string> {
|
|
370
|
+
return (await resolveSafeHostUrl(url, opts)).url;
|
|
371
|
+
}
|
|
372
|
+
// END SHARED HOST GUARD
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openlines/opl-pi-sht",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "opl-pi-sht (shouldHaveThat) Pi coding-agent extension kit: token-efficient context, tool and session safety, fingerprinted /init, model benchmarking, web access, and browser automation.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"extensions/",
|
|
11
|
-
"configs
|
|
11
|
+
"configs/*.json.sample",
|
|
12
12
|
"images/"
|
|
13
13
|
],
|
|
14
14
|
"keywords": [
|
|
@@ -42,20 +42,27 @@
|
|
|
42
42
|
"type": "module",
|
|
43
43
|
"scripts": {
|
|
44
44
|
"test": "node tests/test-summary.mjs",
|
|
45
|
-
"test:
|
|
46
|
-
"test:opl-
|
|
47
|
-
"test:opl-
|
|
48
|
-
"test:opl-
|
|
49
|
-
"test:opl-
|
|
50
|
-
"test:opl-
|
|
45
|
+
"test:shared": "bun test tests/agent-dir-config.test.mjs tests/net-guard-parity.test.mjs tests/cross-extension-seams.test.mjs tests/git-guard.test.mjs tests/session-cwd.test.mjs tests/install-sh.test.mjs tests/package-metadata.test.mjs",
|
|
46
|
+
"test:opl-browser": "bun test tests/opl-browser-helpers.test.mjs tests/opl-browser-page-target.test.mjs tests/opl-browser-screenshot-path.test.mjs && OPL_EXTENSION=opl-browser bun test tests/extension-smoke.test.mjs",
|
|
47
|
+
"test:opl-configurator": "bun test tests/opl-configurator.test.mjs && OPL_EXTENSION=opl-configurator bun test tests/extension-smoke.test.mjs",
|
|
48
|
+
"test:opl-footer": "bun test tests/opl-footer-helpers.test.mjs tests/opl-footer-lifecycle.test.mjs tests/opl-footer-git-invalidation.test.mjs tests/opl-footer-render-budget.test.mjs && OPL_EXTENSION=opl-footer bun test tests/extension-smoke.test.mjs",
|
|
49
|
+
"test:opl-guardian": "bun test tests/opl-guardian.test.mjs tests/opl-guardian-incident-log.test.mjs && OPL_EXTENSION=opl-guardian bun test tests/extension-smoke.test.mjs",
|
|
50
|
+
"test:opl-init": "bun test tests/opl-init-crawl.test.mjs tests/opl-init-fingerprint.test.mjs tests/opl-init-fingerprint-budget.test.mjs tests/opl-init-glob.test.mjs tests/opl-init-packet.test.mjs && OPL_EXTENSION=opl-init bun test tests/extension-smoke.test.mjs",
|
|
51
|
+
"test:opl-input": "bun test tests/opl-input-style.test.mjs tests/opl-input-pi-tui-markers.test.mjs tests/opl-input-config.test.mjs && OPL_EXTENSION=opl-input bun test tests/extension-smoke.test.mjs",
|
|
52
|
+
"test:opl-modes": "bun test tests/opl-modes-helpers.test.mjs tests/opl-modes-lifecycle.test.mjs tests/opl-modes-plan-budget.test.mjs && OPL_EXTENSION=opl-modes bun test tests/extension-smoke.test.mjs",
|
|
51
53
|
"test:opl-questionnaire": "bun test tests/opl-questionnaire-helpers.test.mjs && OPL_EXTENSION=opl-questionnaire bun test tests/extension-smoke.test.mjs",
|
|
52
|
-
"test:opl-todo": "bun test tests/opl-todo-config.test.mjs tests/opl-todo-render.test.mjs && OPL_EXTENSION=opl-todo bun test tests/extension-smoke.test.mjs",
|
|
53
|
-
"test:opl-webaccess": "bun test tests/opl-webaccess-helpers.test.mjs && OPL_EXTENSION=opl-webaccess bun test tests/extension-smoke.test.mjs",
|
|
54
|
+
"test:opl-todo": "bun test tests/opl-todo-config.test.mjs tests/opl-todo-render.test.mjs tests/opl-todo-widget-height.test.mjs && OPL_EXTENSION=opl-todo bun test tests/extension-smoke.test.mjs",
|
|
55
|
+
"test:opl-webaccess": "bun test tests/opl-webaccess-helpers.test.mjs tests/opl-webaccess-storage.test.mjs tests/opl-webaccess-search.test.mjs tests/opl-webaccess-http.test.mjs tests/opl-webaccess-redirect-pin.test.mjs && OPL_EXTENSION=opl-webaccess bun test tests/extension-smoke.test.mjs",
|
|
54
56
|
"test:opl-simplebench": "bun test tests/opl-simplebench.test.ts && OPL_EXTENSION=opl-simplebench bun test tests/extension-smoke.test.mjs",
|
|
55
57
|
"test:opl-ctxtrim": "bun test tests/opl-ctxtrim.test.ts && OPL_EXTENSION=opl-ctxtrim bun test tests/extension-smoke.test.mjs",
|
|
56
|
-
"test:pi-host": "bun test tests/pi-host-loader.test.mjs"
|
|
58
|
+
"test:pi-host": "bun test tests/pi-host-loader.test.mjs",
|
|
59
|
+
"typecheck": "tsc -p tsconfig.json"
|
|
57
60
|
},
|
|
58
61
|
"devDependencies": {
|
|
59
|
-
"@earendil-works/pi-
|
|
62
|
+
"@earendil-works/pi-ai": ">=0.87.0",
|
|
63
|
+
"@earendil-works/pi-coding-agent": ">=0.87.0",
|
|
64
|
+
"@earendil-works/pi-tui": ">=0.87.0",
|
|
65
|
+
"@types/node": "^26.0.0",
|
|
66
|
+
"typescript": "^7.0.2"
|
|
60
67
|
}
|
|
61
68
|
}
|