Add wrapper-script env var support for claude-code's self-update guard
All checks were successful
CI / build (pull_request) Successful in 47s
CI / test (pull_request) Successful in 3m31s
CI / audit (pull_request) Successful in 14s
CI / coverage (pull_request) Successful in 6m59s

The previously-installed claude-code package (2.1.273-1, an AUR build)
wraps its real binary in a /usr/bin/claude script that sets
DISABLE_UPDATES=1 and DISABLE_INSTALLATION_CHECKS=1 before exec-ing
/opt/claude-code/bin/claude — almost certainly to stop Claude Code's own
self-updater from fighting with a package manager already managing it,
which applies just as much to a pkgwatch-managed install. The generated
PKGBUILD had no way to replicate that: it only ever wrote one file.

Add Package::env (a sorted BTreeMap for deterministic output). When set
and non-empty, builder.rs now installs the real binary under
/usr/lib/<pkgname>/ and generates a /usr/bin/<binary_name> wrapper that
exports the declared vars before exec-ing it, written inline via a
quoted heredoc (no bash expansion at PKGBUILD-build time). The wrapper
finds its sibling binary via $(dirname "$0") rather than a hardcoded
absolute path, since /bin/sh is bash on this box and sets $0 to the
full resolved path when found via PATH (confirmed empirically) — so the
same wrapper resolves correctly both under sanity.rs's staging-directory
pkgdir check and after a real pacman install.

Wired claude-code.toml to declare both vars. Verified end to end against
a scratch repo: build succeeds, the sanity check (which now runs through
the wrapper, not the raw binary) passes, and the built package's wrapper
genuinely exports both vars at runtime before exec-ing the real binary
(confirmed by hand, substituting the exec line for an env dump).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Austin Schaefer 2026-09-18 13:07:03 +02:00
parent d96b7a32f6
commit 994cee65f5
3 changed files with 281 additions and 2 deletions

View file

@ -38,3 +38,13 @@ checksum_asset_pattern = "SHASUMS256.txt"
[package.claude-code.sanity_check] [package.claude-code.sanity_check]
command = "claude --version" command = "claude --version"
version_regex = '(\d+\.\d+\.\d+) \(Claude Code\)' version_regex = '(\d+\.\d+\.\d+) \(Claude Code\)'
# The previously-installed AUR package (claude-code 2.1.273-1) shipped these
# via a /usr/bin/claude wrapper around the real /opt/claude-code/bin/claude
# binary — almost certainly to stop Claude Code's own self-updater from
# fighting with a package manager already managing it, which applies just
# as much here. builder.rs replicates that wrapper when `env` is set: real
# binary under /usr/lib/claude-code/, generated /usr/bin/claude wrapper.
[package.claude-code.env]
DISABLE_UPDATES = "1"
DISABLE_INSTALLATION_CHECKS = "1"

View file

