@openlines/opl-pi-sht 0.2.12 → 0.3.0

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.
Files changed (81) hide show
  1. package/README.md +92 -84
  2. package/configs/opl-browser.json.sample +3 -2
  3. package/configs/opl-footer.json.sample +3 -2
  4. package/configs/opl-guardian.json.sample +8 -2
  5. package/configs/opl-modes.json.sample +5 -0
  6. package/configs/opl-webaccess.json.sample +7 -1
  7. package/extensions/opl-browser/README.md +81 -55
  8. package/extensions/opl-browser/browser.ts +244 -50
  9. package/extensions/opl-browser/config.ts +15 -7
  10. package/extensions/opl-browser/index.ts +5 -4
  11. package/extensions/opl-browser/package.json +4 -4
  12. package/extensions/opl-browser/validate.ts +313 -60
  13. package/extensions/opl-configurator/README.md +40 -0
  14. package/extensions/opl-configurator/configurator.ts +148 -0
  15. package/extensions/opl-configurator/index.ts +12 -0
  16. package/extensions/opl-ctxtrim/README.md +45 -14
  17. package/extensions/opl-ctxtrim/index.ts +33 -13
  18. package/extensions/opl-footer/README.md +31 -6
  19. package/extensions/opl-footer/config.ts +11 -10
  20. package/extensions/opl-footer/configure.ts +157 -115
  21. package/extensions/opl-footer/git-status.ts +58 -1
  22. package/extensions/opl-footer/icons.ts +3 -0
  23. package/extensions/opl-footer/index.ts +209 -105
  24. package/extensions/opl-footer/segments/caveman.ts +9 -1
  25. package/extensions/opl-footer/segments/chat-mode.ts +8 -1
  26. package/extensions/opl-footer/segments/compactions.ts +13 -0
  27. package/extensions/opl-footer/segments/context.ts +6 -5
  28. package/extensions/opl-footer/segments/index.ts +2 -0
  29. package/extensions/opl-footer/segments/mode-switcher.ts +14 -1
  30. package/extensions/opl-footer/segments/path.ts +1 -1
  31. package/extensions/opl-footer/segments/plan-mode.ts +8 -1
  32. package/extensions/opl-footer/theme.ts +5 -2
  33. package/extensions/opl-footer/types.ts +4 -0
  34. package/extensions/opl-guardian/README.md +17 -5
  35. package/extensions/opl-guardian/config.ts +72 -6
  36. package/extensions/opl-guardian/guardian.ts +42 -10
  37. package/extensions/opl-guardian/index.ts +52 -17
  38. package/extensions/opl-guardian/policies.ts +52 -16
  39. package/extensions/opl-init/README.md +12 -4
  40. package/extensions/opl-init/index.ts +228 -27
  41. package/extensions/opl-input/README.md +17 -3
  42. package/extensions/opl-input/config.ts +59 -5
  43. package/extensions/opl-input/configure.ts +144 -0
  44. package/extensions/opl-input/index.ts +38 -30
  45. package/extensions/opl-input/mode-style.ts +6 -2
  46. package/extensions/opl-modes/README.md +16 -4
  47. package/extensions/opl-modes/config.ts +62 -8
  48. package/extensions/opl-modes/index.ts +48 -17
  49. package/extensions/opl-modes/state.ts +50 -2
  50. package/extensions/opl-modes/types.ts +6 -0
  51. package/extensions/opl-modes/utils.ts +18 -8
  52. package/extensions/opl-questionnaire/index.ts +10 -4
  53. package/extensions/opl-questionnaire/types.ts +4 -0
  54. package/extensions/opl-simplebench/README.md +21 -15
  55. package/extensions/opl-simplebench/artifact.ts +7 -4
  56. package/extensions/opl-simplebench/benchmark.ts +96 -35
  57. package/extensions/opl-simplebench/coding.ts +9 -4
  58. package/extensions/opl-simplebench/index.ts +19 -19
  59. package/extensions/opl-simplebench/llama-server.ts +3 -2
  60. package/extensions/opl-simplebench/report.ts +0 -6
  61. package/extensions/opl-simplebench/research.ts +5 -4
  62. package/extensions/opl-simplebench/scoring.ts +4 -2
  63. package/extensions/opl-simplebench/util/config.ts +15 -8
  64. package/extensions/opl-simplebench/util/exec-env.ts +36 -0
  65. package/extensions/opl-simplebench/util/format.ts +2 -2
  66. package/extensions/opl-simplebench/util/providers.ts +23 -194
  67. package/extensions/opl-todo/README.md +5 -5
  68. package/extensions/opl-todo/config.ts +7 -2
  69. package/extensions/opl-todo/index.ts +50 -9
  70. package/extensions/opl-webaccess/README.md +31 -5
  71. package/extensions/opl-webaccess/config.ts +36 -7
  72. package/extensions/opl-webaccess/extract.ts +37 -47
  73. package/extensions/opl-webaccess/http.ts +259 -0
  74. package/extensions/opl-webaccess/index.ts +12 -6
  75. package/extensions/opl-webaccess/package.json +4 -9
  76. package/extensions/opl-webaccess/pdf.ts +4 -4
  77. package/extensions/opl-webaccess/search.ts +19 -8
  78. package/extensions/opl-webaccess/storage.ts +134 -2
  79. package/extensions/opl-webaccess/types.ts +4 -0
  80. package/extensions/opl-webaccess/utils.ts +221 -33
  81. 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
