From 039095d9ba4428dfc99c36a5ee9e4fd9532ff040 Mon Sep 17 00:00:00 2001 From: HeiTuz <79418013+HeiTuz@users.noreply.github.com> Date: Tue, 21 Jul 2026 06:01:15 +0900 Subject: [PATCH 1/3] feat: add durable browser generation and QC controls --- README.md | 39 +- backend/__init__.py | 117 +++++ backend/plugin.yaml | 7 + dashboard/plugin_api.py | 463 +++++++++++++++- docs/midjourney-web-bridge-api.md | 118 +++++ package.json | 2 +- plugin.js | 818 +++++++++++++++++++++++++++-- scripts/higgsfield-control.mjs | 197 +++++++ scripts/install.mjs | 269 +++++++--- scripts/lib.mjs | 53 +- scripts/midjourney-control.mjs | 162 ++++++ scripts/qc-core.mjs | 226 ++++++-- tests/higgsfield-control.test.mjs | 127 +++++ tests/install.test.mjs | 161 +++++- tests/midjourney-control.test.mjs | 47 ++ tests/packed-install.test.mjs | 185 +++++++ tests/plugin-runtime-core.test.mjs | 197 ++++++- tests/qc-core.test.mjs | 167 +++++- tests/redteam-plugin.test.mjs | 45 ++ tests/test_plugin_api_compat.py | 51 ++ tests/test_plugin_api_ledger.py | 219 ++++++++ tests/test_plugin_api_websocket.py | 56 ++ tests/test_redteam_dashboard.py | 210 ++++++++ 23 files changed, 3738 insertions(+), 198 deletions(-) create mode 100644 backend/__init__.py create mode 100644 backend/plugin.yaml create mode 100644 docs/midjourney-web-bridge-api.md create mode 100644 scripts/higgsfield-control.mjs create mode 100644 scripts/midjourney-control.mjs create mode 100644 tests/higgsfield-control.test.mjs create mode 100644 tests/midjourney-control.test.mjs create mode 100644 tests/packed-install.test.mjs create mode 100644 tests/redteam-plugin.test.mjs create mode 100644 tests/test_plugin_api_compat.py create mode 100644 tests/test_plugin_api_ledger.py create mode 100644 tests/test_plugin_api_websocket.py create mode 100644 tests/test_redteam_dashboard.py diff --git a/README.md b/README.md index f520522..bfff781 100644 --- a/README.md +++ b/README.md @@ -53,15 +53,30 @@ The installer writes: ```text $HERMES_HOME/desktop-plugins/visual-workbench/plugin.js $HERMES_HOME/skills/midjourney-visual-workbench/SKILL.md +$HERMES_HOME/plugins/visual-workbench/plugin.yaml +$HERMES_HOME/plugins/visual-workbench/__init__.py +$HERMES_HOME/plugins/visual-workbench/dashboard/manifest.json +$HERMES_HOME/plugins/visual-workbench/dashboard/plugin_api.py ``` When `HERMES_HOME` is unset, it uses `~/.hermes`. -Hermes Desktop watches this directory and normally hot-loads the plugin. If it does not appear, open the command palette and run **Reload desktop plugins**. +Hermes Desktop watches the desktop-plugin directory and normally hot-loads the JavaScript plugin. The Python backend/dashboard files require a backend restart; the installer prints the matching `hermes plugins enable visual-workbench` reminder. ### Update -Run the install command again. Changed plugin and skill files are backed up separately before replacement. The install marker records both hashes. +Run the install command again. Changed managed files and the prior marker are backed up with one transaction stamp before replacement; unchanged files remain part of that same prior state. + +### Rollback + +Preview or restore the exact newest update transaction: + +```bash +npx --yes github:HeiTuz/hermes-visual-workbench -- --rollback --dry-run +npx --yes github:HeiTuz/hermes-visual-workbench -- --rollback +``` + +Rollback restores only files changed by that transaction, keeps unchanged managed files, removes files that did not exist in the prior marker, and restores the prior marker bytes. It never combines per-file backups from different updates. ### Uninstall @@ -69,7 +84,7 @@ Run the install command again. Changed plugin and skill files are backed up sepa npx --yes github:HeiTuz/hermes-visual-workbench -- --uninstall ``` -The uninstaller refuses to delete either managed file when its hash changed. It pins deletion to the expected plugin and skill paths instead of trusting paths stored in the marker. Use `--force` only when you intentionally want to remove local modifications. +The uninstaller refuses to delete any of the six managed files when its hash changed. It pins deletion to the expected plugin, skill, backend, and dashboard paths instead of trusting paths stored in the marker. Use `--force` only when you intentionally want to remove local modifications. ### Custom Hermes home or test target @@ -78,7 +93,7 @@ npx --yes github:HeiTuz/hermes-visual-workbench -- --hermes-home /path/to/.herme npx --yes github:HeiTuz/hermes-visual-workbench -- --target /tmp/visual-workbench --skill-target /tmp/midjourney-skill ``` -`--target` and `--skill-target` are a required pair so a test install cannot accidentally write the skill into the real Hermes home. Installation preflights regular files, rejects managed symlinks, and rolls back earlier writes if a later managed write fails. +`--target` and `--skill-target` are a required pair so a test install cannot accidentally write the skill into the real Hermes home. Installation preflights every managed destination, backup, temporary file, marker, and ancestor; rejects containment escapes and symlinks; and rolls back earlier writes and directory creation if a later operation fails. ## Hermes compatibility @@ -100,7 +115,7 @@ Until the upstream capability PR is merged, use a Hermes Desktop build containin The plugin owns workflow and presentation. Hermes core remains authoritative for privileged guest creation, navigation filtering, popup denial, permission handling, attachment handling, and PNG capture bounds. -The plugin does not ship a Python backend and does not copy credentials. +The package ships a bounded Python backend/dashboard bridge for local control and result receipts. It creates its own mode-`0600` local control token under the plugin directory and never copies external provider credentials. It uses the existing `persist:hermes-browser` partition as-is and never reads, exports, deletes, or migrates its cookies. Midjourney submit, upscale, and variation remain agent approval gates: the plugin displays state and QC but never clicks those controls itself. @@ -108,6 +123,20 @@ Midjourney automation is pinned to the Browser pane inside the Hermes Desktop wi The Browser pane displays a visible **Automation target** affordance. With the privileged guest active it reads `Automation target · Hermes internal Browser pane · persist:hermes-browser`; in iframe fallback it reads `Automation target unavailable`, and the packaged workflow hard-stops as `internal_pane_unavailable` instead of retargeting any external browser or isolated `browser_*` session. Agents must re-verify this affordance from a fresh `app="Hermes"` capture immediately before every pointer, focus, or type action. +## Higgsfield CLI provenance bridge + +Higgsfield results can be brought into QC from the authenticated `higgsfield` CLI instead of an MCP tool card. `scripts/higgsfield-control.mjs` is a strictly read-only bridge: it maps only observation subcommands (`account status`, `generate list`, `soul-id list`, `model list`, `generate get`) and has no argv passthrough, so paid or mutating subcommands (`generate create`, `soul-id train`, `upload`, `publish`, `auth token`, ...) are unreachable by construction. + +```bash +# Normalize one existing job into Visual Workbench provider evidence (signed query stripped) +node scripts/higgsfield-control.mjs evidence --url "" +node scripts/higgsfield-control.mjs evidence --job-id "" +``` + +The agent binds that provenance into QC over the command socket with `set-target { url, providerEvidence }` followed by `link { profileId: "higgsfield-image" }`. `set-target` accepts `providerEvidence` only when it normalizes to a recognized provider and its result URL path matches the target URL; the URL is sanitized before storage, so no signed query survives. + +The bridge never uses the `hf` alias, which on many hosts resolves to the HuggingFace CLI; it always invokes `higgsfield` (override with `HIGGSFIELD_BIN`). No generation is ever triggered from this bridge. + ## Non-billable fixture E2E Create a complete local artifact job without a Midjourney submission: diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000..828241f --- /dev/null +++ b/backend/__init__.py @@ -0,0 +1,117 @@ +"""Visual Workbench local bearer-token provider for its bounded control API.""" +from __future__ import annotations + +import hmac +import os +import secrets +import stat +from pathlib import Path +from typing import Optional + +from hermes_cli.dashboard_auth.base import ( + DashboardAuthProvider, + LoginStart, + Session, + TokenPrincipal, +) + +_PLUGIN_NAME = "visual-workbench" +_TOKEN_FILE = "control.token" +_TOKEN_ROUTES = ( + "/api/plugins/visual-workbench/command", + "/api/plugins/visual-workbench/control/result", +) + + +def _plugin_dir() -> Path: + from hermes_constants import get_hermes_home + + return Path(get_hermes_home()) / "plugins" / _PLUGIN_NAME + + +def _read_or_create_token() -> str: + directory = _plugin_dir() + directory.mkdir(mode=0o700, parents=True, exist_ok=True) + token_path = directory / _TOKEN_FILE + try: + fd = os.open(token_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + except FileExistsError: + info = token_path.lstat() + if not stat.S_ISREG(info.st_mode) or stat.S_ISLNK(info.st_mode): + raise RuntimeError(f"Visual Workbench control token must be a regular file: {token_path}") + if info.st_mode & 0o077: + raise RuntimeError(f"Visual Workbench control token permissions are too broad: {token_path}") + token = token_path.read_text(encoding="utf-8").strip() + if len(token) < 43: + raise RuntimeError("Visual Workbench control token is missing or too short") + return token + + try: + token = secrets.token_urlsafe(48) + os.write(fd, f"{token}\n".encode("utf-8")) + os.fsync(fd) + finally: + os.close(fd) + return token + + +class VisualWorkbenchTokenProvider(DashboardAuthProvider): + name = "visual-workbench-local" + display_name = "Visual Workbench local control" + supports_token = True + supports_session = False + + def __init__(self, token: str) -> None: + if len(token) < 43: + raise ValueError("Visual Workbench control token must contain at least 256 bits") + self._token = token + + def verify_token(self, *, token: str) -> Optional[TokenPrincipal]: + if token and hmac.compare_digest(token.encode("utf-8"), self._token.encode("utf-8")): + return TokenPrincipal( + principal="visual-workbench-local-control", + provider=self.name, + scopes=("visual-workbench-control",), + ) + return None + + def start_login(self, *, redirect_uri: str) -> LoginStart: + raise NotImplementedError("Visual Workbench local control has no interactive login flow") + + def complete_login( + self, *, code: str, state: str, code_verifier: str, redirect_uri: str + ) -> Session: + raise NotImplementedError("Visual Workbench local control has no interactive login flow") + + def verify_session(self, *, access_token: str) -> Optional[Session]: + return None + + def refresh_session(self, *, refresh_token: str) -> Session: + raise NotImplementedError("Visual Workbench local control has no interactive login flow") + + def revoke_session(self, *, refresh_token: str) -> None: + return None + + +def _host_auth_supported() -> bool: + try: + from hermes_cli import __version__ + from hermes_cli import web_server + parts = tuple(int(part) for part in __version__.split(".")[:3]) + except Exception: + return False + # Capability-gated: no hard upper bound. Support any host at or above the floor + # that still exposes a callable token-auth capability; a missing capability or a + # below-floor host fails closed so a breaking version bump is caught, not assumed. + return parts >= (0, 18, 2) and callable(getattr(web_server, "_ws_auth_ok", None)) + +def register(ctx) -> None: + if not _host_auth_supported(): + return + token = _read_or_create_token() + ctx.register_dashboard_auth_provider(VisualWorkbenchTokenProvider(token)) + + from hermes_cli.dashboard_auth.token_auth import register_token_route + + for path in _TOKEN_ROUTES: + register_token_route(path) diff --git a/backend/plugin.yaml b/backend/plugin.yaml new file mode 100644 index 0000000..e98beb2 --- /dev/null +++ b/backend/plugin.yaml @@ -0,0 +1,7 @@ +manifest_version: 1 +name: visual-workbench +version: 0.6.0 +kind: standalone +host_version: ">=0.18.2" +description: "Local Visual Workbench dashboard bridge for browser capture, QC, and bounded Midjourney control." +author: "Yuna" diff --git a/dashboard/plugin_api.py b/dashboard/plugin_api.py index 31d8ab2..3dc00f0 100644 --- a/dashboard/plugin_api.py +++ b/dashboard/plugin_api.py @@ -6,8 +6,18 @@ from __future__ import annotations import asyncio +import fcntl +import hashlib +import json +import os +import secrets +import stat from collections import deque -from typing import Any +from contextlib import contextmanager +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Iterator +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit from fastapi import APIRouter, HTTPException, Request, WebSocket, WebSocketDisconnect, status as http_status from fastapi.responses import JSONResponse @@ -15,16 +25,9 @@ router = APIRouter() _COMMAND_OPS = { - "status", - "set-target", - "link", - "capture", - "inspect", - "page-checks", - "set-check", - "score-candidate", - "select-candidate", - "import-qc", + "status", "set-target", "link", "capture", "inspect", "page-checks", + "midjourney-probe", "midjourney-control", "set-check", "score-candidate", + "select-candidate", "import-qc", } _COMMAND_QUEUE: deque[dict[str, Any]] = deque(maxlen=200) _RESULTS: dict[str, dict[str, Any]] = {} @@ -32,17 +35,389 @@ _LATEST_STATE: dict[str, Any] | None = None _COMMAND_CLIENTS: set[WebSocket] = set() _COMMAND_LOCK = asyncio.Lock() +_BILLABLE_LEDGER_LOCK = asyncio.Lock() +_BILLABLE_ACTIONS = {"upscale", "vary", "reroll", "pan", "zoom"} +_BILLABLE_LEDGER_FILE = "midjourney-billable-ledger.json" +_BILLABLE_LEDGER_LOCK_FILE = ".midjourney-billable-ledger.lock" +_BILLABLE_LEDGER_LIMIT = 256 -def _ws_upgrade_authorized(ws: WebSocket) -> bool: - """Delegate WebSocket authentication to Hermes' canonical auth gate.""" +class _BillableLedgerError(Exception): + """Internal error whose public response must not expose local details.""" + + +class LedgerUnavailableError(_BillableLedgerError): + """The durable ledger cannot safely participate in a billable request.""" + + +def _canonical_plugin_dir() -> Path: try: - from hermes_cli import web_server as _ws + from hermes_constants import get_hermes_home + home = Path(get_hermes_home()) except Exception: - return True + home = Path(os.environ.get("HERMES_HOME", "~/.hermes")) + return home.expanduser().resolve(strict=False) / "plugins" / "visual-workbench" + + +def _safe_ledger_file(plugin_dir: Path) -> Path: + try: + plugin_dir.mkdir(mode=0o700, parents=True, exist_ok=True) + directory_info = plugin_dir.lstat() + except OSError as exc: + raise _BillableLedgerError() from exc + if stat.S_ISLNK(directory_info.st_mode) or not stat.S_ISDIR(directory_info.st_mode): + raise _BillableLedgerError() + # Resolve after rejecting the configured plugin directory itself; its canonical target must also be a directory. + canonical = Path(os.path.realpath(os.fspath(plugin_dir))) + try: + canonical_info = canonical.lstat() + except OSError as exc: + raise _BillableLedgerError() from exc + if stat.S_ISLNK(canonical_info.st_mode) or not stat.S_ISDIR(canonical_info.st_mode): + raise _BillableLedgerError() + return canonical / _BILLABLE_LEDGER_FILE + + +def _ledger_entry_valid(entry: dict[str, Any]) -> bool: + fields = { + "operationId", "action", "scope", "idempotencyKeyHash", "requestFingerprint", + "targetFingerprint", "createdAt", "updatedAt", "status", + } + return ( + isinstance(entry, dict) and set(entry) == fields + and all(isinstance(entry[key], str) for key in fields) + and entry["action"] in {"submit", "higgsfield-generate", *_BILLABLE_ACTIONS} + and entry["status"] in {"reserved", "acknowledged"} + and all(len(entry[key]) == 64 for key in ("operationId", "idempotencyKeyHash", "requestFingerprint", "targetFingerprint")) + ) + + +def _v2_payload_valid(payload: dict[str, Any]) -> bool: + if set(payload) != {"version", "entries", "receiptContexts", "contextSummaries"} or payload["version"] != 2: + return False + contexts, summaries = payload["receiptContexts"], payload["contextSummaries"] + return ( + isinstance(payload["entries"], list) and all(_ledger_entry_valid(entry) for entry in payload["entries"]) + and isinstance(contexts, dict) and isinstance(summaries, dict) + and all(isinstance(key, str) and len(key) == 64 and isinstance(value, dict) + and set(value) == {"batchContextId", "expiresAt"} + and isinstance(value["batchContextId"], str) and isinstance(value["expiresAt"], str) + for key, value in contexts.items()) + and all(isinstance(key, str) and isinstance(value, dict) + and set(value) == {"batchFingerprint", "consumedCount"} + and isinstance(value["batchFingerprint"], str) and len(value["batchFingerprint"]) == 64 + and isinstance(value["consumedCount"], int) and 0 <= value["consumedCount"] <= 3 + for key, value in summaries.items()) + ) + + +def _decode_v1_payload(payload: dict[str, Any]) -> dict[str, Any]: + if set(payload) != {"version", "entries"} or payload["version"] != 1 or not isinstance(payload["entries"], list): + raise _BillableLedgerError() + converted = {"version": 2, "entries": payload["entries"], "receiptContexts": {}, "contextSummaries": {}} + if not _v2_payload_valid(converted): + raise _BillableLedgerError() + return converted + + +def _load_billable_ledger_payload() -> dict[str, Any]: + ledger_path = _safe_ledger_file(_canonical_plugin_dir()) + try: + info = ledger_path.lstat() + except FileNotFoundError: + return {"version": 2, "entries": [], "receiptContexts": {}, "contextSummaries": {}} + except OSError as exc: + raise _BillableLedgerError() from exc + if stat.S_ISLNK(info.st_mode) or not stat.S_ISREG(info.st_mode) or info.st_mode & 0o077: + raise _BillableLedgerError() + try: + fd = os.open(ledger_path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)) + with os.fdopen(fd, "r", encoding="utf-8") as source: + raw = json.load(source) + except (OSError, ValueError, TypeError) as exc: + raise _BillableLedgerError() from exc + if not isinstance(raw, dict): + raise _BillableLedgerError() + payload = _decode_v1_payload(raw) if raw.get("version") == 1 else raw + if not _v2_payload_valid(payload): + raise _BillableLedgerError() + return payload + + +def _load_billable_ledger() -> tuple[Path, list[dict[str, Any]]]: + return _safe_ledger_file(_canonical_plugin_dir()), _load_billable_ledger_payload()["entries"] + + +def _write_billable_ledger_payload(ledger_path: Path, payload: dict[str, Any]) -> None: + if not _v2_payload_valid(payload): + raise _BillableLedgerError() + directory, temporary = ledger_path.parent, None + try: + try: + info = ledger_path.lstat() + except FileNotFoundError: + pass + else: + if stat.S_ISLNK(info.st_mode) or not stat.S_ISREG(info.st_mode) or info.st_mode & 0o077: + raise _BillableLedgerError() + encoded = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8") + temporary = directory / f".{_BILLABLE_LEDGER_FILE}.{secrets.token_hex(16)}.tmp" + fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0), 0o600) + with os.fdopen(fd, "wb") as destination: + destination.write(encoded) + destination.flush() + os.fsync(destination.fileno()) + os.replace(temporary, ledger_path) + temporary = None + os.chmod(ledger_path, 0o600) + directory_fd = os.open(directory, os.O_RDONLY) + try: + os.fsync(directory_fd) + finally: + os.close(directory_fd) + except _BillableLedgerError: + raise + except OSError as exc: + raise _BillableLedgerError() from exc + finally: + if temporary is not None: + try: + temporary.unlink() + except OSError: + pass + + + + +@contextmanager +def _ledger_transaction() -> Iterator[tuple[Path, dict[str, Any]]]: + ledger_path = _safe_ledger_file(_canonical_plugin_dir()) + lock_path = ledger_path.parent / _BILLABLE_LEDGER_LOCK_FILE + try: + fd = os.open(lock_path, os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0), 0o600) + info = os.fstat(fd) + if not stat.S_ISREG(info.st_mode) or info.st_mode & 0o077: + os.close(fd) + raise LedgerUnavailableError() + fcntl.flock(fd, fcntl.LOCK_EX) + except LedgerUnavailableError: + raise + except OSError as exc: + raise LedgerUnavailableError() from exc + try: + yield ledger_path, _load_billable_ledger_payload() + finally: + try: + fcntl.flock(fd, fcntl.LOCK_UN) + except OSError: + pass + os.close(fd) + + +def _hash_value(value: Any) -> str: + encoded = json.dumps(value, separators=(",", ":"), sort_keys=True, ensure_ascii=True).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def _receipt_hash(receipt: str) -> str: + return hashlib.sha256(receipt.encode("utf-8")).hexdigest() + + +_REDACTED_VALUE = "" + + +def _sanitize_sensitive_urls(value: Any, depth: int = 0) -> Any: + """Return a bounded copy with credential-like HTTP(S) query parameters removed.""" + if depth >= 12: + return _REDACTED_VALUE + if isinstance(value, str): + parsed = urlsplit(value) + if parsed.scheme.lower() not in {"http", "https"} or not parsed.netloc: + return value + query = [(key, item) for key, item in parse_qsl(parsed.query, keep_blank_values=True) + if not any(marker in key.lower() for marker in ("token", "sig", "signature", "expires", "key", "auth"))] + return urlunsplit((parsed.scheme, parsed.netloc, parsed.path, urlencode(query, doseq=True), parsed.fragment)) + if isinstance(value, dict): + return { + _sanitize_sensitive_urls(key, depth + 1) if isinstance(key, str) else key: + _sanitize_sensitive_urls(item, depth + 1) + for key, item in value.items() + } + if isinstance(value, list): + return [_sanitize_sensitive_urls(item, depth + 1) for item in value] + if isinstance(value, tuple): + return tuple(_sanitize_sensitive_urls(item, depth + 1) for item in value) + return value + + +def _billable_command(command: dict[str, Any]) -> tuple[str, str] | None: + payload = command.get("payload") + if not isinstance(payload, dict): + return None + if command.get("op") == "higgsfield-control" and payload.get("action") == "generate": + scope = "higgsfield-generate" + approved, key = payload.get("billableConfirmed"), payload.get("idempotencyKey") + elif command.get("op") == "midjourney-control": + action = payload.get("action") + scope = action if action == "submit" else payload.get("name") if action == "action" else None + if scope not in {"submit", *_BILLABLE_ACTIONS}: + return None + approved, key = payload.get("approved"), payload.get("idempotencyKey") + else: + return None + if approved is not True or not isinstance(key, str) or not 8 <= len(key) <= 128 or any(ord(c) < 33 or ord(c) > 126 for c in key): + raise HTTPException(status_code=400, detail="Billable command requires current approval and idempotency key") + return str(scope), key + + +def _sanitized_scope(value: Any) -> str: + if isinstance(value, str) and 0 < len(value) <= 64 and all(c.isascii() and (c.isalnum() or c in "._-") for c in value): + return value + return _hash_value(value) + + +def _billable_record(command: dict[str, Any], scope: str, idempotency_key: str) -> dict[str, Any]: + payload = dict(command["payload"]) + payload["idempotencyKey"] = "" + if "validateReceipt" in payload: + payload["validateReceipt"] = "" + request = {key: value for key, value in command.items() if key != "id"} + request["payload"] = payload + target = {key: value for key, value in {"panelId": command.get("panelId"), "targetId": command.get("targetId", payload.get("targetId")), "url": command.get("url", payload.get("url"))}.items() if isinstance(value, str)} + now = datetime.now(timezone.utc).isoformat() + return {"operationId": _hash_value(command["id"]), "action": scope, "scope": _sanitized_scope(command.get("panelId")), "idempotencyKeyHash": _hash_value(idempotency_key), "requestFingerprint": _hash_value(request), "targetFingerprint": _hash_value(target), "createdAt": now, "updatedAt": now, "status": "reserved"} + + +def _prune_terminal_entries(entries: list[dict[str, Any]]) -> None: + while len(entries) >= _BILLABLE_LEDGER_LIMIT: + terminal = next((index for index, entry in enumerate(entries) if entry["status"] != "reserved"), None) + if terminal is None: + raise _BillableLedgerError() + entries.pop(terminal) + + +def _consume_submit_batch(payload: dict[str, Any], ledger: dict[str, Any]) -> None: + receipt = payload.get("validateReceipt") + fingerprint = payload.get("batchFingerprint") + if not isinstance(receipt, str) or not receipt or not isinstance(fingerprint, str) or len(fingerprint) != 64: + raise HTTPException(status_code=400, detail="Billable submit requires a valid batch identity") + try: + int(fingerprint, 16) + except ValueError as exc: + raise HTTPException(status_code=409, detail="Billable submit batch context is invalid") from exc + context = ledger["receiptContexts"].get(_receipt_hash(receipt)) + if context is None: + raise HTTPException(status_code=409, detail="Billable submit batch context is invalid") + try: + expired = datetime.fromisoformat(context["expiresAt"].replace("Z", "+00:00")) <= datetime.now(timezone.utc) + except ValueError: + expired = True + summary = ledger["contextSummaries"].get(context["batchContextId"]) + if expired or summary is None or summary["batchFingerprint"] != fingerprint or summary["consumedCount"] >= 3: + raise HTTPException(status_code=409, detail="Billable submit batch quota is unavailable") + summary["consumedCount"] += 1 + +def _reserve_in_ledger(command: dict[str, Any], scope: str, idempotency_key: str, ledger: dict[str, Any]) -> tuple[dict[str, Any], bool]: + candidate = _billable_record(command, scope, idempotency_key) + for entry in ledger["entries"]: + if entry["idempotencyKeyHash"] == candidate["idempotencyKeyHash"]: + if entry["requestFingerprint"] != candidate["requestFingerprint"]: + raise HTTPException(status_code=409, detail="Idempotency key is already reserved for a different billable command") + return entry, True + if scope == "submit": + _consume_submit_batch(command["payload"], ledger) + _prune_terminal_entries(ledger["entries"]) + ledger["entries"].append(candidate) + return candidate, False + + +def _reserve_billable_command(command: dict[str, Any], scope: str, idempotency_key: str) -> tuple[dict[str, Any], bool]: + with _ledger_transaction() as (ledger_path, ledger): + reservation, existing = _reserve_in_ledger(command, scope, idempotency_key, ledger) + if not existing: + _write_billable_ledger_payload(ledger_path, ledger) + return reservation, existing + + +def _acknowledge_in_ledger(result_id: str, ledger: dict[str, Any]) -> None: + operation_id = _hash_value(result_id) + for entry in ledger["entries"]: + if entry["operationId"] == operation_id: + entry["status"] = "acknowledged" + entry["updatedAt"] = datetime.now(timezone.utc).isoformat() + return + + +def _acknowledge_billable_result(result_id: str) -> None: + with _ledger_transaction() as (ledger_path, ledger): + _acknowledge_in_ledger(result_id, ledger) + _write_billable_ledger_payload(ledger_path, ledger) + + +def _host_auth_supported() -> bool: + try: + from hermes_cli import __version__ + from hermes_cli import web_server as ws + parts = tuple(int(part) for part in __version__.split(".")[:3]) + except Exception: + return False + # Capability-gated: no hard upper bound. Support any host at or above the floor + # that still exposes a callable token-auth capability; genuinely missing capability + # (or a below-floor host) fails closed so a breaking version bump is caught, not assumed. + return parts >= (0, 18, 2) and callable(getattr(ws, "_ws_auth_ok", None)) + + +def _ws_upgrade_authorized(ws: WebSocket) -> bool: + if not _host_auth_supported(): + return False + from hermes_cli import web_server as _ws return bool(_ws._ws_auth_ok(ws)) +def _validate_acknowledged_link(command: dict[str, Any]) -> None: + if command.get("op") != "midjourney-control": + return + payload = command.get("payload") + if not isinstance(payload, dict) or payload.get("action") != "link": + return + operation_id = payload.get("operationId") + if not isinstance(operation_id, str) or len(operation_id) != 64: + raise HTTPException(status_code=400, detail="Result linkage requires an acknowledged operation") + try: + int(operation_id, 16) + _path, entries = _load_billable_ledger() + except (ValueError, _BillableLedgerError) as exc: + raise HTTPException(status_code=503, detail="Billable command ledger is unavailable") from exc + matched = next((entry for entry in entries if entry["operationId"] == operation_id and entry["action"] == "submit" and entry["status"] == "acknowledged"), None) + if matched is None: + raise HTTPException(status_code=409, detail="Result linkage operation is not an acknowledged submit") + payload["acknowledged"] = True + payload["ledgerCreatedAt"] = matched["createdAt"] + +def _register_receipt_context(result: dict[str, Any], ledger: dict[str, Any]) -> None: + context = result.get("receiptContext") + if context is None: + return + if not isinstance(context, dict) or set(context) != {"receiptHash", "batchContextId", "expiresAt", "batchFingerprint"}: + raise HTTPException(status_code=409, detail="Billable receipt context is invalid") + receipt_hash = context["receiptHash"] + batch_context_id = context["batchContextId"] + expires_at = context["expiresAt"] + fingerprint = context["batchFingerprint"] + if not all(isinstance(value, str) for value in (receipt_hash, batch_context_id, expires_at, fingerprint)) or len(receipt_hash) != 64 or len(fingerprint) != 64: + raise HTTPException(status_code=409, detail="Billable receipt context is invalid") + try: + if datetime.fromisoformat(expires_at.replace("Z", "+00:00")) <= datetime.now(timezone.utc): + raise ValueError() + except ValueError as exc: + raise HTTPException(status_code=409, detail="Billable receipt context is invalid") from exc + existing_context = ledger["receiptContexts"].get(receipt_hash) + existing_summary = ledger["contextSummaries"].get(batch_context_id) + if (existing_context is not None and existing_context != {"batchContextId": batch_context_id, "expiresAt": expires_at}) or (existing_summary is not None and existing_summary["batchFingerprint"] != fingerprint): + raise HTTPException(status_code=409, detail="Billable receipt context is invalid") + ledger["receiptContexts"][receipt_hash] = {"batchContextId": batch_context_id, "expiresAt": expires_at} + ledger["contextSummaries"].setdefault(batch_context_id, {"batchFingerprint": fingerprint, "consumedCount": 0}) + async def _json_body(request: Request) -> dict[str, Any]: try: body = await request.json() @@ -79,10 +454,28 @@ async def _broadcast_command(command: dict[str, Any]) -> int: async def post_command(request: Request): command = await _json_body(request) _validate_command(command) + _validate_acknowledged_link(command) + billable = _billable_command(command) + reservation = None + if billable is not None: + if not _host_auth_supported(): + raise HTTPException(status_code=503, detail="Billable command host authentication is unavailable") + scope, idempotency_key = billable + async with _BILLABLE_LEDGER_LOCK: + try: + reservation, existing = _reserve_billable_command(command, scope, idempotency_key) + except _BillableLedgerError as exc: + raise HTTPException(status_code=503, detail="Billable command ledger is unavailable") from exc + if existing: + return {"queued": False, "existing": True, "id": command["id"], "operationId": reservation["operationId"], "status": reservation["status"]} async with _COMMAND_LOCK: if not await _broadcast_command(command): _COMMAND_QUEUE.append(command) - return {"queued": True, "id": command["id"]} + return { + "queued": True, + "id": command["id"], + **({"operationId": reservation["operationId"], "status": reservation["status"]} if reservation else {}), + } @router.websocket("/commands") @@ -116,28 +509,58 @@ async def post_result(request: Request): if not isinstance(result_id, str) or not result_id or len(result_id) > 64: raise HTTPException(status_code=400, detail="Result id must be a non-empty string of at most 64 characters") + if not _host_auth_supported(): + raise HTTPException(status_code=503, detail="Billable command host authentication is unavailable") + async with _BILLABLE_LEDGER_LOCK: + try: + with _ledger_transaction() as (ledger_path, ledger): + _register_receipt_context(result, ledger) + _acknowledge_in_ledger(result_id, ledger) + _write_billable_ledger_payload(ledger_path, ledger) + except _BillableLedgerError as exc: + raise HTTPException(status_code=503, detail="Billable command ledger is unavailable") from exc if result_id in _RESULTS: _RESULT_IDS.remove(result_id) elif len(_RESULT_IDS) == _RESULT_IDS.maxlen: _RESULTS.pop(_RESULT_IDS.popleft(), None) _RESULT_IDS.append(result_id) - _RESULTS[result_id] = result - state = result.get("state") + sanitized_result = _sanitize_sensitive_urls(result) + _RESULTS[result_id] = sanitized_result + state = sanitized_result.get("state") if isinstance(state, dict): _LATEST_STATE = state return {"stored": True, "id": result_id} +@router.get("/result") +async def list_results(cursor: int = 0): + """Return bounded result receipts after *cursor* for local CLI polling.""" + if cursor < 0: + raise HTTPException(status_code=400, detail="Result cursor must be non-negative") + ids = list(_RESULT_IDS) + start = min(cursor, len(ids)) + return { + "results": [_sanitize_sensitive_urls(_RESULTS[result_id]) for result_id in ids[start:]], + "nextCursor": len(ids), + } + + +@router.get("/control/result") +async def list_control_results(cursor: int = 0): + """Token-authenticated local-control view of bounded result receipts.""" + return await list_results(cursor) + + @router.get("/result/{command_id}") async def get_result(command_id: str): result = _RESULTS.get(command_id) if result is None: return JSONResponse(status_code=202, content={"pending": True}) - return result + return _sanitize_sensitive_urls(result) @router.get("/state") async def get_state(): if _LATEST_STATE is None: return JSONResponse(status_code=404, content={"reported": False}) - return _LATEST_STATE + return _sanitize_sensitive_urls(_LATEST_STATE) diff --git a/docs/midjourney-web-bridge-api.md b/docs/midjourney-web-bridge-api.md new file mode 100644 index 0000000..1cec0d2 --- /dev/null +++ b/docs/midjourney-web-bridge-api.md @@ -0,0 +1,118 @@ +# Midjourney Web Bridge API + +**Status:** live local control surface — selector registry `mj-web-2026-07-19.v3` +**Owner:** user-local `hermes-visual-workbench`; no upstream Hermes PR/release is implied. + +## Decision + +Midjourney control moves out of macOS coordinate automation. The supported control plane is a local HTTP API backed by the authenticated, visible Hermes Desktop Browser guest (`persist:hermes-browser`) and a versioned CDP recipe. + +CUA remains only for visual recovery and final pixel QC. It is not a submission transport. + +This bridge does **not** call undocumented Midjourney backend endpoints and does not read, export, log, or persist cookies, headers, tokens, storage, or credentials. + +## Implemented seam and billable boundary + +`dashboard/plugin_api.py` accepts the strict `midjourney-control` command operation and relays it to the installed Desktop plugin. `scripts/midjourney-control.mjs` discovers loopback Hermes Agent listeners from `/openapi.json`, selects the one that accepts this plugin's `0600` bearer token, queues the command, and waits for the matching result receipt. This disambiguates concurrent profile backends without printing the token. + +The installed Desktop host exposes `window.hermesDesktop.browser.control`, a provider-neutral typed seam with strict request unions for bounded semantic snapshot, focus/insertText, local file input, allowlisted click/Enter activation, bounded wait, and verified download. The local `mj-web-2026-07-19.v3` recipe owns every selector and ARIA-name matcher. Callers cannot provide selectors, JavaScript, CDP methods, download URLs, or absolute artifact destinations. + +The lifecycle implements draft readback, bounded image upload, role classification, mode conversion, fresh validation receipts, approval-gated one-shot submit/result actions, bounded result discovery, acknowledged-submit linkage, wait/grid/capture/QC, and job-linked download. Every result has `operationId`, lifecycle `state`, bounded `evidence`, and a sanitized `error`. The current Add Images picker can route a file into Start Frame while its Image Prompts tab is open; strict single-role attachment therefore fails closed until an unambiguous role selector and remove control are identified. + +Billable activation remains fail-closed. Three approved Creative Upscale attempts were durably reserved but stopped before activation with `Midjourney target upscale is ambiguous or unavailable`; no upscale result or charge was observed. The durable backend ledger atomically reserves billable idempotency keys before dispatch and rejects replay after restart. + +## API surface + +The transport endpoint is `POST /api/plugins/visual-workbench/command` with `op: "midjourney-control"`, `panelId: "result"`, and an action-specific payload. Results are read from `GET /api/plugins/visual-workbench/control/result?cursor=N`. Callers should use the CLI rather than handling the token. + +### CLI commands + +| Command | Purpose | +| --- | --- | +| `capabilities` | Reports registry version, implemented actions, and billable approval gates. | +| `navigate --url https://www.midjourney.com/` | Navigates the Browser result panel and assigns a new target identity. | +| `probe` / `state` / `results` | Returns live URL, fixed targets, bounds, bounded sanitized DOM/ARIA evidence, and visible job IDs without URLs or image sources. | +| `settings [--name NAME --value VALUE]` | Opens the slider-shaped settings control beside the composer and reads the panel, or sets aspect preset, model, raw mode, speed, video resolution, or the `P` personalization toggle with post-change readback. Numeric stylization/weirdness/variety and exact aspect ratio remain deterministic `draft --parameters` values. | +| `draft --prompt TEXT --parameters JSON` | Uses typed focus/insertText and requires exact Accessibility value readback. Never submits. | +| `attach --path FILE --role ROLE` / `detach --role ROLE` | Validates one local image and requires exact post-upload role evidence; current live Image Prompt routing fails closed and is not production-ready. | +| `validate` | Issues a short-lived receipt bound to panel, targetId, URL, exact composer value, reference roles, settings, and timestamp. | +| `submit --approve-billable --idempotency-key KEY --validate-receipt RECEIPT --batch-fingerprint 64LOWERHEX` | One-shot typed Enter activation after fresh validation and durable reservation. Requires the validation `batchFingerprint` (64 lowercase hex) so the durable submit ledger can enforce the atomic three-submit-per-batch quota; fails closed without it. Never auto-retries. | +| `wait --timeout-ms N` / `grid` | Samples bounded live state and A–D candidate metadata. | +| `link --operation-id HASH --job-id ID --prompt TEXT` | Binds an acknowledged submit receipt to an exact visible job ID and prompt hash before navigating to the canonical job page. | +| `action --name NAME --candidate A-D ...` | Fixed select/upscale/vary/reroll/pan/zoom controls; all except select require current approval and durable idempotency. | +| `download --job-id ID --filename NAME` | Downloads the visible job image under `~/.hermes/artifacts/midjourney/`, validating URL linkage, MIME/extension, magic bytes, byte count, and dimensions. | +| `capture` / `qc` | Captures the exact Browser target and returns linked QC evidence. | + +Example: + +```bash +node scripts/midjourney-control.mjs navigate --url https://www.midjourney.com/ +node scripts/midjourney-control.mjs probe +node scripts/midjourney-control.mjs settings +node scripts/midjourney-control.mjs settings --name raw --value raw +node scripts/midjourney-control.mjs settings --name personalization --value false +node scripts/midjourney-control.mjs draft --prompt 'studio portrait' --parameters '{"ar":"3:4","raw":true}' +node scripts/midjourney-control.mjs validate +node scripts/midjourney-control.mjs capture +``` + +`--base-url http://127.0.0.1:PORT` is available for diagnostics; normal use dynamically discovers the current port. + +## Host guest-actuation seam + +The Desktop host must expose only fixed, typed operations to plugins granted a dedicated capability. It must retain existing ownership checks: guest belongs to the requesting window and uses the configured persistent Browser partition. + +Allowed primitives: + +- `snapshot`: bounded semantic state for a recipe-defined selector set; text, role, enabled/visible, bounds, and URL only. +- `focusText`: focus a validated recipe target and insert bounded text. +- `setFileInput`: set one approved local file on a validated `` recipe target. +- `activate`: invoke one validated recipe target's click/keyboard action. +- `waitFor`: wait for a bounded predicate built from the above snapshot fields. +- `capture` and `download`: existing bounded media/evidence flow. + +Forbidden primitives: + +- generic `Runtime.evaluate` +- `Network.*`, cookie APIs, storage APIs, request headers, or arbitrary page source extraction +- arbitrary CDP methods or arbitrary CSS/JavaScript supplied by an API caller +- unbounded DOM/HTML reads + +The **recipe is local, declarative, versioned, and allowlisted**. API callers send an intent such as `attach-reference` or `submit`; they never send selectors or JavaScript. + +## Fail-closed rules + +1. The bridge checks the visible route and authenticated Create composer before every mutation. +2. Reference role is observed after upload. A `start-frame`, animation, or wrong-slot attachment is a hard failure, never an inferred success. +3. `submit`, `upscale`, `vary`, `reroll`, `pan`, and `zoom` require an explicit billable confirmation and idempotency key. +4. The result card must match the draft prompt fingerprint and preflight settings before any result action. +5. Navigation, selector mismatch, stale target identity, unsupported model mode, or changed settings invalidate the pending action. +6. No automatic retry after a possible billable click. The bridge first searches the newest creation feed for a matching card. +7. Downloads write verified provenance under `~/.hermes/artifacts/midjourney//`; billable reservations are atomically persisted under the plugin data directory. Neither stores credentials or browser-session data. + +## Current acceptance boundary + +Non-billable live acceptance covers exact acknowledged-submit linkage to job `32728f26-45f7-4bc6-be7b-80b4288e5ee6`, restart-safe duplicate-submit rejection, a verified A-D composite grid, verified 1024×1024 JPEG download, 1536×2048 capture, QC state, dynamic listener discovery, and bearer behavior `401/401/200`. Attachment upload was attempted with a 32-pixel fixture; the picker routed it to Start Frame and the bridge rejected the requested Image Prompt role. The composer was returned to Image mode and no submission was made. Style Reference and Omni were not live-smoked. + +The original submit is acknowledged under operation hash `b20d14d835b346f3ce907f2146c72d8f395660490330c6ce3dbd602fbe767472`; replay returned `existing=true`, `queued=false`, and did not click again. Approved upscale attempts failed before activation because the live job page exposes `Subtle`/`Creative` controls outside the current fixed target resolver. `vary`, `reroll`, `pan`, and `zoom` were not exercised. No credit-consuming continuation was observed. + +## Rollback + +The installer creates timestamped backups before replacing managed files. To roll back the Desktop plugin: + +```bash +cp ~/.hermes/desktop-plugins/visual-workbench/backups/plugin/plugin.js-.bak \ + ~/.hermes/desktop-plugins/visual-workbench/plugin.js +``` + +Then run **Reload desktop plugins** from the Hermes command palette. Backend rollbacks use the corresponding files under `~/.hermes/plugins/visual-workbench/backups/`, followed by a Hermes Agent backend restart. Do not delete `control.token`; rollback preserves the existing local credential and its `0600` mode. + +## Acceptance evidence + +A feature is complete only when a real authenticated Midjourney browser guest demonstrates the target action with: + +- preflight snapshot proving the intended role/control; +- post-action visible state or matching creation card; +- sanitized operation receipt and artifact provenance; +- a fresh capture/QC record for any generated grid; and +- no cookie, token, storage, or raw network payload in the logs or artifact. diff --git a/package.json b/package.json index c12a188..323cb8b 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "manifest.json", "skill/", "dashboard/", - "fixtures/", + "backend/", "scripts/", "README.md", "LICENSE" diff --git a/plugin.js b/plugin.js index f510a14..beabb83 100644 --- a/plugin.js +++ b/plugin.js @@ -79,7 +79,7 @@ const QC_PROFILES = { } // WORKBENCH_CORE_BEGIN -const PERSISTED_SCHEMA_VERSION = 6 +const PERSISTED_SCHEMA_VERSION = 7 const QC_DOCUMENT_SCHEMA_VERSION = 1 const MAX_QC_JSON_BYTES = 64 * 1024 const CANDIDATE_IDS = ['A', 'B', 'C', 'D'] @@ -178,7 +178,7 @@ const browserWebviewSyncInstalled = new WeakSet() const browserViewportTasks = new Map() function persistedState() { - return { + const persisted = { schemaVersion: PERSISTED_SCHEMA_VERSION, browserSplit: state.browserSplit, browserPanels: state.browserPanels, @@ -192,6 +192,7 @@ function persistedState() { qcJson: state.qcJson, capture: state.capture?.path ? state.capture : null } + return { ...restoredState(persisted), capture: persisted.capture ? restoredCapture(persisted.capture) : null } } function restoredState(saved) { @@ -202,7 +203,7 @@ function restoredState(saved) { result: restoredPanel(source.browserPanels?.result, DEFAULT_BROWSER_PANELS.result, legacyUrl), reference: restoredPanel(source.browserPanels?.reference, DEFAULT_BROWSER_PANELS.reference) } - const reviewContext = restoredReviewContext(source.reviewContext, browserPanels, source.schemaVersion !== PERSISTED_SCHEMA_VERSION) + const reviewContext = restoredReviewContext(source.reviewContext, browserPanels, Number(source.schemaVersion) < 6) const restored = { ...DEFAULT_STATE, schemaVersion: PERSISTED_SCHEMA_VERSION, @@ -218,7 +219,7 @@ function restoredState(saved) { qcJson: typeof source.qcJson === 'string' ? source.qcJson : '', capture: restoredCapture(source.capture) } - if (!restored.capture && source.schemaVersion !== PERSISTED_SCHEMA_VERSION && isRecord(source.capture)) { + if (!restored.capture && Number(source.schemaVersion) < 6 && isRecord(source.capture)) { const panel = browserPanels[source.capture.panelId] if (panel && source.capture.url === panel.url) { restored.capture = restoredCapture({ ...source.capture, targetId: panel.targetId }) @@ -249,6 +250,31 @@ function isRecord(value) { function boundedMetadataString(value, max = 4000) { return typeof value === 'string' ? value.slice(0, max) : '' } +const URL_SECRET_PARAM = /(?:^|[_-])(?:token|sig(?:nature)?|expires?|key|auth(?:entication|orization)?|secret|credential|session)(?:$|[_-])/i + +function sanitizeUrl(value) { + const raw = boundedMetadataString(value, 4096) + if (!raw) return '' + try { + const url = new URL(raw) + for (const key of [...url.searchParams.keys()]) { + if (URL_SECRET_PARAM.test(key)) url.searchParams.delete(key) + } + const base = raw.split(/[?#]/, 1)[0] + return url.searchParams.size ? `${base}?${url.searchParams}` : base + } catch { + return raw + } +} + +// Mandatory gate for any future Higgsfield provider invocation. +async function invokeHiggsfieldReadOnly(toolName, invoke, ...args) { + const operation = String(toolName || '').toLowerCase().replace(/^.*__/, '') + if (!['show_generations', 'job_status', 'get_generation', 'get_job', 'inspect', 'status'].includes(operation)) { + throw new Error(`Higgsfield operation ${operation || 'unknown'} is read-only only`) + } + return invoke(toolName, ...args) +} function parsedRecord(value) { if (isRecord(value)) return value @@ -300,14 +326,23 @@ function comparableUrl(value) { } function restoredProviderEvidence(value) { - if (!isRecord(value) || value.source !== 'higgsfield-mcp') return null + if (!isRecord(value)) return null + if (value.source === 'midjourney') { + const jobId = boundedMetadataString(value.jobId, 128).toLowerCase() + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(jobId)) return null + return { + source: 'midjourney', jobId, operationId: boundedMetadataString(value.operationId, 128), + resultUrl: sanitizeUrl(value.resultUrl) + } + } + if (!['higgsfield-mcp', 'higgsfield-web'].includes(value.source)) return null const width = Number.isFinite(value.width) && value.width > 0 ? Math.round(value.width) : 0 const height = Number.isFinite(value.height) && value.height > 0 ? Math.round(value.height) : 0 const duration = Number.isFinite(value.duration) && value.duration > 0 ? value.duration : 0 const count = Number.isInteger(value.count) && value.count > 0 ? Math.min(value.count, 20) : 1 const referenceCount = Number.isInteger(value.referenceCount) && value.referenceCount >= 0 ? Math.min(value.referenceCount, 20) : 0 return { - source: 'higgsfield-mcp', + source: value.source, jobId: boundedMetadataString(value.jobId, 128), status: boundedMetadataString(value.status, 64), model: boundedMetadataString(value.model, 128), @@ -321,7 +356,7 @@ function restoredProviderEvidence(value) { resolution: boundedMetadataString(value.resolution, 32), count, referenceCount, - resultUrl: boundedMetadataString(value.resultUrl, 4096), + resultUrl: sanitizeUrl(value.resultUrl), createdAt: typeof value.createdAt === 'string' || Number.isFinite(value.createdAt) ? value.createdAt : '', checkedAt: boundedMetadataString(value.checkedAt, 64) } @@ -329,18 +364,41 @@ function restoredProviderEvidence(value) { function providerEvidenceIdentity(evidence) { return evidence ? `${evidence.jobId}|${evidence.resultUrl}|${evidence.model}` : '' } +function midjourneyJobLocation(rawUrl) { + try { + const url = new URL(String(rawUrl || '')) + const match = url.protocol === 'https:' && !url.username && !url.password && url.port === '' && + /^(?:www\.)?midjourney\.com$/.test(url.hostname.toLowerCase()) && + url.pathname.match(/^\/jobs\/([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$/i) + const candidateIndex = match && url.search === '' && url.hash === '' + ? -1 + : match && url.hash === '' && /^index=[0-3]$/.test(url.search.slice(1)) ? Number(url.search.slice(-1)) : null + return { jobId: match?.[1]?.toLowerCase() || '', candidateIndex } + } catch { return { jobId: '', candidateIndex: null } } +} +function midjourneyProviderEvidenceForUrl(rawUrl) { + const location = midjourneyJobLocation(rawUrl) + return location.jobId && location.candidateIndex !== null + ? restoredProviderEvidence({ source: 'midjourney', jobId: location.jobId, resultUrl: String(rawUrl) }) + : null +} +function sameMidjourneyCandidateSwitch(previousUrl, nextUrl, linkedJobId) { + const previous = midjourneyJobLocation(previousUrl) + const next = midjourneyJobLocation(nextUrl) + return Boolean(linkedJobId && previous.jobId === String(linkedJobId).toLowerCase() && + next.jobId === previous.jobId && previous.candidateIndex !== null && + Number.isInteger(next.candidateIndex) && next.candidateIndex >= 0 && previous.candidateIndex !== next.candidateIndex) +} function providerEvidenceFor(input = {}) { const toolName = String(input.toolName || '').toLowerCase() if (!toolName.includes('higgsfield')) return null const records = providerRecords(input.toolResult) const src = String(input.src || '') - const matching = records.filter(record => resultUrls(record).some(url => comparableUrl(url) === comparableUrl(src))) - const recordsWithUrls = records.filter(record => resultUrls(record).length > 0) - const record = matching[0] || (recordsWithUrls.length === 0 && records.length === 1 ? records[0] : null) - if (!record) return null + const matching = records.filter(record => resultUrls(record).some(url => url === src)) + if (matching.length !== 1) return null + const record = matching[0] const params = isRecord(record.params) ? record.params : {} - const urls = resultUrls(record) const mediaType = ['image', 'video', 'audio', '3d'].includes(record.type) ? record.type : /\.(mp4|mov|webm|mkv|avi)(?:[?#]|$)/i.test(src) ? 'video' : 'image' @@ -359,7 +417,7 @@ function providerEvidenceFor(input = {}) { resolution: params.resolution || record.resolution || '', count: params.batch_size || params.count || record.count || 1, referenceCount: Array.isArray(params.medias) ? params.medias.length : 0, - resultUrl: urls.find(url => comparableUrl(url) === comparableUrl(src)) || urls[0] || src, + resultUrl: src, createdAt: record.createdAt || record.created_at || '', checkedAt: new Date().toISOString() }) @@ -368,7 +426,7 @@ function providerEvidenceFor(input = {}) { function restoredInspection(value) { if (!isRecord(value)) return null return { - url: boundedMetadataString(value.url, 4096), + url: sanitizeUrl(value.url), summary: boundedMetadataString(value.summary, 1000), checkedAt: boundedMetadataString(value.checkedAt, 64) } @@ -379,7 +437,7 @@ function restoredReviewContext(value, panels, allowLegacy = false) { const panel = panels[value.panelId] const legacy = !Object.hasOwn(value, 'contextId') if (legacy) { - const url = boundedMetadataString(value.url, 4096) + const url = sanitizeUrl(value.url) if (!allowLegacy || !url || !panel?.url || url !== panel.url) return null return { contextId: createId('c'), panelId: value.panelId, targetId: panel.targetId, profileId: value.profileId, @@ -391,7 +449,7 @@ function restoredReviewContext(value, panels, allowLegacy = false) { !['image', 'video', 'page'].includes(value.mediaKind) || !isRecord(value.viewport) || typeof value.stale !== 'boolean' || !['', 'url-changed', 'viewport-changed', 'panels-swapped', 'provenance-changed', 'load-failed'].includes(value.staleReason) || typeof value.linkedAt !== 'string' || value.linkedAt.length > 64) return null - const url = boundedMetadataString(value.url, 4096) + const url = sanitizeUrl(value.url) const viewport = value.viewport if (!url || !Number.isFinite(viewport.width) || viewport.width < 240 || !Number.isFinite(viewport.height) || viewport.height < 240 || typeof viewport.preset !== 'string' || typeof viewport.responsive !== 'boolean') return null @@ -427,29 +485,34 @@ function panelLinkedToQc(current, panelId) { function updatePanelState(current, panelId, patch, options = {}, makeId = createId) { const panel = current.browserPanels[panelId] if (!panel) return current - const { preserveQcProfileHint = false, preserveProviderEvidence = false } = options + const { preserveQcProfileHint = false, preserveProviderEvidence = false, preserveMidjourneyCandidate = false } = options const urlChanged = Object.hasOwn(patch, 'url') && patch.url !== panel.url - const nextUrl = Object.hasOwn(patch, 'url') ? patch.url : panel.url + const nextUrl = Object.hasOwn(patch, 'url') ? sanitizeUrl(patch.url) : panel.url const viewportChanged = ['preset', 'width', 'height'].some(key => Object.hasOwn(patch, key) && patch[key] !== panel[key]) const provenanceChanged = Object.hasOwn(patch, 'providerEvidence') && providerEvidenceIdentity(patch.providerEvidence) !== providerEvidenceIdentity(panel.providerEvidence) - const targetChanged = urlChanged || (viewportChanged && mediaKind(nextUrl) === 'page') || provenanceChanged + const candidateSwitch = urlChanged && preserveMidjourneyCandidate + const targetChanged = !candidateSwitch && (urlChanged || (viewportChanged && mediaKind(nextUrl) === 'page') || provenanceChanged) const staleReason = urlChanged ? 'url-changed' : viewportChanged ? 'viewport-changed' : 'provenance-changed' const nextPanel = { ...panel, ...patch, + ...(Object.hasOwn(patch, 'url') ? { url: nextUrl } : {}), + ...(Object.hasOwn(patch, 'providerEvidence') ? { providerEvidence: restoredProviderEvidence(patch.providerEvidence) } : {}), ...(targetChanged ? { targetId: makeId('t'), inspection: null } : {}), ...(urlChanged && !preserveQcProfileHint && !Object.hasOwn(patch, 'qcProfileHint') ? { qcProfileHint: '' } : {}), ...(urlChanged && !preserveProviderEvidence && !Object.hasOwn(patch, 'providerEvidence') ? { providerEvidence: null } : {}) } const context = current.reviewContext - const reviewContext = targetChanged && context?.panelId === panelId && context.targetId === panel.targetId - ? { ...context, stale: true, staleReason } - : context + const reviewContext = candidateSwitch && context?.panelId === panelId && context.targetId === panel.targetId && !context.stale + ? { ...context, url: nextUrl, mediaKind: mediaKind(nextUrl), viewport: viewportFor(nextPanel), linkedAt: new Date().toISOString() } + : targetChanged && context?.panelId === panelId && context.targetId === panel.targetId + ? { ...context, stale: true, staleReason } + : context return { ...current, browserPanels: { ...current.browserPanels, [panelId]: nextPanel }, reviewContext, - ...(targetChanged && current.capture?.panelId === panelId ? { capture: null } : {}) + ...((targetChanged || candidateSwitch) && current.capture?.panelId === panelId ? { capture: null } : {}) } } @@ -459,17 +522,24 @@ function linkPanelState(current, panelId, input = {}, makeId = createId) { const profileId = QC_PROFILE_IDS.includes(input.profileId) ? input.profileId : QC_PROFILE_IDS.includes(panel.qcProfileHint) ? panel.qcProfileHint : 'design' + const inferredProviderEvidence = profileId === 'midjourney' ? midjourneyProviderEvidenceForUrl(panel.url) : null + const providerEvidence = profileId === 'midjourney' ? inferredProviderEvidence : restoredProviderEvidence(panel.providerEvidence) + const nextPanel = profileId === 'midjourney' && providerEvidenceIdentity(providerEvidence) !== providerEvidenceIdentity(panel.providerEvidence) + ? { ...panel, providerEvidence } + : panel const previous = current.reviewContext const same = previous && !previous.stale && previous.panelId === panelId && - previous.targetId === panel.targetId && previous.profileId === profileId + previous.targetId === panel.targetId && previous.profileId === profileId && + providerEvidenceIdentity(previous.providerEvidence) === providerEvidenceIdentity(providerEvidence) const reviewContext = same ? previous : { contextId: input.contextId || makeId('c'), panelId, targetId: panel.targetId, profileId, url: panel.url, - mediaKind: mediaKind(panel.url), viewport: viewportFor(panel), - providerEvidence: restoredProviderEvidence(panel.providerEvidence), linkedAt: input.linkedAt || new Date().toISOString(), + mediaKind: mediaKind(panel.url), viewport: viewportFor(nextPanel), + providerEvidence, linkedAt: input.linkedAt || new Date().toISOString(), stale: false, staleReason: '' } return { ...current, + browserPanels: nextPanel === panel ? current.browserPanels : { ...current.browserPanels, [panelId]: nextPanel }, browserSplit: panelId === 'reference' ? true : current.browserSplit, qcProfile: profileId, qcTargetPanelId: panelId, reviewContext, ...(same ? {} : { @@ -502,7 +572,11 @@ function markPanelLoadFailedState(current, panelId) { const panel = current.browserPanels[panelId] const context = current.reviewContext if (!panel || !context || context.stale || context.panelId !== panelId || context.targetId !== panel.targetId) return current - return { ...current, reviewContext: { ...context, stale: true, staleReason: 'load-failed' } } + return { + ...current, + reviewContext: { ...context, stale: true, staleReason: 'load-failed' }, + ...(current.capture?.panelId === panelId ? { capture: null } : {}) + } } function restoredDimension(value) { @@ -543,7 +617,7 @@ function restoredJob(value) { function restoredPanel(value, defaults, legacyUrl = '') { const source = isRecord(value) ? value : {} return { - url: typeof source.url === 'string' ? source.url : legacyUrl, + url: sanitizeUrl(typeof source.url === 'string' ? source.url : legacyUrl), targetId: validId(source.targetId, 't') ? source.targetId : createId('t'), preset: typeof source.preset === 'string' ? source.preset : defaults.preset, width: Number.isFinite(source.width) && source.width >= 240 ? source.width : defaults.width, @@ -574,9 +648,13 @@ function restoredCapture(value) { if (!isRecord(value) || !['result', 'reference'].includes(value.panelId) || !validId(value.targetId, 't')) return null if (!Number.isInteger(value.width) || value.width <= 0 || !Number.isInteger(value.height) || value.height <= 0) return null if (typeof value.path !== 'string' || !value.path) return null + const viewport = isRecord(value.viewport) && Number.isFinite(value.viewport.width) && Number.isFinite(value.viewport.height) + ? { preset: String(value.viewport.preset || ''), width: Math.round(value.viewport.width), height: Math.round(value.viewport.height), responsive: value.viewport.responsive === true } + : undefined return { - panelId: value.panelId, targetId: value.targetId, url: typeof value.url === 'string' ? value.url : '', + panelId: value.panelId, targetId: value.targetId, url: sanitizeUrl(value.url), width: value.width, height: value.height, + ...(viewport ? { viewport } : {}), createdAt: Number.isFinite(value.createdAt) ? value.createdAt : typeof value.createdAt === 'string' ? value.createdAt : '', path: value.path } @@ -676,7 +754,7 @@ function qcDocumentFromState() { // Provider descriptor registry. Midjourney is the first adapter: its QC wire // format is the frozen schema-v1 document contract (`validateQcDocument`). -// Descriptor dimensions MUST be drawn from the persisted schema-v6 candidate +// Descriptor dimensions MUST be drawn from the persisted schema-v7 candidate // dimension vocabulary (`QC_DIMENSIONS`) so structured review state stays // storable without a persisted-schema bump; `assertProviderRegistry` enforces // this at module init. @@ -739,6 +817,30 @@ const PROVIDERS = Object.freeze({ chatImageToolNames: Object.freeze(['higgsfield']), qcDocument: null, automation: null + }), + 'higgsfield-web': Object.freeze({ + id: 'higgsfield-web', + label: 'Higgsfield Web — Unlimited', + profileId: 'higgsfield-image', + candidateIds: CANDIDATE_IDS, + structuredReview: true, + dimensions: Object.freeze(['promptFidelity', 'identityReferenceFidelity', 'anatomyGeometry', 'artifacts', 'colorMaterialFidelity', 'typography', 'composition']), + dimensionLabels: Object.freeze({ + promptFidelity: 'Prompt adherence', + identityReferenceFidelity: 'Subject / product identity', + anatomyGeometry: 'Anatomy & geometry', + artifacts: 'Artifacts & cleanup', + colorMaterialFidelity: 'Color grade & critical colors', + typography: 'Text, logo & labels', + composition: 'Framing & crop' + }), + chatImageToolNames: Object.freeze([]), + qcDocument: null, + automation: Object.freeze({ + target: 'hermes-internal-browser-pane', + recipe: 'higgsfield-web-2026-07-20.v1', + externalBrowserFallback: 'forbidden' + }) }) }) @@ -782,26 +884,71 @@ function qcProfileFor(input = {}) { if (/\.(mp4|mov|webm|mkv|avi)(?:[?#]|$)/i.test(src) || toolName.includes('generate_video')) return 'higgsfield-video' return matches.length ? matches[0].profileId : 'design' } -const AGENT_COMMAND_OPS = ['status', 'set-target', 'link', 'capture', 'inspect', 'page-checks', 'set-check', 'score-candidate', 'select-candidate', 'import-qc'] +const AGENT_COMMAND_OPS = ['status', 'set-target', 'link', 'capture', 'inspect', 'page-checks', 'midjourney-probe', 'midjourney-control', 'higgsfield-control', 'set-check', 'score-candidate', 'select-candidate', 'import-qc'] const AGENT_PANEL_IDS = ['result', 'reference'] const AGENT_CHECK_STATUSES = ['pass', 'fail', 'na', 'pending'] +const MIDJOURNEY_CONTROL_ACTIONS = ['capabilities', 'state', 'navigate', 'probe', 'results', 'settings', 'draft', 'attach', 'detach', 'validate', 'submit', 'wait', 'link', 'grid', 'action', 'download', 'capture', 'qc'] +const HIGGSFIELD_MODELS = ['Seedream 5.0 Lite', 'Nano Banana 2', 'Seedream 4.5'] +const HIGGSFIELD_ASPECTS = ['1:1', '16:9', '9:16'] +const HIGGSFIELD_CONTROL_ACTIONS = ['capabilities', 'state', 'navigate', 'draft', 'validate', 'generate', 'results', 'qc'] function agentCommandError(message) { return { ok: false, error: message } } function hasOnlyKeys(value, keys) { return Object.keys(value).every(key => keys.includes(key)) } function validAgentString(value, max) { return typeof value === 'string' && value.length <= max } +function validateMidjourneyControlPayload(payload) { + if (!isRecord(payload) || !MIDJOURNEY_CONTROL_ACTIONS.includes(payload.action)) return false + const common = ['action'] + if (['capabilities', 'state', 'probe', 'results', 'validate', 'grid', 'capture', 'qc'].includes(payload.action)) return hasOnlyKeys(payload, common) + if (payload.action === 'settings') { + if (hasOnlyKeys(payload, common)) return true + if (!hasOnlyKeys(payload, [...common, 'name', 'value']) || !validAgentString(payload.name, 40)) return false + const choices = { + aspect: ['portrait', 'square', 'landscape'], model: ['standard', 'hd'], raw: ['standard', 'raw'], + speed: ['relax', 'fast'], videoResolution: ['sd', 'hd'], personalization: [true, false] + } + if (Object.hasOwn(choices, payload.name)) return choices[payload.name].includes(payload.value) + return false + } + if (payload.action === 'download') return hasOnlyKeys(payload, [...common, 'jobId', 'filename']) && validAgentString(payload.jobId, 80) && /^[A-Za-z0-9_-]+$/.test(payload.jobId) && validAgentString(payload.filename, 120) && /^[A-Za-z0-9._-]+\.(?:png|jpe?g|webp|gif|avif|bmp)$/i.test(payload.filename) + if (payload.action === 'navigate') return hasOnlyKeys(payload, [...common, 'url']) && validAgentString(payload.url, 4096) && /^https:\/\/(?:www\.)?midjourney\.com(?:\/|$)/i.test(payload.url) + if (payload.action === 'draft') return hasOnlyKeys(payload, [...common, 'prompt', 'parameters']) && validAgentString(payload.prompt, 6000) && payload.prompt.trim().length > 0 && (!Object.hasOwn(payload, 'parameters') || isRecord(payload.parameters)) + if (payload.action === 'attach') return hasOnlyKeys(payload, [...common, 'path', 'role']) && validAgentString(payload.path, 4096) && payload.path.length > 0 && ['start-frame', 'image-prompt', 'style-reference', 'omni-reference'].includes(payload.role) + if (payload.action === 'detach') return hasOnlyKeys(payload, [...common, 'role']) && ['start-frame', 'image-prompt', 'style-reference', 'omni-reference'].includes(payload.role) + if (payload.action === 'submit') return hasOnlyKeys(payload, [...common, 'approved', 'idempotencyKey', 'validateReceipt', 'batchFingerprint']) && payload.approved === true && validAgentString(payload.idempotencyKey, 128) && payload.idempotencyKey.length >= 8 && validAgentString(payload.validateReceipt, 80) && payload.validateReceipt.length > 0 && /^[a-f0-9]{64}$/.test(String(payload.batchFingerprint || '')) + if (payload.action === 'wait') return hasOnlyKeys(payload, [...common, 'timeoutMs']) && (!Object.hasOwn(payload, 'timeoutMs') || Number.isInteger(payload.timeoutMs) && payload.timeoutMs >= 1000 && payload.timeoutMs <= 30000) + if (payload.action === 'link') return hasOnlyKeys(payload, [...common, 'operationId', 'prompt', 'jobId', 'acknowledged', 'ledgerCreatedAt']) && /^[a-f0-9]{64}$/.test(String(payload.operationId || '')) && validAgentString(payload.prompt, 6000) && payload.prompt.trim().length > 0 && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(String(payload.jobId || '')) && payload.acknowledged === true && validAgentString(payload.ledgerCreatedAt, 40) && Number.isFinite(Date.parse(payload.ledgerCreatedAt)) + if (payload.action === 'action') { + const validName = ['select', 'upscale', 'vary', 'reroll', 'pan', 'zoom'].includes(payload.name) + const validCandidate = !Object.hasOwn(payload, 'candidate') || ['A', 'B', 'C', 'D', '1', '2', '3', '4'].includes(String(payload.candidate)) + const validJob = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(String(payload.jobId || '')) + if (payload.name === 'select') return hasOnlyKeys(payload, [...common, 'name', 'candidate', 'jobId']) && validName && Object.hasOwn(payload, 'candidate') && validCandidate && validJob + return hasOnlyKeys(payload, [...common, 'name', 'candidate', 'jobId', 'approved', 'idempotencyKey']) && validName && validCandidate && validJob && payload.approved === true && validAgentString(payload.idempotencyKey, 128) && payload.idempotencyKey.length >= 8 + } + return false +} +function validateHiggsfieldControlPayload(payload) { + if (!isRecord(payload) || !HIGGSFIELD_CONTROL_ACTIONS.includes(payload.action)) return false + if (['capabilities', 'state', 'validate', 'results', 'qc'].includes(payload.action)) return hasOnlyKeys(payload, ['action']) + if (payload.action === 'navigate') return hasOnlyKeys(payload, ['action', 'url']) && validAgentString(payload.url, 4096) && /^https:\/\/(?:www\.)?higgsfield\.ai(?:\/|$)/i.test(payload.url) + if (payload.action === 'draft') return hasOnlyKeys(payload, ['action', 'prompt', 'aspect', 'model']) && validAgentString(payload.prompt, 6000) && payload.prompt.trim().length > 0 && HIGGSFIELD_ASPECTS.includes(payload.aspect) && HIGGSFIELD_MODELS.includes(payload.model) + if (payload.action === 'generate') return hasOnlyKeys(payload, ['action', 'billableConfirmed', 'idempotencyKey', 'validateReceipt', 'batchFingerprint']) && payload.billableConfirmed === true && validAgentString(payload.idempotencyKey, 128) && payload.idempotencyKey.length >= 8 && validAgentString(payload.validateReceipt, 80) && /^[a-f0-9]{64}$/.test(String(payload.batchFingerprint || '')) + return false +} function validateAgentCommand(value) { if (!isRecord(value) || !hasOnlyKeys(value, ['id', 'op', 'panelId', 'payload'])) return agentCommandError('Command must be an object with only id, op, panelId, and payload') if (!validAgentString(value.id, 64) || !value.id) return agentCommandError('id must be a non-empty string of at most 64 characters') if (!AGENT_COMMAND_OPS.includes(value.op)) return agentCommandError('op is unknown') if (!isRecord(value.payload)) return agentCommandError('payload must be an object') - const needsPanel = ['set-target', 'link', 'capture', 'inspect', 'page-checks'].includes(value.op) + const needsPanel = ['set-target', 'link', 'capture', 'inspect', 'page-checks', 'midjourney-probe', 'midjourney-control', 'higgsfield-control'].includes(value.op) if (needsPanel ? !AGENT_PANEL_IDS.includes(value.panelId) : Object.hasOwn(value, 'panelId')) return agentCommandError(needsPanel ? 'panelId must be result or reference' : 'panelId is not allowed for this op') const payload = value.payload if (value.op === 'status' && hasOnlyKeys(payload, [])) return { ok: true, command: value } - if (value.op === 'set-target' && hasOnlyKeys(payload, ['url', 'preset', 'width', 'height']) && validAgentString(payload.url, 4096) && /^(https?|file|data):/i.test(payload.url) && (!Object.hasOwn(payload, 'preset') || validAgentString(payload.preset, 64)) && (!Object.hasOwn(payload, 'width') || Number.isInteger(payload.width) && payload.width >= 240) && (!Object.hasOwn(payload, 'height') || Number.isInteger(payload.height) && payload.height >= 240)) return { ok: true, command: value } + if (value.op === 'set-target' && hasOnlyKeys(payload, ['url', 'preset', 'width', 'height', 'providerEvidence']) && validAgentString(payload.url, 4096) && /^(https?|file|data):/i.test(payload.url) && (!Object.hasOwn(payload, 'preset') || validAgentString(payload.preset, 64)) && (!Object.hasOwn(payload, 'width') || Number.isInteger(payload.width) && payload.width >= 240) && (!Object.hasOwn(payload, 'height') || Number.isInteger(payload.height) && payload.height >= 240) && (!Object.hasOwn(payload, 'providerEvidence') || (restoredProviderEvidence(payload.providerEvidence) !== null && comparableUrl(restoredProviderEvidence(payload.providerEvidence).resultUrl) === comparableUrl(payload.url)))) return { ok: true, command: value } if (value.op === 'link' && hasOnlyKeys(payload, ['profileId']) && (!Object.hasOwn(payload, 'profileId') || QC_PROFILE_IDS.includes(payload.profileId))) return { ok: true, command: value } - if (['capture', 'inspect', 'page-checks'].includes(value.op) && hasOnlyKeys(payload, [])) return { ok: true, command: value } + if (['capture', 'inspect', 'page-checks', 'midjourney-probe'].includes(value.op) && hasOnlyKeys(payload, [])) return { ok: true, command: value } + if (value.op === 'midjourney-control' && validateMidjourneyControlPayload(payload)) return { ok: true, command: value } + if (value.op === 'higgsfield-control' && validateHiggsfieldControlPayload(payload)) return { ok: true, command: value } if (value.op === 'set-check' && hasOnlyKeys(payload, ['profileId', 'checkId', 'status', 'note']) && QC_PROFILE_IDS.includes(payload.profileId) && validAgentString(payload.checkId, 64) && payload.checkId && AGENT_CHECK_STATUSES.includes(payload.status) && (!Object.hasOwn(payload, 'note') || validAgentString(payload.note, 2000))) return { ok: true, command: value } if (value.op === 'score-candidate' && hasOnlyKeys(payload, ['candidateId', 'summary', 'score', 'disposition', 'repairPrompt', 'dimensions']) && CANDIDATE_IDS.includes(payload.candidateId) && (!Object.hasOwn(payload, 'summary') || validAgentString(payload.summary, 2000)) && (!Object.hasOwn(payload, 'score') || Number.isInteger(payload.score) && payload.score >= 0 && payload.score <= 100) && (!Object.hasOwn(payload, 'disposition') || DISPOSITIONS.includes(payload.disposition)) && (!Object.hasOwn(payload, 'repairPrompt') || validAgentString(payload.repairPrompt, 4000)) && (!Object.hasOwn(payload, 'dimensions') || isRecord(payload.dimensions) && Object.entries(payload.dimensions).every(([key, dimension]) => QC_DIMENSIONS.includes(key) && isRecord(dimension) && hasOnlyKeys(dimension, ['score', 'evidence']) && Number.isInteger(dimension.score) && dimension.score >= 0 && dimension.score <= 100 && validAgentString(dimension.evidence, 2000)))) return { ok: true, command: value } if (value.op === 'select-candidate' && hasOnlyKeys(payload, ['candidateId']) && CANDIDATE_IDS.includes(payload.candidateId)) return { ok: true, command: value } @@ -821,14 +968,18 @@ function agentStatusSnapshot(current) { qcTargetPanelId: current.qcTargetPanelId, reviewContext: context ? { contextId: context.contextId, panelId: context.panelId, targetId: context.targetId, profileId: context.profileId, - url: context.url, mediaKind: context.mediaKind, stale: context.stale, staleReason: context.staleReason, + url: sanitizeUrl(context.url), mediaKind: context.mediaKind, stale: context.stale, staleReason: context.staleReason, providerJobId: context.providerEvidence?.jobId || '' } : null, panels: Object.fromEntries(AGENT_PANEL_IDS.map(panelId => { const panel = current.browserPanels[panelId] || {} - return [panelId, { url: panel.url || '', targetId: panel.targetId || '', mediaKind: mediaKind(panel.url) }] + return [panelId, { url: sanitizeUrl(panel.url), targetId: panel.targetId || '', mediaKind: mediaKind(panel.url) }] })), - capture: current.capture ? { panelId: current.capture.panelId, targetId: current.capture.targetId, width: current.capture.width, height: current.capture.height, path: current.capture.path } : null, + capture: current.capture ? { + panelId: current.capture.panelId, targetId: current.capture.targetId, url: sanitizeUrl(current.capture.url), + ...(current.capture.viewport ? { viewport: current.capture.viewport } : {}), + width: current.capture.width, height: current.capture.height, path: current.capture.path + } : null, evaluations: Object.fromEntries(Object.entries(current.evaluations || {}).map(([profileId, checks]) => [profileId, Object.fromEntries(Object.entries(checks || {}).map(([checkId, evaluation]) => [checkId, { status: evaluation.status }]))])), candidates: Object.fromEntries(CANDIDATE_IDS.map(id => { const candidate = current.candidates?.[id] || blankCandidate(id) @@ -844,7 +995,7 @@ function applyAgentCommand(current, command, makeId = createId) { const { op, payload } = command if (op === 'status') return { state: current, summary: 'Status snapshot' } if (op === 'set-target') { - const patch = Object.fromEntries(Object.entries(payload).filter(([key]) => ['url', 'preset', 'width', 'height'].includes(key))) + const patch = Object.fromEntries(Object.entries(payload).filter(([key]) => ['url', 'preset', 'width', 'height', 'providerEvidence'].includes(key))) return { state: updatePanelState(current, command.panelId, patch, {}, makeId), summary: `Set ${command.panelId} target` } } if (op === 'link') { @@ -876,7 +1027,7 @@ function applyAgentCommand(current, command, makeId = createId) { function setState(patch) { state = { ...state, ...patch } - pluginContext?.storage.set('workbench.v6', persistedState()) + pluginContext?.storage.set('workbench.v7', persistedState()) listeners.forEach(listener => listener()) } @@ -969,9 +1120,10 @@ async function captureBrowserPanel(panelId, { save = false } = {}) { panelId, targetId: panel.targetId, url: panel.url, width: kind === 'image' ? element.naturalWidth : element.videoWidth, height: kind === 'image' ? element.naturalHeight : element.videoHeight, - createdAt: Date.now(), path: '' + viewport: viewportFor(panel), createdAt: Date.now(), path: '' } - if (state.browserPanels[panelId]?.targetId !== panel.targetId) { + if (state.browserPanels[panelId]?.targetId !== panel.targetId || + JSON.stringify(viewportFor(state.browserPanels[panelId])) !== JSON.stringify(evidence.viewport)) { throw new Error('Target changed during capture; evidence was not attached') } setState({ capture: evidence }) @@ -993,7 +1145,8 @@ async function captureBrowserPanel(panelId, { save = false } = {}) { throw new Error('Host returned an invalid capture') } if (state.browserPanels[panelId]?.targetId !== startTargetId || - await currentBrowserUrl(browserApi, guestId, webview, startUrl) !== startUrl) { + await currentBrowserUrl(browserApi, guestId, webview, startUrl) !== startUrl || + JSON.stringify(viewportFor(state.browserPanels[panelId])) !== JSON.stringify(viewportFor(panel))) { throw new Error('Target changed during capture; evidence was not attached') } @@ -1007,13 +1160,14 @@ async function captureBrowserPanel(panelId, { save = false } = {}) { } if (state.browserPanels[panelId]?.targetId !== startTargetId || - await currentBrowserUrl(browserApi, guestId, webview, startUrl) !== startUrl) { + await currentBrowserUrl(browserApi, guestId, webview, startUrl) !== startUrl || + JSON.stringify(viewportFor(state.browserPanels[panelId])) !== JSON.stringify(viewportFor(panel))) { throw new Error('Target changed before evidence was attached') } const evidence = { panelId, targetId: startTargetId, url: startUrl, width: capture.width, height: capture.height, - createdAt: capture.createdAt, path + viewport: viewportFor(panel), createdAt: capture.createdAt, path } setState({ capture: evidence }) return { ...evidence, canceled } @@ -1050,6 +1204,535 @@ async function inspectBrowserPanel(panelId) { return summary } +const MIDJOURNEY_SELECTOR_REGISTRY = Object.freeze({ + version: 'mj-web-2026-07-19.v3', + targets: Object.freeze(['composer', 'settings-toggle', 'personalization-toggle', 'personalization-menu', 'add-images', 'add-start-frame', 'add-style-reference', 'add-omni-reference', 'switch-to-image', 'switch-to-video', 'image-file-input', 'start-frame', 'image-prompt', 'style-reference', 'omni', 'aspect-portrait', 'aspect-square', 'aspect-landscape', 'model-standard', 'model-hd', 'raw-standard', 'raw-on', 'speed-relax', 'speed-fast', 'video-sd', 'video-hd', 'personalization-on', 'personalization-off', 'submit', 'select', 'upscale', 'vary', 'reroll', 'pan', 'zoom', 'result-image', 'result-link']) +}) +const HIGGSFIELD_SELECTOR_REGISTRY = Object.freeze({ + version: 'higgsfield-web-2026-07-20.v1', + targets: Object.freeze(['higgsfield-composer', 'higgsfield-aspect-square', 'higgsfield-aspect-portrait', 'higgsfield-aspect-landscape', 'higgsfield-model-seedream-5-lite', 'higgsfield-model-nano-banana-2', 'higgsfield-model-seedream-4-5', 'higgsfield-generate']) +}) +const higgsfieldValidations = new Map() +const higgsfieldDrafts = new Map() +const HIGGSFIELD_MODEL_TARGETS = Object.freeze({ + 'Seedream 5.0 Lite': 'higgsfield-model-seedream-5-lite', + 'Nano Banana 2': 'higgsfield-model-nano-banana-2', + 'Seedream 4.5': 'higgsfield-model-seedream-4-5' +}) +const HIGGSFIELD_ASPECT_TARGETS = Object.freeze({ + '1:1': 'higgsfield-aspect-square', '16:9': 'higgsfield-aspect-landscape', '9:16': 'higgsfield-aspect-portrait' +}) +function isHiggsfieldPage(rawUrl) { + try { + const hostname = new URL(rawUrl).hostname.toLowerCase() + return hostname === 'higgsfield.ai' || hostname.endsWith('.higgsfield.ai') + } catch { return false } +} +function currentHiggsfieldContext(panelId) { + const browserApi = window.hermesDesktop?.browser + const webview = browserWebviews.get(panelId) + const panel = state.browserPanels[panelId] + const guestId = webview?.getWebContentsId?.() + const url = String(webview?.getURL?.() || '') + if (!browserApi?.control || !panel || !Number.isInteger(guestId) || !isHiggsfieldPage(url) || panel.url !== url) { + throw new Error('Open the current Higgsfield page in the Browser pane') + } + return { browserApi, webview, panel, guestId, targetId: panel.targetId, url } +} +function sameHiggsfieldContext(panelId, context) { + const panel = state.browserPanels[panelId] + return Boolean(panel && panel.targetId === context.targetId && panel.url === context.url && + browserWebviews.get(panelId)?.getWebContentsId?.() === context.guestId) +} +async function higgsfieldControl(panelId, request, context = currentHiggsfieldContext(panelId)) { + if (!sameHiggsfieldContext(panelId, context)) throw new Error('Higgsfield panel target or URL changed') + const result = await context.browserApi.control(context.guestId, { + provider: 'higgsfield', recipe: HIGGSFIELD_SELECTOR_REGISTRY.version, expectedUrl: context.url, ...request + }) + if (!sameHiggsfieldContext(panelId, context)) throw new Error('Higgsfield panel target or URL changed') + return result +} +function higgsfieldNode(snapshot, id, role, required = true) { + const matches = Array.isArray(snapshot?.nodes) ? snapshot.nodes.filter(node => node?.id === id && (!role || node.role === role)) : [] + if (matches.length !== 1) { + if (!required && matches.length === 0) return null + throw new Error(`Higgsfield target ${id} is ambiguous or unavailable`) + } + return matches[0] +} +async function runHiggsfieldControl(panelId, payload) { + if (payload.action === 'capabilities') return { summary: 'Higgsfield Web — Unlimited control capabilities', detail: { state: 'READY', evidence: { selectorRegistry: HIGGSFIELD_SELECTOR_REGISTRY.version, models: HIGGSFIELD_MODELS, billableGenerate: true } } } + if (payload.action === 'navigate') { + setBrowserPanel(panelId, { url: payload.url, qcProfileHint: 'higgsfield-image' }) + host.panes?.setOpen?.(BROWSER_PANE_ID, true) + return { summary: `Navigated ${panelId} to Higgsfield`, detail: { state: 'NAVIGATING', evidence: { panelId, requestedUrl: payload.url } } } + } + const context = currentHiggsfieldContext(panelId) + if (payload.action === 'state' || payload.action === 'results') { + const result = await higgsfieldControl(panelId, { op: payload.action === 'results' ? 'results' : 'snapshot' }, context) + return { summary: 'Higgsfield typed browser state', detail: { state: 'OBSERVED', evidence: result } } + } + if (payload.action === 'draft') { + await higgsfieldControl(panelId, { op: 'focusText', targetId: 'higgsfield-composer', text: payload.prompt.trim(), replace: true }, context) + await higgsfieldControl(panelId, { op: 'activate', targetId: HIGGSFIELD_ASPECT_TARGETS[payload.aspect] }, context) + await higgsfieldControl(panelId, { op: 'activate', targetId: HIGGSFIELD_MODEL_TARGETS[payload.model] }, context) + higgsfieldDrafts.set(panelId, { targetId: context.targetId, url: context.url, prompt: payload.prompt.trim(), aspect: payload.aspect, model: payload.model }) + return { summary: 'Higgsfield draft set', detail: { state: 'DRAFT', evidence: { panelId, targetId: context.targetId, url: context.url, prompt: payload.prompt.trim(), aspect: payload.aspect, model: payload.model } } } + } + if (payload.action === 'validate') { + const draft = higgsfieldDrafts.get(panelId) + if (!draft || draft.targetId !== context.targetId || draft.url !== context.url) throw new Error('Set a fresh Higgsfield draft before validation') + const snapshot = await higgsfieldControl(panelId, { op: 'snapshot' }, context) + const prompt = higgsfieldNode(snapshot, 'higgsfield-composer', 'textbox').value + const selected = node => ['true', 'on', 'selected'].includes(String(node?.attrs?.['aria-selected'] || node?.attrs?.['aria-pressed'] || node?.attrs?.['data-selected'] || node?.attrs?.['data-active'] || node?.attrs?.['data-state'] || '').toLowerCase()) + if (prompt !== draft.prompt || + !selected(higgsfieldNode(snapshot, HIGGSFIELD_ASPECT_TARGETS[draft.aspect], 'button')) || + !selected(higgsfieldNode(snapshot, HIGGSFIELD_MODEL_TARGETS[draft.model], 'button'))) { + throw new Error('Higgsfield prompt, aspect, or model changed after draft') + } + const receipt = createId('hfv-') + const batchFingerprint = await midjourneyPromptHash(`${prompt}\n${draft.aspect}\n${draft.model}`) + higgsfieldValidations.set(receipt, { panelId, targetId: context.targetId, url: context.url, prompt, aspect: draft.aspect, model: draft.model, batchFingerprint, expiresAt: Date.now() + 60_000 }) + return { summary: 'Higgsfield draft is generate-ready', receiptContext: { receiptHash: await midjourneyPromptHash(receipt), batchFingerprint }, detail: { state: 'READY', evidence: { prompt, aspect: draft.aspect, model: draft.model, receipt } } } + } + if (payload.action === 'generate') { + const validation = higgsfieldValidations.get(payload.validateReceipt) + if (!validation || validation.expiresAt < Date.now() || validation.panelId !== panelId || validation.targetId !== context.targetId || validation.url !== context.url || validation.batchFingerprint !== payload.batchFingerprint) throw new Error('A fresh matching Higgsfield validate receipt is required') + if (payload.billableConfirmed !== true) throw new Error('Higgsfield Generate is billable and requires billableConfirmed=true') + higgsfieldValidations.delete(payload.validateReceipt) + const snapshot = await higgsfieldControl(panelId, { op: 'snapshot' }, context) + const prompt = higgsfieldNode(snapshot, 'higgsfield-composer', 'textbox').value + const selected = node => ['true', 'on', 'selected'].includes(String(node?.attrs?.['aria-selected'] || node?.attrs?.['aria-pressed'] || node?.attrs?.['data-selected'] || node?.attrs?.['data-active'] || node?.attrs?.['data-state'] || '').toLowerCase()) + if (prompt !== validation.prompt || + !selected(higgsfieldNode(snapshot, HIGGSFIELD_ASPECT_TARGETS[validation.aspect], 'button')) || + !selected(higgsfieldNode(snapshot, HIGGSFIELD_MODEL_TARGETS[validation.model], 'button'))) { + throw new Error('Higgsfield prompt, aspect, or model changed after validation') + } + await higgsfieldControl(panelId, { op: 'generate', targetId: 'higgsfield-generate', billable: true }, context) + return { summary: 'Higgsfield generation submitted (billable external action)', detail: { state: 'SUBMITTED', evidence: { panelId, targetId: context.targetId, receipt: payload.validateReceipt } } } + } + return { summary: 'Higgsfield QC state', detail: { state: 'QC_RUNNING', evidence: { panelId, qc: agentStatusSnapshot(state) } } } +} +const MIDJOURNEY_CAPABILITIES = Object.freeze({ + selectorRegistry: MIDJOURNEY_SELECTOR_REGISTRY.version, + transport: 'window.hermesDesktop.browser.control', + supported: Object.freeze(['capabilities', 'state', 'navigate', 'probe', 'results', 'settings', 'draft', 'attach', 'detach', 'validate', 'submit', 'wait', 'link', 'grid', 'action', 'download', 'capture', 'qc']), + approvalGated: Object.freeze(['submit', 'upscale', 'vary', 'reroll', 'pan', 'zoom']), + implemented: Object.freeze(['settings', 'attach', 'detach', 'draft', 'link', 'grid', 'select', 'upscale', 'vary', 'reroll', 'pan', 'zoom', 'download']) +}) +const midjourneyDrafts = new Map() +const midjourneyAttachments = new Map() +const midjourneyValidations = new Map() +const midjourneyLinks = new Map() +const MIDJOURNEY_LEDGER_MAX = 32 +const MIDJOURNEY_VALIDATION_TTL_MS = 60_000 +const MIDJOURNEY_WAIT_MAX_MS = 30_000 + +function isMidjourneyPage(rawUrl) { + try { + const hostname = new URL(rawUrl).hostname.toLowerCase() + return hostname === 'midjourney.com' || hostname.endsWith('.midjourney.com') + } catch { return false } +} +function midjourneyError(error) { + return String(error instanceof Error ? error.message : error).replace(/[\r\n\t]+/g, ' ').replace(/[^\x20-\x7e]/g, '').slice(0, 300) +} +function boundedMidjourneyLedger(ledger, key, value) { + ledger.set(key, value) + while (ledger.size > MIDJOURNEY_LEDGER_MAX) ledger.delete(ledger.keys().next().value) +} +function midjourneyOperation(stateName, evidence = {}, error = '') { + return { operationId: createId('mj-'), state: stateName, evidence, error: midjourneyError(error) } +} +function currentMidjourneyContext(panelId) { + const browserApi = window.hermesDesktop?.browser + const webview = browserWebviews.get(panelId) + const panel = state.browserPanels[panelId] + const guestId = webview?.getWebContentsId?.() + const url = String(webview?.getURL?.() || '') + if (!browserApi?.control || !panel || !Number.isInteger(guestId) || !isMidjourneyPage(url) || panel.url !== url) { + throw new Error('Open the current Midjourney page in the Browser pane') + } + return { browserApi, webview, panel, guestId, targetId: panel.targetId, url } +} +function sameMidjourneyContext(panelId, context) { + const panel = state.browserPanels[panelId] + return Boolean(panel && panel.targetId === context.targetId && panel.url === context.url && + browserWebviews.get(panelId)?.getWebContentsId?.() === context.guestId && + String(browserWebviews.get(panelId)?.getURL?.() || '') === context.url) +} +async function midjourneyControl(panelId, request, context = currentMidjourneyContext(panelId)) { + if (!sameMidjourneyContext(panelId, context)) throw new Error('Midjourney panel target or URL changed') + const result = await context.browserApi.control(context.guestId, { + recipe: MIDJOURNEY_SELECTOR_REGISTRY.version, expectedUrl: context.url, ...request + }) + if (!sameMidjourneyContext(panelId, context)) throw new Error('Midjourney panel target or URL changed') + return result +} +function midjourneyNodes(snapshot) { + if (!snapshot || typeof snapshot.url !== 'string' || !Array.isArray(snapshot.nodes)) throw new Error('Midjourney snapshot is incomplete') + return snapshot.nodes.slice(0, 48) +} +function midjourneyNode(snapshot, id, role, required = true) { + const matches = midjourneyNodes(snapshot).filter(node => node?.id === id && (!role || node.role === role)) + if (matches.length !== 1) { + if (!required && matches.length === 0) return null + throw new Error(`Midjourney target ${id} is ambiguous or unavailable`) + } + return matches[0] +} +function midjourneyRoleSelected(node) { + const attrs = node?.attrs || {} + return attrs['aria-pressed'] === 'true' || attrs['aria-selected'] === 'true' || + ['active', 'checked', 'on', 'selected', 'true'].includes(String(attrs['data-state'] || '').toLowerCase()) || + String(attrs.class || '').split(/\s+/).includes('text-splash') +} +function normalizedMidjourneyPrompt(value) { + return String(value || '').trim().replace(/\s+/g, ' ') +} +function normalizedMidjourneyPromptBody(value) { + return normalizedMidjourneyPrompt(value).replace(/\s--[a-z][\s\S]*$/i, '').trim() +} +async function midjourneyPromptHash(value) { + const bytes = new TextEncoder().encode(normalizedMidjourneyPrompt(value)) + return [...new Uint8Array(await crypto.subtle.digest('SHA-256', bytes))].map(byte => byte.toString(16).padStart(2, '0')).join('') +} +function midjourneyJobId(rawUrl) { + return midjourneyJobLocation(rawUrl).jobId +} +function selectedMidjourneyRoles(snapshot) { + return ['start-frame', 'image-prompt', 'style-reference', 'omni'] + .filter(id => { + const node = midjourneyNode(snapshot, id, 'button', false) + return Boolean(node?.visible && midjourneyRoleSelected(node)) + }) +} +async function midjourneySnapshot(panelId, context = currentMidjourneyContext(panelId)) { + const snapshot = await midjourneyControl(panelId, { op: 'snapshot' }, context) + if (snapshot.url !== context.url) throw new Error('Midjourney snapshot URL changed') + return { context, snapshot, nodes: midjourneyNodes(snapshot) } +} +async function midjourneySettingsSnapshot(panelId, context, snapshot) { + const openMarker = midjourneyNode(snapshot, 'aspect-portrait', 'button', false) + if (openMarker?.visible) return snapshot + const toggle = midjourneyNode(snapshot, 'settings-toggle', 'button') + if (!toggle.visible || !toggle.enabled) throw new Error('Midjourney settings control is unavailable') + await midjourneyControl(panelId, { op: 'activate', targetId: 'settings-toggle' }, context) + await midjourneyControl(panelId, { op: 'waitFor', targetId: 'aspect-portrait', predicate: 'visible', timeoutMs: 5000 }, context) + return (await midjourneySnapshot(panelId, context)).snapshot +} +function midjourneySettingsEvidence(snapshot) { + const selected = (ids) => ids.map(id => midjourneyNode(snapshot, id, 'button', false)).find(node => node?.visible && midjourneyRoleSelected(node))?.id || '' + const personalization = midjourneyNode(snapshot, 'personalization-toggle', 'button', false) + return { + aspect: selected(['aspect-portrait', 'aspect-square', 'aspect-landscape']).replace('aspect-', ''), + model: selected(['model-standard', 'model-hd']).replace('model-', ''), + raw: selected(['raw-standard', 'raw-on']).replace('raw-on', 'raw').replace('raw-', ''), + speed: selected(['speed-relax', 'speed-fast']).replace('speed-', ''), + videoResolution: selected(['video-sd', 'video-hd']).replace('video-', ''), + personalization: Boolean(personalization?.visible && /\bPersonalize\b/.test(personalization.text)) + } +} +function midjourneyGrid(snapshot) { + const image = midjourneyNodes(snapshot).find(node => node.id === 'result-image' && node.role === 'image' && node.visible) + return { candidateCount: 0, candidates: [], compositeVisible: Boolean(image) } +} +async function probeMidjourneyPanel(panelId) { + const { context, snapshot, nodes } = await midjourneySnapshot(panelId) + const grid = midjourneyGrid(snapshot) + const jobStatus = grid.candidateCount ? 'GRID_READY' : nodes.some(node => node.id === 'submit' && node.visible) ? 'READY' : 'BROWSING' + const semanticTrace = Array.isArray(snapshot.semanticTrace) ? snapshot.semanticTrace.slice(0, 120) : [] + const evidence = { panelId, targetId: context.targetId, url: context.url, nodes, semanticTrace, checkedAt: new Date().toISOString() } + return { snapshot: { registryVersion: MIDJOURNEY_SELECTOR_REGISTRY.version, url: context.url, targets: nodes, semanticTrace, jobStatus, grid }, summary: `Midjourney typed snapshot · ${nodes.length} targets`, evidence } +} +function formatMidjourneyDraft(prompt, parameters = {}) { + const specs = { ar: value => /^\d{1,3}:\d{1,3}$/.test(String(value)), chaos: value => Number.isFinite(Number(value)) && Number(value) >= 0 && Number(value) <= 100, quality: value => ['.25', '.5', '1', '2'].includes(String(value)), seed: value => /^\d{1,10}$/.test(String(value)), stylize: value => Number.isFinite(Number(value)) && Number(value) >= 0 && Number(value) <= 1000, weird: value => Number.isFinite(Number(value)) && Number(value) >= 0 && Number(value) <= 3000, version: value => /^[\w.-]{1,16}$/.test(String(value)), profile: value => /^[\w.-]{1,64}$/.test(String(value)), raw: value => value === true, tile: value => value === true, no: value => typeof value === 'string' && value.trim().length > 0 && value.length <= 500 } + const flags = [] + for (const [key, value] of Object.entries(parameters)) { + if (!Object.hasOwn(specs, key) || !specs[key](value)) throw new Error(`Unsupported or invalid Midjourney parameter: ${key}`) + flags.push(value === true ? `--${key}` : `--${key} ${String(value).trim()}`) + } + return `${prompt.trim()}${flags.length ? ` ${flags.join(' ')}` : ''}` +} +async function validationFor(panelId, context, snapshot) { + const draft = midjourneyDrafts.get(panelId) + const attachments = midjourneyAttachments.get(panelId) || [] + const composer = midjourneyNode(snapshot, 'composer', 'textbox') + const settings = ['start-frame', 'image-prompt', 'style-reference', 'omni'].map(id => midjourneyNode(snapshot, id, 'button', false)).filter(Boolean) + const exactPrompt = Boolean(draft && draft.text === composer.value && draft.targetId === context.targetId && draft.url === context.url) + const roles = attachments.map(item => item.role) + const receipt = createId('mjv-') + const createdAt = Date.now() + const selectedSettings = settings.filter(midjourneyRoleSelected) + const evidence = { panelId, targetId: context.targetId, url: context.url, composer: composer.value, exactPrompt, attachmentRoles: roles, settings: settings.map(node => ({ id: node.id, visible: node.visible, enabled: node.enabled, selected: midjourneyRoleSelected(node) })), timestamp: new Date(createdAt).toISOString(), expiresAt: new Date(createdAt + MIDJOURNEY_VALIDATION_TTL_MS).toISOString() } + const approved = exactPrompt && midjourneyNode(snapshot, 'submit', null).visible && roles.every(role => selectedSettings.some(node => node.id === role && node.visible)) && selectedSettings.every(node => roles.includes(node.id)) + const batchFingerprint = await midjourneyPromptHash(JSON.stringify({ panelId, targetId: context.targetId, composer: composer.value, attachmentRoles: roles, settings: selectedSettings.map(node => node.id) })) + const validation = { receipt, batchFingerprint, approved, createdAt, expiresAt: createdAt + MIDJOURNEY_VALIDATION_TTL_MS, evidence } + boundedMidjourneyLedger(midjourneyValidations, receipt, validation) + return validation +} +function requireMidjourneyValidation(receipt, batchFingerprint, panelId, context, snapshot) { + const validation = midjourneyValidations.get(receipt) + const composer = midjourneyNode(snapshot, 'composer', 'textbox') + if (!validation || !validation.approved || validation.expiresAt < Date.now() || validation.batchFingerprint !== batchFingerprint || + validation.evidence.panelId !== panelId || validation.evidence.targetId !== context.targetId || + validation.evidence.url !== context.url || validation.evidence.composer !== composer.value) { + throw new Error('A fresh matching Midjourney validate receipt is required and batch identity must match') + } + return validation +} +async function runMidjourneyControl(panelId, payload) { + let envelope = midjourneyOperation('FAILED') + try { + if (payload.action === 'capabilities') return { summary: 'Midjourney control capabilities', detail: { ...envelope, state: 'READY', evidence: MIDJOURNEY_CAPABILITIES } } + if (payload.action === 'navigate') { + const panel = state.browserPanels[panelId] + const currentUrl = panel?.url || '' + const link = midjourneyLinks.get(panelId) + const panelEvidence = restoredProviderEvidence(panel?.providerEvidence) + const candidateJobId = link?.jobId || (panelEvidence?.source === 'midjourney' ? panelEvidence.jobId : '') + const preserveMidjourneyCandidate = sameMidjourneyCandidateSwitch(currentUrl, payload.url, candidateJobId) + if (!preserveMidjourneyCandidate) midjourneyLinks.delete(panelId) + setBrowserPanel(panelId, preserveMidjourneyCandidate + ? { url: payload.url } + : { url: payload.url, qcProfileHint: 'midjourney' }, { + preserveMidjourneyCandidate, + preserveProviderEvidence: preserveMidjourneyCandidate, + preserveQcProfileHint: preserveMidjourneyCandidate + }) + host.panes?.setOpen?.(BROWSER_PANE_ID, true) + return { summary: `Navigated ${panelId} to ${payload.url}`, detail: { ...envelope, state: 'NAVIGATING', evidence: { panelId, requestedUrl: payload.url } } } + } + const { context, snapshot } = await midjourneySnapshot(panelId) + if (payload.action === 'probe' || payload.action === 'state') { + const probed = await probeMidjourneyPanel(panelId) + return { summary: probed.summary, detail: { ...envelope, state: probed.snapshot.jobStatus, evidence: probed.evidence, snapshot: probed.snapshot } } + } + if (payload.action === 'results') { + const resultState = await midjourneyControl(panelId, { op: 'results' }, context) + return { summary: 'Midjourney bounded result evidence', detail: { ...envelope, state: 'OBSERVED', evidence: resultState } } + } + if (payload.action === 'settings') { + if (payload.name === 'personalization') { + let personalizationSnapshot = snapshot + const openSettings = midjourneyNode(personalizationSnapshot, 'aspect-portrait', 'button', false) + if (openSettings?.visible) { + await midjourneyControl(panelId, { op: 'activate', targetId: 'settings-toggle' }, context) + await midjourneyControl(panelId, { op: 'waitFor', targetId: 'personalization-toggle', predicate: 'enabled', timeoutMs: 5000 }, context) + personalizationSnapshot = (await midjourneySnapshot(panelId, context)).snapshot + } + const before = midjourneyNode(personalizationSnapshot, 'personalization-toggle', 'button') + if (!before.visible || !before.enabled) throw new Error('Midjourney personalization control is unavailable') + const enabled = /\bPersonalize\b/.test(before.text) + if (enabled !== payload.value) { + if (payload.value) { + await midjourneyControl(panelId, { op: 'activate', targetId: 'personalization-toggle' }, context) + } else { + await midjourneyControl(panelId, { op: 'activate', targetId: 'personalization-menu' }, context) + await midjourneyControl(panelId, { op: 'waitFor', targetId: 'personalization-off', predicate: 'enabled', timeoutMs: 5000 }, context) + await midjourneyControl(panelId, { op: 'activate', targetId: 'personalization-off' }, context) + } + } + let after = (await midjourneySnapshot(panelId, context)).snapshot + const readback = midjourneyNode(after, 'personalization-toggle', 'button') + if (/\bPersonalize\b/.test(readback.text) !== payload.value) throw new Error('Midjourney personalization readback failed') + if (midjourneyNode(after, 'personalization-off', 'button', false)?.visible) { + await midjourneyControl(panelId, { op: 'activate', targetId: 'personalization-menu' }, context) + after = (await midjourneySnapshot(panelId, context)).snapshot + if (midjourneyNode(after, 'personalization-off', 'button', false)?.visible) throw new Error('Midjourney personalization menu did not close') + } + return { summary: `Midjourney personalization ${payload.value ? 'on' : 'off'}`, detail: { ...envelope, state: 'SETTINGS', evidence: { panelId, targetId: context.targetId, url: context.url, personalization: payload.value } } } + } + let current = await midjourneySettingsSnapshot(panelId, context, snapshot) + if (!Object.hasOwn(payload, 'name')) { + return { summary: 'Midjourney settings readback', detail: { ...envelope, state: 'SETTINGS', evidence: { panelId, targetId: context.targetId, url: context.url, settings: midjourneySettingsEvidence(current) } } } + } + const optionTargets = { + aspect: { portrait: 'aspect-portrait', square: 'aspect-square', landscape: 'aspect-landscape' }, + model: { standard: 'model-standard', hd: 'model-hd' }, raw: { standard: 'raw-standard', raw: 'raw-on' }, + speed: { relax: 'speed-relax', fast: 'speed-fast' }, videoResolution: { sd: 'video-sd', hd: 'video-hd' } + } + const targetId = optionTargets[payload.name]?.[payload.value] + if (!targetId) throw new Error('Midjourney setting target is unsupported') + const option = midjourneyNode(current, targetId, 'button') + if (!option.visible || !option.enabled) throw new Error(`Midjourney setting ${payload.name} is unavailable`) + if (!midjourneyRoleSelected(option)) await midjourneyControl(panelId, { op: 'activate', targetId }, context) + current = (await midjourneySnapshot(panelId, context)).snapshot + const settings = midjourneySettingsEvidence(current) + const actual = settings[payload.name] + if (actual !== payload.value) throw new Error(`Midjourney setting ${payload.name} readback failed`) + return { summary: `Midjourney setting ${payload.name}=${payload.value}`, detail: { ...envelope, state: 'SETTINGS', evidence: { panelId, targetId: context.targetId, url: context.url, settings } } } + } + if (payload.action === 'grid') { + const link = midjourneyLinks.get(panelId) + const jobId = midjourneyJobId(context.url) + if (!link || !jobId || link.jobId !== jobId) throw new Error('Midjourney grid requires the exact linked current job') + const resultState = await midjourneyControl(panelId, { op: 'results' }, context) + const currentJob = resultState.currentJob + const quadrants = currentJob?.jobId === jobId && Array.isArray(currentJob.compositeGrid?.quadrants) + ? currentJob.compositeGrid.quadrants : [] + const labels = quadrants.map(item => item?.label) + if (quadrants.length !== 4 || labels.join('') !== 'ABCD' || quadrants.some(item => !item?.bounds || item.bounds.width <= 0 || item.bounds.height <= 0)) { + throw new Error('Midjourney current job does not expose a verified complete A-D grid') + } + const candidates = quadrants.map((item, index) => ({ candidateId: item.label, ordinal: index + 1, quadrant: item.bounds, sourceKind: 'composite', jobId })) + return { summary: 'Midjourney A-D grid verified', detail: { ...envelope, state: 'GRID_READY', evidence: { panelId, targetId: context.targetId, url: context.url, jobId, operationId: link.operationId, promptHash: link.promptHash, candidates } } } + } + if (payload.action === 'draft') { + const text = formatMidjourneyDraft(payload.prompt, payload.parameters || {}) + await midjourneyControl(panelId, { op: 'focusText', targetId: 'composer', text, replace: true }, context) + const after = (await midjourneySnapshot(panelId, context)).snapshot + if (midjourneyNode(after, 'composer', 'textbox').value !== text) throw new Error('Midjourney composer readback did not match the requested draft') + boundedMidjourneyLedger(midjourneyDrafts, panelId, { targetId: context.targetId, url: context.url, text, setAt: new Date().toISOString() }) + return { summary: `Draft set (${text.length} characters)`, detail: { ...envelope, state: 'DRAFT', evidence: { panelId, targetId: context.targetId, url: context.url, composer: text } } } + } + if (payload.action === 'attach') { + const role = payload.role === 'omni-reference' ? 'omni' : payload.role + let current = snapshot + const modeSwitch = role === 'start-frame' ? 'switch-to-video' : 'switch-to-image' + const existingInput = midjourneyNode(current, 'image-file-input', null, false) + if (!existingInput) { + const opener = { + 'image-prompt': 'add-images', + 'start-frame': 'add-start-frame', + 'style-reference': 'add-style-reference', + omni: 'add-omni-reference' + }[role] + const add = midjourneyNode(current, opener, 'button') + if (!add.visible || !add.enabled) throw new Error(`Midjourney ${role} attachment control is unavailable`) + await midjourneyControl(panelId, { op: 'activate', targetId: opener }, context) + await midjourneyControl(panelId, { op: 'waitFor', targetId: 'image-file-input', predicate: 'enabled', timeoutMs: 5000 }, context) + current = (await midjourneySnapshot(panelId, context)).snapshot + } + const requestedMode = role === 'start-frame' ? 'video' : 'image' + if (current.composerMode !== requestedMode) { + const switchNode = midjourneyNode(current, modeSwitch, 'button', false) + if (!switchNode?.visible || !switchNode.enabled) throw new Error(`Midjourney ${requestedMode} composer mode is not positively available`) + await midjourneyControl(panelId, { op: 'activate', targetId: modeSwitch }, context) + current = (await midjourneySnapshot(panelId, context)).snapshot + } + if (current.composerMode !== requestedMode) throw new Error(`Midjourney ${requestedMode} composer mode readback failed`) + const beforeRoles = selectedMidjourneyRoles(current) + if (beforeRoles.length) throw new Error(`Midjourney attachment precondition is not empty: ${beforeRoles.join(', ')}`) + const input = midjourneyNode(current, 'image-file-input', null) + if (!input.enabled) throw new Error('Midjourney image file input is unavailable') + await midjourneyControl(panelId, { op: 'setFileInput', targetId: 'image-file-input', filePath: payload.path }, context) + const roleDeadline = Date.now() + 10000 + do { + current = (await midjourneySnapshot(panelId, context)).snapshot + const requestedRole = midjourneyNode(current, role, 'button', false) + if (requestedRole && midjourneyRoleSelected(requestedRole)) break + await new Promise(resolve => setTimeout(resolve, 250)) + } while (Date.now() <= roleDeadline) + await midjourneyControl(panelId, { op: 'waitFor', targetId: role, predicate: 'visible', timeoutMs: 10000 }, context) + current = (await midjourneySnapshot(panelId, context)).snapshot + const selectedRoles = selectedMidjourneyRoles(current) + if (selectedRoles.length !== 1 || selectedRoles[0] !== role) throw new Error(`Midjourney attachment roles did not read back exactly: ${selectedRoles.join(', ') || 'none'}`) + const roleNode = midjourneyNode(current, role, 'button') + if (!roleNode.visible || !roleNode.enabled || !midjourneyRoleSelected(roleNode)) throw new Error(`Midjourney attachment role ${role} was not verified`) + const readback = { roleControl: roleNode.text, confirmed: midjourneyRoleSelected(roleNode) } + if (!readback.confirmed) throw new Error(`Midjourney attachment role ${role} readback did not confirm selection`) + const attachments = [...(midjourneyAttachments.get(panelId) || []).filter(item => item.role !== role), { role, targetId: context.targetId, url: context.url, attachedAt: new Date().toISOString() }].slice(-4) + boundedMidjourneyLedger(midjourneyAttachments, panelId, attachments) + return { summary: `Attached image as ${role}`, detail: { ...envelope, state: 'READY', evidence: { panelId, targetId: context.targetId, url: context.url, attachmentRoles: attachments.map(item => item.role), roleReadback: readback } } } + } + if (payload.action === 'detach') { + const role = payload.role === 'omni-reference' ? 'omni' : payload.role + const selectedRoles = selectedMidjourneyRoles(snapshot) + if (selectedRoles.length !== 1 || selectedRoles[0] !== role) throw new Error(`Midjourney ${role} is not the sole selected attachment role`) + const clear = midjourneyNode(snapshot, role, 'button') + if (!clear.visible || !clear.enabled || !/^Clear\b/.test(clear.text)) throw new Error(`Midjourney ${role} has no exact typed clear action`) + await midjourneyControl(panelId, { op: 'activate', targetId: role }, context) + const deadline = Date.now() + 5000 + let after = snapshot + do { + after = (await midjourneySnapshot(panelId, context)).snapshot + if (!selectedMidjourneyRoles(after).length) break + await new Promise(resolve => setTimeout(resolve, 250)) + } while (Date.now() <= deadline) + if (selectedMidjourneyRoles(after).length) throw new Error(`Midjourney ${role} cleanup readback failed`) + boundedMidjourneyLedger(midjourneyAttachments, panelId, (midjourneyAttachments.get(panelId) || []).filter(item => item.role !== role)) + return { summary: `Detached ${role}`, detail: { ...envelope, state: 'READY', evidence: { panelId, targetId: context.targetId, url: context.url, attachmentRoles: [] } } } + } + if (payload.action === 'validate') { + const validation = await validationFor(panelId, context, snapshot) + return { summary: validation.approved ? 'Midjourney draft is submit-ready' : 'Midjourney draft is not submit-ready', receiptContext: { receiptHash: await midjourneyPromptHash(validation.receipt), batchContextId: validation.batchFingerprint, expiresAt: validation.evidence.expiresAt, batchFingerprint: validation.batchFingerprint }, detail: { ...envelope, state: validation.approved ? 'READY' : 'FAILED', evidence: validation.evidence, validation } } + } + if (payload.action === 'link') { + const resultState = await midjourneyControl(panelId, { op: 'results' }, context) + const requestedPrompt = normalizedMidjourneyPrompt(payload.prompt) + const requestedJobId = String(payload.jobId).toLowerCase() + const submittedAt = Date.parse(payload.ledgerCreatedAt) + const latestAt = submittedAt + 30 * 60_000 + const matches = (Array.isArray(resultState.results) ? resultState.results : []).filter(result => { + const createdAt = Date.parse(result?.createdAt || '') + return /^[0-9a-f-]{36}$/i.test(String(result?.jobId || '')) && + String(result.jobId).toLowerCase() === requestedJobId && + (!result?.prompt || normalizedMidjourneyPromptBody(result.prompt) === normalizedMidjourneyPromptBody(requestedPrompt)) && + (!result?.createdAt || Number.isFinite(createdAt) && createdAt >= submittedAt - 5000 && createdAt <= latestAt) + }) + if (matches.length !== 1) throw new Error(`Midjourney acknowledged submit linkage is ${matches.length ? 'ambiguous' : 'unavailable'}`) + const linked = matches[0] + const promptHash = await midjourneyPromptHash(requestedPrompt) + const record = { jobId: requestedJobId, operationId: payload.operationId, promptHash, submittedAt: payload.ledgerCreatedAt, providerCreatedAt: linked.createdAt, linkedAt: new Date().toISOString() } + boundedMidjourneyLedger(midjourneyLinks, panelId, record) + const requestedUrl = `https://www.midjourney.com/jobs/${record.jobId}` + setBrowserPanel(panelId, { + url: requestedUrl, qcProfileHint: 'midjourney', + providerEvidence: { source: 'midjourney', jobId: record.jobId, operationId: record.operationId, resultUrl: requestedUrl } + }) + host.panes?.setOpen?.(BROWSER_PANE_ID, true) + return { summary: `Linked Midjourney job ${record.jobId}`, detail: { ...envelope, state: 'NAVIGATING', evidence: { panelId, sourceTargetId: context.targetId, sourceUrl: context.url, requestedUrl, ...record } } } + } + if (payload.action === 'submit' || payload.action === 'action') { + const name = payload.action === 'submit' ? 'submit' : payload.name + if (name !== 'select' && payload.approved !== true) throw new Error(`${name} requires approved=true`) + if (payload.action === 'submit') requireMidjourneyValidation(payload.validateReceipt, payload.batchFingerprint, panelId, context, snapshot) + if (payload.action === 'action') { + const link = midjourneyLinks.get(panelId) + const currentJobId = midjourneyJobId(context.url) + if (!link || !currentJobId || link.jobId !== currentJobId || payload.jobId.toLowerCase() !== currentJobId) throw new Error('Midjourney result action requires the exact linked current job') + } + const target = name + const node = midjourneyNode(snapshot, target, target === 'submit' ? null : 'button') + if (!node.visible || !node.enabled) throw new Error(`Midjourney ${target} control is unavailable`) + const activation = { op: 'activate', targetId: target } + if (payload.action === 'action' && payload.candidate) activation.candidate = payload.candidate + await midjourneyControl(panelId, activation, context) + return { summary: `${name} activated once`, detail: { ...envelope, state: name === 'submit' ? 'SUBMITTED' : 'MUTATED', evidence: { panelId, targetId: context.targetId, url: context.url, jobId: payload.jobId || '', candidate: payload.candidate || '', action: name, idempotencyKey: payload.idempotencyKey } } } + } + if (payload.action === 'wait') { + const timeoutMs = Math.min(payload.timeoutMs || 5000, MIDJOURNEY_WAIT_MAX_MS) + const link = midjourneyLinks.get(panelId) + const jobId = midjourneyJobId(context.url) + if (!link || !jobId || link.jobId !== jobId) throw new Error('Midjourney wait requires the exact linked current job') + const result = await midjourneyControl(panelId, { op: 'waitFor', targetId: 'result-image', predicate: 'visible', timeoutMs }, context) + return { summary: 'Midjourney result state observed', detail: { ...envelope, state: 'RESULT_READY', evidence: { panelId, targetId: context.targetId, url: context.url, jobId, timeoutMs, result: result.node || null } } } + } + if (payload.action === 'download') { + const jobId = payload.jobId + const filename = payload.filename + const currentJobId = midjourneyJobId(context.url) + const link = midjourneyLinks.get(panelId) + if (!currentJobId || currentJobId !== jobId.toLowerCase() || link?.jobId !== currentJobId) throw new Error('Midjourney download job does not match the exact linked current page') + const safe = `midjourney/${jobId}/${filename}` + if (!/^midjourney\/[A-Za-z0-9_-]{1,80}\/[A-Za-z0-9._-]{1,120}\.(?:png|jpe?g|webp|gif|avif|bmp)$/i.test(safe)) throw new Error('Midjourney download artifact path is unsafe') + const result = await midjourneyControl(panelId, { op: 'download', targetId: 'result-image', artifactRelativePath: safe }, context) + if (!/^image\//i.test(result.mime || '') || !Number.isFinite(result.bytes) || result.bytes <= 0 || !Number.isFinite(result.width) || result.width <= 0 || !Number.isFinite(result.height) || result.height <= 0) throw new Error('Midjourney download did not return verified image provenance') + return { summary: `Downloaded ${filename}`, detail: { ...envelope, state: 'DOWNLOADED', evidence: { panelId, targetId: context.targetId, url: context.url, jobId, artifactRelativePath: safe, artifact: result } } } + } + if (payload.action === 'capture') { + const captured = await captureBrowserPanel(panelId) + return { summary: `Captured ${captured.width}×${captured.height}`, detail: { ...envelope, state: 'CAPTURED', evidence: { panelId, targetId: context.targetId, url: context.url, capture: captured } } } + } + if (payload.action === 'qc') return { summary: 'Midjourney QC state', detail: { ...envelope, state: 'QC_RUNNING', evidence: { panelId, targetId: context.targetId, url: context.url, qc: agentStatusSnapshot(state) } } } + throw new Error(`Unsupported Midjourney control action: ${payload.action}`) + } catch (error) { + envelope = { ...envelope, error: midjourneyError(error) } + throw new Error(envelope.error) + } +} + async function runDesignPageChecks(panelId) { if (!reviewContextMatches(state, 'design')) { throw new Error('Link the target in the Browser pane before running page checks') @@ -1106,6 +1789,8 @@ async function dispatchAgentCommand(ctx, received, seenIds) { let ok = false let summary = '' let error = '' + let detail = null + let receiptContext = null try { if (!commandId || seenIds.has(commandId)) throw new Error(commandId ? 'Duplicate command id' : 'Command id is required') seenIds.add(commandId) @@ -1120,6 +1805,20 @@ async function dispatchAgentCommand(ctx, received, seenIds) { ? await inspectBrowserPanel(command.panelId) : await runDesignPageChecks(command.panelId) summary = typeof result === 'string' ? result : command.op === 'capture' ? `Captured ${command.panelId}` : command.op + } else if (command.op === 'midjourney-probe') { + const result = await probeMidjourneyPanel(command.panelId) + summary = result.summary + detail = { ...midjourneyOperation(result.snapshot.jobStatus, result.evidence), snapshot: result.snapshot } + } else if (command.op === 'midjourney-control') { + const result = await runMidjourneyControl(command.panelId, command.payload) + summary = result.summary + detail = result.detail + receiptContext = result.receiptContext || null + } else if (command.op === 'higgsfield-control') { + const result = await runHiggsfieldControl(command.panelId, command.payload) + summary = result.summary + detail = result.detail + receiptContext = result.receiptContext || null } else if (command.op === 'import-qc') { if (!reviewContextMatches(state, state.qcProfile)) throw new Error('Link the target in the Browser pane before editing QC') const provider = providerForProfile(state.qcProfile) @@ -1139,12 +1838,13 @@ async function dispatchAgentCommand(ctx, received, seenIds) { } ok = true } catch (caught) { - error = caught instanceof Error ? caught.message : String(caught) + error = midjourneyError(caught) + if (op === 'midjourney-control' || op === 'midjourney-probe') detail = midjourneyOperation('FAILED', {}, error) } const message = ok ? summary : error setState({ agentActivity: { op, at: new Date().toISOString(), ok, summary: message } }) host.notify({ kind: ok ? 'success' : 'warning', message: `Agent QC · ${op} — ${message}` }) - const ack = { id: commandId, ok, error: ok ? '' : error, summary: message, state: agentStatusSnapshot(state) } + const ack = { id: commandId, ok, error: ok ? '' : error, summary: message, ...(detail ? { detail } : {}), ...(receiptContext ? { receiptContext } : {}), state: agentStatusSnapshot(state) } try { await ctx.rest('/result', { method: 'POST', body: ack }) } catch (postError) { @@ -1274,8 +1974,11 @@ function BrowserSurface({ panelId, url, viewport }) { const nextUrl = String(event?.url || element.getURL?.() || '') const currentUrl = state.browserPanels[panelId]?.url || '' if (nextUrl && nextUrl !== currentUrl) { - const preserveProvenance = comparableUrl(nextUrl) === comparableUrl(currentUrl) + const link = midjourneyLinks.get(panelId) + const preserveMidjourneyCandidate = sameMidjourneyCandidateSwitch(currentUrl, nextUrl, link?.jobId) + const preserveProvenance = preserveMidjourneyCandidate || comparableUrl(nextUrl) === comparableUrl(currentUrl) setBrowserPanel(panelId, { url: nextUrl }, { + preserveMidjourneyCandidate, preserveProviderEvidence: preserveProvenance, preserveQcProfileHint: preserveProvenance }) @@ -1635,7 +2338,9 @@ function QcTargetCard({ workbench }) { const panel = workbench.browserPanels[panelId] || DEFAULT_BROWSER_PANELS[panelId] const viewport = viewportFor(panel) const hasTarget = Boolean(panel.url) - const linkedCapture = workbench.capture?.panelId === panelId && workbench.capture?.targetId === panel.targetId + const linkedCapture = workbench.capture?.panelId === panelId && workbench.capture?.targetId === panel.targetId && + workbench.capture?.url === panel.url && + JSON.stringify(workbench.capture?.viewport) === JSON.stringify(viewportFor(panel)) ? workbench.capture : null const [status, setStatus] = useState('') @@ -1820,7 +2525,9 @@ function QcReadinessCard({ workbench, profileId, provider }) { const panelId = workbench.qcTargetPanelId || 'result' const panel = workbench.browserPanels[panelId] || DEFAULT_BROWSER_PANELS[panelId] const hasTarget = Boolean(panel.url) - const hasCapture = Boolean(workbench.capture?.panelId === panelId && workbench.capture?.targetId === panel.targetId) + const hasCapture = Boolean(workbench.capture?.panelId === panelId && workbench.capture?.targetId === panel.targetId && + workbench.capture?.url === panel.url && + JSON.stringify(workbench.capture?.viewport) === JSON.stringify(viewportFor(panel))) const hasInspection = Boolean(panel.inspection?.checkedAt && comparableUrl(panel.inspection.url) === comparableUrl(panel.url)) const hasReviewContext = reviewContextMatches(workbench, profileId) const providerEvidence = hasReviewContext ? workbench.reviewContext.providerEvidence : null @@ -1870,10 +2577,10 @@ function QcReadinessCard({ workbench, profileId, provider }) { detail: hasInspection ? panel.inspection.summary : 'Inspect the live Browser guest and viewport.' }) : null, isHiggsfield ? jsx(ReadinessRow, { - label: 'MCP metadata', value: providerEvidence ? 'LINKED' : 'NOT LINKED', variant: providerEvidence ? 'default' : 'warn', + label: 'Provider metadata', value: providerEvidence ? 'LINKED' : 'NOT LINKED', variant: providerEvidence ? 'default' : 'warn', detail: providerEvidence ? [providerEvidence.jobId || 'job id unavailable', providerEvidence.model || 'model unavailable', providerEvidence.soulId ? `Soul ${providerEvidence.soulId}` : ''].filter(Boolean).join(' · ') - : 'Open the asset from a Higgsfield MCP tool result to bind job, model, prompt, and output settings.' + : 'Bind provenance from the Higgsfield CLI bridge or a Higgsfield tool result to attach job, model, prompt, and output settings.' }) : null, providerEvidence ? jsxs('div', { style: { padding: '8px 0 2px' }, children: [ jsxs('div', { style: { display: 'flex', flexWrap: 'wrap', gap: 5 }, children: [ @@ -2198,7 +2905,9 @@ function QcPane() { const targetPanel = workbench.browserPanels[targetPanelId] || DEFAULT_BROWSER_PANELS.result const hasResult = Boolean(targetPanel.url) const hasReference = Boolean(workbench.browserPanels.reference.url) - const hasCapture = workbench.capture?.panelId === targetPanelId && workbench.capture?.targetId === targetPanel.targetId + const hasCapture = workbench.capture?.panelId === targetPanelId && workbench.capture?.targetId === targetPanel.targetId && + workbench.capture?.url === targetPanel.url && + JSON.stringify(workbench.capture?.viewport) === JSON.stringify(viewportFor(targetPanel)) return jsxs('div', { style: { display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0 }, @@ -2234,14 +2943,15 @@ export default { version: PLUGIN_VERSION, register(ctx) { pluginContext = ctx + const savedV7 = ctx.storage.get('workbench.v7', null) const savedV6 = ctx.storage.get('workbench.v6', null) const savedV5 = ctx.storage.get('workbench.v5', null) const savedV4 = ctx.storage.get('workbench.v4', null) const savedV3 = ctx.storage.get('workbench.v3', null) const savedV2 = ctx.storage.get('workbench.v2', null) const savedV1 = ctx.storage.get('workbench.v1', DEFAULT_STATE) - state = restoredState(savedV6 || savedV5 || savedV4 || savedV3 || savedV2 || savedV1) - ctx.storage.set('workbench.v6', persistedState()) + state = restoredState(savedV7 || savedV6 || savedV5 || savedV4 || savedV3 || savedV2 || savedV1) + ctx.storage.set('workbench.v7', persistedState()) const seenAgentCommandIds = new Set() ctx.socket('/commands', received => { void dispatchAgentCommand(ctx, received, seenAgentCommandIds) }) diff --git a/scripts/higgsfield-control.mjs b/scripts/higgsfield-control.mjs new file mode 100644 index 0000000..7ba27c3 --- /dev/null +++ b/scripts/higgsfield-control.mjs @@ -0,0 +1,197 @@ +#!/usr/bin/env node + +import { execFile } from 'node:child_process' +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { realpathSync } from 'node:fs' +import { promisify } from 'node:util' + +import { providerEvidenceFor } from './qc-core.mjs' + +const execFileAsync = promisify(execFile) + +// Structural read-only allowlist. Every permitted command maps to an observation-only +// `higgsfield` subcommand. There is no argv passthrough, so paid or mutating subcommands +// (generate create, soul-id train, upload, delete, publish, auth token, ...) are unreachable. +export const READ_ONLY_COMMANDS = Object.freeze({ + account: () => ['account', 'status', '--json'], + generations: () => ['generate', 'list', '--json'], + souls: () => ['soul-id', 'list', '--json'], + models: () => ['model', 'list', '--json'], + job: options => ['generate', 'get', requireJobId(options.jobId), '--json'] +}) + +// Defensive second layer with explicit denial messages for anything mutating or credential-bearing. +export const BLOCKED_COMMANDS = Object.freeze([ + 'create', 'generate-create', 'train', 'soul-train', 'delete', 'remove', 'update', + 'upload', 'publish', 'deploy', 'buy', 'purchase', 'voice-change', 'token', 'login', 'logout' +]) + +const HIGGSFIELD_BIN = process.env.HIGGSFIELD_BIN || 'higgsfield' + +function usage() { + process.stderr.write(`Usage: higgsfield-control [flags] + +Read-only Higgsfield CLI bridge for Visual Workbench QC provenance. + +Commands: + account Print signed-in account status (higgsfield account status) + generations List existing generation jobs (higgsfield generate list) + souls List Soul references (higgsfield soul-id list) + models List available models (higgsfield model list) + job --job-id ID Inspect one existing job (higgsfield generate get ID) + evidence Print normalized Visual Workbench provider evidence for one job + +Flags: + --job-id ID Target job id (job / evidence) + --url URL Result URL to match one exact job (evidence) + --input PATH Read a generations JSON file instead of calling the CLI (offline) + --print-argv Print the resolved read-only higgsfield argv and exit (no execution) + --json Reserved; CLI output is already JSON + +Never invokes paid, mutating, or credential-printing subcommands. Never uses the 'hf' +alias, which collides with the HuggingFace CLI. +`) + process.exitCode = 1 +} + +function requireJobId(jobId) { + if (!/^[0-9a-fA-F][0-9a-fA-F-]{7,63}$/.test(String(jobId || ''))) { + throw new Error('job requires a valid --job-id') + } + return String(jobId) +} + +export function assertHiggsfieldBinary(binary) { + const name = String(binary || '').split('/').pop() + if (name === 'hf') { + throw new Error("Refusing 'hf': it resolves to the HuggingFace CLI on this host. Use 'higgsfield'.") + } + return binary +} + +export function resolveReadOnlyArgv(command, options = {}) { + if (BLOCKED_COMMANDS.includes(command)) { + throw new Error(`Higgsfield command '${command}' is not permitted: this bridge is strictly read-only`) + } + const build = READ_ONLY_COMMANDS[command] + if (!build) throw new Error(`Unknown read-only command: '${command}'`) + return build(options) +} + +export function extractJobs(payload) { + if (Array.isArray(payload)) return payload + if (!payload || typeof payload !== 'object') return [] + for (const key of ['items', 'jobs', 'data', 'generations', 'results']) { + if (Array.isArray(payload[key])) return payload[key] + } + return [] +} + +function jobResultUrls(job) { + const urls = [] + for (const value of [job?.result_url, job?.resultUrl, job?.url, job?.min_result_url]) { + if (typeof value === 'string' && value) urls.push(value) + } + const results = job && typeof job.results === 'object' && job.results ? job.results : {} + for (const value of [results.rawUrl, results.minUrl, results.url]) { + if (typeof value === 'string' && value) urls.push(value) + } + return [...new Set(urls)] +} + +export function selectJobByUrl(jobs, url) { + if (typeof url !== 'string' || !url) return null + const matches = extractJobs(jobs).filter(job => jobResultUrls(job).includes(url)) + return matches.length === 1 ? matches[0] : null +} + +export function selectJobById(jobs, jobId) { + if (typeof jobId !== 'string' || !jobId) return null + const matches = extractJobs(jobs).filter(job => (job?.id || job?.job_id || job?.jobId) === jobId) + return matches.length === 1 ? matches[0] : null +} + +export function evidenceForJob(job, srcOverride) { + if (!job || typeof job !== 'object') return null + const src = typeof srcOverride === 'string' && srcOverride ? srcOverride : jobResultUrls(job)[0] + if (typeof src !== 'string' || !src) return null + // Reuse the exact provenance normalization the Visual Workbench already trusts for + // Higgsfield results. The synthetic tool name keeps the existing extractor path while + // the result URL is sanitized (signed query params removed) inside restoredProviderEvidence. + return providerEvidenceFor({ toolName: 'higgsfield-cli', src, toolResult: { items: [job] } }) +} + +function parseArgs(argv) { + const options = { command: '', jobId: '', url: '', input: '', printArgv: false } + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index] + if (arg === '--job-id') options.jobId = argv[++index] || '' + else if (arg === '--url') options.url = argv[++index] || '' + else if (arg === '--input') options.input = argv[++index] || '' + else if (arg === '--print-argv') options.printArgv = true + else if (arg === '--json') continue + else if (arg === '--help' || arg === '-h') { usage(); return null } + else if (!options.command) options.command = arg + else { usage(); return null } + } + if (!options.command) { usage(); return null } + return options +} + +async function runHiggsfield(args) { + assertHiggsfieldBinary(HIGGSFIELD_BIN) + const { stdout } = await execFileAsync(HIGGSFIELD_BIN, args, { maxBuffer: 16 * 1024 * 1024 }) + return stdout +} + +async function loadJobs(options) { + if (options.input) return extractJobs(JSON.parse(await readFile(options.input, 'utf8'))) + return extractJobs(JSON.parse(await runHiggsfield(['generate', 'list', '--json']))) +} + +async function main() { + const options = parseArgs(process.argv.slice(2)) + if (!options) return + + if (options.command === 'evidence') { + const jobs = await loadJobs(options) + const job = options.url ? selectJobByUrl(jobs, options.url) : selectJobById(jobs, options.jobId) + const evidence = evidenceForJob(job, options.url) + if (!evidence) { + process.stderr.write('No single exact Higgsfield job matched the requested url or job id\n') + process.exitCode = 1 + return + } + process.stdout.write(`${JSON.stringify(evidence)}\n`) + return + } + + const argv = resolveReadOnlyArgv(options.command, options) + if (options.printArgv) { + process.stdout.write(`${JSON.stringify(argv)}\n`) + return + } + if (options.input && options.command === 'generations') { + process.stdout.write(`${JSON.stringify(extractJobs(JSON.parse(await readFile(options.input, 'utf8'))))}\n`) + return + } + process.stdout.write(await runHiggsfield(argv)) +} + +function isDirectRun() { + const entry = process.argv[1] + if (!entry) return false + try { + return realpathSync(entry) === realpathSync(fileURLToPath(import.meta.url)) + } catch { + return false + } +} + +if (isDirectRun()) { + main().catch(error => { + process.stderr.write(`higgsfield-control: ${error instanceof Error ? error.message : String(error)}\n`) + process.exitCode = 1 + }) +} diff --git a/scripts/install.mjs b/scripts/install.mjs index 156f6f7..5a43877 100755 --- a/scripts/install.mjs +++ b/scripts/install.mjs @@ -1,12 +1,13 @@ #!/usr/bin/env node -import { access, lstat, mkdir, readFile, rm } from 'node:fs/promises' -import { join } from 'node:path' +import { access, copyFile, lstat, mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises' +import { basename, dirname, join } from 'node:path' import { atomicWrite, - backupFile, + assertSafeDestination, dashboardDirectory, + hermesHomeDirectory, MARKER_NAME, PACKAGE_ROOT, parseArgs, @@ -21,6 +22,9 @@ const usage = `Hermes Visual Workbench installer Usage: hermes-visual-workbench [--hermes-home PATH] hermes-visual-workbench --target PLUGIN_DIRECTORY --skill-target SKILL_DIRECTORY + hermes-visual-workbench --install|--update [--dry-run] + hermes-visual-workbench --verify + hermes-visual-workbench --rollback [--dry-run] hermes-visual-workbench --uninstall [--force] Options: @@ -28,6 +32,11 @@ Options: --target PATH Exact plugin installation directory --skill-target PATH Exact workflow skill installation directory --uninstall Remove all managed files + --install Install or idempotently repair managed files (default) + --update Update managed files through the same transaction + --verify Verify marker and source/install hashes without writing + --rollback Restore the exact newest update transaction + --dry-run Print the transaction without writing --force Uninstall even when a managed file was modified --help Show this help ` @@ -53,9 +62,12 @@ async function readManagedFile(path) { } function expectedManagedFiles(target, skillTarget, dashboardTarget) { + const backendTarget = dirname(dashboardTarget) return [ { id: 'plugin', path: join(target, 'plugin.js') }, { id: 'skill', path: join(skillTarget, 'SKILL.md') }, + { id: 'backend-manifest', path: join(backendTarget, 'plugin.yaml') }, + { id: 'backend-init', path: join(backendTarget, '__init__.py') }, { id: 'dashboard-manifest', path: join(dashboardTarget, 'manifest.json') }, { id: 'dashboard-api', path: join(dashboardTarget, 'plugin_api.py') } ] @@ -123,78 +135,206 @@ async function uninstall(target, skillTarget, dashboardTarget, options) { console.log(`Removed Visual Workbench from ${target}`) } -async function install(target, skillTarget, dashboardTarget) { +async function install(target, skillTarget, dashboardTarget, dryRun = false) { const packageJson = await readJson(join(PACKAGE_ROOT, 'package.json')) const markerPath = join(target, MARKER_NAME) const sourcePaths = { - plugin: join(PACKAGE_ROOT, 'plugin.js'), - skill: join(PACKAGE_ROOT, 'skill', 'SKILL.md'), - 'dashboard-manifest': join(PACKAGE_ROOT, 'dashboard', 'manifest.json'), - 'dashboard-api': join(PACKAGE_ROOT, 'dashboard', 'plugin_api.py') - } - const managed = expectedManagedFiles(target, skillTarget, dashboardTarget).map(file => ({ - ...file, - sourcePath: sourcePaths[file.id] + plugin: join(PACKAGE_ROOT, 'plugin.js'), skill: join(PACKAGE_ROOT, 'skill', 'SKILL.md'), + 'backend-manifest': join(PACKAGE_ROOT, 'backend', 'plugin.yaml'), 'backend-init': join(PACKAGE_ROOT, 'backend', '__init__.py'), + 'dashboard-manifest': join(PACKAGE_ROOT, 'dashboard', 'manifest.json'), 'dashboard-api': join(PACKAGE_ROOT, 'dashboard', 'plugin_api.py') + } + const plans = await Promise.all(expectedManagedFiles(target, skillTarget, dashboardTarget).map(async file => { + const [source, current] = await Promise.all([readFile(sourcePaths[file.id]), readManagedFile(file.path)]) + return { ...file, source, current, sourceHash: sha256(source), changed: !current || !current.equals(source) } })) - - await Promise.all([mkdir(target, { recursive: true }), mkdir(skillTarget, { recursive: true }), mkdir(dashboardTarget, { recursive: true })]) + const files = plans.map(file => ({ id: file.id, path: file.path, sha256: file.sourceHash })) const markerBefore = await readManagedFile(markerPath) - const plans = [] + let marker + try { marker = markerBefore && JSON.parse(markerBefore.toString('utf8')) } catch {} + const markerChanged = plans.some(file => file.changed) || marker?.schemaVersion !== 2 || marker?.package !== packageJson.name || + marker?.version !== packageJson.version || JSON.stringify(marker?.files) !== JSON.stringify(files) + + if (dryRun) { + console.log(`Dry run install/update: ${plans.filter(file => file.changed).length} managed file(s) would change`) + for (const file of plans) console.log(`${file.changed ? 'write' : 'keep'} ${file.id}`) + return + } + + const stamp = `${process.pid}-${Date.now()}` + const operations = [] + const missingDirectories = new Set() + async function addMissingDirectories(path) { + for (let current = path; !(await exists(current)); current = dirname(current)) missingDirectories.add(current) + } + for (const file of plans.filter(file => file.changed)) await addMissingDirectories(dirname(file.path)) + if (markerChanged) await addMissingDirectories(dirname(markerPath)) + for (const file of plans.filter(file => file.changed && file.current)) await addMissingDirectories(join(target, 'backups', file.id)) + if (markerChanged && markerBefore) await addMissingDirectories(join(target, 'backups', 'marker')) + for (const path of [...missingDirectories].sort((a, b) => a.split('/').length - b.split('/').length)) operations.push({ type: 'mkdir', path }) + + for (const file of plans.filter(file => file.changed)) { + const temp = `${file.path}.tmp-${stamp}` + if (file.current) operations.push({ type: 'backup', path: file.path, backup: join(target, 'backups', file.id, `${basename(file.path)}-${stamp}.bak`) }) + operations.push({ type: 'temp', path: temp, value: file.source }) + operations.push({ type: 'replace', path: file.path, temp, before: file.current }) + } + if (markerChanged) { + const temp = `${markerPath}.tmp-${stamp}` + if (markerBefore) operations.push({ type: 'backup', path: markerPath, backup: join(target, 'backups', 'marker', `${MARKER_NAME}-${stamp}.bak`) }) + operations.push({ type: 'temp', path: temp, value: Buffer.from(`${JSON.stringify({ schemaVersion: 2, installedAt: new Date().toISOString(), package: packageJson.name, version: packageJson.version, files }, null, 2)}\n`) }) + operations.push({ type: 'replace', path: markerPath, temp, before: markerBefore }) + } + + let journalDirectory = dirname(target) + while (!(await exists(journalDirectory))) journalDirectory = dirname(journalDirectory) + const journalPath = join(journalDirectory, `.${basename(target)}.install-journal-${stamp}.json`) + await writeFile(journalPath, JSON.stringify(operations.map(operation => ({ + ...operation, value: operation.value?.toString('base64'), before: operation.before?.toString('base64') + }))) + '\n', { mode: 0o600 }) + const injectedFailure = process.env.HERMES_VW_TEST_FAIL_OPERATION + const operationCounts = new Map() + try { + for (const operation of operations) { + const count = (operationCounts.get(operation.type) || 0) + 1 + operationCounts.set(operation.type, count) + if (injectedFailure === operation.type || injectedFailure === `${operation.type}:${count}`) throw new Error(`Injected ${operation.type} failure`) + if (operation.type === 'mkdir') await mkdir(operation.path) + else if (operation.type === 'backup') await copyFile(operation.path, operation.backup) + else if (operation.type === 'temp') await writeFile(operation.path, operation.value, { mode: 0o644 }) + else if (operation.type === 'replace') await rename(operation.temp, operation.path) + } + } catch (error) { + const rollbackErrors = [] + for (const operation of [...operations].reverse()) { + try { + if (operation.type === 'replace') operation.before ? await writeFile(operation.path, operation.before, { mode: 0o644 }) : await rm(operation.path, { force: true }) + else if (operation.type === 'temp') await rm(operation.path, { force: true }) + else if (operation.type === 'backup') await rm(operation.backup, { force: true }) + else if (operation.type === 'mkdir') await rm(operation.path, { force: true }) + } catch (rollbackError) { rollbackErrors.push(`${operation.path}: ${rollbackError.message}`) } + } + await rm(journalPath, { force: true }).catch(rollbackError => rollbackErrors.push(`${journalPath}: ${rollbackError.message}`)) + throw new Error(`Installation failed: ${error instanceof Error ? error.message : String(error)}.${rollbackErrors.length ? ` Rollback also failed: ${rollbackErrors.join('; ')}` : ' Previous managed files were restored.'}`) + } + await rm(journalPath, { force: true }) + for (const operation of operations.filter(operation => operation.type === 'backup')) { + console.log(`Backed up existing ${operation.path === markerPath ? 'marker' : plans.find(file => file.path === operation.path)?.id} to ${operation.backup}`) + } + console.log(`${plans.some(file => file.changed) ? 'Installed' : 'Verified'} Visual Workbench ${packageJson.version} at ${target}`) + console.log(`Installed Midjourney workflow skill to ${skillTarget}`) + console.log('Run: hermes plugins enable visual-workbench (backend restart required)') +} + +async function verify(target, skillTarget, dashboardTarget) { + const markerPath = join(target, MARKER_NAME) + const markerBytes = await readManagedFile(markerPath) + if (!markerBytes) throw new Error(`No managed installation marker at ${markerPath}`) + let marker + try { marker = JSON.parse(markerBytes.toString('utf8')) } catch { throw new Error('Installation marker is not valid JSON') } + const expected = expectedManagedFiles(target, skillTarget, dashboardTarget) + const marked = validatedMarkerFiles(marker, expected) + const sourcePaths = { + plugin: join(PACKAGE_ROOT, 'plugin.js'), skill: join(PACKAGE_ROOT, 'skill', 'SKILL.md'), + 'backend-manifest': join(PACKAGE_ROOT, 'backend', 'plugin.yaml'), 'backend-init': join(PACKAGE_ROOT, 'backend', '__init__.py'), + 'dashboard-manifest': join(PACKAGE_ROOT, 'dashboard', 'manifest.json'), 'dashboard-api': join(PACKAGE_ROOT, 'dashboard', 'plugin_api.py') + } + for (const file of expected) { + const [source, current] = await Promise.all([readFile(sourcePaths[file.id]), readManagedFile(file.path)]) + const sourceHash = sha256(source) + const markedHash = marked.find(entry => entry.id === file.id)?.sha256 + if (!current || sha256(current) !== sourceHash || markedHash !== sourceHash) { + throw new Error(`Compatibility error: managed file differs from source: ${file.id}. Run hermes-visual-workbench --update`) + } + } + console.log(`Verified Visual Workbench ${marker.version || 'unknown'} at ${target}`) +} + +async function rollback(target, skillTarget, dashboardTarget, dryRun = false) { + const managed = expectedManagedFiles(target, skillTarget, dashboardTarget) + const markerPath = join(target, MARKER_NAME) + const markerDirectory = join(target, 'backups', 'marker') + let markerBackupNames + try { markerBackupNames = await readdir(markerDirectory) } catch (error) { + if (error?.code === 'ENOENT') throw new Error('No rollback transaction backup') + throw error + } + const markerPrefix = `${MARKER_NAME}-` + const candidates = await Promise.all(markerBackupNames + .filter(name => name.startsWith(markerPrefix) && name.endsWith('.bak')) + .map(async name => ({ name, modified: (await lstat(join(markerDirectory, name))).mtimeMs }))) + candidates.sort((a, b) => a.modified - b.modified || a.name.localeCompare(b.name)) + const latestMarker = candidates.at(-1)?.name + if (!latestMarker) throw new Error('No rollback transaction backup') + const stamp = latestMarker.slice(markerPrefix.length, -'.bak'.length) + const markerBackupPath = join(markerDirectory, latestMarker) + const markerBackup = await readManagedFile(markerBackupPath) + if (!markerBackup) throw new Error('Rollback transaction marker backup is unavailable') + + let previousMarker + try { previousMarker = JSON.parse(markerBackup.toString('utf8')) } catch { + throw new Error('Rollback transaction marker backup is not valid JSON') + } + const previousEntries = new Map() + for (const entry of Array.isArray(previousMarker?.files) ? previousMarker.files : []) { + if (!entry || typeof entry.id !== 'string' || previousEntries.has(entry.id)) { + throw new Error('Rollback transaction marker has invalid managed file entries') + } + previousEntries.set(entry.id, entry) + } + + const actions = [] for (const file of managed) { - const [source, current] = await Promise.all([readFile(file.sourcePath), readManagedFile(file.path)]) - plans.push({ ...file, source, sourceHash: sha256(source), current }) - } - - const files = [] - let changed = false - for (const file of plans) { - if (file.current) { - const currentHash = sha256(file.current) - if (currentHash !== file.sourceHash) { - const backup = await backupFile(file.path, join(target, 'backups', file.id)) - console.log(`Backed up existing ${file.id} to ${backup}`) - changed = true - } - } else { - changed = true + const current = await readManagedFile(file.path) + const backupPath = join(target, 'backups', file.id, `${basename(file.path)}-${stamp}.bak`) + const backup = await readManagedFile(backupPath) + if (backup) { + actions.push({ ...file, type: 'restore', backup, current }) + continue + } + + const previous = previousEntries.get(file.id) + if (!previous) { + actions.push({ ...file, type: 'remove', current }) + continue + } + if (previous.path !== file.path || !/^[a-f0-9]{64}$/.test(previous.sha256)) { + throw new Error(`Rollback transaction marker has invalid metadata for ${file.id}`) } - files.push({ id: file.id, path: file.path, sha256: file.sourceHash }) + if (!current || sha256(current) !== previous.sha256) { + throw new Error(`Rollback transaction is missing the exact backup for changed file: ${file.id}`) + } + actions.push({ ...file, type: 'keep', current }) + } + + if (dryRun) { + const restored = actions.filter(file => file.type === 'restore').length + const kept = actions.filter(file => file.type === 'keep').length + const removed = actions.filter(file => file.type === 'remove').length + console.log(`Dry run rollback: ${restored} managed file(s) would be restored, ${kept} kept, ${removed} removed`) + for (const file of actions) console.log(`${file.type} ${file.id}`) + return } + const markerBefore = await readManagedFile(markerPath) const applied = [] try { - for (const file of plans) { - await atomicWrite(file.path, file.source) + for (const file of actions) { + if (file.type === 'restore') await atomicWrite(file.path, file.backup) + else if (file.type === 'remove') await rm(file.path, { force: true }) + else continue applied.push(file) } - await atomicWrite( - markerPath, - `${JSON.stringify({ schemaVersion: 2, installedAt: new Date().toISOString(), package: packageJson.name, version: packageJson.version, files }, null, 2)}\n` - ) - } catch (installError) { - const rollbackErrors = [] + await atomicWrite(markerPath, markerBackup) + } catch (error) { for (const file of [...applied].reverse()) { - try { - if (file.current) await atomicWrite(file.path, file.current) - else await rm(file.path, { force: true }) - } catch (rollbackError) { - rollbackErrors.push(`${file.path}: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`) - } - } - try { - if (markerBefore) await atomicWrite(markerPath, markerBefore) - else await rm(markerPath, { force: true }) - } catch (rollbackError) { - rollbackErrors.push(`${markerPath}: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`) + if (file.current) await atomicWrite(file.path, file.current) + else await rm(file.path, { force: true }) } - const suffix = rollbackErrors.length - ? ` Rollback also failed: ${rollbackErrors.join('; ')}` - : ' Previous managed files were restored.' - throw new Error(`Installation failed: ${installError instanceof Error ? installError.message : String(installError)}.${suffix}`) + if (markerBefore) await atomicWrite(markerPath, markerBefore) + else await rm(markerPath, { force: true }) + throw error } - console.log(`${changed ? 'Installed' : 'Verified'} Visual Workbench ${packageJson.version} at ${target}`) - console.log(`Installed Midjourney workflow skill to ${skillTarget}`) - console.log('Run: hermes plugins enable visual-workbench (backend restart required)') + console.log(`Rolled back Visual Workbench at ${target}`) } try { @@ -205,8 +345,17 @@ try { const target = targetDirectory(options) const skillTarget = skillDirectory(options) const dashboardTarget = dashboardDirectory(options) + const hermesHome = hermesHomeDirectory(options) + const destinations = [ + target, skillTarget, dashboardTarget, + join(target, MARKER_NAME), join(target, 'backups'), + ...expectedManagedFiles(target, skillTarget, dashboardTarget).map(file => file.path) + ] + for (const destination of destinations) await assertSafeDestination(hermesHome, destination) if (options.uninstall) await uninstall(target, skillTarget, dashboardTarget, options) - else await install(target, skillTarget, dashboardTarget) + else if (options.verify) await verify(target, skillTarget, dashboardTarget) + else if (options.rollback) await rollback(target, skillTarget, dashboardTarget, options.dryRun) + else await install(target, skillTarget, dashboardTarget, options.dryRun) } } catch (error) { console.error(`hermes-visual-workbench: ${error instanceof Error ? error.message : String(error)}`) diff --git a/scripts/lib.mjs b/scripts/lib.mjs index 6a1ba9b..a039d3e 100644 --- a/scripts/lib.mjs +++ b/scripts/lib.mjs @@ -1,7 +1,7 @@ import { createHash } from 'node:crypto' -import { copyFile, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises' +import { copyFile, lstat, mkdir, readFile, realpath, rename, rm, writeFile } from 'node:fs/promises' import { homedir } from 'node:os' -import { basename, dirname, join, resolve } from 'node:path' +import { basename, dirname, join, relative, resolve } from 'node:path' import { fileURLToPath } from 'node:url' export const PACKAGE_ROOT = fileURLToPath(new URL('..', import.meta.url)) @@ -12,11 +12,16 @@ export function parseArgs(argv) { const args = [...argv] while (args[0] === '--') args.shift() - const options = { force: false, help: false, hermesHome: '', skillTarget: '', target: '', uninstall: false } + const options = { dryRun: false, force: false, help: false, hermesHome: '', rollback: false, skillTarget: '', target: '', uninstall: false, update: false, verify: false } for (let index = 0; index < args.length; index += 1) { const arg = args[index] if (arg === '--force') options.force = true + else if (arg === '--dry-run') options.dryRun = true + else if (arg === '--install') {} + else if (arg === '--update') options.update = true + else if (arg === '--verify') options.verify = true + else if (arg === '--rollback') options.rollback = true else if (arg === '--help' || arg === '-h') options.help = true else if (arg === '--uninstall') options.uninstall = true else if (arg === '--target' || arg === '--skill-target' || arg === '--hermes-home') { @@ -37,6 +42,12 @@ export function parseArgs(argv) { if (options.force && !options.uninstall) { throw new Error('--force is only valid with --uninstall') } + if ([options.uninstall, options.verify, options.rollback].filter(Boolean).length > 1) { + throw new Error('--uninstall, --verify, and --rollback are mutually exclusive') + } + if (options.dryRun && (options.uninstall || options.verify)) { + throw new Error('--dry-run is valid only with install, update, or rollback') + } return options } @@ -62,6 +73,42 @@ export function dashboardDirectory(options, env = process.env) { export function sha256(value) { return createHash('sha256').update(value).digest('hex') } +export function isContainedPath(root, destination) { + const canonicalRoot = resolve(root) + const canonicalDestination = resolve(destination) + const path = relative(canonicalRoot, canonicalDestination) + return path === '' || (!path.startsWith('..') && !path.includes(`..${process.platform === 'win32' ? '\\' : '/'}`) && !path.startsWith('/')) +} + +export async function assertSafeDestination(root, destination) { + if (!isContainedPath(root, destination)) { + throw new Error(`Installation destination escapes Hermes home: ${destination}`) + } + + const configuredRoot = resolve(root) + const configuredDestination = resolve(destination) + const trustedParent = dirname(configuredRoot) + for (let current = configuredDestination; ; current = dirname(current)) { + try { + const info = await lstat(current) + if (info.isSymbolicLink()) throw new Error(`Installation destination has a symlinked parent: ${current}`) + if (!info.isDirectory() && current !== configuredDestination) { + throw new Error(`Installation destination has a non-directory parent: ${current}`) + } + } catch (error) { + if (error?.code !== 'ENOENT') throw error + } + if (current === trustedParent) break + if (current === dirname(current)) break + } + + const canonicalTrustedParent = await realpath(trustedParent) + const canonicalRoot = join(canonicalTrustedParent, basename(configuredRoot)) + const canonicalDestination = join(canonicalTrustedParent, relative(trustedParent, configuredDestination)) + if (!isContainedPath(canonicalTrustedParent, canonicalRoot) || !isContainedPath(canonicalRoot, canonicalDestination)) { + throw new Error(`Installation destination escapes Hermes home: ${destination}`) + } +} export async function atomicWrite(path, value, mode = 0o644) { await mkdir(dirname(path), { recursive: true }) diff --git a/scripts/midjourney-control.mjs b/scripts/midjourney-control.mjs new file mode 100644 index 0000000..673c59c --- /dev/null +++ b/scripts/midjourney-control.mjs @@ -0,0 +1,162 @@ +#!/usr/bin/env node + +import { randomUUID } from 'node:crypto' +import { execFile } from 'node:child_process' +import { readFile, stat } from 'node:fs/promises' +import os from 'node:os' +import { join } from 'node:path' +import { promisify } from 'node:util' + +const execFileAsync = promisify(execFile) +const COMMANDS = new Set(['capabilities', 'state', 'navigate', 'probe', 'results', 'settings', 'draft', 'attach', 'detach', 'validate', 'submit', 'wait', 'link', 'grid', 'action', 'download', 'capture', 'qc']) + +function usage() { + console.error(`Usage: midjourney-control.mjs [--base-url http://127.0.0.1:PORT] [--serialize] [options] +Commands: capabilities, state, navigate --url URL, probe, results, settings [--name NAME --value VALUE], draft --prompt TEXT [--parameters JSON], + attach --path FILE --role ROLE, detach --role ROLE, validate, + submit --approve-billable --idempotency-key KEY --validate-receipt RECEIPT --batch-fingerprint 64LOWERHEX, + wait [--timeout-ms N], link --operation-id HASH --job-id ID --prompt TEXT --acknowledged --ledger-created-at ISO, + grid, action --name NAME --job-id ID [--candidate A-D] [--approve-billable --idempotency-key KEY], + download --job-id ID --filename NAME, capture, qc +--serialize prints the command envelope without contacting Hermes (for local verification).`) + process.exit(2) +} + +function parseArgs(argv) { + const options = { baseUrl: '', panelId: 'result', command: '', payload: {}, timeout: 20_000, serialize: false } + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index] + if (arg === '--base-url') options.baseUrl = argv[++index] || '' + else if (arg === '--serialize') options.serialize = true + else if (arg === '--panel-id') options.panelId = argv[++index] || '' + else if (arg === '--url') options.payload.url = argv[++index] || '' + else if (arg === '--prompt') options.payload.prompt = argv[++index] || '' + else if (arg === '--parameters') { + try { options.payload.parameters = JSON.parse(argv[++index] || '') } catch { usage() } + } else if (arg === '--path') options.payload.path = argv[++index] || '' + else if (arg === '--role') options.payload.role = argv[++index] || '' + else if (arg === '--name') options.payload.name = argv[++index] || '' + else if (arg === '--value') { + const value = argv[++index] || '' + options.payload.value = value === 'true' ? true : value === 'false' ? false : /^-?\d+(?:\.\d+)?$/.test(value) ? Number(value) : value + } + else if (arg === '--candidate') options.payload.candidate = argv[++index] || '' + else if (arg === '--idempotency-key') options.payload.idempotencyKey = argv[++index] || '' + else if (arg === '--validate-receipt') options.payload.validateReceipt = argv[++index] || '' + else if (arg === '--batch-fingerprint') options.payload.batchFingerprint = argv[++index] || '' + else if (arg === '--operation-id') options.payload.operationId = argv[++index] || '' + else if (arg === '--acknowledged') options.payload.acknowledged = true + else if (arg === '--ledger-created-at') options.payload.ledgerCreatedAt = argv[++index] || '' + else if (arg === '--job-id') options.payload.jobId = argv[++index] || '' + else if (arg === '--filename') options.payload.filename = argv[++index] || '' + else if (arg === '--approve-billable') options.payload.approved = true + else if (arg === '--timeout-ms') { + const value = Number(argv[++index]) + if (!Number.isInteger(value)) usage() + options.payload.timeoutMs = value + options.timeout = Math.max(options.timeout, value + 5_000) + } else if (!options.command) options.command = arg + else usage() + } + if (!COMMANDS.has(options.command)) usage() + if (options.baseUrl && !/^http:\/\/127\.0\.0\.1:\d+$/.test(options.baseUrl)) usage() + if (!['result', 'reference'].includes(options.panelId)) usage() + options.payload.action = options.command + if (options.command === 'submit') { + if (!options.payload.validateReceipt) throw new Error('submit requires a validate receipt') + if (!/^[0-9a-f]{64}$/.test(options.payload.batchFingerprint || '')) throw new Error('submit requires a valid lowercase 64-hex batch fingerprint') + } + return options +} + +async function discoverBaseUrl(token) { + const { stdout } = await execFileAsync('lsof', ['-nP', '-iTCP', '-sTCP:LISTEN'], { maxBuffer: 1024 * 1024 }) + const ports = [...new Set([...stdout.matchAll(/127\.0\.0\.1:(\d+) \(LISTEN\)/g)].map(match => Number(match[1])))] + for (const port of ports) { + try { + const response = await fetch(`http://127.0.0.1:${port}/openapi.json`, { signal: AbortSignal.timeout(500) }) + const body = await response.json() + if (!response.ok || body?.info?.title !== 'Hermes Agent') continue + const authorized = await fetch(`http://127.0.0.1:${port}/api/plugins/visual-workbench/control/result?cursor=0`, { + headers: { authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(500) + }) + if (authorized.ok) return `http://127.0.0.1:${port}` + } catch {} + } + throw new Error('Hermes Agent loopback API was not found') +} + +async function request(baseUrl, token, path, init = {}) { + const response = await fetch(`${baseUrl}${path}`, { + ...init, + headers: { + authorization: `Bearer ${token}`, + 'content-type': 'application/json', + ...(init.headers || {}) + } + }) + const body = await response.json().catch(() => ({})) + if (!response.ok) throw new Error(`${response.status} ${body.detail || body.error || 'request failed'}`) + return body +} +function commandEnvelope(options, id) { + return { + id, + op: options.command === 'probe' ? 'midjourney-probe' : 'midjourney-control', + panelId: options.panelId, + payload: options.command === 'probe' ? {} : options.payload + } +} + +async function main() { + const options = parseArgs(process.argv.slice(2)) + const id = `mj-${options.command}-${randomUUID()}`.slice(0, 64) + if (options.serialize) { + process.stdout.write(`${JSON.stringify(commandEnvelope(options, id), null, 2)}\n`) + return + } + const hermesHome = process.env.HERMES_HOME || join(os.homedir(), '.hermes') + const tokenPath = join(hermesHome, 'plugins', 'visual-workbench', 'control.token') + const tokenStat = await stat(tokenPath) + if ((tokenStat.mode & 0o777) !== 0o600) throw new Error('Visual Workbench control token must have mode 0600') + const token = (await readFile(tokenPath, 'utf8')).trim() + if (token.length < 43) throw new Error('Visual Workbench control token is missing or invalid') + options.baseUrl ||= await discoverBaseUrl(token) + + const queued = await request(options.baseUrl, token, '/api/plugins/visual-workbench/command', { + method: 'POST', + body: JSON.stringify(commandEnvelope(options, id)) + }) + if (queued?.existing === true && queued?.queued === false) { + process.stdout.write(`${JSON.stringify({ id, ok: true, duplicate: true, existing: true, queued: false, operationId: queued.operationId, status: queued.status }, null, 2)}\n`) + return + } + + const deadline = Date.now() + options.timeout + let cursor = 0 + while (Date.now() < deadline) { + const receiptPage = await request( + options.baseUrl, + token, + `/api/plugins/visual-workbench/control/result?cursor=${cursor}` + ) + const result = Array.isArray(receiptPage?.results) + ? receiptPage.results.find(receipt => receipt?.id === id) + : null + if (result) { + const output = queued?.operationId ? { ...result, reservationOperationId: queued.operationId } : result + process.stdout.write(`${JSON.stringify(output, null, 2)}\n`) + if (!result.ok) process.exitCode = 1 + return + } + if (Number.isInteger(receiptPage?.nextCursor) && receiptPage.nextCursor >= cursor) cursor = receiptPage.nextCursor + await new Promise(resolve => setTimeout(resolve, 250)) + } + throw new Error(`Timed out waiting for Visual Workbench ${options.command} result`) +} + +main().catch(error => { + console.error(`midjourney-control: ${error instanceof Error ? error.message : String(error)}`) + process.exitCode = 1 +}) diff --git a/scripts/qc-core.mjs b/scripts/qc-core.mjs index 5a8500c..2f69936 100644 --- a/scripts/qc-core.mjs +++ b/scripts/qc-core.mjs @@ -1,4 +1,4 @@ -export const PERSISTED_SCHEMA_VERSION = 6 +export const PERSISTED_SCHEMA_VERSION = 7 export const QC_DOCUMENT_SCHEMA_VERSION = 1 export const MAX_QC_JSON_BYTES = 64 * 1024 export const CANDIDATE_IDS = Object.freeze(['A', 'B', 'C', 'D']) @@ -75,6 +75,31 @@ function isRecord(value) { function boundedMetadataString(value, max = 4000) { return typeof value === 'string' ? value.slice(0, max) : '' } +const URL_SECRET_PARAM = /(?:^|[_-])(?:token|sig(?:nature)?|expires?|key|auth(?:entication|orization)?|secret|credential|session)(?:$|[_-])/i + +export function sanitizeUrl(value) { + const raw = boundedMetadataString(value, 4096) + if (!raw) return '' + try { + const url = new URL(raw) + for (const key of [...url.searchParams.keys()]) { + if (URL_SECRET_PARAM.test(key)) url.searchParams.delete(key) + } + const base = raw.split(/[?#]/, 1)[0] + return url.searchParams.size ? `${base}?${url.searchParams}` : base + } catch { + return raw + } +} + +// Mandatory gate for any future Higgsfield provider invocation. +export async function invokeHiggsfieldReadOnly(toolName, invoke, ...args) { + const operation = String(toolName || '').toLowerCase().replace(/^.*__/, '') + if (!['show_generations', 'job_status', 'get_generation', 'get_job', 'inspect', 'status'].includes(operation)) { + throw new Error(`Higgsfield operation ${operation || 'unknown'} is read-only only`) + } + return invoke(toolName, ...args) +} function parsedRecord(value) { if (isRecord(value)) return value @@ -152,14 +177,23 @@ function comparableUrl(value) { } export function restoredProviderEvidence(value) { - if (!isRecord(value) || value.source !== 'higgsfield-mcp') return null + if (!isRecord(value)) return null + if (value.source === 'midjourney') { + const jobId = boundedMetadataString(value.jobId, 128).toLowerCase() + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(jobId)) return null + return { + source: 'midjourney', jobId, operationId: boundedMetadataString(value.operationId, 128), + resultUrl: sanitizeUrl(value.resultUrl) + } + } + if (!['higgsfield-mcp', 'higgsfield-web'].includes(value.source)) return null const width = Number.isFinite(value.width) && value.width > 0 ? Math.round(value.width) : 0 const height = Number.isFinite(value.height) && value.height > 0 ? Math.round(value.height) : 0 const duration = Number.isFinite(value.duration) && value.duration > 0 ? value.duration : 0 const count = Number.isInteger(value.count) && value.count > 0 ? Math.min(value.count, 20) : 1 const referenceCount = Number.isInteger(value.referenceCount) && value.referenceCount >= 0 ? Math.min(value.referenceCount, 20) : 0 return { - source: 'higgsfield-mcp', + source: value.source, jobId: boundedMetadataString(value.jobId, 128), status: boundedMetadataString(value.status, 64), model: boundedMetadataString(value.model, 128), @@ -173,7 +207,7 @@ export function restoredProviderEvidence(value) { resolution: boundedMetadataString(value.resolution, 32), count, referenceCount, - resultUrl: boundedMetadataString(value.resultUrl, 4096), + resultUrl: sanitizeUrl(value.resultUrl), createdAt: typeof value.createdAt === 'string' || Number.isFinite(value.createdAt) ? value.createdAt : '', checkedAt: boundedMetadataString(value.checkedAt, 64) } @@ -181,18 +215,42 @@ export function restoredProviderEvidence(value) { export function providerEvidenceIdentity(evidence) { return evidence ? `${evidence.jobId}|${evidence.resultUrl}|${evidence.model}` : '' } +export function midjourneyJobLocation(rawUrl) { + try { + const url = new URL(String(rawUrl || '')) + const match = url.protocol === 'https:' && !url.username && !url.password && url.port === '' && + /^(?:www\.)?midjourney\.com$/.test(url.hostname.toLowerCase()) && + url.pathname.match(/^\/jobs\/([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$/i) + const candidateIndex = match && url.search === '' && url.hash === '' + ? -1 + : match && url.hash === '' && /^index=[0-3]$/.test(url.search.slice(1)) ? Number(url.search.slice(-1)) : null + return { jobId: match?.[1]?.toLowerCase() || '', candidateIndex } + } catch { return { jobId: '', candidateIndex: null } } +} +export function midjourneyProviderEvidenceForUrl(rawUrl) { + const location = midjourneyJobLocation(rawUrl) + return location.jobId && location.candidateIndex !== null + ? restoredProviderEvidence({ source: 'midjourney', jobId: location.jobId, resultUrl: String(rawUrl) }) + : null +} + +export function sameMidjourneyCandidateSwitch(previousUrl, nextUrl, linkedJobId) { + const previous = midjourneyJobLocation(previousUrl) + const next = midjourneyJobLocation(nextUrl) + return Boolean(linkedJobId && previous.jobId === String(linkedJobId).toLowerCase() && + next.jobId === previous.jobId && previous.candidateIndex !== null && + Number.isInteger(next.candidateIndex) && next.candidateIndex >= 0 && previous.candidateIndex !== next.candidateIndex) +} export function providerEvidenceFor(input = {}) { const toolName = String(input.toolName || '').toLowerCase() if (!toolName.includes('higgsfield')) return null const records = providerRecords(input.toolResult) const src = String(input.src || '') - const matching = records.filter(record => resultUrls(record).some(url => comparableUrl(url) === comparableUrl(src))) - const recordsWithUrls = records.filter(record => resultUrls(record).length > 0) - const record = matching[0] || (recordsWithUrls.length === 0 && records.length === 1 ? records[0] : null) - if (!record) return null + const matching = records.filter(record => resultUrls(record).some(url => url === src)) + if (matching.length !== 1) return null + const record = matching[0] const params = isRecord(record.params) ? record.params : {} - const urls = resultUrls(record) const mediaType = ['image', 'video', 'audio', '3d'].includes(record.type) ? record.type : /\.(mp4|mov|webm|mkv|avi)(?:[?#]|$)/i.test(src) ? 'video' : 'image' @@ -211,7 +269,7 @@ export function providerEvidenceFor(input = {}) { resolution: params.resolution || record.resolution || '', count: params.batch_size || params.count || record.count || 1, referenceCount: Array.isArray(params.medias) ? params.medias.length : 0, - resultUrl: urls.find(url => comparableUrl(url) === comparableUrl(src)) || urls[0] || src, + resultUrl: src, createdAt: record.createdAt || record.created_at || '', checkedAt: new Date().toISOString() }) @@ -220,7 +278,7 @@ export function providerEvidenceFor(input = {}) { function restoredInspection(value) { if (!isRecord(value)) return null return { - url: boundedMetadataString(value.url, 4096), + url: sanitizeUrl(value.url), summary: boundedMetadataString(value.summary, 1000), checkedAt: boundedMetadataString(value.checkedAt, 64) } @@ -231,7 +289,7 @@ function restoredReviewContext(value, panels, allowLegacy = false) { const panel = panels[value.panelId] const legacy = !Object.hasOwn(value, 'contextId') if (legacy) { - const url = boundedMetadataString(value.url, 4096) + const url = sanitizeUrl(value.url) if (!allowLegacy || !url || !panel?.url || url !== panel.url) return null return { contextId: createId('c'), panelId: value.panelId, targetId: panel.targetId, profileId: value.profileId, @@ -244,7 +302,7 @@ function restoredReviewContext(value, panels, allowLegacy = false) { !isRecord(value.viewport) || typeof value.stale !== 'boolean' || !['', 'url-changed', 'viewport-changed', 'panels-swapped', 'provenance-changed', 'load-failed'].includes(value.staleReason) || typeof value.linkedAt !== 'string' || value.linkedAt.length > 64) return null - const url = boundedMetadataString(value.url, 4096) + const url = sanitizeUrl(value.url) const viewport = value.viewport if (!url || !Number.isFinite(viewport.width) || viewport.width < 240 || !Number.isFinite(viewport.height) || viewport.height < 240 || typeof viewport.preset !== 'string' || typeof viewport.responsive !== 'boolean') return null @@ -280,29 +338,34 @@ export function panelLinkedToQc(current, panelId) { export function updatePanelState(state, panelId, patch, options = {}, makeId = createId) { const panel = state.browserPanels[panelId] if (!panel) return state - const { preserveQcProfileHint = false, preserveProviderEvidence = false } = options + const { preserveQcProfileHint = false, preserveProviderEvidence = false, preserveMidjourneyCandidate = false } = options const urlChanged = Object.hasOwn(patch, 'url') && patch.url !== panel.url - const nextUrl = Object.hasOwn(patch, 'url') ? patch.url : panel.url + const nextUrl = Object.hasOwn(patch, 'url') ? sanitizeUrl(patch.url) : panel.url const viewportChanged = ['preset', 'width', 'height'].some(key => Object.hasOwn(patch, key) && patch[key] !== panel[key]) const provenanceChanged = Object.hasOwn(patch, 'providerEvidence') && providerEvidenceIdentity(patch.providerEvidence) !== providerEvidenceIdentity(panel.providerEvidence) - const targetChanged = urlChanged || (viewportChanged && mediaKind(nextUrl) === 'page') || provenanceChanged + const candidateSwitch = urlChanged && preserveMidjourneyCandidate + const targetChanged = !candidateSwitch && (urlChanged || (viewportChanged && mediaKind(nextUrl) === 'page') || provenanceChanged) const staleReason = urlChanged ? 'url-changed' : viewportChanged ? 'viewport-changed' : 'provenance-changed' const nextPanel = { ...panel, ...patch, + ...(Object.hasOwn(patch, 'url') ? { url: nextUrl } : {}), + ...(Object.hasOwn(patch, 'providerEvidence') ? { providerEvidence: restoredProviderEvidence(patch.providerEvidence) } : {}), ...(targetChanged ? { targetId: makeId('t'), inspection: null } : {}), ...(urlChanged && !preserveQcProfileHint && !Object.hasOwn(patch, 'qcProfileHint') ? { qcProfileHint: '' } : {}), ...(urlChanged && !preserveProviderEvidence && !Object.hasOwn(patch, 'providerEvidence') ? { providerEvidence: null } : {}) } const context = state.reviewContext - const reviewContext = targetChanged && context?.panelId === panelId && context.targetId === panel.targetId - ? { ...context, stale: true, staleReason } - : context + const reviewContext = candidateSwitch && context?.panelId === panelId && context.targetId === panel.targetId && !context.stale + ? { ...context, url: nextUrl, mediaKind: mediaKind(nextUrl), viewport: viewportFor(nextPanel), linkedAt: new Date().toISOString() } + : targetChanged && context?.panelId === panelId && context.targetId === panel.targetId + ? { ...context, stale: true, staleReason } + : context return { ...state, browserPanels: { ...state.browserPanels, [panelId]: nextPanel }, reviewContext, - ...(targetChanged && state.capture?.panelId === panelId ? { capture: null } : {}) + ...((targetChanged || candidateSwitch) && state.capture?.panelId === panelId ? { capture: null } : {}) } } @@ -312,17 +375,24 @@ export function linkPanelState(state, panelId, input = {}, makeId = createId) { const profileId = QC_PROFILE_IDS.includes(input.profileId) ? input.profileId : QC_PROFILE_IDS.includes(panel.qcProfileHint) ? panel.qcProfileHint : 'design' + const inferredProviderEvidence = profileId === 'midjourney' ? midjourneyProviderEvidenceForUrl(panel.url) : null + const providerEvidence = profileId === 'midjourney' ? inferredProviderEvidence : restoredProviderEvidence(panel.providerEvidence) + const nextPanel = profileId === 'midjourney' && providerEvidenceIdentity(providerEvidence) !== providerEvidenceIdentity(panel.providerEvidence) + ? { ...panel, providerEvidence } + : panel const previous = state.reviewContext const same = previous && !previous.stale && previous.panelId === panelId && - previous.targetId === panel.targetId && previous.profileId === profileId + previous.targetId === panel.targetId && previous.profileId === profileId && + providerEvidenceIdentity(previous.providerEvidence) === providerEvidenceIdentity(providerEvidence) const reviewContext = same ? previous : { contextId: input.contextId || makeId('c'), panelId, targetId: panel.targetId, profileId, url: panel.url, - mediaKind: mediaKind(panel.url), viewport: viewportFor(panel), - providerEvidence: restoredProviderEvidence(panel.providerEvidence), linkedAt: input.linkedAt || new Date().toISOString(), + mediaKind: mediaKind(panel.url), viewport: viewportFor(nextPanel), + providerEvidence, linkedAt: input.linkedAt || new Date().toISOString(), stale: false, staleReason: '' } return { ...state, + browserPanels: nextPanel === panel ? state.browserPanels : { ...state.browserPanels, [panelId]: nextPanel }, browserSplit: panelId === 'reference' ? true : state.browserSplit, qcProfile: profileId, qcTargetPanelId: panelId, reviewContext, ...(same ? {} : { @@ -355,7 +425,11 @@ export function markPanelLoadFailedState(state, panelId) { const panel = state.browserPanels[panelId] const context = state.reviewContext if (!panel || !context || context.stale || context.panelId !== panelId || context.targetId !== panel.targetId) return state - return { ...state, reviewContext: { ...context, stale: true, staleReason: 'load-failed' } } + return { + ...state, + reviewContext: { ...context, stale: true, staleReason: 'load-failed' }, + ...(state.capture?.panelId === panelId ? { capture: null } : {}) + } } function restoredDimension(value) { @@ -396,7 +470,7 @@ function restoredJob(value) { function restoredPanel(value, defaults, legacyUrl = '') { const source = isRecord(value) ? value : {} return { - url: typeof source.url === 'string' ? source.url : legacyUrl, + url: sanitizeUrl(typeof source.url === 'string' ? source.url : legacyUrl), targetId: validId(source.targetId, 't') ? source.targetId : createId('t'), preset: typeof source.preset === 'string' ? source.preset : defaults.preset, width: Number.isFinite(source.width) && source.width >= 240 ? source.width : defaults.width, @@ -427,9 +501,13 @@ function restoredCapture(value) { if (!isRecord(value) || !['result', 'reference'].includes(value.panelId) || !validId(value.targetId, 't')) return null if (!Number.isInteger(value.width) || value.width <= 0 || !Number.isInteger(value.height) || value.height <= 0) return null if (typeof value.path !== 'string' || !value.path) return null + const viewport = isRecord(value.viewport) && Number.isFinite(value.viewport.width) && Number.isFinite(value.viewport.height) + ? { preset: String(value.viewport.preset || ''), width: Math.round(value.viewport.width), height: Math.round(value.viewport.height), responsive: value.viewport.responsive === true } + : undefined return { - panelId: value.panelId, targetId: value.targetId, url: typeof value.url === 'string' ? value.url : '', + panelId: value.panelId, targetId: value.targetId, url: sanitizeUrl(value.url), width: value.width, height: value.height, + ...(viewport ? { viewport } : {}), createdAt: Number.isFinite(value.createdAt) ? value.createdAt : typeof value.createdAt === 'string' ? value.createdAt : '', path: value.path } @@ -559,7 +637,7 @@ export function migratePersistedState(saved, defaults) { result: restoredPanel(source.browserPanels?.result, defaults.browserPanels.result, legacyUrl), reference: restoredPanel(source.browserPanels?.reference, defaults.browserPanels.reference) } - const reviewContext = restoredReviewContext(source.reviewContext, browserPanels, source.schemaVersion !== PERSISTED_SCHEMA_VERSION) + const reviewContext = restoredReviewContext(source.reviewContext, browserPanels, Number(source.schemaVersion) < 6) const restored = { ...defaults, schemaVersion: PERSISTED_SCHEMA_VERSION, @@ -575,7 +653,7 @@ export function migratePersistedState(saved, defaults) { qcJson: typeof source.qcJson === 'string' ? source.qcJson : '', capture: restoredCapture(source.capture) } - if (!restored.capture && source.schemaVersion !== PERSISTED_SCHEMA_VERSION && isRecord(source.capture)) { + if (!restored.capture && Number(source.schemaVersion) < 6 && isRecord(source.capture)) { const panel = browserPanels[source.capture.panelId] if (panel && source.capture.url === panel.url) { restored.capture = restoredCapture({ ...source.capture, targetId: panel.targetId }) @@ -597,7 +675,7 @@ export function migratePersistedState(saved, defaults) { // Provider descriptor registry. Midjourney is the first adapter: its QC wire // format is the frozen schema-v1 document contract (`validateQcDocument`). -// Descriptor dimensions MUST be drawn from the persisted schema-v6 candidate +// Descriptor dimensions MUST be drawn from the persisted schema-v7 candidate // dimension vocabulary (`QC_DIMENSIONS`) so structured review state stays // storable without a persisted-schema bump; `assertProviderRegistry` enforces // this at module init. @@ -660,6 +738,30 @@ export const PROVIDERS = Object.freeze({ chatImageToolNames: Object.freeze(['higgsfield']), qcDocument: null, automation: null + }), + 'higgsfield-web': Object.freeze({ + id: 'higgsfield-web', + label: 'Higgsfield Web — Unlimited', + profileId: 'higgsfield-image', + candidateIds: CANDIDATE_IDS, + structuredReview: true, + dimensions: Object.freeze(['promptFidelity', 'identityReferenceFidelity', 'anatomyGeometry', 'artifacts', 'colorMaterialFidelity', 'typography', 'composition']), + dimensionLabels: Object.freeze({ + promptFidelity: 'Prompt adherence', + identityReferenceFidelity: 'Subject / product identity', + anatomyGeometry: 'Anatomy & geometry', + artifacts: 'Artifacts & cleanup', + colorMaterialFidelity: 'Color grade & critical colors', + typography: 'Text, logo & labels', + composition: 'Framing & crop' + }), + chatImageToolNames: Object.freeze([]), + qcDocument: null, + automation: Object.freeze({ + target: 'hermes-internal-browser-pane', + recipe: 'higgsfield-web-2026-07-20.v1', + externalBrowserFallback: 'forbidden' + }) }) }) @@ -703,9 +805,13 @@ export function qcProfileFor(input = {}) { if (/\.(mp4|mov|webm|mkv|avi)(?:[?#]|$)/i.test(src) || toolName.includes('generate_video')) return 'higgsfield-video' return matches.length ? matches[0].profileId : 'design' } -const AGENT_COMMAND_OPS = ['status', 'set-target', 'link', 'capture', 'inspect', 'page-checks', 'set-check', 'score-candidate', 'select-candidate', 'import-qc'] +const AGENT_COMMAND_OPS = ['status', 'set-target', 'link', 'capture', 'inspect', 'page-checks', 'midjourney-probe', 'midjourney-control', 'higgsfield-control', 'set-check', 'score-candidate', 'select-candidate', 'import-qc'] const AGENT_PANEL_IDS = ['result', 'reference'] const AGENT_CHECK_STATUSES = ['pass', 'fail', 'na', 'pending'] +const MIDJOURNEY_CONTROL_ACTIONS = ['capabilities', 'state', 'navigate', 'probe', 'results', 'settings', 'draft', 'attach', 'detach', 'validate', 'submit', 'wait', 'link', 'grid', 'action', 'download', 'capture', 'qc'] +const HIGGSFIELD_MODELS = ['Seedream 5.0 Lite', 'Nano Banana 2', 'Seedream 4.5'] +const HIGGSFIELD_ASPECTS = ['1:1', '16:9', '9:16'] +const HIGGSFIELD_CONTROL_ACTIONS = ['capabilities', 'state', 'navigate', 'draft', 'validate', 'generate', 'results', 'qc'] function agentCommandError(message) { return { ok: false, error: message } @@ -718,6 +824,45 @@ function hasOnlyKeys(value, keys) { function validAgentString(value, max) { return typeof value === 'string' && value.length <= max } +function validateMidjourneyControlPayload(payload) { + if (!isRecord(payload) || !MIDJOURNEY_CONTROL_ACTIONS.includes(payload.action)) return false + const common = ['action'] + if (['capabilities', 'state', 'probe', 'results', 'validate', 'grid', 'capture', 'qc'].includes(payload.action)) return hasOnlyKeys(payload, common) + if (payload.action === 'settings') { + if (hasOnlyKeys(payload, common)) return true + if (!hasOnlyKeys(payload, [...common, 'name', 'value']) || !validAgentString(payload.name, 40)) return false + const choices = { + aspect: ['portrait', 'square', 'landscape'], model: ['standard', 'hd'], raw: ['standard', 'raw'], + speed: ['relax', 'fast'], videoResolution: ['sd', 'hd'], personalization: [true, false] + } + if (Object.hasOwn(choices, payload.name)) return choices[payload.name].includes(payload.value) + return false + } + if (payload.action === 'download') return hasOnlyKeys(payload, [...common, 'jobId', 'filename']) && validAgentString(payload.jobId, 80) && /^[A-Za-z0-9_-]+$/.test(payload.jobId) && validAgentString(payload.filename, 120) && /^[A-Za-z0-9._-]+\.(?:png|jpe?g|webp|gif|avif|bmp)$/i.test(payload.filename) + if (payload.action === 'navigate') return hasOnlyKeys(payload, [...common, 'url']) && validAgentString(payload.url, 4096) && /^https:\/\/(?:www\.)?midjourney\.com(?:\/|$)/i.test(payload.url) + if (payload.action === 'draft') return hasOnlyKeys(payload, [...common, 'prompt', 'parameters']) && validAgentString(payload.prompt, 6000) && payload.prompt.trim().length > 0 && (!Object.hasOwn(payload, 'parameters') || isRecord(payload.parameters)) + if (payload.action === 'attach') return hasOnlyKeys(payload, [...common, 'path', 'role']) && validAgentString(payload.path, 4096) && payload.path.length > 0 && ['start-frame', 'image-prompt', 'style-reference', 'omni-reference'].includes(payload.role) + if (payload.action === 'detach') return hasOnlyKeys(payload, [...common, 'role']) && ['start-frame', 'image-prompt', 'style-reference', 'omni-reference'].includes(payload.role) + if (payload.action === 'submit') return hasOnlyKeys(payload, [...common, 'approved', 'idempotencyKey', 'validateReceipt', 'batchFingerprint']) && payload.approved === true && validAgentString(payload.idempotencyKey, 128) && payload.idempotencyKey.length >= 8 && validAgentString(payload.validateReceipt, 80) && payload.validateReceipt.length > 0 && /^[a-f0-9]{64}$/.test(String(payload.batchFingerprint || '')) + if (payload.action === 'wait') return hasOnlyKeys(payload, [...common, 'timeoutMs']) && (!Object.hasOwn(payload, 'timeoutMs') || Number.isInteger(payload.timeoutMs) && payload.timeoutMs >= 1000 && payload.timeoutMs <= 30000) + if (payload.action === 'link') return hasOnlyKeys(payload, [...common, 'operationId', 'prompt', 'jobId', 'acknowledged', 'ledgerCreatedAt']) && /^[a-f0-9]{64}$/.test(String(payload.operationId || '')) && validAgentString(payload.prompt, 6000) && payload.prompt.trim().length > 0 && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(String(payload.jobId || '')) && payload.acknowledged === true && validAgentString(payload.ledgerCreatedAt, 40) && Number.isFinite(Date.parse(payload.ledgerCreatedAt)) + if (payload.action === 'action') { + const validName = ['select', 'upscale', 'vary', 'reroll', 'pan', 'zoom'].includes(payload.name) + const validCandidate = !Object.hasOwn(payload, 'candidate') || ['A', 'B', 'C', 'D', '1', '2', '3', '4'].includes(String(payload.candidate)) + const validJob = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(String(payload.jobId || '')) + if (payload.name === 'select') return hasOnlyKeys(payload, [...common, 'name', 'candidate', 'jobId']) && validName && Object.hasOwn(payload, 'candidate') && validCandidate && validJob + return hasOnlyKeys(payload, [...common, 'name', 'candidate', 'jobId', 'approved', 'idempotencyKey']) && validName && validCandidate && validJob && payload.approved === true && validAgentString(payload.idempotencyKey, 128) && payload.idempotencyKey.length >= 8 + } + return false +} +export function validateHiggsfieldControlPayload(payload) { + if (!isRecord(payload) || !HIGGSFIELD_CONTROL_ACTIONS.includes(payload.action)) return false + if (['capabilities', 'state', 'validate', 'results', 'qc'].includes(payload.action)) return hasOnlyKeys(payload, ['action']) + if (payload.action === 'navigate') return hasOnlyKeys(payload, ['action', 'url']) && validAgentString(payload.url, 4096) && /^https:\/\/(?:www\.)?higgsfield\.ai(?:\/|$)/i.test(payload.url) + if (payload.action === 'draft') return hasOnlyKeys(payload, ['action', 'prompt', 'aspect', 'model']) && validAgentString(payload.prompt, 6000) && payload.prompt.trim().length > 0 && HIGGSFIELD_ASPECTS.includes(payload.aspect) && HIGGSFIELD_MODELS.includes(payload.model) + if (payload.action === 'generate') return hasOnlyKeys(payload, ['action', 'billableConfirmed', 'idempotencyKey', 'validateReceipt', 'batchFingerprint']) && payload.billableConfirmed === true && validAgentString(payload.idempotencyKey, 128) && payload.idempotencyKey.length >= 8 && validAgentString(payload.validateReceipt, 80) && /^[a-f0-9]{64}$/.test(String(payload.batchFingerprint || '')) + return false +} export function validateAgentCommand(value) { if (!isRecord(value) || !hasOnlyKeys(value, ['id', 'op', 'panelId', 'payload'])) return agentCommandError('Command must be an object with only id, op, panelId, and payload') @@ -725,21 +870,24 @@ export function validateAgentCommand(value) { if (!AGENT_COMMAND_OPS.includes(value.op)) return agentCommandError('op is unknown') if (!isRecord(value.payload)) return agentCommandError('payload must be an object') - const needsPanel = ['set-target', 'link', 'capture', 'inspect', 'page-checks'].includes(value.op) + const needsPanel = ['set-target', 'link', 'capture', 'inspect', 'page-checks', 'midjourney-probe', 'midjourney-control', 'higgsfield-control'].includes(value.op) if (needsPanel ? !AGENT_PANEL_IDS.includes(value.panelId) : Object.hasOwn(value, 'panelId')) { return agentCommandError(needsPanel ? 'panelId must be result or reference' : 'panelId is not allowed for this op') } const payload = value.payload if (value.op === 'status' && hasOnlyKeys(payload, [])) return { ok: true, command: value } - if (value.op === 'set-target' && hasOnlyKeys(payload, ['url', 'preset', 'width', 'height']) && + if (value.op === 'set-target' && hasOnlyKeys(payload, ['url', 'preset', 'width', 'height', 'providerEvidence']) && validAgentString(payload.url, 4096) && /^(https?|file|data):/i.test(payload.url) && (!Object.hasOwn(payload, 'preset') || validAgentString(payload.preset, 64)) && (!Object.hasOwn(payload, 'width') || Number.isInteger(payload.width) && payload.width >= 240) && - (!Object.hasOwn(payload, 'height') || Number.isInteger(payload.height) && payload.height >= 240)) return { ok: true, command: value } + (!Object.hasOwn(payload, 'height') || Number.isInteger(payload.height) && payload.height >= 240) && + (!Object.hasOwn(payload, 'providerEvidence') || (restoredProviderEvidence(payload.providerEvidence) !== null && comparableUrl(restoredProviderEvidence(payload.providerEvidence).resultUrl) === comparableUrl(payload.url)))) return { ok: true, command: value } if (value.op === 'link' && hasOnlyKeys(payload, ['profileId']) && (!Object.hasOwn(payload, 'profileId') || QC_PROFILE_IDS.includes(payload.profileId))) return { ok: true, command: value } - if (['capture', 'inspect', 'page-checks'].includes(value.op) && hasOnlyKeys(payload, [])) return { ok: true, command: value } + if (['capture', 'inspect', 'page-checks', 'midjourney-probe'].includes(value.op) && hasOnlyKeys(payload, [])) return { ok: true, command: value } + if (value.op === 'midjourney-control' && validateMidjourneyControlPayload(payload)) return { ok: true, command: value } + if (value.op === 'higgsfield-control' && validateHiggsfieldControlPayload(payload)) return { ok: true, command: value } if (value.op === 'set-check' && hasOnlyKeys(payload, ['profileId', 'checkId', 'status', 'note']) && QC_PROFILE_IDS.includes(payload.profileId) && validAgentString(payload.checkId, 64) && payload.checkId && AGENT_CHECK_STATUSES.includes(payload.status) && @@ -771,15 +919,17 @@ export function agentStatusSnapshot(state) { qcTargetPanelId: state.qcTargetPanelId, reviewContext: context ? { contextId: context.contextId, panelId: context.panelId, targetId: context.targetId, profileId: context.profileId, - url: context.url, mediaKind: context.mediaKind, stale: context.stale, staleReason: context.staleReason, + url: sanitizeUrl(context.url), mediaKind: context.mediaKind, stale: context.stale, staleReason: context.staleReason, providerJobId: context.providerEvidence?.jobId || '' } : null, panels: Object.fromEntries(AGENT_PANEL_IDS.map(panelId => { const panel = state.browserPanels[panelId] || {} - return [panelId, { url: panel.url || '', targetId: panel.targetId || '', mediaKind: mediaKind(panel.url) }] + return [panelId, { url: sanitizeUrl(panel.url), targetId: panel.targetId || '', mediaKind: mediaKind(panel.url) }] })), capture: state.capture ? { - panelId: state.capture.panelId, targetId: state.capture.targetId, width: state.capture.width, height: state.capture.height, path: state.capture.path + panelId: state.capture.panelId, targetId: state.capture.targetId, url: sanitizeUrl(state.capture.url), + ...(state.capture.viewport ? { viewport: state.capture.viewport } : {}), + width: state.capture.width, height: state.capture.height, path: state.capture.path } : null, evaluations: Object.fromEntries(Object.entries(state.evaluations || {}).map(([profileId, checks]) => [ profileId, Object.fromEntries(Object.entries(checks || {}).map(([checkId, evaluation]) => [checkId, { status: evaluation.status }])) @@ -798,7 +948,7 @@ export function applyAgentCommand(state, command, makeId = createId) { const { op, payload } = command if (op === 'status') return { state, summary: 'Status snapshot' } if (op === 'set-target') { - const patch = Object.fromEntries(Object.entries(payload).filter(([key]) => ['url', 'preset', 'width', 'height'].includes(key))) + const patch = Object.fromEntries(Object.entries(payload).filter(([key]) => ['url', 'preset', 'width', 'height', 'providerEvidence'].includes(key))) return { state: updatePanelState(state, command.panelId, patch, {}, makeId), summary: `Set ${command.panelId} target` } } if (op === 'link') { diff --git a/tests/higgsfield-control.test.mjs b/tests/higgsfield-control.test.mjs new file mode 100644 index 0000000..d237697 --- /dev/null +++ b/tests/higgsfield-control.test.mjs @@ -0,0 +1,127 @@ +import assert from 'node:assert/strict' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { spawnSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import test from 'node:test' + +import { + READ_ONLY_COMMANDS, + BLOCKED_COMMANDS, + resolveReadOnlyArgv, + assertHiggsfieldBinary, + extractJobs, + selectJobByUrl, + selectJobById, + evidenceForJob +} from '../scripts/higgsfield-control.mjs' + +const script = fileURLToPath(new URL('../scripts/higgsfield-control.mjs', import.meta.url)) +const SIGNED = 'https://cdn.higgsfield.ai/generations/abc.png?token=secret-token&sig=secret-sig&Expires=123&Key-Pair-Id=secret-key' + +function job(overrides = {}) { + return { + id: '7483fa69-b1e8-42b1-b830-b3ae443ec3d1', + status: 'completed', + display_name: 'Higgsfield Soul V2', + job_set_type: 'text2image_soul_v2', + result_url: SIGNED, + created_at: 1784570752.16, + params: { prompt: 'a cinematic test prompt', width: 1152, height: 2048, batch_size: 1, seed: 81272, aspect_ratio: '9:16', custom_reference_id: 'soul-123' }, + ...overrides + } +} + +function invoke(args, env = {}) { + return spawnSync(process.execPath, [script, ...args], { encoding: 'utf8', env: { ...process.env, ...env } }) +} + +test('read-only allowlist maps only observation subcommands', () => { + assert.deepEqual(resolveReadOnlyArgv('account'), ['account', 'status', '--json']) + assert.deepEqual(resolveReadOnlyArgv('generations'), ['generate', 'list', '--json']) + assert.deepEqual(resolveReadOnlyArgv('souls'), ['soul-id', 'list', '--json']) + assert.deepEqual(resolveReadOnlyArgv('models'), ['model', 'list', '--json']) + assert.deepEqual(resolveReadOnlyArgv('job', { jobId: '7483fa69-b1e8-42b1-b830-b3ae443ec3d1' }), [ + 'generate', 'get', '7483fa69-b1e8-42b1-b830-b3ae443ec3d1', '--json' + ]) + assert.ok(!Object.keys(READ_ONLY_COMMANDS).includes('create')) +}) + +test('read-only firewall refuses every mutating or credential command', () => { + for (const command of BLOCKED_COMMANDS) { + assert.throws(() => resolveReadOnlyArgv(command), /strictly read-only/) + } + assert.throws(() => resolveReadOnlyArgv('unknown-op'), /Unknown read-only command/) + assert.throws(() => resolveReadOnlyArgv('job', { jobId: 'bad id' }), /valid --job-id/) +}) + +test('refuses the hf binary that collides with the HuggingFace CLI', () => { + assert.throws(() => assertHiggsfieldBinary('hf'), /HuggingFace/) + assert.throws(() => assertHiggsfieldBinary('/opt/homebrew/bin/hf'), /HuggingFace/) + assert.equal(assertHiggsfieldBinary('higgsfield'), 'higgsfield') + assert.equal(assertHiggsfieldBinary('/Users/x/.npm-global/bin/higgsfield'), '/Users/x/.npm-global/bin/higgsfield') +}) + +test('normalizes a CLI job into workbench provider evidence and redacts the signed url', () => { + const evidence = evidenceForJob(job()) + assert.equal(evidence.source, 'higgsfield-mcp') + assert.equal(evidence.jobId, '7483fa69-b1e8-42b1-b830-b3ae443ec3d1') + assert.equal(evidence.model, 'text2image_soul_v2') + assert.equal(evidence.mediaType, 'image') + assert.match(evidence.prompt, /cinematic test prompt/) + assert.equal(evidence.width, 1152) + assert.equal(evidence.height, 2048) + assert.equal(evidence.soulId, 'soul-123') + assert.ok(evidence.resultUrl.startsWith('https://cdn.higgsfield.ai/generations/abc.png')) + for (const secret of ['secret-token', 'secret-sig', 'secret-key']) { + assert.ok(!evidence.resultUrl.includes(secret), `resultUrl leaked ${secret}`) + } +}) + +test('selects exactly one job and rejects ambiguity', () => { + assert.equal(selectJobByUrl([job()], SIGNED)?.id, '7483fa69-b1e8-42b1-b830-b3ae443ec3d1') + assert.equal(selectJobByUrl([job(), job()], SIGNED), null) + assert.equal(selectJobByUrl([], SIGNED), null) + assert.equal(selectJobById([job()], '7483fa69-b1e8-42b1-b830-b3ae443ec3d1')?.status, 'completed') + assert.equal(selectJobById([job(), job()], '7483fa69-b1e8-42b1-b830-b3ae443ec3d1'), null) +}) + +test('extractJobs reads common list envelopes', () => { + assert.equal(extractJobs([job()]).length, 1) + assert.equal(extractJobs({ items: [job()] }).length, 1) + assert.equal(extractJobs({ jobs: [job(), job()] }).length, 2) + assert.equal(extractJobs({ nothing: true }).length, 0) +}) + +test('--print-argv prints the resolved read-only argv without executing', () => { + const account = invoke(['--print-argv', 'account']) + assert.equal(account.status, 0, account.stderr) + assert.deepEqual(JSON.parse(account.stdout), ['account', 'status', '--json']) +}) + +test('--print-argv fails closed for a mutating command', () => { + const create = invoke(['--print-argv', 'create']) + assert.notEqual(create.status, 0) + assert.match(create.stderr, /read-only/) +}) + +test('evidence subcommand normalizes an offline generations file', async t => { + const directory = await mkdtemp(join(tmpdir(), 'hf-control-')) + t.after(() => rm(directory, { force: true, recursive: true })) + const file = join(directory, 'generations.json') + await writeFile(file, JSON.stringify({ items: [job()] })) + + const result = invoke(['evidence', '--input', file, '--url', SIGNED]) + assert.equal(result.status, 0, result.stderr) + const evidence = JSON.parse(result.stdout) + assert.equal(evidence.jobId, '7483fa69-b1e8-42b1-b830-b3ae443ec3d1') + assert.equal(evidence.model, 'text2image_soul_v2') + for (const secret of ['secret-token', 'secret-sig', 'secret-key']) { + assert.ok(!JSON.stringify(evidence).includes(secret), `evidence leaked ${secret}`) + } + + const ambiguous = invoke(['evidence', '--input', file, '--url', 'https://cdn.higgsfield.ai/missing.png']) + assert.notEqual(ambiguous.status, 0) + assert.match(ambiguous.stderr, /No single exact/) +}) diff --git a/tests/install.test.mjs b/tests/install.test.mjs index 3305c11..8c5d784 100644 --- a/tests/install.test.mjs +++ b/tests/install.test.mjs @@ -9,17 +9,20 @@ const root = resolve(import.meta.dirname, '..') const installer = join(root, 'scripts', 'install.mjs') const sourcePlugin = await readFile(join(root, 'plugin.js'), 'utf8') const sourceSkill = await readFile(join(root, 'skill', 'SKILL.md'), 'utf8') +const sourceBackendManifest = await readFile(join(root, 'backend', 'plugin.yaml'), 'utf8') +const sourceBackendInit = await readFile(join(root, 'backend', '__init__.py'), 'utf8') const sourceDashboardManifest = await readFile(join(root, 'dashboard', 'manifest.json'), 'utf8') const sourceDashboardApi = await readFile(join(root, 'dashboard', 'plugin_api.py'), 'utf8') +const sourceControlCli = await readFile(join(root, 'scripts', 'midjourney-control.mjs'), 'utf8') -function run(args) { +function run(args, env = {}) { const forwarded = [...args] const targetIndex = forwarded.indexOf('--target') if (targetIndex >= 0 && !forwarded.includes('--skill-target')) { forwarded.push('--skill-target', join(forwarded[targetIndex + 1], 'skill')) } if (targetIndex >= 0 && !forwarded.includes('--hermes-home')) forwarded.push('--hermes-home', forwarded[targetIndex + 1]) - return spawnSync(process.execPath, [installer, ...forwarded], { cwd: root, encoding: 'utf8' }) + return spawnSync(process.execPath, [installer, ...forwarded], { cwd: root, encoding: 'utf8', env: { ...process.env, ...env } }) } async function workspace() { @@ -36,10 +39,19 @@ test('installs, updates with backup, and uninstalls managed plugin, skill, and d assert.match(installed.stdout, /Run: hermes plugins enable visual-workbench \(backend restart required\)/) assert.equal(await readFile(join(target, 'plugin.js'), 'utf8'), sourcePlugin) assert.equal(await readFile(join(target, 'skill', 'SKILL.md'), 'utf8'), sourceSkill) + assert.equal(await readFile(join(target, 'plugins', 'visual-workbench', 'plugin.yaml'), 'utf8'), sourceBackendManifest) + assert.equal(await readFile(join(target, 'plugins', 'visual-workbench', '__init__.py'), 'utf8'), sourceBackendInit) assert.equal(await readFile(join(dashboardTarget, 'manifest.json'), 'utf8'), sourceDashboardManifest) assert.equal(await readFile(join(dashboardTarget, 'plugin_api.py'), 'utf8'), sourceDashboardApi) const marker = JSON.parse(await readFile(join(target, '.hermes-visual-workbench-install.json'), 'utf8')) - assert.deepEqual(marker.files.map(file => file.id), ['plugin', 'skill', 'dashboard-manifest', 'dashboard-api']) + assert.deepEqual(marker.files.map(file => file.id), [ + 'plugin', + 'skill', + 'backend-manifest', + 'backend-init', + 'dashboard-manifest', + 'dashboard-api' + ]) await writeFile(join(target, 'plugin.js'), '// locally modified\n') await writeFile(join(dashboardTarget, 'plugin_api.py'), '# locally modified\n') @@ -60,6 +72,8 @@ test('installs, updates with backup, and uninstalls managed plugin, skill, and d assert.equal(removed.status, 0, removed.stderr) await assert.rejects(readFile(join(target, 'plugin.js')), /ENOENT/) await assert.rejects(readFile(join(target, 'skill', 'SKILL.md')), /ENOENT/) + await assert.rejects(readFile(join(target, 'plugins', 'visual-workbench', 'plugin.yaml')), /ENOENT/) + await assert.rejects(readFile(join(target, 'plugins', 'visual-workbench', '__init__.py')), /ENOENT/) await assert.rejects(readFile(join(dashboardTarget, 'manifest.json')), /ENOENT/) await assert.rejects(readFile(join(dashboardTarget, 'plugin_api.py')), /ENOENT/) }) @@ -73,6 +87,66 @@ test('accepts package-runner forwarded arguments after --', async t => { assert.equal(await readFile(join(target, 'plugin.js'), 'utf8'), sourcePlugin) }) +test('supports dry-run, verify, update, and compatibility errors', async t => { + const target = await workspace() + t.after(() => rm(target, { force: true, recursive: true })) + const dry = run(['--target', target, '--dry-run']) + assert.equal(dry.status, 0, dry.stderr) + assert.match(dry.stdout, /Dry run install\/update: 6 managed file\(s\) would change/) + await assert.rejects(readFile(join(target, 'plugin.js')), /ENOENT/) + assert.equal(run(['--target', target, '--install']).status, 0) + assert.equal(run(['--target', target, '--verify']).status, 0) + await writeFile(join(target, 'plugin.js'), '// drift\n') + const incompatible = run(['--target', target, '--verify']) + assert.notEqual(incompatible.status, 0) + assert.match(incompatible.stderr, /Compatibility error.*--update/) + assert.equal(run(['--target', target, '--update']).status, 0) + assert.equal(run(['--target', target, '--verify']).status, 0) +}) + +test('rolls back every managed file from its newest backup', async t => { + const target = await workspace() + t.after(() => rm(target, { force: true, recursive: true })) + assert.equal(run(['--target', target]).status, 0) + const prior = [ + ['plugin.js', '// prior plugin\n'], ['skill/SKILL.md', '# prior skill\n'], + ['plugins/visual-workbench/plugin.yaml', 'prior manifest\n'], + ['plugins/visual-workbench/__init__.py', '# prior backend\n'], + ['plugins/visual-workbench/dashboard/manifest.json', '{}\n'], + ['plugins/visual-workbench/dashboard/plugin_api.py', '# prior api\n'] + ] + for (const [relative, value] of prior) await writeFile(join(target, relative), value) + assert.equal(run(['--target', target, '--update']).status, 0) + const dry = run(['--target', target, '--rollback', '--dry-run']) + assert.equal(dry.status, 0, dry.stderr) + assert.match(dry.stdout, /Dry run rollback: 6 managed file\(s\) would be restored/) + assert.equal(run(['--target', target, '--rollback']).status, 0) + assert.equal(await readFile(join(target, 'plugin.js'), 'utf8'), '// prior plugin\n') + const incompatible = run(['--target', target, '--verify']) + assert.notEqual(incompatible.status, 0) + assert.match(incompatible.stderr, /Compatibility error/) +}) + +test('rollback restores one coherent partial-update transaction', async t => { + const target = await workspace() + t.after(() => rm(target, { force: true, recursive: true })) + assert.equal(run(['--target', target]).status, 0) + const skillBefore = await readFile(join(target, 'skill', 'SKILL.md'), 'utf8') + const markerBefore = await readFile(join(target, '.hermes-visual-workbench-install.json'), 'utf8') + + await writeFile(join(target, 'plugin.js'), '// prior partial plugin\n') + assert.equal(run(['--target', target, '--update']).status, 0) + + const dry = run(['--target', target, '--rollback', '--dry-run']) + assert.equal(dry.status, 0, dry.stderr) + assert.match(dry.stdout, /restore plugin/) + assert.match(dry.stdout, /keep skill/) + assert.equal(run(['--target', target, '--rollback']).status, 0) + assert.equal(await readFile(join(target, 'plugin.js'), 'utf8'), '// prior partial plugin\n') + assert.equal(await readFile(join(target, 'skill', 'SKILL.md'), 'utf8'), skillBefore) + assert.equal(await readFile(join(target, '.hermes-visual-workbench-install.json'), 'utf8'), markerBefore) +}) + test('requires custom plugin and skill targets to be supplied together', async t => { const target = await workspace() t.after(() => rm(target, { force: true, recursive: true })) @@ -159,9 +233,40 @@ test('rejects symlinked managed files without touching their targets', async t = await symlink(sentinel, join(target, 'plugin.js')) const result = run(['--target', target]) assert.notEqual(result.status, 0) - assert.match(result.stderr, /must be a regular file/) + assert.match(result.stderr, /(must be a regular file|symlinked parent)/) assert.equal(await readFile(sentinel, 'utf8'), 'outside content\n') }) +test('rejects destination roots that escape Hermes home or traverse symlinked parents', async t => { + const home = await workspace() + const outside = await workspace() + const linked = join(home, 'linked') + t.after(async () => { + await rm(home, { force: true, recursive: true }) + await rm(outside, { force: true, recursive: true }) + }) + await symlink(outside, linked) + const escaped = spawnSync(process.execPath, [installer, '--hermes-home', home, '--target', outside, '--skill-target', join(outside, 'skill')], { cwd: root, encoding: 'utf8' }) + assert.notEqual(escaped.status, 0) + assert.match(escaped.stderr, /escapes Hermes home/) + const symlinked = spawnSync(process.execPath, [installer, '--hermes-home', home, '--target', linked, '--skill-target', join(linked, 'skill')], { cwd: root, encoding: 'utf8' }) + assert.notEqual(symlinked.status, 0) + assert.match(symlinked.stderr, /symlink/) +}) +test('rejects a nonexistent Hermes home below a symlinked profile parent', async t => { + const profile = await workspace() + const outside = await workspace() + const linkedProfile = join(profile, 'linked-profile') + t.after(async () => { + await rm(profile, { force: true, recursive: true }) + await rm(outside, { force: true, recursive: true }) + }) + await symlink(outside, linkedProfile) + const home = join(linkedProfile, '.hermes') + const result = spawnSync(process.execPath, [installer, '--hermes-home', home], { cwd: root, encoding: 'utf8' }) + assert.notEqual(result.status, 0) + assert.match(result.stderr, /symlink/) + await assert.rejects(readFile(join(outside, '.hermes', 'desktop-plugins', 'visual-workbench', 'plugin.js')), /ENOENT/) +}) test('rolls back an earlier managed write when a later write fails', async t => { const target = await workspace() @@ -178,13 +283,28 @@ test('rolls back an earlier managed write when a later write fails', async t => await writeFile(pluginPath, '// preserve on rollback\n') await chmod(skillTarget, 0o500) - const failed = run(['--target', target]) + const failed = run(['--target', target], { HERMES_VW_TEST_FAIL_OPERATION: 'temp:2' }) assert.notEqual(failed.status, 0) assert.match(failed.stderr, /Installation failed/) assert.equal(await readFile(pluginPath, 'utf8'), '// preserve on rollback\n') assert.equal(await readFile(markerPath, 'utf8'), markerBefore) }) +test('is mutation-idempotent when managed bytes and marker are current', async t => { + const target = await workspace() + t.after(() => rm(target, { force: true, recursive: true })) + assert.equal(run(['--target', target]).status, 0) + const markerPath = join(target, '.hermes-visual-workbench-install.json') + const markerBefore = await readFile(markerPath) + const pluginBefore = await readFile(join(target, 'plugin.js')) + const repeated = run(['--target', target]) + assert.equal(repeated.status, 0, repeated.stderr) + assert.match(repeated.stdout, /Verified Visual Workbench/) + assert.deepEqual(await readFile(markerPath), markerBefore) + assert.deepEqual(await readFile(join(target, 'plugin.js')), pluginBefore) + await assert.rejects(readdir(join(target, 'backups')), /ENOENT/) +}) + test('keeps package, manifest, and plugin versions aligned', async () => { const packageJson = JSON.parse(await readFile(join(root, 'package.json'), 'utf8')) const manifest = JSON.parse(await readFile(join(root, 'manifest.json'), 'utf8')) @@ -205,3 +325,34 @@ test('pins Midjourney automation to the Hermes internal Browser pane', () => { assert.doesNotMatch(sourceSkill, /internal_browser_unavailable/) assert.match(sourceSkill, /Never fall back to an external browser/) }) + +test('keeps desktop acknowledgements on session auth and local polling on token auth', () => { + assert.match(sourceBackendInit, /\/api\/plugins\/visual-workbench\/command/) + assert.match(sourceBackendInit, /\/api\/plugins\/visual-workbench\/control\/result/) + assert.doesNotMatch(sourceBackendInit, /["']\/api\/plugins\/visual-workbench\/result["']/) + assert.match(sourceDashboardApi, /@router\.post\("\/result"\)/) + assert.match(sourceDashboardApi, /@router\.get\("\/control\/result"\)/) + assert.match(sourceDashboardApi, /"operationId": reservation\["operationId"\]/) + assert.match(sourceControlCli, /control\/result\?cursor=\$\{cursor\}/) + assert.doesNotMatch(sourceControlCli, /control\/result\/\$\{encodeURIComponent\(id\)\}/) + assert.match(sourceControlCli, /--acknowledged/) + assert.match(sourceControlCli, /--ledger-created-at/) + assert.match(sourceDashboardApi, /"midjourney-control"/) +}) +test('durably reserves billable Midjourney commands before the command broadcast', () => { + assert.match(sourceDashboardApi, /_BILLABLE_LEDGER_LOCK = asyncio\.Lock\(\)/) + assert.match(sourceDashboardApi, /home\.expanduser\(\)\.resolve\(strict=False\) \/ "plugins" \/ "visual-workbench"/) + assert.match(sourceDashboardApi, /"idempotencyKeyHash"/) + assert.match(sourceDashboardApi, /"requestFingerprint"/) + assert.match(sourceDashboardApi, /"targetFingerprint"/) + assert.match(sourceDashboardApi, /os\.fsync\(destination\.fileno\(\)\)/) + assert.match(sourceDashboardApi, /os\.replace\(temporary, ledger_path\)/) + assert.match(sourceDashboardApi, /os\.fsync\(directory_fd\)/) + assert.match(sourceDashboardApi, /stat\.S_ISLNK/) + assert.match(sourceDashboardApi, /os\.chmod\(ledger_path, 0o600\)/) + assert.match(sourceDashboardApi, /if existing:\n return \{"queued": False, "existing": True/) + assert.match(sourceDashboardApi, /with _ledger_transaction\(\) as \(ledger_path, ledger\):/) + assert.match(sourceDashboardApi, /_register_receipt_context\(result, ledger\)/) + assert.match(sourceDashboardApi, /_acknowledge_in_ledger\(result_id, ledger\)/) + assert.match(sourceDashboardApi, /_write_billable_ledger_payload\(ledger_path, ledger\)/) +}) diff --git a/tests/midjourney-control.test.mjs b/tests/midjourney-control.test.mjs new file mode 100644 index 0000000..8bcfe85 --- /dev/null +++ b/tests/midjourney-control.test.mjs @@ -0,0 +1,47 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import test from 'node:test' +import { fileURLToPath } from 'node:url' + +const script = fileURLToPath(new URL('../scripts/midjourney-control.mjs', import.meta.url)) +const receipt = 'validation-receipt-from-one-validate-result' +const fingerprint = 'a'.repeat(64) + +function invoke(...args) { + return spawnSync(process.execPath, [script, '--serialize', ...args], { encoding: 'utf8' }) +} + +test('submit serialization preserves the validation receipt and batch fingerprint together', () => { + const result = invoke( + 'submit', + '--approve-billable', + '--idempotency-key', + 'idempotency-key', + '--validate-receipt', + receipt, + '--batch-fingerprint', + fingerprint + ) + + assert.equal(result.status, 0, result.stderr) + const command = JSON.parse(result.stdout) + assert.deepEqual(command.payload, { + action: 'submit', + approved: true, + idempotencyKey: 'idempotency-key', + validateReceipt: receipt, + batchFingerprint: fingerprint + }) +}) + +test('submit without a valid lowercase 64-hex batch fingerprint fails closed', () => { + for (const args of [ + ['submit', '--approve-billable', '--idempotency-key', 'idempotency-key', '--validate-receipt', receipt], + ['submit', '--approve-billable', '--idempotency-key', 'idempotency-key', '--validate-receipt', receipt, '--batch-fingerprint', 'A'.repeat(64)] + ]) { + const result = invoke(...args) + assert.notEqual(result.status, 0) + assert.match(result.stderr, /batch fingerprint/i) + assert.equal(result.stdout, '') + } +}) diff --git a/tests/packed-install.test.mjs b/tests/packed-install.test.mjs new file mode 100644 index 0000000..b907916 --- /dev/null +++ b/tests/packed-install.test.mjs @@ -0,0 +1,185 @@ +import assert from 'node:assert/strict' +import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { spawnSync } from 'node:child_process' +import test from 'node:test' + +const root = resolve(import.meta.dirname, '..') +const PACKED_HOST_PROBE = String.raw` +import asyncio +import importlib.util +import os +import sys +import types +from pathlib import Path + +package_root = Path(sys.argv[1]) +home = Path(sys.argv[2]) +token_routes = [] + +class DashboardAuthProvider: + pass + +class TokenPrincipal: + def __init__(self, **values): + self.__dict__.update(values) + +class Placeholder: + pass + +hermes_cli = types.ModuleType("hermes_cli") +hermes_cli.__path__ = [] +hermes_cli.__version__ = "0.18.2" +web_server = types.ModuleType("hermes_cli.web_server") +web_server._ws_auth_ok = lambda ws: False +hermes_cli.web_server = web_server +dashboard_auth = types.ModuleType("hermes_cli.dashboard_auth") +dashboard_auth.__path__ = [] +auth_base = types.ModuleType("hermes_cli.dashboard_auth.base") +auth_base.DashboardAuthProvider = DashboardAuthProvider +auth_base.LoginStart = Placeholder +auth_base.Session = Placeholder +auth_base.TokenPrincipal = TokenPrincipal +token_auth = types.ModuleType("hermes_cli.dashboard_auth.token_auth") +token_auth.register_token_route = token_routes.append +hermes_constants = types.ModuleType("hermes_constants") +hermes_constants.get_hermes_home = lambda: str(home) +sys.modules.update({ + "hermes_cli": hermes_cli, + "hermes_cli.web_server": web_server, + "hermes_cli.dashboard_auth": dashboard_auth, + "hermes_cli.dashboard_auth.base": auth_base, + "hermes_cli.dashboard_auth.token_auth": token_auth, + "hermes_constants": hermes_constants, +}) + +def load(name, path): + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + assert spec and spec.loader + spec.loader.exec_module(module) + return module + +backend = load("packed_visual_workbench_backend", package_root / "backend" / "__init__.py") + +class Context: + def __init__(self): + self.providers = [] + def register_dashboard_auth_provider(self, provider): + self.providers.append(provider) + +context = Context() +backend.register(context) +assert token_routes == [ + "/api/plugins/visual-workbench/command", + "/api/plugins/visual-workbench/control/result", +] +assert len(context.providers) == 1 +provider = context.providers[0] +assert provider.verify_token(token="wrong") is None +principal = provider.verify_token(token=provider._token) +assert principal and principal.principal == "visual-workbench-local-control" +token_path = home / "plugins" / "visual-workbench" / "control.token" +assert token_path.is_file() +assert os.stat(token_path).st_mode & 0o077 == 0 + +api = load("packed_visual_workbench_dashboard", package_root / "dashboard" / "plugin_api.py") +route_methods = { + route.path: set(getattr(route, "methods", None) or []) + for route in api.router.routes +} +assert "GET" in route_methods["/result"] +assert "GET" in route_methods["/state"] +assert "POST" in route_methods["/command"] +assert "GET" in route_methods["/control/result"] +assert "/commands" in route_methods + +class Socket: + def __init__(self): + self.accepted = False + self.closed = None + async def close(self, code): + self.closed = code + async def accept(self): + self.accepted = True + async def send_json(self, value): + pass + async def receive_text(self): + raise api.WebSocketDisconnect() + +unauthorized = Socket() +web_server._ws_auth_ok = lambda ws: False +asyncio.run(api.stream_commands(unauthorized)) +assert not unauthorized.accepted +assert unauthorized.closed == api.http_status.WS_1008_POLICY_VIOLATION + +authorized = Socket() +web_server._ws_auth_ok = lambda ws: True +asyncio.run(api.stream_commands(authorized)) +assert authorized.accepted +assert authorized.closed is None +print("packed-host-probe-ok") +` + +function command(command, args, cwd) { + return spawnSync(command, args, { cwd, encoding: 'utf8' }) +} + +test('packed archive contains installer sources and installs from packed bytes', async t => { + const directory = await mkdtemp(join(tmpdir(), 'hermes-visual-workbench-packed-')) + t.after(() => rm(directory, { force: true, recursive: true })) + + const packed = command('npm', ['pack', '--pack-destination', directory], root) + assert.equal(packed.status, 0, packed.stderr) + const archive = join(directory, (await readdir(directory)).find(name => name.endsWith('.tgz'))) + const listing = command('tar', ['-tzf', archive], directory) + assert.equal(listing.status, 0, listing.stderr) + assert.match(listing.stdout, /^package\/backend\/plugin.yaml$/m) + assert.match(listing.stdout, /^package\/backend\/__init__.py$/m) + assert.doesNotMatch(listing.stdout, /^package\/fixtures\//m) + + const project = join(directory, 'project') + await mkdir(project) + const npmInstalled = command('npm', ['install', '--offline', '--ignore-scripts', '--no-audit', '--no-fund', archive], project) + assert.equal(npmInstalled.status, 0, npmInstalled.stderr) + const packageRoot = join(project, 'node_modules', 'hermes-visual-workbench') + const home = join(directory, 'home') + const installer = join(project, 'node_modules', '.bin', 'hermes-visual-workbench') + const run = args => command(installer, ['--hermes-home', home, ...args], project) + + assert.equal(run(['--dry-run']).status, 0) + const installed = run(['--install']) + assert.equal(installed.status, 0, installed.stderr) + assert.equal(await readFile(join(home, 'desktop-plugins', 'visual-workbench', 'plugin.js'), 'utf8'), await readFile(join(packageRoot, 'plugin.js'), 'utf8')) + assert.equal(run(['--verify']).status, 0) + const repeated = run(['--install']) + assert.equal(repeated.status, 0, repeated.stderr) + assert.match(repeated.stdout, /Verified Visual Workbench/) + + const hostProbe = command('python3', ['-c', PACKED_HOST_PROBE, packageRoot, home], project) + assert.equal(hostProbe.status, 0, hostProbe.stderr) + assert.equal(hostProbe.stdout.trim(), 'packed-host-probe-ok') + + const packedPlugin = await readFile(join(packageRoot, 'plugin.js'), 'utf8') + for (const capability of ['browser?.capture', 'browser?.cdp']) { + assert.ok(packedPlugin.includes(capability), `packed plugin lacks typed ${capability} capability`) + } + assert.match(packedPlugin, /ctx\.socket\('\/commands'/) + for (const [relative, contents] of [ + ['plugin.js', '// prior packed plugin\n'], + ['skills/midjourney-visual-workbench/SKILL.md', '# prior packed skill\n'], + ['plugins/visual-workbench/plugin.yaml', 'prior packed manifest\n'], + ['plugins/visual-workbench/__init__.py', '# prior packed backend\n'], + ['plugins/visual-workbench/dashboard/manifest.json', '{}\n'], + ['plugins/visual-workbench/dashboard/plugin_api.py', '# prior packed api\n'] + ]) await writeFile(join(home, 'desktop-plugins', 'visual-workbench', relative), contents).catch(async error => { + if (error.code !== 'ENOENT') throw error + await writeFile(join(home, relative), contents) + }) + const updated = run(['--update']) + assert.equal(updated.status, 0, updated.stderr) + const rolledBack = run(['--rollback']) + assert.equal(rolledBack.status, 0, rolledBack.stderr) + assert.equal(await readFile(join(home, 'desktop-plugins', 'visual-workbench', 'plugin.js'), 'utf8'), '// prior packed plugin\n') +}) diff --git a/tests/plugin-runtime-core.test.mjs b/tests/plugin-runtime-core.test.mjs index ebd81a2..d848ef8 100644 --- a/tests/plugin-runtime-core.test.mjs +++ b/tests/plugin-runtime-core.test.mjs @@ -15,6 +15,7 @@ import { linkPanelState, markPanelLoadFailedState, providerEvidenceIdentity, + restoredProviderEvidence, swapPanelsState, updatePanelState, QC_PROFILE_IDS, @@ -28,11 +29,12 @@ import { const NOW = '2026-07-17T00:00:00.000Z' const root = resolve(import.meta.dirname, '..') const pluginSource = await readFile(resolve(root, 'plugin.js'), 'utf8') +const midjourneyCliSource = await readFile(resolve(root, 'scripts', 'midjourney-control.mjs'), 'utf8') function validDocument() { return { schemaVersion: 1, - job: { id: 'runtime-parity', state: 'GRID_READY', brief: 'Runtime parity', createdAt: NOW, updatedAt: NOW }, + job: { id: 'runtime-parity', state: 'SELECTED', brief: 'Runtime parity', createdAt: NOW, updatedAt: NOW }, selectedCandidate: 'A', candidates: CANDIDATE_IDS.map((id, index) => { const candidate = blankCandidate(id) @@ -58,7 +60,7 @@ function loadRuntimeCore() { assert.notEqual(finish, -1, 'plugin core end marker is missing') assert.ok(finish > start, 'plugin core markers are out of order') const source = pluginSource.slice(start + begin.length, finish) - return Function(`${source}\nreturn { DEFAULT_STATE, persistedState, restoredState, validateQcDocument, validateAgentCommand, applyAgentCommand, agentStatusSnapshot, PROVIDERS, PROVIDER_IDS, providerEvidenceFor, providerForProfile, qcProfileFor, reviewContextMatches, panelLinkedToQc, updatePanelState, linkPanelState, swapPanelsState, markPanelLoadFailedState, providerEvidenceIdentity, setRuntimeState(value) { state = { ...DEFAULT_STATE, ...value } } }`)() + return Function(`${source}\nreturn { DEFAULT_STATE, persistedState, restoredState, restoredProviderEvidence, validateQcDocument, validateAgentCommand, validateHiggsfieldControlPayload, HIGGSFIELD_MODELS, HIGGSFIELD_ASPECTS, applyAgentCommand, agentStatusSnapshot, PROVIDERS, PROVIDER_IDS, providerEvidenceFor, providerForProfile, qcProfileFor, reviewContextMatches, panelLinkedToQc, updatePanelState, linkPanelState, swapPanelsState, markPanelLoadFailedState, providerEvidenceIdentity, midjourneyJobLocation, midjourneyProviderEvidenceForUrl, sameMidjourneyCandidateSwitch, setRuntimeState(value) { state = { ...DEFAULT_STATE, ...value } } }`)() } function serializableProvider(provider) { @@ -94,6 +96,47 @@ test('runtime plugin validator stays behaviorally aligned with the standalone QC assert.throws(() => validateQcDocument(value)) } }) +test('runtime submit commands require the validation batch identity', () => { + const runtime = loadRuntimeCore() + const command = { + id: 'mj-submit', + op: 'midjourney-control', + panelId: 'result', + payload: { action: 'submit', approved: true, idempotencyKey: 'abcdefgh', validateReceipt: 'mjv-receipt', batchFingerprint: 'a'.repeat(64) } + } + assert.equal(runtime.validateAgentCommand(command).ok, true) + delete command.payload.batchFingerprint + assert.equal(runtime.validateAgentCommand(command).ok, false) + assert.equal(validateAgentCommand(command).ok, false) +}) +test('Higgsfield Web exposes exact models and one-shot billable validation', async () => { + const runtime = loadRuntimeCore() + assert.deepEqual(runtime.HIGGSFIELD_MODELS, ['Seedream 5.0 Lite', 'Nano Banana 2', 'Seedream 4.5']) + assert.equal(runtime.PROVIDERS['higgsfield-web'].label, 'Higgsfield Web — Unlimited') + const draft = { action: 'draft', prompt: 'studio product image', aspect: '16:9', model: 'Nano Banana 2' } + assert.equal(runtime.validateHiggsfieldControlPayload(draft), true) + const generate = { action: 'generate', billableConfirmed: true, idempotencyKey: 'hf-generate-fixture-1', validateReceipt: 'hfv-receipt', batchFingerprint: 'a'.repeat(64) } + assert.equal(runtime.validateAgentCommand({ id: 'hf-generate', op: 'higgsfield-control', panelId: 'result', payload: generate }).ok, true) + assert.equal(validateAgentCommand({ id: 'hf-generate', op: 'higgsfield-control', panelId: 'result', payload: generate }).ok, true) + generate.billableConfirmed = false + assert.equal(runtime.validateAgentCommand({ id: 'hf-generate', op: 'higgsfield-control', panelId: 'result', payload: generate }).ok, false) + assert.equal(validateAgentCommand({ id: 'hf-generate', op: 'higgsfield-control', panelId: 'result', payload: generate }).ok, false) + const source = await readFile(resolve('plugin.js'), 'utf8') + assert.match(source, /higgsfieldValidations\.delete\(payload\.validateReceipt\)/) + assert.match(source, /Higgsfield prompt, aspect, or model changed after validation/) +}) + +test('Higgsfield Web rejects caller-asserted QC provenance', () => { + const runtime = loadRuntimeCore() + const command = { + id: 'hf-link', + op: 'higgsfield-control', + panelId: 'result', + payload: { action: 'link', providerJobId: 'hf-job-123', resultUrl: 'https://cdn.higgsfield.ai/generations/result.png' } + } + assert.equal(runtime.validateAgentCommand(command).ok, false) + assert.equal(validateAgentCommand(command).ok, false) +}) test('runtime persisted-state restore repairs malformed and partial candidate data', () => { const runtime = loadRuntimeCore() @@ -149,7 +192,7 @@ test('runtime persistence drops ephemeral captures and preserves durable provena capture: durable }) assert.equal(restored.browserPanels.result.qcProfileHint, 'higgsfield-image') - assert.equal(restored.capture.createdAt, durable.createdAt) + assert.deepEqual(restored.capture, durable) }) test('runtime panel identity reducers stay behaviorally aligned with the standalone QC core', () => { @@ -304,11 +347,11 @@ test('runtime QC target routing and Browser-to-QC controls stay connected', () = } assert.match(pluginSource, /panelLinkedToQc\(workbench, panelId\)/) assert.match(pluginSource, /children: linkedCapture \? 'Refresh evidence' : 'Capture evidence'/) - assert.match(pluginSource, /pluginContext\?\.storage\.set\('workbench\.v6'/) + assert.match(pluginSource, /pluginContext\?\.storage\.set\('workbench\.v7'/) assert.match(pluginSource, /element\.addEventListener\('did-navigate', syncUrl\)/) assert.match(pluginSource, /browserSplit: panelId === 'reference' \? true : current\.browserSplit/) assert.match(pluginSource, /children: 'Inspection status'/) - assert.match(pluginSource, /label: 'MCP metadata'/) + assert.match(pluginSource, /label: 'Provider metadata'/) assert.match(pluginSource, /children: 'Run page checks'/) assert.match(pluginSource, /value: 'READ ONLY'/) assert.match(pluginSource, /providerEvidence: providerEvidenceFor\(input\)/) @@ -340,3 +383,147 @@ test('runtime agent command exports stay aligned with the standalone QC core', ( test('runtime dispatcher subscribes to the command socket', () => { assert.match(pluginSource, /ctx\.socket\('\/commands', received => \{ void dispatchAgentCommand\(ctx, received, seenAgentCommandIds\) \}\)/) }) + +test('runtime Midjourney QC links infer strict URL provenance without a control ledger', () => { + const runtime = loadRuntimeCore() + const jobId = '123e4567-e89b-42d3-a456-426614174000' + const url = `https://www.midjourney.com/jobs/${jobId}?index=0` + const state = structuredClone(runtime.DEFAULT_STATE) + state.browserPanels.result = { ...state.browserPanels.result, url, targetId: 'tmj-0000', providerEvidence: null } + const linked = runtime.linkPanelState(state, 'result', { profileId: 'midjourney', contextId: 'cmj-0000', linkedAt: NOW }, prefix => `${prefix}next-0000`) + assert.deepEqual(linked.reviewContext.providerEvidence, { + source: 'midjourney', jobId, operationId: '', resultUrl: url + }) + assert.deepEqual(linked.browserPanels.result.providerEvidence, linked.reviewContext.providerEvidence) + for (const invalidUrl of [ + `https://midjourney.com/jobs/${jobId}/`, + `https://midjourney.com/jobs/${jobId}?index=0&extra=1`, + `https://midjourney.example/jobs/${jobId}`, + `https://midjourney.com/jobs/not-a-uuid` + ]) assert.equal(runtime.midjourneyProviderEvidenceForUrl(invalidUrl), null, invalidUrl) +}) + +test('Midjourney candidate navigation preserves only the linked job while capture and stale guards remain strict', () => { + const runtime = loadRuntimeCore() + const jobId = '123e4567-e89b-42d3-a456-426614174000' + const url = `https://www.midjourney.com/jobs/${jobId}` + const evidence = { source: 'midjourney', jobId, operationId: 'op-1', resultUrl: url } + const base = structuredClone(runtime.DEFAULT_STATE) + base.browserPanels.result = { + ...base.browserPanels.result, url, targetId: 'tmj-0000', qcProfileHint: 'midjourney', providerEvidence: evidence + } + const linked = runtime.linkPanelState(base, 'result', { profileId: 'midjourney', contextId: 'cmj-0000', linkedAt: NOW }, prefix => `${prefix}next-0000`) + linked.capture = { + panelId: 'result', targetId: 'tmj-0000', url, width: 1440, height: 900, + viewport: { preset: 'desktop', width: 1440, height: 900, responsive: false }, createdAt: 1, path: '' + } + + assert.equal(runtime.agentStatusSnapshot(linked).capture.url, url) + assert.deepEqual(runtime.agentStatusSnapshot(linked).capture.viewport, linked.capture.viewport) + const candidateUrl = `https://www.midjourney.com/jobs/${jobId}?index=0` + assert.equal(runtime.sameMidjourneyCandidateSwitch(url, candidateUrl, jobId), true) + const navigateStart = pluginSource.indexOf("if (payload.action === 'navigate') {") + const navigateEnd = pluginSource.indexOf("const { context, snapshot } = await midjourneySnapshot(panelId)", navigateStart) + const navigateSource = pluginSource.slice(navigateStart, navigateEnd) + assert.match(navigateSource, /const panelEvidence = restoredProviderEvidence\(panel\?\.providerEvidence\)/) + assert.match(navigateSource, /const candidateJobId = link\?\.jobId \|\| \(panelEvidence\?\.source === 'midjourney' \? panelEvidence\.jobId : ''\)/) + assert.match(navigateSource, /sameMidjourneyCandidateSwitch\(currentUrl, payload\.url, candidateJobId\)/) + assert.match(navigateSource, /if \(!preserveMidjourneyCandidate\) midjourneyLinks\.delete\(panelId\)/) + assert.match(navigateSource, /preserveProviderEvidence: preserveMidjourneyCandidate/) + assert.match(navigateSource, /preserveQcProfileHint: preserveMidjourneyCandidate/) + assert.doesNotMatch(navigateSource, /preserveMidjourneyCandidate: true/) + const candidate = runtime.updatePanelState(linked, 'result', { url: candidateUrl }, { + preserveMidjourneyCandidate: runtime.sameMidjourneyCandidateSwitch(url, candidateUrl, jobId), + preserveProviderEvidence: true, preserveQcProfileHint: true + }, prefix => `${prefix}unexpected-0000`) + assert.equal(candidate.browserPanels.result.targetId, 'tmj-0000') + assert.equal(candidate.reviewContext.stale, false) + assert.equal(candidate.reviewContext.url, candidateUrl) + assert.equal(candidate.reviewContext.providerEvidence.jobId, jobId) + assert.equal(runtime.agentStatusSnapshot(candidate).reviewContext.providerJobId, jobId) + assert.equal(candidate.capture, null) + assert.deepEqual(runtime.restoredProviderEvidence({ source: 'midjourney', jobId: jobId.toUpperCase(), operationId: 'op-1', resultUrl: url }), evidence) + assert.equal(runtime.restoredProviderEvidence({ source: 'midjourney', jobId: 'not-a-uuid' }), null) + const nextCandidateUrl = `https://www.midjourney.com/jobs/${jobId}?index=3` + const nextCandidate = runtime.updatePanelState(candidate, 'result', { url: nextCandidateUrl }, { + preserveMidjourneyCandidate: runtime.sameMidjourneyCandidateSwitch(candidateUrl, nextCandidateUrl, jobId), + preserveProviderEvidence: true, preserveQcProfileHint: true + }, prefix => `${prefix}next-candidate-0000`) + assert.equal(nextCandidate.reviewContext.stale, false) + assert.equal(nextCandidate.browserPanels.result.targetId, 'tmj-0000') + assert.equal(runtime.sameMidjourneyCandidateSwitch(candidateUrl, candidateUrl, jobId), false) + assert.equal(runtime.sameMidjourneyCandidateSwitch(url, url, jobId), false) + assert.equal(runtime.sameMidjourneyCandidateSwitch(url, `${url}?index=4`, jobId), false) + assert.equal(runtime.sameMidjourneyCandidateSwitch(candidateUrl, `${url}?index=0&extra=1`, jobId), false) + assert.equal(runtime.sameMidjourneyCandidateSwitch(candidateUrl, `${url}?index=0&index=1`, jobId), false) + assert.equal(runtime.sameMidjourneyCandidateSwitch(candidateUrl, `${url}?index=x`, jobId), false) + + const otherUrl = 'https://www.midjourney.com/jobs/223e4567-e89b-42d3-a456-426614174000?index=0' + const other = runtime.updatePanelState(candidate, 'result', { url: otherUrl }, { + preserveMidjourneyCandidate: runtime.sameMidjourneyCandidateSwitch(candidateUrl, otherUrl, jobId) + }, prefix => `${prefix}other-0000`) + assert.equal(other.reviewContext.stale, true) + assert.equal(other.reviewContext.staleReason, 'url-changed') + assert.equal(other.capture, null) + + const viewport = runtime.updatePanelState(linked, 'result', { preset: 'mobile', width: 390, height: 844 }, {}, prefix => `${prefix}viewport-0000`) + assert.equal(viewport.reviewContext.staleReason, 'viewport-changed') + assert.equal(viewport.capture, null) + const failed = runtime.markPanelLoadFailedState(linked, 'result') + assert.equal(failed.reviewContext.staleReason, 'load-failed') + assert.equal(failed.capture, null) +}) + +test('Midjourney schema-v1 A-D import document validates and A selection is SELECTED', () => { + const runtime = loadRuntimeCore() + const document = validDocument() + assert.deepEqual(runtime.validateQcDocument(JSON.stringify(document)), document) + const selected = runtime.applyAgentCommand({ + ...structuredClone(runtime.DEFAULT_STATE), + browserPanels: { + ...runtime.DEFAULT_STATE.browserPanels, + result: { ...runtime.DEFAULT_STATE.browserPanels.result, url: 'https://www.midjourney.com/jobs/123e4567-e89b-42d3-a456-426614174000?index=0', targetId: 'tmj-select' } + }, + qcTargetPanelId: 'result', + qcProfile: 'midjourney', + reviewContext: { + contextId: 'cmj-select', panelId: 'result', targetId: 'tmj-select', profileId: 'midjourney', + url: 'https://www.midjourney.com/jobs/123e4567-e89b-42d3-a456-426614174000?index=0', + mediaKind: 'page', viewport: { preset: 'desktop', width: 1440, height: 900, responsive: false }, + providerEvidence: null, linkedAt: NOW, stale: false, staleReason: '' + } + }, { op: 'select-candidate', payload: { candidateId: 'A' } }) + assert.equal(document.job.state, 'SELECTED') + assert.equal(selected.state.selectedCandidate, 'A') +}) +test('Midjourney control surface uses the typed Hermes seam and excludes forbidden browser APIs', () => { + const midjourneySource = pluginSource.slice(pluginSource.indexOf('const MIDJOURNEY_SELECTOR_REGISTRY'), pluginSource.indexOf('async function runDesignPageChecks')) + assert.match(midjourneySource, /mj-web-2026-07-19\.v3/) + assert.match(midjourneySource, /browserApi\.control\(context\.guestId/) + assert.match(midjourneySource, /op: 'focusText', targetId: 'composer', text, replace: true/) + assert.match(midjourneySource, /op: 'setFileInput', targetId: 'image-file-input'/) + assert.match(midjourneySource, /payload\.action === 'settings'/) + assert.match(midjourneySource, /Midjourney personalization readback failed/) + assert.match(midjourneySource, /current\.composerMode !== requestedMode/) + assert.match(midjourneySource, /selectedRoles\.length !== 1 \|\| selectedRoles\[0\] !== role/) + assert.match(midjourneySource, /op: 'results'/) + assert.match(midjourneySource, /payload\.action === 'results'/) + assert.match(midjourneySource, /Midjourney acknowledged submit linkage is/) + assert.match(midjourneySource, /normalizedMidjourneyPromptBody/) + assert.match(midjourneySource, /String\(result\.jobId\)\.toLowerCase\(\) === requestedJobId/) + assert.match(midjourneySource, /Midjourney grid requires the exact linked current job/) + assert.doesNotMatch(midjourneySource, /nearestStartFrameRight|wrong-slot Start Frame/) + assert.match(midjourneySource, /artifactRelativePath: safe/) + assert.match(midjourneySource, /A fresh matching Midjourney validate receipt is required/) + assert.match(midjourneySource, /Midjourney panel target or URL changed/) + assert.match(midjourneySource, /operationId/) + assert.doesNotMatch(midjourneySource, /Runtime\.evaluate|DOM\.querySelector|Input\.|Network\.|document\.cookie|localStorage|sessionStorage/) + assert.match(midjourneyCliSource, /existing === true && queued\?\.queued === false/) + assert.match(midjourneyCliSource, /operationId: queued\.operationId/) + assert.match(midjourneyCliSource, /openapi\.json/) + assert.match(midjourneyCliSource, /body\?\.info\?\.title !== 'Hermes Agent'/) + assert.match(midjourneyCliSource, /visual-workbench\/control\/result\?cursor=0/) + assert.match(midjourneyCliSource, /if \(authorized\.ok\) return/) + assert.match(midjourneyCliSource, /control token must have mode 0600/) + assert.doesNotMatch(midjourneyCliSource, /console\.(?:log|error)\([^\n]*token/) +}) diff --git a/tests/qc-core.test.mjs b/tests/qc-core.test.mjs index c39c256..9493eb2 100644 --- a/tests/qc-core.test.mjs +++ b/tests/qc-core.test.mjs @@ -13,6 +13,8 @@ import { PROVIDER_IDS, PROVIDERS, providerEvidenceFor, + sanitizeUrl, + invokeHiggsfieldReadOnly, providerForProfile, QC_DIMENSIONS, qcDocumentFromState, @@ -20,6 +22,8 @@ import { qcProfileFor, reviewContextMatches, restoredProviderEvidence, + sameMidjourneyCandidateSwitch, + midjourneyProviderEvidenceForUrl, transitionJob, linkPanelState, markPanelLoadFailedState, @@ -253,8 +257,8 @@ test('routes Midjourney URLs and tool provenance to the matching QC profile', () assert.equal(qcProfileFor({ src: 'https://example.test/page' }), 'design') }) -test('extracts bounded Higgsfield MCP metadata for the exact displayed result', () => { - const src = 'https://cdn.example.test/result.png?token=display' +test('binds Higgsfield metadata only to one exact raw receipt and redacts URL secrets', () => { + const src = 'https://cdn.example.test/result.png?token=display&keep=ok' const evidence = providerEvidenceFor({ src, toolName: 'mcp__higgsfield__show_generations', @@ -266,7 +270,7 @@ test('extracts bounded Higgsfield MCP metadata for the exact displayed result', aspect_ratio: '3:4', batch_size: 2, height: 1168, medias: [{ role: 'image' }], prompt: 'Product fidelity prompt', resolution: '1k', width: 880 }, - results: { rawUrl: 'https://cdn.example.test/result.png?token=raw' }, + results: { rawUrl: src }, createdAt: 123 }] } @@ -283,8 +287,33 @@ test('extracts bounded Higgsfield MCP metadata for the exact displayed result', assert.equal(evidence.aspectRatio, '3:4') assert.equal(evidence.count, 2) assert.equal(evidence.referenceCount, 1) - assert.equal(evidence.resultUrl, 'https://cdn.example.test/result.png?token=raw') + assert.equal(evidence.resultUrl, 'https://cdn.example.test/result.png?keep=ok') assert.ok(evidence.checkedAt) + + const collision = { + src, + toolName: 'mcp__higgsfield__show_generations', + toolResult: { structuredContent: { items: [ + { id: 'one', results: { rawUrl: 'https://cdn.example.test/result.png?token=other' } }, + { id: 'two', results: { rawUrl: 'https://cdn.example.test/result.png?sig=other' } } + ] } } + } + assert.equal(providerEvidenceFor(collision), null) + collision.toolResult.structuredContent.items.push({ id: 'exact', results: { rawUrl: src } }) + assert.equal(providerEvidenceFor(collision).jobId, 'exact') + collision.toolResult.structuredContent.items.push({ id: 'duplicate', results: { rawUrl: src } }) + assert.equal(providerEvidenceFor(collision), null) + assert.equal(sanitizeUrl('https://cdn.example.test/a?token=x&sig=y&signature=z&expires=1&key=k&auth=a&keep=yes#secret'), 'https://cdn.example.test/a?keep=yes') +}) + +test('Higgsfield invocation firewall allows inspections only and blocks mutations before invocation', async () => { + const calls = [] + const invoke = async name => { calls.push(name); return { ok: true } } + await invokeHiggsfieldReadOnly('mcp__higgsfield__show_generations', invoke) + await invokeHiggsfieldReadOnly('mcp__higgsfield__job_status', invoke) + await assert.rejects(invokeHiggsfieldReadOnly('mcp__higgsfield__generate_image', invoke), /read-only/) + await assert.rejects(invokeHiggsfieldReadOnly('mcp__higgsfield__upscale', invoke), /read-only/) + assert.deepEqual(calls, ['mcp__higgsfield__show_generations', 'mcp__higgsfield__job_status']) }) test('extracts Soul V2 metadata from the Higgsfield job_status raw_data envelope', () => { @@ -375,7 +404,7 @@ test('accepts exactly 64 KiB of JSON and rejects one byte more', () => { }) test('exposes a provider registry with midjourney as the schema-v1 adapter', () => { - assert.deepEqual([...PROVIDER_IDS], ['midjourney', 'higgsfield-image']) + assert.deepEqual([...PROVIDER_IDS], ['midjourney', 'higgsfield-image', 'higgsfield-web']) const midjourney = providerForProfile('midjourney') assert.equal(midjourney, PROVIDERS.midjourney) @@ -490,7 +519,7 @@ function v6State() { } function resultCapture() { - return { panelId: 'result', targetId: 'tresult-0001', url: 'https://example.test/page', width: 800, height: 600, createdAt: NOW, path: '/tmp/x.png' } + return { panelId: 'result', targetId: 'tresult-0001', url: 'https://example.test/page', width: 800, height: 600, viewport: { preset: 'desktop', width: 1440, height: 900, responsive: false }, createdAt: NOW, path: '/tmp/x.png' } } test('link creates one atomic review context and resets review families', () => { @@ -512,6 +541,47 @@ test('link creates one atomic review context and resets review families', () => assert.equal(panelLinkedToQc(linked, 'result'), true) }) +test('Midjourney QC links infer only strict verified job URL provenance', () => { + const jobId = '123e4567-e89b-42d3-a456-426614174000' + const validUrls = [ + `https://midjourney.com/jobs/${jobId}`, + `https://www.midjourney.com/jobs/${jobId}?index=3` + ] + for (const url of validUrls) { + const inferred = midjourneyProviderEvidenceForUrl(url) + assert.deepEqual(inferred, { source: 'midjourney', jobId, operationId: '', resultUrl: url }) + const state = v6State() + state.browserPanels.result = { ...state.browserPanels.result, url, targetId: 'tmj-0000', providerEvidence: null } + const linked = linkPanelState(state, 'result', { profileId: 'midjourney', contextId: 'cmj-0000', linkedAt: NOW }, sequentialIds()) + assert.deepEqual(linked.browserPanels.result.providerEvidence, inferred) + assert.deepEqual(linked.reviewContext.providerEvidence, inferred) + } + + const invalidUrls = [ + `http://midjourney.com/jobs/${jobId}`, + `https://evil.midjourney.com/jobs/${jobId}`, + `https://midjourney.com/jobs/${jobId}/`, + `https://midjourney.com/imagine/${jobId}`, + 'https://midjourney.com/jobs/not-a-uuid', + `https://midjourney.com/jobs/${jobId}?index=4`, + `https://midjourney.com/jobs/${jobId}?index=0&extra=1`, + `https://midjourney.com/jobs/${jobId}?foo=bar`, + `https://midjourney.com/jobs/${jobId}#fragment` + ] + for (const url of invalidUrls) { + assert.equal(midjourneyProviderEvidenceForUrl(url), null, url) + const state = v6State() + state.browserPanels.result = { + ...state.browserPanels.result, url, targetId: 'tmj-invalid', providerEvidence: { + source: 'midjourney', jobId, operationId: 'transient', resultUrl: `https://midjourney.com/jobs/${jobId}` + } + } + const linked = linkPanelState(state, 'result', { profileId: 'midjourney', contextId: 'cmj-invalid', linkedAt: NOW }, sequentialIds()) + assert.equal(linked.reviewContext.providerEvidence, null, url) + assert.equal(linked.browserPanels.result.providerEvidence, null, url) + } +}) + test('URL change rotates target identity, clears evidence, and stales the context', () => { const makeId = sequentialIds() const linked = linkPanelState({ ...v6State(), capture: resultCapture() }, 'result', { profileId: 'design', contextId: 'clink-0001', linkedAt: NOW }, makeId) @@ -688,7 +758,7 @@ test('failed loads explicitly stale the linked context without touching other st const failed = markPanelLoadFailedState(linked, 'result') assert.equal(failed.reviewContext.stale, true) assert.equal(failed.reviewContext.staleReason, 'load-failed') - assert.deepEqual(failed.capture, resultCapture(), 'already-verified evidence is preserved') + assert.equal(failed.capture, null, 'failed navigation clears target capture') assert.equal(failed.browserPanels.result.targetId, 'tresult-0001', 'target identity does not rotate on load failure') assert.equal(reviewContextMatches(failed, 'design'), false) assert.equal(panelLinkedToQc(failed, 'result'), false) @@ -711,6 +781,12 @@ test('validates every agent command and rejects malformed command payloads', () { id: 'capture', op: 'capture', panelId: 'result', payload: {} }, { id: 'inspect', op: 'inspect', panelId: 'result', payload: {} }, { id: 'checks', op: 'page-checks', panelId: 'result', payload: {} }, + { id: 'mj-state', op: 'midjourney-control', panelId: 'result', payload: { action: 'state' } }, + { id: 'mj-nav', op: 'midjourney-control', panelId: 'result', payload: { action: 'navigate', url: 'https://www.midjourney.com/' } }, + { id: 'mj-draft', op: 'midjourney-control', panelId: 'result', payload: { action: 'draft', prompt: 'non-billable smoke', parameters: { ar: '3:4' } } }, + { id: 'mj-download', op: 'midjourney-control', panelId: 'result', payload: { action: 'download', jobId: 'job_123', filename: 'result.webp' } }, + { id: 'mj-select', op: 'midjourney-control', panelId: 'result', payload: { action: 'action', name: 'select', candidate: 'A', jobId: '123e4567-e89b-42d3-a456-426614174000' } }, + { id: 'mj-submit', op: 'midjourney-control', panelId: 'result', payload: { action: 'submit', approved: true, idempotencyKey: 'abcdefgh', validateReceipt: 'mjv-receipt', batchFingerprint: 'a'.repeat(64) } }, { id: 'check', op: 'set-check', payload: { profileId: 'design', checkId: 'contrast', status: 'pass' } }, { id: 'score', op: 'score-candidate', payload: { candidateId: 'A', score: 90 } }, { id: 'select', op: 'select-candidate', payload: { candidateId: 'A' } }, @@ -720,6 +796,41 @@ test('validates every agent command and rejects malformed command payloads', () assert.equal(validateAgentCommand({ id: '', op: 'status', payload: {} }).ok, false) assert.equal(validateAgentCommand({ id: 'bad', op: 'unknown', payload: {} }).ok, false) assert.equal(validateAgentCommand({ id: 'bad', op: 'set-target', panelId: 'result', payload: { url: 'https://example.test', extra: true } }).ok, false) + assert.equal(validateAgentCommand({ id: 'bad-mj', op: 'midjourney-control', panelId: 'result', payload: { action: 'navigate', url: 'https://example.test' } }).ok, false) + assert.equal(validateAgentCommand({ id: 'bad-submit', op: 'midjourney-control', panelId: 'result', payload: { action: 'submit', approved: false, idempotencyKey: 'abcdefgh' } }).ok, false) + assert.equal(validateAgentCommand({ id: 'bad-download', op: 'midjourney-control', panelId: 'result', payload: { action: 'download', jobId: '../bad', filename: 'result.png' } }).ok, false) + assert.equal(validateAgentCommand({ id: 'bad-stale-validation', op: 'midjourney-control', panelId: 'result', payload: { action: 'submit', approved: true, idempotencyKey: 'abcdefgh' } }).ok, false) + assert.equal(validateAgentCommand({ id: 'missing-batch-identity', op: 'midjourney-control', panelId: 'result', payload: { action: 'submit', approved: true, idempotencyKey: 'abcdefgh', validateReceipt: 'mjv-receipt' } }).ok, false) + assert.equal(validateAgentCommand({ id: 'bad-billable-action', op: 'midjourney-control', panelId: 'result', payload: { action: 'action', name: 'upscale', jobId: '123e4567-e89b-42d3-a456-426614174000', idempotencyKey: 'abcdefgh' } }).ok, false) +}) +test('Midjourney candidate freshness accepts only the linked base and index transitions', () => { + const jobId = '123e4567-e89b-42d3-a456-426614174000' + const base = `https://www.midjourney.com/jobs/${jobId}` + const first = `${base}?index=0` + const second = `${base}?index=3` + assert.equal(sameMidjourneyCandidateSwitch(base, first, jobId), true) + assert.equal(sameMidjourneyCandidateSwitch(first, second, jobId), true) + assert.equal(sameMidjourneyCandidateSwitch(first, first, jobId), false) + assert.equal(sameMidjourneyCandidateSwitch(base, base, jobId), false) + assert.equal(sameMidjourneyCandidateSwitch(first, `${base}?index=0&x=1`, jobId), false) + assert.equal(sameMidjourneyCandidateSwitch(first, `${base}?index=0&index=1`, jobId), false) + assert.equal(sameMidjourneyCandidateSwitch(first, `${base}?index=4`, jobId), false) + assert.equal(sameMidjourneyCandidateSwitch(first, 'https://www.midjourney.com/jobs/223e4567-e89b-42d3-a456-426614174000?index=1', jobId), false) +}) +test('standalone Midjourney evidence and status retain strict job and capture provenance', () => { + const jobId = '123e4567-e89b-42d3-a456-426614174000' + const jobUrl = `https://www.midjourney.com/jobs/${jobId}` + const evidence = restoredProviderEvidence({ source: 'midjourney', jobId: jobId.toUpperCase(), operationId: 'op-1', resultUrl: jobUrl }) + assert.deepEqual(evidence, { source: 'midjourney', jobId, operationId: 'op-1', resultUrl: jobUrl }) + assert.equal(restoredProviderEvidence({ source: 'midjourney', jobId: 'not-a-uuid' }), null) + const state = { ...v6State(), capture: { ...resultCapture(), url: jobUrl } } + state.browserPanels.result = { ...state.browserPanels.result, url: jobUrl, providerEvidence: evidence } + const linked = linkPanelState(state, 'result', { profileId: 'midjourney', contextId: 'cmj-0001', linkedAt: NOW }) + assert.deepEqual(linked.reviewContext.providerEvidence, { source: 'midjourney', jobId, operationId: '', resultUrl: jobUrl }) + const status = agentStatusSnapshot(linked) + assert.equal(status.reviewContext.providerJobId, jobId) + assert.equal(status.capture.url, linked.capture.url) + assert.deepEqual(status.capture.viewport, linked.capture.viewport) }) test('applies linked agent QC commands and emits the complete status snapshot contract', () => { @@ -735,3 +846,45 @@ test('applies linked agent QC commands and emits the complete status snapshot co assert.deepEqual(Object.keys(agentStatusSnapshot(scored.state).reviewContext).sort(), ['contextId', 'mediaKind', 'panelId', 'profileId', 'providerJobId', 'stale', 'staleReason', 'targetId', 'url'].sort()) assert.deepEqual(Object.keys(agentStatusSnapshot(scored.state).panels).sort(), ['reference', 'result']) }) + +test('set-target binds sanitized Higgsfield CLI provenance and links it into QC', () => { + const url = 'https://cdn.higgsfield.test/generations/job.png' + const signed = `${url}?token=secret-token&sig=secret-sig` + const providerEvidence = { + source: 'higgsfield-mcp', jobId: 'cli-job-1', status: 'completed', model: 'text2image_soul_v2', + soulId: 'soul-9', mediaType: 'image', prompt: 'a portrait', width: 1152, height: 2048, + resultUrl: signed, createdAt: 1784570752 + } + const makeId = prefix => `${prefix}cli-0000` + let state = migratePersistedState({}, v5Defaults()) + + // Provenance whose result URL path does not match the target URL is rejected. + assert.equal( + validateAgentCommand({ id: 'mismatch', op: 'set-target', panelId: 'result', payload: { url, providerEvidence: { ...providerEvidence, resultUrl: 'https://cdn.higgsfield.test/other.png' } } }).ok, + false + ) + // Unrecognized provenance source is rejected. + assert.equal( + validateAgentCommand({ id: 'bad-source', op: 'set-target', panelId: 'result', payload: { url, providerEvidence: { source: 'unknown', jobId: 'x', resultUrl: url } } }).ok, + false + ) + // Matching provenance is accepted. + assert.equal( + validateAgentCommand({ id: 'ok', op: 'set-target', panelId: 'result', payload: { url, providerEvidence } }).ok, + true + ) + + state = applyAgentCommand(state, { op: 'set-target', panelId: 'result', payload: { url, providerEvidence } }, makeId).state + const panel = state.browserPanels.result + assert.equal(panel.url, url) + assert.equal(panel.providerEvidence.jobId, 'cli-job-1') + assert.equal(panel.providerEvidence.model, 'text2image_soul_v2') + // Signed query is stripped from the bound provenance. + assert.ok(!JSON.stringify(panel.providerEvidence).includes('secret-token')) + assert.ok(!JSON.stringify(panel.providerEvidence).includes('secret-sig')) + + state = applyAgentCommand(state, { op: 'link', panelId: 'result', payload: { profileId: 'higgsfield-image' } }, makeId).state + assert.equal(state.reviewContext.profileId, 'higgsfield-image') + assert.equal(state.reviewContext.providerEvidence.jobId, 'cli-job-1') + assert.equal(agentStatusSnapshot(state).reviewContext.providerJobId, 'cli-job-1') +}) diff --git a/tests/redteam-plugin.test.mjs b/tests/redteam-plugin.test.mjs new file mode 100644 index 0000000..5be84f9 --- /dev/null +++ b/tests/redteam-plugin.test.mjs @@ -0,0 +1,45 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { sanitizeUrl, providerEvidenceFor } from '../scripts/qc-core.mjs' + +const raw = 'https://cdn.example.test/asset.png?token=synthetic-token&sig=synthetic-sig&signature=synthetic-signature&expires=synthetic-expires&key=synthetic-key&auth=synthetic-auth&keep=ok' +const secrets = ['synthetic-token', 'synthetic-sig', 'synthetic-signature', 'synthetic-expires', 'synthetic-key', 'synthetic-auth'] + +test('red-team: persisted and observed JS URL sinks remove every signed query secret', () => { + const clean = sanitizeUrl(raw) + for (const secret of secrets) assert.doesNotMatch(clean, new RegExp(secret)) + assert.match(clean, /keep=ok/) + const evidence = providerEvidenceFor({ + toolName: 'mcp__higgsfield__show_generations', + src: raw, + toolResult: { structuredContent: { items: [{ + id: 'job-clean', status: 'completed', type: 'image', model: 'seedream_v5_pro', + results: { rawUrl: raw } + }] } } + }) + assert.ok(evidence) + for (const secret of secrets) assert.doesNotMatch(JSON.stringify(evidence), new RegExp(secret)) +}) + +test('red-team: Higgsfield requires an exact URL match and rejects duplicate exact matches', () => { + const exact = 'https://cdn.example.test/shared.png?sig=first' + const evidence = providerEvidenceFor({ + src: exact, + toolName: 'mcp__higgsfield__show_generations', + toolResult: { structuredContent: { items: [ + { id: 'one', status: 'completed', type: 'image', results: { rawUrl: exact } }, + { id: 'two', status: 'completed', type: 'image', results: { rawUrl: 'https://cdn.example.test/shared.png?sig=second' } } + ] } } + }) + assert.equal(evidence?.jobId, 'one', 'the exact signed URL must win over a redaction collision') + const collision = providerEvidenceFor({ + src: exact, + toolName: 'mcp__higgsfield__show_generations', + toolResult: { structuredContent: { items: [ + { id: 'one', status: 'completed', type: 'image', results: { rawUrl: exact } }, + { id: 'two', status: 'completed', type: 'image', results: { rawUrl: exact } } + ] } } + }) + assert.equal(collision, null, 'duplicate exact matches must fail closed') +}) diff --git a/tests/test_plugin_api_compat.py b/tests/test_plugin_api_compat.py new file mode 100644 index 0000000..6846b56 --- /dev/null +++ b/tests/test_plugin_api_compat.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +import importlib.util +import sys +import types +import unittest +from pathlib import Path +from unittest.mock import patch + +MODULE_PATH = Path(__file__).resolve().parents[1] / "dashboard" / "plugin_api.py" + + +def load_dashboard(): + spec = importlib.util.spec_from_file_location("visual_workbench_plugin_api_compat", MODULE_PATH) + module = importlib.util.module_from_spec(spec) + assert spec and spec.loader + spec.loader.exec_module(module) + return module + + +def host(version: str, helper): + package = types.ModuleType("hermes_cli") + package.__version__ = version + web_server = types.ModuleType("hermes_cli.web_server") + web_server._ws_auth_ok = helper + package.web_server = web_server + return {"hermes_cli": package, "hermes_cli.web_server": web_server} + + +class HostCompatibilityTests(unittest.TestCase): + def test_capability_predicate_is_side_effect_free_and_fail_closed(self): + dashboard = load_dashboard() + calls = [] + supported = lambda ws: calls.append(ws) or True + matrix = [ + ({}, False), + (host("0.18.1", supported), False), + (host("0.18.2", supported), True), + (host("0.19.0", supported), True), + (host("0.25.7", supported), True), + (host("0.19.0", object()), False), + (host("0.18.2", object()), False), + ] + for modules, expected in matrix: + with patch.dict(sys.modules, modules, clear=False): + self.assertEqual(dashboard._host_auth_supported(), expected) + self.assertEqual(calls, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_plugin_api_ledger.py b/tests/test_plugin_api_ledger.py new file mode 100644 index 0000000..5de129d --- /dev/null +++ b/tests/test_plugin_api_ledger.py @@ -0,0 +1,219 @@ +from __future__ import annotations + +import importlib.util +import os +import stat +import tempfile +import unittest +from pathlib import Path + +from fastapi import FastAPI, HTTPException +from fastapi.testclient import TestClient +from unittest.mock import patch + + +MODULE_PATH = Path(__file__).resolve().parents[1] / "dashboard" / "plugin_api.py" +SPEC = importlib.util.spec_from_file_location("visual_workbench_plugin_api_test", MODULE_PATH) +assert SPEC and SPEC.loader +plugin_api = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(plugin_api) + + +class BillableLedgerTests(unittest.TestCase): + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory() + self.previous_home = os.environ.get("HERMES_HOME") + os.environ["HERMES_HOME"] = self.temp.name + self.receipt = "fixture-receipt" + self.batch_fingerprint = "b" * 64 + ledger_path = plugin_api._safe_ledger_file(plugin_api._canonical_plugin_dir()) + plugin_api._write_billable_ledger_payload(ledger_path, { + "version": 2, + "entries": [], + "receiptContexts": {plugin_api._receipt_hash(self.receipt): { + "batchContextId": "fixture-batch", "expiresAt": "2999-01-01T00:00:00+00:00", + }}, + "contextSummaries": {"fixture-batch": { + "batchFingerprint": self.batch_fingerprint, "consumedCount": 0, + }}, + }) + + def tearDown(self) -> None: + if self.previous_home is None: + os.environ.pop("HERMES_HOME", None) + else: + os.environ["HERMES_HOME"] = self.previous_home + self.temp.cleanup() + + @staticmethod + def command(command_id: str, key: str = "fixture-key-1234", prompt: str = "fixture") -> dict: + return { + "id": command_id, + "op": "midjourney-control", + "panelId": "result", + "payload": { + "action": "submit", + "approved": True, + "idempotencyKey": key, + "promptFingerprint": prompt, + "validateReceipt": "fixture-receipt", + "batchFingerprint": "b" * 64, + }, + } + def test_higgsfield_generate_is_durably_idempotent(self) -> None: + command = { + "id": "hf-generate-1", + "op": "higgsfield-control", + "panelId": "result", + "payload": { + "action": "generate", + "billableConfirmed": True, + "idempotencyKey": "hf-fixture-key-1234", + "validateReceipt": "hfv-fixture", + "batchFingerprint": "c" * 64, + }, + } + self.assertEqual(plugin_api._billable_command(command), ("higgsfield-generate", "hf-fixture-key-1234")) + first, existing = plugin_api._reserve_billable_command(command, "higgsfield-generate", "hf-fixture-key-1234") + self.assertFalse(existing) + replay = {**command, "id": "hf-generate-2"} + second, existing = plugin_api._reserve_billable_command(replay, "higgsfield-generate", "hf-fixture-key-1234") + self.assertTrue(existing) + self.assertEqual(second, first) + + def test_reservation_survives_reload_and_never_rebroadcasts(self) -> None: + first, existing = plugin_api._reserve_billable_command(self.command("submit-1"), "submit", "fixture-key-1234") + self.assertFalse(existing) + second, existing = plugin_api._reserve_billable_command(self.command("submit-2"), "submit", "fixture-key-1234") + self.assertTrue(existing) + self.assertEqual(second, first) + ledger = Path(self.temp.name) / "plugins" / "visual-workbench" / plugin_api._BILLABLE_LEDGER_FILE + self.assertEqual(stat.S_IMODE(ledger.stat().st_mode), 0o600) + + def test_same_key_with_changed_request_fails_closed(self) -> None: + plugin_api._reserve_billable_command(self.command("submit-1"), "submit", "fixture-key-1234") + with self.assertRaises(HTTPException) as caught: + plugin_api._reserve_billable_command( + self.command("submit-2", prompt="different"), "submit", "fixture-key-1234" + ) + self.assertEqual(caught.exception.status_code, 409) + + def test_acknowledgement_is_durable(self) -> None: + plugin_api._reserve_billable_command(self.command("submit-1"), "submit", "fixture-key-1234") + plugin_api._acknowledge_billable_result("submit-1") + _path, entries = plugin_api._load_billable_ledger() + self.assertEqual(entries[0]["status"], "acknowledged") + + def test_result_link_attestation_comes_from_acknowledged_ledger(self) -> None: + reservation, _existing = plugin_api._reserve_billable_command( + self.command("submit-1"), "submit", "fixture-key-1234" + ) + link = { + "id": "link-1", + "op": "midjourney-control", + "panelId": "result", + "payload": { + "action": "link", + "operationId": reservation["operationId"], + "prompt": "fixture", + }, + } + with self.assertRaises(HTTPException) as caught: + plugin_api._validate_acknowledged_link(link) + self.assertEqual(caught.exception.status_code, 409) + plugin_api._acknowledge_billable_result("submit-1") + plugin_api._validate_acknowledged_link(link) + self.assertIs(link["payload"]["acknowledged"], True) + self.assertEqual(link["payload"]["ledgerCreatedAt"], reservation["createdAt"]) + + def test_result_link_rejects_unknown_operation(self) -> None: + link = { + "op": "midjourney-control", + "payload": {"action": "link", "operationId": "0" * 64, "prompt": "fixture"}, + } + with self.assertRaises(HTTPException) as caught: + plugin_api._validate_acknowledged_link(link) + self.assertEqual(caught.exception.status_code, 409) + + def test_unsafe_mode_and_symlink_are_rejected(self) -> None: + plugin_api._reserve_billable_command(self.command("submit-1"), "submit", "fixture-key-1234") + ledger, _entries = plugin_api._load_billable_ledger() + ledger.chmod(0o644) + with self.assertRaises(plugin_api._BillableLedgerError): + plugin_api._load_billable_ledger() + ledger.unlink() + target = ledger.with_suffix(".target") + target.write_text('{"version":1,"entries":[]}', encoding="utf-8") + ledger.symlink_to(target) + with self.assertRaises(plugin_api._BillableLedgerError): + plugin_api._load_billable_ledger() + + + def test_v1_ledger_decodes_to_v2_shape(self) -> None: + ledger_dir = Path(self.temp.name) / "plugins" / "visual-workbench" + ledger_dir.mkdir(parents=True, exist_ok=True) + entry = plugin_api._billable_record(self.command("submit-1"), "submit", "fixture-key-1234") + (ledger_dir / plugin_api._BILLABLE_LEDGER_FILE).write_text( + __import__("json").dumps({"version": 1, "entries": [entry]}), encoding="utf-8" + ) + (ledger_dir / plugin_api._BILLABLE_LEDGER_FILE).chmod(0o600) + _path, entries = plugin_api._load_billable_ledger() + self.assertEqual(entries[0]["operationId"], entry["operationId"]) + self.assertEqual(entries[0]["status"], "reserved") + self.assertEqual(plugin_api._load_billable_ledger_payload()["version"], 2) + def test_v1_rejects_extra_or_snake_case_fields(self) -> None: + entry = plugin_api._billable_record(self.command("submit-1"), "submit", "fixture-key-1234") + entry["operation_id"] = entry.pop("operationId") + with self.assertRaises(plugin_api._BillableLedgerError): + plugin_api._decode_v1_payload({"version": 1, "entries": [entry]}) + + def test_submit_batch_context_allows_three_distinct_reservations(self) -> None: + receipt = "batch-receipt" + receipt_hash = plugin_api._receipt_hash(receipt) + ledger = { + "version": 2, "entries": [], + "receiptContexts": {receipt_hash: {"batchContextId": "batch", "expiresAt": "2999-01-01T00:00:00+00:00"}}, + "contextSummaries": {"batch": {"batchFingerprint": "b" * 64, "consumedCount": 0}}, + } + for index in range(3): + command = self.command(f"submit-{index}", f"fixture-key-{index:04d}") + command["payload"]["validateReceipt"] = receipt + plugin_api._reserve_in_ledger(command, "submit", command["payload"]["idempotencyKey"], ledger) + fourth = self.command("submit-4", "fixture-key-0004") + fourth["payload"]["validateReceipt"] = receipt + with self.assertRaises(HTTPException) as caught: + plugin_api._reserve_in_ledger(fourth, "submit", fourth["payload"]["idempotencyKey"], ledger) + self.assertEqual(caught.exception.status_code, 409) + self.assertEqual(ledger["contextSummaries"]["batch"]["consumedCount"], 3) + def test_non_submit_billable_reservation_does_not_consume_submit_quota(self) -> None: + command = self.command("upscale-1", "fixture-key-4567") + command["payload"] = { + "action": "action", + "name": "upscale", + "approved": True, + "idempotencyKey": "fixture-key-4567", + } + _reservation, existing = plugin_api._reserve_billable_command(command, "upscale", "fixture-key-4567") + self.assertFalse(existing) + self.assertEqual(plugin_api._load_billable_ledger_payload()["contextSummaries"]["fixture-batch"]["consumedCount"], 0) + + def test_post_submit_quota_is_durable_and_requires_batch_identity(self) -> None: + app = FastAPI() + app.include_router(plugin_api.router) + client = TestClient(app) + with patch.object(plugin_api, "_host_auth_supported", return_value=True): + statuses = [ + client.post("/command", json=self.command(f"submit-{index}", f"fixture-key-{index:04d}")).status_code + for index in range(4) + ] + self.assertEqual(statuses, [202, 202, 202, 409]) + for command_id, key in (("alternate-job", "fixture-key-0005"), ("rotated-operation", "fixture-key-0006")): + self.assertEqual(client.post("/command", json=self.command(command_id, key)).status_code, 409) + self.assertEqual(client.post("/command", json={ + "id": "missing-batch", "op": "midjourney-control", "panelId": "result", + "payload": {"action": "submit", "approved": True, "idempotencyKey": "fixture-key-0007"}, + }).status_code, 400) + reloaded = plugin_api._load_billable_ledger_payload() + self.assertEqual(reloaded["contextSummaries"]["fixture-batch"]["consumedCount"], 3) +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_plugin_api_websocket.py b/tests/test_plugin_api_websocket.py new file mode 100644 index 0000000..249f9d7 --- /dev/null +++ b/tests/test_plugin_api_websocket.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import asyncio +import importlib.util +import sys +import types +import unittest +from pathlib import Path +from unittest.mock import patch + +MODULE_PATH = Path(__file__).resolve().parents[1] / "dashboard" / "plugin_api.py" +SPEC = importlib.util.spec_from_file_location("visual_workbench_plugin_api_websocket", MODULE_PATH) +plugin_api = importlib.util.module_from_spec(SPEC) +assert SPEC and SPEC.loader +SPEC.loader.exec_module(plugin_api) + + +class Socket: + def __init__(self): + self.closed = [] + self.accepted = False + async def close(self, code): self.closed.append(code) + async def accept(self): self.accepted = True + async def receive_text(self): raise plugin_api.WebSocketDisconnect() + + +def modules(token_ok): + package = types.ModuleType("hermes_cli") + package.__version__ = "0.18.2" + web_server = types.ModuleType("hermes_cli.web_server") + web_server._ws_auth_ok = token_ok + package.web_server = web_server + return {"hermes_cli": package, "hermes_cli.web_server": web_server} + + +class WebsocketAuthTests(unittest.TestCase): + def test_unsupported_host_closes_before_accept(self): + socket = Socket() + with patch.dict(sys.modules, {}, clear=True): + asyncio.run(plugin_api.stream_commands(socket)) + self.assertEqual(socket.closed, [1008]) + self.assertFalse(socket.accepted) + + def test_supported_host_calls_auth_only_during_upgrade(self): + calls = [] + socket = Socket() + with patch.dict(sys.modules, modules(lambda ws: calls.append(ws) or True), clear=False): + self.assertTrue(plugin_api._host_auth_supported()) + self.assertEqual(calls, []) + asyncio.run(plugin_api.stream_commands(socket)) + self.assertTrue(socket.accepted) + self.assertEqual(calls, [socket]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_redteam_dashboard.py b/tests/test_redteam_dashboard.py new file mode 100644 index 0000000..d2df745 --- /dev/null +++ b/tests/test_redteam_dashboard.py @@ -0,0 +1,210 @@ +from __future__ import annotations + +import asyncio +import importlib.util +import os +import sys +import tempfile +import types +import unittest +from pathlib import Path +from unittest.mock import patch + +from fastapi import FastAPI, HTTPException +from fastapi.testclient import TestClient + +MODULE_PATH = Path(__file__).resolve().parents[1] / "dashboard" / "plugin_api.py" +SPEC = importlib.util.spec_from_file_location("visual_workbench_plugin_api_redteam", MODULE_PATH) +assert SPEC and SPEC.loader +plugin_api = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(plugin_api) + + +class Socket: + def __init__(self): + self.closed = [] + self.accepted = False + + async def close(self, code): + self.closed.append(code) + + async def accept(self): + self.accepted = True + + async def receive_text(self): + raise plugin_api.WebSocketDisconnect() + + +def host_modules(version: str, auth): + package = types.ModuleType("hermes_cli") + package.__version__ = version + web_server = types.ModuleType("hermes_cli.web_server") + web_server._ws_auth_ok = auth + package.web_server = web_server + return {"hermes_cli": package, "hermes_cli.web_server": web_server} + + +class RedTeamDashboardTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.previous_home = os.environ.get("HERMES_HOME") + os.environ["HERMES_HOME"] = self.temp.name + plugin_api._COMMAND_QUEUE.clear() + plugin_api._COMMAND_CLIENTS.clear() + plugin_api._RESULTS.clear() + plugin_api._RESULT_IDS.clear() + plugin_api._LATEST_STATE = None + + def tearDown(self): + if self.previous_home is None: + os.environ.pop("HERMES_HOME", None) + else: + os.environ["HERMES_HOME"] = self.previous_home + self.temp.cleanup() + + @staticmethod + def command(command_id, key, receipt): + return {"id": command_id, "op": "midjourney-control", "panelId": "result", "payload": { + "action": "submit", "approved": True, "idempotencyKey": key, + "promptFingerprint": "red-team", "validateReceipt": receipt, "batchFingerprint": "b" * 64, + }} + + def test_post_command_submit_quota_is_durable_and_fail_closed(self): + receipt = "red-team-receipt" + batch_fingerprint = "b" * 64 + ledger_path = plugin_api._safe_ledger_file(plugin_api._canonical_plugin_dir()) + plugin_api._write_billable_ledger_payload(ledger_path, { + "version": 2, + "entries": [], + "receiptContexts": { + plugin_api._receipt_hash(receipt): { + "batchContextId": "red-team-batch", + "expiresAt": "2999-01-01T00:00:00+00:00", + }, + }, + "contextSummaries": { + "red-team-batch": { + "batchFingerprint": batch_fingerprint, + "consumedCount": 0, + }, + }, + }) + app = FastAPI() + app.include_router(plugin_api.router) + client = TestClient(app) + + non_submit = { + "id": "upscale-reservation", + "op": "midjourney-control", + "panelId": "result", + "payload": { + "action": "action", + "name": "upscale", + "approved": True, + "idempotencyKey": "upscale-key", + }, + } + with patch.object(plugin_api, "_host_auth_supported", return_value=True): + self.assertEqual(client.post("/command", json=non_submit).status_code, 202) + self.assertEqual( + plugin_api._load_billable_ledger_payload()["contextSummaries"]["red-team-batch"]["consumedCount"], + 0, + ) + + statuses = [ + client.post("/command", json=self.command(f"job-{index}", f"submit-key-{index}", receipt)).status_code + for index in range(4) + ] + self.assertEqual(statuses, [202, 202, 202, 409]) + + for command_id, key, operation_id in ( + ("alternate-job-id", "submit-key-four", "0" * 64), + ("alternate-operation-id", "submit-key-five", "f" * 64), + ): + command = self.command(command_id, key, receipt) + command["payload"]["operationId"] = operation_id + self.assertEqual(client.post("/command", json=command).status_code, 409) + + reloaded = plugin_api._load_billable_ledger_payload() + self.assertEqual(reloaded["contextSummaries"]["red-team-batch"]["consumedCount"], 3) + self.assertEqual( + client.post("/command", json=self.command("after-ledger-reload", "submit-key-six", receipt)).status_code, + 409, + ) + + missing_batch = self.command("missing-batch", "submit-key-seven", receipt) + del missing_batch["payload"]["batchFingerprint"] + self.assertEqual(client.post("/command", json=missing_batch).status_code, 400) + + entries = plugin_api._load_billable_ledger_payload()["entries"] + self.assertEqual(len(entries), 4) + self.assertEqual(entries[0]["action"], "upscale") + self.assertEqual( + plugin_api._load_billable_ledger_payload()["contextSummaries"]["red-team-batch"]["consumedCount"], + 3, + ) + + def test_ws_auth_fail_closed_before_accept_for_bad_host_conditions(self): + cases = [ + ({}, "import failure"), + (host_modules("0.18.1", lambda _: True), "below floor host"), + (host_modules("0.18.2", None), "non-callable auth"), + (host_modules("0.20.5", None), "future host missing capability"), + (host_modules("0.18.2", lambda _: False), "wrong token"), + ] + for modules, label in cases: + with self.subTest(label=label): + socket = Socket() + with patch.dict(sys.modules, modules, clear=not bool(modules)): + asyncio.run(plugin_api.stream_commands(socket)) + self.assertEqual(socket.closed, [1008]) + self.assertFalse(socket.accepted) + for version in ("0.18.2", "0.19.0", "0.20.5"): + with self.subTest(accepted=version): + socket = Socket() + with patch.dict(sys.modules, host_modules(version, lambda _: True), clear=False): + asyncio.run(plugin_api.stream_commands(socket)) + self.assertTrue(socket.accepted) + self.assertEqual(socket.closed, []) + + def test_result_read_sinks_never_return_signed_query_secrets(self): + raw = "https://cdn.example.test/image.png?token=raw-token&sig=raw-sig&signature=raw-signature&expires=raw-expires&key=raw-key&auth=raw-auth" + result = {"id": "redteam-result", "url": raw, "state": {"url": raw, "nested": {"download": raw}}} + plugin_api._RESULTS.clear() + plugin_api._RESULT_IDS.clear() + plugin_api._LATEST_STATE = None + with patch.object(plugin_api, "_host_auth_supported", return_value=True): + client = TestClient(plugin_api.router) + self.assertEqual(client.post("/result", json=result).status_code, 200) + for path in ("/result", "/control/result", "/state", "/result/redteam-result"): + with self.subTest(path=path): + response = client.get(path) + self.assertEqual(response.status_code, 200) + body = response.text + for secret in ("raw-token", "raw-sig", "raw-signature", "raw-expires", "raw-key", "raw-auth"): + self.assertNotIn(secret, body, f"{path} leaked {secret}") + def test_result_read_sinks_fail_closed_for_over_budget_values_and_url_keys(self): + secrets = ("deep-token", "deep-sig", "key-token", "key-auth") + signed_value = "https://cdn.example.test/deep.png?token=deep-token&sig=deep-sig" + signed_key = "https://cdn.example.test/key.png?token=key-token&auth=key-auth" + deep = signed_value + for _ in range(13): + deep = {"nested": deep} + result = { + "id": "redteam-over-budget", + "state": {"deep": deep, signed_key: "present"}, + } + with patch.object(plugin_api, "_host_auth_supported", return_value=True): + client = TestClient(plugin_api.router) + self.assertEqual(client.post("/result", json=result).status_code, 200) + for path in ("/result", "/control/result", "/state", "/result/redteam-over-budget"): + with self.subTest(path=path): + response = client.get(path) + self.assertEqual(response.status_code, 200) + for secret in secrets: + self.assertNotIn(secret, response.text, f"{path} leaked {secret}") + self.assertIn("", client.get("/state").text) + + +if __name__ == "__main__": + unittest.main() From c9c2c8862f38af01830802f743b4bae1daf07601 Mon Sep 17 00:00:00 2001 From: HeiTuz <79418013+HeiTuz@users.noreply.github.com> Date: Tue, 21 Jul 2026 07:29:55 +0900 Subject: [PATCH 2/3] feat: add trusted Higgsfield web observation --- README.md | 9 +-- dashboard/plugin_api.py | 58 +++++++++++++- plugin.js | 121 +++++++++++++++++++++++++---- scripts/qc-core.mjs | 42 ++++++---- tests/plugin-runtime-core.test.mjs | 48 +++++++++++- tests/qc-core.test.mjs | 49 +++++------- tests/test_plugin_api_ledger.py | 54 +++++++++++++ 7 files changed, 314 insertions(+), 67 deletions(-) diff --git a/README.md b/README.md index bfff781..0476a09 100644 --- a/README.md +++ b/README.md @@ -123,19 +123,18 @@ Midjourney automation is pinned to the Browser pane inside the Hermes Desktop wi The Browser pane displays a visible **Automation target** affordance. With the privileged guest active it reads `Automation target · Hermes internal Browser pane · persist:hermes-browser`; in iframe fallback it reads `Automation target unavailable`, and the packaged workflow hard-stops as `internal_pane_unavailable` instead of retargeting any external browser or isolated `browser_*` session. Agents must re-verify this affordance from a fresh `app="Hermes"` capture immediately before every pointer, focus, or type action. -## Higgsfield CLI provenance bridge +## Higgsfield read-only CLI inspection -Higgsfield results can be brought into QC from the authenticated `higgsfield` CLI instead of an MCP tool card. `scripts/higgsfield-control.mjs` is a strictly read-only bridge: it maps only observation subcommands (`account status`, `generate list`, `soul-id list`, `model list`, `generate get`) and has no argv passthrough, so paid or mutating subcommands (`generate create`, `soul-id train`, `upload`, `publish`, `auth token`, ...) are unreachable by construction. +Existing Higgsfield jobs can still be inspected through the authenticated `higgsfield` CLI without an MCP tool card. `scripts/higgsfield-control.mjs` maps only observation subcommands (`account status`, `generate list`, `soul-id list`, `model list`, `generate get`) and has no argv passthrough, so paid or mutating subcommands are unreachable by construction. ```bash -# Normalize one existing job into Visual Workbench provider evidence (signed query stripped) node scripts/higgsfield-control.mjs evidence --url "" node scripts/higgsfield-control.mjs evidence --job-id "" ``` -The agent binds that provenance into QC over the command socket with `set-target { url, providerEvidence }` followed by `link { profileId: "higgsfield-image" }`. `set-target` accepts `providerEvidence` only when it normalizes to a recognized provider and its result URL path matches the target URL; the URL is sanitized before storage, so no signed query survives. +CLI evidence is diagnostic only and cannot be attached to QC through `set-target`. Trusted Web provenance is created inside Hermes: run the typed Higgsfield `observe` action on a completed result, then consume its short-lived one-shot `observationReceipt` with the typed `link` action. The Electron observer verifies the Unlimited UI state and exact result provenance; the plugin strips signed URL data before durable storage. -The bridge never uses the `hf` alias, which on many hosts resolves to the HuggingFace CLI; it always invokes `higgsfield` (override with `HIGGSFIELD_BIN`). No generation is ever triggered from this bridge. +The bridge never uses the `hf` alias, which commonly resolves to the HuggingFace CLI; it always invokes `higgsfield` (override with `HIGGSFIELD_BIN`). This read-only bridge never triggers generation. ## Non-billable fixture E2E diff --git a/dashboard/plugin_api.py b/dashboard/plugin_api.py index 3dc00f0..b82a2d4 100644 --- a/dashboard/plugin_api.py +++ b/dashboard/plugin_api.py @@ -26,8 +26,8 @@ _COMMAND_OPS = { "status", "set-target", "link", "capture", "inspect", "page-checks", - "midjourney-probe", "midjourney-control", "set-check", "score-candidate", - "select-candidate", "import-qc", + "midjourney-probe", "midjourney-control", "higgsfield-control", "set-check", + "score-candidate", "select-candidate", "import-qc", } _COMMAND_QUEUE: deque[dict[str, Any]] = deque(maxlen=200) _RESULTS: dict[str, dict[str, Any]] = {} @@ -236,7 +236,8 @@ def _sanitize_sensitive_urls(value: Any, depth: int = 0) -> Any: return value query = [(key, item) for key, item in parse_qsl(parsed.query, keep_blank_values=True) if not any(marker in key.lower() for marker in ("token", "sig", "signature", "expires", "key", "auth"))] - return urlunsplit((parsed.scheme, parsed.netloc, parsed.path, urlencode(query, doseq=True), parsed.fragment)) + hostname = parsed.hostname or "" + return urlunsplit((parsed.scheme, hostname, parsed.path, urlencode(query, doseq=True), "")) if isinstance(value, dict): return { _sanitize_sensitive_urls(key, depth + 1) if isinstance(key, str) else key: @@ -428,12 +429,63 @@ async def _json_body(request: Request) -> dict[str, Any]: return body +def _valid_higgsfield_command(command: dict[str, Any]) -> bool: + payload = command.get("payload") + if not isinstance(payload, dict) or command.get("op") != "higgsfield-control": + return True + action = payload.get("action") + if action in {"capabilities", "state", "validate", "results", "observe", "qc"}: + return set(payload) == {"action"} + if action == "navigate": + return set(payload) == {"action", "url"} and isinstance(payload["url"], str) and payload["url"].startswith("https://") + if action == "draft": + return ( + set(payload) == {"action", "prompt", "aspect", "model"} + and isinstance(payload["prompt"], str) and bool(payload["prompt"].strip()) + and payload["aspect"] in {"1:1", "16:9", "9:16"} + and payload["model"] in {"Seedream 5.0 Lite", "Nano Banana 2", "Seedream 4.5"} + ) + if action == "link": + return ( + set(payload) == {"action", "observationReceipt"} + and isinstance(payload["observationReceipt"], str) + and bool(payload["observationReceipt"]) + and len(payload["observationReceipt"]) <= 80 + ) + if action == "repair": + return set(payload) == {"action", "approved"} and payload["approved"] is True + if action == "generate": + fingerprint = payload.get("batchFingerprint") + return ( + set(payload) == {"action", "billableConfirmed", "idempotencyKey", "validateReceipt", "batchFingerprint"} + and payload["billableConfirmed"] is True + and isinstance(payload["idempotencyKey"], str) and 8 <= len(payload["idempotencyKey"]) <= 128 + and isinstance(payload["validateReceipt"], str) and bool(payload["validateReceipt"]) + and isinstance(fingerprint, str) and len(fingerprint) == 64 + and all(char in "0123456789abcdef" for char in fingerprint.lower()) + ) + return False + + def _validate_command(command: dict[str, Any]) -> None: command_id = command.get("id") if not isinstance(command_id, str) or not command_id or len(command_id) > 64: raise HTTPException(status_code=400, detail="Command id must be a non-empty string of at most 64 characters") if command.get("op") not in _COMMAND_OPS: raise HTTPException(status_code=400, detail="Command op is not supported") + if command.get("op") == "set-target": + payload = command.get("payload") + if not isinstance(payload, dict) or not set(payload).issubset({"url", "preset", "width", "height"}) or "url" not in payload: + raise HTTPException(status_code=400, detail="Set-target command must use the typed target contract") + if not isinstance(payload["url"], str) or not payload["url"]: + raise HTTPException(status_code=400, detail="Set-target URL must be a non-empty string") + if "preset" in payload and not isinstance(payload["preset"], str): + raise HTTPException(status_code=400, detail="Set-target preset must be a string") + for dimension in ("width", "height"): + if dimension in payload and (not isinstance(payload[dimension], int) or isinstance(payload[dimension], bool) or payload[dimension] < 240): + raise HTTPException(status_code=400, detail=f"Set-target {dimension} must be an integer of at least 240") + if not _valid_higgsfield_command(command): + raise HTTPException(status_code=400, detail="Higgsfield command must use the typed lifecycle contract") async def _broadcast_command(command: dict[str, Any]) -> int: diff --git a/plugin.js b/plugin.js index beabb83..a51733c 100644 --- a/plugin.js +++ b/plugin.js @@ -250,7 +250,7 @@ function isRecord(value) { function boundedMetadataString(value, max = 4000) { return typeof value === 'string' ? value.slice(0, max) : '' } -const URL_SECRET_PARAM = /(?:^|[_-])(?:token|sig(?:nature)?|expires?|key|auth(?:entication|orization)?|secret|credential|session)(?:$|[_-])/i +const URL_SECRET_PARAM = /token|sig|signature|expires|apikey|accesskey|keypair|auth|secret|credential|session|cookie|password|(?:^|[^a-z])key(?:$|[^a-z])/i function sanitizeUrl(value) { const raw = boundedMetadataString(value, 4096) @@ -258,12 +258,21 @@ function sanitizeUrl(value) { try { const url = new URL(raw) for (const key of [...url.searchParams.keys()]) { - if (URL_SECRET_PARAM.test(key)) url.searchParams.delete(key) + const normalized = key.toLowerCase().replace(/[^a-z0-9]/g, '') + if (URL_SECRET_PARAM.test(normalized)) url.searchParams.delete(key) } - const base = raw.split(/[?#]/, 1)[0] - return url.searchParams.size ? `${base}?${url.searchParams}` : base + if (['http:', 'https:'].includes(url.protocol)) { + const path = url.pathname === '/' ? '' : url.pathname + const base = `${url.protocol}//${url.hostname}${path}` + return url.searchParams.size ? `${base}?${url.searchParams}` : base + } + if (!['file:', 'data:'].includes(url.protocol)) return '' + url.username = '' + url.password = '' + url.hash = '' + return url.toString() } catch { - return raw + return '' } } @@ -362,7 +371,10 @@ function restoredProviderEvidence(value) { } } function providerEvidenceIdentity(evidence) { - return evidence ? `${evidence.jobId}|${evidence.resultUrl}|${evidence.model}` : '' + return evidence ? [ + evidence.source, evidence.jobId, evidence.resultUrl, evidence.model, + evidence.status, evidence.aspectRatio + ].join('|') : '' } function midjourneyJobLocation(rawUrl) { try { @@ -889,8 +901,9 @@ const AGENT_PANEL_IDS = ['result', 'reference'] const AGENT_CHECK_STATUSES = ['pass', 'fail', 'na', 'pending'] const MIDJOURNEY_CONTROL_ACTIONS = ['capabilities', 'state', 'navigate', 'probe', 'results', 'settings', 'draft', 'attach', 'detach', 'validate', 'submit', 'wait', 'link', 'grid', 'action', 'download', 'capture', 'qc'] const HIGGSFIELD_MODELS = ['Seedream 5.0 Lite', 'Nano Banana 2', 'Seedream 4.5'] -const HIGGSFIELD_ASPECTS = ['1:1', '16:9', '9:16'] -const HIGGSFIELD_CONTROL_ACTIONS = ['capabilities', 'state', 'navigate', 'draft', 'validate', 'generate', 'results', 'qc'] +const HIGGSFIELD_ASPECTS = ['1:1', '9:16', '16:9'] +const HIGGSFIELD_GENERATION_STATUSES = ['idle', 'queued', 'generating', 'complete', 'failed', 'unknown'] +const HIGGSFIELD_CONTROL_ACTIONS = ['capabilities', 'state', 'navigate', 'draft', 'validate', 'generate', 'results', 'observe', 'link', 'repair', 'qc'] function agentCommandError(message) { return { ok: false, error: message } } function hasOnlyKeys(value, keys) { return Object.keys(value).every(key => keys.includes(key)) } @@ -928,10 +941,12 @@ function validateMidjourneyControlPayload(payload) { } function validateHiggsfieldControlPayload(payload) { if (!isRecord(payload) || !HIGGSFIELD_CONTROL_ACTIONS.includes(payload.action)) return false - if (['capabilities', 'state', 'validate', 'results', 'qc'].includes(payload.action)) return hasOnlyKeys(payload, ['action']) + if (['capabilities', 'state', 'validate', 'results', 'observe', 'qc'].includes(payload.action)) return hasOnlyKeys(payload, ['action']) if (payload.action === 'navigate') return hasOnlyKeys(payload, ['action', 'url']) && validAgentString(payload.url, 4096) && /^https:\/\/(?:www\.)?higgsfield\.ai(?:\/|$)/i.test(payload.url) if (payload.action === 'draft') return hasOnlyKeys(payload, ['action', 'prompt', 'aspect', 'model']) && validAgentString(payload.prompt, 6000) && payload.prompt.trim().length > 0 && HIGGSFIELD_ASPECTS.includes(payload.aspect) && HIGGSFIELD_MODELS.includes(payload.model) if (payload.action === 'generate') return hasOnlyKeys(payload, ['action', 'billableConfirmed', 'idempotencyKey', 'validateReceipt', 'batchFingerprint']) && payload.billableConfirmed === true && validAgentString(payload.idempotencyKey, 128) && payload.idempotencyKey.length >= 8 && validAgentString(payload.validateReceipt, 80) && /^[a-f0-9]{64}$/.test(String(payload.batchFingerprint || '')) + if (payload.action === 'link') return hasOnlyKeys(payload, ['action', 'observationReceipt']) && validAgentString(payload.observationReceipt, 80) && payload.observationReceipt.length > 0 + if (payload.action === 'repair') return hasOnlyKeys(payload, ['action', 'approved']) && payload.approved === true return false } @@ -944,7 +959,7 @@ function validateAgentCommand(value) { if (needsPanel ? !AGENT_PANEL_IDS.includes(value.panelId) : Object.hasOwn(value, 'panelId')) return agentCommandError(needsPanel ? 'panelId must be result or reference' : 'panelId is not allowed for this op') const payload = value.payload if (value.op === 'status' && hasOnlyKeys(payload, [])) return { ok: true, command: value } - if (value.op === 'set-target' && hasOnlyKeys(payload, ['url', 'preset', 'width', 'height', 'providerEvidence']) && validAgentString(payload.url, 4096) && /^(https?|file|data):/i.test(payload.url) && (!Object.hasOwn(payload, 'preset') || validAgentString(payload.preset, 64)) && (!Object.hasOwn(payload, 'width') || Number.isInteger(payload.width) && payload.width >= 240) && (!Object.hasOwn(payload, 'height') || Number.isInteger(payload.height) && payload.height >= 240) && (!Object.hasOwn(payload, 'providerEvidence') || (restoredProviderEvidence(payload.providerEvidence) !== null && comparableUrl(restoredProviderEvidence(payload.providerEvidence).resultUrl) === comparableUrl(payload.url)))) return { ok: true, command: value } + if (value.op === 'set-target' && hasOnlyKeys(payload, ['url', 'preset', 'width', 'height']) && validAgentString(payload.url, 4096) && /^(https?|file|data):/i.test(payload.url) && (!Object.hasOwn(payload, 'preset') || validAgentString(payload.preset, 64)) && (!Object.hasOwn(payload, 'width') || Number.isInteger(payload.width) && payload.width >= 240) && (!Object.hasOwn(payload, 'height') || Number.isInteger(payload.height) && payload.height >= 240)) return { ok: true, command: value } if (value.op === 'link' && hasOnlyKeys(payload, ['profileId']) && (!Object.hasOwn(payload, 'profileId') || QC_PROFILE_IDS.includes(payload.profileId))) return { ok: true, command: value } if (['capture', 'inspect', 'page-checks', 'midjourney-probe'].includes(value.op) && hasOnlyKeys(payload, [])) return { ok: true, command: value } if (value.op === 'midjourney-control' && validateMidjourneyControlPayload(payload)) return { ok: true, command: value } @@ -1213,6 +1228,7 @@ const HIGGSFIELD_SELECTOR_REGISTRY = Object.freeze({ targets: Object.freeze(['higgsfield-composer', 'higgsfield-aspect-square', 'higgsfield-aspect-portrait', 'higgsfield-aspect-landscape', 'higgsfield-model-seedream-5-lite', 'higgsfield-model-nano-banana-2', 'higgsfield-model-seedream-4-5', 'higgsfield-generate']) }) const higgsfieldValidations = new Map() +const higgsfieldObservationReceipts = new Map() const higgsfieldDrafts = new Map() const HIGGSFIELD_MODEL_TARGETS = Object.freeze({ 'Seedream 5.0 Lite': 'higgsfield-model-seedream-5-lite', @@ -1222,6 +1238,48 @@ const HIGGSFIELD_MODEL_TARGETS = Object.freeze({ const HIGGSFIELD_ASPECT_TARGETS = Object.freeze({ '1:1': 'higgsfield-aspect-square', '16:9': 'higgsfield-aspect-landscape', '9:16': 'higgsfield-aspect-portrait' }) +const HIGGSFIELD_OBSERVATION_TTL_MS = 60_000 +function redactedHiggsfieldResultUrl(value) { + try { + const url = new URL(String(value || '')) + url.search = '' + url.hash = '' + return url.toString() + } catch { return '' } +} +function observedHiggsfieldState(value, context) { + if (!isRecord(value) || value.guestWebContentsId !== context.guestId || value.url !== context.url || + !HIGGSFIELD_MODELS.includes(value.selectedModel) || !HIGGSFIELD_ASPECTS.includes(value.selectedAspect) || + value.unlimited !== true || !HIGGSFIELD_GENERATION_STATUSES.includes(value.generationStatus) || !Array.isArray(value.results)) { + throw new Error('Higgsfield observation did not satisfy the browser-control contract') + } + const results = value.results.map(result => isRecord(result) && typeof result.url === 'string' && + typeof result.providerJobId === 'string' && result.url ? { url: result.url, providerJobId: result.providerJobId } : null) + if (results.some(result => !result) || results.length > 1) throw new Error('Higgsfield observation contains an invalid or ambiguous result') + return { ...value, results } +} +async function observeHiggsfield(panelId, context, requireLinkableResult = true) { + const observed = observedHiggsfieldState(await higgsfieldControl(panelId, { op: 'observeHiggsfield' }, context), context) + if (!requireLinkableResult) return { observed, receipt: '' } + if (observed.generationStatus !== 'complete' || observed.results.length !== 1) { + throw new Error('A completed unambiguous Higgsfield result is required before linking') + } + const receipt = createId('hfo-') + higgsfieldObservationReceipts.set(receipt, { + panelId, targetId: context.targetId, guestId: context.guestId, sourceUrl: context.url, + result: observed.results[0], selectedModel: observed.selectedModel, selectedAspect: observed.selectedAspect, + generationStatus: observed.generationStatus, expiresAt: Date.now() + HIGGSFIELD_OBSERVATION_TTL_MS + }) + return { observed, receipt } +} +function consumeHiggsfieldObservation(panelId, context, receipt) { + const observation = higgsfieldObservationReceipts.get(receipt) + higgsfieldObservationReceipts.delete(receipt) + if (!observation || observation.expiresAt < Date.now() || observation.panelId !== panelId || + observation.targetId !== context.targetId || observation.guestId !== context.guestId || + observation.sourceUrl !== context.url) throw new Error('A fresh matching Higgsfield observation receipt is required') + return observation +} function isHiggsfieldPage(rawUrl) { try { const hostname = new URL(rawUrl).hostname.toLowerCase() @@ -1267,10 +1325,41 @@ async function runHiggsfieldControl(panelId, payload) { host.panes?.setOpen?.(BROWSER_PANE_ID, true) return { summary: `Navigated ${panelId} to Higgsfield`, detail: { state: 'NAVIGATING', evidence: { panelId, requestedUrl: payload.url } } } } + if (payload.action === 'repair') { + const context = state.reviewContext + const providerEvidence = context?.providerEvidence + if (!reviewContextMatches(state, 'higgsfield-image') || context?.panelId !== panelId || + providerEvidence?.source !== 'higgsfield-web' || !providerEvidence.jobId || providerEvidence.status !== 'complete') { + throw new Error('Link a fresh completed Higgsfield Web result in this panel before approving repair') + } + const candidateId = state.selectedCandidate + const candidate = state.candidates[candidateId] + if (candidate?.disposition !== 'REPAIR' || !candidate.repairPrompt?.trim()) { + throw new Error('The selected QC candidate must have a reviewed REPAIR disposition and stored repair prompt') + } + return { summary: 'Higgsfield repair draft handoff approved', detail: { state: 'DRAFT_HANDOFF', evidence: { provider: 'higgsfield-web', candidateId, prompt: candidate.repairPrompt.trim(), generation: 'not-invoked' } } } + } const context = currentHiggsfieldContext(panelId) - if (payload.action === 'state' || payload.action === 'results') { - const result = await higgsfieldControl(panelId, { op: payload.action === 'results' ? 'results' : 'snapshot' }, context) - return { summary: 'Higgsfield typed browser state', detail: { state: 'OBSERVED', evidence: result } } + if (payload.action === 'state') { + const { observed } = await observeHiggsfield(panelId, context, false) + return { summary: 'Higgsfield typed browser state', detail: { state: 'OBSERVED', evidence: observed } } + } + if (payload.action === 'results' || payload.action === 'observe') { + const { observed, receipt } = await observeHiggsfield(panelId, context) + return { summary: 'Higgsfield typed browser observation', detail: { state: 'OBSERVED', evidence: observed, observationReceipt: receipt } } + } + if (payload.action === 'link') { + const observation = consumeHiggsfieldObservation(panelId, context, payload.observationReceipt) + const resultUrl = redactedHiggsfieldResultUrl(observation.result.url) + if (!resultUrl) throw new Error('Observed Higgsfield result URL is invalid') + const providerEvidence = restoredProviderEvidence({ + source: 'higgsfield-web', jobId: observation.result.providerJobId, status: observation.generationStatus, + model: observation.selectedModel, aspectRatio: observation.selectedAspect, resultUrl + }) + setState(linkPanelState(updatePanelState(state, panelId, { + url: resultUrl, providerEvidence, qcProfileHint: 'higgsfield-image' + }), panelId, { profileId: 'higgsfield-image' }, createId)) + return { summary: 'Observed Higgsfield result linked to Quality Control', detail: { state: 'LINKED', evidence: { providerEvidence } } } } if (payload.action === 'draft') { await higgsfieldControl(panelId, { op: 'focusText', targetId: 'higgsfield-composer', text: payload.prompt.trim(), replace: true }, context) @@ -1292,8 +1381,10 @@ async function runHiggsfieldControl(panelId, payload) { } const receipt = createId('hfv-') const batchFingerprint = await midjourneyPromptHash(`${prompt}\n${draft.aspect}\n${draft.model}`) - higgsfieldValidations.set(receipt, { panelId, targetId: context.targetId, url: context.url, prompt, aspect: draft.aspect, model: draft.model, batchFingerprint, expiresAt: Date.now() + 60_000 }) - return { summary: 'Higgsfield draft is generate-ready', receiptContext: { receiptHash: await midjourneyPromptHash(receipt), batchFingerprint }, detail: { state: 'READY', evidence: { prompt, aspect: draft.aspect, model: draft.model, receipt } } } + const expiresAtMs = Date.now() + 60_000 + const expiresAt = new Date(expiresAtMs).toISOString() + higgsfieldValidations.set(receipt, { panelId, targetId: context.targetId, url: context.url, prompt, aspect: draft.aspect, model: draft.model, batchFingerprint, expiresAt: expiresAtMs }) + return { summary: 'Higgsfield draft is generate-ready', receiptContext: { receiptHash: await midjourneyPromptHash(receipt), batchContextId: batchFingerprint, expiresAt, batchFingerprint }, detail: { state: 'READY', evidence: { prompt, aspect: draft.aspect, model: draft.model, receipt, expiresAt } } } } if (payload.action === 'generate') { const validation = higgsfieldValidations.get(payload.validateReceipt) diff --git a/scripts/qc-core.mjs b/scripts/qc-core.mjs index 2f69936..a2691c1 100644 --- a/scripts/qc-core.mjs +++ b/scripts/qc-core.mjs @@ -75,7 +75,7 @@ function isRecord(value) { function boundedMetadataString(value, max = 4000) { return typeof value === 'string' ? value.slice(0, max) : '' } -const URL_SECRET_PARAM = /(?:^|[_-])(?:token|sig(?:nature)?|expires?|key|auth(?:entication|orization)?|secret|credential|session)(?:$|[_-])/i +const URL_SECRET_PARAM = /token|sig|signature|expires|apikey|accesskey|keypair|auth|secret|credential|session|cookie|password|(?:^|[^a-z])key(?:$|[^a-z])/i export function sanitizeUrl(value) { const raw = boundedMetadataString(value, 4096) @@ -83,12 +83,21 @@ export function sanitizeUrl(value) { try { const url = new URL(raw) for (const key of [...url.searchParams.keys()]) { - if (URL_SECRET_PARAM.test(key)) url.searchParams.delete(key) + const normalized = key.toLowerCase().replace(/[^a-z0-9]/g, '') + if (URL_SECRET_PARAM.test(normalized)) url.searchParams.delete(key) } - const base = raw.split(/[?#]/, 1)[0] - return url.searchParams.size ? `${base}?${url.searchParams}` : base + if (['http:', 'https:'].includes(url.protocol)) { + const path = url.pathname === '/' ? '' : url.pathname + const base = `${url.protocol}//${url.hostname}${path}` + return url.searchParams.size ? `${base}?${url.searchParams}` : base + } + if (!['file:', 'data:'].includes(url.protocol)) return '' + url.username = '' + url.password = '' + url.hash = '' + return url.toString() } catch { - return raw + return '' } } @@ -213,7 +222,10 @@ export function restoredProviderEvidence(value) { } } export function providerEvidenceIdentity(evidence) { - return evidence ? `${evidence.jobId}|${evidence.resultUrl}|${evidence.model}` : '' + return evidence ? [ + evidence.source, evidence.jobId, evidence.resultUrl, evidence.model, + evidence.status, evidence.aspectRatio + ].join('|') : '' } export function midjourneyJobLocation(rawUrl) { try { @@ -809,9 +821,10 @@ const AGENT_COMMAND_OPS = ['status', 'set-target', 'link', 'capture', 'inspect', const AGENT_PANEL_IDS = ['result', 'reference'] const AGENT_CHECK_STATUSES = ['pass', 'fail', 'na', 'pending'] const MIDJOURNEY_CONTROL_ACTIONS = ['capabilities', 'state', 'navigate', 'probe', 'results', 'settings', 'draft', 'attach', 'detach', 'validate', 'submit', 'wait', 'link', 'grid', 'action', 'download', 'capture', 'qc'] -const HIGGSFIELD_MODELS = ['Seedream 5.0 Lite', 'Nano Banana 2', 'Seedream 4.5'] -const HIGGSFIELD_ASPECTS = ['1:1', '16:9', '9:16'] -const HIGGSFIELD_CONTROL_ACTIONS = ['capabilities', 'state', 'navigate', 'draft', 'validate', 'generate', 'results', 'qc'] +export const HIGGSFIELD_MODELS = Object.freeze(['Seedream 5.0 Lite', 'Nano Banana 2', 'Seedream 4.5']) +export const HIGGSFIELD_ASPECTS = Object.freeze(['1:1', '9:16', '16:9']) +export const HIGGSFIELD_GENERATION_STATUSES = Object.freeze(['idle', 'queued', 'generating', 'complete', 'failed', 'unknown']) +export const HIGGSFIELD_CONTROL_ACTIONS = Object.freeze(['capabilities', 'state', 'navigate', 'draft', 'validate', 'generate', 'results', 'observe', 'link', 'repair', 'qc']) function agentCommandError(message) { return { ok: false, error: message } @@ -857,10 +870,12 @@ function validateMidjourneyControlPayload(payload) { } export function validateHiggsfieldControlPayload(payload) { if (!isRecord(payload) || !HIGGSFIELD_CONTROL_ACTIONS.includes(payload.action)) return false - if (['capabilities', 'state', 'validate', 'results', 'qc'].includes(payload.action)) return hasOnlyKeys(payload, ['action']) + if (['capabilities', 'state', 'validate', 'results', 'observe', 'qc'].includes(payload.action)) return hasOnlyKeys(payload, ['action']) if (payload.action === 'navigate') return hasOnlyKeys(payload, ['action', 'url']) && validAgentString(payload.url, 4096) && /^https:\/\/(?:www\.)?higgsfield\.ai(?:\/|$)/i.test(payload.url) if (payload.action === 'draft') return hasOnlyKeys(payload, ['action', 'prompt', 'aspect', 'model']) && validAgentString(payload.prompt, 6000) && payload.prompt.trim().length > 0 && HIGGSFIELD_ASPECTS.includes(payload.aspect) && HIGGSFIELD_MODELS.includes(payload.model) if (payload.action === 'generate') return hasOnlyKeys(payload, ['action', 'billableConfirmed', 'idempotencyKey', 'validateReceipt', 'batchFingerprint']) && payload.billableConfirmed === true && validAgentString(payload.idempotencyKey, 128) && payload.idempotencyKey.length >= 8 && validAgentString(payload.validateReceipt, 80) && /^[a-f0-9]{64}$/.test(String(payload.batchFingerprint || '')) + if (payload.action === 'link') return hasOnlyKeys(payload, ['action', 'observationReceipt']) && validAgentString(payload.observationReceipt, 80) && payload.observationReceipt.length > 0 + if (payload.action === 'repair') return hasOnlyKeys(payload, ['action', 'approved']) && payload.approved === true return false } @@ -877,12 +892,11 @@ export function validateAgentCommand(value) { const payload = value.payload if (value.op === 'status' && hasOnlyKeys(payload, [])) return { ok: true, command: value } - if (value.op === 'set-target' && hasOnlyKeys(payload, ['url', 'preset', 'width', 'height', 'providerEvidence']) && + if (value.op === 'set-target' && hasOnlyKeys(payload, ['url', 'preset', 'width', 'height']) && validAgentString(payload.url, 4096) && /^(https?|file|data):/i.test(payload.url) && (!Object.hasOwn(payload, 'preset') || validAgentString(payload.preset, 64)) && (!Object.hasOwn(payload, 'width') || Number.isInteger(payload.width) && payload.width >= 240) && - (!Object.hasOwn(payload, 'height') || Number.isInteger(payload.height) && payload.height >= 240) && - (!Object.hasOwn(payload, 'providerEvidence') || (restoredProviderEvidence(payload.providerEvidence) !== null && comparableUrl(restoredProviderEvidence(payload.providerEvidence).resultUrl) === comparableUrl(payload.url)))) return { ok: true, command: value } + (!Object.hasOwn(payload, 'height') || Number.isInteger(payload.height) && payload.height >= 240)) return { ok: true, command: value } if (value.op === 'link' && hasOnlyKeys(payload, ['profileId']) && (!Object.hasOwn(payload, 'profileId') || QC_PROFILE_IDS.includes(payload.profileId))) return { ok: true, command: value } if (['capture', 'inspect', 'page-checks', 'midjourney-probe'].includes(value.op) && hasOnlyKeys(payload, [])) return { ok: true, command: value } @@ -948,7 +962,7 @@ export function applyAgentCommand(state, command, makeId = createId) { const { op, payload } = command if (op === 'status') return { state, summary: 'Status snapshot' } if (op === 'set-target') { - const patch = Object.fromEntries(Object.entries(payload).filter(([key]) => ['url', 'preset', 'width', 'height', 'providerEvidence'].includes(key))) + const patch = Object.fromEntries(Object.entries(payload).filter(([key]) => ['url', 'preset', 'width', 'height'].includes(key))) return { state: updatePanelState(state, command.panelId, patch, {}, makeId), summary: `Set ${command.panelId} target` } } if (op === 'link') { diff --git a/tests/plugin-runtime-core.test.mjs b/tests/plugin-runtime-core.test.mjs index d848ef8..6232253 100644 --- a/tests/plugin-runtime-core.test.mjs +++ b/tests/plugin-runtime-core.test.mjs @@ -21,6 +21,8 @@ import { QC_PROFILE_IDS, qcProfileFor, validateAgentCommand, + validateHiggsfieldControlPayload, + HIGGSFIELD_GENERATION_STATUSES, applyAgentCommand, agentStatusSnapshot, validateQcDocument @@ -60,7 +62,7 @@ function loadRuntimeCore() { assert.notEqual(finish, -1, 'plugin core end marker is missing') assert.ok(finish > start, 'plugin core markers are out of order') const source = pluginSource.slice(start + begin.length, finish) - return Function(`${source}\nreturn { DEFAULT_STATE, persistedState, restoredState, restoredProviderEvidence, validateQcDocument, validateAgentCommand, validateHiggsfieldControlPayload, HIGGSFIELD_MODELS, HIGGSFIELD_ASPECTS, applyAgentCommand, agentStatusSnapshot, PROVIDERS, PROVIDER_IDS, providerEvidenceFor, providerForProfile, qcProfileFor, reviewContextMatches, panelLinkedToQc, updatePanelState, linkPanelState, swapPanelsState, markPanelLoadFailedState, providerEvidenceIdentity, midjourneyJobLocation, midjourneyProviderEvidenceForUrl, sameMidjourneyCandidateSwitch, setRuntimeState(value) { state = { ...DEFAULT_STATE, ...value } } }`)() + return Function(`${source}\nreturn { DEFAULT_STATE, persistedState, restoredState, restoredProviderEvidence, validateQcDocument, validateAgentCommand, validateHiggsfieldControlPayload, HIGGSFIELD_MODELS, HIGGSFIELD_ASPECTS, HIGGSFIELD_GENERATION_STATUSES, applyAgentCommand, agentStatusSnapshot, PROVIDERS, PROVIDER_IDS, providerEvidenceFor, providerForProfile, qcProfileFor, reviewContextMatches, panelLinkedToQc, updatePanelState, linkPanelState, swapPanelsState, markPanelLoadFailedState, providerEvidenceIdentity, midjourneyJobLocation, midjourneyProviderEvidenceForUrl, sameMidjourneyCandidateSwitch, setRuntimeState(value) { state = { ...DEFAULT_STATE, ...value } } }`)() } function serializableProvider(provider) { @@ -137,6 +139,50 @@ test('Higgsfield Web rejects caller-asserted QC provenance', () => { assert.equal(runtime.validateAgentCommand(command).ok, false) assert.equal(validateAgentCommand(command).ok, false) }) +test('runtime rejects caller-asserted Higgsfield provider evidence', () => { + const runtime = loadRuntimeCore() + const command = { + id: 'hf-set-target', + op: 'set-target', + panelId: 'result', + payload: { + url: 'https://cdn.higgsfield.ai/generations/result.png', + providerEvidence: { source: 'higgsfield-web', jobId: 'caller-job', resultUrl: 'https://cdn.higgsfield.ai/generations/result.png' } + } + } + assert.equal(runtime.validateAgentCommand(command).ok, false) +}) +test('Higgsfield observation receipt implementation is exact, one-shot, and redacts result URLs', () => { + assert.match(pluginSource, /op: 'observeHiggsfield'/) + assert.match(pluginSource, /guestWebContentsId !== context\.guestId/) + assert.match(pluginSource, /value\.unlimited !== true/) + assert.match(pluginSource, /results\.length > 1/) + assert.match(pluginSource, /higgsfieldObservationReceipts\.delete\(receipt\)/) + assert.match(pluginSource, /observation\.expiresAt < Date\.now\(\)/) + assert.match(pluginSource, /observation\.targetId !== context\.targetId/) + assert.match(pluginSource, /observation\.guestId !== context\.guestId/) + assert.match(pluginSource, /observation\.sourceUrl !== context\.url/) + assert.match(pluginSource, /url\.search = ''/) + assert.match(pluginSource, /url\.hash = ''/) + assert.match(pluginSource, /observed\.generationStatus !== 'complete'/) +}) +test('Higgsfield link provenance and repair handoff reject caller evidence and never generate', () => { + const runtime = loadRuntimeCore() + assert.equal(runtime.validateHiggsfieldControlPayload({ action: 'link', observationReceipt: 'hfo-receipt' }), true) + assert.equal(runtime.validateHiggsfieldControlPayload({ action: 'link', observationReceipt: 'hfo-receipt', providerJobId: 'caller' }), false) + assert.equal(runtime.validateHiggsfieldControlPayload({ action: 'repair', approved: true }), true) + assert.equal(runtime.validateHiggsfieldControlPayload({ action: 'repair', approved: false }), false) + assert.deepEqual(runtime.HIGGSFIELD_GENERATION_STATUSES, HIGGSFIELD_GENERATION_STATUSES) + assert.deepEqual(runtime.HIGGSFIELD_ASPECTS, ['1:1', '9:16', '16:9']) + const observationAndLink = pluginSource.slice(pluginSource.indexOf('async function observeHiggsfield'), pluginSource.indexOf("if (payload.action === 'draft')")) + assert.doesNotMatch(observationAndLink, /op: 'generate'/) + const repairStart = pluginSource.indexOf("if (payload.action === 'repair')", pluginSource.indexOf('async function runHiggsfieldControl')) + const repair = pluginSource.slice(repairStart, pluginSource.indexOf('const context = currentHiggsfieldContext(panelId)', repairStart)) + assert.match(repair, /reviewContextMatches\(state, 'higgsfield-image'\)/) + assert.match(repair, /candidate\?\.disposition !== 'REPAIR'/) + assert.doesNotMatch(repair, /higgsfieldControl|op: 'generate'/) + assert.match(pluginSource, /receiptContext: \{ receiptHash: await midjourneyPromptHash\(receipt\), batchContextId: batchFingerprint, expiresAt, batchFingerprint \}/) +}) test('runtime persisted-state restore repairs malformed and partial candidate data', () => { const runtime = loadRuntimeCore() diff --git a/tests/qc-core.test.mjs b/tests/qc-core.test.mjs index 9493eb2..e4943d0 100644 --- a/tests/qc-core.test.mjs +++ b/tests/qc-core.test.mjs @@ -304,6 +304,13 @@ test('binds Higgsfield metadata only to one exact raw receipt and redacts URL se collision.toolResult.structuredContent.items.push({ id: 'duplicate', results: { rawUrl: src } }) assert.equal(providerEvidenceFor(collision), null) assert.equal(sanitizeUrl('https://cdn.example.test/a?token=x&sig=y&signature=z&expires=1&key=k&auth=a&keep=yes#secret'), 'https://cdn.example.test/a?keep=yes') + assert.equal( + sanitizeUrl('https://user:password@cdn.example.test/a?apiKey=x&accessToken=y&keep=yes#fragment'), + 'https://cdn.example.test/a?keep=yes' + ) + assert.equal(sanitizeUrl('https://[malformed'), '') + assert.equal(sanitizeUrl('not a URL'), '') + assert.equal(sanitizeUrl('javascript:alert(1)'), '') }) test('Higgsfield invocation firewall allows inspections only and blocks mutations before invocation', async () => { @@ -744,6 +751,15 @@ test('restore rejects live contexts whose provenance identity differs from the p const dropped = migratePersistedState(substituted, defaults) assert.equal(dropped.reviewContext, null, 'live context provenance must match panel provenance') assert.deepEqual(dropped.evaluations, {}) + for (const [field, value] of [['source', 'higgsfield-web'], ['status', 'failed'], ['aspectRatio', '9:16']]) { + const changed = structuredClone(linked) + changed.browserPanels.reference.providerEvidence[field] = value + assert.equal( + migratePersistedState(changed, defaults).reviewContext, + null, + `${field} substitution must stale restored provenance` + ) + } const staleHistorical = structuredClone(substituted) staleHistorical.reviewContext.stale = true @@ -847,44 +863,19 @@ test('applies linked agent QC commands and emits the complete status snapshot co assert.deepEqual(Object.keys(agentStatusSnapshot(scored.state).panels).sort(), ['reference', 'result']) }) -test('set-target binds sanitized Higgsfield CLI provenance and links it into QC', () => { +test('set-target rejects caller-asserted Higgsfield provenance', () => { const url = 'https://cdn.higgsfield.test/generations/job.png' - const signed = `${url}?token=secret-token&sig=secret-sig` const providerEvidence = { source: 'higgsfield-mcp', jobId: 'cli-job-1', status: 'completed', model: 'text2image_soul_v2', soulId: 'soul-9', mediaType: 'image', prompt: 'a portrait', width: 1152, height: 2048, - resultUrl: signed, createdAt: 1784570752 + resultUrl: `${url}?token=secret-token&sig=secret-sig`, createdAt: 1784570752 } - const makeId = prefix => `${prefix}cli-0000` - let state = migratePersistedState({}, v5Defaults()) - - // Provenance whose result URL path does not match the target URL is rejected. assert.equal( - validateAgentCommand({ id: 'mismatch', op: 'set-target', panelId: 'result', payload: { url, providerEvidence: { ...providerEvidence, resultUrl: 'https://cdn.higgsfield.test/other.png' } } }).ok, + validateAgentCommand({ id: 'caller-provenance', op: 'set-target', panelId: 'result', payload: { url, providerEvidence } }).ok, false ) - // Unrecognized provenance source is rejected. assert.equal( - validateAgentCommand({ id: 'bad-source', op: 'set-target', panelId: 'result', payload: { url, providerEvidence: { source: 'unknown', jobId: 'x', resultUrl: url } } }).ok, - false - ) - // Matching provenance is accepted. - assert.equal( - validateAgentCommand({ id: 'ok', op: 'set-target', panelId: 'result', payload: { url, providerEvidence } }).ok, + validateAgentCommand({ id: 'plain-target', op: 'set-target', panelId: 'result', payload: { url } }).ok, true ) - - state = applyAgentCommand(state, { op: 'set-target', panelId: 'result', payload: { url, providerEvidence } }, makeId).state - const panel = state.browserPanels.result - assert.equal(panel.url, url) - assert.equal(panel.providerEvidence.jobId, 'cli-job-1') - assert.equal(panel.providerEvidence.model, 'text2image_soul_v2') - // Signed query is stripped from the bound provenance. - assert.ok(!JSON.stringify(panel.providerEvidence).includes('secret-token')) - assert.ok(!JSON.stringify(panel.providerEvidence).includes('secret-sig')) - - state = applyAgentCommand(state, { op: 'link', panelId: 'result', payload: { profileId: 'higgsfield-image' } }, makeId).state - assert.equal(state.reviewContext.profileId, 'higgsfield-image') - assert.equal(state.reviewContext.providerEvidence.jobId, 'cli-job-1') - assert.equal(agentStatusSnapshot(state).reviewContext.providerJobId, 'cli-job-1') }) diff --git a/tests/test_plugin_api_ledger.py b/tests/test_plugin_api_ledger.py index 5de129d..d43ee01 100644 --- a/tests/test_plugin_api_ledger.py +++ b/tests/test_plugin_api_ledger.py @@ -80,6 +80,60 @@ def test_higgsfield_generate_is_durably_idempotent(self) -> None: second, existing = plugin_api._reserve_billable_command(replay, "higgsfield-generate", "hf-fixture-key-1234") self.assertTrue(existing) self.assertEqual(second, first) + def test_higgsfield_typed_command_rejects_caller_asserted_result_link(self) -> None: + command = { + "id": "hf-caller-link", + "op": "higgsfield-control", + "panelId": "result", + "payload": { + "action": "results", + "providerJobId": "caller-job", + "resultUrl": "https://cdn.higgsfield.ai/result.png?token=secret", + }, + } + self.assertFalse(plugin_api._valid_higgsfield_command(command)) + def test_higgsfield_observation_link_and_repair_use_typed_payloads(self) -> None: + base = {"id": "hf-lifecycle", "op": "higgsfield-control", "panelId": "result"} + self.assertTrue(plugin_api._valid_higgsfield_command({**base, "payload": {"action": "observe"}})) + self.assertTrue(plugin_api._valid_higgsfield_command({ + **base, "payload": {"action": "link", "observationReceipt": "hfo-fixture"} + })) + self.assertTrue(plugin_api._valid_higgsfield_command({ + **base, "payload": {"action": "repair", "approved": True} + })) + self.assertFalse(plugin_api._valid_higgsfield_command({ + **base, "payload": {"action": "link", "observationReceipt": "hfo-fixture", "resultUrl": "https://evil.example/result"} + })) + self.assertFalse(plugin_api._valid_higgsfield_command({ + **base, "payload": {"action": "repair", "approved": False} + })) + def test_set_target_rejects_caller_provenance_at_backend_boundary(self) -> None: + with self.assertRaises(HTTPException): + plugin_api._validate_command({ + "id": "hf-set-target", + "op": "set-target", + "panelId": "result", + "payload": { + "url": "https://example.com/result.png", + "providerEvidence": {"source": "higgsfield-web", "jobId": "caller"}, + }, + }) + plugin_api._validate_command({ + "id": "plain-set-target", + "op": "set-target", + "panelId": "result", + "payload": {"url": "https://example.com/result.png", "width": 1024, "height": 1024}, + }) + + def test_higgsfield_result_urls_are_redacted_before_storage(self) -> None: + result = plugin_api._sanitize_sensitive_urls({ + "provenance": {"resultUrl": "https://cdn.higgsfield.ai/result.png?token=secret&width=1024"}, + }) + self.assertEqual(result["provenance"]["resultUrl"], "https://cdn.higgsfield.ai/result.png?width=1024") + redacted = plugin_api._sanitize_sensitive_urls( + "https://user:password@example.com/result.png?token=secret&width=1024#fragment-secret" + ) + self.assertEqual(redacted, "https://example.com/result.png?width=1024") def test_reservation_survives_reload_and_never_rebroadcasts(self) -> None: first, existing = plugin_api._reserve_billable_command(self.command("submit-1"), "submit", "fixture-key-1234") From a9db3dc234ff332c29c3b3c95654a357dc62b9fb Mon Sep 17 00:00:00 2001 From: HeiTuz <79418013+HeiTuz@users.noreply.github.com> Date: Tue, 21 Jul 2026 07:43:54 +0900 Subject: [PATCH 3/3] chore: release visual workbench v0.7.0 --- backend/plugin.yaml | 2 +- manifest.json | 2 +- package.json | 8 +++++--- plugin.js | 2 +- skill/SKILL.md | 2 +- 5 files changed, 9 insertions(+), 7 deletions(-) diff --git a/backend/plugin.yaml b/backend/plugin.yaml index e98beb2..cb5cc91 100644 --- a/backend/plugin.yaml +++ b/backend/plugin.yaml @@ -1,6 +1,6 @@ manifest_version: 1 name: visual-workbench -version: 0.6.0 +version: 0.7.0 kind: standalone host_version: ">=0.18.2" description: "Local Visual Workbench dashboard bridge for browser capture, QC, and bounded Midjourney control." diff --git a/manifest.json b/manifest.json index 8517f40..2082276 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "visual-workbench", "name": "Visual Workbench", - "version": "0.6.0", + "version": "0.7.0", "entry": "plugin.js", "fullHostCapabilities": [ "chat.imageActions", diff --git a/package.json b/package.json index 323cb8b..fd7c49b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hermes-visual-workbench", - "version": "0.6.0", + "version": "0.7.0", "description": "Midjourney A/B/C/D QC and independent Browser panes for Hermes Desktop", "type": "module", "bin": { @@ -10,8 +10,10 @@ "plugin.js", "manifest.json", "skill/", - "dashboard/", - "backend/", + "dashboard/manifest.json", + "dashboard/plugin_api.py", + "backend/__init__.py", + "backend/plugin.yaml", "scripts/", "README.md", "LICENSE" diff --git a/plugin.js b/plugin.js index a51733c..e107192 100644 --- a/plugin.js +++ b/plugin.js @@ -16,7 +16,7 @@ import { useEffect, useRef, useState, useSyncExternalStore } from 'react' import { jsx, jsxs } from 'react/jsx-runtime' const PLUGIN_ID = 'visual-workbench' -const PLUGIN_VERSION = '0.6.0' +const PLUGIN_VERSION = '0.7.0' const BROWSER_PANE_ID = `${PLUGIN_ID}:browser` const QC_PANE_ID = `${PLUGIN_ID}:qc` const CLOSED_PANE_ATOM = atom(false) diff --git a/skill/SKILL.md b/skill/SKILL.md index 2e50ac7..a103036 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -1,7 +1,7 @@ --- name: midjourney-visual-workbench description: Prepare, capture, QC, select, and optionally upscale Midjourney results in Hermes Desktop without touching cookies or spending credits without current-turn approval. -version: 0.6.0 +version: 0.7.0 platforms: [macos] metadata: hermes: