@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,6 +1,6 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
|
-
import
|
|
3
|
-
import
|
|
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 (
|
|
13
|
-
*
|
|
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
|
-
|
|
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(
|
|
36
|
+
export function loadUserConfig(file = configPath()): BrowserConfig {
|
|
30
37
|
let user: Partial<BrowserConfig> = {};
|
|
31
38
|
try {
|
|
32
|
-
user = JSON.parse(fs.readFileSync(
|
|
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
|
|
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: "
|
|
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": "
|
|
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": "
|
|
10
|
-
"license": "
|
|
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": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
"license": "
|
|
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 {
|
|
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).
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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:
|
|
77
|
+
function isLoopbackIpv4(o: Ipv4): boolean {
|
|
37
78
|
return o[0] === 127;
|
|
38
79
|
}
|
|
39
80
|
|
|
40
|
-
function isMetadataIpv4(o:
|
|
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
|
-
|
|
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 ===
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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:
|
|
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
|
-
|
|
84
|
-
if (
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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"
|
|
107
|
-
return
|
|
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
|
|
112
|
-
*
|
|
113
|
-
*
|
|
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(`
|
|
216
|
+
throw new Error(`Invalid URL: ${url}`);
|
|
122
217
|
}
|
|
123
218
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
124
|
-
throw new Error(`
|
|
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(`
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
144
|
-
|
|
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.
|