Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 72 additions & 2 deletions crates/execution/assets/runners/python-runner.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,16 @@ function createPythonBridgeRpcBridge() {
fsRenameSync(path, destination) {
requestSync('fsRename', { path, destination });
},
fsSymlinkSync(target, path) {
requestSync('fsSymlink', { target, path });
},
fsReadlinkSync(path) {
const result = requestSync('fsReadlink', { path });
return result.target ?? '';
},
fsSetattrSync(path, attr) {
requestSync('fsSetattr', { path, ...attr });
},
httpRequestSync(url, method = 'GET', headersJson = '{}', bodyBase64 = null) {
let headers;
try {
Expand Down Expand Up @@ -781,6 +791,16 @@ function createPythonFdRpcBridge() {
fsRenameSync(path, destination) {
requestSync('fsRename', { path, destination });
},
fsSymlinkSync(target, path) {
requestSync('fsSymlink', { target, path });
},
fsReadlinkSync(path) {
const result = requestSync('fsReadlink', { path });
return result.target ?? '';
},
fsSetattrSync(path, attr) {
requestSync('fsSetattr', { path, ...attr });
},
httpRequestSync(url, method = 'GET', headersJson = '{}', bodyBase64 = null) {
let headers;
try {
Expand Down Expand Up @@ -1465,6 +1485,7 @@ function installPythonWorkspaceFs(pyodide, bridge) {
const memfsDirStreamOps = MEMFS.ops_table.dir.stream;
const memfsFileNodeOps = MEMFS.ops_table.file.node;
const memfsFileStreamOps = MEMFS.ops_table.file.stream;
const memfsLinkNodeOps = MEMFS.ops_table.link.node;
const workspaceDirStreamOps = memfsDirStreamOps;

function joinGuestPath(parentPath, name) {
Expand Down Expand Up @@ -1529,6 +1550,8 @@ function installPythonWorkspaceFs(pyodide, bridge) {
if (FS.isDir(mode)) {
node.node_ops = workspaceDirNodeOps;
node.stream_ops = workspaceDirStreamOps;
} else if (FS.isLink(mode)) {
node.node_ops = workspaceLinkNodeOps;
} else if (FS.isFile(mode)) {
node.node_ops = workspaceFileNodeOps;
node.stream_ops = workspaceFileStreamOps;
Expand Down Expand Up @@ -1630,6 +1653,46 @@ function installPythonWorkspaceFs(pyodide, bridge) {
};
}

function toEpochMs(value) {
if (value == null) return null;
if (typeof value === 'number') return value;
if (typeof value.getTime === 'function') return value.getTime();
return null;
}

// Propagate chmod/chown/utimes from an Emscripten `setattr` into the host VFS.
// (size/truncate is handled via the dirty-write path, not here.)
function propagateSetattrToHost(node, attr) {
if (!attr) return;
const payload = {};
if (attr.mode != null) payload.mode = attr.mode & 0o7777;
if (attr.uid != null) payload.uid = attr.uid;
if (attr.gid != null) payload.gid = attr.gid;
const atimeMs = toEpochMs(attr.atime ?? attr.timestamp);
const mtimeMs = toEpochMs(attr.mtime ?? attr.timestamp);
if (atimeMs != null && mtimeMs != null) {
payload.atimeMs = Math.trunc(atimeMs);
payload.mtimeMs = Math.trunc(mtimeMs);
}
if (Object.keys(payload).length === 0) return;
withFsErrors(() => bridge.fsSetattrSync(nodeGuestPath(node), payload));
}

const workspaceLinkNodeOps = {
// A symlink node reports itself (lstat semantics), not its target — so use
// the in-memory link mode rather than a host stat (which follows the link).
getattr(node) {
return makeStat(node, null);
},
setattr(node, attr) {
memfsLinkNodeOps.setattr(node, attr);
propagateSetattrToHost(node, attr);
},
readlink(node) {
return withFsErrors(() => bridge.fsReadlinkSync(nodeGuestPath(node)));
},
};

const workspaceFileNodeOps = {
getattr(node) {
const stat = node.agentOSDirty
Expand All @@ -1646,6 +1709,7 @@ function installPythonWorkspaceFs(pyodide, bridge) {
node.agentOSDirty = true;
node.agentOSLoaded = true;
}
propagateSetattrToHost(node, attr);
},
};

Expand Down Expand Up @@ -1690,6 +1754,7 @@ function installPythonWorkspaceFs(pyodide, bridge) {
},
setattr(node, attr) {
memfsDirNodeOps.setattr(node, attr);
propagateSetattrToHost(node, attr);
},
lookup(parent, name) {
syncDirectory(parent);
Expand Down Expand Up @@ -1749,8 +1814,13 @@ function installPythonWorkspaceFs(pyodide, bridge) {
syncDirectory(node);
return memfsDirNodeOps.readdir(node);
},
symlink() {
throw new FS.ErrnoError(ERRNO_CODES.ENOSYS);
symlink(parent, newName, oldPath) {
const guestPath = joinGuestPath(nodeGuestPath(parent), newName);
withFsErrors(() => bridge.fsSymlinkSync(oldPath, guestPath));
const node = createWorkspaceNode(parent, newName, 0o120777, 0, guestPath);
node.link = oldPath;
node.usedBytes = oldPath.length;
return node;
},
};

Expand Down
39 changes: 39 additions & 0 deletions crates/execution/src/python.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ pub enum PythonVfsRpcMethod {
Unlink,
Rmdir,
Rename,
Symlink,
ReadLink,
Setattr,
HttpRequest,
DnsLookup,
SubprocessRun,
Expand All @@ -69,6 +72,9 @@ impl PythonVfsRpcMethod {
"fsUnlink" => Some(Self::Unlink),
"fsRmdir" => Some(Self::Rmdir),
"fsRename" => Some(Self::Rename),
"fsSymlink" => Some(Self::Symlink),
"fsReadlink" => Some(Self::ReadLink),
"fsSetattr" => Some(Self::Setattr),
"httpRequest" => Some(Self::HttpRequest),
"dnsLookup" => Some(Self::DnsLookup),
"subprocessRun" => Some(Self::SubprocessRun),
Expand All @@ -84,6 +90,14 @@ pub struct PythonVfsRpcRequest {
pub path: String,
/// Second path for `Rename` (the destination); `None` for other methods.
pub destination: Option<String>,
/// Symlink target (the path the link points at), for `Symlink`.
pub target: Option<String>,
/// `Setattr` metadata fields (each applied only when present).
pub mode: Option<u32>,
pub uid: Option<u32>,
pub gid: Option<u32>,
pub atime_ms: Option<u64>,
pub mtime_ms: Option<u64>,
pub content_base64: Option<String>,
pub recursive: bool,
pub url: Option<String>,
Expand Down Expand Up @@ -136,6 +150,9 @@ pub enum PythonVfsRpcResponsePayload {
stderr: String,
max_buffer_exceeded: bool,
},
SymlinkTarget {
target: String,
},
}

#[derive(Debug, Deserialize)]
Expand All @@ -147,6 +164,19 @@ struct PythonVfsBridgeRequestWire {
#[serde(default)]
destination: Option<String>,
#[serde(default)]
target: Option<String>,
// JS numbers cross the bridge as f64; accept that and narrow below.
#[serde(default)]
mode: Option<f64>,
#[serde(default)]
uid: Option<f64>,
#[serde(default)]
gid: Option<f64>,
#[serde(default, rename = "atimeMs")]
atime_ms: Option<f64>,
#[serde(default, rename = "mtimeMs")]
mtime_ms: Option<f64>,
#[serde(default)]
content_base64: Option<String>,
#[serde(default)]
recursive: bool,
Expand Down Expand Up @@ -477,6 +507,9 @@ impl PythonExecution {
"stderr": stderr,
"maxBufferExceeded": max_buffer_exceeded,
}),
PythonVfsRpcResponsePayload::SymlinkTarget { target } => json!({
"target": target,
}),
};

self.inner
Expand Down Expand Up @@ -1187,6 +1220,12 @@ fn parse_python_bridge_sync_rpc_request(
method,
path: wire.path,
destination: wire.destination,
target: wire.target,
mode: wire.mode.map(|value| value as u32),
uid: wire.uid.map(|value| value as u32),
gid: wire.gid.map(|value| value as u32),
atime_ms: wire.atime_ms.map(|value| value as u64),
mtime_ms: wire.mtime_ms.map(|value| value as u64),
content_base64: wire.content_base64,
recursive: wire.recursive,
url: wire.url,
Expand Down
14 changes: 8 additions & 6 deletions crates/sidecar/src/execution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,9 @@ use crate::protocol::{
OwnershipScope, ProcessExitedEvent, ProcessKilledResponse, ProcessOutputEvent,
ProcessSnapshotEntry, ProcessSnapshotResponse, ProcessSnapshotStatus, ProcessStartedResponse,
PtyResizedResponse, RequestFrame, ResizePtyRequest, ResponseFrame, ResponsePayload,
SidecarRequestPayload, SignalDispositionAction,
SignalHandlerRegistration, SignalStateResponse, SocketStateEntry, StdinClosedResponse,
StdinWrittenResponse, StreamChannel, VmFetchRequest, VmFetchResponse, WasmPermissionTier,
WriteStdinRequest, ZombieTimerCountResponse,
SidecarRequestPayload, SignalDispositionAction, SignalHandlerRegistration, SignalStateResponse,
SocketStateEntry, StdinClosedResponse, StdinWrittenResponse, StreamChannel, VmFetchRequest,
VmFetchResponse, WasmPermissionTier, WriteStdinRequest, ZombieTimerCountResponse,
};
use crate::service::{
audit_fields, dirname, emit_security_audit_event, emit_structured_event, javascript_error,
Expand Down Expand Up @@ -4762,7 +4761,10 @@ where
| PythonVfsRpcMethod::Mkdir
| PythonVfsRpcMethod::Unlink
| PythonVfsRpcMethod::Rmdir
| PythonVfsRpcMethod::Rename => {
| PythonVfsRpcMethod::Rename
| PythonVfsRpcMethod::Symlink
| PythonVfsRpcMethod::ReadLink
| PythonVfsRpcMethod::Setattr => {
filesystem_handle_python_vfs_rpc_request(self, vm_id, process_id, request)
}
PythonVfsRpcMethod::HttpRequest => {
Expand Down Expand Up @@ -16810,7 +16812,7 @@ fn install_kernel_stdin_pipe(kernel: &mut SidecarKernel, pid: u32) -> Result<u32
.fd_close(EXECUTION_DRIVER_NAME, pid, read_fd)
.map_err(kernel_error)?;
Ok(write_fd)
}
}

fn requested_pty_window_size(env: &BTreeMap<String, String>) -> Option<(u16, u16)> {
let cols = env
Expand Down
42 changes: 42 additions & 0 deletions crates/sidecar/src/filesystem.rs
Original file line number Diff line number Diff line change
Expand Up @@ -818,6 +818,48 @@ where
Err(error) => Err(error),
}
}
// Kernel-direct (no shadow mirror): guest Python writes/creates
// land only in the kernel VFS, so mirroring create/modify ops into
// the host-side shadow would leave empty stubs that a later
// shadow->kernel sync resurrects over real content. (Delete/rename
// still mirror — to *remove* stale wire-written shadow entries.)
PythonVfsRpcMethod::Symlink => {
let target = request.target.clone().ok_or_else(|| {
SidecarError::InvalidState(format!(
"python VFS fsSymlink for {} requires a target",
path
))
})?;
vm.kernel
.symlink(&target, &path)
.map(|()| PythonVfsRpcResponsePayload::Empty)
.map_err(kernel_error)
}
PythonVfsRpcMethod::ReadLink => vm
.kernel
.read_link(&path)
.map(|target| PythonVfsRpcResponsePayload::SymlinkTarget { target })
.map_err(kernel_error),
// `setattr` carries any of mode/uid+gid/atime+mtime; apply each
// present field to the host VFS.
PythonVfsRpcMethod::Setattr => {
(|| -> Result<PythonVfsRpcResponsePayload, SidecarError> {
if let Some(mode) = request.mode {
vm.kernel.chmod(&path, mode).map_err(kernel_error)?;
}
if let (Some(uid), Some(gid)) = (request.uid, request.gid) {
vm.kernel.chown(&path, uid, gid).map_err(kernel_error)?;
}
if let (Some(atime_ms), Some(mtime_ms)) =
(request.atime_ms, request.mtime_ms)
{
vm.kernel
.utimes(&path, atime_ms, mtime_ms)
.map_err(kernel_error)?;
}
Ok(PythonVfsRpcResponsePayload::Empty)
})()
}
PythonVfsRpcMethod::HttpRequest
| PythonVfsRpcMethod::DnsLookup
| PythonVfsRpcMethod::SubprocessRun => {
Expand Down
Loading
Loading