diff --git a/QUALIFICATION.md b/QUALIFICATION.md index 0dc3c7d..d03a882 100644 --- a/QUALIFICATION.md +++ b/QUALIFICATION.md @@ -15,6 +15,41 @@ credentials; requires manual iOS profile installation + full trust for WSS. Priv key URL remains404. Do not expose the entire cert directory. Initial container-copy profile disappeared during intervening deployment; persistent alias fixed it. +## Hermes auto image input — live receipt 2026-10-02 + +Verified upstream `/v1/responses` with model `auto`, an inline PNG containing +red-left/blue-right panels, and reasoning effort `none`: HTTP200, served model +`Qwen3.8-27b`, answer `Red Blue`, zero reasoning tokens. The initial control-exec +probe was interrupted with exit137 during a control replacement; the successful +probe ran from the stable administrator Hermes container. + +The shared provisioner now declares only +`providers.hope-upstream.models.auto.supports_vision: true`. Existing managed +model lists migrate to metadata mappings, preserving other settings. A durable +private reload marker ensures restart retries survive control interruption. +No global vision override was added for other selectable models. + +Deployed control image `hope-voice-control:vision-auto-v1`, also tagged `latest`, +image `sha256:570319b3fd60c8161741a6c46cfc61f16a214383b8f8114c5692e00ca506197d`. +Startup migrated and restarted both currently deployed accounts in place: + +- `hope-hermes-admin-69101358`: ready; real authenticated gateway image request + at port20000 returned HTTP200 and `Blue, red` for a blue-left/red-right PNG. +- `hope-hermes-shane-fc44d140`: ready; real authenticated gateway image request + at port20001 returned HTTP200 and `Green, red.` for a green-left/red-right PNG. + +Both runtime capability lookups returned true for the custom provider's `auto`; +both retained configs have the declaration and no pending reload marker. Account +settings, credentials, container identities, ports and bind mounts were retained; +voice remained healthy and control health returned OK. No unrelated deployment +was changed. This qualifies gateway image input, not a browser upload workflow. + +Protected config backups/control inspect receipt are under +`/mnt/user/appdata/hope-voice/rollback/vision-20261002T201506Z`; original control is +retained stopped as `hope-control-pre-vision-20261002T201506Z`. Clean source was +staged separately; the NAS development checkout was not overwritten. Offline +account/provisioning/bootstrap/deployment rollback regression:44 passed. + ## Per-account Hermes WebUI — live receipt 2026-10-02 Installed pinned nesquena/hermes-webui `62e4d7b9e2560f9d49c0c29c2dc1414fbd4e2d12` diff --git a/control/README.md b/control/README.md index 480f423..78a4a36 100644 --- a/control/README.md +++ b/control/README.md @@ -114,6 +114,28 @@ ports. Strict outbound-only enforcement needs a separate host ingress firewall policy; host networking alone cannot guarantee that. Host networking does not bypass LAN ACLs or configure mDNS/DNS. +## Image input capability + +The managed upstream `auto` model is explicitly declared with +`providers.hope-upstream.models.auto.supports_vision: true`. This is an exact-model +capability, not a global `model.supports_vision` override: account-selected models +are not automatically declared vision-capable. Non-auto `HERMES_UPSTREAM_MODEL` +overrides require independent qualification before declaring image support. + +New accounts receive this declaration. On control startup/reconciliation, retained +accounts with an existing managed `auto` entry are migrated from a model list to a +metadata mapping, preserving other model IDs, capability metadata and settings. +Missing/custom provider declarations are not replaced with defaults. The migration +only changes the verified `auto` capability and existing managed listener settings. + +A private `config-reload-pending` marker is written before an existing config is +changed. Running owned containers restart by immutable ID to load it; new/stopped +containers load it on start. The marker clears only after authenticated gateway +and (when enabled) WebUI health pass. Failed restarts/readiness retain the marker +for retry, including across control restarts. Expect brief per-account downtime +and interruption of active runs during migration. Credentials, ports, homes and +workspaces remain unchanged. Readiness rejects accounts awaiting this migration. + ## Per-account Hermes WebUI Build `hermes-webui.Dockerfile` as `hope-hermes-webui:62e4d7b`. This packages the diff --git a/control/provisioner.py b/control/provisioner.py index cd8bea7..14b6179 100644 --- a/control/provisioner.py +++ b/control/provisioner.py @@ -252,6 +252,40 @@ class Provisioner: raise ProvisioningError('Invalid persisted Hermes API credential') return key + @staticmethod + def _declare_auto_vision(value): + """Patch only the verified managed auto model, preserving other metadata.""" + providers = value.get('providers', {}) + if not isinstance(providers, dict): + raise ValueError('Invalid provider configuration') + provider = providers.get('hope-upstream') + if provider is None: + return False + if not isinstance(provider, dict): + raise ValueError('Invalid managed provider') + models = provider.get('models', {}) + if isinstance(models, list): + if not all(isinstance(model, str) for model in models): + raise ValueError('Invalid model list') + if 'auto' not in models: + return False + models = {model: {} for model in models} + provider['models'] = models + if not isinstance(models, dict): + raise ValueError('Invalid model metadata') + if 'auto' not in models: + return False + metadata = models['auto'] + if not isinstance(metadata, dict): + raise ValueError('Invalid auto metadata') + if metadata.get('supports_vision') is True: + return False + metadata['supports_vision'] = True + return True + + def _reload_marker(self, user): + return self._paths(user)[0] / 'config-reload-pending' + def _prepare(self, user, exists, port): private, home, workspace = self._paths(user) self._directory(self.state_dir) @@ -270,7 +304,7 @@ class Provisioner: elif config.is_symlink(): raise ProvisioningError('Hermes config cannot be a symlink') else: - # Preserve user configuration; only control-owned listener settings change. + # Preserve user configuration except managed listeners/verified auto capability. try: value = yaml.safe_load(config.read_text()) if not isinstance(value, dict): @@ -278,8 +312,13 @@ class Provisioner: server = value.setdefault('gateway', {}).setdefault('api_server', {}) if not isinstance(server, dict): raise ValueError() + changed = self._declare_auto_vision(value) if server.get('host') != '127.0.0.1' or server.get('port') != port: server.update(host='127.0.0.1', port=port) + changed = True + if changed: + # Durable before writing: retry must reload even after a crash. + self._write_private(self._reload_marker(user), 'pending\n') self._write_private(config, yaml.safe_dump(value, sort_keys=False)) except (ValueError, TypeError, AttributeError, yaml.YAMLError) as exc: raise ProvisioningError('Invalid Hermes runtime configuration') from exc @@ -298,7 +337,7 @@ class Provisioner: 'model': {'default': model, 'provider': 'custom:hope-upstream'}, 'providers': {'hope-upstream': {'api': os.environ.get('HERMES_UPSTREAM_URL', 'http://192.168.86.5:2099/v1'), 'key_env': 'HERMES_UPSTREAM_API_KEY', 'transport': 'codex_responses', 'default_model': model, - 'models': [model], 'discover_models': False}}, + 'models': {'auto': {'supports_vision': True}} if model == 'auto' else [model], 'discover_models': False}}, 'agent': {'reasoning_effort': 'none'}, 'terminal': {'backend': 'local', 'cwd': '/workspace'}, 'platform_toolsets': {'api_server': ['terminal', 'file', 'web', 'browser', 'memory', 'skills', 'delegation']}, @@ -422,8 +461,13 @@ class Provisioner: # Never start a host-networked container with an unexpected listener. self._connection(user, info, key) await self._remove_legacy_network(user) + marker = self._reload_marker(user) + if marker.is_symlink(): + raise ProvisioningError('Reload marker cannot be a symlink') if not info.get('State', {}).get('Running'): await self._request('POST', f'/containers/{info["Id"]}/start', allowed=(204, 304)) + elif marker.exists(): + await self._request('POST', f'/containers/{info["Id"]}/restart', allowed=(204,)) # One overall deadline also bounds Docker inspect / health I/O; no # deletion on failed health, so a retry starts/adopts the same state. try: @@ -436,6 +480,10 @@ class Provisioner: if info.get('State', {}).get('Running'): connection = self._connection(user, info, key) if await self._healthy(connection): + if marker.exists(): + if marker.is_symlink() or marker.resolve() != self._paths(user)[0].resolve() / 'config-reload-pending': + raise ProvisioningError('Invalid reload marker path') + marker.unlink() return connection await asyncio.sleep(self.poll_interval) except TimeoutError as exc: @@ -446,9 +494,18 @@ class Provisioner: info = await self._inspect(container_name(user)) if not info or not info.get('State', {}).get('Running'): return False + marker = self._reload_marker(user) + if marker.exists() or marker.is_symlink(): + return False + config = self._paths(user)[1] / 'config.yaml' + if config.is_symlink(): + return False + value = yaml.safe_load(config.read_text()) + if not isinstance(value, dict) or self._declare_auto_vision(value): + return False key = self._read_key(user) return bool(key) and await self._healthy(self._connection(user, info, key)) - except (ProvisioningError, ValueError, OSError): + except (ProvisioningError, ValueError, OSError, yaml.YAMLError): return False async def remove(self, user: dict): diff --git a/tests/unit/test_provisioner.py b/tests/unit/test_provisioner.py index 5c9da3c..abc00b9 100644 --- a/tests/unit/test_provisioner.py +++ b/tests/unit/test_provisioner.py @@ -19,6 +19,8 @@ class DockerBoundary: def __init__(self): self.containers, self.networks, self.created, self.deleted = {}, {}, [], [] self.fail_start = False + self.restarted = [] + self.fail_restart = False def __call__(self, request): path, method = request.url.path, request.method @@ -51,6 +53,13 @@ class DockerBoundary: if method == 'GET': return httpx.Response(200, json=info) if method == 'POST': + if path.endswith('/restart'): + self.restarted.append(key) + if self.fail_restart: + self.fail_restart = False + return httpx.Response(500) + elif not path.endswith('/start'): + raise AssertionError(path) if self.fail_start: self.fail_start = False return httpx.Response(500) @@ -120,7 +129,7 @@ def test_account_isolation_and_runtime_configuration(boundary): assert config['model'] == {'default': 'auto', 'provider': 'custom:hope-upstream'} assert config['providers']['hope-upstream'] == {'api': 'http://192.168.86.5:2099/v1', 'key_env': 'HERMES_UPSTREAM_API_KEY', 'transport': 'codex_responses', 'default_model': 'auto', - 'models': ['auto'], 'discover_models': False} + 'models': {'auto': {'supports_vision': True}}, 'discover_models': False} assert config['agent']['reasoning_effort'] == 'none' assert config['terminal'] == {'backend': 'local', 'cwd': '/workspace'} assert {'memory', 'browser', 'web', 'terminal', 'file', 'skills'} <= set(config['platform_toolsets']['api_server']) @@ -209,6 +218,68 @@ def test_owned_container_cannot_rotate_missing_private_secret(boundary): assert len(docker.created) == 1 +@pytest.mark.parametrize('models', [ + ['auto', 'text-only'], + {'auto': {'supports_vision': False, 'context_window': 32768}, 'text-only': {'supports_vision': False}}, +]) +def test_auto_vision_migration_preserves_settings_and_reloads_once(boundary, models): + p, docker, _ = boundary + original = asyncio.run(p.ensure(ALICE)) + private, home, workspace = p._paths(ALICE) + config = yaml.safe_load((home / 'config.yaml').read_text()) + config['providers']['hope-upstream']['models'] = models + config['model']['default'] = 'text-only' + config['agent']['reasoning_effort'] = 'high' + config['retained'] = {'private': True} + (home / 'config.yaml').write_text(yaml.safe_dump(config)) + (workspace / 'work.txt').write_text('retained') + assert not asyncio.run(p.ready(ALICE)) + assert asyncio.run(p.ensure(ALICE)) == original + result = yaml.safe_load((home / 'config.yaml').read_text()) + expected = copy.deepcopy(config) + if isinstance(models, list): + expected['providers']['hope-upstream']['models'] = {m: {} for m in models} + expected['providers']['hope-upstream']['models']['auto']['supports_vision'] = True + assert result == expected + assert 'supports_vision' not in result['model'] + assert docker.restarted == ['id-' + container_name(ALICE)] + assert not docker.deleted and len(docker.created) == 1 + assert (workspace / 'work.txt').read_text() == 'retained' + assert not p._reload_marker(ALICE).exists() + assert asyncio.run(p.ready(ALICE)) + asyncio.run(p.ensure(ALICE)) + assert len(docker.restarted) == 1 + + +def test_failed_reload_is_retried_after_control_restart(boundary): + p, docker, _ = boundary + original = asyncio.run(p.ensure(ALICE)) + _, home, _ = p._paths(ALICE) + config = yaml.safe_load((home / 'config.yaml').read_text()) + config['providers']['hope-upstream']['models'] = ['auto'] + (home / 'config.yaml').write_text(yaml.safe_dump(config)) + docker.fail_restart = True + with pytest.raises(ProvisioningError, match='HTTP 500'): + asyncio.run(p.ensure(ALICE)) + assert p._reload_marker(ALICE).exists() + assert not asyncio.run(p.ready(ALICE)) + restarted = Provisioner(p.state_dir, docker_client=p.docker, health_client=p.health, + upstream_api_key='runtime-provider-secret', runtime_uid=p.runtime_uid, runtime_gid=p.runtime_gid) + assert asyncio.run(restarted.ensure(ALICE)) == original + assert len(docker.restarted) == 2 and not docker.deleted + assert not p._reload_marker(ALICE).exists() + + +def test_vision_patch_skips_unverified_models_and_preserves_metadata(): + for config in ({'retained': True}, {'providers': {'hope-upstream': {'models': ['text-only']}}}): + original = copy.deepcopy(config) + assert Provisioner._declare_auto_vision(config) is False + assert config == original + for models in ('auto', [None], {'auto': None}): + with pytest.raises(ValueError): + Provisioner._declare_auto_vision({'providers': {'hope-upstream': {'models': models}}}) + + def test_upstream_runtime_overrides(monkeypatch): monkeypatch.setenv('HERMES_UPSTREAM_URL', 'http://127.0.0.1:9999/v1') monkeypatch.setenv('HERMES_UPSTREAM_MODEL', 'served-model')