cadet-agent 0.20.2 → 0.24.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/src/install.mjs CHANGED
@@ -1,14 +1,21 @@
1
- import { createWriteStream, mkdirSync, readFileSync, unlinkSync, existsSync, readdirSync, statSync } from 'node:fs';
2
- import { join, dirname, relative } from 'node:path';
3
- import { inflateRawSync } from 'node:zlib';
4
- import { Readable } from 'node:stream';
1
+ import { readFileSync, unlinkSync, existsSync, readdirSync, statSync } from 'node:fs';
2
+ import { join, relative } from 'node:path';
5
3
  import { runUpgrades } from './upgrades.mjs';
4
+ import {
5
+ extractArchive, readArchiveEntry, findEocd,
6
+ DEFAULT_ARCHIVE_LIMITS, ArchiveError,
7
+ } from './harness/archive.mjs';
6
8
 
7
9
  // ── Constants ────────────────────────────────────────────────────────────────
8
10
 
9
11
  const DEFAULT_API = 'https://api.github.com/repos/naishtech/cadet-agent/releases/latest';
10
12
  const USER_AGENT = 'cadet-agent-cli';
11
13
 
14
+ /** Network timeouts and bounded download limits. */
15
+ const FETCH_TIMEOUT_MS = 60_000;
16
+ const READ_TIMEOUT_MS = 120_000;
17
+ const MAX_DOWNLOAD_BYTES = 25 * 1024 * 1024;
18
+
12
19
  function resolveApiUrl(override) {
13
20
  return override || process.env.CADET_AGENT_RELEASE_URL || DEFAULT_API;
14
21
  }
@@ -29,106 +36,35 @@ function buildHeaders(extra = {}) {
29
36
  return headers;
30
37
  }
31
38
 
