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.
Files changed (102) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +106 -0
  3. package/dist/assets/bridge/bridge.js +202 -0
  4. package/dist/assets/guest/guest-agent-mac.js +586 -0
  5. package/dist/assets/guest/guest-agent-ubuntu.js +481 -0
  6. package/dist/assets/guest/guest-agent-windows.js +466 -0
  7. package/dist/assets/images/mac/CHANGELOG.md +265 -0
  8. package/dist/assets/images/mac/README.md +63 -0
  9. package/dist/assets/images/mac/sandbox.pkr.hcl +468 -0
  10. package/dist/assets/images/mac/vars/sandbox-macos-tahoe.pkrvars.hcl +39 -0
  11. package/dist/assets/images/ubuntu-arm64-vmware/CHANGELOG.md +239 -0
  12. package/dist/assets/images/ubuntu-arm64-vmware/README.md +177 -0
  13. package/dist/assets/images/ubuntu-arm64-vmware/autoinstall/meta-data +2 -0
  14. package/dist/assets/images/ubuntu-arm64-vmware/autoinstall/user-data +34 -0
  15. package/dist/assets/images/ubuntu-arm64-vmware/sandbox.pkr.hcl +776 -0
  16. package/dist/assets/images/ubuntu-arm64-vmware/vars/sandbox-ubuntu-24-04-arm64-vmware.pkrvars.hcl +71 -0
  17. package/dist/assets/images/windows-arm64-qemu/CHANGELOG.md +209 -0
  18. package/dist/assets/images/windows-arm64-qemu/README.md +142 -0
  19. package/dist/assets/images/windows-arm64-qemu/autounattend.xml +325 -0
  20. package/dist/assets/images/windows-arm64-qemu/qemu-with-tpm.sh +170 -0
  21. package/dist/assets/images/windows-arm64-qemu/sandbox.pkr.hcl +1012 -0
  22. package/dist/assets/images/windows-arm64-qemu/vars/sandbox-windows-11-arm64-qemu.pkrvars.hcl +83 -0
  23. package/dist/assets/images/windows-arm64-vmware/CHANGELOG.md +270 -0
  24. package/dist/assets/images/windows-arm64-vmware/README.md +171 -0
  25. package/dist/assets/images/windows-arm64-vmware/autounattend.xml +311 -0
  26. package/dist/assets/images/windows-arm64-vmware/sandbox.pkr.hcl +1036 -0
  27. package/dist/assets/images/windows-arm64-vmware/vars/sandbox-windows-11-arm64-vmware.pkrvars.hcl +83 -0
  28. package/dist/assets/rules/agent-rules-linux.md +68 -0
  29. package/dist/assets/rules/agent-rules.md +71 -0
  30. package/dist/assets/watchdog/watch-build-ocr.swift +40 -0
  31. package/dist/assets/watchdog/watch-build.py +275 -0
  32. package/dist/cli.js +52 -0
  33. package/dist/commands/delete.js +179 -0
  34. package/dist/commands/doctor.js +165 -0
  35. package/dist/commands/list.js +38 -0
  36. package/dist/commands/not-yet.js +15 -0
  37. package/dist/commands/register.js +150 -0
  38. package/dist/commands/run.js +65 -0
  39. package/dist/commands/status.js +142 -0
  40. package/dist/commands/stop.js +186 -0
  41. package/dist/commands/sync.js +98 -0
  42. package/dist/lib/exec.js +250 -0
  43. package/dist/lib/ghcr.js +82 -0
  44. package/dist/lib/git.js +100 -0
  45. package/dist/lib/logger.js +87 -0
  46. package/dist/lib/network.js +63 -0
  47. package/dist/lib/paths.js +116 -0
  48. package/dist/lib/platform.js +87 -0
  49. package/dist/lib/prompt.js +66 -0
  50. package/dist/lib/qemu.js +373 -0
  51. package/dist/lib/regex.js +9 -0
  52. package/dist/lib/ssh.js +230 -0
  53. package/dist/lib/tart.js +210 -0
  54. package/dist/lib/template.js +35 -0
  55. package/dist/lib/vars.js +99 -0
  56. package/dist/lib/vmrun.js +334 -0
  57. package/dist/lifecycle/build-macos.js +23 -0
  58. package/dist/lifecycle/build-qemu.js +204 -0
  59. package/dist/lifecycle/build-shared.js +355 -0
  60. package/dist/lifecycle/build-ubuntu.js +184 -0
  61. package/dist/lifecycle/build-watchdog.js +146 -0
  62. package/dist/lifecycle/build-windows-vmware.js +112 -0
  63. package/dist/lifecycle/build.js +55 -0
  64. package/dist/lifecycle/catalog.js +165 -0
  65. package/dist/lifecycle/deploy.js +161 -0
  66. package/dist/lifecycle/tag.js +77 -0
  67. package/dist/lifecycle/watch-build.js +64 -0
  68. package/dist/runners/bridges.js +156 -0
  69. package/dist/runners/framework.js +66 -0
  70. package/dist/runners/macos-bridges.js +143 -0
  71. package/dist/runners/macos-guest.js +76 -0
  72. package/dist/runners/macos-rules.js +77 -0
  73. package/dist/runners/macos-summary.js +145 -0
  74. package/dist/runners/macos.js +258 -0
  75. package/dist/runners/openchamber.js +60 -0
  76. package/dist/runners/options.js +56 -0
  77. package/dist/runners/qemu-image.js +183 -0
  78. package/dist/runners/rules.js +49 -0
  79. package/dist/runners/ubuntu-bridges.js +154 -0
  80. package/dist/runners/ubuntu-guest.js +136 -0
  81. package/dist/runners/ubuntu-image.js +26 -0
  82. package/dist/runners/ubuntu-rules.js +86 -0
  83. package/dist/runners/ubuntu-shared.js +65 -0
  84. package/dist/runners/ubuntu-summary.js +141 -0
  85. package/dist/runners/ubuntu.js +117 -0
  86. package/dist/runners/vmware-common.js +158 -0
  87. package/dist/runners/vmware-image.js +242 -0
  88. package/dist/runners/windows-autologon.js +119 -0
  89. package/dist/runners/windows-bridges.js +136 -0
  90. package/dist/runners/windows-guest.js +213 -0
  91. package/dist/runners/windows-image.js +23 -0
  92. package/dist/runners/windows-qemu-summary.js +85 -0
  93. package/dist/runners/windows-qemu.js +189 -0
  94. package/dist/runners/windows-shared.js +68 -0
  95. package/dist/runners/windows-summary.js +104 -0
  96. package/dist/runners/windows.js +96 -0
  97. package/dist/settings/common.js +107 -0
  98. package/dist/settings/macos-copy.js +223 -0
  99. package/dist/settings/macos.js +41 -0
  100. package/dist/settings/ubuntu-copy.js +223 -0
  101. package/dist/settings/ubuntu.js +68 -0
  102. package/package.json +52 -0
