zames_pro 2.53.0 → 2.54.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.
@@ -1,6 +1,7 @@
1
1
  import fs from 'fs/promises';
2
2
  import path from 'path';
3
3
  import os from 'os';
4
+ import { existsSync } from 'fs';
4
5
  import { createHash } from 'crypto';
5
6
  import { execFileSync } from 'child_process';
6
7
  import { extractPathToken } from './path-token.js';
@@ -233,7 +234,7 @@ export function windowsReachable() {
233
234
  if (process.platform !== 'linux')
234
235
  return false;
235
236
  try {
236
- return require('fs').existsSync('/mnt/c/Windows');
237
+ return existsSync('/mnt/c/Windows');
237
238
  }
238
239
  catch {
239
240
  return false;
package/dist/config.js CHANGED
@@ -10,6 +10,13 @@ const HOME_CONFIG = path.join(ZAMES_HOME, 'config.json');
10
10
  export function projectConfigPath() {
11
11
  return path.join(process.cwd(), '.zamesrc.json');
12
12
  }
13
+ // Write via a temp file + rename so a crash or Ctrl+C mid-write can never
14
+ // leave a truncated/broken config (it is read at every startup).
15
+ function atomicWriteJson(file, value) {
16
+ const tmp = `${file}.tmp-${process.pid}`;
17
+ fs.writeFileSync(tmp, JSON.stringify(value, null, 2) + '\n', 'utf-8');
18
+ fs.renameSync(tmp, file);
19
+ }
13
20
  export const DEFAULTS = {
14
21
  maxIterations: 0,
15
22
  // Headless by default: the browser runs without a visible window. Set
@@ -426,7 +433,7 @@ export function readConfigFile(file) {
426
433
  catch (e) {
427
434
  const err = e;
428
435
  if (err.code !== 'ENOENT') {
429
- throw new Error(`не удалось прочитать ${file}: ${err.message}`);
436
+ throw new Error(`failed to read ${file}: ${err.message}`);
430
437
  }
431
438
  }
432
439
  return {};
@@ -444,7 +451,7 @@ export function writeConfigValue(scope, key, raw) {
444
451
  const data = readConfigFile(file);
445
452
  setByPath(data, key, value);
446
453
  fs.mkdirSync(path.dirname(file), { recursive: true });
447
- fs.writeFileSync(file, JSON.stringify(data, null, 2) + '\n', 'utf-8');
454
+ atomicWriteJson(file, data);
448
455
  return { value, file };
449
456
  }
450
457
  /** Reset a setting to the default (remove the key from the file). */
@@ -467,7 +474,7 @@ export function resetConfigValue(scope, key) {
467
474
  if (cur)
468
475
  delete cur[parts[parts.length - 1]];
469
476
  fs.mkdirSync(path.dirname(file), { recursive: true });
470
- fs.writeFileSync(file, JSON.stringify(data, null, 2) + '\n', 'utf-8');
477
+ atomicWriteJson(file, data);
471
478
  return file;
472
479
  }
473
480
  // 'PROJECT_CONFIG' stays a getter so existing callers that only DISPLAY the
package/dist/i18n.js CHANGED
@@ -26,7 +26,7 @@ export function normalizeLocale(v) {
26
26
  return DEFAULT_LOCALE;
27
27
  }
28
28
  // String catalog. Key -> { ru, en }. If a key is missing — we return the key itself.
29
- const CATALOG = {
29
+ export const CATALOG = {
30
30
  'app.tagline': {
31
31
  ru: 'агент поверх chat.deepseek.com через Playwright',
32
32
  en: 'coding agent over chat.deepseek.com via Playwright',
@@ -1,5 +1,6 @@
1
1
  import path from 'path';
2
2
  import fs from 'fs/promises';
3
+ import { readdirSync } from 'fs';
3
4
  import { theme, divider } from './theme.js';
4
5
  import { translate, DEFAULT_LOCALE } from './i18n.js';
5
6
  import { fileURLToPath } from 'url';
@@ -13,7 +14,7 @@ function resolveSrcDir() {
13
14
  const candidates = [__dirname, path.join(__dirname, '..', 'src')];
14
15
  for (const dir of candidates) {
15
16
  try {
16
- const entries = require('fs').readdirSync(dir);
17
+ const entries = readdirSync(dir);
17
18
  if (entries.some((e) => e.endsWith('.ts')))
18
19
  return dir;
19
20
  }
package/dist/undo.js CHANGED
@@ -22,7 +22,10 @@ export class UndoStore {
22
22
  }
23
23
  }
24
24
  async _writeIndex(history) {
25
- await fs.writeFile(INDEX, JSON.stringify(history, null, 2));
25
+ // Atomic write: a crash mid-write must not corrupt the undo history.
26
+ const tmp = `${INDEX}.tmp-${process.pid}`;
27
+ await fs.writeFile(tmp, JSON.stringify(history, null, 2));
28
+ await fs.rename(tmp, INDEX);
26
29
  }
27
30
  async backup(filePath) {
28
31
  if (!this.enabled)
package/dist/web.js CHANGED
@@ -1,4 +1,6 @@
1
1
  import { chromium } from 'playwright';
2
+ import { lookup } from 'node:dns/promises';
3
+ import { isIP } from 'node:net';
2
4
  const DEFAULT_TIMEOUT = 20_000;
3
5
  const MAX_TEXT = 12_000;
4
6
  // Removes scripts, styles, and turns HTML into readable text.
@@ -42,6 +44,102 @@ function htmlToText(html) {
42
44
  .join('\n');
43
45
  return s;
44
46
  }
47
+ // ---------- SSRF guard ----------
48
+ //
49
+ // WebFetch takes an arbitrary URL from the model. Without a guard it could be
50
+ // talked into reading cloud metadata (169.254.169.254), internal admin panels
51
+ // or localhost services. We resolve the hostname and refuse private / loopback
52
+ // / link-local addresses. Best-effort: a DNS rebind between the check and the
53
+ // fetch is out of scope for a CLI tool, but the common cases are covered.
54
+ export function isPrivateIp(ip) {
55
+ const v4 = ip.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
56
+ if (v4) {
57
+ const [a, b] = v4.slice(1).map(Number);
58
+ if (a === 10 || a === 127 || a === 0)
59
+ return true;
60
+ if (a === 169 && b === 254)
61
+ return true; // link-local + cloud metadata
62
+ if (a === 172 && b >= 16 && b <= 31)
63
+ return true;
64
+ if (a === 192 && b === 168)
65
+ return true;
66
+ if (a === 100 && b >= 64 && b <= 127)
67
+ return true; // CGNAT
68
+ return false;
69
+ }
70
+ const low = ip.toLowerCase();
71
+ if (low === '::1' || low === '::')
72
+ return true;
73
+ if (low.startsWith('fe80') || low.startsWith('fc') || low.startsWith('fd')) {
74
+ return true;
75
+ }
76
+ const mapped = low.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
77
+ if (mapped)
78
+ return isPrivateIp(mapped[1]);
79
+ return false;
80
+ }
81
+ export function isPrivateHostname(host) {
82
+ const h = host.toLowerCase().replace(/\.$/, '');
83
+ if (!h)
84
+ return true;
85
+ if (h === 'localhost' || h.endsWith('.localhost'))
86
+ return true;
87
+ if (h === 'metadata.google.internal')
88
+ return true;
89
+ return false;
90
+ }
91
+ async function assertPublicUrl(rawUrl) {
92
+ const u = new URL(rawUrl);
93
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') {
94
+ throw new Error(`unsupported protocol: ${u.protocol}`);
95
+ }
96
+ const host = u.hostname;
97
+ if (isPrivateHostname(host)) {
98
+ throw new Error(`blocked private host: ${host}`);
99
+ }
100
+ if (isIP(host)) {
101
+ if (isPrivateIp(host))
102
+ throw new Error(`blocked private address: ${host}`);
103
+ return;
104
+ }
105
+ try {
106
+ const addrs = await lookup(host, { all: true });
107
+ for (const a of addrs) {
108
+ if (isPrivateIp(a.address)) {
109
+ throw new Error(`blocked private address for ${host}: ${a.address}`);
110
+ }
111
+ }
112
+ }
113
+ catch (e) {
114
+ // Re-throw our own block, but let a DNS failure fall through so fetch()
115
+ // surfaces the real network error instead of a misleading one.
116
+ if (e.message.startsWith('blocked'))
117
+ throw e;
118
+ }
119
+ }
120
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
121
+ // fetch with retries for transient network errors and 5xx responses.
122
+ async function httpFetchWithRetry(url, opts = {}, retries = 2) {
123
+ let lastErr;
124
+ for (let attempt = 0; attempt <= retries; attempt++) {
125
+ try {
126
+ const r = await httpFetch(url, opts);
127
+ if (r.status >= 500 && attempt < retries) {
128
+ await sleep(300 * 2 ** attempt);
129
+ continue;
130
+ }
131
+ return r;
132
+ }
133
+ catch (e) {
134
+ lastErr = e;
135
+ if (attempt < retries) {
136
+ await sleep(300 * 2 ** attempt);
137
+ continue;
138
+ }
139
+ }
140
+ }
141
+ throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
142
+ }
45
143
  // fetch with redirects and a timeout
46
144
  async function httpFetch(url, { timeout = DEFAULT_TIMEOUT, headers = {}, } = {}) {
47
145
  const ctrl = new AbortController();
@@ -126,12 +224,13 @@ export function createWebTools() {
126
224
  }
127
225
  const limit = Math.min(Number(maxChars) || MAX_TEXT, 60_000);
128
226
  try {
227
+ await assertPublicUrl(String(url));
129
228
  if (render) {
130
229
  const r = await renderWithHeadless(String(url));
131
230
  const text = htmlToText(r.html);
132
231
  return formatResult(r.status, r.url, text, limit);
133
232
  }
134
- const r = await httpFetch(String(url));
233
+ const r = await httpFetchWithRetry(String(url));
135
234
  // If it's JSON/plain text — return as is
136
235
  if (/application\/json|text\/plain|text\/markdown/i.test(r.contentType)) {
137
236
  const trimmed = r.body.slice(0, limit);
@@ -168,7 +267,7 @@ export function createWebTools() {
168
267
  return 'Error: query is empty.';
169
268
  const limit = Math.min(Math.max(Number(maxResults) || 8, 1), 20);
170
269
  try {
171
- const r = await httpFetch(`https://html.duckduckgo.com/html/?q=${q}`, {
270
+ const r = await httpFetchWithRetry(`https://html.duckduckgo.com/html/?q=${q}`, {
172
271
  timeout: 25_000,
173
272
  headers: {
174
273
  Accept: 'text/html,application/xhtml+xml',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zames_pro",
3
- "version": "2.53.0",
3
+ "version": "2.54.0",
4
4
  "description": "Terminal coding agent over chat.deepseek.com via Playwright",
5
5
  "type": "module",
6
6
  "bin": {
@@ -45,7 +45,6 @@
45
45
  "chalk": "^5.3.0",
46
46
  "cli-highlight": "^2.1.11",
47
47
  "markdansi": "^0.3.4",
48
- "marked": "^15.0.12",
49
48
  "ora": "^8.0.1",
50
49
  "playwright": "^1.48.0"
51
50
  },