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 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 message type and, for requests/replies, unsigned LE64 request ID. `T` + ID
requests reading the fixed clip; `Y` means ready; `F` means load failure 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 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 bytes. An unexpected or duplicate reply, wrong ID, extra frame, closed pipe
or oversized frame stops the worker. Warmup deadline is 120 s, transcription 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) temp.unlink(missing_ok=True)
def desired_launcher(root): def desired_launcher(root, legacy=False):
import shlex import shlex
q = lambda path: shlex.quote(str(path)) q = lambda path: shlex.quote(str(path))
font = root / "current/fonts/font.ttf" font = root / "current/fonts/font.ttf"
@@ -208,11 +208,29 @@ def desired_launcher(root):
flags = ["--assets", base / "assets", "--font", font, flags = ["--assets", base / "assets", "--font", font,
"--worker", base / "python/frameyap/worker.py"] "--worker", base / "python/frameyap/worker.py"]
args = " ".join(q(item) for item in flags) 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' return ("#!/bin/sh\n" + MARKER + 'export PYTHONDONTWRITEBYTECODE=1\n'
+ f'export FRAMEYAP_INSTALL_ROOT={q(root)}\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'export LD_LIBRARY_PATH={q(base / "lib")}${{LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}}\n'
+ f'PYTHON=${{FRAMEYAP_PYTHON:-{q(base / "runtime/bin/python3")}}}\n' + config
+ f'MODEL=${{FRAMEYAP_MODEL:-{q(base / "model")}}}\n' + f'PYTHON=${{FRAMEYAP_PYTHON:-{python_default}}}\n'
+ f'MODEL=${{FRAMEYAP_MODEL:-{model_default}}}\n'
+ 'if [ "$#" -eq 0 ]; then set -- --run; fi\n' + 'if [ "$#" -eq 0 ]; then set -- --run; fi\n'
+ 'case "$1" in\n' + 'case "$1" in\n'
+ ' --run)\n' + ' --run)\n'
@@ -242,21 +260,22 @@ def desired_desktop(launcher):
f'Exec="{executable}"\nTerminal=false\nCategories=Utility;\n').encode() 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(): if path.is_symlink():
fail(f"refusing foreign symlink: {path}") fail(f"refusing foreign symlink: {path}")
if path.exists(): if path.exists():
if not path.is_file(): if not path.is_file():
fail(f"refusing foreign path: {path}") fail(f"refusing foreign path: {path}")
data = path.read_bytes() data = path.read_bytes()
if data != expected: if data != expected and data not in alternatives:
fail(f"refusing to replace modified/foreign file: {path}") fail(f"refusing to replace modified/foreign file: {path}")
def check_wrappers(root, launcher): def check_wrappers(root, launcher):
manifest = root / "frameyap.vrmanifest" manifest = root / "frameyap.vrmanifest"
desired = (json.dumps(desired_manifest(launcher), sort_keys=True, indent=2) + "\n").encode() 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(manifest, desired)
check_owned_file(desktop_path(root), desired_desktop(launcher)) check_owned_file(desktop_path(root), desired_desktop(launcher))
return manifest, desired return manifest, desired
@@ -387,7 +406,7 @@ def uninstall(root, launcher):
manifest = root / "frameyap.vrmanifest" manifest = root / "frameyap.vrmanifest"
desired = (json.dumps(desired_manifest(launcher), sort_keys=True, indent=2) + "\n").encode() desired = (json.dumps(desired_manifest(launcher), sort_keys=True, indent=2) + "\n").encode()
check_owned_file(manifest, desired) 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) desktop = desktop_path(root)
check_owned_file(desktop, desired_desktop(launcher)) check_owned_file(desktop, desired_desktop(launcher))
versions = root / "versions" versions = root / "versions"
+13 -6
View File
@@ -25,6 +25,10 @@ except ImportError: # direct executable script
from model_files import REVISION, FILES 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): def read_exact(fd, count):
parts = bytearray() parts = bytearray()
while len(parts) < count: 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.""" """Require a real local directory with weight file(s), never a hub identifier."""
root = Path(path) root = Path(path)
if not root.is_absolute() or not root.is_dir() or root.is_symlink(): 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(): for name, (size, expected) in FILES.items():
file = root / name file = root / name
if not file.is_file() or file.is_symlink() or file.stat().st_size != size: 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() digest = hashlib.sha256()
with file.open("rb") as stream: with file.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""): for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk) digest.update(chunk)
if digest.hexdigest() != expected: 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) return str(root)
@@ -170,14 +174,17 @@ def main(argv=None):
try: try:
try: try:
private_dir(args.clip_dir) 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: except Exception:
send_frame(protocol_fd, b"F", b"M") send_frame(protocol_fd, b"F", b"M")
return 1 return 1
try: try:
model = load_model(args.model, args.threads) 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: except Exception:
send_frame(protocol_fd, b"F", b"D") send_frame(protocol_fd, b"F", b"D")
return 1 return 1
+26 -7
View File
@@ -189,7 +189,7 @@ def select(root, link, target):
temp.unlink(missing_ok=True) temp.unlink(missing_ok=True)
def desired_launcher(root): def desired_launcher(root, legacy=False):
import shlex import shlex
q = lambda path: shlex.quote(str(path)) q = lambda path: shlex.quote(str(path))
font = root / "current/fonts/font.ttf" font = root / "current/fonts/font.ttf"
@@ -197,11 +197,29 @@ def desired_launcher(root):
flags = ["--assets", base / "assets", "--font", font, flags = ["--assets", base / "assets", "--font", font,
"--worker", base / "python/frameyap/worker.py"] "--worker", base / "python/frameyap/worker.py"]
args = " ".join(q(item) for item in flags) 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' return ("#!/bin/sh\n" + MARKER + 'export PYTHONDONTWRITEBYTECODE=1\n'
+ f'export FRAMEYAP_INSTALL_ROOT={q(root)}\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'export LD_LIBRARY_PATH={q(base / "lib")}${{LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}}\n'
+ f'PYTHON=${{FRAMEYAP_PYTHON:-{q(base / "runtime/bin/python3")}}}\n' + config
+ f'MODEL=${{FRAMEYAP_MODEL:-{q(base / "model")}}}\n' + f'PYTHON=${{FRAMEYAP_PYTHON:-{python_default}}}\n'
+ f'MODEL=${{FRAMEYAP_MODEL:-{model_default}}}\n'
+ 'if [ "$#" -eq 0 ]; then set -- --run; fi\n' + 'if [ "$#" -eq 0 ]; then set -- --run; fi\n'
+ 'case "$1" in\n' + 'case "$1" in\n'
+ ' --run)\n' + ' --run)\n'
@@ -231,21 +249,22 @@ def desired_desktop(launcher):
f'Exec="{executable}"\nTerminal=false\nCategories=Utility;\n').encode() 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(): if path.is_symlink():
fail(f"refusing foreign symlink: {path}") fail(f"refusing foreign symlink: {path}")
if path.exists(): if path.exists():
if not path.is_file(): if not path.is_file():
fail(f"refusing foreign path: {path}") fail(f"refusing foreign path: {path}")
data = path.read_bytes() data = path.read_bytes()
if data != expected: if data != expected and data not in alternatives:
fail(f"refusing to replace modified/foreign file: {path}") fail(f"refusing to replace modified/foreign file: {path}")
def check_wrappers(root, launcher): def check_wrappers(root, launcher):
manifest = root / "frameyap.vrmanifest" manifest = root / "frameyap.vrmanifest"
desired = (json.dumps(desired_manifest(launcher), sort_keys=True, indent=2) + "\n").encode() 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(manifest, desired)
check_owned_file(desktop_path(root), desired_desktop(launcher)) check_owned_file(desktop_path(root), desired_desktop(launcher))
return manifest, desired return manifest, desired
@@ -376,7 +395,7 @@ def uninstall(root, launcher):
manifest = root / "frameyap.vrmanifest" manifest = root / "frameyap.vrmanifest"
desired = (json.dumps(desired_manifest(launcher), sort_keys=True, indent=2) + "\n").encode() desired = (json.dumps(desired_manifest(launcher), sort_keys=True, indent=2) + "\n").encode()
check_owned_file(manifest, desired) 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) desktop = desktop_path(root)
check_owned_file(desktop, desired_desktop(launcher)) check_owned_file(desktop, desired_desktop(launcher))
versions = root / "versions" versions = root / "versions"
+4 -2
View File
@@ -268,8 +268,10 @@ std::optional<WorkerReply> Worker::poll() {
} }
if (type == 'F' && !s.loaded) { if (type == 'F' && !s.loaded) {
if (size == 2 && s.input[5] == 'M') if (size == 2 && s.input[5] == 'M')
throw std::runtime_error("worker missing local model weights or private clip directory"); throw std::runtime_error("Missing/mismatched pinned local model weights or private clip directory; check --model");
throw std::runtime_error("worker failed to load local CPU model/runtime"); 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 || if ((type != 'R' && type != 'E') || size < 9 || !s.loaded || !s.pending ||
get64(s.input.data() + 5) != *s.pending || size - 9 > max_text) 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.assertTrue(probe.stdout.startswith("--check-controls\n--assets\n"))
self.assertNotIn("--python", probe.stdout) 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): def test_package_rejects_symlink(self):
(self.stage / "lib/link.so").symlink_to("libtest.so") (self.stage / "lib/link.so").symlink_to("libtest.so")
result = subprocess.run([sys.executable, str(REPO / "scripts/package-release.py"), "--stage", str(self.stage), 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.assertNotEqual(result.returncode, 0)
self.assertIn("links and special files forbidden", result.stderr) 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__": if __name__ == "__main__":
unittest.main() unittest.main()
+4 -3
View File
@@ -32,8 +32,9 @@ def fake_child():
parser.add_argument("--threads", required=True) parser.add_argument("--threads", required=True)
parser.add_argument("--clip-dir", required=True) parser.add_argument("--clip-dir", required=True)
args = parser.parse_args() args = parser.parse_args()
if args.model == "fail": if args.model in ("fail", "missing-model", "missing-import"):
worker.send_frame(1, b"F") worker.send_frame(1, b"F", {"fail": b"", "missing-model": b"M",
"missing-import": b"I"}[args.model])
return return
if args.model == "crash": if args.model == "crash":
return return
@@ -106,7 +107,7 @@ class WorkerTests(unittest.TestCase):
def test_missing_weights_no_dependency_import(self): def test_missing_weights_no_dependency_import(self):
with tempfile.TemporaryDirectory() as path: with tempfile.TemporaryDirectory() as path:
with self.assertRaisesRegex(ValueError, "weights missing"): with self.assertRaisesRegex(worker.LocalModelError, "weights missing"):
worker.local_model(path) worker.local_model(path)
with patch.dict(sys.modules, {"moondream": None}): with patch.dict(sys.modules, {"moondream": None}):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
+11
View File
@@ -63,6 +63,17 @@ int main(int argc, char** argv) {
return failed; return failed;
}); });
assert(!worker.ready()); 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); worker.start(argv[1], argv[2], "stale", 2);
until([&] { worker.poll(); return worker.ready(); }); until([&] { worker.poll(); return worker.ready(); });
worker.submit(4, clip); worker.submit(4, clip);