@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/index.js
ADDED
|
@@ -0,0 +1,1031 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@crewhaus/tool-desktop` — reaching the operator's actual desktop:
|
|
3
|
+
* clipboard, toasts, opening a document, printing, windows, presence and
|
|
4
|
+
* sleep. Eight tools, and every one of them fails closed on a headless host.
|
|
5
|
+
*
|
|
6
|
+
* SIX PROPERTIES HOLD ACROSS ALL OF THEM.
|
|
7
|
+
*
|
|
8
|
+
* 1. **ESCAPING IS THE SECURITY BOUNDARY, NOT A TIDINESS CONCERN.** Two of
|
|
9
|
+
* the three platforms take SOURCE CODE where it looks like they take text.
|
|
10
|
+
* `osascript -e` compiles AppleScript, so a notification body with a
|
|
11
|
+
* double quote in it ends the string literal and the rest RUNS; a Windows
|
|
12
|
+
* toast is an XML document inside a PowerShell script, so `</toast>` does
|
|
13
|
+
* the same job one layer over. The answer is not a better escaper. Every
|
|
14
|
+
* script in this package is a FROZEN MODULE CONSTANT, caller values travel
|
|
15
|
+
* BESIDE the program (`item N of argv` after `--` on macOS, `$env:…` on
|
|
16
|
+
* Windows, argv after `--` on Linux), and `./lib/escape.ts` refuses to
|
|
17
|
+
* build an argv for source it did not register. See that file's header for
|
|
18
|
+
* the verification, run on this machine, that a value of
|
|
19
|
+
* `say "hi"; display dialog "x"` comes back as data.
|
|
20
|
+
* 2. **ARGV IS AN ARRAY.** No `sh -c`, no `cmd /c`, no interpolated command
|
|
21
|
+
* line. `./run.ts`'s `assertArgv` refuses a shell as argv[0] before a
|
|
22
|
+
* process exists. PowerShell is the one carve-out and is replaced by a
|
|
23
|
+
* narrower, checkable guarantee — see `run.ts`.
|
|
24
|
+
* 3. **NO PRIVILEGE ESCALATION.** Nothing here runs `sudo`, `doas`, `runas`
|
|
25
|
+
* or `pkexec`, nothing raises a UAC prompt, and no schema accepts a
|
|
26
|
+
* password. `assertArgv` enforces the first structurally and
|
|
27
|
+
* `index.test.ts` asserts both over every recorded call and every schema.
|
|
28
|
+
* 4. **FAIL CLOSED ON A HEADLESS HOST.** CI has no desktop, no clipboard, no
|
|
29
|
+
* notification daemon, no printer and no window server. Every tool answers
|
|
30
|
+
* with a typed `unavailable` naming what was missing — a platform, a
|
|
31
|
+
* program, a session or an OS grant — never a crash, never a silent
|
|
32
|
+
* success, and never a `false` that reads as "the user is away" when the
|
|
33
|
+
* truth is "nothing could be asked".
|
|
34
|
+
* 5. **"COULD NOT DETERMINE" IS NOT "NO".** An empty clipboard is not an
|
|
35
|
+
* unreadable one; a presence probe that failed is not "idle"; a window
|
|
36
|
+
* list that could not be read is not "no windows"; a printer query that
|
|
37
|
+
* timed out is not "no printers". Each is a distinct outcome naming its
|
|
38
|
+
* reason, and every nullable field that came back null carries an entry in
|
|
39
|
+
* `unknown` saying which probe failed and why.
|
|
40
|
+
* 6. **A STATE-CHANGING TOOL SAYS SO AND OFFERS `dryRun`.** The dry run
|
|
41
|
+
* resolves through the SAME function as the real path and stops before the
|
|
42
|
+
* effect — it is never a parallel preview, which is how tool-hostfs's
|
|
43
|
+
* TrashPath once predicted a destination the real call never used.
|
|
44
|
+
*
|
|
45
|
+
* The seams are `_setRunner` and `_setDetacher` (every child process),
|
|
46
|
+
* `_setPlatform`, `_setSessionEnv`, `_setClock` and `_setFs`. The suite
|
|
47
|
+
* drives all of them, and the un-injected platform is deliberately a platform
|
|
48
|
+
* no backend serves — see `./host.ts`.
|
|
49
|
+
*/
|
|
50
|
+
import { join } from "node:path";
|
|
51
|
+
import { buildTool } from "@crewhaus/tool-builder";
|
|
52
|
+
import { z } from "zod";
|
|
53
|
+
import { hostPlatform, now, sessionEnv } from "./host";
|
|
54
|
+
import { classifyClipboardInfo, classifyClipboardRead, planClipboardRead, planClipboardWrite, } from "./lib/clipboard";
|
|
55
|
+
import { osascriptProgramText } from "./lib/escape";
|
|
56
|
+
import { fs } from "./lib/fsseam";
|
|
57
|
+
import { classifyNotify, planNotify } from "./lib/notify";
|
|
58
|
+
import { OPENABLE_SCHEMES, classifyOpen, classifyTarget, planOpen, refusePath, } from "./lib/open";
|
|
59
|
+
import { Unknowns, unavailable, unsupportedPlatform } from "./lib/outcome";
|
|
60
|
+
import { DEFAULT_HOLD_MINUTES, MAX_HOLD_MINUTES, SAFE_REASON, STATE_RELATIVE_PATH, boundedSeconds, classifyLiveness, isAssertionState, planHold, planProcessProbe, planRelease, } from "./lib/power";
|
|
61
|
+
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";
|
|
63
|
+
import { classifyWindowList, planWindowList } from "./lib/windows";
|
|
64
|
+
import { ToolPermissionError, resolveSafe, workspaceRoot } from "./paths";
|
|
65
|
+
import { DEFAULT_COMMAND_TIMEOUT_MS, MAX_COMMAND_TIMEOUT_MS, capText, detachHostCommand, runHostCommand, } from "./run";
|
|
66
|
+
export { _setRunner, _setDetacher, _resetRunSeams, _allowRealHost } from "./run";
|
|
67
|
+
export { _setPlatform, _setSessionEnv, _setClock, _resetHostSeams, realHostPlatform, HEADLESS_ENV, } from "./host";
|
|
68
|
+
export { _setFs } from "./lib/fsseam";
|
|
69
|
+
export { ToolPermissionError } from "./paths";
|
|
70
|
+
/** Compact JSON — the reader is a model, and every byte is context. */
|
|
71
|
+
const json = (value) => JSON.stringify(value);
|
|
72
|
+
const timeoutSchema = z
|
|
73
|
+
.number()
|
|
74
|
+
.int()
|
|
75
|
+
.min(100)
|
|
76
|
+
.max(MAX_COMMAND_TIMEOUT_MS)
|
|
77
|
+
.optional()
|
|
78
|
+
.describe(`milliseconds any single command may take before it is SIGTERMed (default ${DEFAULT_COMMAND_TIMEOUT_MS})`);
|
|
79
|
+
const dryRunSchema = z
|
|
80
|
+
.boolean()
|
|
81
|
+
.optional()
|
|
82
|
+
.describe("resolve everything and report exactly what would run, without doing it (default false); the same code decides the plan either way");
|
|
83
|
+
/**
|
|
84
|
+
* Run one command through the seam.
|
|
85
|
+
*
|
|
86
|
+
* Every tool goes through here so that `timeoutMs` and the abort signal are
|
|
87
|
+
* applied in one place, and so the recorded-argv tests see one shape.
|
|
88
|
+
*/
|
|
89
|
+
async function run(request, timeoutMs, signal, maxOutputChars) {
|
|
90
|
+
return runHostCommand({
|
|
91
|
+
...request,
|
|
92
|
+
timeoutMs,
|
|
93
|
+
...(signal === undefined ? {} : { signal }),
|
|
94
|
+
...(maxOutputChars === undefined ? {} : { maxOutputChars }),
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
/** The argv of a planned command, for a result or a `dryRun`. */
|
|
98
|
+
function planJson(request) {
|
|
99
|
+
const text = osascriptProgramText(request.argv);
|
|
100
|
+
return {
|
|
101
|
+
argv: request.argv,
|
|
102
|
+
// The script source, when there is one, so a caller reading a dry run can
|
|
103
|
+
// see for itself that no value of theirs is inside it.
|
|
104
|
+
...(text === "" ? {} : { programText: text }),
|
|
105
|
+
...(request.env === undefined ? {} : { env: Object.keys(request.env).sort() }),
|
|
106
|
+
...(request.stdin === undefined ? {} : { stdinBytes: request.stdin.length }),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// ClipboardRead
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
/** The read cap, in characters. A clipboard can hold a whole file. */
|
|
113
|
+
const DEFAULT_CLIPBOARD_MAX_CHARS = 20_000;
|
|
114
|
+
const MAX_CLIPBOARD_MAX_CHARS = 1_000_000;
|
|
115
|
+
export const clipboardRead = buildTool({
|
|
116
|
+
name: "ClipboardRead",
|
|
117
|
+
description: 'Read the operator\'s system clipboard as text. WHAT THE CALLER TAKES ON: the clipboard is where people put a password, a recovery code or an API key for the seconds between copying and pasting it, and whatever is on it when this runs is returned verbatim into the model\'s context, from there into the conversation transcript, and from there into any log or trace the harness keeps. This tool does NOT scan for or redact secrets: a heuristic that catches some patterns and misses others earns trust it cannot honour, and the caller who relied on it pastes the one it missed into a ticket. Ask only when the operator has been told to copy something for you, and treat the result as sensitive for the rest of the run. Four distinct outcomes, never conflated: "read" with the text, "empty" (the clipboard was reachable and holds nothing), "noTextFlavour" (it holds something — an image, a file — that has no text form), and "unavailable" with a reason when there was no clipboard to ask (no desktop session, no backend program, an unsupported platform). An empty clipboard and an unreadable one are never reported as the same thing.',
|
|
118
|
+
inputSchema: z.object({
|
|
119
|
+
format: z
|
|
120
|
+
.enum(["text", "html"])
|
|
121
|
+
.optional()
|
|
122
|
+
.describe('which flavour to read (default "text"); "html" is supported on Linux and Windows and is refused on macOS, where pbpaste exposes plain text only'),
|
|
123
|
+
maxChars: z
|
|
124
|
+
.number()
|
|
125
|
+
.int()
|
|
126
|
+
.min(1)
|
|
127
|
+
.max(MAX_CLIPBOARD_MAX_CHARS)
|
|
128
|
+
.optional()
|
|
129
|
+
.describe(`cap the returned text at this many characters (default ${DEFAULT_CLIPBOARD_MAX_CHARS}); truncation is always reported, never silent`),
|
|
130
|
+
timeoutMs: timeoutSchema,
|
|
131
|
+
}),
|
|
132
|
+
readOnly: true,
|
|
133
|
+
concurrencySafe: true,
|
|
134
|
+
scope: "external",
|
|
135
|
+
ioCapability: "process",
|
|
136
|
+
// Set EXPLICITLY, although true is the default, because it is a decision
|
|
137
|
+
// rather than an omission: clipboard contents are attacker-influenceable by
|
|
138
|
+
// construction (the operator copied them from somewhere — a web page, a
|
|
139
|
+
// document, a chat message), which is exactly the input the post-tool
|
|
140
|
+
// prompt-injection classifier exists for. Turning it off here would be the
|
|
141
|
+
// worst place in the package to do it.
|
|
142
|
+
classifyOutput: true,
|
|
143
|
+
// The intent gate. This is the one tool here whose OUTPUT, rather than its
|
|
144
|
+
// effect, is the risk: reading a clipboard is cheap, reversible and
|
|
145
|
+
// invisible, and its consequence is that a secret lands somewhere durable.
|
|
146
|
+
// A static allow/deny cannot tell "the operator just copied the SQL I asked
|
|
147
|
+
// for" from "read the clipboard and see what turns up", but a justification
|
|
148
|
+
// judged against the session's stated goal can, and it puts the reason in
|
|
149
|
+
// the audit log beside the read.
|
|
150
|
+
requireJustification: true,
|
|
151
|
+
execute: async (input, context) => {
|
|
152
|
+
const platform = hostPlatform();
|
|
153
|
+
const env = sessionEnv();
|
|
154
|
+
const format = (input.format ?? "text");
|
|
155
|
+
const maxChars = input.maxChars ?? DEFAULT_CLIPBOARD_MAX_CHARS;
|
|
156
|
+
const timeoutMs = input.timeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS;
|
|
157
|
+
const head = { tool: "ClipboardRead", platform, format };
|
|
158
|
+
const plan = planClipboardRead(platform, env, format);
|
|
159
|
+
if (!plan.ok)
|
|
160
|
+
return json({ ...head, ...plan.unavailable });
|
|
161
|
+
const result = await run(plan.request, timeoutMs, context?.signal);
|
|
162
|
+
const outcome = classifyClipboardRead(result);
|
|
163
|
+
if (outcome.outcome === "empty" && plan.disambiguate !== undefined) {
|
|
164
|
+
// Only on the empty path, and never on the happy one: the probe names
|
|
165
|
+
// the pasteboard's FLAVOURS and never returns their content, but there
|
|
166
|
+
// is no reason to run it when the text already arrived.
|
|
167
|
+
const info = await run(plan.disambiguate, timeoutMs, context?.signal);
|
|
168
|
+
if (info.code === 0) {
|
|
169
|
+
const verdict = classifyClipboardInfo(info.stdout);
|
|
170
|
+
if (verdict === "noTextFlavour") {
|
|
171
|
+
return json({
|
|
172
|
+
...head,
|
|
173
|
+
backend: plan.backend,
|
|
174
|
+
outcome: "noTextFlavour",
|
|
175
|
+
reason: "the clipboard holds something with no text flavour (an image or a file reference); its content is deliberately not returned",
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
return json({ ...head, backend: plan.backend, outcome: "empty", reason: outcome.reason });
|
|
179
|
+
}
|
|
180
|
+
// The probe itself failed. That does NOT make the clipboard empty — it
|
|
181
|
+
// makes the distinction unknown, and the result says which.
|
|
182
|
+
return json({
|
|
183
|
+
...head,
|
|
184
|
+
backend: plan.backend,
|
|
185
|
+
outcome: "empty",
|
|
186
|
+
reason: outcome.reason,
|
|
187
|
+
unknown: [
|
|
188
|
+
{
|
|
189
|
+
field: "emptyOrNoTextFlavour",
|
|
190
|
+
probe: plan.disambiguate.argv.join(" "),
|
|
191
|
+
reason: "the primary read returned no bytes and the flavour probe failed, so whether the clipboard is empty or holds a non-text flavour could not be established",
|
|
192
|
+
},
|
|
193
|
+
],
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
if (outcome.outcome === "read") {
|
|
197
|
+
const capped = capText(outcome.text, maxChars);
|
|
198
|
+
return json({
|
|
199
|
+
...head,
|
|
200
|
+
backend: plan.backend,
|
|
201
|
+
outcome: "read",
|
|
202
|
+
chars: capped.text.length,
|
|
203
|
+
truncated: capped.truncated || result.stdoutTruncated === true,
|
|
204
|
+
text: capped.text,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
return json({ ...head, backend: plan.backend, ...outcome });
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
// ---------------------------------------------------------------------------
|
|
211
|
+
// ClipboardWrite
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
export const clipboardWrite = buildTool({
|
|
214
|
+
name: "ClipboardWrite",
|
|
215
|
+
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
|
+
inputSchema: z.object({
|
|
217
|
+
text: z
|
|
218
|
+
.string()
|
|
219
|
+
.max(10_000_000)
|
|
220
|
+
.describe("the text to place on the clipboard; it is piped in on stdin, never as an argument"),
|
|
221
|
+
dryRun: dryRunSchema,
|
|
222
|
+
timeoutMs: timeoutSchema,
|
|
223
|
+
}),
|
|
224
|
+
readOnly: false,
|
|
225
|
+
destructive: true,
|
|
226
|
+
concurrencySafe: false,
|
|
227
|
+
scope: "external",
|
|
228
|
+
ioCapability: "process",
|
|
229
|
+
execute: async (input, context) => {
|
|
230
|
+
const platform = hostPlatform();
|
|
231
|
+
const env = sessionEnv();
|
|
232
|
+
const timeoutMs = input.timeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS;
|
|
233
|
+
const head = { tool: "ClipboardWrite", platform };
|
|
234
|
+
// Resolved BEFORE the dryRun branch, by the same call: the dry run and the
|
|
235
|
+
// real write differ only in whether the plan is then executed.
|
|
236
|
+
const plan = planClipboardWrite(platform, env, input.text);
|
|
237
|
+
if (!plan.ok)
|
|
238
|
+
return json({ ...head, ...plan.unavailable });
|
|
239
|
+
if (input.dryRun === true) {
|
|
240
|
+
return json({
|
|
241
|
+
...head,
|
|
242
|
+
outcome: "dryRun",
|
|
243
|
+
backend: plan.backend,
|
|
244
|
+
plan: planJson(plan.request),
|
|
245
|
+
wouldWriteChars: input.text.length,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
const result = await run(plan.request, timeoutMs, context?.signal);
|
|
249
|
+
if (result.refused === true) {
|
|
250
|
+
return json({ ...head, outcome: "failed", backend: plan.backend, reason: result.stderr });
|
|
251
|
+
}
|
|
252
|
+
if (result.missing) {
|
|
253
|
+
return json({
|
|
254
|
+
...head,
|
|
255
|
+
backend: plan.backend,
|
|
256
|
+
...unavailable("program", "the clipboard program for this platform is not installed on this host", plan.backend),
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
if (result.timedOut) {
|
|
260
|
+
return json({
|
|
261
|
+
...head,
|
|
262
|
+
outcome: "failed",
|
|
263
|
+
backend: plan.backend,
|
|
264
|
+
reason: "the clipboard writer did not finish within its timeout and was killed — whether the clipboard was replaced is unknown",
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
if (result.code !== 0) {
|
|
268
|
+
return json({
|
|
269
|
+
...head,
|
|
270
|
+
outcome: "failed",
|
|
271
|
+
backend: plan.backend,
|
|
272
|
+
reason: `${plan.backend} exited ${result.code}: ${result.stderr.trim()}`,
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
return json({
|
|
276
|
+
...head,
|
|
277
|
+
outcome: "written",
|
|
278
|
+
backend: plan.backend,
|
|
279
|
+
chars: input.text.length,
|
|
280
|
+
});
|
|
281
|
+
},
|
|
282
|
+
});
|
|
283
|
+
// ---------------------------------------------------------------------------
|
|
284
|
+
// DesktopNotify
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
const MAX_NOTIFY_FIELD = 2_000;
|
|
287
|
+
export const desktopNotify = buildTool({
|
|
288
|
+
name: "DesktopNotify",
|
|
289
|
+
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
|
+
inputSchema: z.object({
|
|
291
|
+
title: z.string().min(1).max(MAX_NOTIFY_FIELD).describe("the notification's title line"),
|
|
292
|
+
body: z.string().max(MAX_NOTIFY_FIELD).describe("the notification's body text"),
|
|
293
|
+
subtitle: z
|
|
294
|
+
.string()
|
|
295
|
+
.max(MAX_NOTIFY_FIELD)
|
|
296
|
+
.optional()
|
|
297
|
+
.describe("a second line, shown as a subtitle on macOS and Windows; the FreeDesktop spec has no subtitle, so on Linux it is folded into the top of the body rather than dropped"),
|
|
298
|
+
urgency: z
|
|
299
|
+
.enum(["low", "normal", "critical"])
|
|
300
|
+
.optional()
|
|
301
|
+
.describe('Linux only; ignored elsewhere (default "normal")'),
|
|
302
|
+
sound: z
|
|
303
|
+
.boolean()
|
|
304
|
+
.optional()
|
|
305
|
+
.describe("play the platform's default notification sound (default false)"),
|
|
306
|
+
expireMs: z
|
|
307
|
+
.number()
|
|
308
|
+
.int()
|
|
309
|
+
.min(0)
|
|
310
|
+
.max(600_000)
|
|
311
|
+
.optional()
|
|
312
|
+
.describe("Linux only: how long the notification stays up. Omitted means the flag is not sent at all and the daemon's own default applies - note that 0 is NOT that, it is libnotify's \"never expire\"."),
|
|
313
|
+
dryRun: dryRunSchema,
|
|
314
|
+
timeoutMs: timeoutSchema,
|
|
315
|
+
}),
|
|
316
|
+
readOnly: false,
|
|
317
|
+
concurrencySafe: true,
|
|
318
|
+
scope: "external",
|
|
319
|
+
ioCapability: "process",
|
|
320
|
+
execute: async (input, context) => {
|
|
321
|
+
const platform = hostPlatform();
|
|
322
|
+
const env = sessionEnv();
|
|
323
|
+
const timeoutMs = input.timeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS;
|
|
324
|
+
const head = { tool: "DesktopNotify", platform };
|
|
325
|
+
const plan = planNotify(platform, env, {
|
|
326
|
+
title: input.title,
|
|
327
|
+
body: input.body,
|
|
328
|
+
...(input.subtitle === undefined ? {} : { subtitle: input.subtitle }),
|
|
329
|
+
urgency: (input.urgency ?? "normal"),
|
|
330
|
+
sound: input.sound ?? false,
|
|
331
|
+
...(input.expireMs === undefined ? {} : { expireMs: input.expireMs }),
|
|
332
|
+
});
|
|
333
|
+
if (!plan.ok)
|
|
334
|
+
return json({ ...head, ...plan.unavailable });
|
|
335
|
+
if (input.dryRun === true) {
|
|
336
|
+
return json({
|
|
337
|
+
...head,
|
|
338
|
+
outcome: "dryRun",
|
|
339
|
+
backend: plan.backend,
|
|
340
|
+
plan: planJson(plan.request),
|
|
341
|
+
...(plan.sanitized === true ? { sanitized: true } : {}),
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
const result = await run(plan.request, timeoutMs, context?.signal);
|
|
345
|
+
const outcome = classifyNotify(result);
|
|
346
|
+
return json({
|
|
347
|
+
...head,
|
|
348
|
+
backend: plan.backend,
|
|
349
|
+
...(plan.sanitized === true
|
|
350
|
+
? {
|
|
351
|
+
sanitized: true,
|
|
352
|
+
sanitizedReason: "characters an XML 1.0 document cannot carry were removed from the text before it was shown",
|
|
353
|
+
}
|
|
354
|
+
: {}),
|
|
355
|
+
...outcome,
|
|
356
|
+
});
|
|
357
|
+
},
|
|
358
|
+
});
|
|
359
|
+
// ---------------------------------------------------------------------------
|
|
360
|
+
// OpenExternal
|
|
361
|
+
// ---------------------------------------------------------------------------
|
|
362
|
+
export const openExternal = buildTool({
|
|
363
|
+
name: "OpenExternal",
|
|
364
|
+
description: `Hand a URL or a local path to the operating system to open with whatever is registered for it. The scheme set is an ALLOW-LIST and it is short: ${OPENABLE_SCHEMES.join(", ")}. Everything else is refused by name — file: (it would bypass this tool's path containment), smb: and nfs: (they mount a remote share and can leak an authentication handshake to the host named in the URL), javascript:, data:, and the Windows ms-* shell handlers. The allowSchemes input can only NARROW that set, never widen it. What is checked is what is opened: an http(s) URL is handed to the desktop in its normalised form rather than as you typed it, a URL containing a backslash is refused (parsers disagree about whether that is a path separator, so the host checked here would not be the host opened there), a URL carrying credentials before the "@" is refused (they would sit in a process listing and in this transcript, and "google.com@evil.example" reads as the host it is not), and a mailto: may carry only the headers RFC 6068 calls safe — attach= is refused by name, because a mail client that honours it reads a local file into the message. A target with no scheme is treated as a filesystem path, resolved inside the workspace root with symlinks followed, and refused if it lands outside; an EXECUTABLE file is refused even inside the workspace, because "open this" and "run this" are the same gesture to a desktop. The result says "handedOff", not "opened": every opener returns as soon as the handler has been asked and none of them report what it then did.`,
|
|
365
|
+
inputSchema: z.object({
|
|
366
|
+
target: z
|
|
367
|
+
.string()
|
|
368
|
+
.min(1)
|
|
369
|
+
.max(4_096)
|
|
370
|
+
.describe("an https, http or mailto URL, or a path relative to the workspace root (or absolute inside it)"),
|
|
371
|
+
allowSchemes: z
|
|
372
|
+
.array(z.enum(OPENABLE_SCHEMES))
|
|
373
|
+
.min(1)
|
|
374
|
+
.optional()
|
|
375
|
+
.describe(`narrow the schemes this call will open to a subset of ${OPENABLE_SCHEMES.join(", ")} (default: all three). This cannot widen the set — there is no input that makes file:, smb: or ms-* openable.`),
|
|
376
|
+
dryRun: dryRunSchema,
|
|
377
|
+
timeoutMs: timeoutSchema,
|
|
378
|
+
}),
|
|
379
|
+
readOnly: false,
|
|
380
|
+
concurrencySafe: false,
|
|
381
|
+
scope: "external",
|
|
382
|
+
ioCapability: "process",
|
|
383
|
+
execute: async (input, context) => {
|
|
384
|
+
const platform = hostPlatform();
|
|
385
|
+
const env = sessionEnv();
|
|
386
|
+
const timeoutMs = input.timeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS;
|
|
387
|
+
const head = { tool: "OpenExternal", platform };
|
|
388
|
+
const allowed = (input.allowSchemes ?? [...OPENABLE_SCHEMES]);
|
|
389
|
+
const decision = classifyTarget(input.target, allowed);
|
|
390
|
+
if (!decision.ok) {
|
|
391
|
+
return json({
|
|
392
|
+
...head,
|
|
393
|
+
outcome: "refused",
|
|
394
|
+
reason: decision.reason,
|
|
395
|
+
...(decision.scheme === undefined ? {} : { scheme: decision.scheme }),
|
|
396
|
+
allowedSchemes: allowed,
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
let value = decision.target.value;
|
|
400
|
+
let resolvedPath;
|
|
401
|
+
if (decision.target.kind === "path") {
|
|
402
|
+
let safe;
|
|
403
|
+
try {
|
|
404
|
+
safe = resolveSafe("OpenExternal", decision.target.value);
|
|
405
|
+
}
|
|
406
|
+
catch (err) {
|
|
407
|
+
if (err instanceof ToolPermissionError) {
|
|
408
|
+
return json({
|
|
409
|
+
...head,
|
|
410
|
+
outcome: "refused",
|
|
411
|
+
reason: err.message,
|
|
412
|
+
kind: "path",
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
throw err;
|
|
416
|
+
}
|
|
417
|
+
const refusal = refusePath(safe.real, fs().stat(safe.real));
|
|
418
|
+
if (refusal !== undefined) {
|
|
419
|
+
return json({ ...head, outcome: "refused", reason: refusal, kind: "path", path: safe.rel });
|
|
420
|
+
}
|
|
421
|
+
// The REAL path is what is opened: symlinks already followed, so the
|
|
422
|
+
// desktop opens the thing containment actually approved.
|
|
423
|
+
value = safe.real;
|
|
424
|
+
resolvedPath = safe.rel;
|
|
425
|
+
}
|
|
426
|
+
const plan = planOpen(platform, env, value);
|
|
427
|
+
if (!plan.ok)
|
|
428
|
+
return json({ ...head, ...plan.unavailable });
|
|
429
|
+
const kindFields = {
|
|
430
|
+
kind: decision.target.kind,
|
|
431
|
+
...(decision.target.kind === "url"
|
|
432
|
+
? {
|
|
433
|
+
scheme: decision.target.scheme,
|
|
434
|
+
// Reported whenever normalising changed the caller's text, so a
|
|
435
|
+
// rewrite the gate performed is visible rather than silent — the
|
|
436
|
+
// URL that was checked IS the URL that was opened, and the caller
|
|
437
|
+
// can see when the two differ from what they sent.
|
|
438
|
+
...(decision.target.given === undefined
|
|
439
|
+
? {}
|
|
440
|
+
: { url: decision.target.value, given: decision.target.given }),
|
|
441
|
+
}
|
|
442
|
+
: {}),
|
|
443
|
+
...(resolvedPath === undefined ? {} : { path: resolvedPath }),
|
|
444
|
+
};
|
|
445
|
+
if (input.dryRun === true) {
|
|
446
|
+
return json({
|
|
447
|
+
...head,
|
|
448
|
+
outcome: "dryRun",
|
|
449
|
+
backend: plan.backend,
|
|
450
|
+
...kindFields,
|
|
451
|
+
plan: planJson(plan.request),
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
const result = await run(plan.request, timeoutMs, context?.signal);
|
|
455
|
+
return json({
|
|
456
|
+
...head,
|
|
457
|
+
backend: plan.backend,
|
|
458
|
+
...kindFields,
|
|
459
|
+
...classifyOpen(plan.backend, result),
|
|
460
|
+
});
|
|
461
|
+
},
|
|
462
|
+
});
|
|
463
|
+
// ---------------------------------------------------------------------------
|
|
464
|
+
// PrintDocument
|
|
465
|
+
// ---------------------------------------------------------------------------
|
|
466
|
+
export const printDocument = buildTool({
|
|
467
|
+
name: "PrintDocument",
|
|
468
|
+
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
|
+
inputSchema: z.object({
|
|
470
|
+
path: z
|
|
471
|
+
.string()
|
|
472
|
+
.min(1)
|
|
473
|
+
.max(4_096)
|
|
474
|
+
.describe("the document, as a path relative to the workspace root (or absolute inside it)"),
|
|
475
|
+
printer: z
|
|
476
|
+
.string()
|
|
477
|
+
.max(127)
|
|
478
|
+
.optional()
|
|
479
|
+
.describe("the CUPS destination or Windows queue name; omitted means the system default"),
|
|
480
|
+
copies: z
|
|
481
|
+
.number()
|
|
482
|
+
.int()
|
|
483
|
+
.min(1)
|
|
484
|
+
.max(MAX_COPIES)
|
|
485
|
+
.optional()
|
|
486
|
+
.describe("how many copies (default 1)"),
|
|
487
|
+
pageRanges: z
|
|
488
|
+
.string()
|
|
489
|
+
.max(200)
|
|
490
|
+
.optional()
|
|
491
|
+
.describe('a CUPS page range such as "1", "1-4" or "1,3,5-9"; CUPS only'),
|
|
492
|
+
duplex: z
|
|
493
|
+
.enum(DUPLEX_VALUES)
|
|
494
|
+
.optional()
|
|
495
|
+
.describe("two-sided printing; CUPS only"),
|
|
496
|
+
dryRun: dryRunSchema,
|
|
497
|
+
timeoutMs: timeoutSchema,
|
|
498
|
+
}),
|
|
499
|
+
readOnly: false,
|
|
500
|
+
destructive: true,
|
|
501
|
+
concurrencySafe: false,
|
|
502
|
+
scope: "external",
|
|
503
|
+
ioCapability: "process",
|
|
504
|
+
// Printing is the one effect here that spends a physical consumable and
|
|
505
|
+
// cannot be undone by another tool call. The justification is judged
|
|
506
|
+
// against the run's goal and recorded beside the job.
|
|
507
|
+
requireJustification: true,
|
|
508
|
+
execute: async (input, context) => {
|
|
509
|
+
const platform = hostPlatform();
|
|
510
|
+
const timeoutMs = input.timeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS;
|
|
511
|
+
const head = { tool: "PrintDocument", platform };
|
|
512
|
+
let safe;
|
|
513
|
+
try {
|
|
514
|
+
safe = resolveSafe("PrintDocument", input.path);
|
|
515
|
+
}
|
|
516
|
+
catch (err) {
|
|
517
|
+
if (err instanceof ToolPermissionError) {
|
|
518
|
+
return json({ ...head, outcome: "refused", reason: err.message });
|
|
519
|
+
}
|
|
520
|
+
throw err;
|
|
521
|
+
}
|
|
522
|
+
const facts = fs().stat(safe.real);
|
|
523
|
+
if (facts === undefined || !facts.exists || facts.isDirectory) {
|
|
524
|
+
return json({
|
|
525
|
+
...head,
|
|
526
|
+
outcome: "refused",
|
|
527
|
+
path: safe.rel,
|
|
528
|
+
reason: "the path does not exist, or is a directory",
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
const options = {
|
|
532
|
+
absolutePath: safe.real,
|
|
533
|
+
...(input.printer === undefined ? {} : { printer: input.printer }),
|
|
534
|
+
copies: input.copies ?? 1,
|
|
535
|
+
...(input.pageRanges === undefined ? {} : { pageRanges: input.pageRanges }),
|
|
536
|
+
...(input.duplex === undefined ? {} : { duplex: input.duplex }),
|
|
537
|
+
};
|
|
538
|
+
const refusal = refusePrintOptions(platform, options);
|
|
539
|
+
if (refusal !== undefined) {
|
|
540
|
+
return json({ ...head, outcome: "refused", path: safe.rel, reason: refusal });
|
|
541
|
+
}
|
|
542
|
+
// The queue probe runs on BOTH paths. See the module header of ./lib/print.
|
|
543
|
+
const probeRequest = planPrinterProbe(platform);
|
|
544
|
+
if (probeRequest === undefined) {
|
|
545
|
+
return json({ ...head, ...unsupportedPlatform(platform, "printing") });
|
|
546
|
+
}
|
|
547
|
+
const probe = await run(probeRequest, timeoutMs, context?.signal);
|
|
548
|
+
const queues = probe.refused === true || probe.missing || probe.timedOut
|
|
549
|
+
? unreadableQueues(probe.missing
|
|
550
|
+
? "lpstat is not installed on this host, so the queue could not be read"
|
|
551
|
+
: probe.timedOut
|
|
552
|
+
? "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
|
+
: probe.stderr)
|
|
554
|
+
: parseQueues(probe.stdout, probe.stderr);
|
|
555
|
+
const queueJson = {
|
|
556
|
+
printers: queues.printers,
|
|
557
|
+
defaultPrinter: queues.defaultPrinter,
|
|
558
|
+
...(queues.schedulerDown ? { schedulerDown: true } : {}),
|
|
559
|
+
...(queues.unreadable === null ? {} : { queueUnreadable: queues.unreadable }),
|
|
560
|
+
};
|
|
561
|
+
if (queues.schedulerDown) {
|
|
562
|
+
return json({
|
|
563
|
+
...head,
|
|
564
|
+
path: safe.rel,
|
|
565
|
+
queue: queueJson,
|
|
566
|
+
...unavailable("program", "the print scheduler is not running on this host, so no queue would accept this job — that is not the same as the host having no printers", "cupsd"),
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
const plan = planPrint(platform, options);
|
|
570
|
+
if (!plan.ok)
|
|
571
|
+
return json({ ...head, path: safe.rel, queue: queueJson, ...plan.unavailable });
|
|
572
|
+
if (input.dryRun === true) {
|
|
573
|
+
return json({
|
|
574
|
+
...head,
|
|
575
|
+
outcome: "dryRun",
|
|
576
|
+
path: safe.rel,
|
|
577
|
+
backend: plan.backend,
|
|
578
|
+
queue: queueJson,
|
|
579
|
+
plan: planJson(plan.request),
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
const result = await run(plan.request, timeoutMs, context?.signal);
|
|
583
|
+
return json({
|
|
584
|
+
...head,
|
|
585
|
+
path: safe.rel,
|
|
586
|
+
backend: plan.backend,
|
|
587
|
+
queue: queueJson,
|
|
588
|
+
...classifyPrint(result),
|
|
589
|
+
});
|
|
590
|
+
},
|
|
591
|
+
});
|
|
592
|
+
// ---------------------------------------------------------------------------
|
|
593
|
+
// WindowList
|
|
594
|
+
// ---------------------------------------------------------------------------
|
|
595
|
+
export const windowList = buildTool({
|
|
596
|
+
name: "WindowList",
|
|
597
|
+
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
|
+
inputSchema: z.object({ timeoutMs: timeoutSchema }),
|
|
599
|
+
readOnly: true,
|
|
600
|
+
concurrencySafe: true,
|
|
601
|
+
scope: "external",
|
|
602
|
+
ioCapability: "process",
|
|
603
|
+
execute: async (input, context) => {
|
|
604
|
+
const platform = hostPlatform();
|
|
605
|
+
const env = sessionEnv();
|
|
606
|
+
const timeoutMs = input.timeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS;
|
|
607
|
+
const head = { tool: "WindowList", platform };
|
|
608
|
+
const plan = planWindowList(platform, env);
|
|
609
|
+
if (!plan.ok)
|
|
610
|
+
return json({ ...head, ...plan.unavailable });
|
|
611
|
+
const result = await run(plan.request, timeoutMs, context?.signal);
|
|
612
|
+
const outcome = classifyWindowList(plan.backend, result);
|
|
613
|
+
if (outcome.outcome === "listed") {
|
|
614
|
+
return json({
|
|
615
|
+
...head,
|
|
616
|
+
backend: plan.backend,
|
|
617
|
+
outcome: "listed",
|
|
618
|
+
count: outcome.windows.length,
|
|
619
|
+
truncated: outcome.truncated,
|
|
620
|
+
...(outcome.truncated
|
|
621
|
+
? {
|
|
622
|
+
unknown: [
|
|
623
|
+
{
|
|
624
|
+
field: "windows",
|
|
625
|
+
probe: plan.request.argv.join(" "),
|
|
626
|
+
reason: "the enumerator's output hit the capture cap, so this list is a PREFIX of the open windows rather than all of them",
|
|
627
|
+
},
|
|
628
|
+
],
|
|
629
|
+
}
|
|
630
|
+
: {}),
|
|
631
|
+
windows: outcome.windows,
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
return json({ ...head, backend: plan.backend, ...outcome });
|
|
635
|
+
},
|
|
636
|
+
});
|
|
637
|
+
// ---------------------------------------------------------------------------
|
|
638
|
+
// UserPresence
|
|
639
|
+
// ---------------------------------------------------------------------------
|
|
640
|
+
const DEFAULT_IDLE_THRESHOLD_SECONDS = 300;
|
|
641
|
+
export const userPresence = buildTool({
|
|
642
|
+
name: "UserPresence",
|
|
643
|
+
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
|
+
inputSchema: z.object({
|
|
645
|
+
idleThresholdSeconds: z
|
|
646
|
+
.number()
|
|
647
|
+
.int()
|
|
648
|
+
.min(1)
|
|
649
|
+
.max(86_400)
|
|
650
|
+
.optional()
|
|
651
|
+
.describe(`how many idle seconds count as "idle" in the derived boolean (default ${DEFAULT_IDLE_THRESHOLD_SECONDS}); the boolean is null whenever idleSeconds is`),
|
|
652
|
+
timeoutMs: timeoutSchema,
|
|
653
|
+
}),
|
|
654
|
+
readOnly: true,
|
|
655
|
+
concurrencySafe: true,
|
|
656
|
+
scope: "external",
|
|
657
|
+
ioCapability: "process",
|
|
658
|
+
execute: async (input, context) => {
|
|
659
|
+
const platform = hostPlatform();
|
|
660
|
+
const env = sessionEnv();
|
|
661
|
+
const timeoutMs = input.timeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS;
|
|
662
|
+
const threshold = input.idleThresholdSeconds ?? DEFAULT_IDLE_THRESHOLD_SECONDS;
|
|
663
|
+
const unknowns = new Unknowns();
|
|
664
|
+
let idleSeconds = null;
|
|
665
|
+
let screenLocked = null;
|
|
666
|
+
let lockedNote;
|
|
667
|
+
const { probes, notes } = planPresence(platform, env);
|
|
668
|
+
for (const [field, reason] of notes)
|
|
669
|
+
unknowns.add(field, "(no probe on this platform)", reason);
|
|
670
|
+
for (const probe of probes) {
|
|
671
|
+
const result = await run(probe.request, timeoutMs, context?.signal);
|
|
672
|
+
const argvText = probe.request.argv.join(" ");
|
|
673
|
+
if (result.refused === true || result.missing || result.timedOut || result.code !== 0) {
|
|
674
|
+
const why = result.refused === true
|
|
675
|
+
? result.stderr
|
|
676
|
+
: result.missing
|
|
677
|
+
? "the probe program is not installed on this host"
|
|
678
|
+
: result.timedOut
|
|
679
|
+
? "the probe did not finish within its timeout and was killed"
|
|
680
|
+
: `the probe exited ${result.code}: ${result.stderr.trim()}`;
|
|
681
|
+
unknowns.add(probe.field, argvText, why);
|
|
682
|
+
if (platform === "win32" && probe.field === "idleSeconds") {
|
|
683
|
+
unknowns.add("screenLocked", argvText, why);
|
|
684
|
+
}
|
|
685
|
+
continue;
|
|
686
|
+
}
|
|
687
|
+
if (platform === "darwin" && probe.field === "idleSeconds") {
|
|
688
|
+
idleSeconds = parseIoregIdleSeconds(result.stdout);
|
|
689
|
+
if (idleSeconds === null) {
|
|
690
|
+
unknowns.add(probe.field, argvText, 'the dump contained no "HIDIdleTime" key');
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
else if (platform === "darwin") {
|
|
694
|
+
screenLocked = parseIoregLocked(result.stdout);
|
|
695
|
+
if (screenLocked === null) {
|
|
696
|
+
unknowns.add(probe.field, argvText, 'the dump contained neither "IOConsoleLocked" nor "CGSSessionScreenIsLocked" — this macOS version reports lock state somewhere this parser does not look');
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
else if (platform === "linux" && probe.field === "idleSeconds") {
|
|
700
|
+
idleSeconds = parseXprintidleSeconds(result.stdout);
|
|
701
|
+
if (idleSeconds === null) {
|
|
702
|
+
unknowns.add(probe.field, argvText, "xprintidle printed something that was not a number");
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
else if (platform === "linux") {
|
|
706
|
+
const parsed = parseLoginctl(result.stdout);
|
|
707
|
+
screenLocked = parsed.locked;
|
|
708
|
+
if (parsed.locked === null) {
|
|
709
|
+
unknowns.add(probe.field, argvText, "loginctl reported no LockedHint for this session");
|
|
710
|
+
}
|
|
711
|
+
else {
|
|
712
|
+
lockedNote =
|
|
713
|
+
'LockedHint is a hint: a screen locker that does not call SetLockedHint leaves it "no" on a locked screen';
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
else if (platform === "win32") {
|
|
717
|
+
const parsed = parseWindowsPresence(result.stdout);
|
|
718
|
+
idleSeconds = parsed.idleSeconds;
|
|
719
|
+
screenLocked = parsed.locked;
|
|
720
|
+
if (parsed.idleSeconds === null) {
|
|
721
|
+
unknowns.add(probe.field, argvText, "GetLastInputInfo could not be called");
|
|
722
|
+
}
|
|
723
|
+
if (parsed.locked === null) {
|
|
724
|
+
unknowns.add("screenLocked", argvText, "the LogonUI probe printed nothing parseable");
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
const kind = sessionKind(platform, env);
|
|
729
|
+
if (kind === "ssh" && idleSeconds !== null) {
|
|
730
|
+
// The number is real; what it MEANS is not what the caller wants. Say so
|
|
731
|
+
// rather than deleting it.
|
|
732
|
+
unknowns.add("idle", "sessionKind", "this is an ssh session, so idle time measures the machine at the far end and not the person running this harness");
|
|
733
|
+
}
|
|
734
|
+
return json({
|
|
735
|
+
tool: "UserPresence",
|
|
736
|
+
platform,
|
|
737
|
+
sessionKind: kind,
|
|
738
|
+
idleSeconds,
|
|
739
|
+
idleThresholdSeconds: threshold,
|
|
740
|
+
// Null in, null out. A derived boolean that defaults is the exact bug
|
|
741
|
+
// this tool exists not to have.
|
|
742
|
+
idle: idleSeconds === null ? null : idleSeconds >= threshold,
|
|
743
|
+
screenLocked,
|
|
744
|
+
...(lockedNote === undefined ? {} : { screenLockedNote: lockedNote }),
|
|
745
|
+
...(unknowns.size > 0 ? { unknown: unknowns.list() } : {}),
|
|
746
|
+
});
|
|
747
|
+
},
|
|
748
|
+
});
|
|
749
|
+
// ---------------------------------------------------------------------------
|
|
750
|
+
// PowerAssertion
|
|
751
|
+
// ---------------------------------------------------------------------------
|
|
752
|
+
function statePath() {
|
|
753
|
+
return join(workspaceRoot(), STATE_RELATIVE_PATH);
|
|
754
|
+
}
|
|
755
|
+
function readState() {
|
|
756
|
+
const text = fs().readText(statePath());
|
|
757
|
+
if (text === undefined)
|
|
758
|
+
return { kind: "none" };
|
|
759
|
+
let parsed;
|
|
760
|
+
try {
|
|
761
|
+
parsed = JSON.parse(text);
|
|
762
|
+
}
|
|
763
|
+
catch {
|
|
764
|
+
return { kind: "unreadable", reason: "the state file is not valid JSON" };
|
|
765
|
+
}
|
|
766
|
+
// Every field is validated, not just the two that used to be: a record with
|
|
767
|
+
// a pid and a marker but a missing deadline used to be accepted here and
|
|
768
|
+
// then threw `RangeError: Invalid time value` out of `execute` when the
|
|
769
|
+
// result was formatted. See `isAssertionState`.
|
|
770
|
+
if (!isAssertionState(parsed)) {
|
|
771
|
+
return {
|
|
772
|
+
kind: "unreadable",
|
|
773
|
+
reason: "the state file parsed but is not a complete assertion record (it needs pid, marker, backend, platform, scope and numeric startedAt/expiresAt)",
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
return { kind: "state", state: parsed };
|
|
777
|
+
}
|
|
778
|
+
export const powerAssertion = buildTool({
|
|
779
|
+
name: "PowerAssertion",
|
|
780
|
+
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
|
+
inputSchema: z.object({
|
|
782
|
+
action: z
|
|
783
|
+
.enum(["hold", "release", "status"])
|
|
784
|
+
.describe('"hold" starts a bounded inhibitor, "release" ends the one this package is holding, "status" reports on it without changing anything'),
|
|
785
|
+
scope: z
|
|
786
|
+
.enum(["system", "display"])
|
|
787
|
+
.optional()
|
|
788
|
+
.describe('what to keep awake: "system" prevents idle sleep, "display" also keeps the screen on (default "system")'),
|
|
789
|
+
maxMinutes: z
|
|
790
|
+
.number()
|
|
791
|
+
.min(1)
|
|
792
|
+
.max(MAX_HOLD_MINUTES)
|
|
793
|
+
.optional()
|
|
794
|
+
.describe(`how long the inhibitor may last before the holder ends it by itself (default ${DEFAULT_HOLD_MINUTES}, maximum ${MAX_HOLD_MINUTES})`),
|
|
795
|
+
reason: z
|
|
796
|
+
.string()
|
|
797
|
+
.max(120)
|
|
798
|
+
.optional()
|
|
799
|
+
.describe("a short printable-ASCII note shown to an operator running `systemd-inhibit --list`; Linux only"),
|
|
800
|
+
dryRun: dryRunSchema,
|
|
801
|
+
timeoutMs: timeoutSchema,
|
|
802
|
+
}),
|
|
803
|
+
readOnly: false,
|
|
804
|
+
// "Destructive" is used here in the sense the permission engine cares
|
|
805
|
+
// about: the effect OUTLIVES the call and the process, so it must be gated
|
|
806
|
+
// like one rather than treated as a read. `status` is read-only in
|
|
807
|
+
// practice, but a static flag covers the whole tool and fails closed.
|
|
808
|
+
destructive: true,
|
|
809
|
+
concurrencySafe: false,
|
|
810
|
+
scope: "external",
|
|
811
|
+
ioCapability: "process",
|
|
812
|
+
execute: async (input, context) => {
|
|
813
|
+
const platform = hostPlatform();
|
|
814
|
+
const timeoutMs = input.timeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS;
|
|
815
|
+
const head = { tool: "PowerAssertion", platform, action: input.action };
|
|
816
|
+
const stateRead = readState();
|
|
817
|
+
if (stateRead.kind === "unreadable") {
|
|
818
|
+
// FAIL CLOSED, AND SAY WHICH FILE. A record that exists but cannot be
|
|
819
|
+
// trusted is not "no assertion is held": there may be a holder out
|
|
820
|
+
// there whose pid this file was the only copy of. Nothing is signalled
|
|
821
|
+
// (the pid is exactly the field that cannot be trusted) and nothing is
|
|
822
|
+
// started (a new holder would overwrite the last copy of the old one),
|
|
823
|
+
// and the path is named so an operator can clear it.
|
|
824
|
+
return json({
|
|
825
|
+
...head,
|
|
826
|
+
outcome: "unreadableState",
|
|
827
|
+
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: STATE_RELATIVE_PATH,
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
const state = stateRead.kind === "state" ? stateRead.state : undefined;
|
|
832
|
+
const liveness = async (recorded) => {
|
|
833
|
+
const probeRequest = planProcessProbe(platform, recorded.pid);
|
|
834
|
+
if (probeRequest === undefined)
|
|
835
|
+
return { verdict: "unknown", argv: [] };
|
|
836
|
+
const probe = await run(probeRequest, timeoutMs, context?.signal);
|
|
837
|
+
return { verdict: classifyLiveness(recorded.marker, probe), argv: probeRequest.argv };
|
|
838
|
+
};
|
|
839
|
+
const stateJson = (recorded) => ({
|
|
840
|
+
pid: recorded.pid,
|
|
841
|
+
backend: recorded.backend,
|
|
842
|
+
scope: recorded.scope,
|
|
843
|
+
startedAt: new Date(recorded.startedAt).toISOString(),
|
|
844
|
+
expiresAt: new Date(recorded.expiresAt).toISOString(),
|
|
845
|
+
expiresInMs: recorded.expiresAt - now(),
|
|
846
|
+
...(recorded.reason === null ? {} : { reason: recorded.reason }),
|
|
847
|
+
});
|
|
848
|
+
if (input.action === "status") {
|
|
849
|
+
if (state === undefined) {
|
|
850
|
+
return json({ ...head, outcome: "none", reason: "this package is holding no assertion" });
|
|
851
|
+
}
|
|
852
|
+
const { verdict, argv } = await liveness(state);
|
|
853
|
+
const expired = now() >= state.expiresAt;
|
|
854
|
+
return json({
|
|
855
|
+
...head,
|
|
856
|
+
outcome: verdict === "alive"
|
|
857
|
+
? expired
|
|
858
|
+
? "expiring"
|
|
859
|
+
: "held"
|
|
860
|
+
: verdict === "gone"
|
|
861
|
+
? "expired"
|
|
862
|
+
: verdict,
|
|
863
|
+
...stateJson(state),
|
|
864
|
+
liveness: verdict,
|
|
865
|
+
probe: argv.join(" "),
|
|
866
|
+
...(verdict === "unknown"
|
|
867
|
+
? {
|
|
868
|
+
unknown: [
|
|
869
|
+
{
|
|
870
|
+
field: "liveness",
|
|
871
|
+
probe: argv.join(" "),
|
|
872
|
+
reason: "the process probe could not answer, so whether the holder is still running is unknown — it is NOT that it has stopped",
|
|
873
|
+
},
|
|
874
|
+
],
|
|
875
|
+
}
|
|
876
|
+
: {}),
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
if (input.action === "release") {
|
|
880
|
+
if (state === undefined) {
|
|
881
|
+
return json({ ...head, outcome: "none", reason: "this package is holding no assertion" });
|
|
882
|
+
}
|
|
883
|
+
const { verdict, argv } = await liveness(state);
|
|
884
|
+
if (verdict === "unknown") {
|
|
885
|
+
// The state file is DELIBERATELY left in place: the holder may be
|
|
886
|
+
// alive and merely unreachable to `ps`, and forgetting it would leave
|
|
887
|
+
// an inhibitor nobody can release before its deadline.
|
|
888
|
+
return json({
|
|
889
|
+
...head,
|
|
890
|
+
outcome: "unknown",
|
|
891
|
+
...stateJson(state),
|
|
892
|
+
reason: "the process probe could not answer, so nothing was signalled and the recorded assertion was kept — it will still end at its own deadline",
|
|
893
|
+
probe: argv.join(" "),
|
|
894
|
+
});
|
|
895
|
+
}
|
|
896
|
+
if (verdict === "gone") {
|
|
897
|
+
fs().remove(statePath());
|
|
898
|
+
return json({
|
|
899
|
+
...head,
|
|
900
|
+
outcome: "expired",
|
|
901
|
+
...stateJson(state),
|
|
902
|
+
reason: "the holder had already ended; the recorded assertion was cleared",
|
|
903
|
+
});
|
|
904
|
+
}
|
|
905
|
+
if (verdict === "reused") {
|
|
906
|
+
fs().remove(statePath());
|
|
907
|
+
return json({
|
|
908
|
+
...head,
|
|
909
|
+
outcome: "stale",
|
|
910
|
+
...stateJson(state),
|
|
911
|
+
reason: "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 was cleared",
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
const killRequest = planRelease(platform, state.pid);
|
|
915
|
+
if (killRequest === undefined) {
|
|
916
|
+
return json({ ...head, ...unsupportedPlatform(platform, "power-assertion") });
|
|
917
|
+
}
|
|
918
|
+
if (input.dryRun === true) {
|
|
919
|
+
return json({
|
|
920
|
+
...head,
|
|
921
|
+
outcome: "dryRun",
|
|
922
|
+
...stateJson(state),
|
|
923
|
+
plan: planJson(killRequest),
|
|
924
|
+
});
|
|
925
|
+
}
|
|
926
|
+
const killed = await run(killRequest, timeoutMs, context?.signal);
|
|
927
|
+
if (killed.code !== 0 || killed.refused === true) {
|
|
928
|
+
return json({
|
|
929
|
+
...head,
|
|
930
|
+
outcome: "failed",
|
|
931
|
+
...stateJson(state),
|
|
932
|
+
reason: `the release command exited ${killed.code}: ${killed.stderr.trim()}`,
|
|
933
|
+
});
|
|
934
|
+
}
|
|
935
|
+
fs().remove(statePath());
|
|
936
|
+
return json({ ...head, outcome: "released", ...stateJson(state) });
|
|
937
|
+
}
|
|
938
|
+
// ---- hold ----
|
|
939
|
+
const scope = (input.scope ?? "system");
|
|
940
|
+
if (input.reason !== undefined && !SAFE_REASON.test(input.reason)) {
|
|
941
|
+
return json({
|
|
942
|
+
...head,
|
|
943
|
+
outcome: "refused",
|
|
944
|
+
reason: "the reason must be 1-120 printable ASCII characters with no control characters or newlines",
|
|
945
|
+
});
|
|
946
|
+
}
|
|
947
|
+
const bounded = boundedSeconds(input.maxMinutes ?? DEFAULT_HOLD_MINUTES);
|
|
948
|
+
if ("refusal" in bounded) {
|
|
949
|
+
return json({ ...head, outcome: "refused", reason: bounded.refusal });
|
|
950
|
+
}
|
|
951
|
+
if (state !== undefined && now() < state.expiresAt) {
|
|
952
|
+
const { verdict } = await liveness(state);
|
|
953
|
+
// "unknown" refuses as firmly as "alive" does, and that is the point.
|
|
954
|
+
// Starting a second holder would overwrite the state file, and the
|
|
955
|
+
// first holder - which may well be running - could then never be
|
|
956
|
+
// released by this package at all. It would still end at its own
|
|
957
|
+
// deadline, but nothing could end it sooner. A recorded assertion
|
|
958
|
+
// already PAST its deadline is safe to replace, which is why the expiry
|
|
959
|
+
// is checked before the probe rather than after it.
|
|
960
|
+
if (verdict === "alive" || verdict === "unknown") {
|
|
961
|
+
return json({
|
|
962
|
+
...head,
|
|
963
|
+
outcome: verdict === "alive" ? "alreadyHeld" : "refused",
|
|
964
|
+
...stateJson(state),
|
|
965
|
+
reason: verdict === "alive"
|
|
966
|
+
? "an assertion from this package is already held and has not expired; release it first, or wait for its deadline"
|
|
967
|
+
: "an assertion is recorded and has not expired, and the process probe could not say whether its holder is still running - starting a second one would overwrite the record and leave the first unreleasable, so nothing was started",
|
|
968
|
+
});
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
const plan = planHold(platform, scope, bounded.seconds, input.reason ?? null);
|
|
972
|
+
if (!plan.ok)
|
|
973
|
+
return json({ ...head, ...plan.unavailable });
|
|
974
|
+
const startedAt = now();
|
|
975
|
+
const expiresAt = startedAt + bounded.seconds * 1000;
|
|
976
|
+
if (input.dryRun === true) {
|
|
977
|
+
return json({
|
|
978
|
+
...head,
|
|
979
|
+
outcome: "dryRun",
|
|
980
|
+
backend: plan.backend,
|
|
981
|
+
scope,
|
|
982
|
+
seconds: bounded.seconds,
|
|
983
|
+
expiresAt: new Date(expiresAt).toISOString(),
|
|
984
|
+
plan: planJson(plan.request),
|
|
985
|
+
});
|
|
986
|
+
}
|
|
987
|
+
const detached = await detachHostCommand({ ...plan.request, timeoutMs });
|
|
988
|
+
if (!detached.ok) {
|
|
989
|
+
return json({
|
|
990
|
+
...head,
|
|
991
|
+
backend: plan.backend,
|
|
992
|
+
...(detached.missing
|
|
993
|
+
? unavailable("program", `the sleep inhibitor for this platform is not installed on this host (${plan.marker})`, plan.marker)
|
|
994
|
+
: { outcome: "failed", reason: detached.reason }),
|
|
995
|
+
});
|
|
996
|
+
}
|
|
997
|
+
const recorded = {
|
|
998
|
+
pid: detached.pid,
|
|
999
|
+
platform,
|
|
1000
|
+
backend: plan.backend,
|
|
1001
|
+
marker: plan.marker,
|
|
1002
|
+
scope,
|
|
1003
|
+
startedAt,
|
|
1004
|
+
expiresAt,
|
|
1005
|
+
reason: input.reason ?? null,
|
|
1006
|
+
};
|
|
1007
|
+
fs().writeTextAtomic(statePath(), JSON.stringify(recorded));
|
|
1008
|
+
return json({
|
|
1009
|
+
...head,
|
|
1010
|
+
outcome: "held",
|
|
1011
|
+
backend: plan.backend,
|
|
1012
|
+
scope,
|
|
1013
|
+
pid: detached.pid,
|
|
1014
|
+
seconds: bounded.seconds,
|
|
1015
|
+
startedAt: new Date(startedAt).toISOString(),
|
|
1016
|
+
expiresAt: new Date(expiresAt).toISOString(),
|
|
1017
|
+
note: "the deadline is enforced by the holder itself, so this assertion ends on time even if this harness stops",
|
|
1018
|
+
});
|
|
1019
|
+
},
|
|
1020
|
+
});
|
|
1021
|
+
/** Everything this package registers, in one place for the catalog wiring. */
|
|
1022
|
+
export const DESKTOP_TOOLS = [
|
|
1023
|
+
clipboardRead,
|
|
1024
|
+
clipboardWrite,
|
|
1025
|
+
desktopNotify,
|
|
1026
|
+
openExternal,
|
|
1027
|
+
printDocument,
|
|
1028
|
+
windowList,
|
|
1029
|
+
userPresence,
|
|
1030
|
+
powerAssertion,
|
|
1031
|
+
];
|