@crewhaus/tool-desktop 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,258 @@
1
+ /**
2
+ * `WindowList` — enumerating what is on the operator's screen, and the one
3
+ * answer this tool must never give.
4
+ *
5
+ * AN EMPTY LIST IS A CLAIM. It says "this desktop has no windows open", and a
6
+ * workflow that reads it will conclude the operator is looking at nothing.
7
+ * Every failure mode here produces that same empty list unless it is caught:
8
+ * a macOS box that has not granted Accessibility, a Wayland session where
9
+ * enumeration is not permitted at all, a Linux box with no `wmctrl`
10
+ * installed, a truncated stream. So none of them return a list. They return
11
+ * `unavailable` with the reason, and the reason names the grant to give or
12
+ * the package to install.
13
+ *
14
+ * ── THE macOS BACKEND, AND WHY IT IS THE SLOW ONE ─────────────────────────
15
+ *
16
+ * There are two, and the survey sketch was right that it is a real fork:
17
+ *
18
+ * CGWindowListCopyWindowInfo one call, stable CGWindowIDs, bounds, layer
19
+ * and on-screen state. It is a C API with no
20
+ * JS binding, so using it means shipping and
21
+ * code-signing a per-arch native helper.
22
+ * osascript / System Events no build step, an order of magnitude slower,
23
+ * no CGWindowID, and it needs the
24
+ * Accessibility grant.
25
+ *
26
+ * House rule 8 settles it: no new runtime dependency, and a signed native
27
+ * helper is a large one. So this is the System Events backend, and on a
28
+ * machine that has not granted Accessibility the honest answer is
29
+ * `unavailable` naming that grant — NOT an empty list, and not a list with
30
+ * blank titles. (The blank-title failure the sketch describes belongs to the
31
+ * CGWindowList backend, where a missing Screen Recording grant strips titles
32
+ * while still returning every window. That trap is not reachable from here;
33
+ * the System Events path fails loudly instead, which is the better failure.)
34
+ *
35
+ * The script is a frozen constant with arity 0 — no caller value reaches it —
36
+ * and it reports each field separately, so a window whose position cannot be
37
+ * read is a window with unknown bounds rather than a window at 0,0.
38
+ */
39
+ import { linuxDisplayEnv, linuxSession } from "../host";
40
+ import { osascriptArgv, osascriptPermissionReason, powershellArgv, registerAppleScript, registerPowerShellScript, } from "./escape";
41
+ import { firstLine, unavailable, unsupportedPlatform } from "./outcome";
42
+ /**
43
+ * The field and record separators, ASCII 31 and 30.
44
+ *
45
+ * They are built INSIDE each script (`character id 31`, `[char]31`) rather
46
+ * than written into this file, so no control byte travels through argv and no
47
+ * source file in this package holds one (house rule 13's neighbourhood). They
48
+ * are also the two characters a window title cannot plausibly contain, which
49
+ * a printable sentinel like `<|f|>` cannot promise.
50
+ */
51
+ const FS = "\u001F";
52
+ const RS = "\u001E";
53
+ export const WINDOW_LIST_SCRIPT = registerAppleScript({
54
+ name: "window-list",
55
+ lines: [
56
+ "set fs to (character id 31)",
57
+ "set rs to (character id 30)",
58
+ 'set out to ""',
59
+ 'tell application "System Events"',
60
+ "repeat with p in (application processes whose visible is true)",
61
+ "set pname to name of p",
62
+ "set isFront to frontmost of p",
63
+ "repeat with w in (windows of p)",
64
+ 'set wname to ""',
65
+ "try",
66
+ "set wname to name of w",
67
+ "end try",
68
+ 'set wx to "?"',
69
+ 'set wy to "?"',
70
+ 'set ww to "?"',
71
+ 'set wh to "?"',
72
+ "try",
73
+ "set {px, py} to position of w",
74
+ "set {sw, sh} to size of w",
75
+ "set wx to px as string",
76
+ "set wy to py as string",
77
+ "set ww to sw as string",
78
+ "set wh to sh as string",
79
+ "end try",
80
+ 'set wmin to "?"',
81
+ "try",
82
+ 'set wmin to (value of attribute "AXMinimized" of w) as string',
83
+ "end try",
84
+ "set out to out & pname & fs & wname & fs & wx & fs & wy & fs & ww & fs & wh & fs & (isFront as string) & fs & wmin & rs",
85
+ "end repeat",
86
+ "end repeat",
87
+ "end tell",
88
+ "return out",
89
+ ],
90
+ arity: 0,
91
+ });
92
+ export const WINDOWS_WINDOW_LIST = registerPowerShellScript({
93
+ name: "window-list",
94
+ // DOCUMENTED, NOT RECORDED. `MainWindowHandle`/`MainWindowTitle` give one
95
+ // window per process and no geometry; real bounds need EnumWindows +
96
+ // GetWindowRect through a P/Invoke shim, which is a compile step this
97
+ // package will not take (house rule 8). So the Windows rows report bounds
98
+ // as UNKNOWN rather than as zeros, and say why.
99
+ script: "$fs = [char]31; $rs = [char]30; Get-Process | Where-Object { $_.MainWindowHandle -ne 0 } | ForEach-Object { [Console]::Out.Write($_.ProcessName + $fs + $_.MainWindowTitle + $fs + '?' + $fs + '?' + $fs + '?' + $fs + '?' + $fs + 'false' + $fs + '?' + $rs) }",
100
+ reads: [],
101
+ });
102
+ export function planWindowList(platform, env) {
103
+ switch (platform) {
104
+ case "darwin":
105
+ return {
106
+ ok: true,
107
+ backend: "osascript System Events",
108
+ request: { argv: osascriptArgv(WINDOW_LIST_SCRIPT) },
109
+ };
110
+ case "linux": {
111
+ const session = linuxSession(env);
112
+ if (session.kind === "none" || session.kind === "wayland-unreachable") {
113
+ return { ok: false, unavailable: unavailable("session", session.reason) };
114
+ }
115
+ if (session.kind === "wayland") {
116
+ // Not a missing program and not an empty desktop. Wayland has no
117
+ // protocol by which one client may enumerate another's windows; the
118
+ // compositor-specific extensions that do (wlr-foreign-toplevel, the
119
+ // GNOME Shell eval endpoint) are per-compositor and, in GNOME's case,
120
+ // removed. There is nothing to install that would fix this, and the
121
+ // reason says so rather than suggesting a package.
122
+ return {
123
+ ok: false,
124
+ unavailable: unavailable("session", "this is a Wayland session, and Wayland deliberately provides no protocol for one client to enumerate another client's windows — there is no program to install that would change this; an X11 session or a compositor-specific extension is the only way"),
125
+ };
126
+ }
127
+ return {
128
+ ok: true,
129
+ backend: "wmctrl",
130
+ // `-l` list, `-G` geometry, `-p` pid, `-x` WM_CLASS. No caller value.
131
+ request: { argv: ["wmctrl", "-l", "-G", "-p", "-x"], env: linuxDisplayEnv(env) },
132
+ };
133
+ }
134
+ case "win32":
135
+ return {
136
+ ok: true,
137
+ backend: "powershell Get-Process",
138
+ request: { argv: powershellArgv(WINDOWS_WINDOW_LIST, {}).argv },
139
+ };
140
+ default:
141
+ return { ok: false, unavailable: unsupportedPlatform(platform, "window") };
142
+ }
143
+ }
144
+ /** `"?"` is the scripts' unknown marker; everything else must parse. */
145
+ function num(value) {
146
+ if (value === undefined || value === "?" || value.trim() === "")
147
+ return null;
148
+ const parsed = Number(value);
149
+ return Number.isFinite(parsed) ? parsed : null;
150
+ }
151
+ function bool(value) {
152
+ if (value === undefined || value === "?")
153
+ return null;
154
+ const lowered = value.trim().toLowerCase();
155
+ if (lowered === "true" || lowered === "yes")
156
+ return true;
157
+ if (lowered === "false" || lowered === "no")
158
+ return false;
159
+ return null;
160
+ }
161
+ /** Parse the separator-delimited output the macOS and Windows scripts emit. */
162
+ export function parseDelimitedWindows(stdout) {
163
+ const out = [];
164
+ for (const record of stdout.split(RS)) {
165
+ if (record.trim() === "")
166
+ continue;
167
+ const f = record.split(FS);
168
+ if (f.length < 8)
169
+ continue;
170
+ out.push({
171
+ app: (f[0] ?? "").trim(),
172
+ // An empty title is a real state (a palette, a sheet), so it stays an
173
+ // empty string; only a field the script could not read becomes null.
174
+ title: f[1] ?? null,
175
+ x: num(f[2]),
176
+ y: num(f[3]),
177
+ width: num(f[4]),
178
+ height: num(f[5]),
179
+ focused: bool(f[6]),
180
+ minimized: bool(f[7]),
181
+ });
182
+ }
183
+ return out;
184
+ }
185
+ /**
186
+ * Parse `wmctrl -l -G -p -x`.
187
+ *
188
+ * Columns: id, desktop, pid, x, y, width, height, WM_CLASS, client machine,
189
+ * then the title — which contains spaces and is therefore everything left.
190
+ * A desktop of `-1` means "sticky / on all desktops", not an error.
191
+ */
192
+ export function parseWmctrl(stdout) {
193
+ const out = [];
194
+ for (const raw of stdout.split("\n")) {
195
+ const line = raw.trimEnd();
196
+ if (line.trim() === "")
197
+ continue;
198
+ const parts = line.trim().split(/\s+/);
199
+ if (parts.length < 9)
200
+ continue;
201
+ const [id, , pid, x, y, w, h, wmClass] = parts;
202
+ // Nine fixed columns, then the title. The cursor walks the ORIGINAL line
203
+ // rather than re-joining the split, so a title with runs of spaces, or
204
+ // one that repeats a column's text, survives intact.
205
+ let cursor = 0;
206
+ for (let i = 0; i < 9; i += 1) {
207
+ const field = parts[i];
208
+ const next = line.indexOf(field, cursor);
209
+ if (next < 0)
210
+ break;
211
+ cursor = next + field.length;
212
+ }
213
+ const title = line.slice(cursor).trim();
214
+ const parsedPid = num(pid);
215
+ out.push({
216
+ app: (wmClass ?? "").split(".").pop() ?? "",
217
+ title,
218
+ x: num(x),
219
+ y: num(y),
220
+ width: num(w),
221
+ height: num(h),
222
+ // wmctrl does not report focus or minimisation. Unknown, not false —
223
+ // reporting `focused: false` for every window says "nothing has focus",
224
+ // which is never true of a running X session.
225
+ focused: null,
226
+ minimized: null,
227
+ ...(id === undefined ? {} : { id }),
228
+ ...(parsedPid === null ? {} : { pid: parsedPid }),
229
+ });
230
+ }
231
+ return out;
232
+ }
233
+ export function classifyWindowList(backend, result) {
234
+ if (result.refused === true)
235
+ return { outcome: "failed", reason: result.stderr };
236
+ if (result.missing) {
237
+ return unavailable("program", `the window enumerator for this platform is not installed on this host (${backend}; on X11 that is wmctrl)`, backend);
238
+ }
239
+ if (result.timedOut) {
240
+ return unavailable("unknown", "the window enumerator did not finish within its timeout and was killed — the window list could not be read, which is not the same as there being no windows", backend);
241
+ }
242
+ // Checked BEFORE the exit code: System Events reports a missing grant as an
243
+ // ordinary script error, and treating it as one would hand a caller "the
244
+ // script failed" when the actionable fact is "grant Accessibility".
245
+ const permission = osascriptPermissionReason(result.stderr);
246
+ if (permission !== undefined) {
247
+ return unavailable("permission", permission, backend);
248
+ }
249
+ if (result.code !== 0) {
250
+ const detail = firstLine(result.stderr);
251
+ return {
252
+ outcome: "failed",
253
+ reason: detail === "" ? `${backend} exited ${result.code}` : `${backend}: ${detail}`,
254
+ };
255
+ }
256
+ const windows = backend === "wmctrl" ? parseWmctrl(result.stdout) : parseDelimitedWindows(result.stdout);
257
+ return { outcome: "listed", windows, truncated: result.stdoutTruncated === true };
258
+ }
@@ -0,0 +1,42 @@
1
+ import { CrewhausError } from "@crewhaus/errors";
2
+ /** Raised when a caller-supplied path resolves outside the workspace root. */
3
+ export declare class ToolPermissionError extends CrewhausError {
4
+ readonly name = "ToolPermissionError";
5
+ readonly toolName: string;
6
+ readonly path: string;
7
+ constructor(toolName: string, attemptedPath: string);
8
+ }
9
+ /**
10
+ * A validated path, in both forms the tools need.
11
+ *
12
+ * `real` has every in-workspace symlink already resolved and is what I/O
13
+ * should use — operating on it closes most of the check-to-use window.
14
+ * `abs` is the lexical resolution, which is what a tool must use when the
15
+ * question is about the link ITSELF (`Stat` reporting a symlink target,
16
+ * `RemovePath` deleting a link rather than its destination).
17
+ */
18
+ export type SafePath = {
19
+ readonly abs: string;
20
+ readonly real: string;
21
+ /** The path as the caller wrote it, for echoing back in results. */
22
+ readonly given: string;
23
+ /** Slash-separated path relative to the workspace root. */
24
+ readonly rel: string;
25
+ };
26
+ /** The workspace root, resolved. Every path is contained within it. */
27
+ export declare function workspaceRoot(): string;
28
+ export declare function resolveSafe(toolName: string, rel: string, root?: string): SafePath;
29
+ /** A path relative to the workspace root, with `/` separators on every OS. */
30
+ export declare function toPosix(p: string): string;
31
+ /**
32
+ * True when `candidate` is `parent` or lives underneath it. Both must already
33
+ * be absolute. Used to keep an archive entry, or a symlink inside an
34
+ * extracted tree, from reaching outside its destination.
35
+ */
36
+ export declare function isInside(parent: string, candidate: string): boolean;
37
+ /**
38
+ * Reject an archive member name that would escape its destination: an
39
+ * absolute path, a Windows drive prefix, or any `..` segment. Checked on the
40
+ * NAME, before extraction, because after extraction the damage is done.
41
+ */
42
+ export declare function archiveEntryEscapes(name: string): boolean;
package/dist/paths.js ADDED
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Path containment — the gate every caller-supplied path in this package
3
+ * passes through before any syscall touches it.
4
+ *
5
+ * The approach is the one `@crewhaus/tool-fs` uses, and for the same reasons:
6
+ * a lexical check alone is fooled by a symlink that lives inside the
7
+ * workspace and points outside it (CWE-59), so the real path is checked too.
8
+ * The workspace root is `process.cwd()`, matching tool-fs, so a harness that
9
+ * trusts one tool's boundary gets the same boundary here.
10
+ */
11
+ import { lstatSync, readlinkSync, realpathSync } from "node:fs";
12
+ import * as path from "node:path";
13
+ import { CrewhausError } from "@crewhaus/errors";
14
+ /** Raised when a caller-supplied path resolves outside the workspace root. */
15
+ export class ToolPermissionError extends CrewhausError {
16
+ name = "ToolPermissionError";
17
+ toolName;
18
+ path;
19
+ constructor(toolName, attemptedPath) {
20
+ super("tool", `tool "${toolName}" rejected path "${attemptedPath}": resolved location escapes the workspace root`);
21
+ this.toolName = toolName;
22
+ this.path = attemptedPath;
23
+ }
24
+ }
25
+ /** The workspace root, resolved. Every path is contained within it. */
26
+ export function workspaceRoot() {
27
+ return path.resolve(process.cwd());
28
+ }
29
+ /**
30
+ * Resolve `rel` inside the workspace, or throw `ToolPermissionError`.
31
+ *
32
+ * The leaf is allowed not to exist — every tool here that creates something
33
+ * needs to validate a destination before it is made — so the deepest
34
+ * EXISTING ancestor is realpath'd and the missing tail re-appended. Any
35
+ * failure other than that walk fails closed.
36
+ */
37
+ /**
38
+ * True when the NAME exists, whether or not it leads anywhere.
39
+ *
40
+ * `existsSync` FOLLOWS symlinks, so it answers false for a link whose target
41
+ * is missing — and that is exactly the case that matters: a dangling link is
42
+ * still a door, because `open(…, "w")` through `evil -> /outside/x` CREATES
43
+ * `/outside/x`. Probing with `lstat` keeps the link's name in the part of the
44
+ * path that gets resolved, rather than in the "does not exist yet" tail that
45
+ * is re-appended to the root verbatim and so passes the containment check.
46
+ */
47
+ function nameExists(p) {
48
+ try {
49
+ lstatSync(p);
50
+ return true;
51
+ }
52
+ catch {
53
+ return false;
54
+ }
55
+ }
56
+ /**
57
+ * Where `target` would actually land, with every symlink on the way already
58
+ * followed — including one whose own target does not exist yet.
59
+ *
60
+ * `realpathSync` gives up with ENOENT on a dangling link, which would leave
61
+ * that link unresolved and let it stand in for a plain missing file. So the
62
+ * deepest ancestor that exists as a NAME is resolved, a dangling one is
63
+ * followed a hop by hand, and the components that do not exist are appended.
64
+ * The result is the path an `open` would create, which is the only path worth
65
+ * checking containment against.
66
+ */
67
+ function resolveLocation(target, depth = 0) {
68
+ if (depth > 40)
69
+ throw new Error(`symlink chain at "${target}" is too long to resolve`);
70
+ let probe = target;
71
+ const tail = [];
72
+ while (!nameExists(probe)) {
73
+ tail.unshift(path.basename(probe));
74
+ const parent = path.dirname(probe);
75
+ if (parent === probe)
76
+ break; // reached the filesystem root
77
+ probe = parent;
78
+ }
79
+ let probeReal;
80
+ try {
81
+ probeReal = realpathSync(probe);
82
+ }
83
+ catch (err) {
84
+ if (err.code !== "ENOENT")
85
+ throw err;
86
+ // The name is there but `realpath` cannot finish it: a symlink with a
87
+ // missing target. `readlinkSync` throws EINVAL on anything else, which
88
+ // fails closed.
89
+ const link = readlinkSync(probe);
90
+ // A RELATIVE target resolves against the directory that actually CONTAINS
91
+ // the link, which is not the link's lexical parent when that parent is
92
+ // itself reached through a symlink. `<root>/dirlink/x -> ../y` with
93
+ // `dirlink` pointing out of the root really lands at `<elsewhere>/y`, but
94
+ // measured from the lexical parent it reads as `<root>/y` — an in-root
95
+ // path the caller's path does not lead to. So the parent is made real
96
+ // first. An absolute target ignores the base.
97
+ const base = realpathSync(path.dirname(probe));
98
+ probeReal = resolveLocation(path.resolve(base, link), depth + 1);
99
+ }
100
+ return tail.length > 0 ? path.join(probeReal, ...tail) : probeReal;
101
+ }
102
+ export function resolveSafe(toolName, rel, root = workspaceRoot()) {
103
+ const rootResolved = path.resolve(root);
104
+ const abs = path.resolve(rootResolved, rel);
105
+ // 1) Lexical containment — rejects `..` and absolute escapes. The trailing
106
+ // separator avoids the `/root` vs `/root-sibling` prefix pitfall.
107
+ if (abs !== rootResolved && !abs.startsWith(`${rootResolved}${path.sep}`)) {
108
+ throw new ToolPermissionError(toolName, rel);
109
+ }
110
+ let real;
111
+ try {
112
+ const rootReal = realpathSync(rootResolved);
113
+ real = resolveLocation(abs);
114
+ // 2) Symlink-aware containment. The real path must land inside the real
115
+ // root, so an in-workspace symlink pointing at /etc is refused here.
116
+ if (real !== rootReal && !real.startsWith(`${rootReal}${path.sep}`)) {
117
+ throw new ToolPermissionError(toolName, rel);
118
+ }
119
+ }
120
+ catch (err) {
121
+ if (err instanceof ToolPermissionError)
122
+ throw err;
123
+ throw new ToolPermissionError(toolName, rel);
124
+ }
125
+ return {
126
+ abs,
127
+ real,
128
+ given: rel,
129
+ rel: toPosix(path.relative(rootResolved, abs)),
130
+ };
131
+ }
132
+ /** A path relative to the workspace root, with `/` separators on every OS. */
133
+ export function toPosix(p) {
134
+ return path.sep === "/" ? p : p.split(path.sep).join("/");
135
+ }
136
+ /**
137
+ * True when `candidate` is `parent` or lives underneath it. Both must already
138
+ * be absolute. Used to keep an archive entry, or a symlink inside an
139
+ * extracted tree, from reaching outside its destination.
140
+ */
141
+ export function isInside(parent, candidate) {
142
+ const p = path.resolve(parent);
143
+ const c = path.resolve(candidate);
144
+ return c === p || c.startsWith(`${p}${path.sep}`);
145
+ }
146
+ /**
147
+ * Reject an archive member name that would escape its destination: an
148
+ * absolute path, a Windows drive prefix, or any `..` segment. Checked on the
149
+ * NAME, before extraction, because after extraction the damage is done.
150
+ */
151
+ export function archiveEntryEscapes(name) {
152
+ const normalized = name.replace(/\\/g, "/");
153
+ if (normalized.startsWith("/"))
154
+ return true;
155
+ if (/^[A-Za-z]:/.test(normalized))
156
+ return true;
157
+ return normalized.split("/").some((segment) => segment === "..");
158
+ }
package/dist/run.d.ts ADDED
@@ -0,0 +1,187 @@
1
+ /**
2
+ * The one place this package starts a child process, and the seam every test
3
+ * drives instead of the machine.
4
+ *
5
+ * The conventions are `@crewhaus/tool-proc`'s (`src/spawn.ts`) and
6
+ * `@crewhaus/tool-pkgmgr`'s (`src/run.ts`), copied rather than re-invented:
7
+ *
8
+ * - ARGV IS AN ARRAY. There is no shell in this package: no `sh -c`, no
9
+ * `cmd /c`, no interpolated command line. A caller's value is an ELEMENT
10
+ * of an argv array or it does not reach a command at all. `assertArgv`
11
+ * refuses anything that would make that untrue BEFORE a process exists.
12
+ * - EVERY RUN HAS A DEADLINE. SIGTERM at the deadline, SIGKILL after a
13
+ * grace period, and a bounded drain. The drain bound is load-bearing
14
+ * here, not defensive: `xclip -i` and `wl-copy` DELIBERATELY fork a
15
+ * resident holder (under X11 and Wayland the writing process owns the
16
+ * selection, so it must outlive the tool call), and that holder inherits
17
+ * the stdout pipe. Reading to EOF would block until the operator copies
18
+ * something else — possibly for hours.
19
+ * - THE ENVIRONMENT IS PINNED. `PATH` plus a C locale, plus whatever the
20
+ * backend explicitly adds. Nothing else is inherited, so the harness's
21
+ * secrets are not in `pbpaste`'s environment.
22
+ *
23
+ * TWO DELIBERATE EXCEPTIONS, BOTH SECURITY MEASURES RATHER THAN CONVENIENCES.
24
+ *
25
+ * 1. `RunRequest.env` exists so a value can reach a PowerShell script WITHOUT
26
+ * being in the script's source. `powershell.exe -Command <script> a b c`
27
+ * CONCATENATES the trailing arguments onto the command string — argv is
28
+ * not a safe channel there the way it is for `notify-send`. So the Windows
29
+ * backends put caller values in the child's environment and the script
30
+ * reads `$env:CREWHAUS_*`. See ./lib/escape.ts, where that decision lives.
31
+ * 2. `RunRequest.stdin` is the channel for a PAYLOAD. `ClipboardWrite` pipes
32
+ * its text in rather than passing it as an argument, because argv is
33
+ * world-readable through `ps` on both macOS and Linux and a clipboard
34
+ * payload is exactly the kind of thing an operator would not want in a
35
+ * process listing.
36
+ *
37
+ * ── WHY THE "REAL HOST" GATE LIVES HERE AND NOT IN host.ts ────────────────
38
+ *
39
+ * `host.ts` imports from this file; this file imports from nothing in the
40
+ * package. The gate that decides whether an un-injected seam may consult the
41
+ * real machine is read by BOTH (`hostPlatform()` and `sessionEnv()` need it
42
+ * as much as `runHostCommand` does), so it is defined in the file at the
43
+ * bottom of the dependency order. An earlier draft had it in `host.ts` and
44
+ * `run.ts` importing it back, which is an ESM cycle: it happens to work while
45
+ * every use is inside a function body, and breaks the first time either file
46
+ * grows a module-scope constant that reads the other.
47
+ */
48
+ /** `PATH` for a child, with a floor so a stripped harness env still spawns. */
49
+ export declare const FALLBACK_PATH = "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin";
50
+ export type RunRequest = {
51
+ readonly argv: readonly string[];
52
+ readonly timeoutMs: number;
53
+ /** Piped to the child. `undefined` ⇒ stdin is closed, so a program that
54
+ * would read a terminal gets EOF instead of blocking to its deadline. */
55
+ readonly stdin?: string;
56
+ /** Extra environment for the child — see the header. */
57
+ readonly env?: Readonly<Record<string, string>>;
58
+ /** Cap on each captured stream; hitting it is reported, never silent. */
59
+ readonly maxOutputChars?: number;
60
+ readonly signal?: AbortSignal;
61
+ };
62
+ export type RunResult = {
63
+ readonly code: number;
64
+ readonly stdout: string;
65
+ readonly stderr: string;
66
+ readonly timedOut: boolean;
67
+ /** True when the program itself is not installed (ENOENT on spawn). */
68
+ readonly missing: boolean;
69
+ /**
70
+ * The stream was cut at the cap. It matters: a truncated window listing
71
+ * looks exactly like a complete short one, and a caller reads the missing
72
+ * tail as "there is nothing else".
73
+ */
74
+ readonly stdoutTruncated?: boolean;
75
+ readonly stderrTruncated?: boolean;
76
+ /** Set when no runner was installed and the real host was not allowed. */
77
+ readonly refused?: boolean;
78
+ };
79
+ /**
80
+ * A process this package started and deliberately did not wait for.
81
+ *
82
+ * Only `PowerAssertion` uses it: a sleep inhibitor IS the lifetime of a child
83
+ * process, so the tool has to return while the child keeps running.
84
+ */
85
+ export type DetachResult = {
86
+ readonly ok: true;
87
+ readonly pid: number;
88
+ } | {
89
+ readonly ok: false;
90
+ readonly reason: string;
91
+ readonly missing: boolean;
92
+ };
93
+ export type Runner = (request: RunRequest) => Promise<RunResult>;
94
+ export type Detacher = (request: RunRequest) => Promise<DetachResult>;
95
+ export declare const DEFAULT_COMMAND_TIMEOUT_MS = 10000;
96
+ export declare const MAX_COMMAND_TIMEOUT_MS = 120000;
97
+ /**
98
+ * Per-stream cap.
99
+ *
100
+ * A clipboard can legitimately hold a whole file, and `ClipboardRead` applies
101
+ * its OWN, much smaller cap on top of this one — this is only the ceiling
102
+ * past which the process pipe stops being drained.
103
+ */
104
+ export declare const DEFAULT_MAX_OUTPUT_CHARS = 4000000;
105
+ /**
106
+ * Opt in to (or out of) the real machine for an un-injected seam.
107
+ *
108
+ * Exactly one test in this package calls it with `true`, and restores it in
109
+ * the same `finally`. Everything else drives a fixture.
110
+ */
111
+ export declare function _allowRealHost(allowed: boolean): void;
112
+ export declare function _realHostAllowed(): boolean;
113
+ /**
114
+ * True when a runner is installed.
115
+ *
116
+ * Read by `host.ts` to decide the platform default: a test that has installed
117
+ * a recorded runner has already declared "I am not talking to this host", so
118
+ * if it then forgets to declare which host it is PRETENDING to be it gets a
119
+ * platform no backend serves — identically on macOS and on Linux CI. That
120
+ * exact bug (a test inheriting the ambient `process.platform`) cost this
121
+ * project a CI round in tool-hostfs.
122
+ */
123
+ export declare function _runnerInstalled(): boolean;
124
+ export declare function assertArgv(argv: readonly string[]): string | undefined;
125
+ /**
126
+ * A child's environment: `PATH` so the program can be found, a C locale so a
127
+ * parsed message is the same string on every machine, and the backend's
128
+ * explicit additions (a display, a D-Bus address, a `CREWHAUS_*` payload).
129
+ *
130
+ * Nothing else. `process.env` is NOT copied in: `pbpaste` has no business
131
+ * seeing an API key, and neither does a PowerShell one-liner. The session
132
+ * variables a Linux backend needs arrive through `extra`, put there by a
133
+ * backend reading the INJECTED `sessionEnv()` — so a test controls them like
134
+ * everything else rather than inheriting whether the maintainer happened to
135
+ * be in an SSH session.
136
+ */
137
+ export declare function childEnv(extra?: Readonly<Record<string, string>>): Record<string, string>;
138
+ /** Cut a captured stream at the cap, saying whether it was cut. */
139
+ export declare function capText(text: string, max: number): {
140
+ text: string;
141
+ truncated: boolean;
142
+ };
143
+ /**
144
+ * The refusal an un-injected runner produces under test.
145
+ *
146
+ * Shaped like a real failure so every caller's existing "the command did not
147
+ * work" branch handles it, and worded so the test author sees immediately
148
+ * what they forgot.
149
+ */
150
+ export declare const NO_RUNNER_REASON = "no runner is installed and this process is not allowed to touch the real host (see _setRunner / _allowRealHost)";
151
+ /**
152
+ * Turn a `Bun.spawn` throw into the right kind of "it did not run".
153
+ *
154
+ * ADVERSARIAL REVIEW: the first version set `missing: true` for EVERY spawn
155
+ * error, so a program that is installed but not executable by this user
156
+ * (`EACCES`), a name that resolves to a directory (`EISDIR`/`ENOTDIR`) or an
157
+ * argv over the kernel's limit (`E2BIG`) all came back as "the program is not
158
+ * installed on this host" — and `WindowList` then told an operator to install
159
+ * the `wmctrl` they already have. That is rule 6 one level below the tools:
160
+ * "could not start it" is not "it is not there".
161
+ *
162
+ * Only the two errnos that genuinely mean the NAME does not resolve set
163
+ * `missing`. Everything else is a failure carrying the errno, so the caller's
164
+ * reason names what actually happened.
165
+ *
166
+ * Exported because it is the only part of the real-spawn path a test can
167
+ * reach without spawning (house rule 4 allows exactly one real-host test, and
168
+ * it spawns nothing).
169
+ */
170
+ export declare function classifySpawnError(err: unknown): RunResult;
171
+ export declare function runHostCommand(request: RunRequest): Promise<RunResult>;
172
+ /**
173
+ * Start a child and return its pid without waiting for it.
174
+ *
175
+ * `stdio` is all `ignore`: a detached holder that inherited this process's
176
+ * pipes would keep them open after the harness exits, and a holder whose
177
+ * stdout nobody reads eventually blocks on a full pipe — an inhibitor that
178
+ * stops inhibiting halfway through the job it was held for.
179
+ */
180
+ export declare function detachHostCommand(request: RunRequest): Promise<DetachResult>;
181
+ /**
182
+ * Test seam. Pass a function to answer every command from recorded output;
183
+ * pass `undefined` to go back to the (refused, under test) real spawn.
184
+ */
185
+ export declare function _setRunner(runner: Runner | undefined): void;
186
+ export declare function _setDetacher(detacher: Detacher | undefined): void;
187
+ export declare function _resetRunSeams(): void;