@zeldrisho/pi-web-fetch 0.15.0 → 0.16.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.
@@ -0,0 +1,65 @@
1
+ import { createHash, scryptSync } from "node:crypto";
2
+
3
+ /** Bounded proof-of-work request sent to the AWS WAF worker. */
4
+ export interface AwsWafWork {
5
+ type: string;
6
+ input: string;
7
+ checksum: string;
8
+ difficulty: number;
9
+ memory: number;
10
+ }
11
+
12
+ /** Returns whether the first `difficulty` bits of a hash are zero. */
13
+ export function hasLeadingZeroBits(hash: Uint8Array, difficulty: number): boolean {
14
+ for (let bit = 0; bit < difficulty; bit += 1) {
15
+ const byte = hash[Math.floor(bit / 8)];
16
+
17
+ if (byte === undefined || (byte & (1 << (7 - (bit % 8)))) !== 0) return false;
18
+ }
19
+
20
+ return true;
21
+ }
22
+
23
+ /** Solves the bandwidth challenge using a bounded zero-filled buffer. */
24
+ export function solveAwsWafBandwidth(difficulty: number): string {
25
+ const sizes = [1_024, 1_024, 10_240, 102_400, 1_048_576, 10_485_760];
26
+
27
+ return Buffer.alloc(sizes[Math.min(5, Math.max(0, difficulty))] ?? 1_024).toString("base64");
28
+ }
29
+
30
+ /** Computes bounded proof-of-work inside a disposable worker. */
31
+ export function solveAwsWafWork(work: AwsWafWork): string | undefined {
32
+ if (!Number.isInteger(work.difficulty) || work.difficulty < 1 || work.difficulty > 32)
33
+ return undefined;
34
+
35
+ if (work.type === "NetworkBandwidth") return solveAwsWafBandwidth(work.difficulty);
36
+
37
+ if (work.type === "SHA256") {
38
+ for (let nonce = 0; nonce < 2_000_000; nonce += 1) {
39
+ const digest = createHash("sha256").update(`${work.input}${work.checksum}${nonce}`).digest();
40
+
41
+ if (hasLeadingZeroBits(digest, work.difficulty)) return String(nonce);
42
+ }
43
+ }
44
+
45
+ if (work.type === "HashcashScrypt") {
46
+ const cost = work.memory > 0 ? work.memory : 128;
47
+
48
+ // Reject large or invalid costs before invoking OpenSSL.
49
+ if (!Number.isInteger(cost) || cost < 2 || cost > 16_384 || (cost & (cost - 1)) !== 0)
50
+ return undefined;
51
+
52
+ for (let nonce = 0; nonce < 100_000; nonce += 1) {
53
+ const digest = scryptSync(`${work.input}${work.checksum}${nonce}`, work.checksum, 32, {
54
+ N: cost,
55
+ r: 8,
56
+ p: 1,
57
+ maxmem: 64 * 1_024 * 1_024,
58
+ });
59
+
60
+ if (hasLeadingZeroBits(digest, work.difficulty)) return String(nonce);
61
+ }
62
+ }
63
+
64
+ return undefined;
65
+ }
@@ -0,0 +1,14 @@
1
+ /* v8 ignore file -- exercised through worker integration tests. */
2
+ import { parentPort, workerData } from "node:worker_threads";
3
+ import { solveAwsWafWork, type AwsWafWork } from "./aws-waf-solver.ts";
4
+
5
+ const work: AwsWafWork = workerData;
6
+
7
+ try {
8
+ parentPort?.postMessage({ kind: "solved", solution: solveAwsWafWork(work) });
9
+ } catch (error) {
10
+ parentPort?.postMessage({
11
+ kind: "error",
12
+ message: error instanceof Error ? error.message : "AWS WAF worker failed.",
13
+ });
14
+ }
@@ -0,0 +1,34 @@
1
+ import type { IncomingMessage } from "node:http";
2
+ import type { RedirectDependencies } from "../network/network-redirects.ts";
3
+
4
+ /** Cookie that carries an AWS WAF challenge token. */
5
+ export const AWS_WAF_TOKEN_COOKIE = "aws-waf-token";
6
+
7
+ /** A resolved AWS WAF token and the browser identity it was issued for. */
8
+ export interface AwsWafSolution {
9
+ token: string;
10
+ userAgent?: string;
11
+ }
12
+
13
+ /** A request to resolve one AWS WAF challenge. */
14
+ export interface AwsWafResolveContext {
15
+ /** The page URL withheld by the challenge. */
16
+ url: URL;
17
+ /** Cancels the solver request. */
18
+ signal: AbortSignal;
19
+ /** Network dependencies used by the resolver. */
20
+ network?: RedirectDependencies;
21
+ }
22
+
23
+ /** Resolves an AWS WAF challenge to a token accepted by the target origin. */
24
+ export interface AwsWafResolver {
25
+ resolve(context: AwsWafResolveContext): Promise<AwsWafSolution | undefined>;
26
+ }
27
+
28
+ /** Returns whether a response carries an AWS WAF challenge. */
29
+ export function isAwsWafChallengeResponse(response: Pick<IncomingMessage, "headers">): boolean {
30
+ const value = response.headers["x-amzn-waf-action"];
31
+ const action = Array.isArray(value) ? value[0] : value;
32
+
33
+ return action?.trim().toLowerCase() === "challenge";
34
+ }
package/src/index.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { getMarkdownTheme, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import { Markdown, Text } from "@earendil-works/pi-tui";
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { Text } from "@earendil-works/pi-tui";
3
3
  import { Type } from "typebox";
4
4
  import {
5
5
  FETCH_MAX_OFFSET_CHARACTERS,
@@ -7,7 +7,7 @@ import {
7
7
  FETCH_MAX_URL_CHARACTERS,
8
8
  } from "./limits";
9
9
  import { redactUrlForDisplay } from "./redact";
10
- import { formatCollapsibleOutput } from "./render";
10
+ import { renderCollapsibleOutput } from "./render";
11
11
  import { executeWebFetch, webFetchOutputSchema, type WebFetchDetails } from "./service";
12
12
 
13
13
  export { ExpiringLruCache } from "./cache";
@@ -20,6 +20,13 @@ export {
20
20
  type AnubisSolveContext,
21
21
  } from "./challenges/anubis.ts";
22
22
 
23
+ export {
24
+ AWS_WAF_TOKEN_COOKIE,
25
+ isAwsWafChallengeResponse,
26
+ type AwsWafResolveContext,
27
+ type AwsWafResolver,
28
+ } from "./challenges/aws-waf.ts";
29
+
23
30
  export { CookieJar } from "./network/cookies.ts";
24
31
 
25
32
  export type { FetchResult } from "./document/content.ts";
@@ -101,7 +108,7 @@ export default function (pi: ExtensionAPI) {
101
108
  /** Renders the redacted URL, optional focus query, and character offset in the call preview. */
102
109
  renderCall(args, theme) {
103
110
  return new Text(
104
- `${theme.fg("toolTitle", theme.bold("web_fetch"))} ${theme.fg("accent", redactUrlForDisplay(args.url))}`,
111
+ `${theme.fg("toolTitle", theme.bold("web_fetch"))} ${theme.fg("accent", redactUrlForDisplay(args.url))}${args.query === undefined ? "" : theme.fg("muted", ` query=${JSON.stringify(args.query)}`)}`,
105
112
  0,
106
113
  0,
107
114
  );
@@ -115,15 +122,21 @@ export default function (pi: ExtensionAPI) {
115
122
  * Renders partial text or "Fetching…" while pending. Completed text gets a
116
123
  * detail summary when available and a collapsible body; absent text shows "No content".
117
124
  */
118
- renderResult(result, { expanded, isPartial }, theme) {
125
+ renderResult(result, { expanded, isPartial }, theme, context) {
119
126
  const content = result.content.find((item) => item.type === "text");
120
127
 
121
128
  if (isPartial) return new Text(theme.fg("warning", content?.text || "Fetching…"), 0, 0);
122
129
 
123
130
  return content?.type === "text"
124
- ? new Markdown(formatCollapsibleOutput(content.text, expanded), 0, 0, getMarkdownTheme(), {
125
- color: (text) => theme.fg("toolOutput", text),
126
- })
131
+ ? renderCollapsibleOutput(
132
+ content.text,
133
+ expanded,
134
+ theme,
135
+ context?.isError,
136
+ result.details?.truncated && result.details.nextOffset !== undefined
137
+ ? `[Content truncated. Continue with offset=${result.details.nextOffset} to read the next chunk.]`
138
+ : undefined,
139
+ )
127
140
  : new Text(theme.fg("dim", "No content"), 0, 0);
128
141
  },
129
142
  });
@@ -211,6 +211,47 @@ export class CookieJar {
211
211
  this.#evict();
212
212
  }
213
213
 
214
+ /**
215
+ * Stores a challenge token returned by a resolver.
216
+ *
217
+ * The token is untrusted input. It is accepted only for the target origin,
218
+ * scoped to the whole origin, and held in memory for the current acquisition.
219
+ *
220
+ * @param name - Cookie name
221
+ * @param value - Cookie value
222
+ * @param url - The page URL the cookie belongs to
223
+ * @returns Whether the cookie was accepted
224
+ */
225
+ set(name: string, value: string, url: URL): boolean {
226
+ const token = value.trim();
227
+
228
+ if (
229
+ name.length === 0 ||
230
+ name.length > MAX_COOKIE_NAME_CHARACTERS ||
231
+ !COOKIE_NAME.test(name) ||
232
+ token.length === 0 ||
233
+ token.length > MAX_COOKIE_VALUE_CHARACTERS ||
234
+ hasInvalidCookieCharacter(token) ||
235
+ /[\s";,\\]/u.test(token)
236
+ ) {
237
+ return false;
238
+ }
239
+
240
+ const key = `${url.origin}\0${name}\0/`;
241
+ this.#cookies.delete(key);
242
+ this.#cookies.set(key, {
243
+ name,
244
+ value: token,
245
+ origin: url.origin,
246
+ path: "/",
247
+ secure: url.protocol === "https:",
248
+ createdAt: Date.now(),
249
+ });
250
+ this.#evict();
251
+
252
+ return true;
253
+ }
254
+
214
255
  /**
215
256
  * Returns a Cookie header value for a URL, excluding expired, insecure, and
216
257
  * cross-origin cookies.
@@ -29,11 +29,16 @@ export interface RedirectDependencies {
29
29
  target: ValidatedTarget,
30
30
  signal: AbortSignal,
31
31
  headers?: Readonly<Record<string, string>>,
32
+ options?: { method?: string; body?: string | Uint8Array },
32
33
  ) => Promise<IncomingMessage>;
33
34
  random?: () => number;
34
35
  sleep?: (milliseconds: number, signal: AbortSignal) => Promise<void>;
35
36
  /** Origin-scoped cookies for the current acquisition, including challenge handshakes. */
36
37
  cookies?: CookieJar;
38
+ /** User-Agent override that persists across redirects. */
39
+ userAgent?: string;
40
+ /** Headers that survive a redirect, used by challenge handshakes that need browser identity. */
41
+ persistentHeaders?: Readonly<Record<string, string>>;
37
42
  }
38
43
 
39
44
  /** Creates the consistent abort error used by redirect and retry coordination. */
@@ -144,7 +149,14 @@ async function coordinatedRequest(
144
149
  const release = await acquireOrigin(target.url.origin, signal);
145
150
  let response: IncomingMessage;
146
151
  const cookieHeader = dependencies.cookies?.headerFor(target.url);
147
- const requestHeaders = cookieHeader ? { Cookie: cookieHeader, ...headers } : headers;
152
+
153
+ const headersWithUserAgent = dependencies.userAgent
154
+ ? { "User-Agent": dependencies.userAgent, ...headers }
155
+ : headers;
156
+
157
+ const requestHeaders = cookieHeader
158
+ ? { Cookie: cookieHeader, ...headersWithUserAgent }
159
+ : headersWithUserAgent;
148
160
 
149
161
  try {
150
162
  response = await request(target, signal, requestHeaders);
@@ -197,7 +209,8 @@ export async function requestFollowingRedirects(
197
209
  response.resume();
198
210
  target = await awaitWithAbort(validateUrl(new URL(location, target.url)), signal);
199
211
  // Validators describe the originally cached resource and must not leak to a redirect target.
200
- requestHeaders = {};
212
+ // Challenge handshakes opt into a fixed browser identity instead.
213
+ requestHeaders = dependencies.persistentHeaders ?? {};
201
214
  }
202
215
 
203
216
  throw new Error("web_fetch followed too many redirects.");
@@ -8,6 +8,9 @@ import type { ValidatedTarget } from "./network-policy.ts";
8
8
  /** Maximum size in bytes for raw HTTP response bodies. */
9
9
  export const FETCH_MAX_BYTES = 5 * 1_024 * 1_024;
10
10
 
11
+ /** Fixed User-Agent used for page requests and challenge solvers. */
12
+ export const WEB_FETCH_USER_AGENT = "Mozilla/5.0 (compatible; PiWebFetch/1.0; +https://pi.dev)";
13
+
11
14
  /**
12
15
  * Per-address connect deadline. A hanging address is abandoned after this
13
16
  * budget so the next validated address can be tried inside the overall
@@ -67,6 +70,8 @@ async function requestOnce(
67
70
  signal: AbortSignal,
68
71
  attemptTimeoutMs: number,
69
72
  headers: Readonly<Record<string, string>>,
73
+ method = "GET",
74
+ body?: string | Uint8Array,
70
75
  ): Promise<IncomingMessage> {
71
76
  const family = isIP(address);
72
77
 
@@ -114,11 +119,12 @@ async function requestOnce(
114
119
  target.url,
115
120
  {
116
121
  lookup,
122
+ method,
117
123
  signal: controller.signal,
118
124
  headers: {
119
125
  Accept:
120
126
  "text/markdown;q=1.0, text/x-markdown;q=0.9, text/plain;q=0.8, text/html;q=0.7, application/xhtml+xml;q=0.7, application/json;q=0.7, application/xml;q=0.7, */*;q=0.1",
121
- "User-Agent": "Mozilla/5.0 (compatible; PiWebFetch/1.0; +https://pi.dev)",
127
+ "User-Agent": WEB_FETCH_USER_AGENT,
122
128
  ...headers,
123
129
  },
124
130
  },
@@ -139,7 +145,7 @@ async function requestOnce(
139
145
  if (attemptExpired) finish(() => reject(unreachableError()));
140
146
  else finish(() => reject(error));
141
147
  });
142
- outgoing.end();
148
+ outgoing.end(body);
143
149
  });