- pi.appendEntry(CUSTOM_TYPE, data);
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). Loopback is always allowed; cloud metadata is always blocked. */
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::254"]);
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
- type HostClass = "loopback" | "metadata" | "private" | "public";
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): number[] | null {
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): number[] | null {
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.length !== 4 || octets.some((o) => !Number.isInteger(o) || o < 0 || o > 255)) return null;
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: number[]): boolean {
139
+ function isLoopbackIpv4(o: Ipv4): boolean {
90
140
  return o[0] === 127;
91
141
  }
92
142
 
93
- function isMetadataIpv4(o: number[]): boolean {
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: number[]): boolean {
151
+ function isPrivateIpv4(o: Ipv4): boolean {
102
152
  const [a, b, c] = o;
103
153
  return (
104
- a === 0 || a === 10 || // 0.0.0.0/8, 10/8
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
- /** Decode IPv4 from an IPv4-mapped IPv6 literal (::ffff:a.b.c.d or ::ffff:hex:hex). */
118
- function ipv4MappedOctets(ip: string): number[] | null {
119
- const dotted = ip.match(/^::ffff:(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
120
- if (dotted) return dotted.slice(1).map(Number);
121
- const hex = ip.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
122
- if (hex) {
123
- const n = (parseInt(hex[1], 16) << 16) | parseInt(hex[2], 16);
124
- return [(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff];
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: number[]): HostClass {
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
- if (BLOCKED_METADATA_V6.has(ip)) return "metadata";
139
- if (ip === "::1") return "loopback";
140
- const mapped = ipv4MappedOctets(ip);
141
- if (mapped) return classifyIpv4Octets(mapped);
142
- if (ip === "::") return "private"; // unspecified
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" || cls === "public") return false;
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
- * allowed by default for local development; private/link-local/reserved ranges are blocked
168
- * unless allowPrivateNetwork is set; cloud metadata is always blocked.
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(`Unsupported protocol "${parsed.protocol}//" — only http/https`);
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, reserved, and cloud-metadata URLs are not allowed (set allowPrivateNetwork to opt in to private ranges; metadata is always blocked)`);
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.2.12",
3
+ "version": "0.3.0",
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:opl-browser": "bun test tests/opl-browser-helpers.test.mjs && OPL_EXTENSION=opl-browser bun test tests/extension-smoke.test.mjs",
46
- "test:opl-footer": "bun test tests/opl-footer-helpers.test.mjs tests/opl-footer-lifecycle.test.mjs && OPL_EXTENSION=opl-footer bun test tests/extension-smoke.test.mjs",
47
- "test:opl-guardian": "bun test tests/opl-guardian.test.mjs && OPL_EXTENSION=opl-guardian bun test tests/extension-smoke.test.mjs",
48
- "test:opl-init": "bun test tests/opl-init-crawl.test.mjs tests/opl-init-fingerprint.test.mjs tests/opl-init-packet.test.mjs && OPL_EXTENSION=opl-init bun test tests/extension-smoke.test.mjs",
49
- "test:opl-input": "bun test tests/opl-input-style.test.mjs && OPL_EXTENSION=opl-input bun test tests/extension-smoke.test.mjs",
50
- "test:opl-modes": "bun test tests/opl-modes-helpers.test.mjs tests/opl-modes-lifecycle.test.mjs && OPL_EXTENSION=opl-modes bun test tests/extension-smoke.test.mjs",
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-coding-agent": ">=0.87.0"
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
  }