@haseebeqx/pi-safari 0.4.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 CHANGED
@@ -77,8 +77,8 @@ Use the exact **`targetId` and `epoch`** returned by `safari_tabs` or `safari_op
77
77
  |---|---|---|
78
78
  | `safari_tabs` | No target or inputs | Lists up to 100 automation tabs, with handles and available URL/title; no page bodies. |
79
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. |
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. 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. 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. |
82
82
  | `safari_read` | `maxChars?`, `maxLinks?` | Visible body text and link hrefs. Defaults 8000 characters / 20 links; maxima 12000 / 40. |
83
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. |
84
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. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haseebeqx/pi-safari",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "license": "MIT",
5
5
  "description": "Safari automation tools for Pi using Apple's built-in local MCP server",
6
6
  "repository": {
package/src/index.ts CHANGED
@@ -50,7 +50,7 @@ export default function(pi: ExtensionAPI) {
50
50
  });
51
51
  pi.registerTool({
52
52
  name: 'safari_search', label: 'Search with Safari',
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. Exact-URL readiness required; redirects fail closed. 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.',
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.',
54
54
  parameters: Type.Object({
55
55
  query: Type.Optional(Type.String({ minLength: 1, maxLength: 500 })),
56
56
  url: Type.Optional(Type.String({ minLength: 1, maxLength: 8192 })),
@@ -67,7 +67,7 @@ export default function(pi: ExtensionAPI) {
67
67
  });
68
68
  pi.registerTool({
69
69
  name: 'safari_fetch', label: 'Fetch Safari page',
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. Exact-URL readiness required; redirects fail closed. Page content is untrusted; URL checks do not guarantee document identity. Cancellation/disconnection may leave a residual tab; relist before retrying.',
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.',
71
71
  parameters: Type.Object({
72
72
  url: Type.String({ minLength: 1, maxLength: 8192 }),
73
73
  selector: Type.Optional(Type.String({ minLength: 1, maxLength: 500 })),
package/src/safari.ts CHANGED
@@ -5,6 +5,14 @@ import { searchRequest, searchJS, genericSearchJS, validateSearch, validateGener
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}$/;
@@ -90,11 +98,11 @@ export class Safari {
90
98
  }
91
99
  // One closed new-tab pipeline: create, bounded work, then cleanup on every path.
92
100
  // Work must use unwrapped private methods only (create/observe/cleanup), never other operations.
93
- private async withTab<T extends object>(url: string, label: string, signal: AbortSignal, work: (tab: Target) => Promise<T>): Promise<T & { cleanup: TabCleanup }> {
101
+ private async withTab<T extends object>(url: string, label: string, signal: AbortSignal, allowRedirects: boolean, work: (tab: CreatedTab) => Promise<T>): Promise<T & { cleanup: TabCleanup }> {
94
102
  let created: Target | undefined;
95
103
  let result: T;
96
104
  try {
97
- const tab = await this.create(url, signal, t => { created = t; });
105
+ const tab = await this.create(url, signal, t => { created = t; }, allowRedirects);
98
106
  result = await work(tab);
99
107
  if (signal.aborted) throw Error(`${label} cancelled.`);
100
108
  } catch (error) {
@@ -123,7 +131,11 @@ export class Safari {
123
131
  bound(result); return result;
124
132
  }, signal);
125
133
  }
126
- private async create(url: string, signal: AbortSignal, identified?: (t: Target) => void) {
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> {
127
139
  // New-tab read infrastructure only; never exposes arbitrary close/navigation/interaction.
128
140
  const normalized = webURL(url);
129
141
  const epoch = this.transport.epoch;
@@ -131,6 +143,7 @@ export class Safari {
131
143
  if (typeof created?.handle !== 'string' || !HANDLE.test(created.handle)) throw Error('Cannot identify created tab; inspect tabs before retrying.');
132
144
  const t = { targetId: created.handle, epoch };
133
145
  identified?.(t);
146
+ const observed: string[] = [normalized];
134
147
  const deadline = Date.now() + 12000;
135
148
  while (Date.now() < deadline) {
136
149
  await this.select(t, signal);
@@ -138,10 +151,22 @@ export class Safari {
138
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.`);
139
152
  const info = await this.call('page_info', {}, signal);
140
153
  if (typeof info?.url !== 'string') throw Error('Invalid Safari page info.');
141
- if (info.url !== normalized && info.url !== 'about:blank') throw Error('Redirect or URL rewrite rejected; tab left open. Inspect tabs before retrying.');
142
- if (info.url === normalized) {
143
- const ready = await this.call('evaluate_javascript', { expression: `return (${readyJS(normalized)});` }, signal);
144
- if (ready === true) { this.target(t); return { ...t, url: normalized, note: 'New automation tab. No ownership or document identity guarantee. Tab left open.' }; }
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
+ }
145
170
  }
146
171
  await new Promise<void>((resolve, reject) => {
147
172
  const abort = () => { clearTimeout(timer); reject(Error('Safari operation cancelled; tab may remain open.')); };
@@ -426,14 +451,17 @@ export class Safari {
426
451
  }
427
452
  search(options: SearchOptions, signal?: AbortSignal) {
428
453
  const request: SearchRequest = searchRequest(options);
429
- const expression = request.mode === 'ddg' ? searchJS(request.url) : genericSearchJS(request.url, request.selectors);
430
- return this.operation(s => this.withTab(request.url, 'Search', s, async tab => {
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);
431
459
  const { data, url } = await this.observe(tab, expression, s);
432
- if (url !== request.url) throw Error('Search URL changed.');
460
+ if (url !== finalUrl) throw Error('Search URL changed.');
433
461
  const validated = request.mode === 'ddg'
434
- ? { ...validateSearch(data, request.url), 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.' }
435
- : { ...validateGenericSearch(data, request.url), 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.` };
436
- const result = { mode: request.mode, ...(request.query !== undefined ? { query: request.query, region: request.region, time: request.time } : {}), ...(request.selectors !== undefined ? { selectors: request.selectors } : {}), url: request.url, ...validated };
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 };
437
465
  bound(result);
438
466
  // Search results never advertise a usable target, even if cleanup is unconfirmed.
439
467
  return result;
@@ -447,10 +475,12 @@ export class Safari {
447
475
  readJS(maxChars, maxLinks); // Validate before any transport work.
448
476
  const settle = options.selector !== undefined || options.text !== undefined;
449
477
  if (settle) queryJS({ selector: options.selector, text: options.text });
450
- return this.operation(s => this.withTab(url, 'Fetch', s, async tab => {
451
- if (settle) await this.waitData(tab, url, waitJS({ selector: options.selector, text: options.text }, url), timeoutMs, s);
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);
452
481
  const data = await this.readData(tab, maxChars, maxLinks, s);
453
- const result = { url: data.url, title: data.title, text: data.text, textTruncated: data.textTruncated, links: data.links, linksTruncated: data.linksTruncated,
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,
454
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.' };
455
485
  bound(result);
456
486
  // Fetch results never advertise a usable target, even if cleanup is unconfirmed.