zames_pro 2.66.0 → 2.67.0

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/dist/web.js CHANGED
@@ -3,8 +3,9 @@ import { lookup } from 'node:dns/promises';
3
3
  import { isIP } from 'node:net';
4
4
  const DEFAULT_TIMEOUT = 20_000;
5
5
  const MAX_TEXT = 12_000;
6
- // Removes scripts, styles, and turns HTML into readable text.
7
- function htmlToText(html) {
6
+ // Removes scripts, styles, and turns HTML into readable text. Exported for
7
+ // tests — the entity/whitespace/collapse branches are pure and worth pinning.
8
+ export function htmlToText(html) {
8
9
  // Remove blocks we don't need
9
10
  let s = html
10
11
  .replace(/<script[\s\S]*?<\/script>/gi, '')
@@ -88,7 +89,7 @@ export function isPrivateHostname(host) {
88
89
  return true;
89
90
  return false;
90
91
  }
91
- async function assertPublicUrl(rawUrl) {
92
+ export async function assertPublicUrl(rawUrl) {
92
93
  const u = new URL(rawUrl);
93
94
  if (u.protocol !== 'http:' && u.protocol !== 'https:') {
94
95
  throw new Error(`unsupported protocol: ${u.protocol}`);
@@ -164,23 +165,46 @@ async function httpFetchWithRetry(url, opts = {}, retries = 2) {
164
165
  }
165
166
  throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
166
167
  }
167
- // fetch with redirects and a timeout
168
- async function httpFetch(url, { timeout = DEFAULT_TIMEOUT, headers = {}, } = {}) {
168
+ // fetch with redirects, RE-VALIDATING every hop against the SSRF guard.
169
+ // redirect:'follow' would let a public URL bounce to 169.254.169.254 / 127.0.0.1
170
+ // after assertPublicUrl already approved the original — a classic SSRF bypass.
171
+ // So redirects are followed manually and each Location is checked first.
172
+ const MAX_REDIRECTS = 5;
173
+ export async function httpFetch(url, { timeout = DEFAULT_TIMEOUT, headers = {}, } = {}) {
169
174
  const ctrl = new AbortController();
170
175
  const t = setTimeout(() => ctrl.abort(), timeout);
171
176
  try {
172
- const res = await fetch(url, {
173
- signal: ctrl.signal,
174
- redirect: 'follow',
175
- headers: {
176
- 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) ds-agent/1.0',
177
- 'Accept-Language': 'en-US,en;q=0.9,ru;q=0.8',
178
- ...headers,
179
- },
180
- });
181
- const ct = res.headers.get('content-type') || '';
182
- const body = await res.text();
183
- return { status: res.status, url: res.url, contentType: ct, body };
177
+ let current = url;
178
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
179
+ const res = await fetch(current, {
180
+ signal: ctrl.signal,
181
+ redirect: 'manual',
182
+ headers: {
183
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) ds-agent/1.0',
184
+ 'Accept-Language': 'en-US,en;q=0.9,ru;q=0.8',
185
+ ...headers,
186
+ },
187
+ });
188
+ if (res.status >= 300 && res.status < 400) {
189
+ const loc = res.headers.get('location');
190
+ if (loc) {
191
+ const next = new URL(loc, current).toString();
192
+ // Validate the TARGET before requesting it.
193
+ await assertPublicUrl(next);
194
+ current = next;
195
+ continue;
196
+ }
197
+ }
198
+ const ct = res.headers.get('content-type') || '';
199
+ const body = await res.text();
200
+ return {
201
+ status: res.status,
202
+ url: res.url || current,
203
+ contentType: ct,
204
+ body,
205
+ };
206
+ }
207
+ throw new Error(`too many redirects (>${MAX_REDIRECTS})`);
184
208
  }
185
209
  finally {
186
210
  clearTimeout(t);
@@ -214,6 +238,10 @@ async function renderWithHeadless(url, { timeout = DEFAULT_TIMEOUT } = {}) {
214
238
  });
215
239
  const page = await ctx.newPage();
216
240
  try {
241
+ // Validate the pre-redirect URL (page.goto follows redirects internally,
242
+ // unlike fetch we cannot intercept each hop here — but at least the
243
+ // original is checked, matching the previous behavior).
244
+ await assertPublicUrl(url);
217
245
  const resp = await page.goto(url, {
218
246
  waitUntil: 'domcontentloaded',
219
247
  timeout,
@@ -223,6 +251,13 @@ async function renderWithHeadless(url, { timeout = DEFAULT_TIMEOUT } = {}) {
223
251
  const html = await page.content();
224
252
  const status = resp ? resp.status() : 0;
225
253
  const finalUrl = page.url();
254
+ // The page may have redirected somewhere private; check the FINAL url too.
255
+ try {
256
+ await assertPublicUrl(finalUrl);
257
+ }
258
+ catch {
259
+ throw new Error(`blocked private redirect target: ${finalUrl}`);
260
+ }
226
261
  return { status, url: finalUrl, html };
227
262
  }
228
263
  finally {
@@ -307,22 +342,7 @@ export function createWebTools() {
307
342
  if (r.status !== 200) {
308
343
  return `DuckDuckGo returned HTTP ${r.status}`;
309
344
  }
310
- // We parse with simple regexes. The html.duckduckgo.com format is stable.
311
- const results = [];
312
- const re = /<a[^>]*class="[^"]*result__a[^"]*"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>[\s\S]*?(?:<a[^>]*class="[^"]*result__snippet[^"]*"[^>]*>([\s\S]*?)<\/a>)?/gi;
313
- let m;
314
- while ((m = re.exec(r.body)) && results.length < limit) {
315
- let href = m[1];
316
- // DuckDuckGo wraps links in a redirect like /l/?uddg=...
317
- const uddgMatch = href.match(/[?&]uddg=([^&]+)/);
318
- if (uddgMatch)
319
- href = decodeURIComponent(uddgMatch[1]);
320
- const title = htmlToText(m[2] || '').trim();
321
- const snippet = htmlToText(m[3] || '').trim();
322
- if (!title || !href)
323
- continue;
324
- results.push({ title, url: href, snippet });
325
- }
345
+ const results = parseDuckDuckGoResults(r.body, limit);
326
346
  if (!results.length) {
327
347
  return `No results found. The DuckDuckGo output format may have changed.`;
328
348
  }
@@ -337,6 +357,26 @@ export function createWebTools() {
337
357
  },
338
358
  ];
339
359
  }
360
+ // Parse the DuckDuckGo HTML results page. Pure; exported for tests. The
361
+ // html.duckduckgo.com format is stable, so simple regexes suffice. DuckDuckGo
362
+ // wraps links in a /l/?uddg=... redirect which must be unwrapped.
363
+ export function parseDuckDuckGoResults(body, limit) {
364
+ const results = [];
365
+ const re = /<a[^>]*class="[^"]*result__a[^"]*"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>[\s\S]*?(?:<a[^>]*class="[^"]*result__snippet[^"]*"[^>]*>([\s\S]*?)<\/a>)?/gi;
366
+ let m;
367
+ while ((m = re.exec(body)) && results.length < limit) {
368
+ let href = m[1];
369
+ const uddgMatch = href.match(/[?&]uddg=([^&]+)/);
370
+ if (uddgMatch)
371
+ href = decodeURIComponent(uddgMatch[1]);
372
+ const title = htmlToText(m[2] || '').trim();
373
+ const snippet = htmlToText(m[3] || '').trim();
374
+ if (!title || !href)
375
+ continue;
376
+ results.push({ title, url: href, snippet });
377
+ }
378
+ return results;
379
+ }
340
380
  function formatResult(status, url, text, limit) {
341
381
  let out = text;
342
382
  let truncated = false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zames_pro",
3
- "version": "2.66.0",
3
+ "version": "2.67.0",
4
4
  "description": "Terminal coding agent that drives chat.deepseek.com through Playwright — no API key required. Reads and edits files, runs commands, commits to git, like Claude Code / Codex CLI.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -33,6 +33,7 @@
33
33
  "test:watch": "tsx --test --watch test/*.test.ts",
34
34
  "self-smoke": "tsx scripts/self-smoke.mjs",
35
35
  "render-demo": "node scripts/render-demo.mjs",
36
+ "build:readme": "tsx scripts/build-readme.mts",
36
37
  "postinstall": "node scripts/postinstall.mjs",
37
38
  "prepare": "husky"
38
39
  },
@@ -0,0 +1,129 @@
1
+ // Assemble README.md and README.ru.md from two language sources.
2
+ //
3
+ // Why: npm renders exactly ONE readme per package (README.md in the root) and
4
+ // has no language tabs, so a separate README.ru.md is invisible on the npm
5
+ // page. But npm renders the readme with GitHub Flavored Markdown (via GitHub's
6
+ // API), so a <details> block DOES work there -- and on GitHub too. We therefore
7
+ // keep the English text on top and fold the other language into a collapsible
8
+ // block at the bottom.
9
+ //
10
+ // The two sources (docs/readme.en.md, docs/readme.ru.md) are the single point
11
+ // of edit for each language; this script wires the switcher and the collapsible
12
+ // cross-language block so neither README has to be maintained by hand. The
13
+ // generated files are committed; test/readme-built.test.ts re-runs buildReadmes()
14
+ // and fails if they drift from the sources.
15
+ //
16
+ // Usage: node scripts/build-readme.mjs (or: npm run build:readme)
17
+
18
+ import fs from 'node:fs'
19
+ import path from 'node:path'
20
+ import { fileURLToPath } from 'node:url'
21
+
22
+ const NL = String.fromCharCode(10)
23
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
24
+ export const SWITCHER = '<!-- SWITCHER -->'
25
+
26
+ const REPO = 'https://github.com/Viqto0r/zames_pro/blob/master'
27
+ const EN_SWITCH = [
28
+ '<p align="center">',
29
+ ' <strong>English</strong> | <a href="' +
30
+ REPO +
31
+ '/README.ru.md">Русский</a>',
32
+ '</p>',
33
+ ].join(NL)
34
+ const RU_SWITCH = [
35
+ '<p align="center">',
36
+ ' <a href="' + REPO + '/README.md">English</a> | <strong>Русский</strong>',
37
+ '</p>',
38
+ ].join(NL)
39
+
40
+ export function readSource(
41
+ rootDir: string,
42
+ name: string,
43
+ ): { head: string; body: string } {
44
+ const file = path.join(rootDir, 'docs', name)
45
+ const text = fs.readFileSync(file, 'utf-8')
46
+ const i = text.indexOf(SWITCHER)
47
+ if (i < 0) {
48
+ throw new Error(
49
+ file + ' has no ' + SWITCHER + ' marker (was it edited by hand?)',
50
+ )
51
+ }
52
+ return {
53
+ // Everything above the marker: logo, title, badges.
54
+ head: text.slice(0, i).replace(/\n+$/, ''),
55
+ // Everything below: the actual documentation.
56
+ body: text
57
+ .slice(i + SWITCHER.length)
58
+ .replace(/^\n+/, '')
59
+ .replace(/\n+$/, ''),
60
+ }
61
+ }
62
+
63
+ function details(summary: string, body: string): string {
64
+ return [
65
+ '<details>',
66
+ '<summary>' + summary + '</summary>',
67
+ '',
68
+ body,
69
+ '',
70
+ '</details>',
71
+ ].join(NL)
72
+ }
73
+
74
+ function assemble(opts: {
75
+ head: string
76
+ switchLine: string
77
+ body: string
78
+ foldSummary: string
79
+ foldBody: string
80
+ }): string {
81
+ const { head, switchLine, body, foldSummary, foldBody } = opts
82
+ return [
83
+ head,
84
+ '',
85
+ switchLine,
86
+ '',
87
+ body,
88
+ '',
89
+ details(foldSummary, foldBody),
90
+ '',
91
+ ].join(NL)
92
+ }
93
+
94
+ // Returns the two generated readmes WITHOUT touching the disk, so a test can
95
+ // compare them against the committed files.
96
+ export function buildReadmes(rootDir: string): {
97
+ readmeEn: string
98
+ readmeRu: string
99
+ } {
100
+ const en = readSource(rootDir, 'readme.en.md')
101
+ const ru = readSource(rootDir, 'readme.ru.md')
102
+ return {
103
+ readmeEn: assemble({
104
+ head: en.head,
105
+ switchLine: EN_SWITCH,
106
+ body: en.body,
107
+ foldSummary: '🇷🇺 Читать по-русски (Russian)',
108
+ foldBody: ru.body,
109
+ }),
110
+ readmeRu: assemble({
111
+ head: ru.head,
112
+ switchLine: RU_SWITCH,
113
+ body: ru.body,
114
+ foldSummary: '🇬🇧 Read in English',
115
+ foldBody: en.body,
116
+ }),
117
+ }
118
+ }
119
+
120
+ // Run only when invoked directly (not when imported by a test).
121
+ const invoked =
122
+ process.argv[1] &&
123
+ path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
124
+ if (invoked) {
125
+ const { readmeEn, readmeRu } = buildReadmes(root)
126
+ fs.writeFileSync(path.join(root, 'README.md'), readmeEn)
127
+ fs.writeFileSync(path.join(root, 'README.ru.md'), readmeRu)
128
+ console.log('build-readme: wrote README.md and README.ru.md')
129
+ }
@@ -32,6 +32,10 @@ const FLOORS = {
32
32
  'permissions.ts': 85,
33
33
  'changelog.ts': 85,
34
34
  'attach-refs.ts': 85,
35
+ // N12: web.ts holds the SSRF guard and the DuckDuckGo/HTML parsers — a
36
+ // regression there is silent. Floors sit below the current values.
37
+ 'web.ts': 40,
38
+ 'spinner.ts': 55,
35
39
  }
36
40
 
37
41
  const run = spawnSync(
@@ -108,7 +108,18 @@ const subjects = git(['log', '--no-merges', '--pretty=format:%s', ref + '..HEAD'
108
108
  const parsed = subjects
109
109
  .map(parseConventionalCommit)
110
110
  .filter((c): c is NonNullable<typeof c> => c !== null)
111
- const body = renderChangelogDraft(groupCommits(parsed))
111
+ let body = renderChangelogDraft(groupCommits(parsed))
112
+
113
+ // The conventional draft skips chore/docs/test/etc, so a release whose only
114
+ // change is a docs fix (a README tweak users WILL see on the npm page) would
115
+ // otherwise collapse to a useless "Internal improvements." placeholder. Fall
116
+ // back to the raw commit subjects under Changed instead of that stub.
117
+ if (!body) {
118
+ const fallback = subjects.filter((s) => !/^chore(:|\(release\))/i.test(s))
119
+ if (fallback.length) {
120
+ body = ['## Changed', ''].concat(fallback.map((s) => '- ' + s)).join(NL)
121
+ }
122
+ }
112
123
 
113
124
  // CHANGELOG groups are `### Added` (Keep a Changelog), the draft renders
114
125
  // `## Added` — normalize here.