automify 0.2.0 → 0.3.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.
@@ -0,0 +1,568 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { execFile, spawn as spawnProcess } from "node:child_process";
3
+ import { promisify } from "node:util";
4
+
5
+ import { CliAutomify } from "./cli-automify.js";
6
+ import { AutomifyError } from "./errors.js";
7
+ import { applyVirtualCliPreset } from "./presets.js";
8
+ import {
9
+ AUTOMIFY_OPTION_KEYS,
10
+ assertKnownOptions,
11
+ debugLog,
12
+ mergeOptionKeys,
13
+ normalizeLogFile,
14
+ writeDebugLogFile
15
+ } from "./runtime.js";
16
+ import { prepareVirtualSharedFolder } from "./virtual-shared-folder.js";
17
+ import {
18
+ buildQemuArgs,
19
+ defaultQemuCommand,
20
+ getAvailablePort,
21
+ installCommand,
22
+ mountSharedFolderCommand,
23
+ normalizeGuestPath,
24
+ prepareDefaultQemuImage,
25
+ positiveInteger,
26
+ shellQuote,
27
+ sshArgs,
28
+ stopQemuProcess,
29
+ uniquePackages,
30
+ waitForSsh
31
+ } from "./qemu-runtime.js";
32
+
33
+ const execFileAsync = promisify(execFile);
34
+
35
+ const DEFAULT_CWD = "/workspace";
36
+ const DEFAULT_TIMEOUT_MS = 30_000;
37
+ const DEFAULT_STARTUP_TIMEOUT_MS = 120_000;
38
+ const VIRTUAL_CLI_OPTION_KEYS = mergeOptionKeys(AUTOMIFY_OPTION_KEYS, [
39
+ "preset",
40
+ "command",
41
+ "commands",
42
+ "cwd",
43
+ "env",
44
+ "shell",
45
+ "timeoutMs",
46
+ "runner",
47
+ "confirmCommand",
48
+ "approval",
49
+ "allowedCommands",
50
+ "blockedCommands",
51
+ "instructions",
52
+ "logFile",
53
+ "session",
54
+ "vm",
55
+ "qemuCommand",
56
+ "qemuImgCommand",
57
+ "qemuImageCacheDir",
58
+ "qemuImageUrl",
59
+ "defaultImageCache",
60
+ "createCloudInitServer",
61
+ "image",
62
+ "diskImage",
63
+ "diskFormat",
64
+ "vmName",
65
+ "existingVM",
66
+ "keepVM",
67
+ "workdir",
68
+ "workspacePath",
69
+ "guestCwd",
70
+ "startupCommand",
71
+ "packages",
72
+ "additionalAptPackages",
73
+ "installDependencies",
74
+ "memory",
75
+ "cpus",
76
+ "accel",
77
+ "machine",
78
+ "cpu",
79
+ "firmware",
80
+ "network",
81
+ "networkDevice",
82
+ "extraQemuArgs",
83
+ "ssh",
84
+ "sshCommand",
85
+ "sshKeygenCommand",
86
+ "sshHost",
87
+ "sshPort",
88
+ "sshUser",
89
+ "sshKeyPath",
90
+ "sshOptions",
91
+ "sshTimeoutMs",
92
+ "sudo",
93
+ "shared",
94
+ "sharedFolder",
95
+ "sharedFiles",
96
+ "files",
97
+ "sharedMode",
98
+ "sharedTag",
99
+ "sharedSecurityModel",
100
+ "startupTimeoutMs",
101
+ "qemuTimeoutMs",
102
+ "commandMaxBuffer",
103
+ "execFile",
104
+ "spawn"
105
+ ]);
106
+ export const VIRTUAL_CLI_AUTOMIFY_OPTION_KEYS = VIRTUAL_CLI_OPTION_KEYS;
107
+
108
+ const QEMU_VM_KEYS = new Set([
109
+ "qemu",
110
+ "qemuCommand",
111
+ "qemuImgCommand",
112
+ "imageCacheDir",
113
+ "imageUrl",
114
+ "defaultImageCache",
115
+ "image",
116
+ "diskImage",
117
+ "diskFormat",
118
+ "name",
119
+ "existing",
120
+ "keep",
121
+ "memory",
122
+ "cpus",
123
+ "accel",
124
+ "machine",
125
+ "cpu",
126
+ "firmware",
127
+ "network",
128
+ "networkDevice",
129
+ "extraArgs",
130
+ "timeoutMs"
131
+ ]);
132
+ const QEMU_SSH_KEYS = new Set(["command", "host", "port", "user", "keyPath", "options", "timeoutMs", "sudo"]);
133
+
134
+ export function createVirtualCliAutomify(options = {}) {
135
+ options = normalizeVirtualCliOptions(options);
136
+ return new VirtualCliAutomify(options);
137
+ }
138
+
139
+ export class VirtualCliAutomify extends CliAutomify {
140
+ constructor(options = {}) {
141
+ options = normalizeVirtualCliOptions(options);
142
+ const session = options.session ?? new QemuCliSession(options);
143
+ super({
144
+ ...cliOptionsFromVirtualOptions(options),
145
+ cwd: options.cwd ?? session.cwd,
146
+ runner: options.runner ?? ((command, runOptions) => session.run(command, runOptions))
147
+ });
148
+ this.session = session;
149
+ }
150
+
151
+ get sharedFolder() {
152
+ return this.session.sharedFolder?.data;
153
+ }
154
+
155
+ async do(instruction, runOptions = {}, maybeOptions) {
156
+ await this.session.prepareSharedFolder();
157
+ if (runOptions && typeof runOptions === "object" && !Array.isArray(runOptions)) {
158
+ const data = runOptions.data;
159
+ const canAttachSharedFolder =
160
+ data && typeof data === "object" && !Array.isArray(data) && data.sharedFolder == null && this.sharedFolder;
161
+
162
+ if (canAttachSharedFolder) {
163
+ return super.do(
164
+ instruction,
165
+ {
166
+ ...runOptions,
167
+ data: {
168
+ ...data,
169
+ sharedFolder: this.sharedFolder
170
+ }
171
+ },
172
+ maybeOptions
173
+ );
174
+ }
175
+ }
176
+
177
+ return super.do(instruction, runOptions, maybeOptions);
178
+ }
179
+
180
+ async close() {
181
+ await this.session.close();
182
+ }
183
+ }
184
+
185
+ export class QemuCliSession {
186
+ constructor(options = {}) {
187
+ options = normalizeVirtualCliOptions(options);
188
+ this.options = options;
189
+ this.qemu = options.qemuCommand ?? defaultQemuCommand();
190
+ this.sshCommand = options.sshCommand ?? "ssh";
191
+ this.execFile = options.execFile ?? execFileAsync;
192
+ this.spawn = options.spawn ?? spawnProcess;
193
+ this.image = options.image;
194
+ this.extraQemuArgs = options.extraQemuArgs ?? [];
195
+ this.originalSshUser = options.sshUser;
196
+ this.originalSshKeyPath = options.sshKeyPath;
197
+ this.originalSudo = options.sudo;
198
+ this.defaultImage = null;
199
+ this.preparedPackages = new Set();
200
+ this.usesDefaultImage = !this.image && !options.existingVM;
201
+ this.name = options.vmName ?? `automify-vm-cli-${randomUUID()}`;
202
+ this.cwd = normalizeGuestPath(options.cwd, DEFAULT_CWD);
203
+ this.sshPort = positiveInteger(options.sshPort);
204
+ this.started = false;
205
+ this.created = false;
206
+ this.sharedFolder = null;
207
+ this.process = null;
208
+ }
209
+
210
+ async prepareSharedFolder() {
211
+ if (this.sharedFolder) return this.sharedFolder;
212
+ this.sharedFolder = await prepareVirtualSharedFolder(
213
+ { ...this.options, keepContainer: this.options.keepVM },
214
+ {
215
+ prefix: "automify-qemu-cli-",
216
+ containerPath: this.cwd
217
+ }
218
+ );
219
+ return this.sharedFolder;
220
+ }
221
+
222
+ async start() {
223
+ if (this.started) return;
224
+ await this.prepareSharedFolder();
225
+ if (!this.sshPort) this.sshPort = await getAvailablePort();
226
+
227
+ if (this.options.existingVM) {
228
+ debugVirtualCli(this.options, "use_existing_vm", { vmName: this.name, sshPort: this.sshPort });
229
+ this.started = true;
230
+ return;
231
+ }
232
+
233
+ try {
234
+ await this.prepareDefaultImage();
235
+ const args = buildQemuArgs({
236
+ ...this.options,
237
+ name: this.name,
238
+ image: this.image,
239
+ sshPort: this.sshPort,
240
+ sharedFolder: this.sharedFolder
241
+ });
242
+ debugVirtualCli(this.options, "vm_start", {
243
+ vmName: this.name,
244
+ qemu: this.qemu,
245
+ image: this.image,
246
+ sshPort: this.sshPort
247
+ });
248
+ this.process = this.spawn(this.qemu, args, {
249
+ stdio: "ignore"
250
+ });
251
+ this.process.unref?.();
252
+ this.created = true;
253
+ this.started = true;
254
+ await waitForSsh(this.execFile, this.sshCommand, this.sshOptions());
255
+ await this.runSsh(this.startupScript(), {
256
+ timeout: this.startupTimeoutMs()
257
+ });
258
+ debugVirtualCli(this.options, "vm_ready", { vmName: this.name });
259
+ } catch (error) {
260
+ await this.close();
261
+ throw new AutomifyError("QEMU virtual CLI did not become ready before startupTimeoutMs.", {
262
+ cause: error
263
+ });
264
+ }
265
+ }
266
+
267
+ async prepareDefaultImage() {
268
+ if (!this.usesDefaultImage || this.defaultImage) return;
269
+ debugVirtualCli(this.options, "default_image_prepare", {
270
+ vmName: this.name,
271
+ imageUrl: this.options.qemuImageUrl ?? process.env.AUTOMIFY_QEMU_DEFAULT_IMAGE_URL
272
+ });
273
+ const prepared = await prepareDefaultQemuImage({
274
+ execFile: this.execFile,
275
+ fetchImpl: this.options.fetchImpl,
276
+ cacheDir: this.options.qemuImageCacheDir,
277
+ imageUrl: this.options.qemuImageUrl,
278
+ defaultImageCache: this.options.defaultImageCache,
279
+ qemuImgCommand: this.options.qemuImgCommand,
280
+ qemuCommand: this.qemu,
281
+ memory: this.options.memory,
282
+ cpus: this.options.cpus,
283
+ accel: this.options.accel,
284
+ machine: this.options.machine,
285
+ cpu: this.options.cpu,
286
+ firmware: this.options.firmware,
287
+ networkDevice: this.options.networkDevice,
288
+ sshKeygenCommand: this.options.sshKeygenCommand,
289
+ sshCommand: this.sshCommand,
290
+ sshPort: this.sshPort,
291
+ sshTimeoutMs: this.options.sshTimeoutMs,
292
+ startupTimeoutMs: this.options.startupTimeoutMs,
293
+ timeoutMs: this.startupTimeoutMs(),
294
+ qemuTimeoutMs: this.options.qemuTimeoutMs,
295
+ createCloudInitServer: this.options.createCloudInitServer,
296
+ preparedPackages: this.options.installDependencies === false ? [] : this.dependencyPackages(),
297
+ spawn: this.spawn,
298
+ vmName: this.name
299
+ });
300
+ this.defaultImage = prepared;
301
+ this.preparedPackages = new Set(prepared.preparedPackages ?? []);
302
+ this.image = prepared.image;
303
+ this.options = {
304
+ ...this.options,
305
+ image: prepared.image,
306
+ diskFormat: this.options.diskFormat ?? prepared.diskFormat,
307
+ sshUser: prepared.sshUser,
308
+ sshKeyPath: prepared.sshKeyPath,
309
+ sudo: this.options.sudo ?? prepared.sudo,
310
+ extraQemuArgs: [...prepared.extraQemuArgs, ...this.extraQemuArgs]
311
+ };
312
+ debugVirtualCli(this.options, "default_image_ready", {
313
+ vmName: this.name,
314
+ image: prepared.image,
315
+ baseImage: prepared.baseImage
316
+ });
317
+ }
318
+
319
+ startupScript() {
320
+ const packages = this.dependencyPackages().filter((pkg) => !this.preparedPackages.has(pkg));
321
+ const startupCommand = this.options.startupCommand ?? ":";
322
+ return [
323
+ installCommand(packages, this.options),
324
+ mountSharedFolderCommand(this.sharedFolder, this.options),
325
+ `${this.options.sudo ? "sudo -n " : ""}mkdir -p ${shellQuote(this.cwd)}`,
326
+ startupCommand
327
+ ].join(" && ");
328
+ }
329
+
330
+ dependencyPackages() {
331
+ return uniquePackages([...(this.options.packages ?? []), ...(this.options.additionalAptPackages ?? [])]);
332
+ }
333
+
334
+ startupTimeoutMs() {
335
+ return positiveInteger(this.options.startupTimeoutMs) ?? DEFAULT_STARTUP_TIMEOUT_MS;
336
+ }
337
+
338
+ async run(command, options = {}) {
339
+ await this.start();
340
+ const cwd = normalizeGuestPath(options.cwd ?? this.cwd, this.cwd);
341
+ const timeoutMs =
342
+ positiveInteger(options.timeoutMs) ?? positiveInteger(this.options.timeoutMs) ?? DEFAULT_TIMEOUT_MS;
343
+ const envPrefix = Object.entries(options.env ?? {})
344
+ .map(([key, value]) => `${shellEnvName(key)}=${shellQuote(value)}`)
345
+ .join(" ");
346
+ const remoteCommand = [
347
+ `cd ${shellQuote(cwd)}`,
348
+ envPrefix ? `${envPrefix} sh -lc ${shellQuote(command)}` : `sh -lc ${shellQuote(command)}`
349
+ ].join(" && ");
350
+
351
+ debugVirtualCli(this.options, "command", { command: { command, cwd, timeoutMs } });
352
+ try {
353
+ const { stdout, stderr } = await this.runSsh(remoteCommand, {
354
+ timeout: timeoutMs,
355
+ maxBuffer: this.options.commandMaxBuffer ?? 10 * 1024 * 1024
356
+ });
357
+ const result = {
358
+ command,
359
+ cwd,
360
+ exitCode: 0,
361
+ stdout: String(stdout ?? ""),
362
+ stderr: String(stderr ?? ""),
363
+ timedOut: false
364
+ };
365
+ debugVirtualCli(this.options, "command_result", summarizeCommandResult(result));
366
+ return result;
367
+ } catch (error) {
368
+ const result = {
369
+ command,
370
+ cwd,
371
+ exitCode: typeof error.code === "number" ? error.code : null,
372
+ signal: error.signal,
373
+ stdout: String(error.stdout ?? ""),
374
+ stderr: String(error.stderr || error.message || ""),
375
+ timedOut: error.killed === true || error.signal === "SIGTERM"
376
+ };
377
+ debugVirtualCli(this.options, "command_result", summarizeCommandResult(result));
378
+ return result;
379
+ }
380
+ }
381
+
382
+ async runSsh(command, options = {}) {
383
+ return this.execFile(this.sshCommand, sshArgs(this.sshOptions(), command), options);
384
+ }
385
+
386
+ sshOptions() {
387
+ return {
388
+ ...this.options,
389
+ sshPort: this.sshPort
390
+ };
391
+ }
392
+
393
+ async close() {
394
+ if (this.created && !this.options.existingVM && !this.options.keepVM) {
395
+ debugVirtualCli(this.options, "vm_close", { vmName: this.name });
396
+ await stopQemuProcess(this.process, positiveInteger(this.options.qemuTimeoutMs) ?? 1500);
397
+ }
398
+ await this.defaultImage?.close();
399
+ this.defaultImage = null;
400
+ this.preparedPackages = new Set();
401
+ if (this.usesDefaultImage) {
402
+ this.image = null;
403
+ this.options = {
404
+ ...this.options,
405
+ image: undefined,
406
+ sshUser: this.originalSshUser,
407
+ sshKeyPath: this.originalSshKeyPath,
408
+ sudo: this.originalSudo,
409
+ extraQemuArgs: this.extraQemuArgs
410
+ };
411
+ }
412
+ await this.sharedFolder?.close();
413
+ this.started = false;
414
+ this.created = false;
415
+ }
416
+ }
417
+
418
+ export const QemuVirtualCliSession = QemuCliSession;
419
+
420
+ function normalizeVirtualCliOptions(options = {}) {
421
+ assertKnownOptions("QEMU virtual CLI adapter", options, VIRTUAL_CLI_OPTION_KEYS);
422
+ assertKnownOptions("QEMU virtual CLI vm", options.vm, QEMU_VM_KEYS);
423
+ assertKnownOptions("QEMU virtual CLI ssh", options.ssh, QEMU_SSH_KEYS);
424
+ options = applyVirtualCliPreset(options);
425
+ const vm = options.vm ?? {};
426
+ const ssh = options.ssh ?? {};
427
+ const cwd = options.cwd ?? options.workdir ?? options.workspacePath ?? options.guestCwd;
428
+
429
+ return {
430
+ ...options,
431
+ debug: options.debug ?? false,
432
+ logFile: normalizeLogFile(options.logFile, "QEMU virtual CLI logFile"),
433
+ qemuCommand: options.qemuCommand ?? vm.qemuCommand ?? vm.qemu,
434
+ qemuImgCommand: options.qemuImgCommand ?? vm.qemuImgCommand ?? vm.imgCommand,
435
+ qemuImageCacheDir: options.qemuImageCacheDir ?? vm.imageCacheDir,
436
+ qemuImageUrl: options.qemuImageUrl ?? vm.imageUrl,
437
+ defaultImageCache: options.defaultImageCache ?? vm.defaultImageCache,
438
+ image: options.image ?? options.diskImage ?? vm.image ?? vm.diskImage ?? process.env.AUTOMIFY_QEMU_IMAGE,
439
+ diskFormat: options.diskFormat ?? vm.diskFormat,
440
+ vmName: options.vmName ?? vm.name,
441
+ existingVM: options.existingVM ?? vm.existing,
442
+ keepVM: options.keepVM ?? vm.keep,
443
+ memory: options.memory ?? vm.memory,
444
+ cpus: options.cpus ?? vm.cpus,
445
+ accel: options.accel ?? vm.accel,
446
+ machine: options.machine ?? vm.machine,
447
+ cpu: options.cpu ?? vm.cpu,
448
+ firmware: options.firmware ?? vm.firmware,
449
+ network: options.network ?? vm.network,
450
+ networkDevice: options.networkDevice ?? vm.networkDevice,
451
+ extraQemuArgs: options.extraQemuArgs ?? vm.extraArgs,
452
+ qemuTimeoutMs: options.qemuTimeoutMs ?? vm.timeoutMs,
453
+ sshCommand: options.sshCommand ?? ssh.command,
454
+ sshKeygenCommand: options.sshKeygenCommand,
455
+ sshHost: options.sshHost ?? ssh.host,
456
+ sshPort: options.sshPort ?? ssh.port,
457
+ sshUser: options.sshUser ?? ssh.user,
458
+ sshKeyPath: options.sshKeyPath ?? ssh.keyPath,
459
+ sshOptions: options.sshOptions ?? ssh.options,
460
+ sshTimeoutMs: options.sshTimeoutMs ?? ssh.timeoutMs,
461
+ sudo: options.sudo ?? ssh.sudo,
462
+ cwd,
463
+ additionalAptPackages: options.additionalAptPackages,
464
+ sharedFolder: options.sharedFolder ?? options.shared,
465
+ files: options.files ?? options.sharedFiles
466
+ };
467
+ }
468
+
469
+ function cliOptionsFromVirtualOptions(options) {
470
+ const {
471
+ openaiApiKey,
472
+ client,
473
+ model,
474
+ baseURL,
475
+ fetchImpl,
476
+ maxSteps,
477
+ limits,
478
+ request,
479
+ requestOptions,
480
+ command,
481
+ commands,
482
+ cwd,
483
+ env,
484
+ shell,
485
+ timeoutMs,
486
+ runner,
487
+ confirmCommand,
488
+ approval,
489
+ allowedCommands,
490
+ blockedCommands,
491
+ instructions,
492
+ hooks,
493
+ onStep,
494
+ onRequest,
495
+ onResponse,
496
+ onComplete,
497
+ debug,
498
+ logFile,
499
+ silent,
500
+ reasoning,
501
+ safetyIdentifier,
502
+ preset
503
+ } = options;
504
+
505
+ return {
506
+ openaiApiKey,
507
+ client,
508
+ model,
509
+ baseURL,
510
+ fetchImpl,
511
+ maxSteps,
512
+ limits,
513
+ request,
514
+ requestOptions,
515
+ command,
516
+ commands,
517
+ cwd,
518
+ env,
519
+ shell,
520
+ timeoutMs,
521
+ runner,
522
+ confirmCommand,
523
+ approval,
524
+ allowedCommands,
525
+ blockedCommands,
526
+ instructions,
527
+ hooks,
528
+ onStep,
529
+ onRequest,
530
+ onResponse,
531
+ onComplete,
532
+ debug,
533
+ logFile,
534
+ silent,
535
+ reasoning,
536
+ safetyIdentifier,
537
+ preset
538
+ };
539
+ }
540
+
541
+ function debugVirtualCli(options, message, details) {
542
+ writeDebugLogFile(options.logFile, "automify:qemu-cli", message, details, { silent: options.silent });
543
+ debugLog(options.debug, "automify:qemu-cli", message, details, { silent: options.silent });
544
+ }
545
+
546
+ function shellEnvName(value) {
547
+ const key = String(value ?? "");
548
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
549
+ throw new AutomifyError(`Invalid environment variable name: ${key}`);
550
+ }
551
+ return key;
552
+ }
553
+
554
+ function summarizeCommandResult(result) {
555
+ return {
556
+ command: {
557
+ command: result.command,
558
+ cwd: result.cwd
559
+ },
560
+ exitCode: result.exitCode,
561
+ signal: result.signal,
562
+ timedOut: result.timedOut,
563
+ stdout: result.stdout,
564
+ stderr: result.stderr,
565
+ stdoutLength: typeof result.stdout === "string" ? result.stdout.length : undefined,
566
+ stderrLength: typeof result.stderr === "string" ? result.stderr.length : undefined
567
+ };
568
+ }