diff --git a/CLAUDE.md b/CLAUDE.md index 8147ee9..0648105 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,6 +15,16 @@ plus closed models via fal.ai. > `cd core && python main.py --front-end-root ../dist-web` runs Core and serves the built UI on one > port. +> **GitHub org: `inlineresearch`.** Every repo lives there - never `inline-studio/` or any other +> org in a URL, manifest, or doc. +> +> | Repo | What it is | +> | ----------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | +> | [`inlineresearch/Inline-Studio`](https://github.com/inlineresearch/Inline-Studio) | This repo: the UI client + Inline Core | +> | [`inlineresearch/Inline-Core`](https://github.com/inlineresearch/Inline-Core) | The engine's own repo (subtree source for `core/`) | +> | [`inlineresearch/Inline-Registry`](https://github.com/inlineresearch/Inline-Registry) | The published extension index the Available tab reads | +> | [`inlineresearch/Inline-Studio-Extension-Guide`](https://github.com/inlineresearch/Inline-Studio-Extension-Guide) | The reference extension authors copy | + > Read this file before changing code. It defines the architecture and the non-negotiable rules. ## Mental model (everything is organised around this) @@ -121,6 +131,10 @@ clear "not available" for them. (`src/shared/nodes/`); their execution is Core's fal relay. - **Files & naming.** Components `PascalCase.tsx`, hooks `useX.ts`, one component per file, feature-foldered views. Keep files under ~300 lines without a good reason. +- **Comments are short.** One or two lines, and only for the **why** a reader can't infer from the + code - a non-obvious constraint, a rejected alternative, an ordering that matters. Never narrate + what the code does or restate the line below. If the reasoning needs paragraphs, it belongs in a + doc, not in the source. - **Icons, never emoji.** Never use emoji in the UI (no 🎬/🎵/✂/🔊 as glyphs). Use crisp, consistent line SVG icons (Lucide-style: `viewBox="0 0 24 24"`, `fill="none"`, `stroke="currentColor"`) that inherit color/size via `currentColor` + a size class. Follow the diff --git a/README.md b/README.md index 7c0f959..74e5838 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ It runs as a **single process on one port**: the Inline Core engine (Python) ser - **Video editing on the canvas** - the **Video Director node** is a timeline-in-a-node that assembles your rendered frames into a single cut, with layered audio (the videos' own audio plus your own music/VO), per-input and per-layer volume, an in-node preview to scrub, and high-res export; the **Trim Video/Audio node** lets you drop in a clip, drag the in/out handles over its filmstrip/waveform, and pass just the trimmed segment downstream. - **Local generation, built in** - the Inline Core engine runs diffusion models on your own GPU. Z-Image Turbo from a single model file, no external server to set up. - **API Nodes for hosted models** - run closed models right on the canvas with no GPU. Add a Generate node, pick a model, and bring your own provider key. See [API Nodes](#api-nodes). +- **Community extensions** - install custom nodes from a GitHub repo in one click, security-reviewed and dependency-isolated. Browse the [registry](https://github.com/inlineresearch/Inline-Registry) or [build your own](https://github.com/inlineresearch/Inline-Studio-Extension-Guide). - **Free & open source (MIT)** - one process (Python + a browser); runs on macOS, Windows, and Linux. [**Follow our Animated Short Film with LTX 2.3 and GPT Image Generation tutorial →**](https://inlinestudio.art/projects/circuit-race) @@ -75,12 +76,30 @@ Inline Core is a from-scratch generation engine for local rendering. It keeps th - **A single device policy owns all placement** - device, dtype, offload, and attention, so the same graph runs on a 4090, a 6 GB laptop, pure CPU, or split across several GPUs without touching the graph. - **Bring your own models, no hidden downloads** - a drop-in `models/` layout feeds a typed catalog and versioned node descriptors; nothing is fetched behind your back. -### Multi-GPU: split one image across GPUs +### Community extensions + +Install community-built nodes straight from a GitHub repo, from the Extensions dialog or a repo URL. + +- **One-click install**, with a live stepper showing download, security review, dependency resolution, and activation. +- **Every install is reviewed.** Code that could replace Inline's PyTorch, hide a payload, or run at install time is blocked outright; subprocesses, sockets, and unknown network hosts need your explicit approval. +- **Extensions can't break your setup.** Their dependencies install into their own folder and can never touch the shared torch/diffusers runtime, and genuine conflicts fail at install with both versions named. +- **Nodes appear on the canvas immediately**, with their own params, model downloads, take history, and Run control. No restart, and no frontend code from the author. +- **Toggle any node on or off**, roll back to a previous version, or uninstall, and see when an update is available. +- **Publish by tagging.** Authors list once in the registry; after that a new tag reaches users with no further PR. + +Browse the [**extension registry**](https://github.com/inlineresearch/Inline-Registry), or copy the +[**extension guide**](https://github.com/inlineresearch/Inline-Studio-Extension-Guide) to build your +own: four working nodes, declared model downloads, and a full authoring reference. + +
+Multi-GPU: split one image across GPUs Got two or more GPUs? Inline Core can cut a single image's latency by running its **denoise loop** (the expensive, iterative sampling step) collectively across them. This is not "one image per GPU" (independent renders); it's **one image whose sampling is shared by all the GPUs**, so a single render finishes faster. It's done with [xDiT](https://github.com/xdit-project/xDiT) (`xfuser`), which parallelizes diffusion-transformer inference in an isolated worker group (one process per GPU via `torchrun`, over local IPC). The HTTP server, database, and graph stay single-process; only the denoise distributes, and it sits behind a sampler seam so single-GPU/CPU runs pay no overhead. The split method is chosen from the interconnect Core detects: **PipeFusion** (default, works over plain PCIe) or **Ulysses** (sequence-parallel attention, used when NVLink is present). Turn it on with `./webui.sh --multi-gpu` after `uv pip install -e ".[parallel]"`. +
+ For the full engineering story (the graph/sampler/device-policy design, the node vocabularies, and the xDiT worker group), see **[core/README.md](core/README.md)** and **[core/CLAUDE.md](core/CLAUDE.md)**. ## API Nodes @@ -114,6 +133,9 @@ Prefer pip? `pip install -r requirements.txt` (from the repo root) pulls the eng ### Hardware support +
+GPU, CPU, Apple Silicon, and ROCm setup + Honest status - what's actually been run, versus what has a code path but no one has verified: | Hardware | Status | Extra steps | @@ -153,8 +175,13 @@ Two gotchas: - **Local model coverage is Z-Image Turbo only** today. Flux, SDXL and others are planned; hosted models via [API Nodes](#api-nodes) need no GPU at all. - **1024² with Guidance (CFG) above 0 needs more than 16 GB.** CFG runs the prompt and negative prompt together, doubling the denoise. Z-Image Turbo is distilled to run CFG-free - at Guidance 0, 1024² fits in ~11.5 GB. +
+ ### From source (for UI development) +
+Build the UI and run the engine locally + To hack on the web UI you need [Node.js](https://nodejs.org) 20.11+ as well, and you serve a local SPA build: ```bash @@ -174,6 +201,8 @@ Then open **http://127.0.0.1:8848**. Add your [fal.ai API key](https://fal.ai/da **Hot-reload:** run the engine as above, then in another terminal `npm run dev:web` (Vite serves the UI with HMR and proxies API calls to Core). +
+ ### Command-line options The friendly `webui.sh` launcher (in `core/`) maps flags onto the engine's `INLINE_*` environment knobs; `core/main.py` takes the same flags when you run the engine directly. `./webui.sh --help` lists them all. diff --git a/core/CLAUDE.md b/core/CLAUDE.md index 6d08e37..fc43c1d 100644 --- a/core/CLAUDE.md +++ b/core/CLAUDE.md @@ -6,9 +6,14 @@ models across macOS, Windows, and Linux - from CPU-only boxes and low-VRAM lapto machines that split a single image's sampling across GPUs (via xDiT). **It is the render backend that replaces ComfyUI for Inline.** -> The UI client lives in the separate **Inline Studio / Storyline** repo (`inline-studio`, an Electron -> app). It drives this engine over the `/v1` HTTP + websocket API. Inline Core is headless and knows -> nothing about the UI. +> The UI client lives in the separate **Inline Studio** repo +> ([`inlineresearch/Inline-Studio`](https://github.com/inlineresearch/Inline-Studio)), which vendors +> this engine under `core/` via `git subtree`. It drives the engine over the `/v1` HTTP + websocket +> API; Inline Core is headless and knows nothing about the UI. + +> **GitHub org: `inlineresearch`** - never `inline-studio/` or any other org in a URL, manifest, or +> doc. Sibling repos: `Inline-Studio` (UI + this engine), `Inline-Registry` (the published extension +> index served to the Available tab), `Inline-Studio-Extension-Guide` (the reference extension). > Read this file before changing code. It defines the architecture and the non-negotiable rules. > `README.md` is the user/product-facing version of the same story; this is the engineering contract. @@ -170,6 +175,12 @@ real codec that moves tensors lives with the model runner. - **Typed, strict.** `pyright` in strict mode (`[tool.pyright]`, `typeCheckingMode = "strict"`), all of `src` + `tests`. No silent `Any` leaks across component/graph boundaries. +- **Comments are short.** One or two lines, and only for the **why** a reader can't infer from the + code - a non-obvious constraint, a rejected alternative, an ordering that matters. Module + docstrings: 1-3 sentences. Function docstrings: one line, or none when the signature says it. + Never narrate what the code does, never write an essay in a docstring, never leave a comment that + restates the line below it. If the reasoning genuinely needs paragraphs, it belongs in a doc, not + in the source. - **Lint.** `ruff` with `select = ["E", "F", "I", "UP", "B"]`, line length 100, target `py311`. - **Typed graph, validated before run.** Never execute an unvalidated graph. Edge type-checking (`graph/validate.py` + `port_satisfies`) rejects bad wiring at submit. New port kinds go in diff --git a/core/extensions/.cache/registry.json b/core/extensions/.cache/registry.json new file mode 100644 index 0000000..c5b6d54 --- /dev/null +++ b/core/extensions/.cache/registry.json @@ -0,0 +1,14 @@ +{ + "entries": [ + { + "id": "demo", + "name": "Inline Demo Extension", + "description": "A reference extension: a runnable generate node plus three image nodes.", + "repo": "https://github.com/inlineresearch/Inline-Extension-Reference", + "homepage": "https://github.com/inlineresearch/Inline-Extension-Reference", + "author": "Inline Research", + "tags": ["reference", "image"] + } + ], + "etag": "\"bdbc10b5cdc9f8646693623ba904dda4898bd71f583125fe2eeb69da1968a01e\"" +} diff --git a/core/src/inline_core/config.py b/core/src/inline_core/config.py index 494b6f2..df085b2 100644 --- a/core/src/inline_core/config.py +++ b/core/src/inline_core/config.py @@ -19,6 +19,23 @@ def data_dir() -> Path: return Path(env).expanduser() if env else Path(".inline") +def extensions_dir() -> Path: + """Community extensions root. `INLINE_EXTENSIONS_DIR`, else `./extensions` (so a dev checkout + keeps it beside `./models` and `./.inline`). Holds `state.json`, the host constraint snapshot, + and one directory per installed extension.""" + env = os.environ.get("INLINE_EXTENSIONS_DIR") + return Path(env).expanduser() if env else Path("extensions") + + +def registry_url() -> str: + """Where the Available tab fetches its extension index. `INLINE_EXTENSION_REGISTRY`, else the + public registry. Point it at a fork or a file:// path to test a registry change.""" + return os.environ.get( + "INLINE_EXTENSION_REGISTRY", + "https://raw.githubusercontent.com/inlineresearch/Inline-Registry/main/index.json", + ) + + def server_host() -> str: """Address the /v1 server binds. `INLINE_HOST`, else loopback (`127.0.0.1`).""" return os.environ.get("INLINE_HOST", "127.0.0.1") diff --git a/core/src/inline_core/extensions/__init__.py b/core/src/inline_core/extensions/__init__.py new file mode 100644 index 0000000..16941c4 --- /dev/null +++ b/core/src/inline_core/extensions/__init__.py @@ -0,0 +1,10 @@ +"""Community extensions: installable node extensions from a git repo. + +A **extension** is one repo, installed at one pinned commit, holding N independently toggleable +**modules**, each owning node types, optional model requirements, and optional UI. + +Authors import only ``inline_core.extensions.api``. + +Dependencies get private resolution with install-time conflict detection - not runtime isolation. +See ``importer`` for why the distinction matters. +""" diff --git a/core/src/inline_core/extensions/api.py b/core/src/inline_core/extensions/api.py new file mode 100644 index 0000000..b7e6d0e --- /dev/null +++ b/core/src/inline_core/extensions/api.py @@ -0,0 +1,214 @@ +"""The extension author's public surface. Everything an extension imports lives here. + +An extension ships one ``register(reg: ExtensionRegistrar)`` entry point that registers +``@inline_node``-decorated ``NodeRunner`` classes. + +The decorator has no import-time side effect - it only attaches a descriptor. That is what lets the +same code load into a scratch registry during install validation and the live one on activation. +""" + +from __future__ import annotations + +from collections.abc import Callable, Sequence +from dataclasses import replace +from pathlib import Path +from typing import Any + +from ..device.policy import DevicePolicy +from ..graph.descriptor import NodeDescriptor, ParamField, Port +from ..graph.registry import Registry +from ..graph.runners import NodeRunner +from ..graph.schema import PortKind +from ..media import MediaKind +from ..models.requirements import RequirementsProvider, RequirementsRegistry +from ..runtime.store import TakeStore + +#: Attribute the decorator attaches to a runner class. +DESCRIPTOR_ATTR = "__inline_descriptor__" + +#: ``inline_node`` takes a keyword argument named ``type`` to mirror ``NodeDescriptor.type``, which +#: shadows the builtin inside its body. Captured here so the runtime class check still works. +_type = type + + +class ExtensionError(RuntimeError): + """An extension broke the contract. Fails that extension, never the server.""" + + +def inline_node( + *, + type: str, # noqa: A002 - mirrors NodeDescriptor.type + title: str, + category: str, + inputs: Sequence[Port] = (), + outputs: Sequence[Port] = (), + params: Sequence[ParamField] = (), + output_kind: MediaKind | None = None, + icon: str = "", + hidden: bool = False, +) -> Callable[[type[NodeRunner]], type[NodeRunner]]: + """Attach a ``NodeDescriptor`` to a ``NodeRunner`` subclass. ``source`` is stamped by the + registrar, not settable here, so provenance can't be spoofed.""" + + def decorate(cls: type[NodeRunner]) -> type[NodeRunner]: + # Checked at runtime because the annotation is a promise an author can break. + if not (isinstance(cls, _type) and issubclass(cls, NodeRunner)): # pyright: ignore[reportUnnecessaryIsInstance] + raise ExtensionError(f"@inline_node requires a NodeRunner subclass, got {cls!r}") + setattr( + cls, + DESCRIPTOR_ATTR, + NodeDescriptor( + type=type, + title=title, + category=category, + inputs=tuple(inputs), + outputs=tuple(outputs), + params=tuple(params), + output_kind=output_kind, + icon=icon, + hidden=hidden, + ), + ) + return cls + + return decorate + + +def descriptor_of(cls: type[NodeRunner]) -> NodeDescriptor | None: + descriptor = getattr(cls, DESCRIPTOR_ATTR, None) + return descriptor if isinstance(descriptor, NodeDescriptor) else None + + +class ExtensionRegistrar: + """Handed to an extension's ``register()``. The entire v1 capability surface. + + Namespacing is enforced here, not by convention: channels carry the ``ext::`` prefix, node + types must be manifest-declared, and ``source`` is stamped.""" + + def __init__( + self, + registry: Registry, + extension_id: str, + *, + store: TakeStore, + policy: DevicePolicy, + requirements: RequirementsRegistry, + data_root: Path, + declared_nodes: Sequence[str] = (), + enabled_nodes: Sequence[str] | None = None, + rpc: Any = None, + events: Any = None, + ) -> None: + self._registry = registry + self._extension_id = extension_id + self._store = store + self._policy = policy + self._requirements = requirements + self._data_root = data_root + self._declared = frozenset(declared_nodes) + #: None means "register everything declared" (install-time validation). + self._enabled = frozenset(enabled_nodes) if enabled_nodes is not None else None + self._rpc = rpc + self._events = events + #: What actually landed in the registry, so disabling can be undone precisely. + self.registered_nodes: list[str] = [] + self.registered_channels: list[str] = [] + #: Declared and offered by the author but switched off by the user. + self.skipped_nodes: list[str] = [] + + # --- nodes ----------------------------------------------------------------------------------- + + def node(self, cls: type[NodeRunner], runner: NodeRunner | None = None) -> None: + """Register one ``@inline_node``-decorated runner class. + + A node the user has switched off is validated and then skipped, so toggling it back on is + just another ``register()`` call - never a restart. + """ + descriptor = descriptor_of(cls) + if descriptor is None: + raise ExtensionError(f"{cls.__name__} is missing the @inline_node decorator") + if self._declared and descriptor.type not in self._declared: + raise ExtensionError( + f"node type {descriptor.type!r} is not declared in the manifest's nodes[]" + ) + _check_ports(descriptor) + if self._enabled is not None and descriptor.type not in self._enabled: + self.skipped_nodes.append(descriptor.type) + return + existing = self._registry.has(descriptor.type) + if existing and not self._registry.get(descriptor.type).source.startswith("ext:"): + raise ExtensionError( + f"node type {descriptor.type!r} is already provided by Core and cannot be replaced" + ) + stamped = replace(descriptor, source=f"ext:{self._extension_id}") + self._registry.register(stamped, runner if runner is not None else cls()) + self.registered_nodes.append(descriptor.type) + + def nodes(self, *classes: type[NodeRunner]) -> None: + for cls in classes: + self.node(cls) + + # --- model requirements ----------------------------------------------------------------------- + + def models(self, node_type: str, provider: RequirementsProvider) -> None: + """Declare what a node needs on disk, so it flows through the existing download popup.""" + self._requirements.register(node_type, provider) + + # --- backend channels ------------------------------------------------------------------------ + + def rpc_channel(self, method: str, fn: Callable[..., Any]) -> None: + """Register ``ext::``. The prefix is forced, so an author cannot shadow a + core channel like ``project:open``.""" + if self._rpc is None: + return + import inspect + + channel = self.channel(method) + + async def handler(args: list[Any]) -> Any: + result = fn(*args) + if inspect.isawaitable(result): + result = await result + return result + + self._rpc.register(channel, handler) + self.registered_channels.append(channel) + + def emit(self, event: str, payload: Any) -> None: + """Broadcast ``ext::`` to connected clients.""" + if self._events is not None: + self._events.broadcast(self.channel(event), payload) + + def channel(self, name: str) -> str: + if not name or ":" in name: + raise ExtensionError(f"channel name {name!r} must be a bare method name") + return f"ext:{self._extension_id}:{name}" + + # --- engine handles -------------------------------------------------------------------------- + + @property + def takes(self) -> TakeStore: + return self._store + + @property + def device(self) -> DevicePolicy: + """Placement policy. Never pick a device yourself - ask ``device.placement(role)``.""" + return self._policy + + @property + def data_dir(self) -> Path: + """This extension's private scratch dir. Writing elsewhere is a CRITICAL scanner finding.""" + path = self._data_root / self._extension_id + path.mkdir(parents=True, exist_ok=True) + return path + + +def _check_ports(descriptor: NodeDescriptor) -> None: + """Reject custom port kinds: ``port_satisfies`` must stay total, or graph validation could no + longer decide edge legality without running extension code.""" + for port in (*descriptor.inputs, *descriptor.outputs): + if not isinstance(port.kind, PortKind): # pyright: ignore[reportUnnecessaryIsInstance] + raise ExtensionError( + f"port {port.id!r} on {descriptor.type!r} uses an unsupported kind " + f"{port.kind!r}; extensions must use the built-in PortKind values" + ) diff --git a/core/src/inline_core/extensions/constraints.py b/core/src/inline_core/extensions/constraints.py new file mode 100644 index 0000000..0b5efe1 --- /dev/null +++ b/core/src/inline_core/extensions/constraints.py @@ -0,0 +1,179 @@ +"""Pins the host's packages so an extension can never replace them. + +An extension may depend on torch/diffusers/transformers but never install its own - two copies in +one process is a corrupted CUDA context, not a version skew. + +The constraint file pins every installed distribution. A requirement needing a different version +fails to resolve, and that failure *is* the host-override signal. +""" + +from __future__ import annotations + +import hashlib +import importlib.metadata +import re +from dataclasses import dataclass +from pathlib import Path + +from .paths import write_atomic + +#: Packages an extension may never provide, even at the host's exact version. Anything here is +#: pruned from an extension's private site/ after install, so the finder cannot route it. +HOST_PROTECTED: frozenset[str] = frozenset( + { + "torch", + "torchvision", + "torchaudio", + "torchao", + "diffusers", + "transformers", + "accelerate", + "safetensors", + "tokenizers", + "huggingface-hub", + "numpy", + "scipy", + "fastapi", + "uvicorn", + "starlette", + "pydantic", + "pydantic-core", + "psutil", + "xfuser", + "nvidia-ml-py", + "inline-core", + } +) + +_NAME_RE = re.compile(r"^\s*([A-Za-z0-9][A-Za-z0-9._-]*)") + + +def canonical(name: str) -> str: + """PEP 503 normalization, so ``Huggingface_Hub`` and ``huggingface-hub`` compare equal.""" + return re.sub(r"[-_.]+", "-", name).lower() + + +@dataclass(frozen=True) +class Conflict: + """One dependency an extension wants that the host already owns at another version.""" + + name: str + host_version: str + wanted: str + + @property + def protected(self) -> bool: + return canonical(self.name) in HOST_PROTECTED + + def message(self) -> str: + if self.protected: + return ( + f"{self.name} is part of the shared Inline runtime and cannot be replaced " + f"(installed: {self.host_version}, extension wants: {self.wanted})" + ) + return ( + f"{self.name} {self.wanted} conflicts with the installed {self.name} " + f"{self.host_version}" + ) + + def to_json(self) -> dict[str, object]: + return { + "name": self.name, + "hostVersion": self.host_version, + "wanted": self.wanted, + "protected": self.protected, + "message": self.message(), + } + + +def host_distributions() -> dict[str, str]: + """Canonical name -> version for every distribution in the running interpreter.""" + found: dict[str, str] = {} + for dist in importlib.metadata.distributions(): + name = dist.metadata["Name"] if dist.metadata else None + if not name: + continue # a malformed dist-info in site-packages must not break installs + version = dist.version + if version: + found[canonical(name)] = version + return found + + +def fingerprint(host: dict[str, str] | None = None) -> str: + """Digest of the host's package set, so the constraint file is regenerated after an upgrade.""" + dists = host if host is not None else host_distributions() + payload = ";".join(f"{name}=={version}" for name, version in sorted(dists.items())) + return hashlib.sha256(payload.encode()).hexdigest()[:16] + + +def render(host: dict[str, str] | None = None) -> str: + dists = host if host is not None else host_distributions() + lines = [ + "# Generated by Inline Core - do not edit.", + "# Every package installed in the host interpreter, pinned. An extension that needs a", + "# different version of any of these fails to resolve rather than silently replacing it.", + f"# host-fingerprint: {fingerprint(dists)}", + ] + lines.extend(f"{name}=={version}" for name, version in sorted(dists.items())) + return "\n".join(lines) + "\n" + + +def write_constraints(path: Path, host: dict[str, str] | None = None) -> Path: + """Write the constraint file, refreshing it only when the host's packages changed.""" + dists = host if host is not None else host_distributions() + wanted = fingerprint(dists) + try: + if f"# host-fingerprint: {wanted}" in path.read_text(encoding="utf-8"): + return path + except OSError: + pass + write_atomic(path, render(dists)) + return path + + +def requirement_name(requirement: str) -> str: + """The distribution name from a PEP 508 requirement string, canonicalized.""" + head = requirement.split(";", 1)[0].split("@", 1)[0].strip() + match = _NAME_RE.match(head) + return canonical(match.group(1)) if match else "" + + +def protected_requirements(requirements: tuple[str, ...]) -> list[str]: + """Requirements naming a host-protected package. Runs in the security scan, before any + resolution, so listing ``torch`` is blocked outright rather than diagnosed later.""" + return [req for req in requirements if requirement_name(req) in HOST_PROTECTED] + + +def parse_lock(lock_text: str) -> dict[str, str]: + """Canonical name -> version from a fully pinned ``uv pip compile`` lockfile.""" + pinned: dict[str, str] = {} + for raw in lock_text.splitlines(): + line = raw.split("#", 1)[0].strip() + if not line or line.startswith("-"): + continue + name, sep, version = line.partition("==") + if not sep: + continue + cleaned = version.split(";", 1)[0].split("--", 1)[0].strip() + if cleaned: + pinned[canonical(name.strip())] = cleaned + return pinned + + +def conflicts(lock_text: str, host: dict[str, str] | None = None) -> list[Conflict]: + """Pinned packages in an unconstrained lock that disagree with the host. Run only after a + constrained resolve failed, to name the culprit instead of dumping a resolver trace.""" + installed = host if host is not None else host_distributions() + found: list[Conflict] = [] + for name, version in sorted(parse_lock(lock_text).items()): + current = installed.get(name) + if current is not None and current != version: + found.append(Conflict(name=name, host_version=current, wanted=f"=={version}")) + return found + + +def prunable(site_dists: dict[str, str], host: dict[str, str] | None = None) -> list[str]: + """Private-site distributions that duplicate a host package. Constraints already pinned them to + the host version, so removing them guarantees the finder can't route e.g. numpy privately.""" + installed = host if host is not None else host_distributions() + return sorted(name for name in site_dists if name in installed or name in HOST_PROTECTED) diff --git a/core/src/inline_core/extensions/fetch.py b/core/src/inline_core/extensions/fetch.py new file mode 100644 index 0000000..46ca4ae --- /dev/null +++ b/core/src/inline_core/extensions/fetch.py @@ -0,0 +1,183 @@ +"""Fetching an extension repo at a pinned commit. + +A bare mirror under ``.cache/git`` is kept so re-fetching a tag is cheap. The working copy is +extracted with ``git archive``, which produces no ``.git`` directory and no hooks - nothing in a +fetched repo can execute before the scanner has seen it. +""" + +from __future__ import annotations + +import io +import re +import subprocess +import tarfile +from dataclasses import dataclass +from pathlib import Path + +from .tools import FETCH_TIMEOUT, GIT + +#: https/ssh for real repos, file:// for the extension-author dev loop (install your own checkout). +#: A bare path is still rejected, so nothing can be read as a git option. +_URL_RE = re.compile(r"^(https://|git@|file:///)[A-Za-z0-9]") +_REF_RE = re.compile(r"^[A-Za-z0-9._/-]{1,128}$") + + +class FetchError(RuntimeError): + pass + + +@dataclass(frozen=True) +class Fetched: + source: Path + sha: str + ref: str + + +def fetch(url: str, ref: str, *, mirror: Path, dest: Path) -> Fetched: + """Clone/refresh ``url`` into ``mirror`` and extract ``ref`` into ``dest``.""" + GIT.require() + _validate(url, ref) + _sync_mirror(url, mirror) + sha = _resolve(mirror, ref) + _extract(mirror, sha, dest) + return Fetched(source=dest, sha=sha, ref=ref) + + +def remote_sha(url: str, ref: str) -> str | None: + """The commit ``ref`` currently points at upstream, without cloning. None when unreachable - + an update check must never fail the dialog.""" + try: + _validate(url, ref) + done = _git("ls-remote", url, ref, check=False) + except (FetchError, OSError, subprocess.SubprocessError): + return None + if done.returncode != 0: + return None + line = done.stdout.strip().split("\n")[0] + sha = line.split()[0] if line else "" + return sha or None + + +def _validate(url: str, ref: str) -> None: + if not _URL_RE.match(url): + raise FetchError(f"{url!r} is not an https or ssh git URL") + if not _REF_RE.match(ref): + raise FetchError(f"{ref!r} is not a valid tag, branch, or commit") + + +def _sync_mirror(url: str, mirror: Path) -> None: + if (mirror / "HEAD").is_file(): + # Re-point at the URL in case a registry entry moved the repo. + _git("remote", "set-url", "origin", url, cwd=mirror) + _git("fetch", "--prune", "--tags", "origin", "+refs/heads/*:refs/heads/*", cwd=mirror) + return + mirror.parent.mkdir(parents=True, exist_ok=True) + _git("clone", "--bare", "--quiet", url, str(mirror)) + + +def _resolve(mirror: Path, ref: str) -> str: + """The full commit sha for ``ref``. Pinning by sha is what makes a version reproducible even + if the tag is later moved.""" + for candidate in (f"refs/tags/{ref}", f"refs/heads/{ref}", ref): + args = ("rev-parse", "--verify", "--quiet", f"{candidate}^{{commit}}") + done = _git(*args, cwd=mirror, check=False) + sha = done.stdout.strip() + if done.returncode == 0 and sha: + return sha + raise FetchError(f"{ref!r} was not found in the repository") + + +def _extract(mirror: Path, sha: str, dest: Path) -> None: + dest.mkdir(parents=True, exist_ok=True) + done = subprocess.run( # noqa: S603 - fixed argv, no shell + [GIT.require(), "archive", "--format=tar", sha], + cwd=mirror, + capture_output=True, + timeout=FETCH_TIMEOUT, + check=False, + ) + if done.returncode != 0: + raise FetchError(f"could not read {sha[:7]} from the repository") + with tarfile.open(fileobj=io.BytesIO(done.stdout), mode="r|") as archive: + for member in archive: + if not _safe_member(member): + raise FetchError(f"the repository contains an unsafe path: {member.name!r}") + archive.extract(member, dest) + + +def _safe_member(member: tarfile.TarInfo) -> bool: + """Belt and braces over git's own tree rules: no absolute paths, no traversal, no links.""" + if member.issym() or member.islnk() or member.isdev(): + return False + path = Path(member.name) + return not path.is_absolute() and ".." not in path.parts + + +def _git( + *args: str, cwd: Path | None = None, check: bool = True +) -> subprocess.CompletedProcess[str]: + done = subprocess.run( # noqa: S603 - fixed argv, no shell + [GIT.require(), *args], + cwd=cwd, + capture_output=True, + text=True, + timeout=FETCH_TIMEOUT, + check=False, + ) + if check and done.returncode != 0: + raise FetchError(_tail(done.stderr) or "git failed") + return done + + +def _tail(text: str, lines: int = 4) -> str: + kept = [line for line in text.strip().splitlines() if line.strip()][-lines:] + return "\n".join(kept) + + +#: `v1.2.3`, `1.2.3`, `v1.2.3-beta.1`. Anything else is not a release tag and is ignored. +_SEMVER_RE = re.compile(r"^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$") + + +def version_key(tag: str) -> tuple[int, int, int, int, str] | None: + """Sort key for a release tag, or None when the tag isn't one. + + A prerelease sorts below the same release (`1.2.0-rc.1` < `1.2.0`), so floating to the newest + tag never picks a release candidate over the finished version. + """ + match = _SEMVER_RE.match(tag.strip()) + if match is None: + return None + major, minor, patch, pre = match.groups() + return (int(major), int(minor), int(patch), 0 if pre else 1, pre or "") + + +def latest_tag(url: str, *, prereleases: bool = False) -> str | None: + """The newest stable release tag upstream, or None when there is none (or it's unreachable). + + This is how a listing floats: the registry names the repository, and the newest tag is resolved + here, so an author publishes by tagging rather than by opening a registry PR. + + Prereleases are skipped by default. ``v2.0.0-rc.1`` is semver-newer than ``v1.10.0``, but + floating a user onto a release candidate they never asked for is not; install one by naming + its tag explicitly. + """ + try: + _validate(url, "HEAD") + done = _git("ls-remote", "--tags", "--refs", url, check=False) + except (FetchError, OSError, subprocess.SubprocessError): + return None + if done.returncode != 0: + return None + + best: tuple[tuple[int, int, int, int, str], str] | None = None + for line in done.stdout.splitlines(): + parts = line.split() + if len(parts) != 2: + continue + tag = parts[1].removeprefix("refs/tags/") + key = version_key(tag) + if key is None or (not prereleases and key[3] == 0): + continue + if best is None or key > best[0]: + best = (key, tag) + return best[1] if best else None diff --git a/core/src/inline_core/extensions/handlers.py b/core/src/inline_core/extensions/handlers.py new file mode 100644 index 0000000..0df5678 --- /dev/null +++ b/core/src/inline_core/extensions/handlers.py @@ -0,0 +1,145 @@ +"""The ``ext:manage:*`` RPC channels behind the Extensions dialog. + +``ext:manage:`` is reserved; extension-provided channels are ``ext::`` and the +registrar rejects anything else, so an extension can never register here. +""" + +from __future__ import annotations + +import inspect +import json +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from ..config import registry_url +from .install import Installer, InstallError, InstallRequest +from .manifest import JsonObject, as_array, as_object +from .paths import ExtensionsRoot, write_atomic +from .tools import can_install, tool_status + +CHANNEL_PREFIX = "ext:manage:" + + +def register_extension_handlers(rpc: Any, installer: Installer) -> None: + def reg(method: str, fn: Callable[..., Any]) -> None: + async def handler(args: list[Any]) -> Any: + result = fn(*args) + if inspect.isawaitable(result): + result = await result + return result + + rpc.register(CHANNEL_PREFIX + method, handler) + + async def install(source: str, ref: str = "main", consents: object = None) -> dict[str, Any]: + accepted = tuple(c for c in (as_array(consents) or []) if isinstance(c, str)) + try: + result = await installer.install( + InstallRequest(source=source, ref=ref, consents=accepted) + ) + except InstallError as error: + # Structured, not a bare string: the dialog renders conflicts and scan findings. + return {"ok": False, **error.to_json()} + return {"ok": True, **result.to_json()} + + def status() -> dict[str, Any]: + return { + "canInstall": can_install(), + "tools": [t.to_json() for t in tool_status()], + "extensions": installer.list_packs(), + } + + def versions(extension_id: str) -> dict[str, Any]: + extension = ExtensionsRoot.resolve().extension(extension_id) + return { + "extensionId": extension_id, + "current": extension.current() or "", + "versions": extension.installed_versions(), + } + + def registry_index(refresh: bool = False) -> dict[str, Any]: + return _registry_index(refresh=refresh) + + reg("list", installer.list_packs) + reg("status", status) + reg("install", install) + reg("uninstall", installer.uninstall) + reg("setEnabled", installer.set_enabled) + reg("setNodeEnabled", installer.set_node_enabled) + reg("versions", versions) + reg("switchVersion", installer.switch_version) + reg("checkUpdates", installer.check_updates) + reg("registryIndex", registry_index) + + +def _registry_index(*, refresh: bool) -> dict[str, Any]: + """The published extension list, from ``config.registry_url()``. + + Cached on disk with its ETag: a refresh is a conditional GET, and an unreachable registry + degrades to the cached entries marked ``stale`` rather than an empty dialog. + """ + paths = ExtensionsRoot.resolve() + cached, etag = _read_cache(paths) + if not refresh and cached is not None: + return {"entries": cached, "stale": False} + + fetched = _fetch_index(etag if cached is not None else None) + if fetched is None: + return {"entries": cached or [], "stale": True} + if fetched.unchanged: + return {"entries": cached or [], "stale": False} + + paths.cache.mkdir(parents=True, exist_ok=True) + write_atomic( + paths.registry_index, + json.dumps({"entries": fetched.entries, "etag": fetched.etag}, indent=2), + ) + return {"entries": fetched.entries, "stale": False} + + +def _read_cache(paths: ExtensionsRoot) -> tuple[list[JsonObject] | None, str | None]: + try: + raw = as_object(json.loads(paths.registry_index.read_text(encoding="utf-8"))) + except (OSError, json.JSONDecodeError): + return None, None + if raw is None: + return None, None + tag = raw.get("etag") + return _entries(raw.get("entries")), tag if isinstance(tag, str) else None + + +@dataclass +class _Fetched: + entries: list[JsonObject] + etag: str | None + #: True on a 304: the cache is current and must not be overwritten with an empty body. + unchanged: bool = False + + +def _fetch_index(etag: str | None) -> _Fetched | None: + from urllib.error import HTTPError, URLError + from urllib.request import Request, urlopen + + request = Request(registry_url()) + if etag: + request.add_header("If-None-Match", etag) + try: + with urlopen(request, timeout=10) as response: # noqa: S310 - URL comes from config + decoded = json.loads(response.read().decode("utf-8")) + fresh_etag = response.headers.get("ETag") + except HTTPError as error: + if error.code == 304: + return _Fetched(entries=[], etag=etag, unchanged=True) + return None + except (URLError, OSError, ValueError): + return None + raw = as_object(decoded) + entries = _entries(raw.get("entries") if raw is not None else decoded) + return None if entries is None else _Fetched(entries=entries, etag=fresh_etag) + + +def _entries(value: object) -> list[JsonObject] | None: + items = as_array(value) + if items is None: + return None + return [entry for entry in (as_object(i) for i in items) if entry is not None] diff --git a/core/src/inline_core/extensions/importer.py b/core/src/inline_core/extensions/importer.py new file mode 100644 index 0000000..eebdafa --- /dev/null +++ b/core/src/inline_core/extensions/importer.py @@ -0,0 +1,91 @@ +"""Routes an extension's private dependencies to its own ``site/``. + +Ownership is decided at **install** time, never here: Python checks ``sys.modules`` before +``sys.meta_path``, so a finder cannot give two extensions different versions of the same module: +the first import wins and the second extension's finder is never called. The installer therefore +assigns one owner per top-level name, and a real conflict fails the install rather than mismatching +silently. +""" + +from __future__ import annotations + +import importlib.abc +import importlib.machinery +import sys +from collections.abc import Iterable +from dataclasses import dataclass +from importlib.metadata import Distribution, MetadataPathFinder +from pathlib import Path +from types import ModuleType +from typing import Any + + +@dataclass(frozen=True) +class OwnedModules: + extension_id: str + site: Path + modules: frozenset[str] + + +class ExtensionFinder(importlib.abc.MetaPathFinder): + """Maps a fixed set of top-level module names to the private site dir that owns them.""" + + def __init__(self) -> None: + self._owner: dict[str, OwnedModules] = {} + + def add(self, extension: OwnedModules) -> None: + # setdefault, not assignment: re-pointing a module another extension already owns would + # reintroduce the load-order nondeterminism this design exists to prevent. + for module in extension.modules: + self._owner.setdefault(module, extension) + + def remove(self, extension_id: str) -> None: + self._owner = {m: p for m, p in self._owner.items() if p.extension_id != extension_id} + + def owner_of(self, module: str) -> str | None: + extension = self._owner.get(module) + return extension.extension_id if extension else None + + def sites(self) -> list[str]: + return sorted({str(p.site) for p in self._owner.values()}) + + def find_spec( + self, + fullname: str, + path: Any = None, + target: ModuleType | None = None, + ) -> importlib.machinery.ModuleSpec | None: + if path is not None: + return None # submodule: the parent package's __path__ already routes it + extension = self._owner.get(fullname.partition(".")[0]) + if extension is None: + return None + return importlib.machinery.PathFinder.find_spec(fullname, [str(extension.site)], target) + + def find_distributions(self, context: Any = None) -> Iterable[Distribution]: + # Distribution discovery is a separate protocol from import; without this a vendored dep + # that checks its own version raises PackageNotFoundError despite importing fine. + for site in self.sites(): + yield from MetadataPathFinder().find_distributions(_context_for(context, site)) + + +def _context_for(context: Any, site: str) -> Any: + from importlib.metadata import DistributionFinder + + name = getattr(context, "name", None) + return DistributionFinder.Context(name=name, path=[site]) + + +def install_finder() -> ExtensionFinder: + """Install (or return) the process-wide finder.""" + for entry in sys.meta_path: + if isinstance(entry, ExtensionFinder): + return entry + finder = ExtensionFinder() + # Prepended so a private dep is never shadowed by a same-named module in the cwd. + sys.meta_path.insert(0, finder) + return finder + + +def uninstall_finder() -> None: + sys.meta_path[:] = [e for e in sys.meta_path if not isinstance(e, ExtensionFinder)] diff --git a/core/src/inline_core/extensions/install.py b/core/src/inline_core/extensions/install.py new file mode 100644 index 0000000..6a1c96a --- /dev/null +++ b/core/src/inline_core/extensions/install.py @@ -0,0 +1,663 @@ +"""The install state machine. + +Every phase writes only inside a staging directory. Nothing outside it is touched until ACTIVATE, +so a failure at any point has nothing to undo - that is the entire rollback story. + +REGISTER imports into a *scratch* registry, so an extension that raises on import or collides on a +node type is caught before the live registry is mutated. +""" + +from __future__ import annotations + +import asyncio +import json +import shutil +import sys +import uuid +from dataclasses import dataclass, field +from enum import StrEnum +from pathlib import Path +from typing import Any + +from ..device.policy import DevicePolicy +from ..graph.registry import Registry +from ..models.requirements import RequirementsRegistry +from ..runtime.store import TakeStore +from .api import ExtensionRegistrar +from .constraints import Conflict +from .fetch import FetchError, fetch, latest_tag, remote_sha +from .loader import resolve_entry +from .manifest import ( + JsonObject, + Manifest, + ManifestError, + as_object, + load_manifest, + parse_manifest, +) +from .paths import ExtensionsRoot, VersionPaths, version_dirname, write_atomic +from .resolve import Resolution, ResolutionError, resolve_and_install +from .scanner import ScanReport, scan +from .state import StateStore +from .tools import ToolMissing, missing_tools, require_tools + + +class Phase(StrEnum): + FETCH = "fetch" + VALIDATE = "validate" + SCAN = "scan" + PREFLIGHT = "preflight" + RESOLVE = "resolve" + INSTALL = "install" + LOCK = "lock" + REGISTER = "register" + ACTIVATE = "activate" + DONE = "done" + + +class InstallError(RuntimeError): + """A failed install. Carries the structured detail the dialog needs to explain itself.""" + + def __init__( + self, + message: str, + *, + phase: Phase, + conflicts: list[Conflict] | None = None, + report: ScanReport | None = None, + restart_required: bool = False, + ) -> None: + super().__init__(message) + self.phase = phase + self.conflicts = conflicts or [] + self.report = report + self.restart_required = restart_required + + def to_json(self) -> dict[str, Any]: + return { + "error": str(self), + "phase": self.phase.value, + "conflicts": [c.to_json() for c in self.conflicts], + "scan": self.report.to_json() if self.report else None, + "restartRequired": self.restart_required, + } + + +@dataclass(frozen=True) +class InstallRequest: + source: str + ref: str = "main" + #: Scan rules the user accepted. A HIGH/MEDIUM finding not listed here pauses the install. + consents: tuple[str, ...] = () + + +@dataclass +class InstallResult: + extension_id: str + version: str + name: str = "" + node_types: list[str] = field(default_factory=lambda: []) + scan: ScanReport | None = None + #: True when the extension was already imported this session, so the new code is not live yet. + restart_required: bool = False + #: Set when the install paused for consent; nothing was activated. + needs_consent: bool = False + + def to_json(self) -> dict[str, Any]: + return { + "extensionId": self.extension_id, + "version": self.version, + "name": self.name, + "nodeTypes": self.node_types, + "scan": self.scan.to_json() if self.scan else None, + "restartRequired": self.restart_required, + "needsConsent": self.needs_consent, + } + + +class Installer: + """Runs installs and the lifecycle operations around them.""" + + def __init__( + self, + registry: Registry, + store: TakeStore, + policy: DevicePolicy, + *, + requirements: RequirementsRegistry, + paths: ExtensionsRoot | None = None, + rpc: Any = None, + events: Any = None, + ) -> None: + self._registry = registry + self._store = store + self._policy = policy + self._requirements = requirements + self._paths = paths or ExtensionsRoot.resolve() + self._rpc = rpc + self._events = events + self._paths.ensure_dirs() + self._state = StateStore(self._paths) + #: Extensions imported this session. Re-installing one of these needs a restart to take + #: effect. + self._imported: set[str] = set() + + @property + def state(self) -> StateStore: + return self._state + + # --- install ---------------------------------------------------------------------------------- + + async def install(self, request: InstallRequest) -> InstallResult: + """Run the pipeline off the event loop; git and uv both block.""" + loop = asyncio.get_running_loop() + return await asyncio.to_thread(self._install, request, loop) + + def _install(self, request: InstallRequest, loop: Any = None) -> InstallResult: + if missing := missing_tools(): + raise InstallError( + f"installing extensions needs {' and '.join(missing)}", phase=Phase.FETCH + ) + require_tools() + + token = uuid.uuid4().hex[:12] + staging = self._paths.new_staging(token) + try: + return self._run_phases(request, staging, loop) + finally: + shutil.rmtree(staging, ignore_errors=True) + + def _run_phases(self, request: InstallRequest, staging: Path, loop: Any) -> InstallResult: + source = staging / "source" + + self._emit(loop, Phase.FETCH, 0.05, "Downloading…") + ref = self._resolve_ref(request.source, request.ref) + try: + fetched = fetch( + request.source, + ref, + mirror=self._paths.git_mirror(_mirror_name(request.source)), + dest=source, + ) + except (FetchError, ToolMissing) as error: + raise InstallError(str(error), phase=Phase.FETCH) from error + + self._emit(loop, Phase.VALIDATE, 0.15, "Checking the manifest…") + try: + manifest = load_manifest(source) + except ManifestError as error: + raise InstallError(str(error), phase=Phase.VALIDATE) from error + + self._emit(loop, Phase.SCAN, 0.25, "Reviewing the code…") + report = scan(source, manifest) + if report.blocked: + raise InstallError( + _blocked_message(report), phase=Phase.SCAN, report=report + ) + outstanding = set(report.consent_rules()) - set(request.consents) + if outstanding: + # Pause, don't fail: the dialog shows the report and re-calls with consents. + return InstallResult( + extension_id=manifest.id, + version=manifest.version, + name=manifest.name, + scan=report, + needs_consent=True, + ) + + self._emit(loop, Phase.PREFLIGHT, 0.35, "Checking for conflicts…") + self._preflight(manifest) + + version = version_dirname(manifest.version, fetched.sha) + target = self._paths.extension(manifest.id).version(version) + + self._emit(loop, Phase.RESOLVE, 0.45, "Resolving dependencies…") + resolution = self._resolve(manifest, staging) + + self._emit(loop, Phase.LOCK, 0.75, "Recording the install…") + self._write_metadata( + staging, manifest, fetched.sha, fetched.ref, request.source, report, resolution + ) + + self._emit(loop, Phase.REGISTER, 0.85, "Loading nodes…") + titles = self._register_scratch(manifest, staging) + + self._emit(loop, Phase.ACTIVATE, 0.95, "Activating…") + result = self._activate(manifest, staging, target, version, resolution, report, titles) + + self._emit(loop, Phase.DONE, 1.0, "Installed") + self._broadcast(loop, "events:extensionInstallDone", result.to_json()) + return result + + def _resolve_ref(self, source: str, ref: str) -> str: + """Turn a floating request into a concrete tag. + + A registry listing names the repository, not a version: authors publish by tagging, so + ``latest`` resolves to the newest release tag here. An explicit ref is used as given. + """ + if ref and ref != "latest": + return ref + newest = latest_tag(source) + if newest is None: + raise InstallError( + "no release tag found in that repository; tag a release (for example v1.0.0) " + "or install a branch by name", + phase=Phase.FETCH, + ) + return newest + + def _preflight(self, manifest: Manifest) -> None: + """Catch collisions before any extension code runs.""" + for node_type in manifest.node_types(): + if not self._registry.has(node_type): + continue + source = self._registry.get(node_type).source + # Reinstalling or upgrading the same extension is fine; another owner is not. + if source != f"ext:{manifest.id}": + owner = "Inline Core" if source == "builtin" else source + raise InstallError( + f"node type {node_type!r} is already provided by {owner}", + phase=Phase.PREFLIGHT, + ) + for module, owner in self._state.import_owners().items(): + if owner != manifest.id and module in {r.split("=")[0] for r in manifest.requirements}: + raise InstallError( + f"{module!r} is already provided by the {owner!r} extension; " + "disable it or align the versions", + phase=Phase.PREFLIGHT, + ) + + def _resolve(self, manifest: Manifest, staging: Path) -> Resolution: + if _prebuilt_for(manifest) is not None: + # Seam for the Node-Packer path: a matching tarball skips resolution entirely. + raise InstallError( + "prebuilt extension packages are not supported yet; " + "install from source or ask the author to publish a source install", + phase=Phase.RESOLVE, + ) + try: + return resolve_and_install( + manifest.requirements, + site=staging / "site", + lock_dir=staging / "lock", + constraints_path=self._paths.constraints, + log=staging / "lock" / "install.log", + ) + except ResolutionError as error: + raise InstallError( + str(error), phase=Phase.RESOLVE, conflicts=error.conflicts + ) from error + + def _write_metadata( + self, + staging: Path, + manifest: Manifest, + sha: str, + ref: str, + source: str, + report: ScanReport, + resolution: Resolution, + ) -> None: + raw = (staging / "source" / "inline-extension.json").read_text(encoding="utf-8") + write_atomic(staging / "manifest.json", raw) + write_atomic(staging / "scan.json", json.dumps(report.to_json(), indent=2)) + write_atomic( + staging / "lock" / "owned-modules.json", + json.dumps(resolution.to_json(), indent=2), + ) + write_atomic( + staging / "receipt.json", + json.dumps( + { + "sha": sha, + "ref": ref, + "source": source, + "version": manifest.version, + "python": sys.version.split()[0], + "consents": report.consent_rules(), + }, + indent=2, + ), + ) + + def _register_scratch(self, manifest: Manifest, staging: Path) -> dict[str, str]: + """Import and register into a throwaway registry first, so a broken extension never + touches the live one. Import failures leave modules in sys.modules, hence restart_required. + + Every declared node is registered here, including default-off ones: an extension that only + breaks once a node is switched on would be a nasty surprise later. + """ + scratch = Registry() + python_root = str((staging / "source" / "python").resolve()) + sys.path.insert(0, python_root) + try: + registrar = ExtensionRegistrar( + scratch, + manifest.id, + store=self._store, + policy=self._policy, + requirements=RequirementsRegistry(), + data_root=staging / "data", + declared_nodes=manifest.node_types(), + ) + try: + resolve_entry(manifest.entry)(registrar) + except Exception as error: # noqa: BLE001 - plugin boundary + raise InstallError( + str(error), phase=Phase.REGISTER, restart_required=True + ) from error + missing = set(manifest.node_types()) - set(registrar.registered_nodes) + if missing: + raise InstallError( + f"the manifest declares {', '.join(sorted(missing))} but register() did not " + "provide them", + phase=Phase.REGISTER, + restart_required=True, + ) + # Titles come from the descriptor, so a node the user has switched off still shows a + # readable name in the dialog rather than its raw type. + return {t: scratch.get(t).title for t in registrar.registered_nodes} + finally: + sys.path[:] = [p for p in sys.path if p != python_root] + + def _activate( + self, + manifest: Manifest, + staging: Path, + target: VersionPaths, + version: str, + resolution: Resolution, + report: ScanReport, + titles: dict[str, str], + ) -> InstallResult: + extension = self._paths.extension(manifest.id) + extension.versions.mkdir(parents=True, exist_ok=True) + shutil.rmtree(target.root, ignore_errors=True) + shutil.move(str(staging), str(target.root)) + + previous = extension.current() + extension.set_current(version) + self._state.activate( + manifest.id, + version=version, + owns=resolution.modules, + consents=report.consent_rules(), + nodes={n.type: n.default_enabled for n in manifest.nodes}, + ) + extension.prune() + + restart = manifest.id in self._imported + if not restart: + self._load_now(manifest, target) + live = {n.type for n in manifest.nodes if n.default_enabled} + write_atomic(target.titles, json.dumps(titles, indent=2)) + return InstallResult( + extension_id=manifest.id, + version=version, + name=manifest.name, + node_types=[n for n in titles if n in live], + scan=report, + restart_required=restart or previous is not None, + ) + + def _load_now(self, manifest: Manifest, target: VersionPaths) -> None: + """Register into the live registry, so a first install needs no restart.""" + from .loader import load_extension_into + + load_extension_into( + manifest, + target, + registry=self._registry, + store=self._store, + policy=self._policy, + requirements=self._requirements, + state=self._state, + rpc=self._rpc, + events=self._events, + ) + self._imported.add(manifest.id) + + # --- lifecycle -------------------------------------------------------------------------------- + + def list_packs(self) -> list[dict[str, Any]]: + out: list[dict[str, Any]] = [] + for extension_id in self._paths.installed_extensions(): + extension = self._paths.extension(extension_id) + current = extension.current() + extension_state = self._state.extension(extension_id) + manifest = self._manifest_of(extension_id, current) + titles = self._titles_of(extension_id, current) + receipt = self._receipt_of(extension_id, current) + out.append( + { + "extensionId": extension_id, + "name": manifest.name if manifest else extension_id, + "description": manifest.description if manifest else "", + "version": manifest.version if manifest else "", + "installed": current or "", + "enabled": extension_state.enabled if extension_state else False, + "versions": extension.installed_versions(), + "homepage": manifest.homepage if manifest else "", + "license": manifest.license if manifest else "", + "repo": str(receipt.get("source", "")), + "ref": str(receipt.get("ref", "")), + "sha": str(receipt.get("sha", ""))[:7], + "nodes": [ + { + "type": node.type, + "title": titles.get(node.type, node.type), + "enabled": extension_state.node_enabled( + node.type, default=node.default_enabled + ) + if extension_state + else False, + } + for node in (manifest.nodes if manifest else ()) + ], + } + ) + return out + + def check_updates(self) -> list[dict[str, Any]]: + """Ask each extension's origin what its ref points at now, so the card can show drift. + + Network-bound and best-effort, hence separate from ``list``: the dialog opens instantly and + the badges fill in when this returns. An unreachable origin reports ``checked: false`` + rather than claiming the extension is up to date. + """ + out: list[dict[str, Any]] = [] + for extension_id in self._paths.installed_extensions(): + current = self._paths.extension(extension_id).current() + receipt = self._receipt_of(extension_id, current) + source, ref = str(receipt.get("source", "")), str(receipt.get("ref", "")) + installed = str(receipt.get("sha", "")) + if not source or not ref or not installed: + continue + newest = latest_tag(source) + # Two ways to be out of date: a newer tag exists, or the installed tag itself moved. + upstream = remote_sha(source, newest or ref) + behind = bool( + (newest and newest != ref) or (upstream and upstream != installed) + ) + out.append( + { + "extensionId": extension_id, + "ref": ref, + "latestTag": newest, + "installedSha": installed[:7], + "remoteSha": upstream[:7] if upstream else None, + "behind": behind, + "checked": upstream is not None or newest is not None, + } + ) + return out + + def set_enabled(self, extension_id: str, enabled: bool) -> dict[str, Any]: + self._state.set_enabled(extension_id, enabled) + if not enabled: + self._unload(extension_id) + return {"extensionId": extension_id, "enabled": False, "restartRequired": False} + restart = self._load_enabled(extension_id) + return {"extensionId": extension_id, "enabled": True, "restartRequired": restart} + + def set_node_enabled(self, extension_id: str, node_type: str, enabled: bool) -> dict[str, Any]: + """Toggle one node. Never needs a restart: the extension's code is already imported, so + this only adds or removes a registry entry.""" + self._state.set_node_enabled(extension_id, node_type, enabled) + if enabled: + self._load_enabled(extension_id) + else: + self._registry.unregister(node_type) + self._requirements.unregister(node_type) + return { + "extensionId": extension_id, + "nodeType": node_type, + "enabled": enabled, + "restartRequired": False, + } + + def _load_enabled(self, extension_id: str) -> bool: + """Register whatever is enabled but not yet live. Returns True if a restart is needed.""" + extension = self._paths.extension(extension_id) + current = extension.current() + if current is None: + return False + version_paths = extension.version(current) + manifest = self._manifest_of(extension_id, current) + if manifest is None: + return False + from .loader import load_extension_into + + loaded = load_extension_into( + manifest, + version_paths, + registry=self._registry, + store=self._store, + policy=self._policy, + requirements=self._requirements, + state=self._state, + rpc=self._rpc, + events=self._events, + ) + self._imported.add(extension_id) + return loaded.error is not None + + def switch_version(self, extension_id: str, version: str) -> dict[str, Any]: + """Roll back or forward to an already-installed version. Always needs a restart: Python + cannot unload the modules already imported from the old one.""" + extension = self._paths.extension(extension_id) + if version not in extension.installed_versions(): + raise InstallError(f"version {version!r} is not installed", phase=Phase.ACTIVATE) + extension.set_current(version) + self._state.set_current(extension_id, version) + return {"extensionId": extension_id, "version": version, "restartRequired": True} + + def uninstall(self, extension_id: str) -> dict[str, Any]: + self._unload(extension_id) + self._state.remove(extension_id) + shutil.rmtree(self._paths.extension(extension_id).root, ignore_errors=True) + return {"extensionId": extension_id, "restartRequired": extension_id in self._imported} + + def _unload(self, extension_id: str) -> None: + """Drop every node and channel this extension registered. Its Python modules stay in + sys.modules, unreachable but harmless.""" + source = f"ext:{extension_id}" + for descriptor in list(self._registry.descriptors()): + if descriptor.source == source: + self._registry.unregister(descriptor.type) + self._requirements.unregister(descriptor.type) + if self._rpc is not None: + for channel in list(getattr(self._rpc, "_handlers", {})): + if channel.startswith(f"ext:{extension_id}:"): + self._rpc.unregister(channel) + + def _receipt_of(self, extension_id: str, version: str | None) -> JsonObject: + """Install provenance: the source URL, the ref asked for, and the commit it resolved to.""" + if not version: + return {} + try: + raw = as_object( + json.loads( + self._paths.extension(extension_id).version(version).receipt.read_text("utf-8") + ) + ) + except (OSError, json.JSONDecodeError): + return {} + return raw or {} + + def _titles_of(self, extension_id: str, version: str | None) -> dict[str, str]: + if not version: + return {} + try: + raw = as_object( + json.loads( + self._paths.extension(extension_id).version(version).titles.read_text("utf-8") + ) + ) + except (OSError, json.JSONDecodeError): + return {} + return {key: str(value) for key, value in raw.items()} if raw is not None else {} + + def _manifest_of(self, extension_id: str, version: str | None) -> Manifest | None: + if not version: + return None + path = self._paths.extension(extension_id).version(version).manifest + try: + return parse_manifest(json.loads(path.read_text(encoding="utf-8")), + expect_id=extension_id) + except (OSError, json.JSONDecodeError, ManifestError): + return None + + # --- events ----------------------------------------------------------------------------------- + + def _emit(self, loop: Any, phase: Phase, fraction: float, status: str) -> None: + self._broadcast( + loop, + "events:extensionInstallProgress", + {"phase": phase.value, "fraction": fraction, "status": status}, + ) + + def _broadcast(self, loop: Any, channel: str, payload: Any) -> None: + if self._events is None: + return + if loop is None: + self._events.broadcast(channel, payload) + return + # EventBroadcaster is not thread-safe and we run off the loop. + loop.call_soon_threadsafe(self._events.broadcast, channel, payload) + + +def _mirror_name(url: str) -> str: + """A filesystem-safe cache key for a repo URL.""" + cleaned = url.rstrip("/").removesuffix(".git") + tail = cleaned.rsplit("/", 2)[-2:] + return "-".join(part.replace(":", "-") for part in tail if part) or "repo" + + +def _prebuilt_for(manifest: Manifest) -> object | None: + """Match a prebuilt artifact to this platform. Deferred: the seam exists, the fetch does not.""" + if not manifest.prebuilt: + return None + import platform as platform_mod + + machine = platform_mod.machine().lower() + system = {"Linux": "linux", "Darwin": "macos", "Windows": "windows"}.get( + platform_mod.system(), "" + ) + tag = f"cp{sys.version_info.major}{sys.version_info.minor}" + for built in manifest.prebuilt: + if built.python == tag and built.platform.lower() in { + f"{system}-{machine}", + f"{system}-{'x86_64' if machine == 'amd64' else machine}", + }: + return built + return None + + +def _blocked_message(report: ScanReport) -> str: + from .scanner import Severity + + critical = report.by_severity(Severity.CRITICAL) + head = critical[0].message if critical else "the security scan blocked this extension" + extra = f" (and {len(critical) - 1} more)" if len(critical) > 1 else "" + return head + extra diff --git a/core/src/inline_core/extensions/loader.py b/core/src/inline_core/extensions/loader.py new file mode 100644 index 0000000..ea5432b --- /dev/null +++ b/core/src/inline_core/extensions/loader.py @@ -0,0 +1,285 @@ +"""Boot-time extension loading: read state, route private imports, register modules. + +Failure is contained per module and surfaced on ``LoadedExtension``, never raised - hence the broad +``except Exception``, the same plugin-boundary pattern used by RPC dispatch and the uploader. +""" + +from __future__ import annotations + +import importlib +import json +import logging +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from ..device.policy import DevicePolicy +from ..graph.registry import Registry +from ..models.requirements import RequirementsRegistry +from ..runtime.store import TakeStore +from .api import ExtensionRegistrar +from .importer import ExtensionFinder, OwnedModules, install_finder +from .manifest import Manifest, ManifestError, as_array, as_object, load_manifest +from .models import ManifestRequirements +from .paths import ExtensionsRoot, VersionPaths +from .state import StateStore + +log = logging.getLogger(__name__) + + +@dataclass +class LoadedExtension: + extension_id: str + version: str + name: str = "" + enabled: bool = True + #: Node types actually registered (the enabled ones). + node_types: tuple[str, ...] = () + #: Declared but switched off by the user - toggling one on needs no restart. + skipped: tuple[str, ...] = () + #: Set when the extension failed (bad manifest, missing files, incompatible Core, bad import). + error: str | None = None + + +def load_extensions( + registry: Registry, + store: TakeStore, + policy: DevicePolicy, + *, + requirements: RequirementsRegistry | None = None, + rpc: Any = None, + events: Any = None, + root: Path | None = None, + core_version: str | None = None, +) -> list[LoadedExtension]: + """Load every enabled extension recorded in ``state.json``.""" + paths = ExtensionsRoot.resolve(root) + if not paths.root.is_dir(): + return [] + paths.ensure_dirs() + state = StateStore(paths) + reqs = requirements if requirements is not None else RequirementsRegistry() + finder = install_finder() + + loaded: list[LoadedExtension] = [] + for extension_id, pack_state in sorted(state.extensions().items()): + if not pack_state.enabled: + loaded.append(LoadedExtension(extension_id, pack_state.current, enabled=False)) + continue + loaded.append( + _load_pack( + extension_id, + paths, + state, + registry, + store, + policy, + reqs, + finder, + rpc, + events, + core_version, + ) + ) + _empty_trash(paths) + return loaded + + +def _load_pack( + extension_id: str, + paths: ExtensionsRoot, + state: StateStore, + registry: Registry, + store: TakeStore, + policy: DevicePolicy, + requirements: RequirementsRegistry, + finder: ExtensionFinder, + rpc: Any, + events: Any, + core_version: str | None, +) -> LoadedExtension: + extension_state = state.extension(extension_id) + version = extension_state.current if extension_state else "" + extension_paths = extension_paths_for(paths, extension_id) + current = extension_paths.current() + if current is None: + return LoadedExtension( + extension_id, version, error="installed files are missing; reinstall the extension" + ) + + version_paths = extension_paths.version(current) + try: + manifest = _manifest_of(version_paths, extension_id) + except ManifestError as error: + return LoadedExtension(extension_id, current, error=f"invalid manifest: {error}") + + if core_version is not None and not _compatible(manifest, core_version): + return LoadedExtension( + extension_id, + current, + name=manifest.name, + error=f"requires Inline Core {manifest.core_compat} (running {core_version})", + ) + + return load_extension_into( + manifest, + version_paths, + registry=registry, + store=store, + policy=policy, + requirements=requirements, + state=state, + rpc=rpc, + events=events, + finder=finder, + ) + + +def load_extension_into( + manifest: Manifest, + version_paths: VersionPaths, + *, + registry: Registry, + store: TakeStore, + policy: DevicePolicy, + requirements: RequirementsRegistry, + state: StateStore, + rpc: Any = None, + events: Any = None, + finder: ExtensionFinder | None = None, +) -> LoadedExtension: + """Import an extension once and register its enabled nodes. Shared by boot and by a fresh + install, so a first-time install goes live without a restart.""" + extension_state = state.extension(manifest.id) + enabled = [ + node.type + for node in manifest.nodes + if ( + extension_state.node_enabled(node.type, default=node.default_enabled) + if extension_state + else node.default_enabled + ) + ] + # Route private dependencies before importing any of the extension's code. + (finder or install_finder()).add(_owned_modules(manifest.id, version_paths)) + _ensure_on_path(version_paths) + + registrar = ExtensionRegistrar( + registry, + manifest.id, + store=store, + policy=policy, + requirements=requirements, + data_root=version_paths.root.parent.parent / "data", + declared_nodes=manifest.node_types(), + enabled_nodes=enabled, + rpc=rpc, + events=events, + ) + try: + resolve_entry(manifest.entry)(registrar) + # Manifest-declared weights need no extension code of their own. + for node in manifest.nodes: + if node.models and node.type in registrar.registered_nodes: + requirements.register(node.type, ManifestRequirements(node.models)) + except Exception as error: # noqa: BLE001 - plugin boundary; must not kill boot + log.warning("extension %s failed to load: %s", manifest.id, error) + _rollback(registry, rpc, requirements, registrar) + return LoadedExtension( + manifest.id, version_paths.root.name, name=manifest.name, error=str(error) + ) + return LoadedExtension( + manifest.id, + version_paths.root.name, + name=manifest.name, + node_types=tuple(registrar.registered_nodes), + skipped=tuple(registrar.skipped_nodes), + ) + + +def _rollback( + registry: Registry, + rpc: Any, + requirements: RequirementsRegistry, + registrar: ExtensionRegistrar, +) -> None: + """Undo a partially-registered extension, so a failure halfway through ``register()`` + cannot leave half its nodes live.""" + for node_type in registrar.registered_nodes: + registry.unregister(node_type) + requirements.unregister(node_type) + if rpc is not None: + for channel in registrar.registered_channels: + rpc.unregister(channel) + + +def resolve_entry(entry: str) -> Any: + module_name, _, attr = entry.partition(":") + module = importlib.import_module(module_name) + target = getattr(module, attr, None) + if not callable(target): + raise ImportError(f"entry point {entry!r} is not callable") + return target + + +def _manifest_of(version_paths: VersionPaths, extension_id: str) -> Manifest: + """Prefer the normalized copy written at install time; fall back to the repo's own file.""" + if version_paths.manifest.is_file(): + try: + raw = json.loads(version_paths.manifest.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise ManifestError([f"$: could not read the installed manifest ({error})"]) from error + from .manifest import parse_manifest + + return parse_manifest(raw, expect_id=extension_id) + return load_manifest(version_paths.source, expect_id=extension_id) + + +def _owned_modules(extension_id: str, version_paths: VersionPaths) -> OwnedModules: + owned: list[str] = [] + try: + raw = as_object(json.loads(version_paths.owned_modules.read_text(encoding="utf-8"))) + top = as_array(raw.get("topLevel")) if raw is not None else None + owned = [m for m in (top or []) if isinstance(m, str)] + except (OSError, json.JSONDecodeError): + owned = [] # no private deps recorded: the extension resolves entirely against the host + return OwnedModules(extension_id=extension_id, site=version_paths.site, + modules=frozenset(owned)) + + +def _ensure_on_path(version_paths: VersionPaths) -> None: + """The extension's own code lives in ``source/python/`` and is imported by its mandatory unique + top-level name, so a plain ``sys.path`` entry is safe.""" + python_root = str((version_paths.source / "python").resolve()) + if python_root not in sys.path: + sys.path.insert(0, python_root) + + +def _compatible(manifest: Manifest, core_version: str) -> bool: + """Best-effort PEP 440 check. Without ``packaging`` installed we allow the load rather than + locking every extension out - the manifest range is advisory, not a security control.""" + spec = manifest.core_compat.strip() + if not spec: + return True + try: + from packaging.specifiers import SpecifierSet + from packaging.version import Version + except ImportError: + return True + try: + return Version(core_version) in SpecifierSet(spec) + except Exception: # noqa: BLE001 - a malformed range must not block boot + return True + + +def extension_paths_for(paths: ExtensionsRoot, extension_id: str) -> Any: + return paths.extension(extension_id) + + +def _empty_trash(paths: ExtensionsRoot) -> None: + for extension_id in paths.installed_extensions(): + try: + paths.extension(extension_id).empty_trash() + except OSError: + continue diff --git a/core/src/inline_core/extensions/manifest.py b/core/src/inline_core/extensions/manifest.py new file mode 100644 index 0000000..b9d3f10 --- /dev/null +++ b/core/src/inline_core/extensions/manifest.py @@ -0,0 +1,361 @@ +"""``inline-extension.json``: the extension manifest, its typed form, and its validator. + +Hand-written rather than jsonschema-backed to keep Core's base deps at numpy + psutil. Validation +reports every problem at once, each message naming its JSON path. +""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Literal, cast + +from ..models.catalog import CATEGORIES + +#: `json.loads` returns `Any`; these narrow it once at the boundary so the rest of the module is +#: fully typed under pyright strict rather than sprinkled with per-line suppressions. +JsonObject = dict[str, Any] +JsonArray = list[Any] + + +def as_object(value: object) -> JsonObject | None: + return cast(JsonObject, value) if isinstance(value, dict) else None + + +def as_array(value: object) -> JsonArray | None: + return cast(JsonArray, value) if isinstance(value, list) else None + +#: Extension ids name a directory, the `ext::*` RPC prefix, and (with `-` as `_`) the mandatory +#: top-level Python package, so they stay conservative. +EXTENSION_ID_RE = re.compile(r"^[a-z0-9][a-z0-9-]{1,63}$") +#: `module.path:callable` +ENTRY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_.]*:[A-Za-z_][A-Za-z0-9_]*$") +#: Node types are namespaced like the built-ins (`alibaba/z-image-turbo`). +NODE_TYPE_RE = re.compile(r"^[a-z0-9][a-z0-9._-]*/[a-z0-9][a-z0-9._-]*$") + +MANIFEST_FILENAME = "inline-extension.json" +SCHEMA_VERSION = 1 + + +class ManifestError(ValueError): + """One or more manifest problems. ``problems`` is the full list, one per JSON path.""" + + def __init__(self, problems: list[str]) -> None: + self.problems = problems + super().__init__("; ".join(problems)) + + +def package_name(extension_id: str) -> str: + """The top-level Python package an extension **must** ship. Enforced, not conventional: it is + what makes module ownership unambiguous, so no two extensions can collide on their own code.""" + return "inline_ext_" + extension_id.replace("-", "_") + + +@dataclass(frozen=True) +class ModelRequirement: + """One weight file a node needs on disk, in the shape the existing model-download popup + already consumes (see ``models/requirements.py``).""" + + id: str + label: str + category: str + repo: str + repo_file: str + filename: str + optional: bool = False + approx_bytes: int | None = None + + +@dataclass(frozen=True) +class ExtensionNode: + """One node an extension provides. Title, ports, and params come from the ``@inline_node`` + decorator - the manifest only declares what exists, so the two can never disagree.""" + + #: Declared ahead of import so collisions are caught in preflight, before any extension code + #: runs. The registrar rejects a runner whose type is not listed here. + type: str + models: tuple[ModelRequirement, ...] = () + default_enabled: bool = True + + +@dataclass(frozen=True) +class Prebuilt: + """An author-built ``site/`` tarball for one platform+interpreter. When one matches, install + skips resolution entirely - the Node-Packer path reduced to "an extension with nothing to + resolve".""" + + platform: str + python: str + url: str + sha256: str + + +@dataclass(frozen=True) +class Manifest: + schema: int + id: str + name: str + version: str + core_compat: str + python_requires: str + requirements: tuple[str, ...] + #: One entry point for the whole extension: ``module.path:register``. Because everything is + #: imported at once, enabling a node later never needs a restart. + entry: str + nodes: tuple[ExtensionNode, ...] + isolation: Literal["shared", "process"] = "shared" + prebuilt: tuple[Prebuilt, ...] = () + ui: str | None = None + author: str = "" + homepage: str = "" + license: str = "" + description: str = "" + + @property + def package(self) -> str: + return package_name(self.id) + + def node(self, node_type: str) -> ExtensionNode | None: + return next((n for n in self.nodes if n.type == node_type), None) + + def node_types(self) -> tuple[str, ...]: + return tuple(node.type for node in self.nodes) + + +# --- validation --------------------------------------------------------------------------------- + + +@dataclass +class _Ctx: + """Accumulates problems so one pass reports them all.""" + + problems: list[str] = field(default_factory=lambda: []) + + def bad(self, path: str, message: str) -> None: + self.problems.append(f"{path}: {message}") + + def text(self, obj: JsonObject, key: str, path: str, *, required: bool = True) -> str: + value = obj.get(key) + if value is None: + if required: + self.bad(f"{path}.{key}", "is required") + return "" + if not isinstance(value, str) or not value.strip(): + self.bad(f"{path}.{key}", "must be a non-empty string") + return "" + return value + + +def parse_manifest(raw: object, *, expect_id: str | None = None) -> Manifest: + """Validate a decoded manifest. ``expect_id`` (the extension directory name) must match ``id`` - + the two are the same identity and a mismatch would desynchronize the on-disk layout.""" + ctx = _Ctx() + obj = as_object(raw) + if obj is None: + raise ManifestError(["$: manifest must be a JSON object"]) + + schema = obj.get("schema") + if schema != SCHEMA_VERSION: + ctx.bad("$.schema", f"must be {SCHEMA_VERSION} (got {schema!r})") + + extension_id = ctx.text(obj, "id", "$") + if extension_id and not EXTENSION_ID_RE.match(extension_id): + ctx.bad("$.id", "must be lowercase letters, digits and dashes (2-64 chars)") + if extension_id and expect_id is not None and extension_id != expect_id: + ctx.bad("$.id", f"must equal the extension directory name {expect_id!r}") + + name = ctx.text(obj, "name", "$") + version = ctx.text(obj, "version", "$") + core_compat = ctx.text(obj, "coreCompat", "$") + python_requires = obj.get("pythonRequires") or ">=3.11" + if not isinstance(python_requires, str): + ctx.bad("$.pythonRequires", "must be a string") + python_requires = ">=3.11" + + isolation = obj.get("isolation", "shared") + if isolation not in ("shared", "process"): + ctx.bad("$.isolation", "must be 'shared' or 'process'") + isolation = "shared" + + entry = ctx.text(obj, "entry", "$") + if entry: + if not ENTRY_RE.match(entry): + ctx.bad("$.entry", "must look like 'module.path:callable'") + elif extension_id and not entry.startswith(package_name(extension_id)): + # The ownership model rests on this: an extension may only import from its own + # mandatory top-level package, so no two can shadow each other's code. + ctx.bad("$.entry", f"must live inside the extension's package " + f"{package_name(extension_id)!r}") + + requirements = _str_list(ctx, obj.get("requirements", []), "$.requirements") + prebuilt = _prebuilt(ctx, obj.get("prebuilt", [])) + nodes = _nodes(ctx, obj.get("nodes")) + ui = _ui(ctx, obj.get("ui"), "$") + + if ctx.problems: + raise ManifestError(ctx.problems) + + return Manifest( + schema=SCHEMA_VERSION, + id=extension_id, + name=name, + version=version, + core_compat=core_compat, + python_requires=python_requires, + requirements=requirements, + entry=entry, + nodes=nodes, + isolation="process" if isolation == "process" else "shared", + prebuilt=prebuilt, + ui=ui, + author=_opt_str(obj.get("author")), + homepage=_opt_str(obj.get("homepage")), + license=_opt_str(obj.get("license")), + description=_opt_str(obj.get("description")), + ) + + +def load_manifest(source_dir: Path, *, expect_id: str | None = None) -> Manifest: + """Read + validate ``/inline-extension.json``.""" + path = source_dir / MANIFEST_FILENAME + if not path.is_file(): + raise ManifestError([f"$: {MANIFEST_FILENAME} not found at the repository root"]) + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise ManifestError([f"$: could not read {MANIFEST_FILENAME} ({error})"]) from error + return parse_manifest(raw, expect_id=expect_id) + + +def _nodes(ctx: _Ctx, raw: object) -> tuple[ExtensionNode, ...]: + entries = as_array(raw) + if entries is None or not entries: + ctx.bad("$.nodes", "must be a non-empty array") + return () + items: list[ExtensionNode] = [] + seen: set[str] = set() + for index, entry in enumerate(entries): + path = f"$.nodes[{index}]" + node = as_object(entry) + if node is None: + ctx.bad(path, "must be an object") + continue + node_type = ctx.text(node, "type", path) + if node_type and not NODE_TYPE_RE.match(node_type): + ctx.bad(f"{path}.type", f"node type {node_type!r} must look like 'owner/name'") + if node_type and node_type in seen: + ctx.bad(f"{path}.type", f"node type {node_type!r} is declared twice") + seen.add(node_type) + + items.append( + ExtensionNode( + type=node_type, + models=_models(ctx, node.get("models", []), f"{path}.models"), + default_enabled=bool(node.get("defaultEnabled", True)), + ) + ) + return tuple(items) + + +def _ui(ctx: _Ctx, raw: object, path: str) -> str | None: + if raw is None: + return None + if not isinstance(raw, str) or not raw.strip(): + ctx.bad(f"{path}.ui", "must be a non-empty string when present") + return None + if raw.startswith("/") or ".." in Path(raw).parts: + ctx.bad(f"{path}.ui", "must be a relative path inside the repository") + return None + return raw + + +def _models(ctx: _Ctx, raw: object, path: str) -> tuple[ModelRequirement, ...]: + entries = as_array(raw) + if entries is None: + ctx.bad(path, "must be an array") + return () + items: list[ModelRequirement] = [] + for index, entry in enumerate(entries): + at = f"{path}[{index}]" + model = as_object(entry) + if model is None: + ctx.bad(at, "must be an object") + continue + category = ctx.text(model, "category", at) + if category and category not in CATEGORIES: + # Rejected, never rewritten: the category picks both the folder and the options_from + # dropdown, so a wrong one downloads fine then reports the model missing. + ctx.bad( + f"{at}.category", + f"must be one of {', '.join(CATEGORIES)} (got {category!r})", + ) + filename = model.get("filename") or model.get("repoFile") + if isinstance(filename, str) and ("/" in filename or ".." in filename): + ctx.bad(f"{at}.filename", "must be a bare filename, not a path") + filename = None + approx = model.get("approxBytes") + if approx is not None and not isinstance(approx, int): + ctx.bad(f"{at}.approxBytes", "must be an integer when present") + approx = None + items.append( + ModelRequirement( + id=ctx.text(model, "id", at), + label=ctx.text(model, "label", at), + category=category, + repo=ctx.text(model, "repo", at), + repo_file=ctx.text(model, "repoFile", at), + filename=filename if isinstance(filename, str) else "", + optional=bool(model.get("optional", False)), + approx_bytes=approx if isinstance(approx, int) else None, + ) + ) + return tuple(items) + + +def _prebuilt(ctx: _Ctx, raw: object) -> tuple[Prebuilt, ...]: + entries = as_array(raw) + if entries is None: + ctx.bad("$.prebuilt", "must be an array") + return () + items: list[Prebuilt] = [] + for index, entry in enumerate(entries): + at = f"$.prebuilt[{index}]" + built = as_object(entry) + if built is None: + ctx.bad(at, "must be an object") + continue + url = ctx.text(built, "url", at) + if url and not url.startswith("https://"): + ctx.bad(f"{at}.url", "must be https") + digest = ctx.text(built, "sha256", at) + if digest and not re.fullmatch(r"[0-9a-f]{64}", digest): + ctx.bad(f"{at}.sha256", "must be a 64-character hex sha256") + items.append( + Prebuilt( + platform=ctx.text(built, "platform", at), + python=ctx.text(built, "python", at), + url=url, + sha256=digest, + ) + ) + return tuple(items) + + +def _str_list(ctx: _Ctx, raw: object, path: str) -> tuple[str, ...]: + entries = as_array(raw) + if entries is None: + ctx.bad(path, "must be an array of strings") + return () + out: list[str] = [] + for index, item in enumerate(entries): + if not isinstance(item, str) or not item.strip(): + ctx.bad(f"{path}[{index}]", "must be a non-empty string") + continue + out.append(item) + return tuple(out) + + +def _opt_str(value: object) -> str: + return value if isinstance(value, str) else "" diff --git a/core/src/inline_core/extensions/models.py b/core/src/inline_core/extensions/models.py new file mode 100644 index 0000000..07ee6d1 --- /dev/null +++ b/core/src/inline_core/extensions/models.py @@ -0,0 +1,58 @@ +"""Manifest-declared weights, as a ``RequirementsProvider``. + +Turns manifest data into the interface Z-Image implements, so extension models reuse the existing +model popup, download events, and ``options_from`` dropdowns with no frontend work. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from ..config import models_dir +from ..models.catalog import CATEGORIES +from ..models.requirements import ModelComponent +from .manifest import ModelRequirement + + +class ManifestRequirements: + """Requirements for a node whose weights are declared in the manifest.""" + + def __init__(self, requirements: tuple[ModelRequirement, ...]) -> None: + self._requirements = requirements + + def components(self, params: dict[str, object] | None = None) -> list[ModelComponent]: + root = models_dir() + out: list[ModelComponent] = [] + for req in self._requirements: + category = _checked_category(req.category) + filename = req.filename or Path(req.repo_file).name + out.append( + ModelComponent( + id=req.id, + label=req.label, + category=category, + present=(root / category / filename).exists(), + filename=filename, + repo=req.repo, + repo_file=req.repo_file, + ) + ) + return out + + def download_target(self, component: ModelComponent) -> Path: + return models_dir() / _checked_category(component.category) + + def estimate(self, policy: Any) -> dict[str, Any] | None: + """None in v1: the manifest carries no footprint data, and a wrong "this will fit" is worse + than no estimate.""" + return None + + +def _checked_category(category: str) -> str: + """Confine downloads to a known models subfolder. Manifest validation rejects bad categories + first, so reaching here means a hand-edited install - raise rather than guess a substitute.""" + cleaned = category.strip().strip("/") + if cleaned not in CATEGORIES: + raise ValueError(f"unknown model category {category!r}") + return cleaned diff --git a/core/src/inline_core/extensions/paths.py b/core/src/inline_core/extensions/paths.py new file mode 100644 index 0000000..c15f643 --- /dev/null +++ b/core/src/inline_core/extensions/paths.py @@ -0,0 +1,235 @@ +"""The on-disk layout of the extensions root. One place that knows where anything lives. + +:: + + extensions/ + state.json which extensions are installed, active, and node-enabled + host-constraints.txt snapshot of the host's installed distributions + .cache/ git/.git bare mirrors; registry.json offline index + .staging// every install builds here; renamed into place on success + / one directory per extension, beside the metadata above + current pointer to the active version directory + versions/+/ source/ site/ lock/ manifest.json receipt.json + trash// pruned versions, removed lazily at next boot + +Staging-then-rename is what makes rollback free: nothing outside ``staging/`` is touched until the +install succeeds, so a failure has nothing to undo. +""" + +from __future__ import annotations + +import os +import shutil +from dataclasses import dataclass +from pathlib import Path + +from ..config import extensions_dir + +#: How many non-current versions to retain per extension for rollback. +KEEP_VERSIONS = 2 + + +def version_dirname(version: str, sha: str) -> str: + """``1.4.0+9f2c1ab`` - unique per commit, so re-installing a moved tag never reuses a stale + directory.""" + return f"{version}+{sha[:7]}" + + +@dataclass(frozen=True) +class VersionPaths: + """One installed version of one extension.""" + + root: Path + + @property + def source(self) -> Path: + return self.root / "source" + + @property + def site(self) -> Path: + return self.root / "site" + + @property + def lock(self) -> Path: + return self.root / "lock" + + @property + def manifest(self) -> Path: + return self.root / "manifest.json" + + @property + def receipt(self) -> Path: + return self.root / "receipt.json" + + @property + def titles(self) -> Path: + """node type -> title, captured at install so a disabled node still shows a readable name + (its descriptor isn't in the registry while it's off).""" + return self.root / "node-titles.json" + + @property + def owned_modules(self) -> Path: + return self.lock / "owned-modules.json" + + @property + def requirements_in(self) -> Path: + return self.lock / "requirements.in" + + @property + def requirements_lock(self) -> Path: + return self.lock / "requirements.lock" + + @property + def install_log(self) -> Path: + return self.lock / "install.log" + + @property + def scan(self) -> Path: + return self.root / "scan.json" + + @property + def ui(self) -> Path: + """Served at ``/ext//web/`` for extensions that ship a frontend bundle.""" + return self.source / "ui" + + def ensure(self) -> None: + self.source.mkdir(parents=True, exist_ok=True) + self.site.mkdir(parents=True, exist_ok=True) + self.lock.mkdir(parents=True, exist_ok=True) + + +@dataclass(frozen=True) +class ExtensionPaths: + root: Path + extension_id: str + + @property + def versions(self) -> Path: + return self.root / "versions" + + @property + def staging(self) -> Path: + return self.root / "staging" + + @property + def trash(self) -> Path: + return self.root / "trash" + + @property + def current_pointer(self) -> Path: + return self.root / "current" + + def version(self, dirname: str) -> VersionPaths: + return VersionPaths(self.versions / dirname) + + def installed_versions(self) -> list[str]: + """Version directory names present on disk, newest-modified first.""" + if not self.versions.is_dir(): + return [] + entries = [p for p in self.versions.iterdir() if p.is_dir()] + entries.sort(key=lambda p: p.stat().st_mtime, reverse=True) + return [p.name for p in entries] + + def current(self) -> str | None: + """The active version directory name, or None when nothing is activated. A text file, not a + symlink - symlinks need elevation on Windows outside developer mode.""" + try: + name = self.current_pointer.read_text(encoding="utf-8").strip() + except OSError: + return None + return name if name and (self.versions / name).is_dir() else None + + def set_current(self, dirname: str) -> None: + write_atomic(self.current_pointer, dirname + "\n") + + def new_staging(self, token: str) -> Path: + path = self.staging / token + path.mkdir(parents=True, exist_ok=True) + return path + + def prune(self, *, keep: int = KEEP_VERSIONS) -> list[str]: + """Move all but ``current`` + the ``keep`` most recent versions into ``trash/``. Moving, not + deleting: on Windows a file mapped by a loaded module can't be unlinked.""" + current = self.current() + stale = [v for v in self.installed_versions() if v != current][keep:] + for name in stale: + self.trash.mkdir(parents=True, exist_ok=True) + target = self.trash / name + shutil.rmtree(target, ignore_errors=True) + try: + os.replace(self.versions / name, target) + except OSError: + continue + return stale + + def empty_trash(self) -> None: + shutil.rmtree(self.trash, ignore_errors=True) + + +@dataclass(frozen=True) +class ExtensionsRoot: + """The extensions root.""" + + root: Path + + @classmethod + def resolve(cls, root: Path | None = None) -> ExtensionsRoot: + return cls((root or extensions_dir()).expanduser()) + + @property + def state(self) -> Path: + return self.root / "state.json" + + @property + def constraints(self) -> Path: + return self.root / "host-constraints.txt" + + @property + def cache(self) -> Path: + return self.root / ".cache" + + @property + def staging(self) -> Path: + """Global, not per-extension: the extension id is only known once the manifest is read.""" + return self.root / ".staging" + + def new_staging(self, token: str) -> Path: + path = self.staging / token + shutil.rmtree(path, ignore_errors=True) + path.mkdir(parents=True, exist_ok=True) + return path + + def clear_staging(self) -> None: + shutil.rmtree(self.staging, ignore_errors=True) + + @property + def registry_index(self) -> Path: + return self.cache / "registry.json" + + def git_mirror(self, extension_id: str) -> Path: + return self.cache / "git" / f"{extension_id}.git" + + def extension(self, extension_id: str) -> ExtensionPaths: + return ExtensionPaths(self.root / extension_id, extension_id) + + def installed_extensions(self) -> list[str]: + """Extension directories sit at the root beside state.json. Ids are `[a-z0-9-]+`, so they + can never collide with the dot-directories or the two metadata files.""" + if not self.root.is_dir(): + return [] + return sorted( + p.name for p in self.root.iterdir() if p.is_dir() and not p.name.startswith(".") + ) + + def ensure_dirs(self) -> None: + self.root.mkdir(parents=True, exist_ok=True) + (self.cache / "git").mkdir(parents=True, exist_ok=True) + + +def write_atomic(path: Path, text: str) -> None: + """Temp file + ``os.replace``: a crash mid-write must never half-write ``state.json`` and + strand every installed extension.""" + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_name(path.name + ".tmp") + tmp.write_text(text, encoding="utf-8") + os.replace(tmp, path) diff --git a/core/src/inline_core/extensions/resolve.py b/core/src/inline_core/extensions/resolve.py new file mode 100644 index 0000000..b309c55 --- /dev/null +++ b/core/src/inline_core/extensions/resolve.py @@ -0,0 +1,298 @@ +"""Resolves and installs an extension's dependencies into its private ``site/``. + +compile against the host constraints -> install the pinned lock with ``--no-deps`` -> prune host +duplicates -> derive the owned module names from the surviving RECORD files. + +Every uv call passes ``--python sys.executable``; without it uv picks an interpreter and can install +wheels for the wrong ABI. +""" + +from __future__ import annotations + +import subprocess +import sys +from dataclasses import dataclass, field +from pathlib import Path + +from .constraints import ( + Conflict, + canonical, + conflicts, + host_distributions, + prunable, + write_constraints, +) +from .tools import RESOLVE_TIMEOUT, UV + + +class ResolutionError(RuntimeError): + """Dependency resolution failed. ``conflicts`` is populated when the cause was a host clash.""" + + def __init__(self, message: str, conflicts: list[Conflict] | None = None) -> None: + super().__init__(message) + self.conflicts = conflicts or [] + + +@dataclass +class Resolution: + """What landed in an extension's private site directory.""" + + lock_text: str = "" + #: Top-level module names the finder will route to this extension's site/. + modules: list[str] = field(default_factory=lambda: []) + #: Canonical distribution name -> version actually installed privately. + distributions: dict[str, str] = field(default_factory=lambda: {}) + pruned: list[str] = field(default_factory=lambda: []) + + def to_json(self) -> dict[str, object]: + return { + "topLevel": sorted(self.modules), + "distributions": self.distributions, + "pruned": self.pruned, + } + + +def resolve_and_install( + requirements: tuple[str, ...], + *, + site: Path, + lock_dir: Path, + constraints_path: Path, + log: Path | None = None, +) -> Resolution: + """Resolve ``requirements`` against the host and install them into ``site``.""" + lock_dir.mkdir(parents=True, exist_ok=True) + site.mkdir(parents=True, exist_ok=True) + + if not requirements: + # Runs entirely on the host stack. A prebuilt install reduces to this too. + return Resolution() + + UV.require() + write_constraints(constraints_path) + + requirements_in = lock_dir / "requirements.in" + requirements_in.write_text("\n".join(requirements) + "\n", encoding="utf-8") + lock_path = lock_dir / "requirements.lock" + + compiled = _compile(requirements_in, lock_path, constraints_path, log) + if not compiled: + raise _explain_failure(requirements_in, lock_dir, log) + + _install(lock_path, site, log) + return _finalize(site, lock_path.read_text(encoding="utf-8")) + + +def _compile( + requirements_in: Path, lock_path: Path, constraints_path: Path, log: Path | None +) -> bool: + done = _run( + [ + "uv", + "pip", + "compile", + str(requirements_in), + "--constraint", + str(constraints_path), + "--python", + sys.executable, + "--output-file", + str(lock_path), + ], + log, + ) + return done.returncode == 0 + + +def _explain_failure(requirements_in: Path, lock_dir: Path, log: Path | None) -> ResolutionError: + """Turn a constrained-resolution failure into a sentence a user can act on: re-resolve + unconstrained, then diff against the host to name the package and both versions.""" + scratch = lock_dir / "unconstrained.lock" + done = _run( + [ + "uv", + "pip", + "compile", + str(requirements_in), + "--python", + sys.executable, + "--output-file", + str(scratch), + ], + log, + ) + if done.returncode != 0: + # Not a host clash at all - the requirements are unsatisfiable on their own terms. + return ResolutionError(_tail(done.stderr) or "dependency resolution failed") + + try: + found = conflicts(scratch.read_text(encoding="utf-8")) + except OSError: + found = [] + finally: + scratch.unlink(missing_ok=True) + + if not found: + return ResolutionError( + "dependency resolution failed against the installed packages; " + "see install.log for the resolver output" + ) + headline = "; ".join(c.message() for c in found[:3]) + if len(found) > 3: + headline += f" (and {len(found) - 3} more)" + return ResolutionError(headline, conflicts=found) + + +def _install(lock_path: Path, site: Path, log: Path | None) -> None: + done = _run( + [ + "uv", + "pip", + "install", + "--requirement", + str(lock_path), + "--target", + str(site), + "--python", + sys.executable, + "--no-deps", # the lock is complete; never re-resolve at install time + ], + log, + ) + if done.returncode != 0: + raise ResolutionError(_tail(done.stderr) or "installing dependencies failed") + + +def _finalize(site: Path, lock_text: str) -> Resolution: + """Prune host duplicates, then derive the module names the finder will own. Pruning first means + the module list can never contain a shared package.""" + installed = _site_distributions(site) + host = host_distributions() + pruned: list[str] = [] + for name in prunable(installed, host): + if _remove_distribution(site, name): + pruned.append(name) + installed.pop(name, None) + + return Resolution( + lock_text=lock_text, + modules=sorted(_top_level_modules(site)), + distributions=installed, + pruned=pruned, + ) + + +def _dist_infos(site: Path) -> list[Path]: + return sorted(site.glob("*.dist-info")) if site.is_dir() else [] + + +def _site_distributions(site: Path) -> dict[str, str]: + found: dict[str, str] = {} + for info in _dist_infos(site): + name, _, version = info.stem.rpartition("-") + if name: + found[canonical(name)] = version + return found + + +def _remove_distribution(site: Path, canonical_name: str) -> bool: + """Delete a distribution's files and its dist-info, using RECORD as the file list.""" + removed = False + for info in _dist_infos(site): + dist_name, _, _version = info.stem.rpartition("-") + if canonical(dist_name) != canonical_name: + continue + for relative in _record_paths(info): + target = site / relative + try: + if target.is_file() or target.is_symlink(): + target.unlink() + except OSError: + continue + _rmtree(info) + _prune_empty_dirs(site) + removed = True + return removed + + +def _record_paths(info: Path) -> list[str]: + try: + record = (info / "RECORD").read_text(encoding="utf-8") + except OSError: + return [] + paths: list[str] = [] + for line in record.splitlines(): + entry = line.split(",", 1)[0].strip() + # Never follow a RECORD entry out of the site directory. + if entry and not entry.startswith(("/", "..")) and ".." not in Path(entry).parts: + paths.append(entry) + return paths + + +def _top_level_modules(site: Path) -> set[str]: + """Top-level importable names in ``site``. ``top_level.txt`` when present, else the first path + segment of each RECORD entry, so a wheel without that metadata still routes.""" + modules: set[str] = set() + for info in _dist_infos(site): + declared = info / "top_level.txt" + if declared.is_file(): + try: + modules.update( + line.strip() + for line in declared.read_text(encoding="utf-8").splitlines() + if line.strip() + ) + continue + except OSError: + pass + for relative in _record_paths(info): + head = Path(relative).parts[0] if Path(relative).parts else "" + if not head or head.endswith(".dist-info") or head == "..": + continue + modules.add(head[:-3] if head.endswith(".py") else head) + return {m for m in modules if m and not m.startswith(".")} + + +def _prune_empty_dirs(site: Path) -> None: + for path in sorted(site.rglob("*"), key=lambda p: len(p.parts), reverse=True): + if path.is_dir(): + try: + path.rmdir() # only succeeds when empty + except OSError: + continue + + +def _rmtree(path: Path) -> None: + import shutil + + shutil.rmtree(path, ignore_errors=True) + + +def _run(argv: list[str], log: Path | None) -> subprocess.CompletedProcess[str]: + done = subprocess.run( # noqa: S603 - fixed argv from our own code, no shell + argv, + capture_output=True, + text=True, + timeout=RESOLVE_TIMEOUT, + check=False, + ) + if log is not None: + _append_log(log, argv, done) + return done + + +def _append_log(log: Path, argv: list[str], done: subprocess.CompletedProcess[str]) -> None: + log.parent.mkdir(parents=True, exist_ok=True) + with log.open("a", encoding="utf-8") as handle: + handle.write(f"$ {' '.join(argv)}\n") + if done.stdout: + handle.write(done.stdout) + if done.stderr: + handle.write(done.stderr) + handle.write(f"[exit {done.returncode}]\n\n") + + +def _tail(text: str, lines: int = 6) -> str: + """The last few lines of resolver output - enough to be useful in a dialog, not a wall.""" + kept = [line for line in text.strip().splitlines() if line.strip()][-lines:] + return "\n".join(kept) diff --git a/core/src/inline_core/extensions/scanner.py b/core/src/inline_core/extensions/scanner.py new file mode 100644 index 0000000..172083b --- /dev/null +++ b/core/src/inline_core/extensions/scanner.py @@ -0,0 +1,448 @@ +"""The install-time security scan, shared verbatim with registry CI. + +This informs a decision; it does not contain anything. In-process Python cannot be sandboxed, so the +real defences are review, provenance, and consent - never imply a clean scan means "safe". + +CRITICAL blocks outright (no legitimate use in an extension). HIGH/MEDIUM needs typed consent. +LOW never gates: egress to a known model host stays LOW so the consent prompt stays rare enough +that users actually read it. +""" + +from __future__ import annotations + +import ast +import json +from dataclasses import dataclass +from enum import StrEnum +from pathlib import Path + +from .constraints import protected_requirements +from .manifest import Manifest, as_array, as_object + + +class Severity(StrEnum): + CRITICAL = "critical" + HIGH = "high" + MEDIUM = "medium" + LOW = "low" + + @property + def blocks(self) -> bool: + return self is Severity.CRITICAL + + @property + def needs_consent(self) -> bool: + return self in (Severity.HIGH, Severity.MEDIUM) + + +#: Hosts an extension may reach without prompting: model weights and release artifacts. +EGRESS_ALLOWLIST: frozenset[str] = frozenset( + { + "huggingface.co", + "hf.co", + "cdn-lfs.huggingface.co", + "cdn-lfs-us-1.hf.co", + "raw.githubusercontent.com", + "github.com", + "objects.githubusercontent.com", + } +) + +#: Callables that reach the network. Value is True when the call carries an explicit URL argument +#: we can try to resolve statically. +_NETWORK_CALLS: dict[str, bool] = { + "urlopen": True, + "urlretrieve": True, + "get": True, + "post": True, + "put": True, + "delete": True, + "head": True, + "patch": True, + "request": True, + "hf_hub_download": False, + "snapshot_download": False, +} +_NETWORK_MODULES = {"requests", "httpx", "urllib", "urllib3", "aiohttp", "socket"} +_HF_CALLS = {"hf_hub_download", "snapshot_download", "load_dataset"} + +_EXEC_NAMES = {"exec", "eval", "compile"} +_DECODERS = {"b64decode", "b85decode", "a85decode", "unhexlify", "decompress", "loads"} +_SUBPROCESS = {"subprocess", "os", "pty", "commands"} +_SUBPROCESS_CALLS = {"system", "popen", "spawn", "spawnl", "spawnv", "execv", "execve", "fork"} + + +@dataclass(frozen=True) +class Finding: + severity: Severity + rule: str + message: str + file: str = "" + line: int = 0 + + def to_json(self) -> dict[str, object]: + return { + "severity": self.severity.value, + "rule": self.rule, + "message": self.message, + "file": self.file, + "line": self.line, + } + + +@dataclass +class ScanReport: + findings: list[Finding] + + @property + def blocked(self) -> bool: + return any(f.severity.blocks for f in self.findings) + + @property + def needs_consent(self) -> bool: + return not self.blocked and any(f.severity.needs_consent for f in self.findings) + + def consent_rules(self) -> list[str]: + """Rule ids the user must accept. Recorded in state.json so re-installing the same version + does not re-prompt, while a new version - with possibly new behaviour - does.""" + return sorted({f.rule for f in self.findings if f.severity.needs_consent}) + + def by_severity(self, severity: Severity) -> list[Finding]: + return [f for f in self.findings if f.severity is severity] + + def to_json(self) -> dict[str, object]: + return { + "blocked": self.blocked, + "needsConsent": self.needs_consent, + "consentRules": self.consent_rules(), + "findings": [f.to_json() for f in self.findings], + } + + +def scan(source: Path, manifest: Manifest | None = None) -> ScanReport: + """Scan an extension checkout. ``manifest`` enables the requirements and packaging checks.""" + findings: list[Finding] = [] + if manifest is not None: + findings.extend(_scan_manifest(manifest)) + findings.extend(_scan_packaging(source)) + for path in sorted(source.rglob("*.py")): + if _skip(path, source): + continue + findings.extend(_scan_python(path, source)) + findings.extend(_scan_binaries(source)) + findings.sort(key=lambda f: (_ORDER[f.severity], f.file, f.line)) + return ScanReport(findings) + + +_ORDER = {Severity.CRITICAL: 0, Severity.HIGH: 1, Severity.MEDIUM: 2, Severity.LOW: 3} + + +def _skip(path: Path, source: Path) -> bool: + parts = path.relative_to(source).parts + return any(part in {".git", "__pycache__", ".venv", "site", "node_modules"} for part in parts) + + +# --- manifest + packaging ------------------------------------------------------------------------- + + +def _scan_manifest(manifest: Manifest) -> list[Finding]: + findings: list[Finding] = [] + for requirement in protected_requirements(manifest.requirements): + findings.append( + Finding( + Severity.CRITICAL, + "host-dependency-override", + f"requires {requirement!r}, which is part of the shared Inline runtime. " + "Extensions must depend on the installed torch/diffusers/transformers, never " + "install their own - two copies in one process corrupts the CUDA context.", + file="inline-extension.json", + ) + ) + if not manifest.license: + findings.append( + Finding( + Severity.LOW, + "no-license", + "the manifest declares no license", + file="inline-extension.json", + ) + ) + return findings + + +def _scan_packaging(source: Path) -> list[Finding]: + """Installing must never execute author code. + + Dependencies install from a fully pinned lock with ``--no-deps``, so a ``setup.py`` in the repo + is not run by us - but its presence means the repo expects to be built, and a build backend + runs arbitrary code. Flag it rather than silently relying on our install shape. + """ + findings: list[Finding] = [] + setup_py = source / "setup.py" + if setup_py.is_file(): + findings.append( + Finding( + Severity.CRITICAL, + "install-time-code-execution", + "ships a setup.py, which executes arbitrary code at build time. Extensions are " + "loaded from source and must not require a build step.", + file="setup.py", + ) + ) + return findings + + +def _scan_binaries(source: Path) -> list[Finding]: + findings: list[Finding] = [] + for path in sorted(source.rglob("*")): + if not path.is_file() or _skip(path, source): + continue + name = path.name.lower() + relative = str(path.relative_to(source)) + if name.startswith("libtorch") or name.startswith("libcud") or name.startswith("nvidia"): + findings.append( + Finding( + Severity.CRITICAL, + "bundled-native-runtime", + f"ships {relative!r}. A second copy of the CUDA/torch native runtime in one " + "process segfaults rather than raising, and looks like an Inline Core bug.", + file=relative, + ) + ) + elif path.suffix.lower() in {".so", ".pyd", ".dylib"}: + findings.append( + Finding( + Severity.MEDIUM, + "compiled-binary", + f"ships the compiled binary {relative!r}, which cannot be reviewed as source", + file=relative, + ) + ) + return findings + + +# --- python source -------------------------------------------------------------------------------- + + +def _scan_python(path: Path, source: Path) -> list[Finding]: + relative = str(path.relative_to(source)) + try: + tree = ast.parse(path.read_text(encoding="utf-8"), filename=relative) + except (OSError, SyntaxError, ValueError) as error: + return [ + Finding( + Severity.MEDIUM, + "unparseable-source", + f"could not be parsed for review ({error})", + file=relative, + ) + ] + visitor = _Visitor(relative) + visitor.visit(tree) + return visitor.findings + + +class _Visitor(ast.NodeVisitor): + def __init__(self, relative: str) -> None: + self.file = relative + self.findings: list[Finding] = [] + self._imported_decoders: set[str] = set() + + def _add(self, severity: Severity, rule: str, message: str, node: ast.AST) -> None: + self.findings.append( + Finding(severity, rule, message, file=self.file, line=getattr(node, "lineno", 0)) + ) + + def visit_Import(self, node: ast.Import) -> None: # noqa: N802 - ast API + for alias in node.names: + self._note_import(alias.name.split(".")[0], node) + self.generic_visit(node) + + def visit_ImportFrom(self, node: ast.ImportFrom) -> None: # noqa: N802 - ast API + module = (node.module or "").split(".")[0] + self._note_import(module, node) + for alias in node.names: + if alias.name in _DECODERS: + self._imported_decoders.add(alias.asname or alias.name) + self.generic_visit(node) + + def _note_import(self, module: str, node: ast.AST) -> None: + if module in {"base64", "binascii", "zlib", "marshal", "pickle"}: + self._imported_decoders.add(module) + if module == "ctypes": + self._add( + Severity.HIGH, + "ctypes", + "imports ctypes, which can call arbitrary native code and bypass this review", + node, + ) + elif module == "socket": + self._add( + Severity.HIGH, + "raw-socket", + "opens raw sockets, whose destination cannot be checked statically", + node, + ) + + def visit_Call(self, node: ast.Call) -> None: # noqa: N802 - ast API + name = _call_name(node.func) + qualifier = _call_qualifier(node.func) + + if name in _EXEC_NAMES: + self._check_exec(node, name) + elif qualifier in _SUBPROCESS and name in _SUBPROCESS_CALLS: + self._add( + Severity.HIGH, + "subprocess", + f"runs external commands via {qualifier}.{name}()", + node, + ) + elif qualifier == "subprocess" or name in {"Popen", "run"} and qualifier == "subprocess": + self._add(Severity.HIGH, "subprocess", "runs external commands via subprocess", node) + elif name in {"loads", "load"} and qualifier == "pickle": + self._add( + Severity.HIGH, + "pickle-deserialization", + "deserializes pickle data, which executes arbitrary code on load", + node, + ) + elif name in _NETWORK_CALLS and (qualifier in _NETWORK_MODULES or name in _HF_CALLS): + self._check_egress(node, name, qualifier) + + self.generic_visit(node) + + def _check_exec(self, node: ast.Call, name: str) -> None: + """``exec`` over a decoded blob is the signature of a hidden payload - and unlike a plain + ``exec``, it has no legitimate explanation in an extension, so it blocks.""" + if node.args and _wraps_decoder(node.args[0], self._imported_decoders): + self._add( + Severity.CRITICAL, + "obfuscated-execution", + f"calls {name}() on a decoded or decompressed payload, hiding what it runs", + node, + ) + elif name != "compile": + self._add( + Severity.HIGH, + "dynamic-execution", + f"calls {name}() on a value computed at runtime", + node, + ) + + def _check_egress(self, node: ast.Call, name: str, qualifier: str) -> None: + if name in _HF_CALLS: + self._add( + Severity.LOW, + "model-download", + f"downloads model files via {name}()", + node, + ) + return + host = _literal_host(node) + if host is None: + self._add( + Severity.HIGH, + "network-egress", + f"{qualifier}.{name}() sends a request to a URL built at runtime, so its " + "destination cannot be determined by review", + node, + ) + elif host in EGRESS_ALLOWLIST: + self._add(Severity.LOW, "network-egress-known", f"contacts {host}", node) + else: + self._add( + Severity.HIGH, + "network-egress", + f"contacts {host}, which is not a recognized model or release host", + node, + ) + + +def _call_name(func: ast.expr) -> str: + if isinstance(func, ast.Name): + return func.id + if isinstance(func, ast.Attribute): + return func.attr + return "" + + +def _call_qualifier(func: ast.expr) -> str: + """The leftmost name of a dotted call, e.g. ``requests`` in ``requests.adapters.get()``.""" + if isinstance(func, ast.Attribute): + node: ast.expr = func.value + while isinstance(node, ast.Attribute): + node = node.value + if isinstance(node, ast.Name): + return node.id + return "" + + +def _wraps_decoder(node: ast.expr, imported: set[str]) -> bool: + """True when the expression decodes or decompresses something anywhere inside it. + + The decoder must be traceable to an actual decoding module (``base64.b64decode``) or to a name + imported from one (``from base64 import b64decode``). Matching on the bare call name alone + would make ``exec(json.loads(cfg))`` a CRITICAL finding - and CRITICAL blocks with no override, + so a false positive here is unrecoverable for the user. + """ + for child in ast.walk(node): + if not isinstance(child, ast.Call): + continue + name = _call_name(child.func) + if name not in _DECODERS: + continue + qualifier = _call_qualifier(child.func) + if qualifier in imported or (not qualifier and name in imported): + return True + return False + + +def _literal_host(node: ast.Call) -> str | None: + """The host of a statically known URL argument, or None when it isn't a literal. + + An f-string or a variable returns None on purpose: an unresolvable destination is exactly the + case that must stay HIGH rather than being waved through. + """ + from urllib.parse import urlparse + + candidates = list(node.args) + [kw.value for kw in node.keywords if kw.arg in {"url", "uri"}] + for candidate in candidates: + if isinstance(candidate, ast.Constant) and isinstance(candidate.value, str): + parsed = urlparse(candidate.value) + if parsed.scheme in {"http", "https"} and parsed.hostname: + return parsed.hostname.lower() + return None + + +def load_report(path: Path) -> ScanReport: + """Re-read a persisted scan, so a resumed install does not re-scan.""" + try: + raw = as_object(json.loads(path.read_text(encoding="utf-8"))) + except (OSError, json.JSONDecodeError): + return ScanReport([]) + findings: list[Finding] = [] + for item in as_array(raw.get("findings")) or [] if raw is not None else []: + entry = as_object(item) + if entry is None: + continue + try: + severity = Severity(str(entry.get("severity"))) + except ValueError: + continue + findings.append( + Finding( + severity=severity, + rule=str(entry.get("rule", "")), + message=str(entry.get("message", "")), + file=str(entry.get("file", "")), + line=_int(entry.get("line")), + ) + ) + return ScanReport(findings) + + +def _int(value: object) -> int: + try: + return int(value) # pyright: ignore[reportArgumentType] - guarded by the except + except (TypeError, ValueError): + return 0 diff --git a/core/src/inline_core/extensions/state.py b/core/src/inline_core/extensions/state.py new file mode 100644 index 0000000..ecef224 --- /dev/null +++ b/core/src/inline_core/extensions/state.py @@ -0,0 +1,168 @@ +"""``extensions/state.json``: which extensions are installed, active, and enabled. + +Source of truth for *intent*; the version directories are the source of truth for *content*. Boot +reconciles them - an extension whose recorded version directory vanished is reported broken. + +``import_owners`` is the materialized dependency resolution (one extension per top-level +derivable from the lockfiles but stored so boot is a pure file read. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field + +from .manifest import JsonObject, as_array, as_object +from .paths import ExtensionsRoot, write_atomic + +STATE_SCHEMA = 1 + + +@dataclass +class ExtensionState: + current: str = "" + enabled: bool = True + #: node type -> enabled. Absent means "use the manifest's defaultEnabled". + nodes: dict[str, bool] = field(default_factory=lambda: {}) + #: Security findings the user explicitly accepted, by rule id. + consents: list[str] = field(default_factory=lambda: []) + #: True when the user chose this version explicitly; update checks leave it alone. + pinned: bool = False + + def node_enabled(self, node_type: str, *, default: bool) -> bool: + return self.nodes.get(node_type, default) + + +class StateStore: + """Reads and writes ``state.json``. Every mutation persists immediately; no explicit save, + mirroring the project DB.""" + + def __init__(self, paths: ExtensionsRoot) -> None: + self._paths = paths + self._extensions: dict[str, ExtensionState] = {} + self._import_owners: dict[str, str] = {} + self.reload() + + # --- reading --------------------------------------------------------------------------------- + + def reload(self) -> None: + self._extensions = {} + self._import_owners = {} + try: + data = as_object(json.loads(self._paths.state.read_text(encoding="utf-8"))) + except (OSError, json.JSONDecodeError): + return # absent or corrupt: start empty rather than refusing to boot + if data is None or data.get("schema") != STATE_SCHEMA: + return # a future/unknown schema: ignore rather than misread it + extensions = as_object(data.get("extensions")) + if extensions is not None: + for extension_id, raw_entry in extensions.items(): + entry = as_object(raw_entry) + if entry is not None: + self._extensions[extension_id] = _extension_state(entry) + owners = as_object(data.get("importOwners")) + if owners is not None: + self._import_owners = {k: v for k, v in owners.items() if isinstance(v, str)} + + def extension(self, extension_id: str) -> ExtensionState | None: + return self._extensions.get(extension_id) + + def extensions(self) -> dict[str, ExtensionState]: + return dict(self._extensions) + + def import_owners(self) -> dict[str, str]: + return dict(self._import_owners) + + def owner_of(self, module: str) -> str | None: + return self._import_owners.get(module) + + # --- writing --------------------------------------------------------------------------------- + + def activate( + self, + extension_id: str, + *, + version: str, + owns: list[str], + consents: list[str] | None = None, + nodes: dict[str, bool] | None = None, + ) -> None: + """Record an activation. ``owns`` is the Python import names this extension resolves + privately; ``nodes`` is which of its nodes are enabled. Ownership it no longer + needs is released, so a stale claim can't block another extension.""" + state = self._extensions.setdefault(extension_id, ExtensionState()) + state.current = version + state.enabled = True + if consents is not None: + state.consents = list(consents) + if nodes is not None: + state.nodes = dict(nodes) + self._claim_imports(extension_id, owns) + self._save() + + def set_enabled(self, extension_id: str, enabled: bool) -> None: + state = self._extensions.get(extension_id) + if state is None: + return + state.enabled = enabled + self._save() + + def set_node_enabled(self, extension_id: str, node_type: str, enabled: bool) -> None: + state = self._extensions.get(extension_id) + if state is None: + return + state.nodes[node_type] = enabled + self._save() + + def set_current(self, extension_id: str, version: str, *, pinned: bool = True) -> None: + """Point an extension at an installed version (the rollback / version-switch path).""" + state = self._extensions.get(extension_id) + if state is None: + return + state.current = version + state.pinned = pinned + self._save() + + def remove(self, extension_id: str) -> None: + self._extensions.pop(extension_id, None) + self._import_owners = {m: o for m, o in self._import_owners.items() if o != extension_id} + self._save() + + def _claim_imports(self, extension_id: str, owns: list[str]) -> None: + wanted = set(owns) + self._import_owners = { + name: owner + for name, owner in self._import_owners.items() + if owner != extension_id or name in wanted + } + for name in wanted: + self._import_owners[name] = extension_id + + def _save(self) -> None: + payload = { + "schema": STATE_SCHEMA, + "extensions": { + extension_id: { + "current": s.current, + "enabled": s.enabled, + "nodes": s.nodes, + "consents": s.consents, + "pinned": s.pinned, + } + for extension_id, s in sorted(self._extensions.items()) + }, + "importOwners": dict(sorted(self._import_owners.items())), + } + write_atomic(self._paths.state, json.dumps(payload, indent=2, sort_keys=False) + "\n") + + +def _extension_state(entry: JsonObject) -> ExtensionState: + nodes = as_object(entry.get("nodes")) or {} + consents = as_array(entry.get("consents")) or [] + return ExtensionState( + current=str(entry.get("current", "")), + enabled=bool(entry.get("enabled", True)), + nodes={key: bool(value) for key, value in nodes.items()}, + consents=[c for c in consents if isinstance(c, str)], + pinned=bool(entry.get("pinned", False)), + ) diff --git a/core/src/inline_core/extensions/tools.py b/core/src/inline_core/extensions/tools.py new file mode 100644 index 0000000..2365a3a --- /dev/null +++ b/core/src/inline_core/extensions/tools.py @@ -0,0 +1,138 @@ +"""``git`` and ``uv``, which the installer shells out to and which a wheel install may lack. + +Both are probed before an install starts so a missing one gives an actionable message, not a stack +trace three phases in. No silent pip fallback: a lock produced by one resolver may not reproduce +under the other, and quietly switching resolver is worse than refusing to start. +""" + +from __future__ import annotations + +import platform +import shutil +import subprocess +from dataclasses import dataclass + +#: Wall-clock ceilings. Resolution over a cold wheel cache is genuinely slow; a hung network call +#: must not wedge the install thread forever. +PROBE_TIMEOUT = 10 +FETCH_TIMEOUT = 300 +RESOLVE_TIMEOUT = 900 + + +class ToolMissing(RuntimeError): + """A required external tool is not on PATH. The message is shown to the user verbatim.""" + + +@dataclass(frozen=True) +class Tool: + name: str + probe: tuple[str, ...] + why: str + install_hint: str + + def path(self) -> str | None: + return shutil.which(self.name) + + def available(self) -> bool: + return self.path() is not None + + def version(self) -> str | None: + """Version string, or None when absent or unrunnable - so a tool that is on PATH but broken + fails here rather than mid-install.""" + if not self.available(): + return None + try: + done = subprocess.run( # noqa: S603 - fixed argv, no shell, no user input + list(self.probe), + capture_output=True, + text=True, + timeout=PROBE_TIMEOUT, + check=False, + ) + except (OSError, subprocess.SubprocessError): + return None + return done.stdout.strip() or done.stderr.strip() or None if done.returncode == 0 else None + + def require(self) -> str: + location = self.path() + if location is None: + raise ToolMissing( + f"{self.name} is required to {self.why}, but it was not found on PATH.\n" + f"{self.install_hint}" + ) + return location + + +def _uv_hint() -> str: + if platform.system() == "Windows": + return 'Install it with: powershell -c "irm https://astral.sh/uv/install.ps1 | iex"' + return "Install it with: curl -LsSf https://astral.sh/uv/install.sh | sh" + + +def _git_hint() -> str: + system = platform.system() + if system == "Darwin": + return "Install it with: xcode-select --install" + if system == "Windows": + return "Install it from: https://git-scm.com/download/win" + return "Install it with your package manager, e.g.: sudo apt install git" + + +UV = Tool( + name="uv", + probe=("uv", "--version"), + why="resolve an extension's dependencies", + install_hint=_uv_hint(), +) + +GIT = Tool( + name="git", + probe=("git", "--version"), + why="download an extension from its repository", + install_hint=_git_hint(), +) + +REQUIRED = (GIT, UV) + + +@dataclass(frozen=True) +class ToolStatus: + name: str + available: bool + version: str | None + hint: str + + def to_json(self) -> dict[str, object]: + return { + "name": self.name, + "available": self.available, + "version": self.version, + "hint": self.hint, + } + + +def tool_status() -> list[ToolStatus]: + """What the Extensions dialog shows when installing is unavailable.""" + return [ + ToolStatus( + name=tool.name, + available=tool.available(), + version=tool.version(), + hint=tool.install_hint, + ) + for tool in REQUIRED + ] + + +def missing_tools() -> list[str]: + return [tool.name for tool in REQUIRED if not tool.available()] + + +def require_tools() -> None: + """Raise before any install work begins if a required tool is absent.""" + for tool in REQUIRED: + tool.require() + + +def can_install() -> bool: + return not missing_tools() diff --git a/core/src/inline_core/graph/registry.py b/core/src/inline_core/graph/registry.py index 7badbe7..3d981c0 100644 --- a/core/src/inline_core/graph/registry.py +++ b/core/src/inline_core/graph/registry.py @@ -4,7 +4,7 @@ import hashlib import json -from dataclasses import replace +from dataclasses import asdict, replace from ..errors import UnknownNodeType from .descriptor import NodeDescriptor @@ -24,6 +24,14 @@ def register(self, descriptor: NodeDescriptor, runner: NodeRunner | None = None) if runner is not None: self._runners[descriptor.type] = runner + def unregister(self, node_type: str) -> None: + """Drop a node type. Used when an extension is disabled; unknown types are ignored. + + Only the registry entry goes away - a module already imported stays in ``sys.modules`` + (Python cannot unload), but nothing can reach it once no descriptor points at its runner.""" + self._descriptors.pop(node_type, None) + self._runners.pop(node_type, None) + def get(self, node_type: str) -> NodeDescriptor: descriptor = self._descriptors.get(node_type) if descriptor is None: @@ -43,8 +51,19 @@ def descriptors(self) -> list[NodeDescriptor]: return list(self._descriptors.values()) def version(self) -> str: - # TODO(phase1): fold resolved dynamic options into this so installing a model bumps it. - payload = json.dumps(sorted(self._descriptors), separators=(",", ":")) + """A fingerprint of the **full descriptor content**, not just the set of node types. + + Node types alone are not enough now that extensions can be toggled and switched between + versions: an upgrade that changes a param default while keeping its node types would + produce an identical fingerprint, and the client - which caches ``/v1/models`` against this + as an ETag - would keep serving the stale catalog. + """ + payload = json.dumps( + [asdict(d) for _, d in sorted(self._descriptors.items())], + separators=(",", ":"), + sort_keys=True, + default=str, # enum members serialize by value; anything exotic by repr + ) return f"r_{hashlib.sha256(payload.encode()).hexdigest()[:8]}" diff --git a/core/src/inline_core/models/requirements.py b/core/src/inline_core/models/requirements.py new file mode 100644 index 0000000..5ca5058 --- /dev/null +++ b/core/src/inline_core/models/requirements.py @@ -0,0 +1,87 @@ +"""What a node needs on disk, and who answers that question for it. + +The model popup asks one thing - *"which weight files does this node need, are they here, and where +do I get the missing ones?"* - and until now only Z-Image could answer, by a hardcoded node-type +check in the Studio download layer. This module turns that into a registry keyed by node type, so a +built-in model and a community extension answer it the same way and share the whole download UI. + +Providers are **torch-free and pure-filesystem**: they run on every popup open, on installs with no +ML stack, and must never fetch anything. Downloading stays exclusively the Studio downloader's job. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Protocol, runtime_checkable + + +@dataclass(frozen=True) +class ModelComponent: + """One required model component: whether it's present, and the exact file the popup fetches.""" + + id: str + label: str + category: str # models/ subfolder it belongs to + present: bool + filename: str # the single file that lands flat in models// (a dropdown entry) + repo: str # HF repo the popup downloads from + repo_file: str # exact repo-relative path fetched from ``repo`` + + @property + def local_path(self) -> str: + """Where this file lives / lands, relative to the models root (flat in its category).""" + return f"{self.category}/{self.filename}" + + +@runtime_checkable +class RequirementsProvider(Protocol): + """Answers the model popup for one or more node types. + + Must be torch-free and pure-filesystem - it runs on every popup open, including on a torch-less + install where the node itself is unavailable. + """ + + def components(self, params: dict[str, object] | None = None) -> list[ModelComponent]: + """The components this node needs, with live presence.""" + ... + + def download_target(self, component: ModelComponent) -> Path: + """Absolute directory the component's file lands in - under the models root, flat, never + the hidden Hugging Face cache.""" + ... + + def estimate(self, policy: Any) -> dict[str, Any] | None: + """Whether the model will fit this machine, or None when it can't be sized. + + Returning None is always safe and is the right answer when the on-disk footprint isn't + knowable - a wrong fit estimate is worse than no estimate. + """ + ... + + +class RequirementsRegistry: + """Maps a node type to the provider that answers its model requirements. + + Registration replaces by node type, matching ``graph.Registry``. Built-ins register first and + extensions last, so the loader can detect and refuse a collision. + """ + + def __init__(self) -> None: + self._providers: dict[str, RequirementsProvider] = {} + + def register(self, node_type: str, provider: RequirementsProvider) -> None: + self._providers[node_type] = provider + + def unregister(self, node_type: str) -> None: + """Drop a provider when its extension is disabled; unknown types are ignored.""" + self._providers.pop(node_type, None) + + def get(self, node_type: str) -> RequirementsProvider | None: + return self._providers.get(node_type) + + def has(self, node_type: str) -> bool: + return node_type in self._providers + + def node_types(self) -> list[str]: + return sorted(self._providers) diff --git a/core/src/inline_core/models/zimage/provider.py b/core/src/inline_core/models/zimage/provider.py new file mode 100644 index 0000000..e8a7a32 --- /dev/null +++ b/core/src/inline_core/models/zimage/provider.py @@ -0,0 +1,61 @@ +"""Z-Image's answer to "what do I need on disk" - the first ``RequirementsProvider``. + +The logic is unchanged; it moved here from ``studio/models.py``, where it sat behind a hardcoded +``node_type == "alibaba/z-image-turbo"`` check. Owning it next to the model keeps the Studio +download layer model-agnostic and lets extensions plug in the same way. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from ...config import models_dir +from ..requirements import ModelComponent +from .requirements import ( + footprint_bytes, + resolve_diffusion, + resolve_text_encoder, + resolve_vae, + zimage_requirements, +) + + +class ZImageProvider: + """Requirements + fit estimate for ``alibaba/z-image-turbo``.""" + + def components(self, params: dict[str, object] | None = None) -> list[ModelComponent]: + return zimage_requirements(params) + + def download_target(self, component: ModelComponent) -> Path: + return models_dir() / component.category + + def estimate(self, policy: Any) -> dict[str, Any] | None: + """Whether the model will fit this machine, and how (resident / int8 / offload / won't + fit), so the popup warns BEFORE a load. Pure ``stat`` + a live VRAM/RAM probe; ``None`` + when the runtime/policy is absent or the sizes/device can't be measured (a whole-pipeline + folder).""" + if policy is None: + return None + try: + from ...device.policy import ModelFootprint + except ImportError: + return None + diffusion = resolve_diffusion(None) + diffusion_file = diffusion[1] if diffusion and diffusion[0] == "single_file" else None + footprint = ModelFootprint( + **footprint_bytes(diffusion_file, resolve_vae(None), resolve_text_encoder(None)) + ) + fit = policy.estimate_fit(footprint) # pure - never mutates the shared policy + if fit is None: + return None + soft = not fit.fits or fit.plan in ("int8", "offload") + return { + "plan": fit.plan, + "fits": fit.fits, + "requiredVramMb": int(fit.required_vram_gb * 1024), + "totalVramMb": int(fit.total_vram_gb * 1024) if fit.total_vram_gb else None, + "freeVramMb": policy.free_vram_mb(), + "freeRamMb": policy.free_ram_mb(), + "warning": fit.note if soft else None, + } diff --git a/core/src/inline_core/models/zimage/requirements.py b/core/src/inline_core/models/zimage/requirements.py index 30a2edb..26c16d2 100644 --- a/core/src/inline_core/models/zimage/requirements.py +++ b/core/src/inline_core/models/zimage/requirements.py @@ -19,10 +19,30 @@ from __future__ import annotations import os -from dataclasses import dataclass from pathlib import Path from ...config import models_dir +from ..requirements import ModelComponent + +#: ``ModelComponent`` now lives in ``models/requirements.py`` - it is shared with every other model +#: and with extension-declared requirements. Re-exported here so existing importers keep working. +__all__ = [ + "BASE_REPO", + "DIFFUSION_FILE", + "SPLIT_REPO", + "TEXT_ENCODER_FILE", + "VAE_FILE", + "ModelComponent", + "diffusion_root", + "download_target", + "find_weight_file", + "footprint_bytes", + "pipeline_dir", + "resolve_diffusion", + "resolve_text_encoder", + "resolve_vae", + "zimage_requirements", +] #: Split-file weights repo - ComfyUI's consolidated single files, one ``.safetensors`` per #: component (fast, fully offline to load). The popup pulls exactly one file per component from @@ -158,24 +178,6 @@ def resolve_diffusion(params: dict[str, object] | None = None) -> tuple[str, str # --- the requirements view (the popup's data) --------------------------------------------------- -@dataclass(frozen=True) -class ModelComponent: - """One required model component: whether it's present, and the exact file the popup fetches.""" - - id: str # "diffusion" | "vae" | "text_encoder" - label: str - category: str # models/ subfolder it belongs to - present: bool - filename: str # the single file that lands flat in models// (a dropdown entry) - repo: str # HF repo the popup downloads from - repo_file: str # exact repo-relative path fetched from ``repo`` (under ``split_files/``) - - @property - def local_path(self) -> str: - """Where this file lives / lands, relative to the models root (flat in its category).""" - return f"{self.category}/{self.filename}" - - def _split_component( *, id: str, label: str, category: str, filename: str, present: bool ) -> ModelComponent: diff --git a/core/src/inline_core/runtime/context.py b/core/src/inline_core/runtime/context.py index d7ac80b..97c6ef2 100644 --- a/core/src/inline_core/runtime/context.py +++ b/core/src/inline_core/runtime/context.py @@ -4,10 +4,14 @@ from dataclasses import dataclass from threading import Event +from typing import TYPE_CHECKING from ..device.policy import DevicePolicy from .progress import ProgressEmitter +if TYPE_CHECKING: + from .store import TakeStore + class CancelToken: """Cooperative cancellation. The executor checks it between nodes and steps.""" @@ -29,3 +33,6 @@ class ExecutionContext: policy: DevicePolicy emitter: ProgressEmitter cancel: CancelToken + #: Where a node writes its output. Built-in runners hold their own store from construction; + #: this is how a node built by the extension registrar (which calls `cls()`) reaches one. + takes: TakeStore | None = None diff --git a/core/src/inline_core/server/__main__.py b/core/src/inline_core/server/__main__.py index 578dafd..11d2abf 100644 --- a/core/src/inline_core/server/__main__.py +++ b/core/src/inline_core/server/__main__.py @@ -15,8 +15,10 @@ from ..config import data_dir, server_host, server_port from ..device.detect import cpu_only_torch_warning from ..device.memory import MemoryPolicy +from ..extensions.loader import LoadedExtension from ..graph.cache import InMemoryCache from ..graph.registry import build_default_registry +from ..models.requirements import RequirementsRegistry from ..runtime.file_store import FileTakeStore from ..studio import config as studio_config from ..studio.store import StudioStore @@ -32,10 +34,19 @@ def main() -> None: registry = build_default_registry() data = data_dir() takes = data / "takes" - store = FileTakeStore(takes) + take_store = FileTakeStore(takes) run_store = SqliteRunStore(data / "runs.db") - registered = register_models(registry, store, policy) + # Built before model registration: extensions register their own `ext::*` channels and + # push events while they load, so both must already exist. + rpc = RpcRouter() + events = EventBroadcaster() + requirements = RequirementsRegistry() + registered, extensions = register_models( + registry, take_store, policy, requirements=requirements, rpc=rpc, events=events + ) print(f"Registered models: {registered or 'none (source nodes only)'}") + if extensions: + print(f"Extensions: {_extension_summary(extensions)}") # A CPU-only torch wheel on a CUDA machine is a silent ~100x slowdown, so say it loudly here # rather than letting the user conclude the engine is just slow. torch_warning = cpu_only_torch_warning() @@ -46,8 +57,6 @@ def main() -> None: print(f"Frontend: {fe}") # The Studio app-backend: Core is the sole native backend (projects, frames, moodboard, assets, # generation, fal, timeline). Every InlineStudioApi channel is handled here. - rpc = RpcRouter() - events = EventBroadcaster() store = StudioStore( studio_config.data_dir(), studio_config.workspace_dir(), @@ -65,11 +74,21 @@ def main() -> None: rpc=rpc, events=events, studio_store=store, + requirements=requirements, ) host, port = server_host(), server_port() print(f"Serving on http://{host}:{port}") uvicorn.run(app, host=host, port=port) +def _extension_summary(extensions: list[LoadedExtension]) -> str: + """One line naming what loaded and what didn't, so a broken extension is visible at boot + rather than only inside the Extensions dialog.""" + ok = [p.extension_id for p in extensions if p.error is None] + failed = [f"{p.extension_id} ({p.error})" for p in extensions if p.error is not None] + parts = [f"{len(ok)} loaded"] + ([f"failed: {', '.join(failed)}"] if failed else []) + return "; ".join(parts) + (f" [{', '.join(ok)}]" if ok else "") + + if __name__ == "__main__": main() diff --git a/core/src/inline_core/server/app.py b/core/src/inline_core/server/app.py index d157f7b..927abaf 100644 --- a/core/src/inline_core/server/app.py +++ b/core/src/inline_core/server/app.py @@ -27,6 +27,8 @@ from ..graph.registry import Registry, build_default_registry from ..graph.schema import SCHEMA_VERSION, parse_graph from ..models.catalog import ModelCatalog +from ..models.requirements import RequirementsRegistry +from ..runtime.file_store import FileTakeStore from .assets import AssetStore from .manager import RunConflict, RunManager from .rpc import EventBroadcaster, RpcRouter @@ -41,7 +43,7 @@ class _SuppressAccessNoise(logging.Filter): """Drop high-frequency, uninformative request lines from the uvicorn access log. Two floods bury the useful logs (generation progress, real errors) under identical 200 lines: - - ``GET /v1/runs/`` - Studio polls run status sub-second while a run is in flight. + - ``GET /v1/runs/`` - Studio polls run status module-second while a run is in flight. - ``POST /rpc`` - every Studio backend call (each keystroke's autosave, every store refresh) is one of these; they say nothing on their own. We hide only those successful, chatty lines; submits, cancels, errors, uploads, media, and @@ -99,10 +101,13 @@ def _error(code: str, message: str, status: int, node_id: str | None = None) -> def _version(registry: Registry, catalog: ModelCatalog) -> str: - """Registry version = node types + the scanned model files, so dropping a file bumps it.""" - payload = json.dumps( - {"types": sorted(d.type for d in registry.descriptors()), "models": catalog.fingerprint()} - ) + """Registry version = full descriptor content + the scanned model files, so dropping a file + bumps it - and so does installing, toggling, or version-switching an extension. + + Delegates the descriptor half to ``Registry.version()``: hashing node *types* here would miss a + upgrade that changes a param default while keeping its node types, and the client caches + ``/v1/models`` against this as an ETag.""" + payload = json.dumps({"registry": registry.version(), "models": catalog.fingerprint()}) return f"r_{hashlib.sha256(payload.encode()).hexdigest()[:8]}" @@ -118,16 +123,20 @@ def create_app( rpc: RpcRouter | None = None, events: EventBroadcaster | None = None, studio_store: Any = None, + requirements: RequirementsRegistry | None = None, ) -> FastAPI: _setup_app_logging() _quiet_access_log() registry = registry or build_default_registry() cache = cache or InMemoryCache() policy = policy or MemoryPolicy() + # Empty by default so every existing caller keeps working: a node type with no provider simply + # reports no model requirements, which is what a torch-less install already showed. + reqs = requirements if requirements is not None else RequirementsRegistry() assets = AssetStore(Path(asset_dir or "./.inline-assets")) catalog = ModelCatalog(Path(models_root) if models_root else models_dir()) takes_root = Path(takes_dir or "./.inline-takes") - manager = RunManager(registry, cache, policy, store=run_store) + manager = RunManager(registry, cache, policy, store=run_store, takes=FileTakeStore(takes_root)) rpc = rpc or RpcRouter() events = events or EventBroadcaster() @@ -282,6 +291,8 @@ async def studio_events(websocket: WebSocket) -> None: # The native Studio app-backend: register the InlineStudioApi channels on the RpcRouter + # project media/uploads (the B1 flip - Core becomes the sole backend, no Node proxy). if studio_store is not None: + from ..extensions.handlers import register_extension_handlers + from ..extensions.install import Installer from ..studio.fal import FalGeneration from ..studio.generation import CoreGeneration from ..studio.handlers import register_studio_handlers @@ -306,8 +317,23 @@ def core_status() -> dict[str, Any]: fal_generation=FalGeneration(studio_store, events), timeline=Timeline(studio_store, events), # Explicit model downloads write into models/; rescan so new files bump the registry. - # The policy lets the requirements popup show a memory fit estimate before a load. - model_downloads=ModelDownloads(events, on_change=catalog.rescan, policy=policy), + # The policy lets the requirements popup show a memory fit estimate before a load; + # the requirements registry says which node types have models at all. + model_downloads=ModelDownloads( + events, on_change=catalog.rescan, policy=policy, requirements=reqs + ), + ) + + register_extension_handlers( + rpc, + Installer( + registry, + FileTakeStore(takes_root), + policy, + requirements=reqs, + rpc=rpc, + events=events, + ), ) @app.get("/media/{media_path:path}") diff --git a/core/src/inline_core/server/bootstrap.py b/core/src/inline_core/server/bootstrap.py index e7d31a7..7db5078 100644 --- a/core/src/inline_core/server/bootstrap.py +++ b/core/src/inline_core/server/bootstrap.py @@ -1,20 +1,59 @@ """Best-effort model registration. A model whose deps are absent is skipped; the engine still serves source nodes and the rest of the API, so a torch-less install boots cleanly. + +Built-ins register first and community extensions last: ``Registry.register`` replaces by node type, +so the reverse order would let an installed extension silently take over ``alibaba/z-image-turbo``. """ from __future__ import annotations +from typing import TYPE_CHECKING, Any + from ..device.policy import DevicePolicy from ..graph.registry import Registry +from ..models.requirements import RequirementsRegistry from ..runtime.store import TakeStore +if TYPE_CHECKING: + from ..extensions.loader import LoadedExtension + + +def register_models( + registry: Registry, + store: TakeStore, + policy: DevicePolicy, + *, + requirements: RequirementsRegistry | None = None, + rpc: Any = None, + events: Any = None, +) -> tuple[list[str], list[LoadedExtension]]: + """Register the built-in models, then load installed extensions. + + Returns ``(built_in_node_types, loaded_extensions)``. Neither half can take the server down: a + built-in whose deps are missing is skipped, and an extension that raises is reported on its + ``LoadedExtension`` for the Extensions UI. + """ + reqs = requirements if requirements is not None else RequirementsRegistry() + registered = _register_builtins(registry, store, policy, reqs) + extensions = _load_extensions(registry, store, policy, reqs, rpc, events) + return registered, extensions -def register_models(registry: Registry, store: TakeStore, policy: DevicePolicy) -> list[str]: + +def _register_builtins( + registry: Registry, + store: TakeStore, + policy: DevicePolicy, + requirements: RequirementsRegistry, +) -> list[str]: registered: list[str] = [] try: + from ..models.zimage.provider import ZImageProvider from ..models.zimage.runner import register_zimage register_zimage(registry, store, policy) + # Torch-free, but guarded by the same import: with no runtime the node is unavailable, so + # advertising its requirements would offer a download for a node that cannot run. + requirements.register("alibaba/z-image-turbo", ZImageProvider()) registered.append("alibaba/z-image-turbo") except ImportError: pass @@ -26,3 +65,25 @@ def register_models(registry: Registry, store: TakeStore, policy: DevicePolicy) except ImportError: pass return registered + + +def _load_extensions( + registry: Registry, + store: TakeStore, + policy: DevicePolicy, + requirements: RequirementsRegistry, + rpc: Any, + events: Any, +) -> list[LoadedExtension]: + try: + from ..extensions.loader import load_extensions + except ImportError: + return [] + return load_extensions( + registry, + store, + policy, + requirements=requirements, + rpc=rpc, + events=events, + ) diff --git a/core/src/inline_core/server/manager.py b/core/src/inline_core/server/manager.py index 8c51336..bceb326 100644 --- a/core/src/inline_core/server/manager.py +++ b/core/src/inline_core/server/manager.py @@ -27,6 +27,7 @@ from ..runtime.context import CancelToken, ExecutionContext from ..runtime.progress import ProgressEmitter, ProgressEvent, RunEvent from ..runtime.run import NodeRuntimeState, RunState +from ..runtime.store import TakeStore from ..takes import Take from .run_store import RunStore @@ -65,6 +66,7 @@ def __init__( policy: DevicePolicy | None = None, workers: int = 1, store: RunStore | None = None, + takes: TakeStore | None = None, ) -> None: self._registry = registry self._cache = cache @@ -76,6 +78,7 @@ def __init__( self._lock = threading.Lock() self._loop: asyncio.AbstractEventLoop | None = None self._store = store + self._takes = takes if store is not None: store.interrupt_stale() @@ -163,6 +166,7 @@ def _execute(self, graph: Graph, target: str, record: RunRecord) -> None: policy=self._policy, emitter=_BroadcastEmitter(self, record), cancel=record.cancel, + takes=self._takes, ) Executor(self._registry, self._cache).run(graph, target, ctx, record.state) record.done = True diff --git a/core/src/inline_core/server/rpc.py b/core/src/inline_core/server/rpc.py index 61373e5..36ee35c 100644 --- a/core/src/inline_core/server/rpc.py +++ b/core/src/inline_core/server/rpc.py @@ -21,8 +21,18 @@ def __init__(self) -> None: self._handlers: dict[str, Handler] = {} def register(self, channel: str, handler: Handler) -> None: + """Bind a channel. Registration is **exclusive**: re-registering an existing channel raises + rather than silently replacing it. Extensions register their own ``ext::*`` channels + through this router, and last-write-wins would let one shadow a core channel + (``project:open``, ``settings:get``, ...) and intercept its payloads.""" + if channel in self._handlers: + raise ValueError(f"Channel {channel!r} is already registered.") self._handlers[channel] = handler + def unregister(self, channel: str) -> None: + """Drop a channel. Used when an extension is disabled; unknown channels are ignored.""" + self._handlers.pop(channel, None) + def has(self, channel: str) -> bool: return channel in self._handlers diff --git a/core/src/inline_core/studio/models.py b/core/src/inline_core/studio/models.py index b10e9f0..f32fb4d 100644 --- a/core/src/inline_core/studio/models.py +++ b/core/src/inline_core/studio/models.py @@ -21,18 +21,28 @@ from pathlib import Path from typing import Any -_ZIMAGE_TYPE = "alibaba/z-image-turbo" +from ..models.requirements import RequirementsProvider, RequirementsRegistry class ModelDownloads: - """Answers "what's missing" and downloads components into the models dir on request.""" + """Answers "what's missing" and downloads components into the models dir on request. + + Model-agnostic: *what* a node needs comes from the ``RequirementsRegistry`` (built-ins register + at boot, extensions when they load), and this class only knows how to report presence and move + bytes. It contains no per-model knowledge. + """ def __init__( - self, events: Any, on_change: Callable[[], None] | None = None, policy: Any = None + self, + events: Any, + on_change: Callable[[], None] | None = None, + policy: Any = None, + requirements: RequirementsRegistry | None = None, ) -> None: self._events = events self._on_change = on_change # rescan the model catalog after a download lands self._policy = policy # device policy, for the memory fit estimate (optional) + self._requirements = requirements if requirements is not None else RequirementsRegistry() # --- requirements (the popup's data) -------------------------------------------------------- @@ -50,39 +60,16 @@ def requirements(self, node_type: str) -> dict[str, Any]: } def _estimate(self, node_type: str) -> dict[str, Any] | None: - """Whether the model will fit this machine, and how (resident / int8 / offload / won't fit), - so the popup warns BEFORE a load. Pure ``stat`` + a live VRAM/RAM probe; ``None`` when the - runtime/policy is absent or the sizes/device can't be measured (a whole-pipeline folder).""" - if node_type != _ZIMAGE_TYPE or self._policy is None: + """Whether the model will fit this machine - delegated to the node's provider, which owns + the footprint knowledge. ``None`` whenever it can't be sized: a wrong estimate is worse + than none, so the popup simply omits the warning.""" + provider = self._requirements.get(node_type) + if provider is None or self._policy is None: return None try: - from ..device.policy import ModelFootprint - from ..models.zimage.requirements import ( - footprint_bytes, - resolve_diffusion, - resolve_text_encoder, - resolve_vae, - ) - except ImportError: - return None - diffusion = resolve_diffusion(None) - diffusion_file = diffusion[1] if diffusion and diffusion[0] == "single_file" else None - footprint = ModelFootprint( - **footprint_bytes(diffusion_file, resolve_vae(None), resolve_text_encoder(None)) - ) - fit = self._policy.estimate_fit(footprint) # pure - never mutates the shared policy - if fit is None: + return provider.estimate(self._policy) + except Exception: # noqa: BLE001 - a provider is extension code; never break the popup return None - soft = not fit.fits or fit.plan in ("int8", "offload") - return { - "plan": fit.plan, - "fits": fit.fits, - "requiredVramMb": int(fit.required_vram_gb * 1024), - "totalVramMb": int(fit.total_vram_gb * 1024) if fit.total_vram_gb else None, - "freeVramMb": self._policy.free_vram_mb(), - "freeRamMb": self._policy.free_ram_mb(), - "warning": fit.note if soft else None, - } # --- download (explicit, user-triggered) ---------------------------------------------------- @@ -92,6 +79,9 @@ def download(self, node_type: str, component_id: str) -> None: asyncio.create_task(asyncio.to_thread(self._run, node_type, component_id, loop)) def _run(self, node_type: str, component_id: str, loop: asyncio.AbstractEventLoop) -> None: + provider = self._requirements.get(node_type) + if provider is None: + return components = self._components(node_type) if component_id == "all": targets = [c for c in components if not c.present] @@ -101,6 +91,7 @@ def _run(self, node_type: str, component_id: str, loop: asyncio.AbstractEventLoo payload_id = comp.id try: self._download_component( + provider, comp, lambda frac, status, cid=payload_id: self._emit( loop, @@ -129,23 +120,28 @@ def _run(self, node_type: str, component_id: str, loop: asyncio.AbstractEventLoo # --- internals ------------------------------------------------------------------------------ def _components(self, node_type: str) -> list[Any]: - if node_type != _ZIMAGE_TYPE: + """The node's components, or ``[]`` for a node type with no registered provider (which is + also what a torch-less install sees - the node shows its own "unavailable" state).""" + provider = self._requirements.get(node_type) + if provider is None: return [] try: - from ..models.zimage.requirements import zimage_requirements - except ImportError: - return [] # zimage runtime absent - the node shows "unavailable", no requirements - return zimage_requirements() + return list(provider.components()) + except Exception: # noqa: BLE001 - a provider is extension code; never break the popup + return [] - def _download_component(self, comp: Any, on_progress: Callable[[float, str], None]) -> None: + def _download_component( + self, + provider: RequirementsProvider, + comp: Any, + on_progress: Callable[[float, str], None], + ) -> None: """Fetch a component's single file from its repo into ``models//`` (flat, so the node's dropdown lists it). Downloads into a ``.part`` staging dir first, then moves the file into place under its basename, so a half-finished download never looks installed.""" from huggingface_hub import HfApi, hf_hub_download - from ..models.zimage.requirements import download_target - - category_dir: Path = download_target(comp) + category_dir: Path = provider.download_target(comp) staging = category_dir / (comp.filename + ".part") shutil.rmtree(staging, ignore_errors=True) diff --git a/core/tests/test_cache.py b/core/tests/test_cache.py index ccb40ce..fbfd587 100644 --- a/core/tests/test_cache.py +++ b/core/tests/test_cache.py @@ -3,6 +3,7 @@ from typing import Any from helpers import make_registry + from inline_core.graph.cache import is_cache_eligible, node_cache_key from inline_core.graph.schema import Graph, parse_graph diff --git a/core/tests/test_config.py b/core/tests/test_config.py index 947a9fd..60dd9ee 100644 --- a/core/tests/test_config.py +++ b/core/tests/test_config.py @@ -1,6 +1,7 @@ from __future__ import annotations import pytest + from inline_core.config import server_host, server_port diff --git a/core/tests/test_executor.py b/core/tests/test_executor.py index 794a2ed..3acd0e8 100644 --- a/core/tests/test_executor.py +++ b/core/tests/test_executor.py @@ -1,6 +1,7 @@ from __future__ import annotations from helpers import build_ctx, build_graph, make_registry + from inline_core.graph.cache import InMemoryCache from inline_core.graph.executor import Executor from inline_core.runtime.progress import ( diff --git a/core/tests/test_extension_api.py b/core/tests/test_extension_api.py new file mode 100644 index 0000000..2b83331 --- /dev/null +++ b/core/tests/test_extension_api.py @@ -0,0 +1,210 @@ +"""The extension author's surface: the decorator and the registrar's enforcement.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import pytest + +from inline_core.extensions.api import ( + ExtensionError, + ExtensionRegistrar, + descriptor_of, + inline_node, +) +from inline_core.graph.descriptor import NodeDescriptor, ParamField, Port, Widget +from inline_core.graph.registry import Registry +from inline_core.graph.runners import NodeResult, NodeRunner +from inline_core.graph.schema import PortKind +from inline_core.media import MediaKind +from inline_core.models.requirements import RequirementsRegistry +from inline_core.server.rpc import EventBroadcaster, RpcRouter + + +@inline_node( + type="acme/invert", + title="Invert", + category="Image", + icon="wand", + output_kind=MediaKind.IMAGE, + inputs=(Port("image", "Image", PortKind.IMAGE, required=True),), + outputs=(Port("image", "Image", PortKind.IMAGE),), + params=(ParamField("strength", "Strength", Widget.NUMBER, 1.0, min=0, max=1),), +) +class Invert(NodeRunner): + produces_takes = False + + def run(self, node: Any, inputs: Any, ctx: Any) -> NodeResult: + return NodeResult(outputs={}) + + +def _registrar( + registry: Registry, + tmp_path: Path, + *, + declared: tuple[str, ...] = ("acme/invert",), + enabled: tuple[str, ...] | None = None, + rpc: RpcRouter | None = None, + events: EventBroadcaster | None = None, + requirements: RequirementsRegistry | None = None, +) -> ExtensionRegistrar: + return ExtensionRegistrar( + registry, + "acme-tools", + store=object(), # pyright: ignore[reportArgumentType] - unused by these paths + policy=object(), # pyright: ignore[reportArgumentType] + requirements=requirements or RequirementsRegistry(), + data_root=tmp_path / "data", + declared_nodes=declared, + enabled_nodes=enabled, + rpc=rpc, + events=events, + ) + + +def test_decorator_attaches_a_descriptor_without_registering() -> None: + """No import-time side effect: the same module must be loadable into a scratch registry during + install validation and into the live one on activation.""" + descriptor = descriptor_of(Invert) + assert descriptor is not None + assert descriptor.type == "acme/invert" + assert descriptor.output_kind is MediaKind.IMAGE + assert descriptor.source == "builtin", "source is stamped by the registrar, not the author" + + +def test_decorator_rejects_a_non_runner_class() -> None: + """Regression: `type` is a keyword argument of `inline_node`, so it shadows the builtin inside + the decorator body. A naive `isinstance(cls, type)` compared against the node-type *string* + and raised TypeError for every extension.""" + decorate = inline_node(type="acme/x", title="X", category="Test") + + class NotARunner: + pass + + with pytest.raises(ExtensionError, match="requires a NodeRunner subclass"): + decorate(NotARunner) # pyright: ignore[reportArgumentType] + + +def test_registrar_stamps_provenance(tmp_path: Path) -> None: + registry = Registry() + _registrar(registry, tmp_path).nodes(Invert) + assert registry.get("acme/invert").source == "ext:acme-tools" + + +def test_registrar_rejects_a_node_not_declared_in_the_manifest(tmp_path: Path) -> None: + """Declared node types are what let preflight detect collisions before any extension code + runs; a runner that registers something undeclared would defeat that.""" + registry = Registry() + registrar = _registrar(registry, tmp_path, declared=("acme/something-else",)) + with pytest.raises(ExtensionError, match="not declared in the manifest"): + registrar.node(Invert) + assert not registry.has("acme/invert") + + +def test_registrar_refuses_to_replace_a_core_node(tmp_path: Path) -> None: + registry = Registry() + registry.register( + NodeDescriptor(type="acme/invert", title="Core Thing", category="Generate") + ) + with pytest.raises(ExtensionError, match="already provided by Core"): + _registrar(registry, tmp_path).node(Invert) + assert registry.get("acme/invert").title == "Core Thing" + + +def test_registrar_rejects_a_missing_decorator(tmp_path: Path) -> None: + class Bare(NodeRunner): + def run(self, node: Any, inputs: Any, ctx: Any) -> NodeResult: + return NodeResult(outputs={}) + + with pytest.raises(ExtensionError, match="missing the @inline_node decorator"): + _registrar(registry := Registry(), tmp_path).node(Bare) + assert registry.descriptors() == [] + + +def test_registrar_rejects_a_custom_port_kind(tmp_path: Path) -> None: + """port_satisfies has to stay total, so graph validation can decide edge legality without + running extension code.""" + + @inline_node( + type="acme/custom", + title="Custom", + category="Test", + outputs=(Port("out", "Out", "x/acme/pose"),), # pyright: ignore[reportArgumentType] + ) + class Custom(NodeRunner): + def run(self, node: Any, inputs: Any, ctx: Any) -> NodeResult: + return NodeResult(outputs={}) + + registrar = _registrar(Registry(), tmp_path, declared=("acme/custom",)) + with pytest.raises(ExtensionError, match="unsupported kind"): + registrar.node(Custom) + + +def test_rpc_channels_are_forced_into_the_pack_namespace(tmp_path: Path) -> None: + """Without the forced prefix an extension could re-register `project:open` and silently + intercept it.""" + rpc = RpcRouter() + registrar = _registrar(Registry(), tmp_path, rpc=rpc) + registrar.rpc_channel("listPresets", lambda: ["a"]) + assert rpc.has("ext:acme-tools:listPresets") + assert registrar.registered_channels == ["ext:acme-tools:listPresets"] + + +def test_rpc_channel_rejects_a_qualified_name(tmp_path: Path) -> None: + registrar = _registrar(Registry(), tmp_path, rpc=RpcRouter()) + with pytest.raises(ExtensionError, match="bare method name"): + registrar.rpc_channel("project:open", lambda: None) + + +def test_emit_namespaces_the_event(tmp_path: Path) -> None: + events = EventBroadcaster() + queue = events.add() + _registrar(Registry(), tmp_path, events=events).emit("done", {"ok": True}) + assert queue.get_nowait() == {"channel": "ext:acme-tools:done", "payload": {"ok": True}} + + +def test_data_dir_is_scoped_to_the_pack(tmp_path: Path) -> None: + path = _registrar(Registry(), tmp_path).data_dir + assert path.name == "acme-tools" + assert path.is_dir() + + +def test_a_disabled_node_is_validated_then_skipped(tmp_path: Path) -> None: + """The flattened model: everything imports, so toggling a node on later is just another + register() call and never needs a restart.""" + registry = Registry() + registrar = _registrar(registry, tmp_path, enabled=()) + + registrar.node(Invert) + + assert not registry.has("acme/invert") + assert registrar.skipped_nodes == ["acme/invert"] + assert registrar.registered_nodes == [] + + +def test_an_enabled_node_registers_normally(tmp_path: Path) -> None: + registry = Registry() + registrar = _registrar(registry, tmp_path, enabled=("acme/invert",)) + registrar.node(Invert) + assert registry.has("acme/invert") + assert registrar.skipped_nodes == [] + + +def test_a_disabled_node_is_still_port_checked(tmp_path: Path) -> None: + """Validation must not be skipped just because the node is off - otherwise a broken node only + surfaces when a user switches it on.""" + + @inline_node( + type="acme/bad", + title="Bad", + category="Test", + outputs=(Port("out", "Out", "x/custom"),), # pyright: ignore[reportArgumentType] + ) + class Bad(NodeRunner): + def run(self, node: Any, inputs: Any, ctx: Any) -> NodeResult: + return NodeResult(outputs={}) + + registrar = _registrar(Registry(), tmp_path, declared=("acme/bad",), enabled=()) + with pytest.raises(ExtensionError, match="unsupported kind"): + registrar.node(Bad) diff --git a/core/tests/test_extension_install.py b/core/tests/test_extension_install.py new file mode 100644 index 0000000..85a6574 --- /dev/null +++ b/core/tests/test_extension_install.py @@ -0,0 +1,488 @@ +"""The install state machine, exercised against a real git repository.""" + +from __future__ import annotations + +import json +import shutil +import subprocess +from pathlib import Path +from typing import Any + +import pytest + +from inline_core.device.memory import MemoryPolicy +from inline_core.extensions.install import Installer, InstallError, InstallRequest, Phase +from inline_core.extensions.paths import ExtensionsRoot +from inline_core.graph.registry import Registry +from inline_core.models.requirements import RequirementsRegistry +from inline_core.server.rpc import EventBroadcaster, RpcRouter + +pytestmark = pytest.mark.skipif(shutil.which("git") is None, reason="git is not installed") + +MANIFEST: dict[str, Any] = { + "schema": 1, + "id": "demo-extension", + "name": "Demo Extension", + "version": "1.0.0", + "coreCompat": ">=1.0", + "license": "MIT", + "requirements": [], + "entry": "inline_ext_demo_extension:register", + "nodes": [{"type": "demo/invert"}], +} + +NODE_SOURCE = ''' +from inline_core.extensions.api import inline_node +from inline_core.graph.descriptor import Port +from inline_core.graph.runners import NodeResult, NodeRunner +from inline_core.graph.schema import PortKind + + +@inline_node( + type="demo/invert", + title="Invert", + category="Image", + inputs=(Port("image", "Image", PortKind.IMAGE, required=True),), + outputs=(Port("image", "Image", PortKind.IMAGE),), +) +class Invert(NodeRunner): + produces_takes = False + + def run(self, node, inputs, ctx) -> NodeResult: + return NodeResult(outputs={"image": inputs["image"][0]}) + + +def register(reg) -> None: + reg.nodes(Invert) +''' + + +TWO_NODE_SOURCE = NODE_SOURCE.replace( + "def register(reg) -> None:\n reg.nodes(Invert)", + """ +@inline_node( + type="demo/extra", + title="Extra", + category="Image", + outputs=(Port("image", "Image", PortKind.IMAGE),), +) +class Extra(NodeRunner): + produces_takes = False + + def run(self, node, inputs, ctx) -> NodeResult: + return NodeResult(outputs={}) + + +def register(reg) -> None: + reg.nodes(Invert, Extra) +""", +) + + +def _git(*args: str, cwd: Path) -> None: + subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True) + + +def _renamed(manifest: dict[str, Any], extension_id: str) -> dict[str, Any]: + """A copy of the manifest under a different extension id, which also changes its Python package. + + Tests needing a genuinely fresh import must not reuse a module name: sys.modules is global and + process-wide, so a second install of the same extension reuses the already-imported module - + the same constraint that makes a version switch restart-required in production. + """ + package = "inline_ext_" + extension_id.replace("-", "_") + return {**manifest, "id": extension_id, "entry": f"{package}:register"} + + +def _make_repo(tmp_path: Path, manifest: dict[str, Any], source: str = NODE_SOURCE) -> Path: + extension_id = str(manifest["id"]) + repo = tmp_path / f"{extension_id}-repo" + package = repo / "python" / ("inline_ext_" + extension_id.replace("-", "_")) + package.mkdir(parents=True) + (package / "basic.py").write_text(source, encoding="utf-8") + (package / "__init__.py").write_text( + "from .basic import * # noqa: F403\n" + "from .basic import register # noqa: F401\n", + encoding="utf-8", + ) + (repo / "inline-extension.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8") + + _git("init", "-q", "-b", "main", cwd=repo) + _git("config", "user.email", "test@example.com", cwd=repo) + _git("config", "user.name", "Test", cwd=repo) + _git("add", "-A", cwd=repo) + _git("commit", "-q", "-m", "initial", cwd=repo) + _git("tag", "v1.0.0", cwd=repo) + return repo + + +@pytest.fixture +def installer(tmp_path: Path) -> Installer: + from inline_core.runtime.file_store import FileTakeStore + + return Installer( + Registry(), + FileTakeStore(tmp_path / "takes"), + MemoryPolicy(), + requirements=RequirementsRegistry(), + paths=ExtensionsRoot(tmp_path / "extensions"), + rpc=RpcRouter(), + events=EventBroadcaster(), + ) + + +def _install(installer: Installer, repo: Path, **kwargs: Any) -> Any: + return installer._install(InstallRequest(source=f"file://{repo}", ref="v1.0.0", **kwargs)) + + +# --- the happy path ------------------------------------------------------------------------- + + +def test_installs_an_extension_and_registers_its_nodes( + installer: Installer, tmp_path: Path +) -> None: + repo = _make_repo(tmp_path, MANIFEST) + + result = _install(installer, repo) + + assert result.extension_id == "demo-extension" + assert result.version.startswith("1.0.0+") + assert result.node_types == ["demo/invert"] + assert not result.needs_consent + + +def test_a_first_install_goes_live_without_a_restart(installer: Installer, tmp_path: Path) -> None: + _install(installer, _make_repo(tmp_path, MANIFEST)) + + descriptor = installer._registry.get("demo/invert") + assert descriptor.title == "Invert" + assert descriptor.source == "ext:demo-extension", "provenance is stamped by the registrar" + + +def test_installed_extension_is_listed_with_its_nodes( + installer: Installer, tmp_path: Path +) -> None: + _install(installer, _make_repo(tmp_path, MANIFEST)) + + extensions = installer.list_packs() + assert len(extensions) == 1 + assert extensions[0]["extensionId"] == "demo-extension" + assert extensions[0]["enabled"] is True + assert [n["type"] for n in extensions[0]["nodes"]] == ["demo/invert"] + assert extensions[0]["nodes"][0]["enabled"] is True + + +def test_state_survives_a_restart(installer: Installer, tmp_path: Path) -> None: + from inline_core.extensions.state import StateStore + + _install(installer, _make_repo(tmp_path, MANIFEST)) + + reopened = StateStore(ExtensionsRoot(tmp_path / "extensions")) + state = reopened.extension("demo-extension") + assert state is not None + assert state.enabled is True + assert state.node_enabled("demo/invert", default=False) is True + + +def test_progress_streams_over_the_events_socket(installer: Installer, tmp_path: Path) -> None: + queue = installer._events.add() + _install(installer, _make_repo(tmp_path, MANIFEST)) + + frames = [] + while not queue.empty(): + frames.append(queue.get_nowait()) + channels = [f["channel"] for f in frames] + phases = [f["payload"].get("phase") for f in frames if "phase" in f["payload"]] + + assert "events:extensionInstallDone" in channels + assert Phase.FETCH.value in phases + assert Phase.ACTIVATE.value in phases + + +# --- failures leave nothing behind ---------------------------------------------------------------- + + +def test_an_invalid_manifest_fails_without_installing(installer: Installer, tmp_path: Path) -> None: + repo = _make_repo(tmp_path, {**MANIFEST, "coreCompat": ""}) + + with pytest.raises(InstallError) as excinfo: + _install(installer, repo) + + assert excinfo.value.phase is Phase.VALIDATE + assert installer.list_packs() == [] + + +def test_a_blocked_scan_aborts_and_reports_the_reason( + installer: Installer, tmp_path: Path +) -> None: + repo = _make_repo(tmp_path, {**MANIFEST, "requirements": ["torch>=2.0"]}) + + with pytest.raises(InstallError) as excinfo: + _install(installer, repo) + + assert excinfo.value.phase is Phase.SCAN + assert "shared Inline runtime" in str(excinfo.value) + assert excinfo.value.report is not None and excinfo.value.report.blocked + assert installer.list_packs() == [] + + +def test_staging_is_cleaned_up_after_a_failure(installer: Installer, tmp_path: Path) -> None: + """The rollback story: nothing outside staging is touched, and staging itself is removed.""" + repo = _make_repo(tmp_path, {**MANIFEST, "requirements": ["torch>=2.0"]}) + + with pytest.raises(InstallError): + _install(installer, repo) + + staging = ExtensionsRoot(tmp_path / "extensions").staging + assert not staging.exists() or not list(staging.iterdir()) + + +def test_a_node_type_owned_by_core_is_refused(installer: Installer, tmp_path: Path) -> None: + from inline_core.graph.descriptor import NodeDescriptor + + installer._registry.register( + NodeDescriptor(type="demo/invert", title="Core Invert", category="Image") + ) + + with pytest.raises(InstallError) as excinfo: + _install(installer, _make_repo(tmp_path, MANIFEST)) + + assert excinfo.value.phase is Phase.PREFLIGHT + assert "already provided by Inline Core" in str(excinfo.value) + assert installer._registry.get("demo/invert").title == "Core Invert" + + +def test_a_raising_entry_point_fails_before_the_live_registry_is_touched( + installer: Installer, tmp_path: Path +) -> None: + """REGISTER runs against a scratch registry precisely so this cannot half-install.""" + broken = NODE_SOURCE + "\n\nraise RuntimeError('boom at import')\n" + repo = _make_repo(tmp_path, _renamed(MANIFEST, "broken-extension"), source=broken) + + with pytest.raises(InstallError) as excinfo: + _install(installer, repo) + + assert excinfo.value.phase is Phase.REGISTER + assert excinfo.value.restart_required, "the broken module is stuck in sys.modules" + assert not installer._registry.has("demo/invert") + assert installer.list_packs() == [] + + +def test_an_unknown_ref_fails_at_fetch(installer: Installer, tmp_path: Path) -> None: + repo = _make_repo(tmp_path, MANIFEST) + + with pytest.raises(InstallError) as excinfo: + installer._install(InstallRequest(source=f"file://{repo}", ref="v9.9.9")) + + assert excinfo.value.phase is Phase.FETCH + + +def test_a_non_git_url_is_rejected(installer: Installer) -> None: + with pytest.raises(InstallError) as excinfo: + installer._install(InstallRequest(source="/etc/passwd", ref="main")) + assert excinfo.value.phase is Phase.FETCH + + +# --- consent -------------------------------------------------------------------------------------- + + +def test_a_consent_finding_pauses_instead_of_installing( + installer: Installer, tmp_path: Path +) -> None: + risky = NODE_SOURCE + "\n\nimport subprocess\n\n\ndef go():\n subprocess.run(['ls'])\n" + repo = _make_repo(tmp_path, MANIFEST, source=risky) + + result = _install(installer, repo) + + assert result.needs_consent + assert result.scan is not None + assert "subprocess" in result.scan.consent_rules() + assert installer.list_packs() == [], "nothing is installed until the user consents" + + +def test_supplying_consent_completes_the_install(installer: Installer, tmp_path: Path) -> None: + risky = NODE_SOURCE + "\n\nimport subprocess\n\n\ndef go():\n subprocess.run(['ls'])\n" + repo = _make_repo(tmp_path, MANIFEST, source=risky) + + result = _install(installer, repo, consents=("subprocess",)) + + assert not result.needs_consent + assert result.node_types == ["demo/invert"] + + +# --- lifecycle ------------------------------------------------------------------------------------ + + +def test_disabling_an_extension_unregisters_its_nodes(installer: Installer, tmp_path: Path) -> None: + _install(installer, _make_repo(tmp_path, MANIFEST)) + assert installer._registry.has("demo/invert") + + installer.set_enabled("demo-extension", False) + + assert not installer._registry.has("demo/invert"), "disable is hot; no restart needed" + assert installer.list_packs()[0]["enabled"] is False + + +def test_a_default_off_node_is_validated_but_not_registered( + installer: Installer, tmp_path: Path +) -> None: + """Every declared node is imported and validated at REGISTER, but only default-on ones go + live - and the result must report what is actually live.""" + manifest = _renamed(MANIFEST, "twonode-extension") + manifest["nodes"] = [{"type": "demo/invert"}, {"type": "demo/extra", "defaultEnabled": False}] + repo = _make_repo(tmp_path, manifest, source=TWO_NODE_SOURCE) + + result = _install(installer, repo) + + assert result.node_types == ["demo/invert"] + assert not installer._registry.has("demo/extra") + + +def test_toggling_a_node_never_needs_a_restart(installer: Installer, tmp_path: Path) -> None: + """The whole point of one entry point: the code is already imported, so enabling a node is + just another register() call.""" + manifest = _renamed(MANIFEST, "toggle-extension") + manifest["nodes"] = [{"type": "demo/invert"}, {"type": "demo/extra", "defaultEnabled": False}] + _install(installer, _make_repo(tmp_path, manifest, source=TWO_NODE_SOURCE)) + + on = installer.set_node_enabled("toggle-extension", "demo/extra", True) + + assert on["restartRequired"] is False + assert installer._registry.has("demo/extra") + + off = installer.set_node_enabled("toggle-extension", "demo/extra", False) + + assert off["restartRequired"] is False + assert not installer._registry.has("demo/extra") + assert installer._registry.has("demo/invert"), "a sibling node is unaffected" + + +def test_uninstall_removes_the_extension_from_disk_and_state( + installer: Installer, tmp_path: Path +) -> None: + _install(installer, _make_repo(tmp_path, MANIFEST)) + + installer.uninstall("demo-extension") + + assert installer.list_packs() == [] + assert not (tmp_path / "extensions" / "demo-extension").exists() + assert not installer._registry.has("demo/invert") + + +def test_switching_to_an_uninstalled_version_is_refused( + installer: Installer, tmp_path: Path +) -> None: + _install(installer, _make_repo(tmp_path, MANIFEST)) + + with pytest.raises(InstallError, match="not installed"): + installer.switch_version("demo-extension", "9.9.9+deadbee") + + +def test_rollback_repoints_current_and_requires_a_restart( + installer: Installer, tmp_path: Path +) -> None: + repo = _make_repo(tmp_path, MANIFEST) + first = _install(installer, repo) + + # A second version of the same extension. + (repo / "inline-extension.json").write_text( + json.dumps({**MANIFEST, "version": "1.1.0"}, indent=2), encoding="utf-8" + ) + _git("add", "-A", cwd=repo) + _git("commit", "-q", "-m", "v1.1.0", cwd=repo) + _git("tag", "v1.1.0", cwd=repo) + second = installer._install(InstallRequest(source=f"file://{repo}", ref="v1.1.0")) + + assert second.version != first.version + assert second.restart_required, "the extension was already imported this session" + + result = installer.switch_version("demo-extension", first.version) + + assert result["restartRequired"] is True + current = ExtensionsRoot(tmp_path / "extensions").extension("demo-extension").current() + assert current == first.version + + +GENERATE_SOURCE = ''' +import numpy as np +from inline_core.extensions.api import inline_node +from inline_core.graph.descriptor import ParamField, Port, Widget +from inline_core.graph.runners import NodeResult, NodeRunner +from inline_core.graph.schema import PortKind +from inline_core.media import MediaKind + + +@inline_node( + type="demo/invert", + title="Generate", + category="Generate", + output_kind=MediaKind.IMAGE, + outputs=(Port("image", "Image", PortKind.IMAGE),), + params=(ParamField("size", "Size", Widget.NUMBER, 32),), +) +class Generate(NodeRunner): + produces_takes = True + + def run(self, node, inputs, ctx) -> NodeResult: + size = int({**Generate.__inline_descriptor__.defaults(), **node.params}["size"]) + image = np.zeros((size, size, 3), dtype=np.uint8) + if ctx.takes is None: + return NodeResult(outputs={"image": image}) + return NodeResult( + outputs={"image": image}, takes=[ctx.takes.save(ctx.run_id, node.id, image, {})] + ) + + +def register(reg) -> None: + reg.nodes(Generate) +''' + + +def test_an_extension_node_runs_as_a_graph_and_produces_a_take( + installer: Installer, tmp_path: Path +) -> None: + """An extension node is runnable exactly like a built-in Generate node: `output_kind` gives it + the Run control and take history, and `ctx.takes` is how a registrar-built runner saves.""" + from inline_core.device.memory import MemoryPolicy + from inline_core.graph.cache import InMemoryCache + from inline_core.graph.executor import Executor + from inline_core.graph.schema import parse_graph + from inline_core.media import MediaKind + from inline_core.runtime.context import CancelToken, ExecutionContext + from inline_core.runtime.file_store import FileTakeStore + from inline_core.runtime.progress import ProgressEmitter + from inline_core.runtime.run import RunState + + repo = _make_repo(tmp_path, _renamed(MANIFEST, "gen-extension"), source=GENERATE_SOURCE) + _install(installer, repo) + + descriptor = installer._registry.get("demo/invert") + assert descriptor.output_kind is MediaKind.IMAGE, "this is what puts Run on the node" + assert installer._registry.runner("demo/invert").produces_takes + + class _Silent(ProgressEmitter): + def emit(self, event: Any) -> None: + pass + + takes_dir = tmp_path / "run-takes" + store = FileTakeStore(takes_dir) + graph = parse_graph( + { + "schemaVersion": 1, + "nodes": [{"id": "n1", "type": "demo/invert", "params": {"size": 16}}], + "edges": [], + } + ) + ctx = ExecutionContext( + run_id="run_x", + policy=MemoryPolicy(), + emitter=_Silent(), + cancel=CancelToken(), + takes=store, + ) + Executor(installer._registry, InMemoryCache()).run( + graph, "n1", ctx, RunState(run_id="run_x", target="n1") + ) + + written = list(takes_dir.glob("*.png")) + assert len(written) == 1, "the run wrote exactly one take" diff --git a/core/tests/test_extension_manifest.py b/core/tests/test_extension_manifest.py new file mode 100644 index 0000000..43d9e2c --- /dev/null +++ b/core/tests/test_extension_manifest.py @@ -0,0 +1,184 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from inline_core.extensions.manifest import ( + ManifestError, + load_manifest, + package_name, + parse_manifest, +) + + +def _manifest(**overrides: object) -> dict[str, object]: + base: dict[str, object] = { + "schema": 1, + "id": "acme-tools", + "name": "Acme Tools", + "version": "1.0.0", + "coreCompat": ">=1.2,<2.0", + "requirements": ["einops>=0.7"], + "entry": "inline_ext_acme_tools:register", + "nodes": [{"type": "acme/invert"}], + } + base.update(overrides) + return base + + +def test_parses_a_valid_manifest() -> None: + manifest = parse_manifest(_manifest(), expect_id="acme-tools") + assert manifest.id == "acme-tools" + assert manifest.package == "inline_ext_acme_tools" + assert manifest.isolation == "shared" + assert manifest.node_types() == ("acme/invert",) + assert manifest.node("acme/invert") is not None + assert manifest.node("acme/missing") is None + + +def test_a_node_defaults_to_enabled() -> None: + node = parse_manifest(_manifest(), expect_id="acme-tools").nodes[0] + assert node.default_enabled is True + assert node.models == () + + +def test_default_enabled_false_is_respected() -> None: + manifest = _manifest(nodes=[{"type": "acme/invert", "defaultEnabled": False}]) + assert parse_manifest(manifest, expect_id="acme-tools").nodes[0].default_enabled is False + + +def test_package_name_maps_dashes_to_underscores() -> None: + assert package_name("acme-video-tools") == "inline_ext_acme_video_tools" + + +def test_id_must_match_the_extension_directory() -> None: + """The directory name and the manifest id are one identity; a mismatch would desynchronize + the on-disk layout from state.json.""" + with pytest.raises(ManifestError) as excinfo: + parse_manifest(_manifest(), expect_id="something-else") + assert "must equal the extension directory name" in str(excinfo.value) + + +def test_entry_must_live_in_the_extensions_own_package() -> None: + """This invariant makes module ownership unambiguous - without it an extension could ship a + top-level package that shadows another extension's code.""" + with pytest.raises(ManifestError) as excinfo: + parse_manifest(_manifest(entry="numpy.core:register"), expect_id="acme-tools") + assert "must live inside the extension's package" in str(excinfo.value) + + +def test_entry_is_required() -> None: + bad = _manifest() + del bad["entry"] + with pytest.raises(ManifestError) as excinfo: + parse_manifest(bad, expect_id="acme-tools") + assert "$.entry" in str(excinfo.value) + + +def test_reports_every_problem_at_once() -> None: + with pytest.raises(ManifestError) as excinfo: + parse_manifest({"schema": 99, "id": "BAD ID"}, expect_id="BAD ID") + problems = excinfo.value.problems + assert len(problems) >= 3 + assert any("$.schema" in p for p in problems) + assert any("$.id" in p for p in problems) + assert any("$.nodes" in p for p in problems) + + +def test_rejects_duplicate_node_types() -> None: + bad = _manifest(nodes=[{"type": "acme/x"}, {"type": "acme/x"}]) + with pytest.raises(ManifestError) as excinfo: + parse_manifest(bad, expect_id="acme-tools") + assert "declared twice" in str(excinfo.value) + + +def test_rejects_a_malformed_node_type() -> None: + with pytest.raises(ManifestError) as excinfo: + parse_manifest(_manifest(nodes=[{"type": "notnamespaced"}]), expect_id="acme-tools") + assert "owner/name" in str(excinfo.value) + + +def test_rejects_an_empty_node_list() -> None: + with pytest.raises(ManifestError) as excinfo: + parse_manifest(_manifest(nodes=[]), expect_id="acme-tools") + assert "$.nodes" in str(excinfo.value) + + +def test_rejects_ui_paths_escaping_the_repository() -> None: + with pytest.raises(ManifestError) as excinfo: + parse_manifest(_manifest(ui="../../etc/passwd"), expect_id="acme-tools") + assert "relative path inside the repository" in str(excinfo.value) + + +def test_rejects_model_filename_with_a_path_separator() -> None: + bad = _manifest( + nodes=[ + { + "type": "acme/invert", + "models": [ + { + "id": "w", + "label": "W", + "category": "checkpoints", + "repo": "acme/w", + "repoFile": "w.safetensors", + "filename": "../../w.safetensors", + } + ], + } + ] + ) + with pytest.raises(ManifestError) as excinfo: + parse_manifest(bad, expect_id="acme-tools") + assert "bare filename" in str(excinfo.value) + + +def test_rejects_an_unknown_model_category() -> None: + """The category decides where the file lands AND which options_from dropdown lists it, so a + wrong one downloads successfully and then reports the model as missing.""" + bad = _manifest( + nodes=[ + { + "type": "acme/invert", + "models": [ + { + "id": "w", + "label": "W", + "category": "my_custom_models", + "repo": "acme/w", + "repoFile": "w.safetensors", + "filename": "w.safetensors", + } + ], + } + ] + ) + with pytest.raises(ManifestError) as excinfo: + parse_manifest(bad, expect_id="acme-tools") + assert "must be one of" in str(excinfo.value) + assert "diffusion_models" in str(excinfo.value) + + +def test_prebuilt_requires_https_and_a_real_digest() -> None: + bad = _manifest( + prebuilt=[ + {"platform": "linux-x86_64", "python": "cp311", "url": "http://x/y", "sha256": "nope"} + ] + ) + with pytest.raises(ManifestError) as excinfo: + parse_manifest(bad, expect_id="acme-tools") + assert "must be https" in str(excinfo.value) + assert "64-character hex" in str(excinfo.value) + + +def test_load_manifest_reads_from_disk(tmp_path: Path) -> None: + (tmp_path / "inline-extension.json").write_text(json.dumps(_manifest()), encoding="utf-8") + assert load_manifest(tmp_path, expect_id="acme-tools").name == "Acme Tools" + + +def test_load_manifest_missing_file_names_the_file(tmp_path: Path) -> None: + with pytest.raises(ManifestError) as excinfo: + load_manifest(tmp_path, expect_id="acme-tools") + assert "inline-extension.json" in str(excinfo.value) diff --git a/core/tests/test_extension_resolve.py b/core/tests/test_extension_resolve.py new file mode 100644 index 0000000..ca97468 --- /dev/null +++ b/core/tests/test_extension_resolve.py @@ -0,0 +1,298 @@ +"""Host-constraint generation, conflict diagnosis, and the private-site install.""" + +from __future__ import annotations + +import shutil +from pathlib import Path + +import pytest + +from inline_core.extensions.constraints import ( + HOST_PROTECTED, + canonical, + conflicts, + fingerprint, + host_distributions, + parse_lock, + protected_requirements, + prunable, + render, + requirement_name, + write_constraints, +) +from inline_core.extensions.resolve import ResolutionError, resolve_and_install +from inline_core.extensions.tools import UV, missing_tools, tool_status + +HOST = {"torch": "2.5.1", "numpy": "1.26.4", "einops": "0.8.0"} + + +# --- naming --------------------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + ("Huggingface_Hub", "huggingface-hub"), + ("huggingface.hub", "huggingface-hub"), + ("NumPy", "numpy"), + ], +) +def test_canonical_normalizes_pep503(raw: str, expected: str) -> None: + assert canonical(raw) == expected + + +@pytest.mark.parametrize( + ("requirement", "expected"), + [ + ("einops>=0.7,<0.9", "einops"), + ("torch == 2.5.1", "torch"), + ("pillow ; python_version < '3.12'", "pillow"), + ("mypkg @ https://example.com/mypkg.whl", "mypkg"), + ("Torch-Vision", "torch-vision"), + ], +) +def test_requirement_name_extracts_the_distribution(requirement: str, expected: str) -> None: + assert requirement_name(requirement) == expected + + +def test_protected_requirements_flags_the_shared_ml_stack() -> None: + """The cheap static check: an extension listing torch is blocked before any resolution runs.""" + found = protected_requirements(("einops>=0.7", "torch>=2.0", "TRANSFORMERS==4.60")) + assert found == ["torch>=2.0", "TRANSFORMERS==4.60"] + + +def test_protected_set_covers_the_runtime_extra() -> None: + for name in ("torch", "diffusers", "transformers", "numpy", "safetensors", "accelerate"): + assert name in HOST_PROTECTED + + +# --- constraint file ------------------------------------------------------------------------------ + + +def test_render_pins_every_host_package() -> None: + text = render(HOST) + assert "torch==2.5.1" in text + assert "numpy==1.26.4" in text + assert f"# host-fingerprint: {fingerprint(HOST)}" in text + + +def test_fingerprint_is_order_independent_and_content_sensitive() -> None: + assert fingerprint({"a": "1", "b": "2"}) == fingerprint({"b": "2", "a": "1"}) + assert fingerprint({"a": "1"}) != fingerprint({"a": "2"}) + + +def test_write_constraints_rewrites_only_when_the_host_changed(tmp_path: Path) -> None: + path = tmp_path / "host-constraints.txt" + write_constraints(path, HOST) + first = path.stat().st_mtime_ns + + write_constraints(path, HOST) + assert path.stat().st_mtime_ns == first, "unchanged host must not rewrite the file" + + write_constraints(path, {**HOST, "einops": "0.9.0"}) + assert path.stat().st_mtime_ns != first + + +def test_host_distributions_sees_the_running_interpreter() -> None: + dists = host_distributions() + assert "pytest" in dists + + +# --- conflict diagnosis --------------------------------------------------------------------------- + + +def test_parse_lock_reads_pins_and_ignores_comments_and_flags() -> None: + lock = """ +# via acme +--index-url https://pypi.org/simple +einops==0.8.0 + # via -r requirements.in +transformers==4.60.0 ; python_version >= '3.11' +""" + assert parse_lock(lock) == {"einops": "0.8.0", "transformers": "4.60.0"} + + +def test_conflicts_names_the_package_and_both_versions() -> None: + """The whole point: turn "resolution impossible" into something the user can act on.""" + found = conflicts("transformers==4.60.0\neinops==0.8.0\n", HOST | {"transformers": "4.52.0"}) + assert len(found) == 1 + assert found[0].name == "transformers" + assert found[0].host_version == "4.52.0" + assert found[0].wanted == "==4.60.0" + assert "4.60.0" in found[0].message() and "4.52.0" in found[0].message() + + +def test_conflicts_ignores_packages_that_agree_with_the_host() -> None: + assert conflicts("einops==0.8.0\n", HOST) == [] + + +def test_conflict_marks_shared_runtime_packages_as_protected() -> None: + found = conflicts("torch==2.7.0\n", HOST) + assert found[0].protected is True + assert "shared Inline runtime" in found[0].message() + assert found[0].to_json()["protected"] is True + + +def test_prunable_lists_host_duplicates_and_protected_packages() -> None: + """Pruning is what guarantees the finder can never route a shared package privately.""" + site = {"einops": "0.8.0", "numpy": "1.26.4", "humanize": "4.0.0"} + assert prunable(site, HOST) == ["einops", "numpy"] + + +# --- the real install path ------------------------------------------------------------------------ + + +def test_no_requirements_skips_resolution_entirely(tmp_path: Path) -> None: + """Also the shape a prebuilt install reduces to: nothing private to resolve.""" + result = resolve_and_install( + (), + site=tmp_path / "site", + lock_dir=tmp_path / "lock", + constraints_path=tmp_path / "host-constraints.txt", + ) + assert result.modules == [] + assert result.distributions == {} + + +@pytest.mark.skipif(shutil.which("uv") is None, reason="uv is not installed") +def test_installs_a_private_dependency_and_derives_its_modules(tmp_path: Path) -> None: + """End-to-end against the real resolver: a pure-Python dep lands in site/ and its top-level + module is discovered from the installed metadata.""" + result = resolve_and_install( + ("humanize==4.9.0",), + site=tmp_path / "site", + lock_dir=tmp_path / "lock", + constraints_path=tmp_path / "host-constraints.txt", + log=tmp_path / "lock" / "install.log", + ) + + assert "humanize" in result.modules + assert result.distributions.get("humanize") == "4.9.0" + assert (tmp_path / "site" / "humanize").is_dir() + assert (tmp_path / "lock" / "requirements.lock").is_file() + + +@pytest.mark.skipif(shutil.which("uv") is None, reason="uv is not installed") +def test_requiring_a_different_host_version_fails_with_a_named_conflict(tmp_path: Path) -> None: + """The host-override signal. numpy is installed here, so demanding an impossible version must + fail with the package named rather than a resolver trace.""" + with pytest.raises(ResolutionError) as excinfo: + resolve_and_install( + ("numpy==1.11.0",), + site=tmp_path / "site", + lock_dir=tmp_path / "lock", + constraints_path=tmp_path / "host-constraints.txt", + log=tmp_path / "lock" / "install.log", + ) + assert "numpy" in str(excinfo.value).lower() + + +# --- external tools ------------------------------------------------------------------------------- + + +def test_tool_status_reports_availability_with_an_install_hint() -> None: + statuses = {s.name: s for s in tool_status()} + assert set(statuses) == {"git", "uv"} + for status in statuses.values(): + assert status.hint, "a missing tool must always come with an actionable hint" + if status.available: + assert status.version + + +def test_missing_tools_matches_what_is_on_path() -> None: + assert ("uv" in missing_tools()) is (shutil.which("uv") is None) + + +def test_require_raises_with_an_actionable_message(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(shutil, "which", lambda _name: None) + with pytest.raises(RuntimeError) as excinfo: + UV.require() + message = str(excinfo.value) + assert "not found on PATH" in message + assert "astral.sh/uv/install" in message, "the error must tell the user how to fix it" + + +# --- floating versions ------------------------------------------------------------------------ + + +@pytest.mark.parametrize( + ("lower", "higher"), + [ + ("v1.9.0", "v1.10.0"), # numeric, not lexicographic + ("1.2.0", "1.2.1"), + ("v1.2.0-rc.1", "v1.2.0"), # a prerelease never beats its release + ("v1.2.0", "v2.0.0"), + ], +) +def test_version_ordering(lower: str, higher: str) -> None: + from inline_core.extensions.fetch import version_key + + low, high = version_key(lower), version_key(higher) + assert low is not None and high is not None + assert low < high + + +@pytest.mark.parametrize("tag", ["nightly", "release", "v1.2", "1.2.3.4", ""]) +def test_non_release_tags_are_ignored(tag: str) -> None: + """Floating must only ever pick a real release, never a moving branch-like tag.""" + from inline_core.extensions.fetch import version_key + + assert version_key(tag) is None + + +@pytest.mark.skipif(shutil.which("git") is None, reason="git is not installed") +def test_latest_tag_picks_the_highest_release(tmp_path: Path) -> None: + import subprocess + + from inline_core.extensions.fetch import latest_tag + + repo = tmp_path / "repo" + repo.mkdir() + env = { + "GIT_AUTHOR_NAME": "T", + "GIT_AUTHOR_EMAIL": "t@t.com", + "GIT_COMMITTER_NAME": "T", + "GIT_COMMITTER_EMAIL": "t@t.com", + "PATH": "/usr/bin:/bin", + } + subprocess.run(["git", "init", "-q", "-b", "main"], cwd=repo, check=True, env=env) + subprocess.run( + ["git", "commit", "-q", "--allow-empty", "-m", "x"], cwd=repo, check=True, env=env + ) + for tag in ("v1.0.0", "v1.10.0", "v1.9.0", "v2.0.0-rc.1", "nightly"): + subprocess.run(["git", "tag", tag], cwd=repo, check=True, capture_output=True) + + assert latest_tag(f"file://{repo}") == "v1.10.0", "rc and non-release tags are skipped" + + +def test_latest_tag_on_an_unreachable_repo_is_none() -> None: + from inline_core.extensions.fetch import latest_tag + + assert latest_tag("https://example.invalid/nope.git") is None + + +@pytest.mark.skipif(shutil.which("git") is None, reason="git is not installed") +def test_a_prerelease_can_still_be_installed_by_name(tmp_path: Path) -> None: + """Floating skips prereleases, but asking for one explicitly must still work.""" + import subprocess + + from inline_core.extensions.fetch import latest_tag + + repo = tmp_path / "pre" + repo.mkdir() + env = { + "GIT_AUTHOR_NAME": "T", + "GIT_AUTHOR_EMAIL": "t@t.com", + "GIT_COMMITTER_NAME": "T", + "GIT_COMMITTER_EMAIL": "t@t.com", + "PATH": "/usr/bin:/bin", + } + subprocess.run(["git", "init", "-q", "-b", "main"], cwd=repo, check=True, env=env) + subprocess.run( + ["git", "commit", "-q", "--allow-empty", "-m", "x"], cwd=repo, check=True, env=env + ) + subprocess.run(["git", "tag", "v2.0.0-rc.1"], cwd=repo, check=True, capture_output=True) + + url = f"file://{repo}" + assert latest_tag(url) is None, "a repo with only prereleases has no stable release" + assert latest_tag(url, prereleases=True) == "v2.0.0-rc.1" diff --git a/core/tests/test_extension_scanner.py b/core/tests/test_extension_scanner.py new file mode 100644 index 0000000..d6ab548 --- /dev/null +++ b/core/tests/test_extension_scanner.py @@ -0,0 +1,291 @@ +"""The security scanner: every blocking rule must fire, and a clean extension must not be gated. + +The second half matters as much as the first. A scanner that flags ordinary extensions trains users +to confirm reflexively, and a consent prompt nobody reads protects nobody. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from inline_core.extensions.manifest import ExtensionNode, Manifest +from inline_core.extensions.scanner import ScanReport, Severity, load_report, scan + + +def _write(tmp_path: Path, code: str, name: str = "nodes.py") -> Path: + source = tmp_path / "src" + source.mkdir(parents=True, exist_ok=True) + (source / name).write_text(code, encoding="utf-8") + return source + + +def _manifest(requirements: tuple[str, ...] = (), license_: str = "MIT") -> Manifest: + return Manifest( + schema=1, + id="acme-tools", + name="Acme Tools", + version="1.0.0", + core_compat=">=1.2", + python_requires=">=3.11", + requirements=requirements, + entry="inline_ext_acme_tools:register", + nodes=(ExtensionNode(type="acme/invert"),), + license=license_, + ) + + +def _rules(report: ScanReport) -> set[str]: + return {f.rule for f in report.findings} + + +# --- clean extensions are not gated --------------------------------------------------------------- + + +CLEAN = ''' +"""An ordinary image node.""" +import numpy as np +from inline_core.extensions.api import inline_node +from inline_core.graph.runners import NodeResult, NodeRunner + + +@inline_node(type="acme/invert", title="Invert", category="Image") +class Invert(NodeRunner): + def run(self, node, inputs, ctx) -> NodeResult: + image = inputs["image"][0] + return NodeResult(outputs={"image": np.asarray(255 - image)}) +''' + + +def test_a_clean_extension_produces_no_gate(tmp_path: Path) -> None: + report = scan(_write(tmp_path, CLEAN), _manifest()) + assert not report.blocked + assert not report.needs_consent + assert report.consent_rules() == [] + + +def test_downloading_weights_from_hugging_face_is_informational(tmp_path: Path) -> None: + """The consent-fatigue guard: nearly every useful extension does this, so it must not prompt.""" + code = """ +from huggingface_hub import hf_hub_download + +def fetch(): + return hf_hub_download("acme/weights", "model.safetensors") +""" + report = scan(_write(tmp_path, code), _manifest()) + assert not report.blocked + assert not report.needs_consent + assert "model-download" in _rules(report) + + +def test_egress_to_an_allowlisted_host_is_informational(tmp_path: Path) -> None: + code = """ +import requests + +def fetch(): + return requests.get("https://huggingface.co/acme/weights/resolve/main/model.safetensors") +""" + report = scan(_write(tmp_path, code), _manifest()) + assert not report.needs_consent + assert "network-egress-known" in _rules(report) + + +# --- CRITICAL: each must block -------------------------------------------------------------------- + + +def test_requiring_torch_is_blocked(tmp_path: Path) -> None: + report = scan(_write(tmp_path, CLEAN), _manifest(requirements=("torch>=2.0",))) + assert report.blocked + assert "host-dependency-override" in _rules(report) + + +@pytest.mark.parametrize("protected", ["torch==2.5.1", "diffusers>=0.36", "NumPy", "transformers"]) +def test_every_shared_runtime_package_is_blocked(tmp_path: Path, protected: str) -> None: + report = scan(_write(tmp_path, CLEAN), _manifest(requirements=(protected,))) + assert report.blocked, f"{protected} must not be installable by an extension" + + +def test_obfuscated_execution_is_blocked(tmp_path: Path) -> None: + code = """ +import base64 + +exec(base64.b64decode("cHJpbnQoJ2hpJyk=")) +""" + report = scan(_write(tmp_path, code), _manifest()) + assert report.blocked + assert "obfuscated-execution" in _rules(report) + + +def test_obfuscated_execution_is_caught_through_nesting(tmp_path: Path) -> None: + """The payload is rarely the direct argument in real samples.""" + code = """ +import base64, zlib + +exec(zlib.decompress(base64.b64decode(PAYLOAD)).decode("utf-8")) +""" + report = scan(_write(tmp_path, code), _manifest()) + assert report.blocked + assert "obfuscated-execution" in _rules(report) + + +def test_exec_of_a_non_decoder_call_is_not_a_critical_block(tmp_path: Path) -> None: + """CRITICAL blocks with no override, so a false positive is unrecoverable for the user. + `loads` is a generic name - only a call traceable to a real decoding module counts.""" + code = """ +import json + +exec(json.loads(config)) +""" + report = scan(_write(tmp_path, code), _manifest()) + assert not report.blocked + assert "obfuscated-execution" not in _rules(report) + assert "dynamic-execution" in _rules(report), "still worth consent, just not a block" + + +def test_decoder_imported_by_name_is_still_caught(tmp_path: Path) -> None: + """Tightening the rule must not open the obvious evasion.""" + code = """ +from base64 import b64decode + +exec(b64decode(PAYLOAD)) +""" + report = scan(_write(tmp_path, code), _manifest()) + assert report.blocked + assert "obfuscated-execution" in _rules(report) + + +def test_setup_py_is_blocked(tmp_path: Path) -> None: + source = _write(tmp_path, CLEAN) + (source / "setup.py").write_text("from setuptools import setup\nsetup()\n", encoding="utf-8") + report = scan(source, _manifest()) + assert report.blocked + assert "install-time-code-execution" in _rules(report) + + +def test_bundled_native_runtime_is_blocked(tmp_path: Path) -> None: + """Two libtorch copies in one process segfault rather than raising, and that reads as a Core + bug rather than an extension bug.""" + source = _write(tmp_path, CLEAN) + (source / "libtorch_cpu.so").write_bytes(b"\x7fELF") + report = scan(source, _manifest()) + assert report.blocked + assert "bundled-native-runtime" in _rules(report) + + +# --- HIGH / MEDIUM: consent, not a block ---------------------------------------------------------- + + +def test_unresolvable_egress_requires_consent(tmp_path: Path) -> None: + """A URL built at runtime cannot be reviewed, so it must not be waved through.""" + code = """ +import requests + +def send(cfg, payload): + return requests.post(f"{cfg.endpoint}/collect", json=payload) +""" + report = scan(_write(tmp_path, code), _manifest()) + assert not report.blocked + assert report.needs_consent + assert "network-egress" in report.consent_rules() + + +def test_egress_to_an_unknown_host_requires_consent(tmp_path: Path) -> None: + code = """ +import requests + +requests.post("https://telemetry.example.com/collect", json={}) +""" + report = scan(_write(tmp_path, code), _manifest()) + assert report.needs_consent + finding = next(f for f in report.findings if f.rule == "network-egress") + assert "telemetry.example.com" in finding.message + + +@pytest.mark.parametrize( + ("code", "rule"), + [ + ("import subprocess\nsubprocess.run(['ls'])", "subprocess"), + ("import os\nos.system('ls')", "subprocess"), + ("import pickle\npickle.loads(data)", "pickle-deserialization"), + ("import ctypes\nctypes.CDLL('libc.so.6')", "ctypes"), + ("import socket\ns = socket.socket()", "raw-socket"), + ("eval(user_input)", "dynamic-execution"), + ], +) +def test_capabilities_require_consent(tmp_path: Path, code: str, rule: str) -> None: + report = scan(_write(tmp_path, code), _manifest()) + assert not report.blocked, f"{rule} has legitimate uses and must not hard-block" + assert report.needs_consent + assert rule in _rules(report) + + +def test_compiled_binaries_are_flagged_but_do_not_block(tmp_path: Path) -> None: + source = _write(tmp_path, CLEAN) + (source / "_speedups.cpython-311-x86_64-linux-gnu.so").write_bytes(b"\x7fELF") + report = scan(source, _manifest()) + assert not report.blocked + assert "compiled-binary" in _rules(report) + + +def test_unparseable_source_is_reported_not_ignored(tmp_path: Path) -> None: + report = scan(_write(tmp_path, "def broken(:\n"), _manifest()) + assert "unparseable-source" in _rules(report) + + +def test_missing_license_is_informational_only(tmp_path: Path) -> None: + report = scan(_write(tmp_path, CLEAN), _manifest(license_="")) + assert not report.blocked + assert not report.needs_consent + assert "no-license" in _rules(report) + + +# --- report mechanics ----------------------------------------------------------------------------- + + +def test_findings_are_ordered_most_severe_first(tmp_path: Path) -> None: + code = """ +import base64, subprocess +subprocess.run(['ls']) +exec(base64.b64decode(BLOB)) +""" + report = scan(_write(tmp_path, code), _manifest(license_="")) + severities = [f.severity for f in report.findings] + assert severities[0] is Severity.CRITICAL + assert severities == sorted(severities, key=lambda s: [*Severity].index(s)) + + +def test_blocked_takes_precedence_over_consent(tmp_path: Path) -> None: + """A blocked install must never render as a consent prompt the user can click past.""" + code = "import base64\nimport subprocess\nsubprocess.run(['ls'])\nexec(base64.b64decode(B))" + report = scan(_write(tmp_path, code), _manifest()) + assert report.blocked + assert not report.needs_consent + + +def test_scan_ignores_vendored_and_vcs_directories(tmp_path: Path) -> None: + """site/ holds resolved third-party code, which is governed by the lockfile, not this scan.""" + source = _write(tmp_path, CLEAN) + for folder in ("site", ".git", "__pycache__"): + (source / folder).mkdir() + (source / folder / "evil.py").write_text("import base64\nexec(base64.b64decode(X))\n") + report = scan(source, _manifest()) + assert not report.blocked + + +def test_report_round_trips_through_json(tmp_path: Path) -> None: + import json + + report = scan(_write(tmp_path, "import subprocess\nsubprocess.run(['ls'])"), _manifest()) + path = tmp_path / "scan.json" + path.write_text(json.dumps(report.to_json()), encoding="utf-8") + + restored = load_report(path) + assert restored.needs_consent == report.needs_consent + assert restored.consent_rules() == report.consent_rules() + + +def test_load_report_tolerates_a_corrupt_file(tmp_path: Path) -> None: + path = tmp_path / "scan.json" + path.write_text("{not json", encoding="utf-8") + assert load_report(path).findings == [] diff --git a/core/tests/test_extension_spine.py b/core/tests/test_extension_spine.py new file mode 100644 index 0000000..f32df32 --- /dev/null +++ b/core/tests/test_extension_spine.py @@ -0,0 +1,98 @@ +"""The small Core changes the extension system rests on: content-addressed registry versions, +node unregistration, and exclusive RPC channel registration. +""" + +from __future__ import annotations + +from dataclasses import replace +from typing import Any + +import pytest + +from inline_core.graph.descriptor import NodeDescriptor, ParamField, Port, Widget +from inline_core.graph.registry import Registry +from inline_core.graph.schema import PortKind +from inline_core.server.rpc import RpcRouter + +DESCRIPTOR = NodeDescriptor( + type="acme/thing", + title="Thing", + category="Test", + inputs=(Port("image", "Image", PortKind.IMAGE, required=True),), + outputs=(Port("image", "Image", PortKind.IMAGE),), + params=(ParamField("scale", "Scale", Widget.NUMBER, 2),), +) + + +def test_version_changes_when_a_param_default_changes() -> None: + """The regression that motivated the fix: an extension upgrade can change params while keeping + its node types identical. Hashing types alone left the /v1/models ETag stable and the client + kept serving a stale catalog.""" + registry = Registry() + registry.register(DESCRIPTOR) + before = registry.version() + + registry.register(replace(DESCRIPTOR, params=(ParamField("scale", "Scale", Widget.NUMBER, 4),))) + + assert registry.version() != before + + +def test_version_changes_when_a_node_is_unregistered() -> None: + registry = Registry() + registry.register(DESCRIPTOR) + with_node = registry.version() + registry.unregister(DESCRIPTOR.type) + assert registry.version() != with_node + + +def test_version_is_stable_across_insertion_order() -> None: + other = replace(DESCRIPTOR, type="acme/other") + first, second = Registry(), Registry() + first.register(DESCRIPTOR) + first.register(other) + second.register(other) + second.register(DESCRIPTOR) + assert first.version() == second.version() + + +def test_version_tracks_source_so_provenance_changes_bust_the_cache() -> None: + registry = Registry() + registry.register(DESCRIPTOR) + builtin = registry.version() + registry.register(replace(DESCRIPTOR, source="ext:acme:basic")) + assert registry.version() != builtin + + +def test_unregister_removes_the_descriptor_and_its_runner() -> None: + registry = Registry() + registry.register(DESCRIPTOR) + assert registry.has(DESCRIPTOR.type) + registry.unregister(DESCRIPTOR.type) + assert not registry.has(DESCRIPTOR.type) + assert DESCRIPTOR.type not in [d.type for d in registry.descriptors()] + + +def test_unregister_is_a_no_op_for_unknown_types() -> None: + Registry().unregister("nobody/home") + + +async def _handler(_args: list[Any]) -> str: + return "ok" + + +def test_registering_an_existing_channel_raises() -> None: + """Without this guard an extension could re-register `project:open` or `settings:get` and + silently intercept every call to it - RpcRouter was last-write-wins.""" + router = RpcRouter() + router.register("project:open", _handler) + with pytest.raises(ValueError, match="already registered"): + router.register("project:open", _handler) + + +def test_unregister_frees_the_channel_for_reuse() -> None: + router = RpcRouter() + router.register("ext:acme:go", _handler) + router.unregister("ext:acme:go") + assert not router.has("ext:acme:go") + router.register("ext:acme:go", _handler) # re-registration after disable must work + assert router.has("ext:acme:go") diff --git a/core/tests/test_extension_state.py b/core/tests/test_extension_state.py new file mode 100644 index 0000000..5f90fc0 --- /dev/null +++ b/core/tests/test_extension_state.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +from pathlib import Path + +from inline_core.extensions.paths import ExtensionsRoot, version_dirname +from inline_core.extensions.state import StateStore + + +def _paths(tmp_path: Path) -> ExtensionsRoot: + paths = ExtensionsRoot(tmp_path / "extensions") + paths.ensure_dirs() + return paths + + +def test_activate_persists_across_reload(tmp_path: Path) -> None: + paths = _paths(tmp_path) + store = StateStore(paths) + store.activate("acme", version="1.0.0+abc1234", owns=["einops"], nodes={"acme/x": True}) + + reopened = StateStore(paths) + state = reopened.extension("acme") + assert state is not None + assert state.current == "1.0.0+abc1234" + assert state.enabled is True + assert state.node_enabled("acme/x", default=False) is True + assert reopened.owner_of("einops") == "acme" + + +def test_node_enabled_falls_back_to_the_manifest_default(tmp_path: Path) -> None: + store = StateStore(_paths(tmp_path)) + store.activate("acme", version="1.0.0+abc1234", owns=[]) + state = store.extension("acme") + assert state is not None + assert state.node_enabled("acme/never-recorded", default=True) is True + assert state.node_enabled("acme/never-recorded", default=False) is False + + +def test_reactivating_releases_modules_it_no_longer_needs(tmp_path: Path) -> None: + """An extension that drops a dependency must release its claim, or the stale ownership would + block another extension from ever owning that module.""" + paths = _paths(tmp_path) + store = StateStore(paths) + store.activate("acme", version="1.0.0+aaaaaaa", owns=["einops", "humanize"]) + assert store.owner_of("humanize") == "acme" + + store.activate("acme", version="2.0.0+bbbbbbb", owns=["einops"]) + assert store.owner_of("einops") == "acme" + assert store.owner_of("humanize") is None + + +def test_remove_drops_the_extension_and_its_module_claims(tmp_path: Path) -> None: + paths = _paths(tmp_path) + store = StateStore(paths) + store.activate("acme", version="1.0.0+aaaaaaa", owns=["einops"]) + store.activate("other", version="1.0.0+ccccccc", owns=["humanize"]) + + store.remove("acme") + assert store.extension("acme") is None + assert store.owner_of("einops") is None + assert store.owner_of("humanize") == "other" + + +def test_corrupt_state_file_starts_empty_rather_than_failing_boot(tmp_path: Path) -> None: + paths = _paths(tmp_path) + paths.state.write_text("{not json", encoding="utf-8") + store = StateStore(paths) + assert store.extensions() == {} + + +def test_unknown_schema_is_ignored_rather_than_misread(tmp_path: Path) -> None: + paths = _paths(tmp_path) + paths.state.write_text('{"schema": 99, "extensions": {"acme": {"current": "x"}}}', + encoding="utf-8") + assert StateStore(paths).extensions() == {} + + +def test_current_pointer_round_trips(tmp_path: Path) -> None: + paths = _paths(tmp_path) + extension = paths.extension("acme") + dirname = version_dirname("1.4.0", "9f2c1abcdef") + assert dirname == "1.4.0+9f2c1ab" + extension.version(dirname).ensure() + extension.set_current(dirname) + assert extension.current() == dirname + + +def test_current_returns_none_when_the_version_directory_is_gone(tmp_path: Path) -> None: + """Intent (state.json) and content (the version dirs) are reconciled at boot: a pointer to a + deleted version must read as absent, not as an active install.""" + paths = _paths(tmp_path) + extension = paths.extension("acme") + extension.set_current("1.0.0+aaaaaaa") + assert extension.current() is None + + +def test_prune_keeps_current_plus_the_most_recent(tmp_path: Path) -> None: + paths = _paths(tmp_path) + extension = paths.extension("acme") + names = [f"{n}.0.0+aaaaaa{n}" for n in range(1, 6)] + for index, name in enumerate(names): + version = extension.version(name) + version.ensure() + # Deterministic ordering: prune sorts newest-modified first. + import os + + os.utime(version.root, (1_000_000 + index, 1_000_000 + index)) + extension.set_current(names[0]) + + pruned = extension.prune(keep=2) + + remaining = set(extension.installed_versions()) + assert names[0] in remaining, "the active version is never pruned" + assert len(remaining) == 3, "current + 2 retained for rollback" + assert set(pruned).isdisjoint(remaining) diff --git a/core/tests/test_file_store.py b/core/tests/test_file_store.py index 37eed05..faf2ec7 100644 --- a/core/tests/test_file_store.py +++ b/core/tests/test_file_store.py @@ -3,6 +3,7 @@ from pathlib import Path import numpy as np + from inline_core.media import MediaKind from inline_core.runtime.file_store import FileTakeStore diff --git a/core/tests/test_model_requirements.py b/core/tests/test_model_requirements.py new file mode 100644 index 0000000..2805434 --- /dev/null +++ b/core/tests/test_model_requirements.py @@ -0,0 +1,147 @@ +"""The requirements-provider registry that replaced the hardcoded Z-Image node-type check.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from inline_core.extensions.manifest import ModelRequirement +from inline_core.extensions.models import ManifestRequirements +from inline_core.models.requirements import ModelComponent, RequirementsRegistry +from inline_core.studio.models import ModelDownloads + + +class _StubProvider: + def __init__(self, components: list[ModelComponent]) -> None: + self._components = components + + def components(self, params: dict[str, object] | None = None) -> list[ModelComponent]: + return self._components + + def download_target(self, component: ModelComponent) -> Path: + return Path("models") / component.category + + def estimate(self, policy: Any) -> dict[str, Any] | None: + return {"plan": "resident", "fits": True} + + +def _component(present: bool = False) -> ModelComponent: + return ModelComponent( + id="w", + label="Weights", + category="checkpoints", + present=present, + filename="w.safetensors", + repo="acme/w", + repo_file="w.safetensors", + ) + + +def test_unregistered_node_type_reports_no_requirements() -> None: + """The pre-refactor behaviour for any non-Z-Image node, now the general case.""" + downloads = ModelDownloads(events=None, requirements=RequirementsRegistry()) + assert downloads.requirements("acme/unknown") == { + "components": [], + "allPresent": True, + "estimate": None, + } + + +def test_provider_drives_the_popup_payload() -> None: + registry = RequirementsRegistry() + registry.register("acme/thing", _StubProvider([_component(present=False)])) + downloads = ModelDownloads(events=None, policy=object(), requirements=registry) + + payload = downloads.requirements("acme/thing") + + assert payload["allPresent"] is False + assert payload["estimate"] == {"plan": "resident", "fits": True} + component = payload["components"][0] + assert component["id"] == "w" + assert component["localPath"] == "checkpoints/w.safetensors" + assert component["source"] == "acme/w/w.safetensors" + + +def test_estimate_is_omitted_without_a_policy() -> None: + registry = RequirementsRegistry() + registry.register("acme/thing", _StubProvider([_component()])) + downloads = ModelDownloads(events=None, policy=None, requirements=registry) + assert downloads.requirements("acme/thing")["estimate"] is None + + +def test_a_raising_provider_never_breaks_the_popup() -> None: + """Providers are extension code: a bad one degrades to "no requirements", it does not take + down the model popup for every node.""" + + class Exploding: + def components(self, params: dict[str, object] | None = None) -> list[ModelComponent]: + raise RuntimeError("boom") + + def download_target(self, component: ModelComponent) -> Path: + raise RuntimeError("boom") + + def estimate(self, policy: Any) -> dict[str, Any] | None: + raise RuntimeError("boom") + + registry = RequirementsRegistry() + registry.register("acme/bad", Exploding()) + downloads = ModelDownloads(events=None, policy=object(), requirements=registry) + + payload = downloads.requirements("acme/bad") + assert payload["components"] == [] + assert payload["estimate"] is None + + +def test_registry_unregister_removes_the_provider() -> None: + registry = RequirementsRegistry() + registry.register("acme/thing", _StubProvider([_component()])) + assert registry.has("acme/thing") + registry.unregister("acme/thing") + assert not registry.has("acme/thing") + assert registry.get("acme/thing") is None + + +def test_zimage_provider_matches_the_module_level_requirements() -> None: + """The migration must be behaviour-preserving: the provider is a thin wrapper, not a rewrite.""" + from inline_core.models.zimage.provider import ZImageProvider + from inline_core.models.zimage.requirements import zimage_requirements + + assert ZImageProvider().components() == zimage_requirements() + + +def test_manifest_requirements_refuse_an_unknown_category() -> None: + """Manifest validation rejects these first; reaching the provider means the install directory + was hand-edited. Raising beats guessing a substitute, which would silently download weights + into a folder the node's options_from dropdown never reads.""" + import pytest + + escaping = ModelRequirement( + id="w", + label="W", + category="../../../etc", + repo="acme/w", + repo_file="w.safetensors", + filename="w.safetensors", + ) + with pytest.raises(ValueError, match="unknown model category"): + ManifestRequirements((escaping,)).components() + + +def test_manifest_requirements_keep_a_valid_category() -> None: + valid = ModelRequirement( + id="w", + label="W", + category="upscale_models", + repo="acme/w", + repo_file="nested/w.safetensors", + filename="", + ) + component = ManifestRequirements((valid,)).components()[0] + assert component.category == "upscale_models" + assert component.filename == "w.safetensors", "filename falls back to the repo file's basename" + + +def test_manifest_requirements_have_no_fit_estimate() -> None: + """A wrong "this will fit" is worse than none, and the manifest carries no footprint data.""" + provider = ManifestRequirements(()) + assert provider.estimate(object()) is None diff --git a/core/tests/test_primitives.py b/core/tests/test_primitives.py index 2b44a73..9c03bee 100644 --- a/core/tests/test_primitives.py +++ b/core/tests/test_primitives.py @@ -1,6 +1,7 @@ from __future__ import annotations import pytest + from inline_core.errors import PortTypeError, UnknownNodeType from inline_core.graph.registry import build_default_registry from inline_core.graph.schema import parse_graph diff --git a/core/tests/test_run_store.py b/core/tests/test_run_store.py index a4982a4..b6d0176 100644 --- a/core/tests/test_run_store.py +++ b/core/tests/test_run_store.py @@ -4,6 +4,7 @@ from pathlib import Path from helpers import build_graph, make_registry + from inline_core.graph.cache import InMemoryCache from inline_core.runtime.context import CancelToken from inline_core.runtime.progress import NodeDoneEvent, Phase, ProgressEvent diff --git a/core/tests/test_schema.py b/core/tests/test_schema.py index 423023c..83f0629 100644 --- a/core/tests/test_schema.py +++ b/core/tests/test_schema.py @@ -1,6 +1,7 @@ from __future__ import annotations import pytest + from inline_core.errors import GraphValidationError from inline_core.graph.schema import Edge, PortKind, parse_graph, port_satisfies diff --git a/core/tests/test_server.py b/core/tests/test_server.py index e2e9a8e..de36322 100644 --- a/core/tests/test_server.py +++ b/core/tests/test_server.py @@ -5,6 +5,7 @@ from fastapi.testclient import TestClient from helpers import FAKE_MODEL + from inline_core.graph.cache import InMemoryCache from inline_core.graph.registry import build_default_registry from inline_core.graph.runners import NodeResult, NodeRunner diff --git a/core/tests/test_take_bytes.py b/core/tests/test_take_bytes.py index b1a146d..af20a61 100644 --- a/core/tests/test_take_bytes.py +++ b/core/tests/test_take_bytes.py @@ -7,6 +7,7 @@ import numpy as np from fastapi.testclient import TestClient from helpers import FAKE_MODEL + from inline_core.graph.registry import build_default_registry from inline_core.graph.runners import NodeResult, NodeRunner from inline_core.graph.schema import Node diff --git a/core/tests/test_topo.py b/core/tests/test_topo.py index e81c69f..66ff037 100644 --- a/core/tests/test_topo.py +++ b/core/tests/test_topo.py @@ -1,6 +1,7 @@ from __future__ import annotations import pytest + from inline_core.errors import CycleError from inline_core.graph.topo import topo_sort, upstream_closure diff --git a/core/tests/test_validate.py b/core/tests/test_validate.py index d49fb69..bda1c56 100644 --- a/core/tests/test_validate.py +++ b/core/tests/test_validate.py @@ -2,6 +2,7 @@ import pytest from helpers import make_registry + from inline_core.errors import PortTypeError, UnknownNodeType from inline_core.graph.schema import parse_graph from inline_core.graph.validate import validate diff --git a/src/renderer/store/extensionsStore.ts b/src/renderer/store/extensionsStore.ts new file mode 100644 index 0000000..fcca6e9 --- /dev/null +++ b/src/renderer/store/extensionsStore.ts @@ -0,0 +1,210 @@ +/** + * Installed extensions, the published registry, and live install progress. + * + * Install is a two-step conversation: the first call returns `needsConsent` with a scan report and + * installs nothing; the caller shows the report and calls again with the accepted rules. + */ +import { create } from 'zustand' +import type { + ExtensionInfo, + ExtensionTool, + InstallProgressEvent, + InstallSuccess, + RegistryEntry, + ScanReport, + UpdateStatus, +} from '@shared/extensions' +import { studio } from '@/lib/studio' +import { useCoreNodesStore } from './coreNodesStore' + +/** What the dialog is doing right now. */ +export interface InstallState { + source: string + ref: string + phase: InstallProgressEvent['phase'] | 'idle' + fraction: number + status: string + /** Every phase seen so far, so the stepper can mark them done rather than only showing the + * current one. Phases arrive in order and some (dependency install) are fast enough to miss. */ + seen: InstallProgressEvent['phase'][] + error?: string + /** Set when the install paused for consent, or was blocked outright. */ + report?: ScanReport + conflicts?: { name: string; message: string }[] + /** Set once the install succeeded, so the panel can confirm it instead of silently resetting. */ + done?: InstallSuccess +} + +export type ExtensionsTab = 'installed' | 'available' | 'url' + +interface ExtensionsState { + open: boolean + tab: ExtensionsTab + extensions: ExtensionInfo[] + tools: ExtensionTool[] + canInstall: boolean + registry: RegistryEntry[] + registryStale: boolean + loading: boolean + /** Update status by extension id, filled in asynchronously after the dialog opens. */ + updates: Record + /** True once any operation reported that a restart is needed. */ + restartRequired: boolean + install: InstallState | null + + openDialog: (tab?: ExtensionsTab) => void + closeDialog: () => void + setTab: (tab: ExtensionsTab) => void + refresh: () => Promise + loadRegistry: (refresh?: boolean) => Promise + checkUpdates: () => Promise + beginInstall: (source: string, ref?: string, consents?: string[]) => Promise + clearInstall: () => void + setEnabled: (extensionId: string, enabled: boolean) => Promise + setNodeEnabled: (extensionId: string, nodeType: string, enabled: boolean) => Promise + switchVersion: (extensionId: string, version: string) => Promise + uninstall: (extensionId: string) => Promise + onProgress: (e: InstallProgressEvent) => void +} + +const IDLE: InstallState = { source: '', ref: '', phase: 'idle', fraction: 0, status: '', seen: [] } + +export const useExtensionsStore = create((set, get) => ({ + open: false, + tab: 'installed', + extensions: [], + tools: [], + canInstall: true, + registry: [], + registryStale: false, + loading: false, + updates: {}, + restartRequired: false, + install: null, + + openDialog: (tab) => { + set({ open: true, tab: tab ?? get().tab }) + void get().refresh() + // Fired without awaiting: the list renders immediately and drift badges fill in later. + void get().checkUpdates() + }, + closeDialog: () => set({ open: false, install: null }), + setTab: (tab) => { + set({ tab }) + if (tab === 'available' && get().registry.length === 0) void get().loadRegistry() + }, + + refresh: async () => { + set({ loading: true }) + const res = await studio().extensions.status() + if (res.ok) { + set({ + extensions: res.value.extensions, + tools: res.value.tools, + canInstall: res.value.canInstall, + loading: false, + }) + } else { + set({ loading: false }) + } + }, + + checkUpdates: async () => { + const res = await studio().extensions.checkUpdates() + if (res.ok) { + set({ updates: Object.fromEntries(res.value.map((u) => [u.extensionId, u])) }) + } + }, + + loadRegistry: async (refresh = false) => { + const res = await studio().extensions.registryIndex(refresh) + if (res.ok) set({ registry: res.value.entries, registryStale: res.value.stale }) + }, + + beginInstall: async (source, ref = 'latest', consents) => { + set({ install: { ...IDLE, source, ref, phase: 'fetch', status: 'Starting…', seen: [] } }) + const res = await studio().extensions.install(source, ref, consents) + if (!res.ok) { + set((s) => ({ install: { ...(s.install ?? IDLE), error: res.error, phase: 'idle' } })) + return + } + const outcome = res.value + if (!outcome.ok) { + set((s) => ({ + install: { + ...(s.install ?? IDLE), + phase: outcome.phase, + error: outcome.error, + report: outcome.scan ?? undefined, + conflicts: outcome.conflicts.map((c) => ({ name: c.name, message: c.message })), + }, + })) + return + } + if (outcome.needsConsent) { + // Nothing was installed; the dialog shows the report and calls back with the rules. + set((s) => ({ + install: { ...(s.install ?? IDLE), phase: 'scan', report: outcome.scan ?? undefined }, + })) + return + } + set((s) => ({ + install: { + ...(s.install ?? IDLE), + phase: 'done', + fraction: 1, + status: 'Installed', + seen: [...new Set([...(s.install?.seen ?? []), 'done' as const])], + done: outcome, + }, + restartRequired: s.restartRequired || outcome.restartRequired, + })) + await get().refresh() + // New node types are live: refresh the palette so they appear in the add-node menu. + await useCoreNodesStore.getState().load() + }, + + clearInstall: () => set({ install: null }), + + setEnabled: async (extensionId, enabled) => { + const res = await studio().extensions.setEnabled(extensionId, enabled) + if (res.ok && res.value.restartRequired) set({ restartRequired: true }) + await get().refresh() + await useCoreNodesStore.getState().load() + }, + + setNodeEnabled: async (extensionId, nodeType, enabled) => { + const res = await studio().extensions.setNodeEnabled(extensionId, nodeType, enabled) + if (res.ok && res.value.restartRequired) set({ restartRequired: true }) + await get().refresh() + await useCoreNodesStore.getState().load() + }, + + switchVersion: async (extensionId, version) => { + const res = await studio().extensions.switchVersion(extensionId, version) + if (res.ok) set({ restartRequired: true }) + await get().refresh() + }, + + uninstall: async (extensionId) => { + const res = await studio().extensions.uninstall(extensionId) + if (res.ok && res.value.restartRequired) set({ restartRequired: true }) + await get().refresh() + await useCoreNodesStore.getState().load() + }, + + onProgress: (e) => + set((s) => + s.install + ? { + install: { + ...s.install, + phase: e.phase, + fraction: e.fraction, + status: e.status, + seen: [...new Set([...s.install.seen, e.phase])], + }, + } + : {}, + ), +})) diff --git a/src/renderer/views/Extensions/ExtensionCard.tsx b/src/renderer/views/Extensions/ExtensionCard.tsx new file mode 100644 index 0000000..704610b --- /dev/null +++ b/src/renderer/views/Extensions/ExtensionCard.tsx @@ -0,0 +1,236 @@ +/** + * One installed extension. Collapsed it shows identity and provenance - name, ref, description, + * license, repository, and whether the ref has moved upstream. Nodes live behind the disclosure so + * a list of many extensions stays scannable. + */ +import { useState } from 'react' +import type { ExtensionInfo } from '@shared/extensions' +import { useExtensionsStore } from '../../store/extensionsStore' + +function Toggle({ + on, + onChange, + label, +}: { + on: boolean + onChange: (next: boolean) => void + label: string +}): React.JSX.Element { + return ( + + ) +} + +function ChevronIcon({ open }: { open: boolean }): React.JSX.Element { + return ( + + + + ) +} + +function ExternalIcon(): React.JSX.Element { + return ( + + + + + + ) +} + +/** Read the link as a repository rather than a raw URL. */ +function repoLabel(repo: string): string { + return repo + .replace(/^https:\/\//, '') + .replace(/^file:\/\/\//, '') + .replace(/^git@/, '') + .replace(/\.git$/, '') +} + +export function ExtensionCard({ extension }: { extension: ExtensionInfo }): React.JSX.Element { + const setEnabled = useExtensionsStore((s) => s.setEnabled) + const setNodeEnabled = useExtensionsStore((s) => s.setNodeEnabled) + const switchVersion = useExtensionsStore((s) => s.switchVersion) + const uninstall = useExtensionsStore((s) => s.uninstall) + const update = useExtensionsStore((s) => s.updates[extension.extensionId]) + const [expanded, setExpanded] = useState(false) + const [confirming, setConfirming] = useState(false) + + const others = extension.versions.filter((v) => v !== extension.installed) + const enabledNodes = extension.nodes.filter((n) => n.enabled).length + + return ( +
+
+
+
+ + {extension.name} + + {extension.ref && ( + + {extension.ref} + + )} + {update?.behind && ( + + {update.latestTag && update.latestTag !== extension.ref + ? `${update.latestTag} available` + : 'Update available'} + + )} +
+ + {extension.description && ( +

+ {extension.description} +

+ )} + +
+ {extension.license && {extension.license}} + {extension.sha && ( + + {extension.sha} + {update?.behind && update.remoteSha && ( + → {update.remoteSha} + )} + + )} + {extension.repo && ( + + {repoLabel(extension.repo)} + + + )} +
+
+ void setEnabled(extension.extensionId, next)} + label={`Enable ${extension.name}`} + /> +
+ + + + {expanded && ( +
+ {extension.nodes.map((node) => ( +
+
+
{node.title}
+
{node.type}
+
+ void setNodeEnabled(extension.extensionId, node.type, next)} + label={`Enable ${node.title}`} + /> +
+ ))} + +
+ {others.length > 0 ? ( + + ) : ( + {extension.installed} + )} + + {confirming ? ( +
+ Remove? + + +
+ ) : ( + + )} +
+
+ )} +
+ ) +} diff --git a/src/renderer/views/Extensions/ExtensionsDialog.tsx b/src/renderer/views/Extensions/ExtensionsDialog.tsx new file mode 100644 index 0000000..647cb11 --- /dev/null +++ b/src/renderer/views/Extensions/ExtensionsDialog.tsx @@ -0,0 +1,149 @@ +/** The Extensions dialog: what is installed, what is published, and installing from a URL. */ +import { useEffect } from 'react' +import { studio } from '@/lib/studio' +import { Modal } from '../../components/Modal' +import { useExtensionsStore, type ExtensionsTab } from '../../store/extensionsStore' +import { ExtensionCard } from './ExtensionCard' +import { InstallPanel } from './InstallPanel' + +const TABS: { id: ExtensionsTab; label: string }[] = [ + { id: 'installed', label: 'Installed' }, + { id: 'available', label: 'Available' }, + { id: 'url', label: 'Install from URL' }, +] + +function Empty({ message }: { message: string }): React.JSX.Element { + return
{message}
+} + +function Installed(): React.JSX.Element { + const extensions = useExtensionsStore((s) => s.extensions) + const loading = useExtensionsStore((s) => s.loading) + if (loading && extensions.length === 0) return + if (extensions.length === 0) { + return + } + return ( +
+ {extensions.map((extension) => ( + + ))} +
+ ) +} + +function Available(): React.JSX.Element { + const registry = useExtensionsStore((s) => s.registry) + const stale = useExtensionsStore((s) => s.registryStale) + const installed = useExtensionsStore((s) => s.extensions) + const beginInstall = useExtensionsStore((s) => s.beginInstall) + const loadRegistry = useExtensionsStore((s) => s.loadRegistry) + + if (registry.length === 0) { + return ( +
+ + {stale ? 'Could not reach the extension registry.' : 'No published extensions yet.'} + + +
+ ) + } + + return ( +
+ {stale && ( +
+ Showing a cached list. The registry could not be reached. +
+ )} + {registry.map((entry) => { + const already = installed.some((p) => p.extensionId === entry.id) + return ( +
+
+
{entry.name}
+

+ {entry.description} +

+ {entry.author && ( +
by {entry.author}
+ )} +
+ +
+ ) + })} +
+ ) +} + +export function ExtensionsDialog(): React.JSX.Element | null { + const open = useExtensionsStore((s) => s.open) + const close = useExtensionsStore((s) => s.closeDialog) + const tab = useExtensionsStore((s) => s.tab) + const setTab = useExtensionsStore((s) => s.setTab) + const restartRequired = useExtensionsStore((s) => s.restartRequired) + const install = useExtensionsStore((s) => s.install) + + // Subscribed here, not in the canvas: the dialog is reachable with no project open, and progress + // must still stream. + useEffect(() => { + if (!open) return + return studio().events.onExtensionInstallProgress((e) => { + useExtensionsStore.getState().onProgress(e) + }) + }, [open]) + + // An install started from the Available tab shows its progress there, not on a hidden tab. + useEffect(() => { + if (install?.report || install?.done) setTab('url') + }, [install?.report, install?.done, setTab]) + + if (!open) return null + + return ( + + {restartRequired && ( +
+ Restart Inline Studio to finish applying your changes. Python cannot reload code that is + already running, so an updated or rolled-back extension only takes effect on restart. +
+ )} + +
+ {TABS.map((t) => ( + + ))} +
+ + {tab === 'installed' && } + {tab === 'available' && } + {tab === 'url' && } +
+ ) +} diff --git a/src/renderer/views/Extensions/InstallPanel.tsx b/src/renderer/views/Extensions/InstallPanel.tsx new file mode 100644 index 0000000..2e48be2 --- /dev/null +++ b/src/renderer/views/Extensions/InstallPanel.tsx @@ -0,0 +1,182 @@ +/** Install from a repository URL, with live phase progress and failure detail. */ +import { useState } from 'react' +import { useExtensionsStore } from '../../store/extensionsStore' +import { InstallSteps } from './InstallSteps' +import { SecurityReport } from './SecurityReport' + +export function InstallPanel({ prefill }: { prefill?: string }): React.JSX.Element { + const install = useExtensionsStore((s) => s.install) + const beginInstall = useExtensionsStore((s) => s.beginInstall) + const clearInstall = useExtensionsStore((s) => s.clearInstall) + const canInstall = useExtensionsStore((s) => s.canInstall) + const setTab = useExtensionsStore((s) => s.setTab) + const tools = useExtensionsStore((s) => s.tools) + const [source, setSource] = useState(prefill ?? '') + const [ref, setRef] = useState('main') + + if (!canInstall) { + const missing = tools.filter((t) => !t.available) + return ( +
+

Installing needs a couple of tools

+

+ Inline Studio uses these to download an extension and resolve its dependencies. Install + them, then reopen this dialog. +

+ {missing.map((tool) => ( +
+
+ {tool.name} is not installed +
+ + {tool.hint} + +
+ ))} +
+ ) + } + + // Consent gate takes over the panel: nothing was installed yet. + if (install?.report && (install.report.needsConsent || install.report.blocked)) { + return ( + void beginInstall(install.source, install.ref, rules)} + /> + ) + } + + if (install?.done) { + const { done } = install + return ( +
+
+ + + + + +
+
+ Installed {done.name || done.extensionId} +
+
{done.version}
+

+ {done.nodeTypes.length > 0 + ? `${done.nodeTypes.length} node${done.nodeTypes.length === 1 ? '' : 's'} added to the add-node menu: ${done.nodeTypes.join(', ')}` + : 'No nodes are switched on yet. Enable them under Installed.'} +

+ {done.restartRequired && ( +

+ Restart Inline Studio to finish applying this version. +

+ )} +
+
+ +
+ + +
+
+ ) + } + + const busy = install !== null && install.phase !== 'idle' && !install.error + + return ( +
+
+ + setSource(e.target.value)} + placeholder="https://github.com/author/inline-extension-demo" + spellCheck={false} + className="rounded-md border border-border bg-surface px-2.5 py-1.5 font-mono text-[12px] text-zinc-100 outline-none focus:border-zinc-500" + /> +
+
+
+ + setRef(e.target.value)} + spellCheck={false} + className="rounded-md border border-border bg-surface px-2.5 py-1.5 font-mono text-[12px] text-zinc-100 outline-none focus:border-zinc-500" + /> +
+ +
+ + {busy && ( +
+ +
+ )} + + {install?.error && install.phase !== 'idle' && ( +
+ +
+ )} + + {install?.error && ( +
+
{install.error}
+ {install.conflicts && install.conflicts.length > 0 && ( +
    + {install.conflicts.map((c) => ( +
  • + {c.message} +
  • + ))} +
+ )} + +
+ )} + +

