From f0d2a2480b5f465db29b3732b32087630d86f920 Mon Sep 17 00:00:00 2001 From: ssk015 <18252677381@163.com> Date: Mon, 26 Oct 2026 20:52:52 +0800 Subject: [PATCH] Surface Docker error details in sandbox failures Render the retained HTTP error prefix in ErrorDiagnostic Debug output so SDK failures include the daemon's reason. Keep the capture bound and metadata, escape controls and replace invalid UTF-8. Cover version refusals, truncation and encoding, and document the v1.54 requirement. Related to #457. Validation: 32 sandbox tests; 57 Python tooling tests; all-target Clippy for sandbox/API/Node with -D warnings; workspace formatting; real SDK before/after checks on Docker 29.1.3 and 29.9.2 passed. The full boundary guard retains four unchanged baseline SDK violations; core scope passes. Production LOC: 102158 -> 102160 (delta +2) Scope: first-party core Rust/runtime SQL and API Rust/JavaScript. Core: 96788 -> 96790; API: 5370 -> 5370; dsh absent (moved out, #428). Reference absent (retired R9c; last counted 65,417 at tree 498dd1917812). Method: tools/production_loc.py --root --json, pinned at ad78914e36b258b360daf13aec164b5655814689 (blob 43a566e015896723539b44ab719c81c905f2add1). Identical counter/exclusions for git archive snapshots of the exact first-parent and staged trees: 93e157d97497b4e2028d8cd8bd2880ce0d7c707f -> a21b7676cde6118d58b5a59c690b30835d62de6f. Tests, docs, tools, third-party code and test-only source are excluded. --- api/README.md | 4 + api/how-to-use/agent.md | 5 ++ .../layerfs-sandbox/src/types/failure.rs | 5 +- .../layerfs-sandbox/tests/engine_protocol.rs | 77 ++++++++++++++++++- .../71-ordinary-engine-runtime-foundation.md | 4 +- .../72-owned-sandbox-lifecycle.md | 6 +- 6 files changed, 96 insertions(+), 5 deletions(-) diff --git a/api/README.md b/api/README.md index b7d09b2507..8b9a790f16 100644 --- a/api/README.md +++ b/api/README.md @@ -74,6 +74,10 @@ place, avoiding stale macOS executable-page signature state. `CARGO_TARGET_DIR` must stay inside this worktree; `LAYERFS_NATIVE_OUTPUT` can select a separate platform artifact. Keep Linux addons separate from the host addon. +At runtime, the Docker Engine server selected by `LAYERFS_DOCKER` must accept +Engine API v1.54 requests. The SDK always uses explicit `/v1.54/...` paths and +does not negotiate from the server's maximum API version; see the [agent settings](how-to-use/agent.md#settings). + New Store schema versions are 16–18 (pack layouts) and 22–24 (with acquisition), with history identity format 3. Old Disposable Stores are rejected without migration. Select a fresh project/Store for the new format. diff --git a/api/how-to-use/agent.md b/api/how-to-use/agent.md index e7d63363c3..afb1247edb 100644 --- a/api/how-to-use/agent.md +++ b/api/how-to-use/agent.md @@ -149,6 +149,11 @@ Keep each agent's operations on a Workspace sequential. | `LAYERFS_TRACE_STEP` | Optional step label put on each trace line as `step`; read at every call. | | `LAYERFS_TRACE_START` | `1` adds a start line before each traced call; read at every call. | +The Docker Engine server selected by `LAYERFS_DOCKER` must accept Engine API +v1.54 requests. The SDK always uses explicit `/v1.54/...` paths and does not +negotiate from the server's maximum API version. Check the server's API range +with `docker version`. + The SDK and the `layerfs` command read only these `LAYERFS_` names. An integration that can set only names with its own prefix maps them to these in its own wrapper. diff --git a/core/crates/layerfs-sandbox/src/types/failure.rs b/core/crates/layerfs-sandbox/src/types/failure.rs index 5f3fe8693a..de4ddfe27d 100644 --- a/core/crates/layerfs-sandbox/src/types/failure.rs +++ b/core/crates/layerfs-sandbox/src/types/failure.rs @@ -75,7 +75,8 @@ impl WireFailure { } } -/// Original bounded HTTP error diagnostic; bytes beyond this window are explicitly unobserved. +/// Original bounded HTTP error diagnostic; Debug shows only the retained prefix as lossy UTF-8, +/// with controls escaped, while bytes beyond this window remain explicitly unobserved. pub struct ErrorDiagnostic { pub prefix: Vec, pub observed_bytes: u64, @@ -84,7 +85,9 @@ pub struct ErrorDiagnostic { } impl fmt::Debug for ErrorDiagnostic { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let prefix = String::from_utf8_lossy(&self.prefix); f.debug_struct("ErrorDiagnostic") + .field("prefix", &prefix) .field("prefix_bytes", &self.prefix.len()) .field("observed_bytes", &self.observed_bytes) .field("complete", &self.complete) diff --git a/core/crates/layerfs-sandbox/tests/engine_protocol.rs b/core/crates/layerfs-sandbox/tests/engine_protocol.rs index 334d1cf189..4acf25f551 100644 --- a/core/crates/layerfs-sandbox/tests/engine_protocol.rs +++ b/core/crates/layerfs-sandbox/tests/engine_protocol.rs @@ -3,7 +3,7 @@ #[path = "support/engine.rs"] mod peer; use layerfs_sandbox::{ - backend::docker::{Docker, ExecRequest}, + backend::docker::{CreatePhase, Docker, ExecRequest, SandboxRequest}, CommandIdentity, ContainerId, ExecId, RuntimeError, }; use std::io::{self, Write}; @@ -22,6 +22,14 @@ fn request() -> ExecRequest { stdin: false, } } +fn sandbox_request() -> SandboxRequest { + SandboxRequest { + image: format!("sha256:{}", "d".repeat(64)), + store_volume: "project-store".into(), + port: 30421, + labels: vec![("layerfs.role".into(), "sandbox".into())], + } +} fn created() -> Vec { peer::fixed(201, format!("{{\"Id\":\"{}\"}}", exec()).as_bytes()) } @@ -214,12 +222,79 @@ fn actual_mismatched_inspection_and_http_error_body_remain_distinct_from_selecto let failed = docker.inspect_exec(container(), exec()).unwrap_err(); assert_eq!(failed.observed_exec, None); assert_eq!(failed.requested_exec, Some(exec())); + let rendered = format!("{failed:?}"); + assert!(rendered.contains("original remote error")); let diagnostic = failed.error_body.unwrap(); assert!(diagnostic.complete); assert_eq!(diagnostic.prefix, body); server.finish(); } #[test] +fn sandbox_create_debug_shows_docker_api_version_refusal_without_negotiating() { + let message = "client version 1.54 is too new. Maximum supported API version is 1.52"; + let body = format!("{{\"message\":\"{message}\"}}"); + let volume = br#"{"Name":"project-store","Driver":"local","Scope":"local","Options":null}"#; + let (docker, server) = peer::Peer::new( + vec![peer::fixed(200, volume), peer::fixed(400, body.as_bytes())], + 8192, + ); + let failed = docker.create_sandbox(sandbox_request()).unwrap_err(); + assert_eq!(failed.phase, CreatePhase::Create); + assert_eq!(failed.transfer.status, Some(400)); + let rendered = format!("{failed:?}"); + assert!(rendered.contains(message)); + let requests = server.finish(); + assert_eq!(requests.len(), 2); + assert!(String::from_utf8(requests[0].clone()) + .unwrap() + .starts_with("GET /v1.54/volumes/project-store HTTP/1.1")); + assert!(String::from_utf8(requests[1].clone()) + .unwrap() + .starts_with("POST /v1.54/containers/create HTTP/1.1")); +} +#[test] +fn sandbox_volume_api_version_refusal_stops_before_create() { + let message = "client version 1.54 is too new. Maximum supported API version is 1.52"; + let body = format!("{{\"message\":\"{message}\"}}"); + let (docker, server) = peer::Peer::new(vec![peer::fixed(400, body.as_bytes())], 8192); + let failed = docker.create_sandbox(sandbox_request()).unwrap_err(); + assert_eq!(failed.phase, CreatePhase::Volume); + assert_eq!(failed.transfer.status, Some(400)); + assert!(format!("{failed:?}").contains(message)); + let requests = server.finish(); + assert_eq!(requests.len(), 1); + let request = String::from_utf8(requests[0].clone()).unwrap(); + assert!(request.starts_with("GET /v1.54/volumes/project-store HTTP/1.1")); + assert!(!request.contains("POST /v1.54/containers/create")); +} +#[test] +fn http_error_debug_shows_only_the_bounded_prefix() { + let mut body = vec![b'x'; 1024]; + body.extend_from_slice(b"TAIL_SECRET_MARKER"); + let (docker, server) = peer::Peer::new(vec![peer::fixed(500, &body)], 8192); + let failed = docker.inspect_exec(container(), exec()).unwrap_err(); + let rendered = format!("{failed:?}"); + let diagnostic = failed.error_body.unwrap(); + assert_eq!(diagnostic.prefix, vec![b'x'; 1024]); + assert_eq!(diagnostic.observed_bytes, 1025); + assert!(!diagnostic.complete); + assert!(rendered.contains("prefix_bytes: 1024")); + assert!(rendered.contains("observed_bytes: 1025")); + assert!(rendered.contains("x".repeat(64).as_str())); + assert!(!rendered.contains("TAIL_SECRET_MARKER")); + server.finish(); +} +#[test] +fn http_error_debug_escapes_controls_and_replaces_invalid_utf8() { + let body = b"message:\nnext\t\0\xff"; + let (docker, server) = peer::Peer::new(vec![peer::fixed(400, body)], 8192); + let failed = docker.inspect_exec(container(), exec()).unwrap_err(); + let rendered = format!("{failed:?}"); + assert!(rendered.contains("prefix: \"message:\\nnext\\t\\0\u{fffd}\"")); + assert!(!rendered.contains("message:\nnext")); + server.finish(); +} +#[test] fn wrong_upgrade_media_and_malformed_status_trailers_extensions_refuse() { let valid = String::from_utf8(peer::upgrade(&[])).unwrap(); for wrong in [ diff --git a/core/docs/architecture/71-ordinary-engine-runtime-foundation.md b/core/docs/architecture/71-ordinary-engine-runtime-foundation.md index fa309b3ed6..ea962556eb 100644 --- a/core/docs/architecture/71-ordinary-engine-runtime-foundation.md +++ b/core/docs/architecture/71-ordinary-engine-runtime-foundation.md @@ -32,7 +32,9 @@ remaining counters, never payload allocations. Unknown JSON strings/Arguments stream without collection. Selected-key duplicates, escapes/UTF-8/surrogates, integer types/width, malformed/truncated framing and conflicting headers refuse with original transfer and failed-fence knowledge. Error HTTP statuses retain a -separate bounded prefix/count/completion/error diagnostic with redacted Debug. +separate bounded prefix/count/completion/error diagnostic. Debug shows only the +retained prefix (at most 1024 bytes) as lossy UTF-8 with controls escaped, alongside +the byte count, completion state and read error. The selected metadata profile has an explicit32-level JSON support bound,128B selected-key/prefix capture,8KiB individual header lines and32KiB header/trailer diff --git a/core/docs/architecture/72-owned-sandbox-lifecycle.md b/core/docs/architecture/72-owned-sandbox-lifecycle.md index 28aa4339f7..9b16c53ed4 100644 --- a/core/docs/architecture/72-owned-sandbox-lifecycle.md +++ b/core/docs/architecture/72-owned-sandbox-lifecycle.md @@ -31,8 +31,10 @@ The upload is incremental extraction. A failed/lost response never proves an emp destination and never authorizes another upload, adoption, automatic deletion or Start. Original declared executable/tar sizes, source bytes read, buffer-accepted archive bytes, positive socket body/wire bytes and two disjoint pending windows -remain separate. Pending bytes and HTTP diagnostic prefixes have redacted Debug. -The caller retains the original file/config; SDK failure retains original inputs. +remain separate. Pending request bytes stay redacted in Debug. HTTP error diagnostics +show only the retained prefix (at most 1024 bytes) as lossy UTF-8 with controls +escaped, alongside explicit prefix/count/completion metadata. The caller retains the +original file/config; SDK failure retains original inputs. Each original upload, Start, listener wait, stop and delete has a one-attempt flag. Known acknowledgments are distinct from attempted/unknown outcomes. Explicit