@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,165 @@
1
+ import { existsSync, lstatSync, readlinkSync, realpathSync } from "node:fs";
2
+ import * as path from "node:path";
3
+ /**
4
+ * Is `candidate` the root itself, or something beneath it? Both arguments
5
+ * must already be REAL paths — this is a string containment test, and it is
6
+ * only sound once the symlinks are out of the way.
7
+ */
8
+ export function isInsideRoot(candidate, root) {
9
+ return candidate === root || candidate.startsWith(`${root}${path.sep}`);
10
+ }
11
+ /**
12
+ * True when the NAME exists, whether or not it leads anywhere.
13
+ *
14
+ * `existsSync` follows symlinks, so it answers false for a link whose target
15
+ * is missing — and a missing target is exactly the case that matters here: a
16
+ * dangling link is still a door. A walk that probes with it strides straight
17
+ * past the link, treats it as a plain missing leaf, pushes the name onto the
18
+ * tail and re-appends it to the realpath'd parent, so containment is decided
19
+ * on a path the link does not actually lead to. Probing with `lstat` — a
20
+ * dangling link is a NAME that exists — keeps that name in the part of the
21
+ * path that gets RESOLVED rather than in the "does not exist yet" tail that
22
+ * is appended to the root verbatim.
23
+ */
24
+ function nameExists(p) {
25
+ try {
26
+ lstatSync(p);
27
+ return true;
28
+ }
29
+ catch {
30
+ return false;
31
+ }
32
+ }
33
+ /**
34
+ * Where `target` would actually land, with every symlink on the way already
35
+ * followed — including one whose own target does not exist yet.
36
+ *
37
+ * `realpathSync` gives up with ENOENT on a dangling link, which would leave
38
+ * that link unresolved and let it stand in for a plain missing file. So the
39
+ * deepest ancestor that exists as a name is resolved, a dangling one is
40
+ * followed a hop by hand, and the components that do not exist are appended.
41
+ * The result is the path an `open` would reach, which is the only path worth
42
+ * checking containment against.
43
+ *
44
+ * Throws if the chain cannot be followed at all; `resolveSafe`'s catch turns
45
+ * that into a refusal, so an unresolvable path fails closed.
46
+ */
47
+ function resolveLocation(target, depth = 0) {
48
+ if (depth > 40)
49
+ throw new Error(`symlink chain at "${target}" is too long to resolve`);
50
+ let probe = target;
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
+ }
59
+ let probeReal;
60
+ try {
61
+ probeReal = realpathSync(probe);
62
+ }
63
+ catch (err) {
64
+ if (err.code !== "ENOENT")
65
+ throw err;
66
+ // The name is there but `realpath` cannot finish it: a symlink with a
67
+ // missing target. `readlinkSync` throws EINVAL on anything else, which
68
+ // fails closed. Recursing (rather than returning the raw target) is what
69
+ // resolves an absolute target such as /var/folders/... to its real
70
+ // /private/var/... form, so a legitimate in-workspace dangling link is
71
+ // not wrongly refused.
72
+ const link = readlinkSync(probe);
73
+ // A RELATIVE target resolves against the directory that actually CONTAINS
74
+ // the link, which is not the link's lexical parent when that parent is
75
+ // itself reached through a symlink. `<root>/dirlink/x -> ../y` with
76
+ // `dirlink` pointing out of the root lands at `<elsewhere>/y`, but
77
+ // measured from the lexical parent it reads as `<root>/y` and would be
78
+ // admitted; the mirror case wrongly refuses an in-root link. So the
79
+ // parent is made real first. An absolute target ignores the base.
80
+ const base = realpathSync(path.dirname(probe));
81
+ probeReal = resolveLocation(path.resolve(base, link), depth + 1);
82
+ }
83
+ return tail.length > 0 ? path.join(probeReal, ...tail) : probeReal;
84
+ }
85
+ /**
86
+ * Re-check, at the moment of use, that a path resolved earlier still lives
87
+ * inside the root.
88
+ *
89
+ * `resolveSafe` can only resolve the symlinks that exist when it is called.
90
+ * A path that does not exist at all is admitted on the strength of its
91
+ * parent, and a link planted or swapped AFTER that call was never seen. A
92
+ * tool that polls a path for seconds or minutes — `WaitForFile` does exactly
93
+ * that — would happily report on the target once somebody creates it outside
94
+ * the root, so every poll asks again.
95
+ *
96
+ * (An outward DANGLING link is refused up front now, because `resolveSafe`
97
+ * follows it by hand rather than mistaking it for a missing file. This
98
+ * remains the guard for everything that only becomes a symlink later.)
99
+ *
100
+ * Returns `"absent"` when nothing is there yet (which is not an escape — it
101
+ * is the condition `WaitForFile` may be waiting for), `"inside"` when the
102
+ * real path is contained, and `"escaped"` when it is not.
103
+ */
104
+ export function recheckContainment(target, root = process.cwd()) {
105
+ let rootReal;
106
+ try {
107
+ rootReal = realpathSync(path.resolve(root));
108
+ }
109
+ catch {
110
+ return "escaped";
111
+ }
112
+ let real;
113
+ try {
114
+ real = realpathSync(target);
115
+ }
116
+ catch {
117
+ // Nothing (or a dangling link) is there: there is no target to escape
118
+ // to yet, and the caller reads this as "absent".
119
+ return "absent";
120
+ }
121
+ return isInsideRoot(real, rootReal) ? "inside" : "escaped";
122
+ }
123
+ export function resolveSafe(toolName, rel, root = process.cwd()) {
124
+ const refusal = (why) => ({
125
+ ok: false,
126
+ message: `[${toolName} error] refused path "${rel}": ${why}. Paths must stay inside the workspace root.`,
127
+ });
128
+ const rootResolved = path.resolve(root);
129
+ const abs = path.resolve(rootResolved, rel);
130
+ if (!isInsideRoot(abs, rootResolved)) {
131
+ return refusal("it resolves outside the workspace root");
132
+ }
133
+ try {
134
+ const rootReal = realpathSync(rootResolved);
135
+ // The leaf may not exist yet (a wait can watch for a file's creation),
136
+ // so `resolveLocation` walks up to the deepest ancestor that exists as a
137
+ // NAME and re-appends the tail. "As a name" rather than "exists": a
138
+ // symlink whose target is missing is still followed by whatever opens
139
+ // this path later, so it is resolved here too.
140
+ const real = resolveLocation(abs);
141
+ if (!isInsideRoot(real, rootReal)) {
142
+ return refusal("a symlink on it leads outside the workspace root");
143
+ }
144
+ return { ok: true, path: real };
145
+ }
146
+ catch {
147
+ return refusal("it could not be resolved");
148
+ }
149
+ }
150
+ /**
151
+ * Containment for a directory that must already exist (a command's cwd).
152
+ *
153
+ * `existsSync` is the right probe HERE, unlike in the walk above: a cwd has
154
+ * to be a directory the OS can actually chdir into, so a name that leads
155
+ * nowhere is useless and following the link is exactly what is wanted.
156
+ */
157
+ export function resolveSafeDir(toolName, rel, root) {
158
+ const resolved = resolveSafe(toolName, rel, root);
159
+ if (!resolved.ok)
160
+ return resolved;
161
+ if (!existsSync(resolved.path)) {
162
+ return { ok: false, message: `[${toolName} error] directory "${rel}" does not exist.` };
163
+ }
164
+ return resolved;
165
+ }
@@ -0,0 +1,28 @@
1
+ export type RunOutcome = {
2
+ readonly argv: readonly string[];
3
+ readonly exitCode: number;
4
+ readonly stdout: string;
5
+ readonly stderr: string;
6
+ readonly stdoutTruncated: boolean;
7
+ readonly stderrTruncated: boolean;
8
+ readonly timedOut: boolean;
9
+ readonly durationMs: number;
10
+ /** Set when the command could not be started at all (e.g. no such file). */
11
+ readonly spawnError?: string;
12
+ /**
13
+ * Set when the child outlived even the SIGKILL grace and was left behind:
14
+ * the result is what had been captured by then, and `exitCode` is unknown.
15
+ */
16
+ readonly abandoned?: boolean;
17
+ };
18
+ export type RunOptions = {
19
+ readonly cwd: string;
20
+ readonly env: Record<string, string>;
21
+ readonly stdin?: string;
22
+ readonly timeoutMs: number;
23
+ readonly maxOutputChars: number;
24
+ readonly signal?: AbortSignal;
25
+ };
26
+ export declare function runOnce(argv: readonly string[], options: RunOptions): Promise<RunOutcome>;
27
+ /** A deadline-aware sleep: resolves early (and reports it) when aborted. */
28
+ export declare function sleep(ms: number, signal?: AbortSignal): Promise<void>;
package/dist/spawn.js ADDED
@@ -0,0 +1,156 @@
1
+ import { capText } from "./lib/format";
2
+ /**
3
+ * The one place this package starts a child process.
4
+ *
5
+ * Everything that runs a command — RunCommand, RunPipeline, Retry and the
6
+ * background family — goes through here so a single set of rules holds:
7
+ *
8
+ * - argv is passed to the OS as an ARRAY. There is no shell anywhere in
9
+ * this package, so a filename with a space or a `$(...)` in it is an
10
+ * argument and never a command.
11
+ * - stdin is `ignore` unless the caller supplied it, so a command that
12
+ * reads stdin gets EOF immediately instead of hanging on a terminal
13
+ * that is not there.
14
+ * - every run has a deadline: SIGTERM on expiry, SIGKILL after a short
15
+ * grace period for a child that ignores the first signal.
16
+ * - both streams are capped, so one chatty command cannot fill a context
17
+ * window.
18
+ */
19
+ /** Grace between the deadline's SIGTERM and the SIGKILL that follows it. */
20
+ const KILL_GRACE_MS = 2_000;
21
+ /**
22
+ * A process that forks a grandchild keeps the pipe's write end open after
23
+ * the child itself is gone, so reading to EOF can outlive the process. Give
24
+ * each stream a bounded drain window and move on with what arrived.
25
+ */
26
+ const DRAIN_GRACE_MS = 500;
27
+ /**
28
+ * After the SIGKILL, how long to keep waiting for the child to be reaped
29
+ * before returning without it.
30
+ *
31
+ * SIGKILL cannot be caught, but it cannot interrupt a process wedged in an
32
+ * uninterruptible kernel wait either — a stalled NFS read, a wayward driver.
33
+ * `proc.exited` then never resolves, and every other bound in this file is
34
+ * downstream of that await, so the whole call hangs with no deadline left to
35
+ * save it. This is the last bound: past it the tool reports what it has and
36
+ * lets go. (There is no portable way to put a process into that state, so
37
+ * this is the one guarantee here with no test behind it.)
38
+ */
39
+ const REAP_GRACE_MS = 1_000;
40
+ export async function runOnce(argv, options) {
41
+ const started = performance.now();
42
+ const elapsed = () => Math.round(performance.now() - started);
43
+ let proc;
44
+ try {
45
+ proc = Bun.spawn([...argv], {
46
+ cwd: options.cwd,
47
+ env: options.env,
48
+ stdin: options.stdin === undefined ? "ignore" : new TextEncoder().encode(options.stdin),
49
+ stdout: "pipe",
50
+ stderr: "pipe",
51
+ ...(options.signal !== undefined ? { signal: options.signal } : {}),
52
+ });
53
+ }
54
+ catch (err) {
55
+ return {
56
+ argv,
57
+ exitCode: -1,
58
+ stdout: "",
59
+ stderr: "",
60
+ stdoutTruncated: false,
61
+ stderrTruncated: false,
62
+ timedOut: false,
63
+ durationMs: elapsed(),
64
+ spawnError: err instanceof Error ? err.message : String(err),
65
+ };
66
+ }
67
+ let timedOut = false;
68
+ let reapGiveUp;
69
+ const term = setTimeout(() => {
70
+ timedOut = true;
71
+ try {
72
+ proc.kill("SIGTERM");
73
+ }
74
+ catch {
75
+ // Already gone between the timer firing and the signal.
76
+ }
77
+ }, options.timeoutMs);
78
+ const hardKill = setTimeout(() => {
79
+ try {
80
+ proc.kill("SIGKILL");
81
+ }
82
+ catch {
83
+ // Already gone.
84
+ }
85
+ }, options.timeoutMs + KILL_GRACE_MS);
86
+ try {
87
+ // The spread options above widen Bun's stdio inference, so name the
88
+ // piped shape explicitly rather than let the union leak outward.
89
+ // `.catch` is attached HERE, not after the race: the loser of the race
90
+ // below stays pending, and a stream torn down with the killed process
91
+ // would otherwise reject with nobody listening — an unhandled rejection
92
+ // that takes down the harness rather than the command that caused it.
93
+ const stdoutText = new Response(proc.stdout)
94
+ .text()
95
+ .catch(() => "");
96
+ const stderrText = new Response(proc.stderr)
97
+ .text()
98
+ .catch(() => "");
99
+ const abandonedMarker = Symbol("abandoned");
100
+ const reaped = await Promise.race([
101
+ proc.exited,
102
+ new Promise((resolve) => {
103
+ reapGiveUp = setTimeout(() => resolve(abandonedMarker), options.timeoutMs + KILL_GRACE_MS + REAP_GRACE_MS);
104
+ }),
105
+ ]);
106
+ const abandoned = reaped === abandonedMarker;
107
+ const exitCode = abandoned ? -1 : reaped;
108
+ const drainFallback = () => new Promise((resolve) => setTimeout(() => resolve(""), DRAIN_GRACE_MS));
109
+ const [rawOut, rawErr] = await Promise.all([
110
+ Promise.race([stdoutText, drainFallback()]),
111
+ Promise.race([stderrText, drainFallback()]),
112
+ ]);
113
+ const out = capText(rawOut, options.maxOutputChars);
114
+ const err = capText(rawErr, options.maxOutputChars);
115
+ return {
116
+ argv,
117
+ exitCode,
118
+ stdout: out.text,
119
+ stderr: err.text,
120
+ stdoutTruncated: out.truncated,
121
+ stderrTruncated: err.truncated,
122
+ timedOut: timedOut || abandoned,
123
+ durationMs: elapsed(),
124
+ ...(abandoned ? { abandoned: true } : {}),
125
+ };
126
+ }
127
+ finally {
128
+ clearTimeout(term);
129
+ clearTimeout(hardKill);
130
+ // Unconditional: left running, this one would hold the event loop open
131
+ // for the whole timeout on every command that finished in a millisecond.
132
+ if (reapGiveUp !== undefined)
133
+ clearTimeout(reapGiveUp);
134
+ }
135
+ }
136
+ /** A deadline-aware sleep: resolves early (and reports it) when aborted. */
137
+ export function sleep(ms, signal) {
138
+ if (ms <= 0)
139
+ return Promise.resolve();
140
+ // An ALREADY-aborted signal never fires `abort` again, so without this an
141
+ // abort that landed a moment before the call would still cost the full
142
+ // sleep — the one wait in the package that could outlive its own deadline.
143
+ if (signal?.aborted === true)
144
+ return Promise.resolve();
145
+ return new Promise((resolve) => {
146
+ const timer = setTimeout(() => {
147
+ signal?.removeEventListener("abort", onAbort);
148
+ resolve();
149
+ }, ms);
150
+ function onAbort() {
151
+ clearTimeout(timer);
152
+ resolve();
153
+ }
154
+ signal?.addEventListener("abort", onAbort, { once: true });
155
+ });
156
+ }
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@crewhaus/tool-proc",
3
+ "version": "0.7.0",
4
+ "type": "module",
5
+ "description": "Deterministic process tools: background processes, log tailing, port and file waits, and retry with backoff",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "scripts": {
15
+ "test": "bun test src"
16
+ },
17
+ "dependencies": {
18
+ "@crewhaus/errors": "0.7.0",
19
+ "@crewhaus/tool-builder": "0.7.0",
20
+ "@crewhaus/tool-catalog": "0.7.0",
21
+ "zod": "^3.23.8"
22
+ },
23
+ "devDependencies": {
24
+ "@crewhaus/tool-executor": "workspace:*"
25
+ },
26
+ "license": "Apache-2.0",
27
+ "author": {
28
+ "name": "Max Meier",
29
+ "email": "max@crewhaus.ai",
30
+ "url": "https://crewhaus.ai"
31
+ },
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/crewhaus/factory.git",
35
+ "directory": "packages/tool-proc"
36
+ },
37
+ "homepage": "https://github.com/crewhaus/factory/tree/main/packages/tool-proc#readme",
38
+ "bugs": {
39
+ "url": "https://github.com/crewhaus/factory/issues"
40
+ },
41
+ "publishConfig": {
42
+ "access": "public"
43
+ },
44
+ "files": ["dist", "README.md", "LICENSE", "NOTICE"]
45
+ }