cetrix-connect 0.3.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/agent.mjs ADDED
@@ -0,0 +1,2591 @@
1
+ #!/usr/bin/env node
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropNames = Object.getOwnPropertyNames;
4
+ var __esm = (fn, res) => function __init() {
5
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
6
+ };
7
+ var __export = (target, all) => {
8
+ for (var name in all)
9
+ __defProp(target, name, { get: all[name], enumerable: true });
10
+ };
11
+
12
+ // src/agent/files.mjs
13
+ var files_exports = {};
14
+ __export(files_exports, {
15
+ executeTool: () => executeTool,
16
+ runCommand: () => runCommand,
17
+ safePath: () => safePath
18
+ });
19
+ import fs2 from "node:fs/promises";
20
+ import path2 from "node:path";
21
+ import { constants } from "node:fs";
22
+ import { spawn as spawn2 } from "node:child_process";
23
+ function within(root, target) {
24
+ const rel = path2.relative(root, target);
25
+ return rel === "" || !rel.startsWith(".." + path2.sep) && rel !== ".." && !path2.isAbsolute(rel);
26
+ }
27
+ async function safePath(config, requested2, { write = false } = {}) {
28
+ if (typeof requested2 !== "string" || requested2.length > 1024 || requested2.includes("\0") || /^[\\/]/.test(requested2) || requested2.includes(":")) throw new Error("Only relative paths are allowed");
29
+ const segments = requested2.replaceAll("\\", "/").split("/");
30
+ if (segments.includes("..") || segments.some(protectedName)) throw new Error("Path is outside the allowed file policy");
31
+ const root = await fs2.realpath(config.root), candidate = path2.resolve(root, requested2);
32
+ if (!within(root, candidate)) throw new Error("Path escapes approved root");
33
+ let resolved;
34
+ try {
35
+ resolved = await fs2.realpath(candidate);
36
+ } catch (error) {
37
+ if (!write || error.code !== "ENOENT") throw error;
38
+ resolved = path2.join(await fs2.realpath(path2.dirname(candidate)), path2.basename(candidate));
39
+ }
40
+ if (!within(root, resolved)) throw new Error("Symlink escapes approved root");
41
+ if (config.allowedDirectories !== void 0) {
42
+ const allowed = await Promise.all(config.allowedDirectories.map((item) => fs2.realpath(item)));
43
+ if (!allowed.some((item) => within(root, item) && within(item, resolved))) throw new Error("Path is outside configured allowed directories");
44
+ }
45
+ if (path2.relative(root, resolved).split(path2.sep).some(protectedName)) throw new Error("Path is outside the allowed file policy");
46
+ for (const item of config.deniedPaths || []) {
47
+ const denied = await fs2.realpath(item).catch(() => path2.resolve(item));
48
+ if (within(denied, resolved)) throw new Error("Agent/server credentials are protected");
49
+ }
50
+ return resolved;
51
+ }
52
+ function runCommand(config, args) {
53
+ if (!config.allowShell) throw new Error("Shell execution is disabled on this PC");
54
+ if (typeof args.command !== "string" || args.command.length < 1 || args.command.length > 4096) throw new Error("Invalid command");
55
+ const timeout = Math.min(25, Math.max(1, Number(args.timeoutSeconds) || 15)) * 1e3;
56
+ return new Promise((resolve, reject) => {
57
+ const child = spawn2(args.command, { shell: config.defaultShell || (process.platform === "win32" ? "powershell.exe" : true), cwd: config.root, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
58
+ let stdout = "", stderr = "", size = 0, killed = false, done = false;
59
+ const stop = () => {
60
+ killed = true;
61
+ if (process.platform === "win32" && child.pid) {
62
+ const killer = spawn2("taskkill", ["/pid", String(child.pid), "/t", "/f"], { windowsHide: true, stdio: "ignore" });
63
+ killer.on("error", () => child.kill());
64
+ } else child.kill("SIGKILL");
65
+ };
66
+ const timer = setTimeout(stop, timeout);
67
+ const collect = (which) => (data) => {
68
+ size += data.length;
69
+ if (size > 65536) {
70
+ stop();
71
+ return;
72
+ }
73
+ if (which === "out") stdout += data.toString();
74
+ else stderr += data.toString();
75
+ };
76
+ child.stdout.on("data", collect("out"));
77
+ child.stderr.on("data", collect("err"));
78
+ child.on("error", (error) => {
79
+ if (done) return;
80
+ done = true;
81
+ clearTimeout(timer);
82
+ reject(error);
83
+ });
84
+ child.on("close", (code, signal) => {
85
+ if (done) return;
86
+ done = true;
87
+ clearTimeout(timer);
88
+ resolve({ stdout, stderr, exitCode: code, signal, terminated: killed });
89
+ });
90
+ });
91
+ }
92
+ async function executeTool(config, tool2, args) {
93
+ if (tool2 === "run_command") return runCommand(config, args);
94
+ if (tool2 === "list_directory") {
95
+ const target = await safePath(config, args.path || ".");
96
+ const entries = await fs2.readdir(target, { withFileTypes: true });
97
+ return { entries: entries.filter((e) => !protectedName(e.name)).slice(0, 200).map((e) => ({ name: e.name, type: e.isSymbolicLink() ? "symlink" : e.isDirectory() ? "directory" : "file" })), truncated: entries.length > 200 };
98
+ }
99
+ if (tool2 === "read_file") {
100
+ const target = await safePath(config, args.path), handle = await fs2.open(target, constants.O_RDONLY | (constants.O_NOFOLLOW || 0));
101
+ try {
102
+ const stat = await handle.stat();
103
+ if (!stat.isFile() || stat.size > 65536) throw new Error("Only files up to 64 KiB are supported");
104
+ const buffer = Buffer.alloc(65537);
105
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
106
+ if (bytesRead > 65536) throw new Error("Maximum file size is 64 KiB");
107
+ const data = buffer.subarray(0, bytesRead);
108
+ if (data.includes(0)) throw new Error("Binary file not supported");
109
+ return { content: data.toString("utf8"), bytes: data.length };
110
+ } finally {
111
+ await handle.close();
112
+ }
113
+ }
114
+ if (tool2 === "write_file") {
115
+ if (!config.allowWrite) throw new Error("File writes are disabled on this PC");
116
+ if (typeof args.content !== "string" || Buffer.byteLength(args.content) > 65536) throw new Error("Maximum file content is 64 KiB");
117
+ const target = await safePath(config, args.path, { write: true });
118
+ const flags = constants.O_WRONLY | constants.O_CREAT | (args.overwrite ? constants.O_TRUNC : constants.O_EXCL) | (constants.O_NOFOLLOW || 0);
119
+ const handle = await fs2.open(target, flags, 384);
120
+ try {
121
+ await handle.writeFile(args.content, "utf8");
122
+ } finally {
123
+ await handle.close();
124
+ }
125
+ return { written: true, bytes: Buffer.byteLength(args.content) };
126
+ }
127
+ throw new Error("Tool is not supported by this agent");
128
+ }
129
+ var protectedName;
130
+ var init_files = __esm({
131
+ "src/agent/files.mjs"() {
132
+ protectedName = (name) => /^\.env(?:\..*)?$|^\.git$|^\.ssh$|^\.aws$|^\.cetrix/i.test(name);
133
+ }
134
+ });
135
+
136
+ // src/agent/entry.mjs
137
+ import fs7 from "node:fs/promises";
138
+ import path9 from "node:path";
139
+ import os2 from "node:os";
140
+ import { generateKeyPairSync, randomUUID as randomUUID5 } from "node:crypto";
141
+ import { parseArgs } from "node:util";
142
+ import { spawn as spawn4 } from "node:child_process";
143
+ import { setTimeout as sleep } from "node:timers/promises";
144
+ import { createInterface } from "node:readline/promises";
145
+
146
+ // src/agent/identity.mjs
147
+ import fs from "node:fs/promises";
148
+ import path from "node:path";
149
+ import { spawn } from "node:child_process";
150
+ import { promisify } from "node:util";
151
+ import { execFile } from "node:child_process";
152
+ var run = promisify(execFile);
153
+ function dpapi(mode, input) {
154
+ const operation = mode === "protect" ? "Protect" : "Unprotect";
155
+ const script = "Add-Type -AssemblyName System.Security; $inputText=[Console]::In.ReadToEnd(); $bytes=[Convert]::FromBase64String($inputText); $result=[Security.Cryptography.ProtectedData]::" + operation + "($bytes,$null,[Security.Cryptography.DataProtectionScope]::CurrentUser); [Console]::Out.Write([Convert]::ToBase64String($result))";
156
+ return new Promise((resolve, reject) => {
157
+ const child = spawn("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { windowsHide: true, stdio: ["pipe", "pipe", "pipe"] });
158
+ let output = "", errors = "";
159
+ child.stdout.on("data", (d) => output += d);
160
+ child.stderr.on("data", (d) => errors += d);
161
+ child.on("error", reject);
162
+ child.on("close", (code) => code === 0 ? resolve(Buffer.from(output.trim(), "base64")) : reject(new Error("Windows key protection failed: " + errors.slice(0, 300))));
163
+ child.stdin.end(input.toString("base64"));
164
+ });
165
+ }
166
+ async function saveIdentity(filename, config) {
167
+ await fs.mkdir(path.dirname(filename), { recursive: true, mode: 448 });
168
+ const bytes = Buffer.from(JSON.stringify(config));
169
+ const record = process.platform === "win32" ? { format: "dpapi-current-user-v1", blob: (await dpapi("protect", bytes)).toString("base64") } : { format: "posix-owner-only-v1", config };
170
+ await fs.writeFile(filename, JSON.stringify(record), { mode: 384, flag: "wx" });
171
+ if (process.platform === "win32") {
172
+ const { stdout } = await run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", "[System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value"]);
173
+ await run("icacls.exe", [filename, "/inheritance:r", "/grant:r", "*" + stdout.trim() + ":F"], { windowsHide: true });
174
+ } else await fs.chmod(filename, 384);
175
+ }
176
+ async function loadIdentity(filename) {
177
+ const record = JSON.parse(await fs.readFile(filename, "utf8"));
178
+ if (record.format === "dpapi-current-user-v1") {
179
+ if (process.platform !== "win32") throw new Error("Windows identity cannot be moved to another OS/user");
180
+ return JSON.parse((await dpapi("unprotect", Buffer.from(record.blob, "base64"))).toString("utf8"));
181
+ }
182
+ if (record.format === "posix-owner-only-v1" && process.platform !== "win32") {
183
+ const stat = await fs.stat(filename);
184
+ if ((stat.mode & 63) !== 0) throw new Error("Identity file must be owner-only (chmod 600)");
185
+ return record.config;
186
+ }
187
+ throw new Error("Unsupported identity format");
188
+ }
189
+
190
+ // src/agent/client.mjs
191
+ import { WebSocket as WebSocket2 } from "ws";
192
+ import { sign } from "node:crypto";
193
+
194
+ // src/relay.mjs
195
+ import { WebSocketServer, WebSocket } from "ws";
196
+
197
+ // src/security.mjs
198
+ import { randomBytes, randomUUID, createHash, timingSafeEqual, scrypt as scryptCallback } from "node:crypto";
199
+ import { promisify as promisify2 } from "node:util";
200
+ var scrypt = promisify2(scryptCallback);
201
+
202
+ // src/relay.mjs
203
+ var proofMessage = (deviceId, nonce) => "cetrix-agent-v1\n" + deviceId + "\n" + nonce;
204
+
205
+ // src/agent/runtime.mjs
206
+ init_files();
207
+ import fs6 from "node:fs/promises";
208
+ import path8 from "node:path";
209
+ import os from "node:os";
210
+ import dns from "node:dns/promises";
211
+ import https from "node:https";
212
+ import net from "node:net";
213
+
214
+ // src/agent/filesystem-extra.mjs
215
+ init_files();
216
+ import fs3 from "node:fs/promises";
217
+ import path3 from "node:path";
218
+ import { constants as constants2 } from "node:fs";
219
+ import { randomUUID as randomUUID2 } from "node:crypto";
220
+ var TEXT_BYTES = 72 * 1024;
221
+ var RESULT_BYTES = 640 * 1024;
222
+ var SCAN_BYTES = 128 * 1024 * 1024;
223
+ var EDIT_BYTES = 8 * 1024 * 1024;
224
+ var WRITE_BYTES = 256 * 1024;
225
+ var NOFOLLOW = constants2.O_NOFOLLOW || 0;
226
+ var CHUNK_BYTES = 64 * 1024;
227
+ function requestedPath(args, fallback) {
228
+ return args.path ?? args.file_path ?? args.filePath ?? fallback;
229
+ }
230
+ function writeAllowed(config) {
231
+ if (!config.allowWrite) throw new Error("File writes are disabled on this PC");
232
+ }
233
+ function integer(value, fallback, min, max, label) {
234
+ if (value === void 0) return fallback;
235
+ if (!Number.isSafeInteger(value) || value < min || value > max) throw new Error(`Invalid ${label}: expected an integer from ${min} to ${max}`);
236
+ return value;
237
+ }
238
+ function utf8(buffer, incomplete = false) {
239
+ if (buffer.includes(0)) throw new Error("Binary file not supported by text tools");
240
+ try {
241
+ return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(buffer, { stream: incomplete });
242
+ } catch {
243
+ throw new Error("Only valid UTF-8 text is supported by text tools");
244
+ }
245
+ }
246
+ function relative(root, target) {
247
+ return path3.relative(root, target).replaceAll(path3.sep, "/") || ".";
248
+ }
249
+ function resultSize(value) {
250
+ return Buffer.byteLength(JSON.stringify(value));
251
+ }
252
+ async function openText(config, requested2) {
253
+ const target = await safePath(config, requested2);
254
+ const handle = await fs3.open(target, constants2.O_RDONLY | NOFOLLOW);
255
+ try {
256
+ const stat = await handle.stat();
257
+ if (!stat.isFile()) throw new Error("Expected a regular file");
258
+ return { target, handle, stat };
259
+ } catch (error) {
260
+ await handle.close();
261
+ throw error;
262
+ }
263
+ }
264
+ async function* lines(handle, size, start2 = 0) {
265
+ let position = start2, lineStart = start2, pieces = [], kept = 0, lineBytes = 0;
266
+ while (position < size) {
267
+ if (position - start2 >= SCAN_BYTES) throw new Error("Text scan exceeds 128 MiB; use a smaller offset or a negative offset to read the tail");
268
+ const buffer = Buffer.allocUnsafe(Math.min(CHUNK_BYTES, size - position, SCAN_BYTES - (position - start2)));
269
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, position);
270
+ if (!bytesRead) break;
271
+ const chunk = buffer.subarray(0, bytesRead);
272
+ if (chunk.includes(0)) throw new Error("Binary file not supported by text tools");
273
+ let begin = 0;
274
+ while (begin < chunk.length) {
275
+ const newline = chunk.indexOf(10, begin);
276
+ const end = newline === -1 ? chunk.length : newline;
277
+ const part = chunk.subarray(begin, end);
278
+ if (kept < TEXT_BYTES) {
279
+ const prefix = part.subarray(0, TEXT_BYTES - kept);
280
+ pieces.push(prefix);
281
+ kept += prefix.length;
282
+ }
283
+ lineBytes += part.length;
284
+ if (newline === -1) break;
285
+ let text = utf8(Buffer.concat(pieces, kept), lineBytes > kept);
286
+ if (lineBytes === kept && text.endsWith("\r")) text = text.slice(0, -1);
287
+ yield { text, truncated: lineBytes > kept, byteStart: lineStart, byteEnd: position + newline + 1, newline: true };
288
+ lineStart = position + newline + 1;
289
+ pieces = [];
290
+ kept = 0;
291
+ lineBytes = 0;
292
+ begin = newline + 1;
293
+ }
294
+ position += bytesRead;
295
+ }
296
+ if (lineStart < position) yield { text: utf8(Buffer.concat(pieces, kept), lineBytes > kept), truncated: lineBytes > kept, byteStart: lineStart, byteEnd: position, newline: false };
297
+ }
298
+ async function tailStart(handle, size, count) {
299
+ let position = size, found = 0, scanned = 0;
300
+ while (position > 0) {
301
+ if (scanned >= SCAN_BYTES) throw new Error("Requested tail exceeds the 128 MiB scan limit");
302
+ const length = Math.min(CHUNK_BYTES, position, SCAN_BYTES - scanned);
303
+ position -= length;
304
+ const buffer = Buffer.allocUnsafe(length);
305
+ const { bytesRead } = await handle.read(buffer, 0, length, position);
306
+ if (!bytesRead) break;
307
+ for (let index = bytesRead - 1; index >= 0; index--) {
308
+ if (buffer[index] === 10 && position + index !== size - 1 && ++found === count) return position + index + 1;
309
+ }
310
+ scanned += bytesRead;
311
+ }
312
+ return 0;
313
+ }
314
+ async function readFile(config, args) {
315
+ const requested2 = requestedPath(args);
316
+ const offset = integer(args.offset, 0, -1e6, 1e7, "offset");
317
+ const configuredLimit = integer(config.fileReadLineLimit, 1e3, 1, 1e4, "fileReadLineLimit");
318
+ const length = integer(args.length, configuredLimit, 1, 1e4, "length");
319
+ const limit = Math.min(length, configuredLimit);
320
+ const { handle, stat } = await openText(config, requested2);
321
+ try {
322
+ const byteStart = offset < 0 ? await tailStart(handle, stat.size, -offset) : 0;
323
+ let index = 0, count = 0, content = "", contentBytes = 0, lastByte = byteStart;
324
+ let lineTruncated = false, truncated = false;
325
+ for await (const line of lines(handle, stat.size, byteStart)) {
326
+ if (offset >= 0 && index++ < offset) {
327
+ lastByte = line.byteEnd;
328
+ continue;
329
+ }
330
+ const separator = count ? "\n" : "";
331
+ const bytes = Buffer.byteLength(separator + line.text);
332
+ if (count >= limit || contentBytes + bytes > TEXT_BYTES) {
333
+ truncated = true;
334
+ break;
335
+ }
336
+ content += separator + line.text;
337
+ contentBytes += bytes;
338
+ count++;
339
+ lineTruncated ||= line.truncated;
340
+ lastByte = line.byteEnd;
341
+ if (line.truncated) {
342
+ truncated = true;
343
+ break;
344
+ }
345
+ if (count >= limit) {
346
+ truncated = lastByte < stat.size;
347
+ break;
348
+ }
349
+ }
350
+ if (count && !lineTruncated && lastByte > 0 && contentBytes < TEXT_BYTES) {
351
+ const byte = Buffer.alloc(1);
352
+ await handle.read(byte, 0, 1, lastByte - 1);
353
+ if (byte[0] === 10) {
354
+ content += "\n";
355
+ contentBytes++;
356
+ }
357
+ }
358
+ return {
359
+ path: requested2,
360
+ content,
361
+ bytes: contentBytes,
362
+ fileSize: stat.size,
363
+ offset,
364
+ startLine: offset >= 0 ? offset + 1 : byteStart === 0 ? 1 : null,
365
+ linesRead: count,
366
+ nextOffset: lineTruncated ? null : offset < 0 ? byteStart === 0 ? count : Math.min(0, offset + count) : offset + count,
367
+ truncated: truncated || lineTruncated,
368
+ lineTruncated,
369
+ effectiveLength: limit,
370
+ ...lineTruncated ? { note: "One line exceeds the text output limit; nextOffset is unavailable because the line is incomplete." } : {}
371
+ };
372
+ } finally {
373
+ await handle.close();
374
+ }
375
+ }
376
+ async function listDirectory(config, args) {
377
+ const requested2 = requestedPath(args, ".");
378
+ const depth = integer(args.depth, 2, 1, 8, "depth");
379
+ const root = await fs3.realpath(config.root), target = await safePath(config, requested2);
380
+ if (!(await fs3.stat(target)).isDirectory()) throw new Error("Expected a directory");
381
+ const entries = [], visited = /* @__PURE__ */ new Set([target]);
382
+ let budget = 256, truncated = false;
383
+ async function visit(directory, level) {
384
+ const iterator = await fs3.opendir(directory);
385
+ try {
386
+ for await (const entry of iterator) {
387
+ if (entries.length >= 2e3) {
388
+ truncated = true;
389
+ break;
390
+ }
391
+ const candidate = path3.join(directory, entry.name);
392
+ let resolved;
393
+ try {
394
+ resolved = await safePath(config, relative(root, candidate));
395
+ } catch {
396
+ continue;
397
+ }
398
+ const item = { name: entry.name, path: relative(target, candidate), type: entry.isSymbolicLink() ? "symlink" : entry.isDirectory() ? "directory" : "file" };
399
+ const size = resultSize(item) + 1;
400
+ if (budget + size > RESULT_BYTES) {
401
+ truncated = true;
402
+ break;
403
+ }
404
+ entries.push(item);
405
+ budget += size;
406
+ if (entry.isDirectory() && level < depth && !visited.has(resolved)) {
407
+ visited.add(resolved);
408
+ await visit(resolved, level + 1);
409
+ if (truncated) break;
410
+ }
411
+ }
412
+ } finally {
413
+ await iterator.close().catch((error) => {
414
+ if (error.code !== "ERR_DIR_CLOSED") throw error;
415
+ });
416
+ }
417
+ }
418
+ await visit(target, 1);
419
+ return { path: requested2, entries, depth, truncated, maxEntries: 2e3 };
420
+ }
421
+ async function getFileInfo(config, args) {
422
+ const requested2 = requestedPath(args), target = await safePath(config, requested2), stat = await fs3.stat(target);
423
+ const result = {
424
+ path: requested2,
425
+ type: stat.isDirectory() ? "directory" : stat.isFile() ? "file" : "other",
426
+ size: stat.size,
427
+ created: stat.birthtime.toISOString(),
428
+ modified: stat.mtime.toISOString(),
429
+ accessed: stat.atime.toISOString(),
430
+ mode: stat.mode & 511,
431
+ permissions: (stat.mode & 511).toString(8)
432
+ };
433
+ if (!stat.isFile()) return result;
434
+ const { handle } = await openText(config, requested2);
435
+ try {
436
+ let lineCount = 0, lastLine = "", lastNewline = false, lineTruncated = false;
437
+ if (stat.size <= SCAN_BYTES) {
438
+ for await (const line of lines(handle, stat.size)) {
439
+ lineCount++;
440
+ lastLine = line.text;
441
+ lastNewline = line.newline;
442
+ lineTruncated = line.truncated;
443
+ }
444
+ } else {
445
+ lineCount = null;
446
+ const start2 = await tailStart(handle, stat.size, 1);
447
+ for await (const line of lines(handle, stat.size, start2)) {
448
+ lastLine = line.text;
449
+ lastNewline = line.newline;
450
+ lineTruncated = line.truncated;
451
+ }
452
+ }
453
+ return {
454
+ ...result,
455
+ lineCount,
456
+ lastLine: lineCount === null ? null : Math.max(0, lineCount - 1),
457
+ lastLineText: lastLine,
458
+ lastLineTruncated: lineTruncated,
459
+ appendPosition: { byteOffset: stat.size, line: lineCount === null ? null : lastNewline || !lineCount ? lineCount + 1 : lineCount, column: lineTruncated ? null : lastNewline ? 0 : lastLine.length },
460
+ ...lineCount === null ? { note: "Line counting is limited to 128 MiB; byteOffset remains exact." } : {}
461
+ };
462
+ } catch (error) {
463
+ if (/Binary file|valid UTF-8/.test(error.message)) return { ...result, text: false, lineCount: null, lastLine: null, lastLineText: null, appendPosition: { byteOffset: stat.size, line: null, column: null } };
464
+ if (/128 MiB/.test(error.message)) return { ...result, lineCount: null, lastLine: null, lastLineText: null, appendPosition: { byteOffset: stat.size, line: null, column: null }, note: error.message };
465
+ throw error;
466
+ } finally {
467
+ await handle.close();
468
+ }
469
+ }
470
+ async function writeFile(config, args) {
471
+ writeAllowed(config);
472
+ if (typeof args.content !== "string" || args.content.includes("\0") || Buffer.byteLength(args.content) > WRITE_BYTES) throw new Error("Expected UTF-8 text content up to 256 KiB without NUL bytes");
473
+ const mode = args.mode ?? "create";
474
+ if (!["create", "rewrite", "append"].includes(mode)) throw new Error("mode must be create, rewrite or append");
475
+ const target = await safePath(config, requestedPath(args), { write: true });
476
+ const append2 = mode === "append", overwrite = args.overwrite === true || mode === "rewrite";
477
+ const flags = constants2.O_WRONLY | constants2.O_CREAT | NOFOLLOW | (append2 ? constants2.O_APPEND : overwrite ? constants2.O_TRUNC : constants2.O_EXCL);
478
+ const handle = await fs3.open(target, flags, 384);
479
+ try {
480
+ await handle.writeFile(args.content, "utf8");
481
+ } finally {
482
+ await handle.close();
483
+ }
484
+ return { written: true, bytes: Buffer.byteLength(args.content), mode: append2 ? "append" : overwrite ? "rewrite" : "create" };
485
+ }
486
+ async function readEditable(config, requested2) {
487
+ const { handle, stat, target } = await openText(config, requested2);
488
+ try {
489
+ if (stat.size > EDIT_BYTES) throw new Error("edit_block supports files up to 8 MiB");
490
+ const buffer = Buffer.alloc(Math.min(EDIT_BYTES + 1, stat.size + 1));
491
+ let bytes = 0;
492
+ while (bytes < buffer.length) {
493
+ const read = await handle.read(buffer, bytes, buffer.length - bytes, bytes);
494
+ if (!read.bytesRead) break;
495
+ bytes += read.bytesRead;
496
+ }
497
+ if (bytes > stat.size) throw new Error("File changed while being read; retry the edit");
498
+ return { target, stat, text: utf8(buffer.subarray(0, bytes)) };
499
+ } finally {
500
+ await handle.close();
501
+ }
502
+ }
503
+ async function editBlock(config, args) {
504
+ writeAllowed(config);
505
+ const oldText = args.old_string ?? args.oldString, newText = args.new_string ?? args.newString;
506
+ if (typeof oldText !== "string" || !oldText.length || typeof newText !== "string" || oldText.includes("\0") || newText.includes("\0") || Buffer.byteLength(oldText) + Buffer.byteLength(newText) > WRITE_BYTES) throw new Error("Expected nonempty old_string and UTF-8 new_string, together up to 256 KiB");
507
+ const expected = integer(args.expected_replacements ?? args.expectedReplacements, 1, 1, 1e4, "expected_replacements");
508
+ const requested2 = requestedPath(args);
509
+ const { target, stat, text } = await readEditable(config, requested2);
510
+ let count = 0, position = 0;
511
+ while ((position = text.indexOf(oldText, position)) !== -1) {
512
+ count++;
513
+ position += oldText.length;
514
+ }
515
+ if (count !== expected) throw new Error(`Expected ${expected} exact replacement(s), found ${count}; no changes made`);
516
+ const updated = text.replaceAll(oldText, () => newText);
517
+ if (Buffer.byteLength(updated) > EDIT_BYTES) throw new Error("Result exceeds the 8 MiB edit limit; no changes made");
518
+ const temporary = path3.join(path3.dirname(target), `.cetrix-edit-${randomUUID2()}.tmp`);
519
+ try {
520
+ await fs3.writeFile(temporary, updated, { encoding: "utf8", flag: "wx", mode: stat.mode & 511 });
521
+ const checked = await safePath(config, requested2);
522
+ const current = await fs3.stat(checked);
523
+ if (checked !== target || current.size !== stat.size || current.mtimeMs !== stat.mtimeMs || current.ino !== stat.ino) throw new Error("File changed while preparing the edit; no changes made");
524
+ await fs3.rename(temporary, target);
525
+ return { edited: true, replacements: count, bytes: Buffer.byteLength(updated) };
526
+ } finally {
527
+ await fs3.unlink(temporary).catch((error) => {
528
+ if (error.code !== "ENOENT") throw error;
529
+ });
530
+ }
531
+ }
532
+ async function createDirectory(config, args) {
533
+ writeAllowed(config);
534
+ const requested2 = requestedPath(args);
535
+ await safePath(config, requested2, { write: true }).catch((error) => {
536
+ if (error.code !== "ENOENT") throw error;
537
+ });
538
+ const parts = requested2.replaceAll("\\", "/").split("/").filter((part) => part && part !== ".");
539
+ let created = false;
540
+ for (let i = 1; i <= parts.length; i++) {
541
+ const target = await safePath(config, parts.slice(0, i).join("/"), { write: true });
542
+ try {
543
+ await fs3.mkdir(target, { mode: 448 });
544
+ created = true;
545
+ } catch (error) {
546
+ if (error.code !== "EEXIST" || !(await fs3.stat(target)).isDirectory()) throw error;
547
+ }
548
+ }
549
+ return { created, path: requested2 };
550
+ }
551
+ async function moveFile(config, args) {
552
+ writeAllowed(config);
553
+ const sourcePath = args.source ?? args.source_path ?? args.sourcePath ?? requestedPath(args);
554
+ const destinationPath = args.destination ?? args.destination_path ?? args.destinationPath ?? args.new_path ?? args.newPath;
555
+ const source = await safePath(config, sourcePath), destination = await safePath(config, destinationPath, { write: true });
556
+ const root = await fs3.realpath(config.root);
557
+ if (source === root || destination === root) throw new Error("Cannot move or replace the approved root");
558
+ const stat = await fs3.lstat(path3.resolve(root, sourcePath));
559
+ if (stat.isSymbolicLink()) throw new Error("Moving symlinks is not supported");
560
+ await fs3.lstat(destination).then(() => {
561
+ throw new Error("Destination already exists; no changes made");
562
+ }, (error) => {
563
+ if (error.code !== "ENOENT") throw error;
564
+ });
565
+ if (stat.isDirectory()) {
566
+ const descendant = path3.relative(source, destination);
567
+ if (!descendant.startsWith(".." + path3.sep) && descendant !== ".." && !path3.isAbsolute(descendant)) throw new Error("Cannot move a directory inside itself");
568
+ let checked = 0;
569
+ async function inspect(directory) {
570
+ const iterator = await fs3.opendir(directory);
571
+ for await (const entry of iterator) {
572
+ if (++checked > 1e4) throw new Error("Directory move exceeds the 10,000 entry validation limit");
573
+ const child = path3.join(directory, entry.name);
574
+ await safePath(config, relative(root, child));
575
+ if (entry.isSymbolicLink()) throw new Error("Directory moves containing symlinks are not supported");
576
+ const future = path3.join(destination, path3.relative(source, child));
577
+ await safePath(config, relative(root, future), { write: true }).catch((error) => {
578
+ if (error.code !== "ENOENT") throw error;
579
+ });
580
+ for (const deniedPath of config.deniedPaths || []) {
581
+ const denied = await fs3.realpath(deniedPath).catch(() => path3.resolve(deniedPath));
582
+ const inside = path3.relative(denied, future);
583
+ if (inside === "" || inside !== ".." && !inside.startsWith(".." + path3.sep) && !path3.isAbsolute(inside)) throw new Error("Agent/server credentials are protected");
584
+ }
585
+ if (entry.isDirectory()) await inspect(child);
586
+ }
587
+ }
588
+ await inspect(source);
589
+ await fs3.rename(source, destination);
590
+ } else if (stat.isFile()) {
591
+ await fs3.link(source, destination);
592
+ try {
593
+ await fs3.unlink(source);
594
+ } catch (error) {
595
+ await fs3.unlink(destination);
596
+ throw error;
597
+ }
598
+ } else throw new Error("Only regular files and directories can be moved");
599
+ return { moved: true, source: sourcePath, destination: destinationPath };
600
+ }
601
+ async function executeFilesystemTool(config, tool2, args = {}) {
602
+ if (tool2 === "list_directory") return listDirectory(config, args);
603
+ if (tool2 === "read_file") return readFile(config, args);
604
+ if (tool2 === "get_file_info") return getFileInfo(config, args);
605
+ if (tool2 === "write_file") return writeFile(config, args);
606
+ if (tool2 === "edit_block") return editBlock(config, args);
607
+ if (tool2 === "create_directory") return createDirectory(config, args);
608
+ if (tool2 === "move_file") return moveFile(config, args);
609
+ if (tool2 === "read_multiple_files") {
610
+ if (!Array.isArray(args.paths) || !args.paths.length || args.paths.length > 100) throw new Error("paths must contain 1 to 100 relative file paths");
611
+ const files = [];
612
+ let bytes = 256, truncated = false;
613
+ for (const requested2 of args.paths) {
614
+ let result;
615
+ try {
616
+ result = await readFile(config, { ...args, path: requested2 });
617
+ } catch (error) {
618
+ result = { path: typeof requested2 === "string" ? requested2 : null, error: error.message };
619
+ }
620
+ const size = resultSize(result) + 1;
621
+ if (bytes + size > RESULT_BYTES) {
622
+ truncated = true;
623
+ break;
624
+ }
625
+ files.push(result);
626
+ bytes += size;
627
+ }
628
+ return { files, truncated, nextIndex: truncated ? files.length : null };
629
+ }
630
+ throw new Error("Unsupported filesystem tool");
631
+ }
632
+
633
+ // src/agent/processes.mjs
634
+ init_files();
635
+ import { spawn as spawn3 } from "node:child_process";
636
+ import { StringDecoder } from "node:string_decoder";
637
+ import { EventEmitter } from "node:events";
638
+ var states = /* @__PURE__ */ new WeakMap();
639
+ var MAX_OUTPUT = 262144;
640
+ var MAX_PAGE_CHARS = 65536;
641
+ var MAX_SESSIONS = 32;
642
+ var MAX_RUNNING = 8;
643
+ var IDLE_TTL = 10 * 6e4;
644
+ var MAX_RUNTIME = 60 * 6e4;
645
+ function stateFor(config) {
646
+ if (!states.has(config)) states.set(config, { sessions: /* @__PURE__ */ new Map(), generation: 0, grantGenerations: /* @__PURE__ */ new Map() });
647
+ return states.get(config);
648
+ }
649
+ var owner = (context) => String(context.grantId ?? "local");
650
+ var grantGeneration = (state2, grantId) => state2.grantGenerations.get(grantId) || 0;
651
+ function number(value, fallback, min, max) {
652
+ if (value === void 0) return fallback;
653
+ if (!Number.isInteger(value) || value < min || value > max) throw new Error(`Expected an integer between ${min} and ${max}`);
654
+ return value;
655
+ }
656
+ function permission(config) {
657
+ if (!config.allowShell) throw new Error("Shell execution is disabled on this PC");
658
+ }
659
+ function checkActive(context) {
660
+ if (context.signal?.aborted || context.isActive?.() === false) throw new Error("Agent connection or authorization closed");
661
+ }
662
+ function checkCommand(config, command) {
663
+ const tokens = command.toLowerCase().match(/[a-z0-9_.-]+/g) || [];
664
+ for (const blocked of config.blockedCommands || []) {
665
+ if (typeof blocked !== "string" || !blocked.trim()) continue;
666
+ const expected = blocked.toLowerCase().match(/[a-z0-9_.-]+/g) || [];
667
+ if (expected.length && tokens.some((_, index) => expected.every((token, offset) => tokens[index + offset] === token))) throw new Error("Command is blocked by this PC configuration");
668
+ }
669
+ }
670
+ function sessionFor(state2, pid, grantId) {
671
+ const session = state2.sessions.get(number(pid, void 0, 1, 2147483647));
672
+ if (!session || session.grantId !== grantId) throw new Error("Terminal session not found for this authorization");
673
+ session.touched = Date.now();
674
+ return session;
675
+ }
676
+ function append(session, text) {
677
+ if (!text) return;
678
+ session.output += text;
679
+ if (session.output.length > MAX_OUTPUT) {
680
+ let drop = session.output.length - MAX_OUTPUT;
681
+ const newline = session.output.indexOf("\n", drop);
682
+ if (newline >= 0 && newline - drop < MAX_OUTPUT / 4) drop = newline + 1;
683
+ session.baseLine += (session.output.slice(0, drop).match(/\n/g) || []).length;
684
+ session.baseChar += drop;
685
+ session.output = session.output.slice(drop);
686
+ session.truncated = true;
687
+ }
688
+ session.changed = Date.now();
689
+ session.events.emit("change");
690
+ }
691
+ function summary(session) {
692
+ return {
693
+ pid: session.pid,
694
+ sessionId: String(session.pid),
695
+ command: session.command.slice(0, 1024),
696
+ commandTruncated: session.command.length > 1024,
697
+ cwd: session.cwd,
698
+ status: session.status,
699
+ runtimeMs: Date.now() - session.createdAt,
700
+ exitCode: session.exitCode,
701
+ signal: session.signal,
702
+ terminated: session.terminated,
703
+ outputTruncated: session.truncated,
704
+ waitingForInput: session.status === "running" && /(?:^|\n)(?:>>>|\.\.\.|>|\$|[^\n]*[>$#])\s*$/.test(session.output.slice(-200))
705
+ };
706
+ }
707
+ function outputPage(session, args = {}, config = {}) {
708
+ const offset = number(args.offset, 0, -1e8, 1e8), length = Math.min(number(args.length, config.fileReadLineLimit || 1e3, 1, 2e3), config.fileReadLineLimit || 2e3);
709
+ const all = session.output.split("\n");
710
+ if (all.at(-1) === "") all.pop();
711
+ let start2, output, line;
712
+ if (offset === 0) {
713
+ start2 = Math.max(session.cursor - session.baseChar, 0);
714
+ const remaining = session.output.slice(start2), chunks = remaining.split("\n");
715
+ output = chunks.slice(0, length).join("\n");
716
+ if (chunks.length > length) output += "\n";
717
+ line = session.baseLine + (session.output.slice(0, start2).match(/\n/g) || []).length;
718
+ } else {
719
+ start2 = offset < 0 ? Math.max(0, all.length + offset) : Math.max(0, offset - session.baseLine);
720
+ output = all.slice(start2, start2 + length).join("\n");
721
+ line = session.baseLine + start2;
722
+ }
723
+ const pageTruncated = output.length > MAX_PAGE_CHARS;
724
+ output = output.slice(0, MAX_PAGE_CHARS);
725
+ if (offset === 0) session.cursor = session.baseChar + start2 + output.length;
726
+ const linesRead = output ? output.split("\n").length - (output.endsWith("\n") ? 1 : 0) : 0;
727
+ return {
728
+ ...summary(session),
729
+ output,
730
+ offset: line,
731
+ linesRead,
732
+ nextOffset: line + linesRead,
733
+ totalLines: session.baseLine + all.length,
734
+ firstAvailableLine: session.baseLine,
735
+ pageTruncated,
736
+ hasMore: offset === 0 ? session.cursor < session.baseChar + session.output.length : pageTruncated || line + linesRead < session.baseLine + all.length
737
+ };
738
+ }
739
+ async function waitOutput(session, timeout) {
740
+ if (!timeout || session.status !== "running") return;
741
+ const started = Date.now();
742
+ await new Promise((resolve) => {
743
+ let timer;
744
+ const finish2 = () => {
745
+ clearTimeout(timer);
746
+ session.events.off("change", check);
747
+ resolve();
748
+ };
749
+ const check = () => {
750
+ clearTimeout(timer);
751
+ if (session.status !== "running" || Date.now() - started >= timeout) return finish2();
752
+ if (session.baseChar + session.output.length > session.cursor && Date.now() - session.changed >= 120) return finish2();
753
+ timer = setTimeout(check, Math.min(120, timeout - (Date.now() - started)));
754
+ };
755
+ session.events.on("change", check);
756
+ check();
757
+ });
758
+ }
759
+ async function waitFinished(session, timeout = 2e3) {
760
+ if (session.status !== "running") return;
761
+ await new Promise((resolve) => {
762
+ const finish2 = () => {
763
+ clearTimeout(timer);
764
+ session.events.off("change", check);
765
+ resolve();
766
+ };
767
+ const check = () => {
768
+ if (session.status !== "running") finish2();
769
+ };
770
+ const timer = setTimeout(finish2, timeout);
771
+ session.events.on("change", check);
772
+ check();
773
+ });
774
+ }
775
+ function killTree(session, reason = "terminated") {
776
+ if (session.status !== "running") return;
777
+ session.terminated = true;
778
+ session.terminationReason = reason;
779
+ if (process.platform === "win32") {
780
+ const killer = spawn3("taskkill", ["/PID", String(session.pid), "/T", "/F"], { windowsHide: true, stdio: "ignore" });
781
+ killer.on("error", () => session.child.kill("SIGKILL"));
782
+ killer.on("exit", (code) => {
783
+ if (code && session.status === "running") session.child.kill("SIGKILL");
784
+ });
785
+ } else {
786
+ try {
787
+ process.kill(-session.pid, "SIGKILL");
788
+ } catch {
789
+ session.child.kill("SIGKILL");
790
+ }
791
+ }
792
+ }
793
+ function shellCommand(command, shell) {
794
+ const chosen = (shell || (process.platform === "win32" ? "powershell" : "sh")).toLowerCase();
795
+ if (["powershell", "powershell.exe", "pwsh", "pwsh.exe"].includes(chosen)) return [chosen.startsWith("pwsh") ? "pwsh" : "powershell.exe", ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command]];
796
+ if (["cmd", "cmd.exe"].includes(chosen)) return ["cmd.exe", ["/d", "/s", "/c", command]];
797
+ if (["sh", "bash", "zsh"].includes(chosen)) return [chosen, ["-c", command]];
798
+ if (["/bin/sh", "/bin/bash", "/bin/zsh"].includes(chosen)) return [chosen, ["-c", command]];
799
+ throw new Error("Unsupported shell; use powershell, pwsh, cmd, sh, bash or zsh");
800
+ }
801
+ function expire(state2) {
802
+ for (const session of state2.sessions.values()) {
803
+ if (Date.now() - session.touched > IDLE_TTL || Date.now() - session.createdAt > MAX_RUNTIME) {
804
+ killTree(session, "session expired");
805
+ clearInterval(session.expiry);
806
+ state2.sessions.delete(session.pid);
807
+ }
808
+ }
809
+ }
810
+ async function start(config, args, context, state2) {
811
+ if (typeof args.command !== "string" || !args.command.trim() || args.command.length > 65536 || args.command.includes("\0")) throw new Error("Invalid command");
812
+ checkCommand(config, args.command);
813
+ const timeout = number(args.timeout_ms, 1e3, 0, 25e3), generation = state2.generation, grantId = owner(context), grantLease = grantGeneration(state2, grantId);
814
+ const cwd = await safePath(config, args.cwd || ".");
815
+ if (generation !== state2.generation || grantLease !== grantGeneration(state2, grantId)) throw new Error("Agent connection or authorization closed");
816
+ checkActive(context);
817
+ expire(state2);
818
+ if ([...state2.sessions.values()].filter((s) => s.status === "running").length >= MAX_RUNNING) throw new Error("Maximum of 8 running terminal sessions reached");
819
+ if (state2.sessions.size >= MAX_SESSIONS) {
820
+ const finished = [...state2.sessions.values()].find((s) => s.status !== "running");
821
+ if (!finished) throw new Error("Terminal session limit reached");
822
+ clearInterval(finished.expiry);
823
+ state2.sessions.delete(finished.pid);
824
+ }
825
+ const [exe, argv] = shellCommand(args.command, args.shell || config.defaultShell);
826
+ const child = spawn3(exe, argv, { cwd, windowsHide: true, detached: process.platform !== "win32", stdio: ["pipe", "pipe", "pipe"] });
827
+ const session = {
828
+ child,
829
+ pid: child.pid,
830
+ grantId: owner(context),
831
+ command: args.command,
832
+ cwd: args.cwd || ".",
833
+ createdAt: Date.now(),
834
+ touched: Date.now(),
835
+ changed: 0,
836
+ status: "running",
837
+ exitCode: null,
838
+ signal: null,
839
+ terminated: false,
840
+ output: "",
841
+ baseLine: 0,
842
+ baseChar: 0,
843
+ cursor: 0,
844
+ truncated: false,
845
+ events: new EventEmitter()
846
+ };
847
+ const stdout = new StringDecoder("utf8"), stderr = new StringDecoder("utf8");
848
+ child.stdout.on("data", (data) => append(session, stdout.write(data)));
849
+ child.stderr.on("data", (data) => append(session, stderr.write(data)));
850
+ child.stdin.on("error", () => {
851
+ });
852
+ child.on("error", (error) => {
853
+ session.status = "failed";
854
+ append(session, error.message);
855
+ session.events.emit("change");
856
+ });
857
+ child.on("close", (code, signal) => {
858
+ append(session, stdout.end() + stderr.end());
859
+ session.status = session.status === "failed" ? "failed" : "finished";
860
+ session.exitCode = code;
861
+ session.signal = signal;
862
+ session.events.emit("change");
863
+ });
864
+ if (session.pid) state2.sessions.set(session.pid, session);
865
+ try {
866
+ await new Promise((resolve, reject) => {
867
+ child.once("spawn", resolve);
868
+ child.once("error", reject);
869
+ });
870
+ } catch (error) {
871
+ state2.sessions.delete(session.pid);
872
+ throw error;
873
+ }
874
+ if (generation !== state2.generation || grantLease !== grantGeneration(state2, grantId) || context.signal?.aborted || context.isActive?.() === false) {
875
+ killTree(session, "authorization closed");
876
+ state2.sessions.delete(session.pid);
877
+ throw new Error("Agent connection or authorization closed");
878
+ }
879
+ session.expiry = setInterval(() => expire(state2), 3e4);
880
+ session.expiry.unref();
881
+ await waitOutput(session, timeout);
882
+ return outputPage(session, args, config);
883
+ }
884
+ async function listOSProcesses(state2, grantId) {
885
+ const windows = process.platform === "win32";
886
+ const exe = windows ? "powershell.exe" : "ps";
887
+ const args = windows ? ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", "@(Get-Process | Select-Object -First 1000 Id,ProcessName,CPU,WorkingSet64) | ConvertTo-Json -Compress"] : ["-eo", "pid=,comm=,pcpu=,rss="];
888
+ const output = await new Promise((resolve, reject) => {
889
+ const child = spawn3(exe, args, { windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
890
+ let data = "", failure = "", ended = false;
891
+ const timer = setTimeout(() => {
892
+ child.kill("SIGKILL");
893
+ finish2(new Error("Process listing timed out"));
894
+ }, 5e3);
895
+ const finish2 = (error) => {
896
+ if (ended) return;
897
+ ended = true;
898
+ clearTimeout(timer);
899
+ if (error) reject(error);
900
+ else resolve(data);
901
+ };
902
+ child.stdout.on("data", (chunk) => {
903
+ data += chunk.toString();
904
+ if (data.length > 262144) {
905
+ child.kill("SIGKILL");
906
+ finish2(new Error("Process listing is too large"));
907
+ }
908
+ });
909
+ child.stderr.on("data", (chunk) => {
910
+ failure = (failure + chunk).slice(0, 1e3);
911
+ });
912
+ child.on("error", finish2);
913
+ child.on("close", (code) => finish2(code ? new Error(failure || "Process listing failed") : void 0));
914
+ });
915
+ let processes;
916
+ if (windows) {
917
+ const parsed = JSON.parse(output || "[]");
918
+ processes = (Array.isArray(parsed) ? parsed : [parsed]).map((p) => ({ pid: p.Id, name: p.ProcessName, cpuSeconds: p.CPU ?? null, memoryBytes: p.WorkingSet64 }));
919
+ } else processes = output.trim().split("\n").filter(Boolean).map((line) => {
920
+ const match = line.trim().match(/^(\d+)\s+(.+?)\s+([\d.]+)\s+(\d+)$/);
921
+ return match ? { pid: Number(match[1]), name: match[2], cpuPercent: Number(match[3]), memoryBytes: Number(match[4]) * 1024 } : null;
922
+ }).filter(Boolean);
923
+ return { processes: processes.slice(0, 1e3).map((p) => ({ ...p, owned: state2.sessions.get(p.pid)?.grantId === grantId })), truncated: processes.length >= 1e3, killPolicy: "Owned sessions can be terminated; external PIDs require confirm: true. Agent ancestors and other authorizations are protected." };
924
+ }
925
+ async function processParents() {
926
+ const windows = process.platform === "win32";
927
+ const executable = windows ? "powershell.exe" : "ps";
928
+ const args = windows ? ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", "@(Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId) | ConvertTo-Json -Compress"] : ["-eo", "pid=,ppid="];
929
+ const output = await new Promise((resolve, reject) => {
930
+ const child = spawn3(executable, args, { windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
931
+ let data = "", done = false;
932
+ const finish2 = (error) => {
933
+ if (done) return;
934
+ done = true;
935
+ clearTimeout(timer);
936
+ if (error) reject(error);
937
+ else resolve(data);
938
+ };
939
+ const timer = setTimeout(() => {
940
+ child.kill("SIGKILL");
941
+ finish2(new Error("Process ancestry inspection timed out"));
942
+ }, 5e3);
943
+ child.stdout.on("data", (chunk) => {
944
+ data += chunk;
945
+ if (data.length > 512 * 1024) {
946
+ child.kill("SIGKILL");
947
+ finish2(new Error("Process ancestry list is too large"));
948
+ }
949
+ });
950
+ child.on("error", finish2);
951
+ child.on("close", (code) => finish2(code ? new Error("Cannot inspect process ancestry safely") : void 0));
952
+ });
953
+ const parents = /* @__PURE__ */ new Map();
954
+ if (windows) {
955
+ const rows = JSON.parse(output || "[]");
956
+ for (const row of Array.isArray(rows) ? rows : [rows]) parents.set(Number(row.ProcessId), Number(row.ParentProcessId));
957
+ } else for (const line of output.trim().split("\n")) {
958
+ const match = line.trim().match(/^(\d+)\s+(\d+)$/);
959
+ if (match) parents.set(Number(match[1]), Number(match[2]));
960
+ }
961
+ return parents;
962
+ }
963
+ async function killExternalProcess(state2, args, grantId, context) {
964
+ const pid = number(args.pid, void 0, 1, 2147483647);
965
+ if (pid <= 1 || pid === process.pid || pid === process.ppid) throw new Error("Agent and ancestor processes are protected");
966
+ if (args.confirm !== true) throw new Error("Terminating an external process requires explicit confirm: true");
967
+ const generation = state2.generation, lease = grantGeneration(state2, grantId), parents = await processParents();
968
+ if (generation !== state2.generation || lease !== grantGeneration(state2, grantId)) throw new Error("Agent connection or authorization closed");
969
+ checkActive(context);
970
+ if (!parents.has(pid)) throw new Error("Process not found");
971
+ const seen = /* @__PURE__ */ new Set();
972
+ let ancestor = process.pid;
973
+ while (ancestor > 1 && !seen.has(ancestor)) {
974
+ if (ancestor === pid) throw new Error("Agent and ancestor processes are protected");
975
+ seen.add(ancestor);
976
+ const parent = parents.get(ancestor);
977
+ if (parent === void 0) break;
978
+ ancestor = parent;
979
+ }
980
+ seen.clear();
981
+ ancestor = pid;
982
+ while (ancestor > 1 && !seen.has(ancestor)) {
983
+ const managed = state2.sessions.get(ancestor);
984
+ if (managed && managed.grantId !== grantId) throw new Error("Process belongs to another authorization");
985
+ seen.add(ancestor);
986
+ ancestor = parents.get(ancestor) || 0;
987
+ }
988
+ process.kill(pid, "SIGKILL");
989
+ return { pid, terminationRequested: true, external: true, scope: "Only the explicitly confirmed PID was signaled; OS permissions apply" };
990
+ }
991
+ async function executeProcessTool(config, tool2, args = {}, context = {}) {
992
+ permission(config);
993
+ checkActive(context);
994
+ const state2 = stateFor(config), grantId = owner(context);
995
+ expire(state2);
996
+ if (tool2 === "start_process") return start(config, args, context, state2);
997
+ if (tool2 === "list_sessions") return { sessions: [...state2.sessions.values()].filter((s) => s.grantId === grantId).map(summary) };
998
+ if (tool2 === "list_processes") return listOSProcesses(state2, grantId);
999
+ if (tool2 === "kill_process" && !state2.sessions.has(args.pid)) return killExternalProcess(state2, args, grantId, context);
1000
+ const session = sessionFor(state2, args.pid, grantId);
1001
+ if (tool2 === "force_terminate" || tool2 === "kill_process") {
1002
+ killTree(session);
1003
+ await waitFinished(session);
1004
+ return { ...summary(session), terminationRequested: true };
1005
+ }
1006
+ if (tool2 === "interact_with_process") {
1007
+ if (session.status !== "running") throw new Error("Terminal session has finished");
1008
+ if (typeof args.input !== "string" || Buffer.byteLength(args.input) > 65536) throw new Error("Process input must be text up to 64 KiB");
1009
+ checkCommand(config, args.input);
1010
+ const input = args.input.endsWith("\n") ? args.input : args.input + "\n";
1011
+ await new Promise((resolve, reject) => session.child.stdin.write(input, (error) => error ? reject(error) : resolve()));
1012
+ await waitOutput(session, number(args.timeout_ms, 1e3, 0, 25e3));
1013
+ return outputPage(session, args, config);
1014
+ }
1015
+ if (tool2 === "read_process_output") {
1016
+ if (!args.offset) await waitOutput(session, number(args.timeout_ms, 1e3, 0, 25e3));
1017
+ return outputPage(session, args, config);
1018
+ }
1019
+ throw new Error("Unknown process tool");
1020
+ }
1021
+ async function cleanupProcessSessions(config, { grantId, sessionId } = {}) {
1022
+ const state2 = states.get(config);
1023
+ if (!state2) return;
1024
+ if (grantId !== void 0) state2.grantGenerations.set(String(grantId), grantGeneration(state2, String(grantId)) + 1);
1025
+ else if (sessionId === void 0) {
1026
+ state2.generation++;
1027
+ state2.grantGenerations.clear();
1028
+ }
1029
+ const removed = [];
1030
+ for (const [pid, session] of state2.sessions) {
1031
+ if (grantId !== void 0 && session.grantId !== String(grantId)) continue;
1032
+ if (sessionId !== void 0 && String(pid) !== String(sessionId)) continue;
1033
+ killTree(session, "authorization or connection closed");
1034
+ clearInterval(session.expiry);
1035
+ state2.sessions.delete(pid);
1036
+ removed.push(waitFinished(session));
1037
+ }
1038
+ await Promise.all(removed);
1039
+ }
1040
+
1041
+ // src/agent/search.mjs
1042
+ init_files();
1043
+ import fs4 from "node:fs/promises";
1044
+ import { constants as constants3 } from "node:fs";
1045
+ import path4 from "node:path";
1046
+ import { randomUUID as randomUUID3 } from "node:crypto";
1047
+ import { Worker } from "node:worker_threads";
1048
+ var states2 = /* @__PURE__ */ new WeakMap();
1049
+ var MAX_RESULTS = 2e3;
1050
+ var MAX_VISITED = 1e4;
1051
+ var MAX_BYTES = 32 * 1024 * 1024;
1052
+ var MAX_FILE = 1024 * 1024;
1053
+ var MAX_RUNTIME2 = 3e4;
1054
+ var TTL = 5 * 6e4;
1055
+ var matcherSource = `
1056
+ const {parentPort,workerData}=require('node:worker_threads');
1057
+ const regex=workerData.literal?null:new RegExp(workerData.pattern,workerData.ignoreCase?'i':'');
1058
+ const pattern=workerData.ignoreCase?workerData.pattern.toLowerCase():workerData.pattern;
1059
+ parentPort.on('message',({id,text,limit})=>{
1060
+ try {
1061
+ const lines=text.split(/\\r?\\n/),matches=[];
1062
+ for(let i=0;i<lines.length && matches.length<limit;i++) {
1063
+ const haystack=workerData.ignoreCase?lines[i].toLowerCase():lines[i];
1064
+ const match=regex?regex.exec(lines[i]):null;
1065
+ const position=regex?(match?match.index:-1):haystack.indexOf(pattern);
1066
+ if(position>=0)matches.push({line:i+1,column:position+1,content:lines[i].slice(Math.max(0,position-100),Math.max(0,position-100)+2000)});
1067
+ }
1068
+ parentPort.postMessage({id,matches});
1069
+ } catch(error) {parentPort.postMessage({id,error:error.message});}
1070
+ });`;
1071
+ function stateFor2(config) {
1072
+ if (!states2.has(config)) states2.set(config, { sessions: /* @__PURE__ */ new Map(), generation: 0, grantGenerations: /* @__PURE__ */ new Map() });
1073
+ return states2.get(config);
1074
+ }
1075
+ var owner2 = (context) => String(context.grantId ?? "local");
1076
+ var grantGeneration2 = (state2, grantId) => state2.grantGenerations.get(grantId) || 0;
1077
+ function integer2(value, fallback, min, max) {
1078
+ if (value === void 0) return fallback;
1079
+ if (!Number.isInteger(value) || value < min || value > max) throw new Error(`Expected an integer between ${min} and ${max}`);
1080
+ return value;
1081
+ }
1082
+ function glob(pattern, ignoreCase = true) {
1083
+ const alternatives = pattern.split("|").map((part) => part.split("").map((c) => c === "*" ? ".*" : c === "?" ? "." : c.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(""));
1084
+ return new RegExp("^(?:" + alternatives.join("|") + ")$", ignoreCase ? "i" : "");
1085
+ }
1086
+ function summary2(session) {
1087
+ return {
1088
+ sessionId: session.id,
1089
+ path: session.path,
1090
+ pattern: session.pattern,
1091
+ searchType: session.searchType,
1092
+ status: session.status,
1093
+ runtimeMs: (session.finishedAt || Date.now()) - session.createdAt,
1094
+ totalResults: session.results.length,
1095
+ visited: session.visited,
1096
+ skipped: session.skipped,
1097
+ truncated: session.truncated,
1098
+ reason: session.reason || null
1099
+ };
1100
+ }
1101
+ function page(session, args = {}) {
1102
+ const offset = integer2(args.offset, 0, -1e7, 1e7), length = Math.min(integer2(args.length, 100, 1, 2e3), 200);
1103
+ const start2 = offset < 0 ? Math.max(0, session.results.length + offset) : offset;
1104
+ const count = offset < 0 ? Math.min(-offset, 200) : length;
1105
+ const results = [];
1106
+ let bytes = 0;
1107
+ for (const result of session.results.slice(start2, start2 + count)) {
1108
+ const size = Buffer.byteLength(JSON.stringify(result));
1109
+ if (bytes + size > 4e5) break;
1110
+ results.push(result);
1111
+ bytes += size;
1112
+ }
1113
+ return {
1114
+ ...summary2(session),
1115
+ results,
1116
+ offset: start2,
1117
+ nextOffset: start2 + results.length,
1118
+ hasMore: start2 + results.length < session.results.length,
1119
+ isComplete: session.status !== "running"
1120
+ };
1121
+ }
1122
+ function finish(session, status, reason) {
1123
+ if (session.status !== "running") return;
1124
+ session.status = status;
1125
+ session.reason = reason;
1126
+ session.finishedAt = Date.now();
1127
+ clearTimeout(session.deadline);
1128
+ for (const pending of session.pending.values()) {
1129
+ clearTimeout(pending.timer);
1130
+ pending.reject(new Error(reason || "Search ended"));
1131
+ }
1132
+ session.pending.clear();
1133
+ session.worker?.terminate();
1134
+ }
1135
+ function expire2(state2) {
1136
+ for (const [id, session] of state2.sessions) if (session.finishedAt && Date.now() - session.finishedAt > TTL) {
1137
+ clearTimeout(session.expiry);
1138
+ state2.sessions.delete(id);
1139
+ }
1140
+ }
1141
+ function active(session) {
1142
+ return session.status === "running";
1143
+ }
1144
+ function matcher(session, options) {
1145
+ const worker = new Worker(matcherSource, { eval: true, workerData: options, resourceLimits: { maxOldGenerationSizeMb: 32, maxYoungGenerationSizeMb: 8 } });
1146
+ session.worker = worker;
1147
+ worker.on("message", (message) => {
1148
+ const pending = session.pending.get(message.id);
1149
+ if (!pending) return;
1150
+ session.pending.delete(message.id);
1151
+ clearTimeout(pending.timer);
1152
+ if (message.error) pending.reject(new Error(message.error));
1153
+ else pending.resolve(message.matches);
1154
+ });
1155
+ const fail2 = (error) => {
1156
+ for (const pending of session.pending.values()) {
1157
+ clearTimeout(pending.timer);
1158
+ pending.reject(error);
1159
+ }
1160
+ session.pending.clear();
1161
+ };
1162
+ worker.on("error", fail2);
1163
+ worker.on("exit", () => fail2(new Error("Search matcher stopped")));
1164
+ return (text) => new Promise((resolve, reject) => {
1165
+ if (!active(session)) {
1166
+ reject(new Error("Search stopped"));
1167
+ return;
1168
+ }
1169
+ const id = ++session.sequence;
1170
+ const timer = setTimeout(() => {
1171
+ session.pending.delete(id);
1172
+ worker.terminate();
1173
+ reject(new Error("Pattern matching exceeded its time limit"));
1174
+ }, 1500);
1175
+ session.pending.set(id, { resolve, reject, timer });
1176
+ worker.postMessage({ id, text, limit: MAX_RESULTS - session.results.length });
1177
+ });
1178
+ }
1179
+ async function search(config, session, args, initial) {
1180
+ const ignoreCase = args.ignoreCase !== false;
1181
+ let fileGlob;
1182
+ if (args.searchType === "files" && !args.literalSearch && /^[*?]/.test(args.pattern)) fileGlob = glob(args.pattern, ignoreCase);
1183
+ const filter = args.filePattern ? glob(args.filePattern, ignoreCase) : null;
1184
+ const match = matcher(session, { pattern: fileGlob ? "" : args.pattern, literal: !!args.literalSearch, ignoreCase });
1185
+ const visited = /* @__PURE__ */ new Set(), queue = [{ relative: session.path, resolved: initial, depth: 0 }];
1186
+ const stopLimit = (reason) => {
1187
+ session.truncated = true;
1188
+ finish(session, "completed", reason);
1189
+ };
1190
+ try {
1191
+ while (queue.length && active(session)) {
1192
+ const entry = queue.pop();
1193
+ if (++session.visited > MAX_VISITED) {
1194
+ stopLimit("Traversal limit reached");
1195
+ break;
1196
+ }
1197
+ let resolved, stat;
1198
+ try {
1199
+ resolved = await safePath(config, entry.relative);
1200
+ stat = await fs4.stat(resolved);
1201
+ } catch {
1202
+ session.skipped++;
1203
+ continue;
1204
+ }
1205
+ if (!active(session)) break;
1206
+ if (visited.has(resolved)) {
1207
+ session.skipped++;
1208
+ continue;
1209
+ }
1210
+ visited.add(resolved);
1211
+ if (stat.isDirectory()) {
1212
+ if (entry.depth >= 64) {
1213
+ session.skipped++;
1214
+ continue;
1215
+ }
1216
+ let directory;
1217
+ try {
1218
+ directory = await fs4.opendir(resolved);
1219
+ } catch {
1220
+ session.skipped++;
1221
+ continue;
1222
+ }
1223
+ try {
1224
+ for await (const child of directory) {
1225
+ if (!active(session)) break;
1226
+ if (queue.length + session.visited >= MAX_VISITED) {
1227
+ stopLimit("Traversal limit reached");
1228
+ break;
1229
+ }
1230
+ queue.push({ relative: path4.join(entry.relative, child.name), depth: entry.depth + 1 });
1231
+ }
1232
+ } catch {
1233
+ session.skipped++;
1234
+ }
1235
+ continue;
1236
+ }
1237
+ if (!stat.isFile()) continue;
1238
+ const basename = path4.basename(entry.relative), relative2 = entry.relative.replaceAll("\\", "/").replace(/^\.\//, "");
1239
+ if (filter && !filter.test(relative2) && !filter.test(basename)) continue;
1240
+ if (session.searchType === "files") {
1241
+ const matches = fileGlob ? fileGlob.test(basename) : (await match(basename)).length > 0;
1242
+ if (!active(session)) break;
1243
+ if (matches) session.results.push({ path: relative2, type: "file" });
1244
+ if (matches && args.earlyTermination !== false && (ignoreCase ? basename.toLowerCase() === args.pattern.toLowerCase() : basename === args.pattern)) {
1245
+ finish(session, "completed", "Exact filename found");
1246
+ break;
1247
+ }
1248
+ } else {
1249
+ if (stat.size > MAX_FILE) {
1250
+ session.skipped++;
1251
+ continue;
1252
+ }
1253
+ if (session.bytes + stat.size > MAX_BYTES) {
1254
+ stopLimit("Search byte limit reached");
1255
+ break;
1256
+ }
1257
+ let handle, text;
1258
+ try {
1259
+ handle = await fs4.open(resolved, constants3.O_RDONLY | (constants3.O_NOFOLLOW || 0));
1260
+ const buffer = Buffer.alloc(MAX_FILE + 1), { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
1261
+ session.bytes += bytesRead;
1262
+ if (bytesRead > MAX_FILE || buffer.subarray(0, bytesRead).includes(0)) {
1263
+ session.skipped++;
1264
+ continue;
1265
+ }
1266
+ text = buffer.subarray(0, bytesRead).toString("utf8");
1267
+ } catch {
1268
+ session.skipped++;
1269
+ continue;
1270
+ } finally {
1271
+ await handle?.close();
1272
+ }
1273
+ if (!active(session)) break;
1274
+ const matches = await match(text);
1275
+ if (!active(session)) break;
1276
+ for (const result of matches) session.results.push({ path: relative2, type: "content", ...result });
1277
+ if (matches.length && args.earlyTermination === true) {
1278
+ finish(session, "completed", "First matching file found");
1279
+ break;
1280
+ }
1281
+ }
1282
+ if (session.results.length >= MAX_RESULTS) {
1283
+ stopLimit("Result limit reached");
1284
+ break;
1285
+ }
1286
+ }
1287
+ finish(session, "completed");
1288
+ } catch (error) {
1289
+ if (active(session)) finish(session, "failed", error.message);
1290
+ } finally {
1291
+ clearTimeout(session.deadline);
1292
+ session.worker?.terminate();
1293
+ session.expiry = setTimeout(() => stateFor2(config).sessions.delete(session.id), TTL);
1294
+ session.expiry.unref();
1295
+ }
1296
+ }
1297
+ async function executeSearchTool(config, tool2, args = {}, context = {}) {
1298
+ if (context.signal?.aborted || context.isActive?.() === false) throw new Error("Agent connection or authorization closed");
1299
+ const state2 = stateFor2(config), grantId = owner2(context);
1300
+ expire2(state2);
1301
+ if (tool2 === "list_searches") return { searches: [...state2.sessions.values()].filter((s) => s.grantId === grantId).map(summary2) };
1302
+ if (tool2 === "start_search") {
1303
+ if (typeof args.pattern !== "string" || !args.pattern || args.pattern.length > 1024) throw new Error("Search pattern must be 1 to 1024 characters");
1304
+ if (!["files", "content"].includes(args.searchType)) throw new Error("searchType must be files or content");
1305
+ if (args.filePattern !== void 0 && (typeof args.filePattern !== "string" || args.filePattern.length > 300)) throw new Error("Invalid filePattern");
1306
+ if (!args.literalSearch && !(args.searchType === "files" && /^[*?]/.test(args.pattern))) new RegExp(args.pattern, args.ignoreCase !== false ? "i" : "");
1307
+ if ([...state2.sessions.values()].filter((s) => s.status === "running").length >= 4) throw new Error("Maximum of 4 active searches reached");
1308
+ if (state2.sessions.size >= 16) {
1309
+ const done = [...state2.sessions.values()].find((s) => s.status !== "running");
1310
+ if (!done) throw new Error("Search session limit reached");
1311
+ clearTimeout(done.expiry);
1312
+ state2.sessions.delete(done.id);
1313
+ }
1314
+ const generation = state2.generation, lease = grantGeneration2(state2, grantId), relative2 = args.path || ".", initial = await safePath(config, relative2);
1315
+ if (generation !== state2.generation || lease !== grantGeneration2(state2, grantId) || context.signal?.aborted || context.isActive?.() === false) throw new Error("Agent connection or authorization closed");
1316
+ if ([...state2.sessions.values()].filter((s) => s.status === "running").length >= 4) throw new Error("Maximum of 4 active searches reached");
1317
+ const session2 = {
1318
+ id: randomUUID3(),
1319
+ grantId,
1320
+ path: relative2,
1321
+ pattern: args.pattern,
1322
+ searchType: args.searchType,
1323
+ status: "running",
1324
+ createdAt: Date.now(),
1325
+ results: [],
1326
+ visited: 0,
1327
+ skipped: 0,
1328
+ bytes: 0,
1329
+ truncated: false,
1330
+ sequence: 0,
1331
+ pending: /* @__PURE__ */ new Map()
1332
+ };
1333
+ state2.sessions.set(session2.id, session2);
1334
+ session2.deadline = setTimeout(() => {
1335
+ session2.truncated = true;
1336
+ finish(session2, "completed", "Search time limit reached");
1337
+ }, MAX_RUNTIME2);
1338
+ session2.deadline.unref();
1339
+ session2.task = search(config, session2, args, initial);
1340
+ return page(session2);
1341
+ }
1342
+ const session = state2.sessions.get(args.sessionId);
1343
+ if (!session || session.grantId !== grantId) throw new Error("Search session not found for this authorization");
1344
+ if (tool2 === "get_more_search_results") return page(session, args);
1345
+ if (tool2 === "stop_search") {
1346
+ finish(session, "stopped", "Stopped by request");
1347
+ return page(session, args);
1348
+ }
1349
+ throw new Error("Unknown search tool");
1350
+ }
1351
+ async function cleanupSearchSessions(config, { grantId, sessionId } = {}) {
1352
+ const state2 = states2.get(config);
1353
+ if (!state2) return;
1354
+ if (grantId !== void 0) state2.grantGenerations.set(String(grantId), grantGeneration2(state2, String(grantId)) + 1);
1355
+ else if (sessionId === void 0) {
1356
+ state2.generation++;
1357
+ state2.grantGenerations.clear();
1358
+ }
1359
+ for (const [id, session] of state2.sessions) {
1360
+ if (grantId !== void 0 && session.grantId !== String(grantId)) continue;
1361
+ if (sessionId !== void 0 && id !== sessionId) continue;
1362
+ finish(session, "stopped", "Authorization or connection closed");
1363
+ clearTimeout(session.expiry);
1364
+ state2.sessions.delete(id);
1365
+ }
1366
+ }
1367
+
1368
+ // src/agent/documents.mjs
1369
+ import fs5 from "node:fs/promises";
1370
+ import path6 from "node:path";
1371
+ import { constants as constants4 } from "node:fs";
1372
+ import { randomUUID as randomUUID4 } from "node:crypto";
1373
+ import { deflateSync } from "node:zlib";
1374
+ import { unzipSync, zipSync, strToU8, strFromU8 } from "fflate";
1375
+ import { XMLParser, XMLValidator } from "fast-xml-parser";
1376
+ import XLSX from "xlsx";
1377
+ import { PDFDocument, degrees } from "pdf-lib";
1378
+ import fontkit from "@pdf-lib/fontkit";
1379
+
1380
+ // src/config.mjs
1381
+ import path5 from "node:path";
1382
+ import { fileURLToPath } from "node:url";
1383
+ var projectDir = path5.resolve(path5.dirname(fileURLToPath(import.meta.url)), "..");
1384
+
1385
+ // src/agent/documents.mjs
1386
+ var MAX_FILE2 = 20 * 1024 * 1024;
1387
+ var MAX_PART = 16 * 1024 * 1024;
1388
+ var MAX_EXPANDED = 64 * 1024 * 1024;
1389
+ var MAX_RESULT = 680 * 1024;
1390
+ var MAX_INPUT = 512 * 1024;
1391
+ var IMAGE_LIMIT = 480 * 1024;
1392
+ var XML = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: "@", trimValues: false, parseTagValue: false, processEntities: true });
1393
+ var ORDERED_XML = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: "@", preserveOrder: true, trimValues: false, parseTagValue: false, processEntities: true });
1394
+ function bounded(result) {
1395
+ if (Buffer.byteLength(JSON.stringify(result)) > MAX_RESULT) throw new Error("Document result exceeds 680 KiB; use a smaller range or page length");
1396
+ return result;
1397
+ }
1398
+ function integer3(value, fallback, min, max, name) {
1399
+ if (value === void 0) return fallback;
1400
+ if (!Number.isInteger(value) || value < min || value > max) throw new Error(`${name} must be an integer from ${min} to ${max}`);
1401
+ return value;
1402
+ }
1403
+ function textInput(value, name = "content") {
1404
+ if (typeof value !== "string" || Buffer.byteLength(value) > MAX_INPUT || /[\x00-\x08\x0B\x0C\x0E-\x1F]/.test(value)) throw new Error(`${name} must be text up to 512 KiB without invalid control characters`);
1405
+ return value;
1406
+ }
1407
+ async function readBytes(file, limit = MAX_FILE2) {
1408
+ const handle = await fs5.open(file, constants4.O_RDONLY | (constants4.O_NOFOLLOW || 0));
1409
+ try {
1410
+ const stat = await handle.stat();
1411
+ if (!stat.isFile() || stat.size > limit) throw new Error(`Document must be a regular file no larger than ${Math.floor(limit / 1024)} KiB`);
1412
+ const buffer = Buffer.alloc(stat.size + 1);
1413
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
1414
+ if (bytesRead !== stat.size) throw new Error("Document changed while it was being read");
1415
+ return buffer.subarray(0, bytesRead);
1416
+ } finally {
1417
+ await handle.close();
1418
+ }
1419
+ }
1420
+ function writable(config) {
1421
+ if (!config.allowWrite) throw new Error("File writes are disabled on this PC");
1422
+ }
1423
+ async function saveBytes(config, file, bytes, overwrite = false) {
1424
+ writable(config);
1425
+ if (bytes.length > MAX_FILE2) throw new Error("Generated document exceeds 20 MiB");
1426
+ const previous = await fs5.lstat(file).catch((error) => {
1427
+ if (error.code === "ENOENT") return null;
1428
+ throw error;
1429
+ });
1430
+ if (previous && (!previous.isFile() || previous.isSymbolicLink())) throw new Error("Destination must be a regular file");
1431
+ if (previous && !overwrite) throw new Error("File already exists; set overwrite=true explicitly");
1432
+ const temporary = path6.join(path6.dirname(file), `.cetrix-document-${randomUUID4()}.tmp`);
1433
+ try {
1434
+ await fs5.writeFile(temporary, bytes, { flag: "wx", mode: previous ? previous.mode & 511 : 384 });
1435
+ if (overwrite) await fs5.rename(temporary, file);
1436
+ else {
1437
+ await fs5.link(temporary, file);
1438
+ await fs5.unlink(temporary);
1439
+ }
1440
+ } finally {
1441
+ await fs5.unlink(temporary).catch(() => {
1442
+ });
1443
+ }
1444
+ return { written: true, bytes: bytes.length, format: path6.extname(file).slice(1) };
1445
+ }
1446
+ function validateXml(xml) {
1447
+ if (/<!DOCTYPE|<!ENTITY/i.test(xml)) throw new Error("XML document types and entity declarations are not allowed");
1448
+ const result = XMLValidator.validate(xml);
1449
+ if (result !== true) throw new Error(`Malformed document XML: ${result.err.msg}`);
1450
+ return xml;
1451
+ }
1452
+ function escapeXml(value) {
1453
+ return String(value).replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&apos;" })[char]);
1454
+ }
1455
+ function prettyXml(xml) {
1456
+ const textElements = [];
1457
+ const protectedXml = xml.replace(/<(?:\w+:)?t\b[^>]*>[\s\S]*?<\/(?:\w+:)?t>/g, (value) => `${textElements.push(value) - 1}`);
1458
+ return protectedXml.replace(/>\s*</g, ">\n<").replace(/\u0001(\d+)\u0002/g, (_, index) => textElements[Number(index)]);
1459
+ }
1460
+ var CRC_TABLE = Array.from({ length: 256 }, (_, index) => {
1461
+ let crc = index;
1462
+ for (let bit = 0; bit < 8; bit++) crc = crc >>> 1 ^ 3988292384 & -(crc & 1);
1463
+ return crc >>> 0;
1464
+ });
1465
+ function crc32(bytes) {
1466
+ let crc = 4294967295;
1467
+ for (const byte of bytes) crc = crc >>> 8 ^ CRC_TABLE[(crc ^ byte) & 255];
1468
+ return (crc ^ 4294967295) >>> 0;
1469
+ }
1470
+ function archive(bytes) {
1471
+ let eocd = -1;
1472
+ for (let i = bytes.length - 22; i >= Math.max(0, bytes.length - 65557); i--) {
1473
+ if (bytes.readUInt32LE(i) === 101010256 && i + 22 + bytes.readUInt16LE(i + 20) === bytes.length) {
1474
+ eocd = i;
1475
+ break;
1476
+ }
1477
+ }
1478
+ if (eocd < 0) throw new Error("Malformed ZIP document");
1479
+ const count = bytes.readUInt16LE(eocd + 10), directorySize = bytes.readUInt32LE(eocd + 12), start2 = bytes.readUInt32LE(eocd + 16);
1480
+ if (count > 2e3 || count === 65535 || start2 + directorySize !== eocd || bytes.readUInt16LE(eocd + 4) || bytes.readUInt16LE(eocd + 6)) throw new Error("ZIP document has unsupported or excessive entries");
1481
+ const expected = /* @__PURE__ */ new Map();
1482
+ let cursor = start2, expanded = 0;
1483
+ for (let i = 0; i < count; i++) {
1484
+ if (cursor + 46 > eocd || bytes.readUInt32LE(cursor) !== 33639248) throw new Error("Malformed ZIP central directory");
1485
+ const flags = bytes.readUInt16LE(cursor + 8), compression = bytes.readUInt16LE(cursor + 10), crc = bytes.readUInt32LE(cursor + 16);
1486
+ const compressed = bytes.readUInt32LE(cursor + 20), size = bytes.readUInt32LE(cursor + 24);
1487
+ const nameLength = bytes.readUInt16LE(cursor + 28), extraLength = bytes.readUInt16LE(cursor + 30), commentLength = bytes.readUInt16LE(cursor + 32), local = bytes.readUInt32LE(cursor + 42);
1488
+ const end = cursor + 46 + nameLength + extraLength + commentLength;
1489
+ if (end > eocd || local + 30 > start2 || bytes.readUInt32LE(local) !== 67324752) throw new Error("Malformed ZIP entry");
1490
+ const name = bytes.subarray(cursor + 46, cursor + 46 + nameLength).toString("utf8");
1491
+ expanded += size;
1492
+ if (flags & 1 || ![0, 8].includes(compression) || size > MAX_PART || expanded > MAX_EXPANDED || size > 1024 * 1024 && size > Math.max(1, compressed) * 300) throw new Error("ZIP document exceeds decompression limits");
1493
+ if (!name || name.includes("\0") || name.includes("\\") || name.startsWith("/") || name.split("/").includes("..") || expected.has(name) || name === "__proto__" || name === "constructor") throw new Error("Unsafe or duplicate ZIP entry");
1494
+ const payloadStart = local + 30 + bytes.readUInt16LE(local + 26) + bytes.readUInt16LE(local + 28);
1495
+ if (payloadStart + compressed > start2) throw new Error("Malformed ZIP entry data");
1496
+ expected.set(name, { size, crc });
1497
+ cursor = end;
1498
+ }
1499
+ if (cursor !== eocd) throw new Error("Malformed ZIP directory size");
1500
+ const entries = unzipSync(bytes);
1501
+ if (Object.keys(entries).length !== expected.size) throw new Error("ZIP entry count mismatch");
1502
+ for (const [name, item] of expected) {
1503
+ if (!entries[name] || entries[name].length !== item.size || crc32(entries[name]) !== item.crc) throw new Error("ZIP document integrity check failed");
1504
+ }
1505
+ return entries;
1506
+ }
1507
+ function xmlPart(entries, name) {
1508
+ if (!entries[name]) throw new Error(`Document is missing ${name}`);
1509
+ return validateXml(strFromU8(entries[name]));
1510
+ }
1511
+ function paginated(items, args, defaultLength = 1e3) {
1512
+ const offset = integer3(args.offset, 0, -1e6, 1e6, "offset");
1513
+ const length = integer3(args.length, defaultLength, 1, 1e4, "length");
1514
+ const start2 = offset < 0 ? Math.max(0, items.length + offset) : offset;
1515
+ const end = offset < 0 ? items.length : Math.min(items.length, start2 + length);
1516
+ return { items: items.slice(start2, end), offset: start2, total: items.length, truncated: end < items.length };
1517
+ }
1518
+ function docxText(nodes, textNode = false) {
1519
+ let output = "";
1520
+ for (const node of nodes || []) for (const [key, value] of Object.entries(node)) {
1521
+ if (key === "#text" && textNode) output += value;
1522
+ else if (key === "w:tab") output += " ";
1523
+ else if (key === "w:br" || key === "w:cr") output += "\n";
1524
+ else if (key !== ":@" && Array.isArray(value)) output += docxText(value, key === "w:t");
1525
+ }
1526
+ return output;
1527
+ }
1528
+ function docxNodes(nodes, name) {
1529
+ const found = [];
1530
+ for (const node of nodes || []) for (const [key, value] of Object.entries(node)) {
1531
+ if (key === name) found.push(node);
1532
+ else if (key !== ":@" && Array.isArray(value)) found.push(...docxNodes(value, name));
1533
+ }
1534
+ return found;
1535
+ }
1536
+ function readDocx(bytes, args) {
1537
+ const entries = archive(bytes), xml = xmlPart(entries, "word/document.xml");
1538
+ if (args.offset) {
1539
+ const data2 = paginated(prettyXml(xml).split("\n"), args);
1540
+ return bounded({ format: "docx-xml", part: "word/document.xml", content: data2.items.join("\n"), offset: data2.offset, totalLines: data2.total, truncated: data2.truncated });
1541
+ }
1542
+ const document = ORDERED_XML.parse(xml).find((node) => node["w:document"])?.["w:document"];
1543
+ const bodyNodes = document?.find((node) => node["w:body"])?.["w:body"];
1544
+ if (!bodyNodes) throw new Error("DOCX has no supported WordprocessingML body");
1545
+ const body = bodyNodes.filter((node) => Object.keys(node).some((key) => key !== "#text" && key !== ":@"));
1546
+ const outline = body.flatMap((node, index) => {
1547
+ const kind = Object.keys(node).find((key) => key !== ":@");
1548
+ if (!["w:p", "w:tbl"].includes(kind)) return [];
1549
+ const rows = kind === "w:tbl" ? docxNodes(node[kind], "w:tr").map((row) => docxNodes(row["w:tr"], "w:tc").map((cell) => docxNodes(cell["w:tc"], "w:p").map((paragraph) => docxText(paragraph["w:p"])).join("\n"))) : void 0;
1550
+ const text = rows ? rows.map((row) => row.join(" ")).join("\n") : docxText(node[kind]);
1551
+ const serialized = JSON.stringify(node);
1552
+ const style = docxNodes(node[kind], "w:pStyle")[0]?.[":@"]?.["@w:val"];
1553
+ const images = [...serialized.matchAll(/"@r:embed":"([^"]+)"/g)].map((item) => item[1]);
1554
+ return text || images.length ? [{ index, type: kind === "w:tbl" ? "table" : "paragraph", text, ...rows ? { rows } : {}, ...style ? { style } : {}, ...images.length ? { images } : {} }] : [];
1555
+ });
1556
+ const data = paginated(outline, { length: args.length }, 1e3);
1557
+ return bounded({ format: "docx", outline: data.items, totalElements: body.length, truncated: data.truncated });
1558
+ }
1559
+ var DOCX_STYLES = `<?xml version="1.0" encoding="UTF-8"?><w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/></w:style>${[1, 2, 3, 4, 5, 6].map((level) => `<w:style w:type="paragraph" w:styleId="Heading${level}"><w:name w:val="heading ${level}"/><w:basedOn w:val="Normal"/><w:pPr><w:outlineLvl w:val="${level - 1}"/></w:pPr><w:rPr><w:b/><w:sz w:val="${36 - level * 2}"/></w:rPr></w:style>`).join("")}</w:styles>`;
1560
+ function newDocx(content) {
1561
+ const paragraphs = textInput(content).split(/\r?\n/).map((line) => {
1562
+ const heading = /^(#{1,6})\s+(.*)$/.exec(line);
1563
+ return `<w:p>${heading ? `<w:pPr><w:pStyle w:val="Heading${heading[1].length}"/></w:pPr>` : ""}<w:r><w:t xml:space="preserve">${escapeXml(heading ? heading[2] : line)}</w:t></w:r></w:p>`;
1564
+ }).join("");
1565
+ return zipSync({
1566
+ "[Content_Types].xml": strToU8('<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/></Types>'),
1567
+ "_rels/.rels": strToU8('<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>'),
1568
+ "word/_rels/document.xml.rels": strToU8('<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdStyles" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>'),
1569
+ "word/document.xml": strToU8(`<?xml version="1.0" encoding="UTF-8"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>${paragraphs}<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440"/></w:sectPr></w:body></w:document>`),
1570
+ "word/styles.xml": strToU8(DOCX_STYLES)
1571
+ }, { level: 6 });
1572
+ }
1573
+ async function editDocx(config, file, args) {
1574
+ const oldText = textInput(args.old_string, "old_string"), newText = textInput(args.new_string, "new_string");
1575
+ if (!oldText) throw new Error("old_string cannot be empty");
1576
+ const expected = integer3(args.expected_replacements, 1, 1, 1e4, "expected_replacements");
1577
+ const entries = archive(await readBytes(file));
1578
+ const names = ["word/document.xml", ...Object.keys(entries).filter((name) => /^word\/(header|footer)\d+\.xml$/.test(name))];
1579
+ const sources = names.map((name) => [name, prettyXml(xmlPart(entries, name))]);
1580
+ const count = sources.reduce((sum, [, xml]) => sum + xml.split(oldText).length - 1, 0);
1581
+ if (count !== expected) throw new Error(`Expected ${expected} exact replacements, found ${count}; original document was not changed`);
1582
+ for (const [name, source] of sources) if (source.includes(oldText)) {
1583
+ const updated = validateXml(source.split(oldText).join(newText)), parsed = XML.parse(updated);
1584
+ if (name === "word/document.xml" ? !parsed["w:document"]?.["w:body"] : name.includes("header") ? !parsed["w:hdr"] : !parsed["w:ftr"]) throw new Error("DOCX edit removed its required document structure");
1585
+ entries[name] = strToU8(updated);
1586
+ }
1587
+ const result = await saveBytes(config, file, zipSync(entries, { level: 6 }), true);
1588
+ return { ...result, replacements: count };
1589
+ }
1590
+ function spreadsheet(bytes, extension) {
1591
+ if (extension !== ".xls") archive(bytes);
1592
+ else if (!bytes.subarray(0, 8).equals(Buffer.from("d0cf11e0a1b11ae1", "hex"))) throw new Error("Expected a binary XLS workbook");
1593
+ return XLSX.read(bytes, { type: "buffer", cellFormula: true, cellDates: true, cellNF: true, bookVBA: false, WTF: false });
1594
+ }
1595
+ function chooseSheet(book, value) {
1596
+ const chosen = value === void 0 ? book.SheetNames[0] : book.SheetNames.includes(String(value)) ? String(value) : book.SheetNames[Number(value)];
1597
+ if (!chosen || !book.Sheets[chosen]) throw new Error("Worksheet does not exist");
1598
+ return chosen;
1599
+ }
1600
+ function decodeRange(value) {
1601
+ if (typeof value !== "string" || !/^[A-Z]{1,3}[1-9]\d{0,6}:[A-Z]{1,3}[1-9]\d{0,6}$/i.test(value)) throw new Error("range must use A1:D100 notation");
1602
+ const range = XLSX.utils.decode_range(value.toUpperCase());
1603
+ if (range.s.r > range.e.r || range.s.c > range.e.c || range.e.r >= 1048576 || range.e.c >= 16384) throw new Error("Invalid spreadsheet range");
1604
+ return range;
1605
+ }
1606
+ function readSpreadsheet(bytes, extension, args) {
1607
+ const book = spreadsheet(bytes, extension);
1608
+ const sheets = book.SheetNames.map((name2) => {
1609
+ const reference = book.Sheets[name2]["!ref"];
1610
+ const range2 = reference ? XLSX.utils.decode_range(reference) : null;
1611
+ return { name: name2, range: reference || null, rowCount: range2 ? range2.e.r + 1 : 0, colCount: range2 ? range2.e.c + 1 : 0 };
1612
+ });
1613
+ if (args.metadataOnly === true) return bounded({ format: extension.slice(1), sheets });
1614
+ const name = chooseSheet(book, args.sheet), sheet = book.Sheets[name];
1615
+ const full = sheet["!ref"] ? XLSX.utils.decode_range(sheet["!ref"]) : { s: { r: 0, c: 0 }, e: { r: -1, c: -1 } };
1616
+ let range;
1617
+ if (args.range) range = decodeRange(args.range);
1618
+ else {
1619
+ const offset = integer3(args.offset, 0, -1048576, 1048575, "offset"), length = integer3(args.length, 100, 1, 1e4, "length");
1620
+ const start2 = offset < 0 ? Math.max(0, full.e.r + 1 + offset) : offset;
1621
+ range = { s: { r: start2, c: full.s.c }, e: { r: Math.min(full.e.r, offset < 0 ? full.e.r : start2 + length - 1), c: full.e.c } };
1622
+ }
1623
+ if ((range.e.r - range.s.r + 1) * (range.e.c - range.s.c + 1) > 1e4) throw new Error("Read at most 10000 cells per call using range");
1624
+ const rows = [], formulas = {};
1625
+ for (let r = range.s.r; r <= range.e.r; r++) {
1626
+ const row = [];
1627
+ for (let c = range.s.c; c <= range.e.c; c++) {
1628
+ const address = XLSX.utils.encode_cell({ r, c }), cell = sheet[address];
1629
+ row.push(cell?.v instanceof Date ? cell.v.toISOString() : cell?.v ?? null);
1630
+ if (cell?.f) formulas[address] = cell.f;
1631
+ }
1632
+ rows.push(row);
1633
+ }
1634
+ return bounded({ format: extension.slice(1), sheet: name, range: range.e.r >= range.s.r ? XLSX.utils.encode_range(range) : null, rows, formulas, sheets, truncated: range.e.r < full.e.r, formulasCalculated: false });
1635
+ }
1636
+ function parseCells(content) {
1637
+ const data = typeof content === "string" ? JSON.parse(textInput(content)) : content;
1638
+ if (Buffer.byteLength(JSON.stringify(data) || "") > MAX_INPUT) throw new Error("Spreadsheet input exceeds 512 KiB");
1639
+ return data;
1640
+ }
1641
+ function cellValue(value) {
1642
+ if (value === null) return null;
1643
+ if (typeof value === "string") return { t: "s", v: textInput(value) };
1644
+ if (typeof value === "number" && Number.isFinite(value)) return { t: "n", v: value };
1645
+ if (typeof value === "boolean") return { t: "b", v: value };
1646
+ if (value && typeof value === "object" && !Array.isArray(value)) {
1647
+ if (value.type === "date" && typeof value.value === "string") {
1648
+ const date = new Date(value.value);
1649
+ if (!Number.isFinite(+date)) throw new Error("Invalid date cell");
1650
+ return { t: "d", v: date, z: "yyyy-mm-dd hh:mm:ss" };
1651
+ }
1652
+ if (typeof value.formula === "string") {
1653
+ const formula = textInput(value.formula, "formula").replace(/^=/, "");
1654
+ if (!formula || formula.length > 8192) throw new Error("Invalid cell formula");
1655
+ const result = value.value === void 0 ? { t: "n", v: 0 } : cellValue(value.value);
1656
+ if (!result || !["s", "n", "b"].includes(result.t)) throw new Error("Formula cached value must be string, number or boolean");
1657
+ return { ...result, f: formula };
1658
+ }
1659
+ }
1660
+ throw new Error('Cells must contain strings, numbers, booleans, null, {formula,value}, or {type:"date",value:ISO}');
1661
+ }
1662
+ function validateRows(rows) {
1663
+ if (!Array.isArray(rows) || !rows.length || rows.length > 1e4 || rows.some((row) => !Array.isArray(row))) throw new Error("Spreadsheet content must be a nonempty JSON two-dimensional array");
1664
+ const width = Math.max(...rows.map((row) => row.length));
1665
+ if (!width || width > 1e3 || rows.length * width > 1e4) throw new Error("Write at most 10000 cells per call");
1666
+ return rows.map((row) => row.map(cellValue));
1667
+ }
1668
+ function newWorkbook(content, args, extension) {
1669
+ const data = parseCells(content), book = XLSX.utils.book_new();
1670
+ const sheets = Array.isArray(data) ? { [args.sheet || "Sheet1"]: data } : data;
1671
+ if (!sheets || typeof sheets !== "object" || Object.keys(sheets).length < 1 || Object.keys(sheets).length > 100) throw new Error("Expected a cell array or sheet-name map");
1672
+ for (const [name, values] of Object.entries(sheets)) {
1673
+ if (!name || name.length > 31 || /[\\/?*\[\]:]/.test(name)) throw new Error("Invalid worksheet name");
1674
+ const rows = validateRows(values), sheet = {};
1675
+ rows.forEach((row, r) => row.forEach((cell, c) => {
1676
+ if (cell) sheet[XLSX.utils.encode_cell({ r, c })] = cell;
1677
+ }));
1678
+ sheet["!ref"] = XLSX.utils.encode_range({ s: { r: 0, c: 0 }, e: { r: rows.length - 1, c: Math.max(...rows.map((row) => row.length)) - 1 } });
1679
+ XLSX.utils.book_append_sheet(book, sheet, name);
1680
+ }
1681
+ return XLSX.write(book, { type: "buffer", bookType: extension === ".xls" ? "biff8" : extension.slice(1), compression: true, cellStyles: true });
1682
+ }
1683
+ function cellXml(value, address, old, prefix) {
1684
+ if (!value) return "";
1685
+ let attributes = old?.match(/^<[^\s>]+\s([^>]*?)\/?>(?:.|\n)*$/)?.[1] || "";
1686
+ attributes = attributes.replace(/\b(?:r|t)\s*=\s*(?:"[^"]*"|'[^']*')/g, "").trim();
1687
+ const start2 = `<${prefix}c r="${address}"${attributes ? ` ${attributes}` : ""}`;
1688
+ if (value.f) return `${start2} t="${value.t === "s" ? "str" : value.t}"><${prefix}f>${escapeXml(value.f)}</${prefix}f><${prefix}v>${escapeXml(value.t === "b" ? +value.v : value.v)}</${prefix}v></${prefix}c>`;
1689
+ if (value.t === "s") return `${start2} t="inlineStr"><${prefix}is><${prefix}t xml:space="preserve">${escapeXml(value.v)}</${prefix}t></${prefix}is></${prefix}c>`;
1690
+ if (value.t === "d") {
1691
+ return `${start2} t="d"><${prefix}v>${value.v.toISOString()}</${prefix}v></${prefix}c>`;
1692
+ }
1693
+ return `${start2} t="${value.t}"><${prefix}v>${value.t === "b" ? +value.v : value.v}</${prefix}v></${prefix}c>`;
1694
+ }
1695
+ async function updateSpreadsheet(config, file, args, content) {
1696
+ if (path6.extname(file).toLowerCase() === ".xls") {
1697
+ if (args.allowLossy !== true) throw new Error("Legacy XLS editing may discard formatting and unsupported features; use allowLossy=true explicitly or save as XLSX first");
1698
+ const book2 = spreadsheet(await readBytes(file), ".xls"), name2 = chooseSheet(book2, args.sheet), sheet = book2.Sheets[name2], rows2 = validateRows(parseCells(content));
1699
+ const range2 = args.range ? decodeRange(args.range) : { s: { r: args.mode === "append" ? XLSX.utils.decode_range(sheet["!ref"] || "A1:A1").e.r + 1 : 0, c: 0 } };
1700
+ const width2 = Math.max(...rows2.map((row) => row.length));
1701
+ if (range2.e && (range2.e.r - range2.s.r + 1 !== rows2.length || range2.e.c - range2.s.c + 1 !== width2)) throw new Error("JSON data dimensions must match the target range");
1702
+ if (range2.s.r + rows2.length > 65536 || range2.s.c + width2 > 256) throw new Error("Write exceeds legacy XLS boundaries");
1703
+ rows2.forEach((row, r) => row.forEach((cell, c) => {
1704
+ const address = XLSX.utils.encode_cell({ r: range2.s.r + r, c: range2.s.c + c });
1705
+ if (cell) sheet[address] = cell;
1706
+ else delete sheet[address];
1707
+ }));
1708
+ const previous = XLSX.utils.decode_range(sheet["!ref"] || "A1:A1");
1709
+ sheet["!ref"] = XLSX.utils.encode_range({ s: { r: Math.min(previous.s.r, range2.s.r), c: Math.min(previous.s.c, range2.s.c) }, e: { r: Math.max(previous.e.r, range2.s.r + rows2.length - 1), c: Math.max(previous.e.c, range2.s.c + width2 - 1) } });
1710
+ return { ...await saveBytes(config, file, XLSX.write(book2, { type: "buffer", bookType: "biff8", cellStyles: true }), true), sheet: name2, warning: "Legacy XLS was rebuilt; unsupported formatting, drawings, macros and workbook features may be lost.", formulasCalculated: false };
1711
+ }
1712
+ const bytes = await readBytes(file), entries = archive(bytes), book = spreadsheet(bytes, path6.extname(file).toLowerCase());
1713
+ const name = chooseSheet(book, args.sheet), rows = validateRows(parseCells(content));
1714
+ const sheetIndex = book.SheetNames.indexOf(name);
1715
+ const workbookXml = xmlPart(entries, "xl/workbook.xml"), workbook = XML.parse(workbookXml);
1716
+ const wbRoot = workbook.workbook;
1717
+ const sheetNodes = [].concat(wbRoot?.sheets?.sheet || []), id = sheetNodes[sheetIndex]?.["@r:id"];
1718
+ const relationships = [].concat(XML.parse(xmlPart(entries, "xl/_rels/workbook.xml.rels")).Relationships?.Relationship || []);
1719
+ const relation = relationships.find((item) => item["@Id"] === id);
1720
+ if (!relation || relation["@TargetMode"] === "External") throw new Error("Worksheet relationship is invalid");
1721
+ const entryName = path6.posix.normalize(relation["@Target"].startsWith("/") ? relation["@Target"].slice(1) : `xl/${relation["@Target"]}`);
1722
+ if (!entryName.startsWith("xl/worksheets/")) throw new Error("Worksheet relationship is outside the workbook");
1723
+ let xml = xmlPart(entries, entryName);
1724
+ const prefix = /^\s*(?:<\?xml[^>]*>\s*)?<((?:\w+:)?)worksheet\b/.exec(xml)?.[1] || "";
1725
+ const range = args.range ? decodeRange(args.range) : { s: { r: args.mode === "append" ? XLSX.utils.decode_range(book.Sheets[name]["!ref"] || "A1:A1").e.r + 1 : 0, c: 0 } };
1726
+ const height = rows.length, width = Math.max(...rows.map((row) => row.length));
1727
+ if (range.e && (range.e.r - range.s.r + 1 !== height || range.e.c - range.s.c + 1 !== width)) throw new Error("JSON data dimensions must match the target range");
1728
+ if (range.s.r + height > 1048576 || range.s.c + width > 16384) throw new Error("Write exceeds worksheet boundaries");
1729
+ const section = new RegExp(`<${prefix}sheetData(?:\\s[^>]*)?(?:\\/>|>([\\s\\S]*?)<\\/${prefix}sheetData>)`).exec(xml);
1730
+ if (!section) throw new Error("Worksheet sheetData was not found");
1731
+ const existingRows = /* @__PURE__ */ new Map();
1732
+ for (const match of (section[1] || "").matchAll(new RegExp(`<${prefix}row\\b[^>]*(?:\\/>|>[\\s\\S]*?<\\/${prefix}row>)`, "g"))) {
1733
+ const r = /\br="(\d+)"/.exec(match[0]);
1734
+ if (!r) throw new Error("Worksheet row has no index");
1735
+ existingRows.set(Number(r[1]), match[0]);
1736
+ }
1737
+ rows.forEach((values, rowIndex) => {
1738
+ const r = range.s.r + rowIndex + 1, previous = existingRows.get(r), cells = /* @__PURE__ */ new Map();
1739
+ if (previous) for (const match of previous.matchAll(new RegExp(`<${prefix}c\\b[^>]*(?:\\/>|>[\\s\\S]*?<\\/${prefix}c>)`, "g"))) {
1740
+ const address = /\br="([A-Z]+\d+)"/.exec(match[0])?.[1];
1741
+ if (!address) throw new Error("Worksheet cell has no address");
1742
+ cells.set(address, match[0]);
1743
+ }
1744
+ values.forEach((cell, colIndex) => {
1745
+ const address = XLSX.utils.encode_cell({ r: r - 1, c: range.s.c + colIndex });
1746
+ cells.set(address, cellXml(cell, address, cells.get(address), prefix));
1747
+ });
1748
+ const start2 = previous ? previous.match(/^[^>]+>/)[0].replace(/\/>$/, ">") : `<${prefix}row r="${r}">`;
1749
+ const body = [...cells.entries()].sort(([a], [b]) => XLSX.utils.decode_cell(a).c - XLSX.utils.decode_cell(b).c).map(([, value]) => value).join("");
1750
+ const extra = previous ? previous.replace(/^[^>]+>/, "").replace(new RegExp(`<\\/${prefix}row>$`), "").replace(new RegExp(`<${prefix}c\\b[^>]*(?:\\/>|>[\\s\\S]*?<\\/${prefix}c>)`, "g"), "") : "";
1751
+ existingRows.set(r, `${start2}${body}${extra}</${prefix}row>`);
1752
+ });
1753
+ const sheetData = `<${prefix}sheetData>${[...existingRows.entries()].sort(([a], [b]) => a - b).map(([, value]) => value).join("")}</${prefix}sheetData>`;
1754
+ xml = xml.replace(section[0], sheetData);
1755
+ const oldRange = XLSX.utils.decode_range(book.Sheets[name]["!ref"] || "A1:A1");
1756
+ const bounds = XLSX.utils.encode_range({ s: { r: Math.min(oldRange.s.r, range.s.r), c: Math.min(oldRange.s.c, range.s.c) }, e: { r: Math.max(oldRange.e.r, range.s.r + height - 1), c: Math.max(oldRange.e.c, range.s.c + width - 1) } });
1757
+ xml = xml.replace(new RegExp(`<${prefix}dimension\\b[^>]*\\/>`), `<${prefix}dimension ref="${bounds}"/>`);
1758
+ entries[entryName] = strToU8(validateXml(xml));
1759
+ return { ...await saveBytes(config, file, zipSync(entries, { level: 6 }), true), sheet: name, range: XLSX.utils.encode_range({ s: range.s, e: { r: range.s.r + height - 1, c: range.s.c + width - 1 } }), formulasCalculated: false };
1760
+ }
1761
+ function pngChunk(type, bytes) {
1762
+ const chunk = Buffer.alloc(bytes.length + 12);
1763
+ chunk.writeUInt32BE(bytes.length);
1764
+ chunk.write(type, 4, "ascii");
1765
+ bytes.copy(chunk, 8);
1766
+ chunk.writeUInt32BE(crc32(chunk.subarray(4, 8 + bytes.length)), 8 + bytes.length);
1767
+ return chunk;
1768
+ }
1769
+ function extractedPng(image) {
1770
+ if (!image || !Number.isInteger(image.width) || !Number.isInteger(image.height) || image.width < 1 || image.height < 1) throw new Error("Decoded image has invalid dimensions");
1771
+ if (image.width * image.height > 2e6) throw new Error("Image exceeds 2 million pixels");
1772
+ const color = image.kind === 1 ? 0 : image.kind === 2 ? 2 : image.kind === 3 ? 6 : null;
1773
+ if (color === null || !image.data) throw new Error("Unsupported decoded image format");
1774
+ const depth = image.kind === 1 ? 1 : 8, rowBytes = image.kind === 1 ? Math.ceil(image.width / 8) : image.width * (image.kind === 2 ? 3 : 4);
1775
+ if (image.data.length !== rowBytes * image.height) throw new Error("Decoded image length mismatch");
1776
+ const header = Buffer.alloc(13);
1777
+ header.writeUInt32BE(image.width);
1778
+ header.writeUInt32BE(image.height, 4);
1779
+ header[8] = depth;
1780
+ header[9] = color;
1781
+ const rows = Buffer.alloc((rowBytes + 1) * image.height), source = Buffer.from(image.data.buffer, image.data.byteOffset, image.data.byteLength);
1782
+ for (let y = 0; y < image.height; y++) source.copy(rows, y * (rowBytes + 1) + 1, y * rowBytes, (y + 1) * rowBytes);
1783
+ return Buffer.concat([Buffer.from("89504e470d0a1a0a", "hex"), pngChunk("IHDR", header), pngChunk("IDAT", deflateSync(rows)), pngChunk("IEND", Buffer.alloc(0))]);
1784
+ }
1785
+ async function pdfImageObject(page2, id) {
1786
+ const objects = String(id).startsWith("g_") ? page2.commonObjs : page2.objs;
1787
+ if (objects.has(id)) return objects.get(id);
1788
+ let timer;
1789
+ try {
1790
+ return await Promise.race([new Promise((resolve) => objects.get(id, resolve)), new Promise((_, reject) => {
1791
+ timer = setTimeout(() => reject(new Error("Image decoding timed out")), 2e3);
1792
+ })]);
1793
+ } finally {
1794
+ clearTimeout(timer);
1795
+ }
1796
+ }
1797
+ async function readPdf(bytes, args) {
1798
+ const { getDocument, OPS } = await import("pdfjs-dist/legacy/build/pdf.mjs");
1799
+ const task = getDocument({ data: new Uint8Array(bytes), isEvalSupported: false, useSystemFonts: false, disableFontFace: true, isOffscreenCanvasSupported: false, isImageDecoderSupported: false, maxImageSize: 16e6, verbosity: 0 });
1800
+ try {
1801
+ const document = await task.promise;
1802
+ if (document.numPages > 2e3) throw new Error("PDF exceeds 2000 pages");
1803
+ if (args.metadataOnly === true) return { format: "pdf", totalPages: document.numPages };
1804
+ const offset = integer3(args.offset, 0, -2e3, 2e3, "offset"), length = integer3(args.length, 10, 1, 30, "length");
1805
+ const start2 = offset < 0 ? Math.max(0, document.numPages + offset) : offset;
1806
+ const end = Math.min(document.numPages, start2 + length), pages = [], candidates = [], images = [];
1807
+ let omittedImages = 0, attemptedImages = 0;
1808
+ for (let index = start2; index < end; index++) {
1809
+ const page2 = await document.getPage(index + 1), content = await page2.getTextContent();
1810
+ const text2 = content.items.filter((item) => typeof item.str === "string").map((item) => item.str + (item.hasEOL ? "\n" : " ")).join("").trim();
1811
+ pages.push({ page: index + 1, text: text2, width: page2.view[2] - page2.view[0], height: page2.view[3] - page2.view[1] });
1812
+ bounded({ pages });
1813
+ if (args.includeImages !== false) {
1814
+ try {
1815
+ const operators = await page2.getOperatorList(), seen = /* @__PURE__ */ new Set();
1816
+ for (let i = 0; i < operators.fnArray.length; i++) {
1817
+ const operation = operators.fnArray[i], values = operators.argsArray[i];
1818
+ if (![OPS.paintImageXObject, OPS.paintImageXObjectRepeat, OPS.paintInlineImageXObject, OPS.paintInlineImageXObjectGroup].includes(operation)) continue;
1819
+ const id = typeof values[0] === "string" ? values[0] : `inline-${i}`;
1820
+ if (seen.has(id)) continue;
1821
+ seen.add(id);
1822
+ if (images.length >= 200) {
1823
+ omittedImages++;
1824
+ continue;
1825
+ }
1826
+ const record = { page: index + 1, index: seen.size, status: "skipped" };
1827
+ images.push(record);
1828
+ if (candidates.length >= 10) {
1829
+ record.reason = "Maximum 10 embedded images per read";
1830
+ continue;
1831
+ }
1832
+ if (attemptedImages++ >= 50) {
1833
+ record.reason = "Maximum 50 image decoding attempts per read";
1834
+ continue;
1835
+ }
1836
+ try {
1837
+ const decoded = typeof values[0] === "string" ? await pdfImageObject(page2, values[0]) : values[0];
1838
+ const png = extractedPng(decoded);
1839
+ if (png.length > IMAGE_LIMIT) throw new Error("Encoded image exceeds 480 KiB");
1840
+ candidates.push({ record, block: { type: "image", mimeType: "image/png", data: png.toString("base64") } });
1841
+ record.width = decoded.width;
1842
+ record.height = decoded.height;
1843
+ record.reason = "Response budget";
1844
+ } catch (error) {
1845
+ record.reason = error.message;
1846
+ }
1847
+ }
1848
+ } catch (error) {
1849
+ images.push({ page: index + 1, status: "skipped", reason: `Image extraction failed: ${error.message}` });
1850
+ }
1851
+ }
1852
+ page2.cleanup();
1853
+ }
1854
+ const metadata = { format: "pdf", totalPages: document.numPages, pages, offset: start2, truncated: end < document.numPages, images, omittedImages, note: args.includeImages === false ? "Scanned pages require OCR; image extraction disabled." : "Scanned pages require OCR. Embedded raster images are extracted where supported; vector drawings and image masks are not standalone images. Images above 16 million pixels are omitted by the decoder." };
1855
+ const text = () => pages.map((page2) => `## Page ${page2.page}
1856
+
1857
+ ${page2.text}`).join("\n\n") + `
1858
+
1859
+ ${metadata.note}
1860
+ Image extraction: ${JSON.stringify(images)}${omittedImages ? `
1861
+ ${omittedImages} additional images omitted from extraction listing.` : ""}`;
1862
+ const result = { ...metadata, content: [{ type: "text", text: text() }] };
1863
+ bounded(result);
1864
+ for (const { record, block } of candidates) {
1865
+ record.status = "included";
1866
+ delete record.reason;
1867
+ result.content[0].text = text();
1868
+ result.content.push(block);
1869
+ if (Buffer.byteLength(JSON.stringify(result)) > MAX_RESULT - 2048) {
1870
+ result.content.pop();
1871
+ record.status = "skipped";
1872
+ record.reason = "Response budget";
1873
+ }
1874
+ }
1875
+ result.content[0].text = text();
1876
+ return bounded(result);
1877
+ } finally {
1878
+ await task.destroy();
1879
+ }
1880
+ }
1881
+ function imageResult(bytes, extension) {
1882
+ const mimeType = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp" }[extension];
1883
+ const valid = extension === ".png" ? bytes.subarray(0, 8).equals(Buffer.from("89504e470d0a1a0a", "hex")) : [".jpg", ".jpeg"].includes(extension) ? bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255 : extension === ".gif" ? /^GIF8[79]a/.test(bytes.toString("ascii", 0, 6)) : bytes.toString("ascii", 0, 4) === "RIFF" && bytes.toString("ascii", 8, 12) === "WEBP";
1884
+ if (!valid) throw new Error("Image format does not match its extension");
1885
+ return bounded({ format: "image", content: [{ type: "image", mimeType, data: bytes.toString("base64") }] });
1886
+ }
1887
+ async function readDocument(config, file, args = {}) {
1888
+ const extension = path6.extname(file).toLowerCase();
1889
+ const bytes = await readBytes(file, [".png", ".jpg", ".jpeg", ".gif", ".webp"].includes(extension) ? IMAGE_LIMIT : MAX_FILE2);
1890
+ if (extension === ".docx") return readDocx(bytes, args);
1891
+ if ([".xlsx", ".xls", ".xlsm"].includes(extension)) return readSpreadsheet(bytes, extension, args);
1892
+ if (extension === ".pdf") return readPdf(bytes, args);
1893
+ if ([".png", ".jpg", ".jpeg", ".gif", ".webp"].includes(extension)) return imageResult(bytes, extension);
1894
+ throw new Error("Unsupported document format");
1895
+ }
1896
+ async function writeDocument(config, file, args = {}) {
1897
+ writable(config);
1898
+ const extension = path6.extname(file).toLowerCase();
1899
+ if (extension === ".docx") {
1900
+ if (args.mode === "append" || await fs5.stat(file).then(() => true, (error) => {
1901
+ if (error.code === "ENOENT") return false;
1902
+ throw error;
1903
+ })) throw new Error("Use edit_block to modify an existing DOCX without losing its formatting");
1904
+ return saveBytes(config, file, newDocx(args.content), false);
1905
+ }
1906
+ if ([".xlsx", ".xls", ".xlsm"].includes(extension)) {
1907
+ if (args.range || args.mode === "append") return updateSpreadsheet(config, file, args, args.content);
1908
+ return saveBytes(config, file, newWorkbook(args.content, args, extension), args.overwrite === true || args.mode === "rewrite");
1909
+ }
1910
+ if (extension === ".pdf") throw new Error("Use write_pdf to create or modify PDF files");
1911
+ throw new Error("Document writing supports DOCX and spreadsheet formats");
1912
+ }
1913
+ async function editDocument(config, file, args = {}) {
1914
+ writable(config);
1915
+ const extension = path6.extname(file).toLowerCase();
1916
+ if (extension === ".docx") return editDocx(config, file, args);
1917
+ if ([".xlsx", ".xls", ".xlsm"].includes(extension)) {
1918
+ if (!args.range) throw new Error("Spreadsheet edits require sheet and range");
1919
+ const current = readSpreadsheet(await readBytes(file), extension, args);
1920
+ const expected = parseCells(args.old_string);
1921
+ if (JSON.stringify(current.rows) !== JSON.stringify(expected)) throw new Error("Spreadsheet range no longer matches old_string; original document was not changed");
1922
+ return updateSpreadsheet(config, file, args, args.new_string);
1923
+ }
1924
+ throw new Error("Use write_pdf for PDF changes; this format does not support edit_block");
1925
+ }
1926
+ async function safeRelative(config, requested2, options) {
1927
+ const { safePath: safePath2 } = await Promise.resolve().then(() => (init_files(), files_exports));
1928
+ return safePath2(config, requested2, options);
1929
+ }
1930
+ function number2(value, fallback, min, max, name) {
1931
+ if (value === void 0) return fallback;
1932
+ if (typeof value !== "number" || !Number.isFinite(value) || value < min || value > max) throw new Error(`${name} is outside the supported range`);
1933
+ return value;
1934
+ }
1935
+ async function writePdf(config, args = {}) {
1936
+ writable(config);
1937
+ const target = await safeRelative(config, args.path || args.filePath, { write: true });
1938
+ if (path6.extname(target).toLowerCase() !== ".pdf") throw new Error("PDF output path must end in .pdf");
1939
+ const mode = args.mode || "create";
1940
+ if (!["create", "modify"].includes(mode)) throw new Error("PDF mode must be create or modify");
1941
+ const pdf = mode === "modify" ? await PDFDocument.load(await readBytes(target), { updateMetadata: false }) : await PDFDocument.create();
1942
+ if (pdf.getPageCount() > 2e3) throw new Error("PDF exceeds 2000 pages");
1943
+ pdf.registerFontkit(fontkit);
1944
+ const fontPath = args.font_path ? await safeRelative(config, args.font_path) : path6.join(projectDir, "assets", "fonts", "NotoSans-Regular.ttf");
1945
+ const font = await pdf.embedFont(await readBytes(fontPath, 10 * 1024 * 1024), { subset: true });
1946
+ const draw = (page2, text, options = {}) => {
1947
+ try {
1948
+ page2.drawText(textInput(text, "text"), { font, size: 12, ...options });
1949
+ } catch (error) {
1950
+ if (/WinAnsi cannot encode/.test(error.message)) throw new Error("This text needs a Unicode font: supply font_path pointing to a TTF/OTF font in the approved folder");
1951
+ throw error;
1952
+ }
1953
+ };
1954
+ if (args.content !== void 0) {
1955
+ if (mode !== "create") throw new Error("For PDF modifications use operations; content is for new documents");
1956
+ const content = textInput(args.content), size = number2(args.font_size, 12, 5, 72, "font_size");
1957
+ let page2 = pdf.addPage([595.28, 841.89]), y = 791.89;
1958
+ for (const line of content.split(/\r?\n/)) {
1959
+ const heading = /^(#{1,6})\s+(.*)$/.exec(line), text = heading ? heading[2] : line, currentSize = heading ? Math.max(size, 23 - heading[1].length * 2) : size;
1960
+ const pieces = text.split(/(\s+)/);
1961
+ let wrapped = "";
1962
+ for (const piece of pieces) {
1963
+ let measured;
1964
+ try {
1965
+ measured = font.widthOfTextAtSize(wrapped + piece, currentSize);
1966
+ } catch {
1967
+ throw new Error("This text needs a Unicode font: supply font_path pointing to a TTF/OTF font in the approved folder");
1968
+ }
1969
+ if (measured > 495 && wrapped) {
1970
+ if (y < 50) {
1971
+ page2 = pdf.addPage([595.28, 841.89]);
1972
+ y = 791.89;
1973
+ }
1974
+ draw(page2, wrapped.trimEnd(), { x: 50, y, size: currentSize });
1975
+ y -= currentSize * 1.4;
1976
+ wrapped = piece.trimStart();
1977
+ } else wrapped += piece;
1978
+ }
1979
+ if (y < 50) {
1980
+ page2 = pdf.addPage([595.28, 841.89]);
1981
+ y = 791.89;
1982
+ }
1983
+ draw(page2, wrapped, { x: 50, y, size: currentSize });
1984
+ y -= currentSize * 1.5;
1985
+ if (pdf.getPageCount() > 500) throw new Error("Generated PDF exceeds 500 pages");
1986
+ }
1987
+ }
1988
+ const operations = args.operations || [];
1989
+ if (!Array.isArray(operations) || operations.length > 200) throw new Error("Provide at most 200 PDF operations");
1990
+ for (const operation of operations) {
1991
+ if (!operation || typeof operation !== "object") throw new Error("Invalid PDF operation");
1992
+ if (operation.type === "add_page") {
1993
+ pdf.addPage([number2(operation.width, 595.28, 10, 1e4, "width"), number2(operation.height, 841.89, 10, 1e4, "height")]);
1994
+ continue;
1995
+ }
1996
+ const index = integer3(operation.page, 1, 1, pdf.getPageCount(), "page") - 1, page2 = pdf.getPage(index);
1997
+ if (operation.type === "remove_page") {
1998
+ if (pdf.getPageCount() === 1) throw new Error("Cannot remove the last PDF page");
1999
+ pdf.removePage(index);
2000
+ } else if (operation.type === "rotate_page") {
2001
+ const rotation = integer3(operation.rotation, 90, -360, 360, "rotation");
2002
+ if (rotation % 90) throw new Error("Page rotation must be a multiple of 90");
2003
+ page2.setRotation(degrees(rotation));
2004
+ } else if (operation.type === "text") draw(page2, operation.text, { x: number2(operation.x, 50, -1e4, 1e4, "x"), y: number2(operation.y, page2.getHeight() - 50, -1e4, 1e4, "y"), size: number2(operation.size, 12, 1, 1e3, "size") });
2005
+ else if (operation.type === "image") {
2006
+ const imagePath = await safeRelative(config, operation.image_path), bytes = await readBytes(imagePath, 5 * 1024 * 1024);
2007
+ const extension = path6.extname(imagePath).toLowerCase();
2008
+ if (![".png", ".jpg", ".jpeg"].includes(extension)) throw new Error("PDF images must be PNG or JPEG");
2009
+ const embedded = extension === ".png" ? await pdf.embedPng(bytes) : await pdf.embedJpg(bytes);
2010
+ page2.drawImage(embedded, { x: number2(operation.x, 50, -1e4, 1e4, "x"), y: number2(operation.y, 50, -1e4, 1e4, "y"), width: number2(operation.width, embedded.width, 1, 1e4, "width"), height: number2(operation.height, embedded.height, 1, 1e4, "height") });
2011
+ } else throw new Error(`Unsupported PDF operation: ${operation.type}`);
2012
+ }
2013
+ if (args.form_fields) {
2014
+ if (typeof args.form_fields !== "object" || Array.isArray(args.form_fields) || Object.keys(args.form_fields).length > 200) throw new Error("Invalid PDF form fields");
2015
+ const form = pdf.getForm();
2016
+ for (const [name, value] of Object.entries(args.form_fields)) {
2017
+ const field = form.getField(name);
2018
+ if (typeof value === "boolean" && typeof field.check === "function") value ? field.check() : field.uncheck();
2019
+ else if (typeof value === "string" && typeof field.setText === "function") field.setText(textInput(value));
2020
+ else throw new Error(`Unsupported PDF form field: ${name}`);
2021
+ }
2022
+ form.updateFieldAppearances(font);
2023
+ }
2024
+ if (!pdf.getPageCount()) throw new Error("Provide content or add_page operations for a new PDF");
2025
+ return { ...await saveBytes(config, target, await pdf.save(), mode === "modify" || args.overwrite === true), pages: pdf.getPageCount(), note: mode === "modify" ? "Text operations add content; they do not redact or remove existing text." : void 0 };
2026
+ }
2027
+
2028
+ // src/tool-defs.mjs
2029
+ import { z } from "zod";
2030
+ var path7 = z.string().min(1).max(2048);
2031
+ var paths = { path: path7.optional(), file_path: path7.optional(), filePath: path7.optional() };
2032
+ var paging = { offset: z.number().int().min(-1e7).max(1e7).optional(), length: z.number().int().min(1).max(2e3).optional() };
2033
+ var wait = z.number().int().min(0).max(25e3).optional();
2034
+ var tool = (name, scope, description, shape = {}, readonly = true) => ({ name, scope, description, schema: z.object(shape).strict(), readonly });
2035
+ var TOOL_DEFS = [
2036
+ tool("get_device", "device:read", "Get identity and online status of this authorized PC."),
2037
+ tool("list_devices", "device:read", "List devices visible to this connection. Its OAuth grant is restricted to exactly one PC."),
2038
+ tool("whoami", "device:read", "Get the signed-in CetriX account and current device grant, without credentials."),
2039
+ tool("ping_device", "device:read", "Ping this PC through the agent and report round-trip latency."),
2040
+ tool("shutdown_device", "terminal:execute", "Stop this PC agent after confirming the response. Does not shut down Windows. Start the agent locally to reconnect.", {}, false),
2041
+ tool("get_config", "device:read", "Get safe agent configuration, system information and client history; private keys and environment secrets are never returned."),
2042
+ tool("set_config_value", "terminal:execute", "Update agent preferences. Directory access can only be narrowed within the locally approved root; this cannot grant new write or shell permissions.", { key: z.enum(["blockedCommands", "defaultShell", "allowedDirectories", "fileReadLineLimit", "fileWriteLineLimit", "telemetryEnabled"]), value: z.union([z.string(), z.number(), z.boolean(), z.array(z.string().max(2048)).max(100)]) }, false),
2043
+ tool("get_prompts", "device:read", "Get a CetriX starter prompt by ID (onb2_01 through onb2_05), or list the five prompts. Returned prompts do not execute automatically.", { action: z.enum(["list", "get_prompt"]).optional(), promptId: z.string().max(80).optional() }),
2044
+ tool("get_recent_tool_calls", "device:read", "Get this OAuth connection's in-memory tool history. Content is bounded and never persisted in server audit logs.", { ...paging, includeContent: z.boolean().default(false) }),
2045
+ tool("get_usage_stats", "device:read", "Get this connection's tool counts, errors and durations since the agent started."),
2046
+ tool("give_feedback", "device:read", "Save feedback to this CetriX instance for its owner; no third-party survey or telemetry is contacted.", { message: z.string().min(1).max(4e3) }, false),
2047
+ tool("list_directory", "files:read", "List entries recursively relative to the approved root. Depth and result limits bound large directories.", { ...paths, depth: z.number().int().min(1).max(8).optional() }),
2048
+ tool("get_file_info", "files:read", "Get file or directory metadata, text line counts and spreadsheet sheet information.", paths),
2049
+ tool("read_file", "files:read", "Read UTF-8 text by lines, spreadsheets by sheet/range, DOCX outline or XML, PDF pages, or an image. Paths stay inside this PC's approved root. isUrl supports bounded public HTTPS text.", { ...paths, ...paging, isUrl: z.boolean().optional(), includeImages: z.boolean().optional(), sheet: z.union([z.string().max(100), z.number().int().min(0)]).optional(), range: z.string().max(100).optional() }),
2050
+ tool("read_multiple_files", "files:read", "Read up to 20 files; an error in one file does not stop the other reads. Response size is bounded.", { paths: z.array(path7).min(1).max(20), ...paging }),
2051
+ tool("write_file", "files:write", "Create, overwrite explicitly, or append text; create DOCX or spreadsheet data. Requires local write permission. PDF uses write_pdf.", { ...paths, content: z.union([z.string().max(524288), z.array(z.unknown()), z.record(z.string(), z.unknown())]), overwrite: z.boolean().default(false), mode: z.enum(["rewrite", "append"]).optional(), sheet: z.string().max(100).optional(), range: z.string().max(100).optional(), allowLossy: z.boolean().optional() }, false),
2052
+ tool("edit_block", "files:write", "Replace an exact text/XML block after checking occurrence count, or edit an Excel range. No partial edit is made on a mismatch.", { ...paths, old_string: z.string().max(524288).optional(), new_string: z.string().max(524288).optional(), expected_replacements: z.number().int().min(1).max(1e4).optional(), content: z.union([z.string().max(524288), z.array(z.unknown())]).optional(), sheet: z.string().max(100).optional(), range: z.string().max(100).optional(), allowLossy: z.boolean().optional() }, false),
2053
+ tool("create_directory", "files:write", "Create a directory including missing parent directories, within the approved root.", paths, false),
2054
+ tool("move_file", "files:write", "Move or rename a file/directory inside the approved root. Destination must not already exist.", { source: path7, destination: path7 }, false),
2055
+ tool("write_pdf", "files:write", "Create or modify a PDF: text/images, pages, rotation and form fields. Vietnamese text is supported by the bundled font.", { ...paths, content: z.string().max(524288).optional(), mode: z.enum(["create", "modify"]).optional(), overwrite: z.boolean().optional(), font_path: path7.optional(), font_size: z.number().min(5).max(72).optional(), operations: z.array(z.object({ type: z.enum(["text", "image", "add_page", "remove_page", "rotate_page"]), page: z.number().int().min(1).optional(), text: z.string().max(1e5).optional(), x: z.number().optional(), y: z.number().optional(), size: z.number().min(1).max(200).optional(), image_path: path7.optional(), width: z.number().positive().optional(), height: z.number().positive().optional(), rotation: z.number().optional() }).strict()).max(200).optional(), form_fields: z.record(z.string(), z.union([z.string(), z.boolean()])).optional() }, false),
2056
+ tool("start_search", "files:read", "Start a bounded background search for filenames or text contents. Poll get_more_search_results with the returned sessionId.", { path: path7.default("."), pattern: z.string().min(1).max(500), searchType: z.enum(["files", "content"]).default("files"), literalSearch: z.boolean().optional(), filePattern: z.string().max(300).optional(), ignoreCase: z.boolean().optional(), earlyTermination: z.boolean().optional() }),
2057
+ tool("get_more_search_results", "files:read", "Read a page of results from a search owned by this OAuth connection.", { sessionId: z.string().min(1).max(100), ...paging }),
2058
+ tool("list_searches", "files:read", "List searches belonging to this OAuth connection."),
2059
+ tool("stop_search", "files:read", "Stop an active search while keeping already collected results.", { sessionId: z.string().min(1).max(100) }, false),
2060
+ tool("start_process", "terminal:execute", "Start a managed terminal process on this PC. It can continue beyond a single request. Shell commands run with the OS account permissions, not in a sandbox.", { command: z.string().min(1).max(65536), timeout_ms: wait, cwd: path7.optional(), shell: z.string().max(200).optional() }, false),
2061
+ tool("interact_with_process", "terminal:execute", "Send input to a managed process and wait briefly for output. Sessions belong to this OAuth grant.", { pid: z.number().int().positive(), input: z.string().max(65536), timeout_ms: wait, wait_for_prompt: z.boolean().optional(), verbose_timing: z.boolean().optional() }, false),
2062
+ tool("read_process_output", "terminal:execute", "Read buffered output of an owned process. offset 0 returns new output; positive/negative offsets page from beginning/end.", { pid: z.number().int().positive(), ...paging, timeout_ms: wait }),
2063
+ tool("list_sessions", "terminal:execute", "List this OAuth connection's managed terminal sessions."),
2064
+ tool("force_terminate", "terminal:execute", "Terminate an owned terminal session and its child process tree.", { pid: z.number().int().positive() }, false),
2065
+ tool("list_processes", "terminal:execute", "List operating-system processes with available CPU and memory information."),
2066
+ tool("kill_process", "terminal:execute", "Terminate an owned session, or an unrelated OS process only with confirm:true. The agent, its ancestors and other OAuth grants are protected.", { pid: z.number().int().positive(), confirm: z.boolean().optional() }, false),
2067
+ tool("run_command", "terminal:execute", "Run a bounded shell command on this PC (maximum 25 seconds). For persistent work use start_process.", { command: z.string().min(1).max(4096), timeoutSeconds: z.number().int().min(1).max(25).default(15) }, false)
2068
+ ];
2069
+ var TOOL_NAMES = TOOL_DEFS.map((t) => t.name);
2070
+
2071
+ // src/agent/version.mjs
2072
+ var AGENT_VERSION = "0.3.0";
2073
+
2074
+ // src/agent/runtime.mjs
2075
+ var states3 = /* @__PURE__ */ new WeakMap();
2076
+ var documentExtensions = /* @__PURE__ */ new Set([".pdf", ".docx", ".xlsx", ".xls", ".xlsm", ".png", ".jpg", ".jpeg", ".gif", ".webp"]);
2077
+ var processTools = /* @__PURE__ */ new Set(["start_process", "interact_with_process", "read_process_output", "list_sessions", "force_terminate", "list_processes", "kill_process"]);
2078
+ var searchTools = /* @__PURE__ */ new Set(["start_search", "get_more_search_results", "list_searches", "stop_search"]);
2079
+ var metaTools = /* @__PURE__ */ new Set(["get_config", "get_prompts", "get_recent_tool_calls", "get_usage_stats"]);
2080
+ function state(config) {
2081
+ let s = states3.get(config);
2082
+ if (!s) {
2083
+ s = { startedAt: Date.now(), policyEpoch: 0, grants: /* @__PURE__ */ new Map(), clients: /* @__PURE__ */ new Map() };
2084
+ states3.set(config, s);
2085
+ }
2086
+ return s;
2087
+ }
2088
+ function grantState(config, context) {
2089
+ const s = state(config), id = context.grantId || "local";
2090
+ if (!s.grants.has(id)) {
2091
+ if (s.grants.size >= 32) s.grants.delete(s.grants.keys().next().value);
2092
+ s.grants.set(id, { calls: 0, failed: 0, totalMs: 0, tools: {}, history: [] });
2093
+ }
2094
+ return s.grants.get(id);
2095
+ }
2096
+ function requested(args) {
2097
+ const p = args.path ?? args.file_path ?? args.filePath;
2098
+ if (typeof p !== "string" || !p.trim()) throw new Error("A relative file path is required");
2099
+ return p;
2100
+ }
2101
+ function bounded2(value, limit = 8192) {
2102
+ const s = JSON.stringify(value);
2103
+ return s.length > limit ? { preview: s.slice(0, limit), truncated: true } : value;
2104
+ }
2105
+ function publicAddress(address) {
2106
+ if (net.isIP(address) === 4) {
2107
+ const [a, b] = address.split(".").map(Number);
2108
+ return !(a === 0 || a === 10 || a === 127 || a >= 224 || a === 169 && b === 254 || a === 172 && b >= 16 && b <= 31 || a === 192 && (b === 168 || b === 0) || a === 100 && b >= 64 && b <= 127 || a === 198 && (b === 18 || b === 19));
2109
+ }
2110
+ return net.isIP(address) === 6 && /^[23][0-9a-f]{3}:/i.test(address);
2111
+ }
2112
+ async function readUrl(value, redirects = 0) {
2113
+ const url = new URL(value);
2114
+ if (url.protocol !== "https:" || url.username || url.password || url.port && url.port !== "443") throw new Error("Only public HTTPS URLs without credentials are supported");
2115
+ const host = url.hostname.replace(/^\[|\]$/g, "");
2116
+ const addresses = net.isIP(host) ? [{ address: host, family: net.isIP(host) }] : await dns.lookup(host, { all: true });
2117
+ if (!addresses.length || addresses.some((a) => !publicAddress(a.address))) throw new Error("Private/local URL addresses are not allowed");
2118
+ const address = addresses[0];
2119
+ return new Promise((resolve, reject) => {
2120
+ const req = https.get(url, { lookup: (_hostname, options, cb) => options.all ? cb(null, [address]) : cb(null, address.address, address.family), timeout: 15e3 }, (res) => {
2121
+ if ([301, 302, 303, 307, 308].includes(res.statusCode)) {
2122
+ res.resume();
2123
+ if (redirects >= 3 || !res.headers.location) {
2124
+ reject(new Error("Too many redirects"));
2125
+ return;
2126
+ }
2127
+ readUrl(new URL(res.headers.location, url), redirects + 1).then(resolve, reject);
2128
+ return;
2129
+ }
2130
+ if (res.statusCode !== 200) {
2131
+ res.resume();
2132
+ reject(new Error("URL returned HTTP " + res.statusCode));
2133
+ return;
2134
+ }
2135
+ if (!/^(text\/|application\/(json|xml|javascript|xhtml\+xml))/.test(res.headers["content-type"] || "")) {
2136
+ res.resume();
2137
+ reject(new Error("URL reads support text, JSON and XML. Download binary documents into the approved folder first."));
2138
+ return;
2139
+ }
2140
+ const chunks = [];
2141
+ let size = 0;
2142
+ res.on("data", (chunk) => {
2143
+ size += chunk.length;
2144
+ if (size > 256 * 1024) {
2145
+ req.destroy(new Error("URL exceeds 256 KiB response limit"));
2146
+ return;
2147
+ }
2148
+ chunks.push(chunk);
2149
+ });
2150
+ res.on("end", () => resolve({ url: url.href, content: Buffer.concat(chunks).toString("utf8"), bytes: size }));
2151
+ res.on("error", reject);
2152
+ });
2153
+ req.on("timeout", () => req.destroy(new Error("URL request timed out")));
2154
+ req.on("error", reject);
2155
+ });
2156
+ }
2157
+ var prompts = [
2158
+ { id: "onb2_01", title: "Organize a work folder", prompt: "Inspect files in the approved folder, propose a grouping, and ask before moving or renaming existing files." },
2159
+ { id: "onb2_02", title: "Explain a codebase", prompt: "List the repository and search for entry points. Read relevant files and explain architecture with references. Do not execute project scripts unless requested." },
2160
+ { id: "onb2_03", title: "Create a knowledge base", prompt: "Read the selected notes and propose an index. Create organized documents only after the user chooses the destination and content." },
2161
+ { id: "onb2_04", title: "Analyze a data file", prompt: "Inspect the file metadata and a bounded sample. Analyze the selected data, report assumptions and preserve the original file." },
2162
+ { id: "onb2_05", title: "Check system health", prompt: "Inspect device information and available process statistics. Explain findings; ask before terminating any process or changing settings." }
2163
+ ];
2164
+ async function applyPreference(config, args) {
2165
+ const { key, value } = args;
2166
+ const previous = config[key];
2167
+ if (key === "allowedDirectories") {
2168
+ if (!Array.isArray(value) || value.length < 1 || value.length > 20) throw new Error("At least one approved subdirectory is required; empty never grants full-disk access");
2169
+ const base = await fs6.realpath(config.root), dirs = [];
2170
+ for (const item of value) {
2171
+ if (typeof item !== "string") throw new Error("Invalid directory");
2172
+ const rel = path8.isAbsolute(item) ? path8.relative(base, item) : item;
2173
+ const target = await safePath({ ...config, allowedDirectories: void 0 }, rel || ".");
2174
+ if (!(await fs6.stat(target)).isDirectory()) throw new Error("Not a directory");
2175
+ dirs.push(target);
2176
+ }
2177
+ config.allowedDirectories = dirs;
2178
+ } else if (key === "blockedCommands") {
2179
+ if (!Array.isArray(value) || value.length > 100 || value.some((x) => typeof x !== "string" || !x.trim() || x.length > 200)) throw new Error("Invalid command list");
2180
+ config.blockedCommands = [...value];
2181
+ } else if (key === "defaultShell") {
2182
+ const allowed = process.platform === "win32" ? ["powershell.exe", "pwsh.exe", "cmd.exe"] : ["/bin/sh", "/bin/bash", "/bin/zsh"];
2183
+ if (!allowed.includes(value)) throw new Error("Unsupported shell");
2184
+ config.defaultShell = value;
2185
+ } else if (["fileReadLineLimit", "fileWriteLineLimit"].includes(key)) {
2186
+ if (!Number.isInteger(value) || value < 1 || value > 2e3) throw new Error("Line limit must be 1 through 2000");
2187
+ config[key] = value;
2188
+ } else if (key === "telemetryEnabled") {
2189
+ if (value !== false) throw new Error("CetriX does not send telemetry; this setting can only be false");
2190
+ config.telemetryEnabled = false;
2191
+ } else throw new Error("Configuration key cannot be changed remotely");
2192
+ const preferences = Object.fromEntries(["allowedDirectories", "blockedCommands", "defaultShell", "fileReadLineLimit", "fileWriteLineLimit", "telemetryEnabled"].filter((k) => config[k] !== void 0).map((k) => [k, config[k]]));
2193
+ try {
2194
+ await config.savePreferences?.(preferences);
2195
+ } catch (error) {
2196
+ if (previous === void 0) delete config[key];
2197
+ else config[key] = previous;
2198
+ throw error;
2199
+ }
2200
+ if (key === "allowedDirectories") {
2201
+ state(config).policyEpoch++;
2202
+ await cleanupRuntime(config);
2203
+ }
2204
+ return { updated: true, key, value: config[key] };
2205
+ }
2206
+ function setPreference(config, args, context) {
2207
+ const s = state(config), run2 = (s.preferenceQueue || Promise.resolve()).then(() => {
2208
+ if (context.isActive && !context.isActive()) throw new Error("Authorization ended");
2209
+ return applyPreference(config, args);
2210
+ });
2211
+ s.preferenceQueue = run2.catch(() => {
2212
+ });
2213
+ return run2;
2214
+ }
2215
+ async function readAny(config, args) {
2216
+ if (args.isUrl) return readUrl(requested(args));
2217
+ const target = await safePath(config, requested(args));
2218
+ return documentExtensions.has(path8.extname(target).toLowerCase()) ? readDocument(config, target, args) : executeFilesystemTool(config, "read_file", args);
2219
+ }
2220
+ async function dispatch(config, tool2, args, context) {
2221
+ if (context.isActive && !context.isActive()) throw new Error("Authorization ended");
2222
+ if (tool2 === "ping_device") return { pong: true, deviceId: config.deviceId, timestamp: (/* @__PURE__ */ new Date()).toISOString() };
2223
+ if (tool2 === "shutdown_device") return { stopping: true, deviceId: config.deviceId, message: "Agent is stopping. Restart it locally to reconnect." };
2224
+ if (tool2 === "get_config") return { version: AGENT_VERSION, allowedDirectories: config.allowedDirectories || [config.root], approvedRoot: config.root, allowWrite: !!config.allowWrite, allowShell: !!config.allowShell, blockedCommands: config.blockedCommands || [], defaultShell: config.defaultShell || (process.platform === "win32" ? "powershell.exe" : "/bin/sh"), fileReadLineLimit: config.fileReadLineLimit || 1e3, fileWriteLineLimit: config.fileWriteLineLimit || 50, telemetryEnabled: false, currentClient: context.client || null, clientHistory: [...state(config).clients.values()], systemInfo: { platform: process.platform, arch: process.arch, release: os.release(), hostname: os.hostname(), node: process.version, cpus: os.cpus().length, totalMemory: os.totalmem(), freeMemory: os.freemem() } };
2225
+ if (tool2 === "set_config_value") return setPreference(config, args, context);
2226
+ if (tool2 === "get_prompts") {
2227
+ if (!args.promptId) return { prompts: prompts.map(({ id, title }) => ({ id, title })) };
2228
+ const p = prompts.find((p2) => p2.id === args.promptId);
2229
+ if (!p) throw new Error("Unknown prompt ID");
2230
+ return p;
2231
+ }
2232
+ if (tool2 === "get_usage_stats") {
2233
+ const { history, ...stats } = grantState(config, context);
2234
+ return { since: new Date(state(config).startedAt).toISOString(), ...stats };
2235
+ }
2236
+ if (tool2 === "get_recent_tool_calls") {
2237
+ const entries = grantState(config, context).history, offset = args.offset ?? 0, start2 = offset < 0 ? Math.max(0, entries.length + offset) : offset;
2238
+ return { total: entries.length, calls: entries.slice(start2, start2 + (args.length || 100)).map((entry) => args.includeContent ? entry : Object.fromEntries(Object.entries(entry).filter(([key]) => !["args", "result"].includes(key)))) };
2239
+ }
2240
+ if (tool2 === "read_file") return readAny(config, args);
2241
+ if (tool2 === "read_multiple_files") {
2242
+ const results = [];
2243
+ let size = 0;
2244
+ for (const p of args.paths) {
2245
+ let result;
2246
+ try {
2247
+ result = { path: p, result: await readAny(config, { ...args, path: p }) };
2248
+ } catch (error) {
2249
+ result = { path: p, error: error.message };
2250
+ }
2251
+ size += Buffer.byteLength(JSON.stringify(result));
2252
+ if (size > 640 * 1024) {
2253
+ results.push({ path: p, error: "Combined response limit reached; read remaining files separately" });
2254
+ break;
2255
+ }
2256
+ results.push(result);
2257
+ }
2258
+ const hasImages = results.some((r) => Array.isArray(r.result?.content));
2259
+ return hasImages ? { content: results.flatMap((r) => Array.isArray(r.result?.content) ? [{ type: "text", text: r.path }, ...r.result.content] : [{ type: "text", text: JSON.stringify(r) }]) } : { files: results };
2260
+ }
2261
+ if (["write_file", "edit_block"].includes(tool2)) {
2262
+ const target = await safePath(config, requested(args), { write: tool2 === "write_file" });
2263
+ if (documentExtensions.has(path8.extname(target).toLowerCase())) return tool2 === "write_file" ? writeDocument(config, target, args) : editDocument(config, target, args);
2264
+ }
2265
+ if (tool2 === "write_pdf") return writePdf(config, { ...args, path: requested(args) });
2266
+ if (processTools.has(tool2)) return executeProcessTool(config, tool2, args, context);
2267
+ if (searchTools.has(tool2)) return executeSearchTool(config, tool2, args, context);
2268
+ if (tool2 === "run_command") {
2269
+ const deadline = Date.now() + (args.timeoutSeconds || 15) * 1e3;
2270
+ let result = await executeProcessTool(config, "start_process", { command: args.command, timeout_ms: Math.max(0, deadline - Date.now()) }, context), output = result.output || "";
2271
+ while (result.status === "running" && Date.now() < deadline && Buffer.byteLength(output) <= 65536) {
2272
+ result = await executeProcessTool(config, "read_process_output", { pid: result.pid, timeout_ms: Math.max(0, deadline - Date.now()) }, context);
2273
+ output += result.output || "";
2274
+ }
2275
+ const terminated = result.status === "running", truncated = Buffer.byteLength(output) > 65536;
2276
+ if (terminated) result = await executeProcessTool(config, "force_terminate", { pid: result.pid }, context);
2277
+ return { stdout: Buffer.from(output).subarray(0, 65536).toString("utf8"), stderr: "", outputMerged: true, exitCode: result.exitCode, signal: result.signal, terminated, truncated, pid: result.pid };
2278
+ }
2279
+ const info = await executeFilesystemTool(config, tool2, args);
2280
+ if (tool2 === "get_file_info" && [".xlsx", ".xls", ".xlsm"].includes(path8.extname(requested(args)).toLowerCase())) {
2281
+ const doc = await readDocument(config, await safePath(config, requested(args)), { metadataOnly: true });
2282
+ return { ...info, ...doc };
2283
+ }
2284
+ return info;
2285
+ }
2286
+ async function executeRuntimeTool(config, tool2, args = {}, context = {}) {
2287
+ const def = TOOL_DEFS.find((d) => d.name === tool2);
2288
+ if (!def) throw new Error("Unknown tool");
2289
+ if (def.scope === "files:write" && !config.allowWrite) throw new Error("File writes are disabled on this PC");
2290
+ if (def.scope === "terminal:execute" && !config.allowShell) throw new Error("Shell execution is disabled on this PC");
2291
+ const parsed = def.schema.parse(args), stats = grantState(config, context), started = Date.now();
2292
+ const epoch = state(config).policyEpoch, liveContext = { ...context, isActive: () => (!context.isActive || context.isActive()) && (tool2 === "set_config_value" || state(config).policyEpoch === epoch) };
2293
+ if (context.client) {
2294
+ const s = state(config);
2295
+ s.clients.set(context.client.id, { ...context.client, lastSeen: (/* @__PURE__ */ new Date()).toISOString() });
2296
+ if (s.clients.size > 100) s.clients.delete(s.clients.keys().next().value);
2297
+ }
2298
+ if (["run_command", "start_process", "interact_with_process"].includes(tool2)) {
2299
+ const command = String(parsed.command ?? parsed.input ?? "").toLowerCase();
2300
+ for (const blocked of config.blockedCommands || []) if (command.includes(blocked.toLowerCase())) throw new Error("Command matches the configured block list");
2301
+ }
2302
+ let result, success = false;
2303
+ try {
2304
+ result = await dispatch(config, tool2, parsed, liveContext);
2305
+ if (!liveContext.isActive()) {
2306
+ await cleanupRuntime(config, { grantId: context.grantId });
2307
+ throw new Error("Authorization or directory policy changed");
2308
+ }
2309
+ if (Buffer.byteLength(JSON.stringify(result)) > 900 * 1024) throw new Error("Response too large; request a smaller range");
2310
+ success = true;
2311
+ return result;
2312
+ } finally {
2313
+ if (!metaTools.has(tool2)) {
2314
+ stats.calls++;
2315
+ if (!success) stats.failed++;
2316
+ stats.totalMs += Date.now() - started;
2317
+ stats.tools[tool2] = (stats.tools[tool2] || 0) + 1;
2318
+ stats.history.push({ tool: tool2, at: new Date(started).toISOString(), durationMs: Date.now() - started, success, args: bounded2(parsed), result: bounded2(result ?? null) });
2319
+ if (stats.history.length > 100) stats.history.shift();
2320
+ }
2321
+ }
2322
+ }
2323
+ async function cleanupRuntime(config, filter = {}) {
2324
+ await Promise.allSettled([cleanupProcessSessions(config, filter), cleanupSearchSessions(config, filter)]);
2325
+ const s = states3.get(config);
2326
+ if (filter.grantId) s?.grants.delete(filter.grantId);
2327
+ else if (s) s.grants.clear();
2328
+ }
2329
+
2330
+ // src/agent/client.mjs
2331
+ function connectAgent(config, { onReady = () => {
2332
+ }, onStatus = () => {
2333
+ }, onShutdown = () => {
2334
+ }, reconnect = true, execute = executeRuntimeTool } = {}) {
2335
+ let socket, timer, closed = false, retry = 0, generation = 0;
2336
+ const revoked = /* @__PURE__ */ new Set();
2337
+ const stop = () => {
2338
+ closed = true;
2339
+ generation++;
2340
+ clearTimeout(timer);
2341
+ socket?.close();
2342
+ return cleanupRuntime(config);
2343
+ };
2344
+ const connect = () => {
2345
+ if (closed) return;
2346
+ const url = new URL("/agent", config.server);
2347
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
2348
+ socket = new WebSocket2(url, { maxPayload: 1024 * 1024 });
2349
+ let ready = false, active2 = 0;
2350
+ const seen = /* @__PURE__ */ new Set();
2351
+ const authTimer = setTimeout(() => {
2352
+ if (!ready) socket.close();
2353
+ }, 2e4);
2354
+ socket.on("message", async (raw) => {
2355
+ try {
2356
+ const msg = JSON.parse(raw.toString());
2357
+ if (msg.type === "challenge" && !ready) {
2358
+ if (typeof msg.nonce !== "string" || !/^[A-Za-z0-9_-]{43}$/.test(msg.nonce)) throw new Error("Invalid server challenge");
2359
+ const signature = sign(null, Buffer.from(proofMessage(config.deviceId, msg.nonce)), config.privateKey).toString("base64url");
2360
+ const tools = TOOL_DEFS.filter((d) => (d.scope !== "files:write" || config.allowWrite) && (d.scope !== "terminal:execute" || config.allowShell)).map((d) => d.name);
2361
+ socket.send(JSON.stringify({ type: "auth", deviceId: config.deviceId, signature, version: AGENT_VERSION, tools }));
2362
+ return;
2363
+ }
2364
+ if (msg.type === "ready" && msg.deviceId === config.deviceId) {
2365
+ ready = true;
2366
+ retry = 0;
2367
+ clearTimeout(authTimer);
2368
+ onStatus("online");
2369
+ onReady();
2370
+ return;
2371
+ }
2372
+ if (ready && msg.type === "revoke" && typeof msg.grantId === "string") {
2373
+ revoked.add(msg.grantId);
2374
+ if (revoked.size > 2e3) revoked.delete(revoked.values().next().value);
2375
+ await cleanupRuntime(config, { grantId: msg.grantId });
2376
+ return;
2377
+ }
2378
+ if (ready && msg.type === "call" && typeof msg.id === "string" && !seen.has(msg.id)) {
2379
+ const current = socket;
2380
+ seen.add(msg.id);
2381
+ if (seen.size > 2e3) seen.delete(seen.values().next().value);
2382
+ if (active2 >= 8) {
2383
+ current.send(JSON.stringify({ type: "result", id: msg.id, error: "Agent is busy" }));
2384
+ return;
2385
+ }
2386
+ active2++;
2387
+ const lease = generation, grantId = msg.context?.grantId;
2388
+ const context = { grantId, client: msg.context?.client, isActive: () => !closed && lease === generation && current === socket && !revoked.has(grantId) };
2389
+ try {
2390
+ if (!context.isActive()) throw new Error("Authorization ended");
2391
+ const result = await execute(config, msg.tool, msg.args || {}, context);
2392
+ if (context.isActive() && current.readyState === WebSocket2.OPEN) current.send(JSON.stringify({ type: "result", id: msg.id, result }), (error) => {
2393
+ if (!error && msg.tool === "shutdown_device") void stop().then(onShutdown);
2394
+ });
2395
+ } catch (error) {
2396
+ if (current.readyState === WebSocket2.OPEN) current.send(JSON.stringify({ type: "result", id: msg.id, error: error.message }));
2397
+ } finally {
2398
+ active2--;
2399
+ }
2400
+ }
2401
+ } catch {
2402
+ socket.close(1008, "Invalid relay message");
2403
+ }
2404
+ });
2405
+ socket.on("error", () => onStatus("connection-error"));
2406
+ socket.on("close", (code) => {
2407
+ generation++;
2408
+ void cleanupRuntime(config);
2409
+ clearTimeout(authTimer);
2410
+ onStatus(code === 1008 ? "authorization-rejected" : "offline");
2411
+ if (!closed && reconnect && code !== 1008) {
2412
+ timer = setTimeout(connect, Math.min(3e4, 1e3 * 2 ** Math.min(retry++, 5)) + Math.random() * 500);
2413
+ }
2414
+ });
2415
+ };
2416
+ connect();
2417
+ return { close: stop, get socket() {
2418
+ return socket;
2419
+ } };
2420
+ }
2421
+
2422
+ // src/agent/entry.mjs
2423
+ var shellQuote = (value) => "'" + value.replaceAll("'", process.platform === "win32" ? "''" : "'\\''") + "'";
2424
+ var version = "0.3.0";
2425
+ var remoteServer = "https://connect.cetrix.app";
2426
+ var controlCharacters = /[\u0000-\u001f\u007f]/;
2427
+ function serverOrigin(value) {
2428
+ if (typeof value !== "string" || value.length > 2048 || controlCharacters.test(value)) throw new Error("Invalid server origin");
2429
+ const u = new URL(value);
2430
+ if (u.pathname !== "/" || u.search || u.hash || u.username || u.password) throw new Error("Server must be an origin without credentials");
2431
+ if (u.protocol !== "https:" && !(u.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(u.hostname))) throw new Error("HTTPS required except on loopback");
2432
+ return u.origin;
2433
+ }
2434
+ function decodeOptions(value) {
2435
+ if (value.length > 16384 || !value.length || !/^[A-Za-z0-9_-]+$/.test(value)) throw new Error("Invalid --options: expected base64url JSON");
2436
+ const bytes = Buffer.from(value, "base64url");
2437
+ if (bytes.length > 8192 || bytes.toString("base64url") !== value) throw new Error("Invalid --options encoding");
2438
+ let decoded;
2439
+ try {
2440
+ decoded = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
2441
+ } catch {
2442
+ throw new Error("Invalid --options JSON");
2443
+ }
2444
+ if (!decoded || typeof decoded !== "object" || Array.isArray(decoded)) throw new Error("Invalid --options object");
2445
+ const fields = { server: "string", root: "string", name: "string", allowWrite: "boolean", allowShell: "boolean" };
2446
+ for (const [key, value2] of Object.entries(decoded)) {
2447
+ if (!Object.hasOwn(fields, key) || typeof value2 !== fields[key]) throw new Error("Invalid --options field: " + key);
2448
+ }
2449
+ return decoded;
2450
+ }
2451
+ async function prepareAgentOptions(args, { npmMode = false, input = process.stdin, output = process.stdout, isTTY = !!(input.isTTY && output.isTTY), question } = {}) {
2452
+ const { values: v, positionals: p } = parseArgs({ args, allowPositionals: true, options: { server: { type: "string" }, root: { type: "string" }, name: { type: "string" }, config: { type: "string" }, options: { type: "string" }, "allow-write": { type: "boolean" }, "allow-shell": { type: "boolean" }, "no-browser": { type: "boolean" }, help: { type: "boolean" } } });
2453
+ if (v.help || !p.length && !npmMode) return { help: true };
2454
+ if (p.length > 1) throw new Error("Choose one command: remote, setup, or run. Use --help.");
2455
+ const command = p[0] || (npmMode ? "remote" : void 0);
2456
+ if (!["remote", "setup", "run"].includes(command)) throw new Error("Expected remote, setup, or run. Use --help.");
2457
+ if (v.options !== void 0) {
2458
+ if (command !== "remote") throw new Error("--options is only supported by remote");
2459
+ const decoded = decodeOptions(v.options);
2460
+ for (const [source, target] of [["server", "server"], ["root", "root"], ["name", "name"], ["allowWrite", "allow-write"], ["allowShell", "allow-shell"]]) {
2461
+ if (decoded[source] === void 0) continue;
2462
+ if (v[target] !== void 0) throw new Error("Do not combine --options " + source + " with --" + target);
2463
+ v[target] = decoded[source];
2464
+ }
2465
+ }
2466
+ if (command !== "remote") return { command, values: v };
2467
+ v.server = serverOrigin(v.server || remoteServer);
2468
+ if (!v.root && !isTTY) throw new Error('A data folder is required. Run remote in an interactive terminal, or add --root "C:\\Work" (an existing folder). No folder is granted automatically.');
2469
+ let prompts2;
2470
+ const ask = question || ((prompt) => {
2471
+ prompts2 ||= createInterface({ input, output });
2472
+ return prompts2.question(prompt);
2473
+ });
2474
+ try {
2475
+ if (v.name === void 0 && isTTY) v.name = (await ask("Ten may [" + os2.hostname() + "]: ")).trim() || os2.hostname();
2476
+ if (!v.root) v.root = (await ask("Thu muc du lieu (duong dan day du): ")).trim();
2477
+ } finally {
2478
+ prompts2?.close();
2479
+ }
2480
+ v.name = (v.name ?? os2.hostname()).trim();
2481
+ if (!v.name || v.name.length > 100 || controlCharacters.test(v.name)) throw new Error("Machine name must contain 1\u2013100 characters without control characters");
2482
+ v.root = v.root.trim();
2483
+ if (v.root.startsWith('"') && v.root.endsWith('"')) v.root = v.root.slice(1, -1);
2484
+ if (v.root.length > 1024 || controlCharacters.test(v.root) || !path9.isAbsolute(v.root)) throw new Error("Select an existing data folder using its full absolute path");
2485
+ let root;
2486
+ try {
2487
+ root = await fs7.realpath(v.root);
2488
+ if (!(await fs7.stat(root)).isDirectory()) throw new Error("Not a directory");
2489
+ } catch {
2490
+ throw new Error("Data folder does not exist or cannot be opened: " + v.root);
2491
+ }
2492
+ if (root === path9.parse(root).root) throw new Error("Select a specific work folder, not a drive root");
2493
+ v.root = root;
2494
+ return { command: "setup", values: v };
2495
+ }
2496
+ function openBrowser(url) {
2497
+ const child = process.platform === "win32" ? spawn4("rundll32.exe", ["url.dll,FileProtocolHandler", url], { windowsHide: true, stdio: "ignore" }) : spawn4(process.platform === "darwin" ? "open" : "xdg-open", [url], { stdio: "ignore" });
2498
+ child.on("error", () => {
2499
+ });
2500
+ child.unref();
2501
+ }
2502
+ async function post(server, route, body) {
2503
+ const response = await fetch(server + route, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), signal: AbortSignal.timeout(15e3), redirect: "error" });
2504
+ const data = await response.json();
2505
+ return { ok: response.ok, data };
2506
+ }
2507
+ async function runAgent(args = process.argv.slice(2), options = {}) {
2508
+ const { help, command, values: v } = await prepareAgentOptions(args, options);
2509
+ if (help) {
2510
+ console.log("CetriX Connect agent " + version + "\n\nQuick connect: npx --yes cetrix-connect@" + version + ' remote\nRemote options: --name "PC 01" --root "C:\\Work" --server https://your-host\nSetup: node dist/agent.mjs setup --server https://your-host --root "C:\\Work" --name "PC 01"\nOptional explicit permissions: --allow-write --allow-shell\nRun: ' + (options.npmMode ? "npx --yes cetrix-connect@" + version : "node dist/agent.mjs") + ' run --config "path-to-device.json"\n\nDefault: read-only. Keep the agent open; Ctrl+C disconnects.');
2511
+ return;
2512
+ }
2513
+ let config, filename;
2514
+ if (command === "setup") {
2515
+ if (!v.server || !v.root) throw new Error("--server and --root are required. No home-directory access is granted by default.");
2516
+ const server = serverOrigin(v.server), root = await fs7.realpath(v.root);
2517
+ if (!(await fs7.stat(root)).isDirectory() || root === path9.parse(root).root) throw new Error("Select a specific work folder, not a drive root");
2518
+ const stateDir = process.platform === "win32" ? path9.join(process.env.LOCALAPPDATA || os2.homedir(), "CetriX", "Connect", "agents") : path9.join(os2.homedir(), ".local", "share", "cetrix-connect", "agents");
2519
+ filename = path9.resolve(v.config || path9.join(stateDir, randomUUID5() + ".json"));
2520
+ try {
2521
+ await fs7.access(filename);
2522
+ throw new Error("Config already exists; use run or choose a new config path");
2523
+ } catch (error) {
2524
+ if (error.code !== "ENOENT") throw error;
2525
+ }
2526
+ const { publicKey, privateKey } = generateKeyPairSync("ed25519", { publicKeyEncoding: { type: "spki", format: "pem" }, privateKeyEncoding: { type: "pkcs8", format: "pem" } });
2527
+ const name = v.name || os2.hostname(), allowWrite = !!v["allow-write"], allowShell = !!v["allow-shell"];
2528
+ console.log("CetriX Connect - " + name + "\nApproved root: " + root + "\nWrite: " + allowWrite + " | Shell: " + allowShell);
2529
+ if (allowShell) console.log("WARNING: Shell runs with this OS account privileges. It is NOT sandboxed to the root folder.");
2530
+ const begun = await post(server, "/api/enrollment/start", { name, root, publicKey, allowWrite, allowShell });
2531
+ if (!begun.ok) throw new Error(begun.data.error_description || "Enrollment failed");
2532
+ const enrollment = begun.data;
2533
+ console.log("\nPAIRING CODE: " + enrollment.user_code + "\nFingerprint: " + enrollment.fingerprint + "\nConfirm: " + enrollment.verification_uri_complete);
2534
+ if (!v["no-browser"]) openBrowser(enrollment.verification_uri_complete);
2535
+ const deadline = Date.now() + enrollment.expires_in * 1e3;
2536
+ let paired;
2537
+ while (Date.now() < deadline) {
2538
+ await sleep(5e3);
2539
+ const polled = await post(server, "/api/enrollment/poll", { device_code: enrollment.device_code });
2540
+ if (polled.ok) {
2541
+ paired = polled.data;
2542
+ break;
2543
+ }
2544
+ if (!["authorization_pending", "slow_down"].includes(polled.data.error)) throw new Error(polled.data.error_description || "Enrollment failed");
2545
+ }
2546
+ if (!paired) throw new Error("Pairing expired. Run setup again.");
2547
+ config = { server, root, name, allowWrite, allowShell, deviceId: paired.device_id, privateKey, publicKey, deniedPaths: [stateDir, path9.dirname(filename), path9.join(projectDir, "data"), path9.resolve("data")] };
2548
+ await saveIdentity(filename, config);
2549
+ const restart = options.npmMode ? "npx --yes cetrix-connect@" + version : "node " + shellQuote(path9.resolve(process.argv[1]));
2550
+ console.log("\nMCP endpoint: " + paired.endpoint + "\nIdentity saved to: " + filename + "\nNext run: " + restart + " run --config " + shellQuote(filename));
2551
+ } else if (command === "run") {
2552
+ if (!v.config) throw new Error("--config is required");
2553
+ filename = path9.resolve(v.config);
2554
+ config = await loadIdentity(filename);
2555
+ config.server = serverOrigin(config.server);
2556
+ config.deniedPaths = [...config.deniedPaths || [], path9.dirname(filename), path9.join(projectDir, "data"), path9.resolve("data")];
2557
+ } else throw new Error("Expected setup or run. Use --help.");
2558
+ const settingsFile = filename + ".settings.json";
2559
+ try {
2560
+ const settings = JSON.parse(await fs7.readFile(settingsFile, "utf8"));
2561
+ if (!settings || typeof settings !== "object" || Array.isArray(settings)) throw new Error("Invalid preferences object");
2562
+ if (settings.allowedDirectories !== void 0 && (!Array.isArray(settings.allowedDirectories) || !settings.allowedDirectories.length || settings.allowedDirectories.length > 20 || settings.allowedDirectories.some((p) => typeof p !== "string" || !path9.isAbsolute(p) || p.includes("\0")))) throw new Error("Invalid allowed directories");
2563
+ if (settings.blockedCommands !== void 0 && (!Array.isArray(settings.blockedCommands) || settings.blockedCommands.length > 100 || settings.blockedCommands.some((p) => typeof p !== "string" || !p.trim() || p.length > 200))) throw new Error("Invalid blocked commands");
2564
+ for (const key of ["fileReadLineLimit", "fileWriteLineLimit"]) if (settings[key] !== void 0 && (!Number.isInteger(settings[key]) || settings[key] < 1 || settings[key] > 2e3)) throw new Error("Invalid line limit");
2565
+ if (settings.defaultShell !== void 0 && !(process.platform === "win32" ? ["powershell.exe", "pwsh.exe", "cmd.exe"] : ["/bin/sh", "/bin/bash", "/bin/zsh"]).includes(settings.defaultShell)) throw new Error("Invalid default shell");
2566
+ if (settings.telemetryEnabled !== void 0 && settings.telemetryEnabled !== false) throw new Error("Telemetry is not supported");
2567
+ for (const key of ["blockedCommands", "defaultShell", "allowedDirectories", "fileReadLineLimit", "fileWriteLineLimit", "telemetryEnabled"]) if (settings[key] !== void 0) config[key] = settings[key];
2568
+ } catch (error) {
2569
+ if (error.code !== "ENOENT") throw new Error("Unable to load agent settings: " + error.message);
2570
+ }
2571
+ config.savePreferences = async (preferences) => {
2572
+ const temporary = settingsFile + "." + randomUUID5() + ".tmp";
2573
+ try {
2574
+ await fs7.writeFile(temporary, JSON.stringify(preferences, null, 2), { mode: 384, flag: "wx" });
2575
+ await fs7.rename(temporary, settingsFile);
2576
+ } finally {
2577
+ await fs7.unlink(temporary).catch(() => {
2578
+ });
2579
+ }
2580
+ };
2581
+ const agent = connectAgent(config, { onStatus: (status) => console.log((/* @__PURE__ */ new Date()).toISOString() + " " + status), onShutdown: () => process.exit(0) });
2582
+ for (const sig of ["SIGINT", "SIGTERM"]) process.on(sig, () => {
2583
+ Promise.resolve(agent.close()).finally(() => process.exit(0));
2584
+ });
2585
+ }
2586
+
2587
+ // src/agent/remote.mjs
2588
+ runAgent(process.argv.slice(2), { npmMode: true }).catch((error) => {
2589
+ console.error("CetriX Connect:", error.message);
2590
+ process.exitCode = 1;
2591
+ });