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
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// runners/windows-guest.ts — the guest side of the Windows step 3
|
|
2
|
+
// (VMware + QEMU backends): the credentials (from the image's vars file,
|
|
3
|
+
// WINDOWS_PASSWORD override, the sshd port — 22 direct, the hostfwd port
|
|
4
|
+
// behind QEMU), the PowerShell transport over the ssh2 session (utf16le
|
|
5
|
+
// base64 → -EncodedCommand, the expect guest_ps port) and the wiring of
|
|
6
|
+
// the bundled guest-agent-windows.js (upload over SFTP → install →
|
|
7
|
+
// status → docker info verification). Only the host alias differs
|
|
8
|
+
// (10.0.2.2 vs the NAT router x.y.z.1).
|
|
9
|
+
//
|
|
10
|
+
// Every PowerShell command is wrapped with a completion sentinel: the
|
|
11
|
+
// Windows OpenSSH channel does not close when the guest session's command
|
|
12
|
+
// finishes (the guest relays and OpenChamber hold the console handles),
|
|
13
|
+
// so the exec ends early when the sentinel arrives — the shell's
|
|
14
|
+
// `echo $sentinel` + kill-ssh-client trick, now in lib/ssh.ts.
|
|
15
|
+
import { randomBytes } from 'node:crypto';
|
|
16
|
+
import { readFileSync } from 'node:fs';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
import { varsFor, resolveImage } from '../lifecycle/catalog.js';
|
|
19
|
+
import { sleep } from '../lib/exec.js';
|
|
20
|
+
import { logger } from '../lib/logger.js';
|
|
21
|
+
import { parseGuestStatus } from './ubuntu-guest.js';
|
|
22
|
+
/** The guest's agent install dir (image-fixed C:\tools), SFTP style. */
|
|
23
|
+
const GUEST_AGENT_DIR_WS = 'C:/tools/agent-dev-env';
|
|
24
|
+
/** The guest's agent install dir, PowerShell style. */
|
|
25
|
+
const GUEST_AGENT_DIR_PS = 'C:\\tools\\agent-dev-env';
|
|
26
|
+
/** The absolute guest-agent path, PowerShell style. */
|
|
27
|
+
const GUEST_AGENT_PS = `${GUEST_AGENT_DIR_PS}\\guest-agent-windows.js`;
|
|
28
|
+
/** The guest's sshd port (fixed in the image; the VMware backends reach
|
|
29
|
+
* it directly, the QEMU backend through the hostfwd forward). */
|
|
30
|
+
const GUEST_SSH_PORT = 22;
|
|
31
|
+
/** The bundled Windows guest agent (single-file, node-only). */
|
|
32
|
+
function guestAgentPath() {
|
|
33
|
+
return fileURLToPath(new URL('../assets/guest/guest-agent-windows.js', import.meta.url));
|
|
34
|
+
}
|
|
35
|
+
/** @internal — resolves the guest credentials from the image's vars file
|
|
36
|
+
* (+ WINDOWS_PASSWORD override). Pure so the fallbacks are testable.
|
|
37
|
+
*
|
|
38
|
+
* @param vars - The vars file values.
|
|
39
|
+
* @param env - Environment (WINDOWS_PASSWORD).
|
|
40
|
+
* @param ip - The guest IP (the hostfwd loopback for QEMU).
|
|
41
|
+
* @param sshPort - The sshd port to reach (guest 22; the forwarded host
|
|
42
|
+
* port when the guest sits behind QEMU's hostfwd).
|
|
43
|
+
* @returns The credentials.
|
|
44
|
+
* @throws Error when no winrm_password is available.
|
|
45
|
+
*/
|
|
46
|
+
export function guestCredentials(vars, env, ip, sshPort = GUEST_SSH_PORT) {
|
|
47
|
+
const username = typeof vars.winrm_username === 'string' && vars.winrm_username.trim()
|
|
48
|
+
? vars.winrm_username
|
|
49
|
+
: 'Administrator';
|
|
50
|
+
const password = env.WINDOWS_PASSWORD ?? (typeof vars.winrm_password === 'string' ? vars.winrm_password : '');
|
|
51
|
+
if (!password) {
|
|
52
|
+
throw new Error('could not read winrm_password from the vars file — set WINDOWS_PASSWORD to the guest password.');
|
|
53
|
+
}
|
|
54
|
+
return { host: ip, port: sshPort, username, password };
|
|
55
|
+
}
|
|
56
|
+
/** The credentials for the current run (image vars + env + guest IP).
|
|
57
|
+
*
|
|
58
|
+
* @param image - The image name.
|
|
59
|
+
* @param env - Environment.
|
|
60
|
+
* @param ip - The guest IP (the hostfwd loopback for QEMU).
|
|
61
|
+
* @param sshPort - The sshd port to reach (guest 22; the forwarded host
|
|
62
|
+
* port when the guest sits behind QEMU's hostfwd).
|
|
63
|
+
* @returns The credentials.
|
|
64
|
+
*/
|
|
65
|
+
export function resolveGuestCredentials(image, env, ip, sshPort = GUEST_SSH_PORT) {
|
|
66
|
+
return guestCredentials(varsFor(resolveImage(image)), env, ip, sshPort);
|
|
67
|
+
}
|
|
68
|
+
/** @internal — strips the trailing output after the completion sentinel
|
|
69
|
+
* (the guest's background relays keep trickling output past it).
|
|
70
|
+
*
|
|
71
|
+
* @param output - The raw exec stdout.
|
|
72
|
+
* @param sentinel - The sentinel the remote command echoed.
|
|
73
|
+
* @returns The output up to (and excluding) the sentinel.
|
|
74
|
+
*/
|
|
75
|
+
export function stripSentinel(output, sentinel) {
|
|
76
|
+
const index = output.indexOf(sentinel);
|
|
77
|
+
return index === -1 ? output : output.slice(0, index);
|
|
78
|
+
}
|
|
79
|
+
/** @internal — the -EncodedCommand payload: UTF-16LE base64 (the legacy
|
|
80
|
+
* `iconv -f UTF-8 -t UTF-16LE | base64` port; --encoded-command decodes
|
|
81
|
+
* UTF-16 regardless of BOM). Exported for the co-located test only.
|
|
82
|
+
*
|
|
83
|
+
* @param script - The PowerShell script (ASCII).
|
|
84
|
+
* @returns The base64 string.
|
|
85
|
+
*/
|
|
86
|
+
export function encodePsCommand(script) {
|
|
87
|
+
return Buffer.from(script, 'utf16le').toString('base64');
|
|
88
|
+
}
|
|
89
|
+
/** Runs a PowerShell snippet in the guest (the expect guest_ps port:
|
|
90
|
+
* UTF-16LE base64 via -EncodedCommand, so quoting stays sane; the
|
|
91
|
+
* completion sentinel ends the exec early, because the Windows OpenSSH
|
|
92
|
+
* channel never closes on its own once the guest relays hold the console
|
|
93
|
+
* handles). The snippet MUST be ASCII-only (the packed-agents rule).
|
|
94
|
+
*
|
|
95
|
+
* @param session - The connected guest session.
|
|
96
|
+
* @param script - The PowerShell script (ASCII).
|
|
97
|
+
* @param timeoutMs - Hard budget for the whole command (5 min default,
|
|
98
|
+
* like the perl alarm in the shell).
|
|
99
|
+
* @returns The script's stdout (pre-sentinel; errors land on stderr).
|
|
100
|
+
*/
|
|
101
|
+
export async function psExec(session, script, timeoutMs = 300_000) {
|
|
102
|
+
const b64 = encodePsCommand(script);
|
|
103
|
+
const sentinel = `ade-${randomBytes(8).toString('hex')}`;
|
|
104
|
+
const command = `powershell -NoProfile -NonInteractive -EncodedCommand ${b64} ; ` + `Write-Output ${sentinel}`;
|
|
105
|
+
const res = await session.exec(command, { sentinel, timeoutMs });
|
|
106
|
+
return { stdout: stripSentinel(res.stdout, sentinel), stderr: res.stderr };
|
|
107
|
+
}
|
|
108
|
+
/** Reports the path of the node binary inside the guest (the image ships
|
|
109
|
+
* node; resolve once per session like the other backends).
|
|
110
|
+
*
|
|
111
|
+
* @param session - The connected guest session.
|
|
112
|
+
* @returns The node path, or undefined when not found.
|
|
113
|
+
*/
|
|
114
|
+
export async function findGuestNode(session) {
|
|
115
|
+
const script = [
|
|
116
|
+
'$p = where.exe node 2>$null | Select-Object -First 1',
|
|
117
|
+
'if ($p) { Write-Output $p }',
|
|
118
|
+
].join('\n');
|
|
119
|
+
const res = await psExec(session, script, 20_000);
|
|
120
|
+
const node = res.stdout.trim().split('\n')[0] ?? '';
|
|
121
|
+
return node ? node : undefined;
|
|
122
|
+
}
|
|
123
|
+
/** Uploads the bundled guest agent and runs `install` (idempotent:
|
|
124
|
+
* schtasks re-registered + the env/docker-context setup + the immediate
|
|
125
|
+
* relay start, persisted as ONLOGON tasks). The install writes
|
|
126
|
+
* start-relays.cmd into C:\tools, so the agent dir is created first.
|
|
127
|
+
*
|
|
128
|
+
* @param session - The connected guest session.
|
|
129
|
+
* @param node - The guest's node binary.
|
|
130
|
+
* @param hostAlias - The NAT host address the bridges point at.
|
|
131
|
+
* @param context - The run context (ports).
|
|
132
|
+
*/
|
|
133
|
+
export async function ensureGuestAgent(session, node, hostAlias, context) {
|
|
134
|
+
const mkdir = await psExec(session, `New-Item -ItemType Directory -Force '${GUEST_AGENT_DIR_PS}' | Out-Null`, 30_000);
|
|
135
|
+
if (mkdir.stderr.trim()) {
|
|
136
|
+
logger.warn(`could not prepare the guest agent dir: ${mkdir.stderr.trim()}`);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
try {
|
|
140
|
+
await session.sftpWrite(`${GUEST_AGENT_DIR_WS}/guest-agent-windows.js`, readFileSync(guestAgentPath()));
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
logger.warn(`could not upload the guest agent: ${err.message}`);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const install = await psExec(session, `& "${node}" "${GUEST_AGENT_PS}" install --agent-port ${context.agentPort} ` +
|
|
147
|
+
`--docker-port ${context.dockerPort} --host-alias ${hostAlias}`, 120_000);
|
|
148
|
+
if (!install.stdout.includes('installed:')) {
|
|
149
|
+
logger.warn(`guest agent install failed (${install.stderr.trim() || 'no status line'}).`);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
logger.ok('Guest agent installed (SSH agent + Docker bridges persistent).');
|
|
153
|
+
}
|
|
154
|
+
/** Runs `guest-agent status` and folds the bridge-status lines into the
|
|
155
|
+
* run state. The pipes need a moment after `install` starts the relays
|
|
156
|
+
* (schtasks /Run), so the status is polled a few times; the Windows
|
|
157
|
+
* OpenSSH channel never reports the remote exit code, so the outcome is
|
|
158
|
+
* purely stdout-based (the legacy's `grep bridge-status: | tail -1`).
|
|
159
|
+
*
|
|
160
|
+
* @param session - The connected guest session.
|
|
161
|
+
* @param state - The accumulated run state.
|
|
162
|
+
* @param node - The guest's node binary.
|
|
163
|
+
* @param tries - Poll attempts (6 = up to ~15 s at 3 s apart).
|
|
164
|
+
*/
|
|
165
|
+
export async function readGuestStatus(session, state, node, tries = 6) {
|
|
166
|
+
for (let attempt = 0; attempt < tries; attempt += 1) {
|
|
167
|
+
const res = await psExec(session, `& "${node}" "${GUEST_AGENT_PS}" status`, 20_000);
|
|
168
|
+
const status = parseGuestStatus(res.stdout);
|
|
169
|
+
const anyKnown = status.sshAgent !== undefined || status.docker !== undefined;
|
|
170
|
+
if (anyKnown) {
|
|
171
|
+
if (status.sshAgent !== undefined) {
|
|
172
|
+
state.bridges.agent.guestUp = status.sshAgent;
|
|
173
|
+
}
|
|
174
|
+
if (status.docker !== undefined) {
|
|
175
|
+
state.bridges.docker.guestUp = status.docker;
|
|
176
|
+
}
|
|
177
|
+
// Both roles we expect are reported — no need to keep polling.
|
|
178
|
+
if (status.sshAgent !== undefined && status.docker !== undefined) {
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (attempt < tries - 1) {
|
|
183
|
+
await sleep(3000);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
/** End-to-end check: can the guest's docker CLI reach the host engine
|
|
188
|
+
* through the bridge? The probe loop runs inside one guest script (the
|
|
189
|
+
* legacy guest-setup's 20 x 1 s docker retry).
|
|
190
|
+
*
|
|
191
|
+
* @param session - The connected guest session.
|
|
192
|
+
* @param state - The accumulated run state.
|
|
193
|
+
*/
|
|
194
|
+
export async function verifyGuestDocker(session, state) {
|
|
195
|
+
const script = [
|
|
196
|
+
'$v = $null',
|
|
197
|
+
'for ($i = 0; $i -lt 20 -and -not $v; $i++) {',
|
|
198
|
+
' Start-Sleep -Milliseconds 1000',
|
|
199
|
+
" $v = docker info --format '{{.ServerVersion}}' 2>$null",
|
|
200
|
+
'}',
|
|
201
|
+
'if ($v) { Write-Output "docker-ok:$v" } else { Write-Output \'docker-fail\' }',
|
|
202
|
+
].join('\n');
|
|
203
|
+
const res = await psExec(session, script, 60_000);
|
|
204
|
+
const match = /^docker-(ok|fail):?([^\r\n]*)$/m.exec(res.stdout.trim());
|
|
205
|
+
if (match?.[1] === 'ok') {
|
|
206
|
+
state.bridges.docker.serverVersion = match[2] ?? '';
|
|
207
|
+
state.bridges.docker.engineUp = true;
|
|
208
|
+
logger.ok(`Docker engine is reachable from the guest (server version ${state.bridges.docker.serverVersion}).`);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
logger.warn('Docker engine not reachable from the guest yet — is it running on the host?');
|
|
212
|
+
logger.warn('The bridge reconnects on its own once it is; re-run to re-check.');
|
|
213
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// runners/windows-image.ts — step 1 for the Windows VMware backend: the
|
|
2
|
+
// windows-vmware binding of the shared VMware image flow
|
|
3
|
+
// (vmware-image.ts). The shared module holds the archive pick/extract,
|
|
4
|
+
// the base clone + the display name + the one-time hardware upgrade;
|
|
5
|
+
// this wrapper only binds the platform id and the WINDOWS_VMWARE_IMAGE
|
|
6
|
+
// override var.
|
|
7
|
+
import { ensureVmwareImage, vmwareWorkingVmx } from './vmware-image.js';
|
|
8
|
+
/** The platform id for the VMware path helpers. */
|
|
9
|
+
const PLATFORM = 'windows-vmware';
|
|
10
|
+
/** The working clone's vmx. */
|
|
11
|
+
export function windowsWorkingVmx(image) {
|
|
12
|
+
return vmwareWorkingVmx(PLATFORM, image);
|
|
13
|
+
}
|
|
14
|
+
/** Step 1: select the archive, extract the base, clone the working VM
|
|
15
|
+
* and upgrade it if the installed Fusion supports a newer hardware
|
|
16
|
+
* version.
|
|
17
|
+
*
|
|
18
|
+
* @param context - The run context.
|
|
19
|
+
* @param state - The accumulated run state (imageArchive set here).
|
|
20
|
+
*/
|
|
21
|
+
export async function ensureWindowsImage(context, state) {
|
|
22
|
+
return ensureVmwareImage(PLATFORM, 'WINDOWS_VMWARE_IMAGE', context, state);
|
|
23
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// runners/windows-qemu-summary.ts — the Windows QEMU "Sandbox is ready"
|
|
2
|
+
// block (port of the shell's print_summary in
|
|
3
|
+
// run-windows-qemu-sandbox.sh: same labels, same wording, same color
|
|
4
|
+
// scheme — with the CLI's 12-char label column, like the other
|
|
5
|
+
// summaries). The QEMU guest is reached through the hostfwd forwards on
|
|
6
|
+
// 127.0.0.1, unlike the VMware backends' direct guest-IP lines.
|
|
7
|
+
import { logger } from '../lib/logger.js';
|
|
8
|
+
import { qemuLogPath, qemuPidFile, qemuStateDir } from '../lib/qemu.js';
|
|
9
|
+
import { resolveGuestCredentials } from './windows-guest.js';
|
|
10
|
+
/** Prints the QEMU Windows summary block (image/SSH/RDP/WinRM/bridges/
|
|
11
|
+
* OpenChamber/state/stop hints).
|
|
12
|
+
*
|
|
13
|
+
* @param context - The run context.
|
|
14
|
+
* @param state - The accumulated run state.
|
|
15
|
+
*/
|
|
16
|
+
export async function printQemuSummary(context, state) {
|
|
17
|
+
const creds = resolveGuestCredentials(context.image, context.options.env, '127.0.0.1', context.sshPort);
|
|
18
|
+
logger.step('Sandbox is ready');
|
|
19
|
+
summaryLine('Image:', state.imageArchive ?? 'not available');
|
|
20
|
+
summaryLine('SSH:', `ssh -p ${context.sshPort} ${creds.username}@127.0.0.1 (password: ${creds.password})`);
|
|
21
|
+
summaryLine('RDP:', `127.0.0.1:${context.rdpPort} (${creds.username} / ${creds.password})`);
|
|
22
|
+
summaryLine('WinRM:', `127.0.0.1:${context.winrmPort} (advanced use)`);
|
|
23
|
+
printBridgeLines(context, state);
|
|
24
|
+
printOpenchamberLine(context, state);
|
|
25
|
+
summaryLine('State:', `${qemuStateDir(context.image)} (overlay + TPM + EFI NVRAM; --reset wipes it)`);
|
|
26
|
+
printStopLines(context, state);
|
|
27
|
+
}
|
|
28
|
+
/** One ` LABEL........ VALUE` line. */
|
|
29
|
+
function summaryLine(label, value) {
|
|
30
|
+
logger.out(` ${label.padEnd(12)}${value}`);
|
|
31
|
+
}
|
|
32
|
+
function printBridgeLines(context, state) {
|
|
33
|
+
const agent = state.bridges.agent;
|
|
34
|
+
if (agent.bridged) {
|
|
35
|
+
const value = agent.guestUp
|
|
36
|
+
? `host agent -> TCP ${context.agentPort} -> guest \\\\.\\pipe\\openssh-ssh-agent`
|
|
37
|
+
: `host bridge up (TCP ${context.agentPort}), guest pipe not running`;
|
|
38
|
+
summaryLine('SSH agent:', agent.guestUp ? loggerOk(value) : loggerWarn(value));
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
summaryLine('SSH agent:', 'not bridged');
|
|
42
|
+
}
|
|
43
|
+
const docker = state.bridges.docker;
|
|
44
|
+
if (docker.bridged) {
|
|
45
|
+
if (docker.engineUp) {
|
|
46
|
+
summaryLine('Docker:', loggerOk(`host engine (v${docker.serverVersion}) -> TCP ${context.dockerPort} -> guest (context 'host')`));
|
|
47
|
+
}
|
|
48
|
+
else if (docker.guestUp) {
|
|
49
|
+
summaryLine('Docker:', loggerWarn('bridge up, engine not reachable in the guest — is Docker running on the host?'));
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
summaryLine('Docker:', loggerWarn(`host bridge up (TCP ${context.dockerPort}), guest pipe not running`));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
summaryLine('Docker:', 'not bridged');
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function printOpenchamberLine(context, state) {
|
|
60
|
+
if (state.openchamberUp && state.openchamberUrl) {
|
|
61
|
+
summaryLine('OpenChamber:', loggerOk(`${state.openchamberUrl} (password: sandbox)`));
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
summaryLine('OpenChamber:', loggerWarn(`not responding on http://127.0.0.1:${context.openchamberPort}`));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function printStopLines(context, state) {
|
|
68
|
+
summaryLine('Stop:', `agent-dev-env stop windows-qemu (or: kill $(cat ${qemuPidFile(context.image)}))`);
|
|
69
|
+
if (!context.options.foreground) {
|
|
70
|
+
summaryLine('Background:', `VM keeps running after this CLI exits (qemu log: ${qemuLogPath()})`);
|
|
71
|
+
if (state.bridges.agent.bridged) {
|
|
72
|
+
summaryLine('Bridge:', `host bridge on TCP ${context.agentPort} stays up — stop it with: agent-dev-env stop windows-qemu`);
|
|
73
|
+
}
|
|
74
|
+
if (state.bridges.docker.bridged) {
|
|
75
|
+
summaryLine('Bridge:', `host Docker bridge on TCP ${context.dockerPort} stays up — stop it with: agent-dev-env stop windows-qemu`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// color helpers — the shell's ${c_green}/${c_yellow} inline.
|
|
80
|
+
function loggerOk(text) {
|
|
81
|
+
return logger.color('green') + text + logger.reset();
|
|
82
|
+
}
|
|
83
|
+
function loggerWarn(text) {
|
|
84
|
+
return logger.color('yellow') + text + logger.reset();
|
|
85
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// runners/windows-qemu.ts — the Windows 11 (ARM64) QEMU backend (Phase 6
|
|
2
|
+
// of the port): qemu-system-aarch64 drives the qcow2 disk with swtpm
|
|
3
|
+
// (lib/qemu.ts: overlay + TPM + EFI NVRAM + the exact launch_qemu args),
|
|
4
|
+
// the guest is reached through the hostfwd forwards on 127.0.0.1, the
|
|
5
|
+
// one-time auto-logon (windows-autologon.ts) so OpenChamber fires at
|
|
6
|
+
// boot, the hostfwd bridges + the same guest agent wiring over ssh2
|
|
7
|
+
// (windows-bridges.ts / windows-guest.ts) and the summary. Port of
|
|
8
|
+
// run-windows-qemu-sandbox.sh §steps 0-5.
|
|
9
|
+
import { rmSync } from 'node:fs';
|
|
10
|
+
import { isAlive, sleep } from '../lib/exec.js';
|
|
11
|
+
import { logger } from '../lib/logger.js';
|
|
12
|
+
import { confirmDefault } from '../lib/prompt.js';
|
|
13
|
+
import { buildQemuArgs, isQemuAlive, launchQemu, QEMU_EFI_CODE, qemuEfivarsPath, qemuOverlayPath, qemuStateDir, requireQemu, startSwtpm, stopQemu, stopSwtpm, swtpmSockPath, } from '../lib/qemu.js';
|
|
14
|
+
import { probeSshd, waitForSshd } from '../lib/ssh.js';
|
|
15
|
+
import { ensureBridgeDir } from './bridges.js';
|
|
16
|
+
import { offerOpenInBrowser, waitForOpenchamber } from './openchamber.js';
|
|
17
|
+
import { ensureQemuImage } from './qemu-image.js';
|
|
18
|
+
import { cleanupRunBridge } from './vmware-common.js';
|
|
19
|
+
import { windowsBridges } from './windows-bridges.js';
|
|
20
|
+
import { configureAutologon } from './windows-autologon.js';
|
|
21
|
+
import { resolveGuestCredentials } from './windows-guest.js';
|
|
22
|
+
import { printQemuSummary } from './windows-qemu-summary.js';
|
|
23
|
+
const PLATFORM = 'windows-qemu';
|
|
24
|
+
/** The QEMU Windows backend — qemu-system-aarch64 + swtpm + ssh2 (the
|
|
25
|
+
* state lives under the CLI's data dir, like the VMware backends).
|
|
26
|
+
*/
|
|
27
|
+
export const windowsQemuBackend = {
|
|
28
|
+
preflight: (context) => preflight(context),
|
|
29
|
+
ensureImageAndVm: (context, state) => ensureQemuImage(context, state),
|
|
30
|
+
boot: boot,
|
|
31
|
+
setupBridges: (context, state) => windowsBridges(context, state),
|
|
32
|
+
setupSettings: (context, state) => setupSettings(context, state),
|
|
33
|
+
verifyOpenchamber: (context, state) => verifyOpenchamber(context, state),
|
|
34
|
+
summarize: (context, state) => printQemuSummary(context, state),
|
|
35
|
+
finish: finish,
|
|
36
|
+
};
|
|
37
|
+
async function preflight(context) {
|
|
38
|
+
requireQemu();
|
|
39
|
+
ensureBridgeDir();
|
|
40
|
+
if (context.options.reset) {
|
|
41
|
+
const root = qemuStateDir(context.image);
|
|
42
|
+
logger.info('Resetting the working VM (--reset) — deleting the overlay, TPM state, and EFI NVRAM.');
|
|
43
|
+
rmSync(root, { recursive: true, force: true });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
// --- step 2: boot -----------------------------------------------------------
|
|
47
|
+
async function boot(context, state) {
|
|
48
|
+
await stopRunningQemu(context);
|
|
49
|
+
await startSwtpm(context.image);
|
|
50
|
+
const args = buildQemuArgs({
|
|
51
|
+
efiCode: QEMU_EFI_CODE,
|
|
52
|
+
efivars: qemuEfivarsPath(context.image),
|
|
53
|
+
overlay: qemuOverlayPath(context.image),
|
|
54
|
+
tpmSock: swtpmSockPath(context.image),
|
|
55
|
+
sshPort: context.sshPort,
|
|
56
|
+
rdpPort: context.rdpPort,
|
|
57
|
+
openchamberPort: context.openchamberPort,
|
|
58
|
+
winrmPort: context.winrmPort,
|
|
59
|
+
cpuCount: context.cpuCount,
|
|
60
|
+
memoryMb: context.memoryMb,
|
|
61
|
+
headless: context.options.headless,
|
|
62
|
+
});
|
|
63
|
+
state.qemuPid = await launchQemu(context.image, args);
|
|
64
|
+
const creds = resolveGuestCredentials(context.image, context.options.env, '127.0.0.1', context.sshPort);
|
|
65
|
+
await waitForQemuBoot(creds, state.qemuPid);
|
|
66
|
+
await ensureQemuAutologon(context, creds);
|
|
67
|
+
}
|
|
68
|
+
/** Stops a qemu left running by a previous (detached) run — the old qemu
|
|
69
|
+
* holds the overlay and its swtpm holds the TPM state lock. The legacy
|
|
70
|
+
* stop_running_vm: restart confirm defaults to no (checked via the
|
|
71
|
+
* pidfile, since a previous run's qemu predates this run's state).
|
|
72
|
+
*/
|
|
73
|
+
async function stopRunningQemu(context) {
|
|
74
|
+
if (!isQemuAlive(context.image)) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (!(await confirmDefault('The sandbox VM is already running — restart it?', {
|
|
78
|
+
default: 'n',
|
|
79
|
+
yes: context.options.yes,
|
|
80
|
+
}))) {
|
|
81
|
+
logger.die(`aborted — the VM is already running. Stop it with: agent-dev-env stop ${PLATFORM}`);
|
|
82
|
+
}
|
|
83
|
+
await stopQemu(context.image);
|
|
84
|
+
await sleep(1000);
|
|
85
|
+
}
|
|
86
|
+
/** The sshd wait with the qemu-alive check (the legacy wait_for_sshd:
|
|
87
|
+
* the hostfwd listener binds the moment qemu starts, before the guest
|
|
88
|
+
* even boots — probe for a real sshd instead of the port).
|
|
89
|
+
*/
|
|
90
|
+
async function waitForQemuBoot(creds, qemuPid) {
|
|
91
|
+
process.stdout.write(' Waiting for the VM to boot (up to 10 min)');
|
|
92
|
+
for (let attempt = 0; attempt < 150; attempt += 1) {
|
|
93
|
+
if (!isAlive(qemuPid)) {
|
|
94
|
+
process.stdout.write(` ${logger.color('yellow')}failed${logger.reset()}\n`);
|
|
95
|
+
logger.die(`qemu exited before the VM booted — check the qemu log.`);
|
|
96
|
+
}
|
|
97
|
+
if ((await probeSshd(creds)) === 'up') {
|
|
98
|
+
process.stdout.write(` ${logger.color('green')}done${logger.reset()}\n`);
|
|
99
|
+
logger.ok(`VM is up: ssh ${creds.username}@127.0.0.1:${creds.port}`);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (attempt < 149) {
|
|
103
|
+
await sleep(4000);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
process.stdout.write(` ${logger.color('yellow')}failed${logger.reset()}\n`);
|
|
107
|
+
logger.die(`timed out waiting for the VM to boot (no SSH on 127.0.0.1:${creds.port}).`);
|
|
108
|
+
}
|
|
109
|
+
/** The QEMU auto-logon: configure, then wait for sshd on the same
|
|
110
|
+
* hostfwd target (the guest IP never changes — unlike the VMware flow,
|
|
111
|
+
* no guest-IP refresh is needed).
|
|
112
|
+
*/
|
|
113
|
+
async function ensureQemuAutologon(context, creds) {
|
|
114
|
+
if (!(await configureAutologon(context, creds))) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
logger.info('Rebooting the guest (a minute or two)...');
|
|
118
|
+
process.stdout.write(' Waiting for the guest to reboot (up to 10 min)');
|
|
119
|
+
if (await waitForSshd(creds)) {
|
|
120
|
+
process.stdout.write(` ${logger.color('green')}done${logger.reset()}\n`);
|
|
121
|
+
logger.ok('Guest rebooted with auto-logon enabled.');
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
process.stdout.write(` ${logger.color('yellow')}failed${logger.reset()}\n`);
|
|
125
|
+
logger.die(`timed out waiting for the guest to reboot (no SSH on 127.0.0.1:${creds.port}).`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
// --- step 4/5 hooks ---------------------------------------------------------
|
|
129
|
+
async function setupSettings(context, state) {
|
|
130
|
+
logger.info('Windows guests have no user settings copy — skipping.');
|
|
131
|
+
state.settings = 'skipped';
|
|
132
|
+
}
|
|
133
|
+
async function verifyOpenchamber(context, state) {
|
|
134
|
+
state.openchamberUrl = `http://127.0.0.1:${context.openchamberPort}`;
|
|
135
|
+
state.openchamberUp = await waitForOpenchamber(state.openchamberUrl);
|
|
136
|
+
if (state.openchamberUp) {
|
|
137
|
+
await offerOpenInBrowser(state.openchamberUrl, context.options.yes);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// --- finish: foreground wait + bridge/swtpm cleanup --------------------------
|
|
141
|
+
async function finish(context, state) {
|
|
142
|
+
if (!context.options.foreground) {
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
await waitForQemuStop(state.qemuPid ?? -1);
|
|
146
|
+
// The VM stopped (or Cmd+C was pressed) — kill the bridges this run
|
|
147
|
+
// started and swtpm (it holds the TPM state lock); in background mode
|
|
148
|
+
// they stay up, outliving the CLI by design.
|
|
149
|
+
await cleanupRunBridge(state.bridges.agent.pid, 'agent bridge');
|
|
150
|
+
await cleanupRunBridge(state.bridges.docker.pid, 'Docker bridge');
|
|
151
|
+
await stopSwtpm(context.image);
|
|
152
|
+
}
|
|
153
|
+
/** The foreground wait: poll qemu until it exits; a Cmd+C stops it
|
|
154
|
+
* through a SIGTERM (the shell's EXIT trap killed qemu with the script
|
|
155
|
+
* group; we own the detached child, so kill it explicitly).
|
|
156
|
+
*/
|
|
157
|
+
async function waitForQemuStop(qemuPid) {
|
|
158
|
+
if (!isAlive(qemuPid)) {
|
|
159
|
+
logger.info('VM is not running.');
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
let stopping = false;
|
|
163
|
+
const onSignal = () => {
|
|
164
|
+
if (stopping) {
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
stopping = true;
|
|
168
|
+
logger.info('Stopping the VM (Cmd+C)...');
|
|
169
|
+
try {
|
|
170
|
+
process.kill(qemuPid, 'SIGTERM');
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
// already gone
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
process.on('SIGINT', onSignal);
|
|
177
|
+
process.on('SIGTERM', onSignal);
|
|
178
|
+
logger.info('Waiting for the VM to stop (Cmd+C to stop it now)...');
|
|
179
|
+
try {
|
|
180
|
+
while (isAlive(qemuPid)) {
|
|
181
|
+
await sleep(3000);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
finally {
|
|
185
|
+
process.removeListener('SIGINT', onSignal);
|
|
186
|
+
process.removeListener('SIGTERM', onSignal);
|
|
187
|
+
}
|
|
188
|
+
logger.info('VM stopped.');
|
|
189
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// runners/windows-shared.ts — step 4 for the Windows backend: the shared
|
|
2
|
+
// host directory over HGFS (best-effort, like the Ubuntu backend). VMware
|
|
3
|
+
// Tools for Windows Arm ships no HGFS driver, so Windows 11 ARM guests on
|
|
4
|
+
// Apple silicon can never mount the share — detected via the vmx guestos
|
|
5
|
+
// string and skipped with a warning (the legacy hgfs_unsupported). The
|
|
6
|
+
// vmrun registration path is kept for parity: a share registered by a
|
|
7
|
+
// previous run persists in the vmx.
|
|
8
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
9
|
+
import { logger } from '../lib/logger.js';
|
|
10
|
+
import { enableSharedFolders, isVmRunning } from '../lib/vmrun.js';
|
|
11
|
+
import { addSharedFolderRetry, waitForTools } from './vmware-common.js';
|
|
12
|
+
/** @internal — whether the VM's guest os string is an ARM Windows guest
|
|
13
|
+
* (VMware Tools for Windows Arm has no HGFS driver — the guest can never
|
|
14
|
+
* mount \\vmware-host\Shared Folders\work even when the host publishes
|
|
15
|
+
* it). Pure for the co-located test.
|
|
16
|
+
*
|
|
17
|
+
* @param vmxContent - The raw vmx text.
|
|
18
|
+
* @returns True when the guest cannot mount HGFS shares.
|
|
19
|
+
*/
|
|
20
|
+
export function hgfsUnsupported(vmxContent) {
|
|
21
|
+
return /^[ \t]*guestos[ \t]*=[ \t]*"arm-/m.test(vmxContent);
|
|
22
|
+
}
|
|
23
|
+
/** The shared-folder step: skipped with the platform warning for ARM
|
|
24
|
+
* guests; otherwise the vmrun register + enable flow with the tools
|
|
25
|
+
* gate. Failures warn only — the sandbox works without the share.
|
|
26
|
+
*
|
|
27
|
+
* @param context - The run context (workDir).
|
|
28
|
+
* @param state - The accumulated run state (sharedFolderSkipped).
|
|
29
|
+
* @param workVmx - The working VM vmx.
|
|
30
|
+
*/
|
|
31
|
+
export async function setupWindowsSharedFolder(context, state, workVmx) {
|
|
32
|
+
if (!context.workDir) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
let vmxContent = '';
|
|
36
|
+
try {
|
|
37
|
+
vmxContent = readFileSync(workVmx, 'utf8');
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// no vmx — nothing to share; the running check below reports.
|
|
41
|
+
}
|
|
42
|
+
if (vmxContent && hgfsUnsupported(vmxContent)) {
|
|
43
|
+
state.sharedFolderSkipped = true;
|
|
44
|
+
logger.warn('shared host folder not supported: VMware Tools for Windows 11 ARM guests on ' +
|
|
45
|
+
'Apple silicon has no HGFS driver, so the guest can never see ' +
|
|
46
|
+
'\\\\vmware-host\\Shared Folders\\work.');
|
|
47
|
+
logger.warn('use an alternative instead (SMB share from the Mac, SSH/SCP, RDP clipboard, git) ' +
|
|
48
|
+
'— see docs/windows-vmware.md.');
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (!existsSync(context.workDir)) {
|
|
52
|
+
logger.warn(`work directory '${context.workDir}' does not exist — skipping the shared-directory share.`);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (!(await isVmRunning(workVmx))) {
|
|
56
|
+
logger.warn('shared folder skipped — the VM is not running.');
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
logger.info(`Sharing ${context.workDir} into the guest as 'work' ` +
|
|
60
|
+
'(\\\\vmware-host\\Shared Folders\\work).');
|
|
61
|
+
if (!(await waitForTools(workVmx))) {
|
|
62
|
+
logger.warn('VMware Tools did not report running — skipping the shared-directory share (re-run to retry).');
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
await addSharedFolderRetry(workVmx, 'work', context.workDir);
|
|
66
|
+
await enableSharedFolders(workVmx);
|
|
67
|
+
logger.ok('Shared folder registered (best-effort — HGFS must be enabled by VMware Tools).');
|
|
68
|
+
}
|