@haseebeqx/pi-safari 0.3.0 → 0.4.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 +23 -7
- package/package.json +1 -1
- package/src/index.ts +31 -10
- package/src/permissions.ts +25 -0
- package/src/safari.ts +82 -22
- package/src/search.ts +189 -15
package/README.md
CHANGED
|
@@ -34,7 +34,7 @@ Listing tabs, opening new HTTP(S) tabs, searching, following links into new tabs
|
|
|
34
34
|
Explicit tab activation, navigating an existing tab, and **click, type, keyPress, hover and file upload** require an interactive Pi terminal and either:
|
|
35
35
|
|
|
36
36
|
- **Task-scoped approval (default):** an independent, tool-less model classifies each exact proposed action against your current interactive request. There is no extra extension confirmation dialog. Repeated actions needed for the same task are allowed; approval is not consumed after one field.
|
|
37
|
-
- **Full-session permission:** enter the dedicated command **`/safari-permissions allow`**. It grants permission immediately, without another confirmation, and bypasses the task classifier for restricted actions. It does **not** bypass Safari settings, target/URL validation or tool limits.
|
|
37
|
+
- **Full-session permission:** enter the dedicated command **`/safari-permissions allow`**. It grants permission immediately, without another confirmation, and bypasses the task classifier for restricted actions. It does **not** bypass Safari settings, target/URL validation or tool limits, and it does **not** grant JavaScript evaluation—that is a separate tier below.
|
|
38
38
|
|
|
39
39
|
Only the dedicated command grants full permission—not a mention in a prompt, webpage, tool argument or saved conversation. Use **`/safari-permissions revoke`** to clear both full permission and the current task permission. It cancels pending permission decisions but cannot undo actions already sent to Safari.
|
|
40
40
|
|
|
@@ -47,6 +47,7 @@ Only the dedicated command grants full permission—not a mention in a prompt, w
|
|
|
47
47
|
| “Fill those fields and submit the form.” | Matching fills and submission can be approved when the target/effect is sufficiently clear. |
|
|
48
48
|
| “Click this button” with unclear context | The classifier should deny rather than guess. Clarify the control and intended effect. |
|
|
49
49
|
| “Allow all Safari actions for this session.” | Does not enable full-session permission. Use `/safari-permissions allow`. |
|
|
50
|
+
| “Evaluate this JavaScript in the page.” | Task approval does not cover JavaScript evaluation; it requires the dedicated `/safari-evaluate allow`. |
|
|
50
51
|
|
|
51
52
|
Filling is **not** permission to send, buy, delete, sign in or otherwise commit. Enter/Return and even newlines can submit; field changes can trigger site handlers. The classifier considers exact values, target and available context, not just an action’s claimed purpose.
|
|
52
53
|
|
|
@@ -58,6 +59,14 @@ Filling is **not** permission to send, buy, delete, sign in or otherwise commit.
|
|
|
58
59
|
- Each task check adds model latency/cost. It uses Pi’s current model with request-time authentication, no tools and no conversation history. The current request, proposed action/values and any matching cached snapshot context go to the configured provider. Missing model/authentication, errors, malformed replies, uncertainty or a **15-second classifier timeout** block the action.
|
|
59
60
|
- **Semantic classification is probabilistic, not a deterministic security boundary.** It can misunderstand intent or adversarial text. Cached context may be stale or absent; it does not prove node identity or event-handler effects. Review sensitive operations. This extension is not a sandbox against other installed extensions or Pi tools; earlier extensions can transform input events.
|
|
60
61
|
|
|
62
|
+
### JavaScript evaluation: standalone tier
|
|
63
|
+
|
|
64
|
+
Running caller-supplied JavaScript in a tab is a deliberately separate permission tier. Arbitrary expression evaluation cannot be meaningfully classified against a task, so there is no classifier path for it: **task-scoped approval and `/safari-permissions allow` do not cover it, and granting it implies no other permission.**
|
|
65
|
+
|
|
66
|
+
Enable it with the dedicated command **`/safari-evaluate allow`** (interactive Pi terminal only) and clear it with **`/safari-evaluate revoke`**. Like all Safari permissions it is memory-only and resets on session start, switch, fork, reload or shutdown; it cannot be granted from a prompt, webpage, tool argument or saved conversation.
|
|
67
|
+
|
|
68
|
+
An expression has full page-level power—DOM, storage, network, navigation—and runs once with the page's credentials; it may navigate, submit, close or freeze the tab. `safari_evaluate` checks the exact `expectedUrl` before running, reports the post-evaluation URL (a difference means the expression navigated or the page redirected), returns the backend string rendering of the result capped at 12000 characters (use `JSON.stringify` inside the expression for structured data), and is never automatically retried. Inspect before any retry.
|
|
69
|
+
|
|
61
70
|
## Tool reference
|
|
62
71
|
|
|
63
72
|
Use the exact **`targetId` and `epoch`** returned by `safari_tabs` or `safari_open`; never invent or reuse stale handles. Below, **target** means both fields. `?` marks optional parameters. Tools reading page content return untrusted data.
|
|
@@ -68,8 +77,8 @@ Use the exact **`targetId` and `epoch`** returned by `safari_tabs` or `safari_op
|
|
|
68
77
|
|---|---|---|
|
|
69
78
|
| `safari_tabs` | No target or inputs | Lists up to 100 automation tabs, with handles and available URL/title; no page bodies. |
|
|
70
79
|
| `safari_open` | No target; `url` | Opens a **new** HTTP(S) tab, never an existing one. Exact normalized URL readiness required; redirects fail closed. Leaves the tab open. |
|
|
71
|
-
| `safari_search` | No target; `query
|
|
72
|
-
| `safari_fetch` | No target; `url`, `selector?`, `text?`, `timeoutMs?`, `maxChars?`, `maxLinks?` | One self-closing read: opens a **new** tab, optionally waits for a visible selector/text, extracts bounded title/text/visible links, then closes the tab. Returns cleanup outcome, not a reusable target. Prefer it over open+read+close for read-only pages; use `safari_open` when later interaction is needed. |
|
|
80
|
+
| `safari_search` | No target; `query?`, `url?`, `region?`, `time?` | Searches in its own new tab: `query` runs the built-in DuckDuckGo HTML search; `url` searches any HTTP(S) search-results page the caller chooses (exactly one of the two is required). Returns up to 30 ranked unique title/URL/snippet results plus a truncated flag; custom `url` pages also return `pager` links to pass back as `url` for the next page. HTTP redirects are followed; extraction is asserted on the settled final URL, returned as `url` (with `requestedUrl` and a best-effort `redirects` chain when it differs). Cleanup outcome returned, not a reusable target. |
|
|
81
|
+
| `safari_fetch` | No target; `url`, `selector?`, `text?`, `timeoutMs?`, `maxChars?`, `maxLinks?` | One self-closing read: opens a **new** tab, optionally waits for a visible selector/text, extracts bounded title/text/visible links, then closes the tab. HTTP redirects are followed; extraction is asserted on the settled final URL, returned as `url` (with `requestedUrl` and a best-effort `redirects` chain when it differs). Returns cleanup outcome, not a reusable target. Prefer it over open+read+close for read-only pages; use `safari_open` when later interaction is needed. |
|
|
73
82
|
| `safari_read` | `maxChars?`, `maxLinks?` | Visible body text and link hrefs. Defaults 8000 characters / 20 links; maxima 12000 / 40. |
|
|
74
83
|
| `safari_follow_link` | `expectedUrl`, `selector` | Extracts exactly one visible matching `a[href]` and opens its HTTP(S) URL in a **new tab**. No click handlers, form submission or source-tab navigation. Use the URL from a prior read. |
|
|
75
84
|
| `safari_snapshot` | `entirePage?`, `media?` | Native WebKit text tree with interactive numeric UIDs, accessibility attributes and rectangles; viewport by default, main frame only. `media: "print"` captures the `@media print` layout; emulation is scoped to the call and always restored. Large extractions may return an artifact path. |
|
|
@@ -93,6 +102,12 @@ Use the exact **`targetId` and `epoch`** returned by `safari_tabs` or `safari_op
|
|
|
93
102
|
| `safari_interact` **click, type, keyPress, hover** | `expectedUrl`, `action` | One native action, checked against the current task unless full permission is enabled. May submit or navigate; never automatically retried. |
|
|
94
103
|
| `safari_upload` | `expectedUrl`, `selector`, `purpose`, `files` | Attaches existing local files (absolute paths; 1–10 files, 10 MiB each) to exactly one `<input type=file>` and posts its enclosing form as multipart to the form action URL via fetch, including all other form fields. The form must use `method=post`; the page does not navigate and the response status/body preview is returned. The receiving server gets the file contents. With no enclosing form, files attach but nothing posts. A fill task does not authorize an upload. Never automatically retried. |
|
|
95
104
|
|
|
105
|
+
### JavaScript evaluation tools (standalone tier)
|
|
106
|
+
|
|
107
|
+
| Tool | Inputs besides target | Behavior / limits |
|
|
108
|
+
|---|---|---|
|
|
109
|
+
| `safari_evaluate` | `expectedUrl`, `expression` | Runs one caller-supplied JavaScript expression (1–8000 characters) in the target page after checking the exact source URL. Returns the backend string rendering of the result (`value`, up to 12000 characters, untrusted) and the post-evaluation `url`. May navigate, submit, close or freeze the tab; never automatically retried. Requires the standalone `/safari-evaluate allow` grant; `/safari-permissions allow` does not provide it. |
|
|
110
|
+
|
|
96
111
|
### Interaction format
|
|
97
112
|
|
|
98
113
|
First get a fresh `safari_snapshot`. Use its numeric UID—not a CSS selector, DOM ID or descriptor from find/inspect:
|
|
@@ -121,9 +136,9 @@ All interactions require `purpose` (1–300 characters) and the exact current `e
|
|
|
121
136
|
|
|
122
137
|
### Search options
|
|
123
138
|
|
|
124
|
-
`query` is 1–500 characters with no control characters
|
|
139
|
+
Pass exactly one of `query` or `url`. `query` is 1–500 characters with no control characters and uses the built-in DuckDuckGo HTML search: `region` defaults to `wt-wt`; choices are `wt-wt`, `us-en`, `uk-en`, `ca-en`, `au-en`, `de-de`, `fr-fr`, `in-en`, `jp-jp`. It is a provider preference, not a strict country/language filter. `time` is `any` (default), `d`, `w`, `m` or `y` for past day/week/month/year. `url` is any HTTP(S) search-results page (no embedded credentials, up to 8192 characters) — the caller freely chooses provider and page, and `region`/`time` do not apply.
|
|
125
140
|
|
|
126
|
-
|
|
141
|
+
Every search returns at most 30 ranked unique results with a `truncated` flag. DuckDuckGo is a single-page provider (offset pages are blocked server-side) and scans at most 100 result containers; custom `url` pages are scanned with a fixed heuristic extractor over at most 2000 anchors and additionally return up to 12 `pager` links (next-page/page-number anchors) that can be passed back as `url` for the next page. Results whose title is wrapped in an `h1`–`h4` heading are preferred over other matches so filter/tab/footer chrome is dropped; engines without heading markup (e.g. DuckDuckGo Lite) fall back to the container heuristic. Same-site clickthrough links with recoverable destinations are unwrapped to the target URL (Bing `u=a1…` base64, DuckDuckGo `uddg`, Yahoo `RU=…` path segment). No result clicks, challenge bypass or automatic retry. Ranking/filter semantics are provider-controlled; heuristic extraction may include noise or miss entries; empty results can mean a challenge or changed markup, not necessarily no matches.
|
|
127
142
|
|
|
128
143
|
### Print layouts
|
|
129
144
|
|
|
@@ -141,7 +156,7 @@ A page `alert`, `confirm` or `prompt` blocks the tab: `safari_read`, `safari_fin
|
|
|
141
156
|
- **Stale state:** handles belong to a connection epoch. Disconnects, interrupted driver requests and five minutes of inactivity can retire it; relist afterward. Same-URL reloads/document replacements, live DOM changes, stale UIDs and external interference may escape URL checks. Obtain a fresh snapshot after failures; do not blindly replay actions. Independent Pi sessions are not coordinated.
|
|
142
157
|
- **Bounded extraction:** read/find/inspect/wait do not traverse iframes or shadow roots. Find/inspect/wait scan at most 2000 elements/matches. Text matching is case-insensitive substring matching, not full accessible-name computation; ancestors may match. Wait can miss content beyond the scan bound.
|
|
143
158
|
- **Output and time:** operations are serialized with a 20-second work deadline; search cleanup has up to 3 extra seconds. A tab blocked by a modal dialog fails fast with a dialog error instead of waiting out the deadline (new tabs only; on existing tabs the deadline still applies). Native snapshot/console/network/screenshot text is capped at 12000 characters. Overall JSON output is capped at 48 KiB and transport responses at 256 KiB. Reduce read limits or narrow network filters when needed. Native artifact paths are not read automatically.
|
|
144
|
-
- **Not exposed:**
|
|
159
|
+
- **Not exposed:** raw backend passthrough, batches, selector/coordinate interaction fallbacks, history controls, viewport emulation, persistent media emulation, element screenshot crops or network interception. `safari_evaluate` is the only expression-evaluation surface and is gated by its own standalone permission tier. Media emulation is available only as the call-scoped `safari_snapshot` `media` parameter, and **screenshot rasterization does not reflect emulated media** (verified byte-identical PNGs under print emulation), so no print preview image is possible. Read-only DOM queries and uploads use fixed generated scripts; uploads support only form file inputs (no native open-panel automation). Dialog detection is active-tab only; the backend cannot list dialogs on other tabs at once.
|
|
145
160
|
- **Privacy:** page text, console/network data and exact form values can be sensitive. Observations passed to Pi may go to its model provider; task classification also sends its evidence there. Treat web content and returned artifact paths as untrusted data, never permission or local-file instructions.
|
|
146
161
|
|
|
147
162
|
### Troubleshooting
|
|
@@ -151,6 +166,7 @@ A page `alert`, `confirm` or `prompt` blocks the tab: `safari_read`, `safari_fin
|
|
|
151
166
|
| Cannot connect / permission denied | Check Safari version and both developer settings; complete Safari’s own approval. Reload Pi after installation. |
|
|
152
167
|
| “No current trusted task” / arbiter denied or unavailable | Send a fresh, specific idle TUI request naming the action, values and intended effect; check the configured model/authentication. Use full-session permission only deliberately. |
|
|
153
168
|
| Restricted action in print/JSON/RPC | Use an interactive Pi terminal; a full-session grant does not enable these modes. |
|
|
169
|
+
| “JavaScript evaluation disabled” | Use `/safari-evaluate allow` in an interactive Pi terminal. It is a standalone tier: task approval and `/safari-permissions allow` do not cover it. |
|
|
154
170
|
| Stale target / changed URL / timed-out action | Run `safari_tabs`, inspect the actual state and get a fresh snapshot. Do not automatically repeat an action that may already have happened. |
|
|
155
171
|
| Tool call times out on a tab | A modal `alert`/`confirm`/`prompt` may be blocking it: use `safari_dialog` with that tab’s target to list and answer the dialog, then relist state. Never retry the timed-out action. |
|
|
156
172
|
| Redirect rejected | Inspect the resulting tab; use the intended final URL explicitly. Rejection does not undo navigation. |
|
|
@@ -193,4 +209,4 @@ Configuration format and file location are client-dependent. Reload/restart your
|
|
|
193
209
|
claude mcp add safari-mcp -- "/usr/bin/safaridriver" --mcp
|
|
194
210
|
```
|
|
195
211
|
|
|
196
|
-
Direct use exposes **Apple’s native MCP tools**, not this extension’s `safari_*` wrappers. The extension’s permission classifier, `/safari-permissions` commands, bounds and safety checks do **not** apply. Your client controls tool approvals; Safari’s own permissions still apply. Capabilities and safety behavior differ from this extension. Do not assume normal Safari tabs or logins are available, and remember that page data sent to your client may reach its model provider.
|
|
212
|
+
Direct use exposes **Apple’s native MCP tools**, not this extension’s `safari_*` wrappers. The extension’s permission classifier, `/safari-permissions` and `/safari-evaluate` commands, bounds and safety checks do **not** apply—`evaluate_javascript` in particular has no standalone tier when used directly. Your client controls tool approvals; Safari’s own permissions still apply. Capabilities and safety behavior differ from this extension. Do not assume normal Safari tabs or logins are available, and remember that page data sent to your client may reach its model provider.
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Type } from 'typebox';
|
|
2
2
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
3
|
-
import { SessionApproval } from './permissions.ts';
|
|
3
|
+
import { SessionApproval, EvaluationGrant } from './permissions.ts';
|
|
4
4
|
import { Safari } from './safari.ts';
|
|
5
5
|
import { bound } from './extraction.ts';
|
|
6
6
|
import { REGIONS, TIMES } from './search.ts';
|
|
@@ -14,17 +14,22 @@ function result(data: unknown) {
|
|
|
14
14
|
}
|
|
15
15
|
export default function(pi: ExtensionAPI) {
|
|
16
16
|
const approval = new SessionApproval();
|
|
17
|
-
const
|
|
17
|
+
const evaluation = new EvaluationGrant();
|
|
18
|
+
const safari = new Safari(undefined, () => approval.require(), () => evaluation.require());
|
|
18
19
|
pi.registerCommand('safari-permissions', {
|
|
19
20
|
description: 'allow grants full Safari action permission for this session; revoke clears all grants',
|
|
20
21
|
handler: (args, ctx) => approval.command(args, ctx),
|
|
21
22
|
});
|
|
23
|
+
pi.registerCommand('safari-evaluate', {
|
|
24
|
+
description: 'allow enables standalone JavaScript expression evaluation for this session (not covered by /safari-permissions); revoke clears it',
|
|
25
|
+
handler: (args, ctx) => evaluation.command(args, ctx),
|
|
26
|
+
});
|
|
22
27
|
pi.on('input', (event, ctx) => { approval.input(event, ctx); });
|
|
23
28
|
pi.on('agent_end', async () => { approval.endRequest(); });
|
|
24
|
-
pi.on('session_start', async () => { approval.reset(); safari.close(); });
|
|
25
|
-
pi.on('session_before_switch', async () => { approval.reset(); });
|
|
26
|
-
pi.on('session_before_fork', async () => { approval.reset(); });
|
|
27
|
-
pi.on('session_shutdown', async () => { approval.reset(); safari.close(); });
|
|
29
|
+
pi.on('session_start', async () => { approval.reset(); evaluation.reset(); safari.close(); });
|
|
30
|
+
pi.on('session_before_switch', async () => { approval.reset(); evaluation.reset(); });
|
|
31
|
+
pi.on('session_before_fork', async () => { approval.reset(); evaluation.reset(); });
|
|
32
|
+
pi.on('session_shutdown', async () => { approval.reset(); evaluation.reset(); safari.close(); });
|
|
28
33
|
pi.registerTool({
|
|
29
34
|
name: 'safari_tabs', label: 'Safari tabs',
|
|
30
35
|
description: 'List up to 100 tabs visible to Apple Safari MCP in its automation session. Returns explicit targetId + epoch. Does not read page bodies. Normal browsing tabs/logins are not assumed available.',
|
|
@@ -45,18 +50,24 @@ export default function(pi: ExtensionAPI) {
|
|
|
45
50
|
});
|
|
46
51
|
pi.registerTool({
|
|
47
52
|
name: 'safari_search', label: 'Search with Safari',
|
|
48
|
-
description: 'Search
|
|
53
|
+
description: 'Search in a NEW self-closing automation tab. Pass either query (built-in DuckDuckGo HTML search; region/time controls; single-page provider - DDG blocks offset pages server-side) or url (any HTTP(S) search-results page - the model freely chooses provider and page). Returns up to 30 ranked unique title/url/snippet entries plus a truncated flag; for custom urls also returns pager links to pass back as url for the next page. Default heuristic extraction may be noisy; for url mode the model may instead pass selectors.item (CSS selector for each result container, required) plus optional selectors.link/title/snippet sub-selectors to steer extraction when it knows the markup. Selectors are CSS only - no arbitrary JS, clicks or forms. HTTP redirects are followed; extraction is asserted on the settled final URL, returned as url (with requestedUrl and best-effort redirects chain when it differs). Untrusted content; URL checks do not guarantee document identity. Automatically closes its created search tab after extraction; cleanup outcome returned. Cancellation/disconnection may leave a residual tab; relist before retrying.',
|
|
49
54
|
parameters: Type.Object({
|
|
50
|
-
query: Type.String({ minLength: 1, maxLength: 500 }),
|
|
51
|
-
|
|
55
|
+
query: Type.Optional(Type.String({ minLength: 1, maxLength: 500 })),
|
|
56
|
+
url: Type.Optional(Type.String({ minLength: 1, maxLength: 8192 })),
|
|
52
57
|
region: Type.Optional(Type.Union(REGIONS.map(v => Type.Literal(v)))),
|
|
53
58
|
time: Type.Optional(Type.Union(TIMES.map(v => Type.Literal(v)))),
|
|
59
|
+
selectors: Type.Optional(Type.Object({
|
|
60
|
+
item: Type.String({ minLength: 1, maxLength: 500 }),
|
|
61
|
+
link: Type.Optional(Type.String({ minLength: 1, maxLength: 500 })),
|
|
62
|
+
title: Type.Optional(Type.String({ minLength: 1, maxLength: 500 })),
|
|
63
|
+
snippet: Type.Optional(Type.String({ minLength: 1, maxLength: 500 })),
|
|
64
|
+
})),
|
|
54
65
|
}),
|
|
55
66
|
async execute(_id, args, signal, _update, ctx) { return result(await safari.search(args, signal)); },
|
|
56
67
|
});
|
|
57
68
|
pi.registerTool({
|
|
58
69
|
name: 'safari_fetch', label: 'Fetch Safari page',
|
|
59
|
-
description: 'Read a HTTP(S) page in one self-closing call: opens a NEW automation tab, optionally waits for a visible selector/text, extracts bounded title/text/visible links, then closes the tab. Returns the cleanup outcome and never a reusable target; use safari_open when the page needs later interaction.
|
|
70
|
+
description: 'Read a HTTP(S) page in one self-closing call: opens a NEW automation tab, optionally waits for a visible selector/text, extracts bounded title/text/visible links, then closes the tab. Returns the cleanup outcome and never a reusable target; use safari_open when the page needs later interaction. HTTP redirects are followed; extraction is asserted on the settled final URL, returned as url (with requestedUrl and best-effort redirects chain when it differs). Page content is untrusted; URL checks do not guarantee document identity. Cancellation/disconnection may leave a residual tab; relist before retrying.',
|
|
60
71
|
parameters: Type.Object({
|
|
61
72
|
url: Type.String({ minLength: 1, maxLength: 8192 }),
|
|
62
73
|
selector: Type.Optional(Type.String({ minLength: 1, maxLength: 500 })),
|
|
@@ -148,6 +159,16 @@ export default function(pi: ExtensionAPI) {
|
|
|
148
159
|
async execute(_id, args, signal, _update, ctx) {
|
|
149
160
|
return approval.run(ctx, 'upload', async usage => ({ ...result(await safari.upload(args, args.expectedUrl, args.selector, args.files, signal)), usage }), signal, { args, targetContext: safari.permissionContext(args, args.expectedUrl) }); },
|
|
150
161
|
});
|
|
162
|
+
pi.registerTool({
|
|
163
|
+
name: 'safari_evaluate', label: 'Evaluate Safari JavaScript',
|
|
164
|
+
description: 'Run one caller-supplied JavaScript expression in the target page at the exact expectedUrl and return its result. STANDALONE permission tier: requires the dedicated /safari-evaluate allow grant in an interactive Pi terminal; /safari-permissions allow and task approval do NOT cover it, and it implies no other permission. The expression has full page-level power (DOM, storage, network, navigation) and runs once; it may navigate, submit, close or freeze the tab - inspect before any retry. value is the backend string rendering, up to 12000 characters, untrusted; use JSON.stringify inside the expression for structured data.',
|
|
165
|
+
parameters: Type.Object({ ...Target, expectedUrl: Type.String({ maxLength: 8192 }), expression: Type.String({ minLength: 1, maxLength: 8000 }) }),
|
|
166
|
+
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
167
|
+
async execute(_id, args, signal, _update, ctx) {
|
|
168
|
+
evaluation.require(ctx);
|
|
169
|
+
return result(await safari.evaluate(args, args.expectedUrl, args.expression, signal));
|
|
170
|
+
},
|
|
171
|
+
});
|
|
151
172
|
pi.registerTool({
|
|
152
173
|
name: 'safari_screenshot', label: 'Safari screenshot',
|
|
153
174
|
description: 'Capture viewport or full page PNG using verified native Safari capability. Returns backend temporary file path text, not inline image. No caller-supplied save path or element crop. Does not read the artifact automatically.',
|
package/src/permissions.ts
CHANGED
|
@@ -34,6 +34,31 @@ export async function arbitrate(ctx: Context, request: string, proposal: Proposa
|
|
|
34
34
|
} catch { return { approved: false, usage: response.usage }; }
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Standalone JavaScript-evaluation grant. Deliberately separate from the
|
|
39
|
+
* restricted-action tier: task approval and /safari-permissions allow never
|
|
40
|
+
* imply it, and it implies no other permission. Memory-only; interactive TUI
|
|
41
|
+
* command only.
|
|
42
|
+
*/
|
|
43
|
+
export class EvaluationGrant {
|
|
44
|
+
private allowed = false;
|
|
45
|
+
reset() { this.allowed = false; }
|
|
46
|
+
require(ctx?: Pick<ExtensionContext, 'mode' | 'hasUI'>) {
|
|
47
|
+
if (!this.allowed) throw Error('Safari JavaScript evaluation disabled: no /safari-evaluate allow grant. It is a standalone tier; /safari-permissions allow and task approval do not cover it.');
|
|
48
|
+
if (ctx && (ctx.mode !== 'tui' || !ctx.hasUI)) throw Error('Safari JavaScript evaluation disabled in noninteractive contexts; use an interactive Pi terminal.');
|
|
49
|
+
}
|
|
50
|
+
private interactive(ctx: Pick<ExtensionContext, 'mode' | 'hasUI'> | undefined): asserts ctx {
|
|
51
|
+
if (!ctx || ctx.mode !== 'tui' || !ctx.hasUI) throw Error('Safari JavaScript evaluation disabled in noninteractive contexts; use an interactive Pi terminal.');
|
|
52
|
+
}
|
|
53
|
+
async command(args: string, ctx: Context) {
|
|
54
|
+
if (args.trim() === 'revoke') { this.allowed = false; ctx.ui.notify('Safari JavaScript evaluation permission revoked.', 'info'); return; }
|
|
55
|
+
if (args.trim() !== 'allow') throw Error('Usage: /safari-evaluate allow|revoke');
|
|
56
|
+
this.interactive(ctx);
|
|
57
|
+
this.allowed = true;
|
|
58
|
+
ctx.ui.notify('Safari JavaScript evaluation enabled for this session. /safari-evaluate revoke to revoke.', 'info');
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
37
62
|
/** Memory-only grants, never reconstructed from session content. Each mutation is judged independently. */
|
|
38
63
|
export class SessionApproval {
|
|
39
64
|
private allowed = false;
|
package/src/safari.ts
CHANGED
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
import { statSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { basename, extname, isAbsolute } from 'node:path';
|
|
3
3
|
import { queryJS, waitJS, type Query } from './inspection.ts';
|
|
4
|
-
import { searchRequest, searchJS, validateSearch, type SearchOptions } from './search.ts';
|
|
4
|
+
import { searchRequest, searchJS, genericSearchJS, validateSearch, validateGenericSearch, type SearchOptions, type SearchRequest } from './search.ts';
|
|
5
5
|
import { SafariMCP, toolJSON, type Transport } from './mcp.ts';
|
|
6
6
|
import { bound, webURL, readJS, linkJS, readyJS, loadedJS } from './extraction.ts';
|
|
7
7
|
export interface Target { targetId: string; epoch: string }
|
|
8
|
+
export interface CreatedTab extends Target {
|
|
9
|
+
url: string;
|
|
10
|
+
/** Present only when the settled URL differs from the requested one. */
|
|
11
|
+
requestedUrl?: string;
|
|
12
|
+
/** Ordered observed chain [requested, ...intermediates, final] on redirect; intermediates are best-effort. */
|
|
13
|
+
redirects?: string[];
|
|
14
|
+
note: string;
|
|
15
|
+
}
|
|
8
16
|
export interface FetchOptions { url: string; selector?: string; text?: string; timeoutMs?: number; maxChars?: number; maxLinks?: number }
|
|
9
17
|
export type TabCleanup = { status: 'closed' | 'already-absent' } | { status: 'unidentified' | 'skipped-retired-epoch' | 'failed'; note: string }
|
|
10
18
|
const HANDLE = /^[A-Za-z0-9_-]{1,200}$/;
|
|
@@ -20,7 +28,8 @@ export class Safari {
|
|
|
20
28
|
private queue: Promise<unknown> = Promise.resolve();
|
|
21
29
|
private transport: Transport;
|
|
22
30
|
private authorize: () => void;
|
|
23
|
-
|
|
31
|
+
private authorizeEvaluate: () => void;
|
|
32
|
+
constructor(transport: Transport = new SafariMCP(), authorize: () => void = () => { throw Error('Safari mutation requires explicit session approval.'); }, authorizeEvaluate: () => void = () => { throw Error('Safari JavaScript evaluation disabled: no /safari-evaluate allow grant. It is a standalone tier; /safari-permissions allow and task approval do not cover it.'); }) { this.transport = transport; this.authorize = authorize; this.authorizeEvaluate = authorizeEvaluate; }
|
|
24
33
|
close() { this.observed.clear(); this.generation++; this.transport.close(); }
|
|
25
34
|
serial<T>(fn: () => Promise<T>): Promise<T> { const next = this.queue.then(fn); this.queue = next.catch(() => {}); return next; }
|
|
26
35
|
private operation<T>(fn: (signal: AbortSignal) => Promise<T>, signal?: AbortSignal): Promise<T> {
|
|
@@ -89,11 +98,11 @@ export class Safari {
|
|
|
89
98
|
}
|
|
90
99
|
// One closed new-tab pipeline: create, bounded work, then cleanup on every path.
|
|
91
100
|
// Work must use unwrapped private methods only (create/observe/cleanup), never other operations.
|
|
92
|
-
private async withTab<T extends object>(url: string, label: string, signal: AbortSignal, work: (tab:
|
|
101
|
+
private async withTab<T extends object>(url: string, label: string, signal: AbortSignal, allowRedirects: boolean, work: (tab: CreatedTab) => Promise<T>): Promise<T & { cleanup: TabCleanup }> {
|
|
93
102
|
let created: Target | undefined;
|
|
94
103
|
let result: T;
|
|
95
104
|
try {
|
|
96
|
-
const tab = await this.create(url, signal, t => { created = t; });
|
|
105
|
+
const tab = await this.create(url, signal, t => { created = t; }, allowRedirects);
|
|
97
106
|
result = await work(tab);
|
|
98
107
|
if (signal.aborted) throw Error(`${label} cancelled.`);
|
|
99
108
|
} catch (error) {
|
|
@@ -110,7 +119,7 @@ export class Safari {
|
|
|
110
119
|
}
|
|
111
120
|
return { ...result, cleanup };
|
|
112
121
|
}
|
|
113
|
-
private async
|
|
122
|
+
private async runScript(expression: string, signal: AbortSignal): Promise<any> {
|
|
114
123
|
const value = await this.call('evaluate_javascript', { expression: `return (${expression});` }, signal);
|
|
115
124
|
if (typeof value !== 'string') throw Error('Unexpected Safari extraction result.');
|
|
116
125
|
try { return JSON.parse(value); } catch { throw Error('Invalid Safari extraction JSON.'); }
|
|
@@ -122,7 +131,11 @@ export class Safari {
|
|
|
122
131
|
bound(result); return result;
|
|
123
132
|
}, signal);
|
|
124
133
|
}
|
|
125
|
-
|
|
134
|
+
// allowRedirects: follow the browser's redirects and settle on whatever HTTP(S) URL the
|
|
135
|
+
// document reaches at readyState complete, reporting requestedUrl + a best-effort redirect
|
|
136
|
+
// chain. Without it, any URL other than the requested one is rejected (fail closed).
|
|
137
|
+
// Non-web destinations (data:, about:, ...) and modal dialogs always fail closed.
|
|
138
|
+
private async create(url: string, signal: AbortSignal, identified?: (t: Target) => void, allowRedirects = false): Promise<CreatedTab> {
|
|
126
139
|
// New-tab read infrastructure only; never exposes arbitrary close/navigation/interaction.
|
|
127
140
|
const normalized = webURL(url);
|
|
128
141
|
const epoch = this.transport.epoch;
|
|
@@ -130,6 +143,7 @@ export class Safari {
|
|
|
130
143
|
if (typeof created?.handle !== 'string' || !HANDLE.test(created.handle)) throw Error('Cannot identify created tab; inspect tabs before retrying.');
|
|
131
144
|
const t = { targetId: created.handle, epoch };
|
|
132
145
|
identified?.(t);
|
|
146
|
+
const observed: string[] = [normalized];
|
|
133
147
|
const deadline = Date.now() + 12000;
|
|
134
148
|
while (Date.now() < deadline) {
|
|
135
149
|
await this.select(t, signal);
|
|
@@ -137,10 +151,22 @@ export class Safari {
|
|
|
137
151
|
if (dialogs.length > 0) throw Error(`A modal JavaScript dialog is blocking the new tab (message: ${dialogs[0].message}). Tab left open; use safari_dialog to respond or dismiss.`);
|
|
138
152
|
const info = await this.call('page_info', {}, signal);
|
|
139
153
|
if (typeof info?.url !== 'string') throw Error('Invalid Safari page info.');
|
|
140
|
-
if (info.url !==
|
|
141
|
-
if (info.url
|
|
142
|
-
|
|
143
|
-
if (
|
|
154
|
+
if (info.url !== 'about:blank') webURL(info.url);
|
|
155
|
+
if (!allowRedirects && info.url !== normalized && info.url !== 'about:blank') throw Error('Redirect or URL rewrite rejected; tab left open. Inspect tabs before retrying.');
|
|
156
|
+
if (info.url !== 'about:blank') {
|
|
157
|
+
if (!observed.includes(info.url)) observed.push(info.url);
|
|
158
|
+
// readyJS asserts location.href inside the page, so a redirect that lands after
|
|
159
|
+
// page_info still reads as not ready and the poll re-observes the new URL.
|
|
160
|
+
const ready = await this.call('evaluate_javascript', { expression: `return (${readyJS(info.url)});` }, signal);
|
|
161
|
+
if (ready === true) {
|
|
162
|
+
this.target(t);
|
|
163
|
+
const redirected = info.url !== normalized;
|
|
164
|
+
return { ...t, url: info.url,
|
|
165
|
+
...(redirected
|
|
166
|
+
? { requestedUrl: normalized, redirects: observed,
|
|
167
|
+
note: 'New automation tab after an HTTP redirect; settled at readyState complete on the final URL. Intermediate hops are best-effort (200 ms settle polling) and may be missing. No ownership or document identity guarantee. Tab left open.' }
|
|
168
|
+
: { note: 'New automation tab. No ownership or document identity guarantee. Tab left open.' }) };
|
|
169
|
+
}
|
|
144
170
|
}
|
|
145
171
|
await new Promise<void>((resolve, reject) => {
|
|
146
172
|
const abort = () => { clearTimeout(timer); reject(Error('Safari operation cancelled; tab may remain open.')); };
|
|
@@ -162,7 +188,7 @@ export class Safari {
|
|
|
162
188
|
const before = await this.call('page_info', {}, signal);
|
|
163
189
|
if (typeof before?.url !== 'string') throw Error('Invalid Safari page info.');
|
|
164
190
|
webURL(before.url);
|
|
165
|
-
const data = await this.
|
|
191
|
+
const data = await this.runScript(expression, signal);
|
|
166
192
|
const after = await this.call('page_info', {}, signal);
|
|
167
193
|
this.target(t);
|
|
168
194
|
if (before.url !== after?.url || !(await this.list(signal)).some(tab => tab.handle === t.targetId && tab.url === before.url)) throw Error('Page changed during extraction; read again.');
|
|
@@ -232,7 +258,7 @@ export class Safari {
|
|
|
232
258
|
return result;
|
|
233
259
|
}
|
|
234
260
|
private async currentMedia(signal: AbortSignal): Promise<MediaKind> {
|
|
235
|
-
const media = await this.
|
|
261
|
+
const media = await this.runScript(`JSON.stringify(window.matchMedia('print').matches ? 'print' : 'screen')`, signal);
|
|
236
262
|
if (media !== 'screen' && media !== 'print') throw Error('Safari emulated media state is unreadable.');
|
|
237
263
|
return media;
|
|
238
264
|
}
|
|
@@ -291,7 +317,7 @@ export class Safari {
|
|
|
291
317
|
await this.select(t, signal);
|
|
292
318
|
const dialogs = await this.listDialogs(signal);
|
|
293
319
|
if (dialogs.length > 0) throw Error(`A modal JavaScript dialog is blocking this tab (message: ${dialogs[0].message}). Use safari_dialog to respond or dismiss, then wait again.`);
|
|
294
|
-
const data = await this.
|
|
320
|
+
const data = await this.runScript(expression, signal);
|
|
295
321
|
if (!data || typeof data.ready !== 'boolean' || typeof data.url !== 'string' || typeof data.title !== 'string') throw Error('Invalid Safari load result.');
|
|
296
322
|
lastUrl = data.url;
|
|
297
323
|
if (data.ready) {
|
|
@@ -380,7 +406,7 @@ export class Safari {
|
|
|
380
406
|
this.authorize();
|
|
381
407
|
await this.atURL(t, expectedUrl, s);
|
|
382
408
|
this.authorize();
|
|
383
|
-
const data = await this.
|
|
409
|
+
const data = await this.runScript(expression, s);
|
|
384
410
|
if (!data || typeof data !== 'object' || data.attached !== true || typeof data.submitted !== 'boolean' || typeof data.files !== 'number') throw Error('Invalid Safari upload result.');
|
|
385
411
|
if (data.submitted && (typeof data.action !== 'string' || typeof data.status !== 'number' || typeof data.contentType !== 'string' || typeof data.body !== 'string')) throw Error('Invalid Safari upload result.');
|
|
386
412
|
const info = await this.call('page_info', {}, s); this.target(t);
|
|
@@ -389,6 +415,32 @@ export class Safari {
|
|
|
389
415
|
note: data.submitted ? 'Form posted once as multipart to its action URL via fetch; page did not navigate. Never blindly replay.' : 'Files attached; no enclosing form found, nothing submitted.' };
|
|
390
416
|
}, signal);
|
|
391
417
|
}
|
|
418
|
+
// One caller-supplied expression, gated by the standalone evaluation tier
|
|
419
|
+
// (checked again immediately before dispatch so a retired grant cannot
|
|
420
|
+
// revive queued work). The expression has full page-level power: it may
|
|
421
|
+
// navigate, submit, close or freeze the tab, so the result reports the
|
|
422
|
+
// post-evaluation URL instead of failing on URL change.
|
|
423
|
+
evaluate(t: Target, expectedUrl: string, expression: string, signal?: AbortSignal) {
|
|
424
|
+
webURL(expectedUrl);
|
|
425
|
+
if (typeof expression !== 'string' || expression.length < 1 || expression.length > 8000) throw Error('expression must be 1..8000 characters.');
|
|
426
|
+
return this.operation(async s => {
|
|
427
|
+
this.authorizeEvaluate();
|
|
428
|
+
await this.atURL(t, expectedUrl, s);
|
|
429
|
+
const epoch = this.transport.epoch;
|
|
430
|
+
const reply = await this.transport.call('evaluate_javascript', { expression: `return (${expression});` }, s, () => {
|
|
431
|
+
this.authorizeEvaluate();
|
|
432
|
+
if (epoch !== this.transport.epoch) throw Error('Safari connection changed before evaluation; relist targets. Not replayed.');
|
|
433
|
+
});
|
|
434
|
+
const after = await this.call('page_info', {}, s);
|
|
435
|
+
if (typeof after?.url !== 'string') throw Error('Invalid Safari page info.');
|
|
436
|
+
if (!(await this.list(s)).some(tab => tab.handle === t.targetId)) throw Error('Evaluation closed or lost the tab; relist tabs before retrying.');
|
|
437
|
+
this.target(t);
|
|
438
|
+
const text = reply.content.map(c => c.text ?? '').join('\n');
|
|
439
|
+
const data = { ...t, url: after.url.slice(0, 2048), value: text.slice(0, 12000), truncated: text.length > 12000,
|
|
440
|
+
note: 'Expression executed once. value is the backend string rendering, untrusted data. If url differs from expectedUrl the expression navigated or the page redirected; inspect before any retry.' };
|
|
441
|
+
bound(data); return data;
|
|
442
|
+
}, signal);
|
|
443
|
+
}
|
|
392
444
|
read(t: Target, chars = 8000, links = 20, signal?: AbortSignal) {
|
|
393
445
|
readJS(chars, links); // Validate before any transport work; throws synchronously.
|
|
394
446
|
return this.operation(async s => {
|
|
@@ -398,12 +450,18 @@ export class Safari {
|
|
|
398
450
|
}, signal);
|
|
399
451
|
}
|
|
400
452
|
search(options: SearchOptions, signal?: AbortSignal) {
|
|
401
|
-
const request = searchRequest(options);
|
|
402
|
-
|
|
403
|
-
|
|
453
|
+
const request: SearchRequest = searchRequest(options);
|
|
454
|
+
return this.operation(s => this.withTab(request.url, 'Search', s, true, async tab => {
|
|
455
|
+
// The settled URL is only known once the tab exists, so the exact-URL assertion and
|
|
456
|
+
// the same-site clickthrough host are bound to the final URL after any redirect.
|
|
457
|
+
const finalUrl = tab.url;
|
|
458
|
+
const expression = request.mode === 'ddg' ? searchJS(finalUrl) : genericSearchJS(finalUrl, request.selectors);
|
|
404
459
|
const { data, url } = await this.observe(tab, expression, s);
|
|
405
|
-
if (url !==
|
|
406
|
-
const
|
|
460
|
+
if (url !== finalUrl) throw Error('Search URL changed.');
|
|
461
|
+
const validated = request.mode === 'ddg'
|
|
462
|
+
? { ...validateSearch(data, finalUrl), note: 'Untrusted provider ranking/content; single-page provider (DuckDuckGo blocks offset pages server-side). Empty results may indicate a challenge or changed markup. URL checks do not guarantee document identity.' }
|
|
463
|
+
: { ...validateGenericSearch(data, finalUrl), note: `${request.selectors ? 'Selector-driven' : 'Heuristic'} extraction on an arbitrary search-results page; entries may include navigation noise or be missing. Call safari_search again with a url from pager to move to the next page. Untrusted content; URL checks do not guarantee document identity.` };
|
|
464
|
+
const result = { mode: request.mode, ...(request.query !== undefined ? { query: request.query, region: request.region, time: request.time } : {}), ...(request.selectors !== undefined ? { selectors: request.selectors } : {}), url: finalUrl, ...(tab.requestedUrl !== undefined ? { requestedUrl: tab.requestedUrl, redirects: tab.redirects } : {}), ...validated };
|
|
407
465
|
bound(result);
|
|
408
466
|
// Search results never advertise a usable target, even if cleanup is unconfirmed.
|
|
409
467
|
return result;
|
|
@@ -417,10 +475,12 @@ export class Safari {
|
|
|
417
475
|
readJS(maxChars, maxLinks); // Validate before any transport work.
|
|
418
476
|
const settle = options.selector !== undefined || options.text !== undefined;
|
|
419
477
|
if (settle) queryJS({ selector: options.selector, text: options.text });
|
|
420
|
-
return this.operation(s => this.withTab(url, 'Fetch', s, async tab => {
|
|
421
|
-
|
|
478
|
+
return this.operation(s => this.withTab(url, 'Fetch', s, true, async tab => {
|
|
479
|
+
const finalUrl = tab.url;
|
|
480
|
+
if (settle) await this.waitData(tab, finalUrl, waitJS({ selector: options.selector, text: options.text }, finalUrl), timeoutMs, s);
|
|
422
481
|
const data = await this.readData(tab, maxChars, maxLinks, s);
|
|
423
|
-
|
|
482
|
+
if (data.url !== finalUrl) throw Error('Page URL changed after settle; fetch failed.');
|
|
483
|
+
const result = { url: data.url, ...(tab.requestedUrl !== undefined ? { requestedUrl: tab.requestedUrl, redirects: tab.redirects } : {}), title: data.title, text: data.text, textTruncated: data.textTruncated, links: data.links, linksTruncated: data.linksTruncated,
|
|
424
484
|
note: 'New automation tab closed after extraction; no reusable target. Use safari_open when later interaction is needed. Untrusted page content; URL checks only, not document identity.' };
|
|
425
485
|
bound(result);
|
|
426
486
|
// Fetch results never advertise a usable target, even if cleanup is unconfirmed.
|
package/src/search.ts
CHANGED
|
@@ -3,20 +3,57 @@ import { webURL } from './extraction.ts';
|
|
|
3
3
|
// DuckDuckGo HTML's documented URL controls; deliberately not arbitrary provider parameters.
|
|
4
4
|
export const REGIONS = ['wt-wt', 'us-en', 'uk-en', 'ca-en', 'au-en', 'de-de', 'fr-fr', 'in-en', 'jp-jp'] as const;
|
|
5
5
|
export const TIMES = ['any', 'd', 'w', 'm', 'y'] as const;
|
|
6
|
-
export
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
url
|
|
14
|
-
|
|
15
|
-
|
|
6
|
+
export const PAGE_LIMIT = 30;
|
|
7
|
+
const PAGER_LIMIT = 12;
|
|
8
|
+
// Caller-supplied CSS selectors that steer the generic extractor. CSS only: no JS, no attributes beyond :(), no forms/clicks.
|
|
9
|
+
export interface SearchSelectors { item: string; link?: string; title?: string; snippet?: string }
|
|
10
|
+
export interface SearchOptions { query?: string; url?: string; region?: string; time?: string; selectors?: SearchSelectors }
|
|
11
|
+
export interface SearchRequest {
|
|
12
|
+
mode: 'ddg' | 'generic';
|
|
13
|
+
url: string;
|
|
14
|
+
query?: string;
|
|
15
|
+
region?: string;
|
|
16
|
+
time?: string;
|
|
17
|
+
selectors?: SearchSelectors;
|
|
18
|
+
}
|
|
19
|
+
export function validateSelectors(selectors: unknown): SearchSelectors {
|
|
20
|
+
if (typeof selectors !== 'object' || selectors === null || Array.isArray(selectors)) throw Error('selectors must be an object with an item CSS selector.');
|
|
21
|
+
const s = selectors as Record<string, unknown>;
|
|
22
|
+
if (typeof s.item !== 'string' || s.item.trim().length < 1) throw Error('selectors.item is required when selectors are supplied.');
|
|
23
|
+
const out: SearchSelectors = { item: s.item.trim() };
|
|
24
|
+
for (const key of ['link', 'title', 'snippet'] as const) {
|
|
25
|
+
if (s[key] === undefined) continue;
|
|
26
|
+
if (typeof s[key] !== 'string') throw Error(`selectors.${key} must be a CSS selector string.`);
|
|
27
|
+
out[key] = (s[key] as string).trim();
|
|
28
|
+
}
|
|
29
|
+
for (const [key, value] of Object.entries(out)) {
|
|
30
|
+
if (value.length < 1 || value.length > 500) throw Error(`selectors.${key} must be 1..500 characters.`);
|
|
31
|
+
if (/[^a-zA-Z0-9 _~.,:#\[\]>'"*\\()=^$|&%!\-?+]/.test(value)) throw Error(`selectors.${key} contains disallowed characters.`);
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
export function searchRequest(options: SearchOptions): SearchRequest {
|
|
36
|
+
const { query, url, region = 'wt-wt', time = 'any' } = options;
|
|
37
|
+
const hasQuery = typeof query === 'string';
|
|
38
|
+
const hasURL = typeof url === 'string';
|
|
39
|
+
if (hasQuery === hasURL) throw Error('Pass exactly one of query (built-in DuckDuckGo HTML search) or url (any HTTP(S) search-results page).');
|
|
40
|
+
if (hasQuery) {
|
|
41
|
+
const q = query as string;
|
|
42
|
+
if (!q.trim() || q.length > 500 || /[\u0000-\u001f\u007f]/.test(q)) throw Error('Search query must be 1..500 characters without control characters.');
|
|
43
|
+
if (!(REGIONS as readonly string[]).includes(region) || !(TIMES as readonly string[]).includes(time)) throw Error('Unsupported search region or time filter.');
|
|
44
|
+
const u = new URL('https://html.duckduckgo.com/html/');
|
|
45
|
+
u.searchParams.set('q', q.trim()); u.searchParams.set('kl', region);
|
|
46
|
+
if (time !== 'any') u.searchParams.set('df', time);
|
|
47
|
+
if (options.selectors !== undefined) throw Error('selectors are only for url mode; the built-in DuckDuckGo search uses fixed markup.');
|
|
48
|
+
return { mode: 'ddg', query: q.trim(), region, time, url: u.href };
|
|
49
|
+
}
|
|
50
|
+
const generic: SearchRequest = { mode: 'generic', url: webURL(url as string) };
|
|
51
|
+
if (options.selectors !== undefined) generic.selectors = validateSelectors(options.selectors);
|
|
52
|
+
return generic;
|
|
16
53
|
}
|
|
17
54
|
export interface SearchResult { rank: number; title: string; url: string; snippet: string }
|
|
18
|
-
export function validateSearch(data: any, expectedUrl: string
|
|
19
|
-
if (!data || data.url !== expectedUrl || !Array.isArray(data.results) || data.results.length >
|
|
55
|
+
export function validateSearch(data: any, expectedUrl: string): { results: SearchResult[]; truncated: boolean } {
|
|
56
|
+
if (!data || data.url !== expectedUrl || !Array.isArray(data.results) || data.results.length > PAGE_LIMIT || typeof data.truncated !== 'boolean') throw Error('Invalid Safari search result.');
|
|
20
57
|
const seen = new Set<string>();
|
|
21
58
|
for (const [i, r] of data.results.entries()) {
|
|
22
59
|
if (!r || r.rank !== i + 1 || typeof r.title !== 'string' || !r.title.trim() || r.title.length > 300 || typeof r.snippet !== 'string' || r.snippet.length > 600 || typeof r.url !== 'string' || r.url.length > 2048 || webURL(r.url) !== r.url || seen.has(r.url)) throw Error('Invalid Safari search entry.');
|
|
@@ -24,10 +61,23 @@ export function validateSearch(data: any, expectedUrl: string, maxResults: numbe
|
|
|
24
61
|
}
|
|
25
62
|
return { results: data.results, truncated: data.truncated };
|
|
26
63
|
}
|
|
64
|
+
export function validateGenericSearch(data: any, expectedUrl: string): { results: SearchResult[]; pager: string[]; truncated: boolean } {
|
|
65
|
+
if (!data || data.url !== expectedUrl || !Array.isArray(data.results) || data.results.length > PAGE_LIMIT || !Array.isArray(data.pager) || data.pager.length > PAGER_LIMIT || typeof data.truncated !== 'boolean') throw Error('Invalid Safari search result.');
|
|
66
|
+
const seen = new Set<string>();
|
|
67
|
+
for (const [i, r] of data.results.entries()) {
|
|
68
|
+
if (!r || r.rank !== i + 1 || typeof r.title !== 'string' || !r.title.trim() || r.title.length > 300 || typeof r.snippet !== 'string' || r.snippet.length > 300 || typeof r.url !== 'string' || r.url.length > 2048 || webURL(r.url) !== r.url || seen.has(r.url)) throw Error('Invalid Safari search entry.');
|
|
69
|
+
seen.add(r.url);
|
|
70
|
+
}
|
|
71
|
+
const pages = new Set<string>();
|
|
72
|
+
for (const p of data.pager) {
|
|
73
|
+
if (typeof p !== 'string' || p.length > 8192 || webURL(p) !== p || pages.has(p)) throw Error('Invalid Safari search pager link.');
|
|
74
|
+
pages.add(p);
|
|
75
|
+
}
|
|
76
|
+
return { results: data.results, pager: data.pager, truncated: data.truncated };
|
|
77
|
+
}
|
|
27
78
|
// Fixed, read-only selectors. No raw query, arbitrary selector, click, form or provider script execution.
|
|
28
|
-
export function searchJS(expectedUrl: string
|
|
79
|
+
export function searchJS(expectedUrl: string): string {
|
|
29
80
|
webURL(expectedUrl);
|
|
30
|
-
if (!Number.isInteger(maxResults) || maxResults < 1 || maxResults > 10) throw Error('maxResults must be 1..10.');
|
|
31
81
|
return `JSON.stringify((()=>{
|
|
32
82
|
if(location.href!==${JSON.stringify(expectedUrl)})throw Error('Search URL changed');
|
|
33
83
|
const visible=e=>e&&e.getClientRects().length&&getComputedStyle(e).visibility==='visible'&&getComputedStyle(e).display!=='none';
|
|
@@ -42,10 +92,134 @@ export function searchJS(expectedUrl: string, maxResults: number): string {
|
|
|
42
92
|
}catch{continue;}
|
|
43
93
|
if(!/^https?:$/.test(u.protocol)||u.username||u.password||u.href.length>2048||['duckduckgo.com','html.duckduckgo.com'].includes(u.hostname)||seen.has(u.href))continue;
|
|
44
94
|
const title=text(a,300); if(!title)continue; seen.add(u.href);
|
|
45
|
-
if(results.length===${
|
|
95
|
+
if(results.length===${PAGE_LIMIT}){truncated=true;break;}
|
|
46
96
|
const snippet=node.querySelector('.result__snippet');
|
|
47
97
|
results.push({rank:results.length+1,title,url:u.href,snippet:visible(snippet)?text(snippet,600):''});
|
|
48
98
|
}
|
|
49
99
|
return {url:location.href,results,truncated};
|
|
50
100
|
})())`;
|
|
51
101
|
}
|
|
102
|
+
// Shared read-only guards: exact-URL assertion, visibility and text helpers, and same-site
|
|
103
|
+
// clickthrough unwrapping (Bing a1 base64, DuckDuckGo uddg, Yahoo RU=). Same-site: same host,
|
|
104
|
+
// or same last two dot labels.
|
|
105
|
+
function searchHelpersJS(serpHost: string): string {
|
|
106
|
+
return `const visible=e=>e&&e.getClientRects().length&&getComputedStyle(e).visibility==='visible'&&getComputedStyle(e).display!=='none';
|
|
107
|
+
const text=e=>(e?.innerText||'').replace(/\\s+/g,' ');
|
|
108
|
+
const siteOf=h=>h.split('.').slice(-2).join('.');
|
|
109
|
+
const unwrap=a=>{
|
|
110
|
+
let u; try { u=new URL(a.href,location.href); } catch { return null; }
|
|
111
|
+
if(/^https?:$/.test(u.protocol)&&siteOf(u.hostname.toLowerCase())===${JSON.stringify(serpHost)}){
|
|
112
|
+
let d=null;
|
|
113
|
+
const enc=u.searchParams.get('u')||'';
|
|
114
|
+
if(enc.startsWith('a1')){
|
|
115
|
+
try {
|
|
116
|
+
let b=enc.slice(2).replace(/-/g,'+').replace(/_/g,'/'); while(b.length%4)b+='=';
|
|
117
|
+
const c=new URL(atob(b));
|
|
118
|
+
if(/^https?:$/.test(c.protocol)&&!c.username&&!c.password&&c.href.length<=2048)d=c;
|
|
119
|
+
} catch {}
|
|
120
|
+
}
|
|
121
|
+
if(!d){
|
|
122
|
+
const ud=u.searchParams.get('uddg');
|
|
123
|
+
if(ud){
|
|
124
|
+
try {
|
|
125
|
+
const c=new URL(decodeURIComponent(ud),location.href);
|
|
126
|
+
if(/^https?:$/.test(c.protocol)&&!c.username&&!c.password&&c.href.length<=2048)d=c;
|
|
127
|
+
} catch {}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if(!d){
|
|
131
|
+
for(const seg of u.pathname.split('/')){
|
|
132
|
+
if(!seg.startsWith('RU='))continue;
|
|
133
|
+
try {
|
|
134
|
+
const c=new URL(decodeURIComponent(seg.slice(3)));
|
|
135
|
+
if(/^https?:$/.test(c.protocol)&&!c.username&&!c.password&&c.href.length<=2048)d=c;
|
|
136
|
+
} catch {}
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if(d)u=d;
|
|
141
|
+
}
|
|
142
|
+
if(!/^https?:$/.test(u.protocol)||u.username||u.password||u.href.length>2048)return null;
|
|
143
|
+
return u;
|
|
144
|
+
};`;
|
|
145
|
+
}
|
|
146
|
+
// Pager detection shared by both generic paths: exact next/numbered labels, 12 unique URLs.
|
|
147
|
+
function pagerJS(): string {
|
|
148
|
+
return `const pager=[]; const pages=new Set();
|
|
149
|
+
for(const a of anchors){
|
|
150
|
+
if(!visible(a))continue;
|
|
151
|
+
const t=text(a).trim();
|
|
152
|
+
if(!pagerRe.test(t))continue;
|
|
153
|
+
let u; try { u=new URL(a.href,location.href); } catch { continue; }
|
|
154
|
+
if(!/^https?:$/.test(u.protocol)||u.username||u.password||pages.has(u.href))continue;
|
|
155
|
+
pages.add(u.href);
|
|
156
|
+
if(pager.length===${PAGER_LIMIT})break;
|
|
157
|
+
pager.push(u.href);
|
|
158
|
+
}`;
|
|
159
|
+
}
|
|
160
|
+
// Fixed heuristic extractor for arbitrary search-results pages chosen by the caller as a URL.
|
|
161
|
+
// Same safety style as searchJS: read-only, exact-URL assertion, safe helpers only.
|
|
162
|
+
// Without selectors: two-tier anchor heuristic (heading-anchored results first, then all
|
|
163
|
+
// anchors) for minimal markup. With caller-supplied selectors (generic mode only): item
|
|
164
|
+
// selects each result container; link/title/snippet select sub-parts and are optional,
|
|
165
|
+
// falling back to the anchor, an inner heading and the box tail. Both paths share the
|
|
166
|
+
// same URL guards, dedupe, 30-result cap and pager detection.
|
|
167
|
+
export function genericSearchJS(expectedUrl: string, selectors?: SearchSelectors): string {
|
|
168
|
+
webURL(expectedUrl);
|
|
169
|
+
const selectorJS = selectors ? `
|
|
170
|
+
const items=Array.from(document.querySelectorAll(${JSON.stringify(selectors.item)})).slice(0,300);
|
|
171
|
+
const results=[]; const seen=new Set(); let truncated=items.length>300;
|
|
172
|
+
for(const node of items){
|
|
173
|
+
if(!visible(node))continue;
|
|
174
|
+
const a=node.querySelector(${JSON.stringify(selectors.link ?? 'a[href]')});
|
|
175
|
+
if(!a||!a.href||!visible(a))continue;
|
|
176
|
+
const u=unwrap(a); if(!u||seen.has(u.href))continue; seen.add(u.href);
|
|
177
|
+
if(results.length===${PAGE_LIMIT}){truncated=true;break;}
|
|
178
|
+
let title='';
|
|
179
|
+
${selectors.title ? `const tEl=node.querySelector(${JSON.stringify(selectors.title)}); if(tEl)title=text(tEl).trim().slice(0,300);` : ''}
|
|
180
|
+
if(!title){ const heading=a.querySelector('h1,h2,h3,h4'); title=((heading&&text(heading).trim().length>=8)?text(heading):text(a)).trim().slice(0,300); }
|
|
181
|
+
if(!title)continue;
|
|
182
|
+
let snippet='';
|
|
183
|
+
${selectors.snippet ? `const sEl=node.querySelector(${JSON.stringify(selectors.snippet)}); if(sEl)snippet=text(sEl).trim().slice(0,300);` : ''}
|
|
184
|
+
if(!snippet){ const box=a.closest('article,li'); const raw=box?text(box):''; const at=raw.indexOf(title); if(at>=0)snippet=raw.slice(at+title.length).trim().slice(0,300); }
|
|
185
|
+
results.push({rank:results.length+1,title,url:u.href,snippet});
|
|
186
|
+
}
|
|
187
|
+
const anchors=Array.from(document.querySelectorAll('a[href]')).slice(0,2000);
|
|
188
|
+
` : `
|
|
189
|
+
const anchors=Array.from(document.querySelectorAll('a[href]')).slice(0,2000);
|
|
190
|
+
// Tier 1: anchors whose organic title is an h1..h4 (wrapping or wrapped). Tier 2 fallback:
|
|
191
|
+
// generic container heuristic for minimal markup (e.g. DuckDuckGo Lite).
|
|
192
|
+
const headingOf=a=>a.querySelector('h1,h2,h3,h4')||a.closest('h1,h2,h3,h4');
|
|
193
|
+
const tier1=anchors.filter(a=>visible(a)&&!pagerRe.test(text(a).trim())&&headingOf(a));
|
|
194
|
+
const pool=tier1.length?tier1:anchors;
|
|
195
|
+
const results=[]; const seen=new Set(); let truncated=anchors.length>2000;
|
|
196
|
+
for(const a of pool){
|
|
197
|
+
if(!visible(a))continue;
|
|
198
|
+
const label0=text(a).trim();
|
|
199
|
+
if(pagerRe.test(label0))continue;
|
|
200
|
+
const heading=headingOf(a);
|
|
201
|
+
const label=((heading&&text(heading).trim().length>=8)?text(heading).trim():label0);
|
|
202
|
+
if(label.length<8||label.length>300)continue;
|
|
203
|
+
if(/https?:\\/\\//i.test(label)||/^[\\w.-]+\\.[a-z]{2,}(:\\d+)?(\\/\\S*)?$/i.test(label))continue;
|
|
204
|
+
if(!a.closest('article,li,main,section,td')||a.closest('nav,footer,aside,form'))continue;
|
|
205
|
+
const u=unwrap(a); if(!u||seen.has(u.href))continue;
|
|
206
|
+
seen.add(u.href);
|
|
207
|
+
if(results.length===${PAGE_LIMIT}){truncated=true;break;}
|
|
208
|
+
const box=a.closest('article,li');
|
|
209
|
+
let snippet='';
|
|
210
|
+
if(box){
|
|
211
|
+
const raw=text(box); const at=raw.indexOf(label);
|
|
212
|
+
if(at>=0)snippet=raw.slice(at+label.length).trim().slice(0,300);
|
|
213
|
+
}
|
|
214
|
+
results.push({rank:results.length+1,title:label,url:u.href,snippet});
|
|
215
|
+
}
|
|
216
|
+
`;
|
|
217
|
+
return `JSON.stringify((()=>{
|
|
218
|
+
if(location.href!==${JSON.stringify(expectedUrl)})throw Error('Search URL changed');
|
|
219
|
+
${searchHelpersJS(new URL(expectedUrl).hostname.toLowerCase())}
|
|
220
|
+
const pagerRe=/^(next|next page|\\u203a|\\u00bb|\\u2026|\\d{1,3})$/i;
|
|
221
|
+
${selectorJS}
|
|
222
|
+
${pagerJS()}
|
|
223
|
+
return {url:location.href,results,pager,truncated};
|
|
224
|
+
})())`;
|
|
225
|
+
}
|