@@ -0,0 +1,183 @@
1
+ // runners/qemu-image.ts — step 1 for the Windows QEMU backend: pick the
2
+ // pristine qcow2 (WINDOWS_IMAGE → local build output → cached pull →
3
+ // oras pull with the owner chain — the same precedence as the VMware
4
+ // backends' pickImage), then create the working VM state: a COW overlay
5
+ // over the pristine image (identity-marker gated so a rebuild over the
6
+ // same path drops the stale overlay), the persistent TPM state dir and
7
+ // the EFI NVRAM store (seeded from the build output's efivars.fd or the
8
+ // Homebrew edk2 template). Port of run-windows-qemu-sandbox.sh
9
+ // §step 1/2 (pick_image + ensure_working_vm).
10
+ import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
11
+ import { dirname, join } from 'node:path';
12
+ import { commandExists, run } from '../lib/exec.js';
13
+ import { registryRef, resolveOwner } from '../lib/ghcr.js';
14
+ import { logger } from '../lib/logger.js';
15
+ import { buildDir } from '../lib/paths.js';
16
+ import { confirmDefault } from '../lib/prompt.js';
17
+ import { backingIdentity, QEMU_EFI_VARS_TEMPLATE, qemuBackingMarker, qemuEfivarsPath, qemuImagePath, qemuOverlayPath, qemuTpmDir, } from '../lib/qemu.js';
18
+ /** The env var holding a local qcow2 override (the legacy WINDOWS_IMAGE). */
19
+ const IMAGE_OVERRIDE_ENV = 'WINDOWS_IMAGE';
20
+ /** Step 1: select the pristine qcow2 and create the working VM state.
21
+ *
22
+ * @param context - The run context.
23
+ * @param state - The accumulated run state (imageArchive = disk path).
24
+ */
25
+ export async function ensureQemuImage(context, state) {
26
+ const disk = await pickQemuImage(context);
27
+ state.imageArchive = disk;
28
+ logger.ok(`Using image: ${disk}`);
29
+ await ensureWorkingVm(context, disk);
30
+ }
31
+ /** The shell's pick_image: WINDOWS_IMAGE → local build output → cached
32
+ * pull → oras pull (confirm + owner chain).
33
+ *
34
+ * @param context - The run context.
35
+ * @returns The qcow2 path to run.
36
+ */
37
+ async function pickQemuImage(context) {
38
+ const env = context.options.env;
39
+ const image = context.image;
40
+ const override = env[IMAGE_OVERRIDE_ENV];
41
+ if (override) {
42
+ if (!existsSync(override)) {
43
+ logger.die(`${IMAGE_OVERRIDE_ENV} points to a file that does not exist: ${override}`);
44
+ }
45
+ return override;
46
+ }
47
+ const local = join(buildDir('windows-qemu'), 'output', `${image}.qcow2`);
48
+ if (existsSync(local)) {
49
+ return local;
50
+ }
51
+ const cached = qemuImagePath(image);
52
+ if (existsSync(cached)) {
53
+ return cached;
54
+ }
55
+ return pullQemuImage(context, cached);
56
+ }
57
+ /** The oras pull into the image/ cache dir (owner chain + confirm +
58
+ * disk presence check).
59
+ *
60
+ * @param context - The run context.
61
+ * @param cached - The destination qcow2 path.
62
+ * @returns The pulled qcow2 path.
63
+ */
64
+ async function pullQemuImage(context, cached) {
65
+ if (!commandExists('oras')) {
66
+ logger.die('oras is not installed — needed to pull the image (brew install oras). ' +
67
+ `Set ${IMAGE_OVERRIDE_ENV} to a local qcow2 to skip.`);
68
+ }
69
+ const owner = await resolveOwner({ owner: context.options.owner, env: context.options.env });
70
+ const ref = registryRef(context.image, 'latest', owner);
71
+ if (!(await confirmDefault(`Pull ${ref} (one-time, ~14 GB download)?`, {
72
+ default: 'y',
73
+ yes: context.options.yes,
74
+ }))) {
75
+ logger.die(`aborted — no sandbox image available. Set ${IMAGE_OVERRIDE_ENV} to a local qcow2 or pull manually.`);
76
+ }
77
+ mkdirSync(dirname(cached), { recursive: true });
78
+ logger.info(`Pulling ${ref} (one-time, ~14 GB download)...`);
79
+ const res = await run('oras', ['pull', ref], { cwd: dirname(cached) });
80
+ if (res.code !== 0) {
81
+ logger.die('oras pull failed — check your network connection (public GHCR images pull without a login).');
82
+ }
83
+ if (!existsSync(cached)) {
84
+ logger.die(`oras pull produced no ${cached} — is the image published under ${ref}?`);
85
+ }
86
+ return cached;
87
+ }
88
+ /** Creates the working VM state on first use: a COW overlay over the
89
+ * pristine image, a persistent EFI NVRAM store and a TPM state dir. The
90
+ * overlay records its backing image; when the backing image changes (a
91
+ * rebuild replaces the file at the same path), the overlay is recreated
92
+ * — otherwise Windows would read a corrupt disk.
93
+ *
94
+ * @param context - The run context.
95
+ * @param pristinePath - The pristine qcow2 path.
96
+ */
97
+ async function ensureWorkingVm(context, pristinePath) {
98
+ const image = context.image;
99
+ const overlay = qemuOverlayPath(image);
100
+ const stat = statSync(pristinePath);
101
+ const id = backingIdentity(pristinePath, stat.size, stat.mtimeMs);
102
+ if (backingChanged(image, id)) {
103
+ logger.warn('The backing image changed (new build or pull) — recreating the working VM.');
104
+ logger.warn('Discarding the old overlay, EFI NVRAM, and TPM state (they belong to the previous image).');
105
+ dropWorkingVmState(image);
106
+ }
107
+ if (existsSync(overlay) && readMarker(image) === id) {
108
+ logger.ok(`Working VM exists (${overlay}).`);
109
+ return;
110
+ }
111
+ await createOverlay(image, pristinePath, overlay, id);
112
+ await seedEfivars(image, pristinePath);
113
+ logger.ok(`Working VM created (${overlay}).`);
114
+ }
115
+ /** Whether the recorded backing identity differs from the current disk's
116
+ * (a rebuild replaces the file at the same path — path alone would
117
+ * silently stack the old overlay on a different base, a corrupt disk).
118
+ */
119
+ function backingChanged(image, id) {
120
+ const markerPath = qemuBackingMarker(image);
121
+ if (!existsSync(markerPath)) {
122
+ return false;
123
+ }
124
+ return readFileSync(markerPath, 'utf8').trim() !== id;
125
+ }
126
+ /** The recorded backing identity ('' when the marker is missing). */
127
+ function readMarker(image) {
128
+ const markerPath = qemuBackingMarker(image);
129
+ return existsSync(markerPath) ? readFileSync(markerPath, 'utf8').trim() : '';
130
+ }
131
+ /** Removes the overlay + EFI NVRAM + TPM state that belong to the previous
132
+ * backing image.
133
+ */
134
+ function dropWorkingVmState(image) {
135
+ rmSync(qemuOverlayPath(image), { force: true });
136
+ rmSync(qemuEfivarsPath(image), { force: true });
137
+ rmSync(qemuTpmDir(image), { recursive: true, force: true });
138
+ }
139
+ /** Creates the COW overlay over the pristine disk (qemu-img create) and
140
+ * records the backing identity it was created from.
141
+ */
142
+ async function createOverlay(image, pristinePath, overlay, id) {
143
+ mkdirSync(dirname(overlay), { recursive: true });
144
+ mkdirSync(qemuTpmDir(image), { recursive: true });
145
+ logger.cmd(`qemu-img create -f qcow2 -F qcow2 -b ${pristinePath} ${overlay}`);
146
+ const create = await run('qemu-img', [
147
+ 'create',
148
+ '-f',
149
+ 'qcow2',
150
+ '-F',
151
+ 'qcow2',
152
+ '-b',
153
+ pristinePath,
154
+ overlay,
155
+ ]);
156
+ if (create.code !== 0) {
157
+ logger.die(`qemu-img create failed:\n${create.stderr.trim()}`);
158
+ }
159
+ writeFileSync(qemuBackingMarker(image), id);
160
+ }
161
+ /** Seeds the EFI NVRAM store: the vars file the image was built with (it
162
+ * holds Windows' own Boot0000 for exactly this install) when the build
163
+ * output ships one, otherwise the edk2 template (a fresh NVRAM has no
164
+ * Boot0000 and relies on the \EFI\BOOT\bootaa64.efi fallback the
165
+ * installer writes).
166
+ */
167
+ async function seedEfivars(image, pristinePath) {
168
+ const efivars = qemuEfivarsPath(image);
169
+ if (existsSync(efivars)) {
170
+ return;
171
+ }
172
+ mkdirSync(dirname(efivars), { recursive: true });
173
+ const seeded = join(dirname(pristinePath), 'efivars.fd');
174
+ if (existsSync(seeded)) {
175
+ await run('cp', [seeded, efivars]);
176
+ logger.info('EFI NVRAM: seeded from the build output\u2019s efivars.fd.');
177
+ return;
178
+ }
179
+ if (!existsSync(QEMU_EFI_VARS_TEMPLATE)) {
180
+ logger.die(`EFI NVRAM template not found at ${QEMU_EFI_VARS_TEMPLATE} — is Homebrew's qemu installed?`);
181
+ }
182
+ await run('cp', [QEMU_EFI_VARS_TEMPLATE, efivars]);
183
+ }
@@ -0,0 +1,49 @@
1
+ // runners/rules.ts — the agent-rules step: render the bundled
2
+ // assets/rules/agent-rules.md (macOS) or agent-rules-linux.md (Ubuntu)
3
+ // with the run's actual paths and stream it into the guest's agent
4
+ // (`rules --probe` / `rules --force`), keeping the shell's
5
+ // probe/confirm/overwrite semantics (see run-macos-sandbox.sh
6
+ // §"agent rules").
7
+ //
8
+ // The probe only inspects the guest and reports the pending action; the
9
+ // write happens only after the user confirmed. The SSH agent section is
10
+ // dropped from the render when no agent bridge is actually up, so the
11
+ // rules never claim a bridge that is not running.
12
+ import { readFileSync } from 'node:fs';
13
+ import { fileURLToPath } from 'node:url';
14
+ import { dropSectionFrom, render } from '../lib/template.js';
15
+ /** The SSH section heading in the rules files (shared by both assets). */
16
+ const SSH_RULES_HEADING = '## SSH agent bridge';
17
+ /** Path of a bundled rules asset.
18
+ *
19
+ * @param name - The asset file name (agent-rules.md / agent-rules-linux.md).
20
+ * @returns The absolute path in dist/assets/rules.
21
+ */
22
+ function agentRulesPath(name) {
23
+ return fileURLToPath(new URL(`../assets/rules/${name}`, import.meta.url));
24
+ }
25
+ /** Loads a bundled rules document (missing file → warn, no content).
26
+ *
27
+ * @param name - The asset file name (default agent-rules.md).
28
+ * @returns The raw rules text; empty when the asset is missing.
29
+ */
30
+ export function loadAgentRules(name = 'agent-rules.md') {
31
+ try {
32
+ return readFileSync(agentRulesPath(name), 'utf8');
33
+ }
34
+ catch {
35
+ return '';
36
+ }
37
+ }
38
+ /** Renders the rules document for this run.
39
+ *
40
+ * @param content - The raw rules text.
41
+ * @param substitutions - {{HOST_WORK_DIR}} / {{GUEST_MOUNT}} (and
42
+ * {{NAT_GATEWAY}} for the linux asset) values.
43
+ * @param includeSsh - Whether the SSH agent bridge section stays.
44
+ * @returns The rendered document.
45
+ */
46
+ export function renderAgentRules(content, substitutions, includeSsh) {
47
+ const rendered = render(content, substitutions);
48
+ return includeSsh ? rendered : dropSectionFrom(rendered, SSH_RULES_HEADING);
49
+ }
@@ -0,0 +1,154 @@
1
+ // runners/ubuntu-bridges.ts — step 3 for the Ubuntu backend: the host
2
+ // side of the SSH agent + Docker bridges, bound to the host's address on
3
+ // the guest's NAT segment (the shell's find_host_alias — Fusion's NAT is
4
+ // userspace; the guest gateway x.y.z.2 is vmnetd and does not forward to
5
+ // the host loopback, so the bridges bind the host's x.y.z.1 instead of a
6
+ // gateway). The guest side (upload/install/status over ssh2) lives in
7
+ // ubuntu-guest.ts, the rules step in ubuntu-rules.ts.
8
+ import { sleep } from '../lib/exec.js';
9
+ import { logger } from '../lib/logger.js';
10
+ import { findHostAlias } from '../lib/network.js';
11
+ import { openSshSession } from '../lib/ssh.js';
12
+ import { findHostAgentSocket, findHostDockerSocket, startHostBridge } from './bridges.js';
13
+ import { ensureGuestAgent, findGuestNode, readGuestStatus, resolveGuestCredentials, } from './ubuntu-guest.js';
14
+ import { installLinuxAgentRules } from './ubuntu-rules.js';
15
+ /** The Ubuntu step 3: host alias → host bridges → guest agent → guest
16
+ * bridges → rules. Unlike macOS (Tart gateway), every bridge here binds
17
+ * the host's NAT-segment address, resolved once and reused.
18
+ */
19
+ export async function ubuntuBridges(context, state) {
20
+ const ip = state.vmIp;
21
+ if (!ip) {
22
+ logger.warn('no guest IP — skipping the host bridges, guest setup and rules.');
23
+ return;
24
+ }
25
+ const hostAlias = await findHostAlias(ip);
26
+ if (!hostAlias) {
27
+ logger.warn('could not determine the host NAT-segment address — skipping the bridges.');
28
+ return;
29
+ }
30
+ const creds = resolveGuestCredentials(context.image, context.options.env, ip);
31
+ const session = await openSshSession(creds);
32
+ try {
33
+ const node = await findGuestNode(session);
34
+ if (!node) {
35
+ logger.warn('node not found in the guest — skipping the guest bridges and rules.');
36
+ return;
37
+ }
38
+ if (context.options.noAgent) {
39
+ logger.info('Skipping SSH agent bridge setup (--no-agent).');
40
+ }
41
+ else {
42
+ await setupSshAgent(context, state, hostAlias);
43
+ }
44
+ if (context.options.noDocker) {
45
+ logger.info('Skipping Docker bridge setup (--no-docker).');
46
+ }
47
+ else {
48
+ await setupDockerBridge(context, state, hostAlias);
49
+ }
50
+ const anyBridged = state.bridges.agent.bridged || state.bridges.docker.bridged;
51
+ if (anyBridged) {
52
+ await ensureGuestAgent(session, node, hostAlias, context);
53
+ await readGuestStatus(session, state, node);
54
+ reportBridgeState(context, state);
55
+ if (state.bridges.docker.bridged && state.bridges.docker.guestUp) {
56
+ await verifyGuestDocker(session, state);
57
+ }
58
+ }
59
+ await installLinuxAgentRules(context, state, session, node, hostAlias);
60
+ }
61
+ finally {
62
+ session.end();
63
+ }
64
+ }
65
+ async function setupSshAgent(context, state, hostAlias) {
66
+ const sock = findHostAgentSocket(context.options.env, context.options.home);
67
+ if (!sock) {
68
+ logger.info('No SSH agent override detected — using the default macOS agent.');
69
+ logger.info('To share a password manager\u2019s agent (Bitwarden, 1Password, ...), ' +
70
+ 'enable its SSH agent on the host and re-run.');
71
+ return;
72
+ }
73
+ logger.ok(`Host SSH agent socket found: ${sock}`);
74
+ logger.info(`Bridging it into the guest on TCP port ${context.agentPort} (see docs/ssh-agent.md).`);
75
+ logger.ok(`Guest reaches the host at ${hostAlias}`);
76
+ if (!(await startBridge(context, state, 'ssh-agent', sock, hostAlias))) {
77
+ return;
78
+ }
79
+ }
80
+ async function setupDockerBridge(context, state, hostAlias) {
81
+ const sock = findHostDockerSocket(context.options.home);
82
+ if (!sock) {
83
+ logger.info('No Docker engine socket found on the host (Docker Desktop, Colima, OrbStack).');
84
+ logger.info('Start an engine on the host and re-run to bridge it into the guest.');
85
+ return;
86
+ }
87
+ logger.ok(`Host Docker engine socket found: ${sock}`);
88
+ logger.info(`Bridging it into the guest on TCP port ${context.dockerPort}.`);
89
+ logger.ok(`Guest reaches the host at ${hostAlias}`);
90
+ if (!(await startBridge(context, state, 'docker', sock, hostAlias))) {
91
+ return;
92
+ }
93
+ }
94
+ /** The shared bridge start: the host spawn + state bookkeeping (the
95
+ * shell's start_host_bridge; pidfile + detached bridge.js via
96
+ * runners/bridges.ts — no socat). Returns false when skipped.
97
+ */
98
+ async function startBridge(context, state, role, socket, hostAlias) {
99
+ const result = await startHostBridge({
100
+ role,
101
+ bindHost: hostAlias,
102
+ port: role === 'ssh-agent' ? context.agentPort : context.dockerPort,
103
+ forwardSocket: socket,
104
+ });
105
+ if (result.state === 'failed') {
106
+ logger.warn(`skipping the ${role === 'ssh-agent' ? 'SSH agent' : 'Docker'} bridge.`);
107
+ return false;
108
+ }
109
+ const bridge = role === 'ssh-agent' ? state.bridges.agent : state.bridges.docker;
110
+ bridge.bridged = true;
111
+ bridge.socket = socket;
112
+ bridge.pid = result.state === 'started' ? result.pid : undefined;
113
+ return true;
114
+ }
115
+ function reportBridgeState(context, state) {
116
+ const agent = state.bridges.agent;
117
+ if (agent.bridged) {
118
+ if (agent.guestUp) {
119
+ logger.ok(`Guest bridge is up: /tmp/ssh-agent.sock -> host TCP ${context.agentPort}`);
120
+ }
121
+ else {
122
+ logger.warn('guest bridge did not start — run the guest commands from docs/ssh-agent.md manually.');
123
+ }
124
+ }
125
+ const docker = state.bridges.docker;
126
+ if (docker.bridged) {
127
+ if (docker.guestUp) {
128
+ logger.ok(`Guest Docker bridge is up: /tmp/docker.sock -> host TCP ${context.dockerPort}`);
129
+ }
130
+ else {
131
+ logger.warn('guest Docker bridge did not start — check the guest agent install.');
132
+ }
133
+ }
134
+ }
135
+ /** End-to-end check: can the guest's docker CLI reach the host engine
136
+ * through the bridge? Retries briefly (the engine may still be
137
+ * starting), like the shell's verify_guest_docker (15 x 1 s).
138
+ */
139
+ async function verifyGuestDocker(session, state) {
140
+ const probe = 'bash -lc \'docker info --format "{{.ServerVersion}}"\'';
141
+ for (let attempt = 0; attempt < 15; attempt += 1) {
142
+ const res = await session.exec(probe, { timeoutMs: 20_000 });
143
+ const version = res.code === 0 ? res.stdout.trim() : '';
144
+ if (version) {
145
+ state.bridges.docker.serverVersion = version;
146
+ state.bridges.docker.engineUp = true;
147
+ logger.ok(`Docker engine is reachable from the guest (server version ${version}).`);
148
+ return;
149
+ }
150
+ await sleep(1000);
151
+ }
152
+ logger.warn('Docker engine not reachable from the guest yet — is it running on the host?');
153
+ logger.warn('The bridge reconnects on its own once it is; re-run to re-check.');
154
+ }
@@ -0,0 +1,136 @@
1
+ // runners/ubuntu-guest.ts — the guest side of the Ubuntu step 3: the
2
+ // credentials (from the image's vars file, UBUNTU_PASSWORD override),
3
+ // where the bundled guest agent lives, how it is uploaded over SFTP and
4
+ // how it is run (upload → sudo install → status), plus the status-line
5
+ // parsing. The transport is the ssh2 session (lib/ssh.ts) — the twin of
6
+ // runners/macos-guest.ts, which runs over `tart exec`.
7
+ import { readFileSync } from 'node:fs';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { varsFor, resolveImage } from '../lifecycle/catalog.js';
10
+ import { logger } from '../lib/logger.js';
11
+ /** The guest's agent install dir (image-fixed home `/home/admin`). */
12
+ const GUEST_LIB_DIR = '/home/admin/.local/lib/agent-dev-env';
13
+ /** The absolute guest-agent path. */
14
+ export const GUEST_AGENT = `${GUEST_LIB_DIR}/guest-agent-ubuntu.js`;
15
+ /** The guest's sshd port (fixed in the image; no hostfwd — the VM sits
16
+ * on Fusion's NAT and the host reaches it directly). */
17
+ const GUEST_SSH_PORT = 22;
18
+ /** The bundled Ubuntu guest agent (single-file, node-only). */
19
+ function guestAgentPath() {
20
+ return fileURLToPath(new URL('../assets/guest/guest-agent-ubuntu.js', import.meta.url));
21
+ }
22
+ /** @internal — resolves the guest credentials from the image's vars file
23
+ * (+ UBUNTU_PASSWORD override). Pure so the fallbacks are testable.
24
+ *
25
+ * @param vars - The vars file values.
26
+ * @param env - Environment (UBUNTU_PASSWORD).
27
+ * @param ip - The guest IP.
28
+ * @returns The credentials.
29
+ * @throws Error when no ssh_password is available.
30
+ */
31
+ export function guestCredentials(vars, env, ip) {
32
+ const username = typeof vars.ssh_username === 'string' && vars.ssh_username.trim() ? vars.ssh_username : 'admin';
33
+ const password = env.UBUNTU_PASSWORD ?? (typeof vars.ssh_password === 'string' ? vars.ssh_password : '');
34
+ if (!password) {
35
+ throw new Error('could not read ssh_password from the vars file — set UBUNTU_PASSWORD to the guest password.');
36
+ }
37
+ return { host: ip, port: GUEST_SSH_PORT, username, password };
38
+ }
39
+ /** The credentials for the current run (image vars + env + guest IP).
40
+ *
41
+ * @param image - The image name.
42
+ * @param env - Environment.
43
+ * @param ip - The guest IP.
44
+ * @returns The credentials.
45
+ */
46
+ export function resolveGuestCredentials(image, env, ip) {
47
+ return guestCredentials(varsFor(resolveImage(image)), env, ip);
48
+ }
49
+ /** Reports the path of the node binary inside the guest (opencode needs
50
+ * it, so the image ships it; resolve once per session like the mac
51
+ * backend).
52
+ *
53
+ * @param session - The connected guest session.
54
+ * @returns The absolute node path, or undefined when not found.
55
+ */
56
+ export async function findGuestNode(session) {
57
+ const res = await session.exec('sh -lc "command -v node"');
58
+ const node = res.code === 0 ? res.stdout.trim() : '';
59
+ return node && node.startsWith('/') ? node : undefined;
60
+ }
61
+ /** Uploads the bundled guest agent and runs `install` (idempotent:
62
+ * systemd user units rewritten + the bridge env exports). Runs as the
63
+ * guest user — the agent's systemd units live under the user's
64
+ * ~/.config/systemd/user, and `systemctl --user` must target the admin
65
+ * session (running the agent through sudo would place them under /root;
66
+ * the agent then falls back to the user-level ~/.profile exports).
67
+ *
68
+ * @param session - The connected guest session.
69
+ * @param node - The guest's node binary.
70
+ * @param hostAlias - The NAT host address the bridges point at.
71
+ * @param context - The run context (ports).
72
+ */
73
+ export async function ensureGuestAgent(session, node, hostAlias, context) {
74
+ const mkdir = await session.exec(`mkdir -p ${GUEST_LIB_DIR}`);
75
+ if (mkdir.code !== 0) {
76
+ logger.warn(`could not prepare the guest agent dir (ssh: ${mkdir.code}).`);
77
+ return;
78
+ }
79
+ try {
80
+ await session.sftpWrite(GUEST_AGENT, readFileSync(guestAgentPath()));
81
+ }
82
+ catch (err) {
83
+ logger.warn(`could not upload the guest agent: ${err.message}`);
84
+ return;
85
+ }
86
+ const install = await session.exec(`${node} ${GUEST_AGENT} install ` +
87
+ `--agent-port ${context.agentPort} --docker-port ${context.dockerPort} ` +
88
+ `--host-alias ${hostAlias}`, { timeoutMs: 60_000 });
89
+ if (install.code !== 0) {
90
+ logger.warn(`guest agent install failed (ssh: ${install.code}).`);
91
+ return;
92
+ }
93
+ logger.ok('Guest agent installed (SSH agent + Docker bridges persistent).');
94
+ }
95
+ /** Parses the guest agent's machine-readable status lines — shared by
96
+ * the Ubuntu and Windows guests (identical bridge-status format).
97
+ *
98
+ * @param output - The `guest-agent status` stdout.
99
+ * @returns The per-role bridge state (present per reported line).
100
+ */
101
+ export function parseGuestStatus(output) {
102
+ const status = {};
103
+ for (const line of output.split('\n')) {
104
+ const match = /^bridge-status:(ssh-agent|docker)=(up|down)$/.exec(line.trim());
105
+ if (!match) {
106
+ continue;
107
+ }
108
+ if (match[1] === 'ssh-agent') {
109
+ status.sshAgent = match[2] === 'up';
110
+ }
111
+ else {
112
+ status.docker = match[2] === 'up';
113
+ }
114
+ }
115
+ return status;
116
+ }
117
+ /** Runs `guest-agent status` and folds the bridge-status lines into the
118
+ * run state.
119
+ *
120
+ * @param session - The connected guest session.
121
+ * @param state - The accumulated run state.
122
+ * @param node - The guest's node binary.
123
+ */
124
+ export async function readGuestStatus(session, state, node) {
125
+ const res = await session.exec(`${node} ${GUEST_AGENT} status`, { timeoutMs: 20_000 });
126
+ if (res.code !== 0) {
127
+ return;
128
+ }
129
+ const status = parseGuestStatus(res.stdout);
130
+ if (status.sshAgent !== undefined) {
131
+ state.bridges.agent.guestUp = status.sshAgent;
132
+ }
133
+ if (status.docker !== undefined) {
134
+ state.bridges.docker.guestUp = status.docker;
135
+ }
136
+ }
@@ -0,0 +1,26 @@
1
+ // runners/ubuntu-image.ts — step 1 for the Ubuntu backend: the
2
+ // ubuntu-vmware binding of the shared VMware image flow (vmware-image.ts).
3
+ // The shared module holds the archive pick/extract, the base clone + the
4
+ // display name + the one-time hardware upgrade; this wrapper only binds
5
+ // the platform id and the UBUNTU_VMWARE_IMAGE override var.
6
+ import { ensureVmwareImage, vmwareWorkingVmx } from './vmware-image.js';
7
+ /** The platform id for the VMware path helpers. */
8
+ const PLATFORM = 'ubuntu-vmware';
9
+ /** @internal — the pristine-archive identity (see vmware-image.ts) —
10
+ * re-exported so the co-located test keeps its import path.
11
+ */
12
+ export { archiveIdentity } from './vmware-image.js';
13
+ /** The working clone's vmx. */
14
+ export function ubuntuWorkingVmx(image) {
15
+ return vmwareWorkingVmx(PLATFORM, image);
16
+ }
17
+ /** Step 1: select the archive, extract the base, clone the working VM
18
+ * and upgrade it if the installed Fusion supports a newer hardware
19
+ * version.
20
+ *
21
+ * @param context - The run context.
22
+ * @param state - The accumulated run state (imageArchive set here).
23
+ */
24
+ export async function ensureUbuntuImage(context, state) {
25
+ return ensureVmwareImage(PLATFORM, 'UBUNTU_VMWARE_IMAGE', context, state);
26
+ }
@@ -0,0 +1,86 @@
1
+ // runners/ubuntu-rules.ts — the agent-rules step for the Ubuntu backend:
2
+ // render the bundled agent-rules-linux.md (extra {{NAT_GATEWAY}}
3
+ // substitution) and drive the guest agent's `rules --probe` / `rules
4
+ // --force` over ssh (the mac twin in macos-rules.ts runs over `tart
5
+ // exec`; the probe/confirm/overwrite semantics are the same). The
6
+ // sha256 marker semantics live in the guest agent (guest-rules).
7
+ import { logger } from '../lib/logger.js';
8
+ import { confirmDefault } from '../lib/prompt.js';
9
+ import { loadAgentRules, renderAgentRules } from './rules.js';
10
+ import { GUEST_AGENT } from './ubuntu-guest.js';
11
+ /** The rules asset for Ubuntu (vs agent-rules.md on macOS). */
12
+ const RULES_ASSET = 'agent-rules-linux.md';
13
+ /** The agent-rules probe/confirm/apply over ssh.
14
+ *
15
+ * @param context - The run context (paths to substitute).
16
+ * @param state - The accumulated run state (bridge flags).
17
+ * @param session - The connected guest session.
18
+ * @param node - The guest's node binary.
19
+ * @param hostAlias - The NAT host address ({{NAT_GATEWAY}}).
20
+ */
21
+ export async function installLinuxAgentRules(context, state, session, node, hostAlias) {
22
+ const content = loadAgentRules(RULES_ASSET);
23
+ if (!content) {
24
+ logger.warn('agent rules file not found (assets/rules/agent-rules-linux.md).');
25
+ state.rules = 'failed';
26
+ return;
27
+ }
28
+ const natGateway = hostAlias ?? (state.vmIp ? `${state.vmIp.split('.').slice(0, 3).join('.')}.1` : '');
29
+ const rendered = renderAgentRules(content, {
30
+ HOST_WORK_DIR: context.workDir || '<not shared>',
31
+ GUEST_MOUNT: context.guestMount,
32
+ NAT_GATEWAY: natGateway,
33
+ }, state.bridges.agent.bridged && state.bridges.agent.guestUp);
34
+ const probe = await session.exec(`${node} ${GUEST_AGENT} rules`, {
35
+ input: rendered,
36
+ });
37
+ const action = /^rules:probe=(.+)$/.exec(probe.stdout.trim())?.[1];
38
+ if (probe.code !== 0 || !action) {
39
+ logger.warn('could not inspect the agent rules in the guest.');
40
+ state.rules = 'failed';
41
+ return;
42
+ }
43
+ await applyRulesAction(context, state, session, node, rendered, action);
44
+ }
45
+ /** The probe outcome decision tree (shell parity: install/update ask
46
+ * with the y default, conflict asks with the n default). */
47
+ async function applyRulesAction(context, state, session, node, rendered, action) {
48
+ if (action === 'uptodate') {
49
+ logger.info('Agent rules are up to date in the guest.');
50
+ state.rules = 'uptodate';
51
+ return;
52
+ }
53
+ if (action !== 'install' && action !== 'update' && action !== 'conflict') {
54
+ logger.warn('could not inspect the agent rules in the guest.');
55
+ state.rules = 'failed';
56
+ return;
57
+ }
58
+ const isConflict = action === 'conflict';
59
+ if (isConflict) {
60
+ logger.info('The guest has its own agent rules.');
61
+ }
62
+ const ask = isConflict
63
+ ? "Overwrite the guest's agent rules with the sandbox rules?"
64
+ : 'Install/update the sandbox agent rules in the guest?';
65
+ if (!(await confirmDefault(ask, { default: isConflict ? 'n' : 'y', yes: context.options.yes }))) {
66
+ logger.info(isConflict
67
+ ? "Keeping the guest's own agent rules."
68
+ : "Keeping the guest's agent rules as they are.");
69
+ state.rules = 'kept';
70
+ return;
71
+ }
72
+ const write = await session.exec(`${node} ${GUEST_AGENT} rules --force`, { input: rendered });
73
+ if (write.code !== 0) {
74
+ logger.warn('could not install the agent rules into the guest.');
75
+ state.rules = 'failed';
76
+ return;
77
+ }
78
+ if (isConflict) {
79
+ state.rules = 'overwritten';
80
+ logger.ok("Overwrote the guest's agent rules.");
81
+ }
82
+ else {
83
+ state.rules = action === 'install' ? 'installed' : 'updated';
84
+ logger.ok('Installed/updated the sandbox agent rules (opencode + Copilot CLI).');
85
+ }
86
+ }