fix(P2.1f): isolate scoped library reads via ARDUINO_DIRECTORIES_USER, not --libraries

Empirically, arduino-cli's --libraries ADDS to the search path — the global
sketchbook is STILL scanned, so the prior commit did NOT isolate reads from the
shared global volume. Point ARDUINO_DIRECTORIES_USER at the scratch sketchbook
instead (its <scratch>/libraries becomes the ONLY user-library dir); cores +
board-manager URLs live in the DATA dir and are untouched.

In-container functest (AVR uno): a cache-only lib compiles via the scope (never
in global); a global-only lib NOT in the manifest is INVISIBLE to the scoped
compile and only recovers via the scan-all retry (manifest_incomplete=True) —
proving the global volume is no longer scanned for a manifest-scoped build.
This commit is contained in:
David Montero 2026-06-07 23:27:44 +02:00
parent 2ee443470d
commit d3f06265ac
2 changed files with 38 additions and 28 deletions

View File

@ -317,7 +317,8 @@ async def _run_compile(
# symmetry but currently ignored — those toolchains don't expose the
# ESP32 partition / PSRAM knobs we're surfacing. P2.1f: the manifest scope
# + owner now flow through so arduino-cli reads the content-addressed cache
# (via --libraries) instead of the shared global volume.
# (via a scoped ARDUINO_DIRECTORIES_USER sketchbook) instead of the shared
# global volume.
result = await arduino_cli.compile(
files, request.board_fqbn, board_options=request.board_options,
allowed_libraries=allowed_libraries, owner_id=owner_id,

View File

@ -4,6 +4,7 @@ import asyncio
import base64
import shutil
import re
import os
from pathlib import Path
from app.core.hooks import materialize_library_scope
@ -285,11 +286,12 @@ class ArduinoCLIService:
`allowed_libraries` is the per-board manifest = library resolution SCOPE
(P2.1f). When set, ONLY those libraries are made visible to arduino-cli
(via a throwaway --libraries dir of symlinks materialized by the pro
overlay from the content-addressed cache / owner store), instead of the
shared global volume. `owner_id` is the project OWNER's id so a shared /
embed compile resolves that owner's custom libraries. None/empty manifest
(or no overlay) -> arduino-cli's default scan-all (legacy parity).
(a throwaway scratch sketchbook of symlinks materialized by the pro
overlay from the content-addressed cache / owner store, pointed at via
ARDUINO_DIRECTORIES_USER), instead of the shared global volume.
`owner_id` is the project OWNER's id so a shared / embed compile resolves
that owner's custom libraries. None/empty manifest (or no overlay) ->
arduino-cli's default sketchbook -> scan-all (legacy parity).
Returns:
dict with keys: success, hex_content, stdout, stderr, error
@ -336,20 +338,27 @@ class ArduinoCLIService:
# P2.1f — manifest-scoped library resolution. Symlink ONLY the
# declared libraries (resolved owner-store -> content-addressed
# cache -> legacy global dir) into a throwaway dir and point
# arduino-cli at it with --libraries, instead of letting it scan
# the shared mutable global volume. None/empty manifest (or no pro
# overlay) -> no flag -> arduino-cli's default scan-all (legacy /
# OSS self-host parity). --libraries only overrides the USER library
# search path; cores + board-manager URLs live in the data dir, so
# RP2040 / ATTinyCore / AVR core resolution stays intact.
# cache -> legacy global dir) into a throwaway scratch sketchbook and
# point arduino-cli's USER directory at it, so it scans ONLY those
# libraries instead of the shared mutable global volume. None/empty
# manifest (or no pro overlay) -> no override -> arduino-cli's default
# sketchbook -> legacy global scan-all (parity).
#
# Mechanism: ARDUINO_DIRECTORIES_USER (the sketchbook), NOT the
# --libraries flag. Verified empirically that `--libraries` ADDS to
# the search path (the global sketchbook is STILL scanned, so it does
# not isolate), whereas pointing ARDUINO_DIRECTORIES_USER at the
# scratch root makes <scratch>/libraries the ONLY user-library dir.
# scope_dir == <scratch>/libraries, so its parent is the sketchbook
# root. Cores + board-manager URLs live in the DATA dir and are
# untouched, so RP2040 / ATTinyCore / AVR core resolution stays intact.
scope_dir = None
try:
scope = materialize_library_scope(allowed_libraries, owner_id)
scope_dir = scope[0] if scope else None
lib_args = (
["--libraries", str(scope_dir)] if scope_dir is not None else []
)
compile_env = dict(os.environ)
if scope_dir is not None:
compile_env["ARDUINO_DIRECTORIES_USER"] = str(scope_dir.parent)
# Run compilation using subprocess.run in a thread (Windows compatible)
# ESP32 lcgamboa emulator requires DIO flash mode and
@ -373,12 +382,10 @@ class ArduinoCLIService:
# this define restores it as uint8_t (the type it was).
"--build-property",
"compiler.cpp.extra_flags=-DBitOrder=uint8_t",
*lib_args,
"--output-dir", str(build_dir),
str(sketch_dir)]
else:
cmd = [self.cli_path, "compile", "--fqbn", board_fqbn,
*lib_args,
"--output-dir", str(build_dir),
str(sketch_dir)]
print(f"Running command: {' '.join(cmd)}")
@ -388,7 +395,8 @@ class ArduinoCLIService:
return subprocess.run(
cmd,
capture_output=True,
text=True
text=True,
env=compile_env,
)
result = await asyncio.to_thread(run_compile)
@ -542,15 +550,16 @@ class ArduinoCLIService:
else:
print("=== Compilation failed ===\n")
# P2.1f graceful fallback (mirrors the ESP-IDF path): a
# manifest-scoped compile uses --libraries, which REPLACES
# the library search path. If the manifest omitted a needed
# library or a transitive dependency, a header goes missing
# and the build hard-fails where the legacy global scan-all
# would have found it. So when a scope was applied and the
# failure is a missing #include, retry ONCE without the
# scope (scan-all) and flag the manifest as incomplete. A
# genuine source error fails both attempts and returns the
# original scoped failure below.
# manifest-scoped compile points ARDUINO_DIRECTORIES_USER at
# a sketchbook holding ONLY the declared libraries, so the
# global volume is not scanned. If the manifest omitted a
# needed library or a transitive dependency, a header goes
# missing and the build hard-fails where the legacy global
# scan-all would have found it. So when a scope was applied
# and the failure is a missing #include, retry ONCE without
# the scope (global scan-all) and flag the manifest as
# incomplete. A genuine source error fails both attempts and
# returns the original scoped failure below.
if scope_dir is not None and _looks_like_missing_header(result.stderr):
print("=== Incomplete manifest — retrying scan-all ===\n")
retry = await self.compile(