+ The repository is downloaded at the exact commit behind that tag and reviewed before + anything runs. Its dependencies install into the extension's own folder, so they can never + replace Inline's PyTorch or diffusers. +

+
+ ) +} diff --git a/src/renderer/views/Extensions/InstallSteps.test.ts b/src/renderer/views/Extensions/InstallSteps.test.ts new file mode 100644 index 0000000..f718384 --- /dev/null +++ b/src/renderer/views/Extensions/InstallSteps.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import type { InstallPhase } from '@shared/extensions' +import { stateOf } from './installSteps' + +// Index into the STEPS array: 0 fetch, 1 validate, 2 scan, 3 preflight, 4 resolve, 5 lock, +// 6 register, 7 activate. +const FETCH = 0 +const SCAN = 2 +const RESOLVE = 4 +const ACTIVATE = 7 + +describe('install stepper', () => { + it('marks earlier phases done and the current one active', () => { + const seen: InstallPhase[] = ['fetch', 'validate', 'scan'] + expect(stateOf(FETCH, 'scan', seen, false)).toBe('done') + expect(stateOf(SCAN, 'scan', seen, false)).toBe('active') + expect(stateOf(RESOLVE, 'scan', seen, false)).toBe('pending') + }) + + it('marks every step done once the run finishes', () => { + // A fast local install can emit phases quicker than React renders, so completion must not + // depend on having observed each one. + expect(stateOf(FETCH, 'done', [], false)).toBe('done') + expect(stateOf(ACTIVATE, 'done', [], false)).toBe('done') + }) + + it('marks the failing phase failed and leaves later ones pending', () => { + const seen: InstallPhase[] = ['fetch', 'validate', 'scan'] + expect(stateOf(SCAN, 'scan', seen, true)).toBe('failed') + expect(stateOf(FETCH, 'scan', seen, true)).toBe('done') + expect(stateOf(ACTIVATE, 'scan', seen, true)).toBe('pending') + }) + + it('treats an unstarted install as all pending', () => { + expect(stateOf(FETCH, 'idle', [], false)).toBe('pending') + expect(stateOf(ACTIVATE, 'idle', [], false)).toBe('pending') + }) +}) diff --git a/src/renderer/views/Extensions/InstallSteps.tsx b/src/renderer/views/Extensions/InstallSteps.tsx new file mode 100644 index 0000000..7e5c1f9 --- /dev/null +++ b/src/renderer/views/Extensions/InstallSteps.tsx @@ -0,0 +1,89 @@ +/** + * The install stepper: every phase the server reports, so the user can see where a slow install + * is (dependency resolution can take a while) and that it finished. + */ +import type { InstallPhase } from '@shared/extensions' +import { STEPS, stateOf, type StepState } from './installSteps' + +function Marker({ state }: { state: StepState }): React.JSX.Element { + if (state === 'done') { + return ( + + + + + + ) + } + if (state === 'failed') { + return ( + + + + + + + ) + } + if (state === 'active') { + return ( + + + + ) + } + return ( + + + + ) +} + +export function InstallSteps({ + phase, + seen, + status, + failed = false, +}: { + phase: InstallPhase | 'idle' + seen: InstallPhase[] + status: string + failed?: boolean +}): React.JSX.Element { + return ( +
    + {STEPS.map((step, index) => { + const state = stateOf(index, phase, seen, failed) + const tone = + state === 'done' + ? 'text-zinc-400' + : state === 'active' + ? 'text-zinc-100' + : state === 'failed' + ? 'text-red-300' + : 'text-zinc-600' + return ( +
  1. + + {step.label} + {state === 'active' && status && ( + · {status} + )} +
  2. + ) + })} +
