@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.
Files changed (83) hide show
  1. package/README.md +101 -85
  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-lock.json +3 -3
  12. package/extensions/opl-browser/package.json +4 -4
  13. package/extensions/opl-browser/validate.ts +313 -60
  14. package/extensions/opl-configurator/README.md +40 -0
  15. package/extensions/opl-configurator/configurator.ts +151 -0
  16. package/extensions/opl-configurator/index.ts +12 -0
  17. package/extensions/opl-ctxtrim/README.md +45 -14
  18. package/extensions/opl-ctxtrim/index.ts +33 -13
  19. package/extensions/opl-footer/README.md +31 -6
  20. package/extensions/opl-footer/config.ts +11 -10
  21. package/extensions/opl-footer/configure.ts +159 -115
  22. package/extensions/opl-footer/git-status.ts +58 -1
  23. package/extensions/opl-footer/icons.ts +3 -0
  24. package/extensions/opl-footer/index.ts +209 -105
  25. package/extensions/opl-footer/segments/caveman.ts +9 -1
  26. package/extensions/opl-footer/segments/chat-mode.ts +8 -1
  27. package/extensions/opl-footer/segments/compactions.ts +13 -0
  28. package/extensions/opl-footer/segments/context.ts +6 -5
  29. package/extensions/opl-footer/segments/index.ts +2 -0
  30. package/extensions/opl-footer/segments/mode-switcher.ts +14 -1
  31. package/extensions/opl-footer/segments/path.ts +1 -1
  32. package/extensions/opl-footer/segments/plan-mode.ts +8 -1
  33. package/extensions/opl-footer/theme.ts +5 -2
  34. package/extensions/opl-footer/types.ts +4 -0
  35. package/extensions/opl-guardian/README.md +17 -5
  36. package/extensions/opl-guardian/config.ts +72 -6
  37. package/extensions/opl-guardian/guardian.ts +42 -10
  38. package/extensions/opl-guardian/index.ts +52 -17
  39. package/extensions/opl-guardian/policies.ts +52 -16
  40. package/extensions/opl-init/README.md +12 -4
  41. package/extensions/opl-init/index.ts +228 -27
  42. package/extensions/opl-input/README.md +17 -3
  43. package/extensions/opl-input/config.ts +59 -5
  44. package/extensions/opl-input/configure.ts +146 -0
  45. package/extensions/opl-input/index.ts +38 -30
  46. package/extensions/opl-input/mode-style.ts +6 -2
  47. package/extensions/opl-modes/README.md +22 -8
  48. package/extensions/opl-modes/config.ts +62 -8
  49. package/extensions/opl-modes/index.ts +87 -28
  50. package/extensions/opl-modes/state.ts +50 -2
  51. package/extensions/opl-modes/types.ts +6 -0
  52. package/extensions/opl-modes/utils.ts +18 -8
  53. package/extensions/opl-questionnaire/index.ts +10 -4
  54. package/extensions/opl-questionnaire/types.ts +4 -0
  55. package/extensions/opl-simplebench/README.md +21 -15
  56. package/extensions/opl-simplebench/artifact.ts +7 -4
  57. package/extensions/opl-simplebench/benchmark.ts +96 -35
  58. package/extensions/opl-simplebench/coding.ts +9 -4
  59. package/extensions/opl-simplebench/index.ts +19 -19
  60. package/extensions/opl-simplebench/llama-server.ts +3 -2
  61. package/extensions/opl-simplebench/report.ts +0 -6
  62. package/extensions/opl-simplebench/research.ts +5 -4
  63. package/extensions/opl-simplebench/scoring.ts +4 -2
  64. package/extensions/opl-simplebench/util/config.ts +15 -8
  65. package/extensions/opl-simplebench/util/exec-env.ts +36 -0
  66. package/extensions/opl-simplebench/util/format.ts +2 -2
  67. package/extensions/opl-simplebench/util/providers.ts +23 -194
  68. package/extensions/opl-todo/README.md +5 -5
  69. package/extensions/opl-todo/config.ts +7 -2
  70. package/extensions/opl-todo/index.ts +50 -9
  71. package/extensions/opl-webaccess/README.md +31 -5
  72. package/extensions/opl-webaccess/config.ts +36 -7
  73. package/extensions/opl-webaccess/extract.ts +37 -47
  74. package/extensions/opl-webaccess/http.ts +259 -0
  75. package/extensions/opl-webaccess/index.ts +12 -6
  76. package/extensions/opl-webaccess/package-lock.json +3 -3
  77. package/extensions/opl-webaccess/package.json +4 -9
  78. package/extensions/opl-webaccess/pdf.ts +4 -4
  79. package/extensions/opl-webaccess/search.ts +19 -8
  80. package/extensions/opl-webaccess/storage.ts +134 -2
  81. package/extensions/opl-webaccess/types.ts +4 -0
  82. package/extensions/opl-webaccess/utils.ts +221 -33
  83. package/package.json +19 -12
@@ -1,6 +1,6 @@
1
1
  import * as fs from "node:fs";
