From 6b8e827097e6414825928c65522a5f996b62e57e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 20:25:47 +0000 Subject: [PATCH 1/3] Start fix for #498 Assisted-by: Claude Code:claude-opus-5-5 From 4749634d1a518fcda3074ef1adfd0f7ff0f2417d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 20:31:47 +0000 Subject: [PATCH 2/3] Test vendor under global scope (#498) Run inside a project, `vendor -g` vendored the manifest's records into that project and `vendor --revert -g` unwound the project's vendoring. These tests pin that every form of `vendor` under --global, --global-prefix, SOCKET_GLOBAL and SOCKET_GLOBAL_PREFIX leaves the project's lockfile, artifacts and ledger byte-identical. Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/global_scope_project_state.rs | 178 +++++++++++++++++- 1 file changed, 176 insertions(+), 2 deletions(-) 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 ff0c1559..67be302a 100644 --- a/crates/socket-patch-cli/tests/global_scope_project_state.rs +++ b/crates/socket-patch-cli/tests/global_scope_project_state.rs @@ -13,7 +13,10 @@ //! 3. `rollback -g` and `remove -g` leave a vendored project's wiring, //! artifact and ledger as they were (#445); //! 4. `apply -g` and `scan -g --mode agent` patch the global copy of a purl -//! the project vendors (#445, the reverse direction). +//! the project vendors (#445, the reverse direction); +//! 5. the standalone `vendor` command (plain, `--revert`, `--check`) is a +//! usage error under global scope, so it neither vendors into nor +//! reverts the project (#498). //! //! Binary-driven, `SOCKET_*`-scrubbed child processes (`common::run`). //! Everything is offline except `scan`, which talks to a wiremock API. @@ -280,7 +283,10 @@ fn write_left_pad(dir: &Path, index: &[u8]) { std::fs::write(dir.join("index.js"), index).unwrap(); } -fn vendored_project() -> VendoredProject { +/// The npm project `vendored_project` starts from: `left-pad` installed +/// from the registry, its patch recorded in the manifest, nothing vendored +/// yet. Returns the project and its original lockfile bytes. +fn manifest_project() -> (tempfile::TempDir, Vec) { let tmp = tempfile::tempdir().expect("tempdir"); let root = tmp.path(); write_left_pad(&root.join("node_modules/left-pad"), ORIG_INDEX); @@ -344,7 +350,12 @@ fn vendored_project() -> VendoredProject { ORIG_INDEX, ) .unwrap(); + (tmp, original_lock) +} +fn vendored_project() -> VendoredProject { + let (tmp, original_lock) = manifest_project(); + let root = tmp.path(); let service = prebuilt_common::Server::project(root); let (code, stdout, stderr) = common::run_with_env( root, @@ -545,3 +556,166 @@ async fn global_agent_scan_patches_a_purl_the_project_vendors() { ); project.assert_untouched("scan --global-prefix --mode agent"); } + +// ═══════════════════ 5. the vendor command under global scope ═══════════════════ + +/// Every way to ask for global scope: the flags and their env vars. +fn global_scopes(prefix: &str) -> Vec<(Vec<&str>, Vec<(&'static str, String)>, &'static str)> { + vec![ + (vec!["--global"], vec![], "--global"), + (vec!["-g"], vec![], "--global"), + (vec!["--global-prefix", prefix], vec![], "--global-prefix"), + (vec![], vec![("SOCKET_GLOBAL", "1".to_string())], "--global"), + ( + vec![], + vec![("SOCKET_GLOBAL_PREFIX", prefix.to_string())], + "--global-prefix", + ), + ] +} + +/// Run `vendor ` under every global scope, human and `--json`, and +/// assert each is the exit-2 usage error naming `why`. +fn assert_vendor_refused(root: &Path, extra: &[&str], why: &str, mut after_each: impl FnMut(&str)) { + let prefix = empty_prefix(); + let prefix = prefix.path().to_str().unwrap(); + for (flags, env, flag) in global_scopes(prefix) { + for json in [false, true] { + let mut args = vec!["vendor", "--yes", "--offline", "--lock-timeout", "5"]; + args.extend(extra.iter().copied()); + args.extend(flags.iter().copied()); + if json { + args.push("--json"); + } + let mut envs: Vec<(&str, &str)> = vec![("SOCKET_PATCH_SERVER_URL", PATCH_HOST)]; + envs.extend(env.iter().map(|(k, v)| (*k, v.as_str()))); + let (code, stdout, stderr) = common::run_with_env(root, &args, &envs); + let what = format!("{args:?} {env:?}"); + assert_eq!(code, 2, "{what}: stdout={stdout}\nstderr={stderr}"); + let expected = format!( + "{flag} cannot be used with vendor{}: global installs have no project \ + lockfile to {why}", + extra.first().map(|f| format!(" {f}")).unwrap_or_default() + ); + if json { + let v = parse(&stdout, &stderr); + assert_eq!(v["status"], "error", "{what}: {v}"); + assert_eq!( + v["error"]["code"], "global_scope_unsupported", + "{what}: {v}" + ); + assert_eq!(v["error"]["message"], expected.as_str(), "{what}: {v}"); + } else { + assert_eq!(stderr.trim_end(), format!("Error: {expected}"), "{what}"); + assert!(stdout.is_empty(), "{what}: {stdout:?}"); + } + after_each(&what); + } + } +} + +/// `vendor --revert -g` inside a vendored project must not revert the +/// project's vendoring (#498): that silently unpatched it on the next +/// frozen install. +#[test] +fn global_vendor_revert_leaves_vendored_project_state() { + let project = vendored_project(); + assert_vendor_refused( + project.root(), + &["--revert"], + "revert vendored artifacts from", + |what| project.assert_untouched(what), + ); + // Control: the same revert without global scope does unwind it. + let (code, stdout, stderr) = run( + project.root(), + &[ + "vendor", + "--revert", + "--yes", + "--json", + "--lock-timeout", + "5", + ], + ); + assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); + assert!(!project.tgz().exists(), "control: project revert unwinds"); +} + +/// `vendor -g` inside a project whose manifest holds a record (e.g. one +/// `get -g` wrote) must not vendor into the project (#498). +#[test] +fn global_vendor_does_not_vendor_into_the_project() { + let (tmp, original_lock) = manifest_project(); + let root = tmp.path(); + let service = prebuilt_common::Server::project(root); + let manifest = std::fs::read(root.join(".socket/manifest.json")).unwrap(); + let prefix = empty_prefix(); + let prefix = prefix.path().to_str().unwrap(); + for (flags, env, flag) in global_scopes(prefix) { + let mut args = vec!["vendor", "--json", "--lock-timeout", "5"]; + args.extend(flags.iter().copied()); + let mut envs: Vec<(&str, &str)> = vec![("SOCKET_VENDOR_URL", &service.uri)]; + envs.extend(env.iter().map(|(k, v)| (*k, v.as_str()))); + let (code, stdout, stderr) = common::run_with_env(root, &args, &envs); + let what = format!("{args:?} {env:?}"); + assert_eq!(code, 2, "{what}: stdout={stdout}\nstderr={stderr}"); + let v = parse(&stdout, &stderr); + assert_eq!( + v["error"]["code"], "global_scope_unsupported", + "{what}: {v}" + ); + assert_eq!( + v["error"]["message"], + format!( + "{flag} cannot be used with vendor: global installs have no project lockfile \ + to wire vendored artifacts into" + ) + .as_str(), + "{what}: {v}" + ); + assert_eq!( + std::fs::read(root.join("package-lock.json")).unwrap(), + original_lock, + "{what}: the project's lockfile must stay" + ); + assert!( + !root.join(".socket/vendor").exists(), + "{what}: nothing vendored into the project" + ); + assert_eq!( + std::fs::read(root.join(".socket/manifest.json")).unwrap(), + manifest, + "{what}: the manifest must stay" + ); + } + // The human path refuses the same way. + assert_vendor_refused(root, &[], "wire vendored artifacts into", |what| { + assert!(!root.join(".socket/vendor").exists(), "{what}"); + }); + // Control: without global scope the same project vendors. + let (code, stdout, stderr) = common::run_with_env( + root, + &["vendor", "--json", "--silent", "--lock-timeout", "5"], + &[("SOCKET_VENDOR_URL", &service.uri)], + ); + assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); + assert_ne!( + std::fs::read(root.join("package-lock.json")).unwrap(), + original_lock, + "control: project vendor rewires the lock" + ); +} + +/// `vendor --check -g` checks no project either: the project's vendored +/// state is not a global run's target. +#[test] +fn global_vendor_check_is_refused() { + let project = vendored_project(); + assert_vendor_refused( + project.root(), + &["--check"], + "check vendored artifacts in", + |what| project.assert_untouched(what), + ); +} From 0e617ff0efed1d745c70f8379547df8c6102b324 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 20:31:47 +0000 Subject: [PATCH 3/3] Refuse vendor under global scope (#498) A global run never targets the project it starts in, but the standalone `vendor` command ignored global scope: `vendor --revert -g` silently unpatched a vendored project on its next frozen install, and `vendor -g` rewired the project's lockfile. Every form of `vendor` is now the same exit-2 usage error `scan`/`get --mode vendored -g` give, raised before the project is read or locked. Assisted-by: Claude Code:claude-opus-5-5 --- CHANGELOG.md | 7 ++ crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- crates/socket-patch-cli/src/commands/mod.rs | 16 ++-- .../socket-patch-cli/src/commands/vendor.rs | 81 +++++++++++++------ 4 files changed, 76 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b03e92f8..3593a3d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -183,6 +183,13 @@ limits, and required install commands. unparseable (hosted) or refused as `vendor_lockfile_version_unsupported` (vendored). The lock now keeps its BOM, indent and line endings, and the undo is byte-exact (#324). +- `vendor` under `--global` / `--global-prefix` (or `SOCKET_GLOBAL` / + `SOCKET_GLOBAL_PREFIX`) is now a usage error (exit 2, + `global_scope_unsupported`), like `scan` and `get` with `--mode vendored`. + Run inside a project, `vendor -g` vendored the manifest's records into that + project and rewired its lockfile, and `vendor --revert -g` unwound the + project's vendoring, so its next frozen install was silently unpatched. + Global installs have no project lockfile to vendor into (#498). ### Maintenance diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 72bffac7..b1b0d3ec 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -126,7 +126,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc **Mode resolution (`resolve_mode_flags`, MAJOR in v5.0).** `--mode`, or one of its legacy boolean spellings (`--vendor`, `--apply`/`--sync`), picks the mode. With none of them, `scan` runs **hosted** mode — JSON and human alike; the result nests under the JSON `redirect` sub-object (see the hosted paragraph below). The one exception: a `--prune` or `--global`/`--global-prefix` scan with no mode has no project lockfile to rewire, so it is **report-only** — discovery, the table, the `updates` array and the `redirectState` block below, plus the `--prune` GC — and, in human mode, ends with the hint `To apply these patches in place, run:` / ` socket-patch scan --mode agent [PATHS]` / ` socket-patch get `. An explicit `--mode hosted` or `--mode vendored` (or the hidden `--vendor`) with `--global`/`--global-prefix` is a usage error (exit 2: global installs have no project lockfile to redirect, or to wire vendored artifacts into); `get` enforces the same rule with the same wording. -**Global scope never touches the project's state (v5.0).** A `--global`/`--global-prefix` run that starts inside a project acts on the global installs only. The `--cwd` project's hosted pins and vendor ledger are not its target: `rollback` and `remove` run no hosted or vendored leg (they restore the global copies and drop their manifest records; `rollback` keeps the manifest records of purls the project vendors), a pre-v5 hosted ledger is never retired, and the project's vendor ledger does not own the global copies, so `apply` and `scan --mode agent` patch the global copy of a purl the project vendors (no `vendored` skip, no `vendored_ownership_retained` warning). +**Global scope never touches the project's state (v5.0).** A `--global`/`--global-prefix` run that starts inside a project acts on the global installs only. The `--cwd` project's hosted pins and vendor ledger are not its target: `rollback` and `remove` run no hosted or vendored leg (they restore the global copies and drop their manifest records; `rollback` keeps the manifest records of purls the project vendors), a pre-v5 hosted ledger is never retired, and the project's vendor ledger does not own the global copies, so `apply` and `scan --mode agent` patch the global copy of a purl the project vendors (no `vendored` skip, no `vendored_ownership_retained` warning). The standalone `vendor` command acts only on the project, so every form of it (plain, `--revert`, `--check`) is a usage error under global scope: exit 2, human `Error: cannot be used with vendor[ --revert| --check]: global installs have no project lockfile to …`, JSON `{status: "error", error: {code: "global_scope_unsupported", message}}`, checked before the project is read or locked (#498). **scan never prompts, in any mode** (v5.0): no confirm, no free-tier patch menu (it always takes the top-ranked downloadable patch; see "Which patch gets selected"), and no `Non-interactive mode detected` note. `--yes` does not change a scan. `get` (agent mode only — hosted/vendored `get` never prompts either, v5.0), `rollback`, `remove` and `--update` keep their prompts. diff --git a/crates/socket-patch-cli/src/commands/mod.rs b/crates/socket-patch-cli/src/commands/mod.rs index 798ab487..ea45e691 100644 --- a/crates/socket-patch-cli/src/commands/mod.rs +++ b/crates/socket-patch-cli/src/commands/mod.rs @@ -63,15 +63,21 @@ pub(crate) fn global_mode_conflict( }; Some(format!( "{} cannot be used with --mode {}: global installs have no project lockfile to {why}", - if common.global { - "--global" - } else { - "--global-prefix" - }, + global_scope_flag(common), mode.cli_name(), )) } +/// The flag that put a run in global scope, as usage errors name it +/// (`SOCKET_GLOBAL` / `SOCKET_GLOBAL_PREFIX` set the same fields). +pub(crate) fn global_scope_flag(common: &crate::args::GlobalArgs) -> &'static str { + if common.global { + "--global" + } else { + "--global-prefix" + } +} + /// Lockfile discovery of `root` (core `vex::discover`): the hosted and /// vendored patch references its lockfiles and configs wire, with hosted /// references counted on Socket's public patch server plus the operator's diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index fcf931bb..60a15dee 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -644,7 +644,42 @@ pub(crate) fn note_classic_migration_risk( }); } +/// The usage error for `vendor` under global scope, or `None` for a +/// project run. Every form of the command acts on the `--cwd` project's +/// lockfiles and vendor ledger, which a global run never targets (#498): +/// plain `vendor` would vendor into the project, `--revert` would unwind +/// the project's vendoring, and `--check` would report on it. +fn global_scope_conflict(args: &VendorArgs) -> Option { + if crate::commands::project_state_in_scope(&args.common) { + return None; + } + let (form, why) = if args.check { + (" --check", "check vendored artifacts in") + } else if args.revert { + (" --revert", "revert vendored artifacts from") + } else { + ("", "wire vendored artifacts into") + }; + Some(format!( + "{} cannot be used with vendor{form}: global installs have no project lockfile to {why}", + crate::commands::global_scope_flag(&args.common), + )) +} + 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; + } if args.check { return run_check(&args).await; } @@ -662,31 +697,29 @@ pub async fn run(args: VendorArgs) -> i32 { if !args.revert && tokio::fs::metadata(&manifest_path).await.is_err() { // A hosted project (no manifest, hosted pins in its lockfiles) // ejects: its patch set is the lockfiles' hosted pins. - if !args.common.is_global() { - let inventory = crate::commands::hosted_inventory(&args.common, &args.common.cwd).await; - // Contested hosted wiring: the patch set cannot be read off the - // lockfiles, and a "nothing to vendor" answer would hide it. - if let Some(refusal) = inventory.contested_refusal() { - return emit_eject_refusal(&args.common, "hosted_wiring_contested", &refusal); - } - let pins = hosted_pins_in_scope(&args.common, inventory.pins); - if !pins.is_empty() { - // Eject needs every patch record from the API: an offline - // run (or dry run) refuses before any request. - if args.common.offline { - return emit_eject_refusal( - &args.common, - "offline_eject_unavailable", - &format!( - "ejecting {} needs {} patch record(s) from the Socket API, and this \ - run is offline; re-run without --offline", - plural(pins.len(), "hosted package", "hosted packages"), - pins.len() - ), - ); - } - return run_eject(&args, pins).await; + let inventory = crate::commands::hosted_inventory(&args.common, &args.common.cwd).await; + // Contested hosted wiring: the patch set cannot be read off the + // lockfiles, and a "nothing to vendor" answer would hide it. + if let Some(refusal) = inventory.contested_refusal() { + return emit_eject_refusal(&args.common, "hosted_wiring_contested", &refusal); + } + let pins = hosted_pins_in_scope(&args.common, inventory.pins); + if !pins.is_empty() { + // Eject needs every patch record from the API: an offline + // run (or dry run) refuses before any request. + if args.common.offline { + return emit_eject_refusal( + &args.common, + "offline_eject_unavailable", + &format!( + "ejecting {} needs {} patch record(s) from the Socket API, and this \ + run is offline; re-run without --offline", + plural(pins.len(), "hosted package", "hosted packages"), + pins.len() + ), + ); } + return run_eject(&args, pins).await; } // A requested `--vex` still attests what the `.socket/vendor` // ledgers and lockfiles already wire. Same contract as `apply --vex`