Skip to content
Merged
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
34 changes: 34 additions & 0 deletions sdk/src/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { parse as semverParse } from 'semver';
import { SandboxPolicy, ContainerConfig, ContainmentType, ContainmentBackend } from './types.js';
import { prepareSpawn, diagLogVersion } from './helper.js';
import { diagLog } from './diagnostic.js';
import { MxcError, mxcErrorFromCode } from './errors.js';

const SUPPORTED_VERSION = '0.6.0-alpha';
const MIN_VERSION = '0.4.0-alpha';
Expand Down Expand Up @@ -697,6 +698,15 @@ export function spawnSandboxAsync(
ptyProcess.onExit((event: { exitCode: number; signal?: number }) => {
// Note: wxc-exec doesn't separate stdout/stderr when using PTY
// All output is combined
//
// Check for structured error envelopes from wxc-exec on failure.
if (event.exitCode !== 0) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: don't we also need this in these places?

  1. function spawnWithConfig(
  2. export function spawnSandboxFromConfig(

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i dont think you can because PTY

@bbonaby bbonaby May 22, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm but there is a ptyProcess return in spawnWithConfig though. Users Can use spawnSandbox by itself rather than spawnSandboxAsync . Same thing with spawnSandboxFromConfig there is a ptyProcess option in there too.

EDIT: Oh wait, I might have misinterpreted what you said. I thought you meant, that we can't because they use PTY

const mxcError = tryParseErrorEnvelopeFromLines(output);
if (mxcError) {
reject(mxcError);
return;
}
}
resolve({
stdout: output,
stderr: '',
Expand All @@ -708,3 +718,27 @@ export function spawnSandboxAsync(
}
});
}

/**
* Scans a multi-line string for a JSON error envelope emitted by wxc-exec
* on stderr. Returns the first matching envelope, or null if none found.
* The envelope format is: `{"error": {"code": "...", "message": "...", ...}}`
*/
function tryParseErrorEnvelopeFromLines(output: string): MxcError | null {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: can this be moved out of sandbox.tx and into the helper.ts function? this seems like it can be reused by the state-aware.ts file as well.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wontfix. because no time.

for (const line of output.split('\n')) {
const trimmed = line.trim();
if (!trimmed.startsWith('{')) continue;
try {
const parsed = JSON.parse(trimmed);
if (parsed && typeof parsed === 'object' && 'error' in parsed) {
const env = parsed.error;
if (env && typeof env.code === 'string' && typeof env.message === 'string') {
return mxcErrorFromCode(env.code, env.message, env.details);
}
}
} catch {
// Not valid JSON on this line, continue scanning.
}
}
Comment on lines +723 to +742
Comment on lines +722 to +742
return null;
}
2 changes: 1 addition & 1 deletion sdk/tests/integration/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions src/bwrap_common/src/bwrap_runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ impl ScriptRunner for BubblewrapScriptRunner {
"Bubblewrap: script timed out after {}ms",
request.script_timeout
),
..Default::default()
};
}
Err(WaitError::Io(error)) => {
Expand All @@ -186,6 +187,7 @@ impl ScriptRunner for BubblewrapScriptRunner {
standard_out: join_reader(stdout_handle),
standard_err: join_reader(stderr_handle),
error_message: String::new(),
..Default::default()
}
}
}
Expand Down
1 change: 1 addition & 0 deletions src/lxc_common/src/lxc_runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ impl LxcScriptRunner {
standard_out: stdout,
standard_err: stderr,
error_message: String::new(),
..Default::default()
},
Err(e) => ScriptResponse::error(&format!("Execution failed: {}", e)),
};
Expand Down
11 changes: 4 additions & 7 deletions src/seatbelt_common/src/seatbelt_runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ impl ScriptRunner for SeatbeltScriptRunner {
standard_out: String::new(),
standard_err: String::new(),
error_message: e,
..Default::default()
}
}
};
Expand Down Expand Up @@ -344,21 +345,18 @@ impl SeatbeltScriptRunner {
let result = match wait_with_timeout(&mut child, timeout) {
Ok(status) => ScriptResponse {
exit_code: status.code().unwrap_or(-1),
standard_out: String::new(),
standard_err: String::new(),
error_message: String::new(),
..Default::default()
},
Err(WaitError::Timeout) => {
let _ = child.kill();
let _ = child.wait();
ScriptResponse {
exit_code: -1,
standard_out: String::new(),
standard_err: String::new(),
error_message: format!(
"Seatbelt: terminal timed out after {}ms",
request.script_timeout
),
..Default::default()
}
}
Err(WaitError::Io(error)) => error_response(format!("wait failed: {error}")),
Expand Down Expand Up @@ -427,9 +425,8 @@ fn build_sandbox_command(
fn error_response(message: String) -> ScriptResponse {
ScriptResponse {
exit_code: -1,
standard_out: String::new(),
standard_err: String::new(),
error_message: message,
..Default::default()
}
}

Expand Down
1 change: 1 addition & 0 deletions src/wslc_common/src/wsl_container_runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -834,6 +834,7 @@ impl WSLContainerRunner {
} else {
String::new()
},
..Default::default()
}
}

Expand Down
21 changes: 21 additions & 0 deletions src/wxc/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -573,5 +573,26 @@ fn main() {
if !response.standard_err.is_empty() {
eprint!("{}", response.standard_err);
}

// Emit a structured JSON error envelope on stderr for SDK/caller consumption
// when the runner produced an error message (one-shot flows only).
// In PTY mode stderr is merged into the PTY output stream, so the envelope
// appears inline -- callers (e.g. copilot) can parse it from the output.
if response.exit_code != 0 && !response.error_message.is_empty() {
let mut envelope = serde_json::json!({
"error": {
"code": "backend_error",
"message": response.error_message,
}
});
if !response.extended_error.is_empty() {
envelope["error"]["extended_error"] =
serde_json::Value::String(response.extended_error.clone());
}
if let Ok(json) = serde_json::to_string(&envelope) {
eprintln!("{json}");
}
}

process::exit(response.exit_code);
}
40 changes: 39 additions & 1 deletion src/wxc_common/src/appcontainer_runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,19 @@ impl AppContainerScriptRunner {
// pass CREATE_NEW_CONSOLE or DETACH_PROCESS, the child shares our console.
let mut pi = PROCESS_INFORMATION::default();

// Pre-launch check: abort if policy paths are on ReFS (Dev Drive) volumes
// where BFS cannot enforce filesystem policy.
if let Some(diag) = crate::launch_diagnostics::check_refs_volumes(
&request.policy.readonly_paths,
&request.policy.readwrite_paths,
) {
logger.log_line(&format!(
"Error: Pre-launch diagnostic [{}]: {}",
diag.kind, diag.message
));
return Err(WxcError::Process(diag.message));
}

unsafe {
CreateProcessW(
PCWSTR::null(),
Expand Down Expand Up @@ -564,6 +577,7 @@ impl AppContainerScriptRunner {
standard_out: String::new(),
standard_err: String::new(),
error_message: String::new(),
..Default::default()
})
}

Expand Down Expand Up @@ -605,6 +619,8 @@ impl Default for AppContainerScriptRunner {
impl ScriptRunner for AppContainerScriptRunner {
fn execute(&mut self, request: &CodexRequest, logger: &mut Logger) -> ScriptResponse {
use crate::filesystem_bfs::FileSystemBfsManager;
use crate::launch_diagnostics::diagnose_process_exit;
use crate::models::FailurePhase;
use crate::network_manager::NetworkManager;

// Apply experimental features when flag is set
Expand Down Expand Up @@ -658,13 +674,35 @@ impl ScriptRunner for AppContainerScriptRunner {
}
}

let response = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let mut response = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
self.run_internal(request, logger)
})) {
Ok(r) => r,
Err(_) => ScriptResponse::error("Unknown error during script execution."),
};

// Post-failure diagnostics: if the child failed, check for known
// environment issues and enrich the error message.
if response.exit_code != 0 {
response.failure_phase = FailurePhase::ProcessExited;
if let Some(diag) = diagnose_process_exit(
&request.script_code,
&request.policy.readonly_paths,
&request.policy.readwrite_paths,
response.exit_code as u32,
) {
logger.log_line(&format!(
"Error: Launch diagnostic [{}]: {}",
diag.kind, diag.message
));
if !response.error_message.is_empty() {
response.extended_error = response.error_message.clone();
}
response.error_message = diag.message.clone();
response.standard_err.push_str(&diag.message);
}
}
Comment on lines +684 to +704

network_manager.stop_all(!request.lifecycle.preserve_policy, logger);
if bfs_manager.configured() && !request.lifecycle.preserve_policy {
bfs_manager.remove_configuration(logger);
Expand Down
94 changes: 76 additions & 18 deletions src/wxc_common/src/base_container_runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,13 @@ use windows::Win32::System::Threading::{
};
use windows_core::PCWSTR;

use crate::launch_diagnostics::{diagnose_create_process_failure, diagnose_process_exit};
use crate::log_symbols::{
EMOJI_ALLOWED, EMOJI_BLOCKED, EMOJI_NEUTRAL, EMOJI_SECTION, EMOJI_WARNING,
};
use crate::logger::Logger;
use crate::models::{
CodexRequest, NetworkEnforcementMode, NetworkPolicy, ProxyAddress, ScriptResponse,
CodexRequest, FailurePhase, NetworkEnforcementMode, NetworkPolicy, ProxyAddress, ScriptResponse,
};
use crate::proxy_coordinator::ProxyCoordinator;
use crate::sandbox_tracking::{self, TrackingEntry};
Expand Down Expand Up @@ -85,10 +86,6 @@ pub struct BaseContainerRunner {
proxy_coordinator: ProxyCoordinator,
}

/// Windows error code for a function that exists but is not implemented
/// (e.g., disabled via feature-enablement mechanisms).
const ERROR_CALL_NOT_IMPLEMENTED: u32 = 120;

/// SandboxSpec FlatBuffer schema version embedded in every spec payload.
const SANDBOX_SPEC_VERSION: &str = "0.1.0";

Expand Down Expand Up @@ -595,6 +592,26 @@ impl ScriptRunner for BaseContainerRunner {
let ac_sid_str = derive_sid_string_from_name(&identity);
let _ = writeln!(logger, "AppContainerSID: {ac_sid_str}");

// Pre-launch check: abort if policy paths are on ReFS (Dev Drive) volumes
// where BFS cannot enforce filesystem policy.
if let Some(diag) = crate::launch_diagnostics::check_refs_volumes(
&request.policy.readonly_paths,
&request.policy.readwrite_paths,
) {
let _ = writeln!(
logger,
"Error: Pre-launch diagnostic [{}]: {}",
diag.kind, diag.message
);
return ScriptResponse {
exit_code: -1,
error_message: diag.message.clone(),
standard_err: diag.message,
failure_phase: FailurePhase::LaunchFailed,
..Default::default()
};
}

// 4. Call Experimental_CreateProcessInSandbox.
let success = unsafe {
create_process_in_sandbox(
Expand Down Expand Up @@ -634,18 +651,34 @@ impl ScriptRunner for BaseContainerRunner {
logger,
);
}

//
// Diagnose the launch failure (FailurePhase::LaunchFailed).
//
let err = unsafe { GetLastError() };
if err.0 == ERROR_CALL_NOT_IMPLEMENTED {
return ScriptResponse::error(
"Experimental_CreateProcessInSandbox returned ERROR_CALL_NOT_IMPLEMENTED. \
The BaseContainer feature may be disabled on this OS build \
(e.g., via feature-enablement mechanisms). \
Use schema version '0.4.0-alpha' to fall back to the AppContainer backend.",
);
}
return ScriptResponse::error(&format!(
"Experimental_CreateProcessInSandbox failed: {err:?}"
));
let diag = diagnose_create_process_failure(
err.0,
&request.script_code,
&request.policy.readonly_paths,
);

let extended_error = format!("Experimental_CreateProcessInSandbox failed: {err:?}");
let _ = writeln!(logger, "Error: {extended_error}");

let _ = writeln!(
logger,
"Error: Launch diagnostic [{}]: {}",
diag.kind, diag.message
);

return ScriptResponse {
exit_code: -1,
error_message: diag.message.clone(),
standard_err: diag.message,
extended_error,
failure_phase: FailurePhase::LaunchFailed,
..Default::default()
};
}

let _ = writeln!(logger, "process created (PID: {})", pi.dwProcessId);
Expand Down Expand Up @@ -699,11 +732,36 @@ impl ScriptRunner for BaseContainerRunner {
// Stop the builtin test proxy if it was started.
self.proxy_coordinator.stop(logger);

//
// Diagnose the application failure (FailurePhase::ProcessExited).
//
let (error_message, failure_phase) = if exit_code != 0 {
if let Some(diag) = diagnose_process_exit(
&request.script_code,
&request.policy.readonly_paths,
&request.policy.readwrite_paths,
exit_code,
) {
let _ = writeln!(
logger,
"Error: Launch diagnostic [{}]: {}",
diag.kind, diag.message
);
(diag.message, FailurePhase::ProcessExited)
} else {
(String::new(), FailurePhase::ProcessExited)
}
} else {
(String::new(), FailurePhase::None)
};

ScriptResponse {
exit_code: exit_code as i32,
standard_out: String::new(),
standard_err: String::new(),
error_message: String::new(),
standard_err: error_message.clone(),
error_message,
failure_phase,
..Default::default()
}
}
}
Expand Down
1 change: 1 addition & 0 deletions src/wxc_common/src/isolation_session/one_shot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ impl ScriptRunner for IsolationSessionRunner {
standard_out: String::new(),
standard_err: String::new(),
error_message: String::new(),
..Default::default()
}
}
}
Expand Down
Loading
Loading