@ -98,6 +98,13 @@ fn generate_pkgbuild(req: &BuildRequest) -> Result<String> {
} }
}; };
let package_body = package_body(
req.pkg_name,
binary_name,
&install_source,
req.pkg.env.as_ref(),
)?;
Ok(format!( Ok(format!(
"# Maintainer: pkgwatch (auto-generated — do not edit by hand,\n\ "# Maintainer: pkgwatch (auto-generated — do not edit by hand,\n\
# edits are overwritten on the next update)\n\ # edits are overwritten on the next update)\n\
@ -113,7 +120,7 @@ fn generate_pkgbuild(req: &BuildRequest) -> Result<String> {
sha256sums=('{sha256}')\n\ sha256sums=('{sha256}')\n\
\n\ \n\
package() {{\n\ package() {{\n\
\x20 install -Dm755 \"${{srcdir}}/{install_source}\" \"${{pkgdir}}/usr/bin/{binary_name}\"\n\ {package_body}\
}}\n", }}\n",
name = req.pkg_name, name = req.pkg_name,
version = req.version, version = req.version,
@ -123,6 +130,47 @@ fn generate_pkgbuild(req: &BuildRequest) -> Result<String> {
)) ))
} }
/// Builds the `package()` function body: a plain single-file install, or —
/// when `env` declares variables to export — the real binary installed
/// under `/usr/lib/<pkgname>/` plus a generated `/usr/bin/<binary_name>`
/// wrapper that exports them before `exec`-ing it (see
/// `Package::env`'s doc comment for why this exists).
///
/// The wrapper locates its sibling binary via `$(dirname "$0")` rather
/// than a hardcoded absolute path: on this box `/bin/sh` is `bash`, which
/// sets `$0` to the full resolved path when a script is found via `PATH`
/// (confirmed empirically) — so the same relative lookup resolves
/// correctly both under `sanity.rs`'s staging-directory `pkgdir` check and
/// after a real `pacman` install, with no need to special-case either.
fn package_body(
pkg_name: &str,
binary_name: &str,
install_source: &str,
env: Option<&std::collections::BTreeMap<String, String>>,
) -> Result<String> {
match env.filter(|e| !e.is_empty()) {
None => Ok(format!(
"\x20 install -Dm755 \"${{srcdir}}/{install_source}\" \"${{pkgdir}}/usr/bin/{binary_name}\"\n"
)),
Some(env) => {
let mut exports = String::new();
for (key, value) in env {
validate_env_key(key)?;
validate_single_quoted_safe("env value", value)?;
exports.push_str(&format!("export {key}='{value}'\n"));
}
Ok(format!(
"\x20 install -Dm755 \"${{srcdir}}/{install_source}\" \"${{pkgdir}}/usr/lib/{pkg_name}/{binary_name}\"\n\
\x20 install -Dm755 /dev/stdin \"${{pkgdir}}/usr/bin/{binary_name}\" <<'PKGWATCH_WRAPPER'\n\
#!/bin/sh\n\
{exports}\
exec \"$(dirname \"$0\")/../lib/{pkg_name}/{binary_name}\" \"$@\"\n\
PKGWATCH_WRAPPER\n"
))
}
}
}
/// Matches `<prefix><anything>.pkg.tar.<compression>` — not hardcoded to /// Matches `<prefix><anything>.pkg.tar.<compression>` — not hardcoded to
/// `.zst` specifically, since `PKGEXT` in makepkg.conf can be set to any /// `.zst` specifically, since `PKGEXT` in makepkg.conf can be set to any
/// of pacman's supported compressions (`.xz`, `.gz`, `.bz2`, ...). This /// of pacman's supported compressions (`.xz`, `.gz`, `.bz2`, ...). This
@ -171,6 +219,32 @@ fn validate_shell_safe(field: &str, value: &str) -> Result<()> {
Ok(()) Ok(())
} }
/// A wrapper-script env var name must be a valid POSIX shell identifier —
/// this alone also rules out every shell metacharacter, so `export
/// {key}=...` in the generated wrapper can never be anything but a plain
/// assignment.
fn validate_env_key(key: &str) -> Result<()> {
let valid = !key.is_empty()
&& key.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_')
&& key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
if !valid {
bail!("'{key}' is not a valid environment variable name");
}
Ok(())
}
/// Rejects only what can break out of a *single*-quoted shell string: a
/// literal `'` (ends the quoting early) or a newline (injects an extra
/// statement into the wrapper). Unlike `validate_shell_safe`, `$`/backtick/
/// backslash are fine here — single quotes make them inert, and the
/// generated wrapper only ever embeds `value` inside `export key='value'`.
fn validate_single_quoted_safe(field: &str, value: &str) -> Result<()> {
if value.contains(['\'', '\n']) {
bail!("{field} '{value}' contains an unsafe character for a generated PKGBUILD");
}
Ok(())
}
/// A pacman `pkgver` may only contain alphanumerics, `.`, `_`, `+` — no /// A pacman `pkgver` may only contain alphanumerics, `.`, `_`, `+` — no
/// hyphens (pacman reserves `-` as the pkgver/pkgrel separator in the /// hyphens (pacman reserves `-` as the pkgver/pkgrel separator in the
/// final package filename) and no shell metacharacters. /// final package filename) and no shell metacharacters.
@ -352,6 +426,24 @@ mod tests {
toml::from_str(&toml_text).unwrap() toml::from_str(&toml_text).unwrap()
} }
fn make_package_with_env(entries: &[(&str, &str)]) -> Package {
let env_lines: String = entries
.iter()
.map(|(k, v)| format!("{k} = \"{v}\"\n"))
.collect();
let toml_text = format!(
r#"
repo = "o/r"
asset_pattern = "x"
[verification]
method = "github-attestation"
[env]
{env_lines}
"#
);
toml::from_str(&toml_text).unwrap()
}
#[test] #[test]
fn build_rejects_unsafe_version() { fn build_rejects_unsafe_version() {
let pkg = make_package(None); let pkg = make_package(None);
@ -473,6 +565,137 @@ mod tests {
assert!(generate_pkgbuild(&req).is_err()); assert!(generate_pkgbuild(&req).is_err());
} }
#[test]
fn generate_pkgbuild_with_env_installs_via_lib_and_wrapper() {
let pkg = make_package_with_env(&[
("DISABLE_UPDATES", "1"),
("DISABLE_INSTALLATION_CHECKS", "1"),
]);
let dir = tempfile::tempdir().unwrap();
let artifact_path = dir.path().join("thing.tar.gz");
std::fs::write(&artifact_path, b"data").unwrap();
let req = BuildRequest {
pkg_name: "thing",
pkg: &pkg,
version: "1.0.0",
repo: "o/r",
asset_name: "thing.tar.gz",
download_url: "https://example.com/thing.tar.gz",
artifact_path: &artifact_path,
};
let pkgbuild = generate_pkgbuild(&req).unwrap();
// Real binary goes under /usr/lib/<pkgname>/, not /usr/bin directly.
assert!(pkgbuild.contains(
"install -Dm755 \"${srcdir}/thing/thing\" \"${pkgdir}/usr/lib/thing/thing\""
));
// Wrapper written inline via a quoted heredoc (no bash expansion at
// PKGBUILD-build time) to /usr/bin/<binary_name>.
assert!(pkgbuild.contains(
"install -Dm755 /dev/stdin \"${pkgdir}/usr/bin/thing\" <<'PKGWATCH_WRAPPER'"
));
assert!(pkgbuild.contains("export DISABLE_INSTALLATION_CHECKS='1'"));
assert!(pkgbuild.contains("export DISABLE_UPDATES='1'"));
// Sorted (BTreeMap) — deterministic regardless of TOML source order.
let checks_pos = pkgbuild.find("DISABLE_INSTALLATION_CHECKS").unwrap();
let updates_pos = pkgbuild.find("DISABLE_UPDATES").unwrap();
assert!(checks_pos < updates_pos);
// Relative $0-based lookup, not a hardcoded absolute path — see
// package_body's doc comment for why.
assert!(pkgbuild.contains(r#"exec "$(dirname "$0")/../lib/thing/thing" "$@""#));
}
#[test]
fn generate_pkgbuild_without_env_keeps_single_file_install() {
// Regression guard: packages with no `env` table must keep the
// original one-line install, not gain a wrapper/lib split.
let pkg = make_package(None);
let dir = tempfile::tempdir().unwrap();
let artifact_path = dir.path().join("thing.tar.gz");
std::fs::write(&artifact_path, b"data").unwrap();
let req = BuildRequest {
pkg_name: "thing",
pkg: &pkg,
version: "1.0.0",
repo: "o/r",
asset_name: "thing.tar.gz",
download_url: "https://example.com/thing.tar.gz",
artifact_path: &artifact_path,
};
let pkgbuild = generate_pkgbuild(&req).unwrap();
assert!(!pkgbuild.contains("PKGWATCH_WRAPPER"));
assert!(!pkgbuild.contains("/usr/lib/"));
}
#[test]
fn generate_pkgbuild_rejects_env_value_with_single_quote() {
let pkg = make_package_with_env(&[("FOO", "bar'; touch pwned #")]);
let dir = tempfile::tempdir().unwrap();
let artifact_path = dir.path().join("thing.tar.gz");
std::fs::write(&artifact_path, b"data").unwrap();
let req = BuildRequest {
pkg_name: "thing",
pkg: &pkg,
version: "1.0.0",
repo: "o/r",
asset_name: "thing.tar.gz",
download_url: "https://example.com/thing.tar.gz",
artifact_path: &artifact_path,
};
assert!(generate_pkgbuild(&req).is_err());
}
#[test]
fn generate_pkgbuild_rejects_invalid_env_key() {
let pkg = make_package_with_env(&[("1BAD-KEY", "value")]);
let dir = tempfile::tempdir().unwrap();
let artifact_path = dir.path().join("thing.tar.gz");
std::fs::write(&artifact_path, b"data").unwrap();
let req = BuildRequest {
pkg_name: "thing",
pkg: &pkg,
version: "1.0.0",
repo: "o/r",
asset_name: "thing.tar.gz",
download_url: "https://example.com/thing.tar.gz",
artifact_path: &artifact_path,
};
assert!(generate_pkgbuild(&req).is_err());
}
#[test]
fn validate_env_key_accepts_underscore_and_digits_after_first_char() {
assert!(validate_env_key("DISABLE_UPDATES_2").is_ok());
assert!(validate_env_key("_private").is_ok());
}
#[test]
fn validate_env_key_rejects_leading_digit() {
assert!(validate_env_key("1KEY").is_err());
}
#[test]
fn validate_env_key_rejects_hyphen() {
assert!(validate_env_key("MY-KEY").is_err());
}
#[test]
fn validate_single_quoted_safe_accepts_dollar_and_backtick() {
// Inert inside single quotes, unlike validate_shell_safe's context.
assert!(validate_single_quoted_safe("env value", "$(touch pwned)").is_ok());
assert!(validate_single_quoted_safe("env value", "`touch pwned`").is_ok());
}
#[test]
fn validate_single_quoted_safe_rejects_single_quote() {
assert!(validate_single_quoted_safe("env value", "it's").is_err());
}
#[test] #[test]
fn generate_pkgbuild_rejects_download_url_with_single_quote() { fn generate_pkgbuild_rejects_download_url_with_single_quote() {
let pkg = make_package(None); let pkg = make_package(None);

View file

@ -4,7 +4,7 @@
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use serde::Deserialize; use serde::Deserialize;
use std::collections::HashMap; use std::collections::{BTreeMap, HashMap};
use std::path::Path; use std::path::Path;
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@ -38,6 +38,20 @@ pub struct Package {
/// where the downloaded file *is* the source path already. Defaults to /// where the downloaded file *is* the source path already. Defaults to
/// the stem/`binary_name` convention when omitted. /// the stem/`binary_name` convention when omitted.
pub archive_binary_path: Option<String>, pub archive_binary_path: Option<String>,
/// Environment variables to export before the real binary runs, when
/// the vendor's own install ships them via a wrapper script that
/// `builder.rs` would otherwise not replicate — e.g. claude-code's
/// prior AUR package sets `DISABLE_UPDATES=1`/
/// `DISABLE_INSTALLATION_CHECKS=1` specifically so its self-updater
/// doesn't fight with a package manager already managing it, which
/// applies just as much to pkgwatch-managed installs. `BTreeMap` for
/// deterministic (sorted) ordering in the generated PKGBUILD. When
/// present and non-empty, the real binary installs to
/// `/usr/lib/<pkgname>/<binary_name>` instead of `/usr/bin` directly,
/// and a generated `/usr/bin/<binary_name>` wrapper sets these vars
/// before `exec`-ing it. Omitted or empty: no wrapper, same single-file
/// install as before.
pub env: Option<BTreeMap<String, String>>,
/// Post-build correctness check (not a security control — see /// Post-build correctness check (not a security control — see
/// docs/SPEC.md > Verification trust tiers). Runs `command` against the /// docs/SPEC.md > Verification trust tiers). Runs `command` against the
/// freshly built binary and confirms `version_regex`'s capture group /// freshly built binary and confirms `version_regex`'s capture group
@ -164,6 +178,38 @@ mod tests {
assert_eq!(packages[0].1.verification.tier(), 2); assert_eq!(packages[0].1.verification.tier(), 2);
} }
#[test]
fn loads_env_table_as_sorted_map() {
let dir = tempfile::tempdir().unwrap();
write(
dir.path(),
"pkg.toml",
r#"
[package.pkg]
repo = "o/r"
asset_pattern = "pkg.tar.gz"
[package.pkg.verification]
method = "github-attestation"
[package.pkg.env]
DISABLE_UPDATES = "1"
DISABLE_INSTALLATION_CHECKS = "1"
"#,
);
let packages = load_packages_dir(dir.path()).unwrap();
let env = packages[0].1.env.as_ref().unwrap();
let entries: Vec<(&String, &String)> = env.iter().collect();
assert_eq!(
entries,
vec![
(&"DISABLE_INSTALLATION_CHECKS".to_string(), &"1".to_string()),
(&"DISABLE_UPDATES".to_string(), &"1".to_string()),
]
);
}
#[test] #[test]
fn loads_multiple_files_and_ignores_non_toml() { fn loads_multiple_files_and_ignores_non_toml() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();