From a19d95a3536fe2efe79d9e86347886de8f0dc848 Mon Sep 17 00:00:00 2001 From: baketnk Date: Thu, 24 Sep 2026 12:34:50 -0400 Subject: [PATCH] Allow configured local inference in native-only installs --- docs/worker.md | 3 ++- install.sh | 33 ++++++++++++++++++++++------- python/frameyap/worker.py | 19 +++++++++++------ scripts/install_payload.py | 33 ++++++++++++++++++++++------- src/worker.cpp | 6 ++++-- tests/test_installer.py | 43 ++++++++++++++++++++++++++++++++++++++ tests/test_worker.py | 7 ++++--- tests/worker_test.cpp | 11 ++++++++++ 8 files changed, 129 insertions(+), 26 deletions(-) diff --git a/docs/worker.md b/docs/worker.md index 27833a3..82d1b82 100644 --- a/docs/worker.md +++ b/docs/worker.md @@ -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 diff --git a/install.sh b/install.sh index afacd7f..e447e95 100755 --- a/install.sh +++ b/install.sh @@ -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" diff --git a/python/frameyap/worker.py b/python/frameyap/worker.py index a06cb41..3d451b0 100644 --- a/python/frameyap/worker.py +++ b/python/frameyap/worker.py @@ -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 diff --git a/scripts/install_payload.py b/scripts/install_payload.py index 8c2ec45..ffcbc2a 100644 --- a/scripts/install_payload.py +++ b/scripts/install_payload.py @@ -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" diff --git a/src/worker.cpp b/src/worker.cpp index 1ef59ac..0d7faeb 100644 --- a/src/worker.cpp +++ b/src/worker.cpp @@ -268,8 +268,10 @@ std::optional 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) diff --git a/tests/test_installer.py b/tests/test_installer.py index 7311ec3..daa1e37 100644 --- a/tests/test_installer.py +++ b/tests/test_installer.py @@ -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() diff --git a/tests/test_worker.py b/tests/test_worker.py index 6fdd275..ac1c3a4 100644 --- a/tests/test_worker.py +++ b/tests/test_worker.py @@ -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): diff --git a/tests/worker_test.cpp b/tests/worker_test.cpp index 838c01a..0e1dd61 100644 --- a/tests/worker_test.cpp +++ b/tests/worker_test.cpp @@ -63,6 +63,17 @@ int main(int argc, char** argv) { return failed; }); assert(!worker.ready()); + for (const auto& [mode, message] : std::vector>{ + {"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);