@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,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `PowerAssertion` — keeping a machine awake for a long step, and giving it
|
|
3
|
+
* back afterwards.
|
|
4
|
+
*
|
|
5
|
+
* THE ASSERTION IS THE LIFETIME OF A CHILD PROCESS, NOT A CALL. The tool has
|
|
6
|
+
* to return while the holder keeps running, which makes three things true
|
|
7
|
+
* that an ordinary tool never has to think about:
|
|
8
|
+
*
|
|
9
|
+
* 1. **A DEADLINE IS MANDATORY, AND THE KERNEL ENFORCES IT.** An unbounded
|
|
10
|
+
* inhibitor is a laptop that never sleeps again because a harness crashed
|
|
11
|
+
* three days ago. So the bound is not a reaper in this process — a reaper
|
|
12
|
+
* dies with the harness that crashed — it is an argument to the holder
|
|
13
|
+
* itself. `caffeinate -t <seconds>` exits on its own; `systemd-inhibit …
|
|
14
|
+
* sleep <seconds>` releases when `sleep` returns; the PowerShell holder
|
|
15
|
+
* parks for a fixed `Start-Sleep`. Kill the whole harness and every one of
|
|
16
|
+
* them still ends on time. The result reports `expiresAt` because a
|
|
17
|
+
* caller that cannot see when its own inhibitor lapses will hold another.
|
|
18
|
+
* 2. **A PID IS NOT AN IDENTITY.** Pids are reused, and `release` reading a
|
|
19
|
+
* stale state file would otherwise send SIGTERM to whatever now owns that
|
|
20
|
+
* number. So the state file records the backend program, and `release` and
|
|
21
|
+
* `status` VERIFY the live process's command line before signalling
|
|
22
|
+
* anything. A mismatch is reported as `stale` and the file is cleared —
|
|
23
|
+
* nothing is killed.
|
|
24
|
+
* 3. **WINDOWS CANNOT DO THIS IN A ONE-LINER.** `SetThreadExecutionState` is
|
|
25
|
+
* THREAD-scoped and its effect dies with the thread, so a PowerShell
|
|
26
|
+
* one-liner that sets the flag and exits does precisely nothing — the
|
|
27
|
+
* naive port's failure is silent and total. The holder must stay alive
|
|
28
|
+
* with the flag set, which is what `Start-Sleep` inside the script is for.
|
|
29
|
+
*
|
|
30
|
+
* Nothing here escalates privilege: `caffeinate`, `systemd-inhibit` and
|
|
31
|
+
* `SetThreadExecutionState` are all available to an ordinary user, and
|
|
32
|
+
* `run.ts`'s `assertArgv` refuses an escalation program structurally.
|
|
33
|
+
*/
|
|
34
|
+
import type { HostPlatform } from "../host";
|
|
35
|
+
import type { RunRequest } from "../run";
|
|
36
|
+
import { type Unavailable } from "./outcome";
|
|
37
|
+
/** What the assertion covers. `display` also implies `system`. */
|
|
38
|
+
export type AssertionScope = "system" | "display";
|
|
39
|
+
/** The ceiling, and the reason there is one. Eight hours is a working day. */
|
|
40
|
+
export declare const MAX_HOLD_MINUTES = 480;
|
|
41
|
+
export declare const DEFAULT_HOLD_MINUTES = 30;
|
|
42
|
+
/** Where the held assertion is remembered, relative to the workspace root. */
|
|
43
|
+
export declare const STATE_RELATIVE_PATH = ".crewhaus/power-assertion.json";
|
|
44
|
+
export declare const WINDOWS_INHIBIT: import("./escape").PowerShellScript;
|
|
45
|
+
export declare const WINDOWS_PROCESS_PROBE: import("./escape").PowerShellScript;
|
|
46
|
+
/**
|
|
47
|
+
* The reason string a caller may attach to a Linux inhibitor.
|
|
48
|
+
*
|
|
49
|
+
* Printable ASCII only, and no newline. It becomes one argv ELEMENT of the
|
|
50
|
+
* form `--why=<text>`, so getopt reads the whole tail as the option's value
|
|
51
|
+
* whatever it contains — but `systemd-inhibit --list` prints it to an
|
|
52
|
+
* operator later, and a control character there corrupts a terminal rather
|
|
53
|
+
* than the command.
|
|
54
|
+
*/
|
|
55
|
+
export declare const SAFE_REASON: RegExp;
|
|
56
|
+
/**
|
|
57
|
+
* Is this parsed JSON a state record every field of which can be used?
|
|
58
|
+
*
|
|
59
|
+
* ADVERSARIAL REVIEW FOUND A CRASH HERE. The first version checked `pid` and
|
|
60
|
+
* `marker` and nothing else, so a record carrying those two and a missing or
|
|
61
|
+
* non-numeric `startedAt`/`expiresAt` — an interrupted write, a file from an
|
|
62
|
+
* older layout, an operator who edited it — was accepted, and then
|
|
63
|
+
* `new Date(undefined).toISOString()` threw `RangeError: Invalid time value`
|
|
64
|
+
* straight out of `execute`. Both `status` and `release` did it, which is to
|
|
65
|
+
* say the tool crashed on exactly the path a caller reaches when the recorded
|
|
66
|
+
* assertion is the thing that has gone wrong. `hold` had the quieter half of
|
|
67
|
+
* the same bug: `now() < state.expiresAt` against a non-number is `false`, so
|
|
68
|
+
* it silently overwrote a record it should have refused to replace.
|
|
69
|
+
*
|
|
70
|
+
* Every field is checked, because every field is used: the dates are
|
|
71
|
+
* formatted, the pid is signalled, the marker is matched and the scope and
|
|
72
|
+
* backend are echoed.
|
|
73
|
+
*/
|
|
74
|
+
export declare function isAssertionState(value: unknown): value is AssertionState;
|
|
75
|
+
/** What is remembered between calls. Written atomically; see ./fsseam.ts. */
|
|
76
|
+
export type AssertionState = {
|
|
77
|
+
readonly pid: number;
|
|
78
|
+
readonly platform: HostPlatform;
|
|
79
|
+
readonly backend: string;
|
|
80
|
+
/** The program the live process must still be running to be OURS. */
|
|
81
|
+
readonly marker: string;
|
|
82
|
+
readonly scope: AssertionScope;
|
|
83
|
+
readonly startedAt: number;
|
|
84
|
+
readonly expiresAt: number;
|
|
85
|
+
readonly reason: string | null;
|
|
86
|
+
};
|
|
87
|
+
export type HoldPlan = {
|
|
88
|
+
readonly ok: true;
|
|
89
|
+
readonly backend: string;
|
|
90
|
+
/** The program name `release` matches against a live command line. */
|
|
91
|
+
readonly marker: string;
|
|
92
|
+
readonly request: Omit<RunRequest, "timeoutMs">;
|
|
93
|
+
} | {
|
|
94
|
+
readonly ok: false;
|
|
95
|
+
readonly unavailable: Unavailable;
|
|
96
|
+
};
|
|
97
|
+
/** Build the holder's argv for `seconds` of inhibition. */
|
|
98
|
+
export declare function planHold(platform: HostPlatform, scope: AssertionScope, seconds: number, reason: string | null): HoldPlan;
|
|
99
|
+
/**
|
|
100
|
+
* Ask the host what `pid` is actually running.
|
|
101
|
+
*
|
|
102
|
+
* Not `process.kill(pid, 0)`: that answers "some process has this number",
|
|
103
|
+
* which after a pid rollover is exactly the wrong question. The command line
|
|
104
|
+
* is what distinguishes our holder from whatever inherited its number.
|
|
105
|
+
*/
|
|
106
|
+
export declare function planProcessProbe(platform: HostPlatform, pid: number): Omit<RunRequest, "timeoutMs"> | undefined;
|
|
107
|
+
/** The signal that ends a verified holder. */
|
|
108
|
+
export declare function planRelease(platform: HostPlatform, pid: number): Omit<RunRequest, "timeoutMs"> | undefined;
|
|
109
|
+
/**
|
|
110
|
+
* Is the recorded assertion still ours, and still running?
|
|
111
|
+
*
|
|
112
|
+
* Four answers, and the two failure cases are deliberately different: a probe
|
|
113
|
+
* that could not run at all is NOT the same as a probe that ran and found
|
|
114
|
+
* somebody else's process. The first must not cause a kill, and must not
|
|
115
|
+
* cause the state file to be discarded either — the holder may be alive and
|
|
116
|
+
* unreachable only to `ps`.
|
|
117
|
+
*/
|
|
118
|
+
export type Liveness = "alive" | "gone" | "reused" | "unknown";
|
|
119
|
+
/**
|
|
120
|
+
* Is this command line OUR holder, rather than something that merely mentions
|
|
121
|
+
* it?
|
|
122
|
+
*
|
|
123
|
+
* ADVERSARIAL REVIEW: the first version was `line.includes(marker)`, which
|
|
124
|
+
* answers "yes" for `vim /home/max/notes/caffeinate.md` and for
|
|
125
|
+
* `node ./caffeinate-runner.js`. After a pid rollover that is `release`
|
|
126
|
+
* sending SIGTERM to the operator's editor — precisely the failure the
|
|
127
|
+
* module header promises this check prevents ("A PID IS NOT AN IDENTITY"). A
|
|
128
|
+
* substring test over a whole command line is not an identity test.
|
|
129
|
+
*
|
|
130
|
+
* So the PROGRAM is compared: the first token, unquoted, basename, `.exe`
|
|
131
|
+
* stripped. Windows is the one case where that is not enough and cannot be —
|
|
132
|
+
* the holder there genuinely IS `powershell.exe`, and what makes it ours is
|
|
133
|
+
* the generated type name inside the script it was handed — so the substring
|
|
134
|
+
* test is kept for that case alone, narrowed to a powershell host.
|
|
135
|
+
*/
|
|
136
|
+
export declare function commandMatchesMarker(commandLine: string, marker: string): boolean;
|
|
137
|
+
export declare function classifyLiveness(marker: string, result: {
|
|
138
|
+
readonly code: number;
|
|
139
|
+
readonly stdout: string;
|
|
140
|
+
readonly stderr: string;
|
|
141
|
+
readonly timedOut: boolean;
|
|
142
|
+
readonly missing: boolean;
|
|
143
|
+
readonly refused?: boolean;
|
|
144
|
+
}): Liveness;
|
|
145
|
+
/** Minutes to a bounded second count, or a refusal naming the ceiling. */
|
|
146
|
+
export declare function boundedSeconds(minutes: number): {
|
|
147
|
+
seconds: number;
|
|
148
|
+
} | {
|
|
149
|
+
refusal: string;
|
|
150
|
+
};
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { powershellArgv, registerPowerShellScript } from "./escape";
|
|
2
|
+
import { unsupportedPlatform } from "./outcome";
|
|
3
|
+
/** The ceiling, and the reason there is one. Eight hours is a working day. */
|
|
4
|
+
export const MAX_HOLD_MINUTES = 480;
|
|
5
|
+
export const DEFAULT_HOLD_MINUTES = 30;
|
|
6
|
+
/** Where the held assertion is remembered, relative to the workspace root. */
|
|
7
|
+
export const STATE_RELATIVE_PATH = ".crewhaus/power-assertion.json";
|
|
8
|
+
export const WINDOWS_INHIBIT = registerPowerShellScript({
|
|
9
|
+
name: "power-inhibit",
|
|
10
|
+
// DOCUMENTED, NOT RECORDED. The flags are ES_CONTINUOUS (0x80000000) |
|
|
11
|
+
// ES_SYSTEM_REQUIRED (0x00000001), plus ES_DISPLAY_REQUIRED (0x00000002)
|
|
12
|
+
// when the caller asked for the display — passed as a NUMBER in the
|
|
13
|
+
// environment, never interpolated into this source. `Start-Sleep` is what
|
|
14
|
+
// keeps the thread alive; without it the state is discarded the instant the
|
|
15
|
+
// process exits and the whole call is a no-op.
|
|
16
|
+
script: [
|
|
17
|
+
"$src = 'using System;using System.Runtime.InteropServices;public class CHPwr{[DllImport(\"kernel32.dll\")]public static extern uint SetThreadExecutionState(uint f);}'",
|
|
18
|
+
"Add-Type -TypeDefinition $src -ErrorAction Stop",
|
|
19
|
+
"[void][CHPwr]::SetThreadExecutionState([uint32]$env:CREWHAUS_INHIBIT_FLAGS)",
|
|
20
|
+
"Start-Sleep -Seconds ([int]$env:CREWHAUS_INHIBIT_SECONDS)",
|
|
21
|
+
].join("; "),
|
|
22
|
+
reads: ["CREWHAUS_INHIBIT_FLAGS", "CREWHAUS_INHIBIT_SECONDS"],
|
|
23
|
+
});
|
|
24
|
+
export const WINDOWS_PROCESS_PROBE = registerPowerShellScript({
|
|
25
|
+
name: "process-probe",
|
|
26
|
+
script: '$p = Get-CimInstance Win32_Process -Filter "ProcessId=$env:CREWHAUS_PID" -ErrorAction SilentlyContinue; if ($p) { [Console]::Out.Write($p.CommandLine) }',
|
|
27
|
+
reads: ["CREWHAUS_PID"],
|
|
28
|
+
});
|
|
29
|
+
/**
|
|
30
|
+
* The reason string a caller may attach to a Linux inhibitor.
|
|
31
|
+
*
|
|
32
|
+
* Printable ASCII only, and no newline. It becomes one argv ELEMENT of the
|
|
33
|
+
* form `--why=<text>`, so getopt reads the whole tail as the option's value
|
|
34
|
+
* whatever it contains — but `systemd-inhibit --list` prints it to an
|
|
35
|
+
* operator later, and a control character there corrupts a terminal rather
|
|
36
|
+
* than the command.
|
|
37
|
+
*/
|
|
38
|
+
export const SAFE_REASON = /^[ -~]{1,120}$/;
|
|
39
|
+
/**
|
|
40
|
+
* Is this parsed JSON a state record every field of which can be used?
|
|
41
|
+
*
|
|
42
|
+
* ADVERSARIAL REVIEW FOUND A CRASH HERE. The first version checked `pid` and
|
|
43
|
+
* `marker` and nothing else, so a record carrying those two and a missing or
|
|
44
|
+
* non-numeric `startedAt`/`expiresAt` — an interrupted write, a file from an
|
|
45
|
+
* older layout, an operator who edited it — was accepted, and then
|
|
46
|
+
* `new Date(undefined).toISOString()` threw `RangeError: Invalid time value`
|
|
47
|
+
* straight out of `execute`. Both `status` and `release` did it, which is to
|
|
48
|
+
* say the tool crashed on exactly the path a caller reaches when the recorded
|
|
49
|
+
* assertion is the thing that has gone wrong. `hold` had the quieter half of
|
|
50
|
+
* the same bug: `now() < state.expiresAt` against a non-number is `false`, so
|
|
51
|
+
* it silently overwrote a record it should have refused to replace.
|
|
52
|
+
*
|
|
53
|
+
* Every field is checked, because every field is used: the dates are
|
|
54
|
+
* formatted, the pid is signalled, the marker is matched and the scope and
|
|
55
|
+
* backend are echoed.
|
|
56
|
+
*/
|
|
57
|
+
export function isAssertionState(value) {
|
|
58
|
+
if (typeof value !== "object" || value === null)
|
|
59
|
+
return false;
|
|
60
|
+
const v = value;
|
|
61
|
+
const finite = (x) => typeof x === "number" && Number.isFinite(x);
|
|
62
|
+
return (finite(v["pid"]) &&
|
|
63
|
+
Number.isInteger(v["pid"]) &&
|
|
64
|
+
v["pid"] > 0 &&
|
|
65
|
+
typeof v["marker"] === "string" &&
|
|
66
|
+
v["marker"] !== "" &&
|
|
67
|
+
typeof v["backend"] === "string" &&
|
|
68
|
+
typeof v["platform"] === "string" &&
|
|
69
|
+
(v["scope"] === "system" || v["scope"] === "display") &&
|
|
70
|
+
finite(v["startedAt"]) &&
|
|
71
|
+
finite(v["expiresAt"]) &&
|
|
72
|
+
(v["reason"] === null || typeof v["reason"] === "string"));
|
|
73
|
+
}
|
|
74
|
+
/** Build the holder's argv for `seconds` of inhibition. */
|
|
75
|
+
export function planHold(platform, scope, seconds, reason) {
|
|
76
|
+
switch (platform) {
|
|
77
|
+
case "darwin": {
|
|
78
|
+
// `-i` idle sleep, `-m` disk idle, `-s` only while on AC — omitted on
|
|
79
|
+
// purpose, a laptop on battery still needs the step to finish. `-d`
|
|
80
|
+
// additionally keeps the display awake. `-t` is the deadline, enforced
|
|
81
|
+
// by caffeinate rather than by this process.
|
|
82
|
+
const argv = ["caffeinate", "-i", "-m"];
|
|
83
|
+
if (scope === "display")
|
|
84
|
+
argv.push("-d");
|
|
85
|
+
argv.push("-t", String(seconds));
|
|
86
|
+
return { ok: true, backend: "caffeinate", marker: "caffeinate", request: { argv } };
|
|
87
|
+
}
|
|
88
|
+
case "linux": {
|
|
89
|
+
const what = scope === "display" ? "idle:sleep:handle-lid-switch" : "idle:sleep";
|
|
90
|
+
const argv = [
|
|
91
|
+
"systemd-inhibit",
|
|
92
|
+
`--what=${what}`,
|
|
93
|
+
"--who=crewhaus",
|
|
94
|
+
`--why=${reason ?? "crewhaus PowerAssertion"}`,
|
|
95
|
+
"--mode=block",
|
|
96
|
+
// The inhibitor lives exactly as long as this command. `sleep` is a
|
|
97
|
+
// program, not a shell — `assertArgv` would refuse an interpreter.
|
|
98
|
+
"sleep",
|
|
99
|
+
String(seconds),
|
|
100
|
+
];
|
|
101
|
+
return {
|
|
102
|
+
ok: true,
|
|
103
|
+
backend: "systemd-inhibit",
|
|
104
|
+
marker: "systemd-inhibit",
|
|
105
|
+
request: { argv },
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
case "win32": {
|
|
109
|
+
const flags = scope === "display" ? 0x8000_0003 : 0x8000_0001;
|
|
110
|
+
const built = powershellArgv(WINDOWS_INHIBIT, {
|
|
111
|
+
CREWHAUS_INHIBIT_FLAGS: String(flags),
|
|
112
|
+
CREWHAUS_INHIBIT_SECONDS: String(seconds),
|
|
113
|
+
});
|
|
114
|
+
return {
|
|
115
|
+
ok: true,
|
|
116
|
+
backend: "powershell SetThreadExecutionState",
|
|
117
|
+
marker: "CHPwr",
|
|
118
|
+
request: { argv: built.argv, env: built.env },
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
default:
|
|
122
|
+
return { ok: false, unavailable: unsupportedPlatform(platform, "power-assertion") };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Ask the host what `pid` is actually running.
|
|
127
|
+
*
|
|
128
|
+
* Not `process.kill(pid, 0)`: that answers "some process has this number",
|
|
129
|
+
* which after a pid rollover is exactly the wrong question. The command line
|
|
130
|
+
* is what distinguishes our holder from whatever inherited its number.
|
|
131
|
+
*/
|
|
132
|
+
export function planProcessProbe(platform, pid) {
|
|
133
|
+
switch (platform) {
|
|
134
|
+
case "darwin":
|
|
135
|
+
case "linux":
|
|
136
|
+
// `-o command=` prints the command line with no header, and nothing
|
|
137
|
+
// else. The pid is a number this package produced, not caller input.
|
|
138
|
+
return { argv: ["ps", "-o", "command=", "-p", String(pid)] };
|
|
139
|
+
case "win32":
|
|
140
|
+
return { argv: powershellArgv(WINDOWS_PROCESS_PROBE, { CREWHAUS_PID: String(pid) }).argv };
|
|
141
|
+
default:
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/** The signal that ends a verified holder. */
|
|
146
|
+
export function planRelease(platform, pid) {
|
|
147
|
+
switch (platform) {
|
|
148
|
+
case "darwin":
|
|
149
|
+
case "linux":
|
|
150
|
+
// SIGTERM, not SIGKILL: `systemd-inhibit` must run its own teardown to
|
|
151
|
+
// drop the logind lock, and a SIGKILLed inhibitor leaves the lock held
|
|
152
|
+
// until logind notices the process is gone.
|
|
153
|
+
return { argv: ["kill", "-TERM", String(pid)] };
|
|
154
|
+
case "win32":
|
|
155
|
+
// `/T` takes the child `Start-Sleep` with it; `/F` because a PowerShell
|
|
156
|
+
// host parked in Start-Sleep does not process a polite close.
|
|
157
|
+
return { argv: ["taskkill", "/PID", String(pid), "/T", "/F"] };
|
|
158
|
+
default:
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Is this command line OUR holder, rather than something that merely mentions
|
|
164
|
+
* it?
|
|
165
|
+
*
|
|
166
|
+
* ADVERSARIAL REVIEW: the first version was `line.includes(marker)`, which
|
|
167
|
+
* answers "yes" for `vim /home/max/notes/caffeinate.md` and for
|
|
168
|
+
* `node ./caffeinate-runner.js`. After a pid rollover that is `release`
|
|
169
|
+
* sending SIGTERM to the operator's editor — precisely the failure the
|
|
170
|
+
* module header promises this check prevents ("A PID IS NOT AN IDENTITY"). A
|
|
171
|
+
* substring test over a whole command line is not an identity test.
|
|
172
|
+
*
|
|
173
|
+
* So the PROGRAM is compared: the first token, unquoted, basename, `.exe`
|
|
174
|
+
* stripped. Windows is the one case where that is not enough and cannot be —
|
|
175
|
+
* the holder there genuinely IS `powershell.exe`, and what makes it ours is
|
|
176
|
+
* the generated type name inside the script it was handed — so the substring
|
|
177
|
+
* test is kept for that case alone, narrowed to a powershell host.
|
|
178
|
+
*/
|
|
179
|
+
export function commandMatchesMarker(commandLine, marker) {
|
|
180
|
+
const first = commandLine.trim().split(/\s+/)[0] ?? "";
|
|
181
|
+
const unquoted = first.replace(/^"+|"+$/g, "");
|
|
182
|
+
const base = (unquoted.split(/[\\/]/).pop() ?? unquoted).replace(/\.exe$/i, "");
|
|
183
|
+
if (base === marker)
|
|
184
|
+
return true;
|
|
185
|
+
return /^powershell$/i.test(base) && commandLine.includes(marker);
|
|
186
|
+
}
|
|
187
|
+
export function classifyLiveness(marker, result) {
|
|
188
|
+
if (result.refused === true || result.missing || result.timedOut)
|
|
189
|
+
return "unknown";
|
|
190
|
+
// `ps -p <pid>` exits 1 with no output when nothing has that pid. That is a
|
|
191
|
+
// definite answer, not a failure — but ONLY when `ps` itself said nothing.
|
|
192
|
+
//
|
|
193
|
+
// ADVERSARIAL REVIEW: the first version read every non-zero exit with empty
|
|
194
|
+
// stdout as "gone", and `ps` has other ways to exit non-zero with an empty
|
|
195
|
+
// stdout, all of them loud on stderr. Recorded on macOS 26.6.2:
|
|
196
|
+
// $ ps -o command= -p 999999 ; echo $?
|
|
197
|
+
// ps: process id too large
|
|
198
|
+
// 1
|
|
199
|
+
// Reading that as "gone" makes `release` delete the state file and report
|
|
200
|
+
// `expired` for a holder that may still be running — the exact "could not
|
|
201
|
+
// determine is not no" inversion rule 6 names, on the one path where the
|
|
202
|
+
// consequence is an inhibitor nobody can release before its deadline.
|
|
203
|
+
if (result.code !== 0) {
|
|
204
|
+
if (result.stderr.trim() !== "")
|
|
205
|
+
return "unknown";
|
|
206
|
+
return result.stdout.trim() === "" ? "gone" : "unknown";
|
|
207
|
+
}
|
|
208
|
+
const line = result.stdout.trim();
|
|
209
|
+
if (line === "")
|
|
210
|
+
return "gone";
|
|
211
|
+
return commandMatchesMarker(line, marker) ? "alive" : "reused";
|
|
212
|
+
}
|
|
213
|
+
/** Minutes to a bounded second count, or a refusal naming the ceiling. */
|
|
214
|
+
export function boundedSeconds(minutes) {
|
|
215
|
+
if (!Number.isFinite(minutes) || minutes <= 0) {
|
|
216
|
+
return { refusal: "maxMinutes must be a positive number of minutes" };
|
|
217
|
+
}
|
|
218
|
+
if (minutes > MAX_HOLD_MINUTES) {
|
|
219
|
+
return {
|
|
220
|
+
refusal: `maxMinutes must be at most ${MAX_HOLD_MINUTES} (8 hours) — an assertion held longer than a working day is one nobody is waiting for any more`,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
return { seconds: Math.max(1, Math.round(minutes * 60)) };
|
|
224
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `UserPresence` — is there a human at this machine, and should a workflow
|
|
3
|
+
* interrupt them.
|
|
4
|
+
*
|
|
5
|
+
* THIS IS THE TOOL RULE 6 WAS WRITTEN FOR. Every field here has a tempting
|
|
6
|
+
* fallback that inverts the tool's purpose:
|
|
7
|
+
*
|
|
8
|
+
* - "no idle signal, assume active" tells a workflow the operator is at the
|
|
9
|
+
* keyboard when the truth is that nothing could be asked.
|
|
10
|
+
* - "the probe failed, so idle" tells it the opposite, and a workflow that
|
|
11
|
+
* acts on `idle: true` does things unattended.
|
|
12
|
+
* - "screen-lock has no CLI, so not locked" claims the screen is unlocked
|
|
13
|
+
* on every machine in the world.
|
|
14
|
+
*
|
|
15
|
+
* So every field is `number | null` or `boolean | null`, `null` always
|
|
16
|
+
* carries an entry in `unknown` naming the probe and the reason, and the
|
|
17
|
+
* derived `idle` boolean is `null` whenever `idleSeconds` is.
|
|
18
|
+
*
|
|
19
|
+
* ── WHAT EACH PLATFORM CAN ACTUALLY ANSWER ────────────────────────────────
|
|
20
|
+
*
|
|
21
|
+
* macOS Idle comes out of an `ioreg` text dump, in NANOSECONDS. The unit
|
|
22
|
+
* is the trap: reading it as milliseconds makes every host look
|
|
23
|
+
* permanently active, and nothing about the number's shape gives
|
|
24
|
+
* that away. The incantation matters too — the survey sketch's
|
|
25
|
+
* `ioreg -c IOHIDSystem -d 1` prints NOTHING on macOS 26.6.2
|
|
26
|
+
* (verified); `-r` is required, because `-d 1` without it bounds
|
|
27
|
+
* the depth from the root rather than from the matched object:
|
|
28
|
+
*
|
|
29
|
+
* $ ioreg -c IOHIDSystem -d 1 | grep HIDIdleTime # empty
|
|
30
|
+
* $ ioreg -r -c IOHIDSystem -d 1 | grep HIDIdleTime
|
|
31
|
+
* "HIDIdleTime" = 3254341351000
|
|
32
|
+
*
|
|
33
|
+
* Lock state has no supported CLI, and the framework call
|
|
34
|
+
* (CGSessionCopyCurrentDictionary) is a C API. But the same
|
|
35
|
+
* registry carries it, recorded on the same machine with the screen
|
|
36
|
+
* locked:
|
|
37
|
+
*
|
|
38
|
+
* $ ioreg -n Root -d1
|
|
39
|
+
* "IOConsoleLocked" = Yes
|
|
40
|
+
* "IOConsoleUsers" = ({...,"CGSSessionScreenIsLocked"=Yes,...})
|
|
41
|
+
*
|
|
42
|
+
* Both keys are read, `IOConsoleLocked` first. A dump with neither
|
|
43
|
+
* is reported as unknown rather than as unlocked.
|
|
44
|
+
* Linux `xprintidle` is the only idle source and is usually NOT
|
|
45
|
+
* installed; under Wayland there is no idle query at all. Lock
|
|
46
|
+
* state comes from logind's `LockedHint`, which is a HINT — it is
|
|
47
|
+
* set by a screen locker that bothers to call `SetLockedHint`, and
|
|
48
|
+
* a locker that does not leaves it `no` on a locked screen. It is
|
|
49
|
+
* reported with that caveat attached rather than as a fact.
|
|
50
|
+
* Windows `GetLastInputInfo` through a registered PowerShell script, and
|
|
51
|
+
* the presence of `LogonUI` as the lock signal — that process runs
|
|
52
|
+
* only while the lock or login screen is up.
|
|
53
|
+
*
|
|
54
|
+
* And over all of it: an SSH session. Idle time on a machine you are talking
|
|
55
|
+
* to over a wire says nothing about whether a person is sitting at it, so a
|
|
56
|
+
* remote shell reports `sessionKind: "ssh"` and does not pretend otherwise.
|
|
57
|
+
*/
|
|
58
|
+
import { type HostPlatform, type SessionEnv } from "../host";
|
|
59
|
+
import type { RunRequest } from "../run";
|
|
60
|
+
export type SessionKind = "console" | "ssh" | "headless" | "unknown";
|
|
61
|
+
export declare const WINDOWS_PRESENCE: import("./escape").PowerShellScript;
|
|
62
|
+
/** One probe this tool might run. Each is independently allowed to fail. */
|
|
63
|
+
export type PresenceProbe = {
|
|
64
|
+
readonly field: "idleSeconds" | "screenLocked";
|
|
65
|
+
readonly request: Omit<RunRequest, "timeoutMs">;
|
|
66
|
+
};
|
|
67
|
+
/**
|
|
68
|
+
* Which probes to run, plus the fields this platform cannot probe at all.
|
|
69
|
+
*
|
|
70
|
+
* A platform with no probe for a field is not an error — it is a field that
|
|
71
|
+
* comes back `null` with its reason, which is the whole contract here.
|
|
72
|
+
*/
|
|
73
|
+
export declare function planPresence(platform: HostPlatform, env: SessionEnv): {
|
|
74
|
+
readonly probes: readonly PresenceProbe[];
|
|
75
|
+
readonly notes: ReadonlyArray<[string, string]>;
|
|
76
|
+
};
|
|
77
|
+
/**
|
|
78
|
+
* Parse `HIDIdleTime` out of an ioreg dump.
|
|
79
|
+
*
|
|
80
|
+
* NANOSECONDS. Recorded: `"HIDIdleTime" = 3254341351000` is 3254 seconds —
|
|
81
|
+
* about 54 minutes, which is what the machine had been idle for. Reading it
|
|
82
|
+
* as milliseconds would have reported 3254 SECONDS as 3.2 seconds and made a
|
|
83
|
+
* locked, untouched machine look freshly used.
|
|
84
|
+
*
|
|
85
|
+
* The value passes 2^53 on a host idle for more than about 104 days, so it is
|
|
86
|
+
* divided as a BigInt. A float round there is only seconds of error, but it
|
|
87
|
+
* is error with no reason to exist.
|
|
88
|
+
*/
|
|
89
|
+
export declare function parseIoregIdleSeconds(stdout: string): number | null;
|
|
90
|
+
/**
|
|
91
|
+
* Parse macOS lock state out of an `ioreg -n Root -d1` dump.
|
|
92
|
+
*
|
|
93
|
+
* `null` when neither key is present — that is a macOS version this parser
|
|
94
|
+
* has not seen, and guessing "unlocked" for it would claim every such machine
|
|
95
|
+
* has a human looking at it.
|
|
96
|
+
*/
|
|
97
|
+
export declare function parseIoregLocked(stdout: string): boolean | null;
|
|
98
|
+
/** `xprintidle` prints milliseconds and nothing else. */
|
|
99
|
+
export declare function parseXprintidleSeconds(stdout: string): number | null;
|
|
100
|
+
/** Parse `loginctl show-session -p …` key=value output. */
|
|
101
|
+
export declare function parseLoginctl(stdout: string): {
|
|
102
|
+
readonly locked: boolean | null;
|
|
103
|
+
readonly type: string | null;
|
|
104
|
+
readonly active: boolean | null;
|
|
105
|
+
};
|
|
106
|
+
/** Parse the two lines the Windows script prints. */
|
|
107
|
+
export declare function parseWindowsPresence(stdout: string): {
|
|
108
|
+
readonly idleSeconds: number | null;
|
|
109
|
+
readonly locked: boolean | null;
|
|
110
|
+
};
|
|
111
|
+
/**
|
|
112
|
+
* What kind of session this is.
|
|
113
|
+
*
|
|
114
|
+
* `ssh` wins over everything: a forwarded `DISPLAY` on an ssh connection is
|
|
115
|
+
* still a wire, and the idle time it would report belongs to the machine at
|
|
116
|
+
* the far end rather than to the person typing.
|
|
117
|
+
*/
|
|
118
|
+
export declare function sessionKind(platform: HostPlatform, env: SessionEnv): SessionKind;
|