Skip to content

Commit 0958e11

Browse files
committed
Merge origin/main into agent/fix-global-probe-tool-spawn
# Conflicts: # CHANGELOG.md
2 parents d60a204 + 9d718cf commit 0958e11

82 files changed

Lines changed: 5469 additions & 358 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎CHANGELOG.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,10 @@ limits, and required install commands.
108108
scanned project, so a Yarn Berry project's `global` script can't run or pick
109109
the directory treated as the global install. Composer's global home also
110110
falls back to `%APPDATA%\Composer` and `$XDG_CONFIG_HOME/composer`.
111+
- Gem hosted and vendored modes wire only the manifest Bundler loads. A `gems.rb`
112+
twin or a `BUNDLE_GEMFILE` setting (environment or `.bundle/config`) no longer
113+
leads to an edit of an ignored `Gemfile` that reports success and attests an
114+
unpatched gem; unsupported layouts are refused before any write (#341, #390).
111115
- **npm dependencies installed from git, a URL or `file:` are no longer
112116
reported patched.** npm installs such a dependency from the dependent's
113117
spec (`github:user/repo`, `https://…/x.tgz`, `file:…`) and ignores the
@@ -154,6 +158,9 @@ limits, and required install commands.
154158
- Python rewrites preserve supported markers, groups, extras, source metadata, and
155159
integrity pins. Relocks, out-of-tree environments, and lock-only VEX are handled
156160
consistently with each installer's supported behavior.
161+
- Hosted Pipenv scans read the `Pipfile`, so a conflicting `Pipfile.lock` entry
162+
refuses the patch project-wide instead of half-redirecting a sibling
163+
`requirements.txt` (#333).
157164
- Vendoring reuses valid committed artifacts during service outages. Updates do
158165
not build from a previous patch's modified bytes. Verified service artifacts
159166
keep their identity; integrity failures do not fall through to a local rebuild.
@@ -168,6 +175,15 @@ limits, and required install commands.
168175
- Transient apply locks are removed on normal command exit; no-op scans and full
169176
reversal avoid leaving unused `.socket/` state. Terminal output, telemetry
170177
timeouts, and update-check handling are more consistent.
178+
- npm locks keep their own layout when edited. `scan --mode hosted`,
179+
`scan --mode vendored`, `rollback` and `vendor --revert`
180+
re-serialized `package-lock.json` / `npm-shrinkwrap.json` with LF line
181+
endings (and, in hosted mode, a fixed 2-space indent), so a CRLF or
182+
tab-indented lock got a whole-file diff and the undo did not restore its
183+
bytes. A lock with a UTF-8 BOM, which npm installs from, was skipped as
184+
unparseable (hosted) or refused as `vendor_lockfile_version_unsupported`
185+
(vendored). The lock now keeps its BOM, indent and line endings, and the
186+
undo is byte-exact (#324).
171187

172188
### Maintenance
173189

‎crates/socket-patch-cli/CLI_CONTRACT.md‎

Lines changed: 11 additions & 9 deletions
Large diffs are not rendered by default.

‎crates/socket-patch-cli/src/commands/apply.rs‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1714,7 +1714,14 @@ async fn apply_patches_inner(
17141714
// by ledger key, resolved base purl, or qualifier-stripped key so
17151715
// release-variant manifest keys (pypi `?artifact_id=`…) hit too;
17161716
// unreadable state degrades to "nothing vendored" (fail-open).
1717-
let vendored_purls = socket_patch_core::vendor::vendored_purl_keys(&args.common.cwd).await;
1717+
// The ledger owns the PROJECT's copies only: a global apply restores
1718+
// and patches the global copy even when the cwd project vendors the
1719+
// same purl (see `project_state_in_scope`).
1720+
let vendored_purls = if crate::commands::project_state_in_scope(&args.common) {
1721+
socket_patch_core::vendor::vendored_purl_keys(&args.common.cwd).await
1722+
} else {
1723+
Default::default()
1724+
};
17181725
let is_vendored = |p: &str| purl_keys_cover(&vendored_purls, p);
17191726
let (mut results, mut matched_manifest_purls, vendored_bases) =
17201727
synthesize_vendor_owned_results(&target_manifest_purls, &vendored_purls);

‎crates/socket-patch-cli/src/commands/get.rs‎

Lines changed: 42 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -914,8 +914,9 @@ fn format_save_summary(
914914

915915
/// The summary after a single-uuid save. `what` is `"Patch"` or `"Patch
916916
/// record"`. `ends_run` says an unchanged record really ends the run (the
917-
/// agent path skips apply); the vendored path still runs its vendor step,
918-
/// so it must not promise "nothing to update".
917+
/// agent path under `--save-only`); the agent path still re-applies an
918+
/// unchanged record and the vendored path still runs its vendor step, so
919+
/// neither may promise "nothing to update".
919920
fn format_single_save(
920921
what: &str,
921922
action: &PatchAction,
@@ -1653,6 +1654,10 @@ struct FetchBatch {
16531654
/// Selection size after installed-release narrowing.
16541655
found: usize,
16551656
skipped: usize,
1657+
/// Manifest store only: the `skipped` patches whose same uuid is
1658+
/// already recorded — still owed a nested apply, since the installed
1659+
/// copy may have been reinstalled since the record was written.
1660+
already_recorded: usize,
16561661
failed: usize,
16571662
/// Fetched, recordable patches in selection order.
16581663
fetched: Vec<FetchedPatch>,
@@ -1843,6 +1848,7 @@ async fn fetch_selected_patches(
18431848
let mut batch = FetchBatch {
18441849
found: selected.len(),
18451850
skipped: 0,
1851+
already_recorded: 0,
18461852
failed: 0,
18471853
fetched: Vec::new(),
18481854
reused: Vec::new(),
@@ -1997,6 +2003,7 @@ async fn fetch_selected_patches(
19972003
"action": "skipped",
19982004
}));
19992005
batch.skipped += 1;
2006+
batch.already_recorded += 1;
20002007
continue;
20012008
}
20022009

@@ -2432,10 +2439,16 @@ pub async fn download_and_apply_patches_with(
24322439
return (1, serde_json::json!({ "status": "error", "error": msg }));
24332440
}
24342441
}
2442+
// Every selected patch that is now recorded is owed the nested apply:
2443+
// the fetched ones AND the already-recorded (`skipped`) ones, whose
2444+
// installed copy may be pristine again after a reinstall or a failed
2445+
// earlier apply (#454). Apply is idempotent on already-patched files,
2446+
// so an in-sync re-run stays a no-op on disk.
2447+
let to_apply = downloaded + batch.already_recorded;
24352448
// The lock outlives the manifest write only when a nested apply follows
24362449
// (it is handed the guard and releases it after its last mutation);
24372450
// otherwise nothing more is written and it is released here.
2438-
let apply_lock = if !params.save_only && downloaded > 0 {
2451+
let apply_lock = if !params.save_only && to_apply > 0 {
24392452
Some(guard)
24402453
} else {
24412454
drop(guard);
@@ -2476,21 +2489,21 @@ pub async fn download_and_apply_patches_with(
24762489
.await;
24772490
}
24782491

2479-
// An apply step that ran (patches were added, not --save-only) but
2480-
// failed is a partial failure too — not just download failures. The
2492+
// An apply step that ran (recorded patches selected, not --save-only)
2493+
// but failed is a partial failure too — not just download failures. The
24812494
// `status` field must agree with `exit_code`; reporting `success`
24822495
// alongside a non-zero exit code misleads JSON consumers (the scan
24832496
// wrapper recomputes status from the exit code for exactly this
24842497
// reason, but `get` surfaces this envelope directly).
2485-
let apply_failed = !apply_succeeded && downloaded > 0 && !params.save_only;
2498+
let apply_failed = !apply_succeeded && to_apply > 0 && !params.save_only;
24862499
let (status, exit_code) = run_outcome(batch.failed > 0, apply_failed);
24872500
let mut result_json = serde_json::json!({
24882501
"status": status,
24892502
"found": batch.found,
24902503
"downloaded": downloaded,
24912504
"skipped": batch.skipped,
24922505
"failed": batch.failed,
2493-
"applied": if apply_succeeded { downloaded } else { 0 },
2506+
"applied": if apply_succeeded { to_apply } else { 0 },
24942507
"updated": updated,
24952508
"patches": batch.patches_json,
24962509
});
@@ -2527,6 +2540,12 @@ pub async fn run(args: GetArgs) -> i32 {
25272540
} else {
25282541
super::scan::ScanMode::Hosted
25292542
});
2543+
// Global installs have no project lockfile: an explicit hosted or
2544+
// vendored mode would rewire the cwd project, not the global copy.
2545+
if let Some(conflict) = super::global_mode_conflict(&args.common, mode) {
2546+
report_error(args.common.json, conflict);
2547+
return 2;
2548+
}
25302549
if args.save_only && mode != super::scan::ScanMode::Agent {
25312550
report_error(
25322551
args.common.json,
@@ -3390,9 +3409,12 @@ async fn save_and_apply_patch(args: &GetArgs, client: &ApiClient, patch: &PatchR
33903409
Err(code) => return code,
33913410
};
33923411
let changed = action != PatchAction::Skipped;
3393-
// Carried into the nested apply when one follows (it releases the lock
3394-
// after its last mutation), released here otherwise.
3395-
let apply_lock = if !args.save_only && changed {
3412+
// The record is now in the manifest whatever `action` says, so the
3413+
// nested apply follows unless `--save-only`: a same-uuid re-get must
3414+
// still reconcile an installed copy that was reinstalled pristine since
3415+
// it was recorded (#454). Carried into the nested apply (it releases
3416+
// the lock after its last mutation), released here otherwise.
3417+
let apply_lock = if !args.save_only {
33963418
Some(guard)
33973419
} else {
33983420
drop(guard);
@@ -3427,7 +3449,14 @@ async fn save_and_apply_patch(args: &GetArgs, client: &ApiClient, patch: &PatchR
34273449
if !quiet {
34283450
eprintln!(
34293451
"{}",
3430-
format_single_save("Patch", &action, &manifest_path, &patch.purl, true)
3452+
format_single_save(
3453+
"Patch",
3454+
&action,
3455+
&manifest_path,
3456+
&patch.purl,
3457+
// An unchanged record ends the run only when no apply follows.
3458+
apply_lock.is_none()
3459+
)
34313460
);
34323461
}
34333462

@@ -3446,11 +3475,11 @@ async fn save_and_apply_patch(args: &GetArgs, client: &ApiClient, patch: &PatchR
34463475
.await;
34473476
}
34483477

3449-
// The apply step ran (patch added, not --save-only) but failed →
3478+
// The apply step ran (not --save-only) but failed →
34503479
// partial failure. The `status` field must agree with the exit code
34513480
// returned below; a hardcoded `success` alongside a non-zero exit
34523481
// misleads JSON consumers.
3453-
let apply_failed = !apply_succeeded && changed && !args.save_only;
3482+
let apply_failed = !apply_succeeded && !args.save_only;
34543483
// No "download failed" concept here — a blob failure early-returns
34553484
// with status `error` above — so only the apply step can degrade us.
34563485
let (status, exit_code) = run_outcome(false, apply_failed);

‎crates/socket-patch-cli/src/commands/mod.rs‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,44 @@ pub(crate) const HOSTED_MODE_LABEL: &str = "hosted";
3434
/// `record`, so the ledger is the only place those records live.
3535
pub(crate) const VENDORED_MODE_LABEL: &str = "vendored";
3636

37+
/// Whether the run's target includes the `--cwd` project's own
38+
/// lockfile-backed state: its hosted pins and its vendor ledger. Global
39+
/// scope (`--global` / `--global-prefix`) targets globally installed
40+
/// packages, which have no project lockfile (CLI_CONTRACT.md, Mode
41+
/// resolution). The project a global run happens to start in is not its
42+
/// target, so that project's hosted and vendored state is never rewired,
43+
/// unwound, or consulted for ownership of a global copy.
44+
pub(crate) fn project_state_in_scope(common: &crate::args::GlobalArgs) -> bool {
45+
!common.is_global()
46+
}
47+
48+
/// The usage error for a mode that rewires the project (`hosted`,
49+
/// `vendored`) under global scope, or `None` when `mode` is allowed.
50+
/// Shared by `scan` and `get` so both refuse the same combinations with
51+
/// the same wording.
52+
pub(crate) fn global_mode_conflict(
53+
common: &crate::args::GlobalArgs,
54+
mode: scan::ScanMode,
55+
) -> Option<String> {
56+
if project_state_in_scope(common) {
57+
return None;
58+
}
59+
let why = match mode {
60+
scan::ScanMode::Agent => return None,
61+
scan::ScanMode::Hosted => "redirect",
62+
scan::ScanMode::Vendored => "wire vendored artifacts into",
63+
};
64+
Some(format!(
65+
"{} cannot be used with --mode {}: global installs have no project lockfile to {why}",
66+
if common.global {
67+
"--global"
68+
} else {
69+
"--global-prefix"
70+
},
71+
mode.cli_name(),
72+
))
73+
}
74+
3775
/// Lockfile discovery of `root` (core `vex::discover`): the hosted and
3876
/// vendored patch references its lockfiles and configs wire, with hosted
3977
/// references counted on Socket's public patch server plus the operator's

‎crates/socket-patch-cli/src/commands/remove.rs‎

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -345,13 +345,24 @@ pub async fn run(args: RemoveArgs) -> i32 {
345345
// must not see `.socket/` created and pruned again). The vendor ledger
346346
// is loaded under the lock below; the hosted pins come from read-only
347347
// lockfile discovery (the restore re-reads every file under the lock).
348+
//
349+
// Under global scope the project's hosted pins and vendor ledger are
350+
// not this run's to unwind (see `project_state_in_scope`): a global
351+
// remove restores the global copies and drops their manifest records
352+
// only.
353+
let project_state = crate::commands::project_state_in_scope(&args.common);
348354
let manifest_missing = tokio::fs::metadata(&manifest_path).await.is_err();
349-
let hosted_inventory = crate::commands::hosted_inventory(&args.common, cwd).await;
355+
let hosted_inventory = if project_state {
356+
crate::commands::hosted_inventory(&args.common, cwd).await
357+
} else {
358+
Default::default()
359+
};
350360
let hosted_pins: Vec<HostedPin> = hosted_inventory.pins.clone();
351361
if manifest_missing {
352-
let vendor_ledger_exists = tokio::fs::metadata(cwd.join(VENDOR_STATE_REL))
353-
.await
354-
.is_ok();
362+
let vendor_ledger_exists = project_state
363+
&& tokio::fs::metadata(cwd.join(VENDOR_STATE_REL))
364+
.await
365+
.is_ok();
355366
if !vendor_ledger_exists && hosted_pins.is_empty() {
356367
// Contested hosted wiring is still hosted state: name it
357368
// instead of reporting a bare project.
@@ -440,7 +451,11 @@ pub async fn run(args: RemoveArgs) -> i32 {
440451
// the vendored leg. An unreadable ledger degrades to "nothing vendored"
441452
// for the rollback and fails closed at the vendored leg — exactly where
442453
// the run is about to mutate vendored state.
443-
let vendor_state_result = load_state(cwd).await;
454+
let vendor_state_result = if project_state {
455+
load_state(cwd).await
456+
} else {
457+
Ok(VendorState::default())
458+
};
444459

445460
if matching.is_empty() {
446461
// Ledger-only entries (vendored mode keeps no manifest record) —

‎crates/socket-patch-cli/src/commands/rollback.rs‎

Lines changed: 48 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1043,7 +1043,10 @@ pub(crate) async fn retire_legacy_redirect_ledger(common: &GlobalArgs) -> Option
10431043
let path = common
10441044
.cwd
10451045
.join(socket_patch_core::patch::redirect::REDIRECT_STATE_REL);
1046-
if common.dry_run || tokio::fs::symlink_metadata(&path).await.is_err() {
1046+
if common.dry_run
1047+
|| !crate::commands::project_state_in_scope(common)
1048+
|| tokio::fs::symlink_metadata(&path).await.is_err()
1049+
{
10471050
return None;
10481051
}
10491052
let remaining = crate::commands::discover_wiring(common, &common.cwd).await;
@@ -1111,13 +1114,23 @@ pub async fn run(args: RollbackArgs) -> i32 {
11111114
// themselves are LOADED UNDER the apply lock below: this run persists
11121115
// mutated clones of the ledgers, so a pre-lock snapshot could clobber
11131116
// a concurrent run's writes with stale state.
1117+
//
1118+
// Under global scope the project's hosted pins and vendor ledger are
1119+
// not this run's to unwind (see `project_state_in_scope`): a global
1120+
// rollback restores the global copies and their manifest records only.
1121+
let project_state = crate::commands::project_state_in_scope(&args.common);
11141122
let manifest_missing = tokio::fs::metadata(&manifest_path).await.is_err();
1115-
let vendor_ledger_exists = tokio::fs::metadata(cwd.join(".socket/vendor/state.json"))
1116-
.await
1117-
.is_ok();
1123+
let vendor_ledger_exists = project_state
1124+
&& tokio::fs::metadata(cwd.join(".socket/vendor/state.json"))
1125+
.await
1126+
.is_ok();
11181127
// The hosted pins the lockfiles wire (read-only discovery; the restore
11191128
// re-reads every file under the lock before it writes).
1120-
let hosted_inventory = crate::commands::hosted_inventory(&args.common, &cwd).await;
1129+
let hosted_inventory = if project_state {
1130+
crate::commands::hosted_inventory(&args.common, &cwd).await
1131+
} else {
1132+
Default::default()
1133+
};
11211134
let hosted_pins: Vec<HostedPin> = hosted_inventory.pins.clone();
11221135

11231136
if manifest_missing && !vendor_ledger_exists && hosted_pins.is_empty() {
@@ -1131,7 +1144,7 @@ pub async fn run(args: RollbackArgs) -> i32 {
11311144
// so there is nothing to restore — retire the stale file (a wet run
11321145
// only) instead of failing on the missing manifest.
11331146
let legacy = cwd.join(socket_patch_core::patch::redirect::REDIRECT_STATE_REL);
1134-
if tokio::fs::symlink_metadata(&legacy).await.is_ok() {
1147+
if project_state && tokio::fs::symlink_metadata(&legacy).await.is_ok() {
11351148
let warning = retire_legacy_redirect_ledger(&args.common).await;
11361149
if args.common.json {
11371150
println!(
@@ -1167,7 +1180,11 @@ pub async fn run(args: RollbackArgs) -> i32 {
11671180
// with lockfiles still consuming `.socket/vendor/` artifacts: the
11681181
// ledger holds the pre-vendor originals, so it must come back from
11691182
// version control first.)
1170-
let wired = crate::commands::vendored_backend::repair::scan_vendor_references(&cwd).await;
1183+
let wired = if project_state {
1184+
crate::commands::vendored_backend::repair::scan_vendor_references(&cwd).await
1185+
} else {
1186+
Default::default()
1187+
};
11711188
if !wired.is_empty() {
11721189
emit_rollback_error(
11731190
args.common.json,
@@ -1213,7 +1230,22 @@ pub async fn run(args: RollbackArgs) -> i32 {
12131230
// Load the state stores UNDER the lock (see the discovery note above),
12141231
// each exactly once: the agent leg below receives the manifest and the
12151232
// vendor-ownership key set instead of re-reading them.
1216-
let vendor_state_result = socket_patch_core::vendor::load_state(&cwd).await;
1233+
//
1234+
// Under global scope the ledger is read only to keep the project's
1235+
// vendored manifest records (see the cleanup below): no vendored leg
1236+
// runs, and the ledger does not own the global copies, so the in-place
1237+
// leg restores them.
1238+
let loaded_vendor_state = socket_patch_core::vendor::load_state(&cwd).await;
1239+
let project_vendored_keys: HashSet<String> = loaded_vendor_state
1240+
.as_ref()
1241+
.map(VendorState::purl_keys)
1242+
.unwrap_or_default();
1243+
let ledger_unreadable = loaded_vendor_state.is_err();
1244+
let vendor_state_result = if project_state {
1245+
loaded_vendor_state
1246+
} else {
1247+
Ok(VendorState::default())
1248+
};
12171249
let vendor_corrupt = vendor_state_result.is_err();
12181250
// An unreadable ledger degrades to "nothing vendored" for the in-place
12191251
// leg (its own containment is the `vendor_state_unreadable` exit below).
@@ -1558,7 +1590,11 @@ pub async fn run(args: RollbackArgs) -> i32 {
15581590
.filter(|r| !r.success)
15591591
.map(|r| r.package_key.clone())
15601592
.collect();
1561-
let cleanup_allowed = !args.preserve_state && !aborted && !vendor_corrupt;
1593+
// A global run keeps the project's vendored records too: their
1594+
// vendored state is not unwound, so dropping them would hand a
1595+
// later `vendor` reconcile a revert with no backing record. An
1596+
// unreadable ledger leaves that ownership unknowable either way.
1597+
let cleanup_allowed = !args.preserve_state && !aborted && !ledger_unreadable;
15621598
// A vendor-owned manifest purl is removable only when its
15631599
// ledger entry was cleanly reverted this run (drift-keeps and
15641600
// failures keep the record; the matching mirrors the
@@ -1588,6 +1624,9 @@ pub async fn run(args: RollbackArgs) -> i32 {
15881624
if failed_purls.contains(*purl) {
15891625
return false;
15901626
}
1627+
if !project_state && purl_keys_cover(&project_vendored_keys, purl) {
1628+
return false;
1629+
}
15911630
if vendored_excluded.contains(purl) {
15921631
return vendored_reverted_ok(purl);
15931632
}

0 commit comments

Comments
 (0)