Skip to content

Commit 4acb4c7

Browse files
devlintLaurent Guitton
andauthored
fix(desktop): unbreak OAuth in Linux AppImage by de-polluting child env (#49)
* fix(desktop): unbreak OAuth in Linux AppImage by de-polluting child env GitHub & Azure OAuth (and all forge HTTP traffic) shell out to the system `curl` via `hidden_cmd`. In the released Linux AppImage, AppRun rewrites `LD_LIBRARY_PATH` (and friends) to the bundle's libs; the spawned `curl` inherits them, loads ABI-incompatible bundled libssl/libcurl, and dies at TLS init. With `-s` and an unread stderr, that produced an empty body which reached serde_json as the cryptic "Failed to parse device-code response: EOF while parsing a value at line 1 column 0" (issue #48). It worked under `tauri dev` because no AppImage wrapper polluted the env. - `appimage_env_fixes()` (pure, injected env lookup): inside an AppImage (APPDIR/APPIMAGE set), restore each polluted var from AppRun's `<VAR>_ORIG` or drop the override; no-op otherwise so a legitimate LD_LIBRARY_PATH on a normal install is untouched. Applied in `hidden_cmd` for every spawn (curl, gh, git). - `curl_with_status`: add `-S`/--show-error and a `curl_transport_check` that maps a non-zero curl exit to an explicit transport error carrying curl's stderr, instead of letting an empty body surface as a JSON parse error. Tests: 7 new unit tests over the pure helpers (no global env mutation). * test(desktop): headless repro harness for AppImage curl env pollution (#48) Linux-only shell harness that reproduces issue #48 and proves the fix without the GUI, the Tauri toolchain, or an AppImage build: 1. CLEAN — curl with a normal env succeeds 2. POLLUTED — simulate AppRun (APPDIR + a bundle dir whose libcurl/libssl sonames are deliberately broken on LD_LIBRARY_PATH): curl fails with an empty body == the user-visible EOF parse error 3. SCRUBBED — apply hidden_cmd's env fix (restore <VAR>_ORIG / unset): curl succeeds again Exits non-zero unless all stages behave as expected, so it works as a regression test in any Linux env (OrbStack/Lima/UTM/CI). Refuses to run on non-Linux to avoid false results. * docs+ci: CHANGELOG entry for #48 fix + run repro harness in CI - CHANGELOG.md: document the Linux AppImage OAuth fix under [Unreleased]. - ci.yml: new lightweight `appimage-env-repro` job (bash + curl only, no Tauri toolchain) that runs scripts/repro-issue-48-appimage-curl.sh on every push/PR, keeping the regression covered. --------- Co-authored-by: Laurent Guitton <laurent.guitton@dendreo.com>
1 parent 70b5fd6 commit 4acb4c7

5 files changed

Lines changed: 384 additions & 0 deletions

File tree

.github/workflows/ci.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,19 @@ jobs:
3131

3232
- run: pnpm test
3333

34+
appimage-env-repro:
35+
name: AppImage curl env repro (#48)
36+
runs-on: ubuntu-latest
37+
steps:
38+
- uses: actions/checkout@v4
39+
40+
# Headless regression check: reproduces issue #48 (AppRun's
41+
# LD_LIBRARY_PATH pollution breaking the spawned curl) and verifies the
42+
# hidden_cmd env scrub resolves it. Needs only bash + curl, both present
43+
# on ubuntu-latest — no Tauri toolchain or AppImage build required.
44+
- name: Reproduce issue #48 and verify the fix
45+
run: bash scripts/repro-issue-48-appimage-curl.sh
46+
3447
desktop:
3548
name: Desktop Build (${{ matrix.platform }})
3649
needs: test

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Fixed
11+
12+
- **GitHub & Azure OAuth in the Linux AppImage** — sign-in failed in the released AppImage with `Failed to parse device-code response: EOF while parsing a value at line 1 column 0` (worked under `pnpm tauri dev`). The forge HTTP transport shells out to the system `curl`, which inherited the AppImage `AppRun`'s `LD_LIBRARY_PATH` pollution and loaded ABI-incompatible bundled libs, dying before the TLS request completed (empty body → JSON parse error). `hidden_cmd` now de-pollutes the child environment inside an AppImage (restore each `<VAR>_ORIG` saved by AppRun, else drop the override; no-op outside an AppImage), and the curl transport now adds `--show-error` and reports a non-zero curl exit as an explicit transport error instead of a misleading parse error. Reproduced and verified end-to-end on Linux via `scripts/repro-issue-48-appimage-curl.sh`. (#48)
13+
1014
## [2.20.0] - 2026-06-17
1115

1216
### Added

apps/desktop/src-tauri/src/commands/curl_util.rs

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,33 @@ pub(crate) fn run_curl(
5151
.map_err(|e| format!("curl failed: {}", e))
5252
}
5353

54+
/// Decide whether a finished `curl` invocation represents a transport failure
55+
/// and, if so, build a user-facing error.
56+
///
57+
/// curl exits non-zero only on *transport* problems (DNS, connection, TLS,
58+
/// library-load failures) — an HTTP 4xx/5xx still exits 0 and is handled by the
59+
/// caller via the response status. The empty-diagnostic branch is the AppImage
60+
/// bundled-library case from issue #48: without this, an empty body fell
61+
/// through to `serde_json` as a cryptic "EOF while parsing" error.
62+
///
63+
/// `code` is the process exit code (`None` if killed by a signal); `stderr` is
64+
/// curl's captured standard error (populated thanks to `--show-error`).
65+
fn curl_transport_check(success: bool, code: Option<i32>, stderr: &str) -> Result<(), String> {
66+
if success {
67+
return Ok(());
68+
}
69+
let code_str = code.map(|c| c.to_string()).unwrap_or_else(|| "signal".to_string());
70+
let detail = stderr.trim();
71+
if detail.is_empty() {
72+
Err(format!(
73+
"curl request failed (exit {code_str}) with no diagnostic output — \
74+
this often means a bundled-library conflict in the packaged app"
75+
))
76+
} else {
77+
Err(format!("curl request failed (exit {code_str}): {detail}"))
78+
}
79+
}
80+
5481
/// Execute a curl request and return `(http_status, body_text)`.
5582
///
5683
/// Appends `-w "\n__GW_HTTP_STATUS__%{http_code}"` so the HTTP status is
@@ -69,6 +96,7 @@ pub(crate) fn curl_with_status(
6996
const MARKER: &str = "\n__GW_HTTP_STATUS__";
7097
let mut args: Vec<String> = vec![
7198
"-s".to_string(),
99+
"-S".to_string(), // --show-error: keep stderr diagnostics under -s
72100
"-X".to_string(), method.to_string(),
73101
"-H".to_string(), format!("Accept: {}", accept),
74102
];
@@ -91,6 +119,14 @@ pub(crate) fn curl_with_status(
91119
args.push(url.to_string());
92120

93121
let output = run_curl(&args, auth_config)?;
122+
// A non-zero curl exit is a transport failure (DNS/TLS/connection/library
123+
// load) — surface it instead of letting an empty body reach the JSON
124+
// parser as a cryptic "EOF" error.
125+
curl_transport_check(
126+
output.status.success(),
127+
output.status.code(),
128+
&String::from_utf8_lossy(&output.stderr),
129+
)?;
94130
// Consume the Vec<u8> in-place — zero copy on the valid-UTF-8 fast path.
95131
let combined = String::from_utf8(output.stdout)
96132
.map_err(|e| format!("curl returned invalid UTF-8: {}", e))?;
@@ -100,3 +136,41 @@ pub(crate) fn curl_with_status(
100136
};
101137
Ok((status, body))
102138
}
139+
140+
#[cfg(test)]
141+
mod tests {
142+
use super::*;
143+
144+
#[test]
145+
fn transport_ok_when_curl_succeeds() {
146+
assert!(curl_transport_check(true, Some(0), "").is_ok());
147+
// A non-empty stderr is irrelevant when curl exited successfully.
148+
assert!(curl_transport_check(true, Some(0), "Note: using HTTP/2").is_ok());
149+
}
150+
151+
#[test]
152+
fn transport_error_surfaces_curl_stderr() {
153+
let err = curl_transport_check(
154+
false,
155+
Some(35),
156+
"curl: (35) OpenSSL/3.0: error:0A000086:SSL routines",
157+
)
158+
.unwrap_err();
159+
assert!(err.contains("exit 35"), "got: {err}");
160+
assert!(err.contains("error:0A000086"), "got: {err}");
161+
}
162+
163+
#[test]
164+
fn transport_error_explains_empty_diagnostic() {
165+
// The AppImage bundled-library case: curl dies with no stderr at all.
166+
let err = curl_transport_check(false, Some(127), " \n").unwrap_err();
167+
assert!(err.contains("exit 127"), "got: {err}");
168+
assert!(err.contains("bundled-library"), "got: {err}");
169+
}
170+
171+
#[test]
172+
fn transport_error_handles_signal_kill() {
173+
let err = curl_transport_check(false, None, "").unwrap_err();
174+
assert!(err.contains("signal"), "got: {err}");
175+
}
176+
}

apps/desktop/src-tauri/src/git/cmd.rs

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,61 @@ pub(crate) fn git_binary() -> String {
116116
.clone()
117117
}
118118

119+
/// Environment variables an AppImage's `AppRun` rewrites to point at the
120+
/// bundle's own libraries. Any external process we spawn (`curl`, `gh`,
121+
/// `git`…) inherits them and then loads ABI-incompatible bundled libs — e.g.
122+
/// the system `curl` crashing at TLS init and emitting an empty body, which
123+
/// surfaced as the OAuth "Failed to parse device-code response: EOF" failure
124+
/// in the released Linux AppImage (GitHub issue #48). We undo the pollution
125+
/// for child processes so they pick up the *system* libraries.
126+
const APPIMAGE_POLLUTED_VARS: &[&str] = &[
127+
"LD_LIBRARY_PATH",
128+
"LD_PRELOAD",
129+
"GTK_PATH",
130+
"GIO_MODULE_DIR",
131+
"GSETTINGS_SCHEMA_DIR",
132+
"GDK_PIXBUF_MODULE_FILE",
133+
"GDK_PIXBUF_MODULEDIR",
134+
"GST_PLUGIN_SYSTEM_PATH",
135+
"GST_PLUGIN_PATH",
136+
"QT_PLUGIN_PATH",
137+
"PYTHONPATH",
138+
"PYTHONHOME",
139+
"PERLLIB",
140+
];
141+
142+
/// One fix to apply to a spawned child's environment.
143+
#[derive(Debug, PartialEq, Eq)]
144+
enum EnvFix {
145+
/// Restore the value `AppRun` saved (in `<VAR>_ORIG`) before overriding it.
146+
Restore(String),
147+
/// No saved original — drop the override so the system default applies.
148+
Remove,
149+
}
150+
151+
/// Compute the environment fixes needed so a spawned process uses *system*
152+
/// libraries instead of the AppImage's bundled ones.
153+
///
154+
/// `get` is the environment lookup (injected for testability). Returns no
155+
/// fixes when not running inside an AppImage: on a normal install
156+
/// `LD_LIBRARY_PATH` and friends may be set intentionally and must be left
157+
/// untouched. AppImage's `AppRun` always exports `APPDIR`; `APPIMAGE` points at
158+
/// the `.AppImage` file — either marks the bundled runtime.
159+
fn appimage_env_fixes(get: &dyn Fn(&str) -> Option<String>) -> Vec<(&'static str, EnvFix)> {
160+
if get("APPDIR").is_none() && get("APPIMAGE").is_none() {
161+
return Vec::new();
162+
}
163+
let mut fixes = Vec::new();
164+
for &var in APPIMAGE_POLLUTED_VARS {
165+
match get(&format!("{var}_ORIG")) {
166+
Some(orig) => fixes.push((var, EnvFix::Restore(orig))),
167+
None if get(var).is_some() => fixes.push((var, EnvFix::Remove)),
168+
None => {}
169+
}
170+
}
171+
fixes
172+
}
173+
119174
/// Builds a `Command` for any binary with CREATE_NO_WINDOW on Windows.
120175
/// Prevents black CMD console windows from flashing when spawning child processes.
121176
///
@@ -142,6 +197,18 @@ pub(crate) fn hidden_cmd(bin: &str) -> std::process::Command {
142197
}
143198
cmd.env("PATH", enriched);
144199
}
200+
// Undo AppImage library-path pollution so the spawned binary loads system
201+
// libs, not the bundle's. No-op unless we're running inside an AppImage
202+
// (gated by APPDIR/APPIMAGE), so it's safe to run on every platform. See
203+
// `appimage_env_fixes` — this is the fix for the Linux OAuth failure in
204+
// GitHub issue #48.
205+
let get_env = |k: &str| std::env::var(k).ok();
206+
for (var, fix) in appimage_env_fixes(&get_env) {
207+
match fix {
208+
EnvFix::Restore(value) => { cmd.env(var, value); }
209+
EnvFix::Remove => { cmd.env_remove(var); }
210+
}
211+
}
145212
// Defensive: propagate auth tokens explicitly to every subprocess so
146213
// `gh` (and any other CLI that respects these env vars) bypasses the
147214
// macOS keychain helper, which hangs ≥30s when called from a signed
@@ -215,4 +282,57 @@ pub(crate) fn resolve_git_dir(cwd: &str) -> Result<PathBuf, String> {
215282
Ok(path)
216283
}
217284

285+
#[cfg(test)]
286+
mod tests {
287+
use super::*;
288+
use std::collections::HashMap;
289+
290+
/// Build an env-lookup closure backed by a fixed map (no global state).
291+
fn lookup<'a>(map: &'a HashMap<&'a str, &'a str>) -> impl Fn(&str) -> Option<String> + 'a {
292+
move |k: &str| map.get(k).map(|s| s.to_string())
293+
}
294+
295+
#[test]
296+
fn appimage_env_fixes_left_alone_outside_appimage() {
297+
// No APPDIR/APPIMAGE → a legitimately-set LD_LIBRARY_PATH is untouched.
298+
let env: HashMap<&str, &str> = [("LD_LIBRARY_PATH", "/usr/lib/x86_64-linux-gnu")]
299+
.into_iter()
300+
.collect();
301+
assert!(appimage_env_fixes(&lookup(&env)).is_empty());
302+
}
303+
304+
#[test]
305+
fn appimage_env_fixes_removes_polluted_var_without_orig() {
306+
// Inside an AppImage, a bundled LD_LIBRARY_PATH with no saved original
307+
// is dropped so the child falls back to the system default.
308+
let env: HashMap<&str, &str> =
309+
[("APPDIR", "/tmp/.mount_app"), ("LD_LIBRARY_PATH", "/tmp/.mount_app/usr/lib")]
310+
.into_iter()
311+
.collect();
312+
assert_eq!(
313+
appimage_env_fixes(&lookup(&env)),
314+
vec![("LD_LIBRARY_PATH", EnvFix::Remove)]
315+
);
316+
}
317+
318+
#[test]
319+
fn appimage_env_fixes_restores_apprun_saved_original() {
320+
// AppRun stashes the pre-override value in <VAR>_ORIG; restore it.
321+
let env: HashMap<&str, &str> = [
322+
("APPIMAGE", "/home/u/GitWand.AppImage"),
323+
("LD_LIBRARY_PATH", "/tmp/.mount_app/usr/lib"),
324+
("LD_LIBRARY_PATH_ORIG", "/usr/lib:/usr/local/lib"),
325+
]
326+
.into_iter()
327+
.collect();
328+
assert_eq!(
329+
appimage_env_fixes(&lookup(&env)),
330+
vec![(
331+
"LD_LIBRARY_PATH",
332+
EnvFix::Restore("/usr/lib:/usr/local/lib".to_string())
333+
)]
334+
);
335+
}
336+
}
337+
218338

0 commit comments

Comments
 (0)