@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
package/dist/run.js
ADDED
|
@@ -0,0 +1,483 @@
|
|
|
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 const FALLBACK_PATH = "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin";
|
|
50
|
+
/** Grace between the deadline's SIGTERM and the SIGKILL that follows it. */
|
|
51
|
+
const KILL_GRACE_MS = 2_000;
|
|
52
|
+
/** A child that forked a holder keeps the pipe open; drain this long and move on. */
|
|
53
|
+
const DRAIN_GRACE_MS = 500;
|
|
54
|
+
export const DEFAULT_COMMAND_TIMEOUT_MS = 10_000;
|
|
55
|
+
export const MAX_COMMAND_TIMEOUT_MS = 120_000;
|
|
56
|
+
/**
|
|
57
|
+
* Per-stream cap.
|
|
58
|
+
*
|
|
59
|
+
* A clipboard can legitimately hold a whole file, and `ClipboardRead` applies
|
|
60
|
+
* its OWN, much smaller cap on top of this one — this is only the ceiling
|
|
61
|
+
* past which the process pipe stops being drained.
|
|
62
|
+
*/
|
|
63
|
+
export const DEFAULT_MAX_OUTPUT_CHARS = 4_000_000;
|
|
64
|
+
let runnerOverride;
|
|
65
|
+
let detacherOverride;
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// the real-host gate
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
/**
|
|
70
|
+
* Whether an un-injected seam may consult the real machine.
|
|
71
|
+
*
|
|
72
|
+
* False under `bun test`. Bun sets `NODE_ENV=test` for its own runner
|
|
73
|
+
* (verified on bun 1.3.14 by printing it from inside a test), which is the
|
|
74
|
+
* only signal available without a shared setup file — and a setup file is
|
|
75
|
+
* exactly the thing a new test file forgets to import.
|
|
76
|
+
*
|
|
77
|
+
* `@crewhaus/tool-pkgmgr` deliberately has NO equivalent: it ties its hostile
|
|
78
|
+
* default to the runner seam alone and says so. This package is stricter on
|
|
79
|
+
* purpose, because the failure it is guarding against is worse here. A
|
|
80
|
+
* pkgmgr test that forgets `_setRunner` runs `brew list`; a test in THIS
|
|
81
|
+
* package that forgets it pops a real toast on the maintainer's screen, sends
|
|
82
|
+
* a real job to a real printer, reads whatever the operator last copied into
|
|
83
|
+
* a transcript, or parks a `caffeinate` on their laptop. The runner-seam tie
|
|
84
|
+
* is still here and is still the primary guard (see `hostPlatform`); this is
|
|
85
|
+
* the belt under it, for the case where the seam was never installed at all.
|
|
86
|
+
*
|
|
87
|
+
* The residual cost is stated plainly: a harness deliberately run with
|
|
88
|
+
* `NODE_ENV=test` gets `unavailable` from every tool here, with the reason
|
|
89
|
+
* naming the gate. That is fail-closed, and it is visible rather than silent.
|
|
90
|
+
*/
|
|
91
|
+
let realHostAllowed = process.env["NODE_ENV"] !== "test";
|
|
92
|
+
/**
|
|
93
|
+
* Opt in to (or out of) the real machine for an un-injected seam.
|
|
94
|
+
*
|
|
95
|
+
* Exactly one test in this package calls it with `true`, and restores it in
|
|
96
|
+
* the same `finally`. Everything else drives a fixture.
|
|
97
|
+
*/
|
|
98
|
+
export function _allowRealHost(allowed) {
|
|
99
|
+
realHostAllowed = allowed;
|
|
100
|
+
}
|
|
101
|
+
export function _realHostAllowed() {
|
|
102
|
+
return realHostAllowed;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* True when a runner is installed.
|
|
106
|
+
*
|
|
107
|
+
* Read by `host.ts` to decide the platform default: a test that has installed
|
|
108
|
+
* a recorded runner has already declared "I am not talking to this host", so
|
|
109
|
+
* if it then forgets to declare which host it is PRETENDING to be it gets a
|
|
110
|
+
* platform no backend serves — identically on macOS and on Linux CI. That
|
|
111
|
+
* exact bug (a test inheriting the ambient `process.platform`) cost this
|
|
112
|
+
* project a CI round in tool-hostfs.
|
|
113
|
+
*/
|
|
114
|
+
export function _runnerInstalled() {
|
|
115
|
+
return runnerOverride !== undefined;
|
|
116
|
+
}
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// argv hygiene
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
/**
|
|
121
|
+
* Programs that turn a careful argv array back into one string.
|
|
122
|
+
*
|
|
123
|
+
* The moment argv[0] is `sh`, every escaping guarantee in this package
|
|
124
|
+
* evaporates: the elements after `-c` are source again. Refusing the family
|
|
125
|
+
* structurally is cheaper than auditing each new backend for it.
|
|
126
|
+
*
|
|
127
|
+
* `powershell` IS NOT IN THIS LIST, and that is a carve-out, not an
|
|
128
|
+
* oversight. Two of the three Windows backends here genuinely are PowerShell,
|
|
129
|
+
* run as `powershell.exe <fixed flags> <registered script>` with every caller
|
|
130
|
+
* value in the ENVIRONMENT. The guarantee that replaces the blanket refusal
|
|
131
|
+
* is narrower and checkable: `powershellArgv` only emits a script object
|
|
132
|
+
* registered at module scope, and nothing is ever appended after it (which is
|
|
133
|
+
* what would put a caller's value back into source). `every powershell argv
|
|
134
|
+
* is a registered script with nothing appended` in index.test.ts asserts that
|
|
135
|
+
* over every recorded call.
|
|
136
|
+
*/
|
|
137
|
+
const SHELLS = new Set([
|
|
138
|
+
"sh",
|
|
139
|
+
"bash",
|
|
140
|
+
"zsh",
|
|
141
|
+
"dash",
|
|
142
|
+
"ksh",
|
|
143
|
+
"csh",
|
|
144
|
+
"tcsh",
|
|
145
|
+
"fish",
|
|
146
|
+
"cmd",
|
|
147
|
+
"cmd.exe",
|
|
148
|
+
"command.com",
|
|
149
|
+
"env",
|
|
150
|
+
"eval",
|
|
151
|
+
"xargs",
|
|
152
|
+
]);
|
|
153
|
+
/**
|
|
154
|
+
* House rule 7, enforced structurally rather than only asserted.
|
|
155
|
+
*
|
|
156
|
+
* No tool here acquires privilege. Being root and BECOMING root are different
|
|
157
|
+
* things, and this package does neither — it uses what the process already
|
|
158
|
+
* has. The check covers more than argv[0], because `systemd-inhibit sudo …`
|
|
159
|
+
* is the same escalation one position over.
|
|
160
|
+
*/
|
|
161
|
+
const ESCALATORS = /^(sudo|doas|runas|pkexec|gksudo|gksu)$/i;
|
|
162
|
+
/**
|
|
163
|
+
* `su` is refused as argv[0] only.
|
|
164
|
+
*
|
|
165
|
+
* As a later element it is a plausible VALUE — a CUPS destination may
|
|
166
|
+
* legitimately be called `su`, and `SAFE_DESTINATION` accepts it — so
|
|
167
|
+
* refusing it everywhere would reject a correct print with a message about
|
|
168
|
+
* privilege that made no sense.
|
|
169
|
+
*/
|
|
170
|
+
const ESCALATOR_PROGRAMS = /^(sudo|doas|runas|pkexec|gksudo|gksu|su)$/i;
|
|
171
|
+
/**
|
|
172
|
+
* Could this element be NAMING A PROGRAM, rather than carrying a caller value?
|
|
173
|
+
*
|
|
174
|
+
* ADVERSARIAL REVIEW FOUND THAT THE ANSWER MATTERS. The first version of the
|
|
175
|
+
* escalation scan took `arg.split(/[\\/]/).pop()` of EVERY element and
|
|
176
|
+
* matched that against `ESCALATORS`, which made
|
|
177
|
+
* `OpenExternal({target:"https://docs.example.com/guides/sudo"})` — an
|
|
178
|
+
* ordinary https URL the scheme allow-list had already approved — come back
|
|
179
|
+
* as `argv names "…", and this package never escalates privilege`. A
|
|
180
|
+
* notification body of `see /var/log/sudo` did the same. That is a refusal a
|
|
181
|
+
* caller cannot act on, for a value that could never have been a program:
|
|
182
|
+
* the check was reading a path SEGMENT out of data.
|
|
183
|
+
*
|
|
184
|
+
* It is the trap the `su` carve-out above was written for, one step further
|
|
185
|
+
* out. So a path segment is only read out of an element that could plausibly
|
|
186
|
+
* be a command path: no whitespace, no leading `-` (that is an option), and
|
|
187
|
+
* none of `:` `=` `?` `#` (a URI, an `--opt=value`, a query). An element that
|
|
188
|
+
* is EXACTLY an escalator name is still refused whatever its shape, which is
|
|
189
|
+
* what catches the mistake this rule exists for — a future backend writing
|
|
190
|
+
* `["systemd-inhibit", "--what=idle", "sudo", "sleep", "60"]`.
|
|
191
|
+
*
|
|
192
|
+
* The residual is stated rather than hidden: a caller value that is itself a
|
|
193
|
+
* bare path ending in `/sudo`, in a position with no `--` in front of it, is
|
|
194
|
+
* still refused — `xdg-open /ws/sudo` and `lp /ws/sudo` are the two places
|
|
195
|
+
* that can happen, because neither program has a `--`. That is rare and it
|
|
196
|
+
* fails closed.
|
|
197
|
+
*/
|
|
198
|
+
function couldNameAProgram(arg) {
|
|
199
|
+
return arg !== "" && !arg.startsWith("-") && !/[\s:=?#]/.test(arg);
|
|
200
|
+
}
|
|
201
|
+
export function assertArgv(argv) {
|
|
202
|
+
const program = argv[0];
|
|
203
|
+
if (program === undefined || program.trim() === "") {
|
|
204
|
+
return "argv[0] must name a program to run";
|
|
205
|
+
}
|
|
206
|
+
const base = (program.split(/[\\/]/).pop() ?? program).toLowerCase();
|
|
207
|
+
if (SHELLS.has(base)) {
|
|
208
|
+
return `argv[0] is "${base}", and this package never runs a shell — an argv array handed to a shell is a command line again`;
|
|
209
|
+
}
|
|
210
|
+
if (ESCALATOR_PROGRAMS.test(base.replace(/\.exe$/i, ""))) {
|
|
211
|
+
return `argv[0] is "${base}", and this package never escalates privilege`;
|
|
212
|
+
}
|
|
213
|
+
// Everything after the first `--` is a POSITIONAL by this package's own
|
|
214
|
+
// construction — that separator is only ever emitted to make the tail
|
|
215
|
+
// data — so the escalation scan stops there. The NUL check does not: a NUL
|
|
216
|
+
// truncates an argument at the syscall boundary wherever it sits.
|
|
217
|
+
const separator = argv.indexOf("--");
|
|
218
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
219
|
+
const arg = argv[i];
|
|
220
|
+
if (typeof arg !== "string")
|
|
221
|
+
return "every argv element must be a string";
|
|
222
|
+
// A NUL byte truncates an argument at the syscall boundary: a string that
|
|
223
|
+
// reads as harmless here arrives at the kernel cut short at the NUL, with
|
|
224
|
+
// whatever followed it gone.
|
|
225
|
+
if (arg.includes("\u0000"))
|
|
226
|
+
return "argv may not contain a NUL byte";
|
|
227
|
+
if (separator >= 0 && i > separator)
|
|
228
|
+
continue;
|
|
229
|
+
const whole = arg.replace(/\.exe$/i, "");
|
|
230
|
+
const argBase = couldNameAProgram(arg)
|
|
231
|
+
? (arg.split(/[\\/]/).pop() ?? arg).replace(/\.exe$/i, "")
|
|
232
|
+
: whole;
|
|
233
|
+
if (ESCALATORS.test(argBase)) {
|
|
234
|
+
return `argv names "${arg}", and this package never escalates privilege`;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return undefined;
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* A child's environment: `PATH` so the program can be found, a C locale so a
|
|
241
|
+
* parsed message is the same string on every machine, and the backend's
|
|
242
|
+
* explicit additions (a display, a D-Bus address, a `CREWHAUS_*` payload).
|
|
243
|
+
*
|
|
244
|
+
* Nothing else. `process.env` is NOT copied in: `pbpaste` has no business
|
|
245
|
+
* seeing an API key, and neither does a PowerShell one-liner. The session
|
|
246
|
+
* variables a Linux backend needs arrive through `extra`, put there by a
|
|
247
|
+
* backend reading the INJECTED `sessionEnv()` — so a test controls them like
|
|
248
|
+
* everything else rather than inheriting whether the maintainer happened to
|
|
249
|
+
* be in an SSH session.
|
|
250
|
+
*/
|
|
251
|
+
export function childEnv(extra) {
|
|
252
|
+
const inherited = process.env["PATH"];
|
|
253
|
+
const env = {
|
|
254
|
+
PATH: inherited === undefined || inherited === "" ? FALLBACK_PATH : inherited,
|
|
255
|
+
LC_ALL: "C",
|
|
256
|
+
LANG: "C",
|
|
257
|
+
};
|
|
258
|
+
// `HOME` is forwarded, and it is not decoration: three of these backends
|
|
259
|
+
// resolve USER-level configuration through it, and without it they silently
|
|
260
|
+
// answer for a different user than the operator. `xdg-open` reads the
|
|
261
|
+
// default-application table under `${XDG_DATA_HOME:-$HOME/.local/share}`
|
|
262
|
+
// and with `$HOME` empty falls back to the system table, so a document
|
|
263
|
+
// opens in whatever the distribution shipped rather than in the program the
|
|
264
|
+
// operator chose; `lp` reads `~/.cups/client.conf` for the server and
|
|
265
|
+
// default destination; and macOS `open` consults the per-user LaunchServices
|
|
266
|
+
// database. It is a path, not a credential, which is why it is the one
|
|
267
|
+
// inherited variable here besides `PATH`.
|
|
268
|
+
const home = process.env["HOME"];
|
|
269
|
+
if (home !== undefined && home !== "")
|
|
270
|
+
env["HOME"] = home;
|
|
271
|
+
for (const [key, value] of Object.entries(extra ?? {}))
|
|
272
|
+
env[key] = value;
|
|
273
|
+
return env;
|
|
274
|
+
}
|
|
275
|
+
/** Cut a captured stream at the cap, saying whether it was cut. */
|
|
276
|
+
export function capText(text, max) {
|
|
277
|
+
if (text.length <= max)
|
|
278
|
+
return { text, truncated: false };
|
|
279
|
+
return { text: text.slice(0, max), truncated: true };
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* The refusal an un-injected runner produces under test.
|
|
283
|
+
*
|
|
284
|
+
* Shaped like a real failure so every caller's existing "the command did not
|
|
285
|
+
* work" branch handles it, and worded so the test author sees immediately
|
|
286
|
+
* what they forgot.
|
|
287
|
+
*/
|
|
288
|
+
export const NO_RUNNER_REASON = "no runner is installed and this process is not allowed to touch the real host (see _setRunner / _allowRealHost)";
|
|
289
|
+
/**
|
|
290
|
+
* Turn a `Bun.spawn` throw into the right kind of "it did not run".
|
|
291
|
+
*
|
|
292
|
+
* ADVERSARIAL REVIEW: the first version set `missing: true` for EVERY spawn
|
|
293
|
+
* error, so a program that is installed but not executable by this user
|
|
294
|
+
* (`EACCES`), a name that resolves to a directory (`EISDIR`/`ENOTDIR`) or an
|
|
295
|
+
* argv over the kernel's limit (`E2BIG`) all came back as "the program is not
|
|
296
|
+
* installed on this host" — and `WindowList` then told an operator to install
|
|
297
|
+
* the `wmctrl` they already have. That is rule 6 one level below the tools:
|
|
298
|
+
* "could not start it" is not "it is not there".
|
|
299
|
+
*
|
|
300
|
+
* Only the two errnos that genuinely mean the NAME does not resolve set
|
|
301
|
+
* `missing`. Everything else is a failure carrying the errno, so the caller's
|
|
302
|
+
* reason names what actually happened.
|
|
303
|
+
*
|
|
304
|
+
* Exported because it is the only part of the real-spawn path a test can
|
|
305
|
+
* reach without spawning (house rule 4 allows exactly one real-host test, and
|
|
306
|
+
* it spawns nothing).
|
|
307
|
+
*/
|
|
308
|
+
export function classifySpawnError(err) {
|
|
309
|
+
const code = err?.code;
|
|
310
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
311
|
+
const absent = code === "ENOENT" || code === "ENOTDIR";
|
|
312
|
+
return {
|
|
313
|
+
code: 127,
|
|
314
|
+
stdout: "",
|
|
315
|
+
stderr: absent ? message : `${code ?? "spawn failed"}: ${message}`,
|
|
316
|
+
timedOut: false,
|
|
317
|
+
missing: absent,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
export async function runHostCommand(request) {
|
|
321
|
+
// Checked BEFORE the seam, not after: an injected runner must not be able
|
|
322
|
+
// to make an argv acceptable that the real spawn would refuse, or a test
|
|
323
|
+
// would pass on a command no host could run.
|
|
324
|
+
const bad = assertArgv(request.argv);
|
|
325
|
+
if (bad !== undefined) {
|
|
326
|
+
// `refused`, not a bare non-zero exit. ADVERSARIAL REVIEW: without it
|
|
327
|
+
// every classifier rendered this as "the opener exited -1: <reason>",
|
|
328
|
+
// which claims a process ran and reported a status. Nothing ran — the
|
|
329
|
+
// argv never left this function — and `refused` is the flag every
|
|
330
|
+
// classifier already maps to a plain `failed` with the reason verbatim.
|
|
331
|
+
return {
|
|
332
|
+
code: -1,
|
|
333
|
+
stdout: "",
|
|
334
|
+
stderr: bad,
|
|
335
|
+
timedOut: false,
|
|
336
|
+
missing: false,
|
|
337
|
+
refused: true,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
if (runnerOverride !== undefined)
|
|
341
|
+
return runnerOverride(request);
|
|
342
|
+
if (!realHostAllowed) {
|
|
343
|
+
return {
|
|
344
|
+
code: -1,
|
|
345
|
+
stdout: "",
|
|
346
|
+
stderr: NO_RUNNER_REASON,
|
|
347
|
+
timedOut: false,
|
|
348
|
+
missing: false,
|
|
349
|
+
refused: true,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
let proc;
|
|
353
|
+
try {
|
|
354
|
+
proc = Bun.spawn([...request.argv], {
|
|
355
|
+
stdin: request.stdin === undefined ? "ignore" : new TextEncoder().encode(request.stdin),
|
|
356
|
+
stdout: "pipe",
|
|
357
|
+
stderr: "pipe",
|
|
358
|
+
env: childEnv(request.env),
|
|
359
|
+
...(request.signal !== undefined ? { signal: request.signal } : {}),
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
catch (err) {
|
|
363
|
+
// ENOENT means the backend is not installed on this host, which is a
|
|
364
|
+
// DIFFERENT answer from "it ran and told us nothing" — and a different
|
|
365
|
+
// answer again from "it is there and would not start". See
|
|
366
|
+
// `classifySpawnError`.
|
|
367
|
+
return classifySpawnError(err);
|
|
368
|
+
}
|
|
369
|
+
let timedOut = false;
|
|
370
|
+
const term = setTimeout(() => {
|
|
371
|
+
timedOut = true;
|
|
372
|
+
try {
|
|
373
|
+
proc.kill("SIGTERM");
|
|
374
|
+
}
|
|
375
|
+
catch {
|
|
376
|
+
// Already gone between the timer firing and the signal.
|
|
377
|
+
}
|
|
378
|
+
}, request.timeoutMs);
|
|
379
|
+
const hardKill = setTimeout(() => {
|
|
380
|
+
try {
|
|
381
|
+
proc.kill("SIGKILL");
|
|
382
|
+
}
|
|
383
|
+
catch {
|
|
384
|
+
// Already gone.
|
|
385
|
+
}
|
|
386
|
+
}, request.timeoutMs + KILL_GRACE_MS);
|
|
387
|
+
try {
|
|
388
|
+
// `.catch` is attached HERE and not after the race below: the loser of
|
|
389
|
+
// the race stays pending, and a stream torn down with a killed process
|
|
390
|
+
// rejects with nobody listening — an unhandled rejection that takes down
|
|
391
|
+
// the harness rather than the command that caused it.
|
|
392
|
+
const stdoutText = new Response(proc.stdout)
|
|
393
|
+
.text()
|
|
394
|
+
.catch(() => "");
|
|
395
|
+
const stderrText = new Response(proc.stderr)
|
|
396
|
+
.text()
|
|
397
|
+
.catch(() => "");
|
|
398
|
+
const code = await proc.exited;
|
|
399
|
+
let drainTimer;
|
|
400
|
+
const drainFallback = new Promise((resolve) => {
|
|
401
|
+
drainTimer = setTimeout(() => resolve(""), DRAIN_GRACE_MS);
|
|
402
|
+
});
|
|
403
|
+
try {
|
|
404
|
+
const [rawOut, rawErr] = await Promise.all([
|
|
405
|
+
Promise.race([stdoutText, drainFallback]),
|
|
406
|
+
Promise.race([stderrText, drainFallback]),
|
|
407
|
+
]);
|
|
408
|
+
const cap = request.maxOutputChars ?? DEFAULT_MAX_OUTPUT_CHARS;
|
|
409
|
+
const out = capText(rawOut, cap);
|
|
410
|
+
const err = capText(rawErr, cap);
|
|
411
|
+
return {
|
|
412
|
+
code,
|
|
413
|
+
stdout: out.text,
|
|
414
|
+
stderr: err.text,
|
|
415
|
+
timedOut,
|
|
416
|
+
missing: false,
|
|
417
|
+
...(out.truncated ? { stdoutTruncated: true } : {}),
|
|
418
|
+
...(err.truncated ? { stderrTruncated: true } : {}),
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
finally {
|
|
422
|
+
// One shared timer, cleared as soon as the race settles: a fresh
|
|
423
|
+
// `setTimeout` per stream that nobody cancels keeps the event loop
|
|
424
|
+
// awake for the full grace period after every spawn.
|
|
425
|
+
if (drainTimer !== undefined)
|
|
426
|
+
clearTimeout(drainTimer);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
finally {
|
|
430
|
+
clearTimeout(term);
|
|
431
|
+
clearTimeout(hardKill);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Start a child and return its pid without waiting for it.
|
|
436
|
+
*
|
|
437
|
+
* `stdio` is all `ignore`: a detached holder that inherited this process's
|
|
438
|
+
* pipes would keep them open after the harness exits, and a holder whose
|
|
439
|
+
* stdout nobody reads eventually blocks on a full pipe — an inhibitor that
|
|
440
|
+
* stops inhibiting halfway through the job it was held for.
|
|
441
|
+
*/
|
|
442
|
+
export async function detachHostCommand(request) {
|
|
443
|
+
const bad = assertArgv(request.argv);
|
|
444
|
+
if (bad !== undefined)
|
|
445
|
+
return { ok: false, reason: bad, missing: false };
|
|
446
|
+
if (detacherOverride !== undefined)
|
|
447
|
+
return detacherOverride(request);
|
|
448
|
+
if (!realHostAllowed)
|
|
449
|
+
return { ok: false, reason: NO_RUNNER_REASON, missing: false };
|
|
450
|
+
try {
|
|
451
|
+
const proc = Bun.spawn([...request.argv], {
|
|
452
|
+
stdin: "ignore",
|
|
453
|
+
stdout: "ignore",
|
|
454
|
+
stderr: "ignore",
|
|
455
|
+
env: childEnv(request.env),
|
|
456
|
+
});
|
|
457
|
+
proc.unref();
|
|
458
|
+
return { ok: true, pid: proc.pid };
|
|
459
|
+
}
|
|
460
|
+
catch (err) {
|
|
461
|
+
// Same distinction as `runHostCommand`: only a name that does not resolve
|
|
462
|
+
// is `missing`. An inhibitor that would not START is not one that is not
|
|
463
|
+
// INSTALLED, and telling an operator to install `caffeinate` on a macOS
|
|
464
|
+
// box that has always had it sends them nowhere.
|
|
465
|
+
const failure = classifySpawnError(err);
|
|
466
|
+
return { ok: false, reason: failure.stderr, missing: failure.missing };
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* Test seam. Pass a function to answer every command from recorded output;
|
|
471
|
+
* pass `undefined` to go back to the (refused, under test) real spawn.
|
|
472
|
+
*/
|
|
473
|
+
export function _setRunner(runner) {
|
|
474
|
+
runnerOverride = runner;
|
|
475
|
+
}
|
|
476
|
+
export function _setDetacher(detacher) {
|
|
477
|
+
detacherOverride = detacher;
|
|
478
|
+
}
|
|
479
|
+
export function _resetRunSeams() {
|
|
480
|
+
runnerOverride = undefined;
|
|
481
|
+
detacherOverride = undefined;
|
|
482
|
+
realHostAllowed = process.env["NODE_ENV"] !== "test";
|
|
483
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@crewhaus/tool-desktop",
|
|
3
|
+
"version": "0.7.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Deterministic access to the operator's desktop: clipboard, notifications, opening a document, printing, windows, presence and sleep — failing closed on a headless host",
|
|
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
|
+
"@crewhaus/tool-proc": "0.7.0",
|
|
22
|
+
"zod": "^3.23.8"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@crewhaus/tool-executor": "workspace:*"
|
|
26
|
+
},
|
|
27
|
+
"license": "Apache-2.0",
|
|
28
|
+
"author": {
|
|
29
|
+
"name": "Max Meier",
|
|
30
|
+
"email": "max@crewhaus.ai",
|
|
31
|
+
"url": "https://crewhaus.ai"
|
|
32
|
+
},
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "git+https://github.com/crewhaus/factory.git",
|
|
36
|
+
"directory": "packages/tool-desktop"
|
|
37
|
+
},
|
|
38
|
+
"homepage": "https://github.com/crewhaus/factory/tree/main/packages/tool-desktop#readme",
|
|
39
|
+
"bugs": {
|
|
40
|
+
"url": "https://github.com/crewhaus/factory/issues"
|
|
41
|
+
},
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"access": "public"
|
|
44
|
+
},
|
|
45
|
+
"files": ["dist", "README.md", "LICENSE", "NOTICE"]
|
|
46
|
+
}
|