@pellux/goodvibes-tui 0.19.99 → 0.20.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.
@@ -9,6 +9,14 @@ import {
9
9
  type SandboxLaunchPlan,
10
10
  type SandboxProfile,
11
11
  } from '@pellux/goodvibes-sdk/platform/runtime/sandbox';
12
+ import {
13
+ renderQemuGuestBootstrapScript,
14
+ renderQemuImageCreateScript,
15
+ renderQemuSetupReadme,
16
+ renderQemuWrapperTemplate,
17
+ } from './sandbox-qemu-templates.ts';
18
+
19
+ export { renderQemuWrapperTemplate } from './sandbox-qemu-templates.ts';
12
20
 
13
21
  export interface SandboxGuestBundle {
14
22
  readonly version: 1;
@@ -22,6 +30,7 @@ export interface SandboxGuestBundle {
22
30
  readonly user: string;
23
31
  readonly workspacePath: string;
24
32
  readonly sessionMode: string;
33
+ readonly replJavaScriptCommand: string;
25
34
  };
26
35
  readonly nextSteps: readonly string[];
27
36
  }
@@ -39,6 +48,10 @@ export interface SandboxQemuSetupBundle extends SandboxQemuInitBundle {
39
48
  readonly guestBootstrapScriptPath: string;
40
49
  readonly projectionPolicyPath: string;
41
50
  readonly sshConfigPath: string;
51
+ readonly seedDirectory: string;
52
+ readonly seedIsoPath: string;
53
+ readonly sshKeyPath: string;
54
+ readonly sshPublicKeyPath: string;
42
55
  readonly manifestPath: string;
43
56
  }
44
57
 