+ ) +} diff --git a/src/renderer/views/Extensions/SecurityReport.tsx b/src/renderer/views/Extensions/SecurityReport.tsx new file mode 100644 index 0000000..9e1219f --- /dev/null +++ b/src/renderer/views/Extensions/SecurityReport.tsx @@ -0,0 +1,123 @@ +/** + * The consent gate. Findings grouped by severity in plain language, with a typed confirmation for + * anything needing approval. + * + * The copy is deliberate: an extension runs in the same process as Inline Studio, so a clean scan + * is not a safety guarantee and must never be presented as one. + */ +import { useState } from 'react' +import type { ScanReport, ScanFinding, FindingSeverity } from '@shared/extensions' + +const TONE: Record = { + critical: 'border-red-500/40 bg-red-500/10 text-red-300', + high: 'border-amber-500/40 bg-amber-500/10 text-amber-300', + medium: 'border-amber-500/30 bg-amber-500/5 text-amber-200/90', + low: 'border-border bg-surface/60 text-zinc-400', +} + +const HEADING: Record = { + critical: 'Blocked', + high: 'Needs your approval', + medium: 'Needs your approval', + low: 'For your information', +} + +function FindingRow({ finding }: { finding: ScanFinding }): React.JSX.Element { + return ( +
+
{finding.message}
+ {finding.file && ( +
+ {finding.file} + {finding.line > 0 && `:${finding.line}`} +
+ )} +
+ ) +} + +export function SecurityReport({ + name, + report, + onApprove, + onCancel, +}: { + name: string + report: ScanReport + onApprove: (rules: string[]) => void + onCancel: () => void +}): React.JSX.Element { + const [typed, setTyped] = useState('') + const groups: FindingSeverity[] = ['critical', 'high', 'medium', 'low'] + const confirmed = typed.trim() === name + + return ( +
+
+

+ {report.blocked ? `${name} cannot be installed` : `Review ${name} before installing`} +

+

+ Extensions run with the same access as Inline Studio itself. They can read and write your + files and reach the network. This review flags what the code does; it cannot prevent it. + Only install extensions from authors you trust. +

+
+ + {groups.map((severity) => { + const items = report.findings.filter((f) => f.severity === severity) + if (items.length === 0) return null + return ( +
+

+ {HEADING[severity]} +

+ {items.map((finding, i) => ( + + ))} +
+ ) + })} + + {report.blocked ? ( +
+ +
+ ) : ( +
+ +
+ setTyped(e.target.value)} + placeholder={name} + spellCheck={false} + className="min-w-0 flex-1 rounded-md border border-border bg-surface px-2.5 py-1.5 font-mono text-[12px] text-zinc-100 outline-none focus:border-zinc-500" + /> + + +
+
+ )} +
+ ) +} diff --git a/src/renderer/views/Extensions/installSteps.ts b/src/renderer/views/Extensions/installSteps.ts new file mode 100644 index 0000000..0d7cbba --- /dev/null +++ b/src/renderer/views/Extensions/installSteps.ts @@ -0,0 +1,32 @@ +/** Phase ordering and per-step state for the install stepper. */ +import type { InstallPhase } from '@shared/extensions' + +/** The phases the installer actually emits, in order. `install` is folded into `resolve`. */ +export const STEPS: { phase: InstallPhase; label: string }[] = [ + { phase: 'fetch', label: 'Download the repository' }, + { phase: 'validate', label: 'Check the manifest' }, + { phase: 'scan', label: 'Review the code' }, + { phase: 'preflight', label: 'Check for conflicts' }, + { phase: 'resolve', label: 'Resolve dependencies' }, + { phase: 'lock', label: 'Record the install' }, + { phase: 'register', label: 'Load nodes' }, + { phase: 'activate', label: 'Activate' }, +] + +export type StepState = 'done' | 'active' | 'failed' | 'pending' + +export function stateOf( + index: number, + current: InstallPhase | 'idle', + seen: InstallPhase[], + failed: boolean, +): StepState { + const step = STEPS[index]!.phase + const currentIndex = STEPS.findIndex((s) => s.phase === current) + if (failed && step === current) return 'failed' + // `done` means the whole run finished, so every step before it is complete. + if (current === 'done') return 'done' + if (seen.includes(step) && currentIndex > index) return 'done' + if (step === current) return 'active' + return currentIndex > index ? 'done' : 'pending' +} diff --git a/src/renderer/views/Moodboard/AddNodeMenu.tsx b/src/renderer/views/Moodboard/AddNodeMenu.tsx index 4ab2df4..d956215 100644 --- a/src/renderer/views/Moodboard/AddNodeMenu.tsx +++ b/src/renderer/views/Moodboard/AddNodeMenu.tsx @@ -6,6 +6,7 @@ */ import { addableCoreNodes, type NodeDescriptor } from '@shared/coreNodes' +import { isExtensionNode, extensionOf } from '@shared/extensions' import { listNodeDefs, groupByOwner } from '@shared/nodes/registry' /** The node kinds the Add menu can create (Text has its own toolbar tool, so it's not here). */ @@ -53,7 +54,10 @@ export function AddNodeMenu({ onClose: () => void }): React.JSX.Element { // Only high-level model nodes are offered; loaders/samplers/inputs are hidden plumbing. - const addable = addableCoreNodes(coreNodes) + const all = addableCoreNodes(coreNodes) + // Extension nodes get their own section so it's clear which are community-provided. + const addable = all.filter((n) => !isExtensionNode(n.source)) + const extensionGroups = groupByExtension(all.filter((n) => isExtensionNode(n.source))) // Fal models, grouped by owner (OpenAI, ByteDance, …) - listed like the Inline Core section. const falGroups = groupByOwner(listNodeDefs()) return ( @@ -134,12 +138,50 @@ export function AddNodeMenu({ ))} )} + {extensionGroups.length > 0 && ( +
+
+ Extensions +
+ {extensionGroups.map(([extension, nodes]) => ( +
+
+ {extension} +
+ {nodes.map((n) => ( + + ))} +
+ ))} +
+ )} ) } +/** Extension nodes keyed by their owning extension, from the `ext::` source. */ +function groupByExtension(nodes: NodeDescriptor[]): Array<[string, NodeDescriptor[]]> { + const groups = new Map() + for (const node of nodes) { + const extension = extensionOf(node.source) ?? 'extension' + const list = groups.get(extension) ?? [] + list.push(node) + groups.set(extension, list) + } + return [...groups.entries()] +} + function groupByCategory(nodes: NodeDescriptor[]): Array<[string, NodeDescriptor[]]> { const groups = new Map() for (const node of nodes) { diff --git a/src/renderer/views/Moodboard/nodes/GraphNode.tsx b/src/renderer/views/Moodboard/nodes/GraphNode.tsx index 2855b64..64c25aa 100644 --- a/src/renderer/views/Moodboard/nodes/GraphNode.tsx +++ b/src/renderer/views/Moodboard/nodes/GraphNode.tsx @@ -6,6 +6,7 @@ import { useGenerationStore } from '../../../store/generationStore' import { useGraphSelectionStore } from '../../../store/graphSelectionStore' import { useMoodboardStore } from '../../../store/moodboardStore' import { activeDownload, useModelRequirementsStore } from '../../../store/modelRequirementsStore' +import { useExtensionsStore } from '../../../store/extensionsStore' import { NodeFrame } from './NodeFrame' import { NodeRunToolbar } from './NodeRunToolbar' import { @@ -124,11 +125,35 @@ export function GraphNode({ id, data, selected }: NodeProps): React.JSX.Element if (coreType) void loadReqs(coreType) }, [coreType, registryVersion, loadReqs]) + // An installed-but-disabled extension that declares this node type, so the fallback card can say + // "turn it back on" instead of the generic "not registered". + const disabledPack = useExtensionsStore((s) => + coreType + ? (s.extensions.find((e) => e.nodes.some((n) => n.type === coreType))?.name ?? null) + : null, + ) + const openExtensions = useExtensionsStore((s) => s.openDialog) + if (!item || item.type !== 'core' || !item.data.core || !descriptor) { return (
- {coreType ? ( + {coreType && disabledPack ? ( + // The extension is installed but off, so this is a toggle away from working. + <> + Extension disabled + + {coreType} comes from{' '} + {disabledPack}, which is turned off. + + + + ) : coreType ? ( <> Node unavailable diff --git a/src/renderer/views/Settings/SettingsPanel.tsx b/src/renderer/views/Settings/SettingsPanel.tsx index 24326a6..040b164 100644 --- a/src/renderer/views/Settings/SettingsPanel.tsx +++ b/src/renderer/views/Settings/SettingsPanel.tsx @@ -1,6 +1,7 @@ import { FalKeyField } from '../../components/FalKeyField' import { CloseIcon, SettingsIcon } from '../../components/icons' import { useCanvasPrefsStore, type EdgeStyle } from '../../store/canvasPrefsStore' +import { useExtensionsStore } from '../../store/extensionsStore' /** * Right-hand Settings sidebar, opened from the workspace header's gear button. Holds @@ -31,11 +32,57 @@ export function SettingsPanel({ onClose }: { onClose: () => void }): React.JSX.E
+
+ +
+
+ + ) +} + +/** Opens the Extensions dialog, with a count of what is installed. */ +function ExtensionsField(): React.JSX.Element { + const openDialog = useExtensionsStore((s) => s.openDialog) + const extensions = useExtensionsStore((s) => s.extensions) + const enabled = extensions.filter((p) => p.enabled).length + + return ( +
+
+ Extensions + + {extensions.length === 0 + ? 'Add community nodes to the canvas.' + : `${enabled} of ${extensions.length} enabled.`} +
+
) } +function PuzzleIcon({ className }: { className?: string }): React.JSX.Element { + return ( + + + + ) +} + /** Picks how connectors between nodes are drawn; applies to every edge live. */ function ConnectionStyleField(): React.JSX.Element { const edgeStyle = useCanvasPrefsStore((s) => s.edgeStyle) diff --git a/src/renderer/views/Workspace/Workspace.tsx b/src/renderer/views/Workspace/Workspace.tsx index bb3b864..0ad1a5e 100644 --- a/src/renderer/views/Workspace/Workspace.tsx +++ b/src/renderer/views/Workspace/Workspace.tsx @@ -9,6 +9,7 @@ import { useUiStore, type WorkspaceMode } from '../../store/uiStore' import { MoodboardPanel } from '../Moodboard/MoodboardPanel' import { GeneratePanel } from '../Generate/GeneratePanel' import { SettingsPanel } from '../Settings/SettingsPanel' +import { ExtensionsDialog } from '../Extensions/ExtensionsDialog' import { ContextMenu } from '../../components/ContextMenu' import { MediaLightbox } from '../../components/MediaLightbox' @@ -91,6 +92,7 @@ export function Workspace({ project }: { project: Project }): React.JSX.Element + ) } diff --git a/src/shared/coreNodes.test.ts b/src/shared/coreNodes.test.ts index 28f33df..affd459 100644 --- a/src/shared/coreNodes.test.ts +++ b/src/shared/coreNodes.test.ts @@ -31,7 +31,7 @@ describe('kind helpers', () => { expect(isModelPort('model')).toBe(true) expect(isModelPort('vae')).toBe(true) expect(isModelPort('text-encoder')).toBe(true) - // Signal/content kinds - including the other engine kinds - pack to the top instead. + // Signal/content kinds - including the other engine kinds - extension to the top instead. expect(isModelPort('conditioning')).toBe(false) expect(isModelPort('latent')).toBe(false) expect(isModelPort('image')).toBe(false) diff --git a/src/shared/coreNodes.ts b/src/shared/coreNodes.ts index f9443ae..e48a040 100644 --- a/src/shared/coreNodes.ts +++ b/src/shared/coreNodes.ts @@ -113,8 +113,8 @@ const MODEL_KINDS: readonly PortKind[] = ['model', 'vae', 'text-encoder'] /** * A "model-family" handle - the loader plumbing (diffusion model, VAE, text encoder) that threads a - * component into a node. On the canvas these dots pack to the **bottom** edge of a node while the - * content/signal dots (image, latent, conditioning, …) pack to the **top**, so model wiring reads as + * component into a node. On the canvas these dots extension to the **bottom** edge of a node while the + * content/signal dots (image, latent, conditioning, …) extension to the **top**, so model wiring reads as * one band along the bottom and the actual image flow runs across the top. */ export function isModelPort(kind: PortKind): boolean { diff --git a/src/shared/extensions.ts b/src/shared/extensions.ts new file mode 100644 index 0000000..4f8a978 --- /dev/null +++ b/src/shared/extensions.ts @@ -0,0 +1,191 @@ +/** + * Community extensions: the wire shapes for the `extensions:*` channels. + * + * Mirrors the Python dataclasses in `inline_core/extensions/`. An extension extension is one repo at one + * pinned commit, holding module-extensions that can be toggled independently. + */ + +/** How severely a scan finding gates the install. */ +export type FindingSeverity = 'critical' | 'high' | 'medium' | 'low' + +export interface ScanFinding { + severity: FindingSeverity + /** Stable rule id, e.g. `subprocess`. Consent is recorded per rule. */ + rule: string + message: string + file: string + line: number +} + +export interface ScanReport { + /** A CRITICAL finding: the install is refused and cannot be overridden. */ + blocked: boolean + /** HIGH/MEDIUM findings the user must explicitly accept. */ + needsConsent: boolean + consentRules: string[] + findings: ScanFinding[] +} + +/** A dependency the extension wants that the host already owns at another version. */ +export interface DependencyConflict { + name: string + hostVersion: string + wanted: string + /** Part of the shared ML runtime (torch, diffusers, …) - never replaceable. */ + protected: boolean + message: string +} + +/** One node an extension provides, and whether the user has it switched on. */ +export interface ExtensionNodeInfo { + type: string + title: string + enabled: boolean +} + +export interface ExtensionInfo { + extensionId: string + name: string + description: string + version: string + license: string + /** The URL it was installed from, for the repository link and the update check. */ + repo: string + /** The tag or branch asked for, and the commit it resolved to. */ + ref: string + sha: string + /** The installed version directory, e.g. `1.4.0+9f2c1ab`. */ + installed: string + enabled: boolean + /** Every version kept on disk, newest first - the rollback menu. */ + versions: string[] + homepage: string + nodes: ExtensionNodeInfo[] +} + +/** An external tool the installer needs (`git`, `uv`). */ +export interface ExtensionTool { + name: string + available: boolean + version: string | null + /** Platform-specific install command, shown when `available` is false. */ + hint: string +} + +export interface ExtensionsStatus { + canInstall: boolean + tools: ExtensionTool[] + extensions: ExtensionInfo[] +} + +/** One entry in the published registry index. */ +export interface RegistryEntry { + id: string + name: string + description: string + repo: string + homepage?: string + author?: string + tags?: string[] + /** Rarely set: freezes a listing to one tag. Normally absent, and the newest tag wins. */ + pin?: string +} + +/** Whether an extension's ref has moved upstream since it was installed. */ +export interface UpdateStatus { + extensionId: string + ref: string + installedSha: string + remoteSha: string | null + /** The newest release tag upstream, or null when the repo has none. */ + latestTag: string | null + behind: boolean + /** False when the remote could not be reached; the card shows nothing rather than "up to date". */ + checked: boolean +} + +export interface RegistryIndex { + entries: RegistryEntry[] + /** True when the fetch failed and these entries came from the on-disk cache. */ + stale: boolean +} + +export type InstallPhase = + | 'fetch' + | 'validate' + | 'scan' + | 'preflight' + | 'resolve' + | 'install' + | 'lock' + | 'register' + | 'activate' + | 'done' + +export interface InstallProgressEvent { + phase: InstallPhase + fraction: number + status: string +} + +/** A successful (or consent-paused) install. */ +export interface InstallSuccess { + ok: true + extensionId: string + version: string + name: string + nodeTypes: string[] + scan: ScanReport | null + /** The extension was already imported this session, so the new code is not live until a restart. */ + restartRequired: boolean + /** Paused for consent: nothing was installed. Re-call with the report's `consentRules`. */ + needsConsent: boolean +} + +export interface InstallFailure { + ok: false + error: string + phase: InstallPhase + conflicts: DependencyConflict[] + scan: ScanReport | null + restartRequired: boolean +} + +export type InstallOutcome = InstallSuccess | InstallFailure + +export interface LifecycleResult { + extensionId: string + restartRequired: boolean +} + +/** Human-readable label for an install phase, for the progress line. */ +export const PHASE_LABELS: Record = { + fetch: 'Downloading', + validate: 'Checking the manifest', + scan: 'Reviewing the code', + preflight: 'Checking for conflicts', + resolve: 'Resolving dependencies', + install: 'Installing dependencies', + lock: 'Recording the install', + register: 'Loading nodes', + activate: 'Activating', + done: 'Installed', +} + +export const SEVERITY_LABELS: Record = { + critical: 'Blocked', + high: 'Needs your approval', + medium: 'Needs your approval', + low: 'For your information', +} + +/** True when this node type came from an extension rather than Core. */ +export function isExtensionNode(source: string): boolean { + return source.startsWith('ext:') +} + +/** The owning extension id of an `ext::` source, or null. */ +export function extensionOf(source: string): string | null { + const parts = source.split(':') + return parts[0] === 'ext' && parts[1] ? parts[1] : null +} diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index cf19f77..2f5f480 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -43,6 +43,17 @@ import type { } from './types' import type { Result } from './result' import type { CoreModels, ModelRequirements } from './coreNodes' +import type { + ExtensionInfo, + ExtensionsStatus, + InstallFailure, + InstallOutcome, + InstallProgressEvent, + InstallSuccess, + LifecycleResult, + RegistryIndex, + UpdateStatus, +} from './extensions' export const IpcChannels = { project: { @@ -136,6 +147,22 @@ export const IpcChannels = { /** Explicitly download one component (by id) or `'all'` missing ones into models/. */ download: 'models:download', }, + extensions: { + /** Installed extensions + whether the machine has the tools to install more. */ + status: 'ext:manage:status', + list: 'ext:manage:list', + /** Install from a git URL at a tag/branch/sha. Returns a consent pause or an outcome. */ + install: 'ext:manage:install', + uninstall: 'ext:manage:uninstall', + setEnabled: 'ext:manage:setEnabled', + setNodeEnabled: 'ext:manage:setNodeEnabled', + versions: 'ext:manage:versions', + /** Roll back (or forward) to an already-installed version. Always needs a restart. */ + switchVersion: 'ext:manage:switchVersion', + /** The published registry index, cached on disk so it works offline. */ + checkUpdates: 'ext:manage:checkUpdates', + registryIndex: 'ext:manage:registryIndex', + }, export: { exportFrames: 'export:exportFrames', }, @@ -202,6 +229,10 @@ export const IpcChannels = { modelDownloadProgress: 'events:modelDownloadProgress', modelDownloadDone: 'events:modelDownloadDone', modelDownloadError: 'events:modelDownloadError', + /** Main → renderer: extension install lifecycle (the Extensions dialog). */ + extensionInstallProgress: 'events:extensionInstallProgress', + extensionInstallDone: 'events:extensionInstallDone', + extensionInstallError: 'events:extensionInstallError', /** Main → renderer: auto-update lifecycle. */ updateAvailable: 'events:updateAvailable', updateProgress: 'events:updateProgress', @@ -406,6 +437,33 @@ export interface InlineStudioApi { * progress arrives on `events:modelDownload*`. */ download(nodeType: string, componentId: string): Promise> } + extensions: { + /** Installed extensions + whether git/uv are available to install more. */ + status(): Promise> + list(): Promise> + /** + * Install from a git URL at `ref` - a tag, a branch, or `latest` to resolve the newest + * release tag. Returns `needsConsent` (nothing installed) when the scan + * raised HIGH/MEDIUM findings; re-call with the report's `consentRules` to proceed. + * Progress arrives on `events:extensionInstall*`. + */ + install(source: string, ref?: string, consents?: string[]): Promise> + uninstall(extensionId: string): Promise> + setEnabled(extensionId: string, enabled: boolean): Promise> + setNodeEnabled( + extensionId: string, + nodeType: string, + enabled: boolean, + ): Promise> + versions( + extensionId: string, + ): Promise> + /** Point a extension at an already-installed version. Needs a restart to take effect. */ + switchVersion(extensionId: string, version: string): Promise> + /** Network-bound: asks each origin what its ref points at now. Best-effort. */ + checkUpdates(): Promise> + registryIndex(refresh?: boolean): Promise> + } export: { /** Pick a folder and write each frame's Output in order; null if cancelled. */ exportFrames(): Promise> @@ -509,6 +567,9 @@ export interface InlineStudioApi { onModelDownloadProgress(callback: (e: ModelDownloadProgressEvent) => void): () => void onModelDownloadDone(callback: (e: ModelDownloadDoneEvent) => void): () => void onModelDownloadError(callback: (e: ModelDownloadErrorEvent) => void): () => void + onExtensionInstallProgress(callback: (e: InstallProgressEvent) => void): () => void + onExtensionInstallDone(callback: (e: InstallSuccess) => void): () => void + onExtensionInstallError(callback: (e: InstallFailure) => void): () => void /** Subscribe to auto-update lifecycle pushes. Each returns an unsubscribe fn. */ onUpdateAvailable(callback: (e: UpdateAvailableEvent) => void): () => void onUpdateProgress(callback: (e: UpdateProgressEvent) => void): () => void diff --git a/src/shared/nodes/gptImage2.test.ts b/src/shared/nodes/gptImage2.test.ts index a23958d..9c5b717 100644 --- a/src/shared/nodes/gptImage2.test.ts +++ b/src/shared/nodes/gptImage2.test.ts @@ -11,7 +11,7 @@ describe('GPT_IMAGE_2.resolveEndpoint', () => { expect(GPT_IMAGE_2.resolveEndpoint(emptyResolvedInputs())).toBe('openai/gpt-image-2') }) - it('uses the same base endpoint when images are wired (editing via image_urls, no sub-path)', () => { + it('uses the same base endpoint when images are wired (editing via image_urls, no module-path)', () => { expect(GPT_IMAGE_2.resolveEndpoint(withImages(['https://fal/img.png']))).toBe( 'openai/gpt-image-2', ) diff --git a/src/shared/nodes/gptImage2.ts b/src/shared/nodes/gptImage2.ts index a48e7f9..ec34d44 100644 --- a/src/shared/nodes/gptImage2.ts +++ b/src/shared/nodes/gptImage2.ts @@ -18,7 +18,7 @@ const SIZE_PRESETS = [ // only for fal's own models). Prefixing with fal-ai/ makes the queue 404 ("Application openai not found"). // // There is a SINGLE endpoint: passing `image_urls` switches it to image-editing mode. There is no -// `/image-to-image` sub-path - submitting to one 404s on the queue result fetch. +// `/image-to-image` module-path - submitting to one 404s on the queue result fetch. // See https://fal.ai/models/openai/gpt-image-2/api. const ENDPOINT = 'openai/gpt-image-2' diff --git a/src/shared/types.ts b/src/shared/types.ts index ef8936f..21a7e28 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -363,6 +363,13 @@ export interface ModelDownloadErrorEvent { error: string } +/** Main → renderer: extension install progress. Payload shapes live in `extensions.ts`. */ +export type { + InstallProgressEvent as ExtensionInstallProgressEvent, + InstallSuccess as ExtensionInstallDoneEvent, + InstallFailure as ExtensionInstallErrorEvent, +} from './extensions' + export interface WorkflowTemplate { id: string projectId: string