@vincemakes/kiso-tools-node 0.24.4 → 0.25.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/index.d.ts +24 -1
- package/dist/index.js +218 -13
- package/package.json +2 -2
package/dist/index.d.ts
CHANGED
|
@@ -91,8 +91,31 @@ export interface WorkspaceToolsOptions {
|
|
|
91
91
|
* STRIPPED — a shell command must not inherit the agent's API keys (a
|
|
92
92
|
* nested kiso would hit the REAL provider and blow up faux e2e runs;
|
|
93
93
|
* the keys are an exposure surface for any command).
|
|
94
|
+
*
|
|
95
|
+
* ADR-0031 Amendment 1: a RECORD is the third shape — the STRIPPED
|
|
96
|
+
* environment plus these explicit entries, the entries winning (the
|
|
97
|
+
* MCP surface's decision 2, applied here). An embedding host injects
|
|
98
|
+
* what its tools need without a temp file and without opening the
|
|
99
|
+
* whole environment; an entry may deliberately re-add a stripped
|
|
100
|
+
* name — explicit beats the heuristic, as it does for MCP servers.
|
|
94
101
|
*/
|
|
95
|
-
readonly shellEnv?: "inherit"
|
|
102
|
+
readonly shellEnv?: "inherit" | Readonly<Record<string, string>>;
|
|
103
|
+
/**
|
|
104
|
+
* DC-54 — the bounds that keep a tool call finite. Every field is
|
|
105
|
+
* optional and defaults to the constant beside it; a host embedding
|
|
106
|
+
* kiso over an unusually large or unusually small tree can move them,
|
|
107
|
+
* and the gate can set them low enough to observe the stop.
|
|
108
|
+
*/
|
|
109
|
+
readonly limits?: {
|
|
110
|
+
/** search_text: skip a file larger than this (default 1 MiB). */
|
|
111
|
+
readonly searchMaxFileBytes?: number;
|
|
112
|
+
/** search_text: stop the walk after this many files (default 20,000). */
|
|
113
|
+
readonly searchMaxFiles?: number;
|
|
114
|
+
/** search_text: stop the walk after this long (default 10s). */
|
|
115
|
+
readonly searchMaxMs?: number;
|
|
116
|
+
/** read_file: refuse a file larger than this (default 64 MiB). */
|
|
117
|
+
readonly readMaxFileBytes?: number;
|
|
118
|
+
};
|
|
96
119
|
}
|
|
97
120
|
export declare function readFileTool(opts: WorkspaceToolsOptions): Tool<{
|
|
98
121
|
path: string;
|
package/dist/index.js
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
import { execFile, execFileSync, spawn } from "node:child_process";
|
|
21
21
|
import { promisify } from "node:util";
|
|
22
22
|
import { chmodSync, existsSync, linkSync, readdirSync, readFileSync, realpathSync, renameSync, statSync, appendFileSync, mkdirSync, rmSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
23
|
-
import { readdir } from "node:fs/promises";
|
|
23
|
+
import { open, readdir } from "node:fs/promises";
|
|
24
24
|
import { createHash } from "node:crypto";
|
|
25
25
|
import { tmpdir } from "node:os";
|
|
26
26
|
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
@@ -80,6 +80,36 @@ const DEFAULT_READ_LINES = 200;
|
|
|
80
80
|
const YIELD_EVERY = 64;
|
|
81
81
|
const MAX_SEARCH_MATCHES = 50;
|
|
82
82
|
const MAX_DIR_ENTRIES = 200;
|
|
83
|
+
/**
|
|
84
|
+
* DC-54 — the bounds. The defect they close: `search_text` read every
|
|
85
|
+
* file WHOLE and SYNCHRONOUSLY, so one 994 GB sparse disk image under a
|
|
86
|
+
* workspace rooted at `~` stopped the event loop and never restarted it.
|
|
87
|
+
*
|
|
88
|
+
* Each constant answers a different unbounded thing, and all four are
|
|
89
|
+
* needed — per-file bounds still leave 296,924 files to traverse under
|
|
90
|
+
* `~`, and a call that traverses forever is a freeze whatever it does
|
|
91
|
+
* per file. The tool must ALWAYS return.
|
|
92
|
+
*/
|
|
93
|
+
/** Skip a file larger than this. Not a PREFIX read: a partial match is a
|
|
94
|
+
* result that has to be explained, and a source file over 1 MiB is
|
|
95
|
+
* almost never what the model was looking for. */
|
|
96
|
+
const SEARCH_MAX_FILE_BYTES = 1024 * 1024;
|
|
97
|
+
/** Stop the walk after this many files, whatever it has found. */
|
|
98
|
+
const SEARCH_MAX_FILES = 20_000;
|
|
99
|
+
/** Stop the walk after this long, whatever it has found. */
|
|
100
|
+
const SEARCH_MAX_MS = 10_000;
|
|
101
|
+
/** read_file refuses above this rather than freezing on it. The ceiling
|
|
102
|
+
* is a REFUSAL, not a truncation, because the `[rev:…]` token hashes
|
|
103
|
+
* the whole file: a revision issued over a prefix would never match the
|
|
104
|
+
* full-file hash write_file computes, and every later write would be
|
|
105
|
+
* refused as stale. No read, no revision, no lie. */
|
|
106
|
+
const READ_MAX_FILE_BYTES = 64 * 1024 * 1024;
|
|
107
|
+
/** How much of a file's head decides whether it is text. */
|
|
108
|
+
const BINARY_SNIFF_BYTES = 8 * 1024;
|
|
109
|
+
/** Bytes as the refusal should say them: "128.0 MiB". */
|
|
110
|
+
function mib(bytes) {
|
|
111
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
|
|
112
|
+
}
|
|
83
113
|
function cap(text) {
|
|
84
114
|
return text.length > OUTPUT_CAP ? `${text.slice(0, OUTPUT_CAP)}\n…[truncated]` : text;
|
|
85
115
|
}
|
|
@@ -301,13 +331,44 @@ export function readFileTool(opts) {
|
|
|
301
331
|
promptSnippet: "read_file — whole files or offset/limit ranges, workspace-relative paths",
|
|
302
332
|
promptGuidelines: ["read only the range you need — offset/limit beat whole-file reads"],
|
|
303
333
|
execute: async ({ path, offset, limit }) => {
|
|
334
|
+
const maxReadBytes = opts.limits?.readMaxFileBytes ?? READ_MAX_FILE_BYTES;
|
|
304
335
|
try {
|
|
305
336
|
const full = resolveWithinRoot(opts.workspaceRoot, path);
|
|
306
337
|
const denied = await inodeReadPolicy(opts.workspaceRoot, full);
|
|
307
338
|
if (denied !== null)
|
|
308
339
|
return escapeResult(denied);
|
|
340
|
+
// DC-54 — the ceiling. `read_file` had the same unbounded
|
|
341
|
+
// `readFileSync` that froze `search_text`, and the same 994 GB
|
|
342
|
+
// `Docker.raw` would have frozen it identically.
|
|
343
|
+
//
|
|
344
|
+
// It REFUSES rather than truncating, because the `[rev:…]`
|
|
345
|
+
// token this tool issues hashes the whole file: a revision
|
|
346
|
+
// computed over a prefix would never equal the full-file hash
|
|
347
|
+
// `write_file` computes, so every later write would be refused
|
|
348
|
+
// as stale. A bounded read here would buy a freeze-free read
|
|
349
|
+
// at the price of a write path that silently stops working.
|
|
350
|
+
const size = statSync(full).size;
|
|
351
|
+
if (size > maxReadBytes) {
|
|
352
|
+
return precondition(`read_file: ${path} is ${mib(size)} — too large to read (ceiling ${mib(maxReadBytes)}); use shell with sed/head to take a range`);
|
|
353
|
+
}
|
|
309
354
|
// WR-1: hash the raw bytes BEFORE decoding — the revision is a
|
|
310
355
|
// fact about the world, not about UTF-8 replacement semantics.
|
|
356
|
+
//
|
|
357
|
+
// DC-54, and this read stays SYNCHRONOUS on purpose. The
|
|
358
|
+
// ruling said to make it async; the first build did, and
|
|
359
|
+
// `tui2-r1-visibility` went red on an invariant worth more
|
|
360
|
+
// than the microseconds: the durable log of three concurrent
|
|
361
|
+
// `read_file` calls stopped being deterministic, because
|
|
362
|
+
// completion order became the libuv threadpool's to decide.
|
|
363
|
+
// That test compares a PTY session's log to a pipe session's
|
|
364
|
+
// to prove the rollup is display-side only, and it cannot do
|
|
365
|
+
// that job over a log that varies run to run.
|
|
366
|
+
//
|
|
367
|
+
// The freeze came from UNBOUNDED work, not from synchronous
|
|
368
|
+
// work. With the ceiling above, the worst case here is a
|
|
369
|
+
// 64 MiB read — about 100 ms, measured — against the 180+
|
|
370
|
+
// seconds this finding is named for. Determinism is worth
|
|
371
|
+
// more than that hitch.
|
|
311
372
|
const bytes = readFileSync(full);
|
|
312
373
|
const content = bytes.toString("utf8");
|
|
313
374
|
// The lines the file DISPLAYS: a trailing newline's empty split
|
|
@@ -400,15 +461,26 @@ export function listDirTool(opts) {
|
|
|
400
461
|
execute: async ({ path }) => {
|
|
401
462
|
try {
|
|
402
463
|
const dir = resolveWithinRoot(opts.workspaceRoot, path ?? ".");
|
|
403
|
-
|
|
464
|
+
// DC-54 — TRUNCATED BEFORE IT BUILDS. It was a `.map` over
|
|
465
|
+
// EVERY entry with the 200-entry slice only after: a directory
|
|
466
|
+
// of 200,000 entries built 200,000 strings to show 200 of
|
|
467
|
+
// them. Milder than the `search_text` freeze and the same
|
|
468
|
+
// mistake — unbounded work before the bound.
|
|
469
|
+
//
|
|
470
|
+
// `readdirSync`, still: same reason as read_file above. The
|
|
471
|
+
// listing is one syscall whose cost is the directory's size,
|
|
472
|
+
// which no bound of ours can shrink, and making it async buys
|
|
473
|
+
// nothing while costing the durable log its determinism.
|
|
474
|
+
const dirents = readdirSync(dir, { withFileTypes: true });
|
|
475
|
+
const entries = dirents.slice(0, MAX_DIR_ENTRIES).map((e) => {
|
|
404
476
|
const isDir = e.isDirectory();
|
|
405
477
|
return `${isDir ? "dir " : "file"} ${e.name}${isDir ? "/" : ""}`;
|
|
406
478
|
});
|
|
407
|
-
let content = entries.length ? cap(entries.
|
|
408
|
-
if (
|
|
479
|
+
let content = entries.length ? cap(entries.join("\n")) : "(empty directory)";
|
|
480
|
+
if (dirents.length > MAX_DIR_ENTRIES) {
|
|
409
481
|
// R-C item 2: the N of M form — the cap names its
|
|
410
482
|
// continuation (narrow to a subdirectory for more).
|
|
411
|
-
content += `\n… ${MAX_DIR_ENTRIES} of ${
|
|
483
|
+
content += `\n… ${MAX_DIR_ENTRIES} of ${dirents.length} entries shown (narrow to a subdirectory for more)`;
|
|
412
484
|
}
|
|
413
485
|
return { content, isError: false };
|
|
414
486
|
}
|
|
@@ -486,6 +558,36 @@ export function searchTextTool(opts) {
|
|
|
486
558
|
// DC-52 — what the search did NOT look at, so the note can say so.
|
|
487
559
|
let multiLink = 0;
|
|
488
560
|
let unreadableDirs = 0;
|
|
561
|
+
// DC-54 — the same discipline for the two new refusals, and for
|
|
562
|
+
// the call budget: a silent skip is a result the model cannot
|
|
563
|
+
// tell is incomplete.
|
|
564
|
+
let skippedFiles = 0;
|
|
565
|
+
let filesSeen = 0;
|
|
566
|
+
// An explicit flag, NOT `stoppedAt > 0`: a wall-clock budget can
|
|
567
|
+
// expire before the first file is scanned, and a zero-valued
|
|
568
|
+
// sentinel would then read as "never stopped" — the walk would
|
|
569
|
+
// still end, but silently, which is the one thing every note in
|
|
570
|
+
// this function exists to prevent.
|
|
571
|
+
let stopped = false;
|
|
572
|
+
let stoppedAt = 0;
|
|
573
|
+
const maxFileBytes = opts.limits?.searchMaxFileBytes ?? SEARCH_MAX_FILE_BYTES;
|
|
574
|
+
const maxFiles = opts.limits?.searchMaxFiles ?? SEARCH_MAX_FILES;
|
|
575
|
+
const deadline = Date.now() + (opts.limits?.searchMaxMs ?? SEARCH_MAX_MS);
|
|
576
|
+
/** DC-54 ④ — the CALL budget. Per-file bounds are not enough:
|
|
577
|
+
* under `~` the walk still reaches 296,924 files, and 10 seconds
|
|
578
|
+
* of traversal with nothing on screen is the same freeze from
|
|
579
|
+
* the outside. Whichever bound trips first stops the walk, and
|
|
580
|
+
* the note names the continuation. */
|
|
581
|
+
const outOfBudget = () => {
|
|
582
|
+
if (stopped)
|
|
583
|
+
return true;
|
|
584
|
+
if (filesSeen >= maxFiles || Date.now() > deadline) {
|
|
585
|
+
stopped = true;
|
|
586
|
+
stoppedAt = filesSeen;
|
|
587
|
+
return true;
|
|
588
|
+
}
|
|
589
|
+
return false;
|
|
590
|
+
};
|
|
489
591
|
// R3 — the walk YIELDS. It was `readdirSync` + `readFileSync` all
|
|
490
592
|
// the way down inside an `async` body, which is the shape that
|
|
491
593
|
// blocks Node's event loop for the whole traversal: measured at
|
|
@@ -495,9 +597,15 @@ export function searchTextTool(opts) {
|
|
|
495
597
|
//
|
|
496
598
|
// The fix is not "make it faster" — a big tree is legitimately
|
|
497
599
|
// slow. It is to stop OWNING the loop: `fs.promises.readdir`,
|
|
498
|
-
// and a yield every YIELD_EVERY files
|
|
499
|
-
//
|
|
500
|
-
//
|
|
600
|
+
// and a yield every YIELD_EVERY files.
|
|
601
|
+
//
|
|
602
|
+
// DC-54 corrects what this comment used to claim next — that
|
|
603
|
+
// the yield also stopped "a long read stretch" monopolising the
|
|
604
|
+
// loop. It never did and could not: `breathe()` runs BETWEEN
|
|
605
|
+
// files, and nothing preempts a single synchronous read once
|
|
606
|
+
// entered. R3 bounded the traversal; the FILE stayed unbounded
|
|
607
|
+
// until DC-54 ①–③ above, and one 994 GB image was enough to
|
|
608
|
+
// stop the process dead with this yield fully in place.
|
|
501
609
|
let sinceYield = 0;
|
|
502
610
|
const breathe = async () => {
|
|
503
611
|
sinceYield += 1;
|
|
@@ -512,6 +620,9 @@ export function searchTextTool(opts) {
|
|
|
512
620
|
// Two copies would be two answers to "what does searching this
|
|
513
621
|
// file mean".
|
|
514
622
|
const scanFile = async (full) => {
|
|
623
|
+
if (outOfBudget())
|
|
624
|
+
return;
|
|
625
|
+
filesSeen += 1;
|
|
515
626
|
await breathe();
|
|
516
627
|
try {
|
|
517
628
|
// DC-52 — SEARCH DOES NOT RUN THE INODE GUARD.
|
|
@@ -532,11 +643,62 @@ export function searchTextTool(opts) {
|
|
|
532
643
|
// which is a loss of coverage rather than of safety.
|
|
533
644
|
// read_file keeps the guard (bounded, above), and that
|
|
534
645
|
// is where the file's contents can actually be had.
|
|
535
|
-
|
|
646
|
+
const st = statSync(full);
|
|
647
|
+
if (st.nlink > 1) {
|
|
536
648
|
multiLink += 1;
|
|
537
649
|
return;
|
|
538
650
|
}
|
|
539
|
-
|
|
651
|
+
// DC-54 ① — SIZE, decided before anything is opened.
|
|
652
|
+
//
|
|
653
|
+
// This is the line that was missing when a workspace
|
|
654
|
+
// rooted at `~` met `Docker.raw`: 994 GB went into
|
|
655
|
+
// `readFileSync(full, "utf8")` and the process never came
|
|
656
|
+
// back. Measured on the owner's machine, without it: a
|
|
657
|
+
// 467 MB `.mov` read SUCCESSFULLY, 3,817 ms of dead loop
|
|
658
|
+
// and 2.06 GB of RSS, split into 1,823,112 "lines".
|
|
659
|
+
if (st.size > maxFileBytes) {
|
|
660
|
+
skippedFiles += 1;
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
// DC-54 ② — BINARY, decided from the head alone.
|
|
664
|
+
//
|
|
665
|
+
// One open serves both the sniff and the read. The sniff
|
|
666
|
+
// passes an explicit position, which by contract leaves
|
|
667
|
+
// the handle's own position at 0, so `readFile()` below
|
|
668
|
+
// still sees the whole file.
|
|
669
|
+
const fh = await open(full, "r");
|
|
670
|
+
let text;
|
|
671
|
+
try {
|
|
672
|
+
const headLen = Math.min(BINARY_SNIFF_BYTES, st.size);
|
|
673
|
+
const head = Buffer.alloc(headLen);
|
|
674
|
+
if (headLen > 0) {
|
|
675
|
+
// An explicit `position` leaves the handle's own
|
|
676
|
+
// position at 0, so the whole-file read below still
|
|
677
|
+
// starts at byte 0. The gate proves it with a
|
|
678
|
+
// needle placed PAST the sniff window: were the
|
|
679
|
+
// position to advance, every line number in this
|
|
680
|
+
// file's matches would be silently wrong.
|
|
681
|
+
await fh.read(head, 0, headLen, 0);
|
|
682
|
+
if (head.includes(0)) {
|
|
683
|
+
skippedFiles += 1;
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
// DC-54 ③ — the read is ASYNCHRONOUS. R3 made the walk
|
|
688
|
+
// yield and its comment claimed that stopped a long
|
|
689
|
+
// read owning the loop; it never could. `breathe()`
|
|
690
|
+
// runs BETWEEN files, and nothing preempts a
|
|
691
|
+
// `readFileSync` once entered. Bounded above by ①, so
|
|
692
|
+
// this is at most a 1 MiB read that shares the loop.
|
|
693
|
+
//
|
|
694
|
+
// A file that fits inside the sniff window is already
|
|
695
|
+
// entirely in `head`: reading it a second time would
|
|
696
|
+
// double the syscalls for the commonest small file.
|
|
697
|
+
text = (st.size <= headLen ? head : await fh.readFile()).toString("utf8");
|
|
698
|
+
}
|
|
699
|
+
finally {
|
|
700
|
+
await fh.close();
|
|
701
|
+
}
|
|
540
702
|
for (const [i, line] of text.split("\n").entries()) {
|
|
541
703
|
if (regex.test(line)) {
|
|
542
704
|
totalMatches += 1;
|
|
@@ -551,7 +713,7 @@ export function searchTextTool(opts) {
|
|
|
551
713
|
}
|
|
552
714
|
};
|
|
553
715
|
const walk = async (dir, depth) => {
|
|
554
|
-
if (depth > 8)
|
|
716
|
+
if (depth > 8 || outOfBudget())
|
|
555
717
|
return;
|
|
556
718
|
// DC-52 — a directory the OS REFUSES is skipped, not fatal.
|
|
557
719
|
//
|
|
@@ -575,6 +737,8 @@ export function searchTextTool(opts) {
|
|
|
575
737
|
throw err;
|
|
576
738
|
}
|
|
577
739
|
for (const entry of entries) {
|
|
740
|
+
if (outOfBudget())
|
|
741
|
+
return;
|
|
578
742
|
if (entry.name.startsWith(".") || entry.name === "node_modules")
|
|
579
743
|
continue;
|
|
580
744
|
const full = join(dir, entry.name);
|
|
@@ -600,6 +764,16 @@ export function searchTextTool(opts) {
|
|
|
600
764
|
content += `\n… ${multiLink} multi-link ${multiLink === 1 ? "file" : "files"} skipped (read_file verifies them individually)`;
|
|
601
765
|
if (unreadableDirs > 0)
|
|
602
766
|
content += `\n… ${unreadableDirs} unreadable ${unreadableDirs === 1 ? "directory" : "directories"} skipped`;
|
|
767
|
+
// DC-54 — one merged sentence, and only when it happened. A note
|
|
768
|
+
// that always fires says nothing.
|
|
769
|
+
if (skippedFiles > 0 || stopped) {
|
|
770
|
+
const parts = [];
|
|
771
|
+
if (skippedFiles > 0)
|
|
772
|
+
parts.push(`${skippedFiles} ${skippedFiles === 1 ? "file" : "files"} skipped (large or binary)`);
|
|
773
|
+
if (stopped)
|
|
774
|
+
parts.push(`stopped after ${stoppedAt} ${stoppedAt === 1 ? "file" : "files"} — narrow the path`);
|
|
775
|
+
content += `\n… ${parts.join(" · ")}`;
|
|
776
|
+
}
|
|
603
777
|
if (totalMatches > matches.length) {
|
|
604
778
|
// R-C item 2: the N of M form — the cap names its
|
|
605
779
|
// continuation (narrow the pattern for more).
|
|
@@ -629,6 +803,7 @@ export function writeFileTool(opts) {
|
|
|
629
803
|
promptSnippet: "write_file — create or replace a whole file",
|
|
630
804
|
promptGuidelines: ["write/edit: cite the file's latest revision as expectedRevision; each successful mutation returns the next one; use \"absent\" only to create"],
|
|
631
805
|
execute: async ({ path, content, expectedRevision: citedRevision }) => {
|
|
806
|
+
const maxReadBytes = opts.limits?.readMaxFileBytes ?? READ_MAX_FILE_BYTES;
|
|
632
807
|
// WR-1-F2: normalize every plausible copy of the token FIRST —
|
|
633
808
|
// tolerance in the reader, strictness in the comparison.
|
|
634
809
|
const expectedRevision = citedRevision === undefined ? undefined : normalizeRevision(citedRevision);
|
|
@@ -664,6 +839,19 @@ export function writeFileTool(opts) {
|
|
|
664
839
|
if (!exists) {
|
|
665
840
|
return precondition(`write_file: ${path} no longer exists — pass expectedRevision:"absent" to create it`);
|
|
666
841
|
}
|
|
842
|
+
// DC-54 owed (R14) — the ceiling, before the read.
|
|
843
|
+
//
|
|
844
|
+
// `read_file` got this bound in 0.24.5; these two did not,
|
|
845
|
+
// and they read the WHOLE file to compute a revision BEFORE
|
|
846
|
+
// the comparison that would reject the call — so the freeze
|
|
847
|
+
// happened on the way to the refusal rather than instead of
|
|
848
|
+
// it. Same ceiling, same precondition shape, same reason: a
|
|
849
|
+
// revision over a prefix could never match, so there is
|
|
850
|
+
// nothing to compute and nothing to compare.
|
|
851
|
+
const wSize = statSync(full).size;
|
|
852
|
+
if (wSize > maxReadBytes) {
|
|
853
|
+
return precondition(`write_file: ${path} is ${mib(wSize)} — too large to read (ceiling ${mib(maxReadBytes)}); use shell with sed/head to take a range`);
|
|
854
|
+
}
|
|
667
855
|
const current = contentRevision(readFileSync(full));
|
|
668
856
|
if (current !== expectedRevision) {
|
|
669
857
|
return precondition(`write_file: ${path} changed since ${expectedRevision} — read it again and cite its [rev:…] line, then re-apply the change`);
|
|
@@ -763,6 +951,7 @@ export function editFileTool(opts) {
|
|
|
763
951
|
promptSnippet: "edit_file — replace an exact old_string block (never rewrite whole files)",
|
|
764
952
|
promptGuidelines: ["write/edit: cite the file's latest revision as expectedRevision; each successful mutation returns the next one; use \"absent\" only to create"],
|
|
765
953
|
execute: async ({ path, search, replace, edits, expectedRevision: citedRevision }) => {
|
|
954
|
+
const maxReadBytes = opts.limits?.readMaxFileBytes ?? READ_MAX_FILE_BYTES;
|
|
766
955
|
// WR-1-F2: normalize the citation (see write_file).
|
|
767
956
|
const expectedRevision = citedRevision === undefined ? undefined : normalizeRevision(citedRevision);
|
|
768
957
|
// WR-1E2 — the form XOR (the draft-07 subset has no oneOf; the
|
|
@@ -809,6 +998,19 @@ export function editFileTool(opts) {
|
|
|
809
998
|
// what was edited (the external validation→replacement window
|
|
810
999
|
// remains and is the narrowed claim). Staleness reports BEFORE
|
|
811
1000
|
// the pattern search: the truer cause first.
|
|
1001
|
+
// DC-54 owed (R14) — the ceiling, before the read.
|
|
1002
|
+
//
|
|
1003
|
+
// `read_file` got this bound in 0.24.5; these two did not,
|
|
1004
|
+
// and they read the WHOLE file to compute a revision BEFORE
|
|
1005
|
+
// the comparison that would reject the call — so the freeze
|
|
1006
|
+
// happened on the way to the refusal rather than instead of
|
|
1007
|
+
// it. Same ceiling, same precondition shape, same reason: a
|
|
1008
|
+
// revision over a prefix could never match, so there is
|
|
1009
|
+
// nothing to compute and nothing to compare.
|
|
1010
|
+
const eSize = statSync(full).size;
|
|
1011
|
+
if (eSize > maxReadBytes) {
|
|
1012
|
+
return precondition(`edit_file: ${path} is ${mib(eSize)} — too large to read (ceiling ${mib(maxReadBytes)}); use shell with sed/head to take a range`);
|
|
1013
|
+
}
|
|
812
1014
|
const bytes = readFileSync(full);
|
|
813
1015
|
const current = contentRevision(bytes);
|
|
814
1016
|
if (current !== expectedRevision) {
|
|
@@ -931,13 +1133,16 @@ export function shellTool(opts) {
|
|
|
931
1133
|
// not just the outer shell (Area 4). cwd is the workspace.
|
|
932
1134
|
// bootstrap #3 (finding #7): the shell child NEVER inherits kiso's own
|
|
933
1135
|
// provider credentials by default — only the explicit
|
|
934
|
-
// shellEnv: "inherit" opt-in keeps them.
|
|
1136
|
+
// shellEnv: "inherit" opt-in keeps them. A record (ADR-0031
|
|
1137
|
+
// Amendment 1) rides on the STRIPPED base and wins per key.
|
|
935
1138
|
const child = spawn(command, {
|
|
936
1139
|
shell: true,
|
|
937
1140
|
detached: true,
|
|
938
1141
|
cwd: opts.workspaceRoot,
|
|
939
1142
|
stdio: ["ignore", "pipe", "pipe"],
|
|
940
|
-
env: opts.shellEnv === "inherit"
|
|
1143
|
+
env: opts.shellEnv === "inherit"
|
|
1144
|
+
? process.env
|
|
1145
|
+
: { ...strippedShellEnv(process.env), ...(opts.shellEnv ?? {}) },
|
|
941
1146
|
});
|
|
942
1147
|
let stdout = "";
|
|
943
1148
|
let stderr = "";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vincemakes/kiso-tools-node",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.0",
|
|
4
4
|
"description": "kiso coding tools for Node hosts — read file, list directory, search text, write/edit file, shell command.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"test": "vitest run"
|
|
22
22
|
},
|
|
23
23
|
"dependencies": {
|
|
24
|
-
"@vincemakes/kiso-core": "0.
|
|
24
|
+
"@vincemakes/kiso-core": "0.25.0"
|
|
25
25
|
},
|
|
26
26
|
"devDependencies": {
|
|
27
27
|
"@types/node": "^26.1.2",
|