@@ -51,8 +64,13 @@ export interface SandboxQemuSetupManifest {
51
64
  readonly guestBootstrapScriptPath: string;
52
65
  readonly projectionPolicyPath: string;
53
66
  readonly sshConfigPath: string;
67
+ readonly seedDirectory: string;
68
+ readonly seedIsoPath: string;
69
+ readonly sshKeyPath: string;
70
+ readonly sshPublicKeyPath: string;
54
71
  readonly recommendedSettings: {
55
72
  readonly backend: 'qemu';
73
+ readonly qemuBinary: string;
56
74
  readonly wrapperPath: string;
57
75
  readonly imagePath: string;
58
76
  readonly guestHost: string;
@@ -60,6 +78,7 @@ export interface SandboxQemuSetupManifest {
60
78
  readonly guestUser: string;
61
79
  readonly guestWorkspacePath: string;
62
80
  readonly sessionMode: string;
81
+ readonly replJavaScriptCommand: string;
63
82
  };
64
83
  }
65
84
 
@@ -71,21 +90,6 @@ export interface WritableConfigManagerLike extends ConfigManagerLike {
71
90
  setDynamic(key: string, value: unknown): void;
72
91
  }
73
92
 
74
- export function renderQemuWrapperTemplate(): string {
75
- return `#!/usr/bin/env bash
76
- set -euo pipefail
77
- mode="\${GV_SANDBOX_WRAPPER_MODE:-ssh-guest}"
78
- if [[ "$mode" == "host-exec" ]]; then
79
- exec "$@"
80
- fi
81
- host="\${GOODVIBES_QEMU_GUEST_HOST:-127.0.0.1}"
82
- port="\${GOODVIBES_QEMU_GUEST_PORT:-2222}"
83
- user="\${GOODVIBES_QEMU_GUEST_USER:-goodvibes}"
84
- workspace="\${GOODVIBES_QEMU_WORKSPACE:-/workspace}"
85
- exec ssh -o StrictHostKeyChecking=accept-new -p "$port" "$user@$host" "cd '$workspace' && exec \\"$@\\""
86
- `;
87
- }
88
-
89
93
  export function probeSandboxBackends(manager: ConfigManagerLike): SandboxBackendProbe {
90
94
  const host = detectSandboxHostStatus(manager);
91
95
  const config = getSandboxConfigSnapshot(manager);
@@ -155,15 +159,25 @@ export function buildSandboxLaunchPlan(
155
159
  throw new Error(`Requested QEMU sandbox backend is unavailable (${qemuAvailability?.detail ?? 'probe failed'}); refusing to downgrade to local process isolation. Set sandbox.vmBackend to "local" to use host-local isolation explicitly.`);
156
160
  }
157
161
  const guestPort = config.qemuGuestPort || 2222;
162
+ const wrapperDirectory = config.qemuExecWrapper ? dirname(config.qemuExecWrapper) : resolve(safeWorkspaceRoot, '.goodvibes/tui/sandbox');
163
+ const serialLog = resolve(wrapperDirectory, `logs/serial-${guestPort}.log`);
164
+ const monitorSocket = resolve(wrapperDirectory, `run/monitor-${guestPort}.sock`);
165
+ const seedIso = resolve(wrapperDirectory, 'seed/nocloud.iso');
166
+ ensureDir(dirname(serialLog));
167
+ ensureDir(dirname(monitorSocket));
158
168
  const args = [
159
169
  '-display', 'none',
160
- '-nodefaults',
161
170
  '-name', `gv-${profile.id}`,
162
- '-snapshot',
163
- '-m', '512',
164
- '-nic', `user,hostfwd=tcp::${guestPort}-:22`,
171
+ '-serial', `file:${serialLog}`,
172
+ '-monitor', `unix:${monitorSocket},server,nowait`,
173
+ '-m', '1024',
174
+ '-smp', '2',
175
+ '-netdev', `user,id=net0,hostfwd=tcp:127.0.0.1:${guestPort}-:22`,
176
+ '-device', 'virtio-net-pci,netdev=net0',
177
+ '-smbios', 'type=1,serial=ds=nocloud',
165
178
  ];
166
179
  if (config.qemuImagePath) args.push('-drive', `file=${config.qemuImagePath},if=virtio,format=qcow2`);
180
+ if (existsSync(seedIso)) args.push('-drive', `file=${seedIso},if=virtio,media=cdrom,readonly=on`);
167
181
  return {
168
182
  backend,
169
183
  command: config.qemuBinary || 'qemu-system-x86_64',
@@ -326,6 +340,70 @@ function ensureDir(path: string): void {
326
340
  mkdirSync(path, { recursive: true });
327
341
  }
328
342
 
343
+ function ensureQemuSshKey(directory: string): { readonly keyPath: string; readonly publicKeyPath: string; readonly publicKey: string } {
344
+ const keyPath = resolve(directory, 'keys/goodvibes_qemu_ed25519');
345
+ const publicKeyPath = `${keyPath}.pub`;
346
+ ensureDir(dirname(keyPath));
347
+ if (!existsSync(keyPath) || !existsSync(publicKeyPath)) {
348
+ const generated = spawnSync('ssh-keygen', ['-t', 'ed25519', '-N', '', '-C', 'goodvibes-qemu', '-f', keyPath], {
349
+ stdio: ['ignore', 'pipe', 'pipe'],
350
+ encoding: 'utf8',
351
+ windowsHide: true,
352
+ });
353
+ if (generated.status !== 0) {
354
+ writeFileSync(resolve(directory, 'keys/README.txt'), [
355
+ 'ssh-keygen was not available when this bundle was scaffolded.',
356
+ 'Generate the key manually before building the image:',
357
+ ` ssh-keygen -t ed25519 -N '' -C goodvibes-qemu -f ${keyPath}`,
358
+ '',
359
+ ].join('\n'));
360
+ }
361
+ }
362
+ const publicKey = existsSync(publicKeyPath) ? readFileSync(publicKeyPath, 'utf8').trim() : '';
363
+ return { keyPath, publicKeyPath, publicKey };
364
+ }
365
+
366
+ function renderQemuUserData(publicKey: string): string {
367
+ const authorizedKeys = publicKey
368
+ ? ` ssh_authorized_keys:\n - ${publicKey}\n`
369
+ : ' # Generate keys/goodvibes_qemu_ed25519.pub before rebuilding nocloud.iso.\n';
370
+ return `#cloud-config
371
+ users:
372
+ - default
373
+ - name: goodvibes
374
+ groups: [sudo]
375
+ shell: /bin/bash
376
+ sudo: ['ALL=(ALL) NOPASSWD:ALL']
377
+ lock_passwd: true
378
+ ${authorizedKeys}
379
+ ssh_pwauth: false
380
+ disable_root: true
381
+ manage_etc_hosts: true
382
+
383
+ bootcmd:
384
+ - [ sh, -c, 'systemctl disable systemd-networkd-wait-online.service || true' ]
385
+ - [ sh, -c, 'systemctl mask systemd-networkd-wait-online.service || true' ]
386
+
387
+ runcmd:
388
+ - [ mkdir, -p, /workspace ]
389
+ - [ chown, goodvibes:goodvibes, /workspace ]
390
+ - [ sh, -c, 'systemctl enable ssh ssh.service 2>/dev/null || true' ]
391
+ - [ sh, -c, 'systemctl restart ssh ssh.service 2>/dev/null || true' ]
392
+ `;
393
+ }
394
+
395
+ function renderQemuNetworkConfig(): string {
396
+ return `version: 2
397
+ ethernets:
398
+ ens3:
399
+ match:
400
+ name: "ens3"
401
+ dhcp4: true
402
+ dhcp6: false
403
+ optional: true
404
+ `;
405
+ }
406
+
329
407
  export function scaffoldSandboxQemuInitBundle(
330
408
  manager: ConfigManagerLike,
331
409
  workspaceRoot: string,
@@ -358,8 +436,9 @@ function buildGuestBundle(manager: ConfigManagerLike, _workspaceRoot: string, wr
358
436
  user: config.qemuGuestUser,
359
437
  workspacePath: config.qemuWorkspacePath,
360
438
  sessionMode: config.qemuSessionMode,
439
+ replJavaScriptCommand: config.replJavaScriptCommand,
361
440
  },
362
- nextSteps: ['Create or attach a QEMU guest, run the bootstrap script, then run /sandbox guest-test eval-js.'],
441
+ nextSteps: ['Create the QEMU image, provision guest runtimes, then run /sandbox guest-test eval-py.'],
363
442
  };
364
443
  }
365
444
 
@@ -376,7 +455,18 @@ export function scaffoldSandboxQemuSetupBundle(
376
455
  const projectionPolicyPath = resolve(init.directory, 'projection-policy.json');
377
456
  const sshConfigPath = resolve(init.directory, 'ssh-config');
378
457
  const manifestPath = resolve(init.directory, 'setup-manifest.json');
458
+ const seedDirectory = resolve(init.directory, 'seed');
459
+ const seedIsoPath = resolve(seedDirectory, 'nocloud.iso');
460
+ const logsDirectory = resolve(init.directory, 'logs');
461
+ const runDirectory = resolve(init.directory, 'run');
462
+ const imagesDirectory = resolve(init.directory, 'images');
379
463
  const config = getSandboxConfigSnapshot(manager);
464
+ const qemuBinary = config.qemuBinary || 'qemu-system-x86_64';
465
+ const guestPort = config.qemuGuestPort || 2222;
466
+ const guestUser = config.qemuGuestUser || 'goodvibes';
467
+ const guestWorkspacePath = config.qemuWorkspacePath || '/workspace';
468
+ const replJavaScriptCommand = `/home/${guestUser}/.bun/bin/bun`;
469
+ const sshKey = ensureQemuSshKey(init.directory);
380
470
  const manifest: SandboxQemuSetupManifest = {
381
471
  version: 1,
382
472
  createdAt: Date.now(),
@@ -386,23 +476,57 @@ export function scaffoldSandboxQemuSetupBundle(
386
476
  guestBootstrapScriptPath,
387
477
  projectionPolicyPath,
388
478
  sshConfigPath,
479
+ seedDirectory,
480
+ seedIsoPath,
481
+ sshKeyPath: sshKey.keyPath,
482
+ sshPublicKeyPath: sshKey.publicKeyPath,
389
483
  recommendedSettings: {
390
484
  backend: 'qemu',
485
+ qemuBinary,
391
486
  wrapperPath: init.wrapperPath,
392
487
  imagePath,
393
488
  guestHost: config.qemuGuestHost || '127.0.0.1',
394
- guestPort: config.qemuGuestPort || 2222,
395
- guestUser: config.qemuGuestUser || 'goodvibes',
396
- guestWorkspacePath: config.qemuWorkspacePath || '/workspace',
397
- sessionMode: config.qemuSessionMode || 'attach',
489
+ guestPort,
490
+ guestUser,
491
+ guestWorkspacePath,
492
+ sessionMode: 'launch-per-command',
493
+ replJavaScriptCommand,
398
494
  },
399
495
  };
400
- writeFileSync(imageCreateScriptPath, '#!/usr/bin/env bash\nset -euo pipefail\nqemu-img create -f qcow2 "$1" "${2:-20G}"\n', { mode: 0o755 });
401
- writeFileSync(guestBootstrapScriptPath, '#!/usr/bin/env bash\nset -euo pipefail\nmkdir -p /workspace\n');
496
+ ensureDir(seedDirectory);
497
+ ensureDir(logsDirectory);
498
+ ensureDir(runDirectory);
499
+ ensureDir(imagesDirectory);
500
+ writeFileSync(resolve(seedDirectory, 'meta-data'), 'instance-id: goodvibes-qemu-sandbox\nlocal-hostname: goodvibes-qemu\n');
501
+ writeFileSync(resolve(seedDirectory, 'user-data'), renderQemuUserData(sshKey.publicKey));
502
+ writeFileSync(resolve(seedDirectory, 'network-config'), renderQemuNetworkConfig());
503
+ writeFileSync(imageCreateScriptPath, renderQemuImageCreateScript(init.directory, imagePath, 20), { mode: 0o755 });
504
+ writeFileSync(guestBootstrapScriptPath, renderQemuGuestBootstrapScript(), { mode: 0o755 });
402
505
  writeFileSync(projectionPolicyPath, `${JSON.stringify({ version: 1, workspace: '/workspace' }, null, 2)}\n`);
403
- writeFileSync(sshConfigPath, 'Host goodvibes-qemu\n HostName 127.0.0.1\n Port 2222\n User goodvibes\n');
506
+ writeFileSync(sshConfigPath, [
507
+ 'Host goodvibes-qemu',
508
+ ' HostName 127.0.0.1',
509
+ ` Port ${guestPort}`,
510
+ ` User ${guestUser}`,
511
+ ` IdentityFile ${sshKey.keyPath}`,
512
+ ' StrictHostKeyChecking accept-new',
513
+ '',
514
+ ].join('\n'));
404
515
  writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
405
- return { ...init, imagePath, imageCreateScriptPath, guestBootstrapScriptPath, projectionPolicyPath, sshConfigPath, manifestPath };
516
+ writeFileSync(init.readmePath, renderQemuSetupReadme(init.directory, imagePath, seedIsoPath));
517
+ return {
518
+ ...init,
519
+ imagePath,
520
+ imageCreateScriptPath,
521
+ guestBootstrapScriptPath,
522
+ projectionPolicyPath,
523
+ sshConfigPath,
524
+ seedDirectory,
525
+ seedIsoPath,
526
+ sshKeyPath: sshKey.keyPath,
527
+ sshPublicKeyPath: sshKey.publicKeyPath,
528
+ manifestPath,
529
+ };
406
530
  }
407
531
 
408
532
  export function bootstrapSandboxQemuSetupBundle(
@@ -413,19 +537,13 @@ export function bootstrapSandboxQemuSetupBundle(
413
537
  options: SandboxProvisioningOptions,
414
538
  ): SandboxQemuSetupBundle {
415
539
  const bundle = scaffoldSandboxQemuSetupBundle(manager, workspaceRoot, pathArg, options);
416
- manager.setDynamic('sandbox.vmBackend', 'qemu');
417
- manager.setDynamic('sandbox.qemuExecWrapper', bundle.wrapperPath);
418
- manager.setDynamic('sandbox.qemuImagePath', bundle.imagePath);
540
+ const manifest = loadSandboxQemuSetupManifest(workspaceRoot, bundle.manifestPath);
541
+ applySandboxQemuSetupManifest(manager, manifest);
542
+ manager.setDynamic('sandbox.replIsolation', 'shared-vm');
543
+ manager.setDynamic('sandbox.mcpIsolation', 'shared-vm');
419
544
  return bundle;
420
545
  }
421
546
 
422
- export function createSandboxQemuImage(workspaceRoot: string, imagePathArg: string, sizeGb: number): { readonly path: string; readonly sizeGb: number } {
423
- const path = resolveWorkspacePath(workspaceRoot, imagePathArg);
424
- ensureDir(dirname(path));
425
- if (!existsSync(path)) writeFileSync(path, '');
426
- return { path, sizeGb };
427
- }
428
-
429
547
  export function inspectSandboxQemuSetupManifest(manifest: SandboxQemuSetupManifest): string {
430
548
  return [
431
549
  'QEMU sandbox setup manifest',
@@ -442,6 +560,7 @@ export function loadSandboxQemuSetupManifest(workspaceRoot: string, pathArg: str
442
560
 
443
561
  export function applySandboxQemuSetupManifest(manager: WritableConfigManagerLike, manifest: SandboxQemuSetupManifest): void {
444
562
  manager.setDynamic('sandbox.vmBackend', 'qemu');
563
+ manager.setDynamic('sandbox.qemuBinary', manifest.recommendedSettings.qemuBinary);
445
564
  manager.setDynamic('sandbox.qemuExecWrapper', manifest.recommendedSettings.wrapperPath);
446
565
  manager.setDynamic('sandbox.qemuImagePath', manifest.recommendedSettings.imagePath);
447
566
  manager.setDynamic('sandbox.qemuGuestHost', manifest.recommendedSettings.guestHost);
@@ -449,6 +568,7 @@ export function applySandboxQemuSetupManifest(manager: WritableConfigManagerLike
449
568
  manager.setDynamic('sandbox.qemuGuestUser', manifest.recommendedSettings.guestUser);
450
569
  manager.setDynamic('sandbox.qemuWorkspacePath', manifest.recommendedSettings.guestWorkspacePath);
451
570
  manager.setDynamic('sandbox.qemuSessionMode', manifest.recommendedSettings.sessionMode);
571
+ manager.setDynamic('sandbox.replJavaScriptCommand', manifest.recommendedSettings.replJavaScriptCommand);
452
572
  }
453
573
 
454
574
  export function exportSandboxGuestBundle(
@@ -0,0 +1,340 @@
1
+ import { resolve } from 'node:path';
2
+
3
+ function shellSingleQuote(value: string): string {
4
+ return `'${value.replace(/'/g, `'\\''`)}'`;
5
+ }
6
+
7
+ export function renderQemuWrapperTemplate(): string {
8
+ return `#!/usr/bin/env bash
9
+ set -euo pipefail
10
+
11
+ mode="\${GV_SANDBOX_WRAPPER_MODE:-\${GOODVIBES_QEMU_WRAPPER_MODE:-ssh-guest}}"
12
+ script_dir="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
13
+ host="\${GV_SANDBOX_GUEST_HOST:-\${GOODVIBES_QEMU_GUEST_HOST:-127.0.0.1}}"
14
+ port="\${GV_SANDBOX_GUEST_PORT:-\${GOODVIBES_QEMU_GUEST_PORT:-2222}}"
15
+ user="\${GV_SANDBOX_GUEST_USER:-\${GOODVIBES_QEMU_GUEST_USER:-goodvibes}}"
16
+ workspace="\${GV_SANDBOX_GUEST_WORKSPACE:-\${GOODVIBES_QEMU_WORKSPACE:-/workspace}}"
17
+ workspace_root="\${GV_SANDBOX_WORKSPACE_ROOT:-$PWD}"
18
+ qemu_bin="\${GV_SANDBOX_QEMU_BINARY:-\${GOODVIBES_QEMU_BINARY:-qemu-system-x86_64}}"
19
+ qemu_image="\${GV_SANDBOX_QEMU_IMAGE:-\${GOODVIBES_QEMU_IMAGE:-$script_dir/goodvibes-sandbox.qcow2}}"
20
+ ssh_key="\${GV_SANDBOX_SSH_KEY:-\${GOODVIBES_QEMU_SSH_KEY:-$script_dir/keys/goodvibes_qemu_ed25519}}"
21
+ known_hosts="\${GV_SANDBOX_KNOWN_HOSTS:-$script_dir/known_hosts}"
22
+ logs_dir="\${GV_SANDBOX_LOGS_DIR:-$script_dir/logs}"
23
+ run_dir="\${GV_SANDBOX_RUN_DIR:-$script_dir/run}"
24
+ seed_iso="\${GV_SANDBOX_SEED_ISO:-$script_dir/seed/nocloud.iso}"
25
+ ssh_timeout="\${GOODVIBES_QEMU_SSH_TIMEOUT:-300}"
26
+
27
+ mkdir -p "$logs_dir" "$run_dir"
28
+
29
+ ssh_opts=(-o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile="$known_hosts" -p "$port")
30
+ if [[ -f "$ssh_key" ]]; then
31
+ chmod 600 "$ssh_key" 2>/dev/null || true
32
+ ssh_opts=(-i "$ssh_key" "\${ssh_opts[@]}")
33
+ fi
34
+
35
+ shell_quote() {
36
+ printf '%q' "$1"
37
+ }
38
+
39
+ remote_command() {
40
+ local remote="export PATH=\\$HOME/.bun/bin:\\$HOME/.deno/bin:\\$HOME/.local/bin:\\$PATH; cd $(shell_quote "$workspace") && exec"
41
+ for arg in "$@"; do
42
+ remote+=" $(shell_quote "$arg")"
43
+ done
44
+ printf '%s' "$remote"
45
+ }
46
+
47
+ wait_for_ssh() {
48
+ local timeout="$1"
49
+ local start now
50
+ start="$(date +%s)"
51
+ while true; do
52
+ if ssh "\${ssh_opts[@]}" "$user@$host" "true" >/dev/null 2>&1; then
53
+ return 0
54
+ fi
55
+ now="$(date +%s)"
56
+ if (( now - start >= timeout )); then
57
+ echo "Timed out waiting for SSH on $host:$port" >&2
58
+ return 1
59
+ fi
60
+ sleep 2
61
+ done
62
+ }
63
+
64
+ sync_workspace() {
65
+ if [[ "\${GV_SANDBOX_SYNC_WORKSPACE:-1}" == "0" ]]; then
66
+ return 0
67
+ fi
68
+ tar -C "$workspace_root" \\
69
+ --exclude='./.git' \\
70
+ --exclude='./.goodvibes/tui/sandbox' \\
71
+ -cf - . \\
72
+ | ssh "\${ssh_opts[@]}" "$user@$host" "mkdir -p $(shell_quote "$workspace") && tar -C $(shell_quote "$workspace") -xf -"
73
+ }
74
+
75
+ run_guest_command() {
76
+ ssh "\${ssh_opts[@]}" "$user@$host" "$(remote_command "$@")"
77
+ }
78
+
79
+ start_qemu() {
80
+ if [[ ! -f "$qemu_image" ]]; then
81
+ echo "QEMU image not found: $qemu_image" >&2
82
+ return 1
83
+ fi
84
+ local pidfile="$run_dir/qemu-$port.pid"
85
+ local serial_log="$logs_dir/serial-$port.log"
86
+ local qemu_log="$logs_dir/qemu-$port.log"
87
+ local monitor_sock="$run_dir/monitor-$port.sock"
88
+ rm -f "$monitor_sock"
89
+ if [[ -f "$pidfile" ]]; then
90
+ local old_pid
91
+ old_pid="$(<"$pidfile")"
92
+ if [[ -n "$old_pid" ]] && kill -0 "$old_pid" 2>/dev/null; then
93
+ return 0
94
+ fi
95
+ rm -f "$pidfile"
96
+ fi
97
+ local qemu_args=(
98
+ -display none
99
+ -serial "file:$serial_log"
100
+ -monitor "unix:$monitor_sock,server,nowait"
101
+ -m "\${GOODVIBES_QEMU_MEMORY:-1024}"
102
+ -smp "\${GOODVIBES_QEMU_CPUS:-2}"
103
+ -drive "file=$qemu_image,if=virtio,format=qcow2"
104
+ -netdev "user,id=net0,hostfwd=tcp:127.0.0.1:$port-:22"
105
+ -device "virtio-net-pci,netdev=net0"
106
+ -smbios "type=1,serial=ds=nocloud"
107
+ )
108
+ if [[ -r "$seed_iso" ]]; then
109
+ qemu_args+=( -drive "file=$seed_iso,if=virtio,media=cdrom,readonly=on" )
110
+ fi
111
+ if [[ -e /dev/kvm && -r /dev/kvm && -w /dev/kvm && "\${GOODVIBES_QEMU_ENABLE_KVM:-1}" != "0" ]]; then
112
+ qemu_args=( -enable-kvm "\${qemu_args[@]}" )
113
+ fi
114
+ "$qemu_bin" "\${qemu_args[@]}" >"$qemu_log" 2>&1 &
115
+ echo "$!" > "$pidfile"
116
+ }
117
+
118
+ stop_qemu() {
119
+ local pidfile="$run_dir/qemu-$port.pid"
120
+ local pid=""
121
+ if [[ -f "$pidfile" ]]; then
122
+ pid="$(<"$pidfile")"
123
+ fi
124
+ if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then
125
+ kill "$pid" 2>/dev/null || true
126
+ for _ in {1..20}; do
127
+ kill -0 "$pid" 2>/dev/null || break
128
+ sleep 0.25
129
+ done
130
+ kill -9 "$pid" 2>/dev/null || true
131
+ fi
132
+ rm -f "$pidfile" "$run_dir/monitor-$port.sock"
133
+ }
134
+
135
+ if [[ "$mode" == "host-exec" ]]; then
136
+ exec "$@"
137
+ fi
138
+
139
+ if [[ "$mode" == "ssh-guest" ]]; then
140
+ wait_for_ssh "$ssh_timeout"
141
+ sync_workspace
142
+ run_guest_command "$@"
143
+ exit $?
144
+ fi
145
+
146
+ if [[ "$mode" == "launch-qemu-ssh" ]]; then
147
+ start_qemu
148
+ trap stop_qemu EXIT
149
+ wait_for_ssh "$ssh_timeout"
150
+ sync_workspace
151
+ run_guest_command "$@"
152
+ exit $?
153
+ fi
154
+
155
+ echo "Unknown GV_SANDBOX_WRAPPER_MODE: $mode" >&2
156
+ exit 2
157
+ `;
158
+ }
159
+
160
+ export function renderQemuImageCreateScript(directory: string, imagePath: string, sizeGb: number): string {
161
+ const baseImage = resolve(directory, 'images/debian-12-genericcloud-amd64.qcow2');
162
+ const seedIso = resolve(directory, 'seed/nocloud.iso');
163
+ return `#!/usr/bin/env bash
164
+ set -euo pipefail
165
+ SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
166
+ BASE_URL="\${GOODVIBES_QEMU_BASE_IMAGE_URL:-https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2}"
167
+ BASE_IMAGE="\${GOODVIBES_QEMU_BASE_IMAGE:-${baseImage}}"
168
+ TARGET_IMAGE="\${1:-${imagePath}}"
169
+ SIZE="\${2:-${sizeGb}G}"
170
+ SEED_DIR="$SCRIPT_DIR/seed"
171
+ SEED_ISO="\${GOODVIBES_QEMU_SEED_ISO:-${seedIso}}"
172
+
173
+ mkdir -p "$(dirname "$BASE_IMAGE")" "$(dirname "$TARGET_IMAGE")" "$SEED_DIR"
174
+
175
+ if [[ ! -f "$BASE_IMAGE" ]]; then
176
+ if command -v curl >/dev/null 2>&1; then
177
+ curl -fL "$BASE_URL" -o "$BASE_IMAGE"
178
+ elif command -v wget >/dev/null 2>&1; then
179
+ wget -O "$BASE_IMAGE" "$BASE_URL"
180
+ else
181
+ echo "curl or wget is required to download $BASE_URL" >&2
182
+ exit 1
183
+ fi
184
+ fi
185
+
186
+ cp "$BASE_IMAGE" "$TARGET_IMAGE"
187
+ qemu-img resize "$TARGET_IMAGE" "$SIZE" >/dev/null
188
+
189
+ if command -v xorriso >/dev/null 2>&1; then
190
+ ( cd "$SEED_DIR" && xorriso -as mkisofs -quiet -output "$SEED_ISO" -volid CIDATA -joliet -rock user-data meta-data network-config )
191
+ elif command -v genisoimage >/dev/null 2>&1; then
192
+ ( cd "$SEED_DIR" && genisoimage -quiet -output "$SEED_ISO" -volid CIDATA -joliet -rock user-data meta-data network-config )
193
+ elif command -v mkisofs >/dev/null 2>&1; then
194
+ ( cd "$SEED_DIR" && mkisofs -quiet -output "$SEED_ISO" -volid CIDATA -joliet -rock user-data meta-data network-config )
195
+ else
196
+ echo "xorriso, genisoimage, or mkisofs is required to build $SEED_ISO" >&2
197
+ exit 1
198
+ fi
199
+
200
+ chmod 600 "$SCRIPT_DIR/keys/goodvibes_qemu_ed25519" 2>/dev/null || true
201
+ echo "QEMU image ready: $TARGET_IMAGE"
202
+ echo "NoCloud seed ready: $SEED_ISO"
203
+ `;
204
+ }
205
+
206
+ export function renderQemuGuestBootstrapScript(): string {
207
+ return `#!/usr/bin/env bash
208
+ set -euo pipefail
209
+ export DEBIAN_FRONTEND=noninteractive
210
+
211
+ sudo apt-get update
212
+ sudo apt-get install -y \\
213
+ ca-certificates \\
214
+ curl \\
215
+ wget \\
216
+ git \\
217
+ jq \\
218
+ tar \\
219
+ unzip \\
220
+ xz-utils \\
221
+ build-essential \\
222
+ python3 \\
223
+ python3-pip \\
224
+ python3-venv \\
225
+ nodejs \\
226
+ npm \\
227
+ sqlite3 \\
228
+ postgresql-client \\
229
+ mariadb-client \\
230
+ openssh-server \\
231
+ ripgrep \\
232
+ fd-find \\
233
+ shellcheck \\
234
+ make \\
235
+ pkg-config \\
236
+ libssl-dev \\
237
+ python3-dev \\
238
+ golang \\
239
+ cargo \\
240
+ ruby \\
241
+ ruby-dev
242
+
243
+ if command -v fdfind >/dev/null 2>&1 && ! command -v fd >/dev/null 2>&1; then
244
+ sudo ln -sf /usr/bin/fdfind /usr/local/bin/fd
245
+ fi
246
+
247
+ if command -v npm >/dev/null 2>&1; then
248
+ sudo npm install -g typescript tsx ts-node graphql || true
249
+ sudo npm install -g graphql-cli || true
250
+ fi
251
+
252
+ if [[ "\${GOODVIBES_QEMU_INSTALL_BUN:-1}" == "1" ]]; then
253
+ curl -fsSL https://bun.sh/install | bash || true
254
+ fi
255
+
256
+ if [[ "\${GOODVIBES_QEMU_INSTALL_DENO:-1}" == "1" ]]; then
257
+ curl -fsSL https://deno.land/install.sh | sh || true
258
+ fi
259
+
260
+ if [[ "\${GOODVIBES_QEMU_INSTALL_UV:-1}" == "1" ]]; then
261
+ curl -LsSf https://astral.sh/uv/install.sh | sh || true
262
+ fi
263
+
264
+ if [[ "\${GOODVIBES_QEMU_INSTALL_DUCKDB:-1}" == "1" ]]; then
265
+ curl https://install.duckdb.org | sh || true
266
+ fi
267
+
268
+ if [[ -x "$HOME/.bun/bin/bun" ]]; then
269
+ sudo ln -sf "$HOME/.bun/bin/bun" /usr/local/bin/bun
270
+ fi
271
+ if [[ -x "$HOME/.deno/bin/deno" ]]; then
272
+ sudo ln -sf "$HOME/.deno/bin/deno" /usr/local/bin/deno
273
+ fi
274
+ if [[ -x "$HOME/.local/bin/uv" ]]; then
275
+ sudo ln -sf "$HOME/.local/bin/uv" /usr/local/bin/uv
276
+ fi
277
+ if [[ -x "$HOME/.duckdb/cli/latest/duckdb" ]]; then
278
+ sudo ln -sf "$HOME/.duckdb/cli/latest/duckdb" /usr/local/bin/duckdb
279
+ fi
280
+
281
+ grep -qxF 'export PATH="$HOME/.bun/bin:$HOME/.deno/bin:$HOME/.local/bin:$PATH"' "$HOME/.profile" 2>/dev/null \\
282
+ || echo 'export PATH="$HOME/.bun/bin:$HOME/.deno/bin:$HOME/.local/bin:$PATH"' >> "$HOME/.profile"
283
+
284
+ mkdir -p /workspace
285
+ sudo chown goodvibes:goodvibes /workspace
286
+
287
+ echo "GoodVibes guest bootstrap complete."
288
+ `;
289
+ }
290
+
291
+ export function renderQemuSetupReadme(directory: string, imagePath: string, seedIsoPath: string): string {
292
+ return `GoodVibes QEMU sandbox bootstrap bundle
293
+
294
+ Host prerequisites:
295
+ qemu-system-x86_64 qemu-img ssh ssh-keygen curl-or-wget xorriso-or-genisoimage
296
+ KVM is optional but strongly recommended: /dev/kvm
297
+
298
+ Generated files:
299
+ qemu-wrapper.sh launch/attach wrapper used by GoodVibes
300
+ create-image.sh downloads Debian 12 cloud image, clones it, resizes it, builds NoCloud ISO
301
+ guest-bootstrap.sh run inside the guest to install REPL/MCP-friendly runtimes
302
+ keys/goodvibes_qemu_ed25519 SSH key for goodvibes guest user
303
+ seed/user-data cloud-init user/bootstrap config
304
+ seed/meta-data cloud-init identity
305
+ seed/network-config cloud-init ens3 DHCP config
306
+ seed/nocloud.iso generated cloud-init ISO
307
+ logs/ QEMU and serial logs
308
+ run/ pid/socket runtime files
309
+
310
+ First-run workflow:
311
+ 1. Run: ${shellSingleQuote(resolve(directory, 'create-image.sh'))} ${shellSingleQuote(imagePath)} 20G
312
+ 2. GoodVibes settings should point to:
313
+ sandbox.vmBackend = qemu
314
+ sandbox.qemuBinary = qemu-system-x86_64
315
+ sandbox.qemuImagePath = ${imagePath}
316
+ sandbox.qemuExecWrapper = ${resolve(directory, 'qemu-wrapper.sh')}
317
+ sandbox.qemuGuestHost = 127.0.0.1
318
+ sandbox.qemuGuestPort = 2222
319
+ sandbox.qemuGuestUser = goodvibes
320
+ sandbox.qemuWorkspacePath = /workspace
321
+ sandbox.qemuSessionMode = launch-per-command
322
+ sandbox.replJavaScriptCommand = /home/goodvibes/.bun/bin/bun
323
+ 3. Provision guest runtimes:
324
+ GV_SANDBOX_SYNC_WORKSPACE=0 GV_SANDBOX_WRAPPER_MODE=launch-qemu-ssh ${shellSingleQuote(resolve(directory, 'qemu-wrapper.sh'))} bash -s < ${shellSingleQuote(resolve(directory, 'guest-bootstrap.sh'))}
325
+ 4. Run: /sandbox guest-test eval-py
326
+
327
+ Guest runtimes installed by guest-bootstrap.sh:
328
+ python3, pip, venv, nodejs, npm, typescript, tsx, ts-node, sqlite3,
329
+ postgresql-client, mariadb-client, GraphQL tools, Bun, Deno, uv,
330
+ DuckDB, Go, Rust/Cargo, Ruby, ripgrep, fd, shellcheck, make, pkg-config,
331
+ libssl-dev, and python3-dev.
332
+
333
+ Debugging:
334
+ serial log: ${resolve(directory, 'logs/serial-2222.log')}
335
+ qemu log: ${resolve(directory, 'logs/qemu-2222.log')}
336
+ seed ISO: ${seedIsoPath}
337
+
338
+ The wrapper accepts GOODVIBES_QEMU_SSH_TIMEOUT, defaulting to 300 seconds, because first boot cloud-init can be slow.
339
+ `;
340
+ }
package/src/version.ts CHANGED
@@ -6,7 +6,7 @@ import { join } from 'node:path';
6
6
  // The prebuild script updates the fallback value before compilation.
7
7
  // Uses import.meta.dir (Bun) to locate package.json relative to this file,
8
8
  // which is correct regardless of the process working directory.
9
- let _version = '0.19.99';
9
+ let _version = '0.20.1';
10
10
  try {
11
11
  const pkg = JSON.parse(readFileSync(join(import.meta.dir, '..', 'package.json'), 'utf-8'));
12
12
  _version = pkg.version ?? _version;