automify 0.2.0 → 0.3.0

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