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,161 @@
|
|
|
1
|
+
// deploy.ts — `agent-dev-env deploy`: pushes built images to GHCR (the
|
|
2
|
+
// port of scripts/deploy.sh + the three platform deploy wrappers).
|
|
3
|
+
// macOS images go via tart push; the qcow2/vmware artifacts go via oras
|
|
4
|
+
// push as OCI artifacts (the tar.gz for the VMware pair). The GHCR owner
|
|
5
|
+
// is resolved GHCR_OWNER → --owner → git remote → ameshkov.
|
|
6
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { run, runChecked } from '../lib/exec.js';
|
|
9
|
+
import { findRepoRoot } from '../lib/git.js';
|
|
10
|
+
import { registryRef, resolveOwner } from '../lib/ghcr.js';
|
|
11
|
+
import { logger } from '../lib/logger.js';
|
|
12
|
+
import { buildDirLayout, duHuman, requireCmd } from './build-shared.js';
|
|
13
|
+
import { imageVersion, resolveRequestedImages } from './catalog.js';
|
|
14
|
+
/** `agent-dev-env deploy [image...] [--owner OWNER]`.
|
|
15
|
+
*
|
|
16
|
+
* @param requested - Image names (all images when empty).
|
|
17
|
+
* @param options - Owner override.
|
|
18
|
+
* @returns The exit code (0 on success).
|
|
19
|
+
*/
|
|
20
|
+
export async function deployCmd(requested = [], options = {}) {
|
|
21
|
+
const repoRoot = findRepoRoot();
|
|
22
|
+
const owner = await resolveOwner({ owner: options.owner, repoRoot: repoRoot ?? undefined });
|
|
23
|
+
const targets = resolveRequestedImages(requested);
|
|
24
|
+
if (requested.length === 0) {
|
|
25
|
+
logger.title('Deploying all images:');
|
|
26
|
+
for (const image of targets) {
|
|
27
|
+
logger.info(image.name);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
for (const image of targets) {
|
|
31
|
+
await deployImage(image, owner);
|
|
32
|
+
}
|
|
33
|
+
return 0;
|
|
34
|
+
}
|
|
35
|
+
/** Deploys one image with its platform's flow.
|
|
36
|
+
*
|
|
37
|
+
* @param image - The catalog image.
|
|
38
|
+
* @param owner - The resolved GHCR owner.
|
|
39
|
+
*/
|
|
40
|
+
async function deployImage(image, owner) {
|
|
41
|
+
switch (image.platform) {
|
|
42
|
+
case 'macos':
|
|
43
|
+
await deployMacos(image, owner);
|
|
44
|
+
return;
|
|
45
|
+
case 'windows-qemu':
|
|
46
|
+
await deployQemu(image, owner);
|
|
47
|
+
return;
|
|
48
|
+
case 'windows-vmware':
|
|
49
|
+
case 'ubuntu-vmware':
|
|
50
|
+
await deployVmware(image, owner);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** macOS: `tart push <image> --chunk-size 3 <ref>:<ver> <ref>:latest`.
|
|
55
|
+
*
|
|
56
|
+
* @param image - The catalog image.
|
|
57
|
+
* @param owner - The GHCR owner.
|
|
58
|
+
*/
|
|
59
|
+
async function deployMacos(image, owner) {
|
|
60
|
+
requireCmd('tart', 'brew install cirruslabs/tap/tart');
|
|
61
|
+
const version = imageVersion(image);
|
|
62
|
+
const ref = registryRef(image.name, version, owner);
|
|
63
|
+
logger.title(`Pushing image: ${image.name}`);
|
|
64
|
+
logger.info(`Registry: ${ref} and :latest`);
|
|
65
|
+
await runChecked('tart', tartPushArgs(image.name, ref, registryRef(image.name, 'latest', owner)));
|
|
66
|
+
logger.ok(`Done: ${ref} (and :latest)`);
|
|
67
|
+
}
|
|
68
|
+
/** @internal — the tart push argv (3 MB chunks — GHCR rejects > 4 MB
|
|
69
|
+
* chunks; test-only export).
|
|
70
|
+
*
|
|
71
|
+
* @param imageName - The local VM image name.
|
|
72
|
+
* @param versionRef - The version registry ref.
|
|
73
|
+
* @param latestRef - The latest registry ref.
|
|
74
|
+
* @returns The tart argv.
|
|
75
|
+
*/
|
|
76
|
+
export function tartPushArgs(imageName, versionRef, latestRef) {
|
|
77
|
+
return ['push', imageName, '--chunk-size', '3', versionRef, latestRef];
|
|
78
|
+
}
|
|
79
|
+
/** windows-qemu: oras push of the built qcow2 as an OCI artifact.
|
|
80
|
+
*
|
|
81
|
+
* @param image - The catalog image.
|
|
82
|
+
* @param owner - The GHCR owner.
|
|
83
|
+
*/
|
|
84
|
+
async function deployQemu(image, owner) {
|
|
85
|
+
const dirs = buildDirLayout('windows-qemu');
|
|
86
|
+
const artifact = join(dirs.output, `${image.name}.qcow2`);
|
|
87
|
+
if (!existsSync(artifact)) {
|
|
88
|
+
throw new Error(`no built image at ${artifact}\n Build it first: agent-dev-env build ${image.name}`);
|
|
89
|
+
}
|
|
90
|
+
requireCmd('oras', 'brew install oras');
|
|
91
|
+
const version = imageVersion(image);
|
|
92
|
+
const ref = `${registryRef(image.name, version, owner)},latest`;
|
|
93
|
+
logger.title(`Pushing image: ${image.name}`);
|
|
94
|
+
logger.info(`Registry: ${registryRef(image.name, version, owner)} and :latest`);
|
|
95
|
+
logger.info(`Artifact: ${artifact} (${await duHuman(artifact)})`);
|
|
96
|
+
await runChecked('oras', orasPushArgs(ref, `${image.name}.qcow2`, 'application/vnd.agent-dev-env.qcow2'), {
|
|
97
|
+
cwd: dirs.output,
|
|
98
|
+
});
|
|
99
|
+
logger.ok(`Done: ${registryRef(image.name, version, owner)} (and :latest)`);
|
|
100
|
+
}
|
|
101
|
+
/** @internal — the oras push argv (bare file name — oras rejects absolute
|
|
102
|
+
* paths and stores the file under the name it is given; test-only
|
|
103
|
+
* export).
|
|
104
|
+
*
|
|
105
|
+
* @param ref - The `registry:version,latest` ref.
|
|
106
|
+
* @param file - The bare file name in the cwd.
|
|
107
|
+
* @param artifactType - The OCI artifact media type.
|
|
108
|
+
* @returns The oras argv.
|
|
109
|
+
*/
|
|
110
|
+
export function orasPushArgs(ref, file, artifactType) {
|
|
111
|
+
return [
|
|
112
|
+
'push',
|
|
113
|
+
'--artifact-type',
|
|
114
|
+
artifactType,
|
|
115
|
+
ref,
|
|
116
|
+
`${file}:application/vnd.oci.image.layer.v1.tar`,
|
|
117
|
+
];
|
|
118
|
+
}
|
|
119
|
+
/** windows-vmware / ubuntu-vmware: tar.gz of the VM dir, then oras push.
|
|
120
|
+
*
|
|
121
|
+
* @param image - The catalog image.
|
|
122
|
+
* @param owner - The GHCR owner.
|
|
123
|
+
*/
|
|
124
|
+
async function deployVmware(image, owner) {
|
|
125
|
+
const dirs = buildDirLayout(image.platform);
|
|
126
|
+
const vmx = join(dirs.output, `${image.name}.vmx`);
|
|
127
|
+
if (!existsSync(vmx)) {
|
|
128
|
+
throw new Error(`no built image at ${vmx}\n Build it first: agent-dev-env build ${image.name}`);
|
|
129
|
+
}
|
|
130
|
+
const artifact = join(dirs.output, `${image.name}.tar.gz`);
|
|
131
|
+
logger.step(`packing ${artifact} (excluding vmware logs)`);
|
|
132
|
+
await packageVmwareTar(dirs.output, image.name, artifact);
|
|
133
|
+
requireCmd('oras', 'brew install oras');
|
|
134
|
+
const version = imageVersion(image);
|
|
135
|
+
const ref = `${registryRef(image.name, version, owner)},latest`;
|
|
136
|
+
logger.title(`Pushing image: ${image.name}`);
|
|
137
|
+
logger.info(`Registry: ${registryRef(image.name, version, owner)} and :latest`);
|
|
138
|
+
logger.info(`Artifact: ${artifact} (${await duHuman(artifact)})`);
|
|
139
|
+
await runChecked('oras', orasPushArgs(ref, `${image.name}.tar.gz`, 'application/vnd.agent-dev-env.vmware-vm'), {
|
|
140
|
+
cwd: dirs.output,
|
|
141
|
+
});
|
|
142
|
+
logger.ok(`Done: ${registryRef(image.name, version, owner)} (and :latest)`);
|
|
143
|
+
}
|
|
144
|
+
/** @internal — packages the runnable VM (vmx + nvram + vmdks, no logs)
|
|
145
|
+
* into one tar.gz for oras (test-only export; consumers get a single
|
|
146
|
+
* pull artifact to extract).
|
|
147
|
+
*
|
|
148
|
+
* @param outputDir - Packer's output dir (cwd for the relative names).
|
|
149
|
+
* @param imageName - The image name (file prefix).
|
|
150
|
+
* @param artifact - The tar.gz to create.
|
|
151
|
+
*/
|
|
152
|
+
export async function packageVmwareTar(outputDir, imageName, artifact) {
|
|
153
|
+
const vmdks = readdirSync(outputDir)
|
|
154
|
+
.filter((file) => file.endsWith('.vmdk'))
|
|
155
|
+
.sort();
|
|
156
|
+
const files = [`${imageName}.vmx`, `${imageName}.nvram`, ...vmdks];
|
|
157
|
+
const res = await run('tar', ['-czf', artifact, ...files], { cwd: outputDir });
|
|
158
|
+
if (res.code !== 0) {
|
|
159
|
+
throw new Error(`tar failed: ${res.stderr.trim() || res.stdout.trim()}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// tag.ts — `agent-dev-env tag`: creates and pushes the git release tag
|
|
2
|
+
// for an image (the port of scripts/tag.sh). The tag is
|
|
3
|
+
// <platform>-v<image_version>, where <platform> is the image's directory
|
|
4
|
+
// under images/ (e.g. `mac` → `mac-v1.2.0`). The release convention is
|
|
5
|
+
// enforced: clean working tree, no existing tag, and a `[<tag>]` entry in
|
|
6
|
+
// the platform's CHANGELOG.md. Requires a repo checkout or --repo.
|
|
7
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
8
|
+
import { basename, dirname, join } from 'node:path';
|
|
9
|
+
import { changelogHasTagEntry, createAnnotatedTag, findRepoRoot, isWorkingTreeClean, pushTag, tagExists, } from '../lib/git.js';
|
|
10
|
+
import { logger } from '../lib/logger.js';
|
|
11
|
+
import { imageVersion, resolveRequestedImages } from './catalog.js';
|
|
12
|
+
/** @internal — the platform name (the images/<dir> basename, e.g. `mac`;
|
|
13
|
+
* test-only export).
|
|
14
|
+
*
|
|
15
|
+
* @param image - The catalog image.
|
|
16
|
+
* @returns The platform dir name.
|
|
17
|
+
*/
|
|
18
|
+
export function platformTagName(image) {
|
|
19
|
+
return basename(dirname(dirname(image.varsFile)));
|
|
20
|
+
}
|
|
21
|
+
/** @internal — the release tag for an image (<platform>-v<image_version>;
|
|
22
|
+
* test-only export).
|
|
23
|
+
*
|
|
24
|
+
* @param image - The catalog image.
|
|
25
|
+
* @returns The tag name.
|
|
26
|
+
*/
|
|
27
|
+
export function releaseTag(image) {
|
|
28
|
+
return `${platformTagName(image)}-v${imageVersion(image)}`;
|
|
29
|
+
}
|
|
30
|
+
/** @internal — creates and pushes the tag for one image (test-only
|
|
31
|
+
* export).
|
|
32
|
+
*
|
|
33
|
+
* @param image - The catalog image.
|
|
34
|
+
* @param repoRoot - The repo checkout.
|
|
35
|
+
*/
|
|
36
|
+
export async function tagImage(image, repoRoot) {
|
|
37
|
+
const tag = releaseTag(image);
|
|
38
|
+
const version = imageVersion(image);
|
|
39
|
+
const changelog = join(dirname(dirname(image.varsFile)), 'CHANGELOG.md');
|
|
40
|
+
if (!(await isWorkingTreeClean(repoRoot))) {
|
|
41
|
+
throw new Error('Working tree is dirty — commit the version bump and CHANGELOG entry first.');
|
|
42
|
+
}
|
|
43
|
+
if (await tagExists(repoRoot, tag)) {
|
|
44
|
+
throw new Error(`Tag '${tag}' already exists.`);
|
|
45
|
+
}
|
|
46
|
+
const content = existsSync(changelog) ? readFileSync(changelog, 'utf8') : '';
|
|
47
|
+
if (!changelogHasTagEntry(content, tag)) {
|
|
48
|
+
throw new Error(`No CHANGELOG entry for [${tag}] in ${changelog}.\n` +
|
|
49
|
+
'Add it (and bump image_version) before tagging.');
|
|
50
|
+
}
|
|
51
|
+
logger.step(`Tagging: ${tag} (${image.name} v${version})`);
|
|
52
|
+
await createAnnotatedTag(repoRoot, tag, `Release ${image.name} v${version}`);
|
|
53
|
+
await pushTag(repoRoot, tag);
|
|
54
|
+
}
|
|
55
|
+
/** `agent-dev-env tag [image...] [--repo PATH]`.
|
|
56
|
+
*
|
|
57
|
+
* @param requested - Image names (all images when empty).
|
|
58
|
+
* @param options - Repo override.
|
|
59
|
+
* @returns The exit code (0 on success).
|
|
60
|
+
*/
|
|
61
|
+
export async function tagCmd(requested = [], options = {}) {
|
|
62
|
+
const repoRoot = options.repo ?? findRepoRoot();
|
|
63
|
+
if (!repoRoot) {
|
|
64
|
+
throw new Error('tag needs a checkout of the agent-dev-env repo (pass --repo or run it inside the repo).');
|
|
65
|
+
}
|
|
66
|
+
const targets = resolveRequestedImages(requested);
|
|
67
|
+
if (requested.length === 0) {
|
|
68
|
+
logger.title('Tagging all images:');
|
|
69
|
+
for (const image of targets) {
|
|
70
|
+
logger.info(image.name);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
for (const image of targets) {
|
|
74
|
+
await tagImage(image, repoRoot);
|
|
75
|
+
}
|
|
76
|
+
return 0;
|
|
77
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// watch-build.ts — the hidden `watch-build` command: the port of
|
|
2
|
+
// scripts/watch-build.sh. Checks python3 + vncdotool and swiftc (hard
|
|
3
|
+
// errors — this is the manual watchdog invocation, not a build wrapper),
|
|
4
|
+
// compiles the OCR helper if-stale, then runs the bundled watch-build.py
|
|
5
|
+
// in the foreground (Ctrl+C stops it, same as the shell's `exec`).
|
|
6
|
+
import { spawn } from 'node:child_process';
|
|
7
|
+
import { mkdirSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { commandExists } from '../lib/exec.js';
|
|
10
|
+
import { buildDir } from '../lib/paths.js';
|
|
11
|
+
import { logger } from '../lib/logger.js';
|
|
12
|
+
import { compileOcrHelper, pythonVncdotoolAvailable, watchdogAssets } from './build-watchdog.js';
|
|
13
|
+
/** The default frames dir (<data>/build/windows-qemu/packer_cache/
|
|
14
|
+
* watchdog).
|
|
15
|
+
*
|
|
16
|
+
* @returns The default outdir.
|
|
17
|
+
*/
|
|
18
|
+
function defaultWatchdogOutdir() {
|
|
19
|
+
return join(buildDir('windows-qemu'), 'packer_cache', 'watchdog');
|
|
20
|
+
}
|
|
21
|
+
/** `agent-dev-env watch-build <vnc-port> [outdir]` (hidden).
|
|
22
|
+
*
|
|
23
|
+
* @param vncPort - The VNC port of the packer build.
|
|
24
|
+
* @param outdir - The frames dir (defaults per platform state layout).
|
|
25
|
+
* @param options - The command options (--no-ocr reserved).
|
|
26
|
+
* @returns The python supervisor's exit code.
|
|
27
|
+
*/
|
|
28
|
+
export async function watchBuildCmd(vncPort, outdir, options = {}) {
|
|
29
|
+
void options.noOcr;
|
|
30
|
+
if (!(await pythonVncdotoolAvailable())) {
|
|
31
|
+
logger.die('the vncdotool module is missing — install it with: pip3 install vncdotool');
|
|
32
|
+
}
|
|
33
|
+
if (!commandExists('swiftc')) {
|
|
34
|
+
logger.die('swiftc is missing — install the Xcode command line tools.');
|
|
35
|
+
}
|
|
36
|
+
const assets = watchdogAssets();
|
|
37
|
+
const dir = outdir ?? defaultWatchdogOutdir();
|
|
38
|
+
mkdirSync(dir, { recursive: true });
|
|
39
|
+
const ocr = await compileOcrHelper(dir, assets);
|
|
40
|
+
if (!ocr) {
|
|
41
|
+
logger.warn('swiftc failed — the OCR helper is required for the watchdog.');
|
|
42
|
+
return 1;
|
|
43
|
+
}
|
|
44
|
+
logger.step(`watching VNC port ${vncPort} (frames: ${dir}) — Ctrl+C to stop`);
|
|
45
|
+
return runForeground('python3', [assets.python, String(vncPort), dir, ocr]);
|
|
46
|
+
}
|
|
47
|
+
/** Runs a process with inherited stdio and resolves with its exit code
|
|
48
|
+
* (the shell's `exec`-style foreground run).
|
|
49
|
+
*
|
|
50
|
+
* @param cmd - The command.
|
|
51
|
+
* @param args - The argv.
|
|
52
|
+
* @returns The exit code.
|
|
53
|
+
*/
|
|
54
|
+
function runForeground(cmd, args) {
|
|
55
|
+
return new Promise((resolve) => {
|
|
56
|
+
const child = spawn(cmd, args, { stdio: 'inherit' });
|
|
57
|
+
child.on('error', () => {
|
|
58
|
+
resolve(1);
|
|
59
|
+
});
|
|
60
|
+
child.on('close', (code) => {
|
|
61
|
+
resolve(code ?? 1);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// runners/bridges.ts — the host side of the SSH agent + Docker bridges:
|
|
2
|
+
// socket discovery, the port probe (the legacy "is a listener already
|
|
3
|
+
// bound" check), and the detached spawn of the bundled bridge.js (the
|
|
4
|
+
// socat replacement). The guest side lives in the
|
|
5
|
+
// per-platform guest agents (launchd/schtasks/systemd).
|
|
6
|
+
//
|
|
7
|
+
// The bridge is spawned detached with a pidfile under the logs/state dir
|
|
8
|
+
// and keeps running after the CLI exits (the VM needs it while it runs);
|
|
9
|
+
// `stop` kills it by pidfile. Idempotent: when a listener is already up,
|
|
10
|
+
// nothing is spawned.
|
|
11
|
+
import net from 'node:net';
|
|
12
|
+
import { lstatSync, mkdirSync, rmSync } from 'node:fs';
|
|
13
|
+
import { dirname, join } from 'node:path';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
import { isAlive, killTree, readPidFile, spawnDetached } from '../lib/exec.js';
|
|
16
|
+
import { logger } from '../lib/logger.js';
|
|
17
|
+
import { paths } from '../lib/paths.js';
|
|
18
|
+
/** The bundled bridge entry — where copy-assets.mjs puts it. */
|
|
19
|
+
function bridgeJsPath() {
|
|
20
|
+
// dist/runners/bridges.js -> dist/assets/bridge/bridge.js
|
|
21
|
+
return fileURLToPath(new URL('../assets/bridge/bridge.js', import.meta.url));
|
|
22
|
+
}
|
|
23
|
+
/** @internal — pidfile for a host bridge (logs/state dir). */
|
|
24
|
+
export function bridgePidFile(role) {
|
|
25
|
+
return join(paths.logs, `bridge-${role}.pid`);
|
|
26
|
+
}
|
|
27
|
+
/** @internal — log file for a host bridge. */
|
|
28
|
+
export function bridgeLogFile(role) {
|
|
29
|
+
return join(paths.logs, `bridge-${role}.log`);
|
|
30
|
+
}
|
|
31
|
+
/** Whether the path is a Unix socket. */
|
|
32
|
+
function isUnixSocket(path) {
|
|
33
|
+
try {
|
|
34
|
+
return lstatSync(path).isSocket();
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** The host's SSH agent socket when it is overridden by a password
|
|
41
|
+
* manager's agent (Bitwarden, 1Password, ...). The stock macOS launchd
|
|
42
|
+
* agent (a socket under /var/run/com.apple.launchd.*) is NOT bridged.
|
|
43
|
+
*
|
|
44
|
+
* @param env - Environment (SSH_AUTH_SOCK).
|
|
45
|
+
* @param home - Host home directory (unused, kept for signature parity).
|
|
46
|
+
* @returns The socket path, or undefined when nothing is bridged.
|
|
47
|
+
*/
|
|
48
|
+
export function findHostAgentSocket(env = process.env, _home = process.env.HOME ?? '') {
|
|
49
|
+
const sock = env.SSH_AUTH_SOCK;
|
|
50
|
+
if (!sock || /^\/var\/run\/com\.apple\.launchd\..*\/Listeners$/.test(sock)) {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
if (isUnixSocket(sock)) {
|
|
54
|
+
return sock;
|
|
55
|
+
}
|
|
56
|
+
logger.warn(`SSH_AUTH_SOCK points to '${sock}', but no such socket exists.`);
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
/** The host's Docker engine socket — the engines the sandbox supports:
|
|
60
|
+
* Docker Desktop (4.30+), Colima, OrbStack, then the legacy /var/run.
|
|
61
|
+
* The system-wide candidate is injectable so tests can be deterministic
|
|
62
|
+
* on any host (Linux CI runners always expose /var/run/docker.sock).
|
|
63
|
+
*
|
|
64
|
+
* @param home - Host home directory.
|
|
65
|
+
* @param systemSocket - System-wide Docker socket candidate.
|
|
66
|
+
* @returns The socket path, or undefined when no engine is running.
|
|
67
|
+
*/
|
|
68
|
+
export function findHostDockerSocket(home, systemSocket = '/var/run/docker.sock') {
|
|
69
|
+
const candidates = [
|
|
70
|
+
join(home, '.docker', 'run', 'docker.sock'),
|
|
71
|
+
join(home, '.colima', 'default', 'docker.sock'),
|
|
72
|
+
join(home, '.orbstack', 'run', 'docker.sock'),
|
|
73
|
+
systemSocket,
|
|
74
|
+
];
|
|
75
|
+
return candidates.find((candidate) => isUnixSocket(candidate));
|
|
76
|
+
}
|
|
77
|
+
/** @internal — TCP probe: can a client connect to host:port?
|
|
78
|
+
*
|
|
79
|
+
* @param host - Bind/target host.
|
|
80
|
+
* @param port - TCP port.
|
|
81
|
+
* @param timeoutMs - Probe timeout (default 1000).
|
|
82
|
+
* @returns True when a connection is accepted.
|
|
83
|
+
*/
|
|
84
|
+
export function canConnectTcp(host, port, timeoutMs = 1000) {
|
|
85
|
+
return new Promise((resolve) => {
|
|
86
|
+
const socket = net.connect({ host, port });
|
|
87
|
+
const done = (ok) => {
|
|
88
|
+
socket.destroy();
|
|
89
|
+
resolve(ok);
|
|
90
|
+
};
|
|
91
|
+
socket.once('connect', () => done(true));
|
|
92
|
+
socket.once('error', () => done(false));
|
|
93
|
+
setTimeout(() => done(false), timeoutMs);
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
/** Spawns the detached host bridge (or reports it is already up).
|
|
97
|
+
*
|
|
98
|
+
* @param args - role (pidfile/log naming), bind host + port and the
|
|
99
|
+
* forward socket (unix path on the host).
|
|
100
|
+
* @returns The outcome; nothing is spawned when the port is already
|
|
101
|
+
* served.
|
|
102
|
+
*/
|
|
103
|
+
export async function startHostBridge(args) {
|
|
104
|
+
if (await canConnectTcp(args.bindHost, args.port)) {
|
|
105
|
+
logger.ok(`A listener is already bound to TCP port ${args.port} — assuming the bridge is up.`);
|
|
106
|
+
return { state: 'already-up' };
|
|
107
|
+
}
|
|
108
|
+
const pidFile = bridgePidFile(args.role);
|
|
109
|
+
const pid = spawnDetached(process.execPath, [
|
|
110
|
+
bridgeJsPath(),
|
|
111
|
+
'--listen',
|
|
112
|
+
`tcp:${args.bindHost}:${args.port}`,
|
|
113
|
+
'--forward',
|
|
114
|
+
`unix:${args.forwardSocket}`,
|
|
115
|
+
'--pidfile',
|
|
116
|
+
pidFile,
|
|
117
|
+
], { logFile: bridgeLogFile(args.role) });
|
|
118
|
+
if (pid <= 0) {
|
|
119
|
+
logger.warn('host bridge failed to start — check the agent socket path.');
|
|
120
|
+
return { state: 'failed' };
|
|
121
|
+
}
|
|
122
|
+
// Give the bridge a moment to bind; a dead socket path makes the
|
|
123
|
+
// listener exit immediately (the legacy `kill -0` sleep-1 check).
|
|
124
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
125
|
+
if (!isAlive(pid) && !readPidFile(pidFile)) {
|
|
126
|
+
rmSync(pidFile, { force: true });
|
|
127
|
+
logger.warn('host bridge exited immediately — check the agent socket path.');
|
|
128
|
+
return { state: 'failed' };
|
|
129
|
+
}
|
|
130
|
+
logger.ok(`Host bridge is up (pid ${pid}).`);
|
|
131
|
+
return { state: 'started', pid };
|
|
132
|
+
}
|
|
133
|
+
/** Stops the detached bridge for a role: pidfile → killTree, pidfile
|
|
134
|
+
* removed. No-op when nothing is running (idempotent, like the legacy
|
|
135
|
+
* stop script's "no listener" branch).
|
|
136
|
+
*
|
|
137
|
+
* @param role - The bridge role.
|
|
138
|
+
* @returns The pid when something was stopped, undefined otherwise.
|
|
139
|
+
*/
|
|
140
|
+
export async function stopHostBridge(role) {
|
|
141
|
+
const pidFile = bridgePidFile(role);
|
|
142
|
+
const pid = readPidFile(pidFile);
|
|
143
|
+
if (pid !== undefined && isAlive(pid)) {
|
|
144
|
+
await killTree(pid);
|
|
145
|
+
rmSync(pidFile, { force: true });
|
|
146
|
+
return pid;
|
|
147
|
+
}
|
|
148
|
+
rmSync(pidFile, { force: true });
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
151
|
+
/** Ensures the logs/state dir exists (pidfiles + logs for the bridges
|
|
152
|
+
* and the tart run log).
|
|
153
|
+
*/
|
|
154
|
+
export function ensureBridgeDir() {
|
|
155
|
+
mkdirSync(dirname(bridgePidFile('ssh-agent')), { recursive: true });
|
|
156
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// runners/framework.ts — the shared runner flow: the step order and state
|
|
2
|
+
// types every platform backend follows, plus the host/arch preflight. The
|
|
3
|
+
// four backends (runners/macos.ts, runners/windows-qemu.ts,
|
|
4
|
+
// runners/windows.ts, runners/ubuntu.ts) implement the per-platform hooks;
|
|
5
|
+
// the step titles and their order stay shell-identical.
|
|
6
|
+
//
|
|
7
|
+
// Step order: preflight → 1 image+VM → 2 boot → 3 bridges + rules → 4
|
|
8
|
+
// settings → 5 OpenChamber → summary → foreground wait.
|
|
9
|
+
import { logger } from '../lib/logger.js';
|
|
10
|
+
/** Builds a fresh state object for a run.
|
|
11
|
+
*
|
|
12
|
+
* @param context - The run context (mode + log dir).
|
|
13
|
+
* @returns The initial state.
|
|
14
|
+
*/
|
|
15
|
+
function createRunState(context) {
|
|
16
|
+
return {
|
|
17
|
+
mode: context.options.headless ? 'headless' : 'gui',
|
|
18
|
+
created: false,
|
|
19
|
+
vmAlreadyRunning: false,
|
|
20
|
+
tartLog: '',
|
|
21
|
+
bridges: {
|
|
22
|
+
agent: { bridged: false, guestUp: false },
|
|
23
|
+
docker: { bridged: false, guestUp: false, engineUp: false },
|
|
24
|
+
},
|
|
25
|
+
rules: 'not-installed',
|
|
26
|
+
settings: 'skipped',
|
|
27
|
+
openchamberUp: false,
|
|
28
|
+
sharedFolderSkipped: false,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/** The host requirement, shared by every backend (doctor's §1 checks).
|
|
32
|
+
* Host = macOS Apple Silicon; the runners cannot virtualize ARM64
|
|
33
|
+
* guests on Intel.
|
|
34
|
+
*/
|
|
35
|
+
function assertSandboxHost() {
|
|
36
|
+
if (process.platform !== 'darwin') {
|
|
37
|
+
logger.die('the sandbox runners run on macOS only (Linux is not supported at runtime).');
|
|
38
|
+
}
|
|
39
|
+
if (process.arch !== 'arm64') {
|
|
40
|
+
logger.die('Apple Silicon required — Tart/QEMU/Fusion cannot virtualize ARM64 guests on Intel.');
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** Runs the shared step order for a backend.
|
|
44
|
+
*
|
|
45
|
+
* @param backend - The platform backend.
|
|
46
|
+
* @param context - The resolved run context.
|
|
47
|
+
* @returns The process exit code (0 after a successful run).
|
|
48
|
+
*/
|
|
49
|
+
export async function runSandbox(backend, context) {
|
|
50
|
+
assertSandboxHost();
|
|
51
|
+
const state = createRunState(context);
|
|
52
|
+
await backend.preflight(context, state);
|
|
53
|
+
logger.step('Step 1/5: Sandbox image and working VM');
|
|
54
|
+
await backend.ensureImageAndVm(context, state);
|
|
55
|
+
logger.step('Step 2/5: Starting the VM');
|
|
56
|
+
await backend.boot(context, state);
|
|
57
|
+
logger.step('Step 3/5: Host bridges (SSH agent, Docker)');
|
|
58
|
+
await backend.setupBridges(context, state);
|
|
59
|
+
logger.step('Step 4/5: User settings');
|
|
60
|
+
await backend.setupSettings(context, state);
|
|
61
|
+
logger.step('Step 5/5: OpenChamber');
|
|
62
|
+
await backend.verifyOpenchamber(context, state);
|
|
63
|
+
await backend.summarize(context, state);
|
|
64
|
+
await backend.finish(context, state);
|
|
65
|
+
return 0;
|
|
66
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// runners/macos-bridges.ts — step 3 for the macOS backend: the host side
|
|
2
|
+
// of the SSH agent + Docker bridges (socket discovery + detached bridge.js
|
|
3
|
+
// spawn). The guest side (upload/install/status) lives in macos-guest.ts,
|
|
4
|
+
// the rules step in macos-rules.ts; this module wires them together —
|
|
5
|
+
// port of run-macos-sandbox.sh §step 3, with the socat pair replaced by
|
|
6
|
+
// the Node forwarder on both sides.
|
|
7
|
+
import { sleep } from '../lib/exec.js';
|
|
8
|
+
import { logger } from '../lib/logger.js';
|
|
9
|
+
import { execVm, findGuestNode, gatewayFromVmIp, vmIp } from '../lib/tart.js';
|
|
10
|
+
import { findHostAgentSocket, findHostDockerSocket, startHostBridge } from './bridges.js';
|
|
11
|
+
import { ensureGuestAgent, readGuestStatus } from './macos-guest.js';
|
|
12
|
+
import { installAgentRules } from './macos-rules.js';
|
|
13
|
+
/** The macOS step 3: guest agent + bridges (agent/docker) + rules. The
|
|
14
|
+
* guest agent is installed up front (rules need it; install is
|
|
15
|
+
* idempotent), then each bridge is wired independently.
|
|
16
|
+
*/
|
|
17
|
+
export async function macosBridges(context, state) {
|
|
18
|
+
const node = await findGuestNode(context.vm);
|
|
19
|
+
if (!node) {
|
|
20
|
+
logger.warn('node not found in the guest — skipping the guest bridges and rules.');
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
const gateway = await resolveGateway(context, state);
|
|
24
|
+
await ensureGuestAgent(context, node, gateway);
|
|
25
|
+
if (context.options.noAgent) {
|
|
26
|
+
logger.info('Skipping SSH agent bridge setup (--no-agent).');
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
await setupSshAgent(context, state, node);
|
|
30
|
+
}
|
|
31
|
+
if (context.options.noDocker) {
|
|
32
|
+
logger.info('Skipping Docker bridge setup (--no-docker).');
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
await setupDockerBridge(context, state, node);
|
|
36
|
+
}
|
|
37
|
+
await installAgentRules(context, state, node);
|
|
38
|
+
}
|
|
39
|
+
/** The host's address on Tart's VM network (`.1` of the VM's /24),
|
|
40
|
+
* retrying the IP fetch — it can fail right after boot.
|
|
41
|
+
*/
|
|
42
|
+
async function resolveGateway(context, state) {
|
|
43
|
+
let ip = state.vmIp;
|
|
44
|
+
for (let attempt = 0; attempt < 5 && !ip; attempt += 1) {
|
|
45
|
+
ip = await vmIp(context.vm);
|
|
46
|
+
if (!ip) {
|
|
47
|
+
await sleep(2000);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
state.vmIp = ip;
|
|
51
|
+
if (!ip) {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
return gatewayFromVmIp(ip);
|
|
55
|
+
}
|
|
56
|
+
async function setupSshAgent(context, state, node) {
|
|
57
|
+
const sock = findHostAgentSocket(context.options.env, context.options.home);
|
|
58
|
+
if (!sock) {
|
|
59
|
+
logger.info('No SSH agent override detected — using the default macOS agent.');
|
|
60
|
+
logger.info('To share a password manager\u2019s agent (Bitwarden, 1Password, ...), ' +
|
|
61
|
+
'enable its SSH agent on the host and re-run.');
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
logger.ok(`Host SSH agent socket found: ${sock}`);
|
|
65
|
+
logger.info(`Bridging it into '${context.vm}' on TCP port ${context.agentPort} (see docs/ssh-agent.md).`);
|
|
66
|
+
if (!(await startBridge(context, state, 'ssh-agent', sock))) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
await readGuestStatus(context, state, node);
|
|
70
|
+
if (state.bridges.agent.guestUp) {
|
|
71
|
+
logger.ok(`Guest bridge is up: /tmp/ssh-agent.sock -> host TCP ${context.agentPort}`);
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
logger.warn('guest bridge did not start — run the guest commands from docs/ssh-agent.md manually.');
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
async function setupDockerBridge(context, state, node) {
|
|
78
|
+
const sock = findHostDockerSocket(context.options.home);
|
|
79
|
+
if (!sock) {
|
|
80
|
+
logger.info('No Docker engine socket found on the host (Docker Desktop, Colima, OrbStack).');
|
|
81
|
+
logger.info('Start an engine on the host and re-run to bridge it into the guest.');
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
logger.ok(`Host Docker engine socket found: ${sock}`);
|
|
85
|
+
logger.info(`Bridging it into '${context.vm}' on TCP port ${context.dockerPort}.`);
|
|
86
|
+
if (!(await startBridge(context, state, 'docker', sock))) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
await readGuestStatus(context, state, node);
|
|
90
|
+
if (state.bridges.docker.guestUp) {
|
|
91
|
+
logger.ok(`Guest Docker bridge is up: ~/.docker/run/docker.sock -> host TCP ${context.dockerPort}`);
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
logger.warn('guest Docker bridge did not start — check the guest agent install.');
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
await verifyGuestDocker(context, state);
|
|
98
|
+
}
|
|
99
|
+
/** The shared bridge start: gateway resolution + host spawn + state
|
|
100
|
+
* bookkeeping. Returns false when the bridge was skipped.
|
|
101
|
+
*/
|
|
102
|
+
async function startBridge(context, state, role, socket) {
|
|
103
|
+
const gateway = await resolveGateway(context, state);
|
|
104
|
+
if (!gateway) {
|
|
105
|
+
logger.warn(`could not determine the host gateway address ('tart ip ${context.vm}' failed).`);
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
const result = await startHostBridge({
|
|
109
|
+
role,
|
|
110
|
+
bindHost: gateway,
|
|
111
|
+
port: role === 'ssh-agent' ? context.agentPort : context.dockerPort,
|
|
112
|
+
forwardSocket: socket,
|
|
113
|
+
});
|
|
114
|
+
if (result.state === 'failed') {
|
|
115
|
+
logger.warn(`skipping the ${role === 'ssh-agent' ? 'SSH agent' : 'Docker'} bridge.`);
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
const bridge = role === 'ssh-agent' ? state.bridges.agent : state.bridges.docker;
|
|
119
|
+
bridge.bridged = true;
|
|
120
|
+
bridge.socket = socket;
|
|
121
|
+
bridge.pid = result.state === 'started' ? result.pid : undefined;
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
/** End-to-end check: can the guest's docker CLI reach the host engine
|
|
125
|
+
* through the bridge? Retries briefly (the engine may still be
|
|
126
|
+
* starting), like the shell's verify_guest_docker (15 x 1 s).
|
|
127
|
+
*/
|
|
128
|
+
async function verifyGuestDocker(context, state) {
|
|
129
|
+
const probe = 'export PATH="/opt/homebrew/bin:$PATH"; docker info --format "{{.ServerVersion}}"';
|
|
130
|
+
for (let attempt = 0; attempt < 15; attempt += 1) {
|
|
131
|
+
const res = await execVm(context.vm, ['sh', '-c', probe]);
|
|
132
|
+
const version = res.code === 0 ? res.stdout.trim() : '';
|
|
133
|
+
if (version) {
|
|
134
|
+
state.bridges.docker.serverVersion = version;
|
|
135
|
+
state.bridges.docker.engineUp = true;
|
|
136
|
+
logger.ok(`Docker engine is reachable from the guest (server version ${version}).`);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
await sleep(1000);
|
|
140
|
+
}
|
|
141
|
+
logger.warn('Docker engine not reachable from the guest yet — is it running on the host?');
|
|
142
|
+
logger.warn('The bridge reconnects on its own once it is; re-run this script to re-check.');
|
|
143
|
+
}
|