@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/LICENSE +201 -0
- package/NOTICE +9 -0
- package/README.md +121 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +139 -19
- package/dist/lib/clipboard.d.ts +1 -0
- package/dist/lib/clipboard.js +6 -0
- package/dist/lib/fsseam.d.ts +12 -0
- package/dist/lib/fsseam.js +86 -11
- package/dist/lib/open.d.ts +7 -1
- package/dist/lib/open.js +224 -4
- package/dist/lib/power.d.ts +38 -7
- package/dist/lib/power.js +64 -7
- package/dist/lib/presence.js +8 -1
- package/dist/lib/print.d.ts +8 -0
- package/dist/lib/print.js +63 -3
- package/dist/lib/windows.js +5 -2
- package/dist/paths.d.ts +21 -2
- package/dist/paths.js +72 -13
- package/dist/run.d.ts +7 -0
- package/dist/run.js +55 -75
- package/package.json +12 -7
- package/dist/fixtures.d.ts +0 -84
- package/dist/fixtures.js +0 -130
package/dist/index.js
CHANGED
|
@@ -47,19 +47,22 @@
|
|
|
47
47
|
* drives all of them, and the un-injected platform is deliberately a platform
|
|
48
48
|
* no backend serves — see `./host.ts`.
|
|
49
49
|
*/
|
|
50
|
-
import {
|
|
50
|
+
import { createHash } from "node:crypto";
|
|
51
|
+
import { realpathSync } from "node:fs";
|
|
52
|
+
import { tmpdir } from "node:os";
|
|
53
|
+
import { isAbsolute, join } from "node:path";
|
|
51
54
|
import { buildTool } from "@crewhaus/tool-builder";
|
|
52
55
|
import { z } from "zod";
|
|
53
56
|
import { hostPlatform, now, sessionEnv } from "./host";
|
|
54
57
|
import { classifyClipboardInfo, classifyClipboardRead, planClipboardRead, planClipboardWrite, } from "./lib/clipboard";
|
|
55
58
|
import { osascriptProgramText } from "./lib/escape";
|
|
56
|
-
import { fs } from "./lib/fsseam";
|
|
59
|
+
import { fs, StateFileError } from "./lib/fsseam";
|
|
57
60
|
import { classifyNotify, planNotify } from "./lib/notify";
|
|
58
61
|
import { OPENABLE_SCHEMES, classifyOpen, classifyTarget, planOpen, refusePath, } from "./lib/open";
|
|
59
62
|
import { Unknowns, unavailable, unsupportedPlatform } from "./lib/outcome";
|
|
60
|
-
import { DEFAULT_HOLD_MINUTES, MAX_HOLD_MINUTES, SAFE_REASON,
|
|
63
|
+
import { DEFAULT_HOLD_MINUTES, LEGACY_STATE_RELATIVE_PATH, MAX_HOLD_MINUTES, SAFE_REASON, boundedSeconds, classifyLiveness, isAssertionState, isHolderCommand, planHold, planProcessProbe, planRelease, } from "./lib/power";
|
|
61
64
|
import { parseIoregIdleSeconds, parseIoregLocked, parseLoginctl, parseWindowsPresence, parseXprintidleSeconds, planPresence, sessionKind, } from "./lib/presence";
|
|
62
|
-
import { DUPLEX_VALUES, MAX_COPIES, classifyPrint, parseQueues, planPrint, planPrinterProbe, refusePrintOptions, unreadableQueues, } from "./lib/print";
|
|
65
|
+
import { DUPLEX_VALUES, MAX_COPIES, classifyPrint, parseQueues, parseWindowsQueues, planPrint, planPrinterProbe, refusePrintOptions, unreadableQueues, } from "./lib/print";
|
|
63
66
|
import { classifyWindowList, planWindowList } from "./lib/windows";
|
|
64
67
|
import { ToolPermissionError, resolveSafe, workspaceRoot } from "./paths";
|
|
65
68
|
import { DEFAULT_COMMAND_TIMEOUT_MS, MAX_COMMAND_TIMEOUT_MS, capText, detachHostCommand, runHostCommand, } from "./run";
|
|
@@ -114,7 +117,8 @@ const DEFAULT_CLIPBOARD_MAX_CHARS = 20_000;
|
|
|
114
117
|
const MAX_CLIPBOARD_MAX_CHARS = 1_000_000;
|
|
115
118
|
export const clipboardRead = buildTool({
|
|
116
119
|
name: "ClipboardRead",
|
|
117
|
-
|
|
120
|
+
operativeArgs: [],
|
|
121
|
+
description: 'Read the operator\'s system clipboard as text. Use it only when the operator has been told to copy something for you. WHAT THE CALLER TAKES ON: the clipboard is where people keep a password, a recovery code or an API key for the seconds between copying and pasting it, and whatever is on it is returned verbatim into the model\'s context, the transcript, and any log or trace the harness keeps. This tool does NOT scan for or redact secrets: a heuristic that catches some and misses others earns trust it cannot honour. Treat the result as sensitive for the rest of the run. Four outcomes, never conflated: "read" with the text; "empty" (reachable, holds nothing); "noTextFlavour" (it holds something with no text form, such as an image or a file); and "unavailable" with a reason when there was no clipboard to ask (no desktop session, no backend program, an unsupported platform).',
|
|
118
122
|
inputSchema: z.object({
|
|
119
123
|
format: z
|
|
120
124
|
.enum(["text", "html"])
|
|
@@ -212,6 +216,7 @@ export const clipboardRead = buildTool({
|
|
|
212
216
|
// ---------------------------------------------------------------------------
|
|
213
217
|
export const clipboardWrite = buildTool({
|
|
214
218
|
name: "ClipboardWrite",
|
|
219
|
+
operativeArgs: [],
|
|
215
220
|
description: "Put text on the operator's system clipboard, replacing whatever was there. Declared destructive because that replacement cannot be undone: the thing the operator had copied a moment ago is gone, and there is no clipboard history to restore it from. The payload is piped to the backend on STDIN and never appears as a command argument, because argv is world-readable through a process listing on every platform here. Use dryRun to see which backend and argument list would be used without writing anything. On X11 the writing process owns the selection and must outlive this call, which xclip handles by forking its own resident holder — so on Linux the content survives this tool returning, but not the X session ending.",
|
|
216
221
|
inputSchema: z.object({
|
|
217
222
|
text: z
|
|
@@ -286,6 +291,7 @@ export const clipboardWrite = buildTool({
|
|
|
286
291
|
const MAX_NOTIFY_FIELD = 2_000;
|
|
287
292
|
export const desktopNotify = buildTool({
|
|
288
293
|
name: "DesktopNotify",
|
|
294
|
+
operativeArgs: [],
|
|
289
295
|
description: 'Show a notification on the operator\'s desktop — a macOS notification, a FreeDesktop notification on Linux, a toast on Windows. The title, body and subtitle may contain anything at all: on macOS they travel as argv items that the AppleScript reads with `item N of argv`, so a body containing quotes or semicolons is displayed rather than executed, and on Windows they are XML-escaped into a document that crosses to PowerShell through the environment rather than as source. The result says "dispatched", not "shown": every one of these APIs accepts a notification and returns, and whether a human saw it depends on Do Not Disturb, a Focus mode and whether the screen is on — none of which is observable from here. A host with no notification daemon, no display session or no notifier installed is reported as unavailable with the reason.',
|
|
290
296
|
inputSchema: z.object({
|
|
291
297
|
title: z.string().min(1).max(MAX_NOTIFY_FIELD).describe("the notification's title line"),
|
|
@@ -361,7 +367,8 @@ export const desktopNotify = buildTool({
|
|
|
361
367
|
// ---------------------------------------------------------------------------
|
|
362
368
|
export const openExternal = buildTool({
|
|
363
369
|
name: "OpenExternal",
|
|
364
|
-
|
|
370
|
+
operativeArgs: [{ field: "target", kind: "url" }],
|
|
371
|
+
description: `Ask the operating system to open a URL or a workspace file with its registered handler. Use it to show the operator a page or a file. Only ${OPENABLE_SCHEMES.join(", ")} are accepted, and allowSchemes can only narrow that; file:, smb:, nfs:, javascript:, data: and the Windows ms-* handlers are refused by name. An http(s) URL is handed off normalised; one with a backslash or credentials before the "@" is refused, and a mailto: may carry only RFC 6068's safe headers (attach= is refused). A target with no scheme is a path, resolved inside the workspace with symlinks followed and refused outside it; an executable file is refused, and so is a type the desktop runs, installs or follows (scripts, installers, configuration profiles, shortcut and location files). The result says "handedOff", not "opened": no opener reports what the handler did. What opens acts in the operator's desktop session, with their cookies, and outlives this call, so every call is gated like a change and carries a justification.`,
|
|
365
372
|
inputSchema: z.object({
|
|
366
373
|
target: z
|
|
367
374
|
.string()
|
|
@@ -377,6 +384,21 @@ export const openExternal = buildTool({
|
|
|
377
384
|
timeoutMs: timeoutSchema,
|
|
378
385
|
}),
|
|
379
386
|
readOnly: false,
|
|
387
|
+
// The effect outlives the call and the process — the package's own test for
|
|
388
|
+
// "destructive" (see PowerAssertion): an app is launched, a tab opens with
|
|
389
|
+
// the operator's cookies. As destructive:false it ran with no rule at all
|
|
390
|
+
// in auto mode, so a workspace .fileloc could launch any app unasked
|
|
391
|
+
// (flag-truth-5#6). A destructive tool that goes where the model points
|
|
392
|
+
// also carries a justification (apps/cli/src/flag-rules.test.ts, rule 1):
|
|
393
|
+
// opening `https://host/?q=<secret>` in the operator's browser is an
|
|
394
|
+
// outbound request like HttpRequest's. The justification gate runs AFTER
|
|
395
|
+
// the permission decision, so an `alwaysAllow` OpenExternal rule no longer
|
|
396
|
+
// skips it: every call is scored by `security.justification.judge`, or,
|
|
397
|
+
// when a spec sets none, by the rule-based judge (with its one-time
|
|
398
|
+
// stderr warning), which denies a justification that shares no salient
|
|
399
|
+
// word with the instructions.
|
|
400
|
+
destructive: true,
|
|
401
|
+
requireJustification: true,
|
|
380
402
|
concurrencySafe: false,
|
|
381
403
|
scope: "external",
|
|
382
404
|
ioCapability: "process",
|
|
@@ -465,6 +487,7 @@ export const openExternal = buildTool({
|
|
|
465
487
|
// ---------------------------------------------------------------------------
|
|
466
488
|
export const printDocument = buildTool({
|
|
467
489
|
name: "PrintDocument",
|
|
490
|
+
operativeArgs: [{ field: "path", kind: "path" }],
|
|
468
491
|
description: 'Send a local file to a printer through CUPS (macOS and Linux) or to a Windows queue. The queue is probed with lpstat FIRST, on both the dry run and the real print, so the two resolve through the same code and a dry run is a prefix of the real thing rather than a separate prediction. That probe is also what keeps the answers apart: a host whose scheduler is not running prints nothing and lists nothing, which looks exactly like a host with no printers, and the two are reported differently. Declared destructive because paper and toner do not come back. On Windows the honest scope is narrow and stated rather than papered over: Out-Printer sends TEXT to a queue and cannot rasterise a PDF or an image, and it has no copies, duplex or page-range option — asking for one there is refused by name instead of silently dropped. Printer names and page ranges are matched against closed patterns and refused if they do not fit, because lp documents no "--" and a destination beginning with "-" would become an option.',
|
|
469
492
|
inputSchema: z.object({
|
|
470
493
|
path: z
|
|
@@ -547,11 +570,13 @@ export const printDocument = buildTool({
|
|
|
547
570
|
const probe = await run(probeRequest, timeoutMs, context?.signal);
|
|
548
571
|
const queues = probe.refused === true || probe.missing || probe.timedOut
|
|
549
572
|
? unreadableQueues(probe.missing
|
|
550
|
-
? "lpstat is not installed on this host, so the queue could not be read
|
|
573
|
+
? `${platform === "win32" ? "PowerShell" : "lpstat"} is not installed on this host, so the queue could not be read`
|
|
551
574
|
: probe.timedOut
|
|
552
575
|
? "the queue probe did not finish within its timeout and was killed, so whether this host has printers is unknown — it is NOT that it has none"
|
|
553
576
|
: probe.stderr)
|
|
554
|
-
:
|
|
577
|
+
: platform === "win32"
|
|
578
|
+
? parseWindowsQueues(probe.stdout, probe.stderr)
|
|
579
|
+
: parseQueues(probe.stdout, probe.stderr);
|
|
555
580
|
const queueJson = {
|
|
556
581
|
printers: queues.printers,
|
|
557
582
|
defaultPrinter: queues.defaultPrinter,
|
|
@@ -594,6 +619,7 @@ export const printDocument = buildTool({
|
|
|
594
619
|
// ---------------------------------------------------------------------------
|
|
595
620
|
export const windowList = buildTool({
|
|
596
621
|
name: "WindowList",
|
|
622
|
+
operativeArgs: [],
|
|
597
623
|
description: "List the windows open on the operator's desktop, with the app, title and — where the backend can supply them — position and size. It never answers an empty list for a failure: a macOS host that has not granted Accessibility comes back as unavailable naming that grant in System Settings, a Wayland session comes back as unavailable explaining that Wayland provides no protocol for one client to enumerate another's windows (there is nothing to install that would fix it), a missing wmctrl comes back as unavailable naming the package, and a stream cut at the output cap is reported as truncated so the list is read as a prefix rather than as the whole desktop. Fields a backend cannot supply are null rather than zero or false — wmctrl reports no focus state, so every window's focused is null rather than a claim that nothing has focus.",
|
|
598
624
|
inputSchema: z.object({ timeoutMs: timeoutSchema }),
|
|
599
625
|
readOnly: true,
|
|
@@ -640,6 +666,7 @@ export const windowList = buildTool({
|
|
|
640
666
|
const DEFAULT_IDLE_THRESHOLD_SECONDS = 300;
|
|
641
667
|
export const userPresence = buildTool({
|
|
642
668
|
name: "UserPresence",
|
|
669
|
+
operativeArgs: [],
|
|
643
670
|
description: "Report whether a human is likely at this machine: seconds since the last input, whether the screen is locked, and what kind of session this is (console, ssh or headless). Every field is separately nullable and a null always carries an entry in `unknown` naming the probe that failed, because the tempting fallbacks here invert the tool's purpose — reporting idle:false when nothing could be asked tells a workflow the operator is at the keyboard, and reporting idle:true tells it the opposite and sends it off to act unattended. macOS reads idle time out of the IOKit registry, in nanoseconds, and lock state from the same dump; Linux needs xprintidle (usually not installed, and unavailable under Wayland, which exposes no idle query at all) and reads logind's LockedHint, which is a HINT a screen locker has to set and some do not; Windows uses GetLastInputInfo and the presence of LogonUI. An ssh session reports sessionKind \"ssh\" and does not pretend the far end's idle time says anything about the person typing.",
|
|
644
671
|
inputSchema: z.object({
|
|
645
672
|
idleThresholdSeconds: z
|
|
@@ -749,11 +776,47 @@ export const userPresence = buildTool({
|
|
|
749
776
|
// ---------------------------------------------------------------------------
|
|
750
777
|
// PowerAssertion
|
|
751
778
|
// ---------------------------------------------------------------------------
|
|
779
|
+
/**
|
|
780
|
+
* Where the held assertion is remembered: a file only this user can write,
|
|
781
|
+
* OUTSIDE the workspace, keyed by the workspace.
|
|
782
|
+
*
|
|
783
|
+
* 0.7.0 kept it at `.crewhaus/power-assertion.json` in the workspace, where
|
|
784
|
+
* the agent the tool serves can write, and `release` SIGTERMs the pid the
|
|
785
|
+
* record names (security-10#1). The directory is `$XDG_RUNTIME_DIR/crewhaus`
|
|
786
|
+
* when the session has one (Linux: per-user, 0700, cleared at logout), else
|
|
787
|
+
* `crewhaus-<uid>` under the OS temp directory (per-user on macOS and
|
|
788
|
+
* Windows), created 0700 and refused if another user owns it. The file name
|
|
789
|
+
* carries a hash of the workspace root, so two workspaces never share one.
|
|
790
|
+
*/
|
|
791
|
+
export function powerStateFile() {
|
|
792
|
+
const runtime = process.env["XDG_RUNTIME_DIR"];
|
|
793
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
|
|
794
|
+
const dir = runtime !== undefined && runtime !== "" && isAbsolute(runtime)
|
|
795
|
+
? join(runtime, "crewhaus")
|
|
796
|
+
: join(tmpdir(), uid === undefined ? "crewhaus" : `crewhaus-${uid}`);
|
|
797
|
+
let root = workspaceRoot();
|
|
798
|
+
try {
|
|
799
|
+
root = realpathSync(root);
|
|
800
|
+
}
|
|
801
|
+
catch {
|
|
802
|
+
// keep the lexical root
|
|
803
|
+
}
|
|
804
|
+
const key = createHash("sha256").update(root).digest("hex").slice(0, 16);
|
|
805
|
+
return join(dir, `power-assertion-${key}.json`);
|
|
806
|
+
}
|
|
752
807
|
function statePath() {
|
|
753
|
-
return
|
|
808
|
+
return powerStateFile();
|
|
754
809
|
}
|
|
755
810
|
function readState() {
|
|
756
|
-
|
|
811
|
+
let text;
|
|
812
|
+
try {
|
|
813
|
+
text = fs().readText(statePath());
|
|
814
|
+
}
|
|
815
|
+
catch (err) {
|
|
816
|
+
if (!(err instanceof StateFileError))
|
|
817
|
+
throw err;
|
|
818
|
+
return { kind: "unreadable", reason: err.message };
|
|
819
|
+
}
|
|
757
820
|
if (text === undefined)
|
|
758
821
|
return { kind: "none" };
|
|
759
822
|
let parsed;
|
|
@@ -777,6 +840,7 @@ function readState() {
|
|
|
777
840
|
}
|
|
778
841
|
export const powerAssertion = buildTool({
|
|
779
842
|
name: "PowerAssertion",
|
|
843
|
+
operativeArgs: [],
|
|
780
844
|
description: `Hold a BOUNDED sleep inhibitor around a long step, release it, or report on one. The deadline is mandatory and is not enforced by this process: caffeinate exits at its own -t, systemd-inhibit releases when its sleep returns, and the Windows holder parks for a fixed Start-Sleep — so killing the whole harness still ends the assertion on time, which an in-process reaper could not promise. Every result says when the assertion expires. The maximum is ${MAX_HOLD_MINUTES} minutes and the default is ${DEFAULT_HOLD_MINUTES}. A held assertion is remembered in a state file, and release verifies that the recorded pid is still running THIS package's holder before signalling anything: pids are reused, and a stale file must never become a SIGTERM to whatever now owns that number. A probe that could not run at all leaves the state alone and reports "unknown" rather than assuming the holder is gone.`,
|
|
781
845
|
inputSchema: z.object({
|
|
782
846
|
action: z
|
|
@@ -825,17 +889,37 @@ export const powerAssertion = buildTool({
|
|
|
825
889
|
...head,
|
|
826
890
|
outcome: "unreadableState",
|
|
827
891
|
reason: `${stateRead.reason} — so this package cannot say whether an inhibitor it started is still running. Nothing was signalled and nothing was started; any holder recorded there still ends at its own deadline. Delete the file to start again.`,
|
|
828
|
-
statePath:
|
|
892
|
+
statePath: statePath(),
|
|
829
893
|
});
|
|
830
894
|
}
|
|
831
895
|
const state = stateRead.kind === "state" ? stateRead.state : undefined;
|
|
832
896
|
const liveness = async (recorded) => {
|
|
897
|
+
// A record made on another platform cannot name a holder of ours here:
|
|
898
|
+
// it is somebody else's pid, whatever that process is running.
|
|
899
|
+
if (recorded.platform !== platform)
|
|
900
|
+
return { verdict: "reused", argv: [] };
|
|
833
901
|
const probeRequest = planProcessProbe(platform, recorded.pid);
|
|
834
902
|
if (probeRequest === undefined)
|
|
835
903
|
return { verdict: "unknown", argv: [] };
|
|
836
904
|
const probe = await run(probeRequest, timeoutMs, context?.signal);
|
|
837
|
-
|
|
905
|
+
// The live command line is checked against what THIS package starts on
|
|
906
|
+
// this platform, never against the record's own `marker` field: the
|
|
907
|
+
// record supplies the pid, so it must not also choose what that pid is
|
|
908
|
+
// checked against (security-10#1).
|
|
909
|
+
return {
|
|
910
|
+
verdict: classifyLiveness((line) => isHolderCommand(platform, line), probe),
|
|
911
|
+
argv: probeRequest.argv,
|
|
912
|
+
};
|
|
838
913
|
};
|
|
914
|
+
// 0.7.0's record in the workspace is never read (the agent could have
|
|
915
|
+
// written it), only mentioned, so an operator who upgraded mid-hold knows
|
|
916
|
+
// why release finds nothing and that the holder ends on its own.
|
|
917
|
+
const legacyNote = () => fs().stat(join(workspaceRoot(), LEGACY_STATE_RELATIVE_PATH))?.exists === true
|
|
918
|
+
? {
|
|
919
|
+
legacyStateFile: LEGACY_STATE_RELATIVE_PATH,
|
|
920
|
+
legacyNote: "a record from an earlier version is in the workspace. It is not read, because the agent can write files there; nothing it names is signalled, and any holder it recorded ends at its own deadline. Delete the file.",
|
|
921
|
+
}
|
|
922
|
+
: {};
|
|
839
923
|
const stateJson = (recorded) => ({
|
|
840
924
|
pid: recorded.pid,
|
|
841
925
|
backend: recorded.backend,
|
|
@@ -847,7 +931,12 @@ export const powerAssertion = buildTool({
|
|
|
847
931
|
});
|
|
848
932
|
if (input.action === "status") {
|
|
849
933
|
if (state === undefined) {
|
|
850
|
-
return json({
|
|
934
|
+
return json({
|
|
935
|
+
...head,
|
|
936
|
+
outcome: "none",
|
|
937
|
+
reason: "this package is holding no assertion",
|
|
938
|
+
...legacyNote(),
|
|
939
|
+
});
|
|
851
940
|
}
|
|
852
941
|
const { verdict, argv } = await liveness(state);
|
|
853
942
|
const expired = now() >= state.expiresAt;
|
|
@@ -878,7 +967,12 @@ export const powerAssertion = buildTool({
|
|
|
878
967
|
}
|
|
879
968
|
if (input.action === "release") {
|
|
880
969
|
if (state === undefined) {
|
|
881
|
-
return json({
|
|
970
|
+
return json({
|
|
971
|
+
...head,
|
|
972
|
+
outcome: "none",
|
|
973
|
+
reason: "this package is holding no assertion",
|
|
974
|
+
...legacyNote(),
|
|
975
|
+
});
|
|
882
976
|
}
|
|
883
977
|
const { verdict, argv } = await liveness(state);
|
|
884
978
|
if (verdict === "unknown") {
|
|
@@ -893,22 +987,30 @@ export const powerAssertion = buildTool({
|
|
|
893
987
|
probe: argv.join(" "),
|
|
894
988
|
});
|
|
895
989
|
}
|
|
990
|
+
// A dry run changes nothing, the record included.
|
|
991
|
+
const dry = input.dryRun === true;
|
|
896
992
|
if (verdict === "gone") {
|
|
897
|
-
|
|
993
|
+
if (!dry)
|
|
994
|
+
fs().remove(statePath());
|
|
898
995
|
return json({
|
|
899
996
|
...head,
|
|
900
997
|
outcome: "expired",
|
|
901
998
|
...stateJson(state),
|
|
902
|
-
reason:
|
|
999
|
+
reason: dry
|
|
1000
|
+
? "the holder has already ended; a real release would clear the recorded assertion"
|
|
1001
|
+
: "the holder had already ended; the recorded assertion was cleared",
|
|
903
1002
|
});
|
|
904
1003
|
}
|
|
905
1004
|
if (verdict === "reused") {
|
|
906
|
-
|
|
1005
|
+
if (!dry)
|
|
1006
|
+
fs().remove(statePath());
|
|
907
1007
|
return json({
|
|
908
1008
|
...head,
|
|
909
1009
|
outcome: "stale",
|
|
910
1010
|
...stateJson(state),
|
|
911
|
-
reason:
|
|
1011
|
+
reason: `${state.platform !== platform
|
|
1012
|
+
? `the record was made on ${state.platform}, not ${platform}, so its pid is not a holder of this package's`
|
|
1013
|
+
: "a process with the recorded pid exists but is not this package's holder, so the pid was reused"} — NOTHING was signalled and the recorded assertion ${dry ? "would be" : "was"} cleared`,
|
|
912
1014
|
});
|
|
913
1015
|
}
|
|
914
1016
|
const killRequest = planRelease(platform, state.pid);
|
|
@@ -1004,7 +1106,25 @@ export const powerAssertion = buildTool({
|
|
|
1004
1106
|
expiresAt,
|
|
1005
1107
|
reason: input.reason ?? null,
|
|
1006
1108
|
};
|
|
1007
|
-
|
|
1109
|
+
try {
|
|
1110
|
+
fs().writeTextAtomic(statePath(), JSON.stringify(recorded));
|
|
1111
|
+
}
|
|
1112
|
+
catch (err) {
|
|
1113
|
+
if (!(err instanceof StateFileError))
|
|
1114
|
+
throw err;
|
|
1115
|
+
// The holder is running and bounded; only the record is missing.
|
|
1116
|
+
return json({
|
|
1117
|
+
...head,
|
|
1118
|
+
outcome: "heldUnrecorded",
|
|
1119
|
+
backend: plan.backend,
|
|
1120
|
+
scope,
|
|
1121
|
+
pid: detached.pid,
|
|
1122
|
+
seconds: bounded.seconds,
|
|
1123
|
+
startedAt: new Date(startedAt).toISOString(),
|
|
1124
|
+
expiresAt: new Date(expiresAt).toISOString(),
|
|
1125
|
+
reason: `${err.message} — the inhibitor is held and still ends at its own deadline, but release cannot end it sooner`,
|
|
1126
|
+
});
|
|
1127
|
+
}
|
|
1008
1128
|
return json({
|
|
1009
1129
|
...head,
|
|
1010
1130
|
outcome: "held",
|
package/dist/lib/clipboard.d.ts
CHANGED
|
@@ -111,6 +111,7 @@ export declare function classifyClipboardRead(result: {
|
|
|
111
111
|
readonly missing: boolean;
|
|
112
112
|
readonly refused?: boolean;
|
|
113
113
|
readonly stdoutTruncated?: boolean;
|
|
114
|
+
readonly outputIncomplete?: boolean;
|
|
114
115
|
}): ClipboardReadOutcome;
|
|
115
116
|
/**
|
|
116
117
|
* Read the macOS disambiguating probe.
|
package/dist/lib/clipboard.js
CHANGED
|
@@ -192,6 +192,12 @@ export function classifyClipboardRead(result) {
|
|
|
192
192
|
// have said is unknown, and unknown is not empty.
|
|
193
193
|
return unavailable("unknown", "the clipboard program did not finish within its timeout and was killed - the clipboard could not be read, which is not the same as it being empty");
|
|
194
194
|
}
|
|
195
|
+
if (result.code === 0 && result.stdout.length === 0 && result.outputIncomplete === true) {
|
|
196
|
+
// Something the reader started held its output open past the drain
|
|
197
|
+
// grace, and nothing had arrived by then. Nothing arriving is not the
|
|
198
|
+
// clipboard being empty.
|
|
199
|
+
return unavailable("unknown", "the clipboard program's output could not be read to its end, and nothing had arrived - the clipboard could not be read, which is not the same as it being empty");
|
|
200
|
+
}
|
|
195
201
|
if (result.code === 0) {
|
|
196
202
|
if (result.stdout.length > 0)
|
|
197
203
|
return { outcome: "read", text: result.stdout };
|
package/dist/lib/fsseam.d.ts
CHANGED
|
@@ -7,14 +7,26 @@ export type PathFacts = {
|
|
|
7
7
|
readonly mode: number;
|
|
8
8
|
readonly sizeBytes: number;
|
|
9
9
|
};
|
|
10
|
+
/**
|
|
11
|
+
* The three state-file operations are for a PRIVATE file: one in a directory
|
|
12
|
+
* only this user can write (see `powerStateFile` in ../index.ts). The real
|
|
13
|
+
* implementation refuses the directory when another user owns it or can write
|
|
14
|
+
* it, and refuses a symlink, FIFO or other non-regular file at the leaf —
|
|
15
|
+
* throwing a {@link StateFileError} that names the path and the reason.
|
|
16
|
+
*/
|
|
10
17
|
export type HostFs = {
|
|
11
18
|
stat(absolutePath: string): PathFacts | undefined;
|
|
19
|
+
/** The file's text, or undefined when there is no file. */
|
|
12
20
|
readText(absolutePath: string): string | undefined;
|
|
13
21
|
/** Write via a temporary file and a rename, so a crash cannot leave half a
|
|
14
22
|
* state file behind for the next call to parse. */
|
|
15
23
|
writeTextAtomic(absolutePath: string, text: string): void;
|
|
16
24
|
remove(absolutePath: string): void;
|
|
17
25
|
};
|
|
26
|
+
/** A private state file, or its directory, that cannot be trusted. Never carries the file's content. */
|
|
27
|
+
export declare class StateFileError extends Error {
|
|
28
|
+
readonly name = "StateFileError";
|
|
29
|
+
}
|
|
18
30
|
export declare function fs(): HostFs;
|
|
19
31
|
export declare function _setFs(replacement: HostFs | undefined): void;
|
|
20
32
|
/** The owner/group/other execute bits. */
|
package/dist/lib/fsseam.js
CHANGED
|
@@ -12,8 +12,55 @@
|
|
|
12
12
|
* umask of whoever ran it, and a test that wrote a real state file would be
|
|
13
13
|
* racing every other test in the same process.
|
|
14
14
|
*/
|
|
15
|
-
import {
|
|
16
|
-
import { dirname } from "node:path";
|
|
15
|
+
import { chmodSync, lstatSync, mkdirSync, statSync, unlinkSync } from "node:fs";
|
|
16
|
+
import { basename, dirname } from "node:path";
|
|
17
|
+
import { openForReadSync, writeFileSafe } from "@crewhaus/tool-safety/fs";
|
|
18
|
+
/** A private state file, or its directory, that cannot be trusted. Never carries the file's content. */
|
|
19
|
+
export class StateFileError extends Error {
|
|
20
|
+
name = "StateFileError";
|
|
21
|
+
}
|
|
22
|
+
/** A state record is a few hundred bytes; anything past this is not one. */
|
|
23
|
+
const STATE_MAX_BYTES = 64 * 1024;
|
|
24
|
+
/**
|
|
25
|
+
* Is `dir` a real directory that only this user can write? With `create`,
|
|
26
|
+
* make it (mode 0700) when it is missing, and tighten the mode of one this
|
|
27
|
+
* user owns. A directory someone else owns — another user pre-creating
|
|
28
|
+
* `/tmp/crewhaus-<uid>` to plant a record — is refused, because the record
|
|
29
|
+
* names the pid `release` signals.
|
|
30
|
+
*/
|
|
31
|
+
function checkPrivateDir(dir, create) {
|
|
32
|
+
let stats;
|
|
33
|
+
try {
|
|
34
|
+
stats = lstatSync(dir, { throwIfNoEntry: false });
|
|
35
|
+
}
|
|
36
|
+
catch (err) {
|
|
37
|
+
throw new StateFileError(`the state directory ${dir} cannot be read: ${err.message}`);
|
|
38
|
+
}
|
|
39
|
+
if (stats === undefined) {
|
|
40
|
+
if (!create)
|
|
41
|
+
return "absent";
|
|
42
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
43
|
+
stats = lstatSync(dir);
|
|
44
|
+
}
|
|
45
|
+
if (stats.isSymbolicLink() || !stats.isDirectory()) {
|
|
46
|
+
throw new StateFileError(`the state directory ${dir} is not a directory (a symlink or file is there), so it is not trusted`);
|
|
47
|
+
}
|
|
48
|
+
if (process.platform === "win32")
|
|
49
|
+
return "ok";
|
|
50
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
|
|
51
|
+
if (uid !== undefined && stats.uid !== uid) {
|
|
52
|
+
throw new StateFileError(`the state directory ${dir} belongs to another user, so a record in it is not trusted`);
|
|
53
|
+
}
|
|
54
|
+
// Group or other WRITE is what lets someone else plant a record; read is
|
|
55
|
+
// only the pid and the deadline. A directory this call creates is 0700.
|
|
56
|
+
if ((stats.mode & 0o022) !== 0) {
|
|
57
|
+
if (!create) {
|
|
58
|
+
throw new StateFileError(`the state directory ${dir} can be written by other users (mode ${(stats.mode & 0o777).toString(8)}), so a record in it is not trusted`);
|
|
59
|
+
}
|
|
60
|
+
chmodSync(dir, 0o700);
|
|
61
|
+
}
|
|
62
|
+
return "ok";
|
|
63
|
+
}
|
|
17
64
|
const nodeFs = {
|
|
18
65
|
stat(absolutePath) {
|
|
19
66
|
let stats;
|
|
@@ -41,21 +88,49 @@ const nodeFs = {
|
|
|
41
88
|
};
|
|
42
89
|
},
|
|
43
90
|
readText(absolutePath) {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}
|
|
47
|
-
catch {
|
|
91
|
+
const dir = dirname(absolutePath);
|
|
92
|
+
if (checkPrivateDir(dir, false) === "absent")
|
|
48
93
|
return undefined;
|
|
94
|
+
// Contained to the directory, never through a link at the leaf, and a
|
|
95
|
+
// FIFO is refused before it is opened (a blocking open would freeze
|
|
96
|
+
// every session in the process).
|
|
97
|
+
const read = openForReadSync(dir, basename(absolutePath), {
|
|
98
|
+
maxBytes: STATE_MAX_BYTES,
|
|
99
|
+
followLeafSymlink: false,
|
|
100
|
+
});
|
|
101
|
+
if (!read.ok) {
|
|
102
|
+
if (read.code === "not-found")
|
|
103
|
+
return undefined;
|
|
104
|
+
throw new StateFileError(`the state file ${absolutePath} is not trusted: ${read.reason}`);
|
|
105
|
+
}
|
|
106
|
+
if (read.truncated) {
|
|
107
|
+
throw new StateFileError(`the state file ${absolutePath} is larger than ${STATE_MAX_BYTES} bytes, so it is not a record this package wrote`);
|
|
49
108
|
}
|
|
109
|
+
return read.text;
|
|
50
110
|
},
|
|
51
111
|
writeTextAtomic(absolutePath, text) {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
112
|
+
const dir = dirname(absolutePath);
|
|
113
|
+
checkPrivateDir(dir, true);
|
|
114
|
+
// A random O_EXCL|O_NOFOLLOW temp, renamed into place: the old
|
|
115
|
+
// `<path>.<pid>.tmp` was a predictable name opened through any link.
|
|
116
|
+
const written = writeFileSafe(dir, basename(absolutePath), text, {
|
|
117
|
+
overwrite: true,
|
|
118
|
+
mode: 0o600,
|
|
119
|
+
});
|
|
120
|
+
if (!written.ok) {
|
|
121
|
+
throw new StateFileError(`the state file ${absolutePath} was not written: ${written.reason}`);
|
|
122
|
+
}
|
|
56
123
|
},
|
|
57
124
|
remove(absolutePath) {
|
|
58
|
-
|
|
125
|
+
// unlink removes a link itself, never what it points at.
|
|
126
|
+
try {
|
|
127
|
+
unlinkSync(absolutePath);
|
|
128
|
+
}
|
|
129
|
+
catch (err) {
|
|
130
|
+
if (err.code === "ENOENT")
|
|
131
|
+
return;
|
|
132
|
+
throw new StateFileError(`the state file ${absolutePath} could not be removed: ${err.message}`);
|
|
133
|
+
}
|
|
59
134
|
},
|
|
60
135
|
};
|
|
61
136
|
let fsOverride;
|
package/dist/lib/open.d.ts
CHANGED
|
@@ -106,7 +106,13 @@ export declare function uriScheme(value: string): string | undefined;
|
|
|
106
106
|
* pure.
|
|
107
107
|
*/
|
|
108
108
|
export declare function classifyTarget(target: string, allowed: readonly OpenableScheme[]): TargetDecision;
|
|
109
|
-
/**
|
|
109
|
+
/**
|
|
110
|
+
* The lowercase extension, or `""`.
|
|
111
|
+
*
|
|
112
|
+
* Trailing dots and spaces are dropped first: Windows strips them when it
|
|
113
|
+
* opens a path, so `x.bat.` and `x.bat ` run `x.bat`, and reading them as
|
|
114
|
+
* "no extension" let a refused type through by spelling.
|
|
115
|
+
*/
|
|
110
116
|
export declare function extensionOf(path: string): string;
|
|
111
117
|
/**
|
|
112
118
|
* Why a contained path is still not openable.
|