@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.
- package/dist/fixtures.d.ts +84 -0
- package/dist/fixtures.js +130 -0
- package/dist/host.d.ts +99 -0
- package/dist/host.js +174 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +1031 -0
- package/dist/lib/clipboard.d.ts +122 -0
- package/dist/lib/clipboard.js +230 -0
- package/dist/lib/escape.d.ts +220 -0
- package/dist/lib/escape.js +338 -0
- package/dist/lib/fsseam.d.ts +23 -0
- package/dist/lib/fsseam.js +73 -0
- package/dist/lib/notify.d.ts +110 -0
- package/dist/lib/notify.js +198 -0
- package/dist/lib/open.d.ts +149 -0
- package/dist/lib/open.js +421 -0
- package/dist/lib/outcome.d.ts +94 -0
- package/dist/lib/outcome.js +100 -0
- package/dist/lib/power.d.ts +150 -0
- package/dist/lib/power.js +224 -0
- package/dist/lib/presence.d.ts +118 -0
- package/dist/lib/presence.js +249 -0
- package/dist/lib/print.d.ts +120 -0
- package/dist/lib/print.js +188 -0
- package/dist/lib/windows.d.ts +100 -0
- package/dist/lib/windows.js +258 -0
- package/dist/paths.d.ts +42 -0
- package/dist/paths.js +158 -0
- package/dist/run.d.ts +187 -0
- package/dist/run.js +483 -0
- package/package.json +46 -0
|
@@ -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
|
+
}
|
package/dist/paths.d.ts
ADDED
|
@@ -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;
|