agent-dev-env 0.1.0-canary.2.fbbd044
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +106 -0
- package/dist/assets/bridge/bridge.js +202 -0
- package/dist/assets/guest/guest-agent-mac.js +586 -0
- package/dist/assets/guest/guest-agent-ubuntu.js +481 -0
- package/dist/assets/guest/guest-agent-windows.js +466 -0
- package/dist/assets/images/mac/CHANGELOG.md +265 -0
- package/dist/assets/images/mac/README.md +63 -0
- package/dist/assets/images/mac/sandbox.pkr.hcl +468 -0
- package/dist/assets/images/mac/vars/sandbox-macos-tahoe.pkrvars.hcl +39 -0
- package/dist/assets/images/ubuntu-arm64-vmware/CHANGELOG.md +239 -0
- package/dist/assets/images/ubuntu-arm64-vmware/README.md +177 -0
- package/dist/assets/images/ubuntu-arm64-vmware/autoinstall/meta-data +2 -0
- package/dist/assets/images/ubuntu-arm64-vmware/autoinstall/user-data +34 -0
- package/dist/assets/images/ubuntu-arm64-vmware/sandbox.pkr.hcl +776 -0
- package/dist/assets/images/ubuntu-arm64-vmware/vars/sandbox-ubuntu-24-04-arm64-vmware.pkrvars.hcl +71 -0
- package/dist/assets/images/windows-arm64-qemu/CHANGELOG.md +209 -0
- package/dist/assets/images/windows-arm64-qemu/README.md +142 -0
- package/dist/assets/images/windows-arm64-qemu/autounattend.xml +325 -0
- package/dist/assets/images/windows-arm64-qemu/qemu-with-tpm.sh +170 -0
- package/dist/assets/images/windows-arm64-qemu/sandbox.pkr.hcl +1012 -0
- package/dist/assets/images/windows-arm64-qemu/vars/sandbox-windows-11-arm64-qemu.pkrvars.hcl +83 -0
- package/dist/assets/images/windows-arm64-vmware/CHANGELOG.md +270 -0
- package/dist/assets/images/windows-arm64-vmware/README.md +171 -0
- package/dist/assets/images/windows-arm64-vmware/autounattend.xml +311 -0
- package/dist/assets/images/windows-arm64-vmware/sandbox.pkr.hcl +1036 -0
- package/dist/assets/images/windows-arm64-vmware/vars/sandbox-windows-11-arm64-vmware.pkrvars.hcl +83 -0
- package/dist/assets/rules/agent-rules-linux.md +68 -0
- package/dist/assets/rules/agent-rules.md +71 -0
- package/dist/assets/watchdog/watch-build-ocr.swift +40 -0
- package/dist/assets/watchdog/watch-build.py +275 -0
- package/dist/cli.js +52 -0
- package/dist/commands/delete.js +179 -0
- package/dist/commands/doctor.js +165 -0
- package/dist/commands/list.js +38 -0
- package/dist/commands/not-yet.js +15 -0
- package/dist/commands/register.js +150 -0
- package/dist/commands/run.js +65 -0
- package/dist/commands/status.js +142 -0
- package/dist/commands/stop.js +186 -0
- package/dist/commands/sync.js +98 -0
- package/dist/lib/exec.js +250 -0
- package/dist/lib/ghcr.js +82 -0
- package/dist/lib/git.js +100 -0
- package/dist/lib/logger.js +87 -0
- package/dist/lib/network.js +63 -0
- package/dist/lib/paths.js +116 -0
- package/dist/lib/platform.js +87 -0
- package/dist/lib/prompt.js +66 -0
- package/dist/lib/qemu.js +373 -0
- package/dist/lib/regex.js +9 -0
- package/dist/lib/ssh.js +230 -0
- package/dist/lib/tart.js +210 -0
- package/dist/lib/template.js +35 -0
- package/dist/lib/vars.js +99 -0
- package/dist/lib/vmrun.js +334 -0
- package/dist/lifecycle/build-macos.js +23 -0
- package/dist/lifecycle/build-qemu.js +204 -0
- package/dist/lifecycle/build-shared.js +355 -0
- package/dist/lifecycle/build-ubuntu.js +184 -0
- package/dist/lifecycle/build-watchdog.js +146 -0
- package/dist/lifecycle/build-windows-vmware.js +112 -0
- package/dist/lifecycle/build.js +55 -0
- package/dist/lifecycle/catalog.js +165 -0
- package/dist/lifecycle/deploy.js +161 -0
- package/dist/lifecycle/tag.js +77 -0
- package/dist/lifecycle/watch-build.js +64 -0
- package/dist/runners/bridges.js +156 -0
- package/dist/runners/framework.js +66 -0
- package/dist/runners/macos-bridges.js +143 -0
- package/dist/runners/macos-guest.js +76 -0
- package/dist/runners/macos-rules.js +77 -0
- package/dist/runners/macos-summary.js +145 -0
- package/dist/runners/macos.js +258 -0
- package/dist/runners/openchamber.js +60 -0
- package/dist/runners/options.js +56 -0
- package/dist/runners/qemu-image.js +183 -0
- package/dist/runners/rules.js +49 -0
- package/dist/runners/ubuntu-bridges.js +154 -0
- package/dist/runners/ubuntu-guest.js +136 -0
- package/dist/runners/ubuntu-image.js +26 -0
- package/dist/runners/ubuntu-rules.js +86 -0
- package/dist/runners/ubuntu-shared.js +65 -0
- package/dist/runners/ubuntu-summary.js +141 -0
- package/dist/runners/ubuntu.js +117 -0
- package/dist/runners/vmware-common.js +158 -0
- package/dist/runners/vmware-image.js +242 -0
- package/dist/runners/windows-autologon.js +119 -0
- package/dist/runners/windows-bridges.js +136 -0
- package/dist/runners/windows-guest.js +213 -0
- package/dist/runners/windows-image.js +23 -0
- package/dist/runners/windows-qemu-summary.js +85 -0
- package/dist/runners/windows-qemu.js +189 -0
- package/dist/runners/windows-shared.js +68 -0
- package/dist/runners/windows-summary.js +104 -0
- package/dist/runners/windows.js +96 -0
- package/dist/settings/common.js +107 -0
- package/dist/settings/macos-copy.js +223 -0
- package/dist/settings/macos.js +41 -0
- package/dist/settings/ubuntu-copy.js +223 -0
- package/dist/settings/ubuntu.js +68 -0
- package/package.json +52 -0
package/dist/lib/qemu.js
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
// lib/qemu.ts — the QEMU/Windows backend's host-side helpers (Phase 6):
|
|
2
|
+
// the working-VM state paths (COW overlay + persistent TPM + EFI NVRAM
|
|
3
|
+
// under <data>/windows-qemu/<image>/working/), the backing-image identity
|
|
4
|
+
// marker (a rebuild replaces the qcow2 at the same path — path alone
|
|
5
|
+
// would silently stack the old overlay on a different base), the exact
|
|
6
|
+
// `launch_qemu` args builder (same wiring the image was built with:
|
|
7
|
+
// qemu-with-tpm.sh, minus the install media) and the swtpm/qemu process
|
|
8
|
+
// management (pidfiles, stale-process kills, the pgrep fallback). Port of
|
|
9
|
+
// run-windows-qemu-sandbox.sh §step 2/3/4 helpers + the stop script.
|
|
10
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { dirname, join } from 'node:path';
|
|
12
|
+
import { commandExists, isAlive, readPidFile, run, sleep, spawnDetached } from './exec.js';
|
|
13
|
+
import { logger } from './logger.js';
|
|
14
|
+
import { imageRootDir, paths } from './paths.js';
|
|
15
|
+
/** The platform id the qemu state paths are bound to. */
|
|
16
|
+
const PLATFORM = 'windows-qemu';
|
|
17
|
+
/** The address the guest reaches the host at (QEMU user-mode networking
|
|
18
|
+
* NAT gateway — the host bridges bind 127.0.0.1 instead).
|
|
19
|
+
*/
|
|
20
|
+
export const QEMU_HOST_ALIAS = '10.0.2.2';
|
|
21
|
+
/** Homebrew's qemu UEFI firmware (the efi_code in launch_qemu). */
|
|
22
|
+
export const QEMU_EFI_CODE = '/opt/homebrew/share/qemu/edk2-aarch64-code.fd';
|
|
23
|
+
/** Homebrew's qemu EFI vars template (seeded when the build output ships
|
|
24
|
+
* no efivars.fd). */
|
|
25
|
+
export const QEMU_EFI_VARS_TEMPLATE = '/opt/homebrew/share/qemu/edk2-arm-vars.fd';
|
|
26
|
+
// --- state paths -------------------------------------------------------------
|
|
27
|
+
/** <data>/windows-qemu/<image> — the per-image state root.
|
|
28
|
+
* @param image - The image name.
|
|
29
|
+
* @returns The state root.
|
|
30
|
+
*/
|
|
31
|
+
export function qemuStateDir(image) {
|
|
32
|
+
return imageRootDir(PLATFORM, image);
|
|
33
|
+
}
|
|
34
|
+
/** <data>/windows-qemu/<image>/image/<image>.qcow2 — the pristine disk
|
|
35
|
+
* (or the local pull cache).
|
|
36
|
+
* @param image - The image name.
|
|
37
|
+
* @returns The pristine qcow2 path.
|
|
38
|
+
*/
|
|
39
|
+
export function qemuImagePath(image) {
|
|
40
|
+
return join(qemuStateDir(image), 'image', `${image}.qcow2`);
|
|
41
|
+
}
|
|
42
|
+
/** <data>/windows-qemu/<image>/working — the working VM state dir
|
|
43
|
+
* (overlay, efivars.fd, tpm/, pidfiles, sockets).
|
|
44
|
+
* @param image - The image name.
|
|
45
|
+
* @returns The working dir.
|
|
46
|
+
*/
|
|
47
|
+
export function qemuWorkingDir(image) {
|
|
48
|
+
return join(qemuStateDir(image), 'working');
|
|
49
|
+
}
|
|
50
|
+
/** <data>/windows-qemu/<image>/working/<image>.qcow2 — the COW overlay
|
|
51
|
+
* the running VM writes to.
|
|
52
|
+
* @param image - The image name.
|
|
53
|
+
* @returns The overlay path.
|
|
54
|
+
*/
|
|
55
|
+
export function qemuOverlayPath(image) {
|
|
56
|
+
return join(qemuWorkingDir(image), `${image}.qcow2`);
|
|
57
|
+
}
|
|
58
|
+
/** The backing-image identity marker (path|size|mtime of the pristine
|
|
59
|
+
* disk the overlay was created from).
|
|
60
|
+
* @param image - The image name.
|
|
61
|
+
* @returns The marker path.
|
|
62
|
+
*/
|
|
63
|
+
export function qemuBackingMarker(image) {
|
|
64
|
+
return join(qemuWorkingDir(image), 'backing-image.txt');
|
|
65
|
+
}
|
|
66
|
+
/** The working VM's persistent EFI NVRAM store.
|
|
67
|
+
* @param image - The image name.
|
|
68
|
+
* @returns The efivars.fd path.
|
|
69
|
+
*/
|
|
70
|
+
export function qemuEfivarsPath(image) {
|
|
71
|
+
return join(qemuWorkingDir(image), 'efivars.fd');
|
|
72
|
+
}
|
|
73
|
+
/** The working VM's TPM state dir (Windows 11 needs TPM 2.0 and its
|
|
74
|
+
* credentials must survive reboots).
|
|
75
|
+
* @param image - The image name.
|
|
76
|
+
* @returns The tpm dir.
|
|
77
|
+
*/
|
|
78
|
+
export function qemuTpmDir(image) {
|
|
79
|
+
return join(qemuWorkingDir(image), 'tpm');
|
|
80
|
+
}
|
|
81
|
+
/** The qemu pidfile the runner writes.
|
|
82
|
+
* @param image - The image name.
|
|
83
|
+
* @returns The qemu.pid path.
|
|
84
|
+
*/
|
|
85
|
+
export function qemuPidFile(image) {
|
|
86
|
+
return join(qemuWorkingDir(image), 'qemu.pid');
|
|
87
|
+
}
|
|
88
|
+
/** The swtpm pidfile (swtpm --pid writes it after daemonizing).
|
|
89
|
+
*
|
|
90
|
+
* @internal — test-only export; production callers go through
|
|
91
|
+
* startSwtpm/stopSwtpm, which read this pidfile themselves.
|
|
92
|
+
* @param image - The image name.
|
|
93
|
+
* @returns The swtpm.pid path.
|
|
94
|
+
*/
|
|
95
|
+
export function swtpmPidFile(image) {
|
|
96
|
+
return join(qemuWorkingDir(image), 'swtpm.pid');
|
|
97
|
+
}
|
|
98
|
+
/** The swtpm control socket qemu connects its chardev to.
|
|
99
|
+
* @param image - The image name.
|
|
100
|
+
* @returns The swtpm.sock path.
|
|
101
|
+
*/
|
|
102
|
+
export function swtpmSockPath(image) {
|
|
103
|
+
return join(qemuWorkingDir(image), 'swtpm.sock');
|
|
104
|
+
}
|
|
105
|
+
/** The swtpm log (level=20), next to the socket.
|
|
106
|
+
* @param image - The image name.
|
|
107
|
+
* @returns The swtpm.log path.
|
|
108
|
+
*/
|
|
109
|
+
function swtpmLogPath(image) {
|
|
110
|
+
return join(qemuWorkingDir(image), 'swtpm.log');
|
|
111
|
+
}
|
|
112
|
+
/** The qemu log (the running VM's stdout/stderr).
|
|
113
|
+
* @returns The log path under the CLI's log dir.
|
|
114
|
+
*/
|
|
115
|
+
export function qemuLogPath() {
|
|
116
|
+
return join(paths.logs, 'qemu-windows-11.log');
|
|
117
|
+
}
|
|
118
|
+
/** The pristine-disk identity (path|size|mtime; the same scheme as
|
|
119
|
+
* vmware-image's archive marker — a rebuild packs the new image at the
|
|
120
|
+
* SAME path, so the path alone misses it).
|
|
121
|
+
*
|
|
122
|
+
* @param path - The disk path.
|
|
123
|
+
* @param size - File size in bytes.
|
|
124
|
+
* @param mtimeMs - File mtime (ms since epoch).
|
|
125
|
+
* @returns The identity string.
|
|
126
|
+
*/
|
|
127
|
+
export function backingIdentity(path, size, mtimeMs) {
|
|
128
|
+
return `${path}|${size}|${Math.floor(mtimeMs / 1000)}`;
|
|
129
|
+
}
|
|
130
|
+
/** Builds the exact `launch_qemu` arg list (same wiring the image was
|
|
131
|
+
* built with, minus the install media): virt machine, HVF, AAVMF UEFI,
|
|
132
|
+
* swtpm TPM 2.0 (ppi=off avoids a QEMU 11.1 HVF regression), xhci +
|
|
133
|
+
* keyboard + tablet, user-mode networking with the port forwards and
|
|
134
|
+
* virtio-gpu-pci (the image's driver store resolves viogpudo, so
|
|
135
|
+
* resizing the window changes the guest resolution).
|
|
136
|
+
*
|
|
137
|
+
* @param input - The resolved wiring.
|
|
138
|
+
* @returns The qemu-system-aarch64 arguments.
|
|
139
|
+
*/
|
|
140
|
+
export function buildQemuArgs(input) {
|
|
141
|
+
const hostfwd = [
|
|
142
|
+
`hostfwd=tcp:127.0.0.1:${input.sshPort}-:22`,
|
|
143
|
+
`hostfwd=tcp:127.0.0.1:${input.rdpPort}-:3389`,
|
|
144
|
+
`hostfwd=tcp:127.0.0.1:${input.openchamberPort}-:${input.openchamberPort}`,
|
|
145
|
+
`hostfwd=tcp:127.0.0.1:${input.winrmPort}-:5985`,
|
|
146
|
+
].join(',');
|
|
147
|
+
return [
|
|
148
|
+
'-machine',
|
|
149
|
+
'virt,gic-version=max',
|
|
150
|
+
'-accel',
|
|
151
|
+
'hvf',
|
|
152
|
+
'-cpu',
|
|
153
|
+
'host',
|
|
154
|
+
'-smp',
|
|
155
|
+
String(input.cpuCount),
|
|
156
|
+
'-m',
|
|
157
|
+
String(input.memoryMb),
|
|
158
|
+
'-drive',
|
|
159
|
+
`if=pflash,format=raw,readonly=on,file=${input.efiCode}`,
|
|
160
|
+
'-drive',
|
|
161
|
+
`if=pflash,format=raw,file=${input.efivars}`,
|
|
162
|
+
'-drive',
|
|
163
|
+
`file=${input.overlay},if=virtio,format=qcow2`,
|
|
164
|
+
'-device',
|
|
165
|
+
'virtio-net-pci,netdev=net0',
|
|
166
|
+
'-netdev',
|
|
167
|
+
`user,id=net0,${hostfwd}`,
|
|
168
|
+
'-chardev',
|
|
169
|
+
`socket,id=chrtpm,path=${input.tpmSock}`,
|
|
170
|
+
'-tpmdev',
|
|
171
|
+
'emulator,id=tpm0,chardev=chrtpm',
|
|
172
|
+
'-device',
|
|
173
|
+
'tpm-tis-device,tpmdev=tpm0,ppi=off',
|
|
174
|
+
'-device',
|
|
175
|
+
'virtio-gpu-pci',
|
|
176
|
+
'-device',
|
|
177
|
+
'qemu-xhci,id=usb',
|
|
178
|
+
'-device',
|
|
179
|
+
'usb-kbd,bus=usb.0',
|
|
180
|
+
'-device',
|
|
181
|
+
'usb-tablet,bus=usb.0',
|
|
182
|
+
...(input.headless ? ['-display', 'none'] : ['-display', 'cocoa,zoom-to-fit=on']),
|
|
183
|
+
];
|
|
184
|
+
}
|
|
185
|
+
// --- prereq ------------------------------------------------------------------
|
|
186
|
+
/** The qemu/swtpm prereq (same message as doctor).
|
|
187
|
+
*/
|
|
188
|
+
export function requireQemu() {
|
|
189
|
+
if (!commandExists('qemu-system-aarch64')) {
|
|
190
|
+
logger.die("qemu-system-aarch64 is not installed — run 'brew install qemu' first.");
|
|
191
|
+
}
|
|
192
|
+
if (!commandExists('qemu-img')) {
|
|
193
|
+
logger.die("qemu-img is not installed — run 'brew install qemu' first.");
|
|
194
|
+
}
|
|
195
|
+
if (!commandExists('swtpm')) {
|
|
196
|
+
logger.die("swtpm is not installed — run 'brew install swtpm' first.");
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
// --- process helpers ---------------------------------------------------------
|
|
200
|
+
/** @internal — SIGTERM + poll up to 30 s (the stop script's kill_wait).
|
|
201
|
+
* @param label - The process label (qemu / swtpm).
|
|
202
|
+
* @param pid - The pid to stop.
|
|
203
|
+
* @returns True when the process is gone.
|
|
204
|
+
*/
|
|
205
|
+
async function killWait(label, pid) {
|
|
206
|
+
if (!isAlive(pid)) {
|
|
207
|
+
logger.info(`${label} is already stopped (pid ${pid} is not running).`);
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
logger.cmd(`kill ${pid}`);
|
|
211
|
+
try {
|
|
212
|
+
process.kill(pid, 'SIGTERM');
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
// already gone
|
|
216
|
+
}
|
|
217
|
+
process.stdout.write(` Waiting for ${label} to stop`);
|
|
218
|
+
for (let n = 0; n < 30; n += 1) {
|
|
219
|
+
if (!isAlive(pid)) {
|
|
220
|
+
process.stdout.write(` ${logger.color('green')}stopped${logger.reset()}\n`);
|
|
221
|
+
return true;
|
|
222
|
+
}
|
|
223
|
+
await sleep(1000);
|
|
224
|
+
}
|
|
225
|
+
process.stdout.write(` ${logger.color('yellow')}failed${logger.reset()}\n`);
|
|
226
|
+
logger.warn(`${label} (pid ${pid}) did not stop after 30 s — kill it manually with: kill -9 ${pid}`);
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
/** Whether a runner-launched qemu is alive (pidfile first, then the
|
|
230
|
+
* overlay-path pgrep fallback — the path is unique to this sandbox).
|
|
231
|
+
*
|
|
232
|
+
* @param image - The image name.
|
|
233
|
+
* @returns True when qemu is running.
|
|
234
|
+
*/
|
|
235
|
+
export function isQemuAlive(image) {
|
|
236
|
+
const pid = readPidFile(qemuPidFile(image));
|
|
237
|
+
if (pid !== undefined) {
|
|
238
|
+
return isAlive(pid);
|
|
239
|
+
}
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
/** @internal — runner-launched qemu pids by overlay path command line
|
|
243
|
+
* (a pidfile-less qemu from a crashed run).
|
|
244
|
+
* @param image - The image name.
|
|
245
|
+
* @returns The pids, in pgrep order.
|
|
246
|
+
*/
|
|
247
|
+
async function findQemuPids(image) {
|
|
248
|
+
const res = await run('pgrep', ['-f', `qemu-system-aarch64.*${qemuStateDir(image)}`]);
|
|
249
|
+
if (res.code !== 0) {
|
|
250
|
+
return [];
|
|
251
|
+
}
|
|
252
|
+
return res.stdout
|
|
253
|
+
.split('\n')
|
|
254
|
+
.map((line) => Number.parseInt(line.trim(), 10))
|
|
255
|
+
.filter((pid) => Number.isInteger(pid) && pid > 0);
|
|
256
|
+
}
|
|
257
|
+
/** Stops the sandbox qemu (pidfile first, pgrep fallback) — the stop
|
|
258
|
+
* script's stop_qemu. Idempotent.
|
|
259
|
+
*
|
|
260
|
+
* @param image - The image name.
|
|
261
|
+
*/
|
|
262
|
+
export async function stopQemu(image) {
|
|
263
|
+
const pidfile = qemuPidFile(image);
|
|
264
|
+
const pid = readPidFile(pidfile);
|
|
265
|
+
if (pid !== undefined) {
|
|
266
|
+
if (isAlive(pid)) {
|
|
267
|
+
await killWait('qemu', pid);
|
|
268
|
+
rmSync(pidfile, { force: true });
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
logger.info(`qemu (pid ${pid}) is not running — removing the stale pidfile.`);
|
|
272
|
+
rmSync(pidfile, { force: true });
|
|
273
|
+
}
|
|
274
|
+
const pids = await findQemuPids(image);
|
|
275
|
+
if (pids.length === 0) {
|
|
276
|
+
logger.info('qemu is not running — nothing to stop.');
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
logger.warn(`A sandbox qemu is running (pid(s): ${pids.join(', ')}) with no pidfile — stopping it.`);
|
|
280
|
+
for (const running of pids) {
|
|
281
|
+
await killWait('qemu', running);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
/** Stops swtpm (it otherwise holds the TPM state lock and blocks the next
|
|
285
|
+
* run) and removes the stale pidfile + socket. Idempotent.
|
|
286
|
+
*
|
|
287
|
+
* @param image - The image name.
|
|
288
|
+
*/
|
|
289
|
+
export async function stopSwtpm(image) {
|
|
290
|
+
const pidfile = swtpmPidFile(image);
|
|
291
|
+
const pid = readPidFile(pidfile);
|
|
292
|
+
if (pid !== undefined) {
|
|
293
|
+
if (isAlive(pid)) {
|
|
294
|
+
await killWait('swtpm', pid);
|
|
295
|
+
}
|
|
296
|
+
else {
|
|
297
|
+
logger.info(`swtpm (pid ${pid}) is not running — removing the stale pidfile.`);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
else {
|
|
301
|
+
logger.info(`No swtpm pidfile (${pidfile}) — nothing to stop.`);
|
|
302
|
+
}
|
|
303
|
+
rmSync(pidfile, { force: true });
|
|
304
|
+
rmSync(swtpmSockPath(image), { force: true });
|
|
305
|
+
}
|
|
306
|
+
/** Starts swtpm (TPM 2.0; the state must persist for the credentials
|
|
307
|
+
* inside the guest to keep working). A stale swtpm from a previous run
|
|
308
|
+
* holds a lock on the TPM state dir — kill it first like the shell.
|
|
309
|
+
*
|
|
310
|
+
* @param image - The image name.
|
|
311
|
+
* @returns The daemonized swtpm pid.
|
|
312
|
+
*/
|
|
313
|
+
export async function startSwtpm(image) {
|
|
314
|
+
const sock = swtpmSockPath(image);
|
|
315
|
+
const pidfile = swtpmPidFile(image);
|
|
316
|
+
const tpm = qemuTpmDir(image);
|
|
317
|
+
const old = readPidFile(pidfile);
|
|
318
|
+
if (old !== undefined && isAlive(old)) {
|
|
319
|
+
logger.warn(`stale swtpm (pid ${old}) still running — stopping it.`);
|
|
320
|
+
try {
|
|
321
|
+
process.kill(old, 'SIGTERM');
|
|
322
|
+
}
|
|
323
|
+
catch {
|
|
324
|
+
// already gone
|
|
325
|
+
}
|
|
326
|
+
await sleep(1000);
|
|
327
|
+
}
|
|
328
|
+
rmSync(sock, { force: true });
|
|
329
|
+
rmSync(pidfile, { force: true });
|
|
330
|
+
mkdirSync(tpm, { recursive: true });
|
|
331
|
+
logger.cmd(`swtpm socket --tpm2 --tpmstate dir=${tpm} --ctrl type=unixio,path=${sock}`);
|
|
332
|
+
const res = await run('swtpm', [
|
|
333
|
+
'socket',
|
|
334
|
+
'--tpmstate',
|
|
335
|
+
`dir=${tpm}`,
|
|
336
|
+
'--ctrl',
|
|
337
|
+
`type=unixio,path=${sock}`,
|
|
338
|
+
'--log',
|
|
339
|
+
`file=${swtpmLogPath(image)},level=20`,
|
|
340
|
+
'--pid',
|
|
341
|
+
`file=${pidfile}`,
|
|
342
|
+
'--tpm2',
|
|
343
|
+
'--daemon',
|
|
344
|
+
]);
|
|
345
|
+
if (res.code !== 0) {
|
|
346
|
+
logger.die(`swtpm failed to start:\n${res.stderr.trim()}`);
|
|
347
|
+
}
|
|
348
|
+
for (let n = 0; n < 10 && !existsSync(sock); n += 1) {
|
|
349
|
+
await sleep(1000);
|
|
350
|
+
}
|
|
351
|
+
if (!existsSync(sock)) {
|
|
352
|
+
logger.die(`swtpm socket ${sock} did not appear (see ${swtpmLogPath(image)}).`);
|
|
353
|
+
}
|
|
354
|
+
const pid = readPidFile(pidfile) ?? -1;
|
|
355
|
+
logger.ok(`swtpm is up (pid ${pid}).`);
|
|
356
|
+
return pid;
|
|
357
|
+
}
|
|
358
|
+
/** Launches qemu detached (the VM outlives the CLI by design; the log
|
|
359
|
+
* gets stdout+stderr) and records the pidfile for stop/status.
|
|
360
|
+
*
|
|
361
|
+
* @param image - The image name.
|
|
362
|
+
* @param args - The qemu-system-aarch64 arguments (buildQemuArgs).
|
|
363
|
+
* @returns The qemu pid.
|
|
364
|
+
*/
|
|
365
|
+
export async function launchQemu(image, args) {
|
|
366
|
+
const logDir = dirname(qemuLogPath());
|
|
367
|
+
mkdirSync(logDir, { recursive: true });
|
|
368
|
+
logger.cmd(`qemu-system-aarch64 ${args.join(' ')}`);
|
|
369
|
+
logger.info(`Running the VM in the background (output: ${qemuLogPath()}).`);
|
|
370
|
+
const pid = spawnDetached('qemu-system-aarch64', args, { logFile: qemuLogPath() });
|
|
371
|
+
writeFileSync(qemuPidFile(image), `${pid}\n`);
|
|
372
|
+
return pid;
|
|
373
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// regex.ts — tiny shared helpers.
|
|
2
|
+
/** Escapes a string so it matches literally in a RegExp.
|
|
3
|
+
*
|
|
4
|
+
* @param text - The string to escape.
|
|
5
|
+
* @returns The escaped pattern fragment.
|
|
6
|
+
*/
|
|
7
|
+
export function escapeRegex(text) {
|
|
8
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
9
|
+
}
|
package/dist/lib/ssh.js
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// lib/ssh.ts — the ssh2 transport: the unified replacement for the shell
|
|
2
|
+
// runners' expect/perl/system-ssh glue (guest_sh, settings_ssh,
|
|
3
|
+
// settings_scp and the BatchMode sshd probe). Password auth only (the
|
|
4
|
+
// sandbox images' fixed user/password from the vars file), host-key
|
|
5
|
+
// verification skipped (StrictHostKeyChecking=no + UserKnownHostsFile=
|
|
6
|
+
// /dev/null parity), commands run pty-less so `sudo -S` reads the
|
|
7
|
+
// password from stdin — the same semantics as the shell's expect sessions.
|
|
8
|
+
//
|
|
9
|
+
// The runner-facing surface is the SshSession interface (exec + sftpWrite
|
|
10
|
+
// + end), implemented on top of a single ssh2 Client per session, so the
|
|
11
|
+
// guest-side modules never touch ssh2 directly and can be unit-tested
|
|
12
|
+
// with a fake session.
|
|
13
|
+
import { Client } from 'ssh2';
|
|
14
|
+
const DEFAULT_READY_TIMEOUT_MS = 15_000;
|
|
15
|
+
/** @internal — classifies a connection-error message (exported for the
|
|
16
|
+
* co-located unit tests; the transport keeps it internal).
|
|
17
|
+
*
|
|
18
|
+
* @param message - The Error message from ssh2's `error` event.
|
|
19
|
+
* @returns `up` when sshd answered (auth failed / handshake started),
|
|
20
|
+
* `down` on connect-level failures, `unknown` otherwise.
|
|
21
|
+
*/
|
|
22
|
+
export function sshProbeOutcome(message) {
|
|
23
|
+
if (/authentication|permission denied|key exchange|protocol/i.test(message)) {
|
|
24
|
+
return 'up';
|
|
25
|
+
}
|
|
26
|
+
if (/ECONNREFUSED|ECONNRESET|ETIMEDOUT|ENETUNREACH|EHOSTUNREACH/i.test(message)) {
|
|
27
|
+
return 'down';
|
|
28
|
+
}
|
|
29
|
+
return 'unknown';
|
|
30
|
+
}
|
|
31
|
+
/** Opens a password-authenticated session.
|
|
32
|
+
*
|
|
33
|
+
* @param credentials - Host/port/user/password.
|
|
34
|
+
* @param readyTimeoutMs - Connect handshake budget (default 15 s).
|
|
35
|
+
* @returns A connected session (throws when the handshake fails).
|
|
36
|
+
*/
|
|
37
|
+
export async function openSshSession(credentials, readyTimeoutMs = DEFAULT_READY_TIMEOUT_MS) {
|
|
38
|
+
return new Promise((resolve, reject) => {
|
|
39
|
+
const client = new Client();
|
|
40
|
+
let settled = false;
|
|
41
|
+
client.on('error', (err) => {
|
|
42
|
+
if (settled) {
|
|
43
|
+
// Post-ready errors surface on the channels; a listener must stay
|
|
44
|
+
// attached or the event would crash the process.
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
settled = true;
|
|
48
|
+
reject(err);
|
|
49
|
+
});
|
|
50
|
+
client.once('ready', () => {
|
|
51
|
+
settled = true;
|
|
52
|
+
resolve(createSession(client));
|
|
53
|
+
});
|
|
54
|
+
client.connect({
|
|
55
|
+
host: credentials.host,
|
|
56
|
+
port: credentials.port,
|
|
57
|
+
username: credentials.username,
|
|
58
|
+
password: credentials.password,
|
|
59
|
+
readyTimeout: readyTimeoutMs,
|
|
60
|
+
// StrictHostKeyChecking=no + UserKnownHostsFile=/dev/null parity.
|
|
61
|
+
hostVerifier: () => true,
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
function createSession(client) {
|
|
66
|
+
let ended = false;
|
|
67
|
+
return {
|
|
68
|
+
exec: (command, options = {}) => execOn(client, command, options),
|
|
69
|
+
sftpWrite: (remotePath, data) => sftpWrite(client, remotePath, data),
|
|
70
|
+
end: () => {
|
|
71
|
+
if (!ended) {
|
|
72
|
+
ended = true;
|
|
73
|
+
client.end();
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/** One remote command via `client.exec` — the expect `spawn ssh … $cmd`
|
|
79
|
+
* equivalent. Safe against the legacy hang edge cases: a watchdog
|
|
80
|
+
* terminates the session when the remote never closes the channel, and a
|
|
81
|
+
* sentinel ends the exec as soon as the remote command echoes it (the
|
|
82
|
+
* Windows OpenSSH channel never closes on its own when guest background
|
|
83
|
+
* relays hold the console handles — the shell's `echo $sentinel` trick).
|
|
84
|
+
*
|
|
85
|
+
* @param client - The connected ssh2 client.
|
|
86
|
+
* @param command - The remote shell command.
|
|
87
|
+
* @param options - stdin payload, a hard timeout and/or the completion
|
|
88
|
+
* sentinel.
|
|
89
|
+
* @returns The remote exit code + collected output.
|
|
90
|
+
*/
|
|
91
|
+
function execOn(client, command, options) {
|
|
92
|
+
return new Promise((resolve) => {
|
|
93
|
+
let settled = false;
|
|
94
|
+
let watchdog;
|
|
95
|
+
if (options.timeoutMs !== undefined) {
|
|
96
|
+
watchdog = setTimeout(() => {
|
|
97
|
+
if (!settled) {
|
|
98
|
+
settled = true;
|
|
99
|
+
resolve({
|
|
100
|
+
code: -1,
|
|
101
|
+
stdout: '',
|
|
102
|
+
stderr: `remote command timed out (${options.timeoutMs} ms): ${command}`,
|
|
103
|
+
});
|
|
104
|
+
client.destroy();
|
|
105
|
+
}
|
|
106
|
+
}, options.timeoutMs);
|
|
107
|
+
}
|
|
108
|
+
const finish = (result) => {
|
|
109
|
+
if (settled) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
settled = true;
|
|
113
|
+
if (watchdog !== undefined) {
|
|
114
|
+
clearTimeout(watchdog);
|
|
115
|
+
}
|
|
116
|
+
resolve(result);
|
|
117
|
+
};
|
|
118
|
+
client.exec(command, (err, stream) => {
|
|
119
|
+
if (err) {
|
|
120
|
+
finish({ code: -1, stdout: '', stderr: err.message });
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
runChannel(stream, options.input, options.sentinel, finish);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
/** Collects one exec channel: stdout/stderr until close, stdin payload
|
|
128
|
+
* written and ended. When a sentinel is given, the exec finishes (and the
|
|
129
|
+
* channel is destroyed) as soon as the sentinel shows up on stdout — the
|
|
130
|
+
* completion marker the command echoed after its payload exited; the
|
|
131
|
+
* leftover output after it belongs to the guest's background relays.
|
|
132
|
+
*
|
|
133
|
+
* @param stream - The ssh2 exec channel.
|
|
134
|
+
* @param input - Optional remote stdin payload.
|
|
135
|
+
* @param sentinel - Optional completion marker to look for on stdout.
|
|
136
|
+
* @param finish - The single-settle callback.
|
|
137
|
+
*/
|
|
138
|
+
function runChannel(stream, input, sentinel, finish) {
|
|
139
|
+
let stdout = '';
|
|
140
|
+
let stderr = '';
|
|
141
|
+
stream.on('data', (chunk) => {
|
|
142
|
+
stdout += chunk.toString();
|
|
143
|
+
if (sentinel && stdout.includes(sentinel)) {
|
|
144
|
+
finish({ code: 0, stdout, stderr });
|
|
145
|
+
// Under the guest's control now — killing the channel is exactly
|
|
146
|
+
// what the shell's `exec kill [exp_pid]` did: sshd cleans up the
|
|
147
|
+
// hung session instead of our client waiting for a close that never
|
|
148
|
+
// comes.
|
|
149
|
+
stream.destroy();
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
stream.stderr.on('data', (chunk) => {
|
|
153
|
+
stderr += chunk.toString();
|
|
154
|
+
});
|
|
155
|
+
stream.on('error', (streamErr) => {
|
|
156
|
+
finish({ code: -1, stdout, stderr: streamErr.message });
|
|
157
|
+
});
|
|
158
|
+
stream.on('close', (code) => {
|
|
159
|
+
finish({ code: code ?? -1, stdout, stderr });
|
|
160
|
+
});
|
|
161
|
+
if (input !== undefined) {
|
|
162
|
+
stream.write(input);
|
|
163
|
+
}
|
|
164
|
+
stream.end();
|
|
165
|
+
}
|
|
166
|
+
/** Uploads bytes to a remote file (SFTP writeFile; parent dirs must
|
|
167
|
+
* exist — the callers mkdir first).
|
|
168
|
+
*
|
|
169
|
+
* @param client - The connected ssh2 client.
|
|
170
|
+
* @param remotePath - The destination path in the guest.
|
|
171
|
+
* @param data - The file content.
|
|
172
|
+
*/
|
|
173
|
+
function sftpWrite(client, remotePath, data) {
|
|
174
|
+
return new Promise((resolve, reject) => {
|
|
175
|
+
client.sftp((err, sftp) => {
|
|
176
|
+
if (err) {
|
|
177
|
+
reject(err);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
sftp.writeFile(remotePath, data, (writeErr) => {
|
|
181
|
+
sftp.end();
|
|
182
|
+
if (writeErr) {
|
|
183
|
+
reject(writeErr);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
resolve();
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
/** Probes whether sshd is up at a host — one connect attempt. An auth
|
|
192
|
+
* error means the server answered (the legacy "Permission denied →
|
|
193
|
+
* ready" semantics); anything else depends on the failure kind.
|
|
194
|
+
* Exported for the QEMU backend's boot loop, which interleaves the
|
|
195
|
+
* probe with a qemu-alive check (waitForSshd polls unattended).
|
|
196
|
+
*
|
|
197
|
+
* @param credentials - The probe target.
|
|
198
|
+
* @param readyTimeoutMs - Handshake budget (default 15 s).
|
|
199
|
+
* @returns The classified outcome.
|
|
200
|
+
*/
|
|
201
|
+
export async function probeSshd(credentials, readyTimeoutMs = DEFAULT_READY_TIMEOUT_MS) {
|
|
202
|
+
try {
|
|
203
|
+
const session = await openSshSession(credentials, readyTimeoutMs);
|
|
204
|
+
session.end();
|
|
205
|
+
return 'up';
|
|
206
|
+
}
|
|
207
|
+
catch (err) {
|
|
208
|
+
return sshProbeOutcome(err.message);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
/** Polls until sshd answers (the legacy wait_for_sshd, 150 x 4 s).
|
|
212
|
+
*
|
|
213
|
+
* @param credentials - The guest to probe.
|
|
214
|
+
* @param options - Poll count/delay + per-attempt budget.
|
|
215
|
+
* @returns True when sshd came up within the window.
|
|
216
|
+
*/
|
|
217
|
+
export async function waitForSshd(credentials, options = {}) {
|
|
218
|
+
const tries = options.tries ?? 150;
|
|
219
|
+
const delayMs = options.delayMs ?? 4000;
|
|
220
|
+
for (let attempt = 0; attempt < tries; attempt += 1) {
|
|
221
|
+
const outcome = await probeSshd(credentials, options.readyTimeoutMs);
|
|
222
|
+
if (outcome === 'up') {
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
if (attempt < tries - 1) {
|
|
226
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return false;
|
|
230
|
+
}
|