@crewhaus/tool-desktop 0.7.0 → 0.8.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/paths.js CHANGED
@@ -11,15 +11,45 @@
11
11
  import { lstatSync, readlinkSync, realpathSync } from "node:fs";
12
12
  import * as path from "node:path";
13
13
  import { CrewhausError } from "@crewhaus/errors";
14
- /** Raised when a caller-supplied path resolves outside the workspace root. */
14
+ /**
15
+ * The longest path this resolves, in UTF-16 code units; a longer one is
16
+ * refused before it is walked. Nothing that long can be opened or created:
17
+ * PATH_MAX is 1,024 bytes on macOS and 4,096 on Linux, Windows stops at
18
+ * 32,767 UTF-16 units, and a path's UTF-8 form is never shorter than its
19
+ * UTF-16 one. The limit is twice that because the part of a path that exists
20
+ * is itself under PATH_MAX, so the part that does not is over PATH_MAX on its
21
+ * own: no spelling of the place reaches it, not even one through a symlink
22
+ * that shortens the part that exists.
23
+ */
24
+ export const MAX_PATH_CHARS = 2 * (process.platform === "darwin" ? 1_024 : process.platform === "win32" ? 32_767 : 4_096);
25
+ /** A path as a refusal quotes it: whole, unless it is past {@link MAX_PATH_CHARS}. */
26
+ function shownPath(given) {
27
+ return given.length > MAX_PATH_CHARS ? `${given.slice(0, 64)}…` : given;
28
+ }
29
+ /** Why a path past {@link MAX_PATH_CHARS} is refused: its length and the limit. */
30
+ function tooLong(abs) {
31
+ return `it is ${abs.length} characters once resolved, over the ${MAX_PATH_CHARS}-character limit: no path that long can be opened or created`;
32
+ }
33
+ /**
34
+ * Raised when a caller-supplied path resolves outside the workspace root, or
35
+ * is longer than any path can be.
36
+ */
15
37
  export class ToolPermissionError extends CrewhausError {
16
38
  name = "ToolPermissionError";
17
39
  toolName;
18
40
  path;
19
- constructor(toolName, attemptedPath) {
20
- super("tool", `tool "${toolName}" rejected path "${attemptedPath}": resolved location escapes the workspace root`);
41
+ /**
42
+ * Why, when it is not that the location escapes the workspace root: a path
43
+ * longer than any path can be. A caller that rewords the refusal says this.
44
+ */
45
+ reason;
46
+ constructor(toolName, attemptedPath, reason) {
47
+ super("tool", reason === undefined
48
+ ? `tool "${toolName}" rejected path "${attemptedPath}": resolved location escapes the workspace root`
49
+ : `tool "${toolName}" rejected path "${shownPath(attemptedPath)}": ${reason}`);
21
50
  this.toolName = toolName;
22
51
  this.path = attemptedPath;
52
+ this.reason = reason;
23
53
  }
24
54
  }
25
55
  /** The workspace root, resolved. Every path is contained within it. */
@@ -67,15 +97,7 @@ function nameExists(p) {
67
97
  function resolveLocation(target, depth = 0) {
68
98
  if (depth > 40)
69
99
  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
- }
100
+ const { probe, tail } = deepestName(target);
79
101
  let probeReal;
80
102
  try {
81
103
  probeReal = realpathSync(probe);
@@ -97,7 +119,41 @@ function resolveLocation(target, depth = 0) {
97
119
  const base = realpathSync(path.dirname(probe));
98
120
  probeReal = resolveLocation(path.resolve(base, link), depth + 1);
99
121
  }
100
- return tail.length > 0 ? path.join(probeReal, ...tail) : probeReal;
122
+ // One string, not one argument per name: Bun's `path.join` keeps only
123
+ // (argument count mod 65,536) of its arguments.
124
+ return tail.length > 0 ? path.join(probeReal, tail) : probeReal;
125
+ }
126
+ /**
127
+ * `target`, an absolute path, split where its deepest prefix that exists as
128
+ * a NAME ends: that prefix, and the names below it, which do not exist, as
129
+ * one string ("" when `target` itself exists). This is `path.dirname` and
130
+ * `path.basename` worked by index on `target` itself: handing them each
131
+ * shorter prefix in turn copied the rest of the path once per missing name,
132
+ * the square of a count the caller picks.
133
+ */
134
+ function deepestName(target) {
135
+ const top = path.parse(target).root.length;
136
+ const isSep = (i) => target[i] === "/" || target[i] === path.sep;
137
+ let probe = target;
138
+ let start = target.length;
139
+ let stop = -1;
140
+ while (!nameExists(probe)) {
141
+ // `path.basename(probe)` is `target.slice(start, end)`, and
142
+ // `path.dirname(probe)` is `target.slice(0, up)`.
143
+ let end = probe.length;
144
+ while (end > top && isSep(end - 1))
145
+ end--;
146
+ if (stop < 0)
147
+ stop = end;
148
+ start = end;
149
+ while (start > top && !isSep(start - 1))
150
+ start--;
151
+ const up = start > top ? start - 1 : top;
152
+ if (up === probe.length)
153
+ break; // reached the filesystem root
154
+ probe = target.slice(0, up);
155
+ }
156
+ return { probe, tail: stop < 0 ? "" : target.slice(start, stop) };
101
157
  }
102
158
  export function resolveSafe(toolName, rel, root = workspaceRoot()) {
103
159
  const rootResolved = path.resolve(root);
@@ -107,6 +163,9 @@ export function resolveSafe(toolName, rel, root = workspaceRoot()) {
107
163
  if (abs !== rootResolved && !abs.startsWith(`${rootResolved}${path.sep}`)) {
108
164
  throw new ToolPermissionError(toolName, rel);
109
165
  }
166
+ // Longer than any path can be: refused before the walk below reads it.
167
+ if (abs.length > MAX_PATH_CHARS)
168
+ throw new ToolPermissionError(toolName, rel, tooLong(abs));
110
169
  let real;
111
170
  try {
112
171
  const rootReal = realpathSync(rootResolved);
package/dist/run.d.ts CHANGED
@@ -73,6 +73,13 @@ export type RunResult = {
73
73
  */
74
74
  readonly stdoutTruncated?: boolean;
75
75
  readonly stderrTruncated?: boolean;
76
+ /**
77
+ * The output could not be read to its end: the child exited but something
78
+ * it started kept the pipe open past the drain grace. What arrived is
79
+ * returned, and `stdoutTruncated` is set too. 0.7.0 replaced such output
80
+ * with "", a definite-looking empty answer built from nothing.
81
+ */
82
+ readonly outputIncomplete?: boolean;
76
83
  /** Set when no runner was installed and the real host was not allowed. */
77
84
  readonly refused?: boolean;
78
85
  };
package/dist/run.js CHANGED
@@ -45,6 +45,8 @@
45
45
  * every use is inside a function body, and breaks the first time either file
46
46
  * grows a module-scope constant that reads the other.
47
47
  */
48
+ import { constants as osConstants } from "node:os";
49
+ import { spawnBounded } from "@crewhaus/tool-safety/streams";
48
50
  /** `PATH` for a child, with a floor so a stripped harness env still spawns. */
49
51
  export const FALLBACK_PATH = "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin";
50
52
  /** Grace between the deadline's SIGTERM and the SIGKILL that follows it. */
@@ -349,87 +351,65 @@ export async function runHostCommand(request) {
349
351
  refused: true,
350
352
  };
351
353
  }
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) {
354
+ // Output is capped AS IT ARRIVES (tool-safety's spawnBounded): at most
355
+ // three bytes a character of the cap are kept per stream (no UTF-16 unit
356
+ // takes more), and the rest is drained unstored, so the child never
357
+ // blocks on a full pipe and its exit code stays truthful. 0.7.0 read each
358
+ // pipe to EOF into memory and cut it afterwards, so memory was bounded by
359
+ // the child's output, not by the cap: 100 MiB of output with a
360
+ // 1000-character cap grew the process by about 311 MiB (C163).
361
+ //
362
+ // The drain bound is unchanged in intent: `xclip -i` and `wl-copy` leave
363
+ // a holder with the pipe open, and after `DRAIN_GRACE_MS` reading stops.
364
+ // A holder that exits normally is never killed (it IS the selection);
365
+ // the group is killed only at the deadline or on abort.
366
+ const cap = request.maxOutputChars ?? DEFAULT_MAX_OUTPUT_CHARS;
367
+ const run = await spawnBounded({
368
+ cmd: request.argv,
369
+ env: childEnv(request.env),
370
+ ...(request.stdin === undefined ? {} : { stdin: request.stdin }),
371
+ timeoutMs: request.timeoutMs,
372
+ maxStdoutBytes: cap * 3,
373
+ maxStderrBytes: cap * 3,
374
+ killGraceMs: KILL_GRACE_MS,
375
+ drainGraceMs: DRAIN_GRACE_MS,
376
+ ...(request.signal !== undefined ? { signal: request.signal } : {}),
377
+ });
378
+ if (run.spawnError !== undefined) {
363
379
  // ENOENT means the backend is not installed on this host, which is a
364
380
  // DIFFERENT answer from "it ran and told us nothing" — and a different
365
381
  // answer again from "it is there and would not start". See
366
382
  // `classifySpawnError`.
383
+ const err = new Error(run.spawnError);
384
+ if (run.spawnErrorCode !== undefined)
385
+ err.code = run.spawnErrorCode;
367
386
  return classifySpawnError(err);
368
387
  }
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
- }
388
+ const out = capText(run.stdout, cap);
389
+ const err = capText(run.stderr, cap);
390
+ // Output something the child started kept open past the drain grace is
391
+ // what ARRIVED, not necessarily all of it: it is returned, flagged, and
392
+ // never presented as a complete answer. 0.7.0 replaced it with "".
393
+ const incomplete = !run.outputComplete;
394
+ return {
395
+ code: exitCodeOf(run.exitCode, run.signal),
396
+ stdout: out.text,
397
+ stderr: err.text,
398
+ timedOut: run.timedOut,
399
+ missing: false,
400
+ ...(out.truncated || run.stdoutTruncated || incomplete ? { stdoutTruncated: true } : {}),
401
+ ...(err.truncated || run.stderrTruncated ? { stderrTruncated: true } : {}),
402
+ ...(incomplete ? { outputIncomplete: true } : {}),
403
+ };
404
+ }
405
+ /** The code `Bun.spawn`'s `exited` gave: the exit status, or 128 + the signal's number. */
406
+ function exitCodeOf(exitCode, signal) {
407
+ if (exitCode !== null)
408
+ return exitCode;
409
+ const number = signal === null
410
+ ? undefined
411
+ : osConstants.signals[signal];
412
+ return number === undefined ? -1 : 128 + number;
433
413
  }
434
414
  /**
435
415
  * Start a child and return its pid without waiting for it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crewhaus/tool-desktop",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
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
6
  "main": "dist/index.js",
@@ -8,17 +8,19 @@
8
8
  "exports": {
9
9
  ".": {
10
10
  "types": "./dist/index.d.ts",
11
- "import": "./dist/index.js"
11
+ "import": "./dist/index.js",
12
+ "default": "./dist/index.js"
12
13
  }
13
14
  },
14
15
  "scripts": {
15
16
  "test": "bun test src"
16
17
  },
17
18
  "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",
19
+ "@crewhaus/errors": "0.8.0",
20
+ "@crewhaus/tool-builder": "0.8.0",
21
+ "@crewhaus/tool-catalog": "0.8.0",
22
+ "@crewhaus/tool-proc": "0.8.0",
23
+ "@crewhaus/tool-safety": "0.8.0",
22
24
  "zod": "^3.23.8"
23
25
  },
24
26
  "devDependencies": {
@@ -42,5 +44,8 @@
42
44
  "publishConfig": {
43
45
  "access": "public"
44
46
  },
45
- "files": ["dist", "README.md", "LICENSE", "NOTICE"]
47
+ "files": ["dist", "README.md", "LICENSE", "NOTICE"],
48
+ "engines": {
49
+ "bun": ">=1.3.14"
50
+ }
46
51
  }
@@ -1,84 +0,0 @@
1
- /**
2
- * Recorded output, and where each line came from.
3
- *
4
- * Nothing in this package's suite asks the machine it runs on. CI is a Linux
5
- * container with no desktop at all, this was written on macOS, and a parser
6
- * checked against whatever the test host happens to answer is a parser
7
- * checked against nothing. So every classifier is fed from here.
8
- *
9
- * Each constant says whether it was RECORDED (somebody ran the command and
10
- * pasted the bytes) or DOCUMENTED (written from the program's source or
11
- * manual because nobody here has that platform). The distinction is kept
12
- * deliberately: a documented fixture proves the parser handles the shape it
13
- * was told about, and claims nothing more.
14
- */
15
- /** No X server at all. Exit 1, empty stdout. */
16
- export declare const XCLIP_NO_DISPLAY_STDERR = "Error: Can't open display: (null)\n";
17
- /**
18
- * A reachable display with nothing on the selection. Exit 1, empty stdout —
19
- * the SAME exit code and the SAME empty stdout as the line above, and the
20
- * opposite meaning. This pair is why `classifyClipboardRead` reads stderr.
21
- */
22
- export declare const XCLIP_EMPTY_SELECTION_STDERR = "Error: target STRING not available\n";
23
- /** The happy path. */
24
- export declare const XCLIP_TEXT_STDOUT = "ssh-rsa AAAAB3NzaC1yc2EA... deploy@example\n";
25
- /** `clipboard info` against an EMPTY pasteboard: empty stdout, exit 0. */
26
- export declare const CLIPBOARD_INFO_EMPTY_STDOUT = "";
27
- /**
28
- * `clipboard info` with a screenshot on the pasteboard: flavour names and
29
- * byte counts, and no content. This is what tells "empty" from "there is
30
- * something here pbpaste cannot give you".
31
- */
32
- export declare const CLIPBOARD_INFO_IMAGE_STDOUT = "\u00ABclass PNGf\u00BB, 284913, \u00ABclass 8BPS\u00BB, 1049928, TIFF picture, 1063968";
33
- export declare const NOTIFY_SEND_NO_BUS_STDERR = "Cannot autolaunch D-Bus without X11 $DISPLAY\n";
34
- /**
35
- * The argument-injection this package's `--` exists to prevent: with the
36
- * summary passed as a bare positional, `-u` was consumed as the urgency flag
37
- * and the body became its argument.
38
- */
39
- export declare const NOTIFY_SEND_FLAG_INJECTION_STDERR = "Unknown urgency body specified\n";
40
- export declare const OSASCRIPT_NO_ACCESSIBILITY_STDERR = "44:87: execution error: System Events got an error: osascript is not allowed assistive access. (-25211)\n";
41
- /** The OTHER grant, in a different System Settings pane. */
42
- export declare const OSASCRIPT_NO_AUTOMATION_STDERR = "48:52: execution error: Not authorized to send Apple events to System Events. (-1743)\n";
43
- /**
44
- * `wmctrl -l -G -p -x`. Nine fixed columns then a title with spaces in it;
45
- * the second row's title repeats a column's text on purpose, because a parser
46
- * that re-joins the split fields gets that one wrong.
47
- */
48
- export declare const WMCTRL_STDOUT: string;
49
- /**
50
- * `ioreg -r -c IOHIDSystem -d 1`. NANOSECONDS: 3_254_341_351_000 ns is 3254
51
- * seconds. Reading it as milliseconds would report 3254 seconds of idleness
52
- * as three, and make a locked machine look freshly used.
53
- *
54
- * The `-r` matters as much as the unit. `ioreg -c IOHIDSystem -d 1` — the
55
- * form the survey sketch gave — prints this key NOWHERE on this OS version,
56
- * because without `-r` the depth is measured from the root.
57
- */
58
- export declare const IOREG_HIDIDLE_STDOUT: string;
59
- /** The same machine, `ioreg -n Root -d1`, with the screen locked. */
60
- export declare const IOREG_ROOT_LOCKED_STDOUT: string;
61
- /** An unlocked console, same keys. */
62
- export declare const IOREG_ROOT_UNLOCKED_STDOUT: string;
63
- /** A macOS version that reports lock state somewhere this parser cannot see. */
64
- export declare const IOREG_ROOT_NO_LOCK_KEY_STDOUT = " \"IORegistryPlanes\" = {\"IOService\"=\"IOService\"}";
65
- /** DOCUMENTED: `loginctl show-session self -p LockedHint -p Type -p Active`. */
66
- export declare const LOGINCTL_STDOUT = "LockedHint=no\nType=x11\nActive=yes\n";
67
- /** DOCUMENTED: the Windows presence script's two lines. */
68
- export declare const WINDOWS_PRESENCE_STDOUT = "idleMs=42000\nlocked=no\n";
69
- /**
70
- * `lpstat -p -d`, verbatim. Two printers in two DIFFERENT sentence shapes,
71
- * an INDENTED continuation line that belongs to the row above it, and the
72
- * default-destination line.
73
- */
74
- export declare const LPSTAT_STDOUT: string;
75
- /**
76
- * A host whose scheduler is down. It lists NOTHING, which reads exactly like
77
- * a host with no printers — the rule-6 case this fixture exists for.
78
- */
79
- export declare const LPSTAT_NO_SCHEDULER_STDERR = "lpstat: Scheduler is not running.\n";
80
- /** DOCUMENTED: `lp` answers with prose and the job id is inside it. */
81
- export declare const LP_REQUEST_ID_STDOUT = "request id is Canon_MX490_series-34 (1 file(s))\n";
82
- export declare const PS_CAFFEINATE_STDOUT = "caffeinate -i -m -t 1800\n";
83
- /** The same pid, now owned by something else entirely. */
84
- export declare const PS_REUSED_PID_STDOUT = "/usr/sbin/cupsd -l\n";
package/dist/fixtures.js DELETED
@@ -1,130 +0,0 @@
1
- /**
2
- * Recorded output, and where each line came from.
3
- *
4
- * Nothing in this package's suite asks the machine it runs on. CI is a Linux
5
- * container with no desktop at all, this was written on macOS, and a parser
6
- * checked against whatever the test host happens to answer is a parser
7
- * checked against nothing. So every classifier is fed from here.
8
- *
9
- * Each constant says whether it was RECORDED (somebody ran the command and
10
- * pasted the bytes) or DOCUMENTED (written from the program's source or
11
- * manual because nobody here has that platform). The distinction is kept
12
- * deliberately: a documented fixture proves the parser handles the shape it
13
- * was told about, and claims nothing more.
14
- */
15
- // ---------------------------------------------------------------------------
16
- // clipboard — Linux, RECORDED on Debian 12 / xclip 0.13
17
- // ---------------------------------------------------------------------------
18
- /** No X server at all. Exit 1, empty stdout. */
19
- export const XCLIP_NO_DISPLAY_STDERR = "Error: Can't open display: (null)\n";
20
- /**
21
- * A reachable display with nothing on the selection. Exit 1, empty stdout —
22
- * the SAME exit code and the SAME empty stdout as the line above, and the
23
- * opposite meaning. This pair is why `classifyClipboardRead` reads stderr.
24
- */
25
- export const XCLIP_EMPTY_SELECTION_STDERR = "Error: target STRING not available\n";
26
- /** The happy path. */
27
- export const XCLIP_TEXT_STDOUT = "ssh-rsa AAAAB3NzaC1yc2EA... deploy@example\n";
28
- // ---------------------------------------------------------------------------
29
- // clipboard — macOS, RECORDED on macOS 26.6.2
30
- // ---------------------------------------------------------------------------
31
- /** `clipboard info` against an EMPTY pasteboard: empty stdout, exit 0. */
32
- export const CLIPBOARD_INFO_EMPTY_STDOUT = "";
33
- /**
34
- * `clipboard info` with a screenshot on the pasteboard: flavour names and
35
- * byte counts, and no content. This is what tells "empty" from "there is
36
- * something here pbpaste cannot give you".
37
- */
38
- export const CLIPBOARD_INFO_IMAGE_STDOUT = "«class PNGf», 284913, «class 8BPS», 1049928, TIFF picture, 1063968";
39
- // ---------------------------------------------------------------------------
40
- // notifications — Linux, RECORDED on Debian 12 / libnotify 0.8.1
41
- // ---------------------------------------------------------------------------
42
- export const NOTIFY_SEND_NO_BUS_STDERR = "Cannot autolaunch D-Bus without X11 $DISPLAY\n";
43
- /**
44
- * The argument-injection this package's `--` exists to prevent: with the
45
- * summary passed as a bare positional, `-u` was consumed as the urgency flag
46
- * and the body became its argument.
47
- */
48
- export const NOTIFY_SEND_FLAG_INJECTION_STDERR = "Unknown urgency body specified\n";
49
- // ---------------------------------------------------------------------------
50
- // AppleScript — RECORDED on macOS 26.6.2 with Accessibility NOT granted
51
- // ---------------------------------------------------------------------------
52
- export const OSASCRIPT_NO_ACCESSIBILITY_STDERR = "44:87: execution error: System Events got an error: osascript is not allowed assistive access. (-25211)\n";
53
- /** The OTHER grant, in a different System Settings pane. */
54
- export const OSASCRIPT_NO_AUTOMATION_STDERR = "48:52: execution error: Not authorized to send Apple events to System Events. (-1743)\n";
55
- // ---------------------------------------------------------------------------
56
- // windows — DOCUMENTED (wmctrl 1.07 output format)
57
- // ---------------------------------------------------------------------------
58
- /**
59
- * `wmctrl -l -G -p -x`. Nine fixed columns then a title with spaces in it;
60
- * the second row's title repeats a column's text on purpose, because a parser
61
- * that re-joins the split fields gets that one wrong.
62
- */
63
- export const WMCTRL_STDOUT = [
64
- "0x03c00003 0 2841 64 27 1280 800 Navigator.firefox buildbox Mozilla Firefox",
65
- "0x04200005 0 3120 0 0 2560 1440 gnome-terminal-server.Gnome-terminal buildbox 0x04200005 build log",
66
- "0x05000007 -1 4001 200 180 900 600 code.Code buildbox index.ts - factory",
67
- ].join("\n");
68
- // ---------------------------------------------------------------------------
69
- // presence — RECORDED on macOS 26.6.2, screen locked, ~54 minutes idle
70
- // ---------------------------------------------------------------------------
71
- /**
72
- * `ioreg -r -c IOHIDSystem -d 1`. NANOSECONDS: 3_254_341_351_000 ns is 3254
73
- * seconds. Reading it as milliseconds would report 3254 seconds of idleness
74
- * as three, and make a locked machine look freshly used.
75
- *
76
- * The `-r` matters as much as the unit. `ioreg -c IOHIDSystem -d 1` — the
77
- * form the survey sketch gave — prints this key NOWHERE on this OS version,
78
- * because without `-r` the depth is measured from the root.
79
- */
80
- export const IOREG_HIDIDLE_STDOUT = [
81
- "+-o IOHIDSystem <class IOHIDSystem, id 0x1000005b6, registered, matched, active>",
82
- " | {",
83
- ' | "IOClass" = "IOHIDSystem"',
84
- ' | "HIDIdleTime" = 3254341351000',
85
- ' | "IOProviderClass" = "IOResources"',
86
- " | }",
87
- ].join("\n");
88
- /** The same machine, `ioreg -n Root -d1`, with the screen locked. */
89
- export const IOREG_ROOT_LOCKED_STDOUT = [
90
- ' "IOConsoleLocked" = Yes',
91
- ' "IOConsoleUsers" = ({"kCGSSessionOnConsoleKey"=Yes,"kCGSSessionIDKey"=257,"CGSSessionScreenIsLocked"=Yes,"kCGSSessionUserIDKey"=506})',
92
- ].join("\n");
93
- /** An unlocked console, same keys. */
94
- export const IOREG_ROOT_UNLOCKED_STDOUT = [
95
- ' "IOConsoleLocked" = No',
96
- ' "IOConsoleUsers" = ({"kCGSSessionOnConsoleKey"=Yes,"CGSSessionScreenIsLocked"=No})',
97
- ].join("\n");
98
- /** A macOS version that reports lock state somewhere this parser cannot see. */
99
- export const IOREG_ROOT_NO_LOCK_KEY_STDOUT = ' "IORegistryPlanes" = {"IOService"="IOService"}';
100
- /** DOCUMENTED: `loginctl show-session self -p LockedHint -p Type -p Active`. */
101
- export const LOGINCTL_STDOUT = "LockedHint=no\nType=x11\nActive=yes\n";
102
- /** DOCUMENTED: the Windows presence script's two lines. */
103
- export const WINDOWS_PRESENCE_STDOUT = "idleMs=42000\nlocked=no\n";
104
- // ---------------------------------------------------------------------------
105
- // printing — RECORDED on macOS 26.6.2, CUPS 2.4
106
- // ---------------------------------------------------------------------------
107
- /**
108
- * `lpstat -p -d`, verbatim. Two printers in two DIFFERENT sentence shapes,
109
- * an INDENTED continuation line that belongs to the row above it, and the
110
- * default-destination line.
111
- */
112
- export const LPSTAT_STDOUT = [
113
- "printer Canon_MX490_series is idle. enabled since Sun Jul 26 10:59:45 2026",
114
- "printer Canon_TS5300_series now printing Canon_TS5300_series-33. enabled since Sat Sep 12 00:00:20 2026",
115
- "\tLooking for printer.",
116
- "system default destination: Canon_MX490_series",
117
- ].join("\n");
118
- /**
119
- * A host whose scheduler is down. It lists NOTHING, which reads exactly like
120
- * a host with no printers — the rule-6 case this fixture exists for.
121
- */
122
- export const LPSTAT_NO_SCHEDULER_STDERR = "lpstat: Scheduler is not running.\n";
123
- /** DOCUMENTED: `lp` answers with prose and the job id is inside it. */
124
- export const LP_REQUEST_ID_STDOUT = "request id is Canon_MX490_series-34 (1 file(s))\n";
125
- // ---------------------------------------------------------------------------
126
- // power — DOCUMENTED (`ps -o command= -p <pid>`)
127
- // ---------------------------------------------------------------------------
128
- export const PS_CAFFEINATE_STDOUT = "caffeinate -i -m -t 1800\n";
129
- /** The same pid, now owned by something else entirely. */
130
- export const PS_REUSED_PID_STDOUT = "/usr/sbin/cupsd -l\n";