2
- import * as os from "node:os";
3
- import * as path from "node:path";
2
+ import { join } from "node:path";
3
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
4
4
 
5
5
  export interface BrowserConfig {
6
6
  headless: boolean;
@@ -9,9 +9,12 @@ export interface BrowserConfig {
9
9
  navigationTimeoutMs: number;
10
10
  previewChars: number;
11
11
  getChars: number;
12
- /** Allow navigate/new_page to reach private/link-local ranges (loopback is always
13
- * allowed; cloud metadata is always blocked). Default false. */
12
+ /** Allow navigate/new_page to reach private/link-local ranges (default false).
13
+ * Cloud metadata is always blocked. */
14
14
  allowPrivateNetwork: boolean;
15
+ /** Allow loopback hosts — localhost, 127.0.0.0/8, ::1 (default true, for local dev
16
+ * servers). Cloud metadata is unaffected. */
17
+ allowLoopback: boolean;
15
18
  }
16
19
 
17
20
  export const DEFAULT_CONFIG: BrowserConfig = {
@@ -22,14 +25,18 @@ export const DEFAULT_CONFIG: BrowserConfig = {
22
25
  previewChars: 4000,
23
26
  getChars: 30000,
24
27
  allowPrivateNetwork: false,
28
+ allowLoopback: false,
25
29
  };
26
30
 
27
- const CONFIG_PATH = path.join(os.homedir(), ".pi", "agent", "configs", "opl-browser.json");
31
+ /** Resolved per call so a custom `PI_AGENT_DIR` (pi's own agent dir) is honoured. */
32
+ export function configPath(): string {
33
+ return join(getAgentDir(), "configs", "opl-browser.json");
34
+ }
28
35
 
29
- export function loadUserConfig(configPath = CONFIG_PATH): BrowserConfig {
36
+ export function loadUserConfig(file = configPath()): BrowserConfig {
30
37
  let user: Partial<BrowserConfig> = {};
31
38
  try {
32
- user = JSON.parse(fs.readFileSync(configPath, "utf8"));
39
+ user = JSON.parse(fs.readFileSync(file, "utf8"));
33
40
  } catch {
34
41
  user = {};
35
42
  }
@@ -41,5 +48,6 @@ export function loadUserConfig(configPath = CONFIG_PATH): BrowserConfig {
41
48
  previewChars: user.previewChars ?? DEFAULT_CONFIG.previewChars,
42
49
  getChars: user.getChars ?? DEFAULT_CONFIG.getChars,
43
50
  allowPrivateNetwork: user.allowPrivateNetwork ?? DEFAULT_CONFIG.allowPrivateNetwork,
51
+ allowLoopback: user.allowLoopback ?? DEFAULT_CONFIG.allowLoopback,
44
52
  };
45
53
  }
@@ -35,7 +35,8 @@ export default function (pi: ExtensionAPI) {
35
35
  description:
36
36
  `Drive a real Chromium browser (Playwright) for web testing and inspection. One dispatcher tool; pick action: ${ACTIONS}. ` +
37
37
  "navigate(url|back|forward|reload); snapshot (accessibility tree); extract (rendered-page markdown, optionally scoped to selector); screenshot (saved to file, not inlined); " +
38
- "click/fill/hover/select by CSS selector; press(key); evaluate(script in page); console/network (captured for active page); " +
38
+ "click/fill/hover/select by CSS selector; press(key); evaluate(script in page); console/network (captured per page); " +
39
+ "index selects which page a page-scoped action runs on; without it the selected page is used and a stale selection is an error. " +
39
40
  "wait_for(selector|text); pages/new_page/select_page/close_page; resize(width,height); get(responseId) to retrieve a stored large result; close to shut the browser. " +
40
41
  "Large outputs return a preview + responseId; call action:get with that id for the full text.",
41
42
  parameters: Type.Object({
@@ -48,14 +49,14 @@ export default function (pi: ExtensionAPI) {
48
49
  script: Type.Optional(Type.String({ description: "evaluate: JS expression run in the page" })),
49
50
  path: Type.Optional(Type.String({ description: "screenshot: output file path" })),
50
51
  fullPage: Type.Optional(Type.Boolean({ description: "screenshot: capture full scrollable page" })),
51
- index: Type.Optional(Type.Number({ description: "select_page/close_page: page index" })),
52
+ index: Type.Optional(Type.Number({ description: "page-scoped actions: act on this page index instead of the selected one (see pages)" })),
52
53
  timeoutMs: Type.Optional(Type.Number({ description: "wait_for: timeout in ms" })),
53
54
  width: Type.Optional(Type.Number({ description: "resize: viewport width" })),
54
55
  height: Type.Optional(Type.Number({ description: "resize: viewport height" })),
55
56
  responseId: Type.Optional(Type.String({ description: "get: id from a previous large result" })),
56
57
  offset: Type.Optional(Type.Number({ description: "get: character offset to continue a truncated retrieval" })),
57
58
  }),
58
- async execute(_toolCallId, params) {
59
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
59
60
  const p = params as BrowserParams & { responseId?: string; offset?: number };
60
61
 
61
62
  if (p.action === "get") {
@@ -76,7 +77,7 @@ export default function (pi: ExtensionAPI) {
76
77
 
77
78
  let result;
78
79
  try {
79
- result = await runAction(p, cfg);
80
+ result = await runAction(p, cfg, ctx.cwd);
80
81
  } catch (e) {
81
82
  return err(`browser ${p.action}: ${e instanceof Error ? e.message : String(e)}`);
82
83
  }
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "opl-browser",
3
- "version": "1.1.0",
3
+ "version": "0.2.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "opl-browser",
9
- "version": "1.1.0",
10
- "license": "ISC",
9
+ "version": "0.2.0",
10
+ "license": "MIT",
11
11
  "dependencies": {
12
12
  "@mozilla/readability": "^0.6.0",
13
13
  "linkedom": "^0.18.13",
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "opl-browser",
3
- "version": "1.1.0",
4
- "description": "Chromium browser automation for pi via Playwright. A single dispatcher tool for navigation, snapshots, interaction, console/network capture, and evaluation — replaces the chrome-devtools MCP server.",
5
- "main": "index.js",
6
- "license": "ISC",
3
+ "version": "0.2.0",
4
+ "private": true,
5
+ "description": "Chromium browser automation for pi via Playwright. A single dispatcher tool for navigation, snapshots, interaction, console/network capture, and evaluation \u2014 replaces the chrome-devtools MCP server.",
6
+ "license": "MIT",
7
7
  "dependencies": {
8
8
  "@mozilla/readability": "^0.6.0",
9
9
  "linkedom": "^0.18.13",
@@ -1,13 +1,26 @@
1
1
  // Pure URL/path guards for the browser tool. No Playwright import so tests load it directly.
2
2
 
3
- import { existsSync } from "node:fs";
4
- import { resolve, sep } from "node:path";
3
+ import { closeSync, mkdirSync, openSync, realpathSync, statSync, unlinkSync } from "node:fs";
4
+ import { dirname, resolve, sep } from "node:path";
5
+ import { promises as dns } from "node:dns";
5
6
 
6
7
  export interface HttpUrlOptions {
7
- /** Allow private, link-local, and reserved network hosts (default false). Loopback is always allowed; cloud metadata is always blocked. */
8
+ /** Allow private, link-local, and reserved network hosts (default false). Cloud metadata is always blocked. */
8
9
  allowPrivateNetwork?: boolean;
10
+ /** Allow loopback hosts — localhost, 127.0.0.0/8, ::1 (default false: opt in). */
11
+ allowLoopback?: boolean;
12
+ /** Test seam for host resolution; production callers leave it unset and use node:dns. */
13
+ resolveHost?: (host: string) => Promise<string[]>;
14
+ /** Caller's abort signal. The lookup itself cannot be cancelled, so this only bounds how long
15
+ * the caller waits; without it a stalled getaddrinfo outlives the caller's own timeout. */
16
+ signal?: AbortSignal;
17
+ /** Self-bound for the DNS round-trip when the caller passes no signal (ms). */
18
+ dnsTimeoutMs?: number;
9
19
  }
10
20
 
21
+ // BEGIN SHARED HOST GUARD — duplicated verbatim in extensions/opl-browser/validate.ts
22
+ // (install.sh installs one extension directory at a time, so this cannot be a shared import;
23
+ // tests/net-guard-parity.test.mjs fails if the two copies drift)
11
24
  /** Well-known cloud metadata hostnames that resolve to instance-credential endpoints. */
12
25
  const BLOCKED_METADATA_HOSTS = new Set([
13
26
  "metadata",
@@ -17,77 +30,159 @@ const BLOCKED_METADATA_HOSTS = new Set([
17
30
  ]);
18
31
 
19
32
  /** Cloud metadata IPv6 literals (e.g. AWS IMDS over IPv6). Always blocked. */
20
- const BLOCKED_METADATA_V6 = new Set(["fd00:ec2::254"]);
33
+ const BLOCKED_METADATA_V6 = new Set(["fd00:ec2:0:0:0:0:0:254"]);
21
34
 
22
- type HostClass = "loopback" | "metadata" | "private" | "public";
35
+ type HostClass = "loopback" | "metadata" | "unspecified" | "translation" | "private" | "public";
23
36
 
24
- function ipv4FromInteger(n: number): number[] | null {
37
+ /** The four octets of an IPv4 address. The arity is the whole point of the classifier, so it is a
38
+ * tuple rather than a list whose elements TypeScript must assume could be missing. */
39
+ type Ipv4 = readonly [number, number, number, number];
40
+ /** The eight groups of an IPv6 literal, with any `::` already expanded. */
41
+ type Ipv6Groups = readonly [number, number, number, number, number, number, number, number];
42
+
43
+ /** Build a tuple from a list whose arity a length check established but `split`/`map` cannot show
44
+ * to TypeScript. The length test keeps the previous rejection of over-long quads; the per-element
45
+ * tests are what the compiler needs. These are the only two widening points in the guard. */
46
+ function asIpv4(values: number[]): Ipv4 | null {
47
+ const [a, b, c, d] = values;
48
+ if (values.length !== 4 || a === undefined || b === undefined || c === undefined || d === undefined) return null;
49
+ return [a, b, c, d];
50
+ }
51
+ function asIpv6Groups(values: number[]): Ipv6Groups | null {
52
+ const [a, b, c, d, e, f, g, h] = values;
53
+ if (
54
+ values.length !== 8 ||
55
+ a === undefined || b === undefined || c === undefined || d === undefined ||
56
+ e === undefined || f === undefined || g === undefined || h === undefined
57
+ ) {
58
+ return null;
59
+ }
60
+ return [a, b, c, d, e, f, g, h];
61
+ }
62
+
63
+ /** Decode a bare unsigned 32-bit integer hostname (e.g. 2130706433) into octets. */
64
+ function ipv4FromInteger(n: number): Ipv4 | null {
25
65
  if (!Number.isInteger(n) || n < 0 || n > 0xffffffff) return null;
26
66
  return [(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff];
27
67
  }
28
68
 
29
- function ipv4Octets(host: string): number[] | null {
69
+ /** Parse a host into IPv4 octets (dotted-quad or integer form), or null when not an IPv4 literal. */
70
+ function ipv4Octets(host: string): Ipv4 | null {
30
71
  if (/^\d+$/.test(host)) return ipv4FromInteger(Number(host));
31
72
  const octets = host.split(".").map((part) => (/^\d+$/.test(part) ? Number(part) : NaN));
32
- if (octets.length !== 4 || octets.some((o) => !Number.isInteger(o) || o < 0 || o > 255)) return null;
33
- return octets;
73
+ if (octets.some((o) => !Number.isInteger(o) || o < 0 || o > 255)) return null;
74
+ return asIpv4(octets);
34
75
  }
35
76
 
36
- function isLoopbackIpv4(o: number[]): boolean {
77
+ function isLoopbackIpv4(o: Ipv4): boolean {
37
78
  return o[0] === 127;
38
79
  }
39
80
 
40
- function isMetadataIpv4(o: number[]): boolean {
81
+ function isMetadataIpv4(o: Ipv4): boolean {
41
82
  return (
42
- (o[0] === 169 && o[1] === 254 && o[2] === 169 && o[3] === 254) ||
43
- (o[0] === 100 && o[1] === 100 && o[2] === 100 && o[3] === 200)
83
+ (o[0] === 169 && o[1] === 254 && o[2] === 169 && o[3] === 254) || // AWS/Azure/GCP/Oracle
84
+ (o[0] === 100 && o[1] === 100 && o[2] === 100 && o[3] === 200) // Alibaba
44
85
  );
45
86
  }
46
87
 
47
- function isPrivateIpv4(o: number[]): boolean {
88
+ /** Non-globally-routable (blocked by default, opt-in via allowPrivateNetwork). */
89
+ function isPrivateIpv4(o: Ipv4): boolean {
48
90
  const [a, b, c] = o;
49
91
  return (
50
- a === 0 || a === 10 ||
51
- (a === 100 && b >= 64 && b <= 127) ||
52
- (a === 169 && b === 254) ||
53
- (a === 172 && b >= 16 && b <= 31) ||
54
- (a === 192 && b === 168) ||
55
- (a === 192 && b === 0 && (c === 0 || c === 2)) ||
56
- (a === 198 && (b === 18 || b === 19)) ||
57
- (a === 198 && b === 51 && c === 100) ||
58
- (a === 203 && b === 0 && c === 113) ||
59
- a >= 224
92
+ a === 10 || // 10/8
93
+ (a === 100 && b >= 64 && b <= 127) || // 100.64/10 carrier-grade NAT
94
+ (a === 169 && b === 254) || // 169.254/16 link-local
95
+ (a === 172 && b >= 16 && b <= 31) || // 172.16/12
96
+ (a === 192 && b === 168) || // 192.168/16
97
+ (a === 192 && b === 0 && (c === 0 || c === 2)) || // 192.0.0/24, 192.0.2/24
98
+ (a === 198 && (b === 18 || b === 19)) || // 198.18/15 benchmark
99
+ (a === 198 && b === 51 && c === 100) || // 198.51.100/24
100
+ (a === 203 && b === 0 && c === 113) || // 203.0.113/24
101
+ a >= 224 // 224/4 multicast and 240/4 reserved
60
102
  );
61
103
  }
62
104
 
63
- function ipv4MappedOctets(ip: string): number[] | null {
64
- const dotted = ip.match(/^::ffff:(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
65
- if (dotted) return dotted.slice(1).map(Number);
66
- const hex = ip.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
67
- if (hex) {
68
- const n = (parseInt(hex[1], 16) << 16) | parseInt(hex[2], 16);
69
- return [(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff];
105
+ /** "This host on this network" (0.0.0.0/8, ::) — reaching it means reaching local listeners,
106
+ * so it is never toggleable the way cloud metadata is not. */
107
+ function isUnspecifiedIpv4(o: Ipv4): boolean {
108
+ return o[0] === 0;
109
+ }
110
+
111
+ /** Expand a compressed, dotted-suffix or full IPv6 literal into its eight 16-bit groups. */
112
+ function ipv6Groups(ip: string): Ipv6Groups | null {
113
+ const sides = ip.split("::");
114
+ if (sides.length > 2) return null;
115
+ const parse = (side: string): number[] | null => {
116
+ if (!side) return [];
117
+ const out: number[] = [];
118
+ for (const part of side.split(":")) {
119
+ const dotted = part.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
120
+ if (dotted) {
121
+ const o = asIpv4(dotted.slice(1, 5).map(Number));
122
+ if (!o || o.some((n) => n > 255)) return null;
123
+ const [q0, q1, q2, q3] = o;
124
+ out.push((q0 << 8) | q1, (q2 << 8) | q3);
125
+ continue;
126
+ }
127
+ if (!part || !/^[0-9a-f]{1,4}$/.test(part)) return null;
128
+ out.push(parseInt(part, 16));
129
+ }
130
+ return out;
131
+ };
132
+ const head = parse(sides[0] ?? "");
133
+ if (!head) return null;
134
+ if (sides.length === 1) return asIpv6Groups(head);
135
+ const tail = parse(sides[1] ?? "");
136
+ if (!tail) return null;
137
+ const fill = 8 - head.length - tail.length;
138
+ if (fill < 1) return null; // `::` must stand for at least one zero group
139
+ return asIpv6Groups([...head, ...Array(fill).fill(0), ...tail]);
140
+ }
141
+
142
+ /** IPv4 that an IPv6 literal still routes to: mapped (`::ffff:`), NAT64 (`64:ff9b::/96`),
143
+ * 6to4 (`2002::/16`) and the deprecated IPv4-compatible form (`::a.b`).
144
+ * Wherever those prefixes are still routable they reach the embedded address, so the embedded
145
+ * address is what has to be classified — otherwise `http://[64:ff9b::a9fe:a9fe]/` walks past the
146
+ * metadata block one literal away from 169.254.169.254, and a DNS64 answer can do the same. */
147
+ function embeddedIpv4Octets(ip: string): Ipv4 | null {
148
+ const g = ipv6Groups(ip);
149
+ if (!g) return null;
150
+ const zeros = (from: number, to: number) => g.slice(from, to).every((x) => x === 0);
151
+ const low32 = (): Ipv4 => [(g[6] >> 8) & 0xff, g[6] & 0xff, (g[7] >> 8) & 0xff, g[7] & 0xff];
152
+ if (g[5] === 0xffff && zeros(0, 5)) return low32();
153
+ if (g[0] === 0x2002) {
154
+ 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)
155
+ }
156
+ if (g[0] === 0x0064 && g[1] === 0xff9b) {
157
+ if (zeros(2, 6)) return low32(); // NAT64 well-known prefix
70
158
  }
159
+ if (zeros(0, 6) && (g[6] !== 0 || g[7] !== 0)) return low32(); // IPv4-compatible (deprecated)
71
160
  return null;
72
161
  }
73
162
 
74
- function classifyIpv4Octets(o: number[]): HostClass {
163
+ function classifyIpv4Octets(o: Ipv4): HostClass {
75
164
  if (isLoopbackIpv4(o)) return "loopback";
76
165
  if (isMetadataIpv4(o)) return "metadata";
166
+ if (isUnspecifiedIpv4(o)) return "unspecified";
77
167
  if (isPrivateIpv4(o)) return "private";
78
168
  return "public";
79
169
  }
80
170
 
81
171
  function classifyIpv6Host(host: string): HostClass {
82
172
  const ip = host.replace(/^\[/, "").replace(/\]$/, "").replace(/%.*$/, "").toLowerCase();
83
- if (BLOCKED_METADATA_V6.has(ip)) return "metadata";
84
- if (ip === "::1") return "loopback";
85
- const mapped = ipv4MappedOctets(ip);
86
- if (mapped) return classifyIpv4Octets(mapped);
87
- if (ip === "::") return "private";
88
- if (/^f[cd]/.test(ip)) return "private";
89
- if (/^fe[89ab]/.test(ip)) return "private";
90
- if (/^ff/.test(ip)) return "private";
173
+ const groups = ipv6Groups(ip);
174
+ if (groups) {
175
+ if (BLOCKED_METADATA_V6.has(groups.map((g) => g.toString(16)).join(":"))) return "metadata";
176
+ if (groups.every((g) => g === 0)) return "unspecified";
177
+ if (groups.slice(0, 7).every((g) => g === 0) && groups[7] === 1) return "loopback";
178
+ // Local-use translation layouts vary; no opt-in may bypass this prefix block.
179
+ if (groups[0] === 0x0064 && groups[1] === 0xff9b && groups[2] === 1) return "translation";
180
+ }
181
+ const embedded = embeddedIpv4Octets(ip);
182
+ if (embedded) return classifyIpv4Octets(embedded);
183
+ if (/^f[cd]/.test(ip)) return "private"; // fc00::/7 unique-local
184
+ if (/^fe[89ab]/.test(ip)) return "private"; // fe80::/10 link-local
185
+ if (/^ff/.test(ip)) return "private"; // ff00::/8 multicast
91
186
  return "public";
92
187
  }
93
188
 
@@ -100,48 +195,206 @@ function classifyHost(host: string): HostClass {
100
195
  return octets ? classifyIpv4Octets(octets) : "public";
101
196
  }
102
197
 
103
- function isBlockedNetworkHost(host: string, allowPrivateNetwork: boolean): boolean {
198
+ function isBlockedNetworkHost(host: string, allowPrivateNetwork: boolean, allowLoopback: boolean): boolean {
104
199
  const cls = classifyHost(host);
105
- if (cls === "metadata") return true;
106
- if (cls === "loopback" || cls === "public") return false;
107
- return !allowPrivateNetwork;
200
+ if (cls === "metadata" || cls === "unspecified" || cls === "translation") return true; // never toggleable
201
+ if (cls === "loopback") return !allowLoopback;
202
+ if (cls === "public") return false;
203
+ return !allowPrivateNetwork; // private / link-local / reserved
108
204
  }
109
205
 
110
206
  /**
111
- * Normalize a URL, allowing only http/https, so `file:`/`data:`/`javascript:` URLs and SSRF
112
- * targets can't be navigated (file:// + evaluate = local file bytes returned into model context).
113
- * Loopback is allowed by default for local development; private/link-local/reserved ranges are
114
- * blocked unless allowPrivateNetwork is set; cloud metadata is always blocked.
207
+ * Normalize a URL, allowing only http/https. Loopback (localhost/127.0.0.0/8/::1) is
208
+ * opt-in through allowLoopback; private/link-local/reserved ranges are blocked unless
209
+ * allowPrivateNetwork is set; cloud metadata, unspecified addresses and 64:ff9b:1::/48 are always blocked.
115
210
  */
116
211
  export function assertHttpUrl(url: string, opts: HttpUrlOptions = {}): string {
117
212
  let parsed: URL;
118
213
  try {
119
214
  parsed = new URL(url);
120
215
  } catch {
121
- throw new Error(`invalid URL: ${url}`);
216
+ throw new Error(`Invalid URL: ${url}`);
122
217
  }
123
218
  if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
124
- throw new Error(`only http/https URLs are allowed, got ${parsed.protocol}//`);
219
+ throw new Error(`Only http/https URLs are allowed, "${parsed.protocol}"`);
125
220
  }
126
- if (isBlockedNetworkHost(parsed.hostname, opts.allowPrivateNetwork === true)) {
127
- 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)`);
221
+ if (isBlockedNetworkHost(parsed.hostname, opts.allowPrivateNetwork === true, opts.allowLoopback === true)) {
222
+ 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)`);
128
223
  }
129
224
  return parsed.href;
130
225
  }
131
226
 
132
- /** Require a screenshot path to stay within the project directory and refuse to overwrite
133
- * existing files (guard against overwriting project files or writing outside the repo). */
134
- export function safeScreenshotPath(file: string, cwd: string = process.cwd()): string {
227
+ export function isIpLiteralHost(host: string): boolean {
228
+ const lowered = host.toLowerCase().replace(/^\[|\]$/g, "");
229
+ return lowered.includes(":") || /^\d+$/.test(lowered) || /^\d{1,3}(\.\d{1,3}){3}$/.test(lowered);
230
+ }
231
+
232
+ /** In-flight lookups by host. A page with hundreds of subresources on one host would otherwise
233
+ * serialize hundreds of getaddrinfo calls on libuv's small thread pool. Entries are dropped as
234
+ * soon as the lookup settles, so this deduplicates concurrency only: no decision is reused, and
235
+ * a hostname that changes between two calls still gets two independent answers. */
236
+ const inflightLookups = new Map<string, Promise<string[]>>();
237
+
238
+ /** Default resolver. Rethrows the original error so `.code` survives for callers. */
239
+ export function resolveHostAddresses(host: string): Promise<string[]> {
240
+ const running = inflightLookups.get(host);
241
+ if (running) return running;
242
+ const pending = dns
243
+ .lookup(host, { all: true, verbatim: true })
244
+ .then((hits) => hits.map((hit) => hit.address));
245
+ inflightLookups.set(host, pending);
246
+ const stop = () => {
247
+ if (inflightLookups.get(host) === pending) inflightLookups.delete(host);
248
+ };
249
+ pending.then(stop, stop);
250
+ return pending;
251
+ }
252
+
253
+ /** Reject as soon as the caller gives up. `dns.lookup` cannot be cancelled, so without this a
254
+ * stalled getaddrinfo would hold the tool past its own timeout. */
255
+ function untilAborted<T>(pending: Promise<T>, signal?: AbortSignal): Promise<T> {
256
+ if (!signal) return pending;
257
+ const aborted = new Promise<never>((_, reject) => {
258
+ const fail = () => reject(Object.assign(new Error("Aborted"), { name: "AbortError" }));
259
+ if (signal.aborted) fail();
260
+ else signal.addEventListener("abort", fail, { once: true });
261
+ });
262
+ return Promise.race([pending, aborted]);
263
+ }
264
+
265
+ /** A URL that passed the policy check, together with the addresses that decision used. */
266
+ export interface SafeHttpTarget {
267
+ /** Normalized absolute URL. */
268
+ url: string;
269
+ /** Hostname with any IPv6 brackets stripped. */
270
+ host: string;
271
+ /** The classified answers. A literal-IP host contributes that literal. */
272
+ addresses: string[];
273
+ }
274
+
275
+ /**
276
+ * Same policy as assertHttpUrl, plus a DNS round-trip: a hostname that answers with a
277
+ * loopback, private, link-local, reserved, or metadata address is rejected before any
278
+ * request is made. Literal-IP hosts skip the lookup. Text-only classification is not enough
279
+ * on its own — `http://169.254.169.254.nip.io/` reads as a public hostname.
280
+ *
281
+ * The answers are returned so a caller can pin its socket to one of them: validating a name
282
+ * and then letting the transport resolve it again leaves a window where a short-TTL record
283
+ * answers a public address at check time and an internal one at connect time.
284
+ */
285
+ export async function resolveSafeHostUrl(url: string, opts: HttpUrlOptions = {}): Promise<SafeHttpTarget> {
286
+ const normalized = assertHttpUrl(url, opts);
287
+ const host = new URL(normalized).hostname.replace(/^\[|\]$/g, "");
288
+ if (isIpLiteralHost(host)) return { url: normalized, host, addresses: [host] }; // already classified; no DNS round-trip
289
+ // A stalled getaddrinfo must not outlive the caller's own budget, and with no caller signal
290
+ // (a browser route handler, a frame re-check) the guard bounds itself instead of hanging.
291
+ const dnsSignal = opts.signal ?? (opts.dnsTimeoutMs ? AbortSignal.timeout(opts.dnsTimeoutMs) : undefined);
292
+ const answers = opts.resolveHost
293
+ ? await untilAborted(Promise.resolve(opts.resolveHost(host)), dnsSignal)
294
+ : await untilAborted(resolveHostAddresses(host), dnsSignal);
295
+ if (!answers.length) throw new Error(`Could not resolve host "${host}"`);
296
+ const allowPrivate = opts.allowPrivateNetwork === true;
297
+ const allowLoopback = opts.allowLoopback === true;
298
+ for (const answer of answers) {
299
+ if (isBlockedNetworkHost(answer, allowPrivate, allowLoopback)) {
300
+ 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)`);
301
+ }
302
+ }
303
+ return { url: normalized, host, addresses: answers };
304
+ }
305
+
306
+ /** `resolveSafeHostUrl` for callers that do not open a socket. */
307
+ export async function assertSafeHttpUrl(url: string, opts: HttpUrlOptions = {}): Promise<string> {
308
+ return (await resolveSafeHostUrl(url, opts)).url;
309
+ }
310
+ // END SHARED HOST GUARD
311
+
312
+ const NON_HTTP = /^(data|blob|about|chrome|chrome-extension|devtools|view-source):/i;
313
+
314
+ /**
315
+ * Route-level decision for requests a page makes on its own (subresources, scripts,
316
+ * popups, JS-driven redirects). Only ENOTFOUND fails open: a genuinely missing host
317
+ * cannot be reached by the page either, while any other resolver failure could be
318
+ * hiding an internal answer and must abort.
319
+ */
320
+ export async function decideSubresource(url: string, opts: HttpUrlOptions = {}): Promise<"continue" | "abort"> {
321
+ if (NON_HTTP.test(url)) return "continue";
322
+ try {
323
+ await assertSafeHttpUrl(url, opts);
324
+ return "continue";
325
+ } catch (err) {
326
+ return (err as { code?: string })?.code === "ENOTFOUND" ? "continue" : "abort";
327
+ }
328
+ }
329
+
330
+ /** Resolve the deepest existing ancestor of `abs` through symlinks, or null if none exists.
331
+ * A path that does not exist yet is contained by the directory that would hold it. */
332
+ function realAncestor(abs: string): string | null {
333
+ let cur = abs;
334
+ for (;;) {
335
+ try {
336
+ return realpathSync(cur);
337
+ } catch {
338
+ const parent = dirname(cur);
339
+ if (parent === cur) return null;
340
+ cur = parent;
341
+ }
342
+ }
343
+ }
344
+
345
+ /**
346
+ * Resolve a screenshot target inside the session directory, and *reserve* it.
347
+ *
348
+ * Three things are checked, in this order: the path stays under `cwd` lexically; it stays under
349
+ * the real session directory once every symlink in the existing part of the path is followed (a
350
+ * `shots -> ~/.pi` entry inside the project would otherwise carry the write out of it); and the
351
+ * name is an image. Then the file is created with `wx`, so "refuse to overwrite" and "hand this
352
+ * path to Playwright" are one atomic operation instead of a check-then-write race, and a symlink
353
+ * standing at the target is refused like any other existing path rather than followed onto the
354
+ * file it points at.
355
+ *
356
+ * Returns the absolute path - the caller writes there instead of letting Playwright resolve a
357
+ * relative name against whatever directory the harness happened to start in.
358
+ */
359
+ export function safeScreenshotPath(file: string, cwd: string): string {
135
360
  const root = resolve(cwd);
136
- const abs = resolve(cwd, file);
361
+ const abs = resolve(root, file);
137
362
  if (abs !== root && !abs.startsWith(root + sep)) {
138
363
  throw new Error(`screenshot path must stay within the project directory: ${file}`);
139
364
  }
140
- if (!/\.(png|jpe?g)$/i.test(file)) {
365
+ if (!/\.(png|jpe?g)$/i.test(abs)) {
141
366
  throw new Error(`screenshot path must end in .png or .jpg: ${file}`);
142
367
  }
143
- if (existsSync(abs)) {
144
- throw new Error(`screenshot path already exists (refusing to overwrite): ${file}`);
368
+ let realRoot = root;
369
+ try {
370
+ realRoot = realpathSync(root);
371
+ } catch {
372
+ // The session directory itself is missing; there is no real root to contain anything.
373
+ }
374
+ const real = realAncestor(abs);
375
+ if (real && real !== realRoot && !real.startsWith(realRoot + sep)) {
376
+ throw new Error(`screenshot path resolves outside the project directory: ${file}`);
377
+ }
378
+ mkdirSync(dirname(abs), { recursive: true });
379
+ let fd: number;
380
+ try {
381
+ fd = openSync(abs, "wx");
382
+ } catch (err) {
383
+ if ((err as { code?: string }).code === "EEXIST") {
384
+ throw new Error(`screenshot path already exists (refusing to overwrite): ${file}`);
385
+ }
386
+ throw err;
387
+ }
388
+ closeSync(fd);
389
+ return abs;
390
+ }
391
+
392
+ /** Drop a reservation that never got written (a failed capture must not leave an empty file
393
+ * behind), and leave anything with content - including an earlier screenshot - alone. */
394
+ export function discardEmptyFile(abs: string): void {
395
+ try {
396
+ if (statSync(abs).size === 0) unlinkSync(abs);
397
+ } catch {
398
+ // Already gone, or never created: nothing to clean up.
145
399
  }
146
- return file;
147
400
  }
@@ -0,0 +1,40 @@
1
+ # opl-configurator
2
+
3
+ A generic, extensible configuration shell for the OPL extension kit. It owns the `/configurator` command and a small tab bar; each instrumented extension publishes one tab, and the user switches between tabs by tapping the tab's hotkey (much like the footer's reorder view is reached with `r`).
4
+
5
+ The shell itself has no configuration and no tools.
6
+
7
+ ## How an extension opts in
8
+
9
+ An instrumented extension registers one entry on `globalThis.__oplConfiguratorTabs` at load time:
10
+
11
+ ```ts
12
+ const tab = {
13
+ id: "opl-footer",
14
+ hotkey: "f",
15
+ label: "Footer",
16
+ create(api) {
17
+ return {
18
+ render(width) {},
19
+ invalidate() {},
20
+ handleInput(data) {},
21
+ };
22
+ },
23
+ };
24
+ ```
25
+
26
+ - `id` is a unique extension id (the hotkey and id must not collide with another tab).
27
+ - `hotkey` is a single lowercase letter the shell uses to focus the tab.
28
+ - `label` is the short text in the tab bar.
29
+ - `create(api)` returns a component with the same contract as `ctx.ui.custom`'s return value. `api` carries `{ ctx, tui, theme, keybindings, done }`.
30
+
31
+ The shell reads the registry lazily when `/configurator` runs, so extension load order does not matter. Entries are validated rather than trusted: malformed entries and duplicate ids or hotkeys are dropped, and a tab whose `create` throws is skipped without taking the whole configurator down. The footer and input replace their own tab entries on reload, so their factories use the current extension instances. If every tab fails to load, Escape closes the diagnostic screen.
32
+
33
+ ## Keyboard model
34
+
35
+ The shell reserves two things: `esc` closes the configurator, and each tab's hotkey focuses that tab. Everything else is delegated to the active tab, which keeps its own keys (the footer tab still uses `←`/`→` for its layout tabs and `r` for reorder). `create` is called again on every `/configurator` invocation, so a tab starts in a fresh state each time.
36
+
37
+ ## Extension features
38
+
39
+ - **`/configurator`** command only; no tools and no config file.
40
+ - Ships a tab for each instrumented extension installed beside it: `f` footer (`opl-footer`), `i` input (`opl-input`). With no instrumented extension present, the command reports that nothing is wired up yet.