From a59afe4e2eadeecc44742f2dff4a72e915c45968 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 10:01:44 -0400 Subject: [PATCH 1/8] Add shared JSON error helpers and usage_error (#704) set_error / set_error_keep_status write the top-level error as a {code, message} object and drop any top-level errorCode. legacy_error and print_legacy_error give scan, get and rollback the minimal {status: "error", error: {code, message}} shape. usage_error prints a self-enforced usage error under --json (a full envelope for envelope commands, the legacy shape for scan/get/rollback), or Error: on stderr, and returns 2. Guard tests fail on a bare `return 2;` in src/commands (outside the hidden hosted_bundle harness) and on a "status": "error" JSON literal whose top-level error is a string or carries errorCode. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-cli/src/json_envelope.rs | 259 +++++++++++++++++++ 1 file changed, 259 insertions(+) diff --git a/crates/socket-patch-cli/src/json_envelope.rs b/crates/socket-patch-cli/src/json_envelope.rs index 22f752f56..2a76a9811 100644 --- a/crates/socket-patch-cli/src/json_envelope.rs +++ b/crates/socket-patch-cli/src/json_envelope.rs @@ -463,6 +463,96 @@ impl EnvelopeError { } } +/// The `{code, message}` object every `--json` failure carries as its +/// top-level `error` — the serialized form of an [`EnvelopeError`]. +pub(crate) fn error_object(err: &EnvelopeError) -> serde_json::Value { + serde_json::json!({ "code": err.code, "message": err.message }) +} + +/// Mark a legacy (`scan` / `get` / `rollback`) JSON result as a top-level +/// failure: `status: "error"` plus `error: {code, message}`. Any older +/// top-level `errorCode` sibling is removed — the code lives in +/// `error.code` (v5.0). Per-record `errorCode`s inside arrays are untouched. +pub(crate) fn set_error(value: &mut serde_json::Value, err: EnvelopeError) { + // `status` first, so a fresh object reads `{status, error}`. + if let Some(obj) = value.as_object_mut() { + obj.insert("status".into(), serde_json::json!("error")); + } + set_error_keep_status(value, err); +} + +/// [`set_error`] without touching `status`, for results whose status is +/// itself the routing signal (get's `selection_required`). +pub(crate) fn set_error_keep_status(value: &mut serde_json::Value, err: EnvelopeError) { + if let Some(obj) = value.as_object_mut() { + obj.remove("errorCode"); + obj.insert("error".into(), error_object(&err)); + } +} + +/// The minimal legacy failure shape: `{status: "error", error: {code, +/// message}}`. +pub(crate) fn legacy_error(code: &str, message: &str) -> serde_json::Value { + let mut v = serde_json::json!({}); + set_error(&mut v, EnvelopeError::new(code, message)); + v +} + +/// Print [`legacy_error`] on stdout. +pub(crate) fn print_legacy_error(code: &str, message: &str) { + println!( + "{}", + serde_json::to_string_pretty(&legacy_error(code, message)).expect("json serialize") + ); +} + +/// Whether `command` still prints its legacy (non-[`Envelope`]) JSON shape. +fn is_legacy_shape(command: Command) -> bool { + matches!(command, Command::Scan | Command::Get | Command::Rollback) +} + +/// The JSON a self-enforced usage error prints under `--json`: a full +/// [`Envelope`] for commands already on it, the legacy error shape for +/// `scan` / `get` / `rollback`. +pub(crate) fn usage_error_json( + command: Command, + dry_run: bool, + code: &str, + message: &str, +) -> serde_json::Value { + if is_legacy_shape(command) { + legacy_error(code, message) + } else { + let mut env = Envelope::new(command); + env.dry_run = dry_run; + env.mark_error(EnvelopeError::new(code, message)); + serde_json::to_value(&env).expect("envelope serialize") + } +} + +/// Report a usage error a command enforces itself (clap's own parse errors +/// never reach here) and return its exit code, 2. Under `--json` the coded +/// error goes to stdout so a consumer always gets parseable output; +/// otherwise `Error: ` goes to stderr. +pub(crate) fn usage_error( + command: Command, + json: bool, + dry_run: bool, + code: &str, + message: &str, +) -> i32 { + if json { + println!( + "{}", + serde_json::to_string_pretty(&usage_error_json(command, dry_run, code, message)) + .expect("json serialize") + ); + } else { + eprintln!("Error: {message}"); + } + 2 +} + /// One run-level advisory (see [`Envelope::warnings`]). Same `code`/`detail` /// vocabulary as per-event reasons, but scoped to the whole project/run. #[derive(Debug, Clone, Serialize)] @@ -482,6 +572,175 @@ pub struct RunWarning { mod tests { use super::*; + #[test] + fn set_error_writes_object_and_drops_error_code() { + let mut v = serde_json::json!({ + "status": "success", + "errorCode": "lock_held", + "error": "old", + "patches": [{ "errorCode": "apply_failed", "error": "per-record" }], + }); + set_error(&mut v, EnvelopeError::new("lock_held", "held")); + assert_eq!(v["status"], "error"); + assert_eq!( + v["error"], + serde_json::json!({"code": "lock_held", "message": "held"}) + ); + assert!(v.get("errorCode").is_none(), "{v}"); + // Per-record keys are out of scope and untouched. + assert_eq!(v["patches"][0]["errorCode"], "apply_failed"); + assert_eq!(v["patches"][0]["error"], "per-record"); + } + + #[test] + fn set_error_keep_status_leaves_status() { + let mut v = serde_json::json!({ "status": "selection_required" }); + set_error_keep_status(&mut v, EnvelopeError::new("selection_required", "pick")); + assert_eq!(v["status"], "selection_required"); + assert_eq!(v["error"]["code"], "selection_required"); + assert_eq!(v["error"]["message"], "pick"); + } + + #[test] + fn legacy_error_has_minimal_shape() { + let v = legacy_error("manifest_unreadable", "bad json"); + assert_eq!( + v, + serde_json::json!({ + "status": "error", + "error": { "code": "manifest_unreadable", "message": "bad json" }, + }) + ); + } + + #[test] + fn usage_error_json_legacy_vs_envelope() { + for cmd in [Command::Scan, Command::Get, Command::Rollback] { + let v = usage_error_json(cmd, true, "invalid_args", "bad"); + assert_eq!(v, legacy_error("invalid_args", "bad"), "{cmd:?}"); + } + for cmd in [ + Command::Apply, + Command::List, + Command::Remove, + Command::Repair, + Command::Vendor, + Command::Vex, + ] { + let v = usage_error_json(cmd, true, "invalid_args", "bad"); + assert_eq!(v["command"], serde_json::to_value(cmd).unwrap()); + assert_eq!(v["status"], "error"); + assert_eq!(v["dryRun"], true); + assert_eq!(v["events"], serde_json::json!([])); + assert_eq!(v["error"]["code"], "invalid_args"); + assert_eq!(v["error"]["message"], "bad"); + } + } + + #[test] + fn usage_error_returns_two() { + assert_eq!( + usage_error(Command::Scan, false, false, "invalid_args", "x"), + 2 + ); + assert_eq!( + usage_error(Command::Remove, true, false, "invalid_args", "x"), + 2 + ); + } + + /// Every `src/commands/**/*.rs` file, with its path relative to the + /// crate root. + fn command_sources() -> Vec<(String, String)> { + fn walk(dir: &std::path::Path, out: &mut Vec) { + for entry in std::fs::read_dir(dir).expect("read src/commands") { + let path = entry.expect("dir entry").path(); + if path.is_dir() { + walk(&path, out); + } else if path.extension().is_some_and(|e| e == "rs") { + out.push(path); + } + } + } + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + let mut files = Vec::new(); + walk(&root.join("src/commands"), &mut files); + files.sort(); + assert!(!files.is_empty(), "no command sources found"); + files + .into_iter() + .map(|p| { + let rel = p + .strip_prefix(root) + .unwrap() + .to_string_lossy() + .replace('\\', "/"); + (rel, std::fs::read_to_string(&p).expect("read source")) + }) + .collect() + } + + /// Guard (#704): a command's self-enforced usage error goes through + /// [`usage_error`], which prints the coded error under `--json` and + /// returns 2. A bare `return 2;` would bypass that. + #[test] + fn no_bare_exit_two_in_commands() { + const ALLOW: &[&str] = &["src/commands/hosted_bundle.rs"]; + let mut offenders = Vec::new(); + for (rel, src) in command_sources() { + if ALLOW.contains(&rel.as_str()) { + continue; + } + for (i, line) in src.lines().enumerate() { + if line.trim() == "return 2;" { + offenders.push(format!("{rel}:{}", i + 1)); + } + } + } + assert!( + offenders.is_empty(), + "use json_envelope::usage_error for exit-2 usage errors: {offenders:?}" + ); + } + + /// Guard (#704): a `"status": "error"` JSON literal carries `error` as a + /// `{code, message}` object and no top-level `"errorCode"`. Checks the + /// keys at the same indentation as `"status": "error"`, so per-record + /// keys nested deeper are not flagged. + #[test] + fn error_json_literals_use_the_object_shape() { + let mut offenders = Vec::new(); + for (rel, src) in command_sources() { + let lines: Vec<&str> = src.lines().collect(); + for (i, line) in lines.iter().enumerate() { + if line.trim() != r#""status": "error","# { + continue; + } + let indent = line.len() - line.trim_start().len(); + for next in &lines[i + 1..] { + let trimmed = next.trim_start(); + let next_indent = next.len() - trimmed.len(); + if trimmed.is_empty() || next_indent < indent { + break; + } + if next_indent > indent { + continue; + } + let bad = trimmed.starts_with(r#""errorCode":"#) + || (trimmed.starts_with(r#""error":"#) + && !trimmed[r#""error":"#.len()..].trim_start().starts_with('{')); + if bad { + offenders.push(format!("{rel}:{}: {}", i + 1, trimmed)); + } + } + } + } + assert!( + offenders.is_empty(), + "top-level `error` must be {{code, message}} with no `errorCode`: {offenders:?}" + ); + } + #[test] fn action_tags_round_trip() { // Each variant's serde representation must match the From 8d316df77f22a4fb7dfa811e9078da29fca3cbdc Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 10:01:47 -0400 Subject: [PATCH 2/8] Make every scan/get/rollback --json error a {code, message} object (#704) get: report_error takes a code (patch_fetch_failed, manifest_unreadable, manifest_write_failed, patch_no_applicable_files, offline_unsupported); the lock failure, the nested-apply run error, blob_write_failed and selection_required carry error: {code, message}; top-level errorCode is gone. The nested-apply error keeps status partial_failure. scan: one hosted emitter that requires a code (refusals, lock_held/ lock_io, patch_details_failed, reference_resolve_failed, lockfile_write_failed); discovery failure, --offline, all-batches-failed (api_batch_failed), embedded VEX failure, the vendor step and the socket.yml refusal all write the object. rollback: emit_rollback_error takes a code; manifest_not_found, manifest_invalid, manifest_unreadable, patch_not_found, path_glob_no_match, hosted_wiring_contested, vendor_ledger_missing and rollback_failed. Usage errors (exit 2) in scan, get, rollback, remove, repair, vendor and vex go through json_envelope::usage_error, so under --json they print the coded error on stdout. Breaking change to the v5.0 JSON contract. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-cli/src/commands/get.rs | 126 +++++++++---- .../socket-patch-cli/src/commands/remove.rs | 11 +- .../socket-patch-cli/src/commands/repair.rs | 15 +- .../socket-patch-cli/src/commands/rollback.rs | 48 ++--- .../src/commands/scan/hosted.rs | 43 ++--- .../socket-patch-cli/src/commands/scan/mod.rs | 166 +++++++++++------- .../src/commands/scan/policy.rs | 7 +- .../src/commands/scan/vendor_flow.rs | 9 +- .../socket-patch-cli/src/commands/vendor.rs | 16 +- crates/socket-patch-cli/src/commands/vex.rs | 9 +- 10 files changed, 263 insertions(+), 187 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs index 0cbd9ce4b..66c3a5bf4 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -43,6 +43,7 @@ use crate::ecosystem_dispatch::{ crawl_all_ecosystems, find_all_packages_for_rollback, find_packages_for_rollback, partition_purls, }; +use crate::json_envelope::{usage_error, Command as JsonCommand}; use crate::ui::{print_json, select_one, SelectError}; /// Best-effort ecosystem extractor for a `pkg:/...` PURL. Used as @@ -228,26 +229,28 @@ async fn report_fetch_failure( ) -> i32 { let msg = error.to_string(); track_patch_fetch_failed(identifier, &msg, fallback_to_proxy, api_token, org_slug).await; - report_error(json, msg); + report_error(json, "patch_fetch_failed", msg); 1 } -/// Report an error to the caller: a `{status, error}` envelope on -/// stdout when `json` is true, otherwise a plain `Error: ...` on stderr. -fn report_error(json: bool, message: impl std::fmt::Display) { +/// Report an error to the caller: a `{status: "error", error: {code, +/// message}}` object on stdout when `json` is true, otherwise a plain +/// `Error: ...` on stderr. Every top-level `get` failure goes through here +/// (or [`report_lock_failure`]) so the error shape cannot drift. +fn report_error(json: bool, code: &str, message: impl std::fmt::Display) { let message = message.to_string(); if json { - print_json(&serde_json::json!({"status": "error", "error": message})); + crate::json_envelope::print_legacy_error(code, &message); } else { eprintln!("Error: {message}"); } } /// Report a failed apply-lock acquire in get's legacy error shape — the -/// `{status: "error", error: ""}` envelope every other hard error -/// here uses, plus the stable `errorCode` (`lock_held` / `lock_io`) the -/// other lock sites emit — and return the envelope for the caller's -/// early-return guard. The message/code mapping is +/// `{status: "error", error: {code, message}}` object every other hard +/// error here uses, with the stable code (`lock_held` / `lock_io`) the +/// other lock sites emit — and return it for the caller's early-return +/// guard. The message/code mapping is /// [`crate::commands::lock_cli::lock_failure`]'s, so the waited clause and /// the I/O rendering cannot drift from `apply`'s. fn report_lock_failure( @@ -257,11 +260,7 @@ fn report_lock_failure( timeout: Duration, ) -> serde_json::Value { let (code, message) = lock_failure(err, timeout); - let envelope = serde_json::json!({ - "status": "error", - "errorCode": code, - "error": message, - }); + let envelope = crate::json_envelope::legacy_error(code, &message); if json { print_json(&envelope); } else { @@ -1075,7 +1074,10 @@ pub(crate) fn select_patches( .collect(); print_json(&serde_json::json!({ "status": "selection_required", - "error": format!("Multiple patches available for {purl}. Re-run with the chosen UUID as the identifier (`socket-patch get `) to select one."), + "error": { + "code": "selection_required", + "message": format!("Multiple patches available for {purl}. Re-run with the chosen UUID as the identifier (`socket-patch get `) to select one."), + }, "purl": purl, "options": options_json, })); @@ -2399,7 +2401,8 @@ fn apply_key_covers(key: &str, record: &str) -> bool { /// as on every `failed` record); any other failed manifest patch (one this /// run did not select) gets its own `failed` record (`uuid_of` looks up /// its uuid); a run-level reason rides the envelope's top-level -/// `errorCode` / `error`. `failed` grows by every record marked or +/// `error: {code, message}` (status stays `partial_failure`: the downloads +/// it reports still landed). `failed` grows by every record marked or /// appended here. Returns `applied`: how many of the run's recorded /// patches apply really patched (or found already patched). fn fold_apply_failures( @@ -2465,8 +2468,10 @@ fn fold_apply_failures( let failed = envelope["failed"].as_u64().unwrap_or(0) as usize + added; envelope["failed"] = serde_json::json!(failed); if let Some((code, error)) = &report.run_error { - envelope["errorCode"] = serde_json::json!(code); - envelope["error"] = serde_json::json!(error); + crate::json_envelope::set_error_keep_status( + envelope, + crate::json_envelope::EnvelopeError::new(code, error), + ); } applied } @@ -2512,8 +2517,11 @@ pub async fn download_and_apply_patches_with( // and destroy every tracked patch record. Err(e) => { let err = format!("Failed to read manifest: {e}"); - report_error(params.json, &err); - return (1, serde_json::json!({"status": "error", "error": err})); + report_error(params.json, "manifest_unreadable", &err); + return ( + 1, + crate::json_envelope::legacy_error("manifest_unreadable", &err), + ); } }; @@ -2565,8 +2573,11 @@ pub async fn download_and_apply_patches_with( // unwind exactly those (a pre-existing record's blobs stay). unwind_new_blobs(&blobs_dir, &new_blobs).await; let msg = format!("Failed to write manifest: {e}"); - report_error(params.json, &msg); - return (1, serde_json::json!({ "status": "error", "error": msg })); + report_error(params.json, "manifest_write_failed", &msg); + return ( + 1, + crate::json_envelope::legacy_error("manifest_write_failed", &msg), + ); } } // Every selected patch that is now recorded is owed the nested apply: @@ -2664,11 +2675,13 @@ pub async fn run(args: GetArgs) -> i32 { .filter(|&&f| f) .count(); if type_flags > 1 { - report_error( + return usage_error( + JsonCommand::Get, args.common.json, + args.common.dry_run, + "invalid_args", "Only one of --id, --cve, --ghsa, or --package can be specified", ); - return 2; } // v5: hosted by default, like scan. `--save-only` (records a manifest // entry) and global installs (no project lockfile) mean agent mode. @@ -2683,20 +2696,27 @@ pub async fn run(args: GetArgs) -> i32 { // Global installs have no project lockfile: an explicit hosted or // vendored mode would rewire the cwd project, not the global copy. if let Some(conflict) = super::global_mode_conflict(&args.common, mode) { - report_error(args.common.json, conflict); - return 2; + return usage_error( + JsonCommand::Get, + args.common.json, + args.common.dry_run, + "global_scope_unsupported", + &conflict, + ); } if args.save_only && mode != super::scan::ScanMode::Agent { - report_error( + return usage_error( + JsonCommand::Get, args.common.json, - format!( + args.common.dry_run, + "invalid_args", + &format!( "--save-only cannot be used with --mode {}: hosted mode never writes the \ manifest, and vendored mode's vendor step IS the persistence (plain \ `get --save-only` already records without applying)", mode.cli_name() ), ); - return 2; } // Strict airgap (CLI_CONTRACT.md `--offline`: never contact the // network; operations that need remote data fail loudly). Every `get` @@ -2707,6 +2727,7 @@ pub async fn run(args: GetArgs) -> i32 { if args.common.offline { report_error( args.common.json, + "offline_unsupported", "Fetching patches needs network access, so `get` cannot run with \ --offline/SOCKET_OFFLINE (strict airgap)", ); @@ -2729,8 +2750,13 @@ pub async fn run(args: GetArgs) -> i32 { // a typo reads as a plain message instead of a raw API 400 body. if args.id || args.cve || args.ghsa { if let Some(err) = forced_identifier_error(&args.identifier, id_type) { - report_error(args.common.json, err); - return 2; + return usage_error( + JsonCommand::Get, + args.common.json, + args.common.dry_run, + "identifier_invalid", + &err, + ); } } @@ -3356,7 +3382,11 @@ async fn agent_dry_run( let manifest = match read_manifest(&args.common.resolved_manifest_path()).await { Ok(m) => m.unwrap_or_else(PatchManifest::new), Err(e) => { - report_error(args.common.json, format!("Failed to read manifest: {e}")); + report_error( + args.common.json, + "manifest_unreadable", + format!("Failed to read manifest: {e}"), + ); return 1; } }; @@ -3446,7 +3476,11 @@ async fn save_patch_record( // treated as empty would be rewritten below with only this one // patch, destroying every tracked record. Err(e) => { - report_error(args.common.json, format!("Failed to read manifest: {e}")); + report_error( + args.common.json, + "manifest_unreadable", + format!("Failed to read manifest: {e}"), + ); return Err(1); } }; @@ -3463,6 +3497,7 @@ async fn save_patch_record( if files.is_empty() { report_error( args.common.json, + "patch_no_applicable_files", format!( "Patch {} has no applicable files; nothing to apply", patch.purl @@ -3488,7 +3523,10 @@ async fn save_patch_record( "found": 1, "downloaded": 0, "applied": 0, - "error": "Blob decode or write failed", + "error": { + "code": "blob_write_failed", + "message": "Blob decode or write failed", + }, "patches": [{ "purl": patch.purl, "uuid": patch.uuid, @@ -3511,7 +3549,11 @@ async fn save_patch_record( if let Err(e) = write_manifest(manifest_path, &manifest).await { // No record points at the blobs just written: unwind exactly those. unwind_new_blobs(&blobs_dir, &new_blobs).await; - report_error(args.common.json, format!("Failed to write manifest: {e}")); + report_error( + args.common.json, + "manifest_write_failed", + format!("Failed to write manifest: {e}"), + ); return Err(1); } Ok(action) @@ -3958,8 +4000,10 @@ async fn run_get_vendored( result["vendor"] = serde_json::to_value(&*venv).unwrap_or_else(|_| serde_json::json!({})); } - result["status"] = serde_json::json!("error"); - result["error"] = serde_json::json!({ "code": code, "message": message }); + crate::json_envelope::set_error( + &mut result, + crate::json_envelope::EnvelopeError::new(code, message), + ); print_json(&result); } else { eprintln!( @@ -4953,8 +4997,12 @@ mod tests { applied: Vec::new(), }; assert_eq!(fold_apply_failures(&mut env, &report, |_| None), 0); - assert_eq!(env["errorCode"], "yarn_pnp_unsupported", "{env}"); - assert_eq!(env["error"], "pnp", "{env}"); + assert!(env.get("errorCode").is_none(), "{env}"); + assert_eq!( + env["error"], + serde_json::json!({"code": "yarn_pnp_unsupported", "message": "pnp"}), + "{env}" + ); assert_eq!(env["failed"], 0, "{env}"); } diff --git a/crates/socket-patch-cli/src/commands/remove.rs b/crates/socket-patch-cli/src/commands/remove.rs index d69b1183e..03aed32cd 100644 --- a/crates/socket-patch-cli/src/commands/remove.rs +++ b/crates/socket-patch-cli/src/commands/remove.rs @@ -319,11 +319,14 @@ pub async fn run(args: RemoveArgs) -> i32 { // `--preserve-state` restores the tree and keeps the state — together // they select the do-nothing quadrant. if args.preserve_state && args.skip_rollback { - eprintln!( - "Error: --preserve-state cannot be used with --skip-rollback: the \ - combination would be a no-op (nothing would change)" + return crate::json_envelope::usage_error( + Command::Remove, + args.common.json, + args.common.dry_run, + "invalid_args", + "--preserve-state cannot be used with --skip-rollback: the \ + combination would be a no-op (nothing would change)", ); - return 2; } let (telemetry_client, _) = diff --git a/crates/socket-patch-cli/src/commands/repair.rs b/crates/socket-patch-cli/src/commands/repair.rs index de049be4d..6f71f0361 100644 --- a/crates/socket-patch-cli/src/commands/repair.rs +++ b/crates/socket-patch-cli/src/commands/repair.rs @@ -47,14 +47,13 @@ pub async fn run(args: RepairArgs) -> i32 { // --offline implies strict airgap: no network calls. `--download-only` // is the inverse (network-only). The two are now mutually exclusive. if args.common.offline && args.download_only { - let msg = "--offline and --download-only are mutually exclusive"; - if args.common.json { - let env = error_envelope(Command::Repair, args.common.dry_run, "invalid_args", msg); - println!("{}", env.to_pretty_json()); - } else { - eprintln!("Error: {msg}"); - } - return 2; + return crate::json_envelope::usage_error( + Command::Repair, + args.common.json, + args.common.dry_run, + "invalid_args", + "--offline and --download-only are mutually exclusive", + ); } let manifest_path = args.common.resolved_manifest_path(); diff --git a/crates/socket-patch-cli/src/commands/rollback.rs b/crates/socket-patch-cli/src/commands/rollback.rs index 950bf5af9..2ee022bf1 100644 --- a/crates/socket-patch-cli/src/commands/rollback.rs +++ b/crates/socket-patch-cli/src/commands/rollback.rs @@ -725,19 +725,12 @@ fn missing_blob_abort_results( } /// Legacy top-level error emission (the pre-envelope rollback shape): -/// `{status: "error", error}` on `--json`, an `Error:` stderr line -/// otherwise. Errors print even under --silent ("errors only", never -/// "nothing"). -fn emit_rollback_error(json: bool, msg: &str) { +/// `{status: "error", error: {code, message}}` on `--json`, an `Error:` +/// stderr line otherwise. Errors print even under --silent ("errors only", +/// never "nothing"). +fn emit_rollback_error(json: bool, code: &str, msg: &str) { if json { - println!( - "{}", - serde_json::to_string_pretty(&serde_json::json!({ - "status": "error", - "error": msg, - })) - .expect("serializing an in-memory JSON value cannot fail") - ); + crate::json_envelope::print_legacy_error(code, msg); } else { eprintln!("Error: {}", capitalize_first(msg)); } @@ -1012,13 +1005,18 @@ pub async fn run(args: RollbackArgs) -> i32 { } } - // An unparseable glob is a usage error — same exit-2 stderr shape as - // scan's self-enforced mode conflicts. + // An unparseable glob is a usage error — same exit-2 shape as scan's + // self-enforced usage errors. let path_scope = match crate::path_scope::PathScope::parse(&path_patterns) { Ok(s) => s, Err(e) => { - eprintln!("Error: {}", capitalize_first(&e.to_string())); - return 2; + return crate::json_envelope::usage_error( + crate::json_envelope::Command::Rollback, + args.common.json, + args.common.dry_run, + "path_glob_invalid", + &capitalize_first(&e), + ); } }; @@ -1066,7 +1064,7 @@ pub async fn run(args: RollbackArgs) -> i32 { // Hosted wiring the lockfiles name but cannot attribute is still // hosted state: refuse, naming it, instead of "Manifest not found". if let Some(refusal) = hosted_inventory.contested_refusal() { - emit_rollback_error(args.common.json, &refusal); + emit_rollback_error(args.common.json, "hosted_wiring_contested", &refusal); return 1; } // Only a pre-v5 hosted ledger left: no lockfile pins it any more, @@ -1121,6 +1119,7 @@ pub async fn run(args: RollbackArgs) -> i32 { if !wired.is_empty() { emit_rollback_error( args.common.json, + "vendor_ledger_missing", "lockfiles still reference .socket/vendor/ artifacts but the vendor ledger \ is missing — restore .socket/vendor/state.json from version control, then \ roll back (or restore the lockfiles with `git checkout -- `)", @@ -1132,7 +1131,10 @@ pub async fn run(args: RollbackArgs) -> i32 { "{}", serde_json::to_string_pretty(&serde_json::json!({ "status": "error", - "error": "Manifest not found", + "error": { + "code": "manifest_not_found", + "message": "Manifest not found", + }, "path": manifest_path.display().to_string(), })) .expect("serializing an in-memory JSON value cannot fail") @@ -1200,13 +1202,13 @@ pub async fn run(args: RollbackArgs) -> i32 { org_slug.as_deref(), ) .await; - emit_rollback_error(args.common.json, "Invalid manifest"); + emit_rollback_error(args.common.json, "manifest_invalid", "Invalid manifest"); return 1; } Err(e) => { let msg = e.to_string(); track_patch_rollback_failed(&msg, api_token.as_deref(), org_slug.as_deref()).await; - emit_rollback_error(args.common.json, &msg); + emit_rollback_error(args.common.json, "manifest_unreadable", &msg); return 1; } } @@ -1271,7 +1273,7 @@ pub async fn run(args: RollbackArgs) -> i32 { "{}", serde_json::to_string_pretty(&serde_json::json!({ "status": "error", - "error": msg, + "error": { "code": "patch_not_found", "message": msg }, "rolledBack": 0, "alreadyOriginal": 0, "failed": 0, @@ -1345,7 +1347,7 @@ pub async fn run(args: RollbackArgs) -> i32 { unmatched.1 ); track_patch_rollback_failed(&msg, api_token.as_deref(), org_slug.as_deref()).await; - emit_rollback_error(args.common.json, &msg); + emit_rollback_error(args.common.json, "path_glob_no_match", &msg); return 1; } for purl in &path_selected { @@ -1994,7 +1996,7 @@ pub async fn run(args: RollbackArgs) -> i32 { "{}", serde_json::to_string_pretty(&serde_json::json!({ "status": "error", - "error": e, + "error": { "code": "rollback_failed", "message": e }, "rolledBack": 0, "alreadyOriginal": 0, "failed": 0, diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index 27ab8dc18..68c9d55e0 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -62,29 +62,18 @@ fn wheel_metadata_concurrency(use_public_proxy: bool) -> usize { /// the success path — folding in `status`/`error` and a minimal `redirect` /// block — instead of a bare shape that flips the schema. When absent (never /// in JSON mode today) the bare envelope is emitted. A `--json` consumer must -/// always get parseable stdout — never empty output plus an exit code. -fn emit_json_error(scan_result: Option, message: &str) { - emit_json_error_with_code(scan_result, None, message); -} - -/// `emit_json_error` plus an additive top-level `errorCode` (the stable -/// routing tag the CLI contract gives every classified failure) when the -/// refusal has one; `error` stays the human message. -fn emit_json_error_with_code( - scan_result: Option, - code: Option<&str>, - message: &str, -) { - let mut result = scan_result.unwrap_or_else(|| serde_json::json!({ "status": "error" })); - result["status"] = serde_json::json!("error"); - result["error"] = serde_json::json!(message); +/// always get parseable stdout — never empty output plus an exit code. The +/// top-level `error` is `{code, message}` like every command's (v5.0). +fn emit_json_error(scan_result: Option, code: &str, message: &str) { + let mut result = scan_result.unwrap_or_else(|| serde_json::json!({})); + crate::json_envelope::set_error( + &mut result, + crate::json_envelope::EnvelopeError::new(code, message), + ); // The rollout block describes a successful run only. if let Some(obj) = result.as_object_mut() { obj.remove("rollout"); } - if let Some(code) = code { - result["errorCode"] = serde_json::json!(code); - } if !result.get("redirect").is_some_and(|r| r.is_object()) { result["redirect"] = serde_json::json!({ "mode": "hosted" }); } @@ -131,7 +120,7 @@ fn refuse( ) -> i32 { eprintln!("Error ({}): {}", refusal.code, refusal.message); if common.json { - emit_json_error_with_code(scan_result, Some(&refusal.code), &refusal.message); + emit_json_error(scan_result, &refusal.code, &refusal.message); } 1 } @@ -165,7 +154,7 @@ fn acquire_hosted_lock( crate::commands::lock_cli::format_lock_error(&socket_dir, &err, timeout) ); if common.json { - emit_json_error_with_code(scan_result.take(), Some(code), &message); + emit_json_error(scan_result.take(), code, &message); } Err(1) } @@ -584,7 +573,7 @@ pub(super) async fn run_redirect( // stdout is never empty on failure. Err((code, message)) => { if args.common.json { - emit_json_error(scan_result.take(), &message); + emit_json_error(scan_result.take(), super::PATCH_DETAILS_FAILED, &message); } else if code == 0 && !args.common.silent { // Unreachable from scan (it never prompts, so selection // cannot be cancelled); kept for a code-0 selection error. @@ -709,7 +698,7 @@ pub(crate) async fn run_redirect_selected( format_error_line(&message) ); if common.json { - emit_json_error(scan_result.take(), &message); + emit_json_error(scan_result.take(), "reference_resolve_failed", &message); } return 1; } @@ -1164,7 +1153,7 @@ pub(crate) async fn run_redirect_selected( let message = format!("failed to write {rel}: {e}"); eprintln!("{}", format_error_line(&message)); if common.json { - emit_json_error(scan_result.take(), &message); + emit_json_error(scan_result.take(), "lockfile_write_failed", &message); } return 1; } @@ -1432,8 +1421,10 @@ pub(crate) async fn run_redirect_selected( .expect("RunWarning is a plain string struct: serialization cannot fail"); } } else if let Some(e) = &vex_error { - result["status"] = serde_json::json!("error"); - result["error"] = serde_json::json!({ "code": e.code, "message": e.message }); + crate::json_envelope::set_error( + &mut result, + crate::json_envelope::EnvelopeError::new(e.code.to_string(), e.message.clone()), + ); super::append_vex_error_warnings(&mut result, &vex_warnings); } println!( diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index 66df3be83..cf48c7a4e 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -31,6 +31,7 @@ use std::path::{Path, PathBuf}; use crate::args::{apply_env_toggles, GlobalArgs}; use crate::commands::vex::{generate_vex_from_manifest_path, VexEmbedArgs}; use crate::ecosystem_dispatch::{crawl_ecosystems, crawl_ecosystems_with_npm}; +use crate::json_envelope::{usage_error, Command as JsonCommand}; use crate::ui::{self, plural, print_json, StatusLine}; use super::get::{download_and_apply_patches_with, DownloadParams, DownloadRun}; @@ -387,11 +388,10 @@ async fn embed_vex_into_json( 0 } Err(e) => { - result["status"] = serde_json::json!("error"); - result["error"] = serde_json::json!({ - "code": e.code, - "message": e.message, - }); + crate::json_envelope::set_error( + result, + crate::json_envelope::EnvelopeError::new(e.code.to_string(), e.message.clone()), + ); append_vex_error_warnings(result, &e.embedded_warnings()); 1 } @@ -757,10 +757,14 @@ async fn fetch_patch_details( /// print it. The discovery counts already in `result` stay — they were /// computed from the (successful) batch phase — while `status`/`error` /// mirror the all-batches-failed envelope so JSON consumers see one -/// consistent scan-error schema instead of empty stdout. +/// consistent scan-error schema instead of empty stdout. The code is +/// [`PATCH_DETAILS_FAILED`]: every patch-detail query failing is the only +/// way discovery fails. fn emit_discovery_error_json(result: &mut serde_json::Value, message: &str) { - result["status"] = serde_json::json!("error"); - result["error"] = serde_json::json!(message); + crate::json_envelope::set_error( + result, + crate::json_envelope::EnvelopeError::new(PATCH_DETAILS_FAILED, message), + ); if let Some(obj) = result.as_object_mut() { obj.remove("rollout"); } @@ -1473,10 +1477,10 @@ fn push_scan_json_warning(result: &mut serde_json::Value, code: &str, detail: &s /// Print the scan error envelope for a refusal before any scanning /// (`--offline`): the all-batches-failed shape with every count at /// zero, so JSON consumers see one consistent scan-error schema. -fn print_zero_error_envelope(err: &str, paths: &[String]) { +fn print_zero_error_envelope(code: &str, err: &str, paths: &[String]) { let result = serde_json::json!({ "status": "error", - "error": err, + "error": { "code": code, "message": err }, "scannedPackages": 0, "lockfileOnlyPackages": 0, "packagesWithPatches": 0, @@ -1507,15 +1511,23 @@ pub async fn run(args: ScanArgs) -> i32 { /// PATH is a directory, or a glob matching directories, relative to /// `--cwd`. Sorted and deduplicated; the flag says whether the user named /// the directory literally (explicit roots skip the built-in default path -/// ignores; glob matches are discovered roots). -fn project_dirs(cwd: &Path, paths: &[String]) -> Result, String> { +/// ignores; glob matches are discovered roots). `Err` is a usage error: +/// `(code, message)`. +fn project_dirs( + cwd: &Path, + paths: &[String], +) -> Result, (&'static str, String)> { let mut dirs: Vec<(PathBuf, bool)> = Vec::new(); for raw in paths { let joined = cwd.join(raw); if raw.contains(['*', '?', '[']) { let pattern = joined.to_string_lossy().into_owned(); - let matches = - glob::glob(&pattern).map_err(|e| format!("invalid path pattern `{raw}`: {e}"))?; + let matches = glob::glob(&pattern).map_err(|e| { + ( + "path_glob_invalid", + format!("invalid path pattern `{raw}`: {e}"), + ) + })?; let before = dirs.len(); dirs.extend( matches @@ -1524,12 +1536,15 @@ fn project_dirs(cwd: &Path, paths: &[String]) -> Result, St .map(|p| (p, false)), ); if dirs.len() == before { - return Err(format!("`{raw}` matches no directory")); + return Err(( + "path_glob_no_match", + format!("`{raw}` matches no directory"), + )); } } else if joined.is_dir() { dirs.push((joined, true)); } else { - return Err(format!("`{raw}` is not a directory")); + return Err(("path_not_directory", format!("`{raw}` is not a directory"))); } } // A directory both named and matched counts as named. @@ -1547,44 +1562,56 @@ async fn run_project_dirs( telemetry: &mut PendingTelemetry, invocation: &InvocationPolicy, ) -> i32 { + let usage = |code: &str, message: &str| { + usage_error( + JsonCommand::Scan, + args.common.json, + args.common.dry_run, + code, + message, + ) + }; let dirs = match project_dirs(&args.common.cwd, &args.paths) { Ok(dirs) => dirs, - Err(message) => { - eprintln!("Error: {message}"); - return 2; - } + Err((code, message)) => return usage(code, &message), }; if !args.common.is_global() { for (dir, _) in &dirs { let resolved = std::fs::canonicalize(dir).unwrap_or_else(|_| dir.clone()); if !resolved.starts_with(&invocation.repo_root) { - eprintln!( - "Error: `{}` is outside {} (the repository root socket.yml is read \ - from; without a trusted .git it is --cwd): run one scan per repository, \ - or pass --cwd at a common parent", - dir.display(), - invocation.repo_root.display() + return usage( + "path_outside_repo", + &format!( + "`{}` is outside {} (the repository root socket.yml is read \ + from; without a trusted .git it is --cwd): run one scan per \ + repository, or pass --cwd at a common parent", + dir.display(), + invocation.repo_root.display() + ), ); - return 2; } } } if args.common.json && dirs.len() > 1 { - eprintln!( - "Error: --json takes one project directory ({} given); run one scan per directory", - dirs.len() + return usage( + "invalid_args", + &format!( + "--json takes one project directory ({} given); run one scan per directory", + dirs.len() + ), ); - return 2; } // `--vex ` names one document: each directory's run would write // (or, on a failed generation, remove) the same file, so the last run // would silently clobber the others' attestations. if args.vex.vex.is_some() && dirs.len() > 1 { - eprintln!( - "Error: --vex takes one project directory ({} given); run one scan per directory", - dirs.len() + return usage( + "invalid_args", + &format!( + "--vex takes one project directory ({} given); run one scan per directory", + dirs.len() + ), ); - return 2; } // One budget per invocation (§5.2): the directories spend it in sorted // order, and a package admitted in one is admitted free in the next. @@ -1593,10 +1620,7 @@ async fn run_project_dirs( .resolve_from_env(invocation.policy.max_new_patches()) { Ok(max) => max, - Err(message) => { - eprintln!("Error: {message}"); - return 2; - } + Err(message) => return usage("invalid_env", &message), }; let root = std::fs::canonicalize(&args.common.cwd).unwrap_or_else(|_| args.common.cwd.clone()); let carry = rollout_args::RolloutCarry::new(configured, root); @@ -1615,6 +1639,18 @@ async fn run_project_dirs( code } +/// Report a usage error `scan` enforces itself (exit 2): the coded error +/// on stdout under `--json`, `Error: ...` on stderr otherwise. +fn scan_usage_error(args: &ScanArgs, code: &str, message: &str) -> i32 { + usage_error( + JsonCommand::Scan, + args.common.json, + args.common.dry_run, + code, + message, + ) +} + /// Print a policy file that cannot be honored (fail closed, exit 1). fn report_policy_error(err: &socket_patch_core::policy::PolicyError, args: &ScanArgs) -> i32 { if args.common.json { @@ -1637,11 +1673,20 @@ async fn run_scan( // Fold the legacy mode booleans into `args.mode` (see // `resolve_mode_flags`). Cross-mode combinations are usage errors - // (exit 2), which print no JSON envelope even under --json, like - // clap's own. + // (exit 2); under --json they print the coded error on stdout. if let Err(message) = resolve_mode_flags(&mut args) { - eprintln!("Error: {message}"); - return 2; + // The global-install refusal is the one with its own code: it is + // exactly `global_mode_conflict`'s message for the folded mode. + let global = args + .mode + .and_then(|mode| crate::commands::global_mode_conflict(&args.common, mode)) + .is_some_and(|conflict| conflict == message); + let code = if global { + "global_scope_unsupported" + } else { + "invalid_args" + }; + return scan_usage_error(&args, code, &message); } // The repo's socket.yml policy, read once per invocation before any @@ -1655,8 +1700,7 @@ async fn run_scan( &loaded } Err(PolicyLoadError::Usage(message)) => { - eprintln!("Error: {message}"); - return 2; + return scan_usage_error(&args, "invalid_env", &message); } Err(PolicyLoadError::Policy(err)) => return report_policy_error(&err, &args), }, @@ -1681,10 +1725,7 @@ async fn run_scan( // is a usage error, same exit-2 shape as the mode conflicts. let path_scope = match crate::path_scope::PathScope::parse(&args.paths) { Ok(s) => s, - Err(message) => { - eprintln!("Error: {message}"); - return 2; - } + Err(message) => return scan_usage_error(&args, "path_glob_invalid", &message), }; // The per-run cap on NEW patches (`--max-new-patches` > env > the @@ -1697,10 +1738,7 @@ async fn run_scan( .resolve_from_env(invocation.policy.max_new_patches()) { Ok(max) => max, - Err(message) => { - eprintln!("Error: {message}"); - return 2; - } + Err(message) => return scan_usage_error(&args, "invalid_env", &message), }, }; let mut stage = @@ -1715,7 +1753,7 @@ async fn run_scan( if args.common.json { // Mirror the all-batches-failed error envelope shape so JSON // consumers see one consistent scan-error schema. - print_zero_error_envelope(err, path_scope.raw()); + print_zero_error_envelope("offline_unsupported", err, path_scope.raw()); } else { eprintln!("Error: {err}"); } @@ -2277,7 +2315,7 @@ async fn run_scan( if args.common.json { let result = serde_json::json!({ "status": "error", - "error": err, + "error": { "code": API_BATCH_FAILED, "message": err }, "scannedPackages": package_count, "lockfileOnlyPackages": lockfile_only.purls.len(), "packagesWithPatches": 0, @@ -3249,15 +3287,15 @@ mod tests { ("libs/core".to_string(), true) ] ); - assert!(project_dirs(tmp.path(), &["apps/README".into()]) - .unwrap_err() - .contains("is not a directory")); - assert!(project_dirs(tmp.path(), &["nope/*".into()]) - .unwrap_err() - .contains("matches no directory")); - assert!(project_dirs(tmp.path(), &["x[".into()]) - .unwrap_err() - .contains("invalid path pattern")); + let err = project_dirs(tmp.path(), &["apps/README".into()]).unwrap_err(); + assert_eq!(err.0, "path_not_directory"); + assert!(err.1.contains("is not a directory")); + let err = project_dirs(tmp.path(), &["nope/*".into()]).unwrap_err(); + assert_eq!(err.0, "path_glob_no_match"); + assert!(err.1.contains("matches no directory")); + let err = project_dirs(tmp.path(), &["x[".into()]).unwrap_err(); + assert_eq!(err.0, "path_glob_invalid"); + assert!(err.1.contains("invalid path pattern")); } #[test] diff --git a/crates/socket-patch-cli/src/commands/scan/policy.rs b/crates/socket-patch-cli/src/commands/scan/policy.rs index 5f5fe8afe..692075c33 100644 --- a/crates/socket-patch-cli/src/commands/scan/policy.rs +++ b/crates/socket-patch-cli/src/commands/scan/policy.rs @@ -24,7 +24,7 @@ use crate::hosted_memory::roots::{marker_ecosystem, UNSUPPORTED_MARKERS}; pub(crate) enum PolicyLoadError { /// A malformed flag or env value: exit 2. Usage(String), - /// A policy file that cannot be honored: exit 1, `errorCode`. + /// A policy file that cannot be honored: exit 1, `error.code`. Policy(PolicyError), } @@ -467,12 +467,11 @@ impl ScanPolicy { } /// The JSON error object for a policy file that cannot be honored: scan's -/// error shape plus `errorCode`. +/// error shape, `error: {code, message}`. pub(crate) fn policy_error_json(err: &PolicyError, paths: &[String]) -> serde_json::Value { serde_json::json!({ "status": "error", - "error": err.to_string(), - "errorCode": err.code(), + "error": { "code": err.code(), "message": err.to_string() }, "scannedPackages": 0, "lockfileOnlyPackages": 0, "packagesWithPatches": 0, diff --git a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs index 7240cd1f1..a1f39cd05 100644 --- a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs +++ b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs @@ -669,11 +669,10 @@ async fn run_vendor_json_path( result["vendor"] = serde_json::to_value(&*venv).unwrap_or_else(|_| serde_json::json!({})); } - result["status"] = serde_json::json!("error"); - result["error"] = serde_json::json!({ - "code": code, - "message": message, - }); + crate::json_envelope::set_error( + result, + crate::json_envelope::EnvelopeError::new(code, message), + ); if let Some(obj) = result.as_object_mut() { obj.remove("rollout"); } diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index 2590c2673..7ece1c63d 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -888,15 +888,13 @@ pub async fn run(args: VendorArgs) -> i32 { // Usage errors exit 2, like scan's and get's global mode guard. Checked // before anything reads or locks the project. if let Some(message) = global_scope_conflict(&args) { - if args.common.json { - let mut env = Envelope::new(Command::Vendor); - env.dry_run = args.common.dry_run; - env.mark_error(EnvelopeError::new("global_scope_unsupported", message)); - println!("{}", env.to_pretty_json()); - } else { - eprintln!("Error: {message}"); - } - return 2; + return crate::json_envelope::usage_error( + Command::Vendor, + args.common.json, + args.common.dry_run, + "global_scope_unsupported", + &message, + ); } if args.check { return run_check(&args).await; diff --git a/crates/socket-patch-cli/src/commands/vex.rs b/crates/socket-patch-cli/src/commands/vex.rs index 6fbc6a594..597646ecc 100644 --- a/crates/socket-patch-cli/src/commands/vex.rs +++ b/crates/socket-patch-cli/src/commands/vex.rs @@ -313,15 +313,14 @@ pub async fn run(args: VexArgs) -> i32 { if args.common.json && output.is_none() { // A usage error, not a generation failure: no telemetry POST and no // config read (argument errors never report), just the envelope. - emit_envelope_error( - &args, + return crate::json_envelope::usage_error( + Command::Vex, + args.common.json, + args.common.dry_run, "json_requires_output", "--json requires --output (the VEX document is itself JSON; \ route it to a file so the envelope can use stdout)", - &[], - &[], ); - return 2; } // `-o` is `--org`, `-O` is `--output`: a file-shaped org slug is almost From e4b5819f4bd78e27bf80ce99ce53307fe9ffec9a Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 10:01:50 -0400 Subject: [PATCH 3/8] Update tests and backtest script for the {code, message} error shape (#704) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tests/cli/api_client_errors_e2e.rs | 2 +- .../tests/cli/output_modes_e2e.rs | 4 +- .../tests/cli/telemetry_e2e.rs | 10 ++++- .../tests/covgap_commands_get.rs | 36 ++++++++++-------- .../tests/covgap_commands_rollback.rs | 10 +++-- .../tests/covgap_commands_scan_hosted.rs | 14 +++---- .../tests/covgap_commands_scan_mod.rs | 11 +++++- .../tests/e2e_redirect_vlt_build.rs | 6 +-- .../tests/e2e_socket_yml_policy.rs | 8 ++-- .../tests/get/coverage_fix_get_double_json.rs | 2 +- .../tests/get/get_batch_paths_e2e.rs | 6 +-- .../tests/get/get_modes_e2e.rs | 2 +- .../tests/global_scope_project_state.rs | 2 +- .../tests/in_process_redirect.rs | 14 ++++--- .../tests/in_process_redirect_pnpm.rs | 12 +++--- .../tests/in_process_rollback_hosted.rs | 2 +- .../tests/in_process_rollback_hosted/vlt.rs | 5 ++- .../tests/mode_migration_vlt.rs | 2 +- .../tests/rollback/rollback_invariants.rs | 8 +++- .../tests/scan/hosted_management_refusals.rs | 2 +- .../tests/scan/hosted_symlinked_files.rs | 6 ++- .../tests/scan/scan_invariants.rs | 4 +- .../scan/scan_ordered_concurrency_e2e.rs | 4 +- .../tests/scan/scan_paths_e2e.rs | 37 ++++++++++++------- .../tests/scan_api_retry_e2e.rs | 2 +- scripts/backtest-pipenv.py | 2 +- 26 files changed, 129 insertions(+), 84 deletions(-) diff --git a/crates/socket-patch-cli/tests/cli/api_client_errors_e2e.rs b/crates/socket-patch-cli/tests/cli/api_client_errors_e2e.rs index 9415fb024..a4175802d 100644 --- a/crates/socket-patch-cli/tests/cli/api_client_errors_e2e.rs +++ b/crates/socket-patch-cli/tests/cli/api_client_errors_e2e.rs @@ -57,7 +57,7 @@ fn assert_error_envelope(v: &serde_json::Value, needle: &str) { v["status"], "error", "expected status=error envelope, got: {v}" ); - let msg = v["error"] + let msg = v["error"]["message"] .as_str() .unwrap_or_else(|| panic!("error field must be a string, got: {v}")); assert!(!msg.is_empty(), "error message must not be empty: {v}"); diff --git a/crates/socket-patch-cli/tests/cli/output_modes_e2e.rs b/crates/socket-patch-cli/tests/cli/output_modes_e2e.rs index 31291661d..37713fef5 100644 --- a/crates/socket-patch-cli/tests/cli/output_modes_e2e.rs +++ b/crates/socket-patch-cli/tests/cli/output_modes_e2e.rs @@ -570,7 +570,7 @@ fn get_with_explicit_cve_flag_works() { v["status"], "error", "must report a structured error; got: {stdout}" ); - let err = v["error"].as_str().unwrap_or_default(); + let err = v["error"]["message"].as_str().unwrap_or_default(); assert!( err.contains("by-cve/CVE-2099-99999"), "--cve must route to the by-cve endpoint; got error: {err}" @@ -678,7 +678,7 @@ fn bare_uuid_fallback_treats_uuid_as_get_identifier() { let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("must emit parseable JSON"); assert_eq!(v["status"], "error", "got: {stdout}"); - let err = v["error"].as_str().unwrap_or_default(); + let err = v["error"]["message"].as_str().unwrap_or_default(); assert!( err.contains("patches/view/11111111-1111-4111-8111-111111111111"), "bare-UUID fallback must route to the patch-view endpoint; got error: {err}" diff --git a/crates/socket-patch-cli/tests/cli/telemetry_e2e.rs b/crates/socket-patch-cli/tests/cli/telemetry_e2e.rs index 7301b3ffa..b628df9ec 100644 --- a/crates/socket-patch-cli/tests/cli/telemetry_e2e.rs +++ b/crates/socket-patch-cli/tests/cli/telemetry_e2e.rs @@ -301,7 +301,10 @@ async fn scan_skips_telemetry_in_airgap_mode() { "offline scan must report an error envelope; stdout={stdout}" ); assert!( - v["error"].as_str().unwrap_or_default().contains("offline"), + v["error"]["message"] + .as_str() + .unwrap_or_default() + .contains("offline"), "offline scan's error must name the offline gate; stdout={stdout}" ); @@ -452,7 +455,10 @@ async fn get_skips_telemetry_in_airgap_mode() { "offline get must report an error envelope; stdout={stdout}" ); assert!( - v["error"].as_str().unwrap_or_default().contains("offline"), + v["error"]["message"] + .as_str() + .unwrap_or_default() + .contains("offline"), "offline get's error must name the offline gate; stdout={stdout}" ); diff --git a/crates/socket-patch-cli/tests/covgap_commands_get.rs b/crates/socket-patch-cli/tests/covgap_commands_get.rs index c98f057a7..34ecfcaee 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_get.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_get.rs @@ -523,7 +523,7 @@ async fn get_uuid_view_without_after_hashes_fails_no_applicable_files() { let v = parse_single_json_doc(&stdout); assert_eq!(v["status"], "error", "stdout={stdout}"); assert!( - v["error"] + v["error"]["message"] .as_str() .unwrap_or_default() .contains("no applicable files"), @@ -555,7 +555,10 @@ async fn get_uuid_traversal_after_hash_fails_blob_write_both_modes() { assert_eq!(code, 1, "blob failure must exit 1; stdout={stdout}"); let v = parse_single_json_doc(&stdout); assert_eq!(v["status"], "error", "stdout={stdout}"); - assert_eq!(v["error"], "Blob decode or write failed", "stdout={stdout}"); + assert_eq!( + v["error"]["message"], "Blob decode or write failed", + "stdout={stdout}" + ); assert_eq!(v["patches"][0]["action"], "failed", "stdout={stdout}"); assert_no_manifest(tmp.path()); assert!( @@ -974,9 +977,9 @@ async fn engine_socket_path_occupied_fails_before_any_fetch() { assert_eq!(code, 1, "json={json}"); assert_eq!(json["status"], "error", "json={json}"); - assert_eq!(json["errorCode"], "lock_io", "json={json}"); + assert_eq!(json["error"]["code"], "lock_io", "json={json}"); assert!( - json["error"] + json["error"]["message"] .as_str() .unwrap_or_default() .contains(".socket"), @@ -1027,9 +1030,9 @@ async fn engine_readonly_socket_fails_closed_before_any_fetch() { assert_eq!(code, 1, "json={json}"); assert_eq!(json["status"], "error", "json={json}"); - assert_eq!(json["errorCode"], "lock_io", "json={json}"); + assert_eq!(json["error"]["code"], "lock_io", "json={json}"); assert!( - json["error"] + json["error"]["message"] .as_str() .unwrap_or_default() .contains(".socket"), @@ -1074,7 +1077,7 @@ async fn engine_readonly_socket_fails_manifest_write() { assert_eq!(code, 1, "json={json}"); assert_eq!(json["status"], "error", "json={json}"); assert!( - json["error"] + json["error"]["message"] .as_str() .unwrap_or_default() .contains("Failed to write manifest"), @@ -1122,7 +1125,7 @@ async fn engine_manifest_write_failure_unwinds_the_blobs_it_wrote() { assert_eq!(code, 1, "json={json}"); assert!( - json["error"] + json["error"]["message"] .as_str() .unwrap_or_default() .contains("Failed to write manifest"), @@ -2087,7 +2090,7 @@ async fn engine_human_readonly_socket_manifest_write_failure_still_errors() { assert_eq!(code, 1, "json={json}"); assert_eq!(json["status"], "error", "json={json}"); assert!( - json["error"] + json["error"]["message"] .as_str() .unwrap_or_default() .contains("Failed to write manifest"), @@ -2457,14 +2460,14 @@ async fn hosted_lock_held_get_errors_with_top_level_error_code() { assert_eq!(code, 1, "stdout={stdout}\nstderr={stderr}"); let v = parse_single_json_doc(&stdout); assert_eq!(v["status"], "error", "stdout={stdout}"); - assert_eq!(v["errorCode"], "lock_held", "stdout={stdout}"); + assert_eq!(v["error"]["code"], "lock_held", "stdout={stdout}"); assert_eq!( - v["error"], HELD, - "the hosted envelope carries a string `error`, not the vendored object; stdout={stdout}" + v["error"]["message"], HELD, + "the hosted envelope carries the `{{code, message}}` error object; stdout={stdout}" ); assert!( - v["error"].get("code").is_none(), - "no nested `error.code` on the hosted shape; stdout={stdout}" + v.get("errorCode").is_none(), + "no top-level `errorCode` (v5.0); stdout={stdout}" ); assert_eq!( v["redirect"]["mode"], "hosted", @@ -2838,7 +2841,10 @@ async fn forced_identifier_type_is_validated_locally() { assert_eq!(code, 2); let v = parse_single_json_doc(&stdout); assert_eq!(v["status"], "error", "{v}"); - assert!(v["error"].as_str().unwrap().contains(what), "{v}"); + assert!( + v["error"]["message"].as_str().unwrap().contains(what), + "{v}" + ); } assert!( received_paths(&server).await.is_empty(), diff --git a/crates/socket-patch-cli/tests/covgap_commands_rollback.rs b/crates/socket-patch-cli/tests/covgap_commands_rollback.rs index 2c2ca6b33..de23f8f1b 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_rollback.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_rollback.rs @@ -514,7 +514,7 @@ fn corrupt_manifest_json_errors_in_both_modes() { let v = parse_envelope(&stdout, &stderr); assert_eq!(v["status"], "error", "stdout=\n{stdout}"); assert!( - v["error"] + v["error"]["message"] .as_str() .is_some_and(|e| e.contains("Failed to parse manifest JSON")), "the error must name the parse failure; stdout=\n{stdout}" @@ -572,7 +572,9 @@ fn blobs_path_as_file_yields_legacy_error_envelope() { let v = parse_envelope(&stdout, &stderr); assert_eq!(v["status"], "error", "stdout=\n{stdout}"); assert!( - v["error"].as_str().is_some_and(|e| !e.is_empty()), + v["error"]["message"] + .as_str() + .is_some_and(|e| !e.is_empty()), "the envelope carries the io error; stdout=\n{stdout}" ); assert_eq!(v["rolledBack"], 0, "stdout=\n{stdout}"); @@ -705,7 +707,7 @@ fn ledgerless_wired_lock_errors_with_state_json_guidance() { let v = parse_envelope(&stdout, &stderr); assert_eq!(v["status"], "error", "stdout=\n{stdout}"); assert!( - v["error"].as_str().is_some_and(|e| { + v["error"]["message"].as_str().is_some_and(|e| { e.contains("lockfiles still reference .socket/vendor/ artifacts") && e.contains("restore .socket/vendor/state.json") }), @@ -2499,7 +2501,7 @@ fn manifest_deleted_under_held_lock_fails_with_invalid_manifest() { ); let v = parse_envelope(&stdout, &stderr); assert_eq!(v["status"], "error", "stdout=\n{stdout}"); - match v["error"].as_str() { + match v["error"]["message"].as_str() { Some("Invalid manifest") => return, // target interleaving reached Some("Manifest not found") => continue, // probed after the delete — retry other => panic!( diff --git a/crates/socket-patch-cli/tests/covgap_commands_scan_hosted.rs b/crates/socket-patch-cli/tests/covgap_commands_scan_hosted.rs index 15a052534..470e156ee 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_scan_hosted.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_scan_hosted.rs @@ -678,8 +678,8 @@ async fn takeover_refuses_symlinked_wiring_file_before_reverting() { "dry_run={dry_run}: a symlinked revert target fails the run: {doc:#}" ); assert_eq!(doc["status"], "error", "dry_run={dry_run}: {doc:#}"); - assert_eq!(doc["errorCode"], CODE, "dry_run={dry_run}: {doc:#}"); - let error = doc["error"].as_str().unwrap_or_default(); + assert_eq!(doc["error"]["code"], CODE, "dry_run={dry_run}: {doc:#}"); + let error = doc["error"]["message"].as_str().unwrap_or_default(); assert!( error.starts_with("package-lock.json is a symbolic link") && error.ends_with("nothing was written"), @@ -738,9 +738,9 @@ async fn hosted_lock_held_refuses_before_any_write() { let (code, doc) = scan_hosted_json(root, &server.uri(), &[], &[]); assert_eq!(code, 1, "a held lock refuses the wet run: {doc:#}"); assert_eq!(doc["status"], "error", "{doc:#}"); - assert_eq!(doc["errorCode"], "lock_held", "{doc:#}"); + assert_eq!(doc["error"]["code"], "lock_held", "{doc:#}"); assert_eq!( - doc["error"], HELD, + doc["error"]["message"], HELD, "no --lock-timeout: no waited clause; {doc:#}" ); assert_eq!( @@ -845,7 +845,7 @@ async fn zero_grant_wet_run_ignores_a_malformed_pre_v5_ledger() { .unwrap(); let (code, doc) = scan_hosted_json(root, &no_grant.uri(), &[], &[]); assert_ne!( - doc["errorCode"], "lock_held", + doc["error"]["code"], "lock_held", "a zero-grant run never contends: {doc:#}" ); assert_ignored(code, &doc, "held lock"); @@ -894,8 +894,8 @@ async fn hosted_lock_io_when_a_file_squats_on_socket_dir() { let (code, doc) = scan_hosted_json(root, &server.uri(), &[], &[]); assert_eq!(code, 1, "{doc:#}"); assert_eq!(doc["status"], "error", "{doc:#}"); - assert_eq!(doc["errorCode"], "lock_io", "{doc:#}"); - let error = doc["error"].as_str().unwrap_or_default(); + assert_eq!(doc["error"]["code"], "lock_io", "{doc:#}"); + let error = doc["error"]["message"].as_str().unwrap_or_default(); assert!( error.starts_with("failed to open lock file at ") && error.contains(".socket"), "the fault names the squatting path: {error}" diff --git a/crates/socket-patch-cli/tests/covgap_commands_scan_mod.rs b/crates/socket-patch-cli/tests/covgap_commands_scan_mod.rs index 71767eca0..dc92b7552 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_scan_mod.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_scan_mod.rs @@ -2287,10 +2287,17 @@ fn scan_hosted_rejects_global() { "{stderr:?}" ); assert!(stdout.is_empty()); - // Like every usage error (and clap's own), no JSON envelope under --json. + // Under --json a self-enforced usage error prints the coded error on + // stdout (v5.0; clap's own parse errors still print nothing there). let (code, stdout, _) = run_scan(tmp.path(), &["--mode", "hosted", "--global", "--json"]); assert_eq!(code, 2); - assert!(stdout.trim().is_empty(), "{stdout:?}"); + let v: serde_json::Value = serde_json::from_str(&stdout).expect("JSON on stdout"); + assert_eq!(v["status"], "error", "{v}"); + assert_eq!(v["error"]["code"], "global_scope_unsupported", "{v}"); + assert!(v["error"]["message"] + .as_str() + .unwrap() + .starts_with("--global cannot be used with --mode hosted")); let prefix = tempfile::tempdir().unwrap(); let (code, _, stderr) = run_scan( tmp.path(), diff --git a/crates/socket-patch-cli/tests/e2e_redirect_vlt_build.rs b/crates/socket-patch-cli/tests/e2e_redirect_vlt_build.rs index 3807f9e8f..b71d70629 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_vlt_build.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_vlt_build.rs @@ -658,7 +658,7 @@ async fn resave_install_rollback(name: &'static str, crlf: bool) { let dropped = lock_bytes(&fx.proj); let out = fx.rollback(&[]); assert_eq!( - (out.code, out.json()["error"].as_str()), + (out.code, out.json()["error"]["message"].as_str()), (1, Some("Manifest not found")), "a URL-less node is no hosted pin: {out}" ); @@ -729,7 +729,7 @@ async fn vlt_pinned_matrix_hosted_resave_update_rollback() { // The update already put the lock back on the registry: v5 keeps no // ledger, so no hosted pin (and no heal target) is left to find. assert_eq!( - (out.code, out.json()["error"].as_str()), + (out.code, out.json()["error"]["message"].as_str()), (1, Some("Manifest not found")), "already reverted: nothing to roll back: {out}" ); @@ -1203,7 +1203,7 @@ async fn vlt_pinned_matrix_hosted_idempotence() { let files = package_files(&fx.proj); let out = fx.rollback(&[]); assert_eq!( - (out.code, out.json()["error"].as_str()), + (out.code, out.json()["error"]["message"].as_str()), (1, Some("Manifest not found")), "a second rollback finds no state: {out}" ); diff --git a/crates/socket-patch-cli/tests/e2e_socket_yml_policy.rs b/crates/socket-patch-cli/tests/e2e_socket_yml_policy.rs index 50018d6a9..ab55826f4 100644 --- a/crates/socket-patch-cli/tests/e2e_socket_yml_policy.rs +++ b/crates/socket-patch-cli/tests/e2e_socket_yml_policy.rs @@ -564,8 +564,8 @@ async fn invalid_file_fails_closed_before_any_request_or_write() { let (code, doc) = scan_json(&repo.dir("services/web"), &server.uri(), &[], &[]); assert_eq!(code, 1); assert_eq!(doc["status"], "error"); - assert_eq!(doc["errorCode"], "socket_yml_invalid"); - let message = doc["error"].as_str().unwrap(); + assert_eq!(doc["error"]["code"], "socket_yml_invalid"); + let message = doc["error"]["message"].as_str().unwrap(); assert!(message.contains("patches.minSeverty"), "{message}"); assert!(message.contains("did you mean `minSeverity`"), "{message}"); assert!(message.contains("--no-socket-yml"), "{message}"); @@ -596,7 +596,7 @@ async fn both_files_disagreeing_is_ambiguous() { std::fs::write(repo.root.join("socket.yaml"), "version: 2\npatches:\n maxNewPatches: 2\n").unwrap(); let (code, doc) = scan_json(&repo.dir("services/web"), &server.uri(), &[], &[]); assert_eq!(code, 1); - assert_eq!(doc["errorCode"], "socket_yml_ambiguous"); + assert_eq!(doc["error"]["code"], "socket_yml_ambiguous"); } #[tokio::test] @@ -705,7 +705,7 @@ async fn report_only_json_fails_when_every_detail_query_fails() { assert_eq!(code, 1, "{doc:#}"); assert_eq!(doc["status"], "error", "{doc:#}"); assert!( - doc["error"].as_str().unwrap_or_default().contains("patch-detail queries failed"), + doc["error"]["message"].as_str().unwrap_or_default().contains("patch-detail queries failed"), "{doc:#}" ); assert_eq!(repo.snapshot(), before); diff --git a/crates/socket-patch-cli/tests/get/coverage_fix_get_double_json.rs b/crates/socket-patch-cli/tests/get/coverage_fix_get_double_json.rs index 430895942..905a9268d 100644 --- a/crates/socket-patch-cli/tests/get/coverage_fix_get_double_json.rs +++ b/crates/socket-patch-cli/tests/get/coverage_fix_get_double_json.rs @@ -85,7 +85,7 @@ async fn search_get_json_engine_hard_error_prints_one_document() { }); assert_eq!(v["status"], "error", "envelope drifted: {v}"); assert!( - v["error"] + v["error"]["message"] .as_str() .is_some_and(|m| m.contains("Failed to read manifest")), "error message drifted: {v}" diff --git a/crates/socket-patch-cli/tests/get/get_batch_paths_e2e.rs b/crates/socket-patch-cli/tests/get/get_batch_paths_e2e.rs index 0c0883851..77b00b329 100644 --- a/crates/socket-patch-cli/tests/get/get_batch_paths_e2e.rs +++ b/crates/socket-patch-cli/tests/get/get_batch_paths_e2e.rs @@ -156,7 +156,7 @@ async fn get_by_purl_with_multiple_patches_emits_selection_required() { // happens by re-running with the chosen UUID as the positional // identifier; a "Specify --id " instruction would send users // straight into a clap usage error. - let err = v["error"].as_str().unwrap_or(""); + let err = v["error"]["message"].as_str().unwrap_or(""); assert!( !err.contains("--id <"), "error must not instruct the value-taking `--id ` form the CLI rejects; got {err:?}" @@ -268,7 +268,7 @@ async fn get_uuid_returning_500_emits_error() { let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON error envelope"); assert_eq!(v["status"], "error", "5xx must surface as error"); - let err = v["error"] + let err = v["error"]["message"] .as_str() .expect("error envelope must carry an error string"); assert!( @@ -296,7 +296,7 @@ async fn get_uuid_returning_malformed_json_emits_error() { let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON error envelope"); assert_eq!(v["status"], "error", "parse failure must surface as error"); - let err = v["error"] + let err = v["error"]["message"] .as_str() .expect("error envelope must carry an error string"); assert!( diff --git a/crates/socket-patch-cli/tests/get/get_modes_e2e.rs b/crates/socket-patch-cli/tests/get/get_modes_e2e.rs index cada79646..cb1364f45 100644 --- a/crates/socket-patch-cli/tests/get/get_modes_e2e.rs +++ b/crates/socket-patch-cli/tests/get/get_modes_e2e.rs @@ -502,7 +502,7 @@ async fn save_only_with_mode_conflicts_exit_two() { ); let v = parse_single_json_doc(&stdout); assert_eq!(v["status"], "error", "envelope={v}"); - let msg = v["error"] + let msg = v["error"]["message"] .as_str() .unwrap_or_else(|| panic!("get's conflict envelope carries a string error; got {v}")); assert!( diff --git a/crates/socket-patch-cli/tests/global_scope_project_state.rs b/crates/socket-patch-cli/tests/global_scope_project_state.rs index 67be302a9..8f0f9b945 100644 --- a/crates/socket-patch-cli/tests/global_scope_project_state.rs +++ b/crates/socket-patch-cli/tests/global_scope_project_state.rs @@ -84,7 +84,7 @@ fn get_refuses_project_modes_under_global_scope() { let v = parse(&stdout, ""); assert_eq!(v["status"], "error", "{v}"); assert!( - v["error"] + v["error"]["message"] .as_str() .unwrap() .starts_with(&expected["Error: ".len()..]), diff --git a/crates/socket-patch-cli/tests/in_process_redirect.rs b/crates/socket-patch-cli/tests/in_process_redirect.rs index 7a2f81271..dfa780d59 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect.rs @@ -1911,7 +1911,7 @@ async fn symlinked_bun_lockb_refuses_before_editing_including_dry_run() { let env: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); assert_eq!(output.status.code(), Some(1), "{env:#}"); assert_eq!( - env["errorCode"], "redirect_symlinked_file_unsupported", + env["error"]["code"], "redirect_symlinked_file_unsupported", "{env:#}" ); assert_eq!( @@ -3516,7 +3516,9 @@ async fn redirect_json_mode_failures_emit_error_envelope() { "{leg}: envelope status; stdout=\n{stdout}" ); assert!( - v["error"].as_str().is_some_and(|m| !m.is_empty()), + v["error"]["message"] + .as_str() + .is_some_and(|m| !m.is_empty()), "{leg}: envelope must carry the error message; stdout=\n{stdout}" ); assert_eq!( @@ -3620,7 +3622,9 @@ fn assert_write_failure_envelope(out: &std::process::Output, leg: &str) { }); assert_eq!(v["status"], "error", "{leg}: status; stdout=\n{stdout}"); assert!( - v["error"].as_str().is_some_and(|m| !m.is_empty()), + v["error"]["message"] + .as_str() + .is_some_and(|m| !m.is_empty()), "{leg}: envelope must carry the error message; stdout=\n{stdout}" ); assert_eq!( @@ -5062,11 +5066,11 @@ async fn cargo_hosted_scan_from_workspace_member_refuses() { assert_eq!(out.status.code(), Some(1), "{doc}"); assert_eq!(doc["status"], "error", "{doc}"); assert_eq!( - doc["errorCode"], "cargo_manifest_not_workspace_root", + doc["error"]["code"], "cargo_manifest_not_workspace_root", "{doc}" ); assert!( - doc["error"] + doc["error"]["message"] .as_str() .is_some_and(|m| m.contains("workspace root") && m.contains("nothing was written")), "{doc}" diff --git a/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs b/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs index 47937792d..bbc04bb99 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs @@ -1241,10 +1241,10 @@ fn assert_refused_lock_elsewhere( ); assert_eq!(doc["status"], "error", "{doc}"); assert_eq!( - doc["errorCode"], "redirect_pnpm_lockfile_elsewhere", + doc["error"]["code"], "redirect_pnpm_lockfile_elsewhere", "{doc}" ); - let message = doc["error"].as_str().unwrap_or_default(); + let message = doc["error"]["message"].as_str().unwrap_or_default(); assert!( message.contains("pnpm-lock.yaml") && message.contains("nothing was written"), "the error names the governing lock: {message}" @@ -1464,10 +1464,10 @@ async fn hosted_scan_from_pnpm_member_with_own_lock_never_nests_trust_config() { assert_eq!(code, Some(1), "{doc}"); assert_eq!(doc["status"], "error", "{doc}"); assert_eq!( - doc["errorCode"], "redirect_pnpm_settings_elsewhere", + doc["error"]["code"], "redirect_pnpm_settings_elsewhere", "{doc}" ); - let message = doc["error"].as_str().unwrap_or_default(); + let message = doc["error"]["message"].as_str().unwrap_or_default(); assert!( message.contains(&root_ws.display().to_string()) && message.contains("trustLockfile: true") @@ -1650,10 +1650,10 @@ fn assert_refused_workspace_lock_elsewhere( ); assert_eq!(doc["status"], "error", "{case}: {doc}"); assert_eq!( - doc["errorCode"], "redirect_workspace_lockfile_elsewhere", + doc["error"]["code"], "redirect_workspace_lockfile_elsewhere", "{case}: {doc}" ); - let message = doc["error"].as_str().unwrap_or_default(); + let message = doc["error"]["message"].as_str().unwrap_or_default(); let lock_name = lock.file_name().unwrap().to_str().unwrap(); assert!( message.contains(lock_name) && message.contains("nothing was written"), diff --git a/crates/socket-patch-cli/tests/in_process_rollback_hosted.rs b/crates/socket-patch-cli/tests/in_process_rollback_hosted.rs index 5091adb59..32dca93ec 100644 --- a/crates/socket-patch-cli/tests/in_process_rollback_hosted.rs +++ b/crates/socket-patch-cli/tests/in_process_rollback_hosted.rs @@ -1210,7 +1210,7 @@ async fn hosted_only_project_without_manifest() { ); assert_eq!(envelope["status"], "error", "{envelope}"); assert!( - envelope["error"] + envelope["error"]["message"] .as_str() .unwrap_or_default() .contains("Manifest not found"), diff --git a/crates/socket-patch-cli/tests/in_process_rollback_hosted/vlt.rs b/crates/socket-patch-cli/tests/in_process_rollback_hosted/vlt.rs index 0695f6fdf..cc0067b93 100644 --- a/crates/socket-patch-cli/tests/in_process_rollback_hosted/vlt.rs +++ b/crates/socket-patch-cli/tests/in_process_rollback_hosted/vlt.rs @@ -164,7 +164,10 @@ async fn vlt_rollback_after_a_relock_has_no_hosted_state_left() { let what = format!("flags={flags} version={version}"); assert_eq!(code, 1, "{what}: {doc:#}\n{stderr}"); - assert_eq!(doc["error"], "Manifest not found", "{what}: {doc:#}"); + assert_eq!( + doc["error"]["message"], "Manifest not found", + "{what}: {doc:#}" + ); assert_eq!(read(root, "vlt-lock.json"), relocked, "{what}"); assert!( store_dir(root, TILDE_ID).join("index.js").exists(), diff --git a/crates/socket-patch-cli/tests/mode_migration_vlt.rs b/crates/socket-patch-cli/tests/mode_migration_vlt.rs index ce9c6341d..0d6c4b17f 100644 --- a/crates/socket-patch-cli/tests/mode_migration_vlt.rs +++ b/crates/socket-patch-cli/tests/mode_migration_vlt.rs @@ -723,7 +723,7 @@ async fn vlt_pinned_matrix_migration_pm_switch_vlt_to_npm() { let files = package_files(&fx.proj); let out = rollback_all(&fx, &fx.proj, &[]); assert_eq!( - (out.code, out.json()["error"].as_str()), + (out.code, out.json()["error"]["message"].as_str()), (1, Some("Manifest not found")), "no lock, no pin, nothing to roll back: {out}" ); diff --git a/crates/socket-patch-cli/tests/rollback/rollback_invariants.rs b/crates/socket-patch-cli/tests/rollback/rollback_invariants.rs index 8a994e448..f0831c770 100644 --- a/crates/socket-patch-cli/tests/rollback/rollback_invariants.rs +++ b/crates/socket-patch-cli/tests/rollback/rollback_invariants.rs @@ -120,7 +120,9 @@ fn rollback_with_no_manifest_emits_error() { assert_eq!(v["status"], "error"); // Pin the *specific* error so a regression that exits 1 for some other // reason (e.g. ambient env steering it elsewhere) can't pass. - let err = v["error"].as_str().expect("error message string"); + let err = v["error"]["message"] + .as_str() + .expect("error message string"); assert!( err.contains("Manifest not found"), "unexpected error message: {err}" @@ -138,7 +140,9 @@ fn rollback_unknown_identifier_emits_error() { assert_eq!(code, 1, "unknown identifier must exit 1; stdout=\n{stdout}"); let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); assert_eq!(v["status"], "error"); - let err = v["error"].as_str().expect("error message string"); + let err = v["error"]["message"] + .as_str() + .expect("error message string"); assert!( err.contains("No patch found matching identifier"), "unexpected error: {err}" diff --git a/crates/socket-patch-cli/tests/scan/hosted_management_refusals.rs b/crates/socket-patch-cli/tests/scan/hosted_management_refusals.rs index 19b0f4b39..e6d756302 100644 --- a/crates/socket-patch-cli/tests/scan/hosted_management_refusals.rs +++ b/crates/socket-patch-cli/tests/scan/hosted_management_refusals.rs @@ -100,7 +100,7 @@ fn rollback_refuses_contested_hosted_wiring_and_names_it() { let before = snapshot(tmp.path()); let (code, v) = run_json(&["rollback", "--json", "--yes"], tmp.path()); assert_eq!(code, Some(1), "{v}"); - let err = v["error"].as_str().unwrap_or_default(); + let err = v["error"]["message"].as_str().unwrap_or_default(); assert!( err.contains("npm-shrinkwrap.json") && err.contains("git checkout --"), "the refusal names the contested file and the remedy: {v}" diff --git a/crates/socket-patch-cli/tests/scan/hosted_symlinked_files.rs b/crates/socket-patch-cli/tests/scan/hosted_symlinked_files.rs index 431211e70..6bc79e5b5 100644 --- a/crates/socket-patch-cli/tests/scan/hosted_symlinked_files.rs +++ b/crates/socket-patch-cli/tests/scan/hosted_symlinked_files.rs @@ -327,8 +327,10 @@ fn assert_refused_untouched( "a symlinked rewrite target must fail the run: {doc:#}" ); assert_eq!(doc["status"], "error", "{doc:#}"); - assert_eq!(doc["errorCode"], CODE, "{doc:#}"); - let message = doc["error"].as_str().unwrap_or_else(|| panic!("{doc:#}")); + assert_eq!(doc["error"]["code"], CODE, "{doc:#}"); + let message = doc["error"]["message"] + .as_str() + .unwrap_or_else(|| panic!("{doc:#}")); assert!( message.contains(linked) && message.contains("symbolic link"), "the error must name the linked file: {message}" diff --git a/crates/socket-patch-cli/tests/scan/scan_invariants.rs b/crates/socket-patch-cli/tests/scan/scan_invariants.rs index c3316ee78..7596c89b5 100644 --- a/crates/socket-patch-cli/tests/scan/scan_invariants.rs +++ b/crates/socket-patch-cli/tests/scan/scan_invariants.rs @@ -1074,7 +1074,7 @@ async fn scan_apply_all_detail_queries_failed_emits_json_error_envelope() { "a total detail-phase failure must be reported as status=error; envelope={v}" ); assert!( - v["error"].is_string() && !v["error"].as_str().unwrap().is_empty(), + v["error"]["message"].is_string() && !v["error"]["message"].as_str().unwrap().is_empty(), "the error envelope must carry a diagnosable message; envelope={v}" ); assert_ne!(code, 0, "exit code must stay non-zero; envelope={v}"); @@ -1110,7 +1110,7 @@ async fn scan_vendored_all_detail_queries_failed_emits_json_error_envelope() { "a total detail-phase failure must be reported as status=error; envelope={v}" ); assert!( - v["error"].is_string() && !v["error"].as_str().unwrap().is_empty(), + v["error"]["message"].is_string() && !v["error"]["message"].as_str().unwrap().is_empty(), "the error envelope must carry a diagnosable message; envelope={v}" ); assert_ne!(code, 0, "exit code must stay non-zero; envelope={v}"); diff --git a/crates/socket-patch-cli/tests/scan/scan_ordered_concurrency_e2e.rs b/crates/socket-patch-cli/tests/scan/scan_ordered_concurrency_e2e.rs index 8595a1286..454ad4396 100644 --- a/crates/socket-patch-cli/tests/scan/scan_ordered_concurrency_e2e.rs +++ b/crates/socket-patch-cli/tests/scan/scan_ordered_concurrency_e2e.rs @@ -487,7 +487,7 @@ async fn all_batches_failed_reports_the_last_chunks_error() { let v: serde_json::Value = serde_json::from_str(&stdout).unwrap(); assert_eq!(v["status"], "error"); assert_eq!( - v["error"].as_str().unwrap(), + v["error"]["message"].as_str().unwrap(), format!( "API request failed with status 500: boom-{}", NAMES[order[5]] @@ -760,7 +760,7 @@ async fn all_detail_fetches_failed_reports_the_last_packages_error() { ); assert_eq!(code, 1, "stdout={stdout} stderr={stderr}"); let v: serde_json::Value = serde_json::from_str(&stdout).unwrap(); - let err = v["error"].as_str().unwrap(); + let err = v["error"]["message"].as_str().unwrap(); assert!( err.starts_with("all 6 patch-detail queries failed: ") && err.ends_with(&format!("detail-boom-{}", NAMES[5])), diff --git a/crates/socket-patch-cli/tests/scan/scan_paths_e2e.rs b/crates/socket-patch-cli/tests/scan/scan_paths_e2e.rs index 8ad94e551..afc5e3f54 100644 --- a/crates/socket-patch-cli/tests/scan/scan_paths_e2e.rs +++ b/crates/socket-patch-cli/tests/scan/scan_paths_e2e.rs @@ -564,24 +564,22 @@ async fn paths_with_hosted_or_vendored_mode_name_project_directories() { "a PATH that is not a directory is a usage error (exit 2) under --mode {mode}; \ stdout={stdout}; stderr={stderr}" ); - assert!( - stderr.contains("`packages/app` is not a directory"), - "stderr={stderr}" - ); - assert!( - stdout.trim().is_empty(), - "a usage error must not print a JSON envelope; stdout={stdout}" + // Under --json a usage error prints the coded error on stdout. + assert_usage_error( + &stdout, + "path_not_directory", + "`packages/app` is not a directory", ); } // --json keeps stdout one document: one project directory only. let (code, stdout, stderr) = run_scan(tmp.path(), "http://127.0.0.1:1", &["apps/*"]); assert_eq!(code, 2, "stdout={stdout}; stderr={stderr}"); - assert!( - stderr.contains("--json takes one project directory (2 given)"), - "stderr={stderr}" + assert_usage_error( + &stdout, + "invalid_args", + "--json takes one project directory (2 given)", ); - assert!(stdout.trim().is_empty(), "stdout={stdout}"); // --vex names one output document, so it takes one project directory // too: two runs would overwrite (or on a failure remove) the same file. @@ -626,10 +624,23 @@ async fn paths_with_hosted_or_vendored_mode_name_project_directories() { code, 2, "an invalid glob must be a usage error (exit 2); stdout={stdout}; stderr={stderr}" ); + assert_usage_error(&stdout, "path_glob_invalid", "invalid path pattern"); +} + +/// A `--json` usage error: exactly `{status: "error", error: {code, +/// message}}` on stdout, the message containing `needle`. +fn assert_usage_error(stdout: &str, code: &str, needle: &str) { + let v = parse_envelope(stdout); + assert_eq!(v["status"], "error", "{v}"); + assert_eq!(v["error"]["code"], code, "{v}"); assert!( - stderr.to_lowercase().contains("invalid path pattern"), - "the error must name the invalid pattern; stderr={stderr}" + v["error"]["message"] + .as_str() + .is_some_and(|m| m.contains(needle)), + "{v}" ); + assert!(v.get("errorCode").is_none(), "{v}"); + assert_eq!(v.as_object().unwrap().len(), 2, "{v}"); } // --------------------------------------------------------------------------- diff --git a/crates/socket-patch-cli/tests/scan_api_retry_e2e.rs b/crates/socket-patch-cli/tests/scan_api_retry_e2e.rs index 37613825c..0b36f1f41 100644 --- a/crates/socket-patch-cli/tests/scan_api_retry_e2e.rs +++ b/crates/socket-patch-cli/tests/scan_api_retry_e2e.rs @@ -371,7 +371,7 @@ async fn every_batch_exhausted_is_the_all_failed_error() { assert_eq!(code, 1, "{stdout}\n{stderr}"); let v = json(&stdout); assert_eq!(v["status"], "error"); - let err = v["error"].as_str().unwrap(); + let err = v["error"]["message"].as_str().unwrap(); assert!( err.starts_with("API request failed with status 503: busy-") && err.ends_with(" (gave up after 3 retries)"), diff --git a/scripts/backtest-pipenv.py b/scripts/backtest-pipenv.py index e6bfe5228..ab29df08b 100755 --- a/scripts/backtest-pipenv.py +++ b/scripts/backtest-pipenv.py @@ -1188,7 +1188,7 @@ def cli_run(penv_, *rest, log): lock_ok = post == relocked if mode == "hosted" and not hybrid: # No pin, no ledger, no manifest: nothing to roll back. - retired = rrb.rc == 1 and erb2.get("error") == "Manifest not found" + retired = rrb.rc == 1 and (erb2.get("error") or {}).get("message") == "Manifest not found" else: retired = rrb.ok() check("rollbackAfterRelockRetires", retired and cleared and lock_ok, {"exit": rrb.rc, "cleared": cleared, "hybridRelock": hybrid, "lockKeptRelocked": post == relocked, "lockRestoredOriginal": post == pristine_lock, "referenceLeft": marker in post, "envelope": {k: erb2.get(k) for k in ("status", "hosted", "vendoredReverted", "failed") if k in erb2}, "tail": rrb.tail(400) if not rrb.ok() else None}) From ba92cea6982594dcbe7d871f5b9276245f3706b0 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 10:01:51 -0400 Subject: [PATCH 4/8] Document the single --json error shape in the contract and v5 guide (#704) Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-cli/CLI_CONTRACT.md | 75 ++++++++++++++++++------- docs/migrating-to-v5.md | 24 ++++++++ 2 files changed, 79 insertions(+), 20 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index b02fa44fa..4dca08bb2 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -161,7 +161,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc **Vendored entries and the rest of the CLI.** Because nothing is in the manifest, vendored patches are invisible to `apply` (nothing to apply in place) but fully visible to `list` (listed from the ledger, labeled `Mode: vendored (recorded in .socket/vendor/state.json)` in human mode, exit 0 on a vendored-only project), `vex` (attested from the embedded records while a lockfile still wires the artifact — see "Manifest-less VEX"), `repair` (health-checked and rebuilt from the ledger), and `scan --prune` (lockfile-driven reconcile). They are exempt from standalone `vendor`'s manifest reconcile (`reconcile_dropped` never touches `detached` entries) and exit via `remove ` (which reverts them), `vendor --revert`, or `rollback`, whose vendored leg reverts every in-scope ledger entry (unscoped and identifier-scoped runs; path-scoped runs reach them only when an installed copy matches). -`scan --mode hosted` swaps the in-place apply for the registry-redirect pipeline: discover → resolve hosted-patch references (grant token + integrity + per-dep registry override) → rewrite ONLY the patched dependencies' lockfile / registry-config entries to point at the hosted packages. A dep counts as **redirected** only when its hosted-artifact URL (or per-dep registry index URL) actually landed in a project file — a granted reference whose rewriter found nothing to edit is neither counted nor attested. **No ledger (v5.0)**: hosted mode writes ONLY the lockfile / registry-config edits — `.socket/vendor/redirect-state.json` is never written (on success or failure), and a pre-v5 one on disk is ignored (never read for planning, never quarantined, left byte-identical). The lockfiles are the only record of a hosted patch: `list`, `vex`, `rollback`, `remove`, `vendor` and `repair` all discover the hosted pins from them (a hosted URL counts only on `https://patch.socket.dev` or the `--patch-server-url` / `SOCKET_PATCH_SERVER_URL` origin), and commit-ready output is just the lockfile / config changes. Cargo and golang are confirmed only by their rewriter's own report (`confirmed_cargo_uuids` / `confirmed_golang_uuids`): a golang dep counts only when its go.mod `replace M V => patch.socket.dev/gopatch/ ` and both go.sum lines are in place, never because the patch-server origin or leftover go.sum lines appear somewhere. Gradle is confirmed the same way (`confirmed_gradle_uuids`): only when the final files hold the owned script, the index row, the live apply line in every build's settings file and the suffixed version in every lock entry of the GA (see [Gradle builds](#gradle-builds-v50)). A golang module that go.mod does not require and go.sum does not list at the patched version is outside the build graph and is refused with `redirect_golang_not_in_module_graph` (nothing written). Only the exact module `patch.socket.dev/gopatch/` is socket-owned; any other module path is refused with `redirect_golang_untrusted_module_path`. A vendored golang module is taken over like cargo and the npm family: its vendor wiring, committed copy and ledger entry are reverted first (`redirect_takeover_reverted_vendored`). A vendored PyPI package (requirements.txt, Poetry, Pipenv, uv, Hatch, PDM, pylock) is taken over the same way: its vendored wiring is restored to the recorded registry entry, its ledger entry and wheel are removed, and only then is it redirected. The Python rewriters treat any non-registry source as user-authored, so without the revert they refused socket-patch's own vendored source and left the project vendored. A takeover revert that leaves vendored wiring in place is refused with `redirect_vendored_revert_failed`. That covers a drift-skipped record (`vendor_lock_entry_drifted`) and a reverted file that still references the artifact (`vendor_revert_residual_reference`). The ledger entry and artifact are kept, and the package stays vendored and skipped. `--dry-run` predicts the same refusal from the same signals instead of previewing `redirect_would_revert_vendored`. The hosted requirements.txt rewriter only rewrites an existing pin in the root `requirements.txt`, so a vendored requirements.txt package whose wiring is a pin in a `-r` include or a `(transitive)` line vendored mode appended is refused BEFORE its revert, wet and `--dry-run` alike, with `redirect_requirements_takeover_unreachable` (`redirect.warnings[]`, and `redirect.skipped[].reason`). Its wiring, ledger entry and wheel are kept, so it stays vendored and patched (exit 0). A taken-over package whose wiring was reverted but that was then not pinned to hosted now installs the unpatched registry release in both modes. Causes include a refused lock, unavailable hosted wheel metadata, or a vendored ledger update that failed after the revert (refused with `redirect_vendored_revert_failed`). It is reported as `redirect_takeover_unpatched` with `status: "partial_failure"` and exit 1, never as success. That warning also prints under `--silent`. Human output prints no `Migrated …` progress line for the package and no "keep the hosted patches" next steps. Re-runs over already-rewritten output plan from the current lock text and are idempotent (exit 0, lock unchanged). **Lock (v5.0)**: the hosted engine acquires `<.socket>/apply.lock` around its first wet write (the takeover pre-reverts) — not on `--dry-run`, and not when the run would write nothing (zero redirects, all skipped) — so previews and no-op runs never create `.socket/`; contention is `lock_held` and a lock-file I/O fault (a read-only project root, a file squatting on `.socket/`) is `lock_io` — both exit 1, refused BEFORE any project file is written, and rendered like every other lock holder: human `Error (): ` on stderr (+ the `--lock-timeout` hint for a live holder); JSON keeps the hosted shape — top-level `status: "error"`, `errorCode: "lock_held" | "lock_io"`, a string `error`, and `redirect: {mode: "hosted"}` retained (NOT the vendored `error: {code, message}` object). **Takeover symlink pre-check (v5.0)**: a vendored→hosted takeover whose recorded wiring file is a symlink is refused up front with `redirect_symlinked_file_unsupported` — wet and `--dry-run` alike, before any revert — so "nothing was written" holds. **Human mode (v5.0)**: hosted `scan` prints the results table and update detection like the other modes, then rewrites without a prompt (scan never prompts); `--dry-run` previews through the engine, and a detail fetch that leaves nothing to redirect enters the engine as a no-op (`Redirected 0 packages; rewrote 0 files.`, no lock, no `.socket/`). The detail fetch prints the same progress counter and per-package `Warning: could not fetch details for …` lines as the agent arm. An EMPTY hosted discovery prints `No patches available for installed packages.` and exits 0 without entering the engine; a discovery whose every offer is paid-tier for an org without paid access prints the table's paid nudge, then `No downloadable patches (paid subscription required).`, and exits 0 without entering the engine (parity with the agent/vendored arms). JSON output gains a `redirect` sub-object: `{ mode: "hosted", redirected, rewrittenFiles, skipped, warnings, dryRun }` (`mode` is additive so consumers can dispatch without inferring it). Rewriter warnings carry stable `redirect_*` codes (e.g. `redirect_npm_no_lockfile`, `redirect_gradle_manual_snippet`, `redirect_golang_unsupported`); new codes are additive (MINOR). v5.0 additive codes: `redirect_composer_no_lockfile` / `redirect_gem_no_gemfile` (composer / gem: neither manifest nor lock present — once per run, after the intake gates), `redirect_gem_bundle_gemfile_unsupported` (gem: `BUNDLE_GEMFILE` — `BUNDLE_GEMFILE:` in the bundler app config, which outranks the environment variable as in `Bundler::Settings`, else the environment variable — names a manifest other than the project's `Gemfile` / `gems.rb`, so no gem is redirected or attested; a value naming one of those two selects that pair even when the other spelling is present), `redirect_gem_mirror_overrides_source` (gem: Bundler's all-source, exact patch-source or patch-hostname mirror can route the per-dep `source` block to an unpatched upstream gem. Intake reads the app config (`BUNDLE_APP_CONFIG`, where a set-but-empty value selects `/config`, honoring `BUNDLE_IGNORE_CONFIG`) and all `BUNDLE_MIRROR__...` variables visible to the scan; app config overrides the environment per encoded key, then `mirror.all` takes precedence over exact source, which takes precedence over hostname. URI matching follows Bundler's whole-URI case folding, default-port/trailing-slash normalization and single slash key alias, not URL prefixes. An exact-source fallback-timeout key without a mirror URL shadows the hostname mirror and fetches that source directly; a configured URL is conservatively refused even if a timeout could bypass an unreachable mirror at install time. Like `redirect_gem_bundle_gemfile_unsupported`, the gate leaves the Gemfile pair byte-identical and confirms no gem redirect. On an embedded `scan --vex`, rediscovered older hosted gem pins may attest only from verified installed bytes: a missing tree is not excused by the lockfile, and `--vex-no-verify` omits those hosted gems with `mirror_overrides_source` rather than trusting their intercepted source. Agent/vendored evidence, unrelated ecosystems and standalone VEX behavior are unchanged. Details identify the setting form and its app/environment origin without printing mirror values or source URLs, which may contain credentials. Remove the applicable all/source/hostname setting (including any slash alias) from that origin and reuse its existing mirror URL under `mirror.https://rubygems.org` to clear the refusal; an environment setting must be unset in the scan/install environment. User-global Bundler config and mirrors set only in a later install environment are not inspected; keep those mirrors scoped to the upstream source too), `redirect_maven_no_pom` (no `pom.xml` and no Gradle build), `redirect_nuget_lock_unparseable` (a present-but-corrupt `packages.lock.json` — warned once, nothing mutated; an absent lock still proceeds), `redirect_cargo_lock_pkg_ambiguous` (several same-name+version `[[package]]` blocks and none carries the index `source` — transactional skip). Also v5.0: a registry override of the wrong kind (or none at all) warns the arm's missing-override code for nuget/gem/golang. Refusals stay fail-closed with a diagnosis that names the actual cause: a yarn-berry lock entry resolving through a non-`npm:` protocol keeps `redirect_yarn_berry_unsupported_protocol` with the entry's ACTUAL protocol in the detail — except socket-patch's OWN vendored wiring (a `file:` range into `.socket/vendor/`), which gets the distinct `redirect_yarn_berry_vendored_entry` code whose detail names the retirement path (`remove ` per package, or `vendor --revert` which unwinds every vendored package, then re-run `scan --mode hosted`). Both leave the entry byte-identical; neither changes exit code or status. **yarn berry line endings (v5.0)**: yarn writes a NEW `yarn.lock` with the OS line ending (`os.EOL` — CRLF on Windows) and keeps an existing lock's majority ending on every later write, and a `core.autocrlf` checkout turns an LF lock CRLF on any OS — so a uniformly CRLF lock is rewritten in its own ending: every untouched byte (a leading BOM included) round-trips (and `rollback`'s upstream restore keeps the lock's own ending). A lock that MIXES CRLF and LF (or holds a bare CR) has no single ending to keep — yarn's own `--immutable` check rejects it too (YN0028) — so it is refused untouched with `redirect_yarn_berry_mixed_line_endings` (the detail names `yarn install`, which normalizes it). The root `package.json`, which the rewrite re-renders to add `resolutions`, gets the same gate: a mixed one is refused untouched with the same code — the decision vendored mode takes with `vendor_yarn_berry_mixed_line_endings`, from the same shared berry gate set. This replaces v4's `redirect_yarn_berry_crlf_unsupported`, which refused every CRLF lock and is no longer emitted. A vendored→hosted takeover runs these berry gates (mixed line endings, unsupported `cacheKey`, a non-zero `.yarnrc.yml` `compressionLevel`) BEFORE reverting a vendored berry purl — wet and `--dry-run` alike — so a refused purl keeps its vendored wiring, ledger entry and artifact byte-identical and is skipped with the gate's code (never announced as `redirect_takeover_reverted_vendored` and then left unpatched in both modes). +`scan --mode hosted` swaps the in-place apply for the registry-redirect pipeline: discover → resolve hosted-patch references (grant token + integrity + per-dep registry override) → rewrite ONLY the patched dependencies' lockfile / registry-config entries to point at the hosted packages. A dep counts as **redirected** only when its hosted-artifact URL (or per-dep registry index URL) actually landed in a project file — a granted reference whose rewriter found nothing to edit is neither counted nor attested. **No ledger (v5.0)**: hosted mode writes ONLY the lockfile / registry-config edits — `.socket/vendor/redirect-state.json` is never written (on success or failure), and a pre-v5 one on disk is ignored (never read for planning, never quarantined, left byte-identical). The lockfiles are the only record of a hosted patch: `list`, `vex`, `rollback`, `remove`, `vendor` and `repair` all discover the hosted pins from them (a hosted URL counts only on `https://patch.socket.dev` or the `--patch-server-url` / `SOCKET_PATCH_SERVER_URL` origin), and commit-ready output is just the lockfile / config changes. Cargo and golang are confirmed only by their rewriter's own report (`confirmed_cargo_uuids` / `confirmed_golang_uuids`): a golang dep counts only when its go.mod `replace M V => patch.socket.dev/gopatch/ ` and both go.sum lines are in place, never because the patch-server origin or leftover go.sum lines appear somewhere. Gradle is confirmed the same way (`confirmed_gradle_uuids`): only when the final files hold the owned script, the index row, the live apply line in every build's settings file and the suffixed version in every lock entry of the GA (see [Gradle builds](#gradle-builds-v50)). A golang module that go.mod does not require and go.sum does not list at the patched version is outside the build graph and is refused with `redirect_golang_not_in_module_graph` (nothing written). Only the exact module `patch.socket.dev/gopatch/` is socket-owned; any other module path is refused with `redirect_golang_untrusted_module_path`. A vendored golang module is taken over like cargo and the npm family: its vendor wiring, committed copy and ledger entry are reverted first (`redirect_takeover_reverted_vendored`). A vendored PyPI package (requirements.txt, Poetry, Pipenv, uv, Hatch, PDM, pylock) is taken over the same way: its vendored wiring is restored to the recorded registry entry, its ledger entry and wheel are removed, and only then is it redirected. The Python rewriters treat any non-registry source as user-authored, so without the revert they refused socket-patch's own vendored source and left the project vendored. A takeover revert that leaves vendored wiring in place is refused with `redirect_vendored_revert_failed`. That covers a drift-skipped record (`vendor_lock_entry_drifted`) and a reverted file that still references the artifact (`vendor_revert_residual_reference`). The ledger entry and artifact are kept, and the package stays vendored and skipped. `--dry-run` predicts the same refusal from the same signals instead of previewing `redirect_would_revert_vendored`. The hosted requirements.txt rewriter only rewrites an existing pin in the root `requirements.txt`, so a vendored requirements.txt package whose wiring is a pin in a `-r` include or a `(transitive)` line vendored mode appended is refused BEFORE its revert, wet and `--dry-run` alike, with `redirect_requirements_takeover_unreachable` (`redirect.warnings[]`, and `redirect.skipped[].reason`). Its wiring, ledger entry and wheel are kept, so it stays vendored and patched (exit 0). A taken-over package whose wiring was reverted but that was then not pinned to hosted now installs the unpatched registry release in both modes. Causes include a refused lock, unavailable hosted wheel metadata, or a vendored ledger update that failed after the revert (refused with `redirect_vendored_revert_failed`). It is reported as `redirect_takeover_unpatched` with `status: "partial_failure"` and exit 1, never as success. That warning also prints under `--silent`. Human output prints no `Migrated …` progress line for the package and no "keep the hosted patches" next steps. Re-runs over already-rewritten output plan from the current lock text and are idempotent (exit 0, lock unchanged). **Lock (v5.0)**: the hosted engine acquires `<.socket>/apply.lock` around its first wet write (the takeover pre-reverts) — not on `--dry-run`, and not when the run would write nothing (zero redirects, all skipped) — so previews and no-op runs never create `.socket/`; contention is `lock_held` and a lock-file I/O fault (a read-only project root, a file squatting on `.socket/`) is `lock_io` — both exit 1, refused BEFORE any project file is written, and rendered like every other lock holder: human `Error (): ` on stderr (+ the `--lock-timeout` hint for a live holder); JSON keeps the hosted shape — top-level `status: "error"`, `error: {code: "lock_held" | "lock_io", message}` (v5.0: the same object every command uses; no top-level `error.code`), and `redirect: {mode: "hosted"}` retained. **Takeover symlink pre-check (v5.0)**: a vendored→hosted takeover whose recorded wiring file is a symlink is refused up front with `redirect_symlinked_file_unsupported` — wet and `--dry-run` alike, before any revert — so "nothing was written" holds. **Human mode (v5.0)**: hosted `scan` prints the results table and update detection like the other modes, then rewrites without a prompt (scan never prompts); `--dry-run` previews through the engine, and a detail fetch that leaves nothing to redirect enters the engine as a no-op (`Redirected 0 packages; rewrote 0 files.`, no lock, no `.socket/`). The detail fetch prints the same progress counter and per-package `Warning: could not fetch details for …` lines as the agent arm. An EMPTY hosted discovery prints `No patches available for installed packages.` and exits 0 without entering the engine; a discovery whose every offer is paid-tier for an org without paid access prints the table's paid nudge, then `No downloadable patches (paid subscription required).`, and exits 0 without entering the engine (parity with the agent/vendored arms). JSON output gains a `redirect` sub-object: `{ mode: "hosted", redirected, rewrittenFiles, skipped, warnings, dryRun }` (`mode` is additive so consumers can dispatch without inferring it). Rewriter warnings carry stable `redirect_*` codes (e.g. `redirect_npm_no_lockfile`, `redirect_gradle_manual_snippet`, `redirect_golang_unsupported`); new codes are additive (MINOR). v5.0 additive codes: `redirect_composer_no_lockfile` / `redirect_gem_no_gemfile` (composer / gem: neither manifest nor lock present — once per run, after the intake gates), `redirect_gem_bundle_gemfile_unsupported` (gem: `BUNDLE_GEMFILE` — `BUNDLE_GEMFILE:` in the bundler app config, which outranks the environment variable as in `Bundler::Settings`, else the environment variable — names a manifest other than the project's `Gemfile` / `gems.rb`, so no gem is redirected or attested; a value naming one of those two selects that pair even when the other spelling is present), `redirect_gem_mirror_overrides_source` (gem: Bundler's all-source, exact patch-source or patch-hostname mirror can route the per-dep `source` block to an unpatched upstream gem. Intake reads the app config (`BUNDLE_APP_CONFIG`, where a set-but-empty value selects `/config`, honoring `BUNDLE_IGNORE_CONFIG`) and all `BUNDLE_MIRROR__...` variables visible to the scan; app config overrides the environment per encoded key, then `mirror.all` takes precedence over exact source, which takes precedence over hostname. URI matching follows Bundler's whole-URI case folding, default-port/trailing-slash normalization and single slash key alias, not URL prefixes. An exact-source fallback-timeout key without a mirror URL shadows the hostname mirror and fetches that source directly; a configured URL is conservatively refused even if a timeout could bypass an unreachable mirror at install time. Like `redirect_gem_bundle_gemfile_unsupported`, the gate leaves the Gemfile pair byte-identical and confirms no gem redirect. On an embedded `scan --vex`, rediscovered older hosted gem pins may attest only from verified installed bytes: a missing tree is not excused by the lockfile, and `--vex-no-verify` omits those hosted gems with `mirror_overrides_source` rather than trusting their intercepted source. Agent/vendored evidence, unrelated ecosystems and standalone VEX behavior are unchanged. Details identify the setting form and its app/environment origin without printing mirror values or source URLs, which may contain credentials. Remove the applicable all/source/hostname setting (including any slash alias) from that origin and reuse its existing mirror URL under `mirror.https://rubygems.org` to clear the refusal; an environment setting must be unset in the scan/install environment. User-global Bundler config and mirrors set only in a later install environment are not inspected; keep those mirrors scoped to the upstream source too), `redirect_maven_no_pom` (no `pom.xml` and no Gradle build), `redirect_nuget_lock_unparseable` (a present-but-corrupt `packages.lock.json` — warned once, nothing mutated; an absent lock still proceeds), `redirect_cargo_lock_pkg_ambiguous` (several same-name+version `[[package]]` blocks and none carries the index `source` — transactional skip). Also v5.0: a registry override of the wrong kind (or none at all) warns the arm's missing-override code for nuget/gem/golang. Refusals stay fail-closed with a diagnosis that names the actual cause: a yarn-berry lock entry resolving through a non-`npm:` protocol keeps `redirect_yarn_berry_unsupported_protocol` with the entry's ACTUAL protocol in the detail — except socket-patch's OWN vendored wiring (a `file:` range into `.socket/vendor/`), which gets the distinct `redirect_yarn_berry_vendored_entry` code whose detail names the retirement path (`remove ` per package, or `vendor --revert` which unwinds every vendored package, then re-run `scan --mode hosted`). Both leave the entry byte-identical; neither changes exit code or status. **yarn berry line endings (v5.0)**: yarn writes a NEW `yarn.lock` with the OS line ending (`os.EOL` — CRLF on Windows) and keeps an existing lock's majority ending on every later write, and a `core.autocrlf` checkout turns an LF lock CRLF on any OS — so a uniformly CRLF lock is rewritten in its own ending: every untouched byte (a leading BOM included) round-trips (and `rollback`'s upstream restore keeps the lock's own ending). A lock that MIXES CRLF and LF (or holds a bare CR) has no single ending to keep — yarn's own `--immutable` check rejects it too (YN0028) — so it is refused untouched with `redirect_yarn_berry_mixed_line_endings` (the detail names `yarn install`, which normalizes it). The root `package.json`, which the rewrite re-renders to add `resolutions`, gets the same gate: a mixed one is refused untouched with the same code — the decision vendored mode takes with `vendor_yarn_berry_mixed_line_endings`, from the same shared berry gate set. This replaces v4's `redirect_yarn_berry_crlf_unsupported`, which refused every CRLF lock and is no longer emitted. A vendored→hosted takeover runs these berry gates (mixed line endings, unsupported `cacheKey`, a non-zero `.yarnrc.yml` `compressionLevel`) BEFORE reverting a vendored berry purl — wet and `--dry-run` alike — so a refused purl keeps its vendored wiring, ledger entry and artifact byte-identical and is skipped with the gate's code (never announced as `redirect_takeover_reverted_vendored` and then left unpatched in both modes). The rewriter reads a fixed set of candidate files from the project root: the npm-family locks (`package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, `shrinkwrap.yaml`, `yarn.lock`, plus `.yarnrc.yml` for the berry cache-config gate, `bun.lock` / `bun.lockb`, and `vlt-lock.json` with `vlt.json` and `node_modules/.vlt-lock.json` read only), `requirements.txt` / `uv.lock` / `Pipfile.lock` (pipfile-spec 6; see the Pipenv section below) / `poetry.lock` (every Poetry lock generation from 1.0 on — the 0.12 `[metadata.hashes]` layout is refused because that installer ignores URL sources; a Poetry < 1.4 writer additionally gets `redirect_poetry_stale_install_risk`, see `docs/testing/poetry-compatibility.md`) / `pdm.lock` (PDM lock formats `2` and `4.3`–`4.5.1`; the identity-losing `3.1` / `4.0`–`4.2` formats and unknown future formats are refused with `redirect_pdm_refused`, and a lock-format-`2` writer additionally gets `redirect_pdm_legacy_sync_required`, see `docs/testing/pdm-compatibility.md`; when `uv.lock` or `poetry.lock` sits beside it they drive and `pdm.lock` is left alone), `Cargo.toml` / `Cargo.lock` / `.cargo/config.toml` (plus the legacy extensionless `.cargo/config` — cargo reads that spelling in preference when both exist, so the managed `[registries.…]` block is written into whichever one is present; **cargo also reads every workspace-member manifest** — the `[workspace] members` globs minus `exclude` — and every in-root path-dependency manifest, recursively, reached without crossing a symbolic link and never under `.socket/`, and pins the crate in each one that declares it, so those `/Cargo.toml` files can appear in `rewrittenFiles`. A crate is redirected only when every declaration pins and every other `Cargo.lock` package depending on it is a planned member: one a registry or git crate — or a path package outside the root or behind a link — also depends on is refused `redirect_cargo_transitive_dependents` (a pin reaches only the declarations it sits on), a crate no manifest declares keeps `redirect_cargo_toml_dep_not_found` with a transitive-only detail naming `--mode vendored`, a crate every declaration of which requires another version (no requirement accepts the patched version) is refused `redirect_cargo_toml_dep_unrewritable`, and so is a requirement that also matches another locked version of the crate — each a transactional skip, never recorded or attested. With NO `Cargo.lock` there is no resolved graph to ask, so the dependents question is answered from the manifests instead: a crate declared beside any other dependency — anything but a path dependency on a manifest this run also pins, or a `workspace = true` inheritor of a table it scans — or beside a workspace member this run did not read (a `members` glob, or a member outside the project or behind a symbolic link, which member discovery drops) is refused `redirect_cargo_lockless_dependents`, whose detail names the remedies (commit a lockfile, or `--mode vendored`); a project whose only dependency is the patched crate has nothing that could pull it in and still redirects. All-CRLF manifests, locks and configs are rewritten with CRLF kept (mixed endings keep refusing where the grammar does not match), and `remove` / rollback match the recorded fragments across a later CRLF↔LF checkout conversion), `composer.lock`, `nuget.config` / `packages.lock.json`, `Gemfile` / `Gemfile.lock`, `pom.xml` (+ `.mvn/maven.config` / `.mvn/checksums/checksums.sha256` for maven Trusted Checksums merge, and, for a Gradle build, every settings, build, `buildSrc`, included-build, applied and plugin-source script, version catalog and lock file the script graph reaches, plus `gradle/verification-metadata.xml`, `gradle/wrapper/gradle-wrapper.properties` and the owned `.socket/gradle/` files), and the sbt build files (`socket-patch.sbt`, `socket-patch-vendor.sbt`, `build.sbt`, `project/build.properties`, `.sbtopts`, `.jvmopts`; `build.sbt.lock` and the Mill / scala-cli build files `build.mill`, `build.mill.yaml`, `build.sc`, `.mill-version`, `project.scala` for their presence only) — read, never edited; `socket-patch.sbt` is the only sbt file hosted mode writes (see **Hosted sbt** below). **npm-family flavor coverage**: package-lock / npm-shrinkwrap, pnpm (root OR any nested `*/pnpm-lock.yaml`), yarn classic (a yarn 2+ install migrates a v1 `yarn.lock` and drops its pins, so a run whose v1 lock carries a hosted pin warns `redirect_yarn_classic_berry_migration_risk` — the hosted twin of the vendored `yarn_classic_berry_migration_risk` — unless the root `package.json`, read as advisory input, declares `"packageManager": "yarn@1…"`), **yarn berry** (the pin yarn writes for a root `resolutions` entry: the root `package.json` — edited only beside a berry `yarn.lock` — gains one `"@npm:": ""` selector per locked range (`redirect_yarn_berry_resolution` edits), and only that `yarn.lock` entry is re-keyed `"@"` with the same `resolution:` + `yarnBerry10c0` checksum (`redirect_yarn_berry_entry`), moved to yarn's key order; never an `npm:` locator, whose fetcher sends npm registry auth to the patch host, nor a tarball locator under an `npm:` key, which hardened mode rejects (YN0078). An older release's `npm:::__archiveUrl=` pin is still recognized and is re-pinned on the next run; rollback rebuilds the key from the selectors and drops them. Refused, nothing written: a user-authored `resolutions` entry for the package `redirect_yarn_berry_resolutions_conflict`, no root manifest `redirect_yarn_berry_manifest_missing`, a builtin `patch:` entry wrapping the same descriptor `redirect_yarn_berry_shared_descriptor`, an artifact URL yarn cannot fetch as a tarball `redirect_yarn_berry_artifact_url_unsupported`; cacheKey `10c0` and `.yarnrc.yml compressionLevel 0` gated by `redirect_yarn_berry_cache_unsupported`), and **bun** (text `bun.lock` lockfileVersion 0, 1 or 2 — 0 is the `--save-text-lockfile` opt-in lock of Bun 1.1.39–1.1.45, 1 the 1.2–1.3 default, 2 the 1.4+ default; all three emit one `packages` grammar, so the registry 4-tuple → URL 3-tuple rewrite is version-independent and the lock's own version line is kept. Any other or missing version, or a `packages` section outside bun's single-line grammar, is refused `redirect_bun_lock_unsupported` — the detail is the shared version gate's text (a newer version: update socket-patch, re-locking would reproduce it; no integer: re-lock with Bun ≥ 1.2), identical to the vendored refusal. A version-0 lock holding `workspace:` packages is refused `redirect_bun_workspace_unsupported` (its 2-tuple workspace grammar cannot keep the hosted tuple through a frozen install); the remedy is to delete `bun.lock` and re-run `bun install` with Bun ≥ 1.2, which writes lockfileVersion 1 (accepted). A plain in-place `bun install` bumps the version only when a workspace depends on another workspace (e.g. root → member — the shape the matrix measured); otherwise Bun 1.2.0 keeps version 0 and Bun 1.2.23+ fail to resolve, so the in-place bump is not the documented remedy. Bun lock version, grammar and workspace compatibility are checked before a vendored takeover, including during dry-run: these refusals preserve the existing lock, artifact and vendor ledger. Version-1 and version-2 workspace locks are rewritten, nested versions included. A granted dep with no rewritable entry warns `redirect_bun_entry_not_found`, a grant without a sha512 `redirect_bun_missing_sha512`; a CRLF lock keeps `\r\n` on the rewritten line, and a hosted URL left by an earlier grant of the same `name@version` is re-pinned in place. **Digest-less re-saves (Bun 1.1.39–1.3.9)**: every text-lock Bun below 1.3.10 re-saves a URL tuple WITHOUT its `sha512` whenever the lock is re-saved for another reason (`bun add`, `bun install` after a package.json or workspace change), leaving the 2-tuple `["name@", {meta}]` — the spec Bun installs from is intact. The CLI treats that spelling as its own wiring: a repeat hosted run counts the dep as redirected (no `redirect_bun_entry_not_found`) and HEALS the line back to the 3-tuple with the current `sha512`, recording the heal as a further `redirect_bun_lock_package` edit whose `original` is the 2-tuple (a stale URL is re-pinned from either spelling); `rollback`, scoped `rollback ` / `remove ` and the vendored takeover accept the digest-less spelling of a recorded `new` line (same key, spec and meta, only the trailing `"sha512-…"` missing) and restore the recorded original over it, so the chain always unwinds to the pristine registry line. Anything else — another uuid/token, another version, a re-laid meta object — is still drift. **Native `bun.lockb`**: when no text `bun.lock` exists, binary format versions 1, 2 and 3 are read and rewritten directly. Socket Patch does not invoke Bun or convert the project to a text lockfile. Exact matching package records are rewritten to hosted tarballs with the granted integrity, preserving dependency resolution IDs, workspace/dependency topology and unrelated package metadata; binary pointers and the package metadata hash are updated. Per-package `redirect_bun_lockb_package` snapshots support scoped rollback, repeat runs, superseding grants and hosted ↔ vendored takeover. A regular binary lock is discoverable even with no Bun runtime or `node_modules`; a dry run previews the same binary edits without writing them. A malformed, unreadable, unsupported or unverified binary structure is `redirect_bun_lockb_invalid` (exit 0, `redirected: 0`), and it refuses the npm rewrite before any takeover or sibling npm-family lock mutation. A symlinked binary write target is `redirect_symlinked_file_unsupported` (exit 1, including dry-run). `bun.lock` wins when both spellings exist. Binary-only projects do not receive `redirect_npm_no_lockfile`. Measured boundaries and the real-Bun matrix: `docs/testing/bun-compatibility.md`), and **vlt** (`vlt-lock.json` without `lockfileVersion`, `0` or `1`; see the vlt hosted-mode contract below). **Rush monorepos**: when `rush.json` is present the rewriter also reads `common/config/rush/pnpm-lock.yaml` and each `common/config/subspaces//pnpm-lock.yaml` (sorted for determinism) under their repo-relative keys and repoints them in place; editing them emits `redirect_rush_repo_state_stale` when `common/config/rush/repo-state.json` exists (the `pnpmShrinkwrapHash` desync is refreshed by `rush update`, which the redirect survives). **maven** is fail-closed via version suffixing: a `mavenSuffixedVersion` + `mavenPomSha256` override pins the Socket-only `-socket.` by rewriting the literal `` (`redirect_maven_dep_version`) or adding a `` entry (`redirect_maven_dep_management_added`), plus optional Trusted Checksums (`redirect_maven_trusted_checksums`, conflicts as `redirect_maven_trusted_checksums_conflict`; when `.mvn/wrapper/maven-wrapper.properties` pins a Maven older than 3.9.4, which ignores those files, the additive warning `redirect_maven_trusted_checksums_unenforced`); a `${property}` version is refused (`redirect_maven_dep_unpinned`), a non-matching literal skipped (`redirect_maven_dep_version_mismatch`), and an override without a suffixed version falls back to same-GAV repository injection (`redirect_maven_same_gav_fallback`, NOT fail-closed). **gradle** (v5.0) is automated wiring, no longer a pasted snippet: the owned settings script `.socket/gradle/socket-patch.hosted.settings.gradle` with its index `.socket/gradle/hosted-index.tsv`, one apply line per build's settings file, every lock entry of the GA moved to the suffixed version, and the suffixed component in an existing `gradle/verification-metadata.xml`. A refused dep writes nothing and keeps `redirect_gradle_manual_snippet` as its fallback; same-GAV grants are refused (`redirect_gradle_same_gav_unsupported`). Rules, refusals and codes: [Gradle builds](#gradle-builds-v50). @@ -176,9 +176,9 @@ The rewriter reads a fixed set of candidate files from the project root: the npm * `.socket/vendor/state.json` — the **vendored**-mode ledger (see "Ownership, state, and reversal" below): wiring edits with verbatim pre-vendor originals, artifact fingerprints, and the embedded patch `record` — for every entry written by `scan`/`get --mode vendored` beside `detached: true` (the record is that entry's only source), and for standalone `vendor` fed by an agent-mode manifest as a fallback copy without `detached` (the manifest record stays authoritative while the manifest covers the entry, by ledger key or base purl; `vex`, `list` and `setup --check` fall back to the embedded copy when it does not, `repair` only with no manifest at all). Entries written before 5.0 by standalone `vendor` carry no `record`; readers tolerate its absence. **Schema version 2 (v5.0)**: the `new` of a whole-file wiring record (kinds `maven_pom_repository`, `nuget_config_source`, `python_lock_document`, `python_script_metadata`, `hatch_document`) of 1 KiB or more, when its `original` is a string, is stored as an edit of that same record's `original`: `{"snapshot": "", "ops": [[start, len] | "inserted text", …]}` (the text is the ops concatenated in order: a `[start, len]` byte range copied from the `original`, a string inserted as is), and the ledger's `version` is `2`; the `original` stays a plain string, no other record kind is touched, and a ledger without such a record keeps the version-1 bytes. Both versions are read; a version-2 edit is rebuilt and checked against its hash (a mismatch, a missing `original`, an out-of-range copy, or any other `{"snapshot": …}` value is `vendor_state_unreadable`), so every consumer sees the same full texts as with an inline version-1 ledger. Records are self-contained, so an older socket-patch re-saving a version-2 ledger (it keeps `original` / `new` verbatim and drops unknown fields) loses nothing. * `.socket/vendor/redirect-state.json` — the **pre-v5 hosted**-mode ledger (`RedirectState` in `socket-patch-core/src/patch/redirect/state.rs`: `{ version, mode, edits[], records{} }`). **Retired in v5.0**: no command writes it, and `scan` / `get --mode hosted` ignore it. It is read for migration only — `list` and `vex` take a record from it for a hosted pin with the same purl and uuid (the lockfiles still decide what is hosted; its `edits` are never replayed), and a malformed one is only the `redirect_ledger_corrupt` warning there — and `rollback` / `remove` delete it once no lockfile pins a hosted patch any more (a `rollback` in a project whose ONLY state is this file removes it and exits 0, JSON `legacyRedirectLedgerRemoved: true`). A project scanned in hosted mode by v5 commits only its lockfile / config edits. -**get --mode and installed narrowing (v3.6).** `get --mode hosted|vendored` consumes the resolved patch(es) through the SAME engines as `scan --mode hosted|vendored`, so for the same selected (purl, uuid) set the on-disk result is identical by construction — the per-advisory selector for hosted/vendored (`get --save-only` then `vendor` still works). **Agent mode (v5.0 lock + residue rules)**: the download phase runs under `<.socket>/apply.lock` and hands the guard to the nested apply, so download → manifest write → apply is one lock window (the nested apply never re-acquires and inherits every caller flag — `--lock-timeout` and `--verbose` included); a failed acquire is `{status: "error", errorCode: "lock_held" | "lock_io", error}` on get's legacy envelope, exit 1, before any fetch (a read-only `.socket/` fails here, naming the lock path). `.socket/` and `.socket/blobs/` are created only when a record is actually persisted — an all-skipped or all-failed run leaves no `.socket/` on a fresh project — and a same-uuid `get ` re-run rewrites neither the manifest nor the blobs. Semantics: +**get --mode and installed narrowing (v3.6).** `get --mode hosted|vendored` consumes the resolved patch(es) through the SAME engines as `scan --mode hosted|vendored`, so for the same selected (purl, uuid) set the on-disk result is identical by construction — the per-advisory selector for hosted/vendored (`get --save-only` then `vendor` still works). **Agent mode (v5.0 lock + residue rules)**: the download phase runs under `<.socket>/apply.lock` and hands the guard to the nested apply, so download → manifest write → apply is one lock window (the nested apply never re-acquires and inherits every caller flag — `--lock-timeout` and `--verbose` included); a failed acquire is `{status: "error", error: {code: "lock_held" | "lock_io", message}}` on get's legacy envelope, exit 1, before any fetch (a read-only `.socket/` fails here, naming the lock path). `.socket/` and `.socket/blobs/` are created only when a record is actually persisted — an all-skipped or all-failed run leaves no `.socket/` on a fresh project — and a same-uuid `get ` re-run rewrites neither the manifest nor the blobs. Semantics: -* **Hosted** (`get GHSA-… --mode hosted`): resolves the advisory, then hands the selected (purl, uuid) pairs to scan's hosted engine — reference grants, cross-mode takeover pre-revert, lockfile rewrite (no ledger, v5.0), gem stale-install probe, warnings, confirmation rules (cargo via `confirmed_cargo_uuids`, golang via `confirmed_golang_uuids` only) all identical to `scan --mode hosted`, and (v5.0) under the same `apply.lock` acquisition — taken around the first wet write, never on `--dry-run` or when nothing would be written; a failed acquire folds as top-level `errorCode: "lock_held" | "lock_io"` + string `error` (exit 1), and `--dry-run` under a held lock still exits 0. **No manifest write, no blobs, no ledger** — the lockfile edits are the persistence. JSON: get's legacy envelope gains the same nested `redirect` sub-object as scan's (`{mode:"hosted", redirected, rewrittenFiles, skipped, warnings, dryRun}`); the top-level shape is `{status, found, patches:[], warnings?}` — `downloaded`/`applied` are absent (nothing is downloaded into `.socket/`). Exit codes follow scan's hosted semantics: skipped grants and rewriter warnings never flip the exit; infra errors (reference fetch, file writes) exit 1. Human prompt: `Redirect N packages to the hosted patch server?` (singular for one; `--yes`/`--json`/non-TTY auto-accept as usual). This confirm is get's alone: `scan` never prompts. +* **Hosted** (`get GHSA-… --mode hosted`): resolves the advisory, then hands the selected (purl, uuid) pairs to scan's hosted engine — reference grants, cross-mode takeover pre-revert, lockfile rewrite (no ledger, v5.0), gem stale-install probe, warnings, confirmation rules (cargo via `confirmed_cargo_uuids`, golang via `confirmed_golang_uuids` only) all identical to `scan --mode hosted`, and (v5.0) under the same `apply.lock` acquisition — taken around the first wet write, never on `--dry-run` or when nothing would be written; a failed acquire folds as top-level `error: {code: "lock_held" | "lock_io", message}` (exit 1), and `--dry-run` under a held lock still exits 0. **No manifest write, no blobs, no ledger** — the lockfile edits are the persistence. JSON: get's legacy envelope gains the same nested `redirect` sub-object as scan's (`{mode:"hosted", redirected, rewrittenFiles, skipped, warnings, dryRun}`); the top-level shape is `{status, found, patches:[], warnings?}` — `downloaded`/`applied` are absent (nothing is downloaded into `.socket/`). Exit codes follow scan's hosted semantics: skipped grants and rewriter warnings never flip the exit; infra errors (reference fetch, file writes) exit 1. Human prompt: `Redirect N packages to the hosted patch server?` (singular for one; `--yes`/`--json`/non-TTY auto-accept as usual). This confirm is get's alone: `scan` never prompts. * **Vendored** (`get GHSA-… --mode vendored`): the download phase is scan's vendored posture — **manifest-free (v5.0)**: the selected records are fetched into memory (`download_patch_records`; no blob staging; nothing under `.socket/` is written; the nested apply never runs), then scan's vendor step runs under the apply lock over exactly the selected records, like `scan --mode vendored` (no whole-manifest scope and no `[note]` about other records — that blast radius is retired with the manifest; a legacy manifest record for a vendored purl is migrated out of `.socket/manifest.json` the same way scan does it). JSON: get's envelope takes the detached download envelope's shape — `{status, found, downloaded, skipped, failed, detached: true, patches: [{purl, uuid, action: "downloaded" | "skipped" | "failed", …}], warnings?}` (`applied` is absent; `detached: true` is pinned; a `downloaded` record for a purl the vendor ledger holds at another uuid carries the additive `oldUuid`, derived from the ledger — the human `[fetch]` line reads ` (replacing )`) — and gains the nested `vendor` Envelope exactly like scan's `result["vendor"]`; a vendor-step error folds the partial envelope + `{status:"error", error:{code,message}}` in (a pre-failure takeover reconcile may have already mutated the ledger — its events must reach the consumer). Exit: download failures or vendor `has_errors` → `partial_failure`/1. Human prompt: `Download and vendor N patches?`; `--dry-run` prints `[dry-run] Would download and vendor N patches. No changes made.` on both identifier paths (uuid and search). Telemetry mirrors scan's vendored arms (`track_outcomes_for_vendor` / `track_patch_vendor_failed`). **Bun vendored preflight (additive)** — shared by `get --mode vendored` on both its paths and `scan --mode vendored`: before ANY patch download, and only when the selection holds a `pkg:npm/` purl, the download phase reads `bun.lock`/`bun.lockb` once (`preflight_vendor`) and, when the vendor backend would refuse the project — a malformed, unreadable or unsupported `bun.lockb` → `vendor_bun_lockb_invalid`; an unreadable `bun.lock` → `vendor_lockfile_missing`; a `lockfileVersion` other than 0/1/2 or a non-canonical `packages` grammar → `vendor_lockfile_version_unsupported`; `workspace:` packages in a lock below version 2 → `vendor_bun_workspace_unsupported` — every `pkg:npm/` result becomes `{action:"failed", errorCode:, error:}` with NO fetch (the patch view is never requested) and no patch record; other ecosystems' results are untouched. **Search path** (`get --mode vendored`) and `scan --mode vendored`: the records ride `patches[]` / `download.patches[]` with `downloaded: 0`, the download phase writes nothing under `.socket/` (v5.0 — a pre-existing `.socket/manifest.json`, including a record seeded for another purl, is left byte-untouched), the vendor step still runs over the remaining records (no event for the refused purl), exit `partial_failure`/1. **uuid path** (`get --mode vendored`): the uuid lookup is the only fetch; the run exits 1 BEFORE the vendor step with exactly `{status:"error", found:1, downloaded:0, skipped:0, failed:1, error:{code, message}, patches:[{purl, uuid, action:"failed", errorCode, error}]}` (the `error` OBJECT is the vendored-mode error shape of the vendor-step fold-in above) and writes nothing — no `.socket/` on a fresh project; human mode prints `Error (): ` on stderr. **Already-vendored exemption**: a purl is exempt from the workspace refusal only when every instance of its `name@version` in `bun.lock` is already a `.socket/vendor/npm/…` local tuple (any uuid; the digest-less 2-tuple counts) — the engine's own criterion — so in-sync re-runs, `repair`, and a superseding patch uuid on a project vendored before it grew a workspace member all flow to the engine (re-pinning an already-local tuple adds no workspace-relative exposure); a wiped ledger alone is not a refusal (the engine path decides). UUID equality in the ledger alone never exempts a purl: `rollback --preserve-state` retains its record after unwiring. Dry-run refusal takes priority over `already_vendored`. **Unreadable vendor ledger**: a `.socket/vendor/state.json` the preflight cannot read or parse is itself the refusal — `vendor_state_unreadable` with the io/parse detail, fail-closed (nothing is exempt) — on the uuid path, the search / `scan` path and the `--dry-run` preview alike; never a Bun lock code. **`--silent`** is "errors only" and never mutes the refusal: the code-tagged `[error] (): ` (per-patch paths) / `Error (): …` (uuid path) line stays on stderr with an empty stdout. **`--dry-run`** previews the refusal as the additive `would_refuse` action (see `--dry-run` below). Agent-mode `get --save-only` is NOT preflighted (record-only intent has no consumption precondition). Pinned by `tests/vendor/in_process_vendor_bun.rs` (exact uuid-path envelope, seeded-manifest survival, `--silent`, `--dry-run`) and `tests/scan_vendor_e2e.rs`. **Lock-text refusals before the download (v5.0)** — shared by `get --mode vendored` on both its paths and `scan --mode vendored`, after the Bun preflight above and the ledger's `already vendored` skip: a `pkg:npm/` result in a **pnpm, yarn classic or yarn berry** project, or a `pkg:cargo/` result, that its vendor backend refuses on the project's lock and manifest text alone is refused BEFORE its patch view is fetched — the pnpm / classic / berry gates the backend runs before it reads the package (coordinates, the lock and manifest reads and their line-ending / version / `cacheKey` / `.yarnrc.yml` gates, override and `resolutions` conflicts, the lock entry present and rewritable) and cargo's `locked_version_mismatch` (only when it is the crate's FIRST refusal; an in-tree `cargo vendor` copy still refuses in the loop as `already_vendored_in_tree`). **Scope:** only a package the vendor loop would hand to its backend is refused early — one installed on disk (the loop's own qualified-aware resolver plus the npm identity lookup), or one the lockfile inventory resolves to a verifiable registry source (a lock entry with an integrity, or the ledger-recovered pre-vendor resolution — exactly the entry the pristine fetch would use). A package absent from the lock and not installed never reached its backend and is untouched: its view is fetched, it downloads, and the vendor loop skips it `skipped` / `package_not_installed` as in v4.x (so cargo's `locked_version_mismatch` is refused early only for a crate installed at the unlocked version). The result becomes `{action:"failed", errorCode:, error:}` in `download.patches[]` / `patches[]` with the backend's exact code and detail, no view and no pristine fetch, no patch record, and therefore no vendor event: compared with v4.x, `download.downloaded` drops and `download.failed` rises by the number of such packages, `vendor.summary.failed` and `vendor.events` lose their `failed` events, and a lockfile-only package among them loses its `vendor_fetched_missing` event (it is never fetched). Exit code and top-level `status` are unchanged (`partial_failure`/1); the nested `vendor.status` becomes `success` when those refusals were the vendor step's only failures (observed on the depscan fixture: 3 refusals, `partialFailure` → `success`), and when every selected package is refused this way the human `scan --vendor` arm prints `Nothing was vendored: N patches failed (see above).`. **Precedence:** the lock-text refusal is decided before the view, so it wins over every view-derived outcome — a package that would also have been a paid-access 403 (`[PAID]`/no access), a failed view fetch, or a no-applicable-files skip reports the lock refusal instead (the Bun refusal and the ledger's `already vendored` skip still come first). The human `[error] (): ` line is printed during the download instead of the vendor step's failure line (the human (non-`--silent`) `scan --vendor` arm's baseline pre-check still fetches the views it verifies; only the download, the pristine fetch and the vendor step skip the package there). A purl the lockfiles pin hosted keeps the loop's refusal (its takeover restore rewrites the lock the gates read); other flavors (package-lock, pnpm-legacy, bun) and ecosystems are untouched, and `--dry-run` is unchanged. `vendor` (manifest-driven, no view fetch) keeps its per-package `failed` events but no longer fetches the pristine source of a lockfile-only package it refuses this way — the source is deferred to the backend, which refuses before reading it (no `vendor_fetched_missing` event and no registry request; a refused package whose registry is unreachable reports the gate's code instead of `vendor_fetch_failed`); only a package the lock resolves to a verifiable source is deferred, and one it does not resolve keeps its `package_not_installed` skip. Pinned by `tests/scan_vendor_e2e.rs` (`exact_download_plan`: scan and exact-purl get, pnpm and cargo scope), `tests/e2e_yarn_legacy_cachekey_refusal_build.rs` and `tests/vendor/vendor_rerun_no_network_e2e.rs`. @@ -232,7 +232,7 @@ patches: **Validation (fail closed).** Because the file only narrows, a file that cannot be honored never means "no policy". Checked in order: file access (a regular file after resolving, at most 64 KiB, read from the opened handle), encoding (UTF-8; a BOM is stripped and CRLF is fine; UTF-16 and NUL bytes are errors), YAML 1.2 syntax (duplicate keys, a non-mapping top level, nesting deeper than 32 and a second document are errors), a top-level key that looks like a misspelled `patches` (equal to `patch`/`patches` ignoring case, or within two edits of it and starting `pat`/`pac`, e.g. `patchs`), a top-level merge key (`<<`) or aliased key (either could carry a `patches` block other YAML readers apply), the version gate (`patches` requires `version: 2`), then the keys. Inside `patches` and `projectIgnorePaths`, anchors, aliases, merge keys (`<<`) and custom tags are errors (aliases elsewhere are never expanded). An unknown key under `patches` is an error with a did-you-mean hint and "a newer socket-patch may support it". Wrong types are errors — no coercion (`"false"` is not a bool; YAML 1.2, so `no` is a string) — as are an unknown severity, an out-of-range `maxNewPatches`, an invalid pattern or spec, a list over 1000 entries and an entry over 1024 bytes. Every error names the file and the key path (`patches.minSeverity`). `projectIgnorePaths` is validated strictly when a `patches` block exists (a single string is coerced to a one-element list); without one, a malformed value only warns `socket_yml_ignored_value` and is ignored, and it is honored whatever the `version`. An empty or comment-only file counts as no file. When both `socket.yml` and `socket.yaml` exist, both are validated; if their `projectIgnorePaths` and `patches` are equal as parsed values `socket.yml` is used, otherwise the run fails with `socket_yml_ambiguous`. -**Error output.** Before any request or write, `scan` exits **1** with scan's error object plus an additive `errorCode` (`socket_yml_invalid` or `socket_yml_ambiguous`): `{"status": "error", "error": "socket.yml: patches.minSeverty: unknown key … (fix the file, or pass --no-socket-yml to ignore it)", "errorCode": "socket_yml_invalid", …}` with every count at zero; no `policy` block. Human output: `Error (socket_yml_invalid): …` on stderr. The in-memory engine reports `policyError: {code, detail}` (the detail without the CLI remedy) with no root processed and no file changed. +**Error output.** Before any request or write, `scan` exits **1** with scan's error object, its `error.code` `socket_yml_invalid` or `socket_yml_ambiguous`: `{"status": "error", "error": {"code": "socket_yml_invalid", "message": "socket.yml: patches.minSeverty: unknown key … (fix the file, or pass --no-socket-yml to ignore it)"}, …}` with every count at zero; no `policy` block. v5.0 (MAJOR): the code moved from a top-level `errorCode` beside a string `error` into `error.code`. Human output: `Error (socket_yml_invalid): …` on stderr. The in-memory engine reports `policyError: {code, detail}` (the detail without the CLI remedy) with no root processed and no file changed. **The trust boundary holds.** No key names an endpoint, a credential, an org, a mode, a download format or a safety switch — such keys are unknown keys and fail validation. Every key only removes candidates or (`maxNewPatches`) delays them; none can add a package or bypass the tier filter, the agent partition, reference grants, containment checks or any refusal. @@ -900,7 +900,7 @@ worse, lets a warm cache silently serve unpatched bytes): A bare `rollback` (or a scoped one, for its scope) restores the SYSTEM to unpatched and cleans up the local state, in phases under one `apply.lock` acquisition: -1. **State discovery.** A missing manifest is no longer fatal when the vendor ledger or the lockfiles' hosted pins hold work (`rollback` runs manifest-less on hosted-only / vendored projects — every `scan`/`get --mode vendored` and v5 `scan --mode hosted` project is manifest-less). The **truly-empty** project — no manifest, no vendor ledger, no hosted pin — keeps the legacy "Manifest not found" exit 1 (JSON: the legacy `{status: "error", error: "Manifest not found", path}` shape), with one v5.0 exception: when a pre-v5 `.socket/vendor/redirect-state.json` is the only thing left, nothing pins it any more, so a wet run deletes it and exits 0 (human `Removed the pre-v5 hosted ledger .socket/vendor/redirect-state.json: no lockfile pins a hosted patch.`, `Would remove …` on `--dry-run`, which deletes nothing; JSON `{status: "success", rolledBack: 0, alreadyOriginal: 0, failed: 0, dryRun, warnings, legacyRedirectLedgerRemoved}` — a minimal envelope without the keys below; a failed delete is the `legacy_redirect_ledger_kept` warning, still exit 0). A project whose lockfiles still reference `.socket/vendor/` artifacts but whose vendor ledger is missing errors asking for `.socket/vendor/state.json` to be restored from version control first (v5.0: `repair` no longer reconstructs the ledger). **Corrupt-ledger containment**: an unreadable vendor ledger fails ONLY the legs that need it — the vendored leg, manifest cleanup, and GC are skipped fail-closed (`vendor_state_unreadable` warning) while the agent and hosted legs still run; it drives `partial_failure` exit 1, and an emergency restore is never blocked by it. When the ONLY state on disk is an unreadable vendor ledger, the run fails closed naming the store. A pre-v5 redirect ledger is never read by rollback (v4's `redirect_state_unreadable` is no longer emitted). Under `--global`/`--global-prefix` the project's hosted pins and vendor ledger are not discovered at all, so the vendored and hosted legs below do not run (see "Global scope never touches the project's state"). +1. **State discovery.** A missing manifest is no longer fatal when the vendor ledger or the lockfiles' hosted pins hold work (`rollback` runs manifest-less on hosted-only / vendored projects — every `scan`/`get --mode vendored` and v5 `scan --mode hosted` project is manifest-less). The **truly-empty** project — no manifest, no vendor ledger, no hosted pin — keeps the legacy "Manifest not found" exit 1 (JSON: the legacy `{status: "error", error: {code: "manifest_not_found", message: "Manifest not found"}, path}` shape), with one v5.0 exception: when a pre-v5 `.socket/vendor/redirect-state.json` is the only thing left, nothing pins it any more, so a wet run deletes it and exits 0 (human `Removed the pre-v5 hosted ledger .socket/vendor/redirect-state.json: no lockfile pins a hosted patch.`, `Would remove …` on `--dry-run`, which deletes nothing; JSON `{status: "success", rolledBack: 0, alreadyOriginal: 0, failed: 0, dryRun, warnings, legacyRedirectLedgerRemoved}` — a minimal envelope without the keys below; a failed delete is the `legacy_redirect_ledger_kept` warning, still exit 0). A project whose lockfiles still reference `.socket/vendor/` artifacts but whose vendor ledger is missing errors asking for `.socket/vendor/state.json` to be restored from version control first (v5.0: `repair` no longer reconstructs the ledger). **Corrupt-ledger containment**: an unreadable vendor ledger fails ONLY the legs that need it — the vendored leg, manifest cleanup, and GC are skipped fail-closed (`vendor_state_unreadable` warning) while the agent and hosted legs still run; it drives `partial_failure` exit 1, and an emergency restore is never blocked by it. When the ONLY state on disk is an unreadable vendor ledger, the run fails closed naming the store. A pre-v5 redirect ledger is never read by rollback (v4's `redirect_state_unreadable` is no longer emitted). Under `--global`/`--global-prefix` the project's hosted pins and vendor ledger are not discovered at all, so the vendored and hosted legs below do not run (see "Global scope never touches the project's state"). 2. **Agent leg** — the existing in-place restore machinery, unchanged (v5.0 presentation: the human `No patches found in manifest` line prints only for an unscoped run with no work in ANY leg — a run whose work is all vendored/hosted stays quiet about the manifest): multi-copy restore, release-variant narrowing, the before-blob gate (+ on-demand download; a gate abort still exits 1 with per-package `missing_blob` failure results **and** skips manifest cleanup + GC entirely — nothing was restored, and the retry's revert data must survive), local-go redirect drop, and the `not_installed` exit-0 asymmetry verbatim. Vendor-owned purls are still excluded here (see the vendored-mode section) — they are handled by the next leg instead of being punted to other commands. 3. **Vendored leg** — each in-scope ledger entry (embedded-record entries included) is reverted through the vendor backends: lockfile wiring restored, artifact dir deleted (and its emptied `.socket/vendor//` husk pruned, v5.0), ledger entry dropped + persisted per purl (crash-consistent, like `vendor --revert`). A **drift-keep** (the backend refused a drifted lock) keeps the entry, the artifact, AND the manifest record (`vendoredKept`, exit 1 — the system is still patched); a failure is recorded and other entries proceed. 4. **Hosted leg** — each in-scope hosted pin is restored to its default upstream registry entry; see "Hosted unwind coverage" below. After a hosted leg with no failure, a wet run deletes a pre-v5 `redirect-state.json` once no lockfile pins a hosted patch any more (a failed delete is the `legacy_redirect_ledger_kept` warning). @@ -936,10 +936,11 @@ v5.0 replaces v4's per-purl reverts and whole-ledger reverse replay (`revert_rem ### JSON envelope (legacy shape + additive always-present keys) -`rollback --json` keeps its legacy top-level shape (`status` — `"success"` \| `"partial_failure"` — `rolledBack`, `alreadyOriginal`, `failed`, `dryRun`, `results[]`) and adds these keys, ALL always present so consumers never null-check: +`rollback --json` keeps its legacy top-level shape (`status` — `"success"` \| `"partial_failure"` \| `"error"` — `rolledBack`, `alreadyOriginal`, `failed`, `dryRun`, `results[]`) and adds these keys, ALL always present so consumers never null-check (except `error`, present only on `status: "error"`): | Key | Shape | Meaning | |---|---|---| +| `error` | `{code, message}` | Only on `status: "error"` (v5.0, MAJOR: was a string). Codes: `manifest_not_found`, `manifest_invalid`, `manifest_unreadable`, `patch_not_found`, `path_glob_no_match`, `hosted_wiring_contested`, `vendor_ledger_missing`, `rollback_failed`, `lock_held` / `lock_io`, and `path_glob_invalid` (a usage error, exit 2). Per-result `results[*].error` stays a string. | | `warnings` | `[{code, detail}]` | Run-level warnings, now populated (previously always empty): `reinstall_required`, `hosted_state_not_preservable`, `out_of_scope_copies_restored`, `vendor_state_unreadable`, `cleanup_failed`, `manifest_write_failed`, `legacy_redirect_ledger_kept`, the upstream-restore advisories (`npm_allow_remote_left`, `pnpm_trust_lockfile_left`, `maven_trusted_checksums_left`, `nuget_default_config_left`, `upstream_uv_override_removed`, `upstream_registry_fallback`), `ownership_not_restored` (a restored file whose ownership could not be put back — see the apply warnings), `rollback_record_superseded` (a manifest record superseded by a live hosted pin, left to the hosted leg — see Manifest cleanup), plus vendored/hosted leg advisories. New codes are additive (MINOR) | | `vendored` | `[purl]` | **Meaning narrowed (MAJOR)**: vendor-owned purls the run did NOT act on — today exactly the corrupt-vendor-ledger skip. | | `vendoredReverted` | `[purl]` | Ledger entries cleanly reverted this run (unwired + artifact deleted + entry dropped; previewed on dry-run) | @@ -1138,6 +1139,8 @@ The v3.0 legacy names `SOCKET_PATCH_PROXY_URL`, `SOCKET_PATCH_DEBUG` and `SOCKET Every `--json` invocation emits a single JSON object that follows the **unified envelope** below. The envelope was introduced in v3.0; older per-command shapes are deprecated. See `src/json_envelope.rs` for the source of truth; its unit tests pin the serialized names, and each command's e2e tests assert the envelope it emits. The `tests/cli_parse_*.rs` files pin the parsed clap arguments, not this shape (a few, such as `cli_parse_list.rs`, also spot-check `list`'s envelope). +**One error shape (v5.0, MAJOR).** On every command, every `--json` failure carries its top-level `error` as a `{code, message}` object — the envelope's `EnvelopeError` — including on the legacy shapes `scan`, `get` and `rollback` still print. `code` is a stable snake_case tag (see [Top-level `EnvelopeError` codes](#top-level-envelopeerror-codes)); `message` is for humans. No command prints a top-level `errorCode` any more (it moved into `error.code`). Per-record keys are unchanged: `patches[*].error` / `patches[*].errorCode`, `events[*].error` / `events[*].errorCode` and rollback's `results[*].error` stay strings. + ### Envelope shape ```jsonc @@ -1274,14 +1277,14 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `vendor_would_revert_redirect` / `vendor_takeover_reverted_redirect` | `skipped` (advisory event) | vendor / scan / get `--mode vendored` over a hosted pin (every ecosystem, v5.0): dry run — the upstream restore was resolved (registry lookups included) and would succeed (for bun, only after the Bun vendored preflight accepted the lock; a refused lock is previewed as the wet run's `failed ` instead) / wet run — the pin's lock entries were restored to their upstream registry entry before vendoring (mode takeover; detail ` was hosted; restored its upstream registry entry () before vendoring (mode takeover)`), so `vendor --revert` later returns to upstream. Fires on the run that takes over, not on re-runs, and not for a purl whose takeover was rolled back because the backend refused it (see "Takeover reconciliation"). | | `redirect_revert_failed` | `failed` | vendor / scan / get `--mode vendored` (dry and wet): the upstream restore of a hosted pin was refused (`--offline`, a registry that does not answer, a lock shape the restore refuses — for `bun.lockb`, a record the codec cannot rebuild) — detail `cannot vendor over the live hosted pin: cannot restore to its upstream registry entry: ; restore it from version control instead (`git checkout -- `)`; nothing vendored for the purl, hosted wiring left in place, exit 1 `partial_failure`. | | `patch_fetch_failed` (eject) | `failed` | vendor eject (v5.0): a hosted pin's patch record could not be fetched from `…/patches/view/`; the whole eject is refused (`eject_refused`), nothing touched, exit 1. | -| `redirect_pnpm_lockfile_elsewhere` / `redirect_workspace_lockfile_elsewhere` / `cargo_manifest_not_workspace_root` (hosted) | top-level `errorCode` (`status: "error"`) | scan / get `--mode hosted` (v5.0): the project directory is a workspace member whose lock lives in another directory, so the rewriters, which read only the project directory, would pin nothing (pnpm: no npm-family lock here, and the nearest ancestor `pnpm-workspace.yaml` or the project's `lockfile-dir` (`.npmrc`) / `lockfileDir` (`pnpm-workspace.yaml`) puts `pnpm-lock.yaml` elsewhere; npm / yarn / Bun, `redirect_workspace_lockfile_elsewhere`: no npm-family lock here, and the nearest ancestor `package.json` whose `workspaces` (array, or the object form's `packages`) matches the directory holds `package-lock.json`, `npm-shrinkwrap.json`, `yarn.lock`, `bun.lock` or `bun.lockb`; a matching root with none of them that is itself listed by an outer root's `workspaces` hands the check to that root; when a pnpm workspace also governs the directory, the nearer root is named and a tie goes to `redirect_pnpm_lockfile_elsewhere`) or rewrite the member as a lockless project (cargo: the vendored workspace-root check). Refused before any takeover or write, `--dry-run` included; the message names the directory to run from; exit 1. Disk runs only (an in-memory project has no ancestors). | -| `redirect_pnpm_settings_elsewhere` | top-level `errorCode` (`status: "error"`) | scan / get `--mode hosted`: the project directory is a pnpm workspace member with its own v9 `pnpm-lock.yaml` (`sharedWorkspaceLockfile: false`) and no `pnpm-workspace.yaml` of its own, so its pnpm settings come from the nearest ancestor `pnpm-workspace.yaml`, which pnpm reads alone (a member's own file is ignored). When that file neither carries `trustLockfile: true` nor explicitly sets another value, the trust auto-config has nowhere to go: refused before any takeover or write, `--dry-run` included; the message names the root file to add `trustLockfile: true` to (or `--no-trust-lockfile-config` pins without it); exit 1. Once the root file trusts the lock (or opts out), the member is pinned and no nested `pnpm-workspace.yaml` is created; the `redirect_pnpm_trust_lockfile` warning names the root file. Disk runs only. | -| `eject_refused` | top-level `errorCode` (`status: "error"`) | vendor eject (v5.0): a record fetch failed or a pin's upstream restore was refused while planning; nothing was changed, exit 1. | +| `redirect_pnpm_lockfile_elsewhere` / `redirect_workspace_lockfile_elsewhere` / `cargo_manifest_not_workspace_root` (hosted) | top-level `error.code` (`status: "error"`) | scan / get `--mode hosted` (v5.0): the project directory is a workspace member whose lock lives in another directory, so the rewriters, which read only the project directory, would pin nothing (pnpm: no npm-family lock here, and the nearest ancestor `pnpm-workspace.yaml` or the project's `lockfile-dir` (`.npmrc`) / `lockfileDir` (`pnpm-workspace.yaml`) puts `pnpm-lock.yaml` elsewhere; npm / yarn / Bun, `redirect_workspace_lockfile_elsewhere`: no npm-family lock here, and the nearest ancestor `package.json` whose `workspaces` (array, or the object form's `packages`) matches the directory holds `package-lock.json`, `npm-shrinkwrap.json`, `yarn.lock`, `bun.lock` or `bun.lockb`; a matching root with none of them that is itself listed by an outer root's `workspaces` hands the check to that root; when a pnpm workspace also governs the directory, the nearer root is named and a tie goes to `redirect_pnpm_lockfile_elsewhere`) or rewrite the member as a lockless project (cargo: the vendored workspace-root check). Refused before any takeover or write, `--dry-run` included; the message names the directory to run from; exit 1. Disk runs only (an in-memory project has no ancestors). | +| `redirect_pnpm_settings_elsewhere` | top-level `error.code` (`status: "error"`) | scan / get `--mode hosted`: the project directory is a pnpm workspace member with its own v9 `pnpm-lock.yaml` (`sharedWorkspaceLockfile: false`) and no `pnpm-workspace.yaml` of its own, so its pnpm settings come from the nearest ancestor `pnpm-workspace.yaml`, which pnpm reads alone (a member's own file is ignored). When that file neither carries `trustLockfile: true` nor explicitly sets another value, the trust auto-config has nowhere to go: refused before any takeover or write, `--dry-run` included; the message names the root file to add `trustLockfile: true` to (or `--no-trust-lockfile-config` pins without it); exit 1. Once the root file trusts the lock (or opts out), the member is pinned and no nested `pnpm-workspace.yaml` is created; the `redirect_pnpm_trust_lockfile` warning names the root file. Disk runs only. | +| `eject_refused` | top-level `error.code` (`status: "error"`) | vendor eject (v5.0): a record fetch failed or a pin's upstream restore was refused while planning; nothing was changed, exit 1. | | `eject_planned` | `applied` (reason) | vendor eject `--dry-run` (v5.0): the pin would be restored upstream and vendored; nothing written. | | `eject_rolled_back` | warning | vendor eject (v5.0): a package failed after the restore began; every touched file was put back from the pre-eject snapshot, so the project is still hosted; `partial_failure`, exit 1. | -| `eject_rollback_failed` | top-level `errorCode` | vendor eject (v5.0): putting the pre-eject snapshot back failed; the detail names the files to `git checkout --`; exit 1. | -| `offline_eject_unavailable` | top-level `errorCode` | vendor eject under `--offline` / `SOCKET_OFFLINE` (v5.0): records and registry entries cannot be fetched offline; zero network requests, nothing touched, exit 1. | -| `hosted_wiring_contested` | top-level `errorCode` (list: warning when it can still list) | rollback / remove / vendor eject / list (v5.0): a lockfile mentions a recognized hosted patch uuid that discovery rejected (or a pin with no lockfile), so the hosted set is not known exactly; refused with nothing touched, exit 1. Remedy: fix or `git checkout` the named lockfile. | +| `eject_rollback_failed` | top-level `error.code` | vendor eject (v5.0): putting the pre-eject snapshot back failed; the detail names the files to `git checkout --`; exit 1. | +| `offline_eject_unavailable` | top-level `error.code` | vendor eject under `--offline` / `SOCKET_OFFLINE` (v5.0): records and registry entries cannot be fetched offline; zero network requests, nothing touched, exit 1. | +| `hosted_wiring_contested` | top-level `error.code` (list: warning when it can still list) | rollback / remove / vendor eject / list (v5.0): a lockfile mentions a recognized hosted patch uuid that discovery rejected (or a pin with no lockfile), so the hosted set is not known exactly; refused with nothing touched, exit 1. Remedy: fix or `git checkout` the named lockfile. | | `vendor_pnpm_settings_elsewhere` | `failed` | vendor / scan / get `--mode vendored` (pnpm, v9 lock): the project directory is a pnpm workspace member with its own `pnpm-lock.yaml` and no `pnpm-workspace.yaml` of its own; pnpm reads `overrides:` only from the nearest ancestor `pnpm-workspace.yaml`, so an override wired into the member (its `package.json` or a nested workspace file) would be ignored, failing frozen installs on pnpm >= 11 and silently reinstalling the unpatched package on a plain install. Refused before any write (the pre-download preflight and `--dry-run` included); the detail names the governing file; remedy: `--mode hosted`. | | `vendor_dir_symlink_unsupported` | `failed` | vendor / scan / get `--mode vendored` (every ecosystem): `.socket/vendor`, `.socket/vendor/` or the patch's `` dir is a symlink or junction. socket-patch creates those directories itself and never writes links, so a linked one is not ours; its target may be another project's vendor store. Refused before any write. The vendored revert (`vendor --revert`, `rollback`, `remove`, the vendored → hosted takeover) fails on the same check with the same detail before it edits a lock or deletes anything, so it never deletes another project's artifacts through the link. The detail names the linked path. Remedy: replace the link with a real directory and re-run. | | `vendor_yarn_berry_cache_unsupported` | `failed` | vendor (yarn berry): lock `cacheKey ≠ 10c0` or non-default `.yarnrc.yml` `compressionLevel` — the cache-zip checksum is not reproducible. | @@ -1348,9 +1351,33 @@ Every `--json` invocation emits a single JSON object that follows the **unified | Code | Subcommands | Meaning | |-----------------------|----------------------------------|---------| -| `manifest_not_found` | remove, repair, rollback, vex (not `list` since v5.0: a missing manifest is an empty list) | `.socket/manifest.json` doesn't exist. For `vex` (and `scan --vex`) it fires only when, in addition, NOTHING else names a patch — no vendor-ledger entry, no lockfile reference (hosted or vendored) — and the message says so (exit 2 standalone; `apply`/`vendor --vex` treat it as their calm no-op). v3.5: `repair` proceeds anyway (vendored phase only) when a vendor ledger or vendor-path lockfile references exist, and exits 0 with a `redirect_only_project` skip (not this error) when the project's only patch state is hosted pins in its lockfiles (v5.0; or a pre-v5 `redirect-state.json`). `list` likewise no longer fires this on a hosted-only project: v5.0 lists every hosted pin the lockfiles wire (exit 0, labeled `details.mode: "hosted"` + `details.lockfiles: []` — no `details.ledger`, since hosted mode keeps none; when the manifest exists too, both are shown, purl-sorted with the manifest entry first on a tie). A pin carries its uuid and empty details unless a pre-v5 redirect ledger records the same purl and uuid (read for migration only: its record supplies the vulnerabilities / tier / description); a pre-v5 ledger record whose pin is in no lockfile is not listed. v5.0: `list` reads the vendor ledger the same way — a vendored-only project (every `scan`/`get --mode vendored` project) lists its ledger entries' embedded records labeled `Mode: vendored (recorded in .socket/vendor/state.json)` in human mode — the twin of the hosted `Mode: hosted (wired in )` line — (`details.mode: "vendored"` + `details.ledger: ".socket/vendor/state.json"` in JSON), exit 0. A standalone-`vendor` entry's fallback `record` lists the same way once no manifest entry covers it (by ledger key or base purl) — the copy manifest-less `vex` attests from, so `list` never reports `manifest_not_found` for a tree whose VEX document attests a patch; while the manifest covers it, only the manifest entry is listed. All sources always come from the SAME project: the vendor ledger and the lockfiles are resolved against the root the RESOLVED manifest path implies (its `.socket` parent's parent in the standard layout, else the manifest file's directory — exactly `--cwd` for the default path), so `--manifest-path` into another project reads that project's state, never the local one. The error still fires when NONE of the three sources has a patch, and a present-but-broken manifest still reports `manifest_invalid`/`manifest_unreadable` regardless (corruption is never masked). A malformed pre-v5 redirect ledger degrades to "nothing to consult" with a stderr warning, muted by `--silent` (the pins still list); `list --json` carries it in the run-level `warnings[]` as `redirect_ledger_corrupt` instead of on stderr. v5.0: `rollback` likewise proceeds manifest-less when the vendor ledger or the lockfiles' hosted pins hold work (its error is the legacy `{status: "error", error: "Manifest not found", path}` shape, not this envelope code); only the truly-empty project — no manifest, no vendor ledger, no hosted pin (a lone pre-v5 redirect ledger is deleted, exit 0) — keeps the exit-1 error, and a project whose lockfiles still reference `.socket/vendor/` artifacts with NO vendor ledger gets a distinct error naming `socket-patch repair`. `remove` (v5.0) proceeds manifest-less whenever a vendor ledger file exists or the lockfiles pin a hosted patch (an existence probe and the read-only hosted-pin discovery before the lock; the vendor ledger loads under it): ANY vendor-ledger entry matching the identifier — detached or not — is removed through the ledger path (`--preserve-state` and drift-keeps behave exactly as on the manifest path), a hosted-only match restores its upstream registry entry, and when that state exists but holds nothing for the identifier the error is `not_found` (exit 1), not this code — `manifest_not_found` fires from `remove` only when all three sources are empty. Manifest entries are removed in sorted purl order. | -| `manifest_invalid` | list, remove | Manifest exists but is unparseable. | -| `manifest_unreadable` | list, remove, vex | I/O error reading manifest (vex: also an unparseable manifest; exit 2). | +| `manifest_not_found` | remove, repair, rollback, vex (not `list` since v5.0: a missing manifest is an empty list) | `.socket/manifest.json` doesn't exist. For `vex` (and `scan --vex`) it fires only when, in addition, NOTHING else names a patch — no vendor-ledger entry, no lockfile reference (hosted or vendored) — and the message says so (exit 2 standalone; `apply`/`vendor --vex` treat it as their calm no-op). v3.5: `repair` proceeds anyway (vendored phase only) when a vendor ledger or vendor-path lockfile references exist, and exits 0 with a `redirect_only_project` skip (not this error) when the project's only patch state is hosted pins in its lockfiles (v5.0; or a pre-v5 `redirect-state.json`). `list` likewise no longer fires this on a hosted-only project: v5.0 lists every hosted pin the lockfiles wire (exit 0, labeled `details.mode: "hosted"` + `details.lockfiles: []` — no `details.ledger`, since hosted mode keeps none; when the manifest exists too, both are shown, purl-sorted with the manifest entry first on a tie). A pin carries its uuid and empty details unless a pre-v5 redirect ledger records the same purl and uuid (read for migration only: its record supplies the vulnerabilities / tier / description); a pre-v5 ledger record whose pin is in no lockfile is not listed. v5.0: `list` reads the vendor ledger the same way — a vendored-only project (every `scan`/`get --mode vendored` project) lists its ledger entries' embedded records labeled `Mode: vendored (recorded in .socket/vendor/state.json)` in human mode — the twin of the hosted `Mode: hosted (wired in )` line — (`details.mode: "vendored"` + `details.ledger: ".socket/vendor/state.json"` in JSON), exit 0. A standalone-`vendor` entry's fallback `record` lists the same way once no manifest entry covers it (by ledger key or base purl) — the copy manifest-less `vex` attests from, so `list` never reports `manifest_not_found` for a tree whose VEX document attests a patch; while the manifest covers it, only the manifest entry is listed. All sources always come from the SAME project: the vendor ledger and the lockfiles are resolved against the root the RESOLVED manifest path implies (its `.socket` parent's parent in the standard layout, else the manifest file's directory — exactly `--cwd` for the default path), so `--manifest-path` into another project reads that project's state, never the local one. The error still fires when NONE of the three sources has a patch, and a present-but-broken manifest still reports `manifest_invalid`/`manifest_unreadable` regardless (corruption is never masked). A malformed pre-v5 redirect ledger degrades to "nothing to consult" with a stderr warning, muted by `--silent` (the pins still list); `list --json` carries it in the run-level `warnings[]` as `redirect_ledger_corrupt` instead of on stderr. v5.0: `rollback` likewise proceeds manifest-less when the vendor ledger or the lockfiles' hosted pins hold work (its error is the legacy `{status: "error", error: {code: "manifest_not_found", message: "Manifest not found"}, path}` shape); only the truly-empty project — no manifest, no vendor ledger, no hosted pin (a lone pre-v5 redirect ledger is deleted, exit 0) — keeps the exit-1 error, and a project whose lockfiles still reference `.socket/vendor/` artifacts with NO vendor ledger gets a distinct error naming `socket-patch repair`. `remove` (v5.0) proceeds manifest-less whenever a vendor ledger file exists or the lockfiles pin a hosted patch (an existence probe and the read-only hosted-pin discovery before the lock; the vendor ledger loads under it): ANY vendor-ledger entry matching the identifier — detached or not — is removed through the ledger path (`--preserve-state` and drift-keeps behave exactly as on the manifest path), a hosted-only match restores its upstream registry entry, and when that state exists but holds nothing for the identifier the error is `not_found` (exit 1), not this code — `manifest_not_found` fires from `remove` only when all three sources are empty. Manifest entries are removed in sorted purl order. | +| `manifest_invalid` | list, remove, rollback | Manifest exists but is unparseable. | +| `manifest_unreadable` | list, remove, vex, get, rollback | I/O error reading manifest (vex: also an unparseable manifest; exit 2). | +| `manifest_write_failed` | get | The manifest write after a download failed; the blobs that run wrote are removed again. | +| `patch_fetch_failed` | get | The patch search or view request failed. | +| `patch_no_applicable_files` | get | The fetched patch has no file it could record; nothing written. | +| `blob_write_failed` | get | A patch blob could not be decoded or written; the per-patch record keeps its string `error`. | +| `selection_required` | get | Several patches match and `--json` cannot prompt; `status` stays `selection_required`, `options[]` lists them. | +| `offline_unsupported` | scan, get | `--offline` / `SOCKET_OFFLINE`: the command needs the patch API. | +| `patch_details_failed` | scan | Every patch-detail query failed. | +| `api_batch_failed` | scan | Every batch query failed. | +| `reference_resolve_failed` | scan (hosted) | Hosted patch references could not be resolved; nothing changed. | +| `lockfile_write_failed` | scan (hosted) | A rewritten lockfile could not be written. | +| `socket_yml_invalid` / `socket_yml_ambiguous` | scan | The socket.yml patch policy cannot be honored (see socket.yml patch policy). | +| `patch_not_found` | rollback | No manifest, ledger or hosted patch matches the identifier. | +| `path_glob_no_match` | rollback, scan | rollback: a path target matched no patched package (exit 1). scan: a PATH glob matched no directory (usage error, exit 2). | +| `vendor_ledger_missing` | rollback | Lockfiles reference `.socket/vendor/` artifacts but the vendor ledger is missing. | +| `rollback_failed` | rollback | The rollback pipeline failed before any per-package result. | +| `lock_held` / `lock_io` | every lock-taking command | Another live run holds `apply.lock`, or the lock file could not be opened. | +| `invalid_args` | scan, get, remove, repair | Usage error (exit 2): flags that cannot be combined. | +| `global_scope_unsupported` | scan, get, vendor | Usage error (exit 2): `--global`/`--global-prefix` with hosted or vendored mode. | +| `identifier_invalid` | get | Usage error (exit 2): the identifier does not match the forced `--id`/`--cve`/`--ghsa` type. | +| `path_not_directory` | scan | Usage error (exit 2): a hosted/vendored PATH is not a directory. | +| `path_outside_repo` | scan | Usage error (exit 2): a PATH is outside the repository root socket.yml is read from. | +| `path_glob_invalid` | scan, rollback | Usage error (exit 2): a path glob cannot be parsed. | +| `invalid_env` | scan | Usage error (exit 2): a malformed `SOCKET_MIN_SEVERITY` or `SOCKET_MAX_NEW_PATCHES`. | +| `json_requires_output` | vex | Usage error (exit 2): `--json` without `--output`. | | `no_patches` | vex | The manifest file exists but is empty AND no vendor-ledger record or lockfile reference names a patch (exit 1). | | `vendor_ledger_corrupt` | vex (every form) | `.socket/vendor/state.json` exists but is malformed or unreadable. The vendor ledger is an attestation input (records and liveness), so attesting from a partial view is refused (exit 2 standalone; the host command fails). A missing ledger is simply empty. | | `redirect_ledger_corrupt` | vex, list (`warnings[]`) | v5.0: a WARNING, no longer an error — a pre-v5 `.socket/vendor/redirect-state.json` exists but is malformed or unreadable. v5 hosted mode keeps no ledger (hosted references come from the lockfiles, their records from the API), so the file is only an optional migration record source: its records are not consulted and the run continues. Delete the file or restore it from version control. | @@ -1380,7 +1407,7 @@ The unified envelope is the v3.0 contract. As of this release, these commands em - ✅ `remove` - ✅ `vendor` -The remaining commands still emit their pre-v3.0 ad-hoc JSON shapes and will migrate in a follow-up PR. Until then, downstream consumers should branch on the `command` field (envelope) vs the legacy shape (no `command` field, `status` in snake_case): +The remaining commands still emit their pre-v3.0 ad-hoc JSON shapes and will migrate in a follow-up PR. Until then, downstream consumers should branch on the `command` field (envelope) vs the legacy shape (no `command` field, `status` in snake_case). v5.0: their failures already share the envelope's error shape — a top-level `error: {code, message}`, never a string or a top-level `errorCode`: - ⏳ `scan` — still emits the discovery + `apply.patches[*]` + `gc.*` shape documented in earlier drafts of this file. - ⏳ `get` — still emits per-patch action arrays. @@ -1475,8 +1502,10 @@ nested apply covers the whole `--ecosystems`-scoped manifest) is appended as its own `failed` record. `failed` counts these records beside the download failures, and `applied` counts only the patches that did apply. A failure no single patch explains (an unreadable manifest, the yarn PnP -refusal, unavailable patch sources) sets top-level `errorCode` / `error` -on the same object (`apply` in `scan`'s envelope). +refusal, unavailable patch sources) sets top-level `error: {code, message}` +on the same object (`apply` in `scan`'s envelope); `status` stays +`partial_failure`. v5.0: this replaced the top-level `error.code` + string +`error` pair (MAJOR). `vulnerabilities[]` is always sorted by `id` so consumer diffs and test snapshots are stable. `severity` at the top level is the max @@ -1619,6 +1648,12 @@ socket-patch repair --json | jq '{ }' ``` +Why a run failed, on any command (v5.0: `error` is always `{code, message}`): + +```bash +socket-patch scan --json | jq -r 'select(.status == "error") | "\(.error.code): \(.error.message)"' +``` + Combined apply summary for a PR description: ```bash @@ -1641,9 +1676,9 @@ Exit `1` when `status` is `partialFailure` (any `events[*].action == "failed"`) |---|---| | `0` | Success | | `1` | Error (missing/invalid manifest, fetch failed, apply failed, selection cancelled in non-JSON mode, an invalid or ambiguous socket.yml on `scan` (v5.0), etc.) | -| `2` | Usage error: clap parse failures (unknown flag/value, missing required arg, an unknown subcommand such as the removed `setup`) and the conflicts the commands enforce themselves — `scan`'s cross-mode conflicts (`--mode` combined with a DIFFERENT mode's boolean spelling, rejected in `resolve_mode_flags`) and `--mode hosted\|vendored` with `--global`/`--global-prefix` (same enforcement point; `get` refuses the same combination); in hosted/vendored `scan` (bare `scan` included), a PATH that is not a directory, a PATH glob matching no directory, and `--json` with more than one project directory (`run_project_dirs`); `remove --preserve-state --skip-rollback` (the no-op quadrant; flag- or env-sourced alike), an unparseable path glob on `scan`/`rollback`, a `scan` PATH outside the repository root and a malformed `SOCKET_MIN_SEVERITY` or `SOCKET_MAX_NEW_PATCHES` (v5.0), `repair --offline --download-only`. `vex` also exits `2` on hard errors before document generation (see its tri-state table below). v5.0: `get`'s self-enforced conflicts exit `2` too (`--id`/`--cve`/`--ghsa`/`--package` multi-select, `--mode hosted\|vendored --save-only`, a malformed identifier for a forced `--id`/`--cve`/`--ghsa`) — previously `1` (MAJOR). The never-implemented `get --one-off` / `rollback --one-off` (and `SOCKET_ONE_OFF`) are removed in v5.0; `--one-off` is now an ordinary unknown-flag clap error. | +| `2` | Usage error: clap parse failures (unknown flag/value, missing required arg, an unknown subcommand such as the removed `setup`) and the conflicts the commands enforce themselves — `scan`'s cross-mode conflicts (`--mode` combined with a DIFFERENT mode's boolean spelling, rejected in `resolve_mode_flags`) and `--mode hosted\|vendored` with `--global`/`--global-prefix` (same enforcement point; `get` refuses the same combination); in hosted/vendored `scan` (bare `scan` included), a PATH that is not a directory, a PATH glob matching no directory, and `--json` with more than one project directory (`run_project_dirs`); `remove --preserve-state --skip-rollback` (the no-op quadrant; flag- or env-sourced alike), an unparseable path glob on `scan`/`rollback`, a `scan` PATH outside the repository root and a malformed `SOCKET_MIN_SEVERITY` or `SOCKET_MAX_NEW_PATCHES` (v5.0), `repair --offline --download-only`. Under `--json`, every usage error a command enforces itself prints the coded error on stdout (v5.0, MAJOR: `scan`, `remove` and `rollback` printed nothing there): a full envelope for the envelope commands, `{status: "error", error: {code, message}}` for `scan`, `get` and `rollback`; clap's own parse errors still print nothing on stdout. `vex` also exits `2` on hard errors before document generation (see its tri-state table below). v5.0: `get`'s self-enforced conflicts exit `2` too (`--id`/`--cve`/`--ghsa`/`--package` multi-select, `--mode hosted\|vendored --save-only`, a malformed identifier for a forced `--id`/`--cve`/`--ghsa`) — previously `1` (MAJOR). The never-implemented `get --one-off` / `rollback --one-off` (and `SOCKET_ONE_OFF`) are removed in v5.0; `--one-off` is now an ordinary unknown-flag clap error. | -`list` returns **`0`** for every project it can read, empty or not (**v5.0, BREAKING**: a project with no manifest and no ledger record — normal for hosted mode, which writes no manifest — used to exit `1` with `manifest_not_found`; it is now an empty list: `No patches in this project. Run \`socket-patch scan\`.` on stdout, and under `--json` the success envelope with `events: []`). Only an unreadable or invalid manifest (`manifest_unreadable` / `manifest_invalid`) exits `1`. Every lock-taking subcommand — including `scan`/`get --mode hosted` as of v5.0 — returns **`1`** with `errorCode: lock_held` when another live socket-patch process holds `<.socket>/apply.lock`. +`list` returns **`0`** for every project it can read, empty or not (**v5.0, BREAKING**: a project with no manifest and no ledger record — normal for hosted mode, which writes no manifest — used to exit `1` with `manifest_not_found`; it is now an empty list: `No patches in this project. Run \`socket-patch scan\`.` on stdout, and under `--json` the success envelope with `events: []`). Only an unreadable or invalid manifest (`manifest_unreadable` / `manifest_invalid`) exits `1`. Every lock-taking subcommand — including `scan`/`get --mode hosted` as of v5.0 — returns **`1`** with `error.code: lock_held` when another live socket-patch process holds `<.socket>/apply.lock`. `vex` exit codes are tri-state: diff --git a/docs/migrating-to-v5.md b/docs/migrating-to-v5.md index e597b32d0..966c69615 100644 --- a/docs/migrating-to-v5.md +++ b/docs/migrating-to-v5.md @@ -29,6 +29,30 @@ existing scripts against the new CLI; the [changelog](../CHANGELOG.md) and of a hosted ledger. Scripts must use the updated [JSON shapes and exit codes](../crates/socket-patch-cli/CLI_CONTRACT.md#json-output-shapes). +## JSON output + +Every `--json` failure now reports its top-level `error` as an object, +`{"code": "...", "message": "..."}`, on every command. `apply`, `list`, `remove`, +`repair`, `vendor` and `vex` already did; `scan`, `get` and `rollback` change: + +- Read `.error.message` where you read `.error`, and route on `.error.code`. +- The top-level `errorCode` key is gone. Read `.error.code` instead. This affects + `get`'s and hosted `scan`'s `lock_held` / `lock_io`, hosted `scan` refusals, + `scan`'s socket.yml refusals (`socket_yml_invalid`, `socket_yml_ambiguous`) and + the nested-apply error `get` and `scan --mode agent` report. +- Per-record keys do not change: `patches[*].error`, `patches[*].errorCode` and + rollback's `results[*].error` stay strings. +- Usage errors (exit 2) that `scan`, `remove` and `rollback` enforce themselves + now print the coded error on stdout under `--json`, as `get`, `repair`, + `vendor` and `vex` do. Clap's own parse errors still print nothing on stdout. + +```bash +socket-patch scan --json | jq -r 'select(.status == "error") | .error.code' +``` + +The codes are listed in the +[CLI contract](../crates/socket-patch-cli/CLI_CONTRACT.md#top-level-envelopeerror-codes). + ## Installation channels v5 distributes standalone binaries, Cargo crates, and npm packages. The PyPI and From 15ba36ff3a337318901179c21fd04b0079dc6a55 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 10:13:21 -0400 Subject: [PATCH 5/8] Test the coded --json error on usage, offline and rollback failures (#704) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tests/json_error_shape.rs | 223 ++++++++++++++++++ 1 file changed, 223 insertions(+) create mode 100644 crates/socket-patch-cli/tests/json_error_shape.rs diff --git a/crates/socket-patch-cli/tests/json_error_shape.rs b/crates/socket-patch-cli/tests/json_error_shape.rs new file mode 100644 index 000000000..bbcbd37a6 --- /dev/null +++ b/crates/socket-patch-cli/tests/json_error_shape.rs @@ -0,0 +1,223 @@ +//! #704: every `--json` failure carries its top-level `error` as a +//! `{code, message}` object, with no top-level `errorCode`, on every +//! command. Self-enforced usage errors (exit 2) print that coded error on +//! stdout: a full envelope for the envelope commands, `{status, error}` for +//! `scan`, `get` and `rollback`. None of these cases reaches the network. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use socket_patch_cli::args::{GLOBAL_ARG_ENV_VARS, LOCAL_ARG_ENV_VARS}; + +fn binary() -> PathBuf { + env!("CARGO_BIN_EXE_socket-patch").into() +} + +fn run(cwd: &Path, args: &[&str], env: &[(&str, &str)]) -> (i32, String, String) { + let mut cmd = Command::new(binary()); + cmd.args(args).current_dir(cwd); + for var in GLOBAL_ARG_ENV_VARS.iter().chain(LOCAL_ARG_ENV_VARS.iter()) { + cmd.env_remove(var); + } + cmd.env_remove("VIRTUAL_ENV"); + cmd.env_remove("SOCKET_MAX_NEW_PATCHES"); + cmd.env_remove("SOCKET_MIN_SEVERITY"); + cmd.env("SOCKET_TELEMETRY_DISABLED", "1"); + // Unroutable: a case that reached the network would fail differently. + cmd.env("SOCKET_API_URL", "http://127.0.0.1:1"); + cmd.env("SOCKET_API_TOKEN", "fake-token-for-test"); + cmd.env("SOCKET_ORG_SLUG", "test-org"); + for (k, v) in env { + cmd.env(k, v); + } + let out = cmd.output().expect("run socket-patch"); + ( + out.status.code().unwrap_or(-1), + String::from_utf8_lossy(&out.stdout).to_string(), + String::from_utf8_lossy(&out.stderr).to_string(), + ) +} + +/// The shared assertions: `status: "error"`, an object `error` with the +/// expected code and a non-empty message, and no top-level `errorCode`. +fn assert_error_object(stdout: &str, code: &str) -> serde_json::Value { + let v: serde_json::Value = serde_json::from_str(stdout.trim()) + .unwrap_or_else(|e| panic!("stdout must be one JSON document ({e}): {stdout:?}")); + assert_eq!(v["status"], "error", "{v}"); + assert!(v["error"].is_object(), "error must be an object: {v}"); + assert_eq!(v["error"]["code"], code, "{v}"); + assert!( + v["error"]["message"] + .as_str() + .is_some_and(|m| !m.is_empty()), + "{v}" + ); + assert!(v.get("errorCode").is_none(), "no top-level errorCode: {v}"); + v +} + +/// A legacy-shape usage error is exactly `{status, error}`. +fn assert_legacy_usage(args: &[&str], env: &[(&str, &str)], code: &str) { + let tmp = tempfile::tempdir().unwrap(); + let (exit, stdout, stderr) = run(tmp.path(), args, env); + assert_eq!(exit, 2, "{args:?}: stdout={stdout} stderr={stderr}"); + let v = assert_error_object(&stdout, code); + assert_eq!(v.as_object().unwrap().len(), 2, "{args:?}: {v}"); +} + +/// An envelope-command usage error is a full envelope. +fn assert_envelope_usage(args: &[&str], command: &str, code: &str) { + let tmp = tempfile::tempdir().unwrap(); + let (exit, stdout, stderr) = run(tmp.path(), args, &[]); + assert_eq!(exit, 2, "{args:?}: stdout={stdout} stderr={stderr}"); + let v = assert_error_object(&stdout, code); + assert_eq!(v["command"], command, "{v}"); + assert_eq!(v["events"], serde_json::json!([]), "{v}"); +} + +#[test] +fn scan_usage_errors_print_the_coded_error() { + assert_legacy_usage( + &["scan", "--mode", "hosted", "--global", "--json"], + &[], + "global_scope_unsupported", + ); + assert_legacy_usage( + &["scan", "--mode", "hosted", "missing-dir", "--json"], + &[], + "path_not_directory", + ); + assert_legacy_usage( + &["scan", "--mode", "hosted", "nothing-*", "--json"], + &[], + "path_glob_no_match", + ); + assert_legacy_usage( + &["scan", "--mode", "agent", "x[", "--json"], + &[], + "path_glob_invalid", + ); + assert_legacy_usage( + &["scan", "--mode", "agent", "--json"], + &[("SOCKET_MAX_NEW_PATCHES", "lots")], + "invalid_env", + ); + assert_legacy_usage( + &["scan", "--mode", "agent", "--json"], + &[("SOCKET_MIN_SEVERITY", "extreme")], + "invalid_env", + ); +} + +#[test] +fn get_usage_errors_print_the_coded_error() { + assert_legacy_usage( + &["get", "lodash", "--id", "--cve", "--json"], + &[], + "invalid_args", + ); + assert_legacy_usage( + &["get", "lodash", "--id", "--json"], + &[], + "identifier_invalid", + ); + assert_legacy_usage( + &["get", "lodash", "--save-only", "--mode", "hosted", "--json"], + &[], + "invalid_args", + ); + assert_legacy_usage( + &["get", "lodash", "--mode", "hosted", "--global", "--json"], + &[], + "global_scope_unsupported", + ); +} + +#[test] +fn rollback_usage_error_prints_the_coded_error() { + assert_legacy_usage(&["rollback", "x[", "--json"], &[], "path_glob_invalid"); +} + +#[test] +fn envelope_command_usage_errors_print_an_envelope() { + assert_envelope_usage( + &[ + "remove", + "pkg:npm/a@1.0.0", + "--preserve-state", + "--skip-rollback", + "--json", + ], + "remove", + "invalid_args", + ); + assert_envelope_usage( + &["repair", "--offline", "--download-only", "--json"], + "repair", + "invalid_args", + ); + assert_envelope_usage(&["vex", "--json"], "vex", "json_requires_output"); +} + +#[test] +fn usage_errors_without_json_keep_stdout_empty() { + let tmp = tempfile::tempdir().unwrap(); + for args in [ + &["scan", "--mode", "hosted", "--global"][..], + &["rollback", "x["][..], + &[ + "remove", + "pkg:npm/a@1.0.0", + "--preserve-state", + "--skip-rollback", + ][..], + ] { + let (exit, stdout, stderr) = run(tmp.path(), args, &[]); + assert_eq!(exit, 2, "{args:?}"); + assert!(stdout.is_empty(), "{args:?}: {stdout:?}"); + assert!(stderr.starts_with("Error: "), "{args:?}: {stderr:?}"); + } +} + +#[test] +fn offline_refusals_carry_a_code() { + let tmp = tempfile::tempdir().unwrap(); + let (exit, stdout, _) = run( + tmp.path(), + &["scan", "--mode", "agent", "--offline", "--json"], + &[], + ); + assert_eq!(exit, 1); + let v = assert_error_object(&stdout, "offline_unsupported"); + assert_eq!(v["scannedPackages"], 0, "{v}"); + + let (exit, stdout, _) = run(tmp.path(), &["get", "lodash", "--offline", "--json"], &[]); + assert_eq!(exit, 1); + assert_error_object(&stdout, "offline_unsupported"); +} + +#[test] +fn rollback_on_an_empty_project_is_manifest_not_found() { + let tmp = tempfile::tempdir().unwrap(); + let (exit, stdout, _) = run(tmp.path(), &["rollback", "--json"], &[]); + assert_eq!(exit, 1, "{stdout}"); + let v = assert_error_object(&stdout, "manifest_not_found"); + assert_eq!(v["error"]["message"], "Manifest not found", "{v}"); + assert!(v["path"].is_string(), "{v}"); +} + +#[test] +fn rollback_unknown_identifier_is_patch_not_found() { + let tmp = tempfile::tempdir().unwrap(); + let socket = tmp.path().join(".socket"); + std::fs::create_dir_all(&socket).unwrap(); + std::fs::write(socket.join("manifest.json"), r#"{"patches": {}}"#).unwrap(); + let (exit, stdout, _) = run( + tmp.path(), + &["rollback", "pkg:npm/nothing@1.0.0", "--json"], + &[], + ); + assert_eq!(exit, 1, "{stdout}"); + let v = assert_error_object(&stdout, "patch_not_found"); + assert_eq!(v["results"], serde_json::json!([]), "{v}"); +} From 619a6662f29e833c3672404131a01e62dbc595e1 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 10:30:56 -0400 Subject: [PATCH 6/8] Fix stale errorCode wording in the contract and test docs (#704) The contract's nested-apply note said the v5 shape replaced a "top-level error.code + string error pair"; the replaced pair was the top-level errorCode. Three test doc comments still described the hosted lock_held/lock_io envelope as a top-level errorCode with a string error. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- crates/socket-patch-cli/tests/covgap_commands_get.rs | 6 +++--- crates/socket-patch-cli/tests/covgap_commands_rollback.rs | 2 +- .../socket-patch-cli/tests/covgap_commands_scan_hosted.rs | 6 +++--- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 4dca08bb2..1644d87c8 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -1504,7 +1504,7 @@ download failures, and `applied` counts only the patches that did apply. A failure no single patch explains (an unreadable manifest, the yarn PnP refusal, unavailable patch sources) sets top-level `error: {code, message}` on the same object (`apply` in `scan`'s envelope); `status` stays -`partial_failure`. v5.0: this replaced the top-level `error.code` + string +`partial_failure`. v5.0: this replaced the top-level `errorCode` + string `error` pair (MAJOR). `vulnerabilities[]` is always sorted by `id` so consumer diffs and diff --git a/crates/socket-patch-cli/tests/covgap_commands_get.rs b/crates/socket-patch-cli/tests/covgap_commands_get.rs index 34ecfcaee..b9134f068 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_get.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_get.rs @@ -2427,9 +2427,9 @@ async fn mount_granted_reference(server: &MockServer, uuid: &str, purl: &str, ur /// Hosted twin of `vendored_lock_held_vendor_step_errors_without_vendor_envelope` /// (and of `covgap_commands_scan_hosted::hosted_lock_held_refuses_before_any_write`): /// `get --mode hosted` folds its result into the HOSTED error -/// envelope, so a held apply lock surfaces as the top-level `errorCode` -/// with a string `error` — NOT the vendored `error: {code, message}` -/// object — exit 1, `redirect.mode` retained, nothing written; the human +/// envelope, so a held apply lock surfaces as the top-level +/// `error: {code: "lock_held", message}` object (v5.0: no top-level +/// `errorCode`) — exit 1, `redirect.mode` retained, nothing written; the human /// arm prints the shared `Error: Another socket-patch process …` line plus /// the `--lock-timeout` hint. A /// `--dry-run` never contends: it previews the redirect under the held diff --git a/crates/socket-patch-cli/tests/covgap_commands_rollback.rs b/crates/socket-patch-cli/tests/covgap_commands_rollback.rs index de23f8f1b..c9e482711 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_rollback.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_rollback.rs @@ -762,7 +762,7 @@ fn lock_contention_exits_with_lock_held_envelope() { assert_eq!( envelope_error_code(&v), Some("lock_held"), - "expected errorCode=lock_held; stdout=\n{stdout}" + "expected error.code=lock_held; stdout=\n{stdout}" ); assert_eq!( json_string(&v, "status"), diff --git a/crates/socket-patch-cli/tests/covgap_commands_scan_hosted.rs b/crates/socket-patch-cli/tests/covgap_commands_scan_hosted.rs index 470e156ee..f775c58ac 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_scan_hosted.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_scan_hosted.rs @@ -705,7 +705,7 @@ async fn takeover_refuses_symlinked_wiring_file_before_reverting() { /// `scan --mode hosted` takes the same `.socket/apply.lock` every other /// mutating command holds — but only when it could write. A WET run with a /// granted reference refuses a held lock with `lock_held` (the hosted -/// envelope's top-level `errorCode`, the shared contention message, exit 1) +/// envelope's top-level `error.code`, the shared contention message, exit 1) /// BEFORE the ledger load or any file write; the human arm prints the /// shared `Error: Another socket-patch process …` line plus the /// `--lock-timeout` hint. A `--dry-run`, @@ -870,8 +870,8 @@ async fn zero_grant_wet_run_ignores_a_malformed_pre_v5_ledger() { /// The hosted `lock_io` envelope (CLI_CONTRACT.md "Lock lifecycle (v5.0)" and /// the hosted-mode "Lock (v5.0)" clause): a regular file /// squatting on `.socket/` makes the wet run's lock acquire fail with an I/O -/// fault, not contention — top-level `errorCode: "lock_io"`, a string -/// `error` naming the squatting path, `redirect: {mode: "hosted"}` retained, +/// fault, not contention — top-level `error.code: "lock_io"`, an +/// `error.message` naming the squatting path, `redirect: {mode: "hosted"}` retained, /// exit 1, refused BEFORE the ledger is read or written. The human arm prints /// the shared `Error: Failed to open lock file at …` line WITHOUT the /// `--lock-timeout` hint (that is a live-holder remedy). The squatting file is never removed or truncated. From a458ba982f59e91081f6e9332af8f5c893a316fb Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 12:06:22 -0400 Subject: [PATCH 7/8] Fix CI: keep the forced identifier off stderr, align tests with #704 - get: the forced --id/--cve/--ghsa shape error no longer echoes the argument. With usage_error printing it, CodeQL traced a test's patch uuid into eprintln (rust/cleartext-logging). - remove/scan tests: under --json the self-enforced usage error is now the coded error on stdout, so assert it there instead of on stderr. - json_error_shape.rs spawns through hermetic::binary_command, as spawn_env_hygiene requires. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-cli/src/commands/get.rs | 13 ++++++++---- .../tests/covgap_commands_get.rs | 2 +- .../tests/json_error_shape.rs | 14 +++++-------- .../tests/remove/remove_duality_invariants.rs | 21 +++++++++++-------- .../tests/scan_rollout_e2e.rs | 10 ++++++++- 5 files changed, 36 insertions(+), 24 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs index 66c3a5bf4..3d0903311 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -960,6 +960,11 @@ const APPLY_FAILED: &str = "Error: Some patches could not be applied."; /// `--ghsa`, so a typo fails fast with a readable message instead of a raw /// API 400 body. `None` when it is well-formed (or the type is not /// shape-checked). +/// +/// The message never echoes the argument: it reaches stderr, and CodeQL +/// treats anything that may be a patch uuid as sensitive +/// (rust/cleartext-logging). The user typed it, so naming the expected +/// form is enough. fn forced_identifier_error(identifier: &str, id_type: IdentifierType) -> Option { let (ok, what, form) = match id_type { IdentifierType::Uuid => ( @@ -975,7 +980,7 @@ fn forced_identifier_error(identifier: &str, id_type: IdentifierType) -> Option< ), IdentifierType::Purl | IdentifierType::Package => return None, }; - (!ok).then(|| format!("\"{identifier}\" is not a valid {what} (expected {form})")) + (!ok).then(|| format!("The identifier is not a valid {what} (expected {form})")) } /// Select one patch per PURL from available patches. @@ -5932,15 +5937,15 @@ mod tests { fn forced_identifier_shapes() { assert_eq!( forced_identifier_error("lodash", IdentifierType::Uuid).as_deref(), - Some("\"lodash\" is not a valid patch UUID (expected xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)") + Some("The identifier is not a valid patch UUID (expected xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)") ); assert_eq!( forced_identifier_error("lodash", IdentifierType::Cve).as_deref(), - Some("\"lodash\" is not a valid CVE ID (expected CVE-YYYY-NNNN)") + Some("The identifier is not a valid CVE ID (expected CVE-YYYY-NNNN)") ); assert_eq!( forced_identifier_error("GHSA-1", IdentifierType::Ghsa).as_deref(), - Some("\"GHSA-1\" is not a valid GHSA ID (expected GHSA-xxxx-xxxx-xxxx)") + Some("The identifier is not a valid GHSA ID (expected GHSA-xxxx-xxxx-xxxx)") ); assert_eq!( forced_identifier_error("a8b05a61-1e2f-4c5f-a65b-93e71deba1ae", IdentifierType::Uuid), diff --git a/crates/socket-patch-cli/tests/covgap_commands_get.rs b/crates/socket-patch-cli/tests/covgap_commands_get.rs index b9134f068..54a4870a1 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_get.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_get.rs @@ -2834,7 +2834,7 @@ async fn forced_identifier_type_is_validated_locally() { let (code, stdout, stderr) = run_get_bin(tmp.path(), &server.uri(), &["lodash", flag]); assert_eq!(code, 2, "{flag}: stdout={stdout}\nstderr={stderr}"); assert!( - stderr.contains(&format!("Error: \"lodash\" {what} (expected ")), + stderr.contains(&format!("Error: The identifier {what} (expected ")), "{flag}: stderr={stderr}" ); let (code, stdout, _) = run_get_bin(tmp.path(), &server.uri(), &["lodash", flag, "--json"]); diff --git a/crates/socket-patch-cli/tests/json_error_shape.rs b/crates/socket-patch-cli/tests/json_error_shape.rs index bbcbd37a6..fe59b08a4 100644 --- a/crates/socket-patch-cli/tests/json_error_shape.rs +++ b/crates/socket-patch-cli/tests/json_error_shape.rs @@ -4,24 +4,20 @@ //! stdout: a full envelope for the envelope commands, `{status, error}` for //! `scan`, `get` and `rollback`. None of these cases reaches the network. -use std::path::{Path, PathBuf}; -use std::process::Command; +use std::path::Path; use socket_patch_cli::args::{GLOBAL_ARG_ENV_VARS, LOCAL_ARG_ENV_VARS}; -fn binary() -> PathBuf { - env!("CARGO_BIN_EXE_socket-patch").into() -} +#[path = "common/hermetic.rs"] +mod hermetic; fn run(cwd: &Path, args: &[&str], env: &[(&str, &str)]) -> (i32, String, String) { - let mut cmd = Command::new(binary()); + let mut cmd = hermetic::binary_command(); + hermetic::scrub_extra(&mut cmd, &[hermetic::Extra::Venv]); cmd.args(args).current_dir(cwd); for var in GLOBAL_ARG_ENV_VARS.iter().chain(LOCAL_ARG_ENV_VARS.iter()) { cmd.env_remove(var); } - cmd.env_remove("VIRTUAL_ENV"); - cmd.env_remove("SOCKET_MAX_NEW_PATCHES"); - cmd.env_remove("SOCKET_MIN_SEVERITY"); cmd.env("SOCKET_TELEMETRY_DISABLED", "1"); // Unroutable: a case that reached the network would fail differently. cmd.env("SOCKET_API_URL", "http://127.0.0.1:1"); diff --git a/crates/socket-patch-cli/tests/remove/remove_duality_invariants.rs b/crates/socket-patch-cli/tests/remove/remove_duality_invariants.rs index b5910cb11..dad2604c2 100644 --- a/crates/socket-patch-cli/tests/remove/remove_duality_invariants.rs +++ b/crates/socket-patch-cli/tests/remove/remove_duality_invariants.rs @@ -397,13 +397,16 @@ fn preserve_conflicts_with_skip_rollback() { code, 2, "the conflict is a usage error; stdout=\n{stdout}\nstderr=\n{stderr}" ); + // Under --json the coded usage error is the envelope on stdout (#704). + let v: serde_json::Value = serde_json::from_str(stdout.trim()) + .unwrap_or_else(|e| panic!("stdout must be one JSON envelope ({e}): {stdout:?}")); + assert_eq!(v["status"], "error", "{v}"); + assert_eq!(v["error"]["code"], "invalid_args", "{v}"); assert!( - stderr.contains("no-op"), - "the error must explain the no-op quadrant; got {stderr:?}" - ); - assert!( - stdout.trim().is_empty(), - "usage errors print to stderr, not a JSON envelope; got {stdout:?}" + v["error"]["message"] + .as_str() + .is_some_and(|m| m.contains("no-op")), + "the error must explain the no-op quadrant; got {v}" ); // The conflict fires before any store is read or created. assert!( @@ -414,7 +417,7 @@ fn preserve_conflicts_with_skip_rollback() { // Env-sourced: SOCKET_PRESERVE_STATE=true + --skip-rollback conflicts // exactly the same way (the contract row says flag- or env-sourced alike). let tmp2 = tempfile::tempdir().expect("tempdir"); - let (code2, _stdout2, stderr2) = run_remove( + let (code2, stdout2, stderr2) = run_remove( tmp2.path(), &["pkg:npm/x@1.0.0", "--json", "--yes", "--skip-rollback"], &[("SOCKET_PRESERVE_STATE", "true")], @@ -424,8 +427,8 @@ fn preserve_conflicts_with_skip_rollback() { "env-sourced preserve-state must conflict too; stderr=\n{stderr2}" ); assert!( - stderr2.contains("no-op"), - "same self-enforced usage error text; got {stderr2:?}" + stdout2.contains("no-op"), + "same self-enforced usage error text; got {stdout2:?}" ); assert!(!tmp2.path().join(".socket").exists()); } diff --git a/crates/socket-patch-cli/tests/scan_rollout_e2e.rs b/crates/socket-patch-cli/tests/scan_rollout_e2e.rs index 6af41211c..4c799d839 100644 --- a/crates/socket-patch-cli/tests/scan_rollout_e2e.rs +++ b/crates/socket-patch-cli/tests/scan_rollout_e2e.rs @@ -789,7 +789,15 @@ async fn a_malformed_env_cap_is_a_usage_error_unless_the_flag_overrides_it() { .env("SOCKET_MAX_NEW_PATCHES", "lots"); let out = cmd.output().unwrap(); assert_eq!(out.status.code(), Some(2)); - assert!(String::from_utf8_lossy(&out.stderr).contains("SOCKET_MAX_NEW_PATCHES")); + // Under --json the coded usage error goes to stdout (#704). + let v: Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!(v["error"]["code"], "invalid_env", "{v}"); + assert!( + v["error"]["message"] + .as_str() + .is_some_and(|m| m.contains("SOCKET_MAX_NEW_PATCHES")), + "{v}" + ); // A flag overrides the env value without reading it. let mut with_flag = Command::new(binary()); From 57dfc10b5e457e887b15fb2308bf371f47259f4e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 15:01:47 +0000 Subject: [PATCH 8/8] Keep rollback's invalid-glob JSON message verbatim The path_glob_invalid usage error capitalized the message before handing it to usage_error, so the --json error.message was capitalized too. capitalize_first is a human-only transform: every other rollback JSON error (emit_rollback_error) and scan's path_glob_invalid keep the verbatim message. Capitalize only for the stderr line, and pin both forms in the json_error_shape test. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LD4qUfZKhg2qgt3x9vGeFf --- crates/socket-patch-cli/src/commands/rollback.rs | 9 ++++++++- crates/socket-patch-cli/tests/json_error_shape.rs | 11 +++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/crates/socket-patch-cli/src/commands/rollback.rs b/crates/socket-patch-cli/src/commands/rollback.rs index c5e4a62bf..2d5760137 100644 --- a/crates/socket-patch-cli/src/commands/rollback.rs +++ b/crates/socket-patch-cli/src/commands/rollback.rs @@ -1009,12 +1009,19 @@ pub async fn run(args: RollbackArgs) -> i32 { let path_scope = match crate::path_scope::PathScope::parse(&path_patterns) { Ok(s) => s, Err(e) => { + // Like emit_rollback_error: JSON keeps the verbatim message, + // only the human stderr line is capitalized. + let message = if args.common.json { + e + } else { + capitalize_first(&e) + }; return crate::json_envelope::usage_error( crate::json_envelope::Command::Rollback, args.common.json, args.common.dry_run, "path_glob_invalid", - &capitalize_first(&e), + &message, ); } }; diff --git a/crates/socket-patch-cli/tests/json_error_shape.rs b/crates/socket-patch-cli/tests/json_error_shape.rs index fe59b08a4..07492ab44 100644 --- a/crates/socket-patch-cli/tests/json_error_shape.rs +++ b/crates/socket-patch-cli/tests/json_error_shape.rs @@ -132,6 +132,17 @@ fn get_usage_errors_print_the_coded_error() { #[test] fn rollback_usage_error_prints_the_coded_error() { assert_legacy_usage(&["rollback", "x[", "--json"], &[], "path_glob_invalid"); + // JSON carries the verbatim message; only stderr is capitalized. + let tmp = tempfile::tempdir().unwrap(); + let (_, stdout, _) = run(tmp.path(), &["rollback", "x[", "--json"], &[]); + let v = assert_error_object(&stdout, "path_glob_invalid"); + let message = v["error"]["message"].as_str().unwrap(); + assert!(message.starts_with("invalid path pattern"), "{v}"); + let (_, _, stderr) = run(tmp.path(), &["rollback", "x["], &[]); + assert!( + stderr.starts_with("Error: Invalid path pattern"), + "{stderr:?}" + ); } #[test]