Allow configured local inference in native-only installs

This commit is contained in:
baketnk committed 2026-09-24 12:34:50 -04:00
1 parent 1ba0f26e0e
commit a19d95a353
8 files changed
+129 -26

No files matched your search

+2 -1
View File
@@ -26,7 +26,8 @@ are deliberately generic.
The private pipes use unsigned LE32 payload lengths (1..65536), a one-byte
message type and, for requests/replies, unsigned LE64 request ID. `T` + ID
requests reading the fixed clip; `Y` means ready; `F` means load failure
(optional `M` for missing model, `D` for runtime failure); `R` + ID + UTF-8
(`M` for missing/mismatched pinned model or private clip directory, `I` for a
missing Python dependency, `D` for runtime/model load failure); `R` + ID + UTF-8
text and `E` + ID + generic UTF-8 error are replies. Text is at most 4096
bytes. An unexpected or duplicate reply, wrong ID, extra frame, closed pipe
or oversized frame stops the worker. Warmup deadline is 120 s, transcription
+26 -7
View File
@@ -200,7 +200,7 @@ def select(root, link, target):
temp.unlink(missing_ok=True)
def desired_launcher(root):
def desired_launcher(root, legacy=False):
import shlex
q = lambda path: shlex.quote(str(path))
font = root / "current/fonts/font.ttf"
@@ -208,11 +208,29 @@ def desired_launcher(root):
flags = ["--assets", base / "assets", "--font", font,
"--worker", base / "python/frameyap/worker.py"]
args = " ".join(q(item) for item in flags)
# Parse two literal absolute paths, never source/eval this user-owned file.
# Environment overrides remain useful for a one-off explicit launch.
config = ('' if legacy else
'CONFIG=${XDG_CONFIG_HOME:-$HOME/.config}/frameyap/paths.conf\n'
'CONFIG_PYTHON= CONFIG_MODEL=\n'
'if [ -f "$CONFIG" ] && [ ! -L "$CONFIG" ]; then\n'
' while IFS= read -r line || [ -n "$line" ]; do\n'
' case "$line" in\n'
' python=/*) CONFIG_PYTHON=${line#python=};;\n'
' model=/*) CONFIG_MODEL=${line#model=};;\n'
' esac\n'
' done < "$CONFIG"\n'
'fi\n')
python_default = (q(base / "runtime/bin/python3") if legacy else
'${CONFIG_PYTHON:-' + q(base / "runtime/bin/python3") + '}')
model_default = (q(base / "model") if legacy else
'${CONFIG_MODEL:-' + q(base / "model") + '}')
return ("#!/bin/sh\n" + MARKER + 'export PYTHONDONTWRITEBYTECODE=1\n'
+ f'export FRAMEYAP_INSTALL_ROOT={q(root)}\n'
+ f'export LD_LIBRARY_PATH={q(base / "lib")}${{LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}}\n'
+ f'PYTHON=${{FRAMEYAP_PYTHON:-{q(base / "runtime/bin/python3")}}}\n'
+ f'MODEL=${{FRAMEYAP_MODEL:-{q(base / "model")}}}\n'
+ config
+ f'PYTHON=${{FRAMEYAP_PYTHON:-{python_default}}}\n'
+ f'MODEL=${{FRAMEYAP_MODEL:-{model_default}}}\n'
+ 'if [ "$#" -eq 0 ]; then set -- --run; fi\n'
+ 'case "$1" in\n'
+ ' --run)\n'
@@ -242,21 +260,22 @@ def desired_desktop(launcher):
f'Exec="{executable}"\nTerminal=false\nCategories=Utility;\n').encode()
def check_owned_file(path, expected):
def check_owned_file(path, expected, alternatives=()):
if path.is_symlink():
fail(f"refusing foreign symlink: {path}")
if path.exists():
if not path.is_file():
fail(f"refusing foreign path: {path}")
data = path.read_bytes()
if data != expected:
if data != expected and data not in alternatives:
fail(f"refusing to replace modified/foreign file: {path}")
def check_wrappers(root, launcher):
manifest = root / "frameyap.vrmanifest"
desired = (json.dumps(desired_manifest(launcher), sort_keys=True, indent=2) + "\n").encode()
check_owned_file(launcher, desired_launcher(root))
# Accept only the exact earlier managed script for migration.
check_owned_file(launcher, desired_launcher(root), (desired_launcher(root, legacy=True),))
check_owned_file(manifest, desired)
check_owned_file(desktop_path(root), desired_desktop(launcher))
return manifest, desired
@@ -387,7 +406,7 @@ def uninstall(root, launcher):
manifest = root / "frameyap.vrmanifest"
desired = (json.dumps(desired_manifest(launcher), sort_keys=True, indent=2) + "\n").encode()
check_owned_file(manifest, desired)
check_owned_file(launcher, desired_launcher(root))
check_owned_file(launcher, desired_launcher(root), (desired_launcher(root, legacy=True),))
desktop = desktop_path(root)
check_owned_file(desktop, desired_desktop(launcher))
versions = root / "versions"
+13 -6
View File
@@ -25,6 +25,10 @@ except ImportError: # direct executable script
from model_files import REVISION, FILES
class LocalModelError(ValueError):
"""Missing, incomplete or wrong pinned local weights (never a download cue)."""
def read_exact(fd, count):
parts = bytearray()
while len(parts) < count:
@@ -72,17 +76,17 @@ def local_model(path):
"""Require a real local directory with weight file(s), never a hub identifier."""
root = Path(path)
if not root.is_absolute() or not root.is_dir() or root.is_symlink():
raise ValueError("absolute local model directory required")
raise LocalModelError("absolute local model directory required")
for name, (size, expected) in FILES.items():
file = root / name
if not file.is_file() or file.is_symlink() or file.stat().st_size != size:
raise ValueError("pinned local Redux model weights missing or incomplete")
raise LocalModelError("pinned local Redux model weights missing or incomplete")
digest = hashlib.sha256()
with file.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
if digest.hexdigest() != expected:
raise ValueError("local Redux model does not match pinned revision")
raise LocalModelError("local Redux model does not match pinned revision")
return str(root)
@@ -170,14 +174,17 @@ def main(argv=None):
try:
try:
private_dir(args.clip_dir)
# The full hash validation occurs exactly once in load_model.
if not Path(args.model).is_dir():
raise ValueError("missing model")
except Exception:
send_frame(protocol_fd, b"F", b"M")
return 1
try:
model = load_model(args.model, args.threads)
except LocalModelError:
send_frame(protocol_fd, b"F", b"M")
return 1
except ImportError:
send_frame(protocol_fd, b"F", b"I")
return 1
except Exception:
send_frame(protocol_fd, b"F", b"D")
return 1
+26 -7
View File
@@ -189,7 +189,7 @@ def select(root, link, target):
temp.unlink(missing_ok=True)
def desired_launcher(root):
def desired_launcher(root, legacy=False):
import shlex
q = lambda path: shlex.quote(str(path))
font = root / "current/fonts/font.ttf"
@@ -197,11 +197,29 @@ def desired_launcher(root):
flags = ["--assets", base / "assets", "--font", font,
"--worker", base / "python/frameyap/worker.py"]
args = " ".join(q(item) for item in flags)
# Parse two literal absolute paths, never source/eval this user-owned file.
# Environment overrides remain useful for a one-off explicit launch.
config = ('' if legacy else
'CONFIG=${XDG_CONFIG_HOME:-$HOME/.config}/frameyap/paths.conf\n'
'CONFIG_PYTHON= CONFIG_MODEL=\n'
'if [ -f "$CONFIG" ] && [ ! -L "$CONFIG" ]; then\n'
' while IFS= read -r line || [ -n "$line" ]; do\n'
' case "$line" in\n'
' python=/*) CONFIG_PYTHON=${line#python=};;\n'
' model=/*) CONFIG_MODEL=${line#model=};;\n'
' esac\n'
' done < "$CONFIG"\n'
'fi\n')
python_default = (q(base / "runtime/bin/python3") if legacy else
'${CONFIG_PYTHON:-' + q(base / "runtime/bin/python3") + '}')
model_default = (q(base / "model") if legacy else
'${CONFIG_MODEL:-' + q(base / "model") + '}')
return ("#!/bin/sh\n" + MARKER + 'export PYTHONDONTWRITEBYTECODE=1\n'
+ f'export FRAMEYAP_INSTALL_ROOT={q(root)}\n'
+ f'export LD_LIBRARY_PATH={q(base / "lib")}${{LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}}\n'
+ f'PYTHON=${{FRAMEYAP_PYTHON:-{q(base / "runtime/bin/python3")}}}\n'
+ f'MODEL=${{FRAMEYAP_MODEL:-{q(base / "model")}}}\n'
+ config
+ f'PYTHON=${{FRAMEYAP_PYTHON:-{python_default}}}\n'
+ f'MODEL=${{FRAMEYAP_MODEL:-{model_default}}}\n'
+ 'if [ "$#" -eq 0 ]; then set -- --run; fi\n'
+ 'case "$1" in\n'
+ ' --run)\n'
@@ -231,21 +249,22 @@ def desired_desktop(launcher):
f'Exec="{executable}"\nTerminal=false\nCategories=Utility;\n').encode()
def check_owned_file(path, expected):
def check_owned_file(path, expected, alternatives=()):
if path.is_symlink():
fail(f"refusing foreign symlink: {path}")
if path.exists():
if not path.is_file():
fail(f"refusing foreign path: {path}")
data = path.read_bytes()
if data != expected:
if data != expected and data not in alternatives:
fail(f"refusing to replace modified/foreign file: {path}")
def check_wrappers(root, launcher):
manifest = root / "frameyap.vrmanifest"
desired = (json.dumps(desired_manifest(launcher), sort_keys=True, indent=2) + "\n").encode()
check_owned_file(launcher, desired_launcher(root))
# Accept only the exact earlier managed script for migration.
check_owned_file(launcher, desired_launcher(root), (desired_launcher(root, legacy=True),))
check_owned_file(manifest, desired)
check_owned_file(desktop_path(root), desired_desktop(launcher))
return manifest, desired
@@ -376,7 +395,7 @@ def uninstall(root, launcher):
manifest = root / "frameyap.vrmanifest"
desired = (json.dumps(desired_manifest(launcher), sort_keys=True, indent=2) + "\n").encode()
check_owned_file(manifest, desired)
check_owned_file(launcher, desired_launcher(root))
check_owned_file(launcher, desired_launcher(root), (desired_launcher(root, legacy=True),))
desktop = desktop_path(root)
check_owned_file(desktop, desired_desktop(launcher))
versions = root / "versions"
+4 -2
View File
@@ -268,8 +268,10 @@ std::optional<WorkerReply> Worker::poll() {
}
if (type == 'F' && !s.loaded) {
if (size == 2 && s.input[5] == 'M')
throw std::runtime_error("worker missing local model weights or private clip directory");
throw std::runtime_error("worker failed to load local CPU model/runtime");
throw std::runtime_error("Missing/mismatched pinned local model weights or private clip directory; check --model");
if (size == 2 && s.input[5] == 'I')
throw std::runtime_error("Authorized Python lacks compatible CPU moondream/torch dependencies; check --python");
throw std::runtime_error("Local Redux model failed to load; check authorized CPU runtime and weights");
}
if ((type != 'R' && type != 'E') || size < 9 || !s.loaded || !s.pending ||
get64(s.input.data() + 5) != *s.pending || size - 9 > max_text)
+43
View File
@@ -290,6 +290,40 @@ class InstallTests(unittest.TestCase):
self.assertTrue(probe.stdout.startswith("--check-controls\n--assets\n"))
self.assertNotIn("--python", probe.stdout)
def test_external_runtime_paths_config_and_legacy_launcher_migration(self):
import shutil
shutil.rmtree(self.stage / "runtime")
native = self.stage / "bin/frameyap"
native.write_text('#!/bin/sh\nprintf "%s\\n" "$@"\n')
native.chmod(0o755)
a, h = self.package("external", "--external-runtime")
self.install("external", a, h)
root = self.data / "frameyap"
launcher = self.home / ".local/bin/frameyap"
# A pre-config release launcher may be upgraded only when its exact
# bytes match the managed legacy template, not just its marker.
launcher.write_bytes(installer.desired_launcher(root, legacy=True))
self.install("external", a, h)
self.assertEqual(launcher.read_bytes(), installer.desired_launcher(root))
config = self.home / ".config/frameyap/paths.conf"
config.parent.mkdir(parents=True)
config.write_text("# Literal paths, not shell code\npython=/opt/approved python/bin/python3\n"
"model=/opt/$(printf not-executed)/local model\n")
run = subprocess.run([str(launcher)], capture_output=True, text=True, check=True)
self.assertIn("--python\n/opt/approved python/bin/python3\n"
"--model\n/opt/$(printf not-executed)/local model\n", run.stdout)
override = subprocess.run([str(launcher)], capture_output=True, text=True, check=True,
env={**os.environ, "FRAMEYAP_PYTHON": "/other/python"})
self.assertIn("--python\n/other/python\n--model\n/opt/$(printf not-executed)/local model\n", override.stdout)
config.unlink()
config.symlink_to(self.stage / "model/weights.bin")
symlinked = subprocess.run([str(launcher)], capture_output=True, text=True, check=True)
self.assertIn(f"--model\n{root}/current/model\n", symlinked.stdout)
# A user-modified launcher must remain protected, even with the marker.
launcher.write_bytes(installer.desired_launcher(root, legacy=True) + b"# changed\n")
with self.assertRaisesRegex(ValueError, "foreign file"):
self.install("external", a, h)
def test_package_rejects_symlink(self):
(self.stage / "lib/link.so").symlink_to("libtest.so")
result = subprocess.run([sys.executable, str(REPO / "scripts/package-release.py"), "--stage", str(self.stage),
@@ -298,6 +332,15 @@ class InstallTests(unittest.TestCase):
self.assertNotEqual(result.returncode, 0)
self.assertIn("links and special files forbidden", result.stderr)
def test_utc_timestamp_release_tag_roundtrip(self):
version = "2026-09-24T162712Z-g417f81c-dirty"
archive, digest = self.package(version)
self.assertEqual(archive.name, f"frameyap-{version}-linux-aarch64.tar.gz")
self.install(version, archive, digest)
root = self.data / "frameyap"
self.assertEqual(os.readlink(root / "current"), f"versions/{version}")
self.assertEqual(json.loads((root / "current/release.json").read_text())["version"], version)
if __name__ == "__main__":
unittest.main()
+4 -3
View File
@@ -32,8 +32,9 @@ def fake_child():
parser.add_argument("--threads", required=True)
parser.add_argument("--clip-dir", required=True)
args = parser.parse_args()
if args.model == "fail":
worker.send_frame(1, b"F")
if args.model in ("fail", "missing-model", "missing-import"):
worker.send_frame(1, b"F", {"fail": b"", "missing-model": b"M",
"missing-import": b"I"}[args.model])
return
if args.model == "crash":
return
@@ -106,7 +107,7 @@ class WorkerTests(unittest.TestCase):
def test_missing_weights_no_dependency_import(self):
with tempfile.TemporaryDirectory() as path:
with self.assertRaisesRegex(ValueError, "weights missing"):
with self.assertRaisesRegex(worker.LocalModelError, "weights missing"):
worker.local_model(path)
with patch.dict(sys.modules, {"moondream": None}):
with self.assertRaises(ValueError):
+11
View File
@@ -63,6 +63,17 @@ int main(int argc, char** argv) {
return failed;
});
assert(!worker.ready());
for (const auto& [mode, message] : std::vector<std::pair<const char*, const char*>>{
{"missing-model", "check --model"}, {"missing-import", "check --python"}}) {
worker.start(argv[1], argv[2], mode, 2);
failed = false;
until([&] {
try { worker.poll(); }
catch (const std::runtime_error& e) { failed = std::string(e.what()).find(message) != std::string::npos; }
return failed;
});
assert(!worker.ready());
}
worker.start(argv[1], argv[2], "stale", 2);
until([&] { worker.poll(); return worker.ready(); });
worker.submit(4, clip);