@pellux/goodvibes-tui 0.19.99 → 0.20.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.
- package/CHANGELOG.md +9 -0
- package/README.md +10 -4
- package/docs/foundation-artifacts/operator-contract.json +1 -1
- package/package.json +2 -2
- package/src/input/commands/local-setup-review.ts +4 -1
- package/src/input/commands/platform-sandbox-qemu.ts +17 -35
- package/src/input/commands/platform-sandbox-runtime.ts +4 -2
- package/src/renderer/settings-modal-helpers.ts +1 -0
- package/src/runtime/sandbox-public-gaps.ts +158 -38
- package/src/runtime/sandbox-qemu-templates.ts +340 -0
- package/src/version.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,15 @@ All notable changes to GoodVibes TUI.
|
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
+
## [0.20.0] — 2026-05-11
|
|
8
|
+
|
|
9
|
+
### Changes
|
|
10
|
+
- Updated `@pellux/goodvibes-sdk` to `0.33.30` for guest-side QEMU REPL execution and exec working-directory fixes.
|
|
11
|
+
- Added generated QEMU sandbox setup docs, runtime bootstrap scripts, cloud-init seed files, and guest runtime provisioning guidance.
|
|
12
|
+
- Wired `sandbox.replJavaScriptCommand` through generated QEMU setup manifests so JavaScript-family REPL snippets use the guest Bun runtime.
|
|
13
|
+
- Removed the obsolete `/sandbox qemu create-image` command in favor of the generated `create-image.sh` bootstrap workflow.
|
|
14
|
+
- Added focused coverage for QEMU setup scaffolding, command-level exec working directories, context auto-compaction thresholds, and retrospective setup-guide intent classification.
|
|
15
|
+
|
|
7
16
|
## [0.19.99] — 2026-05-12
|
|
8
17
|
|
|
9
18
|
### Changes
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://github.com/mgd34msu/goodvibes-tui/actions/workflows/ci.yml)
|
|
4
4
|
[](https://opensource.org/licenses/MIT)
|
|
5
|
-
[](https://github.com/mgd34msu/goodvibes-tui)
|
|
6
6
|
|
|
7
7
|
A terminal-native AI coding, operations, automation, knowledge, and integration console with a typed runtime, omnichannel surfaces, structured memory/knowledge, and a raw ANSI renderer.
|
|
8
8
|
|
|
@@ -626,13 +626,16 @@ The QEMU path includes:
|
|
|
626
626
|
|
|
627
627
|
- setup bundle generation
|
|
628
628
|
- first-run bootstrap scaffolding
|
|
629
|
-
-
|
|
629
|
+
- Debian cloud-image download, mutable qcow2 clone, resize, and NoCloud ISO creation through the generated `create-image.sh`
|
|
630
630
|
- host-side wrapper generation
|
|
631
|
+
- guest cloud-init seed generation for the `goodvibes` sudo user, SSH key auth, `/workspace`, and `ens3` DHCP
|
|
631
632
|
- guest-test and wrapper-test validation
|
|
632
633
|
- session-backed command execution
|
|
633
634
|
- guest bundle export / inspect flows
|
|
634
635
|
- setup manifest export / apply flows
|
|
635
636
|
- `attach` and `launch-per-command` execution modes
|
|
637
|
+
- REPL/MCP-friendly guest bootstrap packages for Python, JavaScript, TypeScript, SQL, GraphQL, Bun, Deno, DuckDB, Go, Rust, Ruby, and common CLI build/search tools
|
|
638
|
+
- guest JavaScript-family REPL execution through `sandbox.replJavaScriptCommand`, defaulting the generated QEMU setup to `/home/goodvibes/.bun/bin/bun`
|
|
636
639
|
|
|
637
640
|
Key commands:
|
|
638
641
|
|
|
@@ -643,7 +646,6 @@ Key commands:
|
|
|
643
646
|
- `/sandbox probe`
|
|
644
647
|
- `/sandbox qemu setup <dir>`
|
|
645
648
|
- `/sandbox qemu bootstrap <dir> [size-gb]`
|
|
646
|
-
- `/sandbox qemu create-image <path> [size-gb]`
|
|
647
649
|
- `/sandbox qemu inspect-setup <manifest>`
|
|
648
650
|
- `/sandbox qemu apply-setup <manifest>`
|
|
649
651
|
- `/sandbox session ...`
|
|
@@ -654,10 +656,14 @@ Typical first-run path:
|
|
|
654
656
|
|
|
655
657
|
```sh
|
|
656
658
|
/sandbox qemu bootstrap .goodvibes/tui/sandbox 20
|
|
659
|
+
.goodvibes/tui/sandbox/create-image.sh .goodvibes/tui/sandbox/goodvibes-sandbox.qcow2 20G
|
|
660
|
+
GV_SANDBOX_SYNC_WORKSPACE=0 GV_SANDBOX_WRAPPER_MODE=launch-qemu-ssh .goodvibes/tui/sandbox/qemu-wrapper.sh bash -s < .goodvibes/tui/sandbox/guest-bootstrap.sh
|
|
657
661
|
/sandbox doctor
|
|
658
|
-
/sandbox guest-test eval-
|
|
662
|
+
/sandbox guest-test eval-py
|
|
659
663
|
```
|
|
660
664
|
|
|
665
|
+
See [QEMU sandbox bootstrapping](docs/qemu-sandbox.md) for host prerequisites, generated files, first-boot behavior, guest runtime packages, and troubleshooting logs.
|
|
666
|
+
|
|
661
667
|
---
|
|
662
668
|
|
|
663
669
|
## Remote, Local Services, And Integration Helpers
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pellux/goodvibes-tui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.0",
|
|
4
4
|
"description": "Terminal-native GoodVibes product for coding, operations, automation, knowledge, channels, and daemon-backed control-plane workflows.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/main.ts",
|
|
@@ -97,7 +97,7 @@
|
|
|
97
97
|
"@anthropic-ai/vertex-sdk": "^0.16.0",
|
|
98
98
|
"@ast-grep/napi": "^0.42.0",
|
|
99
99
|
"@aws/bedrock-token-generator": "^1.1.0",
|
|
100
|
-
"@pellux/goodvibes-sdk": "0.33.
|
|
100
|
+
"@pellux/goodvibes-sdk": "0.33.30",
|
|
101
101
|
"bash-language-server": "^5.6.0",
|
|
102
102
|
"fuse.js": "^7.1.0",
|
|
103
103
|
"graphql": "^16.13.2",
|
|
@@ -155,10 +155,13 @@ export function renderSetupSandboxReview(ctx: CommandContext, snapshot: SetupRev
|
|
|
155
155
|
];
|
|
156
156
|
if (backend === 'local') {
|
|
157
157
|
lines.push(' /sandbox qemu bootstrap .goodvibes/tui/sandbox 20');
|
|
158
|
+
lines.push(' .goodvibes/tui/sandbox/create-image.sh .goodvibes/tui/sandbox/goodvibes-sandbox.qcow2 20G');
|
|
159
|
+
lines.push(' GV_SANDBOX_SYNC_WORKSPACE=0 GV_SANDBOX_WRAPPER_MODE=launch-qemu-ssh .goodvibes/tui/sandbox/qemu-wrapper.sh bash -s < .goodvibes/tui/sandbox/guest-bootstrap.sh');
|
|
158
160
|
lines.push(' /sandbox doctor');
|
|
159
161
|
} else if (!image || !wrapper) {
|
|
160
162
|
lines.push(' /sandbox qemu setup .goodvibes/tui/sandbox');
|
|
161
|
-
lines.push(' /sandbox
|
|
163
|
+
lines.push(' .goodvibes/tui/sandbox/create-image.sh .goodvibes/tui/sandbox/goodvibes-sandbox.qcow2 20G');
|
|
164
|
+
lines.push(' GV_SANDBOX_SYNC_WORKSPACE=0 GV_SANDBOX_WRAPPER_MODE=launch-qemu-ssh .goodvibes/tui/sandbox/qemu-wrapper.sh bash -s < .goodvibes/tui/sandbox/guest-bootstrap.sh');
|
|
162
165
|
lines.push(' /sandbox doctor');
|
|
163
166
|
} else {
|
|
164
167
|
lines.push(' /sandbox guest-test eval-js');
|
|
@@ -4,7 +4,6 @@ import type { CommandContext } from '../command-registry.ts';
|
|
|
4
4
|
import {
|
|
5
5
|
applySandboxQemuSetupManifest,
|
|
6
6
|
bootstrapSandboxQemuSetupBundle,
|
|
7
|
-
createSandboxQemuImage,
|
|
8
7
|
inspectSandboxQemuSetupManifest,
|
|
9
8
|
loadSandboxQemuSetupManifest,
|
|
10
9
|
scaffoldSandboxQemuSetupBundle,
|
|
@@ -27,29 +26,25 @@ export async function handleSandboxQemuCommand(args: string[], ctx: CommandConte
|
|
|
27
26
|
return true;
|
|
28
27
|
}
|
|
29
28
|
const bundle = scaffoldSandboxQemuSetupBundle(ctx.platform.configManager, shellPaths.workingDirectory, dirArg, { surfaceRoot: 'tui' });
|
|
30
|
-
|
|
31
|
-
ctx.platform.configManager
|
|
32
|
-
ctx.platform.configManager.setDynamic('sandbox.
|
|
33
|
-
|
|
34
|
-
ctx.platform.configManager.setDynamic('sandbox.qemuGuestHost', '127.0.0.1');
|
|
35
|
-
}
|
|
36
|
-
if (!`${ctx.platform.configManager.get('sandbox.qemuGuestUser') ?? ''}`.trim()) {
|
|
37
|
-
ctx.platform.configManager.setDynamic('sandbox.qemuGuestUser', 'goodvibes');
|
|
38
|
-
}
|
|
39
|
-
if (!`${ctx.platform.configManager.get('sandbox.qemuWorkspacePath') ?? ''}`.trim()) {
|
|
40
|
-
ctx.platform.configManager.setDynamic('sandbox.qemuWorkspacePath', '/workspace');
|
|
41
|
-
}
|
|
29
|
+
const manifest = loadSandboxQemuSetupManifest(shellPaths.workingDirectory, bundle.manifestPath);
|
|
30
|
+
applySandboxQemuSetupManifest(ctx.platform.configManager, manifest);
|
|
31
|
+
ctx.platform.configManager.setDynamic('sandbox.replIsolation', 'shared-vm');
|
|
32
|
+
ctx.platform.configManager.setDynamic('sandbox.mcpIsolation', 'shared-vm');
|
|
42
33
|
ctx.print([
|
|
43
34
|
`Initialized QEMU sandbox setup bundle in ${bundle.directory}`,
|
|
44
35
|
` wrapper: ${bundle.wrapperPath}`,
|
|
45
36
|
` image path: ${bundle.imagePath}`,
|
|
46
37
|
` image create script: ${bundle.imageCreateScriptPath}`,
|
|
47
38
|
` guest bootstrap: ${bundle.guestBootstrapScriptPath}`,
|
|
39
|
+
` seed directory: ${bundle.seedDirectory}`,
|
|
40
|
+
` seed ISO: ${bundle.seedIsoPath}`,
|
|
41
|
+
` ssh key: ${bundle.sshKeyPath}`,
|
|
48
42
|
` projection policy: ${bundle.projectionPolicyPath}`,
|
|
49
43
|
` ssh config: ${bundle.sshConfigPath}`,
|
|
50
44
|
` manifest: ${bundle.manifestPath}`,
|
|
51
|
-
' applied: backend=qemu, wrapper path, image path,
|
|
52
|
-
` next:
|
|
45
|
+
' applied: backend=qemu, qemu binary, wrapper path, image path, launch-per-command guest settings, shared VM isolation',
|
|
46
|
+
` next: ${bundle.imageCreateScriptPath} ${bundle.imagePath} 20G`,
|
|
47
|
+
` then provision runtimes: GV_SANDBOX_SYNC_WORKSPACE=0 GV_SANDBOX_WRAPPER_MODE=launch-qemu-ssh ${bundle.wrapperPath} bash -s < ${bundle.guestBootstrapScriptPath}`,
|
|
53
48
|
].join('\n'));
|
|
54
49
|
return true;
|
|
55
50
|
}
|
|
@@ -67,33 +62,20 @@ export async function handleSandboxQemuCommand(args: string[], ctx: CommandConte
|
|
|
67
62
|
` wrapper: ${bundle.wrapperPath}`,
|
|
68
63
|
` image path: ${bundle.imagePath}`,
|
|
69
64
|
` guest bootstrap: ${bundle.guestBootstrapScriptPath}`,
|
|
65
|
+
` seed ISO: ${bundle.seedIsoPath}`,
|
|
66
|
+
` ssh key: ${bundle.sshKeyPath}`,
|
|
70
67
|
` projection policy: ${bundle.projectionPolicyPath}`,
|
|
71
68
|
` manifest: ${bundle.manifestPath}`,
|
|
72
|
-
' applied: backend=qemu, wrapper path, image path,
|
|
73
|
-
|
|
69
|
+
' applied: backend=qemu, qemu binary, wrapper path, image path, launch-per-command guest settings, shared VM isolation',
|
|
70
|
+
` next: ${bundle.imageCreateScriptPath} ${bundle.imagePath} ${sizeGb}G`,
|
|
71
|
+
` then provision runtimes: GV_SANDBOX_SYNC_WORKSPACE=0 GV_SANDBOX_WRAPPER_MODE=launch-qemu-ssh ${bundle.wrapperPath} bash -s < ${bundle.guestBootstrapScriptPath}`,
|
|
72
|
+
' then: /sandbox guest-test eval-py',
|
|
74
73
|
].join('\n'));
|
|
75
74
|
} catch (error) {
|
|
76
75
|
ctx.print(summarizeError(error));
|
|
77
76
|
}
|
|
78
77
|
return true;
|
|
79
78
|
}
|
|
80
|
-
if (sub === 'create-image') {
|
|
81
|
-
const imagePath = args[2];
|
|
82
|
-
const sizeGb = Number.parseInt(args[3] ?? '20', 10);
|
|
83
|
-
if (!imagePath || !Number.isInteger(sizeGb) || sizeGb < 1) {
|
|
84
|
-
ctx.print('Usage: /sandbox qemu create-image <path> [size-gb]');
|
|
85
|
-
return true;
|
|
86
|
-
}
|
|
87
|
-
try {
|
|
88
|
-
const created = createSandboxQemuImage(shellPaths.workingDirectory, imagePath, sizeGb);
|
|
89
|
-
ctx.platform.configManager.setDynamic('sandbox.qemuImagePath', created.path);
|
|
90
|
-
ctx.platform.configManager.setDynamic('sandbox.vmBackend', 'qemu');
|
|
91
|
-
ctx.print(`Created QEMU image ${created.path} (${created.sizeGb}G).`);
|
|
92
|
-
} catch (error) {
|
|
93
|
-
ctx.print(summarizeError(error));
|
|
94
|
-
}
|
|
95
|
-
return true;
|
|
96
|
-
}
|
|
97
79
|
if (sub === 'recover') {
|
|
98
80
|
const sessionId = args[2];
|
|
99
81
|
if (!sessionId) {
|
|
@@ -132,6 +114,6 @@ export async function handleSandboxQemuCommand(args: string[], ctx: CommandConte
|
|
|
132
114
|
ctx.print(`Applied QEMU sandbox setup from ${shellPaths.resolveWorkspacePath(pathArg)}.`);
|
|
133
115
|
return true;
|
|
134
116
|
}
|
|
135
|
-
ctx.print('Usage: /sandbox qemu <setup <directory>|bootstrap <directory> [size-gb]|
|
|
117
|
+
ctx.print('Usage: /sandbox qemu <setup <directory>|bootstrap <directory> [size-gb]|recover <session-id>|inspect-setup <setup-manifest.json>|apply-setup <setup-manifest.json>>');
|
|
136
118
|
return true;
|
|
137
119
|
}
|
|
@@ -61,6 +61,7 @@ function applySandboxPreset(
|
|
|
61
61
|
configManager.setDynamic('sandbox.qemuGuestUser' as never, preset.config.qemuGuestUser);
|
|
62
62
|
configManager.setDynamic('sandbox.qemuWorkspacePath' as never, preset.config.qemuWorkspacePath);
|
|
63
63
|
configManager.setDynamic('sandbox.qemuSessionMode' as never, preset.config.qemuSessionMode);
|
|
64
|
+
configManager.setDynamic('sandbox.replJavaScriptCommand' as never, preset.config.replJavaScriptCommand);
|
|
64
65
|
return true;
|
|
65
66
|
}
|
|
66
67
|
|
|
@@ -83,6 +84,7 @@ function renderSandboxPresetDetail(presetId: string): string | null {
|
|
|
83
84
|
` qemu guest user: ${preset.config.qemuGuestUser || '(not configured)'}`,
|
|
84
85
|
` qemu workspace: ${preset.config.qemuWorkspacePath || '(not configured)'}`,
|
|
85
86
|
` qemu session mode: ${preset.config.qemuSessionMode}`,
|
|
87
|
+
` repl JavaScript command: ${preset.config.replJavaScriptCommand || 'bun'}`,
|
|
86
88
|
...preset.notes.map((note) => ` note: ${note}`),
|
|
87
89
|
].join('\n');
|
|
88
90
|
}
|
|
@@ -91,7 +93,7 @@ export function registerPlatformSandboxRuntimeCommands(registry: CommandRegistry
|
|
|
91
93
|
registry.register({
|
|
92
94
|
name: 'sandbox',
|
|
93
95
|
description: 'Review and configure VM isolation policy for MCP and evaluation runtimes',
|
|
94
|
-
usage: '[open|review|recommend|profiles|presets|preset <id>|apply-preset <id>|probe|doctor|wrapper-test <profile>|guest-test <profile>|init-qemu <dir>|qemu <setup|bootstrap|
|
|
96
|
+
usage: '[open|review|recommend|profiles|presets|preset <id>|apply-preset <id>|probe|doctor|wrapper-test <profile>|guest-test <profile>|init-qemu <dir>|qemu <setup|bootstrap|recover|inspect-setup|apply-setup> ...|session ...|bundle ...|guest-bundle <export|inspect> <path>|scaffold-qemu-wrapper <path>|set-mcp <mode>|set-repl <mode>|set-windows <mode>|set-backend <mode>|set-qemu-binary <path>|set-qemu-image <path>|set-qemu-wrapper <path>|set-qemu-guest-host <host>|set-qemu-guest-port <port>|set-qemu-guest-user <user>|set-qemu-workspace <path>|set-qemu-session-mode <attach|launch-per-command>]',
|
|
95
97
|
async handler(args, ctx) {
|
|
96
98
|
const shellPaths = requireShellPaths(ctx);
|
|
97
99
|
const sub = args[0] ?? 'open';
|
|
@@ -400,7 +402,7 @@ export function registerPlatformSandboxRuntimeCommands(registry: CommandRegistry
|
|
|
400
402
|
ctx.print([`Scaffolded QEMU wrapper to ${targetPath}`, ' bridge test mode: GV_SANDBOX_WRAPPER_MODE=host-exec', ' next: /sandbox set-qemu-wrapper ' + targetPath].join('\n'));
|
|
401
403
|
return;
|
|
402
404
|
}
|
|
403
|
-
ctx.print('Usage: /sandbox [open|review|recommend|profiles|presets|preset <id>|apply-preset <id>|probe|doctor|wrapper-test <profile>|guest-test <profile>|init-qemu <dir>|qemu <setup|bootstrap|
|
|
405
|
+
ctx.print('Usage: /sandbox [open|review|recommend|profiles|presets|preset <id>|apply-preset <id>|probe|doctor|wrapper-test <profile>|guest-test <profile>|init-qemu <dir>|qemu <setup|bootstrap|recover|inspect-setup|apply-setup> ...|session ...|bundle export <path>|bundle inspect <path>|guest-bundle <export|inspect> <path>|scaffold-qemu-wrapper <path>|set-mcp <mode>|set-repl <mode>|set-windows <mode>|set-backend <mode>|set-qemu-binary <path>|set-qemu-image <path>|set-qemu-wrapper <path>|set-qemu-guest-host <host>|set-qemu-guest-port <port>|set-qemu-guest-user <user>|set-qemu-workspace <path>|set-qemu-session-mode <attach|launch-per-command>]');
|
|
404
406
|
},
|
|
405
407
|
});
|
|
406
408
|
}
|
|
@@ -121,6 +121,7 @@ export const SETTING_LABELS: Partial<Record<string, string>> = {
|
|
|
121
121
|
'sandbox.qemuBinary': 'QEMU Binary',
|
|
122
122
|
'sandbox.qemuImagePath': 'QEMU Image',
|
|
123
123
|
'sandbox.qemuExecWrapper': 'QEMU Wrapper',
|
|
124
|
+
'sandbox.replJavaScriptCommand': 'REPL JS Command',
|
|
124
125
|
'tools.llmProvider': 'Tool LLM Provider',
|
|
125
126
|
'tools.llmModel': 'Tool LLM Model',
|
|
126
127
|
'tools.autoHeal': 'Auto-Heal',
|
|
@@ -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
|
-
'-
|
|
163
|
-
'-
|
|
164
|
-
'-
|
|
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
|
|
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
|
|
395
|
-
guestUser
|
|
396
|
-
guestWorkspacePath
|
|
397
|
-
sessionMode:
|
|
489
|
+
guestPort,
|
|
490
|
+
guestUser,
|
|
491
|
+
guestWorkspacePath,
|
|
492
|
+
sessionMode: 'launch-per-command',
|
|
493
|
+
replJavaScriptCommand,
|
|
398
494
|
},
|
|
399
495
|
};
|
|
400
|
-
|
|
401
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
417
|
-
manager
|
|
418
|
-
manager.setDynamic('sandbox.
|
|
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.
|
|
9
|
+
let _version = '0.20.0';
|
|
10
10
|
try {
|
|
11
11
|
const pkg = JSON.parse(readFileSync(join(import.meta.dir, '..', 'package.json'), 'utf-8'));
|
|
12
12
|
_version = pkg.version ?? _version;
|