@crewhaus/tool-proc 0.7.0 → 0.8.1
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/LICENSE +201 -0
- package/NOTICE +9 -0
- package/README.md +11 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +202 -90
- package/dist/lib/addr.d.ts +129 -0
- package/dist/lib/addr.js +391 -0
- package/dist/lib/config.d.ts +34 -0
- package/dist/lib/config.js +133 -0
- package/dist/lib/env.d.ts +18 -5
- package/dist/lib/env.js +18 -4
- package/dist/lib/format.d.ts +12 -7
- package/dist/lib/format.js +31 -81
- package/dist/lib/which.d.ts +17 -0
- package/dist/lib/which.js +80 -0
- package/dist/registry.d.ts +39 -0
- package/dist/registry.js +66 -2
- package/dist/safe-path.d.ts +11 -0
- package/dist/safe-path.js +62 -10
- package/dist/spawn.d.ts +39 -0
- package/dist/spawn.js +85 -121
- package/package.json +11 -6
package/dist/lib/format.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* reads the filesystem or looks at a clock, so the behaviour that these
|
|
4
4
|
* back can be unit-tested without a world.
|
|
5
5
|
*/
|
|
6
|
+
import { compileUserRegex } from "@crewhaus/tool-safety/regex";
|
|
6
7
|
/**
|
|
7
8
|
* Cap a captured stream to a character budget, keeping BOTH ends.
|
|
8
9
|
*
|
|
@@ -37,88 +38,37 @@ export function formatArgv(argv) {
|
|
|
37
38
|
/**
|
|
38
39
|
* Compile a caller-supplied regular expression, refusing the shapes behind
|
|
39
40
|
* catastrophic backtracking. The pattern reaches us from a model, and a
|
|
40
|
-
* waiting tool applies it repeatedly to a growing buffer
|
|
41
|
-
*
|
|
41
|
+
* waiting tool applies it repeatedly to a growing buffer.
|
|
42
|
+
*
|
|
43
|
+
* The screen is @crewhaus/tool-safety's `compileUserRegex`, the one every
|
|
44
|
+
* tool shares (C079). 0.7.0 kept a scanner here that counted only `*` and
|
|
45
|
+
* `+` inside a group, so `(\w{1,})*$`, `(\w{1,64})*$` and `(\w\w?)*$`
|
|
46
|
+
* passed it and JavaScriptCore then gave up on them — a false "no match"
|
|
47
|
+
* after pinning the event loop. The shared screen models `{n,m}` and `?`
|
|
48
|
+
* too, accepts a repetition only when each pass ends in exactly one place,
|
|
49
|
+
* and is fuzzed; it also admits safe alternations the old scanner refused
|
|
50
|
+
* wholesale, such as `(a|b)*`. WaitForOutput still runs the match in the
|
|
51
|
+
* regex worker under a deadline: the screen is the second layer, not the
|
|
52
|
+
* first.
|
|
42
53
|
*/
|
|
43
54
|
export function compileSafePattern(source, flags) {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
}
|
|
63
|
-
catch (err) {
|
|
64
|
-
return { ok: false, message: `invalid regex: ${err.message}` };
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
function backtrackingRisk(source) {
|
|
68
|
-
let depth = 0;
|
|
69
|
-
let nested = false;
|
|
70
|
-
let quantifiedAlternation = false;
|
|
71
|
-
const quantifiedAt = [];
|
|
72
|
-
const alternationAt = [];
|
|
73
|
-
for (let i = 0; i < source.length; i++) {
|
|
74
|
-
const ch = source[i];
|
|
75
|
-
if (ch === "\\") {
|
|
76
|
-
i++;
|
|
77
|
-
continue;
|
|
78
|
-
}
|
|
79
|
-
if (ch === "[") {
|
|
80
|
-
while (i < source.length && source[i] !== "]") {
|
|
81
|
-
if (source[i] === "\\")
|
|
82
|
-
i++;
|
|
83
|
-
i++;
|
|
84
|
-
}
|
|
85
|
-
continue;
|
|
86
|
-
}
|
|
87
|
-
if (ch === "(") {
|
|
88
|
-
depth++;
|
|
89
|
-
quantifiedAt[depth] = false;
|
|
90
|
-
alternationAt[depth] = false;
|
|
91
|
-
continue;
|
|
92
|
-
}
|
|
93
|
-
if (ch === "|") {
|
|
94
|
-
if (depth > 0)
|
|
95
|
-
alternationAt[depth] = true;
|
|
96
|
-
continue;
|
|
97
|
-
}
|
|
98
|
-
if (ch === ")") {
|
|
99
|
-
const next = source[i + 1];
|
|
100
|
-
if (next === "*" || next === "+" || next === "{" || next === "?") {
|
|
101
|
-
// This group is itself quantified. `?` cannot repeat, so it only
|
|
102
|
-
// carries an inner quantifier outward; `*`, `+` and `{n,}` repeat,
|
|
103
|
-
// which is what turns an overlapping alternation catastrophic.
|
|
104
|
-
if (quantifiedAt[depth] === true && next !== "?")
|
|
105
|
-
nested = true;
|
|
106
|
-
if (alternationAt[depth] === true && next !== "?")
|
|
107
|
-
quantifiedAlternation = true;
|
|
108
|
-
if (depth > 1)
|
|
109
|
-
quantifiedAt[depth - 1] = true;
|
|
110
|
-
}
|
|
111
|
-
depth = Math.max(0, depth - 1);
|
|
112
|
-
continue;
|
|
113
|
-
}
|
|
114
|
-
if (ch === "*" || ch === "+") {
|
|
115
|
-
if (depth > 0)
|
|
116
|
-
quantifiedAt[depth] = true;
|
|
117
|
-
}
|
|
55
|
+
const compiled = compileUserRegex(source, flags);
|
|
56
|
+
if (compiled.ok)
|
|
57
|
+
return compiled;
|
|
58
|
+
switch (compiled.code) {
|
|
59
|
+
case "nested-quantifier":
|
|
60
|
+
return {
|
|
61
|
+
ok: false,
|
|
62
|
+
message: `pattern nests one quantifier inside another, which can backtrack exponentially: ${compiled.reason}`,
|
|
63
|
+
};
|
|
64
|
+
case "overlapping-alternation":
|
|
65
|
+
return {
|
|
66
|
+
ok: false,
|
|
67
|
+
message: `pattern repeats an alternation whose branches can match the same text, e.g. (a|a)*, which can backtrack exponentially: ${compiled.reason}`,
|
|
68
|
+
};
|
|
69
|
+
case "invalid-syntax":
|
|
70
|
+
return { ok: false, message: `invalid regex: ${compiled.reason}` };
|
|
71
|
+
default:
|
|
72
|
+
return { ok: false, message: compiled.reason };
|
|
118
73
|
}
|
|
119
|
-
if (nested)
|
|
120
|
-
return "nested-quantifier";
|
|
121
|
-
if (quantifiedAlternation)
|
|
122
|
-
return "quantified-alternation";
|
|
123
|
-
return "none";
|
|
124
74
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** The platform CommandExists resolves for. */
|
|
2
|
+
export declare function hostPlatform(): NodeJS.Platform;
|
|
3
|
+
/** Test seam: pretend to be another platform; `undefined` restores the real one. */
|
|
4
|
+
export declare function _setPlatform(platform: NodeJS.Platform | undefined): void;
|
|
5
|
+
/** Windows' default when PATHEXT is unset. */
|
|
6
|
+
export declare const DEFAULT_PATHEXT = ".COM;.EXE;.BAT;.CMD";
|
|
7
|
+
export type PathSearch = {
|
|
8
|
+
/** The first match, or null. */
|
|
9
|
+
readonly path: string | null;
|
|
10
|
+
/** How many PATH entries were searched. */
|
|
11
|
+
readonly searchedDirs: number;
|
|
12
|
+
};
|
|
13
|
+
export declare function searchPath(name: string, options: {
|
|
14
|
+
readonly pathValue: string;
|
|
15
|
+
readonly platform: NodeJS.Platform;
|
|
16
|
+
readonly pathext?: string | undefined;
|
|
17
|
+
}): PathSearch;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a bare program name resolves on PATH — the question CommandExists
|
|
3
|
+
* answers — by the platform's own rule (C125).
|
|
4
|
+
*
|
|
5
|
+
* POSIX: each PATH directory in order, the first regular file with the name
|
|
6
|
+
* that is executable. Windows: an installed program is `git.exe` or
|
|
7
|
+
* `npm.cmd`, and the shell and Bun.spawn both find it by its bare name
|
|
8
|
+
* through PATHEXT. 0.7.0 probed only the bare name, so on Windows every
|
|
9
|
+
* program was "not found" while RunCommand could run it. Here, on win32,
|
|
10
|
+
* PATH is split on `;` (a quoted entry unquoted), and each directory is
|
|
11
|
+
* tried in order for the name as given when its extension is already one of
|
|
12
|
+
* PATHEXT's, then for the name plus each PATHEXT extension in PATHEXT's
|
|
13
|
+
* order. Existence and being a regular file is enough there: Windows has no
|
|
14
|
+
* execute bit to check.
|
|
15
|
+
*
|
|
16
|
+
* The platform is a parameter, read through `hostPlatform()` (with a test
|
|
17
|
+
* seam) so the Windows rule is exercised on a Linux or macOS CI runner.
|
|
18
|
+
*/
|
|
19
|
+
import { accessSync, constants as fsConstants, statSync } from "node:fs";
|
|
20
|
+
import * as path from "node:path";
|
|
21
|
+
let platformOverride;
|
|
22
|
+
/** The platform CommandExists resolves for. */
|
|
23
|
+
export function hostPlatform() {
|
|
24
|
+
return platformOverride ?? process.platform;
|
|
25
|
+
}
|
|
26
|
+
/** Test seam: pretend to be another platform; `undefined` restores the real one. */
|
|
27
|
+
export function _setPlatform(platform) {
|
|
28
|
+
platformOverride = platform;
|
|
29
|
+
}
|
|
30
|
+
/** Windows' default when PATHEXT is unset. */
|
|
31
|
+
export const DEFAULT_PATHEXT = ".COM;.EXE;.BAT;.CMD";
|
|
32
|
+
export function searchPath(name, options) {
|
|
33
|
+
const windows = options.platform === "win32";
|
|
34
|
+
const dirs = options.pathValue
|
|
35
|
+
.split(windows ? ";" : ":")
|
|
36
|
+
.map((d) => (windows ? d.trim().replace(/^"(.*)"$/, "$1") : d))
|
|
37
|
+
.filter((d) => d !== "");
|
|
38
|
+
const candidates = windows ? windowsCandidates(name, options.pathext) : [name];
|
|
39
|
+
for (const dir of dirs) {
|
|
40
|
+
for (const candidate of candidates) {
|
|
41
|
+
// The HOST join: on a real Windows host this is path.win32 already.
|
|
42
|
+
const full = path.join(dir, candidate);
|
|
43
|
+
if (isProgram(full, windows))
|
|
44
|
+
return { path: full, searchedDirs: dirs.length };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return { path: null, searchedDirs: dirs.length };
|
|
48
|
+
}
|
|
49
|
+
/** The spellings Windows would try for `name`, in order. */
|
|
50
|
+
function windowsCandidates(name, pathext) {
|
|
51
|
+
const exts = (pathext === undefined || pathext.trim() === "" ? DEFAULT_PATHEXT : pathext)
|
|
52
|
+
.split(";")
|
|
53
|
+
.map((e) => e.trim())
|
|
54
|
+
.filter((e) => e.startsWith(".") && e.length > 1);
|
|
55
|
+
const lowered = exts.map((e) => e.toLowerCase());
|
|
56
|
+
const out = [];
|
|
57
|
+
const own = path.extname(name).toLowerCase();
|
|
58
|
+
if (own !== "" && lowered.includes(own))
|
|
59
|
+
out.push(name);
|
|
60
|
+
for (const [i, ext] of exts.entries()) {
|
|
61
|
+
// Both spellings: Windows matches case-insensitively, a case-sensitive
|
|
62
|
+
// filesystem (a test on Linux, a WSL mount) does not.
|
|
63
|
+
out.push(`${name}${lowered[i]}`);
|
|
64
|
+
if (ext !== lowered[i])
|
|
65
|
+
out.push(`${name}${ext}`);
|
|
66
|
+
}
|
|
67
|
+
return [...new Set(out)];
|
|
68
|
+
}
|
|
69
|
+
function isProgram(full, windows) {
|
|
70
|
+
try {
|
|
71
|
+
if (!statSync(full).isFile())
|
|
72
|
+
return false;
|
|
73
|
+
if (!windows)
|
|
74
|
+
accessSync(full, fsConstants.X_OK);
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
package/dist/registry.d.ts
CHANGED
|
@@ -30,6 +30,16 @@ export type BgProc = {
|
|
|
30
30
|
stdoutReturned: number;
|
|
31
31
|
stderrReturned: number;
|
|
32
32
|
status: BgStatus;
|
|
33
|
+
/**
|
|
34
|
+
* Each pipe has reached its end, so `stdout` (or `stderr`) holds all the
|
|
35
|
+
* process will ever print there. This can lag `status`: the exit and a
|
|
36
|
+
* pipe's last bytes reach the parent as separate events, and a process it
|
|
37
|
+
* started that shares a pipe can keep it open after it is gone.
|
|
38
|
+
*/
|
|
39
|
+
stdoutEnded: boolean;
|
|
40
|
+
stderrEnded: boolean;
|
|
41
|
+
/** Both pipes have reached their end. */
|
|
42
|
+
outputEnded: boolean;
|
|
33
43
|
exitCode: number | null;
|
|
34
44
|
readonly startedAt: number;
|
|
35
45
|
exitedAt: number | null;
|
|
@@ -55,6 +65,35 @@ export declare function getProc(id: string): BgProc | undefined;
|
|
|
55
65
|
export declare function listProcs(): BgProc[];
|
|
56
66
|
export declare function reapProc(id: string): boolean;
|
|
57
67
|
export declare function markKilled(bg: BgProc): void;
|
|
68
|
+
/** The output a watch reads: one stream, or both. */
|
|
69
|
+
export type WatchedStream = "stdout" | "stderr" | "both";
|
|
70
|
+
/**
|
|
71
|
+
* Whether a process has finished as far as a watch of `stream` goes: the
|
|
72
|
+
* next look at that output is the last one worth taking.
|
|
73
|
+
*
|
|
74
|
+
* An exited process has finished once the watched pipe has ended too. The
|
|
75
|
+
* exit can reach the parent before the line printed just ahead of it, and a
|
|
76
|
+
* process it started can still hold the pipe and print. Only the watched
|
|
77
|
+
* pipe counts: one that holds just the other pipe cannot print on this one.
|
|
78
|
+
*
|
|
79
|
+
* A stopped one has finished once its own exit has been seen (`exitCode` is
|
|
80
|
+
* set then, to a number even for a death by signal), whatever its pipes are
|
|
81
|
+
* doing. Until then it is still running, whatever ProcessStop asked of it.
|
|
82
|
+
* ProcessStop signals the process alone, so one it started (a script running
|
|
83
|
+
* a server without `exec`) can hold the pipes for as long as it runs, and a
|
|
84
|
+
* wait on them would run to its deadline. The cost is the one 0.7.0 had: a
|
|
85
|
+
* line a stopped process prints as it goes, such as a SIGTERM handler's last
|
|
86
|
+
* words, can reach the parent after its exit and be missed. Stopping the
|
|
87
|
+
* whole process group would let the pipes decide here too.
|
|
88
|
+
*/
|
|
89
|
+
export declare function watchFinished(bg: Pick<BgProc, "status" | "exitCode" | "stdoutEnded" | "stderrEnded" | "outputEnded">, stream: WatchedStream): boolean;
|
|
58
90
|
/** Test-only: kill every live process and empty the table, including the id
|
|
59
91
|
* counter, so each test file starts from the same state. */
|
|
60
92
|
export declare function __resetRegistryForTest(): void;
|
|
93
|
+
/**
|
|
94
|
+
* Test-only: {@link __resetRegistryForTest}, then wait (at most `timeoutMs`)
|
|
95
|
+
* until every process it knew of has exited. A test removes its workspace
|
|
96
|
+
* after this, and on Windows a directory that is a live process's cwd cannot
|
|
97
|
+
* be removed (EBUSY), while a kill there returns before the process is gone.
|
|
98
|
+
*/
|
|
99
|
+
export declare function __stopAllForTest(timeoutMs?: number): Promise<void>;
|
package/dist/registry.js
CHANGED
|
@@ -117,6 +117,9 @@ export function startBackground(argv, options) {
|
|
|
117
117
|
stdoutReturned: 0,
|
|
118
118
|
stderrReturned: 0,
|
|
119
119
|
status: "running",
|
|
120
|
+
stdoutEnded: false,
|
|
121
|
+
stderrEnded: false,
|
|
122
|
+
outputEnded: false,
|
|
120
123
|
exitCode: null,
|
|
121
124
|
startedAt: Date.now(),
|
|
122
125
|
exitedAt: null,
|
|
@@ -124,8 +127,16 @@ export function startBackground(argv, options) {
|
|
|
124
127
|
};
|
|
125
128
|
procs.set(id, bg);
|
|
126
129
|
// Fire and forget: the tool returns while the process keeps running.
|
|
127
|
-
void
|
|
128
|
-
|
|
130
|
+
void Promise.all([
|
|
131
|
+
pump(spawned.stdout, (t) => append(bg, "stdout", t)).then(() => {
|
|
132
|
+
bg.stdoutEnded = true;
|
|
133
|
+
}),
|
|
134
|
+
pump(spawned.stderr, (t) => append(bg, "stderr", t)).then(() => {
|
|
135
|
+
bg.stderrEnded = true;
|
|
136
|
+
}),
|
|
137
|
+
]).then(() => {
|
|
138
|
+
bg.outputEnded = true;
|
|
139
|
+
});
|
|
129
140
|
void spawned.exited.then((code) => {
|
|
130
141
|
if (bg.status === "running")
|
|
131
142
|
bg.status = "exited";
|
|
@@ -155,6 +166,36 @@ export function markKilled(bg) {
|
|
|
155
166
|
bg.exitedAt = Date.now();
|
|
156
167
|
}
|
|
157
168
|
}
|
|
169
|
+
/**
|
|
170
|
+
* Whether a process has finished as far as a watch of `stream` goes: the
|
|
171
|
+
* next look at that output is the last one worth taking.
|
|
172
|
+
*
|
|
173
|
+
* An exited process has finished once the watched pipe has ended too. The
|
|
174
|
+
* exit can reach the parent before the line printed just ahead of it, and a
|
|
175
|
+
* process it started can still hold the pipe and print. Only the watched
|
|
176
|
+
* pipe counts: one that holds just the other pipe cannot print on this one.
|
|
177
|
+
*
|
|
178
|
+
* A stopped one has finished once its own exit has been seen (`exitCode` is
|
|
179
|
+
* set then, to a number even for a death by signal), whatever its pipes are
|
|
180
|
+
* doing. Until then it is still running, whatever ProcessStop asked of it.
|
|
181
|
+
* ProcessStop signals the process alone, so one it started (a script running
|
|
182
|
+
* a server without `exec`) can hold the pipes for as long as it runs, and a
|
|
183
|
+
* wait on them would run to its deadline. The cost is the one 0.7.0 had: a
|
|
184
|
+
* line a stopped process prints as it goes, such as a SIGTERM handler's last
|
|
185
|
+
* words, can reach the parent after its exit and be missed. Stopping the
|
|
186
|
+
* whole process group would let the pipes decide here too.
|
|
187
|
+
*/
|
|
188
|
+
export function watchFinished(bg, stream) {
|
|
189
|
+
if (bg.status === "running")
|
|
190
|
+
return false;
|
|
191
|
+
if (bg.status === "killed")
|
|
192
|
+
return bg.exitCode !== null;
|
|
193
|
+
if (stream === "stdout")
|
|
194
|
+
return bg.stdoutEnded;
|
|
195
|
+
if (stream === "stderr")
|
|
196
|
+
return bg.stderrEnded;
|
|
197
|
+
return bg.outputEnded;
|
|
198
|
+
}
|
|
158
199
|
/** Test-only: kill every live process and empty the table, including the id
|
|
159
200
|
* counter, so each test file starts from the same state. */
|
|
160
201
|
export function __resetRegistryForTest() {
|
|
@@ -171,3 +212,26 @@ export function __resetRegistryForTest() {
|
|
|
171
212
|
procs.clear();
|
|
172
213
|
nextId = 1;
|
|
173
214
|
}
|
|
215
|
+
/**
|
|
216
|
+
* Test-only: {@link __resetRegistryForTest}, then wait (at most `timeoutMs`)
|
|
217
|
+
* until every process it knew of has exited. A test removes its workspace
|
|
218
|
+
* after this, and on Windows a directory that is a live process's cwd cannot
|
|
219
|
+
* be removed (EBUSY), while a kill there returns before the process is gone.
|
|
220
|
+
*/
|
|
221
|
+
export async function __stopAllForTest(timeoutMs = 5_000) {
|
|
222
|
+
const exits = [...procs.values()].map((bg) => bg.proc.exited);
|
|
223
|
+
__resetRegistryForTest();
|
|
224
|
+
let timer;
|
|
225
|
+
try {
|
|
226
|
+
await Promise.race([
|
|
227
|
+
Promise.all(exits),
|
|
228
|
+
new Promise((resolve) => {
|
|
229
|
+
timer = setTimeout(resolve, timeoutMs);
|
|
230
|
+
}),
|
|
231
|
+
]);
|
|
232
|
+
}
|
|
233
|
+
finally {
|
|
234
|
+
if (timer !== undefined)
|
|
235
|
+
clearTimeout(timer);
|
|
236
|
+
}
|
|
237
|
+
}
|
package/dist/safe-path.d.ts
CHANGED
|
@@ -20,6 +20,17 @@ export type SafePath = {
|
|
|
20
20
|
readonly ok: false;
|
|
21
21
|
readonly message: string;
|
|
22
22
|
};
|
|
23
|
+
/**
|
|
24
|
+
* The longest path this resolves, in UTF-16 code units; a longer one is
|
|
25
|
+
* refused before it is walked. Nothing that long can be opened or created:
|
|
26
|
+
* PATH_MAX is 1,024 bytes on macOS and 4,096 on Linux, Windows stops at
|
|
27
|
+
* 32,767 UTF-16 units, and a path's UTF-8 form is never shorter than its
|
|
28
|
+
* UTF-16 one. The limit is twice that because the part of a path that exists
|
|
29
|
+
* is itself under PATH_MAX, so the part that does not is over PATH_MAX on its
|
|
30
|
+
* own: no spelling of the place reaches it, not even one through a symlink
|
|
31
|
+
* that shortens the part that exists.
|
|
32
|
+
*/
|
|
33
|
+
export declare const MAX_PATH_CHARS: number;
|
|
23
34
|
/**
|
|
24
35
|
* Is `candidate` the root itself, or something beneath it? Both arguments
|
|
25
36
|
* must already be REAL paths — this is a string containment test, and it is
|
package/dist/safe-path.js
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
import { existsSync, lstatSync, readlinkSync, realpathSync } from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* The longest path this resolves, in UTF-16 code units; a longer one is
|
|
5
|
+
* refused before it is walked. Nothing that long can be opened or created:
|
|
6
|
+
* PATH_MAX is 1,024 bytes on macOS and 4,096 on Linux, Windows stops at
|
|
7
|
+
* 32,767 UTF-16 units, and a path's UTF-8 form is never shorter than its
|
|
8
|
+
* UTF-16 one. The limit is twice that because the part of a path that exists
|
|
9
|
+
* is itself under PATH_MAX, so the part that does not is over PATH_MAX on its
|
|
10
|
+
* own: no spelling of the place reaches it, not even one through a symlink
|
|
11
|
+
* that shortens the part that exists.
|
|
12
|
+
*/
|
|
13
|
+
export const MAX_PATH_CHARS = 2 * (process.platform === "darwin" ? 1_024 : process.platform === "win32" ? 32_767 : 4_096);
|
|
14
|
+
/** A path as a refusal quotes it: whole, unless it is past {@link MAX_PATH_CHARS}. */
|
|
15
|
+
function shownPath(given) {
|
|
16
|
+
return given.length > MAX_PATH_CHARS ? `${given.slice(0, 64)}…` : given;
|
|
17
|
+
}
|
|
18
|
+
/** Why a path past {@link MAX_PATH_CHARS} is refused: its length and the limit. */
|
|
19
|
+
function tooLong(abs) {
|
|
20
|
+
return `it is ${abs.length} characters once resolved, over the ${MAX_PATH_CHARS}-character limit: no path that long can be opened or created`;
|
|
21
|
+
}
|
|
3
22
|
/**
|
|
4
23
|
* Is `candidate` the root itself, or something beneath it? Both arguments
|
|
5
24
|
* must already be REAL paths — this is a string containment test, and it is
|
|
@@ -47,15 +66,7 @@ function nameExists(p) {
|
|
|
47
66
|
function resolveLocation(target, depth = 0) {
|
|
48
67
|
if (depth > 40)
|
|
49
68
|
throw new Error(`symlink chain at "${target}" is too long to resolve`);
|
|
50
|
-
|
|
51
|
-
const tail = [];
|
|
52
|
-
while (!nameExists(probe)) {
|
|
53
|
-
tail.unshift(path.basename(probe));
|
|
54
|
-
const parent = path.dirname(probe);
|
|
55
|
-
if (parent === probe)
|
|
56
|
-
break; // reached the filesystem root
|
|
57
|
-
probe = parent;
|
|
58
|
-
}
|
|
69
|
+
const { probe, tail } = deepestName(target);
|
|
59
70
|
let probeReal;
|
|
60
71
|
try {
|
|
61
72
|
probeReal = realpathSync(probe);
|
|
@@ -80,7 +91,41 @@ function resolveLocation(target, depth = 0) {
|
|
|
80
91
|
const base = realpathSync(path.dirname(probe));
|
|
81
92
|
probeReal = resolveLocation(path.resolve(base, link), depth + 1);
|
|
82
93
|
}
|
|
83
|
-
|
|
94
|
+
// One string, not one argument per name: Bun's `path.join` keeps only
|
|
95
|
+
// (argument count mod 65,536) of its arguments.
|
|
96
|
+
return tail.length > 0 ? path.join(probeReal, tail) : probeReal;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* `target`, an absolute path, split where its deepest prefix that exists as
|
|
100
|
+
* a NAME ends: that prefix, and the names below it, which do not exist, as
|
|
101
|
+
* one string ("" when `target` itself exists). This is `path.dirname` and
|
|
102
|
+
* `path.basename` worked by index on `target` itself: handing them each
|
|
103
|
+
* shorter prefix in turn copied the rest of the path once per missing name,
|
|
104
|
+
* the square of a count the caller picks.
|
|
105
|
+
*/
|
|
106
|
+
function deepestName(target) {
|
|
107
|
+
const top = path.parse(target).root.length;
|
|
108
|
+
const isSep = (i) => target[i] === "/" || target[i] === path.sep;
|
|
109
|
+
let probe = target;
|
|
110
|
+
let start = target.length;
|
|
111
|
+
let stop = -1;
|
|
112
|
+
while (!nameExists(probe)) {
|
|
113
|
+
// `path.basename(probe)` is `target.slice(start, end)`, and
|
|
114
|
+
// `path.dirname(probe)` is `target.slice(0, up)`.
|
|
115
|
+
let end = probe.length;
|
|
116
|
+
while (end > top && isSep(end - 1))
|
|
117
|
+
end--;
|
|
118
|
+
if (stop < 0)
|
|
119
|
+
stop = end;
|
|
120
|
+
start = end;
|
|
121
|
+
while (start > top && !isSep(start - 1))
|
|
122
|
+
start--;
|
|
123
|
+
const up = start > top ? start - 1 : top;
|
|
124
|
+
if (up === probe.length)
|
|
125
|
+
break; // reached the filesystem root
|
|
126
|
+
probe = target.slice(0, up);
|
|
127
|
+
}
|
|
128
|
+
return { probe, tail: stop < 0 ? "" : target.slice(start, stop) };
|
|
84
129
|
}
|
|
85
130
|
/**
|
|
86
131
|
* Re-check, at the moment of use, that a path resolved earlier still lives
|
|
@@ -130,6 +175,13 @@ export function resolveSafe(toolName, rel, root = process.cwd()) {
|
|
|
130
175
|
if (!isInsideRoot(abs, rootResolved)) {
|
|
131
176
|
return refusal("it resolves outside the workspace root");
|
|
132
177
|
}
|
|
178
|
+
// Longer than any path can be: refused before the walk below reads it.
|
|
179
|
+
if (abs.length > MAX_PATH_CHARS) {
|
|
180
|
+
return {
|
|
181
|
+
ok: false,
|
|
182
|
+
message: `[${toolName} error] refused path "${shownPath(rel)}": ${tooLong(abs)}.`,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
133
185
|
try {
|
|
134
186
|
const rootReal = realpathSync(rootResolved);
|
|
135
187
|
// The leaf may not exist yet (a wait can watch for a file's creation),
|
package/dist/spawn.d.ts
CHANGED
|
@@ -1,3 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place this package starts a child process.
|
|
3
|
+
*
|
|
4
|
+
* Everything that runs a command — RunCommand, RunPipeline, Retry and the
|
|
5
|
+
* background family — goes through here so a single set of rules holds:
|
|
6
|
+
*
|
|
7
|
+
* - argv is passed to the OS as an ARRAY. There is no shell anywhere in
|
|
8
|
+
* this package, so a filename with a space or a `$(...)` in it is an
|
|
9
|
+
* argument and never a command.
|
|
10
|
+
* - stdin is `ignore` unless the caller supplied it, so a command that
|
|
11
|
+
* reads stdin gets EOF immediately instead of hanging on a terminal
|
|
12
|
+
* that is not there.
|
|
13
|
+
* - every run has a deadline: SIGTERM on expiry, SIGKILL after a short
|
|
14
|
+
* grace period for a child that ignores the first signal. The child
|
|
15
|
+
* leads its own process group and the deadline (or an abort) signals the
|
|
16
|
+
* whole group, so a `cmd &` it started cannot outlive the timeout.
|
|
17
|
+
* - the child runs in a SESSION of its own (spawnBounded's detached spawn,
|
|
18
|
+
* setsid), so it has no controlling terminal. That is deliberate, and a
|
|
19
|
+
* change from 0.7.0: a model-run command cannot read the operator's
|
|
20
|
+
* keystrokes from /dev/tty (the answers typed into the harness's own
|
|
21
|
+
* approval prompt), cannot push input into that terminal (TIOCSTI), and
|
|
22
|
+
* a `sudo`/`ssh`/`gpg` prompt fails at once instead of waiting on a
|
|
23
|
+
* terminal the model cannot see. Bun has no "own process group but keep
|
|
24
|
+
* the terminal" spawn, and the group is what makes the deadline real.
|
|
25
|
+
* The background family (ProcessStart) keeps 0.7.0's spawn.
|
|
26
|
+
* - both streams are capped AS THEY ARE READ (C078). The foreground run is
|
|
27
|
+
* @crewhaus/tool-safety's `spawnBounded`: memory is bounded by the cap,
|
|
28
|
+
* not by what the child prints, and the reader keeps draining past the
|
|
29
|
+
* cap so a child never blocks on a full pipe.
|
|
30
|
+
* - output a grandchild still holds open after the child exits is read for
|
|
31
|
+
* a bounded grace, then reading stops and the stream is cancelled: the
|
|
32
|
+
* result keeps what arrived and says `outputIncomplete`, never "".
|
|
33
|
+
*/
|
|
1
34
|
export type RunOutcome = {
|
|
2
35
|
readonly argv: readonly string[];
|
|
3
36
|
readonly exitCode: number;
|
|
@@ -14,6 +47,12 @@ export type RunOutcome = {
|
|
|
14
47
|
* the result is what had been captured by then, and `exitCode` is unknown.
|
|
15
48
|
*/
|
|
16
49
|
readonly abandoned?: boolean;
|
|
50
|
+
/**
|
|
51
|
+
* Set when reading stopped before a stream ended: the child exited but a
|
|
52
|
+
* process it started still held its output open past the drain grace. The
|
|
53
|
+
* text is what arrived by then — possibly all of it, possibly not.
|
|
54
|
+
*/
|
|
55
|
+
readonly outputIncomplete?: boolean;
|
|
17
56
|
};
|
|
18
57
|
export type RunOptions = {
|
|
19
58
|
readonly cwd: string;
|