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,83 @@
1
+ # Windows 11 (ARM64) sandbox image — VMware (Fusion) build.
2
+ #
3
+ # Built with the Packer vmware-iso plugin on Apple Silicon (the vmware-iso
4
+ # builder drives VMware Fusion, which virtualizes ARM64 guests natively):
5
+ # Windows 11 ARM64 ISO + Fusion's ARM64 boot drivers + VMware Tools. See
6
+ # images/windows-arm64-vmware/README.md for the full build flow — the
7
+ # Windows ISO is bring-your-own (Microsoft does not permit redistribution),
8
+ # so it is not part of this repo.
9
+
10
+ windows_version = "11"
11
+
12
+ # SHA256 of the Windows 11 ARM64 ISO. Microsoft publishes the hash on the
13
+ # download page (https://www.microsoft.com/software-download/windows11arm64);
14
+ # paste it here to enable integrity verification. Set WINDOWS_ISO_PATH to
15
+ # the local ISO path when building. Empty = skip verification.
16
+ iso_sha256 = "638AA2C88E94385B00F4F178D071E3DF0B7D9E335577A83BD533B7F2EB65ADF0"
17
+
18
+ # VMware Fusion installation that supplies the ARM64 boot drivers
19
+ # (Contents/Library/isoimages/arm64/drivers-arm64.zip) and the ARM64 VMware
20
+ # Tools ISO (Contents/Library/isoimages/arm64/windows.iso) during the build;
21
+ # the sandbox runner needs the same Fusion to run the VM.
22
+ # Fusion 13.6+ is required (the Packer vmware plugin's minimum).
23
+ vmware_fusion_app_path = "/Applications/VMware Fusion.app"
24
+
25
+ # Toolchain versions installed via Chocolatey (choco package versions —
26
+ # must exist in the community repository).
27
+ nodejs_version = "26.8.1"
28
+ python_version = "3.13.15"
29
+ github_cli_version = "2.97.0"
30
+ ripgrep_version = "15.2.0"
31
+ git_version = "2.55.0.4"
32
+ jq_version = "1.8.1"
33
+ open_code_review_version = "1.9.5"
34
+
35
+ # C/C++ + cross-language toolchains (brought over from AdGuard's
36
+ # build-agent-images windows2022-vs2022 / windows2022-go images).
37
+ # VS2022 Build Tools (choco package version; the finalizer adds the .NET
38
+ # SDKs + VC++ workload + Win11 SDK) and Rust (via rustup, not choco) are
39
+ # installed by dedicated provisioners.
40
+ go_version = "1.27.0"
41
+ rust_version = "1.95"
42
+ wixtoolset_version = "3.14.1.20250415"
43
+ protoc_version = "36.0.0"
44
+ nasm_version = "3.2.0"
45
+ llvm_version = "22.1.7"
46
+ vim_version = "9.2.995"
47
+ nuget_version = "7.9.0"
48
+ mingw_version = "16.1.0"
49
+ make_version = "4.4.1"
50
+ vs_buildtools_version = "117.14.37"
51
+
52
+ # Google Chrome: installed from the Chrome for Testing (CfT) snapshot
53
+ # archive instead of choco — choco's googlechrome package always downloads
54
+ # the live dl.google.com MSI whose hash rotates on every Chrome release,
55
+ # so the pinned package hash breaks between releases. CfT serves versioned
56
+ # zips at storage.googleapis.com/chrome-for-testing-public/<version>/,
57
+ # which stay downloadable and hash-stable. The x64 build runs under Windows
58
+ # on ARM emulation, like the choco MSI did.
59
+ chrome_version = "152.0.7977.54"
60
+ chrome_sha256 = "91850065e6b80bba0c752e17a150fe1b9e39bba51ed705640c1273f565950dda"
61
+
62
+ # VM resources
63
+ disk_size = 100
64
+ cpu_count = 4
65
+ memory_gb = 8
66
+
67
+ # WinRM credentials used for provisioning. They are baked into
68
+ # images/windows-arm64-vmware/autounattend.xml (Administrator password) and
69
+ # become the sandbox's login (SSH/RDP) — keep the two files in sync.
70
+ winrm_username = "Administrator"
71
+ winrm_password = "sandbox1"
72
+
73
+ # OpenChamber web UI password + port. The runner advertises the UI at
74
+ # http://<guest-ip>:4000 (password "sandbox" by default). OpenChamber
75
+ # refuses to serve on the network without a password.
76
+ openchamber_ui_password = "sandbox"
77
+ openchamber_port = 4000
78
+
79
+ # Semantic version this image is published under (also the GHCR push tag,
80
+ # besides :latest). For every release: bump it, add a CHANGELOG.md entry,
81
+ # and create the windows-arm64-vmware-v<version> git tag
82
+ # (npx agent-dev-env tag <image>).
83
+ image_version = "1.0.0"
@@ -0,0 +1,68 @@
1
+ # Sandbox VM environment
2
+
3
+ You are running inside an Ubuntu sandbox VM. Your code lives on the host Mac
4
+ and is shared into the VM; the toolchain (Go, Rust, Node, Python, VS Code,
5
+ opencode, ...) runs in the VM. The environment differs from a normal dev
6
+ machine in a few ways that matter when you run commands or start services.
7
+
8
+ ## Paths
9
+
10
+ - When the runner was started with `--work-dir`, the shared working
11
+ directory is mounted in the VM at `{{GUEST_MOUNT}}` and corresponds to
12
+ the host's `{{HOST_WORK_DIR}}`. Paths quoted from the host — in issues,
13
+ commits, or host-side terminal output — use the host form: map them to
14
+ the guest form when you open files, and back to the host form when you
15
+ return paths to the user.
16
+ - The VM's user is `admin` (home `/home/admin`); your git identity comes
17
+ from the host's git config (the runner shares it into the guest).
18
+
19
+ ## Docker
20
+
21
+ The VM ships the Docker CLI but **no container engine** — the runner
22
+ bridges the *host's* engine into the VM instead:
23
+
24
+ - Docker talks to the host engine through `unix:///tmp/docker.sock`
25
+ (`DOCKER_HOST` is exported; the `host` docker context is the default).
26
+ - **Never try to install or start a local engine** (`dockerd`, Docker
27
+ Desktop, ...) — it cannot work here. If `docker info` fails, the bridge
28
+ or the host engine is down: tell the user to start the engine on the
29
+ host and/or re-run `agent-dev-env run ubuntu-vmware`, and continue with
30
+ work that does not need Docker.
31
+
32
+ ### Containers you launch
33
+
34
+ - Containers run on the **host engine**, and the Docker daemon resolves
35
+ volume-mount paths on the **host's filesystem** — the guest mount
36
+ (`{{GUEST_MOUNT}}`) does not exist there. Bind mounts must use **host
37
+ paths**: mount `{{HOST_WORK_DIR}}/<project>/...` into the container, not
38
+ `{{GUEST_MOUNT}}/<project>/...`. This applies to `docker run -v` and to
39
+ compose `volumes:` entries alike; named volumes are unaffected.
40
+ - Published ports are bound on the **host**. From inside the VM they are
41
+ reachable at `{{NAT_GATEWAY}}:<port>` — **not** at `localhost:<port>`.
42
+ Check a container you started with `curl http://{{NAT_GATEWAY}}:<port>`,
43
+ and tell the user the port is available as `http://localhost:<port>` on
44
+ the host itself.
45
+ - Image builds run on the host engine: `docker build` and `docker buildx`
46
+ work as usual.
47
+
48
+ ## Services you start
49
+
50
+ - A server you start inside the VM (e.g. a dev server) is reachable from
51
+ the host at `http://<vm-ip>:<port>` — the VM's IP is printed when the
52
+ sandbox starts. OpenChamber itself listens on port 4000.
53
+
54
+ ## Config changes
55
+
56
+ - After editing opencode or Copilot settings in the VM, restart OpenChamber
57
+ with `systemctl --user restart agent-dev-env-openchamber` (the CLI
58
+ restarts it automatically when it brings the bridges up). To pull the
59
+ host's versions of the settings instead, the user runs
60
+ `agent-dev-env sync ubuntu-vmware`.
61
+
62
+ ## SSH agent bridge
63
+
64
+ - The runner bridges the host's SSH agent into the VM, so `git push`, `git
65
+ fetch`, `gh`, `ssh` and `scp` work against any host the user's agent
66
+ knows, without keys on disk. `SSH_AUTH_SOCK` points at the bridged
67
+ socket (`/tmp/ssh-agent.sock`), and `~/.ssh/config` sets `IdentityAgent`
68
+ for tools that don't read the export.
@@ -0,0 +1,71 @@
1
+ # Sandbox VM environment
2
+
3
+ You are running inside a macOS sandbox VM. Your code lives on the host Mac and
4
+ is shared into the VM; the toolchain (Xcode, Homebrew, Node, Python, VS Code,
5
+ opencode, ...) runs in the VM. The environment differs from a normal dev
6
+ machine in a few ways that matter when you run commands or start services.
7
+
8
+ ## Paths
9
+
10
+ - The shared working directory is mounted in the VM at `{{GUEST_MOUNT}}` and
11
+ corresponds to the host's `{{HOST_WORK_DIR}}`. Paths quoted from the host —
12
+ in issues, commits, or host-side terminal output — use the host form: map
13
+ them to the guest form when you open files, and back to the host form when
14
+ you return paths to the user.
15
+ - The VM's user is `admin` (home `/Users/admin`); your git identity, aliases
16
+ and signing config come from `~/.gitconfig` (synced from the host with the
17
+ user settings).
18
+
19
+ ## Docker
20
+
21
+ The VM ships the Docker CLI but **no container engine** — macOS guests cannot
22
+ nest VMs, so Docker Desktop, Colima and OrbStack do not run inside the VM. The
23
+ runner bridges the *host's* engine into the VM instead:
24
+
25
+ - The `host` docker context is the default; `docker`, `docker compose` and
26
+ `docker buildx` talk to the host engine via `~/.docker/run/docker.sock`
27
+ (`DOCKER_HOST` and `TESTCONTAINERS_HOST_OVERRIDE` are exported for clients
28
+ that ignore contexts, e.g. testcontainers).
29
+ - **Never try to install or start a local engine** (`colima start`, Docker
30
+ Desktop, ...) — it cannot work here. If `docker info` fails, the bridge or
31
+ the host engine is down: tell the user to start the engine on the host
32
+ and/or re-run `agent-dev-env run macos`, and continue with work that does
33
+ not need Docker.
34
+
35
+ ### Containers you launch
36
+
37
+ - Containers run on the **host engine**, and the Docker daemon resolves
38
+ volume-mount paths on the **host's filesystem** — the guest mount
39
+ (`{{GUEST_MOUNT}}`) does not exist there. Bind mounts must use **host
40
+ paths**: mount `{{HOST_WORK_DIR}}/<project>/...` into the container, not
41
+ `{{GUEST_MOUNT}}/<project>/...`. This applies to `docker run -v` and to
42
+ compose `volumes:` entries alike (compose resolves relative mount paths in
43
+ the guest before the daemon sees them); named volumes are unaffected.
44
+ - Published ports are bound on the **host**. From inside the VM they are
45
+ reachable at the NAT gateway — `192.168.64.1:<port>` (verify with `route -n
46
+ get default`) — **not** at `localhost:<port>`. Check a container you
47
+ started with `curl http://192.168.64.1:<port>`, and tell the user the port
48
+ is available as `http://localhost:<port>` on the host itself.
49
+ - Image builds run on the host engine: `docker build` and `docker buildx`
50
+ work as usual.
51
+
52
+ ## Services you start
53
+
54
+ - A server you start inside the VM (e.g. a dev server) is reachable from the
55
+ host at `http://<vm-ip>:<port>` — the VM's IP is printed when the sandbox
56
+ starts, or ask the user to run `tart ip <vm>`. OpenChamber itself listens
57
+ on port 4000.
58
+
59
+ ## Config changes
60
+
61
+ - After editing opencode or Copilot settings in the VM, restart OpenChamber
62
+ with `openchamber restart`; to pull the host's versions of the settings
63
+ instead, the user runs `agent-dev-env sync macos`.
64
+
65
+ ## SSH agent bridge
66
+
67
+ - The runner bridges the host's SSH agent into the VM, so `git push`, `git
68
+ fetch`, `gh`, `ssh` and `scp` work against any host the user's agent knows,
69
+ without keys on disk. `SSH_AUTH_SOCK` points at the bridged socket
70
+ (`/tmp/ssh-agent.sock`), and `~/.ssh/config` sets `IdentityAgent` for tools
71
+ that don't read the export.
@@ -0,0 +1,40 @@
1
+ // watch-build-ocr.swift — OCR helper for the Windows sandbox build watchdog.
2
+ //
3
+ // Compiled on first use by the CLI's `watch-build` command (swiftc -O)
4
+ // into the watchdog outdir; the Python worker (watch-build.py) runs it on
5
+ // each captured VNC frame. Uses Apple's Vision framework — no external OCR
6
+ // dependency, and the Xcode command line tools are already a build
7
+ // prerequisite on the host.
8
+ //
9
+ // Prints one line per recognized text:
10
+ // <text> | center=(<x>,<y>) box=(<x>,<y> <w>x<h>)
11
+ // with pixel coordinates in the image's own resolution (top-left origin),
12
+ // so the worker can click OCR'd buttons without assuming a framebuffer
13
+ // size.
14
+
15
+ import Vision
16
+ import AppKit
17
+
18
+ let url = URL(fileURLWithPath: CommandLine.arguments[1])
19
+ guard let img = NSImage(contentsOf: url),
20
+ let cg = img.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
21
+ FileHandle.standardError.write("cannot load image\n".data(using: .utf8)!)
22
+ exit(1)
23
+ }
24
+ let w = cg.width
25
+ let h = cg.height
26
+ let request = VNRecognizeTextRequest()
27
+ request.recognitionLevel = .accurate
28
+ request.usesLanguageCorrection = false
29
+ let handler = VNImageRequestHandler(cgImage: cg, options: [:])
30
+ try handler.perform([request])
31
+ for obs in request.results ?? [] {
32
+ if let c = obs.topCandidates(1).first {
33
+ let bb = obs.boundingBox // normalized, origin bottom-left
34
+ let x = Int(bb.origin.x * CGFloat(w))
35
+ let y = Int((1 - bb.origin.y - bb.size.height) * CGFloat(h))
36
+ let bw = Int(bb.size.width * CGFloat(w))
37
+ let bh = Int(bb.size.height * CGFloat(h))
38
+ print("\(c.string) | center=(\(x + bw/2),\(y + bh/2)) box=(\(x),\(y) \(bw)x\(bh))")
39
+ }
40
+ }
@@ -0,0 +1,275 @@
1
+ #!/usr/bin/env python3
2
+ """Watchdog for the headless sandbox builds (Windows + Ubuntu).
3
+
4
+ Why it exists: the Packer build's boot_command types Enter 15 times to
5
+ answer the firmware's boot prompts. The stray keys can hit "Cancel" on
6
+ Windows Setup's "Installing Windows 11" screen — Setup then asks "Are you
7
+ sure you want to quit?" and, headless, nothing dismisses it: the build
8
+ stalls forever. Boot races can also land in the UEFI shell. This watchdog
9
+ polls the build's VNC framebuffer, OCRs each frame (Apple Vision via
10
+ watch-build-ocr.swift), and:
11
+
12
+ - clicks "No" on the quit-confirmation dialog (at the OCR'd button
13
+ position; falls back to measured coordinates for the 800x600 buffer),
14
+ - presses a key when "Press any key to boot from CD or DVD" is on screen,
15
+ - boots the ISO from the UEFI shell (fs0: + EFI\\BOOT\\BOOTAA64.EFI),
16
+ - logs loudly when Windows Setup's "Windows could not complete the
17
+ installation" dialog is on screen (the build then hangs at WinRM —
18
+ nothing to click; the root cause is in the guest's Panther logs),
19
+ - Ubuntu builds: types the autoinstall kernel command into grub when
20
+ the grub menu or shell appears (WATCH_BUILD_BOOT_CMD env var, one
21
+ grub command per line; the env var is read per frame, so it can be
22
+ filled in after the build started, e.g. once the HTTP port is known).
23
+ While the command is still untyped (no .boot-typed marker in the frame
24
+ directory), the supervisor polls every 3 s — grub's menu countdown is
25
+ ~20 s wide and the slow cadence (~2 min per frame) can miss it, which
26
+ makes the interactive Subiquity installer boot instead of autoinstall
27
+ and the build hang waiting for SSH. Once the command is typed (or the
28
+ fast-poll cap passes), the slow cadence resumes. The grub command is
29
+ typed only once per build (a marker file in the frame directory
30
+ records it; the build wrapper removes the marker when the watchdog
31
+ starts), and only after grub is actually on screen — the firmware's
32
+ (variable-length) No-Media/PXE probe cycle no longer races the Packer
33
+ boot_command typing.
34
+
35
+ The supervisor runs every capture in a subprocess with a hard timeout, so a
36
+ hung VNC/OCR cycle cannot stall the watch. See the CLI's `watch-build`
37
+ command for the entry point and prerequisites (python3 + vncdotool +
38
+ swiftc).
39
+
40
+ Usage:
41
+ watch-build.py <vnc-port> <outdir> <ocr-binary>
42
+ watch-build.py --worker <vnc-port> <outdir> <ocr-binary> <frame>
43
+ """
44
+
45
+ import os
46
+ import re
47
+ import subprocess
48
+ import sys
49
+ import time
50
+
51
+ # Fallback "No" button center for the 800x600 framebuffer (top-left origin),
52
+ # used when the OCR did not return a position for the button.
53
+ NO_BUTTON_FALLBACK = (487, 381)
54
+
55
+ # --- poll pacing -----------------------------------------------------------
56
+ #
57
+ # Until the Ubuntu autoinstall command is typed (or the hard cap below
58
+ # passes), the supervisor polls fast so at least one worker lands inside
59
+ # grub's ~20 s menu countdown. The slow cadence (90 s worker + 20 s sleep)
60
+ # scans a frame roughly every 2 minutes, so it can miss the menu entirely —
61
+ # the default entry then boots the interactive Subiquity installer and the
62
+ # build hangs waiting for SSH ("Timeout waiting for SSH" after the 60 m
63
+ # ssh_timeout; observed in the Ubuntu 24.04 build on 2026-08-29).
64
+ # Once .boot-typed exists the slow cadence resumes — the only screens left
65
+ # are the installer / Windows dialogs, which change on the minute timescale.
66
+ # The worker timeout stays at 90 s even in the fast phase: the worker's own
67
+ # OCR timeout (50 s) and connect timeout (10 s) bound a slow worker, and a
68
+ # kill mid-typing would corrupt the grub shell input line.
69
+ FAST_POLL_INTERVAL = 3
70
+ FAST_POLL_MAX_SECONDS = 240
71
+ SLOW_POLL_INTERVAL = 20
72
+ SLOW_POLL_WORKER_TIMEOUT = 90
73
+
74
+ # Ubuntu autoinstall boot command (rendered by the build wrapper; see the
75
+ # module docstring). Read per worker invocation, so the value can be
76
+ # provided after the watchdog started.
77
+ BOOT_CMD = os.environ.get('WATCH_BUILD_BOOT_CMD', '')
78
+
79
+
80
+ def log(msg):
81
+ print(time.ctime(), msg, flush=True)
82
+
83
+
84
+ # --- worker: capture one frame, act on what is on screen --------------------
85
+
86
+ def type_colon(vnc):
87
+ """The VNC keymap mangles ':' (shift modifier dropped), so send
88
+ shift+';' explicitly."""
89
+ vnc.keyDown('lshift')
90
+ time.sleep(0.15)
91
+ vnc.keyPress(';')
92
+ time.sleep(0.15)
93
+ vnc.keyUp('lshift')
94
+ time.sleep(0.2)
95
+
96
+
97
+ def type_string(vnc, text):
98
+ """Type a string character by character. ':' needs the shift trick
99
+ (see type_colon); everything else is a plain key. Per-character
100
+ try/except so an unmappable key never aborts the typing."""
101
+ for ch in text:
102
+ try:
103
+ if ch == ':':
104
+ type_colon(vnc)
105
+ else:
106
+ vnc.keyPress(ch)
107
+ time.sleep(0.08)
108
+ except Exception: # noqa: BLE001 - keep typing the rest
109
+ log(f'WARN: could not type {ch!r}')
110
+
111
+ time.sleep(0.5)
112
+
113
+
114
+ def type_boot_command(vnc, outdir, at_shell):
115
+ """Types the Ubuntu autoinstall grub command (BOOT_CMD, one grub
116
+ command per line). 'c' opens the grub shell from the menu; when the
117
+ shell prompt is already up (at_shell), the command lines are typed
118
+ directly."""
119
+ if not at_shell:
120
+ vnc.keyPress('c')
121
+ time.sleep(1.0)
122
+ for line in BOOT_CMD.split('\n'):
123
+ if not line.strip():
124
+ continue
125
+ type_string(vnc, line)
126
+ vnc.keyPress('return')
127
+ time.sleep(1.5)
128
+ # Snapshot the shell right after typing (before/while the kernel
129
+ # boots) — debugging aid: the frame shows the typed command line.
130
+ try:
131
+ vnc.captureScreen(os.path.join(outdir, 'boot-typed.png'))
132
+ except Exception: # noqa: BLE001 - best effort
133
+ pass
134
+ # The marker: typed once per build (the wrapper removes it when the
135
+ # watchdog starts). The worker is re-exec-ed per frame, so state only
136
+ # survives via this file.
137
+ with open(os.path.join(outdir, '.boot-typed'), 'w') as f:
138
+ f.write(time.ctime())
139
+
140
+
141
+ def click_no(vnc, position):
142
+ vnc.mouseMove(*position)
143
+ time.sleep(0.3)
144
+ vnc.mousePress(1)
145
+ time.sleep(0.2)
146
+ vnc.mouseUp(1)
147
+ time.sleep(0.5)
148
+
149
+
150
+ def shell_rescue(vnc):
151
+ """Boot the Windows ISO from the UEFI shell."""
152
+ for ch in 'fs0':
153
+ vnc.keyPress(ch)
154
+ time.sleep(0.25)
155
+ type_colon(vnc)
156
+ vnc.keyPress('return')
157
+ time.sleep(2.5)
158
+ for ch in r'EFI\BOOT\BOOTAA64.EFI':
159
+ vnc.keyPress(ch)
160
+ time.sleep(0.25)
161
+ vnc.keyPress('return')
162
+ time.sleep(1.5)
163
+ vnc.keyPress('spacebar')
164
+ time.sleep(0.5)
165
+ vnc.keyPress('spacebar')
166
+
167
+
168
+ def find_no_button(ocr_text):
169
+ """OCR lines look like 'No | center=(487,381) box=(...)' — return the
170
+ center of the last line whose text is exactly 'No' (buttons sit at the
171
+ bottom of the dialog), or None."""
172
+ found = None
173
+ for line in ocr_text.splitlines():
174
+ if '|' not in line:
175
+ continue
176
+ label, _, rest = line.partition('|')
177
+ if label.strip().lower() != 'no':
178
+ continue
179
+ m = re.search(r'center=\((\d+),(\d+)\)', rest)
180
+ if m:
181
+ found = (int(m.group(1)), int(m.group(2)))
182
+ return found
183
+
184
+
185
+ def run_worker(port, outdir, ocr, frame):
186
+ from vncdotool import api
187
+
188
+ vnc = api.connect(f'127.0.0.1::{port}', timeout=10)
189
+ try:
190
+ vnc.captureScreen(frame)
191
+ text = subprocess.run([ocr, frame], capture_output=True,
192
+ text=True, timeout=50).stdout
193
+ actions = []
194
+ if 'Are you sure you want to quit?' in text:
195
+ actions.append('NO_CLICK')
196
+ pos = find_no_button(text) or NO_BUTTON_FALLBACK
197
+ click_no(vnc, pos)
198
+ if 'Press any key to boot from CD' in text:
199
+ actions.append('CD_KEY')
200
+ vnc.keyPress('spacebar')
201
+ if 'startup.nsh' in text or 'Shell>' in text or 'Shel1>' in text:
202
+ actions.append('SHELL_RESCUE')
203
+ shell_rescue(vnc)
204
+ if 'could not complete the installation' in text:
205
+ # Windows Setup (Win11, VMware Fusion): the first-boot pass
206
+ # failed ("Windows could not complete the installation. To
207
+ # install Windows on this computer, restart the
208
+ # installation."). Clicking OK only restarts the broken
209
+ # install — do not press anything; shout loudly instead, so
210
+ # the build log shows the real state instead of Packer
211
+ # spinning on "Waiting for WinRM" for 90 min. Diagnose from
212
+ # C:\Windows\Panther\setuperr.log / setupact.log on the disk.
213
+ actions.append('SETUP_FAILED')
214
+ log('WINDOWS SETUP FAILED: "Windows could not complete the '
215
+ 'installation" dialog on screen — the build will hang at '
216
+ 'WinRM. Check C:\\Windows\\Panther\\setuperr.log.')
217
+ if (BOOT_CMD and 'grub>' not in text
218
+ and 'Try or Install Ubuntu Server' not in text):
219
+ # keep silent: grub is not on screen yet
220
+ pass
221
+ elif BOOT_CMD and not os.path.exists(
222
+ os.path.join(outdir, '.boot-typed')):
223
+ # grub menu or shell is up: type the autoinstall command.
224
+ actions.append('AUTOINSTALL_BOOT')
225
+ type_boot_command(vnc, outdir, at_shell='grub>' in text)
226
+ log('actions: ' + (','.join(actions) if actions else 'none'))
227
+ finally:
228
+ vnc.disconnect()
229
+
230
+
231
+ # --- supervisor: run the worker per frame with a hard timeout ---------------
232
+
233
+ def main():
234
+ args = sys.argv[1:]
235
+ if args and args[0] in ('-h', '--help'):
236
+ print(__doc__)
237
+ return
238
+ if args and args[0] == '--worker':
239
+ # Re-exec'd by the supervisor: one capture cycle.
240
+ _, port, outdir, ocr, frame = args
241
+ run_worker(port, outdir, ocr, frame)
242
+ return
243
+
244
+ port, outdir, ocr = args
245
+ os.makedirs(outdir, exist_ok=True)
246
+ log(f'watching VNC port {port} (frames in {outdir})')
247
+ i = 0
248
+ fast_until = time.time() + FAST_POLL_MAX_SECONDS
249
+ try:
250
+ while True:
251
+ frame = os.path.join(outdir, f'w{i:04d}.png')
252
+ # Fast-poll while the Ubuntu autoinstall command still has to be
253
+ # typed (grub's menu countdown is only ~20 s wide); relax once
254
+ # typed or when a boot never reaches grub (hard cap, so a stuck
255
+ # interactive installer is watched at the gentle cadence).
256
+ boot_marker = os.path.join(outdir, '.boot-typed')
257
+ fast = bool(BOOT_CMD) and not os.path.exists(boot_marker) \
258
+ and time.time() < fast_until
259
+ try:
260
+ subprocess.run(
261
+ [sys.executable, os.path.abspath(__file__), '--worker',
262
+ port, outdir, ocr, frame],
263
+ timeout=SLOW_POLL_WORKER_TIMEOUT)
264
+ except subprocess.TimeoutExpired:
265
+ log('worker timed out — skipping this frame')
266
+ except Exception as e: # noqa: BLE001 - keep watching
267
+ log(f'error: {e}')
268
+ i += 1
269
+ time.sleep(FAST_POLL_INTERVAL if fast else SLOW_POLL_INTERVAL)
270
+ except KeyboardInterrupt:
271
+ log('stopping')
272
+
273
+
274
+ if __name__ == '__main__':
275
+ main()
package/dist/cli.js ADDED
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env node
2
+ //
3
+ // cli.ts — the agent-dev-env CLI entry point (commander).
4
+ //
5
+ // The surface is registered in commands/register.ts (small register
6
+ // functions, one per group): the lifecycle commands (build/deploy/tag),
7
+ // the diagnostic commands (list/status/doctor), and the VM commands
8
+ // (run/stop/delete/sync/watch-build) — all implemented for every
9
+ // platform; `sync` is limited to the tart/ssh2 transports (macos,
10
+ // ubuntu-vmware).
11
+ import { Command, CommanderError } from 'commander';
12
+ import { createRequire } from 'node:module';
13
+ import { registerDoctorCommands, registerLifecycleCommands, registerStatusCommands, registerVmCommands, } from './commands/register.js';
14
+ import { logger } from './lib/logger.js';
15
+ // dist/cli.js -> ../package.json is the package root; src/cli.ts ->
16
+ // ../package.json is the repo root. Both work because package.json and
17
+ // dist/ sit next to each other in the published npm package.
18
+ const pkg = createRequire(import.meta.url)('../package.json');
19
+ function buildProgram() {
20
+ const program = new Command();
21
+ program
22
+ .name('agent-dev-env')
23
+ .description('Sandbox VM runner and image lifecycle CLI. Builds, runs, and wires ' +
24
+ 'up sandbox VMs (macOS via Tart, Windows via QEMU/VMware, Ubuntu via ' +
25
+ 'VMware) and manages their image releases on GHCR.')
26
+ .version(pkg.version)
27
+ .showSuggestionAfterError();
28
+ registerVmCommands(program);
29
+ registerStatusCommands(program);
30
+ registerLifecycleCommands(program);
31
+ registerDoctorCommands(program);
32
+ // Errors: commander messages are printed by commander itself; our own
33
+ // throws go through die().
34
+ program.exitOverride();
35
+ return program;
36
+ }
37
+ async function main() {
38
+ const program = buildProgram();
39
+ try {
40
+ await program.parseAsync(process.argv);
41
+ }
42
+ catch (err) {
43
+ if (err instanceof CommanderError) {
44
+ process.exit(err.exitCode);
45
+ }
46
+ if (err instanceof Error) {
47
+ logger.die(err.message);
48
+ }
49
+ process.exit(1);
50
+ }
51
+ }
52
+ void main();