From c18c5ddbc44ae941af19462f089c115cb681a723 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 11:12:57 -0400 Subject: [PATCH 1/7] Resolve the org once per run and route every API call through it (#648) The run's API client now carries one ApiRoute, Org{slug} or Proxy, decided once in get_api_client_with_overrides. With a token and no --org / SOCKET_ORG_SLUG / socket-cli defaultOrg, a failed GET /v0/organizations (network error, 401/403, no orgs, bad answer) makes the whole run an anonymous public-proxy run with one warning, instead of querying /v0/orgs/default/... for JSON while blobs, vendor package references and telemetry went to the proxy. Offline runs with a token and no slug use the proxy route without a network call. - ApiClient: route replaces use_public_proxy + org_slug; a proxy client never keeps the token. patches_path, the batch 404 message, binary_url and vendor_package_url match on the route; the org_slug_or_default fallback, the proxy_url_from_env re-derivation and fetch_registry_references_for_org are gone. - Telemetry takes a TelemetryAuth built from the run's client (TelemetryAuth::for_client); list keeps a no-client constructor. - Embedded --vex reuses the host command's client (VexBuildParams api_client), so scan/apply/vendor --vex no longer resolve the org a second time; standalone vex builds its client at most once and reports telemetry on it. vendored repair hands the client it builds back to repair. - scan --json and get --json report the downgrade as api_auth_fallback in warnings[]. The mid-run 401/403 proxy swap is unchanged (#647). Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-cli/src/args.rs | 10 + crates/socket-patch-cli/src/commands/apply.rs | 41 +- .../src/commands/fetch_stage.rs | 3 +- crates/socket-patch-cli/src/commands/get.rs | 77 +- .../src/commands/hosted_bundle.rs | 3 +- crates/socket-patch-cli/src/commands/list.rs | 8 +- .../socket-patch-cli/src/commands/remove.rs | 105 +-- .../socket-patch-cli/src/commands/repair.rs | 26 +- .../socket-patch-cli/src/commands/rollback.rs | 36 +- .../src/commands/scan/discovery.rs | 3 +- .../src/commands/scan/hosted.rs | 2 +- .../src/commands/scan/hosted/vlt.rs | 3 +- .../socket-patch-cli/src/commands/scan/mod.rs | 85 +- .../src/commands/scan/vendor_flow.rs | 38 +- .../socket-patch-cli/src/commands/vendor.rs | 68 +- .../src/commands/vendored_backend/repair.rs | 23 +- crates/socket-patch-cli/src/commands/vex.rs | 74 +- .../src/commands/vex_sources.rs | 76 +- .../tests/cli/covgap_api_client.rs | 185 ++++- .../tests/e2e_hosted_production.rs | 3 +- .../tests/hosted_memory_common/mod.rs | 3 +- .../socket-patch-core/src/api/blob_fetcher.rs | 3 +- crates/socket-patch-core/src/api/client.rs | 782 ++++++++++-------- .../src/api/vendor_prefetch.rs | 3 +- crates/socket-patch-core/src/patch/jvm_jar.rs | 3 +- .../src/patch/redirect/vlt_preflight.rs | 3 +- crates/socket-patch-core/src/telemetry.rs | 343 ++++---- crates/socket-patch-core/src/vendor/cargo.rs | 3 +- .../src/vendor/composer_lock.rs | 3 +- crates/socket-patch-core/src/vendor/gem.rs | 3 +- crates/socket-patch-core/src/vendor/golang.rs | 3 +- .../src/vendor/maven_repo.rs | 3 +- .../src/vendor/npm_common.rs | 3 +- .../socket-patch-core/src/vendor/npm_lock.rs | 3 +- .../src/vendor/nuget_feed.rs | 12 +- crates/socket-patch-core/src/vendor/pypi.rs | 3 +- .../src/vendor/service_fetch.rs | 3 +- .../src/vendor/test_support.rs | 3 +- .../socket-patch-core/tests/api_retry_e2e.rs | 7 +- .../tests/api_timeout_e2e.rs | 7 +- .../binary_fetch_error_classification_e2e.rs | 3 +- .../tests/blob_fetcher_edges_e2e.rs | 6 +- .../tests/covgap_api_blob_fetcher.rs | 6 +- .../tests/proxy_batch_e2e.rs | 3 +- crates/socket-patch-core/tests/vlt_locks.rs | 3 +- 45 files changed, 1153 insertions(+), 933 deletions(-) diff --git a/crates/socket-patch-cli/src/args.rs b/crates/socket-patch-cli/src/args.rs index a2ecfe542..6fcd5fc41 100644 --- a/crates/socket-patch-cli/src/args.rs +++ b/crates/socket-patch-cli/src/args.rs @@ -20,6 +20,7 @@ use socket_patch_core::api::client::{ }; use socket_patch_core::constants::DEFAULT_PATCH_MANIFEST_PATH; use socket_patch_core::crawlers::Ecosystem; +use socket_patch_core::telemetry::TelemetryAuth; use socket_patch_core::vendor::{VendorServiceConfig, VendorSource}; /// clap value-parser for each `--ecosystems` / `SOCKET_ECOSYSTEMS` token. @@ -495,6 +496,15 @@ impl GlobalArgs { resolve_ambient_credentials(overrides.api_token, overrides.org_slug) } + /// Telemetry's route for a run that built no API client: + /// [`Self::telemetry_credentials`] as a [`TelemetryAuth`] (the org + /// endpoint only for a token + slug; never a network call). A run that + /// has a client uses [`TelemetryAuth::for_client`] instead. + pub(crate) fn telemetry_auth(&self) -> TelemetryAuth { + let (api_token, org_slug) = self.telemetry_credentials(); + TelemetryAuth::from_credentials(api_token.as_deref(), org_slug.as_deref()) + } + /// The vendoring-service config every vendor entry point (`vendor`, /// `scan`/`get --mode vendored`) builds from the same flags — /// `--vendor-source` / `--vendor-url` / `--patch-server-url` / diff --git a/crates/socket-patch-cli/src/commands/apply.rs b/crates/socket-patch-cli/src/commands/apply.rs index d3a23819f..bd61fd182 100644 --- a/crates/socket-patch-cli/src/commands/apply.rs +++ b/crates/socket-patch-cli/src/commands/apply.rs @@ -13,7 +13,7 @@ use socket_patch_core::patch::redirect::golang_local::{ apply_go_redirect, reconcile_go_redirects, verify_go_redirect_state, }; use socket_patch_core::patch::sidecars::{maven as maven_sidecars, SidecarAdvisoryCode}; -use socket_patch_core::telemetry::{track_patch_applied, track_patch_apply_failed}; +use socket_patch_core::telemetry::{track_patch_applied, track_patch_apply_failed, TelemetryAuth}; use socket_patch_core::utils::purl::parse_golang_purl; use socket_patch_core::utils::purl::{normalize_purl, purl_eq, strip_purl_qualifiers}; use socket_patch_core::vendor::purl_keys_cover; @@ -878,7 +878,7 @@ pub async fn run(args: ApplyArgs) -> i32 { println!("No patch manifest found; nothing to apply."); } let vex_result = if !args.common.dry_run && !args.check && args.vex.vex.is_some() { - let params = args.vex.to_build_params(); + let params = args.vex.to_build_params(None); Some(generate_vex_without_manifest(&args.common, ¶ms, &manifest_path).await) } else { None @@ -1065,8 +1065,7 @@ pub(crate) async fn run_locked( client: &ApiClient, lock: LockGuard, ) -> ApplyRunReport { - let api_token = client.api_token().cloned(); - let org_slug = client.org_slug().cloned(); + let telemetry = TelemetryAuth::for_client(client); // ONE parse of the manifest for the whole run — the PnP gate and the // apply loop (embedded VEX re-reads it by design, after the writes). @@ -1077,13 +1076,13 @@ pub(crate) async fn run_locked( Ok(Some(m)) => m, Ok(None) => { lock.release(); - let code = report_apply_failure(&args, "Invalid manifest", &api_token, &org_slug).await; + let code = report_apply_failure(&args, "Invalid manifest", &telemetry).await; return ApplyRunReport::run_failure(code, "apply_failed", "Invalid manifest"); } Err(e) => { lock.release(); let error = e.to_string(); - let code = report_apply_failure(&args, &error, &api_token, &org_slug).await; + let code = report_apply_failure(&args, &error, &telemetry).await; return ApplyRunReport::run_failure(code, "apply_failed", error); } }; @@ -1243,7 +1242,7 @@ pub(crate) async fn run_locked( // `no_applicable_patches`, and would write an attestation // file during --dry-run. Skip instead. let vex_result = if success && !args.common.dry_run && args.vex.vex.is_some() { - let params = args.vex.to_build_params(); + let params = args.vex.to_build_params(Some(client)); Some(generate_vex_from_manifest_path(&args.common, ¶ms, &manifest_path).await) } else { None @@ -1389,19 +1388,12 @@ pub(crate) async fn run_locked( // Track telemetry if success { - track_patch_applied( - patched_count, - args.common.dry_run, - api_token.as_deref(), - org_slug.as_deref(), - ) - .await; + track_patch_applied(patched_count, args.common.dry_run, &telemetry).await; } else { track_patch_apply_failed( "One or more patches failed to apply", args.common.dry_run, - api_token.as_deref(), - org_slug.as_deref(), + &telemetry, ) .await; } @@ -1451,7 +1443,7 @@ pub(crate) async fn run_locked( } Err(e) => { lock.release(); - let code = report_apply_failure(&args, &e, &api_token, &org_slug).await; + let code = report_apply_failure(&args, &e, &telemetry).await; ApplyRunReport::run_failure(code, "apply_failed", e) } } @@ -1462,19 +1454,8 @@ pub(crate) async fn run_locked( /// `--silent` ("errors only", never "nothing" — exit 1 with no message /// would be undiagnosable), exit 1. Shared by the manifest read in `run` /// and `apply_patches_inner`'s `Err` arm. -async fn report_apply_failure( - args: &ApplyArgs, - error: &str, - api_token: &Option, - org_slug: &Option, -) -> i32 { - track_patch_apply_failed( - error, - args.common.dry_run, - api_token.as_deref(), - org_slug.as_deref(), - ) - .await; +async fn report_apply_failure(args: &ApplyArgs, error: &str, telemetry: &TelemetryAuth) -> i32 { + track_patch_apply_failed(error, args.common.dry_run, telemetry).await; if args.common.json { let mut env = Envelope::new(Command::Apply); env.dry_run = args.common.dry_run; diff --git a/crates/socket-patch-cli/src/commands/fetch_stage.rs b/crates/socket-patch-cli/src/commands/fetch_stage.rs index 85831974a..4f64f79d4 100644 --- a/crates/socket-patch-cli/src/commands/fetch_stage.rs +++ b/crates/socket-patch-cli/src/commands/fetch_stage.rs @@ -486,8 +486,7 @@ mod tests { ApiClient::new(socket_patch_core::api::client::ApiClientOptions { api_url: "http://127.0.0.1:1".to_string(), api_token: None, - use_public_proxy: false, - org_slug: None, + route: socket_patch_core::api::client::ApiRoute::Proxy, }) } diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs index 0cbd9ce4b..89e0dc926 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -18,7 +18,7 @@ use socket_patch_core::manifest::records::{build_patch_record, files_for_manifes use socket_patch_core::manifest::schema::{PatchFileInfo, PatchManifest, PatchRecord}; use socket_patch_core::patch::apply::{is_valid_blob_hash, select_installed_variants_any}; use socket_patch_core::patch::apply_lock::{LockError, LockGuard}; -use socket_patch_core::telemetry::{track_patch_fetch_failed, track_patch_fetched}; +use socket_patch_core::telemetry::{track_patch_fetch_failed, track_patch_fetched, TelemetryAuth}; use socket_patch_core::utils::concurrent::{api_concurrency_for, ordered_concurrent}; use socket_patch_core::utils::purl::{ canonical_purl, is_purl, normalize_purl, strip_purl_qualifiers, @@ -222,12 +222,11 @@ async fn report_fetch_failure( identifier: &str, error: impl std::fmt::Display, fallback_to_proxy: bool, - api_token: Option<&str>, - org_slug: Option<&str>, + telemetry: &TelemetryAuth, json: bool, ) -> i32 { let msg = error.to_string(); - track_patch_fetch_failed(identifier, &msg, fallback_to_proxy, api_token, org_slug).await; + track_patch_fetch_failed(identifier, &msg, fallback_to_proxy, telemetry).await; report_error(json, msg); 1 } @@ -2744,8 +2743,17 @@ pub async fn run(args: GetArgs) -> i32 { let overrides = args.common.api_client_overrides(); let (mut api_client, mut use_public_proxy) = get_api_client_with_overrides(overrides.clone()).await; - let telemetry_token = api_client.api_token().cloned(); - let telemetry_org = api_client.org_slug().cloned(); + let telemetry = TelemetryAuth::for_client(&api_client); + // A token whose org could not be resolved put the whole run on the + // public proxy (the client already warned on stderr): `--json` + // consumers get it in `warnings[]` too. + let org_warnings: Vec<(String, String)> = match api_client.org_unresolved() { + Some(reason) if args.common.json => vec![( + crate::commands::vex_sources::NOTE_API_AUTH_FALLBACK.to_string(), + reason.to_string(), + )], + _ => Vec::new(), + }; let download_mode = args.common.download_mode.clone(); // Set to `true` after the first 401/403 from the authenticated // endpoint triggered a rebuild against the public proxy. Plumbed @@ -2796,8 +2804,7 @@ pub async fn run(args: GetArgs) -> i32 { Some(&patch.purl), &patch.uuid, fallback_to_proxy, - telemetry_token.as_deref(), - telemetry_org.as_deref(), + &telemetry, ) .await; } @@ -2825,8 +2832,7 @@ pub async fn run(args: GetArgs) -> i32 { &ecosystem_from_purl(&patch.purl), &download_mode, fallback_to_proxy, - telemetry_token.as_deref(), - telemetry_org.as_deref(), + &telemetry, ) .await; // Mode dispatch. All three reuse THIS fetched patch and @@ -2841,7 +2847,7 @@ pub async fn run(args: GetArgs) -> i32 { } super::scan::ScanMode::Hosted => { let selected = vec![search_result_from_response(&patch)]; - run_get_hosted(&args, &api_client, &selected, &[], &[]).await + run_get_hosted(&args, &api_client, &selected, &[], &org_warnings).await } super::scan::ScanMode::Vendored => { let selected = vec![search_result_from_response(&patch)]; @@ -2852,9 +2858,8 @@ pub async fn run(args: GetArgs) -> i32 { &selected, Some(&patch), &[], - &[], - telemetry_token.as_deref(), - telemetry_org.as_deref(), + &org_warnings, + &telemetry, ) .await } @@ -2869,8 +2874,7 @@ pub async fn run(args: GetArgs) -> i32 { None, &args.identifier, fallback_to_proxy, - telemetry_token.as_deref(), - telemetry_org.as_deref(), + &telemetry, ) .await; } @@ -2879,8 +2883,7 @@ pub async fn run(args: GetArgs) -> i32 { &args.identifier, "not_found", fallback_to_proxy, - telemetry_token.as_deref(), - telemetry_org.as_deref(), + &telemetry, ) .await; if args.common.json { @@ -2895,8 +2898,7 @@ pub async fn run(args: GetArgs) -> i32 { &args.identifier, e, fallback_to_proxy, - telemetry_token.as_deref(), - telemetry_org.as_deref(), + &telemetry, args.common.json, ) .await; @@ -2929,8 +2931,7 @@ pub async fn run(args: GetArgs) -> i32 { &args.identifier, e, fallback_to_proxy, - telemetry_token.as_deref(), - telemetry_org.as_deref(), + &telemetry, args.common.json, ) .await; @@ -2986,8 +2987,7 @@ pub async fn run(args: GetArgs) -> i32 { &args.identifier, e, fallback_to_proxy, - telemetry_token.as_deref(), - telemetry_org.as_deref(), + &telemetry, args.common.json, ) .await; @@ -3098,6 +3098,8 @@ pub async fn run(args: GetArgs) -> i32 { eprintln!("Warning: {detail}"); } } + // Already on stderr from the client: JSON only, after the print above. + narrow_warnings.extend(org_warnings); if accessible.is_empty() { // Every accessible patch was narrowed out. Additive status (never // `no_match`, which is pinned to the fuzzy package-name path): @@ -3265,8 +3267,7 @@ pub async fn run(args: GetArgs) -> i32 { None, &narrow_skips, &narrow_warnings, - telemetry_token.as_deref(), - telemetry_org.as_deref(), + &telemetry, ) .await; } @@ -3307,17 +3308,9 @@ async fn report_paid_required_uuid( purl: Option<&str>, patch_id: &str, fallback_to_proxy: bool, - telemetry_token: Option<&str>, - telemetry_org: Option<&str>, + telemetry: &TelemetryAuth, ) -> i32 { - track_patch_fetch_failed( - patch_id, - "paid_required", - fallback_to_proxy, - telemetry_token, - telemetry_org, - ) - .await; + track_patch_fetch_failed(patch_id, "paid_required", fallback_to_proxy, telemetry).await; if args.common.json { let mut record = serde_json::json!({ "uuid": patch_id, "tier": "paid" }); if let Some(purl) = purl { @@ -3778,8 +3771,7 @@ async fn run_get_vendored( prefetched: Option<&PatchResponse>, narrow_skips: &[serde_json::Value], narrow_warnings: &[(String, String)], - telemetry_token: Option<&str>, - telemetry_org: Option<&str>, + telemetry: &TelemetryAuth, ) -> i32 { // Dry run: ledger-classification preview only (scan's posture) — no // download, no vendor step, no writes. @@ -3851,8 +3843,7 @@ async fn run_get_vendored( socket_patch_core::telemetry::track_patch_vendor_failed( &detail, args.common.dry_run, - telemetry_token, - telemetry_org, + telemetry, ) .await; if args.common.json { @@ -3931,8 +3922,7 @@ async fn run_get_vendored( report_empty: true, prior: None, download_errors: dl_code != 0, - telemetry_token, - telemetry_org, + telemetry_auth: telemetry, }) .await { @@ -7020,8 +7010,7 @@ mod tests { let client = ApiClient::new(socket_patch_core::api::client::ApiClientOptions { api_url: "http://127.0.0.1:1".into(), api_token: None, - use_public_proxy: false, - org_slug: None, + route: socket_patch_core::api::client::ApiRoute::Proxy, }); let run = DownloadRun { api_client: &client, diff --git a/crates/socket-patch-cli/src/commands/hosted_bundle.rs b/crates/socket-patch-cli/src/commands/hosted_bundle.rs index a054e7e78..8887adb8e 100644 --- a/crates/socket-patch-cli/src/commands/hosted_bundle.rs +++ b/crates/socket-patch-cli/src/commands/hosted_bundle.rs @@ -161,8 +161,7 @@ pub async fn run(args: HostedBundleArgs) -> i32 { .filter(|u| !u.is_empty()) .unwrap_or_else(|| DEFAULT_SOCKET_API_URL.to_string()), api_token: Some(token), - use_public_proxy: false, - org_slug: Some(org), + route: socket_patch_core::api::client::ApiRoute::org(org), }); match run_in_memory(input, Arc::new(client), CancellationToken::new()).await { Ok(output) => match serde_json::to_string_pretty(&output) { diff --git a/crates/socket-patch-cli/src/commands/list.rs b/crates/socket-patch-cli/src/commands/list.rs index 8fc77fab1..c84b0798e 100644 --- a/crates/socket-patch-cli/src/commands/list.rs +++ b/crates/socket-patch-cli/src/commands/list.rs @@ -456,13 +456,7 @@ pub async fn run(args: ListArgs) -> i32 { // to its consumers, so it counts the manifest ONLY (0 on a ledger-only // project) rather than the listed entries. let manifest_patch_count = manifest.map_or(0, |m| m.patches.len()); - let (api_token, org_slug) = args.common.telemetry_credentials(); - track_patch_listed( - manifest_patch_count, - api_token.as_deref(), - org_slug.as_deref(), - ) - .await; + track_patch_listed(manifest_patch_count, &args.common.telemetry_auth()).await; if args.common.json { let mut env = build_list_envelope(&entries); diff --git a/crates/socket-patch-cli/src/commands/remove.rs b/crates/socket-patch-cli/src/commands/remove.rs index d69b1183e..fca51446c 100644 --- a/crates/socket-patch-cli/src/commands/remove.rs +++ b/crates/socket-patch-cli/src/commands/remove.rs @@ -4,7 +4,7 @@ use socket_patch_core::manifest::cleanup_blobs::{format_bytes, ArtifactReference use socket_patch_core::manifest::operations::{read_manifest, write_manifest}; use socket_patch_core::manifest::schema::PatchManifest; use socket_patch_core::patch::redirect::upstream::HostedPin; -use socket_patch_core::telemetry::{track_patch_remove_failed, track_patch_removed}; +use socket_patch_core::telemetry::{track_patch_remove_failed, track_patch_removed, TelemetryAuth}; use socket_patch_core::utils::purl::patch_matches; use socket_patch_core::vendor::{ load_state, RevertOpts, VendorEntry, VendorState, VENDOR_STATE_REL, @@ -73,15 +73,9 @@ fn remove_matching( /// matched nothing in any store, tracking the failure. `dry_run` rides the /// envelope so a preview's failures still report `dryRun: true` (matching /// apply's error envelopes and remove's own success envelope). -async fn emit_not_found( - json: bool, - dry_run: bool, - identifier: &str, - api_token: Option<&str>, - org_slug: Option<&str>, -) { +async fn emit_not_found(json: bool, dry_run: bool, identifier: &str, telemetry: &TelemetryAuth) { let msg = format!("No patch found matching identifier: {identifier}"); - track_patch_remove_failed(&msg, api_token, org_slug).await; + track_patch_remove_failed(&msg, telemetry).await; if json { let mut env = Envelope::new(Command::Remove); env.dry_run = dry_run; @@ -328,8 +322,7 @@ pub async fn run(args: RemoveArgs) -> i32 { let (telemetry_client, _) = get_api_client_with_overrides(args.common.api_client_overrides()).await; - let api_token = telemetry_client.api_token().cloned(); - let org_slug = telemetry_client.org_slug().cloned(); + let telemetry = TelemetryAuth::for_client(&telemetry_client); let loud = !args.common.json && !args.common.silent; let manifest_path = args.common.resolved_manifest_path(); @@ -464,14 +457,7 @@ pub async fn run(args: RemoveArgs) -> i32 { if let Ok(state) = vendor_state_result { let ledger_matches = vendor_entries_matching(&state, &args.identifier); if !ledger_matches.is_empty() { - return remove_ledger_only( - &args, - ledger_matches, - state, - api_token.as_deref(), - org_slug.as_deref(), - ) - .await; + return remove_ledger_only(&args, ledger_matches, state, &telemetry).await; } } @@ -480,21 +466,14 @@ pub async fn run(args: RemoveArgs) -> i32 { // per-purl exit path (restoring the upstream entry IS the removal). let hosted_matches = hosted_pins_matching(&hosted_pins, &args.identifier); if !hosted_matches.is_empty() { - return remove_hosted_only( - &args, - hosted_matches, - api_token.as_deref(), - org_slug.as_deref(), - ) - .await; + return remove_hosted_only(&args, hosted_matches, &telemetry).await; } emit_not_found( args.common.json, args.common.dry_run, &args.identifier, - api_token.as_deref(), - org_slug.as_deref(), + &telemetry, ) .await; return 1; @@ -612,12 +591,8 @@ pub async fn run(args: RemoveArgs) -> i32 { rollback_not_installed = outcome.not_installed; rollback_warnings = outcome.warnings; if !outcome.success { - track_patch_remove_failed( - "Rollback failed during patch removal", - api_token.as_deref(), - org_slug.as_deref(), - ) - .await; + track_patch_remove_failed("Rollback failed during patch removal", &telemetry) + .await; // The nested rollback reports per-package failures // inline only under --silent; say why here otherwise. if loud { @@ -678,7 +653,7 @@ pub async fn run(args: RemoveArgs) -> i32 { } } Err(e) => { - track_patch_remove_failed(&e, api_token.as_deref(), org_slug.as_deref()).await; + track_patch_remove_failed(&e, &telemetry).await; let remedy = "Use --skip-rollback to remove from manifest without restoring files."; if args.common.json { // The pinned envelope message keeps its historical prefix. @@ -743,19 +718,13 @@ pub async fn run(args: RemoveArgs) -> i32 { } } else { let keys: Vec = vendored_matches.iter().map(|(k, _)| k.clone()).collect(); - vendor_leg = match revert_vendored_matches( - &args, - &keys, - &mut vendor_state, - api_token.as_deref(), - org_slug.as_deref(), - true, - ) - .await - { - Ok(leg) => leg, - Err(code) => return code, - }; + vendor_leg = + match revert_vendored_matches(&args, &keys, &mut vendor_state, &telemetry, true) + .await + { + Ok(leg) => leg, + Err(code) => return code, + }; printed_progress |= loud && vendor_leg.printed; } } @@ -850,7 +819,7 @@ pub async fn run(args: RemoveArgs) -> i32 { removed (re-run `scan --mode vendored` to normalize, then remove)", args.identifier ); - track_patch_remove_failed(&msg, api_token.as_deref(), org_slug.as_deref()).await; + track_patch_remove_failed(&msg, &telemetry).await; if args.common.json { let mut env = Envelope::new(Command::Remove); env.dry_run = args.common.dry_run; @@ -868,7 +837,7 @@ pub async fn run(args: RemoveArgs) -> i32 { if !args.common.dry_run && !removed.is_empty() { if let Err(e) = write_manifest(&manifest_path, &updated_manifest).await { let msg = e.to_string(); - track_patch_remove_failed(&msg, api_token.as_deref(), org_slug.as_deref()).await; + track_patch_remove_failed(&msg, &telemetry).await; emit_error_envelope(args.common.json, args.common.dry_run, "remove_failed", msg); return 1; } @@ -1098,7 +1067,7 @@ pub async fn run(args: RemoveArgs) -> i32 { } if !args.common.dry_run { - track_patch_removed(removed.len(), api_token.as_deref(), org_slug.as_deref()).await; + track_patch_removed(removed.len(), &telemetry).await; } if vendor_leg.kept.is_empty() { 0 @@ -1155,8 +1124,7 @@ async fn revert_vendored_matches( args: &RemoveArgs, keys: &[String], state: &mut VendorState, - api_token: Option<&str>, - org_slug: Option<&str>, + telemetry: &TelemetryAuth, manifest_backed: bool, ) -> Result { let loud = !args.common.json && !args.common.silent; @@ -1189,12 +1157,8 @@ async fn revert_vendored_matches( match step { VendorRevertStep::Missing => {} VendorRevertStep::Failed(why) => { - track_patch_remove_failed( - "vendor revert failed during patch removal", - api_token, - org_slug, - ) - .await; + track_patch_remove_failed("vendor revert failed during patch removal", telemetry) + .await; emit_error_envelope( args.common.json, args.common.dry_run, @@ -1349,8 +1313,7 @@ fn hosted_unwind_error(err: HostedUnwindError, manifest_backed: bool) -> (&'stat async fn remove_hosted_only( args: &RemoveArgs, hosted_matches: Vec, - api_token: Option<&str>, - org_slug: Option<&str>, + telemetry: &TelemetryAuth, ) -> i32 { let loud = !args.common.json && !args.common.silent; if args.skip_rollback { @@ -1406,7 +1369,7 @@ async fn remove_hosted_only( let leg = match unwind_hosted(&args.common, &hosted_matches).await { Ok(leg) => leg, Err(err) => { - track_patch_remove_failed("hosted redirect revert failed", api_token, org_slug).await; + track_patch_remove_failed("hosted redirect revert failed", telemetry).await; let (code, msg) = hosted_unwind_error(err, false); emit_error_envelope(args.common.json, args.common.dry_run, code, msg); return 1; @@ -1436,7 +1399,7 @@ async fn remove_hosted_only( println!("{}", env.to_pretty_json()); } if !args.common.dry_run { - track_patch_removed(leg.reverted.len(), api_token, org_slug).await; + track_patch_removed(leg.reverted.len(), telemetry).await; } 0 } @@ -1456,8 +1419,7 @@ async fn remove_ledger_only( args: &RemoveArgs, matches: Vec<(String, VendorEntry)>, mut state: VendorState, - api_token: Option<&str>, - org_slug: Option<&str>, + telemetry: &TelemetryAuth, ) -> i32 { let loud = !args.common.json && !args.common.silent; if args.skip_rollback { @@ -1521,11 +1483,10 @@ async fn remove_ledger_only( } let keys: Vec = matches.iter().map(|(k, _)| k.clone()).collect(); - let leg = - match revert_vendored_matches(args, &keys, &mut state, api_token, org_slug, false).await { - Ok(leg) => leg, - Err(code) => return code, - }; + let leg = match revert_vendored_matches(args, &keys, &mut state, telemetry, false).await { + Ok(leg) => leg, + Err(code) => return code, + }; let mut env = Envelope::new(Command::Remove); env.dry_run = args.common.dry_run; @@ -1549,7 +1510,7 @@ async fn remove_ledger_only( removed (re-run `scan --mode vendored` to normalize, then remove)", args.identifier ); - track_patch_remove_failed(&msg, api_token, org_slug).await; + track_patch_remove_failed(&msg, telemetry).await; env.error = Some(EnvelopeError::new("vendor_revert_kept", msg)); } } @@ -1557,7 +1518,7 @@ async fn remove_ledger_only( println!("{}", env.to_pretty_json()); } if !args.common.dry_run { - track_patch_removed(leg.reverted_count, api_token, org_slug).await; + track_patch_removed(leg.reverted_count, telemetry).await; } if leg.kept.is_empty() { 0 diff --git a/crates/socket-patch-cli/src/commands/repair.rs b/crates/socket-patch-cli/src/commands/repair.rs index de049be4d..1f227615b 100644 --- a/crates/socket-patch-cli/src/commands/repair.rs +++ b/crates/socket-patch-cli/src/commands/repair.rs @@ -9,7 +9,9 @@ use socket_patch_core::manifest::cleanup_blobs::{ }; use socket_patch_core::manifest::operations::read_manifest; use socket_patch_core::patch::apply::PatchSources; -use socket_patch_core::telemetry::{track_patch_repair_failed, track_patch_repaired}; +use socket_patch_core::telemetry::{ + track_patch_repair_failed, track_patch_repaired, TelemetryAuth, +}; use std::path::Path; use std::time::Duration; @@ -182,9 +184,10 @@ pub async fn run(args: RepairArgs) -> i32 { .0, ); } - let (api_token, org_slug) = client.as_ref().map_or((None, None), |c| { - (c.api_token().cloned(), c.org_slug().cloned()) - }); + let telemetry = client.as_ref().map_or_else( + || TelemetryAuth::from_credentials(None, None), + TelemetryAuth::for_client, + ); match result { Ok((env, counts)) => { @@ -195,19 +198,14 @@ pub async fn run(args: RepairArgs) -> i32 { // the exit code doesn't treat a half-finished repair as success. let had_failure = matches!(env.status, Status::PartialFailure | Status::Error); if had_failure { - track_patch_repair_failed( - "One or more artifacts failed to download", - api_token.as_deref(), - org_slug.as_deref(), - ) - .await; + track_patch_repair_failed("One or more artifacts failed to download", &telemetry) + .await; } else { track_patch_repaired( counts.downloaded, counts.cleaned, counts.bytes_freed, - api_token.as_deref(), - org_slug.as_deref(), + &telemetry, ) .await; } @@ -221,7 +219,7 @@ pub async fn run(args: RepairArgs) -> i32 { } } Err(e) => { - track_patch_repair_failed(&e, api_token.as_deref(), org_slug.as_deref()).await; + track_patch_repair_failed(&e, &telemetry).await; if args.common.json { let env = error_envelope(Command::Repair, args.common.dry_run, "repair_failed", &e); println!("{}", env.to_pretty_json()); @@ -606,7 +604,7 @@ async fn repair_inner( manifest: manifest.as_ref(), references: &vendor_references, ledger, - client: client.as_ref(), + client: &mut *client, }, &mut env, ) diff --git a/crates/socket-patch-cli/src/commands/rollback.rs b/crates/socket-patch-cli/src/commands/rollback.rs index 950bf5af9..f79b7f8e4 100644 --- a/crates/socket-patch-cli/src/commands/rollback.rs +++ b/crates/socket-patch-cli/src/commands/rollback.rs @@ -13,7 +13,9 @@ use socket_patch_core::patch::rollback::{ cannot_rollback_error, rollback_package_patch, verify_file_rollback, RollbackResult, VerifyRollbackResult, VerifyRollbackStatus, }; -use socket_patch_core::telemetry::{track_patch_rollback_failed, track_patch_rolled_back}; +use socket_patch_core::telemetry::{ + track_patch_rollback_failed, track_patch_rolled_back, TelemetryAuth, +}; use socket_patch_core::utils::composer_version::composer_purls_equivalent; use socket_patch_core::utils::purl::{patch_matches, strip_purl_qualifiers}; use socket_patch_core::vendor::{purl_keys_cover, RevertOpts, VendorState}; @@ -1024,8 +1026,7 @@ pub async fn run(args: RollbackArgs) -> i32 { let (telemetry_client, _) = get_api_client_with_overrides(args.common.api_client_overrides()).await; - let api_token = telemetry_client.api_token().cloned(); - let org_slug = telemetry_client.org_slug().cloned(); + let telemetry = TelemetryAuth::for_client(&telemetry_client); let manifest_path = args.common.resolved_manifest_path(); let cwd = args.common.cwd.clone(); @@ -1194,18 +1195,13 @@ pub async fn run(args: RollbackArgs) -> i32 { match read_manifest(&manifest_path).await { Ok(Some(m)) => m, Ok(None) => { - track_patch_rollback_failed( - "Invalid manifest", - api_token.as_deref(), - org_slug.as_deref(), - ) - .await; + track_patch_rollback_failed("Invalid manifest", &telemetry).await; emit_rollback_error(args.common.json, "Invalid manifest"); return 1; } Err(e) => { let msg = e.to_string(); - track_patch_rollback_failed(&msg, api_token.as_deref(), org_slug.as_deref()).await; + track_patch_rollback_failed(&msg, &telemetry).await; emit_rollback_error(args.common.json, &msg); return 1; } @@ -1265,7 +1261,7 @@ pub async fn run(args: RollbackArgs) -> i32 { format!(" (to target a directory instead, use ./{id} or {id}/**)") }; let msg = format!("No patch found matching identifier: {id}{hint}"); - track_patch_rollback_failed(&msg, api_token.as_deref(), org_slug.as_deref()).await; + track_patch_rollback_failed(&msg, &telemetry).await; if args.common.json { println!( "{}", @@ -1344,7 +1340,7 @@ pub async fn run(args: RollbackArgs) -> i32 { identifier or an unscoped rollback)", unmatched.1 ); - track_patch_rollback_failed(&msg, api_token.as_deref(), org_slug.as_deref()).await; + track_patch_rollback_failed(&msg, &telemetry).await; emit_rollback_error(args.common.json, &msg); return 1; } @@ -1966,19 +1962,9 @@ pub async fn run(args: RollbackArgs) -> i32 { } if success { - track_patch_rolled_back( - rolled_back_count, - api_token.as_deref(), - org_slug.as_deref(), - ) - .await; + track_patch_rolled_back(rolled_back_count, &telemetry).await; } else { - track_patch_rollback_failed( - "One or more rollbacks failed", - api_token.as_deref(), - org_slug.as_deref(), - ) - .await; + track_patch_rollback_failed("One or more rollbacks failed", &telemetry).await; } if success { @@ -1988,7 +1974,7 @@ pub async fn run(args: RollbackArgs) -> i32 { } } Err(e) => { - track_patch_rollback_failed(&e, api_token.as_deref(), org_slug.as_deref()).await; + track_patch_rollback_failed(&e, &telemetry).await; if args.common.json { println!( "{}", diff --git a/crates/socket-patch-cli/src/commands/scan/discovery.rs b/crates/socket-patch-cli/src/commands/scan/discovery.rs index f8e6b467c..f1af9b291 100644 --- a/crates/socket-patch-cli/src/commands/scan/discovery.rs +++ b/crates/socket-patch-cli/src/commands/scan/discovery.rs @@ -1665,8 +1665,7 @@ mod tests { socket_patch_core::api::client::ApiClientOptions { api_url: uri.to_string(), api_token: None, - use_public_proxy: true, - org_slug: None, + route: socket_patch_core::api::client::ApiRoute::Proxy, }, ) } diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index 27ab8dc18..d98567c33 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -1305,7 +1305,7 @@ pub(crate) async fn run_redirect_selected( let mut vex_error: Option = None; let mut vex_code = 0; if vex.vex.is_some() && !common.dry_run { - let mut params = vex.to_build_params(); + let mut params = vex.to_build_params(Some(api_client)); // Hosted mode wrote only lockfiles and config files since scan's // crawl, never a directory the npm root walk descends into, so its // roots and packages still describe the tree (the snapshot checks diff --git a/crates/socket-patch-cli/src/commands/scan/hosted/vlt.rs b/crates/socket-patch-cli/src/commands/scan/hosted/vlt.rs index 79fed157d..3969e01bd 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted/vlt.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted/vlt.rs @@ -488,8 +488,7 @@ mod tests { socket_patch_core::api::client::ApiClientOptions { api_url: "http://127.0.0.1:9".into(), api_token: Some("secret".into()), - use_public_proxy: false, - org_slug: Some("org".into()), + route: socket_patch_core::api::client::ApiRoute::org("org"), }, ) } diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index 66df3be83..fe3ccbff9 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -17,7 +17,7 @@ use socket_patch_core::crawlers::ruby_crawler::config_path_ignored_warning; use socket_patch_core::crawlers::Ecosystem; use socket_patch_core::manifest::schema::PatchManifest; use socket_patch_core::telemetry::{ - spawn_patch_scan_failed, spawn_patch_scanned, PendingTelemetry, + spawn_patch_scan_failed, spawn_patch_scanned, PendingTelemetry, TelemetryAuth, }; use socket_patch_core::utils::composer_version::purl_identity_key; use socket_patch_core::utils::concurrent::{api_concurrency_for, ordered_concurrent}; @@ -349,6 +349,7 @@ pub(crate) use socket_patch_core::policy::package_spec_matches; async fn embed_vex_into_json( common: &GlobalArgs, vex_args: &VexEmbedArgs, + api_client: &ApiClient, manifest_path: &Path, base_code: i32, result: &mut serde_json::Value, @@ -366,7 +367,7 @@ async fn embed_vex_into_json( result["vex"] = serde_json::json!({ "skipped": true, "reason": "dry_run" }); return base_code; } - let mut params = vex_args.to_build_params(); + let mut params = vex_args.to_build_params(Some(api_client)); // A hosted scan that redirected nothing (empty catalog / no grants) // still attests older hosted gem pins: check them against the mirror. params.hosted_gem_mirror_check = hosted; @@ -426,6 +427,7 @@ pub(super) fn append_vex_error_warnings( async fn embed_vex_human( common: &GlobalArgs, vex_args: &VexEmbedArgs, + api_client: &ApiClient, manifest_path: &Path, base_code: i32, hosted: bool, @@ -443,7 +445,7 @@ async fn embed_vex_human( } return base_code; } - let mut params = vex_args.to_build_params(); + let mut params = vex_args.to_build_params(Some(api_client)); // A hosted scan that redirected nothing (empty catalog / no grants) // still attests older hosted gem pins: check them against the mirror. params.hosted_gem_mirror_check = hosted; @@ -1752,8 +1754,7 @@ async fn run_scan( // proxy keeps these chunk boundaries: every chunk is within the proxy's // body cap by construction (`BATCH_BODY_BYTE_CAP`). let batch_size = effective_batch_size(args.batch_size, use_public_proxy); - let telemetry_token = api_client.api_token().cloned(); - let telemetry_org = api_client.org_slug().cloned(); + let telemetry_auth = TelemetryAuth::for_client(&api_client); // Whether scan downgraded to the public proxy mid-run after a 401/403 // (reported in the `patch_scanned` telemetry event). let mut fallback_to_proxy = false; @@ -1795,6 +1796,17 @@ async fn run_scan( // Unsupported layouts and malformed binary Bun locks, kept on empty // scans too: an unreadable graph is not evidence of no dependencies. let mut layout_refusals = unsupported_layout_warnings(&lockfile_only.unsupported); + // A token whose org could not be resolved put the whole run on the + // public proxy (the client already warned on stderr): `--json` + // consumers get it on the same run-level `warnings[]` channel. + if args.common.json { + if let Some(reason) = api_client.org_unresolved() { + layout_refusals.push(( + crate::commands::vex_sources::NOTE_API_AUTH_FALLBACK.to_string(), + reason.to_string(), + )); + } + } // A committed `.bundle/config` whose BUNDLE_PATH resolves outside the // project, refused by the crawler's containment guard: surface it on // the same run-level channel as the layout refusals. @@ -2022,8 +2034,7 @@ async fn run_scan( .unwrap_or_default() .as_slice(), false, - telemetry_token.as_deref(), - telemetry_org.as_deref(), + &telemetry_auth, ); // The result prints right away: nothing to overlap the send with. telemetry.flush().await; @@ -2086,6 +2097,7 @@ async fn run_scan( let code = embed_vex_into_json( &args.common, &args.vex, + &api_client, &manifest_path, 0, &mut result, @@ -2108,7 +2120,15 @@ async fn run_scan( } policy.print_human(args.common.silent, args.common.verbose); } - return embed_vex_human(&args.common, &args.vex, &manifest_path, 0, hosted).await; + return embed_vex_human( + &args.common, + &args.vex, + &api_client, + &manifest_path, + 0, + hosted, + ) + .await; } // Build ecosystem summary @@ -2265,13 +2285,7 @@ async fn run_scan( if total_batches > 0 && batch_error_count == total_batches { status.finish(); let err = last_batch_error.unwrap_or_else(|| "all batches failed".to_string()); - spawn_patch_scan_failed( - telemetry, - &err, - fallback_to_proxy, - telemetry_token.as_deref(), - telemetry_org.as_deref(), - ); + spawn_patch_scan_failed(telemetry, &err, fallback_to_proxy, &telemetry_auth); // The failure prints right away: nothing to overlap the send with. telemetry.flush().await; if args.common.json { @@ -2340,8 +2354,7 @@ async fn run_scan( .unwrap_or_default() .as_slice(), fallback_to_proxy, - telemetry_token.as_deref(), - telemetry_org.as_deref(), + &telemetry_auth, ); let mut updates = detect_updates(update_manifest.as_deref(), &all_packages_with_patches); @@ -2617,8 +2630,7 @@ async fn run_scan( &scanned_purls, &vendored_purls, prune, - telemetry_token.as_deref(), - telemetry_org.as_deref(), + &telemetry_auth, telemetry, npm_crawl.as_ref(), ) @@ -2647,6 +2659,7 @@ async fn run_scan( let final_code = embed_vex_into_json( &args.common, &args.vex, + &api_client, &manifest_path, apply_code, &mut result, @@ -2668,6 +2681,7 @@ async fn run_scan( // (not vendored, which runs its own, nor hosted, which runs none), then // the embedded VEX. An early "nothing to apply" exit still runs the GC. let (args_ref, manifest_ref, socket_ref) = (&args, &manifest_path, &socket_dir); + let client_ref: &ApiClient = &api_client; let (scanned_ref, vendored_ref) = (&scanned_purls, &vendored_purls); let policy_ref: &ScanPolicy = &policy; let finish_human = move |code: i32| async move { @@ -2682,7 +2696,15 @@ async fn run_scan( ) .await; } - embed_vex_human(&args_ref.common, &args_ref.vex, manifest_ref, code, hosted).await + embed_vex_human( + &args_ref.common, + &args_ref.vex, + client_ref, + manifest_ref, + code, + hosted, + ) + .await }; // Every mode stops on an empty discovery, vendored included (restoring @@ -3105,7 +3127,15 @@ async fn run_scan( ) .await; } - return embed_vex_human(&args.common, &args.vex, &manifest_path, 0, hosted).await; + return embed_vex_human( + &args.common, + &args.vex, + &api_client, + &manifest_path, + 0, + hosted, + ) + .await; } // Vendor mode: pre-verify baselines so a content mismatch is reported @@ -3158,8 +3188,7 @@ async fn run_scan( &scanned_purls, &vendored_purls, prune, - telemetry_token.as_deref(), - telemetry_org.as_deref(), + &telemetry_auth, npm_crawl.as_ref(), ) .await @@ -3208,7 +3237,15 @@ async fn run_scan( .await; } - embed_vex_human(&args.common, &args.vex, &manifest_path, code, hosted).await + embed_vex_human( + &args.common, + &args.vex, + &api_client, + &manifest_path, + code, + hosted, + ) + .await } #[cfg(test)] diff --git a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs index 7240cd1f1..723419907 100644 --- a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs +++ b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs @@ -20,7 +20,7 @@ use socket_patch_core::api::client::ApiClient; use socket_patch_core::api::types::{BatchPackagePatches, PatchResponse, PatchSearchResult}; use socket_patch_core::manifest::operations::{read_manifest, write_manifest}; use socket_patch_core::manifest::schema::PatchRecord; -use socket_patch_core::telemetry::{track_patch_vendor_failed, PendingTelemetry}; +use socket_patch_core::telemetry::{track_patch_vendor_failed, PendingTelemetry, TelemetryAuth}; use socket_patch_core::utils::composer_version::composer_purls_equivalent; use socket_patch_core::utils::purl::strip_purl_qualifiers; use socket_patch_core::vendor::{load_state, lookup_entry, save_state, VendorState}; @@ -266,8 +266,7 @@ pub(crate) struct VendorStep<'a> { /// The download phase failed or refused some patch: the run exits 1 /// and its telemetry must not report a clean vendoring. pub(crate) download_errors: bool, - pub(crate) telemetry_token: Option<&'a str>, - pub(crate) telemetry_org: Option<&'a str>, + pub(crate) telemetry_auth: &'a TelemetryAuth, } /// The one vendored-apply entry of `scan --mode vendored` (JSON and @@ -296,8 +295,7 @@ async fn run_vendor_step(step: VendorStep<'_>) -> VendorStepResult { report_empty, prior, download_errors, - telemetry_token, - telemetry_org, + telemetry_auth, } = step; let outcome = vendor_under_lock( common, @@ -317,13 +315,12 @@ async fn run_vendor_step(step: VendorStep<'_>) -> VendorStepResult { download_errors || *vendor_errors, venv, common.dry_run, - telemetry_token, - telemetry_org, + telemetry_auth, ) .await } Err((_, message, _)) => { - track_patch_vendor_failed(message, common.dry_run, telemetry_token, telemetry_org).await + track_patch_vendor_failed(message, common.dry_run, telemetry_auth).await } } outcome.map(|(vendor_errors, venv)| (download_errors || vendor_errors, venv)) @@ -552,8 +549,7 @@ async fn run_vendor_json_path( scanned_purls: &HashSet, vendored_purls: &HashSet, prune: bool, - telemetry_token: Option<&str>, - telemetry_org: Option<&str>, + telemetry_auth: &TelemetryAuth, // Scan's pending telemetry, flushed by `discover_selected` before // anything below writes to stdout. telemetry: &mut PendingTelemetry, @@ -651,8 +647,7 @@ async fn run_vendor_json_path( report_empty: true, prior, download_errors: dl_code != 0, - telemetry_token, - telemetry_org, + telemetry_auth, }) .await { @@ -704,6 +699,7 @@ async fn run_vendor_json_path( let final_code = embed_vex_into_json( &args.common, &args.vex, + api_client, manifest_path, vendor_code, result, @@ -732,8 +728,7 @@ async fn run_vendor_interactive_path( scanned_purls: &HashSet, vendored_purls: &HashSet, prune: bool, - telemetry_token: Option<&str>, - telemetry_org: Option<&str>, + telemetry_auth: &TelemetryAuth, // The npm half of scan's crawl, for the vendor engine to reuse. prior: Option<&NpmCrawlSnapshot>, ) -> i32 { @@ -760,8 +755,7 @@ async fn run_vendor_interactive_path( report_empty: false, prior, download_errors: dl_code != 0, - telemetry_token, - telemetry_org, + telemetry_auth, }) .await { @@ -917,8 +911,7 @@ pub(super) fn boxed_vendor_json_path<'a>( scanned_purls: &'a HashSet, vendored_purls: &'a HashSet, prune: bool, - telemetry_token: Option<&'a str>, - telemetry_org: Option<&'a str>, + telemetry_auth: &'a TelemetryAuth, telemetry: &'a mut PendingTelemetry, prior: Option<&'a NpmCrawlSnapshot>, ) -> std::pin::Pin + 'a>> { @@ -938,8 +931,7 @@ pub(super) fn boxed_vendor_json_path<'a>( scanned_purls, vendored_purls, prune, - telemetry_token, - telemetry_org, + telemetry_auth, telemetry, prior, )) @@ -960,8 +952,7 @@ pub(super) fn boxed_vendor_interactive_path<'a>( scanned_purls: &'a HashSet, vendored_purls: &'a HashSet, prune: bool, - telemetry_token: Option<&'a str>, - telemetry_org: Option<&'a str>, + telemetry_auth: &'a TelemetryAuth, prior: Option<&'a NpmCrawlSnapshot>, ) -> std::pin::Pin + 'a>> { Box::pin(run_vendor_interactive_path( @@ -976,8 +967,7 @@ pub(super) fn boxed_vendor_interactive_path<'a>( scanned_purls, vendored_purls, prune, - telemetry_token, - telemetry_org, + telemetry_auth, prior, )) } diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index 2590c2673..43be0c079 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -27,7 +27,9 @@ use socket_patch_core::manifest::operations::{read_manifest, write_manifest}; use socket_patch_core::manifest::schema::{PatchManifest, PatchRecord}; use socket_patch_core::patch::apply::{verify_file_patch, PatchSources}; use socket_patch_core::patch::redirect::upstream::HostedPin; -use socket_patch_core::telemetry::{track_patch_vendor_failed, track_patch_vendored}; +use socket_patch_core::telemetry::{ + track_patch_vendor_failed, track_patch_vendored, TelemetryAuth, +}; use socket_patch_core::utils::composer_version::composer_purls_equivalent; use socket_patch_core::utils::concurrent::ordered_concurrent; use socket_patch_core::utils::group_commit::{CommittedFile, GroupCommit}; @@ -953,7 +955,7 @@ pub async fn run(args: VendorArgs) -> i32 { } let vex_result = match args.vex.vex.as_ref() { Some(_) if !args.common.dry_run => { - let params = args.vex.to_build_params(); + let params = args.vex.to_build_params(None); Some(generate_vex_without_manifest(&args.common, ¶ms, &manifest_path).await) } _ => None, @@ -1025,11 +1027,11 @@ pub async fn run(args: VendorArgs) -> i32 { } else { let (client, use_public_proxy) = get_api_client_with_overrides(args.common.api_client_overrides()).await; - let telemetry_ids = (client.api_token().cloned(), client.org_slug().cloned()); + let telemetry = TelemetryAuth::for_client(&client); Some(( args.common .vendor_service_config(Some(client), use_public_proxy), - telemetry_ids, + telemetry, )) }; @@ -1075,7 +1077,11 @@ pub async fn run(args: VendorArgs) -> i32 { ); } } else { - let params = args.vex.to_build_params(); + let params = args.vex.to_build_params( + vendor_service + .as_ref() + .and_then(|(svc, _)| svc.client.as_ref()), + ); match generate_vex_from_manifest_path(&args.common, ¶ms, &manifest_path).await { Ok(summary) => { env.vex = Some(VexSummary { @@ -1115,15 +1121,8 @@ pub async fn run(args: VendorArgs) -> i32 { println!("{}", env.to_pretty_json()); } - if let Some((_, (api_token, org_slug))) = &vendor_service { - track_outcomes_for_vendor( - exit != 0, - &env, - args.common.dry_run, - api_token.as_deref(), - org_slug.as_deref(), - ) - .await; + if let Some((_, telemetry)) = &vendor_service { + track_outcomes_for_vendor(exit != 0, &env, args.common.dry_run, telemetry).await; } exit @@ -1607,7 +1606,7 @@ async fn run_eject(args: &VendorArgs, pins: Vec) -> i32 { let common = &args.common; let (client, use_public_proxy) = get_api_client_with_overrides(common.api_client_overrides()).await; - let (api_token, org_slug) = (client.api_token().cloned(), client.org_slug().cloned()); + let telemetry = TelemetryAuth::for_client(&client); if !common.json && !common.silent { println!( "{} {} into .socket/vendor/...", @@ -1674,14 +1673,7 @@ async fn run_eject(args: &VendorArgs, pins: Vec) -> i32 { if common.json { println!("{}", env.to_pretty_json()); } - track_outcomes_for_vendor( - true, - &env, - common.dry_run, - api_token.as_deref(), - org_slug.as_deref(), - ) - .await; + track_outcomes_for_vendor(true, &env, common.dry_run, &telemetry).await; return 1; } @@ -1736,14 +1728,7 @@ async fn run_eject(args: &VendorArgs, pins: Vec) -> i32 { if common.json { println!("{}", env.to_pretty_json()); } - track_outcomes_for_vendor( - true, - &env, - common.dry_run, - api_token.as_deref(), - org_slug.as_deref(), - ) - .await; + track_outcomes_for_vendor(true, &env, common.dry_run, &telemetry).await; return 1; } @@ -1779,8 +1764,7 @@ async fn run_eject(args: &VendorArgs, pins: Vec) -> i32 { if common.json { println!("{}", env.to_pretty_json()); } - track_outcomes_for_vendor(false, &env, true, api_token.as_deref(), org_slug.as_deref()) - .await; + track_outcomes_for_vendor(false, &env, true, &telemetry).await; return 0; } @@ -1941,7 +1925,7 @@ async fn run_eject(args: &VendorArgs, pins: Vec) -> i32 { ); } } else { - let params = args.vex.to_build_params(); + let params = args.vex.to_build_params(Some(&client)); let manifest_path = common.resolved_manifest_path(); match generate_vex_without_manifest(common, ¶ms, &manifest_path).await { ManifestlessVex::Written(summary) => { @@ -1974,14 +1958,7 @@ async fn run_eject(args: &VendorArgs, pins: Vec) -> i32 { if common.json { println!("{}", env.to_pretty_json()); } - track_outcomes_for_vendor( - exit != 0, - &env, - common.dry_run, - api_token.as_deref(), - org_slug.as_deref(), - ) - .await; + track_outcomes_for_vendor(exit != 0, &env, common.dry_run, &telemetry).await; exit } @@ -2017,13 +1994,12 @@ pub(crate) async fn track_outcomes_for_vendor( has_errors: bool, env: &Envelope, dry_run: bool, - token: Option<&str>, - org: Option<&str>, + telemetry: &TelemetryAuth, ) { if has_errors { - track_patch_vendor_failed("vendor completed with failures", dry_run, token, org).await; + track_patch_vendor_failed("vendor completed with failures", dry_run, telemetry).await; } else { - track_patch_vendored(env.summary.applied, dry_run, token, org).await; + track_patch_vendored(env.summary.applied, dry_run, telemetry).await; } } diff --git a/crates/socket-patch-cli/src/commands/vendored_backend/repair.rs b/crates/socket-patch-cli/src/commands/vendored_backend/repair.rs index 464feec93..4415dcf1f 100644 --- a/crates/socket-patch-cli/src/commands/vendored_backend/repair.rs +++ b/crates/socket-patch-cli/src/commands/vendored_backend/repair.rs @@ -227,11 +227,12 @@ pub(crate) struct RepairRequest<'a> { /// The caller's one `load_state` outcome (under the same lock). An /// unreadable ledger fails this phase loudly (`vendor_state_unreadable`). pub(crate) ledger: std::io::Result, - /// The run's API client when the caller already built one: the uuid - /// lookups and the re-vendor reuse it instead of constructing another - /// (and re-printing its token advisory); `None` builds lazily on first - /// need. - pub(crate) client: Option<&'a ApiClient>, + /// The run's API client slot: the uuid lookups and the re-vendor reuse + /// a client the caller already built instead of constructing another + /// (and resolving the org again); when it is `None` the phase builds one + /// lazily on first need and leaves it here, so the caller (telemetry) + /// reuses it too. + pub(crate) client: &'a mut Option, } impl VendoredBackend<'_> { @@ -274,9 +275,9 @@ impl VendoredBackend<'_> { } }; - // The one API client of this phase (and its one-time token-shape - // stderr advisory), seeded from the run's client when there is one. - let mut api_client: Option = req.client.cloned(); + // The run's one API client (and its one-time token-shape stderr + // advisory): the caller's, or built here on first need. + let api_client: &mut Option = req.client; let mut candidates: Vec = Vec::new(); // ── Health check: every in-scope ledger entry ──────────────────── @@ -314,7 +315,7 @@ impl VendoredBackend<'_> { // view from the API. (_, Some(r), None) => r.clone(), (_, None, None) => { - match fetch_record_by_uuid(common, &mut api_client, &entry.uuid).await { + match fetch_record_by_uuid(common, api_client, &entry.uuid).await { Some((_, r)) => r, None => { fail( @@ -655,7 +656,7 @@ impl VendoredBackend<'_> { } if api_client.is_none() && !common.offline { - api_client = Some( + *api_client = Some( get_api_client_with_overrides(common.api_client_overrides()) .await .0, @@ -664,7 +665,7 @@ impl VendoredBackend<'_> { let use_public_proxy = api_client .as_ref() .is_some_and(ApiClient::uses_public_proxy); - let service = common.vendor_service_config(api_client, use_public_proxy); + let service = common.vendor_service_config(api_client.clone(), use_public_proxy); for mut candidate in candidates { match vendor::redownload::restore( &common.cwd, diff --git a/crates/socket-patch-cli/src/commands/vex.rs b/crates/socket-patch-cli/src/commands/vex.rs index 6fbc6a594..133e7001e 100644 --- a/crates/socket-patch-cli/src/commands/vex.rs +++ b/crates/socket-patch-cli/src/commands/vex.rs @@ -20,8 +20,9 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; use clap::Args; +use socket_patch_core::api::client::ApiClient; use socket_patch_core::manifest::schema::{PatchManifest, PatchRecord}; -use socket_patch_core::telemetry::{track_vex_failed, track_vex_generated}; +use socket_patch_core::telemetry::{track_vex_failed, track_vex_generated, TelemetryAuth}; use socket_patch_core::vex::{ build_document, detect_product, BuildOptions, Document, FailedPatch, VendorContext, VerifyOutcome, @@ -29,8 +30,8 @@ use socket_patch_core::vex::{ use crate::args::{apply_env_toggles, parse_bool_flag, GlobalArgs}; use crate::commands::vex_sources::{ - self, Plan, Sources, RECORD_MISMATCH, RECORD_UNAVAILABLE, REDIRECT_UNWIRED, VENDOR_UNWIRED, - WIRING_CONFLICT, + self, Plan, RunApiClient, Sources, RECORD_MISMATCH, RECORD_UNAVAILABLE, REDIRECT_UNWIRED, + VENDOR_UNWIRED, WIRING_CONFLICT, }; use crate::ecosystem_dispatch::{find_manifest_package_copies_reusing, JvmScope}; use crate::json_envelope::{Command, Envelope, EnvelopeError, PatchAction, PatchEvent, RunWarning}; @@ -154,8 +155,13 @@ impl VexEmbedArgs { /// Build the core [`VexBuildParams`] from the embedded flags. The /// output is always the `--vex` path (embedded VEX never writes to /// stdout). Caller must have checked `self.vex.is_some()`. - pub(crate) fn to_build_params(&self) -> VexBuildParams { + /// + /// `api_client` is the host run's client when it built one: the VEX + /// plan then fetches records and reports telemetry on the run's route + /// instead of resolving the org again. `None` builds one on first need. + pub(crate) fn to_build_params(&self, api_client: Option<&ApiClient>) -> VexBuildParams { VexBuildParams { + api_client: RunApiClient::new_with(api_client.cloned()), output: self.vex.clone(), product: self.vex_product.clone(), no_verify: self.vex_no_verify, @@ -229,6 +235,22 @@ pub(crate) struct VexBuildParams { /// records (the post-install standalone `vex` fetches them from the /// API instead). Empty everywhere else. pub hosted_records: std::collections::BTreeMap, + /// The run's API client (see [`RunApiClient`]): seeded by a host + /// command, else built at most once when the plan must fetch records. + /// Telemetry reads its route when it exists. + pub api_client: RunApiClient, +} + +impl VexBuildParams { + /// Where this run's `vex_*` telemetry goes: the run client's route when + /// one was built, else the no-client route from the credential chain + /// (flag / env / socket-cli config), with no network. + fn telemetry(&self, common: &GlobalArgs) -> TelemetryAuth { + match self.api_client.get() { + Some(client) => TelemetryAuth::for_client(client), + None => common.telemetry_auth(), + } + } } /// Successful result of [`generate_vex`]. @@ -350,6 +372,7 @@ pub async fn run(args: VexArgs) -> i32 { product_flag: "--product", npm_prior: None, hosted_records: Default::default(), + api_client: RunApiClient::new(), }; let manifest_path = args.common.resolved_manifest_path(); @@ -536,7 +559,7 @@ async fn generate_vex( let redirected: &[String] = &plan.redirected; let product_id = match resolve_product_id(common, params.product.as_deref(), warnings).await { Ok(id) => id, - Err(reason) => return Err(fail(common, "product_undetected", reason).await), + Err(reason) => return Err(fail(common, params, "product_undetected", reason).await), }; // The help text promises "PURL/identifier", so an arbitrary string is @@ -844,8 +867,7 @@ async fn generate_vex( ) { Some(doc) => doc, None => { - let (token, org) = common.telemetry_credentials(); - track_vex_failed("no_applicable_patches", token.as_deref(), org.as_deref()).await; + track_vex_failed("no_applicable_patches", ¶ms.telemetry(common)).await; let message = "No applied patches with vulnerability metadata to attest.".to_string(); return Err(VexGenError { code: "no_applicable_patches", @@ -862,7 +884,7 @@ async fn generate_vex( serde_json::to_string_pretty(&doc) } { Ok(s) => s, - Err(e) => return Err(fail(common, "serialize_failed", e.to_string()).await), + Err(e) => return Err(fail(common, params, "serialize_failed", e.to_string()).await), }; // Write. The file gets the same trailing newline `println!` gives the @@ -874,6 +896,7 @@ async fn generate_vex( // The raw io::Error names neither the file nor the operation. return Err(fail( common, + params, "write_failed", format!("Failed to write VEX document to {}: {e}", path.display()), ) @@ -887,7 +910,6 @@ async fn generate_vex( } }; - let (token, org) = common.telemetry_credentials(); track_vex_generated( doc.statements.len(), "openvex-0.2.0", @@ -896,8 +918,7 @@ async fn generate_vex( } else { "stdout" }, - token.as_deref(), - org.as_deref(), + ¶ms.telemetry(common), ) .await; @@ -1260,7 +1281,7 @@ async fn generate_vex_from_manifest_path_inner( // Core's text ("Failed to parse manifest JSON: ...") does not // say which file; in a workspace that matters. let message = format!("{e} (in {})", manifest_path.display()); - return Err(fail(common, "manifest_unreadable", message).await); + return Err(fail(common, params, "manifest_unreadable", message).await); } }; let had_manifest_file = manifest_file.is_some(); @@ -1297,7 +1318,7 @@ async fn generate_vex_from_manifest_path_inner( view. Restore it from version control or re-run `socket-patch vendor`.", socket_patch_core::vendor::VENDOR_STATE_REL ); - return Err(fail(common, "vendor_ledger_corrupt", message).await); + return Err(fail(common, params, "vendor_ledger_corrupt", message).await); } }; // Rooted where the ledgers are (`--cwd`), and run under `--global` / @@ -1343,28 +1364,34 @@ async fn generate_vex_from_manifest_path_inner( warnings: Vec::new(), } } else { - fail(common, "manifest_not_found", message).await + fail(common, params, "manifest_not_found", message).await }); } return Err(fail( common, + params, "no_patches", "Manifest is empty — nothing to attest.".to_string(), ) .await); } - let plan = vex_sources::plan(common, sources, ¶ms.assume_applied).await; + let plan = vex_sources::plan(common, sources, ¶ms.assume_applied, ¶ms.api_client).await; generate_vex(common, params, plan, warnings).await } /// Fire `vex_failed` telemetry and build the matching [`VexGenError`]. /// Centralizes the "track then return error" pattern in [`generate_vex`]. -/// Attribution goes through the same layered credential chain as -/// `list` (flag / env / socket-cli `config.json`), not the raw -/// flags — a `socket login`-only user must not report anonymously. -async fn fail(common: &GlobalArgs, code: &'static str, message: String) -> VexGenError { - let (token, org) = common.telemetry_credentials(); - track_vex_failed(code, token.as_deref(), org.as_deref()).await; +/// Attribution follows the run's client when one exists, else the same +/// layered credential chain as `list` (flag / env / socket-cli +/// `config.json`), not the raw flags — a `socket login`-only user must not +/// report anonymously. See [`VexBuildParams::telemetry`]. +async fn fail( + common: &GlobalArgs, + params: &VexBuildParams, + code: &'static str, + message: String, +) -> VexGenError { + track_vex_failed(code, ¶ms.telemetry(common)).await; VexGenError { code, message, @@ -2010,6 +2037,7 @@ mod npm_prior_tests { product_flag: "--vex-product", npm_prior: prior, hosted_records: Default::default(), + api_client: RunApiClient::new(), }; let manifest_path = common.resolved_manifest_path(); match generate_vex_from_manifest_path(common, ¶ms, &manifest_path).await { @@ -2144,7 +2172,7 @@ mod mirror_refusal_tests { vex_no_verify: no_verify, ..Default::default() } - .to_build_params(); + .to_build_params(None); params.hosted_gem_mirror_refused = refused; // Even a supplied assumption cannot revive the refused pin; // qualifier-insensitive exemption matching remains intact. @@ -2265,7 +2293,7 @@ mod mirror_refusal_tests { vex_no_verify: true, ..Default::default() } - .to_build_params(); + .to_build_params(None); // No candidate refused anything this run: only the plan check. params.hosted_gem_mirror_check = check; let summary = generate_vex(&common, ¶ms, plan, &mut Vec::new()) diff --git a/crates/socket-patch-cli/src/commands/vex_sources.rs b/crates/socket-patch-cli/src/commands/vex_sources.rs index 370dc1cee..d7d4f5431 100644 --- a/crates/socket-patch-cli/src/commands/vex_sources.rs +++ b/crates/socket-patch-cli/src/commands/vex_sources.rs @@ -73,7 +73,7 @@ use futures_util::StreamExt; use socket_patch_core::api::client::{ build_proxy_fallback_client, get_api_client_with_overrides, hold_back_debug, - is_fallback_candidate, + is_fallback_candidate, ApiClient, }; use socket_patch_core::manifest::schema::{PatchManifest, PatchRecord}; use socket_patch_core::patch::redirect::RedirectState; @@ -265,10 +265,22 @@ impl Cand { } } +/// The run's API client, built at most once: a host command (`scan`, +/// `apply`, `vendor`) seeds it with the client it already built, so +/// embedded `--vex` reuses the run's one org resolution; standalone `vex` +/// leaves it empty and [`fetch_records`] builds it on first need. +pub(crate) type RunApiClient = tokio::sync::OnceCell; + /// Merge `sources` into a verified-input [`Plan`]. `assume_live` is the /// in-run `scan --mode hosted --vex` confirmed set (qualifier-insensitive): /// those ledger records were just proven wired by the run itself. -pub(crate) async fn plan(common: &GlobalArgs, sources: Sources, assume_live: &[String]) -> Plan { +/// `api_client` is the run's client (see [`RunApiClient`]). +pub(crate) async fn plan( + common: &GlobalArgs, + sources: Sources, + assume_live: &[String], + api_client: &RunApiClient, +) -> Plan { let root = common.cwd.as_path(); let Sources { manifest, @@ -485,7 +497,7 @@ pub(crate) async fn plan(common: &GlobalArgs, sources: Sources, assume_live: &[S u.dedup(); u }; - let fetched = fetch_records(common, &uuids, &mut notes).await; + let fetched = fetch_records(common, api_client, &uuids, &mut notes).await; let mut mismatched: HashSet = HashSet::new(); for &i in &need_api { let cand = &mut based[i].0; @@ -902,6 +914,7 @@ fn local_record_by_uuid( /// (the caller omits those references as `record_unavailable`) and noted. async fn fetch_records( common: &GlobalArgs, + api_client: &RunApiClient, uuids: &[String], notes: &mut Vec, ) -> HashMap { @@ -923,7 +936,12 @@ async fn fetch_records( return out; } let overrides = common.api_client_overrides(); - let (mut client, mut use_public_proxy) = get_api_client_with_overrides(overrides.clone()).await; + // The run's client: the host's, or built here once (standalone `vex`). + let mut client = api_client + .get_or_init(|| async { get_api_client_with_overrides(overrides.clone()).await.0 }) + .await + .clone(); + let mut use_public_proxy = client.uses_public_proxy(); let mut pending: Vec = uuids.to_vec(); // Each view is a heavy response: say what the run is waiting on (a live // line only on a terminal, never under --json / --silent; the VEX @@ -1140,7 +1158,13 @@ mod tests { ..GlobalArgs::default() }; let mut notes: Vec = Vec::new(); - let out = fetch_records(&common, &[U1.to_string(), U2.to_string()], &mut notes).await; + let out = fetch_records( + &common, + &RunApiClient::new(), + &[U1.to_string(), U2.to_string()], + &mut notes, + ) + .await; let fallback = notes .iter() @@ -1210,7 +1234,7 @@ mod tests { redirect: Some(redirect), discovery: discovery(vec![hosted_ref("pkg:npm/x@1.0.0", U2, true)]), }; - let plan = plan(&common(tmp.path()), sources, &[]).await; + let plan = plan(&common(tmp.path()), sources, &[], &RunApiClient::new()).await; assert_eq!(plan.view.patches["pkg:npm/x@1.0.0"].uuid, U2); assert_eq!(plan.redirected, vec!["pkg:npm/x@1.0.0".to_string()]); assert!(plan.lockfile_basis.contains("pkg:npm/x@1.0.0")); @@ -1233,7 +1257,7 @@ mod tests { redirect: None, discovery: discovery(vec![hosted_ref("pkg:npm/x@1.0.0", U2, true)]), }; - let plan = plan(&common(tmp.path()), sources, &[]).await; + let plan = plan(&common(tmp.path()), sources, &[], &RunApiClient::new()).await; assert!( plan.view.patches.is_empty(), "{:?}", @@ -1268,7 +1292,7 @@ mod tests { redirect: Some(redirect), discovery: discovery(vec![hosted_ref("pkg:npm/x@1.0.0", U1, true)]), }; - let plan = plan(&common(tmp.path()), sources, &[]).await; + let plan = plan(&common(tmp.path()), sources, &[], &RunApiClient::new()).await; assert!(plan .gated .contains(&failed("pkg:npm/x@1.0.0", RECORD_MISMATCH))); @@ -1310,7 +1334,7 @@ mod tests { hosted_ref("pkg:npm/y@1.0.0", U2, true), ]), }; - let plan = plan(&common(tmp.path()), sources, &[]).await; + let plan = plan(&common(tmp.path()), sources, &[], &RunApiClient::new()).await; assert_eq!(plan.gated, vec![failed("pkg:npm/x@1.0.0", WIRING_CONFLICT)]); assert!(!plan.view.patches.contains_key("pkg:npm/x@1.0.0")); assert!(!plan.redirected.contains(&"pkg:npm/x@1.0.0".to_string())); @@ -1364,13 +1388,19 @@ mod tests { redirect: Some(redirect.clone()), discovery: Discovery::default(), }; - let live = plan(&common(tmp.path()), mk(), &[key.to_string()]).await; + let live = plan( + &common(tmp.path()), + mk(), + &[key.to_string()], + &RunApiClient::new(), + ) + .await; assert_eq!(live.view.patches[key].uuid, U2); assert_eq!(live.redirected, vec![key.to_string()]); assert!(live.vendor_entries.is_empty()); assert!(live.gated.is_empty(), "{:?}", live.gated); - let dead = plan(&common(tmp.path()), mk(), &[]).await; + let dead = plan(&common(tmp.path()), mk(), &[], &RunApiClient::new()).await; assert!(dead.view.patches.is_empty()); assert_eq!(dead.gated, vec![failed(key, VENDOR_UNWIRED)]); } @@ -1460,7 +1490,13 @@ mod tests { // Control — nothing recognized: the ledgers' own recorded files // decide, and both claims are live. - let live = plan(&common(root), sources(Discovery::default()), &[]).await; + let live = plan( + &common(root), + sources(Discovery::default()), + &[], + &RunApiClient::new(), + ) + .await; assert!(live.gated.is_empty(), "{:?}", live.gated); assert!(live.view.patches.contains_key("pkg:npm/x@1.0.0")); assert_eq!(live.redirected, vec!["pkg:cargo/y@1.0.0".to_string()]); @@ -1488,7 +1524,7 @@ mod tests { }], ..Discovery::default() }; - let dead = plan(&common(root), sources(rejected), &[]).await; + let dead = plan(&common(root), sources(rejected), &[], &RunApiClient::new()).await; assert!( dead.view.patches.is_empty(), "{:?}", @@ -1536,7 +1572,7 @@ mod tests { redirect: Some(redirect), discovery: discovery(vec![hosted_ref("pkg:npm/x@1.0.0", U1, true)]), }; - let plan = plan(&common(tmp.path()), sources, &[]).await; + let plan = plan(&common(tmp.path()), sources, &[], &RunApiClient::new()).await; let wiring = &plan.hosted["pkg:npm/x@1.0.0"]; assert_eq!(wiring.uuid, U1); assert_eq!(wiring.refs.len(), 1); @@ -1566,7 +1602,13 @@ mod tests { redirect: Some(redirect.clone()), discovery, }; - let gated = plan(&common(tmp.path()), sources(found), &[]).await; + let gated = plan( + &common(tmp.path()), + sources(found), + &[], + &RunApiClient::new(), + ) + .await; assert!(gated.view.patches.is_empty() && gated.hosted.is_empty()); assert_eq!(gated.gated, vec![failed(PURL, NOTE_LOCK_ABOVE_BASE)]); assert!( @@ -1579,6 +1621,7 @@ mod tests { &common(tmp.path()), sources(discovery(vec![hosted_ref(PURL, U1, true)])), &[], + &RunApiClient::new(), ) .await; assert!(live.gated.is_empty(), "{:?}", live.gated); @@ -1615,6 +1658,7 @@ mod tests { &common(tmp.path()), sources, &["pkg:pypi/confirmed@1.0".to_string()], + &RunApiClient::new(), ) .await; assert!(plan.view.patches.contains_key("pkg:npm/owned@1.0.0")); @@ -1686,7 +1730,7 @@ mod tests { // not a discovery input: the ledger fallback decides. discovery: Discovery::default(), }; - let plan = plan(&common(root), sources, &[]).await; + let plan = plan(&common(root), sources, &[], &RunApiClient::new()).await; if gemfile_wired { assert_eq!(plan.redirected, vec![purl.to_string()], "{gemfile}"); assert!(plan.gated.is_empty(), "{gemfile}: {:?}", plan.gated); diff --git a/crates/socket-patch-cli/tests/cli/covgap_api_client.rs b/crates/socket-patch-cli/tests/cli/covgap_api_client.rs index 99ccb75fd..5cf2f5ffa 100644 --- a/crates/socket-patch-cli/tests/cli/covgap_api_client.rs +++ b/crates/socket-patch-cli/tests/cli/covgap_api_client.rs @@ -33,7 +33,8 @@ fn json_stdout(out: &std::process::Output) -> serde_json::Value { /// Run `get --save-only --yes` plus `extra` flags with a hash-shaped /// `--api-token` (the dashboard's stored `sha512-...` value) and no `--org`, /// against a fresh mock that 401s org auto-resolution exactly once and -/// 404s the slug-less authenticated view route exactly once. +/// 404s the public proxy's view route exactly once (the failed resolution +/// puts the whole run on the proxy; no `/v0/orgs/` route is ever hit). async fn run_get_with_hash_shaped_token(extra: &[&str]) -> std::process::Output { let mock = MockServer::start().await; // Org auto-resolution: exactly one 401. `.expect(1)` proves the @@ -44,14 +45,20 @@ async fn run_get_with_hash_shaped_token(extra: &[&str]) -> std::process::Output .expect(1) .mount(&mock) .await; - // After failed resolution the slug is unset → the view route falls back - // to the `default` slug segment; a 404 there is a graceful not-found. + // After failed resolution the run is on the public proxy (here the same + // mock, via --proxy-url): a 404 on its view route is a graceful + // not-found. No org-scoped route may be queried. Mock::given(method("GET")) - .and(path(format!("/v0/orgs/default/patches/view/{UUID}"))) + .and(path(format!("/patch/view/{UUID}"))) .respond_with(ResponseTemplate::new(404)) .expect(1) .mount(&mock) .await; + Mock::given(wiremock::matchers::path_regex("^/v0/orgs/")) + .respond_with(ResponseTemplate::new(500)) + .expect(0) + .mount(&mock) + .await; let tmp = tempfile::tempdir().unwrap(); let uri = mock.uri(); @@ -82,14 +89,21 @@ async fn run_get_with_hash_shaped_token(extra: &[&str]) -> std::process::Output } /// The warning text both output modes must print for the 401: the -/// "Could not auto-detect organization" warning WITH the stored-hash hint +/// "Could not determine your organization" warning WITH the stored-hash hint /// naming the `sha512-` prefix and the raw `sktsec_..._api` shape, plus /// the pre-flight token-shape warning. fn assert_hash_token_warnings(stderr: &str, mode: &str) { assert!( - stderr.contains("Warning: Could not auto-detect organization"), + stderr.contains("Warning: Could not determine your organization"), "[{mode}] the failed resolution must warn; stderr={stderr}" ); + assert!( + stderr.contains( + "using the public patch API proxy (free patches only). \ + Pass --org or set SOCKET_ORG_SLUG." + ), + "[{mode}] the warning must say what the run does and how to fix it; stderr={stderr}" + ); assert!( stderr.contains("Hint: --api-token starts with `sha512-`"), "[{mode}] the 401 + hash-shaped token must trigger the stored-hash \ @@ -110,8 +124,8 @@ fn assert_hash_token_warnings(stderr: &str, mode: &str) { } /// Human mode: the 401 produces the stored-hash hint on stderr, and the -/// command degrades gracefully (slug-less authenticated fetch → 404 → -/// not found, exit 0) instead of crashing. +/// command degrades gracefully (proxy fetch → 404 → not found, exit 0) +/// instead of crashing. #[tokio::test] async fn get_with_hash_shaped_token_prints_stored_hash_hint_on_401() { let out = run_get_with_hash_shaped_token(&[]).await; @@ -153,7 +167,7 @@ async fn get_with_hash_shaped_token_under_silent_prints_no_warnings() { let out = run_get_with_hash_shaped_token(&["--json", "--silent"]).await; let stderr = String::from_utf8_lossy(&out.stderr); assert!( - !stderr.contains("Could not auto-detect organization"), + !stderr.contains("Could not determine your organization"), "--silent must mute the org auto-detect warning; stderr={stderr}" ); assert!( @@ -163,3 +177,156 @@ async fn get_with_hash_shaped_token_under_silent_prints_no_warnings() { assert_eq!(out.status.code(), Some(0), "stderr={stderr}"); assert_eq!(json_stdout(&out)["status"], "not_found"); } + +/// #648: a token whose org cannot be resolved (here `/v0/organizations` +/// answers 500) puts the WHOLE run on the public proxy, decided once. +/// `scan --json --vex` on a lockfile-only checkout with a hosted pin runs +/// the batch search, the embedded VEX record fetch and telemetry: every +/// request goes to `/patch/*`, none to `/v0/orgs/`, the org is resolved +/// exactly once (the embedded VEX reuses scan's client instead of building +/// another), and `--json` reports the downgrade in `warnings[]`. +#[tokio::test] +async fn unresolved_org_routes_the_whole_scan_vex_run_to_the_proxy_once() { + const PATCH: &str = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"; + let mock = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v0/organizations")) + .respond_with(ResponseTemplate::new(500).set_body_string("boom")) + .expect(1) + .mount(&mock) + .await; + Mock::given(wiremock::matchers::path_regex("^/v0/orgs/")) + .respond_with(ResponseTemplate::new(500)) + .expect(0) + .mount(&mock) + .await; + Mock::given(method("POST")) + .and(path("/patch/batch")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "packages": [], + "canAccessPaidPatches": false, + }))) + .mount(&mock) + .await; + Mock::given(method("GET")) + .and(path(format!("/patch/view/{PATCH}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "uuid": PATCH, + "purl": "pkg:npm/left-pad@1.3.0", + "publishedAt": "Fri, 27 Mar 2026 00:00:00 GMT", + "files": { "package/index.js": { + "beforeHash": "a".repeat(64), "afterHash": "b".repeat(64) + } }, + "vulnerabilities": { "GHSA-org-once": { + "cves": ["CVE-2026-41"], "summary": "s", "severity": "high", "description": "d" + } }, + "description": "hosted patch", + "license": "MIT", + "tier": "free", + }))) + .mount(&mock) + .await; + Mock::given(method("POST")) + .and(path("/patch/telemetry")) + .respond_with(ResponseTemplate::new(201)) + .mount(&mock) + .await; + + // Lockfile-only checkout pinned to a hosted patch: the embedded VEX + // must fetch this record from the patch API. + let tmp = tempfile::tempdir().unwrap(); + let cwd = tmp.path(); + std::fs::write( + cwd.join("package-lock.json"), + serde_json::json!({ + "name": "app", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { "name": "app", "version": "1.0.0" }, + "node_modules/left-pad": { + "version": "1.3.0", + "resolved": format!( + "https://patch.socket.dev/patch/npm/left-pad/1.3.0/\ + 11111111-2222-4333-8444-555555555555/{PATCH}/left-pad-1.3.0.tgz" + ), + "integrity": "sha512-UEFUQ0hFRHBhdGNoZWRQQVRDSEVEcGF0Y2hlZA==", + }, + }, + }) + .to_string(), + ) + .unwrap(); + let vex_path = cwd.join("out.vex.json"); + let uri = mock.uri(); + let token = format!("sktsec_{}_api", "x".repeat(44)); + let mut cmd = Command::new(binary()); + for (key, _) in std::env::vars() { + if key.starts_with("SOCKET_") { + cmd.env_remove(key); + } + } + let out = cmd + .args([ + "scan", + "--json", + "--cwd", + cwd.to_str().unwrap(), + "--vex", + vex_path.to_str().unwrap(), + "--vex-product", + "pkg:generic/app@1.0.0", + "--api-url", + &uri, + "--proxy-url", + &uri, + "--api-token", + &token, + ]) + .env("SOCKET_NO_CONFIG", "1") + .current_dir(cwd) + .output() + .expect("run socket-patch scan"); + let stderr = String::from_utf8_lossy(&out.stderr); + let v = json_stdout(&out); + + let fallback = v["warnings"] + .as_array() + .and_then(|w| w.iter().find(|w| w["code"] == "api_auth_fallback")) + .unwrap_or_else(|| panic!("no api_auth_fallback warning: {v}; stderr={stderr}")); + let detail = fallback["detail"].as_str().unwrap(); + assert!( + detail.contains("Pass --org or set SOCKET_ORG_SLUG"), + "the warning says how to fix it: {detail}" + ); + + let requests = mock.received_requests().await.unwrap(); + let paths: Vec = requests.iter().map(|r| r.url.path().to_string()).collect(); + assert_eq!( + paths.iter().filter(|p| *p == "/v0/organizations").count(), + 1, + "the org is resolved once per run: {paths:?}" + ); + assert!( + paths + .iter() + .all(|p| p == "/v0/organizations" || p.starts_with("/patch/")), + "every other call goes to the proxy: {paths:?}" + ); + assert!( + paths.iter().any(|p| p == "/patch/batch"), + "the scan searched on the proxy: {paths:?}" + ); + assert!( + paths.iter().any(|p| *p == format!("/patch/view/{PATCH}")), + "the embedded VEX fetched its record on the proxy: {paths:?}; v={v}; stderr={stderr}" + ); + assert!( + requests + .iter() + .filter(|r| r.url.path().starts_with("/patch/")) + .all(|r| !r.headers.contains_key("authorization")), + "no proxy request carries the bearer" + ); +} diff --git a/crates/socket-patch-cli/tests/e2e_hosted_production.rs b/crates/socket-patch-cli/tests/e2e_hosted_production.rs index 69098cbb6..424861f20 100644 --- a/crates/socket-patch-cli/tests/e2e_hosted_production.rs +++ b/crates/socket-patch-cli/tests/e2e_hosted_production.rs @@ -2697,8 +2697,7 @@ async fn public_reference_url(uuid: &str) -> String { let client = ApiClient::new(ApiClientOptions { api_url: PROXY.to_string(), api_token: None, - use_public_proxy: true, - org_slug: None, + route: socket_patch_core::api::client::ApiRoute::Proxy, }); let references = client .fetch_registry_references(&[uuid.to_string()]) diff --git a/crates/socket-patch-cli/tests/hosted_memory_common/mod.rs b/crates/socket-patch-cli/tests/hosted_memory_common/mod.rs index cecd456ba..a68e10a64 100644 --- a/crates/socket-patch-cli/tests/hosted_memory_common/mod.rs +++ b/crates/socket-patch-cli/tests/hosted_memory_common/mod.rs @@ -220,8 +220,7 @@ pub fn client(server: &MockServer) -> Arc { Arc::new(ApiClient::new(ApiClientOptions { api_url: server.uri(), api_token: Some("fake-token".to_string()), - use_public_proxy: false, - org_slug: Some(ORG.to_string()), + route: socket_patch_core::api::client::ApiRoute::org(ORG), })) } diff --git a/crates/socket-patch-core/src/api/blob_fetcher.rs b/crates/socket-patch-core/src/api/blob_fetcher.rs index 836cb67cc..777a7bce3 100644 --- a/crates/socket-patch-core/src/api/blob_fetcher.rs +++ b/crates/socket-patch-core/src/api/blob_fetcher.rs @@ -1048,8 +1048,7 @@ mod tests { let client = ApiClient::new(crate::api::client::ApiClientOptions { api_url: server.uri(), api_token: None, - use_public_proxy: true, - org_slug: None, + route: crate::api::client::ApiRoute::Proxy, }); let body = client .fetch_diff("11111111-1111-4111-8111-111111111111") diff --git a/crates/socket-patch-core/src/api/client.rs b/crates/socket-patch-core/src/api/client.rs index 89ef48073..ce3bbcb94 100644 --- a/crates/socket-patch-core/src/api/client.rs +++ b/crates/socket-patch-core/src/api/client.rs @@ -204,17 +204,50 @@ impl BinaryBody { } } +/// Where a run's patch API calls go — decided once, when the run's +/// [`ApiClient`] is built ([`get_api_client_with_overrides`]), and fixed +/// for the whole run. Every JSON call, blob/diff download, vendor package +/// reference and telemetry event reads this one value, so a run can never +/// query the org API for one call and the public proxy for another. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ApiRoute { + /// The authenticated org API: `/v0/orgs/{slug}/...` with the bearer. + Org { slug: String }, + /// The public patch proxy: `/patch/...`, anonymous, free patches only. + Proxy, +} + +impl ApiRoute { + /// An [`ApiRoute::Org`] for `slug`. + pub fn org(slug: impl Into) -> Self { + Self::Org { slug: slug.into() } + } + + /// The org slug, for [`ApiRoute::Org`] only. + pub fn slug(&self) -> Option<&str> { + match self { + Self::Org { slug } => Some(slug), + Self::Proxy => None, + } + } + + /// Whether this is the public proxy. + pub fn is_proxy(&self) -> bool { + matches!(self, Self::Proxy) + } +} + /// Options for constructing an [`ApiClient`]. #[derive(Debug, Clone)] pub struct ApiClientOptions { + /// Base URL: the org API host for [`ApiRoute::Org`], the proxy host + /// for [`ApiRoute::Proxy`]. pub api_url: String, + /// Bearer token. Kept only for [`ApiRoute::Org`]: a proxy client is + /// anonymous, so a token passed with [`ApiRoute::Proxy`] is dropped. pub api_token: Option, - /// When true, the client will use the public patch API proxy - /// which only provides access to free patches without authentication. - pub use_public_proxy: bool, - /// Organization slug for authenticated API access. - /// Required when using authenticated API (not public proxy). - pub org_slug: Option, + /// Org API or public proxy, fixed for the client's lifetime. + pub route: ApiRoute, } /// HTTP client for the Socket Patch API. @@ -232,9 +265,13 @@ pub struct ApiClient { /// instead of paying a fresh TLS-config build + handshake per request. plain: reqwest::Client, api_url: String, + /// Bearer token; always `None` on an [`ApiRoute::Proxy`] client. api_token: Option, - use_public_proxy: bool, - org_slug: Option, + route: ApiRoute, + /// Set when a token was configured but its org could not be resolved, + /// so this client was built on [`ApiRoute::Proxy`] instead: the warning + /// text, for `--json` `warnings[]` ([`Self::org_unresolved`]). + org_unresolved: Option>, /// Retry policy for the vendoring service's two round trips. vendor_retry: VendorRetryPolicy, /// Consecutive [`Self::fetch_vendor_package`] calls that ended in a @@ -402,14 +439,20 @@ impl ApiClient { pub fn new(options: ApiClientOptions) -> Self { let api_url = options.api_url.trim_end_matches('/').to_string(); let timeouts = ApiTimeouts::default(); + // A proxy client never carries the bearer, so no request it makes + // can leak the token to the proxy host. + let api_token = match options.route { + ApiRoute::Org { .. } => options.api_token, + ApiRoute::Proxy => None, + }; Self { - client: api_client(options.api_token.as_deref(), &timeouts), + client: api_client(api_token.as_deref(), &timeouts), plain: plain_client(&timeouts), api_url, - api_token: options.api_token, - use_public_proxy: options.use_public_proxy, - org_slug: options.org_slug, + api_token, + route: options.route, + org_unresolved: None, vendor_retry: VendorRetryPolicy::default(), vendor_outage: Arc::new(AtomicU32::new(0)), proxy_batch_slots: Arc::new(tokio::sync::Semaphore::new(PROXY_BATCH_PATH_CONCURRENCY)), @@ -469,16 +512,35 @@ impl ApiClient { self.api_token.as_ref() } - /// Returns the org slug, if set. + /// The run's route (see [`ApiRoute`]). + pub fn route(&self) -> &ApiRoute { + &self.route + } + + /// When a token was configured but its org could not be resolved (so + /// this client is on the public proxy), the warning text saying so. + pub fn org_unresolved(&self) -> Option<&str> { + self.org_unresolved.as_deref() + } + + /// The base URL: the org API host, or the proxy host. + pub fn api_url(&self) -> &str { + &self.api_url + } + + /// The org slug; `Some` only for an [`ApiRoute::Org`] client. pub fn org_slug(&self) -> Option<&String> { - self.org_slug.as_ref() + match &self.route { + ApiRoute::Org { slug } => Some(slug), + ApiRoute::Proxy => None, + } } /// Whether this client talks to the public patch proxy (vs. the /// authenticated org API) — picks the concurrency cap /// ([`crate::utils::concurrent::api_concurrency`]). pub fn uses_public_proxy(&self) -> bool { - self.use_public_proxy + self.route.is_proxy() } // ── Internal helpers ────────────────────────────────────────────── @@ -615,7 +677,7 @@ impl ApiClient { |status, text| !is_patch_api_unconfigured(status, text), ) .await?; - Self::handle_json_response(sent, self.use_public_proxy).await + Self::handle_json_response(sent, self.uses_public_proxy()).await } /// Internal POST that deserialises JSON. Returns `Ok(None)` on 404. @@ -639,7 +701,7 @@ impl ApiClient { |status, text| !is_patch_api_unconfigured(status, text), ) .await?; - Self::handle_json_response(sent, self.use_public_proxy).await + Self::handle_json_response(sent, self.uses_public_proxy()).await } /// Map an HTTP response to `Ok(Some(T))`, `Ok(None)` (404), or `Err`. @@ -678,24 +740,14 @@ impl ApiClient { ))) } - /// The org slug an authenticated `/v0/orgs/{slug}/...` route uses: the - /// per-call override, else the client's configured slug, else `default`. - fn org_slug_or_default<'a>(&'a self, org_slug: Option<&'a str>) -> &'a str { - org_slug.or(self.org_slug.as_deref()).unwrap_or("default") - } - /// Path of a patches JSON endpoint: `/patch/{suffix}` on the public - /// proxy, `/v0/orgs/{slug}/patches/{suffix}` on the authenticated API. - /// The one place the proxy-vs-org switch and the slug fallback live for - /// the JSON family (`get_json`/`post_json` prefix `api_url`). - fn patches_path(&self, org_slug: Option<&str>, suffix: &str) -> String { - if self.use_public_proxy { - format!("/patch/{suffix}") - } else { - format!( - "/v0/orgs/{}/patches/{suffix}", - self.org_slug_or_default(org_slug) - ) + /// proxy, `/v0/orgs/{slug}/patches/{suffix}` on the org API. The one + /// place the route picks the path for the JSON family + /// (`get_json`/`post_json` prefix `api_url`). + fn patches_path(&self, suffix: &str) -> String { + match &self.route { + ApiRoute::Org { slug } => format!("/v0/orgs/{slug}/patches/{suffix}"), + ApiRoute::Proxy => format!("/patch/{suffix}"), } } @@ -705,7 +757,7 @@ impl ApiClient { /// /// Returns `Ok(None)` when the patch is not found (404). pub async fn fetch_patch(&self, uuid: &str) -> Result, ApiError> { - let path = self.patches_path(None, &format!("view/{uuid}")); + let path = self.patches_path(&format!("view/{uuid}")); self.get_json(&path).await } @@ -718,7 +770,7 @@ impl ApiClient { identifier: &str, ) -> Result { let encoded = urlencoding_encode(identifier); - let path = self.patches_path(None, &format!("{route}/{encoded}")); + let path = self.patches_path(&format!("{route}/{encoded}")); let mut result = self .get_json::(&path) .await? @@ -764,9 +816,8 @@ impl ApiClient { &self, purls: &[String], ) -> Result { - if !self.use_public_proxy { - let slug = self.org_slug_or_default(None); - let path = self.patches_path(None, "batch"); + if let ApiRoute::Org { slug } = &self.route { + let path = self.patches_path("batch"); let body = BatchSearchBody::new(purls); let result = self .post_json::(&path, &body) @@ -808,29 +859,14 @@ impl ApiClient { /// public proxy `POST /patch/package` (free patches only), in requests /// of at most [`MAX_REFERENCE_BATCH`] UUIDs (the endpoint rejects more). /// Returns a UUID → reference map (missing/404 → empty). - /// - /// Uses the client's configured org slug; see - /// [`Self::fetch_registry_references_for_org`] for a per-call override. pub async fn fetch_registry_references( &self, uuids: &[String], - ) -> Result, ApiError> { - self.fetch_registry_references_for_org(None, uuids).await - } - - /// [`Self::fetch_registry_references`] with a per-call `org_slug` - /// override: `Some(slug)` wins over the client's configured slug. The - /// only patches route that takes one — `fetch_patch` / `search_patches_*` - /// always use the client's configured slug. - pub async fn fetch_registry_references_for_org( - &self, - org_slug: Option<&str>, - uuids: &[String], ) -> Result, ApiError> { if uuids.is_empty() { return Ok(std::collections::HashMap::new()); } - let path = self.patches_path(org_slug, "package"); + let path = self.patches_path("package"); let mut results = std::collections::HashMap::new(); for chunk in uuids.chunks(MAX_REFERENCE_BATCH) { let body = PackageVendorRequest { @@ -1035,8 +1071,7 @@ impl ApiClient { /// Fetch a blob by its SHA-256 hash. /// /// Returns the response body as a [`BinaryBody`] stream, or `Ok(None)` - /// if not found. Uses the authenticated endpoint when token and org - /// slug are available, otherwise falls back to the public proxy. + /// if not found. Follows the client's [`ApiRoute`]. pub async fn fetch_blob(&self, hash: &str) -> Result, ApiError> { // Validate hash format: SHA-256 = 64 hex characters if !is_hex(hash, 64) { @@ -1067,40 +1102,24 @@ impl ApiClient { /// Build the URL (and an `is_authenticated` flag) for a binary fetch of /// `kind` (`blob` / `diff`) identified by `identifier`. /// - /// Uses the authenticated `/v0/orgs//patches/...` endpoint when a - /// token and org slug are configured (and we're not pinned to the public - /// proxy). Otherwise it targets the public proxy. - /// - /// In public-proxy mode the base is the client's own configured `api_url` - /// — the same value the JSON endpoints (`get_json`/`post_json`) use — so an - /// explicit `--proxy-url` / `SOCKET_PROXY_URL` override is honored for - /// binary downloads too. Only when falling back from an *authenticated* - /// client that lacks an org slug (so `api_url` is the auth host, not a - /// proxy) do we re-derive the proxy base from the environment. + /// Follows the client's [`ApiRoute`], like the JSON endpoints: the org + /// API's `/v0/orgs//patches/...` with the bearer, or the proxy's + /// `/patch/...` without it. The base is always the client's own + /// `api_url` — for a proxy client that is the proxy host, so an explicit + /// `--proxy-url` / `SOCKET_PROXY_URL` override is honored here too. fn binary_url(&self, kind: &str, identifier: &str) -> (String, bool) { - if self.api_token.is_some() && self.org_slug.is_some() && !self.use_public_proxy { - let slug = self - .org_slug - .as_deref() - .expect("org_slug is_some checked in this branch's condition"); - let u = format!( - "{}/v0/orgs/{}/patches/{}/{}", - self.api_url, slug, kind, identifier - ); - (u, true) - } else { - let base = if self.use_public_proxy { - self.api_url.clone() - } else { - proxy_url_from_env() - }; - let u = format!( - "{}/patch/{}/{}", - base.trim_end_matches('/'), - kind, - identifier - ); - (u, false) + match &self.route { + ApiRoute::Org { slug } => ( + format!( + "{}/v0/orgs/{}/patches/{}/{}", + self.api_url, slug, kind, identifier + ), + true, + ), + ApiRoute::Proxy => ( + format!("{}/patch/{}/{}", self.api_url, kind, identifier), + false, + ), } } @@ -1442,32 +1461,18 @@ impl ApiClient { /// Build the URL (and an `is_authenticated` flag) for the vendor /// package-reference POST of [`Self::request_vendor_package`]. /// - /// Authenticated `/v0/orgs//patches/package` when a token + org - /// slug are configured and we're not pinned to the public proxy — - /// mirrors [`Self::binary_url`]'s decision so a bearer is never sent to - /// the proxy. Otherwise it targets the proxy's `/patch/package`. - /// `vendor_url` (staging / local-dev) overrides the base in every case. - /// - /// The base mirrors [`Self::binary_url`] too: in public-proxy mode the - /// client's own `api_url` IS the proxy, but an authenticated client that - /// lacks an org slug must re-derive the proxy base from the environment - /// — its `api_url` is the auth host, which has no `/patch/*` routes. + /// Follows the client's [`ApiRoute`], like [`Self::binary_url`]: the org + /// API's `/v0/orgs//patches/package` with the bearer, or the + /// proxy's `/patch/package` without it. The base is the client's + /// `api_url`; `vendor_url` (staging / local-dev) overrides it. fn vendor_package_url(&self, vendor_url: Option<&str>) -> (String, bool) { - let use_auth = - self.api_token.is_some() && self.org_slug.is_some() && !self.use_public_proxy; let base = match vendor_url { - Some(v) => v.trim_end_matches('/').to_string(), - None if use_auth || self.use_public_proxy => self.api_url.clone(), - None => proxy_url_from_env().trim_end_matches('/').to_string(), + Some(v) => v.trim_end_matches('/'), + None => self.api_url.as_str(), }; - if use_auth { - let slug = self - .org_slug - .as_deref() - .expect("use_auth requires org_slug.is_some()"); - (format!("{base}/v0/orgs/{slug}/patches/package"), true) - } else { - (format!("{base}/patch/package"), false) + match &self.route { + ApiRoute::Org { slug } => (format!("{base}/v0/orgs/{slug}/patches/package"), true), + ApiRoute::Proxy => (format!("{base}/patch/package"), false), } } @@ -2059,8 +2064,13 @@ pub struct ApiClientEnvOverrides { /// /// When a token is set but no org slug is provided (argument, /// `SOCKET_ORG_SLUG` env var, or socket-cli config `defaultOrg`), the -/// function will attempt to auto-resolve the org slug by querying -/// `GET /v0/organizations`. +/// function resolves the org slug by querying `GET /v0/organizations`. If +/// that fails, the client is an anonymous public-proxy client (free patches +/// only) and a warning says to pass `--org` / set `SOCKET_ORG_SLUG`. +/// +/// This is the run's one org resolution: the returned client's +/// [`ApiRoute`] is fixed, and every API call and telemetry event in the run +/// reads it. Build one client per run and pass it down. /// /// # Environment variables /// @@ -2073,7 +2083,8 @@ pub struct ApiClientEnvOverrides { /// | `SOCKET_NO_API_TOKEN` | Truthy: ignore ambient tokens (env + config); only an explicit override authenticates | /// | `SOCKET_NO_CONFIG` | Truthy: disable the socket-cli config fallback layer entirely | /// -/// Returns `(client, use_public_proxy)`. +/// Returns `(client, use_public_proxy)`; `use_public_proxy` is +/// `client.uses_public_proxy()`. pub async fn get_api_client_from_env(org_slug: Option<&str>) -> (ApiClient, bool) { get_api_client_with_overrides(ApiClientEnvOverrides { org_slug: org_slug.map(String::from), @@ -2165,15 +2176,24 @@ fn resolve_credentials_with_origin( /// corresponding env var. Used by CLI commands that expose `--api-url`, /// `--api-token`, `--org`, `--proxy-url` flags via [`crate::utils`] in the /// CLI crate. +/// +/// Resolves the run's [`ApiRoute`] exactly once: +/// +/// | Token | Org slug | Result | +/// |---|---|---| +/// | none | any | [`ApiRoute::Proxy`] | +/// | set | given | [`ApiRoute::Org`] | +/// | set | none, online | `GET /v0/organizations`: Org on success, else Proxy + warning | +/// | set | none, offline | [`ApiRoute::Proxy`], no network, no warning | pub async fn get_api_client_with_overrides(overrides: ApiClientEnvOverrides) -> (ApiClient, bool) { let (api_token, origin, resolved_org_slug) = resolve_credentials_with_origin(overrides.api_token, overrides.org_slug); + let proxy_url = overrides + .proxy_url + .filter(|u| !u.is_empty()) + .unwrap_or_else(proxy_url_from_env); - if api_token.is_none() { - let proxy_url = overrides - .proxy_url - .filter(|u| !u.is_empty()) - .unwrap_or_else(proxy_url_from_env); + let Some(api_token) = api_token else { // Offline runs still construct this client (commands build it up // front for telemetry/staging plumbing) but never contact it — under // the strict-airgap contract "using the public patch API proxy" @@ -2189,21 +2209,13 @@ pub async fn get_api_client_with_overrides(overrides: ApiClientEnvOverrides) -> .to_string() }); } - let client = ApiClient::new(ApiClientOptions { - api_url: proxy_url, - api_token: None, - use_public_proxy: true, - org_slug: None, - }); - return (client, true); - } + return (proxy_client_at(proxy_url), true); + }; // Shape check the configured token before the network round-trip so // a "you set the hash, not the token" mistake is loud and immediate. - if let Some(ref t) = api_token { - if let Some(msg) = validate_token_shape(t, origin) { - notice_once(Notice::Warning, &TOKEN_SHAPE_SHOWN, || msg); - } + if let Some(msg) = validate_token_shape(&api_token, origin) { + notice_once(Notice::Warning, &TOKEN_SHAPE_SHOWN, || msg); } let api_url = overrides @@ -2213,45 +2225,80 @@ pub async fn get_api_client_with_overrides(overrides: ApiClientEnvOverrides) -> // telemetry endpoint resolver so the two can't disagree. .unwrap_or_else(socket_cli_config::resolve_api_base_url); - // Build the client once; the org-slug round-trip below runs on it and - // fills in `org_slug` in place (it only needs the token + base URL). - let mut client = ApiClient::new(ApiClientOptions { - api_url, - api_token, - use_public_proxy: false, - org_slug: resolved_org_slug, - }); + if let Some(slug) = resolved_org_slug { + let client = ApiClient::new(ApiClientOptions { + api_url, + api_token: Some(api_token), + route: ApiRoute::org(slug), + }); + return (client, false); + } - // Auto-resolve the org slug if not provided. Strict airgap: `--offline` - // (mirrored into `SOCKET_OFFLINE` by the CLI before any client is built - // — same vocabulary the telemetry kill-switch matches) means zero - // network contact, so the round-trip must not fire. The slug only labels - // org-scoped fetches and telemetry, both already gated off offline. - if client.org_slug.is_none() && !is_offline_env() { - match client.resolve_org_slug().await { - Ok(slug) => client.org_slug = Some(slug), - Err(e) => { - notice_once(Notice::Warning, &ORG_DETECT_SHOWN, || { - let mut msg = format!("Warning: Could not auto-detect organization: {e}"); - if matches!(e, ApiError::Unauthorized(_)) { - if let Some(t) = client.api_token.as_deref() { - if looks_like_token_hash(t) { - msg.push_str(&format!( - "\n Hint: {} starts with `{}-`, which is the \ - stored hash format. Set it to the raw \ - `sktsec_..._api` value instead.", - origin.label(), - t.split('-').next().unwrap_or("sha512") - )); - } - } - } - msg - }); - } + // No org slug given. Strict airgap: `--offline` (mirrored into + // `SOCKET_OFFLINE` by the CLI before any client is built — same + // vocabulary the telemetry kill-switch matches) means zero network + // contact, so the `/v0/organizations` round-trip must not fire. An + // offline client never contacts the API, so the proxy route only + // records that no org is known; it is not worth a warning. + if is_offline_env() { + return (proxy_client_at(proxy_url), true); + } + + // The run's one org resolution, on a short-lived client that carries + // the bearer. Its answer fixes the route for the whole run. + let resolver = ApiClient::new(ApiClientOptions { + api_url: api_url.clone(), + api_token: Some(api_token.clone()), + route: ApiRoute::org(String::new()), + }); + match resolver.resolve_org_slug().await { + Ok(slug) => { + let client = ApiClient::new(ApiClientOptions { + api_url, + api_token: Some(api_token), + route: ApiRoute::org(slug), + }); + (client, false) + } + Err(e) => { + let message = unresolved_org_message(&e, &api_token, origin); + notice_once(Notice::Warning, &ORG_DETECT_SHOWN, || message.clone()); + let mut client = proxy_client_at(proxy_url); + client.org_unresolved = Some(Arc::from( + message.strip_prefix("Warning: ").unwrap_or(&message), + )); + (client, true) } } - (client, false) +} + +/// The warning for a token whose org could not be resolved, so the run +/// uses the public proxy. Keeps the "you set the stored hash" hint for a +/// 401 on a hash-shaped token. +fn unresolved_org_message(e: &ApiError, api_token: &str, origin: TokenSource) -> String { + let mut msg = format!( + "Warning: Could not determine your organization ({e}); using the public patch API \ + proxy (free patches only). Pass --org or set SOCKET_ORG_SLUG." + ); + if matches!(e, ApiError::Unauthorized(_)) && looks_like_token_hash(api_token) { + msg.push_str(&format!( + "\n Hint: {} starts with `{}-`, which is the \ + stored hash format. Set it to the raw \ + `sktsec_..._api` value instead.", + origin.label(), + api_token.split('-').next().unwrap_or("sha512") + )); + } + msg +} + +/// An anonymous public-proxy client at `proxy_url`. +fn proxy_client_at(proxy_url: String) -> ApiClient { + ApiClient::new(ApiClientOptions { + api_url: proxy_url, + api_token: None, + route: ApiRoute::Proxy, + }) } /// Build a public-proxy `ApiClient` from the same overrides used by @@ -2266,12 +2313,7 @@ pub fn build_proxy_fallback_client(overrides: &ApiClientEnvOverrides) -> ApiClie .proxy_url .clone() .unwrap_or_else(proxy_url_from_env); - ApiClient::new(ApiClientOptions { - api_url: proxy_url, - api_token: None, - use_public_proxy: true, - org_slug: None, - }) + proxy_client_at(proxy_url) } /// Return `true` when the configured token value looks like an @@ -2755,7 +2797,7 @@ pub trait PatchApi: Send + Sync { impl PatchApi for ApiClient { fn uses_public_proxy(&self) -> bool { - self.use_public_proxy + self.route.is_proxy() } fn search_patches_batch<'a>( @@ -2905,8 +2947,7 @@ impl std::fmt::Debug for ApiClient { f.debug_struct("ApiClient") .field("api_url", &self.api_url) .field("api_token", &self.api_token.as_ref().map(|_| "")) - .field("use_public_proxy", &self.use_public_proxy) - .field("org_slug", &self.org_slug) + .field("route", &self.route) .field("vendor_retry", &self.vendor_retry) .finish_non_exhaustive() } @@ -2921,8 +2962,7 @@ mod patch_api_seam_tests { let client = ApiClient::new(ApiClientOptions { api_url: "https://api.example".into(), api_token: Some("sktsec_secret_value_api".into()), - use_public_proxy: false, - org_slug: Some("org".into()), + route: ApiRoute::org("org"), }); let rendered = format!("{client:?}"); assert!(!rendered.contains("sktsec_secret_value_api"), "{rendered}"); @@ -2934,8 +2974,7 @@ mod patch_api_seam_tests { let client = ApiClient::new(ApiClientOptions { api_url: "https://api.example".into(), api_token: None, - use_public_proxy: true, - org_slug: None, + route: ApiRoute::Proxy, }); let api: &dyn PatchApi = &client; assert!(api.uses_public_proxy()); @@ -2960,8 +2999,7 @@ mod patch_api_seam_tests { let client = ApiClient::new(ApiClientOptions { api_url: server.uri(), api_token: Some("t".into()), - use_public_proxy: false, - org_slug: Some("org".into()), + route: ApiRoute::org("org"), }); let api: &dyn PatchApi = &client; let purls = vec!["pkg:npm/a@1".to_string(), "pkg:npm/b@1".to_string()]; @@ -2989,8 +3027,7 @@ mod patch_api_seam_tests { let client = ApiClient::new(ApiClientOptions { api_url: server.uri(), api_token: Some("t".into()), - use_public_proxy: false, - org_slug: Some("org".into()), + route: ApiRoute::org("org"), }); let api: &dyn PatchApi = &client; let url = format!("{}/a.whl", server.uri()); @@ -3194,7 +3231,7 @@ mod tests { std::env::remove_var("SOCKET_API_TOKEN"); let (client, is_public) = get_api_client_from_env(None).await; assert!(is_public); - assert!(client.use_public_proxy); + assert!(client.uses_public_proxy()); } #[tokio::test] @@ -3203,8 +3240,9 @@ mod tests { // must be treated as "not provided" and trigger auto-resolution — // not be taken verbatim as an explicit slug, which would build broken // `/v0/orgs//patches/...` URLs. Auto-resolution here targets an - // unreachable URL, so it fails and leaves the slug `None` (never - // `Some("")`). The buggy code skipped resolution and yielded `Some("")`. + // unreachable URL, so it fails and the run uses the public proxy + // (never `Some("")`). The buggy code skipped resolution and yielded + // `Some("")`. std::env::remove_var("SOCKET_ORG_SLUG"); std::env::remove_var("SOCKET_API_URL"); let (client, is_public) = get_api_client_with_overrides(ApiClientEnvOverrides { @@ -3214,7 +3252,11 @@ mod tests { proxy_url: None, }) .await; - assert!(!is_public, "a token was provided, so not public-proxy mode"); + assert!( + is_public, + "failed resolution must route the run to the proxy" + ); + assert_eq!(client.route(), &ApiRoute::Proxy); assert_ne!( client.org_slug().map(String::as_str), Some(""), @@ -3222,7 +3264,7 @@ mod tests { ); assert!( client.org_slug().is_none(), - "failed auto-resolution should leave the slug unset, got {:?}", + "failed auto-resolution has no slug, got {:?}", client.org_slug() ); } @@ -3237,8 +3279,7 @@ mod tests { let client = ApiClient::new(ApiClientOptions { api_url: "http://127.0.0.1:1".into(), api_token: None, - use_public_proxy: true, - org_slug: None, + route: ApiRoute::Proxy, }); let err = client.fetch_blob("not-a-hash").await.unwrap_err(); assert!( @@ -3250,51 +3291,39 @@ mod tests { assert!(msg.contains("64 hex"), "got: {msg}"); } - /// The documented corner of `binary_url`: an *authenticated* client - /// (token set) that lacks an org slug cannot build `/v0/orgs/...` URLs, - /// so it re-derives the public-proxy base from `SOCKET_PROXY_URL` — - /// with `use_auth == false` so `fetch_binary` uses the plain client and - /// the bearer is never sent to the proxy. Serialized: SOCKET_* env is - /// process-global. - #[test] + /// A token whose org resolution fails yields a proxy client whose + /// `api_url` is the proxy base (`SOCKET_PROXY_URL` here, trailing slash + /// trimmed), so `binary_url` targets the proxy with `use_auth == false` + /// and the bearer is never sent. Nothing re-derives the base later. + #[tokio::test] #[serial_test::serial] - fn binary_url_rederives_proxy_from_env_when_org_slug_missing() { + async fn binary_url_uses_the_resolved_proxy_when_org_resolution_fails() { const HASH: &str = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"; + let _env = OrgResolutionEnvGuard::isolate(); let saved_proxy = std::env::var("SOCKET_PROXY_URL").ok(); std::env::set_var("SOCKET_PROXY_URL", "http://env-proxy.test:9999/"); - let client = ApiClient::new(ApiClientOptions { - api_url: "https://api.socket.dev".into(), + let (client, use_public_proxy) = get_api_client_with_overrides(ApiClientEnvOverrides { + api_url: Some("http://127.0.0.1:1".into()), api_token: Some(format!("sktsec_{}_api", "x".repeat(44))), - use_public_proxy: false, - org_slug: None, - }); - let (env_url, env_use_auth) = client.binary_url("blob", HASH); + ..ApiClientEnvOverrides::default() + }) + .await; - // With the var unset the base falls back to the built-in default. + // The base is fixed at construction: changing the env afterwards + // must not move it. std::env::remove_var("SOCKET_PROXY_URL"); - let (default_url, default_use_auth) = client.binary_url("blob", HASH); + let (url, use_auth) = client.binary_url("blob", HASH); match saved_proxy { Some(v) => std::env::set_var("SOCKET_PROXY_URL", v), None => std::env::remove_var("SOCKET_PROXY_URL"), } - assert_eq!( - env_url, - format!("http://env-proxy.test:9999/patch/blob/{HASH}"), - "proxy base from SOCKET_PROXY_URL, trailing slash trimmed" - ); - assert!(!env_use_auth, "the bearer must never target the proxy"); - assert_eq!( - default_url, - format!( - "{}/patch/blob/{HASH}", - crate::constants::DEFAULT_PATCH_API_PROXY_URL - ), - "with no env override the base is the built-in proxy default" - ); - assert!(!default_use_auth); + assert!(use_public_proxy); + assert!(client.api_token().is_none(), "a proxy client has no bearer"); + assert_eq!(url, format!("http://env-proxy.test:9999/patch/blob/{HASH}")); + assert!(!use_auth, "the bearer must never target the proxy"); } /// Guard that snapshots the env vars that can short-circuit org @@ -3328,13 +3357,12 @@ mod tests { /// The network half of `resolve_org_slug`: `GET /v0/organizations` /// answering 404 (e.g. `--api-url` pointed at the wrong host) yields an - /// empty org list → `select_org_slug` errors → the warning arm leaves - /// the slug unset while keeping the client authenticated (never a - /// silent downgrade to proxy mode). The mock's `.expect(1)` proves - /// auto-resolution actually fired exactly once. + /// empty org list → `select_org_slug` errors → the run's route is the + /// public proxy, anonymous, with the reason recorded for `--json`. The + /// mock's `.expect(1)` proves resolution fired exactly once. #[tokio::test] #[serial_test::serial] - async fn org_auto_resolution_404_leaves_slug_unset_but_stays_authenticated() { + async fn org_auto_resolution_404_routes_the_run_to_the_proxy() { use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; @@ -3355,24 +3383,26 @@ mod tests { }) .await; + assert!(use_public_proxy, "failed resolution → public proxy"); + assert_eq!(client.route(), &ApiRoute::Proxy); + assert!(client.org_slug().is_none()); assert!( - !use_public_proxy, - "a token was provided → authenticated mode" + client.api_token().is_none(), + "the proxy client is anonymous" ); + let reason = client.org_unresolved().expect("downgrade reason recorded"); assert!( - client.org_slug().is_none(), - "failed auto-resolution must leave the slug unset, got {:?}", - client.org_slug() + reason.contains("Pass --org or set SOCKET_ORG_SLUG"), + "{reason}" ); - assert_eq!(client.api_token(), Some(&token), "token must be retained"); + assert!(!reason.starts_with("Warning:"), "{reason}"); } /// A 401 on the org auto-resolution round-trip with a hash-shaped token /// exercises the `Unauthorized` + `looks_like_token_hash` hint arm (the /// "you configured the sha512- storage hash, not the token" UX path). - /// Client construction must still succeed: slug unset, token retained, - /// NOT downgraded to proxy mode. (The hint's stderr text is pinned by - /// the process-level twin in the CLI covgap suite.) + /// The run's route is the public proxy, anonymous. (The hint's stderr + /// text is pinned by the process-level twin in the CLI covgap suite.) #[tokio::test] #[serial_test::serial] async fn org_auto_resolution_401_with_hash_shaped_token_hint_arm() { @@ -3396,20 +3426,149 @@ mod tests { }) .await; + assert!(use_public_proxy, "a 401 during resolution → public proxy"); + assert_eq!(client.route(), &ApiRoute::Proxy); + assert!(client.org_slug().is_none()); assert!( - !use_public_proxy, - "a 401 during resolution must not silently downgrade to the proxy" - ); - assert!( - client.org_slug().is_none(), - "unauthorized resolution must leave the slug unset, got {:?}", - client.org_slug() - ); - assert_eq!( - client.api_token(), - Some(&hash_token), - "the (mis)configured token is kept — the hint is advisory" + client.api_token().is_none(), + "the (mis)configured token is never sent to the proxy" ); + let reason = client.org_unresolved().expect("downgrade reason recorded"); + assert!(reason.contains("stored hash format"), "{reason}"); + } + + /// Every URL a run builds — patch view, batch search, blob, diff, + /// vendor package reference and telemetry — follows the one route the + /// client was built with: all org-scoped with the bearer, or all + /// `/patch/*` on the proxy without it. Covers a given slug, a failed + /// org resolution (500 and 401), an offline run, and no token. + #[tokio::test] + #[serial_test::serial] + async fn route_decides_every_url() { + use crate::telemetry::TelemetryAuth; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + const HASH: &str = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"; + const UUID: &str = "11111111-2222-4333-8444-555555555555"; + let _env = OrgResolutionEnvGuard::isolate(); + let token = format!("sktsec_{}_api", "x".repeat(44)); + let proxy = "http://proxy.example.test"; + + /// Assert every URL of `client` sits on one route. + fn assert_one_route(case: &str, client: &ApiClient, org: Option<&str>) { + let urls = [ + client.patches_path(&format!("view/{UUID}")), + client.patches_path("batch"), + client.binary_url("blob", HASH).0, + client.binary_url("diff", UUID).0, + client.vendor_package_url(None).0, + TelemetryAuth::for_client(client).url().to_string(), + ]; + let auth = [ + client.binary_url("blob", HASH).1, + client.binary_url("diff", UUID).1, + client.vendor_package_url(None).1, + TelemetryAuth::for_client(client).is_authenticated(), + ]; + match org { + Some(slug) => { + let scope = format!("/v0/orgs/{slug}/"); + for url in &urls { + assert!(url.contains(&scope), "{case}: {url} is not org-scoped"); + } + assert!( + auth.iter().all(|a| *a), + "{case}: org calls carry the bearer" + ); + assert!(client.api_token().is_some(), "{case}"); + assert_eq!(client.route(), &ApiRoute::org(slug), "{case}"); + } + None => { + for url in &urls { + assert!( + url.contains("/patch/") && !url.contains("/v0/orgs/"), + "{case}: {url} is not a proxy route" + ); + } + assert!( + auth.iter().all(|a| !*a), + "{case}: proxy calls are anonymous" + ); + assert!(client.api_token().is_none(), "{case}"); + assert!(client.uses_public_proxy(), "{case}"); + } + } + } + + // Token + slug: org route, no resolution round-trip. + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v0/organizations")) + .respond_with(ResponseTemplate::new(200)) + .expect(0) + .mount(&server) + .await; + let (client, public) = get_api_client_with_overrides(ApiClientEnvOverrides { + api_url: Some(server.uri()), + api_token: Some(token.clone()), + org_slug: Some("acme".into()), + proxy_url: Some(proxy.into()), + }) + .await; + assert!(!public); + assert_one_route("token + slug", &client, Some("acme")); + drop(server); + + // Token, resolution fails (500, then 401): proxy route for everything. + for status in [500, 401] { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v0/organizations")) + .respond_with(ResponseTemplate::new(status)) + .expect(1) + .mount(&server) + .await; + let (client, public) = get_api_client_with_overrides(ApiClientEnvOverrides { + api_url: Some(server.uri()), + api_token: Some(token.clone()), + proxy_url: Some(proxy.into()), + ..ApiClientEnvOverrides::default() + }) + .await; + assert!(public, "resolve {status}"); + assert!(client.org_unresolved().is_some(), "resolve {status}"); + assert_one_route(&format!("resolve {status}"), &client, None); + assert!( + client.binary_url("blob", HASH).0.starts_with(proxy), + "the proxy override is the base" + ); + } + + // Token, no slug, offline: proxy route, no network, no warning text. + std::env::set_var("SOCKET_OFFLINE", "1"); + let (client, public) = get_api_client_with_overrides(ApiClientEnvOverrides { + api_url: Some("http://127.0.0.1:1".into()), + api_token: Some(token.clone()), + proxy_url: Some(proxy.into()), + ..ApiClientEnvOverrides::default() + }) + .await; + std::env::remove_var("SOCKET_OFFLINE"); + assert!(public); + assert!(client.org_unresolved().is_none()); + assert_one_route("offline", &client, None); + + // No token: proxy route on the override. + std::env::set_var("SOCKET_NO_API_TOKEN", "1"); + let (client, public) = get_api_client_with_overrides(ApiClientEnvOverrides { + proxy_url: Some(proxy.into()), + ..ApiClientEnvOverrides::default() + }) + .await; + std::env::remove_var("SOCKET_NO_API_TOKEN"); + assert!(public); + assert_one_route("no token", &client, None); } // ── Group 6: convert_search_result_to_batch_info edge cases ────── @@ -4021,8 +4180,7 @@ mod tests { ApiClient::new(ApiClientOptions { api_url: api_url.into(), api_token: None, - use_public_proxy: true, - org_slug: None, + route: ApiRoute::Proxy, }) } @@ -4059,8 +4217,7 @@ mod tests { let client = ApiClient::new(ApiClientOptions { api_url: "https://api.socket.dev".into(), api_token: Some("sktsec_x_api".into()), - use_public_proxy: false, - org_slug: Some("my-org".into()), + route: ApiRoute::org("my-org"), }); let (url, use_auth) = client.binary_url("diff", "uuid-123"); assert!(use_auth); @@ -4072,36 +4229,25 @@ mod tests { // ── vendor_package_url: package-reference POST target ─────────────── // - // Regression: an authenticated client *without* an org slug (auto- - // resolution failed, or `SOCKET_OFFLINE` skipped it) built the proxy - // route on its own `api_url` — `https://api.socket.dev/patch/package` — - // a path the auth host does not serve, so every vendor-service fetch - // failed with a 404-shaped `Other` error. `binary_url` re-derives the - // proxy base from the environment for exactly this client state; the - // vendor POST must do the same. + // Regression (pre-route): an authenticated client *without* an org slug + // built the proxy route on the auth host's `api_url`, a path that host + // does not serve. A token whose org cannot be resolved now yields a + // proxy client whose `api_url` is the proxy host. - #[test] - fn vendor_package_url_auth_without_org_slug_targets_proxy_host() { - let client = ApiClient::new(ApiClientOptions { - api_url: "https://api.socket.dev".into(), + #[tokio::test] + #[serial_test::serial] + async fn vendor_package_url_after_failed_org_resolution_targets_proxy_host() { + let _env = OrgResolutionEnvGuard::isolate(); + let (client, _) = get_api_client_with_overrides(ApiClientEnvOverrides { + api_url: Some("http://127.0.0.1:1".into()), api_token: Some("sktsec_x_api".into()), - use_public_proxy: false, - org_slug: None, - }); + proxy_url: Some("https://proxy.example.test/".into()), + ..ApiClientEnvOverrides::default() + }) + .await; let (url, use_auth) = client.vendor_package_url(None); - assert!(!use_auth, "no org slug → unauthenticated proxy request"); - assert!( - !url.starts_with("https://api.socket.dev"), - "must not target the auth host (it has no /patch/* routes); got: {url}" - ); - assert_eq!( - url, - format!( - "{}/patch/package", - proxy_url_from_env().trim_end_matches('/') - ), - "base must be the env-derived proxy host, like binary_url" - ); + assert!(!use_auth, "no org → unauthenticated proxy request"); + assert_eq!(url, "https://proxy.example.test/patch/package"); } #[test] @@ -4119,8 +4265,7 @@ mod tests { let client = ApiClient::new(ApiClientOptions { api_url: "https://api.socket.dev".into(), api_token: Some("sktsec_x_api".into()), - use_public_proxy: false, - org_slug: Some("my-org".into()), + route: ApiRoute::org("my-org"), }); let (url, use_auth) = client.vendor_package_url(None); assert!(use_auth); @@ -4129,24 +4274,17 @@ mod tests { #[test] fn vendor_package_url_vendor_url_overrides_base() { - // The staging override wins for every client state, including the - // no-org-slug fallback (it must not be clobbered by the env proxy). + // The staging override wins for both routes. let auth = ApiClient::new(ApiClientOptions { api_url: "https://api.socket.dev".into(), api_token: Some("sktsec_x_api".into()), - use_public_proxy: false, - org_slug: Some("my-org".into()), + route: ApiRoute::org("my-org"), }); assert_eq!( auth.vendor_package_url(Some("http://localhost:9099/")).0, "http://localhost:9099/v0/orgs/my-org/patches/package" ); - let no_org = ApiClient::new(ApiClientOptions { - api_url: "https://api.socket.dev".into(), - api_token: Some("sktsec_x_api".into()), - use_public_proxy: false, - org_slug: None, - }); + let no_org = proxy_client("https://patches-api.socket.dev"); assert_eq!( no_org.vendor_package_url(Some("http://localhost:9099")).0, "http://localhost:9099/patch/package" @@ -4345,8 +4483,7 @@ mod vendor_package_tests { ApiClient::new(ApiClientOptions { api_url: uri, api_token: Some("sktsec_token_placeholder_value_api".into()), - use_public_proxy: false, - org_slug: Some("acme".into()), + route: ApiRoute::org("acme"), }) } @@ -4354,8 +4491,7 @@ mod vendor_package_tests { ApiClient::new(ApiClientOptions { api_url: uri, api_token: None, - use_public_proxy: true, - org_slug: None, + route: ApiRoute::Proxy, }) } @@ -4762,42 +4898,6 @@ mod vendor_package_tests { assert_eq!(map.len(), uuids.len()); } - /// The package-reference route honors a per-call org override: - /// `Some(slug)` beats the client's configured `acme`, and the one-arg - /// wrapper keeps using `acme`. - #[tokio::test] - async fn fetch_registry_references_for_org_overrides_client_slug() { - let server = MockServer::start().await; - let body = json!({ - "results": { UUID: { "status": "granted", "url": null, "artifacts": [] } } - }); - Mock::given(method("POST")) - .and(path("/v0/orgs/other-org/patches/package")) - .respond_with(ResponseTemplate::new(200).set_body_json(body.clone())) - .expect(1) - .mount(&server) - .await; - Mock::given(method("POST")) - .and(path("/v0/orgs/acme/patches/package")) - .respond_with(ResponseTemplate::new(200).set_body_json(body)) - .expect(1) - .mount(&server) - .await; - - let client = auth_client(server.uri()); - let uuids = [UUID.to_string()]; - let overridden = client - .fetch_registry_references_for_org(Some("other-org"), &uuids) - .await - .expect("override route must succeed"); - assert_eq!(overridden[UUID].status, "granted"); - let configured = client - .fetch_registry_references(&uuids) - .await - .expect("configured-slug route must succeed"); - assert_eq!(configured[UUID].status, "granted"); - } - // ── fetch_vendor_package grant / artifact edge arms ─────────────── /// Forward-compat contract: an unrecognized vendor status must degrade @@ -5117,8 +5217,7 @@ mod vendor_retry_tests { ApiClient::new(ApiClientOptions { api_url: uri.to_string(), api_token: Some("sktsec_placeholder_value_for_tests_api".into()), - use_public_proxy: false, - org_slug: Some("acme".into()), + route: ApiRoute::org("acme"), }) .with_vendor_retry(policy) } @@ -5583,8 +5682,11 @@ mod vendor_retry_tests { let api = ApiClient::new(ApiClientOptions { api_url: uri.clone(), api_token: (!proxy).then(|| "tok".into()), - use_public_proxy: proxy, - org_slug: Some("org".into()), + route: if proxy { + ApiRoute::Proxy + } else { + ApiRoute::org("org") + }, }) .with_vendor_retry(VendorRetryPolicy { attempts: 2, @@ -6031,8 +6133,7 @@ mod authenticated_batch_tests { ApiClient::new(ApiClientOptions { api_url: uri, api_token: Some("sktsec_token_placeholder_value_api".into()), - use_public_proxy: false, - org_slug: Some(slug.into()), + route: ApiRoute::org(slug), }) } @@ -6165,8 +6266,7 @@ mod proxy_batch_path_cap_tests { let client = ApiClient::new(ApiClientOptions { api_url: server.uri(), api_token: None, - use_public_proxy: true, - org_slug: None, + route: ApiRoute::Proxy, }); // Four windows of 10 PURLs, as scan's proxy batch windows run them. let chunks: Vec> = (0..4) diff --git a/crates/socket-patch-core/src/api/vendor_prefetch.rs b/crates/socket-patch-core/src/api/vendor_prefetch.rs index 8512b6722..cb1708db7 100644 --- a/crates/socket-patch-core/src/api/vendor_prefetch.rs +++ b/crates/socket-patch-core/src/api/vendor_prefetch.rs @@ -737,8 +737,7 @@ mod tests { ApiClient::new(ApiClientOptions { api_url: uri.to_string(), api_token: Some("sktsec_placeholder_value_for_tests_api".into()), - use_public_proxy: false, - org_slug: Some("acme".into()), + route: crate::api::client::ApiRoute::org("acme"), }) .with_vendor_retry(VendorRetryPolicy { attempts: 3, diff --git a/crates/socket-patch-core/src/patch/jvm_jar.rs b/crates/socket-patch-core/src/patch/jvm_jar.rs index f38a84403..9bd317379 100644 --- a/crates/socket-patch-core/src/patch/jvm_jar.rs +++ b/crates/socket-patch-core/src/patch/jvm_jar.rs @@ -1034,8 +1034,7 @@ mod tests { let client = crate::api::client::ApiClient::new(crate::api::client::ApiClientOptions { api_url: server.uri(), api_token: None, - use_public_proxy: true, - org_slug: None, + route: crate::api::client::ApiRoute::Proxy, }); let cfg = VendorServiceConfig { maven_config: None, diff --git a/crates/socket-patch-core/src/patch/redirect/vlt_preflight.rs b/crates/socket-patch-core/src/patch/redirect/vlt_preflight.rs index 4f85a6c7f..6cbe7479d 100644 --- a/crates/socket-patch-core/src/patch/redirect/vlt_preflight.rs +++ b/crates/socket-patch-core/src/patch/redirect/vlt_preflight.rs @@ -237,8 +237,7 @@ mod tests { ApiClient::new(ApiClientOptions { api_url: "http://127.0.0.1:9".to_string(), api_token: token.map(str::to_string), - use_public_proxy: false, - org_slug: Some("org".to_string()), + route: crate::api::client::ApiRoute::org("org"), }) } diff --git a/crates/socket-patch-core/src/telemetry.rs b/crates/socket-patch-core/src/telemetry.rs index d49aaa5c6..cccf2ca9a 100644 --- a/crates/socket-patch-core/src/telemetry.rs +++ b/crates/socket-patch-core/src/telemetry.rs @@ -3,6 +3,7 @@ use std::collections::HashMap; use once_cell::sync::Lazy; use uuid::Uuid; +use crate::api::client::ApiRoute; use crate::constants::USER_AGENT; use crate::utils::env_compat::{ is_debug_enabled, is_offline_env, proxy_url_from_env, @@ -219,37 +220,77 @@ fn chrono_now_iso() -> String { // Send event // --------------------------------------------------------------------------- -/// Decide which endpoint a telemetry event goes to, and whether to attach -/// the bearer token. +/// Where a run's telemetry events go, and the bearer they carry: the org +/// API's `/v0/orgs//telemetry` with the token, or the public proxy's +/// `/patch/telemetry` anonymously. /// -/// The authenticated `/v0/orgs//telemetry` endpoint is used only when -/// BOTH a non-empty token and a non-empty org slug are present. An empty -/// string is treated as absent: a `Some("")` slug would otherwise build a -/// malformed `/v0/orgs//telemetry` URL and a `Some("")` token an empty -/// `Bearer ` header. This mirrors the empty-slug guard in -/// `get_api_client_from_env`, keeping the contract robust even if a caller -/// hands us blank values directly. -fn resolve_telemetry_endpoint(api_token: Option<&str>, org_slug: Option<&str>) -> (String, bool) { - let token = api_token.filter(|t| !t.is_empty()); - let slug = org_slug.filter(|s| !s.is_empty()); - - match (token, slug) { - (Some(_token), Some(slug)) => { - // Same env → socket-cli config → default chain as API-client - // construction, so telemetry can't target a different host than - // the client that produced the event. - let api_url = crate::utils::socket_cli_config::resolve_api_base_url(); - // Trim trailing slashes like `ApiClient::new` does, so a base URL - // of `https://host/` doesn't produce a malformed `//v0/...` path. - let api_url = api_url.trim_end_matches('/'); - (format!("{api_url}/v0/orgs/{slug}/telemetry"), true) +/// A command that built an [`ApiClient`](crate::api::client::ApiClient) +/// takes this from it ([`Self::for_client`]), so its telemetry follows the +/// run's one [`ApiRoute`] — the same host and org as every API call. Only a +/// command that never builds a client (`list`) uses +/// [`Self::from_credentials`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TelemetryAuth { + url: String, + /// The bearer token to attach (org endpoint only). + bearer: Option, +} + +impl TelemetryAuth { + /// The client's route: its org endpoint with its token, or the proxy + /// endpoint on its (proxy) base URL. + pub fn for_client(client: &crate::api::client::ApiClient) -> Self { + let base = client.api_url().trim_end_matches('/'); + match client.route() { + ApiRoute::Org { slug } => Self { + url: format!("{base}/v0/orgs/{slug}/telemetry"), + bearer: client.api_token().cloned(), + }, + ApiRoute::Proxy => Self::proxy_at(base), + } + } + + /// The route for a command without a client, with no network: the org + /// endpoint only when BOTH a non-empty token and a non-empty org slug + /// are given (the API base from env → socket-cli config → default, as + /// client construction resolves it), else the proxy from the + /// environment. An empty string is treated as absent: a `Some("")` slug + /// would otherwise build a malformed `/v0/orgs//telemetry` URL and a + /// `Some("")` token an empty `Bearer ` header. + pub fn from_credentials(api_token: Option<&str>, org_slug: Option<&str>) -> Self { + let token = api_token.filter(|t| !t.is_empty()); + let slug = org_slug.filter(|s| !s.is_empty()); + match (token, slug) { + (Some(token), Some(slug)) => { + let api_url = crate::utils::socket_cli_config::resolve_api_base_url(); + // Trim trailing slashes like `ApiClient::new` does, so a base + // URL of `https://host/` doesn't produce a `//v0/...` path. + let api_url = api_url.trim_end_matches('/'); + Self { + url: format!("{api_url}/v0/orgs/{slug}/telemetry"), + bearer: Some(token.to_string()), + } + } + _ => Self::proxy_at(&proxy_url_from_env()), } - _ => { - let proxy_url = proxy_url_from_env(); - let proxy_url = proxy_url.trim_end_matches('/'); - (format!("{proxy_url}/patch/telemetry"), false) + } + + fn proxy_at(base: &str) -> Self { + Self { + url: format!("{}/patch/telemetry", base.trim_end_matches('/')), + bearer: None, } } + + /// The endpoint events are POSTed to. + pub fn url(&self) -> &str { + &self.url + } + + /// Whether events carry the bearer (org endpoint). + pub fn is_authenticated(&self) -> bool { + self.bearer.is_some() + } } /// A telemetry event with its destination resolved, ready to POST. Built @@ -263,22 +304,14 @@ struct PreparedSend { bearer: Option, } -/// Resolve `event`'s endpoint (see [`resolve_telemetry_endpoint`]). -fn prepare_send( - event: PatchTelemetryEvent, - api_token: Option<&str>, - org_slug: Option<&str>, -) -> PreparedSend { - let (url, use_auth) = resolve_telemetry_endpoint(api_token, org_slug); - - debug_log(&format!("Sending telemetry to {url}")); - - let bearer = if use_auth { - api_token.map(str::to_string) - } else { - None - }; - PreparedSend { event, url, bearer } +/// Address `event` per `auth`. +fn prepare_send(event: PatchTelemetryEvent, auth: &TelemetryAuth) -> PreparedSend { + debug_log(&format!("Sending telemetry to {}", auth.url)); + PreparedSend { + event, + url: auth.url.clone(), + bearer: auth.bearer.clone(), + } } /// Send a telemetry event to the API. @@ -381,8 +414,7 @@ impl PendingTelemetry { // --------------------------------------------------------------------------- // Per-event tracker wrappers (the public API) // -// These accept `Option<&str>` for api_token/org_slug to make call sites -// convenient (callers typically have `Option` and call `.as_deref()`). +// Each takes the run's `TelemetryAuth` (see `TelemetryAuth::for_client`). // --------------------------------------------------------------------------- /// Build the event the tracker wrappers below send, or `None` when @@ -394,8 +426,7 @@ fn prepare( command: &'static str, metadata: serde_json::Value, error: Option, - api_token: Option<&str>, - org_slug: Option<&str>, + auth: &TelemetryAuth, ) -> Option { if is_telemetry_disabled() { debug_log("Telemetry is disabled, skipping event"); @@ -408,7 +439,7 @@ fn prepare( }; let error = error.map(|e| ("Error".to_string(), e.to_string())); let event = build_telemetry_event(event_type, command, metadata, error); - Some(prepare_send(event, api_token, org_slug)) + Some(prepare_send(event, auth)) } /// Shared fire-and-forget helper for the per-event tracker wrappers below. @@ -421,13 +452,9 @@ async fn fire( command: &'static str, metadata: serde_json::Value, error: Option, - api_token: Option<&str>, - org_slug: Option<&str>, + auth: &TelemetryAuth, ) { - fire_prepared(prepare( - event_type, command, metadata, error, api_token, org_slug, - )) - .await; + fire_prepared(prepare(event_type, command, metadata, error, auth)).await; } /// Send a prepared event inline, or return at once when telemetry is @@ -439,19 +466,13 @@ async fn fire_prepared(prepared: Option) { } /// Track a successful patch application. -pub async fn track_patch_applied( - patches_count: usize, - dry_run: bool, - api_token: Option<&str>, - org_slug: Option<&str>, -) { +pub async fn track_patch_applied(patches_count: usize, dry_run: bool, auth: &TelemetryAuth) { fire( PatchTelemetryEventType::PatchApplied, "apply", serde_json::json!({ "patches_count": patches_count, "dry_run": dry_run }), None::<&str>, - api_token, - org_slug, + auth, ) .await; } @@ -463,34 +484,26 @@ pub async fn track_patch_applied( pub async fn track_patch_apply_failed( error: impl std::fmt::Display, dry_run: bool, - api_token: Option<&str>, - org_slug: Option<&str>, + auth: &TelemetryAuth, ) { fire( PatchTelemetryEventType::PatchApplyFailed, "apply", serde_json::json!({ "dry_run": dry_run }), Some(error), - api_token, - org_slug, + auth, ) .await; } /// Track a successful vendor run (count = packages vendored). -pub async fn track_patch_vendored( - vendored_count: u32, - dry_run: bool, - api_token: Option<&str>, - org_slug: Option<&str>, -) { +pub async fn track_patch_vendored(vendored_count: u32, dry_run: bool, auth: &TelemetryAuth) { fire( PatchTelemetryEventType::PatchVendored, "vendor", serde_json::json!({ "patches_count": vendored_count, "dry_run": dry_run }), None::<&str>, - api_token, - org_slug, + auth, ) .await; } @@ -499,84 +512,62 @@ pub async fn track_patch_vendored( pub async fn track_patch_vendor_failed( error: impl std::fmt::Display, dry_run: bool, - api_token: Option<&str>, - org_slug: Option<&str>, + auth: &TelemetryAuth, ) { fire( PatchTelemetryEventType::PatchVendorFailed, "vendor", serde_json::json!({ "dry_run": dry_run }), Some(error), - api_token, - org_slug, + auth, ) .await; } /// Track a successful patch removal. -pub async fn track_patch_removed( - removed_count: usize, - api_token: Option<&str>, - org_slug: Option<&str>, -) { +pub async fn track_patch_removed(removed_count: usize, auth: &TelemetryAuth) { fire( PatchTelemetryEventType::PatchRemoved, "remove", serde_json::json!({ "removed_count": removed_count }), None::<&str>, - api_token, - org_slug, + auth, ) .await; } /// Track a failed patch removal. Accepts any `Display` type for the error. -pub async fn track_patch_remove_failed( - error: impl std::fmt::Display, - api_token: Option<&str>, - org_slug: Option<&str>, -) { +pub async fn track_patch_remove_failed(error: impl std::fmt::Display, auth: &TelemetryAuth) { fire( PatchTelemetryEventType::PatchRemoveFailed, "remove", serde_json::Value::Null, Some(error), - api_token, - org_slug, + auth, ) .await; } /// Track a successful patch rollback. -pub async fn track_patch_rolled_back( - rolled_back_count: usize, - api_token: Option<&str>, - org_slug: Option<&str>, -) { +pub async fn track_patch_rolled_back(rolled_back_count: usize, auth: &TelemetryAuth) { fire( PatchTelemetryEventType::PatchRolledBack, "rollback", serde_json::json!({ "rolled_back_count": rolled_back_count }), None::<&str>, - api_token, - org_slug, + auth, ) .await; } /// Track a failed patch rollback. Accepts any `Display` type for the error. -pub async fn track_patch_rollback_failed( - error: impl std::fmt::Display, - api_token: Option<&str>, - org_slug: Option<&str>, -) { +pub async fn track_patch_rollback_failed(error: impl std::fmt::Display, auth: &TelemetryAuth) { fire( PatchTelemetryEventType::PatchRollbackFailed, "rollback", serde_json::Value::Null, Some(error), - api_token, - org_slug, + auth, ) .await; } @@ -597,8 +588,7 @@ fn prepare_patch_scanned( can_access_paid: bool, ecosystems: &[String], fallback_to_proxy: bool, - api_token: Option<&str>, - org_slug: Option<&str>, + auth: &TelemetryAuth, ) -> Option { prepare( PatchTelemetryEventType::PatchScanned, @@ -612,8 +602,7 @@ fn prepare_patch_scanned( "fallback_to_proxy": fallback_to_proxy, }), None::<&str>, - api_token, - org_slug, + auth, ) } @@ -638,8 +627,7 @@ pub async fn track_patch_scanned( can_access_paid: bool, ecosystems: &[String], fallback_to_proxy: bool, - api_token: Option<&str>, - org_slug: Option<&str>, + auth: &TelemetryAuth, ) { fire_prepared(prepare_patch_scanned( packages_scanned, @@ -648,8 +636,7 @@ pub async fn track_patch_scanned( can_access_paid, ecosystems, fallback_to_proxy, - api_token, - org_slug, + auth, )) .await; } @@ -666,8 +653,7 @@ pub fn spawn_patch_scanned( can_access_paid: bool, ecosystems: &[String], fallback_to_proxy: bool, - api_token: Option<&str>, - org_slug: Option<&str>, + auth: &TelemetryAuth, ) { pending.spawn_prepared(prepare_patch_scanned( packages_scanned, @@ -676,8 +662,7 @@ pub fn spawn_patch_scanned( can_access_paid, ecosystems, fallback_to_proxy, - api_token, - org_slug, + auth, )); } @@ -685,16 +670,14 @@ pub fn spawn_patch_scanned( fn prepare_patch_scan_failed( error: impl std::fmt::Display, fallback_to_proxy: bool, - api_token: Option<&str>, - org_slug: Option<&str>, + auth: &TelemetryAuth, ) -> Option { prepare( PatchTelemetryEventType::PatchScanFailed, "scan", serde_json::json!({ "fallback_to_proxy": fallback_to_proxy }), Some(error), - api_token, - org_slug, + auth, ) } @@ -704,16 +687,9 @@ fn prepare_patch_scan_failed( pub async fn track_patch_scan_failed( error: impl std::fmt::Display, fallback_to_proxy: bool, - api_token: Option<&str>, - org_slug: Option<&str>, + auth: &TelemetryAuth, ) { - fire_prepared(prepare_patch_scan_failed( - error, - fallback_to_proxy, - api_token, - org_slug, - )) - .await; + fire_prepared(prepare_patch_scan_failed(error, fallback_to_proxy, auth)).await; } /// [`track_patch_scan_failed`], sent in the background (see @@ -722,15 +698,9 @@ pub fn spawn_patch_scan_failed( pending: &mut PendingTelemetry, error: impl std::fmt::Display, fallback_to_proxy: bool, - api_token: Option<&str>, - org_slug: Option<&str>, + auth: &TelemetryAuth, ) { - pending.spawn_prepared(prepare_patch_scan_failed( - error, - fallback_to_proxy, - api_token, - org_slug, - )); + pending.spawn_prepared(prepare_patch_scan_failed(error, fallback_to_proxy, auth)); } /// Track a successful `get`. Reports patch identity + delivery mode and @@ -742,8 +712,7 @@ pub async fn track_patch_fetched( ecosystem: &str, download_mode: &str, fallback_to_proxy: bool, - api_token: Option<&str>, - org_slug: Option<&str>, + auth: &TelemetryAuth, ) { fire( PatchTelemetryEventType::PatchFetched, @@ -756,8 +725,7 @@ pub async fn track_patch_fetched( "fallback_to_proxy": fallback_to_proxy, }), None::<&str>, - api_token, - org_slug, + auth, ) .await; } @@ -768,16 +736,14 @@ pub async fn track_patch_fetch_failed( uuid: &str, error: impl std::fmt::Display, fallback_to_proxy: bool, - api_token: Option<&str>, - org_slug: Option<&str>, + auth: &TelemetryAuth, ) { fire( PatchTelemetryEventType::PatchFetchFailed, "get", serde_json::json!({ "uuid": uuid, "fallback_to_proxy": fallback_to_proxy }), Some(error), - api_token, - org_slug, + auth, ) .await; } @@ -787,18 +753,13 @@ pub async fn track_patch_fetch_failed( // --------------------------------------------------------------------------- /// Track a successful `list`. Reports the number of patches surfaced. -pub async fn track_patch_listed( - patches_count: usize, - api_token: Option<&str>, - org_slug: Option<&str>, -) { +pub async fn track_patch_listed(patches_count: usize, auth: &TelemetryAuth) { fire( PatchTelemetryEventType::PatchListed, "list", serde_json::json!({ "patches_count": patches_count }), None::<&str>, - api_token, - org_slug, + auth, ) .await; } @@ -808,8 +769,7 @@ pub async fn track_patch_repaired( blobs_added: usize, blobs_removed: usize, bytes_freed: u64, - api_token: Option<&str>, - org_slug: Option<&str>, + auth: &TelemetryAuth, ) { fire( PatchTelemetryEventType::PatchRepaired, @@ -820,25 +780,19 @@ pub async fn track_patch_repaired( "bytes_freed": bytes_freed, }), None::<&str>, - api_token, - org_slug, + auth, ) .await; } /// Track a failed `repair`. -pub async fn track_patch_repair_failed( - error: impl std::fmt::Display, - api_token: Option<&str>, - org_slug: Option<&str>, -) { +pub async fn track_patch_repair_failed(error: impl std::fmt::Display, auth: &TelemetryAuth) { fire( PatchTelemetryEventType::PatchRepairFailed, "repair", serde_json::Value::Null, Some(error), - api_token, - org_slug, + auth, ) .await; } @@ -853,8 +807,7 @@ pub async fn track_vex_generated( advisories_count: usize, format: &str, output_kind: &str, - api_token: Option<&str>, - org_slug: Option<&str>, + auth: &TelemetryAuth, ) { fire( PatchTelemetryEventType::VexGenerated, @@ -865,25 +818,19 @@ pub async fn track_vex_generated( "output_kind": output_kind, }), None::<&str>, - api_token, - org_slug, + auth, ) .await; } /// Track a failed `vex` generation. -pub async fn track_vex_failed( - error: impl std::fmt::Display, - api_token: Option<&str>, - org_slug: Option<&str>, -) { +pub async fn track_vex_failed(error: impl std::fmt::Display, auth: &TelemetryAuth) { fire( PatchTelemetryEventType::VexFailed, "vex", serde_json::Value::Null, Some(error), - api_token, - org_slug, + auth, ) .await; } @@ -1047,12 +994,13 @@ mod tests { // No token / org: both events go to the proxy endpoint above. let ecosystems = vec!["npm".to_string(), "pypi".to_string()]; + let auth = TelemetryAuth::from_credentials(None, None); let mut pending = PendingTelemetry::new(); - track_patch_scanned(5, 3, 2, true, &ecosystems, true, None, None).await; - spawn_patch_scanned(&mut pending, 5, 3, 2, true, &ecosystems, true, None, None); + track_patch_scanned(5, 3, 2, true, &ecosystems, true, &auth).await; + spawn_patch_scanned(&mut pending, 5, 3, 2, true, &ecosystems, true, &auth); pending.flush().await; - track_patch_scan_failed("all batches failed", true, None, None).await; - spawn_patch_scan_failed(&mut pending, "all batches failed", true, None, None); + track_patch_scan_failed("all batches failed", true, &auth).await; + spawn_patch_scan_failed(&mut pending, "all batches failed", true, &auth); pending.flush().await; for (key, value) in saved { @@ -1354,6 +1302,41 @@ mod tests { assert!(millis.parse::().unwrap() < 1000); } + /// [`TelemetryAuth::from_credentials`] as `(url, authenticated)`. + fn resolve_telemetry_endpoint(token: Option<&str>, slug: Option<&str>) -> (String, bool) { + let auth = TelemetryAuth::from_credentials(token, slug); + (auth.url().to_string(), auth.is_authenticated()) + } + + /// A client's telemetry follows its route: the org endpoint on the + /// client's own base with its bearer, or the proxy endpoint on the + /// proxy client's base (an explicit `--proxy-url`) without one. No env + /// lookup is involved. + #[test] + fn for_client_follows_the_clients_route() { + use crate::api::client::{ApiClient, ApiClientOptions}; + let org = ApiClient::new(ApiClientOptions { + api_url: "https://api.example.test/".into(), + api_token: Some("tok".into()), + route: ApiRoute::org("acme"), + }); + let auth = TelemetryAuth::for_client(&org); + assert_eq!( + auth.url(), + "https://api.example.test/v0/orgs/acme/telemetry" + ); + assert!(auth.is_authenticated()); + + let proxy = ApiClient::new(ApiClientOptions { + api_url: "https://proxy.example.test".into(), + api_token: Some("tok".into()), + route: ApiRoute::Proxy, + }); + let auth = TelemetryAuth::for_client(&proxy); + assert_eq!(auth.url(), "https://proxy.example.test/patch/telemetry"); + assert!(!auth.is_authenticated(), "the proxy never gets the bearer"); + } + /// Endpoint selection must use the authenticated org route only when both /// a non-empty token and non-empty slug are present; blank values fall /// back to the public proxy (no `/v0/orgs//telemetry`, no `Bearer `). diff --git a/crates/socket-patch-core/src/vendor/cargo.rs b/crates/socket-patch-core/src/vendor/cargo.rs index 86d4311e6..a0af7962e 100644 --- a/crates/socket-patch-core/src/vendor/cargo.rs +++ b/crates/socket-patch-core/src/vendor/cargo.rs @@ -3511,8 +3511,7 @@ mod tests { ApiClient::new(ApiClientOptions { api_url: uri.to_string(), api_token: Some("sktsec_placeholder_value_for_tests_api".into()), - use_public_proxy: false, - org_slug: Some("acme".into()), + route: crate::api::client::ApiRoute::org("acme"), }) .with_vendor_retry(crate::api::client::VendorRetryPolicy::none()), ), diff --git a/crates/socket-patch-core/src/vendor/composer_lock.rs b/crates/socket-patch-core/src/vendor/composer_lock.rs index 9b54ee3de..c491291e1 100644 --- a/crates/socket-patch-core/src/vendor/composer_lock.rs +++ b/crates/socket-patch-core/src/vendor/composer_lock.rs @@ -2169,8 +2169,7 @@ mod tests { ApiClient::new(ApiClientOptions { api_url: uri.to_string(), api_token: Some("sktsec_placeholder_value_for_tests_api".into()), - use_public_proxy: false, - org_slug: Some("acme".into()), + route: crate::api::client::ApiRoute::org("acme"), }) .with_vendor_retry(crate::api::client::VendorRetryPolicy::none()), ), diff --git a/crates/socket-patch-core/src/vendor/gem.rs b/crates/socket-patch-core/src/vendor/gem.rs index 1ac48bfe2..3e2f10094 100644 --- a/crates/socket-patch-core/src/vendor/gem.rs +++ b/crates/socket-patch-core/src/vendor/gem.rs @@ -4839,8 +4839,7 @@ mod tests { ApiClient::new(ApiClientOptions { api_url: uri.to_string(), api_token: Some("sktsec_placeholder_value_for_tests_api".into()), - use_public_proxy: false, - org_slug: Some("acme".into()), + route: crate::api::client::ApiRoute::org("acme"), }) .with_vendor_retry(crate::api::client::VendorRetryPolicy::none()), ), diff --git a/crates/socket-patch-core/src/vendor/golang.rs b/crates/socket-patch-core/src/vendor/golang.rs index 1af1e96ac..c10499a2d 100644 --- a/crates/socket-patch-core/src/vendor/golang.rs +++ b/crates/socket-patch-core/src/vendor/golang.rs @@ -1658,8 +1658,7 @@ mod tests { ApiClient::new(ApiClientOptions { api_url: uri.to_string(), api_token: Some("sktsec_placeholder_value_for_tests_api".into()), - use_public_proxy: false, - org_slug: Some("acme".into()), + route: crate::api::client::ApiRoute::org("acme"), }) .with_vendor_retry(crate::api::client::VendorRetryPolicy::none()), ), diff --git a/crates/socket-patch-core/src/vendor/maven_repo.rs b/crates/socket-patch-core/src/vendor/maven_repo.rs index 2abc5f9d9..99599f5f0 100644 --- a/crates/socket-patch-core/src/vendor/maven_repo.rs +++ b/crates/socket-patch-core/src/vendor/maven_repo.rs @@ -3543,8 +3543,7 @@ mod tests { ApiClient::new(ApiClientOptions { api_url: uri.to_string(), api_token: Some("sktsec_placeholder_value_for_tests_api".into()), - use_public_proxy: false, - org_slug: Some("acme".into()), + route: crate::api::client::ApiRoute::org("acme"), }) .with_vendor_retry(crate::api::client::VendorRetryPolicy::none()) }), diff --git a/crates/socket-patch-core/src/vendor/npm_common.rs b/crates/socket-patch-core/src/vendor/npm_common.rs index 96403cd64..1abe69585 100644 --- a/crates/socket-patch-core/src/vendor/npm_common.rs +++ b/crates/socket-patch-core/src/vendor/npm_common.rs @@ -1070,8 +1070,7 @@ mod tests { ApiClient::new(ApiClientOptions { api_url: server_uri.to_string(), api_token: Some("sktsec_placeholder_value_for_tests_api".into()), - use_public_proxy: false, - org_slug: Some("acme".into()), + route: crate::api::client::ApiRoute::org("acme"), }) .with_vendor_retry(crate::api::client::VendorRetryPolicy::none()), ), diff --git a/crates/socket-patch-core/src/vendor/npm_lock.rs b/crates/socket-patch-core/src/vendor/npm_lock.rs index 7f14ddf8b..6f7b1e72c 100644 --- a/crates/socket-patch-core/src/vendor/npm_lock.rs +++ b/crates/socket-patch-core/src/vendor/npm_lock.rs @@ -4164,8 +4164,7 @@ mod tests { ApiClient::new(ApiClientOptions { api_url: server_uri.to_string(), api_token: Some("sktsec_placeholder_value_for_tests_api".into()), - use_public_proxy: false, - org_slug: Some("acme".into()), + route: crate::api::client::ApiRoute::org("acme"), }) .with_vendor_retry(crate::api::client::VendorRetryPolicy::none()), ), diff --git a/crates/socket-patch-core/src/vendor/nuget_feed.rs b/crates/socket-patch-core/src/vendor/nuget_feed.rs index 1e8f51d79..d7a8d6e0a 100644 --- a/crates/socket-patch-core/src/vendor/nuget_feed.rs +++ b/crates/socket-patch-core/src/vendor/nuget_feed.rs @@ -3937,8 +3937,7 @@ mod tests { ApiClient::new(ApiClientOptions { api_url: server.uri(), api_token: Some("sktsec_placeholder_value_for_tests_api".into()), - use_public_proxy: false, - org_slug: Some("acme".into()), + route: crate::api::client::ApiRoute::org("acme"), }) .with_vendor_retry(crate::api::client::VendorRetryPolicy::none()), ), @@ -4019,8 +4018,7 @@ mod tests { ApiClient::new(ApiClientOptions { api_url: server.uri(), api_token: Some("sktsec_placeholder_value_for_tests_api".into()), - use_public_proxy: false, - org_slug: Some("acme".into()), + route: crate::api::client::ApiRoute::org("acme"), }) .with_vendor_retry(crate::api::client::VendorRetryPolicy::none()), ), @@ -4510,8 +4508,7 @@ mod tests { ApiClient::new(ApiClientOptions { api_url: server.uri(), api_token: Some("sktsec_placeholder_value_for_tests_api".into()), - use_public_proxy: false, - org_slug: Some("acme".into()), + route: crate::api::client::ApiRoute::org("acme"), }) .with_vendor_retry(crate::api::client::VendorRetryPolicy::none()), ), @@ -4707,8 +4704,7 @@ mod tests { ApiClient::new(ApiClientOptions { api_url: s.uri(), api_token: Some("sktsec_placeholder_value_for_tests_api".into()), - use_public_proxy: false, - org_slug: Some("acme".into()), + route: crate::api::client::ApiRoute::org("acme"), }) }), use_public_proxy: false, diff --git a/crates/socket-patch-core/src/vendor/pypi.rs b/crates/socket-patch-core/src/vendor/pypi.rs index 73a5dc2cf..329d2e72a 100644 --- a/crates/socket-patch-core/src/vendor/pypi.rs +++ b/crates/socket-patch-core/src/vendor/pypi.rs @@ -3255,8 +3255,7 @@ wheels = [ ApiClient::new(ApiClientOptions { api_url: server_uri.to_string(), api_token: Some("sktsec_placeholder_value_for_tests_api".into()), - use_public_proxy: false, - org_slug: Some("acme".into()), + route: crate::api::client::ApiRoute::org("acme"), }) .with_vendor_retry(crate::api::client::VendorRetryPolicy::none()), ), diff --git a/crates/socket-patch-core/src/vendor/service_fetch.rs b/crates/socket-patch-core/src/vendor/service_fetch.rs index 283eb1733..079f430c8 100644 --- a/crates/socket-patch-core/src/vendor/service_fetch.rs +++ b/crates/socket-patch-core/src/vendor/service_fetch.rs @@ -426,8 +426,7 @@ mod tests { ApiClient::new(ApiClientOptions { api_url: server.uri(), api_token: Some("sktsec_placeholder_value_for_tests_api".into()), - use_public_proxy: false, - org_slug: Some("acme".into()), + route: crate::api::client::ApiRoute::org("acme"), }) .with_vendor_retry(crate::api::client::VendorRetryPolicy::none()), ), diff --git a/crates/socket-patch-core/src/vendor/test_support.rs b/crates/socket-patch-core/src/vendor/test_support.rs index 423a841a8..c65791866 100644 --- a/crates/socket-patch-core/src/vendor/test_support.rs +++ b/crates/socket-patch-core/src/vendor/test_support.rs @@ -55,8 +55,7 @@ pub(crate) fn service_cfg( ApiClient::new(ApiClientOptions { api_url: server_uri.to_string(), api_token: Some("sktsec_placeholder_value_for_tests_api".into()), - use_public_proxy: false, - org_slug: Some("acme".into()), + route: crate::api::client::ApiRoute::org("acme"), }) .with_vendor_retry(VendorRetryPolicy::none()), ), diff --git a/crates/socket-patch-core/tests/api_retry_e2e.rs b/crates/socket-patch-core/tests/api_retry_e2e.rs index c14604cc5..e98f29af3 100644 --- a/crates/socket-patch-core/tests/api_retry_e2e.rs +++ b/crates/socket-patch-core/tests/api_retry_e2e.rs @@ -63,8 +63,11 @@ fn options(uri: &str, proxy: bool) -> ApiClientOptions { ApiClientOptions { api_url: uri.to_string(), api_token: (!proxy).then(|| "tok".to_string()), - use_public_proxy: proxy, - org_slug: (!proxy).then(|| ORG.to_string()), + route: if proxy { + socket_patch_core::api::client::ApiRoute::Proxy + } else { + socket_patch_core::api::client::ApiRoute::org(ORG) + }, } } diff --git a/crates/socket-patch-core/tests/api_timeout_e2e.rs b/crates/socket-patch-core/tests/api_timeout_e2e.rs index 54a436f9a..0083ac0ad 100644 --- a/crates/socket-patch-core/tests/api_timeout_e2e.rs +++ b/crates/socket-patch-core/tests/api_timeout_e2e.rs @@ -139,8 +139,11 @@ fn client(uri: &str, proxy: bool) -> ApiClient { ApiClient::new(ApiClientOptions { api_url: uri.to_string(), api_token: (!proxy).then(|| "tok".to_string()), - use_public_proxy: proxy, - org_slug: (!proxy).then(|| "org".to_string()), + route: if proxy { + socket_patch_core::api::client::ApiRoute::Proxy + } else { + socket_patch_core::api::client::ApiRoute::org("org") + }, }) .with_api_retry(ApiRetryPolicy::none(), RetryHooks::default()) .with_api_timeouts(ApiTimeouts { diff --git a/crates/socket-patch-core/tests/binary_fetch_error_classification_e2e.rs b/crates/socket-patch-core/tests/binary_fetch_error_classification_e2e.rs index 2e9dd3c7d..86059be24 100644 --- a/crates/socket-patch-core/tests/binary_fetch_error_classification_e2e.rs +++ b/crates/socket-patch-core/tests/binary_fetch_error_classification_e2e.rs @@ -25,8 +25,7 @@ fn authed_client(api_url: &str) -> ApiClient { ApiClient::new(ApiClientOptions { api_url: api_url.to_string(), api_token: Some("sktsec_token_placeholder_api".to_string()), - use_public_proxy: false, - org_slug: Some("my-org".to_string()), + route: socket_patch_core::api::client::ApiRoute::org("my-org"), }) } diff --git a/crates/socket-patch-core/tests/blob_fetcher_edges_e2e.rs b/crates/socket-patch-core/tests/blob_fetcher_edges_e2e.rs index 31ebb30fd..a9a745511 100644 --- a/crates/socket-patch-core/tests/blob_fetcher_edges_e2e.rs +++ b/crates/socket-patch-core/tests/blob_fetcher_edges_e2e.rs @@ -24,8 +24,7 @@ fn dummy_client() -> ApiClient { ApiClient::new(ApiClientOptions { api_url: "http://127.0.0.1:1".to_string(), api_token: None, - use_public_proxy: true, - org_slug: None, + route: socket_patch_core::api::client::ApiRoute::Proxy, }) } @@ -357,8 +356,7 @@ fn proxy_client(base: &str) -> ApiClient { ApiClient::new(ApiClientOptions { api_url: base.to_string(), api_token: None, - use_public_proxy: true, - org_slug: None, + route: socket_patch_core::api::client::ApiRoute::Proxy, }) } diff --git a/crates/socket-patch-core/tests/covgap_api_blob_fetcher.rs b/crates/socket-patch-core/tests/covgap_api_blob_fetcher.rs index 041c323ad..3fc5f766f 100644 --- a/crates/socket-patch-core/tests/covgap_api_blob_fetcher.rs +++ b/crates/socket-patch-core/tests/covgap_api_blob_fetcher.rs @@ -41,8 +41,7 @@ fn dummy_client() -> ApiClient { ApiClient::new(ApiClientOptions { api_url: "http://127.0.0.1:1".to_string(), api_token: None, - use_public_proxy: true, - org_slug: None, + route: socket_patch_core::api::client::ApiRoute::Proxy, }) } @@ -52,8 +51,7 @@ fn proxy_client(base: &str) -> ApiClient { ApiClient::new(ApiClientOptions { api_url: base.to_string(), api_token: None, - use_public_proxy: true, - org_slug: None, + route: socket_patch_core::api::client::ApiRoute::Proxy, }) } diff --git a/crates/socket-patch-core/tests/proxy_batch_e2e.rs b/crates/socket-patch-core/tests/proxy_batch_e2e.rs index a0ac9f638..c122dba04 100644 --- a/crates/socket-patch-core/tests/proxy_batch_e2e.rs +++ b/crates/socket-patch-core/tests/proxy_batch_e2e.rs @@ -21,8 +21,7 @@ fn proxy_client(api_url: &str) -> ApiClient { ApiClient::new(ApiClientOptions { api_url: api_url.to_string(), api_token: None, - use_public_proxy: true, - org_slug: None, + route: socket_patch_core::api::client::ApiRoute::Proxy, }) } diff --git a/crates/socket-patch-core/tests/vlt_locks.rs b/crates/socket-patch-core/tests/vlt_locks.rs index 186ecfc72..a72910926 100644 --- a/crates/socket-patch-core/tests/vlt_locks.rs +++ b/crates/socket-patch-core/tests/vlt_locks.rs @@ -603,8 +603,7 @@ async fn vendor(case: &Case, staged: &Staged) -> VendorOutcome { client: Some(ApiClient::new(ApiClientOptions { api_url: server.uri(), api_token: None, - org_slug: None, - use_public_proxy: true, + route: socket_patch_core::api::client::ApiRoute::Proxy, })), use_public_proxy: true, vendor_url: None, From a89e1d35eed3b84fec569f90feb1116137f933cd Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 11:12:57 -0400 Subject: [PATCH 2/7] Document the once-per-run org resolution (#648) CLI_CONTRACT.md: the --org / SOCKET_ORG_SLUG rows and the api_auth_fallback warning cover the unresolved-org proxy run. docs/configuration.md and docs/migrating-to-v5.md describe the change. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-cli/CLI_CONTRACT.md | 6 +++--- docs/configuration.md | 5 +++++ docs/migrating-to-v5.md | 7 +++++++ 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index b02fa44fa..e9085d53d 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -50,7 +50,7 @@ Every subcommand accepts the same set of "global" flags via a single shared `Glo | `--manifest-path` | — | `SOCKET_MANIFEST_PATH` | `.socket/manifest.json` | path | Manifest location (resolved relative to `--cwd`) | | `--api-url` | — | `SOCKET_API_URL` | `https://api.socket.dev` | string | Authenticated API endpoint | | `--api-token` | — | `SOCKET_API_TOKEN` | (none) | string | Auth token (absence selects the public proxy) | -| `--org` | `-o` | `SOCKET_ORG_SLUG` | (auto-resolve) | string | Org slug | +| `--org` | `-o` | `SOCKET_ORG_SLUG` | (auto-resolve) | string | Org slug. Resolved once per run from the token (`GET /v0/organizations`) when omitted; if that fails, the whole run uses the public proxy anonymously (free patches only) and warns. | | `--proxy-url` | — | `SOCKET_PROXY_URL` | `https://patches-api.socket.dev` | string | Public proxy when no token | | `--ecosystems` | `-e` | `SOCKET_ECOSYSTEMS` | (all) | CSV → `Vec` | Restrict to these ecosystems | | `--download-mode` | — | `SOCKET_DOWNLOAD_MODE` | **`diff`** | enum: `diff` \| `file` (`package` was removed and is rejected) | Patch artifact format | @@ -329,7 +329,7 @@ Contract details: * **JSON success surface**: `apply` adds a top-level `vex` object to its envelope; `scan` adds a top-level `vex` key to its result. Both carry `{ path, statements, format: "openvex-0.2.0" }`. * `apply`'s no-manifest early exit (the `noManifest` success no-op; v5.0: its human line is `No patch manifest found; nothing to apply.` — it names the missing `.socket/manifest.json`, not the folder, since `.socket/` may legitimately hold vendored state) and `vendor`'s (`No manifest found, nothing to vendor.` — a project with hosted pins ejects instead, v5.0) still generate the document from the lockfiles and the vendor ledger (manifest-less VEX: hosted / vendored checkouts carry no manifest). Nothing referenced anywhere keeps the calm exit 0 (a stale document at the path is removed; `--json` carries any discovery diagnostics in `warnings[]`); any other VEX failure fails the command with exit 1 — including a run whose only candidates are omitted `record_unavailable` (an `--offline` run over a lockfile-wired checkout with no local records), so an ambient `SOCKET_VEX` there fails the install. `--dry-run` skips generation on both, and so does `apply --check` — it stays read-only and offline-safe, leaving the output path untouched. `scan` has no such early exit: with no manifest and nothing wired anywhere its `--vex` fails with `manifest_not_found`. * **Stale-doc removal (v3.5)**: a run that ends in a VEX error removes a recognizably-OpenVEX file (JSON whose `@context` names openvex.dev) already sitting at the output path — a pipeline reusing one path can never ship yesterday's attestation for a now-unpatched tree. Unrelated files at the path are never touched; a mid-write partial that no longer parses as JSON is left for downstream parsers to reject loudly. -* **Additive warnings (v3.5)**: `product_not_iri` (the `--product`/`--vex-product` override is neither a `pkg:` purl nor an absolute IRI; honored verbatim, warned) and `vendored_tree_out_of_sync` (a healthy vendored attestation stands on the committed artifact + lock wiring while the PRESENT installed tree hash-mismatches the patched bytes — run the package manager's install (for a project with Hatch environments the detail also names `hatch env remove `, since Hatch keeps an installed release); the attestation itself is unchanged). Both ride stderr in human mode and `warnings[]` in the standalone `vex --json` envelope. Same channel for `product_multiple_manifests` (auto-detect found several project manifests and names the one it used), `vex_stale_doc_removed` (the stale-doc removal above happened), the manifest-less plan's advisories — `vex_wiring_conflict` (the lockfiles wire a package to different patches: which files, which uuids, how to fix it), `vex_record_superseded` (a recorded patch replaced by the lockfile-wired one), `vex_claim_unwired` (a ledger claim whose patch the lockfiles still mention, but not as wiring), `vex_record_offline` / `vex_record_not_found` / `vex_record_fetch_failed` (why a lockfile-wired patch has no record — the detail behind a `record_unavailable` skip) and `api_auth_fallback` (the authenticated API refused the credentials and the public proxy served free patches only; `get` / `scan`'s warning text) — and, standalone only, `org_looks_like_path` (`-o`/`--org` given a file-shaped value — `-O` is `--output`). The standalone error envelope carries `warnings[]` too. An embedded `--vex` that fails also folds each omitted patch into the host command's `warnings[]` as `vex_omitted` (`: ()` — standalone `vex` lists them as `skipped` events), and `--silent` lists them as `omitted: ()` lines under the error. A corrupt `.socket/vendor/state.json` is no longer degraded with a warning: every form of vex fails with `vendor_ledger_corrupt` (see the error-code table). A malformed pre-v5 `redirect-state.json` is, as of v5.0, only the `redirect_ledger_corrupt` warning (hosted mode keeps no ledger; the file is an optional migration record source). +* **Additive warnings (v3.5)**: `product_not_iri` (the `--product`/`--vex-product` override is neither a `pkg:` purl nor an absolute IRI; honored verbatim, warned) and `vendored_tree_out_of_sync` (a healthy vendored attestation stands on the committed artifact + lock wiring while the PRESENT installed tree hash-mismatches the patched bytes — run the package manager's install (for a project with Hatch environments the detail also names `hatch env remove `, since Hatch keeps an installed release); the attestation itself is unchanged). Both ride stderr in human mode and `warnings[]` in the standalone `vex --json` envelope. Same channel for `product_multiple_manifests` (auto-detect found several project manifests and names the one it used), `vex_stale_doc_removed` (the stale-doc removal above happened), the manifest-less plan's advisories — `vex_wiring_conflict` (the lockfiles wire a package to different patches: which files, which uuids, how to fix it), `vex_record_superseded` (a recorded patch replaced by the lockfile-wired one), `vex_claim_unwired` (a ledger claim whose patch the lockfiles still mention, but not as wiring), `vex_record_offline` / `vex_record_not_found` / `vex_record_fetch_failed` (why a lockfile-wired patch has no record — the detail behind a `record_unavailable` skip) and `api_auth_fallback` (the public proxy served free patches only: the authenticated API refused the credentials, `get` / `scan`'s warning text; or, v5.0, a token was set with no `--org` / `SOCKET_ORG_SLUG` / socket-cli `defaultOrg` and the org could not be resolved, so the whole run used the proxy — `scan --json` and `get --json` also report this case in their top-level `warnings[]`) — and, standalone only, `org_looks_like_path` (`-o`/`--org` given a file-shaped value — `-O` is `--output`). The standalone error envelope carries `warnings[]` too. An embedded `--vex` that fails also folds each omitted patch into the host command's `warnings[]` as `vex_omitted` (`: ()` — standalone `vex` lists them as `skipped` events), and `--silent` lists them as `omitted: ()` lines under the error. A corrupt `.socket/vendor/state.json` is no longer degraded with a warning: every form of vex fails with `vendor_ledger_corrupt` (see the error-code table). A malformed pre-v5 `redirect-state.json` is, as of v5.0, only the `redirect_ledger_corrupt` warning (hosted mode keeps no ledger; the file is an optional migration record source). ### VEX provenance markers (contract) @@ -1023,7 +1023,7 @@ Empty string means unset at every layer: exported-but-empty flag-bound vars are | `SOCKET_MANIFEST_PATH` | `--manifest-path` | `.socket/manifest.json` | — | | `SOCKET_API_URL` | `--api-url` | `https://api.socket.dev` | — | | `SOCKET_API_TOKEN` | `--api-token` | (none) | Absence selects the public proxy. | -| `SOCKET_ORG_SLUG` | `--org` / `-o` | (auto-resolve) | — | +| `SOCKET_ORG_SLUG` | `--org` / `-o` | (auto-resolve) | Resolved once per run from the token (`GET /v0/organizations`) when omitted; if that fails, the whole run uses the public proxy anonymously (free patches only) and warns. | | `SOCKET_PROXY_URL` | `--proxy-url` | `https://patches-api.socket.dev` | — | | `SOCKET_ECOSYSTEMS` | `--ecosystems` / `-e` | (all) | Comma-separated list. | | `SOCKET_DOWNLOAD_MODE` | `--download-mode` | `diff` | One of `diff` / `file`. | diff --git a/docs/configuration.md b/docs/configuration.md index 274692dcd..96d3c3629 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -24,6 +24,11 @@ For the token, organization, and authenticated API URL, precedence is: | Organization | `--org` | `SOCKET_ORG_SLUG` | `SOCKET_CLI_ORG_SLUG` | `defaultOrg` (also accepts `org`) | | Authenticated API | `--api-url` | `SOCKET_API_URL` | `SOCKET_CLI_API_BASE_URL` | `apiBaseUrl` | +With a token but no organization from any of these sources, Socket Patch asks +the API for it (`GET /v0/organizations`) once per run. If that fails, the whole +run uses the public patch API without the token (free patches only) and warns; +set `--org` or `SOCKET_ORG_SLUG` to use your organization's patches. + The separate Socket CLI writes that file through `socket login` or `socket config`. Socket Patch only reads it. Missing configuration is silent; an unreadable or invalid login file produces a warning and falls back to the other sources. diff --git a/docs/migrating-to-v5.md b/docs/migrating-to-v5.md index e597b32d0..2bc652fc8 100644 --- a/docs/migrating-to-v5.md +++ b/docs/migrating-to-v5.md @@ -28,6 +28,13 @@ existing scripts against the new CLI; the [changelog](../CHANGELOG.md) and - `list` succeeds on an empty project. Hosted results identify lockfiles instead of a hosted ledger. Scripts must use the updated [JSON shapes and exit codes](../crates/socket-patch-cli/CLI_CONTRACT.md#json-output-shapes). +- A token whose organization cannot be resolved no longer queries + `/v0/orgs/default/…`. When no `--org`, `SOCKET_ORG_SLUG` or socket-cli + `defaultOrg` is set and `GET /v0/organizations` fails, the whole run uses the + public proxy anonymously (free patches only) and warns once; `scan --json` and + `get --json` report it as `api_auth_fallback` in `warnings[]`. Set `--org` or + `SOCKET_ORG_SLUG` to get org patches. The org is resolved once per run, so an + embedded `--vex` no longer resolves it again. ## Installation channels From 158ed10fc9837ef9762dccaa873d00cbcaec5482 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 11:25:16 -0400 Subject: [PATCH 3/7] Report the unresolved-org proxy run on every get --json and vex --json path (#648) get --json (agent save, paid_required, not_found) and the search path's not_found / paid_required envelopes now carry the api_auth_fallback warning like the other get paths. Standalone vex --json notes it when it built the run's client itself; a host that seeded its client reports it and adds no duplicate. Human mode still warns once, from client construction. Tests pin the warning count and the new JSON entries. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- crates/socket-patch-cli/src/commands/get.rs | 33 ++++-- .../src/commands/vex_sources.rs | 100 +++++++++++++++++- .../tests/cli/covgap_api_client.rs | 18 ++++ docs/migrating-to-v5.md | 4 +- 5 files changed, 144 insertions(+), 13 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index e9085d53d..f1e412d1a 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -329,7 +329,7 @@ Contract details: * **JSON success surface**: `apply` adds a top-level `vex` object to its envelope; `scan` adds a top-level `vex` key to its result. Both carry `{ path, statements, format: "openvex-0.2.0" }`. * `apply`'s no-manifest early exit (the `noManifest` success no-op; v5.0: its human line is `No patch manifest found; nothing to apply.` — it names the missing `.socket/manifest.json`, not the folder, since `.socket/` may legitimately hold vendored state) and `vendor`'s (`No manifest found, nothing to vendor.` — a project with hosted pins ejects instead, v5.0) still generate the document from the lockfiles and the vendor ledger (manifest-less VEX: hosted / vendored checkouts carry no manifest). Nothing referenced anywhere keeps the calm exit 0 (a stale document at the path is removed; `--json` carries any discovery diagnostics in `warnings[]`); any other VEX failure fails the command with exit 1 — including a run whose only candidates are omitted `record_unavailable` (an `--offline` run over a lockfile-wired checkout with no local records), so an ambient `SOCKET_VEX` there fails the install. `--dry-run` skips generation on both, and so does `apply --check` — it stays read-only and offline-safe, leaving the output path untouched. `scan` has no such early exit: with no manifest and nothing wired anywhere its `--vex` fails with `manifest_not_found`. * **Stale-doc removal (v3.5)**: a run that ends in a VEX error removes a recognizably-OpenVEX file (JSON whose `@context` names openvex.dev) already sitting at the output path — a pipeline reusing one path can never ship yesterday's attestation for a now-unpatched tree. Unrelated files at the path are never touched; a mid-write partial that no longer parses as JSON is left for downstream parsers to reject loudly. -* **Additive warnings (v3.5)**: `product_not_iri` (the `--product`/`--vex-product` override is neither a `pkg:` purl nor an absolute IRI; honored verbatim, warned) and `vendored_tree_out_of_sync` (a healthy vendored attestation stands on the committed artifact + lock wiring while the PRESENT installed tree hash-mismatches the patched bytes — run the package manager's install (for a project with Hatch environments the detail also names `hatch env remove `, since Hatch keeps an installed release); the attestation itself is unchanged). Both ride stderr in human mode and `warnings[]` in the standalone `vex --json` envelope. Same channel for `product_multiple_manifests` (auto-detect found several project manifests and names the one it used), `vex_stale_doc_removed` (the stale-doc removal above happened), the manifest-less plan's advisories — `vex_wiring_conflict` (the lockfiles wire a package to different patches: which files, which uuids, how to fix it), `vex_record_superseded` (a recorded patch replaced by the lockfile-wired one), `vex_claim_unwired` (a ledger claim whose patch the lockfiles still mention, but not as wiring), `vex_record_offline` / `vex_record_not_found` / `vex_record_fetch_failed` (why a lockfile-wired patch has no record — the detail behind a `record_unavailable` skip) and `api_auth_fallback` (the public proxy served free patches only: the authenticated API refused the credentials, `get` / `scan`'s warning text; or, v5.0, a token was set with no `--org` / `SOCKET_ORG_SLUG` / socket-cli `defaultOrg` and the org could not be resolved, so the whole run used the proxy — `scan --json` and `get --json` also report this case in their top-level `warnings[]`) — and, standalone only, `org_looks_like_path` (`-o`/`--org` given a file-shaped value — `-O` is `--output`). The standalone error envelope carries `warnings[]` too. An embedded `--vex` that fails also folds each omitted patch into the host command's `warnings[]` as `vex_omitted` (`: ()` — standalone `vex` lists them as `skipped` events), and `--silent` lists them as `omitted: ()` lines under the error. A corrupt `.socket/vendor/state.json` is no longer degraded with a warning: every form of vex fails with `vendor_ledger_corrupt` (see the error-code table). A malformed pre-v5 `redirect-state.json` is, as of v5.0, only the `redirect_ledger_corrupt` warning (hosted mode keeps no ledger; the file is an optional migration record source). +* **Additive warnings (v3.5)**: `product_not_iri` (the `--product`/`--vex-product` override is neither a `pkg:` purl nor an absolute IRI; honored verbatim, warned) and `vendored_tree_out_of_sync` (a healthy vendored attestation stands on the committed artifact + lock wiring while the PRESENT installed tree hash-mismatches the patched bytes — run the package manager's install (for a project with Hatch environments the detail also names `hatch env remove `, since Hatch keeps an installed release); the attestation itself is unchanged). Both ride stderr in human mode and `warnings[]` in the standalone `vex --json` envelope. Same channel for `product_multiple_manifests` (auto-detect found several project manifests and names the one it used), `vex_stale_doc_removed` (the stale-doc removal above happened), the manifest-less plan's advisories — `vex_wiring_conflict` (the lockfiles wire a package to different patches: which files, which uuids, how to fix it), `vex_record_superseded` (a recorded patch replaced by the lockfile-wired one), `vex_claim_unwired` (a ledger claim whose patch the lockfiles still mention, but not as wiring), `vex_record_offline` / `vex_record_not_found` / `vex_record_fetch_failed` (why a lockfile-wired patch has no record — the detail behind a `record_unavailable` skip) and `api_auth_fallback` (the public proxy served free patches only: the authenticated API refused the credentials, `get` / `scan`'s warning text; or, v5.0, a token was set with no `--org` / `SOCKET_ORG_SLUG` / socket-cli `defaultOrg` and the org could not be resolved, so the whole run used the proxy — standalone `vex --json` carries it when it fetched records, and `scan --json` / `get --json` report this case in their top-level `warnings[]`) — and, standalone only, `org_looks_like_path` (`-o`/`--org` given a file-shaped value — `-O` is `--output`). The standalone error envelope carries `warnings[]` too. An embedded `--vex` that fails also folds each omitted patch into the host command's `warnings[]` as `vex_omitted` (`: ()` — standalone `vex` lists them as `skipped` events), and `--silent` lists them as `omitted: ()` lines under the error. A corrupt `.socket/vendor/state.json` is no longer degraded with a warning: every form of vex fails with `vendor_ledger_corrupt` (see the error-code table). A malformed pre-v5 `redirect-state.json` is, as of v5.0, only the `redirect_ledger_corrupt` warning (hosted mode keeps no ledger; the file is an optional migration record source). ### VEX provenance markers (contract) diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs index 89e0dc926..09be58745 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -2805,6 +2805,7 @@ pub async fn run(args: GetArgs) -> i32 { &patch.uuid, fallback_to_proxy, &telemetry, + &org_warnings, ) .await; } @@ -2843,7 +2844,7 @@ pub async fn run(args: GetArgs) -> i32 { return match mode { // Save to manifest and apply in place. super::scan::ScanMode::Agent => { - save_and_apply_patch(&args, &api_client, &patch).await + save_and_apply_patch(&args, &api_client, &patch, &org_warnings).await } super::scan::ScanMode::Hosted => { let selected = vec![search_result_from_response(&patch)]; @@ -2875,6 +2876,7 @@ pub async fn run(args: GetArgs) -> i32 { &args.identifier, fallback_to_proxy, &telemetry, + &org_warnings, ) .await; } @@ -2887,7 +2889,9 @@ pub async fn run(args: GetArgs) -> i32 { ) .await; if args.common.json { - print_json(&empty_result_json("not_found")); + let mut result = empty_result_json("not_found"); + fold_narrowing_into_result(&mut result, &[], &org_warnings); + print_json(&result); } else if !args.common.silent { println!("No patch found with UUID: {}", args.identifier); } @@ -3000,7 +3004,9 @@ pub async fn run(args: GetArgs) -> i32 { if search_response.patches.is_empty() { if args.common.json { - print_json(&empty_result_json("not_found")); + let mut result = empty_result_json("not_found"); + fold_narrowing_into_result(&mut result, &[], &org_warnings); + print_json(&result); } else if !args.common.silent { println!("No patches found for {}: {}", id_type, args.identifier); } @@ -3019,7 +3025,7 @@ pub async fn run(args: GetArgs) -> i32 { if accessible.is_empty() { if args.common.json { - print_json(&serde_json::json!({ + let mut result = serde_json::json!({ "status": "paid_required", "found": search_response.patches.len(), "downloaded": 0, @@ -3029,7 +3035,9 @@ pub async fn run(args: GetArgs) -> i32 { "uuid": p.uuid, "tier": p.tier, })).collect::>(), - })); + }); + fold_narrowing_into_result(&mut result, &[], &org_warnings); + print_json(&result); } else if !args.common.silent { let all: Vec<&PatchSearchResult> = search_response.patches.iter().collect(); if id_type == IdentifierType::Package && !quiet { @@ -3309,6 +3317,7 @@ async fn report_paid_required_uuid( patch_id: &str, fallback_to_proxy: bool, telemetry: &TelemetryAuth, + org_warnings: &[(String, String)], ) -> i32 { track_patch_fetch_failed(patch_id, "paid_required", fallback_to_proxy, telemetry).await; if args.common.json { @@ -3316,13 +3325,15 @@ async fn report_paid_required_uuid( if let Some(purl) = purl { record["purl"] = serde_json::json!(purl); } - print_json(&serde_json::json!({ + let mut result = serde_json::json!({ "status": "paid_required", "found": 1, "downloaded": 0, "applied": 0, "patches": [record], - })); + }); + fold_narrowing_into_result(&mut result, &[], org_warnings); + print_json(&result); } else if !args.common.silent { let name = purl.map(|p| normalize_purl(p).into_owned()); println!( @@ -3514,7 +3525,12 @@ async fn save_patch_record( /// `--save-only`, apply it — under ONE apply lock, on the `client` the /// fetch used (a fresh client could re-hit the 401/403 its proxy fallback /// just recovered from). -async fn save_and_apply_patch(args: &GetArgs, client: &ApiClient, patch: &PatchResponse) -> i32 { +async fn save_and_apply_patch( + args: &GetArgs, + client: &ApiClient, + patch: &PatchResponse, + org_warnings: &[(String, String)], +) -> i32 { // Same "errors only" gate as `run` — informational prints respect // `--silent`; errors and the JSON envelope do not. let quiet = args.common.json || args.common.silent; @@ -3657,6 +3673,7 @@ async fn save_and_apply_patch(args: &GetArgs, client: &ApiClient, patch: &PatchR if !warnings.is_empty() { result_json["warnings"] = serde_json::json!(warnings); } + fold_narrowing_into_result(&mut result_json, &[], org_warnings); print_json(&result_json); } diff --git a/crates/socket-patch-cli/src/commands/vex_sources.rs b/crates/socket-patch-cli/src/commands/vex_sources.rs index d7d4f5431..ef778dce5 100644 --- a/crates/socket-patch-cli/src/commands/vex_sources.rs +++ b/crates/socket-patch-cli/src/commands/vex_sources.rs @@ -187,8 +187,9 @@ pub(crate) const NOTE_RECORD_OFFLINE: &str = "vex_record_offline"; pub(crate) const NOTE_RECORD_NOT_FOUND: &str = "vex_record_not_found"; /// A record fetch failed (transport, server, paid-only, ...). pub(crate) const NOTE_RECORD_FETCH_FAILED: &str = "vex_record_fetch_failed"; -/// The authenticated API refused the credentials; the public proxy served -/// the retry (free patches only) — `get` / `scan`'s fallback. +/// The public proxy served free patches only: the authenticated API refused +/// the credentials (`get` / `scan`'s fallback), or a token was set but its +/// org could not be resolved, so the run's client is on the proxy. pub(crate) const NOTE_API_AUTH_FALLBACK: &str = "api_auth_fallback"; /// Omission tag and note: a hosted Gradle pin is wired, but a lock file @@ -937,10 +938,20 @@ async fn fetch_records( } let overrides = common.api_client_overrides(); // The run's client: the host's, or built here once (standalone `vex`). + let built_here = !api_client.initialized(); let mut client = api_client .get_or_init(|| async { get_api_client_with_overrides(overrides.clone()).await.0 }) .await .clone(); + // A client built here whose org could not be resolved put the run on + // the proxy. Its construction already warned on stderr, so only the + // `--json` envelope needs the note (a host that seeded its client + // reports this itself). + if built_here && common.json { + if let Some(reason) = client.org_unresolved() { + notes.push(note(NOTE_API_AUTH_FALLBACK, reason.to_string())); + } + } let mut use_public_proxy = client.uses_public_proxy(); let mut pending: Vec = uuids.to_vec(); // Each view is a heavy response: say what the run is waiting on (a live @@ -1178,6 +1189,91 @@ mod tests { assert!(out.contains_key(U1) && out.contains_key(U2), "{out:?}"); } + /// Standalone `vex --json` (no host client): a token whose org cannot + /// be resolved builds a proxy client here, the records come from the + /// proxy, and the envelope gets one `api_auth_fallback` note. A client + /// seeded by a host adds no note (the host reports it). + #[tokio::test] + #[serial_test::serial] + async fn unresolved_org_client_built_here_notes_the_fallback_once() { + use wiremock::matchers::{method, path as wm_path, path_regex}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let keys = ["SOCKET_ORG_SLUG", "SOCKET_OFFLINE", "SOCKET_NO_CONFIG"]; + let saved: Vec<(&str, Option)> = keys + .into_iter() + .map(|k| (k, std::env::var(k).ok())) + .collect(); + std::env::remove_var("SOCKET_ORG_SLUG"); + std::env::remove_var("SOCKET_OFFLINE"); + std::env::set_var("SOCKET_NO_CONFIG", "1"); + + let mock = MockServer::start().await; + Mock::given(method("GET")) + .and(wm_path("/v0/organizations")) + .respond_with(ResponseTemplate::new(500)) + .expect(1) + .mount(&mock) + .await; + Mock::given(path_regex("^/v0/orgs/")) + .respond_with(ResponseTemplate::new(500)) + .expect(0) + .mount(&mock) + .await; + Mock::given(method("GET")) + .and(wm_path(format!("/patch/view/{U1}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "uuid": U1, + "purl": "pkg:npm/vexorg@1.0.0", + "publishedAt": "2026-01-01T00:00:00Z", + "files": {}, + "vulnerabilities": {}, + "description": "", + "license": "MIT", + "tier": "free", + }))) + .mount(&mock) + .await; + + let tmp = tempfile::tempdir().unwrap(); + let common = GlobalArgs { + cwd: tmp.path().to_path_buf(), + json: true, + silent: true, + api_url: Some(mock.uri()), + api_token: Some("sktsec_placeholder_value_for_tests_api".into()), + proxy_url: Some(mock.uri()), + ..GlobalArgs::default() + }; + let run_client = RunApiClient::new(); + let mut notes: Vec = Vec::new(); + let out = fetch_records(&common, &run_client, &[U1.to_string()], &mut notes).await; + // A second fetch on the same run reuses the client: no second + // resolution (the mock's `.expect(1)`) and no second note. + let _ = fetch_records(&common, &run_client, &[U1.to_string()], &mut notes).await; + + for (k, v) in saved { + match v { + Some(v) => std::env::set_var(k, v), + None => std::env::remove_var(k), + } + } + + assert!(out.contains_key(U1), "{out:?}"); + let fallbacks: Vec<_> = notes + .iter() + .filter(|n| n.code == NOTE_API_AUTH_FALLBACK) + .collect(); + assert_eq!(fallbacks.len(), 1, "{notes:?}"); + assert!( + fallbacks[0] + .detail + .contains("Pass --org or set SOCKET_ORG_SLUG"), + "{}", + fallbacks[0].detail + ); + } + fn discovery(refs: Vec) -> Discovery { let mut d = Discovery::default(); for r in refs { diff --git a/crates/socket-patch-cli/tests/cli/covgap_api_client.rs b/crates/socket-patch-cli/tests/cli/covgap_api_client.rs index 5cf2f5ffa..87c7f5e8c 100644 --- a/crates/socket-patch-cli/tests/cli/covgap_api_client.rs +++ b/crates/socket-patch-cli/tests/cli/covgap_api_client.rs @@ -104,6 +104,13 @@ fn assert_hash_token_warnings(stderr: &str, mode: &str) { ), "[{mode}] the warning must say what the run does and how to fix it; stderr={stderr}" ); + assert_eq!( + stderr + .matches("Could not determine your organization") + .count(), + 1, + "[{mode}] the run warns once; stderr={stderr}" + ); assert!( stderr.contains("Hint: --api-token starts with `sha512-`"), "[{mode}] the 401 + hash-shaped token must trigger the stored-hash \ @@ -157,6 +164,17 @@ async fn get_with_hash_shaped_token_under_json_keeps_warnings_and_envelope() { "404 after failed org resolution maps to not_found, got: {v}" ); assert_eq!(v["found"], 0, "not_found envelope reports zero found: {v}"); + // The UUID path reports the startup downgrade in `warnings[]` too. + let warnings = v["warnings"] + .as_array() + .unwrap_or_else(|| panic!("no warnings[]: {v}")); + let prefix = "(api_auth_fallback) Could not determine your organization"; + assert!( + warnings + .iter() + .any(|w| w.as_str().is_some_and(|w| w.starts_with(prefix))), + "api_auth_fallback missing from warnings[]: {v}" + ); } /// `--silent` is "errors only": the same misconfiguration prints neither diff --git a/docs/migrating-to-v5.md b/docs/migrating-to-v5.md index 2bc652fc8..3ef4fc99a 100644 --- a/docs/migrating-to-v5.md +++ b/docs/migrating-to-v5.md @@ -31,8 +31,8 @@ existing scripts against the new CLI; the [changelog](../CHANGELOG.md) and - A token whose organization cannot be resolved no longer queries `/v0/orgs/default/…`. When no `--org`, `SOCKET_ORG_SLUG` or socket-cli `defaultOrg` is set and `GET /v0/organizations` fails, the whole run uses the - public proxy anonymously (free patches only) and warns once; `scan --json` and - `get --json` report it as `api_auth_fallback` in `warnings[]`. Set `--org` or + public proxy anonymously (free patches only) and warns once; `scan --json`, + `get --json` and `vex --json` report it as `api_auth_fallback` in `warnings[]`. Set `--org` or `SOCKET_ORG_SLUG` to get org patches. The org is resolved once per run, so an embedded `--vex` no longer resolves it again. From ac1d288347cd2a6257072f6ce50f9003a2fdb6d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 15:25:43 +0000 Subject: [PATCH 4/7] Report api_auth_fallback in apply/vendor --json warnings apply and vendor seed their embedded --vex with the run's client, which suppresses the VEX plan's own api_auth_fallback note on the promise that the host reports it. scan and get copy org_unresolved() into warnings[], but apply and vendor (including the hosted-pin eject path) never did, so a token whose org failed to resolve was invisible to --json consumers. Add a shared api_auth_fallback_warning() helper and push it onto the apply and vendor envelopes under --json, plus an e2e test covering both. Co-Authored-By: Claude --- crates/socket-patch-cli/src/commands/apply.rs | 7 ++ .../socket-patch-cli/src/commands/vendor.rs | 20 ++++- .../src/commands/vex_sources.rs | 16 ++++ .../tests/cli/covgap_api_client.rs | 79 +++++++++++++++++++ 4 files changed, 120 insertions(+), 2 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/apply.rs b/crates/socket-patch-cli/src/commands/apply.rs index 2d10dc7c1..8e535aecf 100644 --- a/crates/socket-patch-cli/src/commands/apply.rs +++ b/crates/socket-patch-cli/src/commands/apply.rs @@ -1549,6 +1549,13 @@ pub(crate) async fn run_locked( // `warnings[]` is their machine channel — stderr is // suppressed under --json. env.warnings.extend(run_warnings.iter().cloned()); + // A token whose org could not be resolved put the run on the + // proxy (stderr already said so); the embedded `--vex` reuses + // this client and leaves reporting it to the host. + env.warnings + .extend(crate::commands::vex_sources::api_auth_fallback_warning( + client, + )); if !success { env.mark_partial_failure(); } diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index e0bc166ae..a17326841 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -1055,6 +1055,17 @@ pub async fn run(args: VendorArgs) -> i32 { let mut env = Envelope::new(Command::Vendor); env.dry_run = args.common.dry_run; + // A token whose org could not be resolved put the run on the proxy + // (stderr already said so); the embedded `--vex` reuses this client and + // leaves reporting it to the host's `warnings[]`. + if args.common.json { + if let Some(client) = vendor_service.as_ref().and_then(|(s, _)| s.client.as_ref()) { + env.warnings + .extend(crate::commands::vex_sources::api_auth_fallback_warning( + client, + )); + } + } let mut exit = match &vendor_service { None => run_revert(&args, &mut env).await, @@ -1233,8 +1244,7 @@ async fn run_check(args: &VendorArgs) -> i32 { // know (the ledger was ignored or dropped from the commit along with the // manifest) leaves every fresh install failing; the manifest keys above // cannot see it, so the references are read from the wiring itself. - let references = - crate::commands::vendored_backend::repair::scan_vendor_references(root).await; + let references = crate::commands::vendored_backend::repair::scan_vendor_references(root).await; for (eco, uuid, rel) in references { let ledgered = state .entries @@ -1794,6 +1804,12 @@ async fn run_eject(args: &VendorArgs, pins: Vec) -> i32 { } }; let mut env = Envelope::new(Command::Vendor); + if common.json { + env.warnings + .extend(crate::commands::vex_sources::api_auth_fallback_warning( + &client, + )); + } let restore = socket_patch_core::patch::redirect::upstream::restore_upstream( &common.cwd, &pins, diff --git a/crates/socket-patch-cli/src/commands/vex_sources.rs b/crates/socket-patch-cli/src/commands/vex_sources.rs index 148feb1a6..392c53635 100644 --- a/crates/socket-patch-cli/src/commands/vex_sources.rs +++ b/crates/socket-patch-cli/src/commands/vex_sources.rs @@ -192,6 +192,22 @@ pub(crate) const NOTE_RECORD_FETCH_FAILED: &str = "vex_record_fetch_failed"; /// org could not be resolved, so the run's client is on the proxy. pub(crate) const NOTE_API_AUTH_FALLBACK: &str = "api_auth_fallback"; +/// The run-level `--json` warning for a client whose org could not be +/// resolved (the run is on the public proxy; construction already warned on +/// stderr). A host that seeds embedded `--vex` with its client suppresses +/// the VEX plan's own `api_auth_fallback` note, so the host's envelope must +/// carry this instead (`scan` / `get` / `apply` / `vendor`). +pub(crate) fn api_auth_fallback_warning( + client: &ApiClient, +) -> Option { + client + .org_unresolved() + .map(|reason| crate::json_envelope::RunWarning { + code: NOTE_API_AUTH_FALLBACK.to_string(), + detail: reason.to_string(), + }) +} + /// Omission tag and note: a hosted Gradle pin is wired, but a lock file /// records a release above its base, which that build resolves instead /// (`vex::Unattested`). diff --git a/crates/socket-patch-cli/tests/cli/covgap_api_client.rs b/crates/socket-patch-cli/tests/cli/covgap_api_client.rs index 87c7f5e8c..a83f51e34 100644 --- a/crates/socket-patch-cli/tests/cli/covgap_api_client.rs +++ b/crates/socket-patch-cli/tests/cli/covgap_api_client.rs @@ -348,3 +348,82 @@ async fn unresolved_org_routes_the_whole_scan_vex_run_to_the_proxy_once() { "no proxy request carries the bearer" ); } + +/// #648: `apply` and `vendor` seed their embedded `--vex` with the run's +/// client, which suppresses the VEX plan's own `api_auth_fallback` note on +/// the promise that the host reports it. So, like `scan` / `get`, their +/// `--json` envelopes must carry the downgrade in `warnings[]` (here on a +/// project whose manifest lists no patches: the host path still builds the +/// client, resolves the org once and prints its envelope). +#[tokio::test] +async fn unresolved_org_reaches_apply_and_vendor_json_warnings() { + for command in ["apply", "vendor"] { + let mock = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v0/organizations")) + .respond_with(ResponseTemplate::new(500).set_body_string("boom")) + .expect(1) + .mount(&mock) + .await; + Mock::given(wiremock::matchers::path_regex("^/v0/orgs/")) + .respond_with(ResponseTemplate::new(500)) + .expect(0) + .mount(&mock) + .await; + Mock::given(method("POST")) + .and(path("/patch/telemetry")) + .respond_with(ResponseTemplate::new(201)) + .mount(&mock) + .await; + + let tmp = tempfile::tempdir().unwrap(); + let cwd = tmp.path(); + std::fs::write( + cwd.join("package.json"), + r#"{"name":"app","version":"1.0.0"}"#, + ) + .unwrap(); + std::fs::create_dir_all(cwd.join(".socket")).unwrap(); + std::fs::write(cwd.join(".socket/manifest.json"), r#"{"patches":{}}"#).unwrap(); + let uri = mock.uri(); + let token = format!("sktsec_{}_api", "x".repeat(44)); + let mut cmd = Command::new(binary()); + for (key, _) in std::env::vars() { + if key.starts_with("SOCKET_") { + cmd.env_remove(key); + } + } + let out = cmd + .args([ + command, + "--json", + "--cwd", + cwd.to_str().unwrap(), + "--api-url", + &uri, + "--proxy-url", + &uri, + "--api-token", + &token, + ]) + .env("SOCKET_NO_CONFIG", "1") + .current_dir(cwd) + .output() + .unwrap_or_else(|e| panic!("run socket-patch {command}: {e}")); + let stderr = String::from_utf8_lossy(&out.stderr); + let v = json_stdout(&out); + assert_eq!(v["command"], command, "{v}"); + let fallback = v["warnings"] + .as_array() + .and_then(|w| w.iter().find(|w| w["code"] == "api_auth_fallback")) + .unwrap_or_else(|| { + panic!("[{command}] no api_auth_fallback warning: {v}; stderr={stderr}") + }); + assert!( + fallback["detail"] + .as_str() + .is_some_and(|d| d.contains("Pass --org or set SOCKET_ORG_SLUG")), + "[{command}] the warning says how to fix it: {fallback}" + ); + } +} From bed1b13e8ff3d32d81323ff59cc718d8654d13c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 17:39:52 +0000 Subject: [PATCH 5/7] Report the API auth fallback on every vendor --eject JSON envelope run_eject builds its client (and may fall back to the public proxy) before any envelope is printed, but only the wet-path envelope carried the api_auth_fallback warning. The dry-run success envelope and the fetch-failure / refused envelopes now carry it too, so --json consumers see the same downgrade stderr already reported. Co-Authored-By: Claude --- crates/socket-patch-cli/src/commands/vendor.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index a17326841..37e0e5e49 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -1663,6 +1663,12 @@ async fn run_eject(args: &VendorArgs, pins: Vec) -> i32 { if !fetch_failures.is_empty() { let mut env = Envelope::new(Command::Vendor); env.dry_run = common.dry_run; + if common.json { + env.warnings + .extend(crate::commands::vex_sources::api_auth_fallback_warning( + &client, + )); + } for (purl, detail) in &fetch_failures { report_vendor_failure(common, purl, detail); env.record( @@ -1718,6 +1724,12 @@ async fn run_eject(args: &VendorArgs, pins: Vec) -> i32 { if !refused.is_empty() { let mut env = Envelope::new(Command::Vendor); env.dry_run = common.dry_run; + if common.json { + env.warnings + .extend(crate::commands::vex_sources::api_auth_fallback_warning( + &client, + )); + } for (purl, code, why) in &refused { report_vendor_failure(common, purl, why); env.record( @@ -1741,6 +1753,12 @@ async fn run_eject(args: &VendorArgs, pins: Vec) -> i32 { if common.dry_run { let mut env = Envelope::new(Command::Vendor); env.dry_run = true; + if common.json { + env.warnings + .extend(crate::commands::vex_sources::api_auth_fallback_warning( + &client, + )); + } for pin in &pins { env.record( PatchEvent::new(PatchAction::Applied, pin.purl.clone()).with_reason( From 7336184089ac0ffa0ad58d06e1e91c82a2c8720f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 22:02:23 +0000 Subject: [PATCH 6/7] Report the API auth fallback on the get agent dry-run envelope save_and_apply_patch folds the run's org warnings into the wet-run JSON envelope, but its --dry-run branch called agent_dry_run with empty warning slices. So `get --json --dry-run --mode agent` after a failed org resolve dropped api_auth_fallback from warnings[], even though the client warned on stderr and the search dry-run path (which extends narrow_warnings with org_warnings) reports it. Pass org_warnings through so the preview envelope matches the wet run. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-cli/src/commands/get.rs | 8 +- .../tests/cli/covgap_api_client.rs | 96 +++++++++++++++++++ 2 files changed, 103 insertions(+), 1 deletion(-) diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs index 3505c9c05..c657853e6 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -1902,7 +1902,13 @@ async fn save_and_apply_patch( // A dry run previews against the manifest and writes nothing — not // even the lock (which would create `.socket/`). if args.common.dry_run { - return agent_dry_run(args, &[search_result_from_response(patch)], &[], &[]).await; + return agent_dry_run( + args, + &[search_result_from_response(patch)], + &[], + org_warnings, + ) + .await; } // See `download_and_apply_patches_with`: the RMW runs under the lock, // which also creates `.socket/` and prunes it again when nothing lands; diff --git a/crates/socket-patch-cli/tests/cli/covgap_api_client.rs b/crates/socket-patch-cli/tests/cli/covgap_api_client.rs index a83f51e34..b388afef6 100644 --- a/crates/socket-patch-cli/tests/cli/covgap_api_client.rs +++ b/crates/socket-patch-cli/tests/cli/covgap_api_client.rs @@ -196,6 +196,102 @@ async fn get_with_hash_shaped_token_under_silent_prints_no_warnings() { assert_eq!(json_stdout(&out)["status"], "not_found"); } +/// #648: the uuid path's agent `--dry-run` preview must report the +/// startup downgrade in `warnings[]` like its wet run does. The org +/// resolve 500s, the proxy serves the patch, and `get --json +/// --dry-run --mode agent` previews it without writing anything. +#[tokio::test] +async fn unresolved_org_reaches_get_uuid_dry_run_json_warnings() { + const PATCH: &str = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"; + let mock = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v0/organizations")) + .respond_with(ResponseTemplate::new(500).set_body_string("boom")) + .expect(1) + .mount(&mock) + .await; + Mock::given(wiremock::matchers::path_regex("^/v0/orgs/")) + .respond_with(ResponseTemplate::new(500)) + .expect(0) + .mount(&mock) + .await; + Mock::given(method("GET")) + .and(path(format!("/patch/view/{PATCH}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "uuid": PATCH, + "purl": "pkg:npm/left-pad@1.3.0", + "publishedAt": "Fri, 27 Mar 2026 00:00:00 GMT", + "files": { "package/index.js": { + "beforeHash": "a".repeat(64), "afterHash": "b".repeat(64) + } }, + "vulnerabilities": {}, + "description": "agent patch", + "license": "MIT", + "tier": "free", + }))) + .expect(1) + .mount(&mock) + .await; + Mock::given(method("POST")) + .and(path("/patch/telemetry")) + .respond_with(ResponseTemplate::new(201)) + .mount(&mock) + .await; + + let tmp = tempfile::tempdir().unwrap(); + let cwd = tmp.path(); + let uri = mock.uri(); + let token = format!("sktsec_{}_api", "x".repeat(44)); + let mut cmd = Command::new(binary()); + for (key, _) in std::env::vars() { + if key.starts_with("SOCKET_") { + cmd.env_remove(key); + } + } + let out = cmd + .args([ + "get", + PATCH, + "--json", + "--dry-run", + "--mode", + "agent", + "--yes", + "--cwd", + cwd.to_str().unwrap(), + "--api-url", + &uri, + "--proxy-url", + &uri, + "--api-token", + &token, + ]) + .env("SOCKET_NO_CONFIG", "1") + .current_dir(cwd) + .output() + .expect("run socket-patch get"); + let stderr = String::from_utf8_lossy(&out.stderr); + let v = json_stdout(&out); + assert_eq!( + v["dryRun"], true, + "the agent dry-run envelope: {v}; stderr={stderr}" + ); + assert!( + !cwd.join(".socket").exists(), + "a dry run writes nothing; stderr={stderr}" + ); + let warnings = v["warnings"] + .as_array() + .unwrap_or_else(|| panic!("no warnings[]: {v}; stderr={stderr}")); + let prefix = "(api_auth_fallback) Could not determine your organization"; + assert!( + warnings + .iter() + .any(|w| w.as_str().is_some_and(|w| w.starts_with(prefix))), + "api_auth_fallback missing from the dry-run warnings[]: {v}; stderr={stderr}" + ); +} + /// #648: a token whose org cannot be resolved (here `/v0/organizations` /// answers 500) puts the WHOLE run on the public proxy, decided once. /// `scan --json --vex` on a lockfile-only checkout with a hosted pin runs From 5082b172a7f9f77a06ecbbd6afdc0b48faa18f18 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 00:55:57 +0000 Subject: [PATCH 7/7] Report repair's public-proxy fallback in the --json warnings When a token's org cannot be resolved, repair's download runs on the public proxy and only stderr said so; scan, get, apply and vendor already carry api_auth_fallback in the envelope's warnings[]. Copy it from the download phase's client (not the telemetry-only client, which fetched nothing). Bugbot finding on #1041. Co-Authored-By: Claude --- .../socket-patch-cli/src/commands/repair.rs | 11 ++- .../tests/repair/repair_invariants.rs | 72 +++++++++++++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/crates/socket-patch-cli/src/commands/repair.rs b/crates/socket-patch-cli/src/commands/repair.rs index 60ddcdd91..345dc9355 100644 --- a/crates/socket-patch-cli/src/commands/repair.rs +++ b/crates/socket-patch-cli/src/commands/repair.rs @@ -165,6 +165,11 @@ pub async fn run(args: RepairArgs) -> i32 { // client's public-proxy advisory ahead of its own output. let mut client: Option = None; let result = repair_inner(&args, &manifest_path, &mut client, vendor_references).await; + // Only the download phase's client served this run; one built below for + // telemetry alone fetched nothing, so its org state is not a run warning. + let auth_fallback = client + .as_ref() + .and_then(crate::commands::vex_sources::api_auth_fallback_warning); // Resolve telemetry credentials through the API client the way // apply/rollback/remove do: passing the raw `--api-token`/`--org` flag @@ -189,7 +194,11 @@ pub async fn run(args: RepairArgs) -> i32 { ); match result { - Ok((env, counts)) => { + Ok((mut env, counts)) => { + // A token whose org could not be resolved put the download on the + // public proxy (stderr already said so); `warnings[]` is the + // machine channel for it, as in scan / get / apply / vendor. + env.warnings.extend(auth_fallback); // A repair where some artifacts failed to download is marked a // partial failure inside `repair_inner` (a `Failed` event plus // `mark_partial_failure`). Mirror `apply`: surface that as a diff --git a/crates/socket-patch-cli/tests/repair/repair_invariants.rs b/crates/socket-patch-cli/tests/repair/repair_invariants.rs index 9467b068f..ba483c50d 100644 --- a/crates/socket-patch-cli/tests/repair/repair_invariants.rs +++ b/crates/socket-patch-cli/tests/repair/repair_invariants.rs @@ -784,6 +784,78 @@ fn gc_alias_behaves_identically_to_repair() { // Online fetch path — exercises the network branch via mock server // --------------------------------------------------------------------------- +/// #648: a token whose org cannot be resolved (`/v0/organizations` answers +/// 500) puts repair's download on the public proxy. Stderr says so at client +/// construction; `--json` must carry the same `api_auth_fallback` warning in +/// `warnings[]`, as scan / get / apply / vendor do. +#[tokio::test] +async fn repair_json_reports_unresolved_org_fallback_in_warnings() { + let content = b"patched-content\n"; + let after_hash = git_sha256(content); + + let mock = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v0/organizations")) + .respond_with(ResponseTemplate::new(500).set_body_string("boom")) + .mount(&mock) + .await; + Mock::given(method("GET")) + .and(path(format!("/patch/blob/{after_hash}"))) + .respond_with(ResponseTemplate::new(200).set_body_bytes(content.to_vec())) + .expect(1) + .mount(&mock) + .await; + Mock::given(method("POST")) + .and(path("/patch/telemetry")) + .respond_with(ResponseTemplate::new(201)) + .mount(&mock) + .await; + + let tmp = tempfile::tempdir().expect("tempdir"); + let socket = tmp.path().join(".socket"); + std::fs::create_dir_all(&socket).unwrap(); + let manifest = MANIFEST_JSON.replace(REFERENCED_HASH, &after_hash); + std::fs::write(socket.join("manifest.json"), manifest).unwrap(); + + let token = format!("sktsec_{}_api", "x".repeat(44)); + let out = socket_cmd(tmp.path()) + .args([ + "repair", + "--json", + "--download-mode", + "file", + "--download-only", + "--api-url", + &mock.uri(), + "--proxy-url", + &mock.uri(), + "--api-token", + &token, + ]) + .env("SOCKET_NO_CONFIG", "1") + .output() + .expect("run socket-patch"); + let stdout = String::from_utf8_lossy(&out.stdout).to_string(); + let stderr = String::from_utf8_lossy(&out.stderr).to_string(); + assert_eq!( + out.status.code(), + Some(0), + "the proxy serves the blob; stdout={stdout}; stderr={stderr}" + ); + let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); + assert_eq!(v["summary"]["downloaded"], 1, "{v}"); + let warnings = v["warnings"] + .as_array() + .unwrap_or_else(|| panic!("no warnings[]: {v}; stderr={stderr}")); + assert!( + warnings.iter().any(|w| w["code"] == "api_auth_fallback" + && w["detail"] + .as_str() + .is_some_and(|d| d.starts_with("Could not determine your organization"))), + "api_auth_fallback missing from repair's warnings[]: {v}; stderr={stderr}" + ); +} + #[tokio::test] async fn repair_online_downloads_missing_blob() { // Manifest references a blob whose content we control. The blob is