144
150
  }
145
151
 
@@ -148,6 +154,10 @@ export interface RequestPinnedOptions {
148
154
  attemptTimeoutMs?: number;
149
155
  /** Additional fixed request headers, used for conditional cache revalidation. */
150
156
  headers?: Readonly<Record<string, string>>;
157
+ /** HTTP method; defaults to GET. */
158
+ method?: string;
159
+ /** Request body for non-GET requests. */
160
+ body?: string | Uint8Array;
151
161
  }
152
162
 
153
163
  /**
@@ -169,7 +179,15 @@ export async function requestPinned(
169
179
 
170
180
  for (const address of addresses) {
171
181
  try {
172
- return await requestOnce(target, address, signal, attemptTimeoutMs, options.headers ?? {});
182
+ return await requestOnce(
183
+ target,
184
+ address,
185
+ signal,
186
+ attemptTimeoutMs,
187
+ options.headers ?? {},
188
+ options.method,
189
+ options.body,
190
+ );
173
191
  } catch (error) {
174
192
  lastError = error;
175
193
 
package/src/render.ts CHANGED
@@ -1,21 +1,46 @@
1
- import { keyHint } from "@earendil-works/pi-coding-agent";
1
+ import { getMarkdownTheme, keyHint } from "@earendil-works/pi-coding-agent";
2
+ import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
2
3
 
3
4
  const COLLAPSED_LINES = 10;
4
5
 
5
- /** Format tool output using Pi's built-in collapsed-preview convention. */
6
- export function formatCollapsibleOutput(output: string, expanded: boolean): string {
6
+ /** Render document content separately from Pi-style interface notices. */
7
+ export function renderCollapsibleOutput(
8
+ output: string,
9
+ expanded: boolean,
10
+ theme: { fg(color: "toolOutput" | "muted" | "warning", text: string): string },
11
+ isError = false,
12
+ warning?: string,
13
+ ): Container {
7
14
  const lines = output.replace(/\r\n/g, "\n").split("\n");
8
15
 
9
16
  while (lines.at(-1) === "") lines.pop();
10
- const totalLines = lines.length;
11
- const visibleLines = expanded ? lines : lines.slice(0, COLLAPSED_LINES);
12
- let text = visibleLines.join("\n");
17
+ const visible = expanded ? lines : lines.slice(0, COLLAPSED_LINES);
18
+ const remaining = lines.length - visible.length;
19
+ const component = new Container();
20
+ const body = `\n${visible.join("\n")}`;
13
21
 
14
- const remaining = totalLines - visibleLines.length;
22
+ component.addChild(
23
+ isError
24
+ ? new Text(theme.fg("toolOutput", body), 0, 0)
25
+ : new Markdown(body, 0, 0, getMarkdownTheme(), {
26
+ color: (text) => theme.fg("toolOutput", text),
27
+ }),
28
+ );
15
29
 
16
30
  if (remaining > 0) {
17
- text += `\n... (${remaining} more lines, ${totalLines} total, ${keyHint("app.tools.expand", "to expand")})`;
31
+ component.addChild(
32
+ new Text(
33
+ `${theme.fg("muted", `... (${remaining} more lines, ${lines.length} total,`)} ${keyHint("app.tools.expand", "to expand")}${theme.fg("muted", ")")}`,
34
+ 0,
35
+ 0,
36
+ ),
37
+ );
18
38
  }
19
39
 
20
- return `\n${text}`;
40
+ if (warning) {
41
+ component.addChild(new Spacer(1));
42
+ component.addChild(new Text(theme.fg("warning", warning), 0, 0));
43
+ }
44
+
45
+ return component;
21
46
  }
package/src/service.ts CHANGED
@@ -8,7 +8,7 @@ import {
8
8
  truncateHead,
9
9
  type AgentToolResult,
10
10
  } from "@earendil-works/pi-coding-agent";
11
- import { ExpiringLruCache, stableKeyHash, type CachePersistence } from "./cache";
11
+ import { ExpiringLruCache, stableKeyHash, canonicalKey, type CachePersistence } from "./cache";
12
12
  import { sliceCompleteDocument, type CompleteDocument } from "./document/content.ts";
13
13
  import { revalidateCompleteDocument, type FetchRemoteDependencies } from "./acquisition/fetch.ts";
14
14
  import { fetchDocumentWithLlmsTxtSupport } from "./acquisition/llms.ts";
@@ -430,7 +430,8 @@ export async function executeWebFetch(
430
430
 
431
431
  const displayRequestedUrl = redactUrlForDisplay(params.url);
432
432
  const sourceUrl = urlWithoutFragment(params.url);
433
- let document = fetchCache.get(sourceUrl);
433
+ const cacheKey = canonicalKey({ url: sourceUrl });
434
+ let document = fetchCache.get(cacheKey);
434
435
  const now = Date.now();
435
436
 
436
437
  const fresh = Boolean(
@@ -459,7 +460,7 @@ export async function executeWebFetch(
459
460
  const stale = document;
460
461
 
461
462
  const acquisition = await inflightFetches.run(
462
- sourceUrl,
463
+ cacheKey,
463
464
  async (sharedSignal) => {
464
465
  let fetched: CompleteDocument;
465
466
  let acquisitionStatus: FetchAcquisition["cacheStatus"] = "miss";
@@ -479,7 +480,7 @@ export async function executeWebFetch(
479
480
  }
480
481
 
481
482
  if (!fetched.extractionDiagnostics?.botWall) {
482
- fetchCache.set(sourceUrl, fetched, Date.now() + CACHE_STALE_RETENTION_MS);
483
+ fetchCache.set(cacheKey, fetched, Date.now() + CACHE_STALE_RETENTION_MS);
483
484
  }
484
485
 
485
486
  return { document: fetched, cacheStatus: acquisitionStatus };
@@ -0,0 +1,56 @@
1
+ import { createHash, scryptSync } from "node:crypto";
2
+
3
+ /** Returns whether the first `difficulty` bits of a hash are zero. */
4
+ export function hasLeadingZeroBits(hash, difficulty) {
5
+ for (let bit = 0; bit < difficulty; bit += 1) {
6
+ const byte = hash[Math.floor(bit / 8)];
7
+
8
+ if (byte === undefined || (byte & (1 << (7 - (bit % 8)))) !== 0) return false;
9
+ }
10
+
11
+ return true;
12
+ }
13
+
14
+ /** Solves the bandwidth challenge using a bounded zero-filled buffer. */
15
+ export function solveAwsWafBandwidth(difficulty) {
16
+ const sizes = [1_024, 1_024, 10_240, 102_400, 1_048_576, 10_485_760];
17
+
18
+ return Buffer.alloc(sizes[Math.min(5, Math.max(0, difficulty))] ?? 1_024).toString("base64");
19
+ }
20
+
21
+ /** Computes bounded proof-of-work inside a disposable worker. */
22
+ export function solveAwsWafWork(work) {
23
+ if (!Number.isInteger(work.difficulty) || work.difficulty < 1 || work.difficulty > 32)
24
+ return undefined;
25
+
26
+ if (work.type === "NetworkBandwidth") return solveAwsWafBandwidth(work.difficulty);
27
+
28
+ if (work.type === "SHA256") {
29
+ for (let nonce = 0; nonce < 2_000_000; nonce += 1) {
30
+ const digest = createHash("sha256").update(`${work.input}${work.checksum}${nonce}`).digest();
31
+
32
+ if (hasLeadingZeroBits(digest, work.difficulty)) return String(nonce);
33
+ }
34
+ }
35
+
36
+ if (work.type === "HashcashScrypt") {
37
+ const cost = work.memory > 0 ? work.memory : 128;
38
+
39
+ // Reject large or invalid costs before invoking OpenSSL.
40
+ if (!Number.isInteger(cost) || cost < 2 || cost > 16_384 || (cost & (cost - 1)) !== 0)
41
+ return undefined;
42
+
43
+ for (let nonce = 0; nonce < 100_000; nonce += 1) {
44
+ const digest = scryptSync(`${work.input}${work.checksum}${nonce}`, work.checksum, 32, {
45
+ N: cost,
46
+ r: 8,
47
+ p: 1,
48
+ maxmem: 64 * 1_024 * 1_024,
49
+ });
50
+
51
+ if (hasLeadingZeroBits(digest, work.difficulty)) return String(nonce);
52
+ }
53
+ }
54
+
55
+ return undefined;
56
+ }
@@ -0,0 +1,14 @@
1
+ /* v8 ignore file -- exercised through worker integration tests. */
2
+ import { parentPort, workerData } from "node:worker_threads";
3
+ import { solveAwsWafWork } from "./aws-waf-solver.js";
4
+
5
+ const work = workerData;
6
+
7
+ try {
8
+ parentPort?.postMessage({ kind: "solved", solution: solveAwsWafWork(work) });
9
+ } catch (error) {
10
+ parentPort?.postMessage({
11
+ kind: "error",
12
+ message: error instanceof Error ? error.message : "AWS WAF worker failed.",
13
+ });
14
+ }