@getforgeai/cli 0.1.0-beta.1
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 +201 -0
- package/README.md +80 -0
- package/dist/bin/forgeai.d.ts +3 -0
- package/dist/bin/forgeai.js +8 -0
- package/dist/cli.d.ts +4 -0
- package/dist/cli.js +180 -0
- package/dist/commands/auth-login.d.ts +4 -0
- package/dist/commands/auth-login.js +202 -0
- package/dist/commands/auth-logout.d.ts +2 -0
- package/dist/commands/auth-logout.js +41 -0
- package/dist/commands/auth-status.d.ts +4 -0
- package/dist/commands/auth-status.js +81 -0
- package/dist/commands/cli-list.d.ts +4 -0
- package/dist/commands/cli-list.js +40 -0
- package/dist/commands/cli-unlink.d.ts +4 -0
- package/dist/commands/cli-unlink.js +45 -0
- package/dist/commands/config-set.d.ts +5 -0
- package/dist/commands/config-set.js +63 -0
- package/dist/commands/connect.d.ts +9 -0
- package/dist/commands/connect.js +165 -0
- package/dist/commands/disconnect.d.ts +5 -0
- package/dist/commands/disconnect.js +126 -0
- package/dist/commands/journal-clear.d.ts +2 -0
- package/dist/commands/journal-clear.js +16 -0
- package/dist/commands/journal-list.d.ts +2 -0
- package/dist/commands/journal-list.js +20 -0
- package/dist/commands/journal-status.d.ts +2 -0
- package/dist/commands/journal-status.js +18 -0
- package/dist/commands/mcp-serve.d.ts +13 -0
- package/dist/commands/mcp-serve.js +74 -0
- package/dist/commands/org-list.d.ts +4 -0
- package/dist/commands/org-list.js +56 -0
- package/dist/commands/org-unlink.d.ts +4 -0
- package/dist/commands/org-unlink.js +63 -0
- package/dist/commands/reconnect.d.ts +4 -0
- package/dist/commands/reconnect.js +46 -0
- package/dist/commands/release-open.d.ts +2 -0
- package/dist/commands/release-open.js +43 -0
- package/dist/commands/session-respond.d.ts +2 -0
- package/dist/commands/session-respond.js +29 -0
- package/dist/commands/task-open.d.ts +2 -0
- package/dist/commands/task-open.js +42 -0
- package/dist/commands/vm.d.ts +21 -0
- package/dist/commands/vm.js +122 -0
- package/dist/commands/workflow-open.d.ts +2 -0
- package/dist/commands/workflow-open.js +43 -0
- package/dist/config/config-manager.d.ts +31 -0
- package/dist/config/config-manager.js +70 -0
- package/dist/config/token-manager.d.ts +11 -0
- package/dist/config/token-manager.js +87 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +22 -0
- package/dist/lib/auth-client.d.ts +1670 -0
- package/dist/lib/auth-client.js +10 -0
- package/dist/lib/branch-manager.d.ts +79 -0
- package/dist/lib/branch-manager.js +620 -0
- package/dist/lib/clone-and-checkout.d.ts +6 -0
- package/dist/lib/clone-and-checkout.js +41 -0
- package/dist/lib/connection-manager.d.ts +58 -0
- package/dist/lib/connection-manager.js +436 -0
- package/dist/lib/daemon-entry.d.ts +2 -0
- package/dist/lib/daemon-entry.js +210 -0
- package/dist/lib/daemon.d.ts +38 -0
- package/dist/lib/daemon.js +148 -0
- package/dist/lib/event-journal.d.ts +24 -0
- package/dist/lib/event-journal.js +76 -0
- package/dist/lib/ipc-client.d.ts +10 -0
- package/dist/lib/ipc-client.js +70 -0
- package/dist/lib/ipc-server.d.ts +36 -0
- package/dist/lib/ipc-server.js +70 -0
- package/dist/lib/node-version-guard.d.ts +8 -0
- package/dist/lib/node-version-guard.js +60 -0
- package/dist/lib/open-directory.d.ts +7 -0
- package/dist/lib/open-directory.js +41 -0
- package/dist/lib/repo-manager.d.ts +29 -0
- package/dist/lib/repo-manager.js +103 -0
- package/dist/lib/resilient-emitter.d.ts +53 -0
- package/dist/lib/resilient-emitter.js +160 -0
- package/dist/lib/rest-client.d.ts +164 -0
- package/dist/lib/rest-client.js +139 -0
- package/dist/lib/skill-manager.d.ts +23 -0
- package/dist/lib/skill-manager.js +167 -0
- package/dist/lib/socket-client.d.ts +68 -0
- package/dist/lib/socket-client.js +160 -0
- package/dist/mcp/mcp-probe.d.ts +11 -0
- package/dist/mcp/mcp-probe.js +106 -0
- package/dist/mcp/mcp-response.d.ts +29 -0
- package/dist/mcp/mcp-response.js +67 -0
- package/dist/mcp/mcp-server.d.ts +19 -0
- package/dist/mcp/mcp-server.js +76 -0
- package/dist/mcp/resources/project-conventions.d.ts +4 -0
- package/dist/mcp/resources/project-conventions.js +50 -0
- package/dist/mcp/resources/task-current.d.ts +4 -0
- package/dist/mcp/resources/task-current.js +51 -0
- package/dist/mcp/tools/forge-add-group-dependency.d.ts +4 -0
- package/dist/mcp/tools/forge-add-group-dependency.js +20 -0
- package/dist/mcp/tools/forge-add-task-dependency.d.ts +4 -0
- package/dist/mcp/tools/forge-add-task-dependency.js +20 -0
- package/dist/mcp/tools/forge-ask-human.d.ts +4 -0
- package/dist/mcp/tools/forge-ask-human.js +80 -0
- package/dist/mcp/tools/forge-column-done.d.ts +4 -0
- package/dist/mcp/tools/forge-column-done.js +46 -0
- package/dist/mcp/tools/forge-complete-step.d.ts +4 -0
- package/dist/mcp/tools/forge-complete-step.js +139 -0
- package/dist/mcp/tools/forge-create-task-group.d.ts +4 -0
- package/dist/mcp/tools/forge-create-task-group.js +22 -0
- package/dist/mcp/tools/forge-create-task.d.ts +4 -0
- package/dist/mcp/tools/forge-create-task.js +28 -0
- package/dist/mcp/tools/forge-delete-task-group.d.ts +4 -0
- package/dist/mcp/tools/forge-delete-task-group.js +17 -0
- package/dist/mcp/tools/forge-delete-task.d.ts +4 -0
- package/dist/mcp/tools/forge-delete-task.js +17 -0
- package/dist/mcp/tools/forge-get-task.d.ts +4 -0
- package/dist/mcp/tools/forge-get-task.js +17 -0
- package/dist/mcp/tools/forge-merge-back.d.ts +4 -0
- package/dist/mcp/tools/forge-merge-back.js +179 -0
- package/dist/mcp/tools/forge-release-merge.d.ts +4 -0
- package/dist/mcp/tools/forge-release-merge.js +159 -0
- package/dist/mcp/tools/forge-release-notes.d.ts +4 -0
- package/dist/mcp/tools/forge-release-notes.js +51 -0
- package/dist/mcp/tools/forge-release-tag.d.ts +4 -0
- package/dist/mcp/tools/forge-release-tag.js +125 -0
- package/dist/mcp/tools/forge-remove-group-dependency.d.ts +4 -0
- package/dist/mcp/tools/forge-remove-group-dependency.js +19 -0
- package/dist/mcp/tools/forge-remove-task-dependency.d.ts +4 -0
- package/dist/mcp/tools/forge-remove-task-dependency.js +19 -0
- package/dist/mcp/tools/forge-requeue.d.ts +4 -0
- package/dist/mcp/tools/forge-requeue.js +39 -0
- package/dist/mcp/tools/forge-submit-pr.d.ts +4 -0
- package/dist/mcp/tools/forge-submit-pr.js +68 -0
- package/dist/mcp/tools/forge-update-status.d.ts +4 -0
- package/dist/mcp/tools/forge-update-status.js +77 -0
- package/dist/mcp/tools/forge-update-task-group.d.ts +4 -0
- package/dist/mcp/tools/forge-update-task-group.js +21 -0
- package/dist/mcp/tools/forge-update-task.d.ts +4 -0
- package/dist/mcp/tools/forge-update-task.js +25 -0
- package/dist/mcp/tools/forge-validate-merge.d.ts +4 -0
- package/dist/mcp/tools/forge-validate-merge.js +194 -0
- package/dist/mcp/types.d.ts +39 -0
- package/dist/mcp/types.js +8 -0
- package/dist/methodology/artifact-manager.d.ts +14 -0
- package/dist/methodology/artifact-manager.js +55 -0
- package/dist/orchestrator/agent-registry.d.ts +53 -0
- package/dist/orchestrator/agent-registry.js +96 -0
- package/dist/orchestrator/agent-spawner.d.ts +112 -0
- package/dist/orchestrator/agent-spawner.js +1518 -0
- package/dist/orchestrator/connectors/claude-code-connector.d.ts +3 -0
- package/dist/orchestrator/connectors/claude-code-connector.js +60 -0
- package/dist/orchestrator/connectors/connector.d.ts +95 -0
- package/dist/orchestrator/connectors/connector.js +25 -0
- package/dist/orchestrator/connectors/docker-connector.d.ts +14 -0
- package/dist/orchestrator/connectors/docker-connector.js +337 -0
- package/dist/orchestrator/connectors/native-vm-connector.d.ts +11 -0
- package/dist/orchestrator/connectors/native-vm-connector.js +162 -0
- package/dist/orchestrator/connectors/opencode-connector.d.ts +3 -0
- package/dist/orchestrator/connectors/opencode-connector.js +39 -0
- package/dist/orchestrator/connectors/vm-connector.d.ts +3 -0
- package/dist/orchestrator/connectors/vm-connector.js +139 -0
- package/dist/orchestrator/dispatch-queue.d.ts +43 -0
- package/dist/orchestrator/dispatch-queue.js +93 -0
- package/dist/orchestrator/resume-handler.d.ts +34 -0
- package/dist/orchestrator/resume-handler.js +159 -0
- package/dist/orchestrator/stream-batcher.d.ts +16 -0
- package/dist/orchestrator/stream-batcher.js +50 -0
- package/dist/orchestrator/stream-json-extractor.d.ts +97 -0
- package/dist/orchestrator/stream-json-extractor.js +579 -0
- package/dist/vm/claude-token-manager.d.ts +36 -0
- package/dist/vm/claude-token-manager.js +223 -0
- package/dist/vm/docker-executor.d.ts +131 -0
- package/dist/vm/docker-executor.js +360 -0
- package/dist/vm/docker-process-proxy.d.ts +31 -0
- package/dist/vm/docker-process-proxy.js +105 -0
- package/dist/vm/forge-vm-agent.d.ts +11 -0
- package/dist/vm/forge-vm-agent.js +159 -0
- package/dist/vm/git-credentials.d.ts +43 -0
- package/dist/vm/git-credentials.js +53 -0
- package/dist/vm/image-manager.d.ts +7 -0
- package/dist/vm/image-manager.js +61 -0
- package/dist/vm/log.d.ts +9 -0
- package/dist/vm/log.js +18 -0
- package/dist/vm/mcp-bridge.d.ts +63 -0
- package/dist/vm/mcp-bridge.js +243 -0
- package/dist/vm/mcp-stub.d.ts +15 -0
- package/dist/vm/mcp-stub.js +67 -0
- package/dist/vm/native-addon.d.ts +69 -0
- package/dist/vm/native-addon.js +68 -0
- package/dist/vm/provider-resolver.d.ts +7 -0
- package/dist/vm/provider-resolver.js +27 -0
- package/dist/vm/providers/lima.d.ts +17 -0
- package/dist/vm/providers/lima.js +191 -0
- package/dist/vm/providers/qemu.d.ts +28 -0
- package/dist/vm/providers/qemu.js +260 -0
- package/dist/vm/providers/vfkit.d.ts +40 -0
- package/dist/vm/providers/vfkit.js +371 -0
- package/dist/vm/providers/wsl2.d.ts +27 -0
- package/dist/vm/providers/wsl2.js +226 -0
- package/dist/vm/session-manager.d.ts +130 -0
- package/dist/vm/session-manager.js +494 -0
- package/dist/vm/session-snapshot.d.ts +31 -0
- package/dist/vm/session-snapshot.js +180 -0
- package/dist/vm/types.d.ts +73 -0
- package/dist/vm/types.js +2 -0
- package/dist/vm/vm-helper-client.d.ts +81 -0
- package/dist/vm/vm-helper-client.js +335 -0
- package/dist/vm/vm-helper.d.ts +11 -0
- package/dist/vm/vm-helper.js +156 -0
- package/dist/vm/vm-ipc-protocol.d.ts +84 -0
- package/dist/vm/vm-ipc-protocol.js +9 -0
- package/dist/vm/vm-manager-facade.d.ts +23 -0
- package/dist/vm/vm-manager-facade.js +43 -0
- package/dist/vm/vm-manager.d.ts +74 -0
- package/dist/vm/vm-manager.js +515 -0
- package/dist/vm/vm-process-proxy.d.ts +34 -0
- package/dist/vm/vm-process-proxy.js +64 -0
- package/dist/vm/vsock-bridge.d.ts +44 -0
- package/dist/vm/vsock-bridge.js +140 -0
- package/dist/vm/vsock-protocol.d.ts +98 -0
- package/dist/vm/vsock-protocol.js +57 -0
- package/package.json +65 -0
- package/rootfs/Dockerfile +50 -0
- package/rootfs/forge-mcp-relay.mjs +158 -0
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
import { spawn, execFile } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { homedir, platform } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import chalk from "chalk";
|
|
7
|
+
import { vmLog } from "../log.js";
|
|
8
|
+
const execFileAsync = promisify(execFile);
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
const VM_BASE_DIR = join(homedir(), ".forgeai", "vm", "instances");
|
|
11
|
+
/**
|
|
12
|
+
* Generate a unique MAC address from a session ID.
|
|
13
|
+
* Uses 52:54:00 prefix (QEMU/KVM convention for locally administered MACs)
|
|
14
|
+
* + 3 bytes derived from hash of the ID.
|
|
15
|
+
*/
|
|
16
|
+
function generateMac(id) {
|
|
17
|
+
const hash = createHash("md5").update(id).digest("hex");
|
|
18
|
+
return `52:54:00:${hash.slice(0, 2)}:${hash.slice(2, 4)}:${hash.slice(4, 6)}`;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Resolve the bundled vfkit binary path.
|
|
22
|
+
* The binary is vendored at packages/cli/vendor/darwin/vfkit and included in the npm package.
|
|
23
|
+
* No download needed — it ships with the CLI.
|
|
24
|
+
*/
|
|
25
|
+
function resolveVfkitPath() {
|
|
26
|
+
// Walk up from dist/vm/providers/ to find vendor/darwin/vfkit
|
|
27
|
+
// In dev: packages/cli/src/vm/providers/vfkit.ts → ../../vendor/darwin/vfkit
|
|
28
|
+
// In dist: packages/cli/dist/vm/providers/vfkit.js → ../../vendor/darwin/vfkit
|
|
29
|
+
const cliRoot = join(import.meta.dirname, "..", "..", "..");
|
|
30
|
+
return join(cliRoot, "vendor", "darwin", "vfkit");
|
|
31
|
+
}
|
|
32
|
+
const VFKIT_PATH = resolveVfkitPath();
|
|
33
|
+
const SSH_USER = "ubuntu";
|
|
34
|
+
/**
|
|
35
|
+
* vfkit provider for macOS.
|
|
36
|
+
* Uses Apple's Virtualization Framework via the vfkit binary (auto-downloaded).
|
|
37
|
+
* No brew install needed — single binary CLI.
|
|
38
|
+
*/
|
|
39
|
+
export class VfkitProvider {
|
|
40
|
+
name = "vfkit";
|
|
41
|
+
vms = new Map();
|
|
42
|
+
async isAvailable() {
|
|
43
|
+
if (platform() !== "darwin")
|
|
44
|
+
return false;
|
|
45
|
+
// Check macOS version >= 13 (Ventura) for Virtualization.framework
|
|
46
|
+
try {
|
|
47
|
+
const { stdout } = await execFileAsync("sw_vers", ["-productVersion"]);
|
|
48
|
+
const major = parseInt(stdout.trim().split(".")[0], 10);
|
|
49
|
+
if (major < 13)
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
// Check bundled vfkit binary exists
|
|
56
|
+
if (!existsSync(VFKIT_PATH)) {
|
|
57
|
+
vmLog.warn("vfkit", `Bundled vfkit binary not found at ${VFKIT_PATH}`);
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
async start(id, config) {
|
|
63
|
+
// Clean up any stale instance from a previous failed run
|
|
64
|
+
const existingVm = this.vms.get(id);
|
|
65
|
+
if (existingVm) {
|
|
66
|
+
console.log(chalk.dim(`[vfkit] Cleaning up stale VM "${id}" before start`));
|
|
67
|
+
await this.destroy(id);
|
|
68
|
+
}
|
|
69
|
+
const instanceDir = join(VM_BASE_DIR, id);
|
|
70
|
+
// Remove stale instance dir (previous crash may have left files without a tracked process)
|
|
71
|
+
if (existsSync(instanceDir)) {
|
|
72
|
+
rmSync(instanceDir, { recursive: true, force: true });
|
|
73
|
+
}
|
|
74
|
+
mkdirSync(instanceDir, { recursive: true, mode: 0o700 });
|
|
75
|
+
const vsockSocket = join(instanceDir, "vsock.sock");
|
|
76
|
+
const consoleLog = join(instanceDir, "console.log");
|
|
77
|
+
const efiVars = join(instanceDir, "efi-vars");
|
|
78
|
+
// Clone the base disk image per session using APFS copy-on-write (instant on macOS)
|
|
79
|
+
const diskImage = join(instanceDir, "disk.raw");
|
|
80
|
+
vmLog.dim("vfkit", `Cloning base image (APFS CoW)...`);
|
|
81
|
+
try {
|
|
82
|
+
await execFileAsync("cp", ["-c", config.rootfsPath, diskImage], {
|
|
83
|
+
timeout: 30_000,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
// Fallback to regular copy if APFS clone not supported (non-APFS volume)
|
|
88
|
+
vmLog.dim("vfkit", `APFS clone failed, falling back to regular copy...`);
|
|
89
|
+
await execFileAsync("cp", [config.rootfsPath, diskImage], {
|
|
90
|
+
timeout: 300_000,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
// Generate SSH key pair for this VM session
|
|
94
|
+
const sshKeyPath = join(instanceDir, "ssh_key");
|
|
95
|
+
await execFileAsync("ssh-keygen", [
|
|
96
|
+
"-t",
|
|
97
|
+
"ed25519",
|
|
98
|
+
"-f",
|
|
99
|
+
sshKeyPath,
|
|
100
|
+
"-N",
|
|
101
|
+
"",
|
|
102
|
+
"-q",
|
|
103
|
+
]);
|
|
104
|
+
const pubKey = readFileSync(`${sshKeyPath}.pub`, "utf-8").trim();
|
|
105
|
+
// Write per-session cloud-init with the SSH key
|
|
106
|
+
const userData = `#cloud-config
|
|
107
|
+
ssh_authorized_keys:
|
|
108
|
+
- ${pubKey}
|
|
109
|
+
hostname: forgeai-agent
|
|
110
|
+
package_update: false
|
|
111
|
+
runcmd:
|
|
112
|
+
- mkdir -p /workspace
|
|
113
|
+
`;
|
|
114
|
+
const metaData = `instance-id: ${id}\nlocal-hostname: forgeai-agent\n`;
|
|
115
|
+
writeFileSync(join(instanceDir, "user-data"), userData);
|
|
116
|
+
writeFileSync(join(instanceDir, "meta-data"), metaData);
|
|
117
|
+
vmLog.cyan("vfkit", `Starting VM "${id}" — ${config.cpus} CPUs, ${config.memoryMB}MB RAM`);
|
|
118
|
+
const args = [
|
|
119
|
+
"--cpus",
|
|
120
|
+
String(config.cpus),
|
|
121
|
+
"--memory",
|
|
122
|
+
String(config.memoryMB),
|
|
123
|
+
"--bootloader",
|
|
124
|
+
`efi,variable-store=${efiVars},create`,
|
|
125
|
+
"--device",
|
|
126
|
+
`virtio-blk,path=${diskImage}`,
|
|
127
|
+
"--device",
|
|
128
|
+
`virtio-fs,mountTag=session,sharedDir=${config.sessionDir}`,
|
|
129
|
+
"--device",
|
|
130
|
+
`virtio-vsock,port=${config.vsockPort},socketURL=${vsockSocket}`,
|
|
131
|
+
"--device",
|
|
132
|
+
`virtio-net,nat,mac=${generateMac(id)}`,
|
|
133
|
+
"--device",
|
|
134
|
+
`virtio-serial,logFilePath=${consoleLog}`,
|
|
135
|
+
"--cloud-init",
|
|
136
|
+
`${join(instanceDir, "user-data")},${join(instanceDir, "meta-data")}`,
|
|
137
|
+
];
|
|
138
|
+
const child = spawn(VFKIT_PATH, args, {
|
|
139
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
140
|
+
detached: true,
|
|
141
|
+
});
|
|
142
|
+
if (!child.pid) {
|
|
143
|
+
throw new Error("Failed to start vfkit — no PID");
|
|
144
|
+
}
|
|
145
|
+
// Log stderr for debugging
|
|
146
|
+
child.stderr?.on("data", (data) => {
|
|
147
|
+
const text = data.toString().trim();
|
|
148
|
+
if (text)
|
|
149
|
+
console.log(chalk.dim(`[vfkit:${id}] ${text}`));
|
|
150
|
+
});
|
|
151
|
+
child.on("exit", (code) => {
|
|
152
|
+
vmLog.dim("vfkit", `VM "${id}" exited (code: ${code})`);
|
|
153
|
+
this.vms.delete(id);
|
|
154
|
+
});
|
|
155
|
+
const vm = {
|
|
156
|
+
process: child,
|
|
157
|
+
pid: child.pid,
|
|
158
|
+
guestIp: "",
|
|
159
|
+
vsockSocket,
|
|
160
|
+
instanceDir,
|
|
161
|
+
};
|
|
162
|
+
this.vms.set(id, vm);
|
|
163
|
+
// Discover guest IP and wait for SSH readiness
|
|
164
|
+
await this.waitForBoot(id);
|
|
165
|
+
vmLog.info("vfkit", `VM "${id}" running (PID: ${child.pid})`);
|
|
166
|
+
return {
|
|
167
|
+
id,
|
|
168
|
+
status: "RUNNING",
|
|
169
|
+
pid: child.pid,
|
|
170
|
+
vsockCid: 3, // vfkit uses vsock Unix socket, CID is implicit
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Discover the guest IP from the ARP table (NAT networking gives 192.168.64.x).
|
|
175
|
+
*/
|
|
176
|
+
async waitForBoot(id) {
|
|
177
|
+
const vm = this.vms.get(id);
|
|
178
|
+
if (!vm)
|
|
179
|
+
throw new Error(`VM "${id}" not tracked — cannot wait for boot`);
|
|
180
|
+
const maxWait = 120_000;
|
|
181
|
+
const interval = 3_000;
|
|
182
|
+
const start = Date.now();
|
|
183
|
+
let attempts = 0;
|
|
184
|
+
while (Date.now() - start < maxWait) {
|
|
185
|
+
attempts++;
|
|
186
|
+
const elapsed = Math.round((Date.now() - start) / 1000);
|
|
187
|
+
vmLog.dim("vfkit", `Waiting for VM "${id}" (attempt ${attempts}, ${elapsed}s elapsed)`);
|
|
188
|
+
// Get all candidate IPs from ARP table
|
|
189
|
+
const candidateIps = await this.discoverCandidateIps();
|
|
190
|
+
// Try SSH on each candidate with OUR key (filters out other VMs)
|
|
191
|
+
for (const ip of candidateIps) {
|
|
192
|
+
try {
|
|
193
|
+
await execFileAsync("ssh", [
|
|
194
|
+
"-o",
|
|
195
|
+
"StrictHostKeyChecking=no",
|
|
196
|
+
"-o",
|
|
197
|
+
"UserKnownHostsFile=/dev/null",
|
|
198
|
+
"-o",
|
|
199
|
+
"ConnectTimeout=2",
|
|
200
|
+
"-o",
|
|
201
|
+
"BatchMode=yes",
|
|
202
|
+
"-i",
|
|
203
|
+
join(vm.instanceDir, "ssh_key"),
|
|
204
|
+
`${SSH_USER}@${ip}`,
|
|
205
|
+
"echo ready",
|
|
206
|
+
], { timeout: 5_000 });
|
|
207
|
+
// SSH works with our key — this is our VM
|
|
208
|
+
vm.guestIp = ip;
|
|
209
|
+
vmLog.info("vfkit", `VM "${id}" ready (IP: ${ip})`);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
// This IP doesn't accept our key or isn't ready — try next
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
await new Promise((resolve) => setTimeout(resolve, interval));
|
|
217
|
+
}
|
|
218
|
+
throw new Error(`VM "${id}" boot timeout — SSH not ready after ${maxWait / 1000}s`);
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Get all candidate guest IPs from the ARP table (192.168.64.x range).
|
|
222
|
+
*/
|
|
223
|
+
async discoverCandidateIps() {
|
|
224
|
+
try {
|
|
225
|
+
const { stdout } = await execFileAsync("arp", ["-a"], { timeout: 5_000 });
|
|
226
|
+
const ips = [];
|
|
227
|
+
for (const match of stdout.matchAll(/\(192\.168\.64\.(\d+)\)/g)) {
|
|
228
|
+
const ip = `192.168.64.${match[1]}`;
|
|
229
|
+
if (ip !== "192.168.64.1" && ip !== "192.168.64.255") {
|
|
230
|
+
ips.push(ip);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return ips;
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
return [];
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
async exec(id, cmd, args) {
|
|
240
|
+
const vm = this.vms.get(id);
|
|
241
|
+
if (!vm)
|
|
242
|
+
throw new Error(`VM "${id}" not running`);
|
|
243
|
+
if (!vm.guestIp)
|
|
244
|
+
throw new Error(`VM "${id}" has no guest IP — boot may have failed`);
|
|
245
|
+
const cmdParts = args ? [cmd, ...args] : [cmd];
|
|
246
|
+
// Shell-escape each argument for SSH remote command
|
|
247
|
+
const escapedArgs = cmdParts
|
|
248
|
+
.map((a) => `'${a.replace(/'/g, "'\\''")}'`)
|
|
249
|
+
.join(" ");
|
|
250
|
+
try {
|
|
251
|
+
const { stdout, stderr } = await execFileAsync("ssh", [
|
|
252
|
+
"-A",
|
|
253
|
+
"-o",
|
|
254
|
+
"StrictHostKeyChecking=no",
|
|
255
|
+
"-o",
|
|
256
|
+
"UserKnownHostsFile=/dev/null",
|
|
257
|
+
"-i",
|
|
258
|
+
join(vm.instanceDir, "ssh_key"),
|
|
259
|
+
`${SSH_USER}@${vm.guestIp}`,
|
|
260
|
+
"--",
|
|
261
|
+
escapedArgs,
|
|
262
|
+
], { timeout: 600_000 });
|
|
263
|
+
return { stdout, stderr, exitCode: 0 };
|
|
264
|
+
}
|
|
265
|
+
catch (error) {
|
|
266
|
+
const execError = error;
|
|
267
|
+
return {
|
|
268
|
+
stdout: execError.stdout ?? "",
|
|
269
|
+
stderr: execError.stderr ?? String(error),
|
|
270
|
+
exitCode: execError.code ?? 1,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Spawn a long-lived process in the VM via SSH with piped stdout/stderr.
|
|
276
|
+
* Returns the SSH ChildProcess which satisfies AgentProcess.
|
|
277
|
+
*/
|
|
278
|
+
spawnStreaming(id, cmd, args, cwd, options) {
|
|
279
|
+
const vm = this.vms.get(id);
|
|
280
|
+
if (!vm)
|
|
281
|
+
throw new Error(`VM "${id}" not running`);
|
|
282
|
+
// Build remote command with cd to cwd
|
|
283
|
+
const escapedArgs = args
|
|
284
|
+
.map((a) => `'${a.replace(/'/g, "'\\''")}'`)
|
|
285
|
+
.join(" ");
|
|
286
|
+
const cmdStr = args.length > 0 ? `'${cmd}' ${escapedArgs}` : `'${cmd}'`;
|
|
287
|
+
// Pass ANTHROPIC_API_KEY if explicitly set (overrides everything else)
|
|
288
|
+
const apiKey = process.env.ANTHROPIC_API_KEY ?? "";
|
|
289
|
+
const envPrefix = apiKey ? `ANTHROPIC_API_KEY='${apiKey}' ` : "";
|
|
290
|
+
const remoteCmd = `${envPrefix}cd '${cwd}' && ${cmdStr}`;
|
|
291
|
+
if (!vm.guestIp)
|
|
292
|
+
throw new Error(`VM "${id}" has no guest IP`);
|
|
293
|
+
// Build SSH args with optional reverse tunnels for MCP bridge
|
|
294
|
+
const tunnelArgs = [];
|
|
295
|
+
for (const t of options?.reverseTunnels ?? []) {
|
|
296
|
+
tunnelArgs.push("-R", `${t.remotePort}:localhost:${t.localPort}`);
|
|
297
|
+
}
|
|
298
|
+
const sshProcess = spawn("ssh", [
|
|
299
|
+
"-A",
|
|
300
|
+
...tunnelArgs,
|
|
301
|
+
"-o",
|
|
302
|
+
"StrictHostKeyChecking=no",
|
|
303
|
+
"-o",
|
|
304
|
+
"UserKnownHostsFile=/dev/null",
|
|
305
|
+
"-o",
|
|
306
|
+
"ServerAliveInterval=30",
|
|
307
|
+
"-i",
|
|
308
|
+
join(vm.instanceDir, "ssh_key"),
|
|
309
|
+
`${SSH_USER}@${vm.guestIp}`,
|
|
310
|
+
"--",
|
|
311
|
+
remoteCmd,
|
|
312
|
+
], {
|
|
313
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
314
|
+
detached: false,
|
|
315
|
+
});
|
|
316
|
+
if (sshProcess.pid === undefined) {
|
|
317
|
+
throw new Error("Failed to spawn SSH process — no PID assigned");
|
|
318
|
+
}
|
|
319
|
+
return { pid: sshProcess.pid, process: sshProcess };
|
|
320
|
+
}
|
|
321
|
+
async stop(id) {
|
|
322
|
+
const vm = this.vms.get(id);
|
|
323
|
+
if (!vm)
|
|
324
|
+
return;
|
|
325
|
+
vmLog.cyan("vfkit", `Stopping VM "${id}"`);
|
|
326
|
+
try {
|
|
327
|
+
vm.process.kill("SIGTERM");
|
|
328
|
+
await new Promise((resolve) => {
|
|
329
|
+
const timeout = setTimeout(() => {
|
|
330
|
+
if (vm.process.exitCode === null)
|
|
331
|
+
vm.process.kill("SIGKILL");
|
|
332
|
+
resolve();
|
|
333
|
+
}, 10_000);
|
|
334
|
+
vm.process.once("exit", () => {
|
|
335
|
+
clearTimeout(timeout);
|
|
336
|
+
resolve();
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
catch {
|
|
341
|
+
// Best-effort
|
|
342
|
+
}
|
|
343
|
+
this.vms.delete(id);
|
|
344
|
+
vmLog.info("vfkit", `VM "${id}" stopped`);
|
|
345
|
+
}
|
|
346
|
+
async destroy(id) {
|
|
347
|
+
await this.stop(id);
|
|
348
|
+
const instanceDir = join(VM_BASE_DIR, id);
|
|
349
|
+
if (existsSync(instanceDir)) {
|
|
350
|
+
rmSync(instanceDir, { recursive: true, force: true });
|
|
351
|
+
}
|
|
352
|
+
vmLog.info("vfkit", `VM "${id}" destroyed`);
|
|
353
|
+
}
|
|
354
|
+
async getInfo(id) {
|
|
355
|
+
const vm = this.vms.get(id);
|
|
356
|
+
if (!vm)
|
|
357
|
+
return null;
|
|
358
|
+
return {
|
|
359
|
+
id,
|
|
360
|
+
status: vm.process.exitCode === null ? "RUNNING" : "STOPPED",
|
|
361
|
+
pid: vm.pid,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
getVsockPath(id) {
|
|
365
|
+
const vm = this.vms.get(id);
|
|
366
|
+
if (!vm)
|
|
367
|
+
throw new Error(`VM "${id}" not running — cannot get vsock path`);
|
|
368
|
+
return vm.vsockSocket;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
//# sourceMappingURL=vfkit.js.map
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type ChildProcess } from "node:child_process";
|
|
2
|
+
import type { VmConfig, VmInfo, VmProvider, ExecResult } from "../types.js";
|
|
3
|
+
/**
|
|
4
|
+
* WSL2 provider for Windows.
|
|
5
|
+
* Manages VM lifecycle using wsl CLI and Windows Hyper-V.
|
|
6
|
+
*/
|
|
7
|
+
export declare class Wsl2Provider implements VmProvider {
|
|
8
|
+
readonly name = "wsl2";
|
|
9
|
+
private vms;
|
|
10
|
+
private vmidMap;
|
|
11
|
+
isAvailable(): Promise<boolean>;
|
|
12
|
+
start(id: string, config: VmConfig): Promise<VmInfo>;
|
|
13
|
+
exec(id: string, cmd: string, args?: string[]): Promise<ExecResult>;
|
|
14
|
+
stop(id: string): Promise<void>;
|
|
15
|
+
destroy(id: string): Promise<void>;
|
|
16
|
+
getInfo(id: string): Promise<VmInfo | null>;
|
|
17
|
+
getVsockPath(id: string): string;
|
|
18
|
+
/**
|
|
19
|
+
* Spawn a long-lived process in the WSL2 distro with piped stdout/stderr.
|
|
20
|
+
*/
|
|
21
|
+
spawnStreaming(id: string, cmd: string, args: string[], cwd: string): {
|
|
22
|
+
pid: number;
|
|
23
|
+
process: ChildProcess;
|
|
24
|
+
};
|
|
25
|
+
private generateSetupScript;
|
|
26
|
+
}
|
|
27
|
+
//# sourceMappingURL=wsl2.d.ts.map
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { execFile, spawn } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import chalk from "chalk";
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
/**
|
|
9
|
+
* WSL2 provider for Windows.
|
|
10
|
+
* Manages VM lifecycle using wsl CLI and Windows Hyper-V.
|
|
11
|
+
*/
|
|
12
|
+
export class Wsl2Provider {
|
|
13
|
+
name = "wsl2";
|
|
14
|
+
vms = new Map();
|
|
15
|
+
vmidMap = new Map();
|
|
16
|
+
async isAvailable() {
|
|
17
|
+
try {
|
|
18
|
+
await execFileAsync("wsl", ["--status"]);
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
async start(id, config) {
|
|
26
|
+
const distroName = `forgeai-${id}`;
|
|
27
|
+
const sessionDir = config.sessionDir;
|
|
28
|
+
const rootfsDir = join(sessionDir, "rootfs");
|
|
29
|
+
mkdirSync(rootfsDir, { recursive: true, mode: 0o700 });
|
|
30
|
+
console.log(chalk.cyan(`[wsl2] Importing distro "${distroName}" from rootfs at ${config.rootfsPath}`));
|
|
31
|
+
try {
|
|
32
|
+
await execFileAsync("wsl", [
|
|
33
|
+
"--import",
|
|
34
|
+
distroName,
|
|
35
|
+
rootfsDir,
|
|
36
|
+
config.rootfsPath,
|
|
37
|
+
]);
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
console.error(chalk.red(`[wsl2] Failed to import distro: ${error instanceof Error ? error.message : String(error)}`));
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
const setupScript = this.generateSetupScript(config);
|
|
44
|
+
const setupPath = join(rootfsDir, ".setup.sh");
|
|
45
|
+
writeFileSync(setupPath, setupScript, { mode: 0o755 });
|
|
46
|
+
console.log(chalk.cyan(`[wsl2] Running setup script in "${distroName}"`));
|
|
47
|
+
try {
|
|
48
|
+
await execFileAsync("wsl", [
|
|
49
|
+
"-d",
|
|
50
|
+
distroName,
|
|
51
|
+
"--exec",
|
|
52
|
+
"bash",
|
|
53
|
+
"/.setup.sh",
|
|
54
|
+
]);
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
console.error(chalk.red(`[wsl2] Setup script failed: ${error instanceof Error ? error.message : String(error)}`));
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
const vmid = `vm-${id}`;
|
|
61
|
+
this.vms.set(id, {
|
|
62
|
+
distroName,
|
|
63
|
+
cid: 0,
|
|
64
|
+
vmid,
|
|
65
|
+
});
|
|
66
|
+
this.vmidMap.set(distroName, vmid);
|
|
67
|
+
console.log(chalk.green(`[wsl2] Distro "${distroName}" started successfully`));
|
|
68
|
+
return {
|
|
69
|
+
id,
|
|
70
|
+
status: "RUNNING",
|
|
71
|
+
vsockCid: 0,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
async exec(id, cmd, args) {
|
|
75
|
+
const vm = this.vms.get(id);
|
|
76
|
+
if (!vm) {
|
|
77
|
+
return {
|
|
78
|
+
stdout: "",
|
|
79
|
+
stderr: `Distro "${id}" not found in running WSL2 distros`,
|
|
80
|
+
exitCode: 1,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
const cmdParts = args ? [cmd, ...args] : [cmd];
|
|
84
|
+
try {
|
|
85
|
+
const { stdout, stderr } = await execFileAsync("wsl", [
|
|
86
|
+
"-d",
|
|
87
|
+
vm.distroName,
|
|
88
|
+
"--",
|
|
89
|
+
...cmdParts,
|
|
90
|
+
]);
|
|
91
|
+
return {
|
|
92
|
+
stdout,
|
|
93
|
+
stderr,
|
|
94
|
+
exitCode: 0,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
const execError = error;
|
|
99
|
+
return {
|
|
100
|
+
stdout: execError.stdout ?? "",
|
|
101
|
+
stderr: execError.stderr ?? String(error),
|
|
102
|
+
exitCode: execError.code ?? 1,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
async stop(id) {
|
|
107
|
+
const vm = this.vms.get(id);
|
|
108
|
+
if (!vm) {
|
|
109
|
+
console.warn(chalk.yellow(`[wsl2] Distro "${id}" not found`));
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
console.log(chalk.cyan(`[wsl2] Terminating distro "${vm.distroName}"`));
|
|
113
|
+
try {
|
|
114
|
+
await execFileAsync("wsl", ["--terminate", vm.distroName]);
|
|
115
|
+
console.log(chalk.green(`[wsl2] Distro "${vm.distroName}" terminated`));
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
console.error(chalk.red(`[wsl2] Failed to terminate distro: ${error instanceof Error ? error.message : String(error)}`));
|
|
119
|
+
throw error;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
async destroy(id) {
|
|
123
|
+
const vm = this.vms.get(id);
|
|
124
|
+
if (!vm) {
|
|
125
|
+
console.warn(chalk.yellow(`[wsl2] Distro "${id}" not found`));
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
console.log(chalk.cyan(`[wsl2] Unregistering distro "${vm.distroName}"`));
|
|
129
|
+
try {
|
|
130
|
+
await execFileAsync("wsl", ["--unregister", vm.distroName]);
|
|
131
|
+
console.log(chalk.green(`[wsl2] Distro "${vm.distroName}" unregistered`));
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
console.error(chalk.red(`[wsl2] Failed to unregister distro: ${error instanceof Error ? error.message : String(error)}`));
|
|
135
|
+
}
|
|
136
|
+
this.vms.delete(id);
|
|
137
|
+
this.vmidMap.delete(vm.distroName);
|
|
138
|
+
const instanceDir = join(homedir(), ".forgeai", "vm", "instances", id);
|
|
139
|
+
if (existsSync(instanceDir)) {
|
|
140
|
+
rmSync(instanceDir, { recursive: true, force: true });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
async getInfo(id) {
|
|
144
|
+
const vm = this.vms.get(id);
|
|
145
|
+
if (!vm)
|
|
146
|
+
return null;
|
|
147
|
+
try {
|
|
148
|
+
const { stdout } = await execFileAsync("wsl", ["--list", "--verbose"]);
|
|
149
|
+
const lines = stdout.split("\n");
|
|
150
|
+
const found = lines.some((line) => line.includes(vm.distroName));
|
|
151
|
+
return {
|
|
152
|
+
id,
|
|
153
|
+
status: found ? "RUNNING" : "STOPPED",
|
|
154
|
+
vsockCid: 0,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return {
|
|
159
|
+
id,
|
|
160
|
+
status: "ERROR",
|
|
161
|
+
vsockCid: 0,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
getVsockPath(id) {
|
|
166
|
+
const vm = this.vms.get(id);
|
|
167
|
+
if (!vm) {
|
|
168
|
+
throw new Error(`Distro "${id}" not found in running WSL2 distros`);
|
|
169
|
+
}
|
|
170
|
+
// WSL2 uses AF_HYPERV — exposed as TCP for Node.js compatibility
|
|
171
|
+
return `tcp:127.0.0.1:${6789 + vm.cid}`;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Spawn a long-lived process in the WSL2 distro with piped stdout/stderr.
|
|
175
|
+
*/
|
|
176
|
+
spawnStreaming(id, cmd, args, cwd) {
|
|
177
|
+
const vm = this.vms.get(id);
|
|
178
|
+
if (!vm)
|
|
179
|
+
throw new Error(`Distro "${id}" not running`);
|
|
180
|
+
// Build the command to run inside WSL with cd to cwd
|
|
181
|
+
const remoteCmd = args.length > 0
|
|
182
|
+
? `cd '${cwd}' && '${cmd}' ${args.map((a) => `'${a.replace(/'/g, "'\\''")}'`).join(" ")}`
|
|
183
|
+
: `cd '${cwd}' && '${cmd}'`;
|
|
184
|
+
const wslProcess = spawn("wsl", ["-d", vm.distroName, "--", "bash", "-c", remoteCmd], {
|
|
185
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
186
|
+
detached: false,
|
|
187
|
+
});
|
|
188
|
+
if (wslProcess.pid === undefined) {
|
|
189
|
+
throw new Error("Failed to spawn WSL process — no PID assigned");
|
|
190
|
+
}
|
|
191
|
+
return { pid: wslProcess.pid, process: wslProcess };
|
|
192
|
+
}
|
|
193
|
+
generateSetupScript(config) {
|
|
194
|
+
// Validate CIDR format and generate iptables rules
|
|
195
|
+
const cidrRegex = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\/\d{1,2}$/;
|
|
196
|
+
const rules = config.networkAllowlist
|
|
197
|
+
.filter((rule) => cidrRegex.test(rule.cidr))
|
|
198
|
+
.map((rule) => `iptables -A OUTPUT -p ${rule.protocol} -d ${rule.cidr} --dport ${rule.port} -j ACCEPT`)
|
|
199
|
+
.join("\n");
|
|
200
|
+
return `#!/bin/bash
|
|
201
|
+
set -e
|
|
202
|
+
|
|
203
|
+
apt-get update -qq
|
|
204
|
+
apt-get install -y -qq iptables openssh-server git
|
|
205
|
+
|
|
206
|
+
# Default policy: DROP all outbound
|
|
207
|
+
iptables -P OUTPUT DROP
|
|
208
|
+
iptables -P INPUT DROP
|
|
209
|
+
iptables -P FORWARD DROP
|
|
210
|
+
|
|
211
|
+
# Allow loopback
|
|
212
|
+
iptables -A OUTPUT -o lo -j ACCEPT
|
|
213
|
+
iptables -A INPUT -i lo -j ACCEPT
|
|
214
|
+
|
|
215
|
+
# Allow established/related connections
|
|
216
|
+
iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
|
|
217
|
+
iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
|
|
218
|
+
|
|
219
|
+
# Per-rule allowlist (CIDR + port + protocol)
|
|
220
|
+
${rules}
|
|
221
|
+
|
|
222
|
+
echo "[wsl2] Setup complete"
|
|
223
|
+
`;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
//# sourceMappingURL=wsl2.js.map
|