32
- // ── ZIP parser (zero-dependency, handles store + deflate) ───────────────────
33
-
34
- const SIG_EOCD = 0x06054b50;
35
- const SIG_CD = 0x02014b50;
36
- const SIG_LFH = 0x04034b50;
37
-
38
- function read32(buf, off) { return buf.readUInt32LE(off); }
39
- function read16(buf, off) { return buf.readUInt16LE(off); }
40
-
41
- function findEocd(buf) {
42
- // Search backwards from end for EOCD signature (comment is max 65535 bytes)
43
- const maxStart = Math.max(0, buf.length - 65535 - 22);
44
- for (let i = buf.length - 22; i >= maxStart; i--) {
45
- if (read32(buf, i) === SIG_EOCD) return i;
46
- }
47
- throw new Error('Not a valid ZIP file: EOCD signature not found');
48
- }
49
-
50
- function* centralDirectoryEntries(buf, cdOffset, cdSize) {
51
- let off = cdOffset;
52
- const end = cdOffset + cdSize;
53
- while (off < end) {
54
- if (read32(buf, off) !== SIG_CD) break;
55
- const method = read16(buf, off + 10);
56
- const compressedSize = read32(buf, off + 20);
57
- const uncompressedSize = read32(buf, off + 24);
58
- const filenameLen = read16(buf, off + 28);
59
- const extraLen = read16(buf, off + 30);
60
- const commentLen = read16(buf, off + 32);
61
- const localHeaderOff = read32(buf, off + 42);
62
- const filename = buf.toString('utf-8', off + 46, off + 46 + filenameLen).replace(/\\/g, '/');
63
-
64
- // Skip directory entries (trailing / in filename, or uncompressedSize == 0 with no method)
65
- if (!filename.endsWith('/')) {
66
- yield { filename, method, compressedSize, uncompressedSize, localHeaderOff };
39
+ /** fetch with an AbortController timeout; never leaves a request hanging. */
40
+ async function fetchWithTimeout(url, options = {}, timeoutMs = FETCH_TIMEOUT_MS) {
41
+ const controller = new AbortController();
42
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
43
+ try {
44
+ return await fetch(url, { ...options, signal: controller.signal });
45
+ } catch (err) {
46
+ if (err.name === 'AbortError') {
47
+ throw new Error(`Request timed out after ${timeoutMs}ms: ${url}`);
67
48
  }
68
-
69
- off += 46 + filenameLen + extraLen + commentLen;
49
+ throw err;
50
+ } finally {
51
+ clearTimeout(timer);
70
52
  }
71
53
  }
72
54
 
73
- function extractFile(buf, entry, targetDir) {
74
- const { filename, method, compressedSize, localHeaderOff } = entry;
55
+ // ── ZIP extraction (hardened via src/harness/archive.mjs) ────────────────────
56
+ //
57
+ // Containment and resource limits live in the archive module; these wrappers
58
+ // preserve the historical return shapes while refusing anything unsafe.
75
59
 
76
- // Read local file header to get filename + extra lengths (they may differ from CD)
77
- const lfhFilenameLen = read16(buf, localHeaderOff + 26);
78
- const lfhExtraLen = read16(buf, localHeaderOff + 28);
60
+ const ARCHIVE_LIMITS = DEFAULT_ARCHIVE_LIMITS;
79
61
 
80
- const dataStart = localHeaderOff + 30 + lfhFilenameLen + lfhExtraLen;
81
- const compressed = buf.subarray(dataStart, dataStart + compressedSize);
82
-
83
- let data;
84
- if (method === 0) {
85
- // Stored — no compression
86
- data = compressed;
87
- } else if (method === 8) {
88
- // Deflate
89
- data = inflateRawSync(compressed);
90
- } else {
91
- throw new Error(
92
- `Unsupported compression method ${method} for ${filename}.\n` +
93
- `This zip uses a compression format this CLI doesn't support.\n` +
94
- `Try downloading cadet-agent.zip manually from:\n` +
95
- ` https://github.com/naishtech/cadet-agent/releases/latest`
96
- );
97
- }
98
-
99
- const outPath = join(targetDir, filename);
100
- mkdirSync(dirname(outPath), { recursive: true });
101
-
102
- return new Promise((resolve, reject) => {
103
- const rs = Readable.from(data);
104
- const ws = createWriteStream(outPath);
105
- rs.pipe(ws);
106
- ws.on('finish', () => resolve(outPath));
107
- ws.on('error', (err) => reject(new Error(`Write failed for ${filename}: ${err.message}`)));
108
- rs.on('error', (err) => reject(new Error(`Read failed for ${filename}: ${err.message}`)));
109
- });
110
- }
62
+ export { ArchiveError, findEocd };
111
63
 
64
+ /** Extract every file entry into targetDir, returning the written paths. */
112
65
  export async function extractZip(buf, targetDir) {
113
- const eocdOff = findEocd(buf);
114
- const cdSize = read32(buf, eocdOff + 12);
115
- const cdOff = read32(buf, eocdOff + 16);
116
-
117
- // ZIP64 detection — 0xFFFFFFFF in 32-bit fields means real values are in ZIP64 extra records
118
- if (cdOff === 0xFFFFFFFF || cdSize === 0xFFFFFFFF) {
119
- throw new Error(
120
- 'This zip uses ZIP64 format, which is not supported.\n' +
121
- 'Try downloading cadet-agent.zip manually from:\n' +
122
- ' https://github.com/naishtech/cadet-agent/releases/latest'
123
- );
124
- }
125
-
126
- const paths = [];
127
- for (const entry of centralDirectoryEntries(buf, cdOff, cdSize)) {
128
- const outPath = await extractFile(buf, entry, targetDir);
129
- paths.push(outPath);
130
- }
131
- return paths;
66
+ const { extracted } = extractArchive(buf, targetDir, { limits: ARCHIVE_LIMITS });
67
+ return extracted;
132
68
  }
133
69
 
134
70
  // ── GitHub release download ─────────────────────────────────────────────────
@@ -137,9 +73,9 @@ async function fetchLatestRelease(apiUrl) {
137
73
  const url = resolveApiUrl(apiUrl);
138
74
  console.log('🔍 Fetching latest Cadet-Agent release...');
139
75
 
140
- const res = await fetch(url, {
76
+ const res = await fetchWithTimeout(url, {
141
77
  headers: buildHeaders({ 'Accept': 'application/vnd.github+json' }),
142
- });
78
+ }, FETCH_TIMEOUT_MS);
143
79
 
144
80
  if (!res.ok) {
145
81
  if (res.status === 403 || res.status === 429) {
@@ -168,33 +104,55 @@ function findZipAsset(release) {
168
104
  async function downloadZip(url) {
169
105
  console.log('⬇️ Downloading cadet-agent.zip...');
170
106
 
171
- const res = await fetch(url, {
107
+ const res = await fetchWithTimeout(url, {
172
108
  headers: buildHeaders({ 'Accept': 'application/octet-stream' }),
173
- });
109
+ }, FETCH_TIMEOUT_MS);
174
110
 
175
111
  if (!res.ok) {
176
112
  throw new Error(`Download failed: ${res.status} ${res.statusText}`);
177
113
  }
178
114
 
179
115
  const contentLength = res.headers.get('content-length');
180
- const total = contentLength ? parseInt(contentLength, 10) : 0;
116
+ const declared = contentLength ? parseInt(contentLength, 10) : 0;
117
+ if (declared > MAX_DOWNLOAD_BYTES) {
118
+ throw new ArchiveError(
119
+ `Refusing download: declared size ${declared} bytes exceeds the ${MAX_DOWNLOAD_BYTES}-byte limit`,
120
+ 'download-too-large'
121
+ );
122
+ }
181
123
 
182
- // Stream to buffer with progress
124
+ // Stream to buffer with progress, enforcing a hard size cap and read timeout.
183
125
  const chunks = [];
184
126
  let downloaded = 0;
185
127
  const reader = res.body.getReader();
128
+ const readTimer = setTimeout(() => { try { reader.cancel('read timeout'); } catch { /* ignore */ } }, READ_TIMEOUT_MS);
186
129
 
187
- while (true) {
188
- const { done, value } = await reader.read();
189
- if (done) break;
190
- chunks.push(value);
191
- downloaded += value.length;
192
- if (total > 0) {
193
- const pct = Math.round((downloaded / total) * 100);
194
- process.stdout.write(`\r ${pct}% (${(downloaded / 1024).toFixed(0)} KB / ${(total / 1024).toFixed(0)} KB)`);
130
+ try {
131
+ while (true) {
132
+ const { done, value } = await reader.read();
133
+ if (done) break;
134
+ downloaded += value.length;
135
+ if (downloaded > MAX_DOWNLOAD_BYTES) {
136
+ try { await reader.cancel('size limit'); } catch { /* ignore */ }
137
+ throw new ArchiveError(
138
+ `Refusing download: body exceeded the ${MAX_DOWNLOAD_BYTES}-byte limit`,
139
+ 'download-too-large'
140
+ );
141
+ }
142
+ chunks.push(value);
143
+ if (declared > 0) {
144
+ const pct = Math.round((downloaded / declared) * 100);
145
+ process.stdout.write(`\r ${pct}% (${(downloaded / 1024).toFixed(0)} KB / ${(declared / 1024).toFixed(0)} KB)`);
146
+ }
195
147
  }
148
+ } catch (err) {
149
+ if (err instanceof ArchiveError) throw err;
150
+ if (err.name === 'AbortError') throw new Error(`Download timed out after ${READ_TIMEOUT_MS}ms`);
151
+ throw err;
152
+ } finally {
153
+ clearTimeout(readTimer);
196
154
  }
197
- if (total > 0) process.stdout.write('\n');
155
+ if (declared > 0) process.stdout.write('\n');
198
156
 
199
157
  return Buffer.concat(chunks);
200
158
  }
@@ -242,11 +200,11 @@ export async function install(targetDir, opts = {}) {
242
200
  console.log(' Reviewer: /cadet-agent-reviewer');
243
201
  console.log(' Git guard: manual — see .continue\\rules\\cadet-agent.md for instructions');
244
202
  console.log(' Claude Code:');
245
- console.log(' Already active — .claude\\skills\\cadet-agent.md loads as a project skill');
203
+ console.log(' Already active — .claude\\skills\\cadet-agent\\SKILL.md loads as a project skill');
246
204
  console.log(' Slash commands: /cadet-requirements, /cadet-architecture, /cadet-spike,');
247
205
  console.log(' /cadet-breakdown, /cadet-tdd, /cadet-debug, /cadet-review, /cadet-resume');
248
206
  console.log(' Reviewer: /cadet-agent-reviewer');
249
- console.log(' Git guard: manual — see .claude\\skills\\cadet-agent.md for instructions');
207
+ console.log(' Git guard: manual — see .claude\\skills\\cadet-agent\\SKILL.md for instructions');
250
208
  console.log('');
251
209
  }
252
210
 
@@ -324,32 +282,28 @@ function deleteObsoleteManagedFiles(targetDir, managedPaths, zipFilenames) {
324
282
  return deleted;
325
283
  }
326
284
 
327
- export async function extractZipWithManifest(buf, targetDir, { preserved, managed }) {
328
- const eocdOff = findEocd(buf);
329
- const cdSize = read32(buf, eocdOff + 12);
330
- const cdOff = read32(buf, eocdOff + 16);
331
-
332
- if (cdOff === 0xFFFFFFFF || cdSize === 0xFFFFFFFF) {
333
- throw new Error(
334
- 'This zip uses ZIP64 format, which is not supported.\n' +
335
- 'Try downloading cadet-agent.zip manually from:\n' +
336
- ' https://github.com/naishtech/cadet-agent/releases/latest'
337
- );
338
- }
339
-
285
+ export async function extractZipWithManifest(buf, targetDir, { preserved, managed, limits = ARCHIVE_LIMITS }) {
340
286
  const updated = [];
341
287
  const preserved_list = [];
342
288
  const added = [];
343
289
  const zipFilenames = [];
344
290
 
345
- for (const entry of centralDirectoryEntries(buf, cdOff, cdSize)) {
346
- zipFilenames.push(entry.filename);
347
- if (matchesPreservedPath(entry.filename, preserved)) {
348
- preserved_list.push(entry.filename);
349
- continue;
350
- }
351
- const outPath = await extractFile(buf, entry, targetDir);
352
- if (matchesManagedPath(entry.filename, managed)) {
291
+ const { extracted } = extractArchive(buf, targetDir, {
292
+ limits,
293
+ filter: (entry) => {
294
+ zipFilenames.push(entry.filename);
295
+ if (matchesPreservedPath(entry.filename, preserved)) {
296
+ preserved_list.push(entry.filename);
297
+ return { skip: true };
298
+ }
299
+ return true;
300
+ },
301
+ });
302
+
303
+ // `extractArchive` skips directory entries; classify the extracted files.
304
+ for (const outPath of extracted) {
305
+ const rel = outPath.replace(/\\/g, '/');
306
+ if (matchesManagedPath(rel, managed)) {
353
307
  updated.push(outPath);
354
308
  } else {
355
309
  added.push(outPath);
@@ -366,26 +320,10 @@ export async function extractZipWithManifest(buf, targetDir, { preserved, manage
366
320
 
367
321
  export function findManagedPathsInZip(buf) {
368
322
  try {
369
- const eocdOff = findEocd(buf);
370
- const cdSize = read32(buf, eocdOff + 12);
371
- const cdOff = read32(buf, eocdOff + 16);
372
-
373
- for (const entry of centralDirectoryEntries(buf, cdOff, cdSize)) {
374
- const fn = entry.filename.replace(/^\.\//, '').replace(/\\/g, '/');
375
- if (fn === '.cadet/agent/core/FrameworkManifest.json') {
376
- // Extract just this one entry to read managedPaths
377
- const lfhFilenameLen = read16(buf, entry.localHeaderOff + 26);
378
- const lfhExtraLen = read16(buf, entry.localHeaderOff + 28);
379
- const dataStart = entry.localHeaderOff + 30 + lfhFilenameLen + lfhExtraLen;
380
- const compressed = buf.subarray(dataStart, dataStart + entry.compressedSize);
381
- let data;
382
- if (entry.method === 0) data = compressed;
383
- else if (entry.method === 8) data = inflateRawSync(compressed);
384
- else break;
385
- const manifest = JSON.parse(data.toString('utf-8'));
386
- return manifest.managedPaths || [];
387
- }
388
- }
323
+ const data = readArchiveEntry(buf, '.cadet/agent/core/FrameworkManifest.json', ARCHIVE_LIMITS);
324
+ if (!data) return [];
325
+ const manifest = JSON.parse(data.toString('utf-8'));
326
+ return manifest.managedPaths || [];
389
327
  } catch {
390
328
  // Not a valid zip or manifest not found
391
329
  }