Skip to content

Commit 158ed10

Browse files
mikolalysenkoclaude
andcommitted
Report the unresolved-org proxy run on every get --json and vex --json path (#648)
get <uuid> --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) <noreply@anthropic.com>
1 parent a89e1d3 commit 158ed10

5 files changed

Lines changed: 144 additions & 13 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -329,7 +329,7 @@ Contract details:
329329
* **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" }`.
330330
* `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`.
331331
* **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.
332-
* **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 <env>`, 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` (`<purl>: <why> (<errorCode>)` — standalone `vex` lists them as `skipped` events), and `--silent` lists them as `omitted: <purl> (<errorCode>)` 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).
332+
* **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 <env>`, 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` (`<purl>: <why> (<errorCode>)` — standalone `vex` lists them as `skipped` events), and `--silent` lists them as `omitted: <purl> (<errorCode>)` 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).
333333

334334
### VEX provenance markers (contract)
335335

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

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2805,6 +2805,7 @@ pub async fn run(args: GetArgs) -> i32 {
28052805
&patch.uuid,
28062806
fallback_to_proxy,
28072807
&telemetry,
2808+
&org_warnings,
28082809
)
28092810
.await;
28102811
}
@@ -2843,7 +2844,7 @@ pub async fn run(args: GetArgs) -> i32 {
28432844
return match mode {
28442845
// Save to manifest and apply in place.
28452846
super::scan::ScanMode::Agent => {
2846-
save_and_apply_patch(&args, &api_client, &patch).await
2847+
save_and_apply_patch(&args, &api_client, &patch, &org_warnings).await
28472848
}
28482849
super::scan::ScanMode::Hosted => {
28492850
let selected = vec![search_result_from_response(&patch)];
@@ -2875,6 +2876,7 @@ pub async fn run(args: GetArgs) -> i32 {
28752876
&args.identifier,
28762877
fallback_to_proxy,
28772878
&telemetry,
2879+
&org_warnings,
28782880
)
28792881
.await;
28802882
}
@@ -2887,7 +2889,9 @@ pub async fn run(args: GetArgs) -> i32 {
28872889
)
28882890
.await;
28892891
if args.common.json {
2890-
print_json(&empty_result_json("not_found"));
2892+
let mut result = empty_result_json("not_found");
2893+
fold_narrowing_into_result(&mut result, &[], &org_warnings);
2894+
print_json(&result);
28912895
} else if !args.common.silent {
28922896
println!("No patch found with UUID: {}", args.identifier);
28932897
}
@@ -3000,7 +3004,9 @@ pub async fn run(args: GetArgs) -> i32 {
30003004

30013005
if search_response.patches.is_empty() {
30023006
if args.common.json {
3003-
print_json(&empty_result_json("not_found"));
3007+
let mut result = empty_result_json("not_found");
3008+
fold_narrowing_into_result(&mut result, &[], &org_warnings);
3009+
print_json(&result);
30043010
} else if !args.common.silent {
30053011
println!("No patches found for {}: {}", id_type, args.identifier);
30063012
}
@@ -3019,7 +3025,7 @@ pub async fn run(args: GetArgs) -> i32 {
30193025

30203026
if accessible.is_empty() {
30213027
if args.common.json {
3022-
print_json(&serde_json::json!({
3028+
let mut result = serde_json::json!({
30233029
"status": "paid_required",
30243030
"found": search_response.patches.len(),
30253031
"downloaded": 0,
@@ -3029,7 +3035,9 @@ pub async fn run(args: GetArgs) -> i32 {
30293035
"uuid": p.uuid,
30303036
"tier": p.tier,
30313037
})).collect::<Vec<_>>(),
3032-
}));
3038+
});
3039+
fold_narrowing_into_result(&mut result, &[], &org_warnings);
3040+
print_json(&result);
30333041
} else if !args.common.silent {
30343042
let all: Vec<&PatchSearchResult> = search_response.patches.iter().collect();
30353043
if id_type == IdentifierType::Package && !quiet {
@@ -3309,20 +3317,23 @@ async fn report_paid_required_uuid(
33093317
patch_id: &str,
33103318
fallback_to_proxy: bool,
33113319
telemetry: &TelemetryAuth,
3320+
org_warnings: &[(String, String)],
33123321
) -> i32 {
33133322
track_patch_fetch_failed(patch_id, "paid_required", fallback_to_proxy, telemetry).await;
33143323
if args.common.json {
33153324
let mut record = serde_json::json!({ "uuid": patch_id, "tier": "paid" });
33163325
if let Some(purl) = purl {
33173326
record["purl"] = serde_json::json!(purl);
33183327
}
3319-
print_json(&serde_json::json!({
3328+
let mut result = serde_json::json!({
33203329
"status": "paid_required",
33213330
"found": 1,
33223331
"downloaded": 0,
33233332
"applied": 0,
33243333
"patches": [record],
3325-
}));
3334+
});
3335+
fold_narrowing_into_result(&mut result, &[], org_warnings);
3336+
print_json(&result);
33263337
} else if !args.common.silent {
33273338
let name = purl.map(|p| normalize_purl(p).into_owned());
33283339
println!(
@@ -3514,7 +3525,12 @@ async fn save_patch_record(
35143525
/// `--save-only`, apply it — under ONE apply lock, on the `client` the
35153526
/// fetch used (a fresh client could re-hit the 401/403 its proxy fallback
35163527
/// just recovered from).
3517-
async fn save_and_apply_patch(args: &GetArgs, client: &ApiClient, patch: &PatchResponse) -> i32 {
3528+
async fn save_and_apply_patch(
3529+
args: &GetArgs,
3530+
client: &ApiClient,
3531+
patch: &PatchResponse,
3532+
org_warnings: &[(String, String)],
3533+
) -> i32 {
35183534
// Same "errors only" gate as `run` — informational prints respect
35193535
// `--silent`; errors and the JSON envelope do not.
35203536
let quiet = args.common.json || args.common.silent;
@@ -3657,6 +3673,7 @@ async fn save_and_apply_patch(args: &GetArgs, client: &ApiClient, patch: &PatchR
36573673
if !warnings.is_empty() {
36583674
result_json["warnings"] = serde_json::json!(warnings);
36593675
}
3676+
fold_narrowing_into_result(&mut result_json, &[], org_warnings);
36603677
print_json(&result_json);
36613678
}
36623679

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

Lines changed: 98 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -187,8 +187,9 @@ pub(crate) const NOTE_RECORD_OFFLINE: &str = "vex_record_offline";
187187
pub(crate) const NOTE_RECORD_NOT_FOUND: &str = "vex_record_not_found";
188188
/// A record fetch failed (transport, server, paid-only, ...).
189189
pub(crate) const NOTE_RECORD_FETCH_FAILED: &str = "vex_record_fetch_failed";
190-
/// The authenticated API refused the credentials; the public proxy served
191-
/// the retry (free patches only) — `get` / `scan`'s fallback.
190+
/// The public proxy served free patches only: the authenticated API refused
191+
/// the credentials (`get` / `scan`'s fallback), or a token was set but its
192+
/// org could not be resolved, so the run's client is on the proxy.
192193
pub(crate) const NOTE_API_AUTH_FALLBACK: &str = "api_auth_fallback";
193194

194195
/// Omission tag and note: a hosted Gradle pin is wired, but a lock file
@@ -937,10 +938,20 @@ async fn fetch_records(
937938
}
938939
let overrides = common.api_client_overrides();
939940
// The run's client: the host's, or built here once (standalone `vex`).
941+
let built_here = !api_client.initialized();
940942
let mut client = api_client
941943
.get_or_init(|| async { get_api_client_with_overrides(overrides.clone()).await.0 })
942944
.await
943945
.clone();
946+
// A client built here whose org could not be resolved put the run on
947+
// the proxy. Its construction already warned on stderr, so only the
948+
// `--json` envelope needs the note (a host that seeded its client
949+
// reports this itself).
950+
if built_here && common.json {
951+
if let Some(reason) = client.org_unresolved() {
952+
notes.push(note(NOTE_API_AUTH_FALLBACK, reason.to_string()));
953+
}
954+
}
944955
let mut use_public_proxy = client.uses_public_proxy();
945956
let mut pending: Vec<String> = uuids.to_vec();
946957
// Each view is a heavy response: say what the run is waiting on (a live
@@ -1178,6 +1189,91 @@ mod tests {
11781189
assert!(out.contains_key(U1) && out.contains_key(U2), "{out:?}");
11791190
}
11801191

1192+
/// Standalone `vex --json` (no host client): a token whose org cannot
1193+
/// be resolved builds a proxy client here, the records come from the
1194+
/// proxy, and the envelope gets one `api_auth_fallback` note. A client
1195+
/// seeded by a host adds no note (the host reports it).
1196+
#[tokio::test]
1197+
#[serial_test::serial]
1198+
async fn unresolved_org_client_built_here_notes_the_fallback_once() {
1199+
use wiremock::matchers::{method, path as wm_path, path_regex};
1200+
use wiremock::{Mock, MockServer, ResponseTemplate};
1201+
1202+
let keys = ["SOCKET_ORG_SLUG", "SOCKET_OFFLINE", "SOCKET_NO_CONFIG"];
1203+
let saved: Vec<(&str, Option<String>)> = keys
1204+
.into_iter()
1205+
.map(|k| (k, std::env::var(k).ok()))
1206+
.collect();
1207+
std::env::remove_var("SOCKET_ORG_SLUG");
1208+
std::env::remove_var("SOCKET_OFFLINE");
1209+
std::env::set_var("SOCKET_NO_CONFIG", "1");
1210+
1211+
let mock = MockServer::start().await;
1212+
Mock::given(method("GET"))
1213+
.and(wm_path("/v0/organizations"))
1214+
.respond_with(ResponseTemplate::new(500))
1215+
.expect(1)
1216+
.mount(&mock)
1217+
.await;
1218+
Mock::given(path_regex("^/v0/orgs/"))
1219+
.respond_with(ResponseTemplate::new(500))
1220+
.expect(0)
1221+
.mount(&mock)
1222+
.await;
1223+
Mock::given(method("GET"))
1224+
.and(wm_path(format!("/patch/view/{U1}")))
1225+
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1226+
"uuid": U1,
1227+
"purl": "pkg:npm/vexorg@1.0.0",
1228+
"publishedAt": "2026-01-01T00:00:00Z",
1229+
"files": {},
1230+
"vulnerabilities": {},
1231+
"description": "",
1232+
"license": "MIT",
1233+
"tier": "free",
1234+
})))
1235+
.mount(&mock)
1236+
.await;
1237+
1238+
let tmp = tempfile::tempdir().unwrap();
1239+
let common = GlobalArgs {
1240+
cwd: tmp.path().to_path_buf(),
1241+
json: true,
1242+
silent: true,
1243+
api_url: Some(mock.uri()),
1244+
api_token: Some("sktsec_placeholder_value_for_tests_api".into()),
1245+
proxy_url: Some(mock.uri()),
1246+
..GlobalArgs::default()
1247+
};
1248+
let run_client = RunApiClient::new();
1249+
let mut notes: Vec<PlanNote> = Vec::new();
1250+
let out = fetch_records(&common, &run_client, &[U1.to_string()], &mut notes).await;
1251+
// A second fetch on the same run reuses the client: no second
1252+
// resolution (the mock's `.expect(1)`) and no second note.
1253+
let _ = fetch_records(&common, &run_client, &[U1.to_string()], &mut notes).await;
1254+
1255+
for (k, v) in saved {
1256+
match v {
1257+
Some(v) => std::env::set_var(k, v),
1258+
None => std::env::remove_var(k),
1259+
}
1260+
}
1261+
1262+
assert!(out.contains_key(U1), "{out:?}");
1263+
let fallbacks: Vec<_> = notes
1264+
.iter()
1265+
.filter(|n| n.code == NOTE_API_AUTH_FALLBACK)
1266+
.collect();
1267+
assert_eq!(fallbacks.len(), 1, "{notes:?}");
1268+
assert!(
1269+
fallbacks[0]
1270+
.detail
1271+
.contains("Pass --org or set SOCKET_ORG_SLUG"),
1272+
"{}",
1273+
fallbacks[0].detail
1274+
);
1275+
}
1276+
11811277
fn discovery(refs: Vec<PatchedRef>) -> Discovery {
11821278
let mut d = Discovery::default();
11831279
for r in refs {

‎crates/socket-patch-cli/tests/cli/covgap_api_client.rs‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,13 @@ fn assert_hash_token_warnings(stderr: &str, mode: &str) {
104104
),
105105
"[{mode}] the warning must say what the run does and how to fix it; stderr={stderr}"
106106
);
107+
assert_eq!(
108+
stderr
109+
.matches("Could not determine your organization")
110+
.count(),
111+
1,
112+
"[{mode}] the run warns once; stderr={stderr}"
113+
);
107114
assert!(
108115
stderr.contains("Hint: --api-token starts with `sha512-`"),
109116
"[{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() {
157164
"404 after failed org resolution maps to not_found, got: {v}"
158165
);
159166
assert_eq!(v["found"], 0, "not_found envelope reports zero found: {v}");
167+
// The UUID path reports the startup downgrade in `warnings[]` too.
168+
let warnings = v["warnings"]
169+
.as_array()
170+
.unwrap_or_else(|| panic!("no warnings[]: {v}"));
171+
let prefix = "(api_auth_fallback) Could not determine your organization";
172+
assert!(
173+
warnings
174+
.iter()
175+
.any(|w| w.as_str().is_some_and(|w| w.starts_with(prefix))),
176+
"api_auth_fallback missing from warnings[]: {v}"
177+
);
160178
}
161179

162180
/// `--silent` is "errors only": the same misconfiguration prints neither

‎docs/migrating-to-v5.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,8 @@ existing scripts against the new CLI; the [changelog](../CHANGELOG.md) and
3131
- A token whose organization cannot be resolved no longer queries
3232
`/v0/orgs/default/…`. When no `--org`, `SOCKET_ORG_SLUG` or socket-cli
3333
`defaultOrg` is set and `GET /v0/organizations` fails, the whole run uses the
34-
public proxy anonymously (free patches only) and warns once; `scan --json` and
35-
`get --json` report it as `api_auth_fallback` in `warnings[]`. Set `--org` or
34+
public proxy anonymously (free patches only) and warns once; `scan --json`,
35+
`get --json` and `vex --json` report it as `api_auth_fallback` in `warnings[]`. Set `--org` or
3636
`SOCKET_ORG_SLUG` to get org patches. The org is resolved once per run, so an
3737
embedded `--vex` no longer resolves it again.
3838

0 commit comments

Comments
 (0)