Skip to content

Commit 15e9b38

Browse files
dpsoftcursoragent
andcommitted
feat: Native WriteFile protocol, skill-aware claudio, and E2E skill pipeline tests
Replace shell-based write_file (sh + base64) with native WriteFile/MkdirP protocol messages handled directly by the guest-agent in Rust. This removes the dependency on BusyBox/shell binaries in the guest initramfs. - Add WriteFile (11), WriteFileResponse (12), MkdirP (13), MkdirPResponse (14) message types to void-box-protocol - Guest-agent handles file writes as root with auto-chown to uid 1000 - Extend claudio to discover provisioned skills (/root/.claude/skills/*.md) and MCP config (mcp.json), report in output, simulate MCP tool calls - Add 6 E2E KVM tests verifying skill provisioning end-to-end Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent b2464ca commit 15e9b38

File tree

8 files changed

+1098
-50
lines changed

8 files changed

+1098
-50
lines changed

claudio/src/main.rs

Lines changed: 291 additions & 38 deletions
Large diffs are not rendered by default.

guest-agent/src/main.rs

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ use serde::Serialize;
1616
use void_box_protocol::{
1717
ExecRequest, ExecResponse, MessageType,
1818
TelemetryBatch, SystemMetrics, ProcessMetrics,
19+
WriteFileRequest, WriteFileResponse,
20+
MkdirPRequest, MkdirPResponse,
1921
};
2022

2123
/// vsock port we listen on
@@ -432,6 +434,20 @@ fn handle_connection(fd: RawFd) -> Result<(), String> {
432434
telemetry_stream_loop(fd);
433435
return Ok(());
434436
}
437+
11 => {
438+
// WriteFile - native file write (no shell/base64 needed)
439+
let request: WriteFileRequest = serde_json::from_slice(&payload)
440+
.map_err(|e| format!("Failed to parse WriteFileRequest: {}", e))?;
441+
let response = handle_write_file(&request);
442+
send_response(fd, MessageType::WriteFileResponse, &response)?;
443+
}
444+
13 => {
445+
// MkdirP - create directories
446+
let request: MkdirPRequest = serde_json::from_slice(&payload)
447+
.map_err(|e| format!("Failed to parse MkdirPRequest: {}", e))?;
448+
let response = handle_mkdir_p(&request);
449+
send_response(fd, MessageType::MkdirPResponse, &response)?;
450+
}
435451
_ => {
436452
eprintln!("Unknown message type: {}", msg_type);
437453
}
@@ -586,6 +602,94 @@ fn send_response<T: Serialize>(fd: RawFd, msg_type: MessageType, payload: &T) ->
586602
Ok(())
587603
}
588604

605+
// ---------------------------------------------------------------------------
606+
// Native file operations (no shell/base64 required)
607+
// ---------------------------------------------------------------------------
608+
609+
/// Handle a WriteFile request: write content directly to the guest filesystem.
610+
/// Runs as root (no privilege drop) since this is host-initiated provisioning.
611+
/// After writing, the file and its parent directories are chowned to uid 1000
612+
/// so the sandbox user can read them (e.g., when claudio runs as uid 1000).
613+
fn handle_write_file(request: &WriteFileRequest) -> WriteFileResponse {
614+
// Create parent directories if requested
615+
if request.create_parents {
616+
if let Some(parent) = std::path::Path::new(&request.path).parent() {
617+
if let Err(e) = std::fs::create_dir_all(parent) {
618+
return WriteFileResponse {
619+
success: false,
620+
error: Some(format!("Failed to create parent dirs {}: {}", parent.display(), e)),
621+
};
622+
}
623+
// Make parent directories readable by sandbox user (uid 1000)
624+
chown_recursive(parent);
625+
}
626+
}
627+
628+
// Write the file content
629+
match std::fs::write(&request.path, &request.content) {
630+
Ok(()) => {
631+
// Make the file readable by sandbox user (uid 1000)
632+
let c_path = std::ffi::CString::new(request.path.as_str()).unwrap_or_default();
633+
unsafe {
634+
libc::chown(c_path.as_ptr(), 1000, 1000);
635+
// Ensure file is world-readable
636+
libc::chmod(c_path.as_ptr(), 0o644);
637+
}
638+
kmsg(&format!("Wrote {} bytes to {}", request.content.len(), request.path));
639+
WriteFileResponse {
640+
success: true,
641+
error: None,
642+
}
643+
}
644+
Err(e) => WriteFileResponse {
645+
success: false,
646+
error: Some(format!("Failed to write {}: {}", request.path, e)),
647+
},
648+
}
649+
}
650+
651+
/// Recursively chown a path and its parents to uid 1000:1000.
652+
/// Only affects directories that are owned by root.
653+
fn chown_recursive(path: &std::path::Path) {
654+
let mut current = path.to_path_buf();
655+
loop {
656+
if let Ok(c_path) = std::ffi::CString::new(current.to_string_lossy().as_ref()) {
657+
unsafe {
658+
libc::chown(c_path.as_ptr(), 1000, 1000);
659+
// Ensure directory is traversable
660+
libc::chmod(c_path.as_ptr(), 0o755);
661+
}
662+
}
663+
match current.parent() {
664+
Some(p) if p != current && p.to_string_lossy() != "/" => {
665+
current = p.to_path_buf();
666+
}
667+
_ => break,
668+
}
669+
}
670+
}
671+
672+
/// Handle a MkdirP request: create directories recursively.
673+
/// Runs as root (no privilege drop) since this is host-initiated provisioning.
674+
/// After creating, directories are chowned to uid 1000 so the sandbox user
675+
/// can access them.
676+
fn handle_mkdir_p(request: &MkdirPRequest) -> MkdirPResponse {
677+
match std::fs::create_dir_all(&request.path) {
678+
Ok(()) => {
679+
chown_recursive(std::path::Path::new(&request.path));
680+
kmsg(&format!("Created directory {}", request.path));
681+
MkdirPResponse {
682+
success: true,
683+
error: None,
684+
}
685+
}
686+
Err(e) => MkdirPResponse {
687+
success: false,
688+
error: Some(format!("Failed to create directory {}: {}", request.path, e)),
689+
},
690+
}
691+
}
692+
589693
// ---------------------------------------------------------------------------
590694
// Telemetry: procfs parsing and streaming
591695
// ---------------------------------------------------------------------------

src/devices/virtio_vsock.rs

Lines changed: 134 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@ use std::time::{Duration, Instant};
1212

1313
use tracing::{info, warn};
1414

15-
use crate::guest::protocol::{ExecRequest, ExecResponse, Message, MessageType, TelemetryBatch};
15+
use crate::guest::protocol::{
16+
ExecRequest, ExecResponse, Message, MessageType, TelemetryBatch,
17+
WriteFileRequest, WriteFileResponse,
18+
MkdirPRequest, MkdirPResponse,
19+
};
1620
use crate::{Error, Result};
1721

1822

@@ -161,6 +165,135 @@ impl VsockDevice {
161165
Ok(response)
162166
}
163167

168+
/// Write a file to the guest filesystem using the native WriteFile protocol.
169+
///
170+
/// This sends a WriteFile message directly to the guest-agent, which writes
171+
/// the file in Rust without needing `sh`, `echo`, or `base64`. Parent
172+
/// directories are created automatically. The write runs as root in the
173+
/// guest (appropriate for host-initiated provisioning like skill files).
174+
pub async fn send_write_file(&self, path: &str, content: &[u8]) -> Result<WriteFileResponse> {
175+
let request = WriteFileRequest {
176+
path: path.to_string(),
177+
content: content.to_vec(),
178+
create_parents: true,
179+
};
180+
181+
let mut stream = self.connect_with_handshake().await?;
182+
183+
// Set generous read timeout
184+
let _ = stream.set_read_timeout(Some(Duration::from_secs(30)));
185+
186+
let message = Message {
187+
msg_type: MessageType::WriteFile,
188+
payload: serde_json::to_vec(&request)?,
189+
};
190+
stream
191+
.write_all(&message.serialize())
192+
.map_err(|e| Error::Guest(format!("Failed to send WriteFile: {}", e)))?;
193+
194+
let response_msg = Message::read_from_sync(&mut stream)?;
195+
if response_msg.msg_type != MessageType::WriteFileResponse {
196+
return Err(Error::Guest(format!(
197+
"Unexpected response type for WriteFile: {:?}",
198+
response_msg.msg_type
199+
)));
200+
}
201+
202+
let response: WriteFileResponse = serde_json::from_slice(&response_msg.payload)?;
203+
Ok(response)
204+
}
205+
206+
/// Create directories in the guest filesystem (mkdir -p).
207+
pub async fn send_mkdir_p(&self, path: &str) -> Result<MkdirPResponse> {
208+
let request = MkdirPRequest {
209+
path: path.to_string(),
210+
};
211+
212+
let mut stream = self.connect_with_handshake().await?;
213+
214+
let _ = stream.set_read_timeout(Some(Duration::from_secs(10)));
215+
216+
let message = Message {
217+
msg_type: MessageType::MkdirP,
218+
payload: serde_json::to_vec(&request)?,
219+
};
220+
stream
221+
.write_all(&message.serialize())
222+
.map_err(|e| Error::Guest(format!("Failed to send MkdirP: {}", e)))?;
223+
224+
let response_msg = Message::read_from_sync(&mut stream)?;
225+
if response_msg.msg_type != MessageType::MkdirPResponse {
226+
return Err(Error::Guest(format!(
227+
"Unexpected response type for MkdirP: {:?}",
228+
response_msg.msg_type
229+
)));
230+
}
231+
232+
let response: MkdirPResponse = serde_json::from_slice(&response_msg.payload)?;
233+
Ok(response)
234+
}
235+
236+
/// Connect to the guest agent and perform a Ping/Pong handshake.
237+
/// Shared helper for send_write_file, send_mkdir_p, etc.
238+
async fn connect_with_handshake(&self) -> Result<VsockStream> {
239+
// Short initial wait for guest kernel to boot
240+
tokio::time::sleep(Duration::from_secs(2)).await;
241+
242+
let mut delay = Duration::from_millis(100);
243+
let deadline = Instant::now() + Duration::from_secs(30);
244+
const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(3);
245+
let mut attempt: u32 = 0;
246+
247+
loop {
248+
if Instant::now() >= deadline {
249+
eprintln!("[vsock] deadline reached after {} connect/handshake attempts", attempt);
250+
return Err(Error::Guest(
251+
"vsock: deadline reached (connect or handshake)".into(),
252+
));
253+
}
254+
255+
attempt += 1;
256+
257+
let mut s = match self.connect_to_guest(GUEST_AGENT_PORT) {
258+
Ok(stream) => {
259+
eprintln!("[vsock] attempt {} connect OK (cid={}, port={})", attempt, self.cid, GUEST_AGENT_PORT);
260+
stream
261+
}
262+
Err(e) => {
263+
eprintln!("[vsock] attempt {} connect failed: {} (retry in {:?})", attempt, e, delay);
264+
tokio::time::sleep(delay).await;
265+
delay = std::cmp::min(delay * 2, Duration::from_secs(2));
266+
continue;
267+
}
268+
};
269+
270+
// Handshake: Ping -> Pong
271+
if let Err(_e) = s.set_read_timeout(Some(HANDSHAKE_TIMEOUT)) {
272+
tokio::time::sleep(delay).await;
273+
delay = std::cmp::min(delay * 2, Duration::from_secs(2));
274+
continue;
275+
}
276+
let ping_msg = Message {
277+
msg_type: MessageType::Ping,
278+
payload: vec![],
279+
};
280+
if s.write_all(&ping_msg.serialize()).is_err() {
281+
tokio::time::sleep(delay).await;
282+
delay = std::cmp::min(delay * 2, Duration::from_secs(2));
283+
continue;
284+
}
285+
match Message::read_from_sync(&mut s) {
286+
Ok(msg) if msg.msg_type == MessageType::Pong => {
287+
return Ok(s);
288+
}
289+
_ => {
290+
tokio::time::sleep(delay).await;
291+
delay = std::cmp::min(delay * 2, Duration::from_secs(2));
292+
}
293+
}
294+
}
295+
}
296+
164297
/// Open a persistent telemetry subscription to the guest agent.
165298
///
166299
/// Connects to the guest, performs a Ping/Pong handshake, sends a

src/sandbox/local.rs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,38 @@ impl LocalSandbox {
176176
}
177177
}
178178

179+
/// Write a file to the guest filesystem using the native WriteFile protocol.
180+
///
181+
/// This bypasses shell/base64 by sending a WriteFile message directly to
182+
/// the guest-agent. Parent directories are created automatically.
183+
/// In simulation mode (no kernel), this is a no-op success.
184+
pub async fn write_file_native(&self, path: &str, content: &[u8]) -> Result<()> {
185+
if self.config.kernel.is_none() {
186+
// Simulation mode -- no-op
187+
return Ok(());
188+
}
189+
190+
self.ensure_started().await?;
191+
192+
let vm_lock = self.vm.lock().await;
193+
let vm = vm_lock.as_ref().ok_or(Error::VmNotRunning)?;
194+
vm.write_file(path, content).await
195+
}
196+
197+
/// Create directories in the guest filesystem (mkdir -p).
198+
/// In simulation mode (no kernel), this is a no-op success.
199+
pub async fn mkdir_p(&self, path: &str) -> Result<()> {
200+
if self.config.kernel.is_none() {
201+
return Ok(());
202+
}
203+
204+
self.ensure_started().await?;
205+
206+
let vm_lock = self.vm.lock().await;
207+
let vm = vm_lock.as_ref().ok_or(Error::VmNotRunning)?;
208+
vm.mkdir_p(path).await
209+
}
210+
179211
/// Internal helper for `exec_claude` -- runs claude-code with extra env.
180212
pub(crate) async fn exec_claude_internal(
181213
&self,

src/sandbox/mod.rs

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -138,15 +138,26 @@ impl Sandbox {
138138
}
139139
}
140140

141-
/// Write a file in the sandbox
141+
/// Write a file in the sandbox using the native WriteFile protocol.
142+
///
143+
/// This sends the file content directly to the guest-agent via vsock,
144+
/// which writes it in Rust without needing `sh`, `echo`, or `base64`.
145+
/// Parent directories are created automatically.
142146
pub async fn write_file(&self, path: &str, content: &[u8]) -> Result<()> {
143-
// Use base64 encoding to handle binary data safely
144-
let encoded = base64_encode(content);
145-
let output = self.exec("sh", &["-c", &format!("echo -n '{}' | base64 -d > {}", encoded, path)]).await?;
146-
if output.success() {
147-
Ok(())
148-
} else {
149-
Err(Error::Guest(format!("Failed to write file: {}", output.stderr_str())))
147+
match &self.inner {
148+
SandboxInner::Local(local) => local.write_file_native(path, content).await,
149+
SandboxInner::Mock(_mock) => {
150+
// Mock: no-op success
151+
Ok(())
152+
}
153+
}
154+
}
155+
156+
/// Create directories in the guest filesystem (mkdir -p).
157+
pub async fn mkdir_p(&self, path: &str) -> Result<()> {
158+
match &self.inner {
159+
SandboxInner::Local(local) => local.mkdir_p(path).await,
160+
SandboxInner::Mock(_mock) => Ok(()),
150161
}
151162
}
152163

@@ -465,7 +476,8 @@ impl MockSandbox {
465476
}
466477
}
467478

468-
/// Simple base64 encoding (for write_file)
479+
/// Simple base64 encoding (kept for potential future use).
480+
#[allow(dead_code)]
469481
fn base64_encode(data: &[u8]) -> String {
470482
const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
471483

0 commit comments

Comments
 (0)