zames_pro 2.66.1 → 2.67.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/CHANGELOG.md +192 -129
- package/README.md +2 -1
- package/dist/attachments.js +0 -17
- package/dist/context.js +0 -3
- package/dist/extraTools.js +10 -11
- package/dist/fsutil.js +38 -1
- package/dist/gitTools.js +83 -26
- package/dist/index.js +18 -18
- package/dist/input.js +134 -30
- package/dist/mcp.js +49 -10
- package/dist/permissions.js +40 -1
- package/dist/run-task.js +6 -0
- package/dist/sandbox.js +49 -0
- package/dist/scheduler.js +8 -1
- package/dist/self-review.js +37 -17
- package/dist/shell.js +74 -32
- package/dist/tools.js +40 -20
- package/dist/transcript.js +15 -2
- package/dist/web.js +107 -38
- package/package.json +1 -2
- package/scripts/build-readme.mts +30 -51
- package/scripts/coverage-gate.mjs +4 -0
- package/README.ru.md +0 -971
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
|
-
|
|
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
|
|
168
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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);
|
|
@@ -198,7 +222,38 @@ async function getHeadless() {
|
|
|
198
222
|
});
|
|
199
223
|
return _headless;
|
|
200
224
|
}
|
|
225
|
+
// ONE reused context for every render-fetch. Creating a fresh context per call
|
|
226
|
+
// (as before) re-allocated the user-agent/network stack each time, which is
|
|
227
|
+
// heavy for a series of render=true fetches. Pages are closed after each fetch,
|
|
228
|
+
// the context itself is reused and closed in closeWeb().
|
|
229
|
+
//
|
|
230
|
+
// The cache is a tiny pure factory so the reuse/reset behaviour can be unit-
|
|
231
|
+
// tested without launching a real browser.
|
|
232
|
+
export function createReusable(factory, dispose) {
|
|
233
|
+
let cached = null;
|
|
234
|
+
return {
|
|
235
|
+
async get() {
|
|
236
|
+
if (cached === null)
|
|
237
|
+
cached = await factory();
|
|
238
|
+
return cached;
|
|
239
|
+
},
|
|
240
|
+
async reset() {
|
|
241
|
+
if (cached === null)
|
|
242
|
+
return;
|
|
243
|
+
const v = cached;
|
|
244
|
+
cached = null;
|
|
245
|
+
await dispose(v).catch(() => { });
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
const _headlessCtxCache = createReusable(async () => {
|
|
250
|
+
const browser = await getHeadless();
|
|
251
|
+
return browser.newContext({
|
|
252
|
+
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
|
253
|
+
});
|
|
254
|
+
}, (ctx) => ctx.close());
|
|
201
255
|
export async function closeWeb() {
|
|
256
|
+
await _headlessCtxCache.reset();
|
|
202
257
|
if (_headless) {
|
|
203
258
|
try {
|
|
204
259
|
await _headless.close();
|
|
@@ -208,12 +263,13 @@ export async function closeWeb() {
|
|
|
208
263
|
}
|
|
209
264
|
}
|
|
210
265
|
async function renderWithHeadless(url, { timeout = DEFAULT_TIMEOUT } = {}) {
|
|
211
|
-
const
|
|
212
|
-
const ctx = await browser.newContext({
|
|
213
|
-
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
|
214
|
-
});
|
|
266
|
+
const ctx = await _headlessCtxCache.get();
|
|
215
267
|
const page = await ctx.newPage();
|
|
216
268
|
try {
|
|
269
|
+
// Validate the pre-redirect URL (page.goto follows redirects internally,
|
|
270
|
+
// unlike fetch we cannot intercept each hop here — but at least the
|
|
271
|
+
// original is checked, matching the previous behavior).
|
|
272
|
+
await assertPublicUrl(url);
|
|
217
273
|
const resp = await page.goto(url, {
|
|
218
274
|
waitUntil: 'domcontentloaded',
|
|
219
275
|
timeout,
|
|
@@ -223,10 +279,18 @@ async function renderWithHeadless(url, { timeout = DEFAULT_TIMEOUT } = {}) {
|
|
|
223
279
|
const html = await page.content();
|
|
224
280
|
const status = resp ? resp.status() : 0;
|
|
225
281
|
const finalUrl = page.url();
|
|
282
|
+
// The page may have redirected somewhere private; check the FINAL url too.
|
|
283
|
+
try {
|
|
284
|
+
await assertPublicUrl(finalUrl);
|
|
285
|
+
}
|
|
286
|
+
catch {
|
|
287
|
+
throw new Error(`blocked private redirect target: ${finalUrl}`);
|
|
288
|
+
}
|
|
226
289
|
return { status, url: finalUrl, html };
|
|
227
290
|
}
|
|
228
291
|
finally {
|
|
229
|
-
|
|
292
|
+
// Close the PAGE, not the shared context.
|
|
293
|
+
await page.close().catch(() => { });
|
|
230
294
|
}
|
|
231
295
|
}
|
|
232
296
|
// ---------- tools ----------
|
|
@@ -307,22 +371,7 @@ export function createWebTools() {
|
|
|
307
371
|
if (r.status !== 200) {
|
|
308
372
|
return `DuckDuckGo returned HTTP ${r.status}`;
|
|
309
373
|
}
|
|
310
|
-
|
|
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
|
-
}
|
|
374
|
+
const results = parseDuckDuckGoResults(r.body, limit);
|
|
326
375
|
if (!results.length) {
|
|
327
376
|
return `No results found. The DuckDuckGo output format may have changed.`;
|
|
328
377
|
}
|
|
@@ -337,6 +386,26 @@ export function createWebTools() {
|
|
|
337
386
|
},
|
|
338
387
|
];
|
|
339
388
|
}
|
|
389
|
+
// Parse the DuckDuckGo HTML results page. Pure; exported for tests. The
|
|
390
|
+
// html.duckduckgo.com format is stable, so simple regexes suffice. DuckDuckGo
|
|
391
|
+
// wraps links in a /l/?uddg=... redirect which must be unwrapped.
|
|
392
|
+
export function parseDuckDuckGoResults(body, limit) {
|
|
393
|
+
const results = [];
|
|
394
|
+
const re = /<a[^>]*class="[^"]*result__a[^"]*"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>[\s\S]*?(?:<a[^>]*class="[^"]*result__snippet[^"]*"[^>]*>([\s\S]*?)<\/a>)?/gi;
|
|
395
|
+
let m;
|
|
396
|
+
while ((m = re.exec(body)) && results.length < limit) {
|
|
397
|
+
let href = m[1];
|
|
398
|
+
const uddgMatch = href.match(/[?&]uddg=([^&]+)/);
|
|
399
|
+
if (uddgMatch)
|
|
400
|
+
href = decodeURIComponent(uddgMatch[1]);
|
|
401
|
+
const title = htmlToText(m[2] || '').trim();
|
|
402
|
+
const snippet = htmlToText(m[3] || '').trim();
|
|
403
|
+
if (!title || !href)
|
|
404
|
+
continue;
|
|
405
|
+
results.push({ title, url: href, snippet });
|
|
406
|
+
}
|
|
407
|
+
return results;
|
|
408
|
+
}
|
|
340
409
|
function formatResult(status, url, text, limit) {
|
|
341
410
|
let out = text;
|
|
342
411
|
let truncated = false;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zames_pro",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.67.1",
|
|
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": {
|
|
@@ -10,7 +10,6 @@
|
|
|
10
10
|
"dist",
|
|
11
11
|
"scripts",
|
|
12
12
|
"README.md",
|
|
13
|
-
"README.ru.md",
|
|
14
13
|
"CHANGELOG.md",
|
|
15
14
|
"LICENSE",
|
|
16
15
|
"logo-small.jpg"
|
package/scripts/build-readme.mts
CHANGED
|
@@ -2,10 +2,13 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Why: npm renders exactly ONE readme per package (README.md in the root) and
|
|
4
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
|
|
6
|
-
// API), so a <details> block DOES work there -- and on GitHub too.
|
|
7
|
-
// keep the English text on top and fold the other language into
|
|
8
|
-
// block at the bottom.
|
|
5
|
+
// page. But npm renders the readme with GitHub Flavored Markdown (via
|
|
6
|
+
// GitHub's API), so a <details> block DOES work there -- and on GitHub too.
|
|
7
|
+
// We therefore keep the English text on top and fold the other language into
|
|
8
|
+
// a collapsible block at the bottom. README.ru.md is generated too so the
|
|
9
|
+
// GitHub switcher can point at it directly, but it is NOT published to npm
|
|
10
|
+
// (see "files" in package.json):
|
|
11
|
+
// it would just duplicate the folded block.
|
|
9
12
|
//
|
|
10
13
|
// The two sources (docs/readme.en.md, docs/readme.ru.md) are the single point
|
|
11
14
|
// of edit for each language; this script wires the switcher and the collapsible
|
|
@@ -13,7 +16,7 @@
|
|
|
13
16
|
// generated files are committed; test/readme-built.test.ts re-runs buildReadmes()
|
|
14
17
|
// and fails if they drift from the sources.
|
|
15
18
|
//
|
|
16
|
-
// Usage:
|
|
19
|
+
// Usage: npm run build:readme
|
|
17
20
|
|
|
18
21
|
import fs from 'node:fs'
|
|
19
22
|
import path from 'node:path'
|
|
@@ -23,17 +26,13 @@ const NL = String.fromCharCode(10)
|
|
|
23
26
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
|
24
27
|
export const SWITCHER = '<!-- SWITCHER -->'
|
|
25
28
|
|
|
26
|
-
|
|
29
|
+
// In-page anchor the switcher links to. GitHub strips `id` on some elements but
|
|
30
|
+
// keeps `name` on an <a>, so both are set.
|
|
31
|
+
export const RU_ANCHOR = '<a id="readme-ru" name="readme-ru"></a>'
|
|
32
|
+
|
|
27
33
|
const EN_SWITCH = [
|
|
28
34
|
'<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>',
|
|
35
|
+
' <strong>English</strong> | <a href="#readme-ru">Русский</a>',
|
|
37
36
|
'</p>',
|
|
38
37
|
].join(NL)
|
|
39
38
|
|
|
@@ -71,50 +70,30 @@ function details(summary: string, body: string): string {
|
|
|
71
70
|
].join(NL)
|
|
72
71
|
}
|
|
73
72
|
|
|
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
73
|
// Returns the two generated readmes WITHOUT touching the disk, so a test can
|
|
95
|
-
// compare them against the committed files.
|
|
74
|
+
// compare them against the committed files. Both files carry the SAME content
|
|
75
|
+
// (English on top, Russian folded): README.md is what npm publishes, and
|
|
76
|
+
// README.ru.md exists only so the GitHub language switcher has a target. The
|
|
77
|
+
// switcher is an IN-PAGE `#readme-ru` anchor, so it works from either file
|
|
78
|
+
// without fetching the other one.
|
|
96
79
|
export function buildReadmes(rootDir: string): {
|
|
97
80
|
readmeEn: string
|
|
98
81
|
readmeRu: string
|
|
99
82
|
} {
|
|
100
83
|
const en = readSource(rootDir, 'readme.en.md')
|
|
101
84
|
const ru = readSource(rootDir, 'readme.ru.md')
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
foldSummary: '🇬🇧 Read in English',
|
|
115
|
-
foldBody: en.body,
|
|
116
|
-
}),
|
|
117
|
-
}
|
|
85
|
+
const doc = [
|
|
86
|
+
en.head,
|
|
87
|
+
'',
|
|
88
|
+
EN_SWITCH,
|
|
89
|
+
'',
|
|
90
|
+
en.body,
|
|
91
|
+
'',
|
|
92
|
+
RU_ANCHOR,
|
|
93
|
+
details('🇷🇺 Читать по-русски (Russian)', ru.body),
|
|
94
|
+
'',
|
|
95
|
+
].join(NL)
|
|
96
|
+
return { readmeEn: doc, readmeRu: doc }
|
|
118
97
|
}
|
|
119
98
|
|
|
120
99
|
// Run only when invoked directly (not when imported by a test).
|
|
@@ -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(
|