Keep offline package verification reproducible with cold caches (U5)
Why: An immutable npm lock caches tarballs but packed consumers also require resolver metadata before candidate execution. Evidence: SELF host replay passed 57 composition and CI credential cases; real npm 10 and 11 cold-cache consumers passed without lock changes. Impact: Acquire only installed exact-version npmjs metadata with clean config, preserve cache paths for offline execution, adopt the repaired Frontend source and record immutable CI fixtures. Refs: R6-R8, R14; dependency-workspace U4/U5; UI 63d289e and Frontend 5467517
This commit is contained in:
@@ -25,6 +25,8 @@ SHA = re.compile(r"[0-9a-f]{40}\Z")
|
||||
IDS = re.compile(r"[A-Za-z0-9][A-Za-z0-9_-]*\Z")
|
||||
BACKEND = "Agent-System-Backend"
|
||||
FRONTEND = "Agent-System-Frontend"
|
||||
NPM_NAME = re.compile(r"(?:@[a-z0-9][a-z0-9._~-]*/)?[a-z0-9][a-z0-9._~-]*\Z")
|
||||
NPM_VERSION = re.compile(r"[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?\Z")
|
||||
|
||||
|
||||
class CompositionError(Exception):
|
||||
@@ -165,6 +167,11 @@ def execution_environment(env):
|
||||
or upper in {"SSH_AUTH_SOCK", "SSH_AGENT_PID", "NODE_OPTIONS", "BASH_ENV", "ENV", "GOFLAGS", "GIT_ASKPASS", "SSH_ASKPASS", "CONTRACTS_TARBALL", "UI_TARBALL"}):
|
||||
del clean[key]
|
||||
clean.update(GOWORK="off", GOTOOLCHAIN="local", GOFLAGS="", CGO_ENABLED="0")
|
||||
# Cache location is transport-independent state, shared by acquisition and
|
||||
# offline consumers. Other npm settings can carry credentials or hooks.
|
||||
for key in ("npm_config_cache", "NPM_CONFIG_CACHE"):
|
||||
if env.get(key):
|
||||
clean[key] = env[key]
|
||||
return clean
|
||||
|
||||
|
||||
@@ -251,6 +258,24 @@ def go_dependencies(source, fetch_env):
|
||||
"downloads": [{key: value[key] for key in ("Path", "Version", "Sum", "GoModSum", "Origin") if key in value} for value in downloads]}
|
||||
|
||||
|
||||
def npm_registry_package(location, package):
|
||||
resolved = package.get("resolved", "")
|
||||
if not isinstance(resolved, str):
|
||||
raise CompositionError("invalid npm lock dependency")
|
||||
if not resolved.startswith(("https://", "http://")):
|
||||
return None
|
||||
registry = urlsplit(resolved)
|
||||
if (registry.scheme != "https" or registry.netloc != "registry.npmjs.org" or
|
||||
registry.username or registry.password or registry.query or registry.fragment):
|
||||
raise CompositionError("npm tarball source is outside the reviewed public registry")
|
||||
name = package.get("name") or location.rsplit("node_modules/", 1)[-1]
|
||||
version = package.get("version", "")
|
||||
if (not isinstance(name, str) or not NPM_NAME.fullmatch(name) or
|
||||
not isinstance(version, str) or not NPM_VERSION.fullmatch(version)):
|
||||
raise CompositionError("invalid locked registry package name or version")
|
||||
return name + "@" + version
|
||||
|
||||
|
||||
def npm_dependencies(source, components, fetch_env):
|
||||
path = source / "package-lock.json"
|
||||
lock = json.loads(path.read_text())
|
||||
@@ -258,7 +283,13 @@ def npm_dependencies(source, components, fetch_env):
|
||||
raise CompositionError("frontend requires a complete npm lock")
|
||||
approved = {component["repo"] for component in components.values()}
|
||||
packages = {}
|
||||
registry_packages = []
|
||||
for name, package in lock["packages"].items():
|
||||
if name:
|
||||
safe_relative(source, name)
|
||||
registry_package = npm_registry_package(name, package)
|
||||
if registry_package:
|
||||
registry_packages.append((name, registry_package))
|
||||
if package.get("link"):
|
||||
raise CompositionError("formal npm build forbids workspace links")
|
||||
for value in [package.get("resolved", ""), *package.get("dependencies", {}).values()]:
|
||||
@@ -273,6 +304,20 @@ def npm_dependencies(source, components, fetch_env):
|
||||
packages[name] = {key: package[key] for key in ("version", "resolved", "integrity") if key in package}
|
||||
before = digest(path)
|
||||
run(["npm", "ci", "--ignore-scripts", "--no-audit", "--no-fund"], source, fetch_env)
|
||||
# npm ci normally stores tarballs without the packuments required when a
|
||||
# disposable packed-package consumer re-resolves peers offline. Fetch the
|
||||
# installed locked versions before candidate execution, from a fixed public
|
||||
# registry with empty config and no acquisition credentials or candidate rc.
|
||||
metadata_env = execution_environment(fetch_env)
|
||||
with tempfile.TemporaryDirectory(prefix="composition-npm-metadata-") as directory:
|
||||
isolated = Path(directory)
|
||||
global_config = isolated / "empty-global.npmrc"
|
||||
global_config.write_text("")
|
||||
selected = {spec for location, spec in registry_packages if (source / location).is_dir()}
|
||||
if selected:
|
||||
run(["npm", "cache", "add", *sorted(selected), "--ignore-scripts",
|
||||
"--registry=https://registry.npmjs.org", "--userconfig=/dev/null",
|
||||
"--globalconfig=" + str(global_config)], isolated, metadata_env)
|
||||
if digest(path) != before:
|
||||
raise CompositionError("npm acquisition changed committed lock")
|
||||
result = {"lock_sha256": before, "packages": packages}
|
||||
|
||||
Reference in New Issue
Block a user