@vincemakes/kiso-tools-node 0.34.0 → 0.37.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 +7 -0
- package/dist/index.js +69 -44
- package/dist/search-miss.d.ts +24 -0
- package/dist/search-miss.js +89 -0
- package/dist/search-worker.d.ts +6 -0
- package/dist/search-worker.js +5 -2
- package/dist/secret-env.d.ts +27 -0
- package/dist/secret-env.js +48 -0
- package/package.json +7 -3
package/dist/index.d.ts
CHANGED
|
@@ -100,6 +100,12 @@ export interface WorkspaceToolsOptions {
|
|
|
100
100
|
* name — explicit beats the heuristic, as it does for MCP servers.
|
|
101
101
|
*/
|
|
102
102
|
readonly shellEnv?: "inherit" | Readonly<Record<string, string>>;
|
|
103
|
+
/** Astra F7: the env var NAMES the configured profiles authenticate with.
|
|
104
|
+
* The strip's suffix rules (`_API_KEY`, `_AUTH_TOKEN`) cannot see a name
|
|
105
|
+
* like `REVIEW_PROVIDER_TOKEN`, and only the config knows which names are
|
|
106
|
+
* secrets — so it says so. Ignored when `shellEnv` is "inherit", which is
|
|
107
|
+
* an explicit opt-in to the whole environment. */
|
|
108
|
+
readonly secretEnvNames?: readonly string[];
|
|
103
109
|
/**
|
|
104
110
|
* DC-54 — the bounds that keep a tool call finite. Every field is
|
|
105
111
|
* optional and defaults to the constant beside it; a host embedding
|
|
@@ -166,6 +172,7 @@ export declare function editFileTool(opts: WorkspaceToolsOptions): Tool<{
|
|
|
166
172
|
}[];
|
|
167
173
|
expectedRevision?: string;
|
|
168
174
|
}>;
|
|
175
|
+
export { SHELL_STRIP_EXACT, strippedShellEnv } from "./secret-env.js";
|
|
169
176
|
export declare function shellTool(opts: WorkspaceToolsOptions): Tool<{
|
|
170
177
|
command: string;
|
|
171
178
|
timeoutMs?: number;
|
package/dist/index.js
CHANGED
|
@@ -27,7 +27,9 @@ import { tmpdir } from "node:os";
|
|
|
27
27
|
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
28
28
|
import { defineTool } from "@vincemakes/kiso-core";
|
|
29
29
|
// WR-1/WR-1A — the revision-guard primitives (unit-tested in wr1a-coda):
|
|
30
|
+
import { strippedShellEnv } from "./secret-env.js";
|
|
30
31
|
import { contentRevision, normalizeRevision, postEffectEscape, precondition, publishNewFile, revalidateBeforeRename } from "./wr1.js";
|
|
32
|
+
import { describeSearchMiss } from "./search-miss.js";
|
|
31
33
|
/**
|
|
32
34
|
* TUI2-R1 (C) — THE SHELL PROGRESS SIDECAR.
|
|
33
35
|
*
|
|
@@ -75,6 +77,12 @@ const DEFAULT_SHELL_TIMEOUT_MS = 30_000;
|
|
|
75
77
|
// red line: every truncation names its continuation — the model always
|
|
76
78
|
// has a path to the full content.
|
|
77
79
|
const DEFAULT_READ_LINES = 200;
|
|
80
|
+
/** The default window's SECOND bound, and the one lines cannot express: 200
|
|
81
|
+
* lines of minified source is megabytes, 200 lines of prose is a few KB.
|
|
82
|
+
* Whichever binds first wins, and the cut is always at a line boundary. An
|
|
83
|
+
* explicit `limit` is the caller saying what they want and is not capped
|
|
84
|
+
* here — the 100k output cap still applies to it. */
|
|
85
|
+
const DEFAULT_READ_CHARS = 16_000;
|
|
78
86
|
/** R3: how many files a search may read before it hands the event loop
|
|
79
87
|
* back. Small enough that the 200ms motion cadence never misses a beat,
|
|
80
88
|
* large enough that the yield costs nothing on a small tree. */
|
|
@@ -304,13 +312,17 @@ const INODE_SCAN_MS = 2_000;
|
|
|
304
312
|
/** The "… N more lines" note — the actionable continuation: the exact
|
|
305
313
|
* line the next read must start at, so the model can always reach the
|
|
306
314
|
* full content in ranges (the red line). */
|
|
307
|
-
function moreLinesNote(nextOffset, remaining) {
|
|
308
|
-
|
|
315
|
+
function moreLinesNote(nextOffset, remaining, limit) {
|
|
316
|
+
// BOTH parameters. Naming only `offset` was an instruction to read the
|
|
317
|
+
// rest of the file: with `limit` absent the read runs to EOF, so a model
|
|
318
|
+
// following its own continuation note defeated the window from the second
|
|
319
|
+
// read onward. Measured before the fix: 7.3% of real reads took that path.
|
|
320
|
+
return `\n… ${remaining} more ${remaining === 1 ? "line" : "lines"} (call again with offset=${nextOffset} limit=${limit})`;
|
|
309
321
|
}
|
|
310
322
|
export function readFileTool(opts) {
|
|
311
323
|
return defineTool({
|
|
312
324
|
name: "read_file",
|
|
313
|
-
description: "Read a workspace file or a range
|
|
325
|
+
description: "Read a workspace file or a range. Without `limit`: 200 lines or 16000 chars from `offset` (default 1), whichever binds, then a note naming the next offset and limit. The final [rev:X] line identifies the version read.",
|
|
314
326
|
parameters: {
|
|
315
327
|
type: "object",
|
|
316
328
|
properties: {
|
|
@@ -397,21 +409,44 @@ export function readFileTool(opts) {
|
|
|
397
409
|
errorKind: "invalid_input",
|
|
398
410
|
};
|
|
399
411
|
}
|
|
400
|
-
|
|
401
|
-
//
|
|
402
|
-
//
|
|
403
|
-
//
|
|
412
|
+
// THE DEFAULT WINDOW applies whenever `limit` is ABSENT, from
|
|
413
|
+
// `offset ?? 1`. It used to apply only when BOTH were absent,
|
|
414
|
+
// so an offset alone read to the end of the file — and since
|
|
415
|
+
// the note named only `offset`, a model following its own
|
|
416
|
+
// continuation note left the window behind after one read.
|
|
404
417
|
let text;
|
|
405
418
|
let note = "";
|
|
406
|
-
if (
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
419
|
+
if (limit === undefined) {
|
|
420
|
+
const lastLine = Math.min(start + DEFAULT_READ_LINES - 1, total);
|
|
421
|
+
const slice = parts.slice(start - 1, lastLine);
|
|
422
|
+
let body = slice.join("\n");
|
|
423
|
+
let shown = slice.length;
|
|
424
|
+
if (body.length > DEFAULT_READ_CHARS) {
|
|
425
|
+
const cut = body.lastIndexOf("\n", DEFAULT_READ_CHARS);
|
|
426
|
+
if (cut > 0) {
|
|
427
|
+
body = body.slice(0, cut);
|
|
428
|
+
shown = body.split("\n").length;
|
|
429
|
+
}
|
|
430
|
+
else {
|
|
431
|
+
// No newline inside the budget: the first line alone
|
|
432
|
+
// is over it. One WHOLE line is the smallest honest
|
|
433
|
+
// answer — a cut mid-line is a lie about the file.
|
|
434
|
+
body = slice[0] ?? "";
|
|
435
|
+
shown = 1;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
// The whole file from line 1 is returned VERBATIM, trailing
|
|
439
|
+
// newline included: `parts.join` would drop it.
|
|
440
|
+
text = start === 1 && shown === total ? content : body;
|
|
441
|
+
const next = start + shown;
|
|
442
|
+
if (next <= total)
|
|
443
|
+
note = moreLinesNote(next, total - next + 1, DEFAULT_READ_LINES);
|
|
410
444
|
}
|
|
411
445
|
else {
|
|
446
|
+
const end = Math.min(start + limit - 1, total);
|
|
412
447
|
text = parts.slice(start - 1, end).join("\n");
|
|
413
448
|
if (end < total)
|
|
414
|
-
note = moreLinesNote(end + 1, total - end);
|
|
449
|
+
note = moreLinesNote(end + 1, total - end, limit);
|
|
415
450
|
}
|
|
416
451
|
// The output cap's cut must STAY actionable: cut at a line
|
|
417
452
|
// boundary and name the exact next offset (the generic cap()
|
|
@@ -662,7 +697,12 @@ export function searchTextTool(opts) {
|
|
|
662
697
|
// CX-1 F4 (audit F4): the walk-and-match runs on its OWN thread, which
|
|
663
698
|
// the deadline and the abort both TERMINATE. A catastrophic regex used
|
|
664
699
|
// to block this loop — no budget check, timer or abort could run.
|
|
665
|
-
const outcome = await runSearchWorker(
|
|
700
|
+
const outcome = await runSearchWorker(
|
|
701
|
+
// The workspace root is realpath'd with the SAME helper the search
|
|
702
|
+
// root uses: `full` is walked from a realpath'd root, and making
|
|
703
|
+
// a path relative between a resolved and an unresolved base
|
|
704
|
+
// yields `../..` the moment a symlink sits between them.
|
|
705
|
+
{ token: 0, root: searchRootReal, workspaceRoot: realOrSelf(opts.workspaceRoot), single, pattern, flags, excluded, maxFileBytes, maxFiles, deadline, maxMatches: MAX_SEARCH_MATCHES, sniffBytes: BINARY_SNIFF_BYTES }, deadline, ctx.signal);
|
|
666
706
|
if (outcome.kind === "aborted")
|
|
667
707
|
return { content: "search_text aborted", isError: true, errorKind: "fatal" };
|
|
668
708
|
if (outcome.kind === "error")
|
|
@@ -945,7 +985,14 @@ export function editFileTool(opts) {
|
|
|
945
985
|
// WR-1A ④: the WORLD lacks the pattern (the input is
|
|
946
986
|
// fine) and nothing ran — precondition; the note never
|
|
947
987
|
// rides an edit that wrote nothing.
|
|
948
|
-
|
|
988
|
+
// The headline says WHAT failed; the detail says WHERE.
|
|
989
|
+
// A refusal that names the divergence costs one line
|
|
990
|
+
// here and saves a whole file read at the caller.
|
|
991
|
+
const headline = hunks.length === 1 && edits === undefined
|
|
992
|
+
? `edit_file: pattern not found in ${path}`
|
|
993
|
+
: `edit_file: pattern not found in ${path} (hunk ${i + 1})`;
|
|
994
|
+
const detail = describeSearchMiss(text, h.search);
|
|
995
|
+
return precondition(detail ? `${headline}\n${detail}` : headline);
|
|
949
996
|
}
|
|
950
997
|
spans.push({ start: at, end: at + h.search.length, replace: h.replace });
|
|
951
998
|
}
|
|
@@ -995,37 +1042,15 @@ export function editFileTool(opts) {
|
|
|
995
1042
|
},
|
|
996
1043
|
});
|
|
997
1044
|
}
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
* through untouched.
|
|
1004
|
-
*/
|
|
1005
|
-
const SHELL_STRIP_EXACT = new Set([
|
|
1006
|
-
"ANTHROPIC_API_KEY",
|
|
1007
|
-
"OPENAI_API_KEY",
|
|
1008
|
-
"ANTHROPIC_BASE_URL",
|
|
1009
|
-
"OPENAI_BASE_URL",
|
|
1010
|
-
"ANTHROPIC_MODEL",
|
|
1011
|
-
"OPENAI_MODEL",
|
|
1012
|
-
]);
|
|
1013
|
-
function strippedShellEnv(env) {
|
|
1014
|
-
const out = {};
|
|
1015
|
-
for (const [key, value] of Object.entries(env)) {
|
|
1016
|
-
if (SHELL_STRIP_EXACT.has(key))
|
|
1017
|
-
continue;
|
|
1018
|
-
if (key.endsWith("_API_KEY") || key.endsWith("_AUTH_TOKEN"))
|
|
1019
|
-
continue;
|
|
1020
|
-
if (value !== undefined)
|
|
1021
|
-
out[key] = value;
|
|
1022
|
-
}
|
|
1023
|
-
return out;
|
|
1024
|
-
}
|
|
1045
|
+
// Astra F7: the strip is ONE implementation, shared with the MCP
|
|
1046
|
+
// extension's stdio children (./secret-env.ts). It used to live here
|
|
1047
|
+
// with a hand-kept copy over there; the copy never learned the
|
|
1048
|
+
// declared names, which is how MCP children kept leaking.
|
|
1049
|
+
export { SHELL_STRIP_EXACT, strippedShellEnv } from "./secret-env.js";
|
|
1025
1050
|
export function shellTool(opts) {
|
|
1026
1051
|
return defineTool({
|
|
1027
1052
|
name: "shell",
|
|
1028
|
-
description: "Run a shell command with the workspace as the working directory.
|
|
1053
|
+
description: "Run a shell command through /bin/sh with the workspace root as the working directory: builds, tests, git, package managers, curl for HTTP APIs, system queries. Side effects are real; the human may be asked to approve the run. Fails loudly on timeout or non-zero exit.",
|
|
1029
1054
|
parameters: {
|
|
1030
1055
|
type: "object",
|
|
1031
1056
|
properties: {
|
|
@@ -1035,7 +1060,7 @@ export function shellTool(opts) {
|
|
|
1035
1060
|
required: ["command"],
|
|
1036
1061
|
additionalProperties: false,
|
|
1037
1062
|
},
|
|
1038
|
-
promptSnippet: "shell —
|
|
1063
|
+
promptSnippet: "shell — any command the task needs (builds, tests, git, curl, system queries)",
|
|
1039
1064
|
promptGuidelines: ["commands run in the workspace root; on failure read the error and adjust — never repeat blindly"],
|
|
1040
1065
|
execute: async ({ command, timeoutMs }, ctx) => {
|
|
1041
1066
|
const timeout = timeoutMs ?? DEFAULT_SHELL_TIMEOUT_MS;
|
|
@@ -1058,7 +1083,7 @@ export function shellTool(opts) {
|
|
|
1058
1083
|
stdio: ["ignore", "pipe", "pipe"],
|
|
1059
1084
|
env: opts.shellEnv === "inherit"
|
|
1060
1085
|
? process.env
|
|
1061
|
-
: { ...strippedShellEnv(process.env), ...(opts.shellEnv ?? {}) },
|
|
1086
|
+
: { ...strippedShellEnv(process.env, opts.secretEnvNames), ...(opts.shellEnv ?? {}) },
|
|
1062
1087
|
});
|
|
1063
1088
|
let stdout = "";
|
|
1064
1089
|
let stderr = "";
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* When a search does not match, say WHERE it stopped matching.
|
|
3
|
+
*
|
|
4
|
+
* `edit_file: pattern not found in src/report.js (hunk 2)` is true and
|
|
5
|
+
* useless. The tool has already established, by failing, that the text is
|
|
6
|
+
* not there — but it also knows, or can cheaply find out, how much of the
|
|
7
|
+
* search DID match and what the file has instead. Withholding that leaves
|
|
8
|
+
* one recourse: read the whole file again.
|
|
9
|
+
*
|
|
10
|
+
* This is not a guess about what callers need. Ten refused edits in one
|
|
11
|
+
* measured session were all `pattern not found`, none stale, none
|
|
12
|
+
* overlapping, and in every one of them a long prefix matched before the
|
|
13
|
+
* search ran into text the caller had not written yet — 93 of 223
|
|
14
|
+
* characters, 94 of 286, 306 of 913. Four more searched for an import
|
|
15
|
+
* line with the new symbol ALREADY IN IT. The failure has one shape: the
|
|
16
|
+
* search describes the file as it will be, not as it is. A message that
|
|
17
|
+
* names the divergence answers that in one line; the current one costs a
|
|
18
|
+
* whole file read to discover.
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
* The detail lines for a failed search, or "" when there is nothing useful
|
|
22
|
+
* to say. The caller owns the headline; this is what follows it.
|
|
23
|
+
*/
|
|
24
|
+
export declare function describeSearchMiss(text: string, search: string): string;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* When a search does not match, say WHERE it stopped matching.
|
|
3
|
+
*
|
|
4
|
+
* `edit_file: pattern not found in src/report.js (hunk 2)` is true and
|
|
5
|
+
* useless. The tool has already established, by failing, that the text is
|
|
6
|
+
* not there — but it also knows, or can cheaply find out, how much of the
|
|
7
|
+
* search DID match and what the file has instead. Withholding that leaves
|
|
8
|
+
* one recourse: read the whole file again.
|
|
9
|
+
*
|
|
10
|
+
* This is not a guess about what callers need. Ten refused edits in one
|
|
11
|
+
* measured session were all `pattern not found`, none stale, none
|
|
12
|
+
* overlapping, and in every one of them a long prefix matched before the
|
|
13
|
+
* search ran into text the caller had not written yet — 93 of 223
|
|
14
|
+
* characters, 94 of 286, 306 of 913. Four more searched for an import
|
|
15
|
+
* line with the new symbol ALREADY IN IT. The failure has one shape: the
|
|
16
|
+
* search describes the file as it will be, not as it is. A message that
|
|
17
|
+
* names the divergence answers that in one line; the current one costs a
|
|
18
|
+
* whole file read to discover.
|
|
19
|
+
*/
|
|
20
|
+
/** The longest prefix of `needle` that occurs in `hay`, by length.
|
|
21
|
+
*
|
|
22
|
+
* Monotone — if a prefix occurs then so does every shorter one — so this
|
|
23
|
+
* binary-searches instead of walking. A linear walk is O(m) substring
|
|
24
|
+
* searches, which on a large file and a long search is the kind of cost
|
|
25
|
+
* that turns a better error message into a worse tool.
|
|
26
|
+
*/
|
|
27
|
+
function longestMatchingPrefix(hay, needle) {
|
|
28
|
+
let lo = 0;
|
|
29
|
+
let hi = needle.length;
|
|
30
|
+
while (lo < hi) {
|
|
31
|
+
const mid = (lo + hi + 1) >> 1;
|
|
32
|
+
if (hay.includes(needle.slice(0, mid)))
|
|
33
|
+
lo = mid;
|
|
34
|
+
else
|
|
35
|
+
hi = mid - 1;
|
|
36
|
+
}
|
|
37
|
+
return lo;
|
|
38
|
+
}
|
|
39
|
+
/** 1-based line number of an offset. */
|
|
40
|
+
function lineAt(text, offset) {
|
|
41
|
+
let n = 1;
|
|
42
|
+
for (let i = 0; i < offset && i < text.length; i += 1)
|
|
43
|
+
if (text.charCodeAt(i) === 10)
|
|
44
|
+
n += 1;
|
|
45
|
+
return n;
|
|
46
|
+
}
|
|
47
|
+
/** A fragment for a one-line message: escaped, and bounded. */
|
|
48
|
+
function fragment(s, max = 60) {
|
|
49
|
+
const cut = s.slice(0, max);
|
|
50
|
+
const shown = JSON.stringify(cut).slice(1, -1); // drop the quotes, keep \n and \t visible
|
|
51
|
+
return s.length > max ? `${shown}…` : shown;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The detail lines for a failed search, or "" when there is nothing useful
|
|
55
|
+
* to say. The caller owns the headline; this is what follows it.
|
|
56
|
+
*/
|
|
57
|
+
export function describeSearchMiss(text, search) {
|
|
58
|
+
if (search.length === 0)
|
|
59
|
+
return "";
|
|
60
|
+
const matched = longestMatchingPrefix(text, search);
|
|
61
|
+
if (matched === 0) {
|
|
62
|
+
const firstLine = search.split("\n", 1)[0] ?? "";
|
|
63
|
+
return ` no part of it appears in the file — it begins "${fragment(firstLine)}"`;
|
|
64
|
+
}
|
|
65
|
+
// First occurrence, matching the tool's own first-occurrence semantics.
|
|
66
|
+
const at = text.indexOf(search.slice(0, matched));
|
|
67
|
+
const endOfMatch = at + matched;
|
|
68
|
+
const line = lineAt(text, at);
|
|
69
|
+
const endLine = lineAt(text, endOfMatch);
|
|
70
|
+
const head = ` ${matched} of ${search.length} characters matched, from line ${line} to line ${endLine}`;
|
|
71
|
+
const rest = search.slice(matched);
|
|
72
|
+
// RUNNING PAST THE END IS THE COMMON CASE AND ITS OWN SENTENCE. Four of
|
|
73
|
+
// the ten refusals in the measured session ended exactly here, and
|
|
74
|
+
// rendering that as `the file then has: ""` buries the one fact worth
|
|
75
|
+
// having: there is no more file. A caller that appended what it meant
|
|
76
|
+
// to ADD onto the end of what it meant to FIND reads its own mistake
|
|
77
|
+
// off this line.
|
|
78
|
+
if (endOfMatch >= text.length) {
|
|
79
|
+
return [
|
|
80
|
+
head,
|
|
81
|
+
` the file ENDS there — your search continues for ${rest.length} more characters: "${fragment(rest)}"`,
|
|
82
|
+
].join("\n");
|
|
83
|
+
}
|
|
84
|
+
return [
|
|
85
|
+
head,
|
|
86
|
+
` the file then has: "${fragment(text.slice(endOfMatch))}"`,
|
|
87
|
+
` your search wanted: "${fragment(rest)}"`,
|
|
88
|
+
].join("\n");
|
|
89
|
+
}
|
package/dist/search-worker.d.ts
CHANGED
|
@@ -18,6 +18,12 @@
|
|
|
18
18
|
export interface SearchRequest {
|
|
19
19
|
readonly token: number;
|
|
20
20
|
readonly root: string;
|
|
21
|
+
/** The WORKSPACE root, which is not always the search root: a search under
|
|
22
|
+
* `packages/runtime` must still name `packages/runtime/src/run.ts` so the
|
|
23
|
+
* result can be handed to `read_file` unchanged. Realpath'd by the
|
|
24
|
+
* caller, because `full` is walked from a realpath'd root and a mixed
|
|
25
|
+
* pair produces `../..` the moment a symlink is involved. */
|
|
26
|
+
readonly workspaceRoot: string;
|
|
21
27
|
/** a single file to scan instead of walking `root` */
|
|
22
28
|
readonly single: string | null;
|
|
23
29
|
readonly pattern: string;
|
package/dist/search-worker.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* message from a superseded worker is ignored.
|
|
17
17
|
*/
|
|
18
18
|
import { open, readdir } from "node:fs/promises";
|
|
19
|
-
import { join, relative } from "node:path";
|
|
19
|
+
import { basename, join, relative } from "node:path";
|
|
20
20
|
import { isMainThread, parentPort } from "node:worker_threads";
|
|
21
21
|
export async function runSearch(req) {
|
|
22
22
|
const regex = new RegExp(req.pattern, req.flags);
|
|
@@ -77,8 +77,11 @@ export async function runSearch(req) {
|
|
|
77
77
|
for (const [i, line] of text.split("\n").entries()) {
|
|
78
78
|
if (regex.test(line)) {
|
|
79
79
|
totalMatches += 1;
|
|
80
|
+
// WORKSPACE-RELATIVE, not absolute: `read_file` refuses an
|
|
81
|
+
// absolute path, so an absolute hit here is a result the
|
|
82
|
+
// model cannot feed back without rewriting it by hand.
|
|
80
83
|
if (matches.length < req.maxMatches)
|
|
81
|
-
matches.push(`${full}:${i + 1}: ${line.trim().slice(0, 160)}`);
|
|
84
|
+
matches.push(`${relative(req.workspaceRoot, full) || basename(full)}:${i + 1}: ${line.trim().slice(0, 160)}`);
|
|
82
85
|
}
|
|
83
86
|
}
|
|
84
87
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE child-environment strip — ONE implementation, both children.
|
|
3
|
+
*
|
|
4
|
+
* bootstrap #3 (finding #7) established the rule for shell children: the
|
|
5
|
+
* agent's own provider surface (both families' keys, base URLs and model
|
|
6
|
+
* choices) plus the generic API-key / auth-token patterns that cover other
|
|
7
|
+
* providers. Everything else passes through untouched.
|
|
8
|
+
*
|
|
9
|
+
* Astra F7 then showed the rule was incomplete AND duplicated. Incomplete:
|
|
10
|
+
* a profile's `apiKeyEnv` can be ANY name — `REVIEW_PROVIDER_TOKEN` ends in
|
|
11
|
+
* neither suffix — so the key a profile authenticates with survived into
|
|
12
|
+
* the child. Duplicated: the MCP extension carried its own copy of the list
|
|
13
|
+
* with a "keep in sync" comment, and a comment is not a mechanism; the copy
|
|
14
|
+
* did not learn the declared names and MCP stdio children kept leaking.
|
|
15
|
+
*
|
|
16
|
+
* The names must come from the caller because only the config knows which
|
|
17
|
+
* env vars are secrets. No names declared = exactly the pre-F7 behaviour,
|
|
18
|
+
* which is what a standalone extension load gets.
|
|
19
|
+
*
|
|
20
|
+
* This module imports NOTHING on purpose: the MCP extension is an esbuild
|
|
21
|
+
* bundle, and a shared rule must not drag a package graph across with it.
|
|
22
|
+
*/
|
|
23
|
+
/** The explicit credential list stripped from every child. */
|
|
24
|
+
export declare const SHELL_STRIP_EXACT: ReadonlySet<string>;
|
|
25
|
+
/** The environment a child process may see: `env` minus the exact list,
|
|
26
|
+
* minus every DECLARED secret name, minus the two suffix families. */
|
|
27
|
+
export declare function strippedShellEnv(env: Record<string, string | undefined>, secretNames?: readonly string[]): Record<string, string | undefined>;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE child-environment strip — ONE implementation, both children.
|
|
3
|
+
*
|
|
4
|
+
* bootstrap #3 (finding #7) established the rule for shell children: the
|
|
5
|
+
* agent's own provider surface (both families' keys, base URLs and model
|
|
6
|
+
* choices) plus the generic API-key / auth-token patterns that cover other
|
|
7
|
+
* providers. Everything else passes through untouched.
|
|
8
|
+
*
|
|
9
|
+
* Astra F7 then showed the rule was incomplete AND duplicated. Incomplete:
|
|
10
|
+
* a profile's `apiKeyEnv` can be ANY name — `REVIEW_PROVIDER_TOKEN` ends in
|
|
11
|
+
* neither suffix — so the key a profile authenticates with survived into
|
|
12
|
+
* the child. Duplicated: the MCP extension carried its own copy of the list
|
|
13
|
+
* with a "keep in sync" comment, and a comment is not a mechanism; the copy
|
|
14
|
+
* did not learn the declared names and MCP stdio children kept leaking.
|
|
15
|
+
*
|
|
16
|
+
* The names must come from the caller because only the config knows which
|
|
17
|
+
* env vars are secrets. No names declared = exactly the pre-F7 behaviour,
|
|
18
|
+
* which is what a standalone extension load gets.
|
|
19
|
+
*
|
|
20
|
+
* This module imports NOTHING on purpose: the MCP extension is an esbuild
|
|
21
|
+
* bundle, and a shared rule must not drag a package graph across with it.
|
|
22
|
+
*/
|
|
23
|
+
/** The explicit credential list stripped from every child. */
|
|
24
|
+
export const SHELL_STRIP_EXACT = new Set([
|
|
25
|
+
"ANTHROPIC_API_KEY",
|
|
26
|
+
"OPENAI_API_KEY",
|
|
27
|
+
"ANTHROPIC_BASE_URL",
|
|
28
|
+
"OPENAI_BASE_URL",
|
|
29
|
+
"ANTHROPIC_MODEL",
|
|
30
|
+
"OPENAI_MODEL",
|
|
31
|
+
]);
|
|
32
|
+
/** The environment a child process may see: `env` minus the exact list,
|
|
33
|
+
* minus every DECLARED secret name, minus the two suffix families. */
|
|
34
|
+
export function strippedShellEnv(env, secretNames = []) {
|
|
35
|
+
const declared = new Set(secretNames);
|
|
36
|
+
const out = {};
|
|
37
|
+
for (const [key, value] of Object.entries(env)) {
|
|
38
|
+
if (SHELL_STRIP_EXACT.has(key))
|
|
39
|
+
continue;
|
|
40
|
+
if (declared.has(key))
|
|
41
|
+
continue;
|
|
42
|
+
if (key.endsWith("_API_KEY") || key.endsWith("_AUTH_TOKEN"))
|
|
43
|
+
continue;
|
|
44
|
+
if (value !== undefined)
|
|
45
|
+
out[key] = value;
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
package/package.json
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vincemakes/kiso-tools-node",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "kiso coding tools for Node hosts
|
|
3
|
+
"version": "0.37.0",
|
|
4
|
+
"description": "kiso coding tools for Node hosts \u2014 read file, list directory, search text, write/edit file, shell command.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"exports": {
|
|
8
8
|
".": {
|
|
9
9
|
"types": "./dist/index.d.ts",
|
|
10
10
|
"default": "./dist/index.js"
|
|
11
|
+
},
|
|
12
|
+
"./secret-env": {
|
|
13
|
+
"types": "./dist/secret-env.d.ts",
|
|
14
|
+
"default": "./dist/secret-env.js"
|
|
11
15
|
}
|
|
12
16
|
},
|
|
13
17
|
"files": [
|
|
@@ -21,7 +25,7 @@
|
|
|
21
25
|
"test": "vitest run"
|
|
22
26
|
},
|
|
23
27
|
"dependencies": {
|
|
24
|
-
"@vincemakes/kiso-core": "0.
|
|
28
|
+
"@vincemakes/kiso-core": "0.37.0"
|
|
25
29
|
},
|
|
26
30
|
"devDependencies": {
|
|
27
31
|
"@types/node": "^26.1.2",
|