fix(shell): detect Docker buildx provenance EPERM and surface actionable hint
When exec_shell runs a Docker build on macOS and Docker Desktop's signed process has written com.apple.provenance-tagged files under ~/.docker/buildx/activity/, the child process spawned by the TUI sandbox gets EPERM when it tries to update those files, producing: failed to update builder last activity time: open /Users/.../.docker/buildx/activity/.tmp-...: operation not permitted Add looks_like_macos_provenance_failure() to detect this pattern via three heuristics (provenance xattr mention, activity-time message, or buildx/activity path + EPERM), with an early-return guard that suppresses the hint on clean exits. Wire the hint into both the foreground exec_shell path and build_shell_delta_tool_result so it surfaces on background task polls too. Four unit tests cover the positive cases and the two guard cases (exit 0, unrelated EPERM). Closes #1449 Signed-off-by: CrepuscularIRIS <serenitygp@qq.com>
This commit is contained in:
committed by
Hunter Bown
parent
97a77d82f0
commit
ae45d1054b
@@ -1418,6 +1418,31 @@ const FOREGROUND_TIMEOUT_RECOVERY_HINT: &str = "Foreground exec_shell is for bou
|
||||
The timed-out process was killed; rerun long work with task_shell_start or exec_shell with \
|
||||
background: true, then poll with task_shell_wait or exec_shell_wait.";
|
||||
|
||||
const MACOS_PROVENANCE_HINT: &str = "Docker buildx failed to update its activity file due to a macOS \
|
||||
com.apple.provenance restriction. Files created by Docker Desktop's signed process carry a \
|
||||
kernel-enforced provenance tag that blocks writes from child processes (including the TUI \
|
||||
shell sandbox). Workarounds: (1) run the Docker build from a regular terminal outside the \
|
||||
TUI, or (2) disable BuildKit with DOCKER_BUILDKIT=0 (only works if your Dockerfiles do not \
|
||||
use RUN --mount directives).";
|
||||
|
||||
pub(crate) fn looks_like_macos_provenance_failure(result: &ShellResult) -> bool {
|
||||
if matches!(result.status, ShellStatus::Completed) && result.exit_code == Some(0) {
|
||||
return false;
|
||||
}
|
||||
let combined = format!("{}\n{}", result.stdout, result.stderr).to_ascii_lowercase();
|
||||
combined.contains("com.apple.provenance")
|
||||
|| combined.contains("update builder last activity")
|
||||
|| (combined.contains("buildx/activity") && combined.contains("operation not permitted"))
|
||||
}
|
||||
|
||||
fn macos_provenance_hint(result: &ShellResult) -> Option<&'static str> {
|
||||
if looks_like_macos_provenance_failure(result) {
|
||||
Some(MACOS_PROVENANCE_HINT)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn command_likely_needs_network(command: &str) -> bool {
|
||||
let normalized = command.to_ascii_lowercase();
|
||||
let Some(primary) = extract_primary_command(&normalized) else {
|
||||
@@ -1933,6 +1958,7 @@ impl ToolSpec for ExecShellTool {
|
||||
};
|
||||
let network_restricted_hint =
|
||||
shell_network_restricted_hint(context, command, &result).map(str::to_string);
|
||||
let provenance_hint = macos_provenance_hint(&result);
|
||||
let mut output = if interactive {
|
||||
format!(
|
||||
"Interactive command completed (exit code: {:?})",
|
||||
@@ -1973,6 +1999,9 @@ impl ToolSpec for ExecShellTool {
|
||||
if let Some(hint) = network_restricted_hint.as_deref() {
|
||||
output = format!("{hint}\n\n{output}");
|
||||
}
|
||||
if let Some(hint) = provenance_hint {
|
||||
output = format!("{hint}\n\n{output}");
|
||||
}
|
||||
|
||||
let mut metadata = json!({
|
||||
"exit_code": result.exit_code,
|
||||
@@ -2027,6 +2056,9 @@ impl ToolSpec for ExecShellTool {
|
||||
metadata["sandbox_network_restricted"] = json!(true);
|
||||
metadata["sandbox_network_denied_hint"] = json!(hint);
|
||||
}
|
||||
if provenance_hint.is_some() {
|
||||
metadata["macos_provenance_restricted"] = json!(true);
|
||||
}
|
||||
|
||||
Ok(ToolResult {
|
||||
content: output,
|
||||
@@ -2072,6 +2104,7 @@ fn build_shell_delta_tool_result(delta: ShellDeltaResult, context: &ToolContext)
|
||||
let result = delta.result;
|
||||
let network_restricted_hint =
|
||||
shell_network_restricted_hint(context, &delta.command, &result).map(str::to_string);
|
||||
let provenance_hint = macos_provenance_hint(&result);
|
||||
let stdout_summary = summarize_output(&result.stdout);
|
||||
let stderr_summary = summarize_output(&result.stderr);
|
||||
let summary = if !stderr_summary.is_empty() {
|
||||
@@ -2096,6 +2129,9 @@ fn build_shell_delta_tool_result(delta: ShellDeltaResult, context: &ToolContext)
|
||||
if let Some(hint) = network_restricted_hint.as_deref() {
|
||||
output = format!("{hint}\n\n{output}");
|
||||
}
|
||||
if let Some(hint) = provenance_hint {
|
||||
output = format!("{hint}\n\n{output}");
|
||||
}
|
||||
|
||||
let mut tool_result = ToolResult {
|
||||
content: output,
|
||||
@@ -2129,6 +2165,12 @@ fn build_shell_delta_tool_result(delta: ShellDeltaResult, context: &ToolContext)
|
||||
object.insert("sandbox_network_restricted".to_string(), json!(true));
|
||||
object.insert("sandbox_network_denied_hint".to_string(), json!(hint));
|
||||
}
|
||||
if provenance_hint.is_some()
|
||||
&& let Some(metadata) = tool_result.metadata.as_mut()
|
||||
&& let Some(object) = metadata.as_object_mut()
|
||||
{
|
||||
object.insert("macos_provenance_restricted".to_string(), json!(true));
|
||||
}
|
||||
tool_result
|
||||
}
|
||||
|
||||
|
||||
@@ -689,3 +689,57 @@ async fn test_exec_shell_cancel_tool_can_kill_all_running_processes() {
|
||||
assert_eq!(first_job.snapshot.status, ShellStatus::Killed);
|
||||
assert_eq!(second_job.snapshot.status, ShellStatus::Killed);
|
||||
}
|
||||
|
||||
fn make_failed_result(stderr: &str) -> ShellResult {
|
||||
ShellResult {
|
||||
task_id: None,
|
||||
status: ShellStatus::Failed,
|
||||
exit_code: Some(1),
|
||||
stdout: String::new(),
|
||||
stderr: stderr.to_string(),
|
||||
duration_ms: 0,
|
||||
stdout_len: 0,
|
||||
stderr_len: stderr.len(),
|
||||
stdout_omitted: 0,
|
||||
stderr_omitted: 0,
|
||||
stdout_truncated: false,
|
||||
sandboxed: false,
|
||||
sandbox_type: None,
|
||||
sandbox_denied: false,
|
||||
stderr_truncated: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_macos_provenance_detected_by_activity_time_message() {
|
||||
let result = make_failed_result(
|
||||
"failed to update builder last activity time: open \
|
||||
/Users/user/.docker/buildx/activity/.tmp-abc: operation not permitted",
|
||||
);
|
||||
assert!(looks_like_macos_provenance_failure(&result));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_macos_provenance_detected_by_activity_path_and_eperm() {
|
||||
let result = make_failed_result(
|
||||
"error: open /home/user/.docker/buildx/activity/foo: operation not permitted",
|
||||
);
|
||||
assert!(looks_like_macos_provenance_failure(&result));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_macos_provenance_not_triggered_on_success() {
|
||||
let mut result = make_failed_result(
|
||||
"failed to update builder last activity time: open \
|
||||
/Users/user/.docker/buildx/activity/.tmp-abc: operation not permitted",
|
||||
);
|
||||
result.status = ShellStatus::Completed;
|
||||
result.exit_code = Some(0);
|
||||
assert!(!looks_like_macos_provenance_failure(&result));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_macos_provenance_not_triggered_on_unrelated_eperm() {
|
||||
let result = make_failed_result("open /some/other/path: operation not permitted");
|
||||
assert!(!looks_like_macos_provenance_failure(&result));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user