@crewhaus/tool-proc 0.7.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.
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Backoff schedules for Retry.
3
+ *
4
+ * Deliberately jitter-free: jitter is randomness, and a tool whose waits
5
+ * differ run to run is a tool whose transcript cannot be replayed. The
6
+ * caller declares the schedule up front and gets exactly that schedule.
7
+ */
8
+ export type BackoffPolicy = {
9
+ readonly kind: "fixed";
10
+ readonly delayMs: number;
11
+ } | {
12
+ readonly kind: "exponential";
13
+ readonly baseMs: number;
14
+ readonly factor?: number;
15
+ readonly maxDelayMs?: number;
16
+ };
17
+ /**
18
+ * The delay to wait BEFORE a 1-based attempt number. Attempt 1 never waits;
19
+ * attempt 2 waits `baseMs`, attempt 3 `baseMs * factor`, and so on.
20
+ */
21
+ export declare function backoffDelayMs(policy: BackoffPolicy, attempt: number): number;
22
+ /** The whole schedule for `maxAttempts` attempts, including the leading 0. */
23
+ export declare function backoffSchedule(policy: BackoffPolicy, maxAttempts: number): number[];
24
+ /** What the whole schedule costs in waiting alone — useful to reject a plan
25
+ * that could not finish inside the caller's overall deadline. */
26
+ export declare function totalBackoffMs(policy: BackoffPolicy, maxAttempts: number): number;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Backoff schedules for Retry.
3
+ *
4
+ * Deliberately jitter-free: jitter is randomness, and a tool whose waits
5
+ * differ run to run is a tool whose transcript cannot be replayed. The
6
+ * caller declares the schedule up front and gets exactly that schedule.
7
+ */
8
+ /**
9
+ * The delay to wait BEFORE a 1-based attempt number. Attempt 1 never waits;
10
+ * attempt 2 waits `baseMs`, attempt 3 `baseMs * factor`, and so on.
11
+ */
12
+ export function backoffDelayMs(policy, attempt) {
13
+ if (attempt <= 1)
14
+ return 0;
15
+ if (policy.kind === "fixed")
16
+ return Math.max(0, Math.round(policy.delayMs));
17
+ const factor = policy.factor ?? 2;
18
+ const raw = policy.baseMs * factor ** (attempt - 2);
19
+ const capped = policy.maxDelayMs === undefined ? raw : Math.min(raw, policy.maxDelayMs);
20
+ return Math.max(0, Math.round(Number.isFinite(capped) ? capped : (policy.maxDelayMs ?? raw)));
21
+ }
22
+ /** The whole schedule for `maxAttempts` attempts, including the leading 0. */
23
+ export function backoffSchedule(policy, maxAttempts) {
24
+ const out = [];
25
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
26
+ out.push(backoffDelayMs(policy, attempt));
27
+ }
28
+ return out;
29
+ }
30
+ /** What the whole schedule costs in waiting alone — useful to reject a plan
31
+ * that could not finish inside the caller's overall deadline. */
32
+ export function totalBackoffMs(policy, maxAttempts) {
33
+ return backoffSchedule(policy, maxAttempts).reduce((a, b) => a + b, 0);
34
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Environment handling for spawned processes.
3
+ *
4
+ * Two rules drive everything here.
5
+ *
6
+ * 1. A spawned command does NOT inherit this process's environment. The
7
+ * harness environment holds API keys, tokens and cloud credentials, and
8
+ * handing all of them to every `ls` is an exfiltration channel nobody
9
+ * asked for. The caller names what it wants forwarded.
10
+ * 2. What is left is pinned for determinism: `LC_ALL`/`LANG` fix collation
11
+ * and message language, `TZ` fixes any timestamp the program prints.
12
+ * Without those, the same command produces different bytes on two
13
+ * machines that differ only in locale.
14
+ */
15
+ export declare function isValidEnvName(name: string): boolean;
16
+ /** PATH used when the parent has none — enough to find the system tools. */
17
+ export declare const FALLBACK_PATH = "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin";
18
+ export type SpawnEnvResult = {
19
+ /** The environment to hand the child, key-sorted so it is stable. */
20
+ readonly env: Record<string, string>;
21
+ /** Names the caller asked to forward that the parent does not define. */
22
+ readonly missing: readonly string[];
23
+ /** Names rejected as not being environment-variable names at all. */
24
+ readonly invalid: readonly string[];
25
+ };
26
+ export declare function buildSpawnEnv(parent: Readonly<Record<string, string | undefined>>, options?: {
27
+ readonly forward?: readonly string[];
28
+ readonly set?: Readonly<Record<string, string>>;
29
+ }): SpawnEnvResult;
30
+ /** One variable as EnvInspect reports it. `value` is present only for names
31
+ * the caller explicitly asked to reveal. */
32
+ export type EnvView = {
33
+ readonly name: string;
34
+ readonly present: boolean;
35
+ readonly chars: number;
36
+ readonly value?: string;
37
+ };
38
+ /**
39
+ * Inspect named variables and nothing else. There is deliberately no way to
40
+ * enumerate the environment: a caller must know the name to learn anything,
41
+ * and learns only whether it is set and how long it is unless it names the
42
+ * variable in `reveal`.
43
+ */
44
+ export declare function inspectEnv(parent: Readonly<Record<string, string | undefined>>, names: readonly string[], reveal?: readonly string[]): {
45
+ readonly views: EnvView[];
46
+ readonly invalid: readonly string[];
47
+ };
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Environment handling for spawned processes.
3
+ *
4
+ * Two rules drive everything here.
5
+ *
6
+ * 1. A spawned command does NOT inherit this process's environment. The
7
+ * harness environment holds API keys, tokens and cloud credentials, and
8
+ * handing all of them to every `ls` is an exfiltration channel nobody
9
+ * asked for. The caller names what it wants forwarded.
10
+ * 2. What is left is pinned for determinism: `LC_ALL`/`LANG` fix collation
11
+ * and message language, `TZ` fixes any timestamp the program prints.
12
+ * Without those, the same command produces different bytes on two
13
+ * machines that differ only in locale.
14
+ */
15
+ const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
16
+ export function isValidEnvName(name) {
17
+ return ENV_NAME.test(name);
18
+ }
19
+ /** PATH used when the parent has none — enough to find the system tools. */
20
+ export const FALLBACK_PATH = "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin";
21
+ export function buildSpawnEnv(parent, options = {}) {
22
+ const out = new Map();
23
+ // The deterministic floor. PATH and HOME are forwarded because almost no
24
+ // real command works without them; everything else must be asked for.
25
+ out.set("PATH", parent["PATH"] ?? FALLBACK_PATH);
26
+ const home = parent["HOME"];
27
+ if (home !== undefined)
28
+ out.set("HOME", home);
29
+ out.set("LC_ALL", "C");
30
+ out.set("LANG", "C");
31
+ out.set("TZ", "UTC");
32
+ const missing = [];
33
+ const invalid = [];
34
+ for (const name of options.forward ?? []) {
35
+ if (!isValidEnvName(name)) {
36
+ invalid.push(name);
37
+ continue;
38
+ }
39
+ const value = parent[name];
40
+ if (value === undefined) {
41
+ missing.push(name);
42
+ continue;
43
+ }
44
+ out.set(name, value);
45
+ }
46
+ for (const [name, value] of Object.entries(options.set ?? {})) {
47
+ if (!isValidEnvName(name)) {
48
+ invalid.push(name);
49
+ continue;
50
+ }
51
+ out.set(name, value);
52
+ }
53
+ const env = {};
54
+ for (const name of [...out.keys()].sort())
55
+ env[name] = out.get(name);
56
+ return { env, missing, invalid };
57
+ }
58
+ /**
59
+ * Inspect named variables and nothing else. There is deliberately no way to
60
+ * enumerate the environment: a caller must know the name to learn anything,
61
+ * and learns only whether it is set and how long it is unless it names the
62
+ * variable in `reveal`.
63
+ */
64
+ export function inspectEnv(parent, names, reveal = []) {
65
+ const revealSet = new Set(reveal);
66
+ const invalid = [];
67
+ const views = [];
68
+ const seen = new Set();
69
+ for (const name of names) {
70
+ if (!isValidEnvName(name)) {
71
+ invalid.push(name);
72
+ continue;
73
+ }
74
+ if (seen.has(name))
75
+ continue;
76
+ seen.add(name);
77
+ const value = parent[name];
78
+ const base = {
79
+ name,
80
+ present: value !== undefined,
81
+ chars: value === undefined ? 0 : value.length,
82
+ };
83
+ views.push(value !== undefined && revealSet.has(name) ? { ...base, value } : base);
84
+ }
85
+ // Sorted by name so two calls with the same names in a different order
86
+ // return the same bytes.
87
+ views.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
88
+ return { views, invalid };
89
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Pure formatting helpers for the process tools. Nothing here spawns,
3
+ * reads the filesystem or looks at a clock, so the behaviour that these
4
+ * back can be unit-tested without a world.
5
+ */
6
+ export type CappedText = {
7
+ readonly text: string;
8
+ readonly truncated: boolean;
9
+ readonly droppedChars: number;
10
+ };
11
+ /**
12
+ * Cap a captured stream to a character budget, keeping BOTH ends.
13
+ *
14
+ * A command's head says what it started doing and its tail says how it
15
+ * ended; dropping either one loses the half a caller usually needs. The
16
+ * elision marker states the exact number of dropped characters so the
17
+ * result is still a faithful description of what the process produced.
18
+ */
19
+ export declare function capText(text: string, maxChars: number): CappedText;
20
+ /**
21
+ * Render an argv array the way a person would type it, for log lines and
22
+ * error messages ONLY. This is never fed back to a shell — the tools spawn
23
+ * the argv array directly — so it quotes for readability, not for safety.
24
+ */
25
+ export declare function formatArgv(argv: readonly string[]): string;
26
+ /**
27
+ * Compile a caller-supplied regular expression, refusing the shapes behind
28
+ * catastrophic backtracking. The pattern reaches us from a model, and a
29
+ * waiting tool applies it repeatedly to a growing buffer, so an exponential
30
+ * pattern would pin a core for the whole deadline.
31
+ */
32
+ export declare function compileSafePattern(source: string, flags: string): {
33
+ readonly ok: true;
34
+ readonly regex: RegExp;
35
+ } | {
36
+ readonly ok: false;
37
+ readonly message: string;
38
+ };
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Pure formatting helpers for the process tools. Nothing here spawns,
3
+ * reads the filesystem or looks at a clock, so the behaviour that these
4
+ * back can be unit-tested without a world.
5
+ */
6
+ /**
7
+ * Cap a captured stream to a character budget, keeping BOTH ends.
8
+ *
9
+ * A command's head says what it started doing and its tail says how it
10
+ * ended; dropping either one loses the half a caller usually needs. The
11
+ * elision marker states the exact number of dropped characters so the
12
+ * result is still a faithful description of what the process produced.
13
+ */
14
+ export function capText(text, maxChars) {
15
+ if (maxChars <= 0) {
16
+ return { text: "", truncated: text.length > 0, droppedChars: text.length };
17
+ }
18
+ if (text.length <= maxChars) {
19
+ return { text, truncated: false, droppedChars: 0 };
20
+ }
21
+ const head = Math.floor(maxChars / 2);
22
+ const tail = maxChars - head;
23
+ const dropped = text.length - maxChars;
24
+ const kept = `${text.slice(0, head)}\n...[${dropped} chars dropped]...\n${text.slice(text.length - tail)}`;
25
+ return { text: kept, truncated: true, droppedChars: dropped };
26
+ }
27
+ /**
28
+ * Render an argv array the way a person would type it, for log lines and
29
+ * error messages ONLY. This is never fed back to a shell — the tools spawn
30
+ * the argv array directly — so it quotes for readability, not for safety.
31
+ */
32
+ export function formatArgv(argv) {
33
+ return argv
34
+ .map((arg) => arg === "" || /[\s"'\\$`*?|&;<>()[\]{}#~!]/.test(arg) ? JSON.stringify(arg) : arg)
35
+ .join(" ");
36
+ }
37
+ /**
38
+ * Compile a caller-supplied regular expression, refusing the shapes behind
39
+ * catastrophic backtracking. The pattern reaches us from a model, and a
40
+ * waiting tool applies it repeatedly to a growing buffer, so an exponential
41
+ * pattern would pin a core for the whole deadline.
42
+ */
43
+ export function compileSafePattern(source, flags) {
44
+ if (source.length > 1_000) {
45
+ return { ok: false, message: `pattern is ${source.length} characters, over the 1000 limit` };
46
+ }
47
+ const risk = backtrackingRisk(source);
48
+ if (risk === "nested-quantifier") {
49
+ return {
50
+ ok: false,
51
+ message: "pattern nests one quantifier inside another, which can backtrack exponentially",
52
+ };
53
+ }
54
+ if (risk === "quantified-alternation") {
55
+ return {
56
+ ok: false,
57
+ message: "pattern quantifies a group containing an alternation, e.g. (a|a)*, which can backtrack exponentially — quantify a character class instead",
58
+ };
59
+ }
60
+ try {
61
+ return { ok: true, regex: new RegExp(source, flags) };
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
+ }
118
+ }
119
+ if (nested)
120
+ return "nested-quantifier";
121
+ if (quantifiedAlternation)
122
+ return "quantified-alternation";
123
+ return "none";
124
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * The background-process registry: the processes THIS harness started, and
3
+ * nothing else. No tool here can see or touch a process the harness did not
4
+ * start, which is what keeps `ProcessList` a description of the harness's
5
+ * own work rather than a view of the machine.
6
+ *
7
+ * Ids are a plain counter (`proc_1`, `proc_2`, ...) rather than random hex:
8
+ * the same sequence of calls produces the same ids, so a transcript replays
9
+ * identically and a test can assert on them.
10
+ *
11
+ * Reap policy. An exited process is KEPT, so its final output can still be
12
+ * read after it is gone — the common case of "it died, what did it say?".
13
+ * Entries leave the registry in exactly three ways: the caller reaps one
14
+ * with `ProcessStop({ reap: true })`, a new start evicts the oldest
15
+ * finished entries once the table is full, or the host process exits (at
16
+ * which point any survivor is SIGKILLed so a session never leaks children).
17
+ */
18
+ /** How many entries the table holds before evicting finished ones. */
19
+ export declare const MAX_RETAINED_PROCS = 64;
20
+ export type BgStatus = "running" | "exited" | "killed";
21
+ export type BgProc = {
22
+ readonly id: string;
23
+ readonly argv: readonly string[];
24
+ readonly cwd: string;
25
+ readonly label: string | undefined;
26
+ readonly proc: ReturnType<typeof Bun.spawn>;
27
+ stdout: string;
28
+ stderr: string;
29
+ /** Characters already handed back by a previous ProcessOutput poll. */
30
+ stdoutReturned: number;
31
+ stderrReturned: number;
32
+ status: BgStatus;
33
+ exitCode: number | null;
34
+ readonly startedAt: number;
35
+ exitedAt: number | null;
36
+ truncated: boolean;
37
+ };
38
+ export type StartResult = {
39
+ readonly ok: true;
40
+ readonly proc: BgProc;
41
+ readonly evicted: number;
42
+ } | {
43
+ readonly ok: false;
44
+ readonly message: string;
45
+ };
46
+ export declare function startBackground(argv: readonly string[], options: {
47
+ readonly cwd: string;
48
+ readonly env: Record<string, string>;
49
+ readonly stdin?: string;
50
+ readonly label?: string;
51
+ }): StartResult;
52
+ export declare function getProc(id: string): BgProc | undefined;
53
+ /** Every entry, in start order — a stable order, unlike Map iteration on
54
+ * a table that has had entries evicted. */
55
+ export declare function listProcs(): BgProc[];
56
+ export declare function reapProc(id: string): boolean;
57
+ export declare function markKilled(bg: BgProc): void;
58
+ /** Test-only: kill every live process and empty the table, including the id
59
+ * counter, so each test file starts from the same state. */
60
+ export declare function __resetRegistryForTest(): void;
@@ -0,0 +1,173 @@
1
+ /**
2
+ * The background-process registry: the processes THIS harness started, and
3
+ * nothing else. No tool here can see or touch a process the harness did not
4
+ * start, which is what keeps `ProcessList` a description of the harness's
5
+ * own work rather than a view of the machine.
6
+ *
7
+ * Ids are a plain counter (`proc_1`, `proc_2`, ...) rather than random hex:
8
+ * the same sequence of calls produces the same ids, so a transcript replays
9
+ * identically and a test can assert on them.
10
+ *
11
+ * Reap policy. An exited process is KEPT, so its final output can still be
12
+ * read after it is gone — the common case of "it died, what did it say?".
13
+ * Entries leave the registry in exactly three ways: the caller reaps one
14
+ * with `ProcessStop({ reap: true })`, a new start evicts the oldest
15
+ * finished entries once the table is full, or the host process exits (at
16
+ * which point any survivor is SIGKILLed so a session never leaks children).
17
+ */
18
+ /** Per-stream retained output for one background process. */
19
+ const MAX_STREAM_CHARS = 256_000;
20
+ /** How many entries the table holds before evicting finished ones. */
21
+ export const MAX_RETAINED_PROCS = 64;
22
+ const procs = new Map();
23
+ let nextId = 1;
24
+ let cleanupInstalled = false;
25
+ function ensureCleanup() {
26
+ if (cleanupInstalled)
27
+ return;
28
+ cleanupInstalled = true;
29
+ // Synchronous, and therefore safe in an exit handler: never leave a live
30
+ // child behind when the session ends.
31
+ process.on("exit", () => {
32
+ for (const bg of procs.values()) {
33
+ if (bg.status === "running") {
34
+ try {
35
+ bg.proc.kill("SIGKILL");
36
+ }
37
+ catch {
38
+ // Already gone.
39
+ }
40
+ }
41
+ }
42
+ });
43
+ }
44
+ function append(bg, stream, chunk) {
45
+ const next = bg[stream] + chunk;
46
+ if (next.length <= MAX_STREAM_CHARS) {
47
+ bg[stream] = next;
48
+ return;
49
+ }
50
+ // Keep the tail: for a long-running process the recent output is the
51
+ // interesting one, and the cursor moves with the window so a poll never
52
+ // re-reads text it already returned.
53
+ const drop = next.length - MAX_STREAM_CHARS;
54
+ bg[stream] = next.slice(drop);
55
+ const cursor = stream === "stdout" ? "stdoutReturned" : "stderrReturned";
56
+ bg[cursor] = Math.max(0, bg[cursor] - drop);
57
+ bg.truncated = true;
58
+ }
59
+ async function pump(stream, onChunk) {
60
+ if (stream === null || stream === undefined)
61
+ return;
62
+ const decoder = new TextDecoder();
63
+ try {
64
+ for await (const chunk of stream)
65
+ onChunk(decoder.decode(chunk, { stream: true }));
66
+ const tail = decoder.decode();
67
+ if (tail.length > 0)
68
+ onChunk(tail);
69
+ }
70
+ catch {
71
+ // Stream torn down because the process was killed — stop draining.
72
+ }
73
+ }
74
+ /** Evict finished entries, oldest first, until the table has room. */
75
+ function evictFinished() {
76
+ let evicted = 0;
77
+ while (procs.size >= MAX_RETAINED_PROCS) {
78
+ const victim = [...procs.values()].find((p) => p.status !== "running");
79
+ if (victim === undefined)
80
+ break; // every entry is live: keep them all.
81
+ procs.delete(victim.id);
82
+ evicted++;
83
+ }
84
+ return evicted;
85
+ }
86
+ export function startBackground(argv, options) {
87
+ ensureCleanup();
88
+ const evicted = evictFinished();
89
+ if (procs.size >= MAX_RETAINED_PROCS) {
90
+ return {
91
+ ok: false,
92
+ message: `the background table is full (${MAX_RETAINED_PROCS} live processes) — stop one first`,
93
+ };
94
+ }
95
+ const id = `proc_${nextId++}`;
96
+ let spawned;
97
+ try {
98
+ spawned = Bun.spawn([...argv], {
99
+ cwd: options.cwd,
100
+ env: options.env,
101
+ stdin: options.stdin === undefined ? "ignore" : new TextEncoder().encode(options.stdin),
102
+ stdout: "pipe",
103
+ stderr: "pipe",
104
+ });
105
+ }
106
+ catch (err) {
107
+ return { ok: false, message: err instanceof Error ? err.message : String(err) };
108
+ }
109
+ const bg = {
110
+ id,
111
+ argv: [...argv],
112
+ cwd: options.cwd,
113
+ label: options.label,
114
+ proc: spawned,
115
+ stdout: "",
116
+ stderr: "",
117
+ stdoutReturned: 0,
118
+ stderrReturned: 0,
119
+ status: "running",
120
+ exitCode: null,
121
+ startedAt: Date.now(),
122
+ exitedAt: null,
123
+ truncated: false,
124
+ };
125
+ procs.set(id, bg);
126
+ // Fire and forget: the tool returns while the process keeps running.
127
+ void pump(spawned.stdout, (t) => append(bg, "stdout", t));
128
+ void pump(spawned.stderr, (t) => append(bg, "stderr", t));
129
+ void spawned.exited.then((code) => {
130
+ if (bg.status === "running")
131
+ bg.status = "exited";
132
+ bg.exitCode = code;
133
+ bg.exitedAt = Date.now();
134
+ });
135
+ return { ok: true, proc: bg, evicted };
136
+ }
137
+ export function getProc(id) {
138
+ return procs.get(id);
139
+ }
140
+ /** Every entry, in start order — a stable order, unlike Map iteration on
141
+ * a table that has had entries evicted. */
142
+ export function listProcs() {
143
+ return [...procs.values()].sort((a, b) => idNumber(a.id) - idNumber(b.id));
144
+ }
145
+ function idNumber(id) {
146
+ const n = Number.parseInt(id.slice("proc_".length), 10);
147
+ return Number.isNaN(n) ? 0 : n;
148
+ }
149
+ export function reapProc(id) {
150
+ return procs.delete(id);
151
+ }
152
+ export function markKilled(bg) {
153
+ if (bg.status === "running") {
154
+ bg.status = "killed";
155
+ bg.exitedAt = Date.now();
156
+ }
157
+ }
158
+ /** Test-only: kill every live process and empty the table, including the id
159
+ * counter, so each test file starts from the same state. */
160
+ export function __resetRegistryForTest() {
161
+ for (const bg of procs.values()) {
162
+ if (bg.status === "running") {
163
+ try {
164
+ bg.proc.kill("SIGKILL");
165
+ }
166
+ catch {
167
+ // ignore
168
+ }
169
+ }
170
+ }
171
+ procs.clear();
172
+ nextId = 1;
173
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Workspace containment for every path a caller supplies — the working
3
+ * directory a command runs in, and the file a wait watches.
4
+ *
5
+ * The algorithm is the one `@crewhaus/tool-fs` uses, reproduced here rather
6
+ * than imported because this package does not depend on that one: resolve
7
+ * lexically first (cheap, catches `..` and absolute escapes), then resolve
8
+ * the real path of the deepest ancestor that EXISTS AS A NAME so an
9
+ * in-workspace symlink pointing outside is caught too (CWE-59). A tool that
10
+ * can be pointed at `/etc` is a defect, so this fails closed: anything it
11
+ * cannot prove is inside the root is refused.
12
+ *
13
+ * It returns a result rather than throwing, because the tools report a
14
+ * caller's mistake as a readable string instead of an exception.
15
+ */
16
+ export type SafePath = {
17
+ readonly ok: true;
18
+ readonly path: string;
19
+ } | {
20
+ readonly ok: false;
21
+ readonly message: string;
22
+ };
23
+ /**
24
+ * Is `candidate` the root itself, or something beneath it? Both arguments
25
+ * must already be REAL paths — this is a string containment test, and it is
26
+ * only sound once the symlinks are out of the way.
27
+ */
28
+ export declare function isInsideRoot(candidate: string, root: string): boolean;
29
+ /**
30
+ * Re-check, at the moment of use, that a path resolved earlier still lives
31
+ * inside the root.
32
+ *
33
+ * `resolveSafe` can only resolve the symlinks that exist when it is called.
34
+ * A path that does not exist at all is admitted on the strength of its
35
+ * parent, and a link planted or swapped AFTER that call was never seen. A
36
+ * tool that polls a path for seconds or minutes — `WaitForFile` does exactly
37
+ * that — would happily report on the target once somebody creates it outside
38
+ * the root, so every poll asks again.
39
+ *
40
+ * (An outward DANGLING link is refused up front now, because `resolveSafe`
41
+ * follows it by hand rather than mistaking it for a missing file. This
42
+ * remains the guard for everything that only becomes a symlink later.)
43
+ *
44
+ * Returns `"absent"` when nothing is there yet (which is not an escape — it
45
+ * is the condition `WaitForFile` may be waiting for), `"inside"` when the
46
+ * real path is contained, and `"escaped"` when it is not.
47
+ */
48
+ export declare function recheckContainment(target: string, root?: string): "inside" | "absent" | "escaped";
49
+ export declare function resolveSafe(toolName: string, rel: string, root?: string): SafePath;
50
+ /**
51
+ * Containment for a directory that must already exist (a command's cwd).
52
+ *
53
+ * `existsSync` is the right probe HERE, unlike in the walk above: a cwd has
54
+ * to be a directory the OS can actually chdir into, so a name that leads
55
+ * nowhere is useless and following the link is exactly what is wanted.
56
+ */
57
+ export declare function resolveSafeDir(toolName: string, rel: string, root?: string): SafePath;