staysfixed 0.3.0 → 0.4.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/README.md +534 -402
- package/package.json +8 -3
- package/src/cli/index.js +14 -0
- package/src/v2/adapters/android-driver.js +1705 -0
- package/src/v2/adapters/android.js +1117 -0
- package/src/v2/adapters/contract.js +565 -0
- package/src/v2/adapters/electron.js +1594 -0
- package/src/v2/adapters/http.js +733 -0
- package/src/v2/adapters/ios-driver.js +1551 -0
- package/src/v2/adapters/ios.js +989 -0
- package/src/v2/adapters/isolate.js +739 -0
- package/src/v2/adapters/process.js +920 -0
- package/src/v2/adapters/source.js +1241 -0
- package/src/v2/adapters/web-driver.js +1532 -0
- package/src/v2/adapters/web.js +1009 -0
- package/src/v2/adapters/windows.js +1329 -0
- package/src/v2/browsers.js +1203 -0
- package/src/v2/cause.js +364 -0
- package/src/v2/check.js +1331 -0
- package/src/v2/ci.js +1209 -0
- package/src/v2/cli.js +657 -0
- package/src/v2/cluster.js +372 -0
- package/src/v2/coverage.js +1116 -0
- package/src/v2/detect.js +1199 -0
- package/src/v2/doctor.js +1690 -0
- package/src/v2/escalate.js +679 -0
- package/src/v2/init.js +1394 -0
- package/src/v2/intent.js +659 -0
- package/src/v2/journeys/from-routes.js +498 -0
- package/src/v2/journeys/from-suite.js +988 -0
- package/src/v2/journeys/index.js +651 -0
- package/src/v2/journeys/record.js +516 -0
- package/src/v2/mcp/server.js +374 -0
- package/src/v2/mcp/tools.js +1571 -0
- package/src/v2/normalise.js +783 -0
- package/src/v2/observation.js +877 -0
- package/src/v2/rank.js +672 -0
- package/src/v2/reference.js +1051 -0
- package/src/v2/remote.js +911 -0
- package/src/v2/run.js +964 -0
- package/src/v2/sealed.js +564 -0
- package/src/v2/selfcheck.js +564 -0
- package/src/v2/ship.js +684 -0
- package/src/v2/store.js +703 -0
- package/src/v2/types.js +503 -0
- package/src/v2/waiver.js +511 -0
- package/src/watch/panel.js +73 -44
package/src/v2/remote.js
ADDED
|
@@ -0,0 +1,911 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Running a check on another machine.
|
|
3
|
+
*
|
|
4
|
+
* This is the general mechanism, not a Windows special case. Android on a spare box, iOS on
|
|
5
|
+
* a Mac mini and Windows on an office desktop are all the same shape:
|
|
6
|
+
*
|
|
7
|
+
* THE ENGINE RUNS HERE. THE WALKING HAPPENS THERE. OBSERVATIONS COME BACK.
|
|
8
|
+
*
|
|
9
|
+
* Nothing is mounted, no shared filesystem is assumed, and nothing is installed on the far
|
|
10
|
+
* machine. A small program is pushed down the connection at the start of a run, lives only in
|
|
11
|
+
* the memory of the process it is running inside, and dies with it. That matters more than it
|
|
12
|
+
* looks: a runner that has to be installed is a runner somebody has to maintain, and the first
|
|
13
|
+
* time it is a version behind, the tool reports a difference that is really its own.
|
|
14
|
+
*
|
|
15
|
+
* WHY ONE HELD CONNECTION, MEASURED RATHER THAN ASSUMED.
|
|
16
|
+
* A fresh `ssh host command` to the office machine takes 740-1130 ms, measured over five
|
|
17
|
+
* dials on 2026-08-29. One request down an already-open connection to a probe that is already
|
|
18
|
+
* running takes 12-24 ms. That is fifty to eighty times, and it is the whole design: open once,
|
|
19
|
+
* hand the far side a program, then talk to it. A runner that shells out per step would spend
|
|
20
|
+
* its entire budget on handshakes and would still be walking the first journey.
|
|
21
|
+
*
|
|
22
|
+
* WHAT THE FAR SIDE IS ALLOWED TO SEND BACK.
|
|
23
|
+
* Lines. Each reply is one line of JSON with a `#SF#` sentinel in front of it. The sentinel is
|
|
24
|
+
* not decoration and it is not paranoia: the login shell prints a message of the day, sudo
|
|
25
|
+
* prints lecture text, PowerShell prints a `#< CLIXML` header the moment anything touches the
|
|
26
|
+
* error stream, and every one of those arrived on the same stream during the checks that built
|
|
27
|
+
* this file. Anything without the sentinel is kept as NOISE and reported, never parsed. A
|
|
28
|
+
* transport that guesses which lines were meant for it is a transport that will one day read a
|
|
29
|
+
* warning as an observation.
|
|
30
|
+
*
|
|
31
|
+
* THE HONESTY RULE THIS FILE EXISTS TO ENFORCE.
|
|
32
|
+
* A connection can die in the middle of a walk — the laptop lid closes, the wifi drops, someone
|
|
33
|
+
* reboots the office machine. When that happens the journey has NOT passed. It has not failed
|
|
34
|
+
* either. It is missing coverage, and it says so, with the reason attached. There is exactly
|
|
35
|
+
* one way for a remote journey to report a clean result, and that is for the far side to say so
|
|
36
|
+
* out loud before the connection closes. Silence is never agreement.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { spawn } from 'node:child_process';
|
|
40
|
+
import { StaysFixedError } from '../core/errors.js';
|
|
41
|
+
import { joinPath, notCovered, observation, sizeBucket, timeBucket, trimForStorage } from './adapters/contract.js';
|
|
42
|
+
|
|
43
|
+
/** @typedef {import('./types.js').Observation} Observation */
|
|
44
|
+
/** @typedef {import('./types.js').Journey} Journey */
|
|
45
|
+
/** @typedef {import('./types.js').Surface} Surface */
|
|
46
|
+
/** @typedef {import('./adapters/contract.js').Missing} Missing */
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// The wire
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* What every reply from the far side starts with.
|
|
54
|
+
*
|
|
55
|
+
* Four characters, chosen to be something no shell, no login banner and no runtime writes by
|
|
56
|
+
* accident. Everything else on the stream is noise by definition.
|
|
57
|
+
*/
|
|
58
|
+
export const SENTINEL = '#SF#';
|
|
59
|
+
|
|
60
|
+
/** The kinds of far side this file knows how to start. */
|
|
61
|
+
export const RUNNER_KINDS = /** @type {const} */ (['posix', 'windows']);
|
|
62
|
+
|
|
63
|
+
/** @typedef {'posix'|'windows'} RunnerKind */
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Where PowerShell lives on a Windows machine, in the order worth trying.
|
|
67
|
+
*
|
|
68
|
+
* These are absolute paths on purpose, and it cost an hour to learn why. The office machine
|
|
69
|
+
* has `appendWindowsPath = true` in its `/etc/wsl.conf`, and `powershell.exe` is STILL not on
|
|
70
|
+
* the path of a non-interactive ssh session — the Windows path is added by the interactive
|
|
71
|
+
* login shell, and ssh does not run one. Anything that probes with `command -v powershell.exe`
|
|
72
|
+
* gets an empty answer and concludes, wrongly, that there is no Windows behind that host.
|
|
73
|
+
* There is. Ask the filesystem, not the path.
|
|
74
|
+
*/
|
|
75
|
+
export const POWERSHELL_PATHS = [
|
|
76
|
+
'/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe',
|
|
77
|
+
'/mnt/c/Program Files/PowerShell/7/pwsh.exe',
|
|
78
|
+
'/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe',
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
/** How long to wait for the far side to say hello before giving up. */
|
|
82
|
+
const HANDSHAKE_MS = 20_000;
|
|
83
|
+
|
|
84
|
+
/** How long one request may take before it is called a hole rather than an answer. */
|
|
85
|
+
const DEFAULT_CALL_MS = 60_000;
|
|
86
|
+
|
|
87
|
+
/** Past this, a reply is truncated rather than kept — a runaway far side must not fill memory. */
|
|
88
|
+
const MAX_REPLY_BYTES = 8 * 1024 * 1024;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The link went away. Distinct from every other failure, because it means the opposite thing:
|
|
92
|
+
* a failed call tells you something about the product, and a lost link tells you nothing at all.
|
|
93
|
+
*/
|
|
94
|
+
export class RemoteLinkLost extends StaysFixedError {
|
|
95
|
+
/**
|
|
96
|
+
* @param {string} host
|
|
97
|
+
* @param {string} why Plain English: what actually happened.
|
|
98
|
+
* @param {string[]} [noise] Unsentinelled lines the far side wrote, which usually explain it.
|
|
99
|
+
*/
|
|
100
|
+
constructor(host, why, noise = []) {
|
|
101
|
+
super(`The connection to ${host} went away: ${why}`, {
|
|
102
|
+
hint: noise.length > 0
|
|
103
|
+
? `The machine said: ${noise.slice(-3).join(' / ')}`
|
|
104
|
+
: 'Nothing this run saw on that machine can be trusted. It is reported as unchecked, not as a pass.',
|
|
105
|
+
});
|
|
106
|
+
this.name = 'RemoteLinkLost';
|
|
107
|
+
/** @type {string} */
|
|
108
|
+
this.host = host;
|
|
109
|
+
/** @type {string[]} */
|
|
110
|
+
this.noise = noise;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// Getting a program onto the far side without installing anything
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Turn a PowerShell script into what `-EncodedCommand` wants: base64 of UTF-16 little-endian.
|
|
120
|
+
*
|
|
121
|
+
* Encoding rather than quoting is not a style choice. The script travels through a POSIX shell
|
|
122
|
+
* on the WSL side and then through Windows command-line parsing, and those two disagree about
|
|
123
|
+
* quotes, backslashes, carets and percent signs. Base64 has none of those characters in it.
|
|
124
|
+
*
|
|
125
|
+
* @param {string} script
|
|
126
|
+
* @returns {string}
|
|
127
|
+
*/
|
|
128
|
+
export function encodePowerShell(script) {
|
|
129
|
+
return Buffer.from(script, 'utf16le').toString('base64');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The two-line PowerShell program that reads the real probe off its own standard input.
|
|
134
|
+
*
|
|
135
|
+
* This exists because of a hard limit that is easy to trip over and unpleasant to debug: a
|
|
136
|
+
* Windows command line is capped at 32,767 characters, and `-EncodedCommand` inflates a script
|
|
137
|
+
* by about 2.7 times on its way to base64. A probe of any real size — the Windows one is well
|
|
138
|
+
* past ten kilobytes — does not fit, and what you get is not a clear error but a truncated
|
|
139
|
+
* script that fails somewhere in the middle.
|
|
140
|
+
*
|
|
141
|
+
* So the command line carries only this, 432 characters encoded, and the probe itself arrives
|
|
142
|
+
* as the first line of standard input, where nothing limits its length. Measured working with
|
|
143
|
+
* a 32,756-character payload on 2026-08-29.
|
|
144
|
+
*
|
|
145
|
+
* @returns {string}
|
|
146
|
+
*/
|
|
147
|
+
export function powerShellBootstrap() {
|
|
148
|
+
return [
|
|
149
|
+
'[Console]::OutputEncoding=[Text.Encoding]::UTF8',
|
|
150
|
+
'$b=[Console]::In.ReadLine()',
|
|
151
|
+
'Invoke-Expression ([Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($b)))',
|
|
152
|
+
].join('\n');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The same trick for a POSIX far side.
|
|
157
|
+
*
|
|
158
|
+
* Node has room on the command line that Windows does not, so this could have been inlined.
|
|
159
|
+
* It is not, because one shape for both kinds means one thing to get right, one thing to test,
|
|
160
|
+
* and one place where a payload could be truncated.
|
|
161
|
+
*
|
|
162
|
+
* @returns {string}
|
|
163
|
+
*/
|
|
164
|
+
export function nodeBootstrap() {
|
|
165
|
+
return [
|
|
166
|
+
"let line='';",
|
|
167
|
+
"process.stdin.setEncoding('utf8');",
|
|
168
|
+
"process.stdin.on('data', function onData(chunk) {",
|
|
169
|
+
" const cut = chunk.indexOf('\\n');",
|
|
170
|
+
' if (cut < 0) { line += chunk; return; }',
|
|
171
|
+
' line += chunk.slice(0, cut);',
|
|
172
|
+
' const rest = chunk.slice(cut + 1);',
|
|
173
|
+
" process.stdin.removeListener('data', onData);",
|
|
174
|
+
" const src = Buffer.from(line, 'base64').toString('utf8');",
|
|
175
|
+
' if (rest) process.stdin.unshift(rest);',
|
|
176
|
+
" const run = new Function('require', 'process', src);",
|
|
177
|
+
" run(require('node:module').createRequire(process.cwd() + '/x.cjs'), process);",
|
|
178
|
+
'});',
|
|
179
|
+
].join('\n');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The command to run on the far machine, as one string for its shell.
|
|
184
|
+
*
|
|
185
|
+
* For `windows` this is a POSIX shell command that starts PowerShell through WSL interop. The
|
|
186
|
+
* `for` loop over candidate paths is there because the path that works is a fact about the
|
|
187
|
+
* machine, not about Windows, and asking is cheaper than a round trip to find out first.
|
|
188
|
+
*
|
|
189
|
+
* @param {RunnerKind} kind
|
|
190
|
+
* @param {{psPath?: string, node?: string}} [opts]
|
|
191
|
+
* @returns {string}
|
|
192
|
+
*/
|
|
193
|
+
export function farSideCommand(kind, opts = {}) {
|
|
194
|
+
if (kind === 'windows') {
|
|
195
|
+
const encoded = encodePowerShell(powerShellBootstrap());
|
|
196
|
+
if (opts.psPath) return `exec "${opts.psPath}" -NoProfile -NonInteractive -EncodedCommand ${encoded}`;
|
|
197
|
+
const candidates = POWERSHELL_PATHS.map((p) => `"${p}"`).join(' ');
|
|
198
|
+
return [
|
|
199
|
+
`for p in ${candidates}; do`,
|
|
200
|
+
` if [ -x "$p" ]; then exec "$p" -NoProfile -NonInteractive -EncodedCommand ${encoded}; fi;`,
|
|
201
|
+
'done;',
|
|
202
|
+
'echo "no powershell.exe on this machine" >&2; exit 127',
|
|
203
|
+
].join(' ');
|
|
204
|
+
}
|
|
205
|
+
const node = opts.node ?? 'node';
|
|
206
|
+
// The bootstrap goes through `$( )` so the far shell hands it over as one argument and never
|
|
207
|
+
// re-reads it, and it travels as base64 so the shell has no quotes, dollars or backslashes of
|
|
208
|
+
// its own to get wrong. Nothing is written to that machine's disk at any point.
|
|
209
|
+
const encoded = Buffer.from(nodeBootstrap(), 'utf8').toString('base64');
|
|
210
|
+
return `${node} -e "$(printf %s '${encoded}' | base64 -d)"`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* The full argument list for ssh.
|
|
215
|
+
*
|
|
216
|
+
* `BatchMode=yes` is the important one. Without it, a host whose key is missing sits there
|
|
217
|
+
* asking for a password that nobody is going to type, and the run hangs instead of reporting
|
|
218
|
+
* an honest "that machine did not let me in".
|
|
219
|
+
*
|
|
220
|
+
* @param {string} host
|
|
221
|
+
* @param {string} remoteCommand
|
|
222
|
+
* @param {{connectTimeoutSec?: number, extra?: string[]}} [opts]
|
|
223
|
+
* @returns {string[]}
|
|
224
|
+
*/
|
|
225
|
+
export function sshCommand(host, remoteCommand, opts = {}) {
|
|
226
|
+
return [
|
|
227
|
+
'-T',
|
|
228
|
+
'-o', 'BatchMode=yes',
|
|
229
|
+
'-o', `ConnectTimeout=${opts.connectTimeoutSec ?? 10}`,
|
|
230
|
+
'-o', 'ServerAliveInterval=15',
|
|
231
|
+
'-o', 'ServerAliveCountMax=3',
|
|
232
|
+
...(opts.extra ?? []),
|
|
233
|
+
host,
|
|
234
|
+
remoteCommand,
|
|
235
|
+
];
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
// Reading the stream
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* @typedef {object} Framed
|
|
244
|
+
* @property {Record<string, any>[]} frames Replies, already parsed.
|
|
245
|
+
* @property {string[]} noise Lines that were not ours, kept verbatim.
|
|
246
|
+
*/
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Split an arriving stream into replies and noise.
|
|
250
|
+
*
|
|
251
|
+
* Kept as its own function with its own state so it can be tested without a machine, and so
|
|
252
|
+
* the rule it encodes is visible: a line either starts with the sentinel and is parsed, or it
|
|
253
|
+
* does not and is kept as noise. There is no third case and no heuristic.
|
|
254
|
+
*
|
|
255
|
+
* @returns {{push: (chunk: string) => Framed, rest: () => string}}
|
|
256
|
+
*/
|
|
257
|
+
export function makeFrames() {
|
|
258
|
+
let buffer = '';
|
|
259
|
+
return {
|
|
260
|
+
push(chunk) {
|
|
261
|
+
buffer += chunk;
|
|
262
|
+
/** @type {Record<string, any>[]} */
|
|
263
|
+
const frames = [];
|
|
264
|
+
/** @type {string[]} */
|
|
265
|
+
const noise = [];
|
|
266
|
+
let cut = buffer.indexOf('\n');
|
|
267
|
+
while (cut >= 0) {
|
|
268
|
+
const line = buffer.slice(0, cut).replace(/\r$/, '');
|
|
269
|
+
buffer = buffer.slice(cut + 1);
|
|
270
|
+
cut = buffer.indexOf('\n');
|
|
271
|
+
if (line.startsWith(SENTINEL)) {
|
|
272
|
+
try {
|
|
273
|
+
frames.push(JSON.parse(line.slice(SENTINEL.length)));
|
|
274
|
+
} catch {
|
|
275
|
+
// A sentinelled line we cannot parse is worse than noise, because something on the
|
|
276
|
+
// far side thinks it is talking to us and is not. Keep it where a person will see it.
|
|
277
|
+
noise.push(`unreadable reply: ${line.slice(0, 200)}`);
|
|
278
|
+
}
|
|
279
|
+
} else if (line.trim() !== '') {
|
|
280
|
+
noise.push(line);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return { frames, noise };
|
|
284
|
+
},
|
|
285
|
+
rest: () => buffer,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ---------------------------------------------------------------------------
|
|
290
|
+
// The generic POSIX agent
|
|
291
|
+
// ---------------------------------------------------------------------------
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* The program that runs on a POSIX far side.
|
|
295
|
+
*
|
|
296
|
+
* Deliberately small. It runs commands, reports what they printed, what they exited with and
|
|
297
|
+
* how long they took, and it says hello with enough facts for `doctor` to describe the machine.
|
|
298
|
+
* It does NOT try to be the CLI adapter over a wire: the process adapter watches a run from
|
|
299
|
+
* inside the child with a loader, and that machinery belongs where the engine is, not scattered
|
|
300
|
+
* across every machine the tool can reach.
|
|
301
|
+
*
|
|
302
|
+
* Written as text rather than shipped as a file because it must never be installed. It exists
|
|
303
|
+
* in the memory of one `node -e` for the length of one run.
|
|
304
|
+
*
|
|
305
|
+
* @returns {string}
|
|
306
|
+
*/
|
|
307
|
+
export function posixAgentScript() {
|
|
308
|
+
return `
|
|
309
|
+
const { execFile } = require('node:child_process');
|
|
310
|
+
const os = require('node:os');
|
|
311
|
+
const fs = require('node:fs');
|
|
312
|
+
const S = ${JSON.stringify(SENTINEL)};
|
|
313
|
+
const emit = (o) => { try { process.stdout.write(S + JSON.stringify(o) + '\\n'); } catch (e) {} };
|
|
314
|
+
|
|
315
|
+
emit({ id: 'hello', ok: true, kind: 'posix', platform: process.platform, arch: process.arch,
|
|
316
|
+
node: process.version, host: os.hostname(), user: os.userInfo().username,
|
|
317
|
+
home: os.homedir(), tmp: os.tmpdir(), cpus: os.cpus().length,
|
|
318
|
+
memoryGb: Math.round(os.totalmem() / 1e9), release: os.release() });
|
|
319
|
+
|
|
320
|
+
const ops = {
|
|
321
|
+
ping: (req, done) => done({ ok: true }),
|
|
322
|
+
which: (req, done) => {
|
|
323
|
+
const found = {};
|
|
324
|
+
for (const name of req.names || []) {
|
|
325
|
+
let where = null;
|
|
326
|
+
for (const dir of (process.env.PATH || '').split(':')) {
|
|
327
|
+
try { const p = dir + '/' + name; fs.accessSync(p, fs.constants.X_OK); where = p; break; } catch (e) {}
|
|
328
|
+
}
|
|
329
|
+
found[name] = where;
|
|
330
|
+
}
|
|
331
|
+
done({ ok: true, found });
|
|
332
|
+
},
|
|
333
|
+
read: (req, done) => {
|
|
334
|
+
try { done({ ok: true, text: fs.readFileSync(req.file, 'utf8').slice(0, req.limit || 65536) }); }
|
|
335
|
+
catch (e) { done({ ok: false, error: String(e.message) }); }
|
|
336
|
+
},
|
|
337
|
+
sh: (req, done) => {
|
|
338
|
+
const started = Date.now();
|
|
339
|
+
execFile(req.shell || '/bin/sh', ['-c', req.command], {
|
|
340
|
+
cwd: req.cwd || undefined,
|
|
341
|
+
env: req.env ? Object.assign({}, process.env, req.env) : process.env,
|
|
342
|
+
timeout: req.timeoutMs || 120000,
|
|
343
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
344
|
+
encoding: 'utf8',
|
|
345
|
+
}, (err, stdout, stderr) => {
|
|
346
|
+
done({
|
|
347
|
+
ok: true,
|
|
348
|
+
stdout: String(stdout || ''),
|
|
349
|
+
stderr: String(stderr || ''),
|
|
350
|
+
code: err && typeof err.code === 'number' ? err.code : err ? 1 : 0,
|
|
351
|
+
killed: Boolean(err && err.killed),
|
|
352
|
+
ms: Date.now() - started,
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
},
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
let pending = '';
|
|
359
|
+
process.stdin.setEncoding('utf8');
|
|
360
|
+
process.stdin.on('data', (chunk) => {
|
|
361
|
+
pending += chunk;
|
|
362
|
+
let cut = pending.indexOf('\\n');
|
|
363
|
+
while (cut >= 0) {
|
|
364
|
+
const line = pending.slice(0, cut); pending = pending.slice(cut + 1); cut = pending.indexOf('\\n');
|
|
365
|
+
if (!line.trim()) continue;
|
|
366
|
+
let req; try { req = JSON.parse(line); } catch (e) { emit({ id: '?', ok: false, error: 'bad json' }); continue; }
|
|
367
|
+
if (req.op === 'bye') { emit({ id: req.id, ok: true }); process.exit(0); }
|
|
368
|
+
const op = ops[req.op];
|
|
369
|
+
if (!op) { emit({ id: req.id, ok: false, error: 'unknown op ' + req.op }); continue; }
|
|
370
|
+
try { op(req, (reply) => emit(Object.assign({ id: req.id }, reply))); }
|
|
371
|
+
catch (e) { emit({ id: req.id, ok: false, error: String(e && e.message || e) }); }
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
process.stdin.on('end', () => process.exit(0));
|
|
375
|
+
`;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// ---------------------------------------------------------------------------
|
|
379
|
+
// The runner
|
|
380
|
+
// ---------------------------------------------------------------------------
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* @typedef {object} RemoteFacts
|
|
384
|
+
* What the far side said about itself at handshake. Everything in here came from the machine,
|
|
385
|
+
* never from a config file, because a config file is a claim and this is a measurement.
|
|
386
|
+
* @property {string} [host]
|
|
387
|
+
* @property {string} [user]
|
|
388
|
+
* @property {string} [platform]
|
|
389
|
+
* @property {string} [kind]
|
|
390
|
+
* @property {boolean} [locked] Windows only: is the desktop locked right now.
|
|
391
|
+
* @property {number} [session] Windows only: which logon session the probe landed in.
|
|
392
|
+
* @property {boolean} [loggedIn] Windows only: is anybody signed in. UI Automation reads the
|
|
393
|
+
* desktop in front of it, and there is no desktop without a
|
|
394
|
+
* session — so this decides whether a walk is possible at all.
|
|
395
|
+
* @property {string} [screen] Windows only: what the screen is doing — 'locked', 'unlocked'.
|
|
396
|
+
* A locked screen still yields per-window pictures but not a
|
|
397
|
+
* whole-desktop one, so the difference has to be recorded.
|
|
398
|
+
* @property {string} [release] What the far side calls its own version.
|
|
399
|
+
*/
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* @typedef {object} RemoteRunnerOptions
|
|
403
|
+
* @property {string} host An entry in the ssh config that already works.
|
|
404
|
+
* @property {RunnerKind} [kind] Default 'posix'.
|
|
405
|
+
* @property {string} [agent] The program to push down. Defaults to the POSIX one.
|
|
406
|
+
* @property {Surface} [surface] What surface observations from here belong to.
|
|
407
|
+
* @property {string} [psPath] A known powershell.exe path, to skip the search.
|
|
408
|
+
* @property {number} [callTimeoutMs]
|
|
409
|
+
* @property {(message: string) => void} [log]
|
|
410
|
+
* @property {string[]} [sshExtra] Extra ssh options, for a port or an identity file.
|
|
411
|
+
*/
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Open a runner on another machine.
|
|
415
|
+
*
|
|
416
|
+
* The returned object is not a full adapter and does not pretend to be one. It is the half an
|
|
417
|
+
* adapter cannot write for itself: a live connection, a request-and-reply channel over it, and
|
|
418
|
+
* an honest account of what happened if it breaks. An adapter — the Windows one, an Android one
|
|
419
|
+
* later — supplies the program that runs on the far side and the knowledge of what its replies
|
|
420
|
+
* mean.
|
|
421
|
+
*
|
|
422
|
+
* @param {RemoteRunnerOptions} opts
|
|
423
|
+
*/
|
|
424
|
+
export function remoteRunner(opts) {
|
|
425
|
+
const kind = opts.kind ?? 'posix';
|
|
426
|
+
const host = opts.host;
|
|
427
|
+
const say = opts.log ?? (() => {});
|
|
428
|
+
const surface = opts.surface;
|
|
429
|
+
const agentSource = opts.agent ?? posixAgentScript();
|
|
430
|
+
|
|
431
|
+
/** @type {import('node:child_process').ChildProcessWithoutNullStreams|null} */
|
|
432
|
+
let child = null;
|
|
433
|
+
/** @type {RemoteFacts} */
|
|
434
|
+
let facts = {};
|
|
435
|
+
/** @type {string[]} */
|
|
436
|
+
const noise = [];
|
|
437
|
+
/** @type {Map<string, {resolve: (v: any) => void, reject: (e: Error) => void, timer: NodeJS.Timeout}>} */
|
|
438
|
+
const waiting = new Map();
|
|
439
|
+
/** @type {RemoteLinkLost|null} */
|
|
440
|
+
let dead = null;
|
|
441
|
+
let counter = 0;
|
|
442
|
+
let bytesIn = 0;
|
|
443
|
+
|
|
444
|
+
const frames = makeFrames();
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Everything in flight gives up at once, with the same reason.
|
|
448
|
+
* @param {string} why
|
|
449
|
+
*/
|
|
450
|
+
function die(why) {
|
|
451
|
+
if (dead) return;
|
|
452
|
+
dead = new RemoteLinkLost(host, why, noise);
|
|
453
|
+
for (const [, entry] of waiting) {
|
|
454
|
+
clearTimeout(entry.timer);
|
|
455
|
+
entry.reject(dead);
|
|
456
|
+
}
|
|
457
|
+
waiting.clear();
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** @param {string} chunk */
|
|
461
|
+
function absorb(chunk) {
|
|
462
|
+
bytesIn += chunk.length;
|
|
463
|
+
if (bytesIn > MAX_REPLY_BYTES) {
|
|
464
|
+
die(`it sent more than ${sizeBucket(MAX_REPLY_BYTES)} of replies, which is not a conversation any more`);
|
|
465
|
+
if (child) child.kill();
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
const { frames: got, noise: extra } = frames.push(chunk);
|
|
469
|
+
for (const line of extra) {
|
|
470
|
+
noise.push(line);
|
|
471
|
+
if (noise.length > 200) noise.shift();
|
|
472
|
+
}
|
|
473
|
+
for (const reply of got) {
|
|
474
|
+
const id = String(reply.id ?? '');
|
|
475
|
+
if (id === 'hello') {
|
|
476
|
+
facts = /** @type {RemoteFacts} */ (reply);
|
|
477
|
+
const hello = waiting.get('hello');
|
|
478
|
+
if (hello) { clearTimeout(hello.timer); waiting.delete('hello'); hello.resolve(reply); }
|
|
479
|
+
continue;
|
|
480
|
+
}
|
|
481
|
+
const entry = waiting.get(id);
|
|
482
|
+
if (!entry) continue;
|
|
483
|
+
clearTimeout(entry.timer);
|
|
484
|
+
waiting.delete(id);
|
|
485
|
+
entry.resolve(reply);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const runner = {
|
|
490
|
+
host,
|
|
491
|
+
kind,
|
|
492
|
+
/** What the far side said about itself. Empty until `open` has finished. */
|
|
493
|
+
get facts() { return facts; },
|
|
494
|
+
/** Lines the far side wrote that were not replies. Usually the explanation for a failure. */
|
|
495
|
+
get noise() { return noise.slice(); },
|
|
496
|
+
/** False the moment anything makes the link untrustworthy. */
|
|
497
|
+
get alive() { return child !== null && dead === null; },
|
|
498
|
+
/** The reason it stopped being trustworthy, or null. */
|
|
499
|
+
get lost() { return dead; },
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Dial the machine, push the program down, and wait for it to say hello.
|
|
503
|
+
*
|
|
504
|
+
* @returns {Promise<RemoteFacts>}
|
|
505
|
+
*/
|
|
506
|
+
async open() {
|
|
507
|
+
if (child) return facts;
|
|
508
|
+
const command = farSideCommand(kind, { psPath: opts.psPath });
|
|
509
|
+
const args = sshCommand(host, command, { extra: opts.sshExtra });
|
|
510
|
+
say(`opening ${host} (${kind})`);
|
|
511
|
+
child = /** @type {any} */ (spawn('ssh', args, { stdio: ['pipe', 'pipe', 'pipe'] }));
|
|
512
|
+
const proc = /** @type {import('node:child_process').ChildProcessWithoutNullStreams} */ (child);
|
|
513
|
+
proc.stdout.setEncoding('utf8');
|
|
514
|
+
proc.stderr.setEncoding('utf8');
|
|
515
|
+
proc.stdout.on('data', absorb);
|
|
516
|
+
proc.stderr.on('data', (/** @type {string} */ text) => {
|
|
517
|
+
for (const line of text.split('\n')) if (line.trim()) noise.push(line.trim());
|
|
518
|
+
});
|
|
519
|
+
proc.on('error', (e) => die(`ssh itself would not run (${e.message})`));
|
|
520
|
+
proc.on('close', (code, signal) => {
|
|
521
|
+
die(signal ? `it was stopped by ${signal}` : `it closed with code ${code}`);
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
const hello = new Promise((resolve, reject) => {
|
|
525
|
+
const timer = setTimeout(
|
|
526
|
+
() => { waiting.delete('hello'); reject(new RemoteLinkLost(host, `it did not answer within ${timeBucket(HANDSHAKE_MS)}`, noise)); },
|
|
527
|
+
HANDSHAKE_MS
|
|
528
|
+
);
|
|
529
|
+
waiting.set('hello', { resolve, reject, timer });
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
// The program itself, on the first line of standard input, where no command-line limit
|
|
533
|
+
// applies. See powerShellBootstrap for why this is not on the command line.
|
|
534
|
+
proc.stdin.write(`${Buffer.from(agentSource, 'utf8').toString('base64')}\n`);
|
|
535
|
+
await hello;
|
|
536
|
+
say(`${host} answered: ${describeFacts(facts)}`);
|
|
537
|
+
return facts;
|
|
538
|
+
},
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* One request, one reply.
|
|
542
|
+
*
|
|
543
|
+
* A timeout here is NOT an error about the product. It is a hole, and the caller is
|
|
544
|
+
* expected to record it as one — which is why this rejects with a link-lost error carrying
|
|
545
|
+
* the reason rather than resolving with something that could be mistaken for an answer.
|
|
546
|
+
*
|
|
547
|
+
* @param {string} op
|
|
548
|
+
* @param {Record<string, unknown>} [payload]
|
|
549
|
+
* @param {{timeoutMs?: number}} [callOpts]
|
|
550
|
+
* @returns {Promise<Record<string, any>>}
|
|
551
|
+
*/
|
|
552
|
+
async call(op, payload = {}, callOpts = {}) {
|
|
553
|
+
if (dead) throw dead;
|
|
554
|
+
if (!child) throw new StaysFixedError(`Cannot talk to ${host} before opening the connection.`);
|
|
555
|
+
const id = `r${++counter}`;
|
|
556
|
+
const timeoutMs = callOpts.timeoutMs ?? opts.callTimeoutMs ?? DEFAULT_CALL_MS;
|
|
557
|
+
const promise = new Promise((resolve, reject) => {
|
|
558
|
+
const timer = setTimeout(() => {
|
|
559
|
+
waiting.delete(id);
|
|
560
|
+
reject(new RemoteLinkLost(host, `"${op}" did not answer within ${timeBucket(timeoutMs)}`, noise));
|
|
561
|
+
}, timeoutMs);
|
|
562
|
+
waiting.set(id, { resolve, reject, timer });
|
|
563
|
+
});
|
|
564
|
+
/** @type {import('node:child_process').ChildProcessWithoutNullStreams} */ (child).stdin.write(
|
|
565
|
+
`${JSON.stringify({ ...payload, id, op })}\n`
|
|
566
|
+
);
|
|
567
|
+
return /** @type {Record<string, any>} */ (await promise);
|
|
568
|
+
},
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Run a command over there and get back what it printed.
|
|
572
|
+
*
|
|
573
|
+
* The convenience the mobile lanes will actually use: `adb devices`, `xcrun simctl list`,
|
|
574
|
+
* `git rev-parse HEAD` on the machine that has the checkout.
|
|
575
|
+
*
|
|
576
|
+
* @param {string} command
|
|
577
|
+
* @param {{cwd?: string, env?: Record<string,string>, timeoutMs?: number}} [shellOpts]
|
|
578
|
+
* @returns {Promise<{stdout: string, stderr: string, code: number, ms: number, killed: boolean}>}
|
|
579
|
+
*/
|
|
580
|
+
async shell(command, shellOpts = {}) {
|
|
581
|
+
const reply = await runner.call('sh', { command, ...shellOpts }, { timeoutMs: shellOpts.timeoutMs });
|
|
582
|
+
return {
|
|
583
|
+
stdout: String(reply.stdout ?? ''),
|
|
584
|
+
stderr: String(reply.stderr ?? ''),
|
|
585
|
+
code: Number(reply.code ?? 0),
|
|
586
|
+
ms: Number(reply.ms ?? 0),
|
|
587
|
+
killed: Boolean(reply.killed),
|
|
588
|
+
};
|
|
589
|
+
},
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Walk a journey made of shell steps and report what was seen.
|
|
593
|
+
*
|
|
594
|
+
* The generic walk, for a far side running the POSIX agent. A platform adapter with a
|
|
595
|
+
* richer far side — Windows, and Android when it lands — walks its own way and uses this
|
|
596
|
+
* only for the shape of the answer.
|
|
597
|
+
*
|
|
598
|
+
* If the link dies part way through, everything already collected is KEPT and everything
|
|
599
|
+
* remaining is reported as a hole. Throwing the collected half away would be tidier and
|
|
600
|
+
* would also delete the evidence of what happened just before the machine went.
|
|
601
|
+
*
|
|
602
|
+
* @param {Journey} journey
|
|
603
|
+
* @param {{command: string, cwd?: string, note?: string}[]} steps
|
|
604
|
+
* @returns {Promise<Observation[]>}
|
|
605
|
+
*/
|
|
606
|
+
async walk(journey, steps) {
|
|
607
|
+
/** @type {Observation[]} */
|
|
608
|
+
const seen = [];
|
|
609
|
+
for (const [index, step] of steps.entries()) {
|
|
610
|
+
const label = step.note ?? step.command;
|
|
611
|
+
try {
|
|
612
|
+
const result = await runner.shell(step.command, { cwd: step.cwd });
|
|
613
|
+
const printed = trimForStorage(`${result.stdout}${result.stderr ? `\n${result.stderr}` : ''}`);
|
|
614
|
+
seen.push(observation({
|
|
615
|
+
channel: 'results',
|
|
616
|
+
path: joinPath('remote', host, journey.name, String(index), 'printed'),
|
|
617
|
+
value: printed.text,
|
|
618
|
+
says: `On ${host}, "${label}" printed this.`,
|
|
619
|
+
journey: journey.name,
|
|
620
|
+
surface,
|
|
621
|
+
}));
|
|
622
|
+
seen.push(observation({
|
|
623
|
+
channel: 'complaints',
|
|
624
|
+
path: joinPath('remote', host, journey.name, String(index), 'exit'),
|
|
625
|
+
value: result.killed ? 'killed' : result.code,
|
|
626
|
+
says: result.killed
|
|
627
|
+
? `On ${host}, "${label}" had to be stopped — it did not finish on its own.`
|
|
628
|
+
: `On ${host}, "${label}" finished with ${result.code}.`,
|
|
629
|
+
journey: journey.name,
|
|
630
|
+
surface,
|
|
631
|
+
}));
|
|
632
|
+
seen.push(observation({
|
|
633
|
+
channel: 'counters',
|
|
634
|
+
path: joinPath('remote', host, journey.name, String(index), 'took'),
|
|
635
|
+
value: timeBucket(result.ms),
|
|
636
|
+
says: `On ${host}, "${label}" took ${timeBucket(result.ms)}.`,
|
|
637
|
+
journey: journey.name,
|
|
638
|
+
surface,
|
|
639
|
+
}));
|
|
640
|
+
} catch (error) {
|
|
641
|
+
// The link went. Everything from here on is unchecked, and it says so.
|
|
642
|
+
return [...seen, ...linkLostHoles({
|
|
643
|
+
host,
|
|
644
|
+
journey,
|
|
645
|
+
surface,
|
|
646
|
+
why: error instanceof RemoteLinkLost ? error.message : String(error),
|
|
647
|
+
from: index,
|
|
648
|
+
total: steps.length,
|
|
649
|
+
})];
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
return seen;
|
|
653
|
+
},
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Close the connection and leave the machine as it was found.
|
|
657
|
+
*
|
|
658
|
+
* Nothing was installed and nothing was written, so there is nothing to clean up except the
|
|
659
|
+
* conversation itself. `bye` is sent first and given a moment, because a probe that exits on
|
|
660
|
+
* its own leaves no orphan; killing ssh and hoping is how a stray process ends up on
|
|
661
|
+
* somebody's desk.
|
|
662
|
+
*/
|
|
663
|
+
async close() {
|
|
664
|
+
if (!child) return;
|
|
665
|
+
const proc = /** @type {import('node:child_process').ChildProcessWithoutNullStreams} */ (child);
|
|
666
|
+
try {
|
|
667
|
+
if (!dead) await runner.call('bye', {}, { timeoutMs: 3000 });
|
|
668
|
+
} catch { /* already gone, which is the outcome we wanted */ }
|
|
669
|
+
try { proc.stdin.end(); } catch { /* nothing to end */ }
|
|
670
|
+
await new Promise((resolve) => {
|
|
671
|
+
const timer = setTimeout(() => { try { proc.kill(); } catch { /* gone */ } resolve(undefined); }, 3000);
|
|
672
|
+
proc.on('close', () => { clearTimeout(timer); resolve(undefined); });
|
|
673
|
+
if (proc.exitCode !== null) { clearTimeout(timer); resolve(undefined); }
|
|
674
|
+
});
|
|
675
|
+
child = null;
|
|
676
|
+
},
|
|
677
|
+
};
|
|
678
|
+
|
|
679
|
+
return runner;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/** @typedef {ReturnType<typeof remoteRunner>} RemoteRunner */
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* The observations that stand in for a walk that did not finish.
|
|
686
|
+
*
|
|
687
|
+
* The whole reason this file exists in the form it does. A journey interrupted by a dead
|
|
688
|
+
* connection has not passed, and there is a real temptation in code like this to return what
|
|
689
|
+
* was collected and let the engine compare it — which would report the missing half as
|
|
690
|
+
* "unchanged" and be exactly the quietly-worthless green run the tool is supposed to make
|
|
691
|
+
* impossible.
|
|
692
|
+
*
|
|
693
|
+
* @param {object} spec
|
|
694
|
+
* @param {string} spec.host
|
|
695
|
+
* @param {Journey} spec.journey
|
|
696
|
+
* @param {string} spec.why
|
|
697
|
+
* @param {number} spec.from Step it stopped at.
|
|
698
|
+
* @param {number} spec.total
|
|
699
|
+
* @param {Surface} [spec.surface]
|
|
700
|
+
* @returns {Observation[]}
|
|
701
|
+
*/
|
|
702
|
+
export function linkLostHoles(spec) {
|
|
703
|
+
const left = Math.max(0, spec.total - spec.from);
|
|
704
|
+
return [
|
|
705
|
+
notCovered({
|
|
706
|
+
channel: 'results',
|
|
707
|
+
path: joinPath('remote', spec.host, spec.journey.name, 'finished'),
|
|
708
|
+
reason: 'timed out',
|
|
709
|
+
says: `"${spec.journey.describe}" did not finish on ${spec.host}. ${spec.why}. `
|
|
710
|
+
+ `${left} of ${spec.total} step${spec.total === 1 ? '' : 's'} were never walked, so nothing here is a pass — `
|
|
711
|
+
+ 'it is simply unchecked.',
|
|
712
|
+
}),
|
|
713
|
+
notCovered({
|
|
714
|
+
channel: 'complaints',
|
|
715
|
+
path: joinPath('remote', spec.host, spec.journey.name, 'link'),
|
|
716
|
+
reason: 'timed out',
|
|
717
|
+
says: `The connection to ${spec.host} broke part way through. Whatever the product did after that, nobody saw it.`,
|
|
718
|
+
}),
|
|
719
|
+
];
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* One line about a machine, for a log.
|
|
724
|
+
* @param {RemoteFacts} f
|
|
725
|
+
* @returns {string}
|
|
726
|
+
*/
|
|
727
|
+
export function describeFacts(f) {
|
|
728
|
+
const parts = [];
|
|
729
|
+
if (f.host) parts.push(f.host);
|
|
730
|
+
if (f.platform) parts.push(f.platform);
|
|
731
|
+
if (f.user) parts.push(`as ${f.user}`);
|
|
732
|
+
if (f.locked === true) parts.push('desktop locked');
|
|
733
|
+
if (f.locked === false) parts.push('desktop unlocked');
|
|
734
|
+
return parts.length > 0 ? parts.join(', ') : 'no facts offered';
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// ---------------------------------------------------------------------------
|
|
738
|
+
// Describing a machine, for doctor
|
|
739
|
+
// ---------------------------------------------------------------------------
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* @typedef {object} RemoteDescription
|
|
743
|
+
* @property {string} host
|
|
744
|
+
* @property {boolean} reachable
|
|
745
|
+
* @property {string} how Plain English: what answered, or why nothing did.
|
|
746
|
+
* @property {string|null} os
|
|
747
|
+
* @property {boolean} windows A real Windows desktop sits behind this host.
|
|
748
|
+
* @property {string|null} windowsVersion
|
|
749
|
+
* @property {string|null} powershell The absolute path that works, when one does.
|
|
750
|
+
* @property {boolean|null} desktopLoggedIn Is anybody logged in for UI Automation to read.
|
|
751
|
+
* @property {boolean|null} desktopLocked Locked desktops read fine but photograph black.
|
|
752
|
+
* @property {Record<string, string|null>} tools What is installed there that we care about.
|
|
753
|
+
* @property {Missing[]} missing What would unlock more, and who has to do it.
|
|
754
|
+
* @property {string[]} notes
|
|
755
|
+
*/
|
|
756
|
+
|
|
757
|
+
/** Things worth knowing about on any far machine, and what each one unlocks. */
|
|
758
|
+
const TOOLS_WORTH_ASKING_ABOUT = ['node', 'git', 'adb', 'emulator', 'java', 'python3', 'xcrun'];
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* What is actually on the other end of an ssh host name.
|
|
762
|
+
*
|
|
763
|
+
* This is the `doctor` half, and it obeys the rule the design put above everything: DETECT,
|
|
764
|
+
* NEVER ASK. A host that already answers must never be reported as something to set up, and
|
|
765
|
+
* the Windows desktop behind a WSL host must never be missed because `powershell.exe` was not
|
|
766
|
+
* on a non-interactive path. Both of those are real failures that happened while this was
|
|
767
|
+
* being written.
|
|
768
|
+
*
|
|
769
|
+
* It never throws. Somebody running doctor is already stuck.
|
|
770
|
+
*
|
|
771
|
+
* @param {string} host
|
|
772
|
+
* @param {{timeoutMs?: number, log?: (m: string) => void}} [opts]
|
|
773
|
+
* @returns {Promise<RemoteDescription>}
|
|
774
|
+
*/
|
|
775
|
+
export async function describeRemote(host, opts = {}) {
|
|
776
|
+
/** @type {RemoteDescription} */
|
|
777
|
+
const out = {
|
|
778
|
+
host,
|
|
779
|
+
reachable: false,
|
|
780
|
+
how: 'it did not answer',
|
|
781
|
+
os: null,
|
|
782
|
+
windows: false,
|
|
783
|
+
windowsVersion: null,
|
|
784
|
+
powershell: null,
|
|
785
|
+
desktopLoggedIn: null,
|
|
786
|
+
desktopLocked: null,
|
|
787
|
+
tools: {},
|
|
788
|
+
missing: [],
|
|
789
|
+
notes: [],
|
|
790
|
+
};
|
|
791
|
+
|
|
792
|
+
const runner = remoteRunner({ host, kind: 'posix', callTimeoutMs: opts.timeoutMs ?? 20_000, log: opts.log });
|
|
793
|
+
try {
|
|
794
|
+
const facts = await runner.open();
|
|
795
|
+
out.reachable = true;
|
|
796
|
+
out.how = 'it answered over ssh with the key already in the config';
|
|
797
|
+
out.os = [facts.platform, facts.release].filter(Boolean).join(' ') || null;
|
|
798
|
+
|
|
799
|
+
const found = await runner.call('which', { names: TOOLS_WORTH_ASKING_ABOUT });
|
|
800
|
+
out.tools = /** @type {Record<string, string|null>} */ (found.found ?? {});
|
|
801
|
+
|
|
802
|
+
// The Windows question, asked of the filesystem rather than of $PATH. See POWERSHELL_PATHS.
|
|
803
|
+
const test = await runner.shell(
|
|
804
|
+
POWERSHELL_PATHS.map((p) => `if [ -x "${p}" ]; then echo "${p}"; fi`).join('; ')
|
|
805
|
+
);
|
|
806
|
+
const psPath = test.stdout.split('\n').map((l) => l.trim()).find((l) => l !== '') ?? null;
|
|
807
|
+
if (psPath) {
|
|
808
|
+
out.powershell = psPath;
|
|
809
|
+
out.windows = true;
|
|
810
|
+
// One PowerShell call for everything, because each one costs about a second of Windows
|
|
811
|
+
// start-up and there is no reason to pay it three times.
|
|
812
|
+
// Encoded rather than quoted. The script passes through a POSIX shell AND then Windows
|
|
813
|
+
// command-line parsing, and a single quote written for one of them is eaten by the other;
|
|
814
|
+
// the first version of this line came back empty for exactly that reason.
|
|
815
|
+
const script = [
|
|
816
|
+
'$os=(Get-CimInstance Win32_OperatingSystem)',
|
|
817
|
+
'$e=@(Get-Process explorer -ErrorAction SilentlyContinue).Count',
|
|
818
|
+
'$l=@(Get-Process LogonUI -ErrorAction SilentlyContinue).Count',
|
|
819
|
+
'Write-Output ($os.Caption + "|" + $os.Version + "|" + $e + "|" + $l)',
|
|
820
|
+
].join('; ');
|
|
821
|
+
const probe = await runner.shell(
|
|
822
|
+
`"${psPath}" -NoProfile -NonInteractive -EncodedCommand ${encodePowerShell(script)}`,
|
|
823
|
+
{ timeoutMs: 45_000 }
|
|
824
|
+
);
|
|
825
|
+
const line = probe.stdout.split('\n').map((l) => l.trim()).filter(Boolean).pop() ?? '';
|
|
826
|
+
const [caption, version, explorers, logonui] = line.split('|');
|
|
827
|
+
if (version) {
|
|
828
|
+
out.windowsVersion = `${caption} ${version}`.trim();
|
|
829
|
+
out.desktopLoggedIn = Number(explorers) > 0;
|
|
830
|
+
out.desktopLocked = Number(logonui) > 0;
|
|
831
|
+
} else {
|
|
832
|
+
out.notes.push('PowerShell is there but did not answer a question about the desktop, so how much of Windows is usable is unknown.');
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
await runner.close();
|
|
836
|
+
} catch (error) {
|
|
837
|
+
out.how = error instanceof RemoteLinkLost ? error.message : `it could not be reached (${String(error)})`;
|
|
838
|
+
try { await runner.close(); } catch { /* nothing to close */ }
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
out.missing = missingOn(out);
|
|
842
|
+
out.notes.push(...notesOn(out));
|
|
843
|
+
return out;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/**
|
|
847
|
+
* What would make this machine more useful, and who has to do it.
|
|
848
|
+
*
|
|
849
|
+
* Written to the design's four states: anything an agent can install is said with the exact
|
|
850
|
+
* command and no `blocking` flag, and anything only a person can do says what it unlocks so the
|
|
851
|
+
* agent can relay one clear sentence instead of inventing instructions.
|
|
852
|
+
*
|
|
853
|
+
* @param {RemoteDescription} d
|
|
854
|
+
* @returns {Missing[]}
|
|
855
|
+
*/
|
|
856
|
+
export function missingOn(d) {
|
|
857
|
+
/** @type {Missing[]} */
|
|
858
|
+
const missing = [];
|
|
859
|
+
if (!d.reachable) {
|
|
860
|
+
missing.push({
|
|
861
|
+
what: `a working ssh connection to ${d.host}`,
|
|
862
|
+
unlocks: 'running checks on that machine at all — its platform is invisible from here without it',
|
|
863
|
+
howToGet: `Check the entry for ${d.host} in ~/.ssh/config, and that the machine is switched on. `
|
|
864
|
+
+ `Test it with: ssh ${d.host} true`,
|
|
865
|
+
blocking: true,
|
|
866
|
+
});
|
|
867
|
+
return missing;
|
|
868
|
+
}
|
|
869
|
+
if (!d.tools.node) {
|
|
870
|
+
missing.push({
|
|
871
|
+
what: 'Node on that machine',
|
|
872
|
+
unlocks: 'the general remote runner, which is how any platform there is walked',
|
|
873
|
+
howToGet: `ssh ${d.host} 'sudo apt-get install -y nodejs' — or whatever that machine installs packages with.`,
|
|
874
|
+
blocking: true,
|
|
875
|
+
});
|
|
876
|
+
}
|
|
877
|
+
if (d.windows && d.desktopLoggedIn === false) {
|
|
878
|
+
missing.push({
|
|
879
|
+
what: 'somebody logged in on that Windows desktop',
|
|
880
|
+
unlocks: 'reading native Windows windows at all — there is nothing to read on a desktop nobody has signed into',
|
|
881
|
+
howToGet: 'Sign in on that machine once and leave the session running. Locking the screen afterwards is fine; signing out is not.',
|
|
882
|
+
blocking: true,
|
|
883
|
+
});
|
|
884
|
+
}
|
|
885
|
+
if (d.windows && d.desktopLocked === true) {
|
|
886
|
+
missing.push({
|
|
887
|
+
what: 'that Windows desktop left unlocked',
|
|
888
|
+
unlocks: 'full-screen pictures as evidence — a locked desktop photographs as solid black, though individual windows still photograph correctly and everything else works',
|
|
889
|
+
howToGet: 'Only a person can unlock it. Nothing else about the check needs this, so it is usually not worth doing.',
|
|
890
|
+
});
|
|
891
|
+
}
|
|
892
|
+
return missing;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/**
|
|
896
|
+
* The honest small print about one machine.
|
|
897
|
+
* @param {RemoteDescription} d
|
|
898
|
+
* @returns {string[]}
|
|
899
|
+
*/
|
|
900
|
+
export function notesOn(d) {
|
|
901
|
+
/** @type {string[]} */
|
|
902
|
+
const notes = [];
|
|
903
|
+
if (!d.reachable) return notes;
|
|
904
|
+
notes.push('Nothing is installed on that machine. The program that does the watching is sent down the connection each run and dies with it.');
|
|
905
|
+
if (d.windows) {
|
|
906
|
+
notes.push(`Windows is reached through ${d.powershell}, called from the Linux side. That absolute path is used deliberately: powershell.exe is not on the path of a non-interactive ssh session even when the machine is configured to add it.`);
|
|
907
|
+
notes.push('Windows shows one desktop, so two builds can never run there at the same time. Runs are one after the other, and that is a real weakening of the same-machine guarantee, not a detail.');
|
|
908
|
+
}
|
|
909
|
+
if (d.desktopLocked === true) notes.push('That desktop is locked right now. Windows can still be read; it just cannot be photographed whole.');
|
|
910
|
+
return notes;
|
|
911
|
+
}
|