@openlines/opl-pi-sht 0.2.6 → 0.2.8
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 +11 -8
- package/configs/opl-browser.json.sample +3 -2
- package/configs/opl-guardian.json.sample +28 -0
- package/configs/opl-modes.json.sample +3 -3
- package/configs/opl-webaccess.json.sample +5 -1
- package/extensions/opl-browser/README.md +20 -6
- package/extensions/opl-browser/browser.ts +76 -19
- package/extensions/opl-browser/config.ts +5 -0
- package/extensions/opl-browser/index.ts +13 -5
- package/extensions/opl-browser/validate.ts +123 -5
- package/extensions/opl-guardian/README.md +25 -27
- package/extensions/opl-guardian/config.ts +199 -0
- package/extensions/opl-guardian/guardian.ts +5 -1
- package/extensions/opl-guardian/index.ts +129 -7
- package/extensions/opl-guardian/policies.ts +123 -0
- package/extensions/opl-modes/README.md +2 -2
- package/extensions/opl-questionnaire/README.md +1 -1
- package/extensions/opl-questionnaire/index.ts +36 -21
- package/extensions/opl-webaccess/README.md +8 -5
- package/extensions/opl-webaccess/config.ts +56 -5
- package/extensions/opl-webaccess/extract.ts +39 -14
- package/extensions/opl-webaccess/index.ts +27 -7
- package/extensions/opl-webaccess/providers/gemini.ts +6 -1
- package/extensions/opl-webaccess/providers/tavily.ts +6 -1
- package/extensions/opl-webaccess/storage.ts +8 -2
- package/extensions/opl-webaccess/utils.ts +117 -2
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -17,7 +17,9 @@ A portable collection of various Pi coding agent extensions. Repository director
|
|
|
17
17
|
<div align="center">
|
|
18
18
|
|
|
19
19
|
>
|
|
20
|
-
> ### v0.2.
|
|
20
|
+
> ### v0.2.8 (29 Sept 2026) - **SSRF and tool-safety hardening**: `opl-webaccess` and `opl-browser` block private/link-local hosts by default and cloud metadata always (localhost stays available for dev), fetch redirects are re-checked per hop, screenshots refuse to overwrite existing files, and `evaluate` no longer crashes on `undefined`. <br/>See [CHANGELOG.md](CHANGELOG.md) for details.
|
|
21
|
+
>
|
|
22
|
+
> `Current project state also marked 'SAFE' for pi v0.99.0 release`<br/>*confirmed this because I was literally typing to push the patches when I noticed the release notif*
|
|
21
23
|
>
|
|
22
24
|
|
|
23
25
|
</div>
|
|
@@ -84,7 +86,7 @@ Copy mode overwrites matching destinations. Link mode skips existing destination
|
|
|
84
86
|
| [`opl-webaccess`](extensions/opl-webaccess/README.md) | Search plus readable URL/PDF retrieval with session recovery. | `web_search`, `fetch_content`, `get_search_content`; `opl-webaccess.json`. |
|
|
85
87
|
| [`opl-browser`](extensions/opl-browser/README.md) | Chromium automation via Playwright with structured extraction of rendered pages; single dispatcher tool replacing the chrome-devtools MCP. | `browser` (action-based); `opl-browser.json`. |
|
|
86
88
|
| [`opl-ctxtrim`](extensions/opl-ctxtrim/README.md) | Trims verbose`ctx_*` tool-schema descriptions on outbound provider requests (~67% smaller schema, ~4,700-6,300 tokens/request). Built specifically for the [context-mode](https://github.com/mksglu/context-mode) extension. | No commands/tools; no config. |
|
|
87
|
-
| [`opl-guardian`](extensions/opl-guardian/README.md) |
|
|
89
|
+
| [`opl-guardian`](extensions/opl-guardian/README.md) | Configurable tool and session safety: dangerous-Bash confirmation, protected paths, destructive-session confirmation, and malformed-call filtering. | No commands/tools; `opl-guardian.json`. |
|
|
88
90
|
| [`opl-todo`](extensions/opl-todo/README.md) | Branch-aware task tool, overlay, and task list. | `todo`, `/todos`; `opl-todo.json`. |
|
|
89
91
|
| [`opl-questionnaire`](extensions/opl-questionnaire/README.md) | Interactive structured-choice tool. | `questionnaire`; no config. |
|
|
90
92
|
| [`opl-input`](extensions/opl-input/README.md) | Configurable replacement editor - enhanced [pikit chat-input](https://github.com/adrianapan/pikit) (because pet is life, and configurable).  | No commands/tools;`opl-input.json`. |
|
|
@@ -116,15 +118,15 @@ Cold prompt-cache write, measured /init session (opl-modes lazy tools + MCP adap
|
|
|
116
118
|
- **`opl-modes`** chat and plan modes swap the active toolset for read-only lists and gate Bash to safe inspection patterns **per shell segment**, so `cat f && node -e '...'`, `cat x & rm -rf /tmp/x`, and `echo "$(node -e ...)"` can no longer ride the first command's allowance. Destructive checks still fire inside otherwise-safe commands (anchored to command position, so `du -sh` and `find . -name '*.sh'` stay allowed; `find -delete`, `find -fprint`, `git log --output`, `sort -o`, `npm audit fix`, `git clean`, `sudo`, and quote-obfuscated `r"m"`/`-del"ete"` are blocked; `env`/`printenv` are not safe-listed). The plan to execute lifecycle keeps exploration and mutation cleanly separated. Add custom modes (like below) for your workflow needs. Execute-mode auto-exit needs pi >= 0.87.0 (the `agent_before_settle` boundary).
|
|
117
119
|

|
|
118
120
|
- `load_tools` activation is bounded by the current mode, so a read-only mode cannot be tricked into enabling a write-capable tool.
|
|
119
|
-
- **`opl-guardian`**
|
|
121
|
+
- **`opl-guardian`** combines configurable safety checks in one extension: it confirms dangerous Bash commands in the terminal or over RPC, blocks configured protected paths for file tools (including symlink targets), and confirms session clear/switch-with-pending-work/fork actions. Without a UI, configured dangerous commands and session actions block by default. It also removes malformed provider tool calls before persistence or replay and records incidents in `err/guardian.jsonl`. Protected-path Bash matching is literal best-effort, not shell sandboxing.
|
|
120
122
|
|
|
121
123
|
|
|
122
124
|
### Move through work faster
|
|
123
125
|
|
|
124
126
|
- **`opl-init`** deterministically crawls and refines `AGENTS.md` with a single out-of-band model call (never a synthetic user message), writes it, and reloads context so the session runs on the new guide. A current fingerprint means zero model calls; mid-session invocations wait for the agent to settle instead of interrupting it. Needs pi >= 0.86.0.
|
|
125
127
|

|
|
126
|
-
- **`opl-browser`** gives full Chromium automation (navigate, snapshot, extract rendered-page markdown, interact, screenshot, console/network capture, evaluate) through a single tool, with handle+preview output for large results — navigation is http(s)-only and screenshots stay in the project directory.
|
|
127
|
-
- **`opl-webaccess`** adds provider-backed search plus readable URL and PDF extraction, with session recovery of earlier results, an http(s)-only fetch, a 10 MB response cap, and a 30s timeout.
|
|
128
|
+
- **`opl-browser`** gives full Chromium automation (navigate, snapshot, extract rendered-page markdown, interact, screenshot, console/network capture, evaluate) through a single tool, with handle+preview output for large results — navigation is http(s)-only, blocks private/link-local hosts by default and cloud metadata always (SSRF guard; localhost is allowed for dev), and screenshots must use `.png`/`.jpg`, stay in the project directory, and never overwrite an existing file.
|
|
129
|
+
- **`opl-webaccess`** adds provider-backed search plus readable URL and PDF extraction, with session recovery of earlier results, an http(s)-only fetch that blocks private/link-local hosts by default and cloud metadata always, re-checks every redirect hop (SSRF guard; localhost is allowed for dev), a 10 MB response cap, and a 30s timeout.
|
|
128
130
|
- **`opl-simplebench`** benchmarks models on deterministic closed-answer contracts, instruction-following, and tool-call generation so you pick a model on evidence, not vibes.
|
|
129
131
|

|
|
130
132
|
- **`opl-todo`** tracks branch-aware tasks that persist across a session and reconstruct from history.
|
|
@@ -150,7 +152,7 @@ Cold prompt-cache write, measured /init session (opl-modes lazy tools + MCP adap
|
|
|
150
152
|
| Run the agent safely on real repos | `opl-modes` (pulls in the `opl-input` + `opl-footer` UI bundle), `opl-guardian` |
|
|
151
153
|
| Research and drive the web | `opl-webaccess`, `opl-browser` |
|
|
152
154
|
| Choose models with data | `opl-simplebench` |
|
|
153
|
-
| The full, coordinated experience | all
|
|
155
|
+
| The full, coordinated experience | all eleven |
|
|
154
156
|
|
|
155
157
|
Selecting `opl-footer`, `opl-input`, or `opl-modes` installs all three, because they share active-mode state.
|
|
156
158
|
|
|
@@ -171,7 +173,7 @@ Installing extensions adds tool schemas (name + description + JSON parameters) t
|
|
|
171
173
|
| `opl-init` | command only (no tool) | ~0 |
|
|
172
174
|
| `opl-input` | UI only | ~0 |
|
|
173
175
|
| `opl-footer` | UI only | ~0 |
|
|
174
|
-
| `opl-guardian` | none (session
|
|
176
|
+
| `opl-guardian` | none (tool/session safety; no added tool schemas) | ~0 |
|
|
175
177
|
| `opl-ctxtrim` | none (payload transformer) | net negative |
|
|
176
178
|
|
|
177
179
|
Command descriptions add roughly another ~120 tokens collectively, and only if your build surfaces them in the prompt or help block.
|
|
@@ -195,9 +197,10 @@ The fixed cost of a full install is small and paid once per session, then cached
|
|
|
195
197
|
|
|
196
198
|
Copy applicable files from [`configs/`](configs/) to `~/.pi/agent/configs/`. For a Pi package installation, the source directory is `~/.pi/agent/npm/node_modules/@openlines/opl-pi-sht/configs/` (npm) or `~/.pi/agent/git/github.com/linellazatin/opl-pi-sht/configs/` (Git):
|
|
197
199
|
|
|
198
|
-
- `opl-footer.json`, `opl-input.json`, `opl-modes.json`, `opl-todo.json`, `opl-webaccess.json`
|
|
200
|
+
- `opl-footer.json`, `opl-input.json`, `opl-modes.json`, `opl-todo.json`, `opl-webaccess.json`, `opl-guardian.json`
|
|
199
201
|
- `opl-browser` has optional configuration (`opl-browser.json`); all fields default, so it works without any config file.
|
|
200
202
|
- `opl-simplebench` has optional `opl-simplebench.json`; copy `configs/opl-simplebench.json.sample` to configure DDGS/SearXNG research and llama metadata endpoints.
|
|
203
|
+
- `opl-guardian` has an optional `opl-guardian.json`; copy `configs/opl-guardian.json.sample` and manually migrate any old permission-gate or protected-path settings. It does not load legacy files. Configured `permissionGate.patterns` replace the defaults, so copied live patterns do not automatically inherit later default-rule updates.
|
|
201
204
|
- `opl-init` and `opl-questionnaire` have no external configuration.
|
|
202
205
|
- Config files must be valid JSON, with no comments or trailing commas beyond deliberate `_comment` keys.
|
|
203
206
|
- `opl-modes` owns active-mode appearance. Each mode's `appearance.prefix`, `prefixColor`, and `borderColor` style `opl-input`; `appearance.modeColor` styles `opl-footer`'s unified mode label. Renderers retain hardcoded fallbacks.
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
{
|
|
2
|
-
"_comment": "Optional. Copy to ~/.pi/agent/configs/opl-browser.json. All fields optional; defaults shown.",
|
|
2
|
+
"_comment": "Optional. Copy to ~/.pi/agent/configs/opl-browser.json. All fields optional; defaults shown. allowPrivateNetwork (default false) blocks navigate/new_page from reaching private/link-local ranges; loopback (localhost) is always allowed for local development, and cloud-metadata endpoints are always blocked.",
|
|
3
3
|
"headless": true,
|
|
4
4
|
"width": 1280,
|
|
5
5
|
"height": 800,
|
|
6
6
|
"navigationTimeoutMs": 30000,
|
|
7
7
|
"previewChars": 4000,
|
|
8
|
-
"getChars": 30000
|
|
8
|
+
"getChars": 30000,
|
|
9
|
+
"allowPrivateNetwork": false
|
|
9
10
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "Copy to ~/.pi/agent/configs/opl-guardian.json and customize. This replaces standalone permission-gate, protected-paths, and confirm-destructive settings.",
|
|
3
|
+
"permissionGate": {
|
|
4
|
+
"patterns": [
|
|
5
|
+
"\\brm\\s+(?:(?:-[a-z]+|--[a-z-]+)\\s+)*(?:-[a-z]*r[a-z]*|--recursive)(?=\\s|$)",
|
|
6
|
+
"\\bsudo\\b",
|
|
7
|
+
"\\b(chmod|chown)\\s+(?:-[a-z]+\\s+)*777(?:\\b|$)",
|
|
8
|
+
"\\bprintenv\\b",
|
|
9
|
+
"(^|\\s)env(\\s|$)"
|
|
10
|
+
],
|
|
11
|
+
"blockWithoutUI": true
|
|
12
|
+
},
|
|
13
|
+
"protectedPaths": {
|
|
14
|
+
"paths": [
|
|
15
|
+
{ "path": ".env", "deny": ["read", "write", "edit", "bash"] },
|
|
16
|
+
{ "path": ".git/", "deny": ["read", "write", "edit"] },
|
|
17
|
+
{ "path": "node_modules/", "deny": ["write", "edit"] },
|
|
18
|
+
{ "path": "~/.pi/agent/auth.json", "deny": ["read", "write", "edit", "bash"] }
|
|
19
|
+
]
|
|
20
|
+
},
|
|
21
|
+
"confirmDestructive": {
|
|
22
|
+
"clearSession": true,
|
|
23
|
+
"switchWithUnsavedWork": true,
|
|
24
|
+
"forkSession": true,
|
|
25
|
+
"blockWithoutUI": true
|
|
26
|
+
},
|
|
27
|
+
"dropMalformedToolCalls": true
|
|
28
|
+
}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
},
|
|
7
7
|
"shortcuts": {
|
|
8
8
|
"_comment": "Shortcut that cycles through all enabled, visible modes in registry order. Default: ctrl+alt+m.",
|
|
9
|
-
"cycleMode": "
|
|
9
|
+
"cycleMode": "ctrl+alt+m"
|
|
10
10
|
},
|
|
11
11
|
"cleanup": {
|
|
12
12
|
"_comment": "Set to true to delete the plan file automatically after plan_complete is called.",
|
|
@@ -17,8 +17,8 @@
|
|
|
17
17
|
"_comment_lazyTools": "Tool names withheld from the active set at rest and enabled on demand via the load_tools tool. Only affects modes that would otherwise include them (off/execute and custom modes that list them). Core built-ins, plan_complete, and load_tools are protected and ignored. Omit or leave empty to keep all tools always active.",
|
|
18
18
|
"lazyTools": [],
|
|
19
19
|
"bashPatterns": {
|
|
20
|
-
"_comment_safe": "Replace-only: providing bashPatterns.safePatterns replaces the built-in safe list. This is the default safe allowlist for every mode and it is checked per shell segment (&&, ||, a lone &, |, ;, newline, $(...), backticks, <( and >( each start a segment), so every chained or backgrounded command must be allowed. Quote-aware: grep \\\"a|b\\\" stays one command, but a $(...) or backtick payload still runs inside double quotes, so it is split out and checked too. Interpreters and tools that can write files or exec programs from their own arguments (awk, sed, xargs, perl, python, env) are deliberately not listed. A mode overrides it with its own safePatterns or opts out of Bash gating with unrestrictedBash. Shown here with the built-in list.",
|
|
21
|
-
"_comment_destructive": "Replace-only: providing bashPatterns.destructivePatterns replaces the built-in destructive list. This is the default destructive blocklist for every mode; a mode overrides it with its own destructivePatterns or opts out of Bash gating with unrestrictedBash. Program-name entries are anchored to command position (match each segment start) so read-only commands that only mention rm/cp/sh in an argument stay allowed, while flag and redirect entries stay unanchored - that is how find -fprint, sort -o in any position, git log --output, npm audit fix, uniq's output operand, and a bare > file are caught even behind a safe command. Descriptor duplication (2>&1, >&2) and redirects into /dev/null are not writes and stay allowed. Shown here with the built-in list.",
|
|
20
|
+
"_comment_safe": "Replace-only: providing bashPatterns.safePatterns replaces the built-in safe list. This is the default safe allowlist for every mode and it is checked per shell segment (&&, ||, a lone &, |, ;, newline, $(...), backticks, <( and >( each start a segment), so every chained or backgrounded command must be allowed. Quote-aware: grep \\\"a|b\\\" stays one command, but a $(...) or backtick payload still runs inside double quotes, so it is split out and checked too. Interpreters and tools that can write files or exec programs from their own arguments (awk, sed, xargs, perl, python, env) are deliberately not listed. A mode overrides it with its own safePatterns or opts out of Bash gating with unrestrictedBash. An empty top-level safePatterns array falls back to the built-in list (it cannot opt out); set a per-mode safePatterns: [] to opt that mode out. Shown here with the built-in list.",
|
|
21
|
+
"_comment_destructive": "Replace-only: providing bashPatterns.destructivePatterns replaces the built-in destructive list. This is the default destructive blocklist for every mode; a mode overrides it with its own destructivePatterns or opts out of Bash gating with unrestrictedBash. Program-name entries are anchored to command position (match each segment start) so read-only commands that only mention rm/cp/sh in an argument stay allowed, while flag and redirect entries stay unanchored - that is how find -fprint, sort -o in any position, git log --output, npm audit fix, uniq's output operand, and a bare > file are caught even behind a safe command. Descriptor duplication (2>&1, >&2) and redirects into /dev/null are not writes and stay allowed. An empty top-level destructivePatterns array falls back to the built-in list (it cannot opt out); set a per-mode destructivePatterns: [] to opt that mode out. Shown here with the built-in list.",
|
|
22
22
|
"safePatterns": [
|
|
23
23
|
"^\\s*cat\\b",
|
|
24
24
|
"^\\s*head\\b",
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
{
|
|
2
|
-
"_comment_limits": "Optional top-level caps: maxContentChars caps the initial web_search/fetch_content body (default 30000); maxRetrievalChars caps one get_search_content page (default 30000). Pass offset=N to get_search_content to continue a truncated retrieval.",
|
|
2
|
+
"_comment_limits": "Optional top-level caps: maxContentChars caps the initial web_search/fetch_content body (default 30000); maxRetrievalChars caps one get_search_content page (default 30000). Pass offset=N to get_search_content to continue a truncated retrieval. maxSearchQueries caps how many queries one web_search call runs (default 10); maxFetchUrls caps how many URLs one fetch_content call fetches (default 20). Excess inputs are skipped and reported in the tool output.",
|
|
3
|
+
"_comment_network": "allowPrivateNetwork defaults to false: fetch_content rejects private-range and link-local hosts. Loopback (localhost/127.0.0.1/::1) is always allowed for local development, and cloud-metadata endpoints are always blocked. Set allowPrivateNetwork true to reach private ranges (LAN servers). This does NOT affect provider API endpoints (ddgs.apiUrl / searxng.instanceUrl), which remain configurable.",
|
|
4
|
+
"allowPrivateNetwork": false,
|
|
3
5
|
"maxContentChars": 30000,
|
|
4
6
|
"maxRetrievalChars": 30000,
|
|
7
|
+
"maxSearchQueries": 10,
|
|
8
|
+
"maxFetchUrls": 20,
|
|
5
9
|
"provider": "searxng",
|
|
6
10
|
"providers": {
|
|
7
11
|
"gemini": {
|
|
@@ -45,15 +45,25 @@ Full action set: `navigate` (url, or `back`/`forward`/`reload`), `snapshot`,
|
|
|
45
45
|
which only sees raw HTTP HTML. Static pages: `fetch_content`; rendered or
|
|
46
46
|
interacted-with pages: `browser:extract`. With a `selector`, the matched element
|
|
47
47
|
is converted verbatim (no article detection): Readability's candidate scoring is
|
|
48
|
-
a whole-document heuristic and mispicks inside small subtrees.
|
|
48
|
+
a whole-document heuristic and mispicks inside small subtrees. A selector that
|
|
49
|
+
matches multiple elements returns a clear error instead of a strict-mode crash,
|
|
50
|
+
and a selector with no match reports `(no elements match ...)`.
|
|
49
51
|
- **Real Chromium via Playwright.** Navigation with `domcontentloaded` waits,
|
|
50
52
|
CSS-selector interaction, viewport control, multi-page management.
|
|
51
53
|
- **Scheme + path guards.** `navigate`/`new_page` accept http(s) only; `file:`,
|
|
52
|
-
`data:`, and `javascript:` are rejected
|
|
53
|
-
|
|
54
|
+
`data:`, and `javascript:` are rejected, private-range and link-local hosts are
|
|
55
|
+
blocked by default, and cloud-metadata endpoints are always blocked (SSRF guard).
|
|
56
|
+
Loopback (`localhost`/`127.0.0.0/8`/`::1`) is allowed by default for local
|
|
57
|
+
development; set `allowPrivateNetwork: true` for private ranges. Redirect targets
|
|
58
|
+
are re-checked on the final URL. Screenshot paths must use `.png`/`.jpg`, stay
|
|
59
|
+
inside the project directory, and are refused when the file already exists, so a
|
|
60
|
+
bad action cannot overwrite or plant arbitrary files.
|
|
54
61
|
- **Per-page capture.** Console messages and network requests are buffered per
|
|
55
62
|
page as they occur; `console` and `network` actions return the active page's
|
|
56
|
-
buffer
|
|
63
|
+
buffer, capped at the most recent `200` entries per page so an active
|
|
64
|
+
long-running page cannot grow the buffer without bound. `navigate: back`/
|
|
65
|
+
`forward` on a fresh session reports `(no history to go back/forward)` instead of
|
|
66
|
+
silently returning the unchanged page.
|
|
57
67
|
- **One reused browser per session.** Launched on first use, closed automatically
|
|
58
68
|
on `session_shutdown`, or on demand via `action: "close"`.
|
|
59
69
|
|
|
@@ -68,6 +78,7 @@ Optional `~/.pi/agent/configs/opl-browser.json` (see `opl-browser.json.sample`):
|
|
|
68
78
|
| `navigationTimeoutMs` | `30000` | Default navigation and `wait_for` timeout. |
|
|
69
79
|
| `previewChars` | `4000` | Inline threshold; larger outputs are stored and previewed. |
|
|
70
80
|
| `getChars` | `30000` | Characters returned by one `action: "get"` page. |
|
|
81
|
+
| `allowPrivateNetwork` | `false` | Allow `navigate`/`new_page` to reach private/link-local ranges (loopback is always allowed; cloud metadata is always blocked). |
|
|
71
82
|
|
|
72
83
|
### Dependencies
|
|
73
84
|
|
|
@@ -86,7 +97,8 @@ npx playwright install chromium
|
|
|
86
97
|
index.ts Pi wiring: registers the single `browser` tool, TTL result store,
|
|
87
98
|
preview/handle logic, and session_shutdown cleanup.
|
|
88
99
|
browser.ts Playwright driver: browser/context/page lifecycle, per-page console
|
|
89
|
-
and network buffers, and the action switch.
|
|
100
|
+
and network buffers, and the action switch. Tool calls are serialized
|
|
101
|
+
on the single shared context.
|
|
90
102
|
validate.ts URL/path guards: http/https-only navigation, screenshot path confined
|
|
91
103
|
to the project directory.
|
|
92
104
|
paging.ts Bounded `get` pagination and continuation metadata.
|
|
@@ -98,4 +110,6 @@ config.ts DEFAULT_CONFIG + loadUserConfig (user overrides win via ??).
|
|
|
98
110
|
Interaction is CSS-selector based. Snapshot-uid interaction (referencing elements
|
|
99
111
|
by ids returned from `snapshot`) is intentionally not implemented; use CSS
|
|
100
112
|
selectors, which are simpler and robust. The in-memory result store expires
|
|
101
|
-
entries after one hour or when the browser is closed
|
|
113
|
+
entries after one hour or when the browser is closed; expiry is checked on read,
|
|
114
|
+
not only when another result is stored, so an idle entry past its TTL is never
|
|
115
|
+
served.
|
|
@@ -12,22 +12,37 @@ let activeIndex = 0;
|
|
|
12
12
|
const consoleBuf = new WeakMap<Page, string[]>();
|
|
13
13
|
const networkBuf = new WeakMap<Page, string[]>();
|
|
14
14
|
|
|
15
|
+
/** Cap on per-page console/network log lines kept in memory. */
|
|
16
|
+
export const MAX_LOG_ENTRIES = 200;
|
|
17
|
+
|
|
18
|
+
/** Push one log line, trimming the oldest entries past the cap. */
|
|
19
|
+
export function pushLogEntry(entries: string[], entry: string, max = MAX_LOG_ENTRIES): string[] {
|
|
20
|
+
entries.push(entry);
|
|
21
|
+
if (entries.length > max) entries.splice(0, entries.length - max);
|
|
22
|
+
return entries;
|
|
23
|
+
}
|
|
24
|
+
|
|
15
25
|
function track(page: Page, cfg: BrowserConfig): void {
|
|
16
26
|
consoleBuf.set(page, []);
|
|
17
27
|
networkBuf.set(page, []);
|
|
18
28
|
page.setDefaultNavigationTimeout(cfg.navigationTimeoutMs);
|
|
19
|
-
page.on("console", (m: ConsoleMessage) =>
|
|
29
|
+
page.on("console", (m: ConsoleMessage) => {
|
|
30
|
+
const buf = consoleBuf.get(page);
|
|
31
|
+
if (buf) pushLogEntry(buf, `[${m.type()}] ${m.text()}`);
|
|
32
|
+
});
|
|
20
33
|
page.on("requestfinished", async (req) => {
|
|
21
34
|
try {
|
|
22
35
|
const res = await req.response();
|
|
23
|
-
networkBuf.get(page)
|
|
36
|
+
const buf = networkBuf.get(page);
|
|
37
|
+
if (buf) pushLogEntry(buf, `${req.method()} ${res?.status() ?? "?"} ${req.url()}`);
|
|
24
38
|
} catch {
|
|
25
39
|
/* response unavailable */
|
|
26
40
|
}
|
|
27
41
|
});
|
|
28
|
-
page.on("requestfailed", (req) =>
|
|
29
|
-
networkBuf.get(page)
|
|
30
|
-
|
|
42
|
+
page.on("requestfailed", (req) => {
|
|
43
|
+
const buf = networkBuf.get(page);
|
|
44
|
+
if (buf) pushLogEntry(buf, `${req.method()} FAILED ${req.url()} (${req.failure()?.errorText ?? "unknown"})`);
|
|
45
|
+
});
|
|
31
46
|
}
|
|
32
47
|
|
|
33
48
|
async function ensure(cfg: BrowserConfig): Promise<BrowserContext> {
|
|
@@ -46,6 +61,13 @@ function page(): Page {
|
|
|
46
61
|
return pages[activeIndex] ?? pages[pages.length - 1];
|
|
47
62
|
}
|
|
48
63
|
|
|
64
|
+
/** Navigate with the host validated both before the request and on the final URL, so a
|
|
65
|
+
* public URL that redirects to a private/link-local host is rejected. */
|
|
66
|
+
async function gotoAllowed(target: Page, url: string, cfg: BrowserConfig): Promise<void> {
|
|
67
|
+
await target.goto(assertHttpUrl(url, { allowPrivateNetwork: cfg.allowPrivateNetwork }), { waitUntil: "domcontentloaded" });
|
|
68
|
+
assertHttpUrl(target.url(), { allowPrivateNetwork: cfg.allowPrivateNetwork });
|
|
69
|
+
}
|
|
70
|
+
|
|
49
71
|
export interface BrowserActionResult {
|
|
50
72
|
text: string;
|
|
51
73
|
file?: string;
|
|
@@ -67,9 +89,23 @@ export interface BrowserParams {
|
|
|
67
89
|
height?: number;
|
|
68
90
|
}
|
|
69
91
|
|
|
70
|
-
|
|
92
|
+
let last: Promise<unknown> = Promise.resolve();
|
|
93
|
+
/** Serialize state-mutating browser work: a single shared context cannot service
|
|
94
|
+
* concurrent tool calls safely (activeIndex, page(), ensure() all race). Each new
|
|
95
|
+
* action is queued behind the previous one, and failures never break the chain. */
|
|
96
|
+
function serialize<T>(task: () => Promise<T>): Promise<T> {
|
|
97
|
+
const run = last.then(task, task);
|
|
98
|
+
last = run.then(() => undefined, () => undefined);
|
|
99
|
+
return run;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function runAction(p: BrowserParams, cfg: BrowserConfig): Promise<BrowserActionResult> {
|
|
103
|
+
return serialize(() => runActionInternal(p, cfg));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function runActionInternal(p: BrowserParams, cfg: BrowserConfig): Promise<BrowserActionResult> {
|
|
71
107
|
if (p.action === "close") {
|
|
72
|
-
await
|
|
108
|
+
await closeBrowserInternal();
|
|
73
109
|
return { text: "Browser closed." };
|
|
74
110
|
}
|
|
75
111
|
|
|
@@ -78,10 +114,15 @@ export async function runAction(p: BrowserParams, cfg: BrowserConfig): Promise<B
|
|
|
78
114
|
switch (p.action) {
|
|
79
115
|
case "navigate": {
|
|
80
116
|
const url = p.url ?? "";
|
|
81
|
-
if (url === "back"
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
117
|
+
if (url === "back" || url === "forward") {
|
|
118
|
+
// Playwright returns null (no throw) when history is exhausted; report it
|
|
119
|
+
// clearly instead of silently returning the unchanged page.
|
|
120
|
+
const moved = url === "back" ? await page().goBack() : await page().goForward();
|
|
121
|
+
if (!moved) return { text: `(no history to go ${url})` };
|
|
122
|
+
return { text: `${page().url()} — ${await page().title()}` };
|
|
123
|
+
}
|
|
124
|
+
if (url === "reload") await page().reload();
|
|
125
|
+
else if (url) await gotoAllowed(page(), url, cfg);
|
|
85
126
|
else throw new Error("navigate requires url (or back|forward|reload)");
|
|
86
127
|
return { text: `${page().url()} — ${await page().title()}` };
|
|
87
128
|
}
|
|
@@ -91,9 +132,18 @@ export async function runAction(p: BrowserParams, cfg: BrowserConfig): Promise<B
|
|
|
91
132
|
}
|
|
92
133
|
case "extract": {
|
|
93
134
|
const selector = p.selector;
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
135
|
+
let html: string;
|
|
136
|
+
if (selector) {
|
|
137
|
+
const loc = page().locator(selector);
|
|
138
|
+
const count = await loc.count();
|
|
139
|
+
if (count === 0) return { text: `(no elements match "${selector}")` };
|
|
140
|
+
if (count > 1) {
|
|
141
|
+
throw new Error(`extract selector "${selector}" matched ${count} elements; use a more specific selector`);
|
|
142
|
+
}
|
|
143
|
+
html = await loc.evaluate((el: Element) => el.outerHTML);
|
|
144
|
+
} else {
|
|
145
|
+
html = await page().content();
|
|
146
|
+
}
|
|
97
147
|
const { markdown } = extractMarkdown(html, { raw: Boolean(selector) });
|
|
98
148
|
return { text: markdown || "(empty extraction)" };
|
|
99
149
|
}
|
|
@@ -130,15 +180,18 @@ export async function runAction(p: BrowserParams, cfg: BrowserConfig): Promise<B
|
|
|
130
180
|
case "evaluate": {
|
|
131
181
|
if (!p.script) throw new Error("evaluate requires script");
|
|
132
182
|
const value = await page().evaluate(p.script);
|
|
133
|
-
|
|
183
|
+
if (value === undefined) return { text: "(undefined result)" };
|
|
184
|
+
return { text: typeof value === "string" ? value : (JSON.stringify(value, null, 2) ?? "(undefined result)") };
|
|
134
185
|
}
|
|
135
186
|
case "console": {
|
|
136
187
|
const msgs = consoleBuf.get(page()) ?? [];
|
|
137
|
-
|
|
188
|
+
const suffix = msgs.length >= MAX_LOG_ENTRIES ? `\n… (showing last ${MAX_LOG_ENTRIES} entries)` : "";
|
|
189
|
+
return { text: msgs.length ? msgs.join("\n") + suffix : "(no console messages)" };
|
|
138
190
|
}
|
|
139
191
|
case "network": {
|
|
140
192
|
const reqs = networkBuf.get(page()) ?? [];
|
|
141
|
-
|
|
193
|
+
const suffix = reqs.length >= MAX_LOG_ENTRIES ? `\n… (showing last ${MAX_LOG_ENTRIES} requests)` : "";
|
|
194
|
+
return { text: reqs.length ? reqs.join("\n") + suffix : "(no network requests)" };
|
|
142
195
|
}
|
|
143
196
|
case "wait_for": {
|
|
144
197
|
const timeout = p.timeoutMs ?? cfg.navigationTimeoutMs;
|
|
@@ -155,7 +208,7 @@ export async function runAction(p: BrowserParams, cfg: BrowserConfig): Promise<B
|
|
|
155
208
|
const pg = await ctx.newPage();
|
|
156
209
|
track(pg, cfg);
|
|
157
210
|
activeIndex = ctx.pages().length - 1;
|
|
158
|
-
if (p.url) await pg
|
|
211
|
+
if (p.url) await gotoAllowed(pg, p.url, cfg);
|
|
159
212
|
return { text: `Opened page [${activeIndex}] ${pg.url()}` };
|
|
160
213
|
}
|
|
161
214
|
case "select_page": {
|
|
@@ -182,7 +235,11 @@ export async function runAction(p: BrowserParams, cfg: BrowserConfig): Promise<B
|
|
|
182
235
|
}
|
|
183
236
|
}
|
|
184
237
|
|
|
185
|
-
export
|
|
238
|
+
export function closeBrowser(): Promise<void> {
|
|
239
|
+
return serialize(closeBrowserInternal);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function closeBrowserInternal(): Promise<void> {
|
|
186
243
|
try {
|
|
187
244
|
await context?.close();
|
|
188
245
|
await browser?.close();
|
|
@@ -9,6 +9,9 @@ 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. */
|
|
14
|
+
allowPrivateNetwork: boolean;
|
|
12
15
|
}
|
|
13
16
|
|
|
14
17
|
export const DEFAULT_CONFIG: BrowserConfig = {
|
|
@@ -18,6 +21,7 @@ export const DEFAULT_CONFIG: BrowserConfig = {
|
|
|
18
21
|
navigationTimeoutMs: 30000,
|
|
19
22
|
previewChars: 4000,
|
|
20
23
|
getChars: 30000,
|
|
24
|
+
allowPrivateNetwork: false,
|
|
21
25
|
};
|
|
22
26
|
|
|
23
27
|
const CONFIG_PATH = path.join(os.homedir(), ".pi", "agent", "configs", "opl-browser.json");
|
|
@@ -36,5 +40,6 @@ export function loadUserConfig(configPath = CONFIG_PATH): BrowserConfig {
|
|
|
36
40
|
navigationTimeoutMs: user.navigationTimeoutMs ?? DEFAULT_CONFIG.navigationTimeoutMs,
|
|
37
41
|
previewChars: user.previewChars ?? DEFAULT_CONFIG.previewChars,
|
|
38
42
|
getChars: user.getChars ?? DEFAULT_CONFIG.getChars,
|
|
43
|
+
allowPrivateNetwork: user.allowPrivateNetwork ?? DEFAULT_CONFIG.allowPrivateNetwork,
|
|
39
44
|
};
|
|
40
45
|
}
|
|
@@ -62,6 +62,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
62
62
|
if (!p.responseId) return err("get requires responseId");
|
|
63
63
|
const hit = STORE.get(p.responseId);
|
|
64
64
|
if (!hit) return err(`No stored result for ${p.responseId} (expires after 1h or on browser close).`);
|
|
65
|
+
// Enforce TTL on read, not just on the next store.
|
|
66
|
+
if (Date.now() - hit.timestamp > TTL_MS) {
|
|
67
|
+
STORE.delete(p.responseId);
|
|
68
|
+
return err(`No stored result for ${p.responseId} (expires after 1h or on browser close).`);
|
|
69
|
+
}
|
|
65
70
|
const page = paginateStored(hit.text, p.offset ?? 0, cfg.getChars);
|
|
66
71
|
return {
|
|
67
72
|
content: [{ type: "text", text: page.text + continuationNotice(page) }],
|
|
@@ -76,15 +81,18 @@ export default function (pi: ExtensionAPI) {
|
|
|
76
81
|
return err(`browser ${p.action}: ${e instanceof Error ? e.message : String(e)}`);
|
|
77
82
|
}
|
|
78
83
|
|
|
84
|
+
// Defensive: never let a missing/invalid action text crash the size gate.
|
|
85
|
+
const text = String(result.text ?? "");
|
|
86
|
+
|
|
79
87
|
// Small outputs return inline; large outputs are stored and previewed.
|
|
80
|
-
if (
|
|
81
|
-
return { content: [{ type: "text", text
|
|
88
|
+
if (text.length <= cfg.previewChars) {
|
|
89
|
+
return { content: [{ type: "text", text }], details: result.file ? { file: result.file } : {} };
|
|
82
90
|
}
|
|
83
|
-
const id = store(p.action,
|
|
84
|
-
const preview =
|
|
91
|
+
const id = store(p.action, text);
|
|
92
|
+
const preview = text.slice(0, cfg.previewChars);
|
|
85
93
|
return {
|
|
86
94
|
content: [{ type: "text", text: `${preview}\n\n… truncated. Full result via action:get responseId: ${id}` }],
|
|
87
|
-
details: { responseId: id, action: p.action, chars:
|
|
95
|
+
details: { responseId: id, action: p.action, chars: text.length },
|
|
88
96
|
};
|
|
89
97
|
},
|
|
90
98
|
});
|
|
@@ -1,10 +1,119 @@
|
|
|
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";
|
|
3
4
|
import { resolve, sep } from "node:path";
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
export interface HttpUrlOptions {
|
|
7
|
+
/** Allow private, link-local, and reserved network hosts (default false). Loopback is always allowed; cloud metadata is always blocked. */
|
|
8
|
+
allowPrivateNetwork?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Well-known cloud metadata hostnames that resolve to instance-credential endpoints. */
|
|
12
|
+
const BLOCKED_METADATA_HOSTS = new Set([
|
|
13
|
+
"metadata",
|
|
14
|
+
"metadata.google.internal",
|
|
15
|
+
"instance-data",
|
|
16
|
+
"instance-data.ec2.internal",
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
/** Cloud metadata IPv6 literals (e.g. AWS IMDS over IPv6). Always blocked. */
|
|
20
|
+
const BLOCKED_METADATA_V6 = new Set(["fd00:ec2::254"]);
|
|
21
|
+
|
|
22
|
+
type HostClass = "loopback" | "metadata" | "private" | "public";
|
|
23
|
+
|
|
24
|
+
function ipv4FromInteger(n: number): number[] | null {
|
|
25
|
+
if (!Number.isInteger(n) || n < 0 || n > 0xffffffff) return null;
|
|
26
|
+
return [(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function ipv4Octets(host: string): number[] | null {
|
|
30
|
+
if (/^\d+$/.test(host)) return ipv4FromInteger(Number(host));
|
|
31
|
+
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;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isLoopbackIpv4(o: number[]): boolean {
|
|
37
|
+
return o[0] === 127;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isMetadataIpv4(o: number[]): boolean {
|
|
41
|
+
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)
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isPrivateIpv4(o: number[]): boolean {
|
|
48
|
+
const [a, b, c] = o;
|
|
49
|
+
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
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
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];
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function classifyIpv4Octets(o: number[]): HostClass {
|
|
75
|
+
if (isLoopbackIpv4(o)) return "loopback";
|
|
76
|
+
if (isMetadataIpv4(o)) return "metadata";
|
|
77
|
+
if (isPrivateIpv4(o)) return "private";
|
|
78
|
+
return "public";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function classifyIpv6Host(host: string): HostClass {
|
|
82
|
+
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";
|
|
91
|
+
return "public";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function classifyHost(host: string): HostClass {
|
|
95
|
+
const lowered = host.toLowerCase();
|
|
96
|
+
if (BLOCKED_METADATA_HOSTS.has(lowered)) return "metadata";
|
|
97
|
+
if (lowered === "localhost" || lowered.endsWith(".localhost")) return "loopback";
|
|
98
|
+
if (lowered.includes(":")) return classifyIpv6Host(lowered);
|
|
99
|
+
const octets = ipv4Octets(lowered);
|
|
100
|
+
return octets ? classifyIpv4Octets(octets) : "public";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function isBlockedNetworkHost(host: string, allowPrivateNetwork: boolean): boolean {
|
|
104
|
+
const cls = classifyHost(host);
|
|
105
|
+
if (cls === "metadata") return true;
|
|
106
|
+
if (cls === "loopback" || cls === "public") return false;
|
|
107
|
+
return !allowPrivateNetwork;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
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.
|
|
115
|
+
*/
|
|
116
|
+
export function assertHttpUrl(url: string, opts: HttpUrlOptions = {}): string {
|
|
8
117
|
let parsed: URL;
|
|
9
118
|
try {
|
|
10
119
|
parsed = new URL(url);
|
|
@@ -14,16 +123,25 @@ export function assertHttpUrl(url: string): string {
|
|
|
14
123
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
15
124
|
throw new Error(`only http/https URLs are allowed, got ${parsed.protocol}//`);
|
|
16
125
|
}
|
|
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)`);
|
|
128
|
+
}
|
|
17
129
|
return parsed.href;
|
|
18
130
|
}
|
|
19
131
|
|
|
20
|
-
/** Require a screenshot path to stay within the project directory
|
|
21
|
-
*
|
|
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). */
|
|
22
134
|
export function safeScreenshotPath(file: string, cwd: string = process.cwd()): string {
|
|
23
135
|
const root = resolve(cwd);
|
|
24
136
|
const abs = resolve(cwd, file);
|
|
25
137
|
if (abs !== root && !abs.startsWith(root + sep)) {
|
|
26
138
|
throw new Error(`screenshot path must stay within the project directory: ${file}`);
|
|
27
139
|
}
|
|
140
|
+
if (!/\.(png|jpe?g)$/i.test(file)) {
|
|
141
|
+
throw new Error(`screenshot path must end in .png or .jpg: ${file}`);
|
|
142
|
+
}
|
|
143
|
+
if (existsSync(abs)) {
|
|
144
|
+
throw new Error(`screenshot path already exists (refusing to overwrite): ${file}`);
|
|
145
|
+
}
|
|
28
146
|
return file;
|
|
29
147
|
}
|