From 489d256a08717c995dda82295a4285fe0a2749b7 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 9 Oct 2026 12:42:53 -0400 Subject: [PATCH 1/6] WIP: keep project mode on bare scan/get Co-Authored-By: Claude Opus 5.5 (1M context) From 4a922c24110b15d5ccf275f5bf5d5247a31c2815 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 9 Oct 2026 13:30:26 -0400 Subject: [PATCH 2/6] Keep the project's mode on a bare scan or get A bare `socket-patch scan` (the quick-start command) or `get` used to run hosted mode on every project. On a vendored project that ran the vendored -> hosted takeover in place, so a repo vendored for air-gapped installs silently became hosted. With no `--mode`, scan and get now keep the mode the project's state already records: a vendor ledger means vendored, a manifest with patches means agent, and a project with neither is hosted as before. Switching modes always needs an explicit `--mode`, so the takeover only runs under `--mode hosted`. A project with both agent and vendored patches is a usage error (`mode_ambiguous`, exit 2) that asks for `--mode`. Human runs print a note when they keep a non-hosted mode. CLI_CONTRACT.md, the README, usage guide and migration guide describe the rule. Fixes #1088 Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 4 +- crates/socket-patch-cli/CLI_CONTRACT.md | 11 +- crates/socket-patch-cli/src/commands/get.rs | 41 +- crates/socket-patch-cli/src/commands/mod.rs | 55 +++ .../socket-patch-cli/src/commands/scan/mod.rs | 36 +- .../tests/scan_get_mode_from_project_state.rs | 425 ++++++++++++++++++ docs/migrating-to-v5.md | 12 +- docs/usage.md | 4 +- 8 files changed, 564 insertions(+), 24 deletions(-) create mode 100644 crates/socket-patch-cli/tests/scan_get_mode_from_project_state.rs diff --git a/README.md b/README.md index ce33f0607..b10152567 100644 --- a/README.md +++ b/README.md @@ -90,13 +90,15 @@ VEX-aware vulnerability scanner. | Mode | Command | What to commit | What installs need | | --- | --- | --- | --- | -| Hosted (default) | `socket-patch scan` | Changed lockfiles, manifests, and registry configuration | Access to Socket's patch server | +| Hosted (default for a new project) | `socket-patch scan` | Changed lockfiles, manifests, and registry configuration | Access to Socket's patch server | | Vendored | `socket-patch scan --mode vendored` | Changed dependency files and `.socket/vendor/` (artifacts and ledger) | The committed patched packages | | Agent | `socket-patch scan --mode agent` | `.socket/manifest.json` and patch data; Go also uses a committed patched tree | `socket-patch apply` after dependency installs | Vendored mode stores **patched dependencies**, not the entire dependency graph. Other dependencies still need their normal registry, mirror, or offline cache. Hosted and vendored installs do not need an install hook or the Socket Patch CLI. +Once a project holds vendored or agent-mode patches, a bare `scan` or `get` keeps +that mode; pass `--mode` to switch. The CLI supports npm, PyPI, Cargo, Go, RubyGems, Maven (including sbt, Mill and scala-cli builds), Composer, NuGet, and Deno. diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index bb4592f4b..01cea3559 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -16,11 +16,11 @@ For task-oriented guidance, start with [usage](../../docs/usage.md), | Name | Visible alias(es) | Notes | |---|---|---| -| `scan` | — | Find patches for installed packages and apply them. **v5.0 (MAJOR)**: a bare `scan` runs hosted mode (rewrites lockfiles so only the patched dependencies resolve to Socket-hosted, integrity-pinned packages); `--mode vendored` / `--mode agent` pick the other modes. Never prompts. See [scan modes](#scan-modes-v50) | +| `scan` | — | Find patches for installed packages and apply them. **v5.0 (MAJOR)**: a bare `scan` runs hosted mode (rewrites lockfiles so only the patched dependencies resolve to Socket-hosted, integrity-pinned packages) on a project with no patch state, and keeps the mode of a project that already holds vendored or agent-mode patches; `--mode vendored` / `--mode agent` / `--mode hosted` pick a mode explicitly. Never prompts. See [scan modes](#scan-modes-v50) | | `vex` | — | Emit an OpenVEX 0.2.0 attestation derived from the local manifest, the vendor ledger, and the hosted / vendored patch references the project's lockfiles wire (no manifest required; hosted records come from the API) | | `vendor` | — | Eject patched dependencies into committable `.socket/vendor/` and rewire lockfiles | | `list` | — | Print patches in the local manifest, plus the vendor ledger's records (v5.0) and the hosted pins the lockfiles wire (labeled; see the action matrix; an empty project exits 0) | -| `get` | — | Fetch a selected patch in hosted mode by default; `--mode agent` selects in-place application, also the default with `--save-only` or global targeting. Requires positional `identifier`. | +| `get` | — | Fetch a selected patch in the project's current mode (hosted for a project with no patch state); `--mode agent` selects in-place application, also the default with `--save-only` or global targeting. Requires positional `identifier`. | | `apply` | — | Agent mode: apply patches from the local manifest | | `rollback` | — | **Full-state rollback (v5.0, MAJOR)**: restore original files AND unwind vendored lockfile wiring / restore hosted pins to their upstream registry entries, remove the rolled-back entries from the manifest, and GC their blobs/archives; takes optional variadic positional `targets` (PURL \| UUID \| package name \| path glob). See [Rollback command contract](#rollback-command-contract-v50) | | `remove` | — | Restore and remove one patch across hosted, vendored, and agent state; requires positional `identifier`. | @@ -104,7 +104,7 @@ Beyond the globals above, each subcommand defines a small set of local arguments | `scan` | `--no-socket-yml` | `SOCKET_NO_SOCKET_YML` | (v5.0) Ignore the repository's socket.yml patch policy (its `patches` block and `projectIgnorePaths`) for this run; the built-in test/fixture ignores still apply. The `policy` block reports `source: "bypassed"`. See "socket.yml patch policy". | | `scan` | `--min-severity ` | `SOCKET_MIN_SEVERITY` | (v5.0) Severity floor for the patch a package may receive (worst advisory severity; unknown severity is skipped whenever a floor is set). Beats `patches.minSeverity`; the flag beats the env; `none` lifts the floor. A malformed value is exit 2. | | `get`, `scan` | `--all-releases` | `SOCKET_ALL_RELEASES` | Download patches for every release/distribution variant of a matched package — PyPI wheel/sdist (`artifact_id`), RubyGems (`platform`), Maven (`classifier`) — not just the one(s) matching the locally-installed distribution. On `scan` this makes the stored manifest portable across environments (e.g. cross-platform CI caches). On `get` (v3.6) it ALSO disables the coarse installed-**version** narrowing of CVE/GHSA fan-outs (see "get --mode and installed narrowing"): every found version's patch is fetched, installed or not | -| `get` | positional `identifier`; `--id` / `--cve` / `--ghsa` / `--package` (`-p`); `--save-only`; `--mode ` | `SOCKET_SAVE_ONLY` | Patch lookup + consumption mode (v3.6). `--mode` reuses scan's value enum (same hidden value aliases `host`/`redirect`/`vendor`; deliberately no env binding, matching scan). Default (v5.0): `hosted`, like scan; `agent` (save + apply in place) when `--save-only` or `--global`/`--global-prefix` is given. An explicit `--mode hosted\|vendored` with `--global`/`--global-prefix` is a usage error (exit 2, scan's wording: global installs have no project lockfile). An explicit `--save-only` conflicts with `--mode hosted\|vendored` — rejected with **exit 1** via get's established self-enforced-conflict style (unlike scan's exit-2 mode conflicts; see the exit-code table) | +| `get` | positional `identifier`; `--id` / `--cve` / `--ghsa` / `--package` (`-p`); `--save-only`; `--mode ` | `SOCKET_SAVE_ONLY` | Patch lookup + consumption mode (v3.6). `--mode` reuses scan's value enum (same hidden value aliases `host`/`redirect`/`vendor`; deliberately no env binding, matching scan). Default (v5.0): the mode the project's patch state records, like scan (see **Mode resolution**), so `hosted` for a project with no state; `agent` (save + apply in place) when `--save-only` or `--global`/`--global-prefix` is given. An explicit `--mode hosted\|vendored` with `--global`/`--global-prefix` is a usage error (exit 2, scan's wording: global installs have no project lockfile). An explicit `--save-only` conflicts with `--mode hosted\|vendored` — rejected with **exit 1** via get's established self-enforced-conflict style (unlike scan's exit-2 mode conflicts; see the exit-code table) | | `remove` | positional `identifier` (target grammar: UUID, PURL with or without version, or exact package name); `--skip-rollback`; `--preserve-state` (v5.0) | `SOCKET_SKIP_ROLLBACK`, `SOCKET_PRESERVE_STATE` | Manifest entry removal. The identifier matches like a `rollback` target (a PyPI name compares by its PEP 503 canonical form). `--preserve-state` is the single-patch twin of `rollback --preserve-state`: restore the tree and unwind the identifier's vendored/hosted wiring, but keep the manifest entry, the vendored artifact + ledger entry, and skip all GC. Combining it with `--skip-rollback` is a self-enforced usage error (exit 2): one flag keeps the tree and drops the state, the other restores the tree and keeps the state — together they select the do-nothing quadrant ("the combination would be a no-op: nothing would change"). The conflict fires whether either flag is spelled on the command line or sourced from its env var | | `rollback` | optional variadic positional `targets` (PURL \| UUID \| package name \| path glob); `--preserve-state` (v5.0) | `SOCKET_PRESERVE_STATE` | Rollback scope. Multiple targets union. A token becomes a path glob ONLY when it is path-SHAPED — contains a separator (`/` or `\`) or a glob metacharacter (`*?[`), or starts with `./`, or is absolute (an npm `@scope/name` is a name, not a path; a relative slash name without glob metacharacters or `.`/`..` segments — composer `vendor/pkg`, a go module path — is a name when it selects a recorded or hosted patch); a `pkg:` prefix is a PURL and every other token keeps the target grammar (UUID, PURL or exact package name), so a mistyped identifier or truncated UUID stays a safe exit-1 "No patch found matching identifier: X" (with a hint suggesting `./X` or `X/**` for directory targeting) instead of silently becoming a path scope. An unparseable glob is a usage error (exit 2) | | `vex` | `--output` / `-O`, `--product`, `--no-verify`, `--doc-id`, `--compact` | `SOCKET_VEX_OUTPUT`, `SOCKET_VEX_PRODUCT`, `SOCKET_VEX_NO_VERIFY`, `SOCKET_VEX_DOC_ID`, `SOCKET_VEX_COMPACT` | OpenVEX 0.2.0 document generation; see "vex output channels" below | @@ -132,7 +132,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc ### Scan modes (v5.0) -**Mode resolution (`resolve_mode_flags`, MAJOR in v5.0).** `--mode`, or `--sync` (agent mode), picks the mode. With neither, `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 `. A global scan's hint carries the run's scope, so it can be run verbatim: `-g` (`scan --mode agent -g` / `get -g <…>`), or `--global-prefix ` when a prefix was given (the directory shell-quoted when it needs it). An explicit `--mode hosted` or `--mode vendored` 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. +**Mode resolution (`resolve_mode_flags`, MAJOR in v5.0).** `--mode`, or `--sync` (agent mode), picks the mode. With neither, `scan` and `get` keep the mode the `--cwd` project's patch state already records (`mode_from_project_state`, #1088): a non-empty vendor ledger (`.socket/vendor/state.json`; also an unreadable or malformed one, so the vendored flow reports it) means **vendored**, a manifest holding patches means **agent**, and a project with neither runs **hosted** mode — JSON and human alike; the hosted result nests under the JSON `redirect` sub-object (see the hosted paragraph below). A project holding both agent and vendored state is a usage error (exit 2, code `mode_ambiguous`, naming the three `--mode` values) and writes nothing. A human-mode run that kept vendored or agent mode says so on stderr (`Note: using --mode vendored because … already holds vendored patches; pass --mode explicitly to switch modes`); `--json` and `--silent` print no note. **Switching modes is always explicit**: the in-place vendored → hosted takeover (see **Staged takeover**) runs only under `--mode hosted`, the hosted → vendored takeover only under `--mode vendored`, and a bare `scan`/`get` never converts a project. With hosted/vendored PATH arguments and no `--mode`, each project directory takes its mode from its own state. The one exception to the state-derived default: 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 `. A global scan's hint carries the run's scope, so it can be run verbatim: `-g` (`scan --mode agent -g` / `get -g <…>`), or `--global-prefix ` when a prefix was given (the directory shell-quoted when it needs it). An explicit `--mode hosted` or `--mode vendored` 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). 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). @@ -1437,6 +1437,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `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. | +| `mode_ambiguous` | scan, get | Usage error (exit 2, v5.0): no `--mode` was given and the project holds both agent-mode patches (`.socket/manifest.json`) and vendored patches (`.socket/vendor/state.json`), so the mode to keep is unknown. Pass `--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. | @@ -1754,7 +1755,7 @@ 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 conflict (`--sync` combined with a `--mode` other than `agent`, 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`); on every project command (all but `--update`), a `--cwd` / `SOCKET_CWD` or `--global-prefix` / `SOCKET_GLOBAL_PREFIX` that is not an existing directory, and a non-default `--manifest-path` / `SOCKET_MANIFEST_PATH` that names a directory or sits in a project directory that does not exist (v5.0; checked once before the command runs, whether or not the command uses the flag — previously these read as an empty project and exited 0, and `remove` / `rollback` exited 1; a missing manifest FILE in an existing project stays legal, and `get` / `scan`, which create the manifest, still create a missing manifest directory); `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, and the path-flag check that runs before every command (`--cwd` / `--global-prefix` / `--manifest-path` naming nothing), 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. | +| `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 conflict (`--sync` combined with a `--mode` other than `agent`, rejected in `resolve_mode_flags`) and `--mode hosted\|vendored` with `--global`/`--global-prefix` (same enforcement point; `get` refuses the same combination); a `scan`/`get` with no `--mode` on a project holding both agent and vendored state (`mode_ambiguous`, v5.0); 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`); on every project command (all but `--update`), a `--cwd` / `SOCKET_CWD` or `--global-prefix` / `SOCKET_GLOBAL_PREFIX` that is not an existing directory, and a non-default `--manifest-path` / `SOCKET_MANIFEST_PATH` that names a directory or sits in a project directory that does not exist (v5.0; checked once before the command runs, whether or not the command uses the flag — previously these read as an empty project and exited 0, and `remove` / `rollback` exited 1; a missing manifest FILE in an existing project stays legal, and `get` / `scan`, which create the manifest, still create a missing manifest directory); `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, and the path-flag check that runs before every command (`--cwd` / `--global-prefix` / `--manifest-path` naming nothing), 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 `error.code: lock_held` when another live socket-patch process holds `<.socket>/apply.lock`. diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs index e83707d17..ad6bea329 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -143,7 +143,8 @@ pub struct GetArgs { pub all_releases: bool, /// How to consume the patches: the same modes as `scan --mode` - /// [default: hosted; agent with `--save-only` or `--global`] + /// [default: the mode the project's patch state already records, else + /// hosted; agent with `--save-only` or `--global`] // agent = record in .socket/manifest.json + blobs and apply in place; // hosted = rewrite lockfiles so the patched deps resolve to Socket's // hosted patch server (no manifest, no blobs, no ledger: the lockfile @@ -1016,16 +1017,34 @@ pub async fn run(args: GetArgs) -> i32 { "Only one of --id, --cve, --ghsa, or --package can be specified", ); } - // v5: hosted by default, like scan. `--save-only` (records a manifest - // entry) and global installs (no project lockfile) mean agent mode. - // Usage errors exit 2, like clap's and scan's (v5.0). - let mode = args - .mode - .unwrap_or(if args.save_only || args.common.is_global() { - super::scan::ScanMode::Agent - } else { - super::scan::ScanMode::Hosted - }); + // v5: with no `--mode`, like scan, the project keeps the mode its + // state already records (#1088) and a project with no state is hosted. + // `--save-only` (records a manifest entry) and global installs (no + // project lockfile) mean agent mode. Usage errors exit 2, like clap's + // and scan's (v5.0). + let mode = match args.mode { + Some(mode) => mode, + None if args.save_only || args.common.is_global() => super::scan::ScanMode::Agent, + None => match super::mode_from_project_state(&args.common).await { + Ok(mode) => { + if !args.common.json && !args.common.silent { + if let Some(note) = super::kept_mode_note(mode) { + eprintln!("{note}"); + } + } + mode + } + Err(message) => { + return usage_error( + JsonCommand::Get, + args.common.json, + args.common.dry_run, + "mode_ambiguous", + &message, + ); + } + }, + }; // 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) { diff --git a/crates/socket-patch-cli/src/commands/mod.rs b/crates/socket-patch-cli/src/commands/mod.rs index 45329a503..ddfb581d1 100644 --- a/crates/socket-patch-cli/src/commands/mod.rs +++ b/crates/socket-patch-cli/src/commands/mod.rs @@ -48,6 +48,61 @@ pub(crate) fn project_state_in_scope(common: &crate::args::GlobalArgs) -> bool { !common.is_global() } +/// The mode a `scan`/`get` run with no `--mode` uses for the `--cwd` +/// project (CLI_CONTRACT.md, Mode resolution): the mode the project's own +/// patch state already records, so a bare run never converts the project +/// to another mode. Hosted is the default for a project with no state. +/// +/// * a non-empty vendor ledger (`.socket/vendor/state.json`) → vendored; +/// * a manifest (`.socket/manifest.json`) holding patches → agent; +/// * neither → hosted (hosted mode keeps no ledger of its own); +/// * both → `Err(usage message)`: the run cannot tell which mode to keep, +/// so it asks for an explicit `--mode` rather than guess. +/// +/// An unreadable or malformed vendor ledger counts as vendored, so the +/// vendored flow reports the corruption instead of a hosted takeover +/// running over it. An unreadable manifest is not agent evidence (hosted +/// and vendored mode never read it). Only called in project scope. +pub(crate) async fn mode_from_project_state( + common: &crate::args::GlobalArgs, +) -> Result { + let manifest_path = common.resolved_manifest_path(); + let (manifest, vendor) = tokio::join!( + socket_patch_core::manifest::operations::read_manifest(&manifest_path), + socket_patch_core::vendor::load_state(&common.cwd), + ); + let vendored = !matches!(vendor, Ok(state) if state.entries.is_empty()); + let agent = matches!(manifest, Ok(Some(m)) if !m.patches.is_empty()); + match (vendored, agent) { + (true, true) => Err(format!( + "{} holds both agent-mode patches ({}) and vendored patches \ + (.socket/vendor/state.json): pass --mode agent, --mode vendored or \ + --mode hosted to choose the mode this run uses", + common.cwd.display(), + manifest_path.display(), + )), + (true, false) => Ok(scan::ScanMode::Vendored), + (false, true) => Ok(scan::ScanMode::Agent), + (false, false) => Ok(scan::ScanMode::Hosted), + } +} + +/// The stderr note a human-mode `scan`/`get` prints when it kept a +/// non-default mode from the project's state. +pub(crate) fn kept_mode_note(mode: scan::ScanMode) -> Option { + let store = match mode { + scan::ScanMode::Hosted => return None, + scan::ScanMode::Vendored => ".socket/vendor/state.json", + scan::ScanMode::Agent => "the manifest", + }; + Some(format!( + "Note: using --mode {} because {store} already holds {} patches; pass \ + --mode explicitly to switch modes", + mode.cli_name(), + mode.cli_name(), + )) +} + /// The usage error for a mode that rewires the project (`hosted`, /// `vendored`) under global scope, or `None` when `mode` is allowed. /// Shared by `scan` and `get` so both refuse the same combinations with diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index a3d5b08e6..75fa9ccdf 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -262,8 +262,10 @@ pub struct ScanArgs { #[arg(long, default_value_t = false)] pub sync: bool, - /// How discovered patches are consumed [default: hosted]. A `--prune` - /// or `--global` scan with no mode only reports + /// How discovered patches are consumed [default: the mode the + /// project's patch state already records, else hosted]. Switching an + /// existing vendored or agent-mode project to another mode needs this + /// flag. A `--prune` or `--global` scan with no mode only reports // `--sync` also selects agent; combining it with a different `--mode` // is rejected in `resolve_mode_flags`. #[arg(long = "mode", value_enum)] @@ -1545,10 +1547,14 @@ fn project_dirs( /// name, as if each were `--cwd`. The exit code is the worst of the runs. /// `--json` takes one directory, so stdout stays one document. Every /// directory must be inside the repository root the policy was read from. +/// +/// `mode_inferred`: the mode came from `--cwd`'s state rather than +/// `--mode`, so each directory takes its mode from its own state. async fn run_project_dirs( args: ScanArgs, telemetry: &mut PendingTelemetry, invocation: &InvocationPolicy, + mode_inferred: bool, ) -> i32 { let usage = |code: &str, message: &str| { usage_error( @@ -1622,6 +1628,9 @@ async fn run_project_dirs( child.paths.clear(); child.common.cwd = dir.clone(); child.rollout.carry = Some(carry.clone()); + if mode_inferred { + child.mode = None; + } code = code.max(Box::pin(run_scan(child, telemetry, Some(invocation), *explicit)).await); } code @@ -1659,6 +1668,9 @@ async fn run_scan( ) -> i32 { apply_env_toggles(&args.common); + // Whether the user chose the mode (`--mode`, or `--sync` = agent). + // Without it, the mode comes from the project's own state below. + let mode_explicit = args.mode.is_some() || args.sync; // Resolve `--mode`/`--sync` into `args.mode` (see // `resolve_mode_flags`). `--sync` with another mode is a usage error // (exit 2); under --json it prints the coded error on stdout. @@ -1676,6 +1688,24 @@ async fn run_scan( }; return scan_usage_error(&args, code, &message); } + // A bare scan keeps the mode the project already has (#1088): only an + // explicit `--mode` converts a vendored or agent-mode project. The + // hosted default above is the only one this replaces (a `--prune` or + // global scan with no mode stays report-only). + let mode_inferred = !mode_explicit && args.mode == Some(ScanMode::Hosted); + if mode_inferred { + match crate::commands::mode_from_project_state(&args.common).await { + Ok(mode) => { + if !args.common.json && !args.common.silent { + if let Some(note) = crate::commands::kept_mode_note(mode) { + eprintln!("{note}"); + } + } + args.mode = Some(mode); + } + Err(message) => return scan_usage_error(&args, "mode_ambiguous", &message), + } + } // The repo's socket.yml policy, read once per invocation before any // write (an invalid file fails the run closed). @@ -1699,7 +1729,7 @@ async fn run_scan( if matches!(args.mode, Some(ScanMode::Hosted) | Some(ScanMode::Vendored)) && !args.paths.is_empty() { - return Box::pin(run_project_dirs(args, telemetry, invocation)).await; + return Box::pin(run_project_dirs(args, telemetry, invocation, mode_inferred)).await; } let mut policy = Box::new(ScanPolicy::for_root( diff --git a/crates/socket-patch-cli/tests/scan_get_mode_from_project_state.rs b/crates/socket-patch-cli/tests/scan_get_mode_from_project_state.rs new file mode 100644 index 000000000..66574f546 --- /dev/null +++ b/crates/socket-patch-cli/tests/scan_get_mode_from_project_state.rs @@ -0,0 +1,425 @@ +//! A `scan`/`get` with no `--mode` keeps the mode the project's patch +//! state already records (#1088): a bare run on a vendored project stays +//! vendored, one on an agent-mode project stays agent, and only an +//! explicit `--mode hosted` runs the vendored→hosted takeover. A project +//! holding both agent and vendored state is a usage error that asks for +//! `--mode`. +//! +//! The API and the npm registry are wiremock; no npm binary is needed. +//! Every child process gets the ambient `SOCKET_*` vars scrubbed and +//! telemetry hard-disabled; each test runs in its own tempdir. + +#[path = "prebuilt_common/mod.rs"] +mod prebuilt_common; + +use std::path::Path; +use std::process::Command; + +use base64::Engine as _; +use serde_json::{json, Value}; +use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes; +use wiremock::matchers::{method, path, path_regex}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +const ORG: &str = "test-org"; +const NAME: &str = "left-pad"; +const VERSION: &str = "1.3.0"; +const PURL: &str = "pkg:npm/left-pad@1.3.0"; +const UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; +const HOSTED_URL: &str = "https://patch.socket.dev/patch/npm/left-pad/1.3.0/55555555-5555-4555-8555-555555555555/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz"; +const PATCHED_SHA512: &str = "sha512-PATCHEDpatchedPATCHEDpatched0123456789=="; +const UPSTREAM_TARBALL: &str = "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz"; +const UPSTREAM_SHA512: &str = "sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA=="; +const ORIG_INDEX: &[u8] = b"module.exports = () => 'orig';\n"; +const PATCHED_INDEX: &[u8] = b"module.exports = () => 'patched';\n"; + +// ───────────────────────────── fixture ───────────────────────────── + +/// The pristine lock npm `lock_version` writes for a root project +/// depending on `left-pad@1.3.0`. +fn pristine_lock(lock_version: u64) -> Value { + let dep = json!({ + "version": VERSION, + "resolved": UPSTREAM_TARBALL, + "integrity": UPSTREAM_SHA512, + }); + if lock_version == 1 { + json!({ + "name": "v6", + "version": "1.0.0", + "lockfileVersion": 1, + "requires": true, + "dependencies": { NAME: dep }, + }) + } else { + json!({ + "name": "v6", + "version": "1.0.0", + "lockfileVersion": lock_version, + "requires": true, + "packages": { + "": { "name": "v6", "version": "1.0.0", "dependencies": { NAME: VERSION } }, + "node_modules/left-pad": dep, + }, + }) + } +} + +/// package.json, the installed (unpatched) copy and the pristine lock +/// named `lock_name`. +fn write_npm_project(root: &Path, lock_name: &str, lock_version: u64) { + std::fs::write( + root.join("package.json"), + format!( + r#"{{"name":"v6","version":"1.0.0","private":true,"dependencies":{{"{NAME}":"{VERSION}"}}}}"# + ), + ) + .unwrap(); + let pkg = root.join("node_modules").join(NAME); + std::fs::create_dir_all(&pkg).unwrap(); + std::fs::write( + pkg.join("package.json"), + format!(r#"{{"name":"{NAME}","version":"{VERSION}"}}"#), + ) + .unwrap(); + std::fs::write(pkg.join("index.js"), ORIG_INDEX).unwrap(); + let mut lock = serde_json::to_string_pretty(&pristine_lock(lock_version)).unwrap(); + lock.push('\n'); + std::fs::write(root.join(lock_name), lock).unwrap(); +} + +fn patch_record() -> Value { + json!({ + "uuid": UUID, + "exportedAt": "2026-01-01T00:00:00Z", + "files": { + "package/index.js": { + "beforeHash": compute_git_sha256_from_bytes(ORIG_INDEX), + "afterHash": compute_git_sha256_from_bytes(PATCHED_INDEX), + } + }, + "vulnerabilities": {}, + "description": "mode-from-state fixture", + "license": "MIT", + "tier": "free" + }) +} + +fn patch_view() -> Value { + let mut view = patch_record(); + view["purl"] = json!(PURL); + view["publishedAt"] = json!("2024-01-01T00:00:00Z"); + view["files"]["package/index.js"]["blobContent"] = + json!(base64::engine::general_purpose::STANDARD.encode(PATCHED_INDEX)); + view +} + +/// The hosted-mode API (discovery + by-package + grant + view) for the one +/// patch over `PURL`, plus the npm registry's version document the +/// upstream restore re-resolves the pristine entry from. +async fn mock_api(server: &MockServer) { + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/batch"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "packages": [{ + "purl": PURL, + "patches": [{ + "uuid": UUID, "purl": PURL, "tier": "free", + "cveIds": [], "ghsaIds": [], "severity": "high", + "title": "mode-from-state fixture" + }] + }], + "canAccessPaidPatches": false, + }))) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path_regex(format!( + "^/v0/orgs/{ORG}/patches/by-package/.+$" + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "patches": [{ + "uuid": UUID, "purl": PURL, + "publishedAt": "2024-01-01T00:00:00Z", + "description": "x", "license": "MIT", "tier": "free", + "vulnerabilities": {} + }], + "canAccessPaidPatches": false, + }))) + .mount(server) + .await; + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/package"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "results": { + UUID: { + "status": "granted", + "url": HOSTED_URL, + "purl": PURL, + "artifacts": [{ + "kind": "tarball", + "url": HOSTED_URL, + "integrity": { "sha512": PATCHED_SHA512 } + }], + "registryOverride": null + } + } + }))) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG}/patches/view/{UUID}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(patch_view())) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path(format!("/{NAME}/{VERSION}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "name": NAME, + "version": VERSION, + "dist": { "tarball": UPSTREAM_TARBALL, "integrity": UPSTREAM_SHA512 } + }))) + .mount(server) + .await; +} + +// ───────────────────────── subprocess runner ───────────────────────── + +/// Run the built binary with every ambient `SOCKET_*` var scrubbed and the +/// npm registry pointed at the mock. Returns `(exit_code, envelope)`. +fn run_json(cwd: &Path, registry: &str, args: &[&str]) -> (i32, Value) { + let mut cmd = Command::new(env!("CARGO_BIN_EXE_socket-patch")); + cmd.current_dir(cwd); + for (key, _) in std::env::vars() { + if key.starts_with("SOCKET_") && key != "SOCKET_NO_CONFIG" { + cmd.env_remove(key); + } + } + cmd.env("SOCKET_TELEMETRY_DISABLED", "1") + .env("SOCKET_NPM_REGISTRY", registry); + let _fixture = prebuilt_common::prepare_command(&mut cmd, cwd, args, &[]); + let out = cmd.output().expect("spawn socket-patch binary"); + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + if !stderr.trim().is_empty() { + println!("[{}] stderr:\n{stderr}", args.first().unwrap_or(&"?")); + } + let env: Value = serde_json::from_str(stdout.trim()).unwrap_or_else(|e| { + panic!("{args:?} must emit a JSON envelope: {e}\nstdout:\n{stdout}\nstderr:\n{stderr}") + }); + (out.status.code().unwrap_or(-1), env) +} + +/// `scan` (or `get `) against the mock API, with `--mode ` +/// only when `mode` is `Some`. The vendored fixture server is passed on +/// every run, so a bare run that resolves to vendored mode can fetch. +fn run_cmd( + cwd: &Path, + api: &str, + command: &str, + mode: Option<&str>, + extra: &[&str], +) -> (i32, Value) { + let fixture = prebuilt_common::Server::view(patch_view()); + let mut args = vec![command]; + if command == "get" { + args.push(PURL); + } + if let Some(mode) = mode { + args.extend(["--mode", mode]); + } + args.extend([ + "--json", + "--yes", + "--api-url", + api, + "--api-token", + "fake", + "--org", + ORG, + "--cwd", + cwd.to_str().unwrap(), + "--vendor-url", + &fixture.uri, + ]); + args.extend_from_slice(extra); + run_json(cwd, api, &args) +} + +/// Every file a mode change would touch, by relative path. +const WATCHED: &[&str] = &[ + "package-lock.json", + ".npmrc", + ".socket/vendor/state.json", + ".socket/manifest.json", +]; + +fn snapshot(root: &Path) -> Vec<(String, Option>)> { + WATCHED + .iter() + .map(|rel| (rel.to_string(), std::fs::read(root.join(rel)).ok())) + .collect() +} + +/// A project vendored by `scan --mode vendored` over a pristine v2 lock. +fn vendor_project(root: &Path, api: &str) { + write_npm_project(root, "package-lock.json", 2); + let (code, env) = run_cmd(root, api, "scan", Some("vendored"), &[]); + assert_eq!(code, 0, "the vendored scan must succeed: {env:#}"); + let lock = std::fs::read_to_string(root.join("package-lock.json")).unwrap(); + assert!( + lock.contains(&format!(".socket/vendor/npm/{UUID}/")), + "the lock must point at the vendored artifact:\n{lock}" + ); + assert!(root.join(".socket/vendor/state.json").exists()); +} + +fn assert_unchanged(root: &Path, before: &[(String, Option>)], env: &Value) { + for ((rel, was), (_, now)) in before.iter().zip(snapshot(root)) { + assert!( + *was == now, + "{rel} must stay byte-identical on a run with no --mode: {env:#}" + ); + } + let lock = std::fs::read_to_string(root.join("package-lock.json")).unwrap(); + assert!( + !lock.contains(HOSTED_URL), + "no hosted pin may appear:\n{lock}" + ); +} + +// ───────────────────────────── scenarios ───────────────────────────── + +/// A bare `scan` on a vendored project stays vendored: the vendor ledger +/// and the lockfile are byte-identical afterwards. +#[tokio::test(flavor = "multi_thread")] +async fn bare_scan_keeps_a_vendored_project_vendored() { + let server = MockServer::start().await; + mock_api(&server).await; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + vendor_project(root, &server.uri()); + let before = snapshot(root); + + let (code, env) = run_cmd(root, &server.uri(), "scan", None, &["--dry-run"]); + assert_eq!(code, 0, "{env:#}"); + assert_unchanged(root, &before, &env); + + let (code, env) = run_cmd(root, &server.uri(), "scan", None, &[]); + assert_eq!(code, 0, "{env:#}"); + assert_unchanged(root, &before, &env); +} + +/// A bare `get ` on a vendored project stays vendored too. +#[tokio::test(flavor = "multi_thread")] +async fn bare_get_keeps_a_vendored_project_vendored() { + let server = MockServer::start().await; + mock_api(&server).await; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + vendor_project(root, &server.uri()); + let before = snapshot(root); + + let (code, env) = run_cmd(root, &server.uri(), "get", None, &[]); + assert_eq!(code, 0, "{env:#}"); + assert_unchanged(root, &before, &env); +} + +/// An explicit `--mode hosted` is the takeover: the vendored project +/// becomes hosted in place. +#[tokio::test(flavor = "multi_thread")] +async fn explicit_mode_hosted_still_takes_over_a_vendored_project() { + let server = MockServer::start().await; + mock_api(&server).await; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + vendor_project(root, &server.uri()); + + let (code, env) = run_cmd(root, &server.uri(), "scan", Some("hosted"), &[]); + assert_eq!(code, 0, "the explicit takeover must succeed: {env:#}"); + let lock = std::fs::read_to_string(root.join("package-lock.json")).unwrap(); + assert!( + lock.contains(HOSTED_URL), + "--mode hosted must pin the hosted artifact:\n{lock}\n{env:#}" + ); + assert!( + !lock.contains(&format!(".socket/vendor/npm/{UUID}/")), + "the vendored wiring must be gone:\n{lock}" + ); +} + +/// The manifest holding patches (agent mode) keeps a bare `scan` in agent +/// mode: no hosted pin is written. +#[tokio::test(flavor = "multi_thread")] +async fn bare_scan_keeps_an_agent_project_out_of_hosted_mode() { + let server = MockServer::start().await; + mock_api(&server).await; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write_npm_project(root, "package-lock.json", 2); + write_manifest(root); + let lock_before = std::fs::read(root.join("package-lock.json")).unwrap(); + + // The mock serves no patch artifacts, so the agent apply step itself + // fails (exit 1); what matters is which mode ran. + let (code, env) = run_cmd(root, &server.uri(), "scan", None, &[]); + assert_ne!(code, 2, "the mode is not ambiguous: {env:#}"); + assert!( + env.get("apply").is_some(), + "the agent apply step ran: {env:#}" + ); + assert!( + env.get("redirect").is_none(), + "no hosted step may run: {env:#}" + ); + assert_eq!( + std::fs::read(root.join("package-lock.json")).unwrap(), + lock_before, + "an agent-mode scan never rewrites the lockfile: {env:#}" + ); + assert!(!root.join(".npmrc").exists(), "{env:#}"); + let manifest: Value = + serde_json::from_slice(&std::fs::read(root.join(".socket/manifest.json")).unwrap()) + .unwrap(); + assert!(manifest["patches"].get(PURL).is_some(), "{manifest:#}"); +} + +/// Agent and vendored state together: the run cannot tell which mode to +/// keep, so it is a usage error naming `--mode`, and nothing is written. +#[tokio::test(flavor = "multi_thread")] +async fn bare_scan_and_get_refuse_a_project_with_agent_and_vendored_state() { + let server = MockServer::start().await; + mock_api(&server).await; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + vendor_project(root, &server.uri()); + write_manifest(root); + let before = snapshot(root); + + for command in ["scan", "get"] { + let (code, env) = run_cmd(root, &server.uri(), command, None, &[]); + assert_eq!(code, 2, "{command}: {env:#}"); + assert_eq!(env["status"], "error", "{command}: {env:#}"); + assert!( + env.to_string().contains("mode_ambiguous"), + "{command}: the error carries its code: {env:#}" + ); + assert!( + env.to_string().contains("--mode"), + "{command}: the error names the remedy: {env:#}" + ); + assert_unchanged(root, &before, &env); + } +} + +/// An agent-mode manifest holding the fixture patch. +fn write_manifest(root: &Path) { + std::fs::create_dir_all(root.join(".socket")).unwrap(); + let record = patch_record(); + let manifest = json!({ "patches": { PURL: record } }); + std::fs::write( + root.join(".socket/manifest.json"), + serde_json::to_string_pretty(&manifest).unwrap(), + ) + .unwrap(); +} diff --git a/docs/migrating-to-v5.md b/docs/migrating-to-v5.md index 25b12b257..06765cac8 100644 --- a/docs/migrating-to-v5.md +++ b/docs/migrating-to-v5.md @@ -6,9 +6,15 @@ existing scripts against the new CLI; the [changelog](../CHANGELOG.md) and ## Defaults and stored state -- Bare `scan` and `get` now patch in hosted mode. `scan` never prompts; - hosted and vendored `get` do not prompt either. Use `scan --dry-run` for a preview - or `--mode agent` to retain in-place patching. Global scans and a mode-less +- Bare `scan` and `get` now patch in hosted mode when the project has no patch + state yet. `scan` never prompts; hosted and vendored `get` do not prompt either. + Use `scan --dry-run` for a preview or `--mode agent` to retain in-place patching. +- A bare `scan` or `get` keeps the mode a project already uses: a project with a + vendor ledger (`.socket/vendor/state.json`) stays vendored, and one whose + `.socket/manifest.json` holds patches stays in agent mode. Switching modes needs an + explicit `--mode` (for example `scan --mode hosted` converts a vendored project in + place). A project holding both agent and vendored patches must pass `--mode` + (`mode_ambiguous`, exit 2). Global scans and a mode-less `scan --prune` do not acquire new patches; `--prune` still performs cleanup. - Hosted state lives in dependency files. No command writes `.socket/vendor/redirect-state.json`. Legacy hosted records can still supply diff --git a/docs/usage.md b/docs/usage.md index 6a607be25..3751c84f1 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -7,7 +7,9 @@ flags, response fields, and diagnostic codes live in the ## Select patches -A bare `scan` applies hosted patches without prompting. Use `--dry-run` to inspect +A bare `scan` applies patches without prompting, in the mode the project already +uses: hosted for a new project, vendored or agent if `.socket/` already holds patches +in that mode. Pass `--mode` to choose or switch modes. Use `--dry-run` to inspect what it would change, and `--json` for a machine-readable result: ```sh From 8f421d262921a964c2e3d4d3c6f87ac2b9977396 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 9 Oct 2026 13:53:30 -0400 Subject: [PATCH 3/6] Spawn the mode-from-state tests hermetically Use hermetic::binary_command so the child gets the hermetic SOCKET_* environment, as spawn_env_hygiene requires. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tests/scan_get_mode_from_project_state.rs | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/crates/socket-patch-cli/tests/scan_get_mode_from_project_state.rs b/crates/socket-patch-cli/tests/scan_get_mode_from_project_state.rs index 66574f546..812348f3c 100644 --- a/crates/socket-patch-cli/tests/scan_get_mode_from_project_state.rs +++ b/crates/socket-patch-cli/tests/scan_get_mode_from_project_state.rs @@ -9,11 +9,12 @@ //! Every child process gets the ambient `SOCKET_*` vars scrubbed and //! telemetry hard-disabled; each test runs in its own tempdir. +#[path = "common/hermetic.rs"] +mod hermetic; #[path = "prebuilt_common/mod.rs"] mod prebuilt_common; use std::path::Path; -use std::process::Command; use base64::Engine as _; use serde_json::{json, Value}; @@ -188,13 +189,8 @@ async fn mock_api(server: &MockServer) { /// Run the built binary with every ambient `SOCKET_*` var scrubbed and the /// npm registry pointed at the mock. Returns `(exit_code, envelope)`. fn run_json(cwd: &Path, registry: &str, args: &[&str]) -> (i32, Value) { - let mut cmd = Command::new(env!("CARGO_BIN_EXE_socket-patch")); + let mut cmd = hermetic::binary_command(); cmd.current_dir(cwd); - for (key, _) in std::env::vars() { - if key.starts_with("SOCKET_") && key != "SOCKET_NO_CONFIG" { - cmd.env_remove(key); - } - } cmd.env("SOCKET_TELEMETRY_DISABLED", "1") .env("SOCKET_NPM_REGISTRY", registry); let _fixture = prebuilt_common::prepare_command(&mut cmd, cwd, args, &[]); From a766766038532dbf1c9b7efd4a82af0a41cf9c5c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 20:39:59 +0000 Subject: [PATCH 4/6] Keep vendored mode when the manifest has its record A bare scan/get on a project vendored through the documented `get --save-only` then `vendor` flow failed with mode_ambiguous (exit 2): the manifest still held the record the vendor ledger owns, and any manifest record counted as agent-mode evidence. Records the ledger already covers (same key or base purl) now count as vendored state; only uncovered records are agent evidence. The ledger is also read from the project root the manifest belongs to, not from --cwd, so a --manifest-path into another project no longer mixes one project's manifest with another's ledger. Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/src/commands/mod.rs | 161 ++++++++++++++++-- .../tests/scan_get_mode_from_project_state.rs | 64 ++++++- 2 files changed, 207 insertions(+), 18 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/mod.rs b/crates/socket-patch-cli/src/commands/mod.rs index ddfb581d1..0325bf30a 100644 --- a/crates/socket-patch-cli/src/commands/mod.rs +++ b/crates/socket-patch-cli/src/commands/mod.rs @@ -48,10 +48,10 @@ pub(crate) fn project_state_in_scope(common: &crate::args::GlobalArgs) -> bool { !common.is_global() } -/// The mode a `scan`/`get` run with no `--mode` uses for the `--cwd` -/// project (CLI_CONTRACT.md, Mode resolution): the mode the project's own -/// patch state already records, so a bare run never converts the project -/// to another mode. Hosted is the default for a project with no state. +/// The mode a `scan`/`get` run with no `--mode` uses for the project +/// (CLI_CONTRACT.md, Mode resolution): the mode the project's own patch +/// state already records, so a bare run never converts the project to +/// another mode. Hosted is the default for a project with no state. /// /// * a non-empty vendor ledger (`.socket/vendor/state.json`) → vendored; /// * a manifest (`.socket/manifest.json`) holding patches → agent; @@ -59,26 +59,45 @@ pub(crate) fn project_state_in_scope(common: &crate::args::GlobalArgs) -> bool { /// * both → `Err(usage message)`: the run cannot tell which mode to keep, /// so it asks for an explicit `--mode` rather than guess. /// +/// Both stores are read from [`GlobalArgs::project_root`], so a +/// `--manifest-path` into another project consults that project's ledger, +/// never a ledger left in `--cwd`. +/// +/// A manifest record the ledger already covers (same key or base purl) is +/// vendored state, not agent evidence: the documented `get --save-only` +/// then `vendor` flow leaves the record in the manifest beside its ledger +/// entry, and that project is vendored. +/// /// An unreadable or malformed vendor ledger counts as vendored, so the /// vendored flow reports the corruption instead of a hosted takeover -/// running over it. An unreadable manifest is not agent evidence (hosted -/// and vendored mode never read it). Only called in project scope. +/// running over it; with no readable ledger no manifest record counts as +/// covered. An unreadable manifest is not agent evidence (hosted and +/// vendored mode never read it). Only called in project scope. +/// +/// [`GlobalArgs::project_root`]: crate::args::GlobalArgs::project_root pub(crate) async fn mode_from_project_state( common: &crate::args::GlobalArgs, ) -> Result { let manifest_path = common.resolved_manifest_path(); + let project_root = common.project_root(); let (manifest, vendor) = tokio::join!( socket_patch_core::manifest::operations::read_manifest(&manifest_path), - socket_patch_core::vendor::load_state(&common.cwd), + socket_patch_core::vendor::load_state(&project_root), ); - let vendored = !matches!(vendor, Ok(state) if state.entries.is_empty()); - let agent = matches!(manifest, Ok(Some(m)) if !m.patches.is_empty()); + let vendored_keys = match &vendor { + Ok(state) => state.purl_keys(), + Err(_) => Default::default(), + }; + let vendored = !matches!(&vendor, Ok(state) if state.entries.is_empty()); + let agent = matches!(&manifest, Ok(Some(m)) if m.patches.keys().any(|purl| { + !socket_patch_core::vendor::state::purl_keys_cover(&vendored_keys, purl) + })); match (vendored, agent) { (true, true) => Err(format!( "{} holds both agent-mode patches ({}) and vendored patches \ (.socket/vendor/state.json): pass --mode agent, --mode vendored or \ --mode hosted to choose the mode this run uses", - common.cwd.display(), + project_root.display(), manifest_path.display(), )), (true, false) => Ok(scan::ScanMode::Vendored), @@ -256,3 +275,125 @@ pub(crate) fn vendor_state_lenient( } } } + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + const LEDGER_PURL: &str = "pkg:npm/left-pad@1.3.0"; + const UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; + + /// A one-entry vendor ledger at `root` for `LEDGER_PURL`, checked to + /// load, so a test never passes on the malformed-ledger branch. + async fn write_ledger(root: &Path) { + let path = root.join(socket_patch_core::vendor::VENDOR_STATE_REL); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + let ledger = serde_json::json!({ + "version": 1, + "entries": { + LEDGER_PURL: { + "ecosystem": "npm", + "basePurl": LEDGER_PURL, + "uuid": UUID, + "artifact": { + "path": format!(".socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz"), + "sha256": "ab".repeat(32), + }, + "wiring": [], + } + } + }); + std::fs::write(&path, serde_json::to_vec_pretty(&ledger).unwrap()).unwrap(); + let loaded = socket_patch_core::vendor::load_state(root) + .await + .expect("the fixture ledger must load"); + assert_eq!(loaded.entries.len(), 1); + } + + /// An agent manifest at `/.socket/manifest.json` holding `purls`. + fn write_manifest(root: &Path, purls: &[&str]) { + let dir = root.join(".socket"); + std::fs::create_dir_all(&dir).unwrap(); + let patches: serde_json::Map = purls + .iter() + .map(|purl| { + ( + purl.to_string(), + serde_json::json!({ + "uuid": UUID, + "exportedAt": "2026-01-01T00:00:00Z", + "files": {}, + "vulnerabilities": {}, + "description": "fixture", + "license": "MIT", + "tier": "free", + }), + ) + }) + .collect(); + std::fs::write( + dir.join("manifest.json"), + serde_json::to_vec_pretty(&serde_json::json!({ "patches": patches })).unwrap(), + ) + .unwrap(); + } + + fn args(cwd: &Path, manifest_path: &str) -> crate::args::GlobalArgs { + crate::args::GlobalArgs { + cwd: PathBuf::from(cwd), + manifest_path: manifest_path.to_string(), + ..crate::args::GlobalArgs::default() + } + } + + /// `get --save-only` then `vendor` leaves the vendored record in the + /// manifest beside its ledger entry: that project is vendored, not + /// ambiguous. + #[tokio::test] + async fn manifest_records_the_ledger_covers_are_vendored_state() { + let tmp = tempfile::tempdir().unwrap(); + write_ledger(tmp.path()).await; + write_manifest(tmp.path(), &[LEDGER_PURL]); + let mode = mode_from_project_state(&args(tmp.path(), ".socket/manifest.json")).await; + assert_eq!(mode, Ok(scan::ScanMode::Vendored)); + } + + /// A manifest record the ledger does not cover is agent state, so a + /// project holding it beside a ledger is still ambiguous. + #[tokio::test] + async fn an_uncovered_manifest_record_beside_a_ledger_is_ambiguous() { + let tmp = tempfile::tempdir().unwrap(); + write_ledger(tmp.path()).await; + write_manifest(tmp.path(), &[LEDGER_PURL, "pkg:npm/is-odd@3.0.1"]); + let mode = mode_from_project_state(&args(tmp.path(), ".socket/manifest.json")).await; + let err = mode.expect_err("agent and vendored state together"); + assert!(err.contains("--mode"), "{err}"); + } + + /// With `--manifest-path` into another project, the ledger is read + /// from that project, not from `--cwd`: a ledger left in `--cwd` is + /// not consulted, and the other project's ledger is. + #[tokio::test] + async fn the_ledger_is_read_from_the_manifest_project_root() { + let tmp = tempfile::tempdir().unwrap(); + let cwd = tmp.path().join("cwd"); + let other = tmp.path().join("other"); + std::fs::create_dir_all(&cwd).unwrap(); + std::fs::create_dir_all(&other).unwrap(); + let manifest_path = other.join(".socket/manifest.json"); + let manifest_path = manifest_path.to_str().unwrap(); + + // A ledger in --cwd only: the manifest's project is agent. + write_ledger(&cwd).await; + write_manifest(&other, &["pkg:npm/is-odd@3.0.1"]); + let mode = mode_from_project_state(&args(&cwd, manifest_path)).await; + assert_eq!(mode, Ok(scan::ScanMode::Agent)); + + // The manifest's project vendored the record: vendored. + write_ledger(&other).await; + write_manifest(&other, &[LEDGER_PURL]); + let mode = mode_from_project_state(&args(&cwd, manifest_path)).await; + assert_eq!(mode, Ok(scan::ScanMode::Vendored)); + } +} diff --git a/crates/socket-patch-cli/tests/scan_get_mode_from_project_state.rs b/crates/socket-patch-cli/tests/scan_get_mode_from_project_state.rs index 812348f3c..fdd85a544 100644 --- a/crates/socket-patch-cli/tests/scan_get_mode_from_project_state.rs +++ b/crates/socket-patch-cli/tests/scan_get_mode_from_project_state.rs @@ -26,6 +26,8 @@ const ORG: &str = "test-org"; const NAME: &str = "left-pad"; const VERSION: &str = "1.3.0"; const PURL: &str = "pkg:npm/left-pad@1.3.0"; +/// A manifest record no vendor ledger entry covers: agent-only state. +const AGENT_ONLY_PURL: &str = "pkg:npm/is-odd@3.0.1"; const UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; const HOSTED_URL: &str = "https://patch.socket.dev/patch/npm/left-pad/1.3.0/55555555-5555-4555-8555-555555555555/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz"; const PATCHED_SHA512: &str = "sha512-PATCHEDpatchedPATCHEDpatched0123456789=="; @@ -353,7 +355,7 @@ async fn bare_scan_keeps_an_agent_project_out_of_hosted_mode() { let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); write_npm_project(root, "package-lock.json", 2); - write_manifest(root); + write_manifest(root, &[PURL]); let lock_before = std::fs::read(root.join("package-lock.json")).unwrap(); // The mock serves no patch artifacts, so the agent apply step itself @@ -380,8 +382,51 @@ async fn bare_scan_keeps_an_agent_project_out_of_hosted_mode() { assert!(manifest["patches"].get(PURL).is_some(), "{manifest:#}"); } -/// Agent and vendored state together: the run cannot tell which mode to -/// keep, so it is a usage error naming `--mode`, and nothing is written. +/// The `get --save-only` then `vendor` flow leaves the vendored record in +/// the manifest beside its ledger entry. That project is vendored, so a +/// bare `scan`/`get` keeps it vendored instead of failing `mode_ambiguous`. +#[tokio::test(flavor = "multi_thread")] +async fn bare_scan_and_get_keep_a_vendored_project_whose_manifest_holds_the_record() { + let server = MockServer::start().await; + mock_api(&server).await; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + vendor_project(root, &server.uri()); + let lock_before = std::fs::read(root.join("package-lock.json")).unwrap(); + + for command in ["scan", "get"] { + write_manifest(root, &[PURL]); + let (code, env) = run_cmd(root, &server.uri(), command, None, &[]); + assert_eq!(code, 0, "{command}: {env:#}"); + assert!( + !env.to_string().contains("mode_ambiguous"), + "{command}: a covered manifest record is not agent state: {env:#}" + ); + assert!( + env.get("redirect").is_none(), + "{command}: no hosted step may run: {env:#}" + ); + assert_eq!( + std::fs::read(root.join("package-lock.json")).unwrap(), + lock_before, + "{command}: the vendored lock is kept: {env:#}" + ); + assert!(!root.join(".npmrc").exists(), "{command}: {env:#}"); + // The vendored flow ran: it moves the record its ledger owns out + // of the manifest (vendored mode is manifest-free). + let manifest: Value = + serde_json::from_slice(&std::fs::read(root.join(".socket/manifest.json")).unwrap()) + .unwrap(); + assert!( + manifest["patches"].get(PURL).is_none(), + "{command}: the vendored flow migrates the covered record: {manifest:#}" + ); + } +} + +/// Agent and vendored state together (a manifest record the ledger does +/// not cover): the run cannot tell which mode to keep, so it is a usage +/// error naming `--mode`, and nothing is written. #[tokio::test(flavor = "multi_thread")] async fn bare_scan_and_get_refuse_a_project_with_agent_and_vendored_state() { let server = MockServer::start().await; @@ -389,7 +434,7 @@ async fn bare_scan_and_get_refuse_a_project_with_agent_and_vendored_state() { let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); vendor_project(root, &server.uri()); - write_manifest(root); + write_manifest(root, &[PURL, AGENT_ONLY_PURL]); let before = snapshot(root); for command in ["scan", "get"] { @@ -408,11 +453,14 @@ async fn bare_scan_and_get_refuse_a_project_with_agent_and_vendored_state() { } } -/// An agent-mode manifest holding the fixture patch. -fn write_manifest(root: &Path) { +/// An agent-mode manifest holding the fixture patch under each of `purls`. +fn write_manifest(root: &Path, purls: &[&str]) { std::fs::create_dir_all(root.join(".socket")).unwrap(); - let record = patch_record(); - let manifest = json!({ "patches": { PURL: record } }); + let patches: serde_json::Map = purls + .iter() + .map(|purl| (purl.to_string(), patch_record())) + .collect(); + let manifest = json!({ "patches": patches }); std::fs::write( root.join(".socket/manifest.json"), serde_json::to_string_pretty(&manifest).unwrap(), From 5a0711222afc214b1d259bd24afce1ca1b627246 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 20:40:20 +0000 Subject: [PATCH 5/6] Document covered manifest records in mode rules A manifest record the vendor ledger already covers is vendored state, and both stores come from the manifest's project root. Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index b24d1601b..0a6c8460b 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -132,7 +132,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc ### Scan modes (v5.0) -**Mode resolution (`resolve_mode_flags`, MAJOR in v5.0).** `--mode`, or `--sync` (agent mode), picks the mode. With neither, `scan` and `get` keep the mode the `--cwd` project's patch state already records (`mode_from_project_state`, #1088): a non-empty vendor ledger (`.socket/vendor/state.json`; also an unreadable or malformed one, so the vendored flow reports it) means **vendored**, a manifest holding patches means **agent**, and a project with neither runs **hosted** mode — JSON and human alike; the hosted result nests under the JSON `redirect` sub-object (see the hosted paragraph below). A project holding both agent and vendored state is a usage error (exit 2, code `mode_ambiguous`, naming the three `--mode` values) and writes nothing. A human-mode run that kept vendored or agent mode says so on stderr (`Note: using --mode vendored because … already holds vendored patches; pass --mode explicitly to switch modes`); `--json` and `--silent` print no note. **Switching modes is always explicit**: the in-place vendored → hosted takeover (see **Staged takeover**) runs only under `--mode hosted`, the hosted → vendored takeover only under `--mode vendored`, and a bare `scan`/`get` never converts a project. With hosted/vendored PATH arguments and no `--mode`, each project directory takes its mode from its own state. The one exception to the state-derived default: 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 `. A global scan's hint carries the run's scope, so it can be run verbatim: `-g` (`scan --mode agent -g` / `get -g <…>`), or `--global-prefix ` when a prefix was given (the directory shell-quoted when it needs it). An explicit `--mode hosted` or `--mode vendored` 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. +**Mode resolution (`resolve_mode_flags`, MAJOR in v5.0).** `--mode`, or `--sync` (agent mode), picks the mode. With neither, `scan` and `get` keep the mode the project's patch state already records (`mode_from_project_state`, #1088; both stores are read from the project the resolved manifest belongs to, so a `--manifest-path` into another project consults that project's ledger): a non-empty vendor ledger (`.socket/vendor/state.json`; also an unreadable or malformed one, so the vendored flow reports it) means **vendored**, a manifest holding patches the ledger does not already cover (by key or base purl) means **agent** — a record the ledger covers, as `get --save-only` then `vendor` leaves, is vendored state — and a project with neither runs **hosted** mode — JSON and human alike; the hosted result nests under the JSON `redirect` sub-object (see the hosted paragraph below). A project holding both agent and vendored state is a usage error (exit 2, code `mode_ambiguous`, naming the three `--mode` values) and writes nothing. A human-mode run that kept vendored or agent mode says so on stderr (`Note: using --mode vendored because … already holds vendored patches; pass --mode explicitly to switch modes`); `--json` and `--silent` print no note. **Switching modes is always explicit**: the in-place vendored → hosted takeover (see **Staged takeover**) runs only under `--mode hosted`, the hosted → vendored takeover only under `--mode vendored`, and a bare `scan`/`get` never converts a project. With hosted/vendored PATH arguments and no `--mode`, each project directory takes its mode from its own state. The one exception to the state-derived default: 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 `. A global scan's hint carries the run's scope, so it can be run verbatim: `-g` (`scan --mode agent -g` / `get -g <…>`), or `--global-prefix ` when a prefix was given (the directory shell-quoted when it needs it). An explicit `--mode hosted` or `--mode vendored` 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). 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). @@ -1437,7 +1437,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `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. | -| `mode_ambiguous` | scan, get | Usage error (exit 2, v5.0): no `--mode` was given and the project holds both agent-mode patches (`.socket/manifest.json`) and vendored patches (`.socket/vendor/state.json`), so the mode to keep is unknown. Pass `--mode`. | +| `mode_ambiguous` | scan, get | Usage error (exit 2, v5.0): no `--mode` was given and the project holds both agent-mode patches (`.socket/manifest.json` records the vendor ledger does not cover) and vendored patches (`.socket/vendor/state.json`), so the mode to keep is unknown. Pass `--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. | From 04b5fc770ff5255be85aef4b1498500db264c5d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 21:17:22 +0000 Subject: [PATCH 6/6] Keep a project with a broken manifest out of hosted A bare scan/get on a project whose .socket/manifest.json is unreadable or malformed treated it as having no agent patches and ran hosted mode, rewriting the lockfiles of what is really an agent project. A broken manifest now counts as agent state, so the agent flow reports the error (or the run asks for --mode when a vendor ledger is also present), matching how a broken vendor ledger already counts as vendored. Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- crates/socket-patch-cli/src/commands/mod.rs | 35 ++++++++++++++++++--- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 0a6c8460b..d60c66350 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -132,7 +132,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc ### Scan modes (v5.0) -**Mode resolution (`resolve_mode_flags`, MAJOR in v5.0).** `--mode`, or `--sync` (agent mode), picks the mode. With neither, `scan` and `get` keep the mode the project's patch state already records (`mode_from_project_state`, #1088; both stores are read from the project the resolved manifest belongs to, so a `--manifest-path` into another project consults that project's ledger): a non-empty vendor ledger (`.socket/vendor/state.json`; also an unreadable or malformed one, so the vendored flow reports it) means **vendored**, a manifest holding patches the ledger does not already cover (by key or base purl) means **agent** — a record the ledger covers, as `get --save-only` then `vendor` leaves, is vendored state — and a project with neither runs **hosted** mode — JSON and human alike; the hosted result nests under the JSON `redirect` sub-object (see the hosted paragraph below). A project holding both agent and vendored state is a usage error (exit 2, code `mode_ambiguous`, naming the three `--mode` values) and writes nothing. A human-mode run that kept vendored or agent mode says so on stderr (`Note: using --mode vendored because … already holds vendored patches; pass --mode explicitly to switch modes`); `--json` and `--silent` print no note. **Switching modes is always explicit**: the in-place vendored → hosted takeover (see **Staged takeover**) runs only under `--mode hosted`, the hosted → vendored takeover only under `--mode vendored`, and a bare `scan`/`get` never converts a project. With hosted/vendored PATH arguments and no `--mode`, each project directory takes its mode from its own state. The one exception to the state-derived default: 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 `. A global scan's hint carries the run's scope, so it can be run verbatim: `-g` (`scan --mode agent -g` / `get -g <…>`), or `--global-prefix ` when a prefix was given (the directory shell-quoted when it needs it). An explicit `--mode hosted` or `--mode vendored` 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. +**Mode resolution (`resolve_mode_flags`, MAJOR in v5.0).** `--mode`, or `--sync` (agent mode), picks the mode. With neither, `scan` and `get` keep the mode the project's patch state already records (`mode_from_project_state`, #1088; both stores are read from the project the resolved manifest belongs to, so a `--manifest-path` into another project consults that project's ledger): a non-empty vendor ledger (`.socket/vendor/state.json`; also an unreadable or malformed one, so the vendored flow reports it) means **vendored**, a manifest holding patches the ledger does not already cover (by key or base purl), or an unreadable or malformed manifest (so the agent flow reports it), means **agent** — a record the ledger covers, as `get --save-only` then `vendor` leaves, is vendored state — and a project with neither runs **hosted** mode — JSON and human alike; the hosted result nests under the JSON `redirect` sub-object (see the hosted paragraph below). A project holding both agent and vendored state is a usage error (exit 2, code `mode_ambiguous`, naming the three `--mode` values) and writes nothing. A human-mode run that kept vendored or agent mode says so on stderr (`Note: using --mode vendored because … already holds vendored patches; pass --mode explicitly to switch modes`); `--json` and `--silent` print no note. **Switching modes is always explicit**: the in-place vendored → hosted takeover (see **Staged takeover**) runs only under `--mode hosted`, the hosted → vendored takeover only under `--mode vendored`, and a bare `scan`/`get` never converts a project. With hosted/vendored PATH arguments and no `--mode`, each project directory takes its mode from its own state. The one exception to the state-derived default: 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 `. A global scan's hint carries the run's scope, so it can be run verbatim: `-g` (`scan --mode agent -g` / `get -g <…>`), or `--global-prefix ` when a prefix was given (the directory shell-quoted when it needs it). An explicit `--mode hosted` or `--mode vendored` 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). 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). diff --git a/crates/socket-patch-cli/src/commands/mod.rs b/crates/socket-patch-cli/src/commands/mod.rs index 0325bf30a..f0118380d 100644 --- a/crates/socket-patch-cli/src/commands/mod.rs +++ b/crates/socket-patch-cli/src/commands/mod.rs @@ -71,8 +71,10 @@ pub(crate) fn project_state_in_scope(common: &crate::args::GlobalArgs) -> bool { /// An unreadable or malformed vendor ledger counts as vendored, so the /// vendored flow reports the corruption instead of a hosted takeover /// running over it; with no readable ledger no manifest record counts as -/// covered. An unreadable manifest is not agent evidence (hosted and -/// vendored mode never read it). Only called in project scope. +/// covered. Likewise an unreadable or malformed manifest counts as agent +/// state, so the agent flow reports it rather than a bare run converting +/// the project to hosted mode (#1088). A missing manifest is no evidence. +/// Only called in project scope. /// /// [`GlobalArgs::project_root`]: crate::args::GlobalArgs::project_root pub(crate) async fn mode_from_project_state( @@ -89,9 +91,14 @@ pub(crate) async fn mode_from_project_state( Err(_) => Default::default(), }; let vendored = !matches!(&vendor, Ok(state) if state.entries.is_empty()); - let agent = matches!(&manifest, Ok(Some(m)) if m.patches.keys().any(|purl| { - !socket_patch_core::vendor::state::purl_keys_cover(&vendored_keys, purl) - })); + let agent = match &manifest { + Ok(Some(m)) => m + .patches + .keys() + .any(|purl| !socket_patch_core::vendor::state::purl_keys_cover(&vendored_keys, purl)), + Ok(None) => false, + Err(_) => true, + }; match (vendored, agent) { (true, true) => Err(format!( "{} holds both agent-mode patches ({}) and vendored patches \ @@ -371,6 +378,24 @@ mod tests { assert!(err.contains("--mode"), "{err}"); } + /// A malformed manifest is agent state: a bare run must not take the + /// project over in hosted mode, so the agent flow reports the error. + #[tokio::test] + async fn a_malformed_manifest_is_not_a_hosted_project() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(tmp.path().join(".socket")).unwrap(); + std::fs::write(tmp.path().join(".socket/manifest.json"), b"{ not json").unwrap(); + let common = args(tmp.path(), ".socket/manifest.json"); + assert_eq!( + mode_from_project_state(&common).await, + Ok(scan::ScanMode::Agent) + ); + + // Beside a vendor ledger it is ambiguous, not silently vendored. + write_ledger(tmp.path()).await; + assert!(mode_from_project_state(&common).await.is_err()); + } + /// With `--manifest-path` into another project, the ledger is read /// from that project, not from `--cwd`: a ledger left in `--cwd` is /// not consulted, and the other project's ledger is.