mirror of
https://github.com/baketnk/frame-yap.git
synced 2026-10-06 00:00:05 +02:00
Allow configured local inference in native-only installs
This commit is contained in:
1 parent
1ba0f26e0e
commit
a19d95a353
8 files changed
+129
-26
No files matched your search
+2
-1
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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):
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user