fix: declare Hermes auto image support and migrate running accounts

This commit is contained in:
snowspeeder committed 2026-10-02 16:17:29 -04:00
1 parent 6b8d55fc92
commit 437745bcca
4 files changed
+189 -4

No files matched your search

+35
View File
@@ -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 key URL remains404. Do not expose the entire cert directory. Initial container-copy
profile disappeared during intervening deployment; persistent alias fixed it. 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 ## Per-account Hermes WebUI — live receipt 2026-10-02
Installed pinned nesquena/hermes-webui `62e4d7b9e2560f9d49c0c29c2dc1414fbd4e2d12` Installed pinned nesquena/hermes-webui `62e4d7b9e2560f9d49c0c29c2dc1414fbd4e2d12`
+22
View File
@@ -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 policy; host networking alone cannot guarantee that. Host networking does not
bypass LAN ACLs or configure mDNS/DNS. 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 ## Per-account Hermes WebUI
Build `hermes-webui.Dockerfile` as `hope-hermes-webui:62e4d7b`. This packages the Build `hermes-webui.Dockerfile` as `hope-hermes-webui:62e4d7b`. This packages the
+60 -3
View File
@@ -252,6 +252,40 @@ class Provisioner:
raise ProvisioningError('Invalid persisted Hermes API credential') raise ProvisioningError('Invalid persisted Hermes API credential')
return key 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): def _prepare(self, user, exists, port):
private, home, workspace = self._paths(user) private, home, workspace = self._paths(user)
self._directory(self.state_dir) self._directory(self.state_dir)
@@ -270,7 +304,7 @@ class Provisioner:
elif config.is_symlink(): elif config.is_symlink():
raise ProvisioningError('Hermes config cannot be a symlink') raise ProvisioningError('Hermes config cannot be a symlink')
else: else:
# Preserve user configuration; only control-owned listener settings change. # Preserve user configuration except managed listeners/verified auto capability.
try: try:
value = yaml.safe_load(config.read_text()) value = yaml.safe_load(config.read_text())
if not isinstance(value, dict): if not isinstance(value, dict):
@@ -278,8 +312,13 @@ class Provisioner:
server = value.setdefault('gateway', {}).setdefault('api_server', {}) server = value.setdefault('gateway', {}).setdefault('api_server', {})
if not isinstance(server, dict): if not isinstance(server, dict):
raise ValueError() raise ValueError()
changed = self._declare_auto_vision(value)
if server.get('host') != '127.0.0.1' or server.get('port') != port: if server.get('host') != '127.0.0.1' or server.get('port') != port:
server.update(host='127.0.0.1', 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)) self._write_private(config, yaml.safe_dump(value, sort_keys=False))
except (ValueError, TypeError, AttributeError, yaml.YAMLError) as exc: except (ValueError, TypeError, AttributeError, yaml.YAMLError) as exc:
raise ProvisioningError('Invalid Hermes runtime configuration') from exc raise ProvisioningError('Invalid Hermes runtime configuration') from exc
@@ -298,7 +337,7 @@ class Provisioner:
'model': {'default': model, 'provider': 'custom:hope-upstream'}, 'model': {'default': model, 'provider': 'custom:hope-upstream'},
'providers': {'hope-upstream': {'api': os.environ.get('HERMES_UPSTREAM_URL', 'http://192.168.86.5:2099/v1'), '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, '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'}, 'agent': {'reasoning_effort': 'none'},
'terminal': {'backend': 'local', 'cwd': '/workspace'}, 'terminal': {'backend': 'local', 'cwd': '/workspace'},
'platform_toolsets': {'api_server': ['terminal', 'file', 'web', 'browser', 'memory', 'skills', 'delegation']}, '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. # Never start a host-networked container with an unexpected listener.
self._connection(user, info, key) self._connection(user, info, key)
await self._remove_legacy_network(user) 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'): if not info.get('State', {}).get('Running'):
await self._request('POST', f'/containers/{info["Id"]}/start', allowed=(204, 304)) 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 # One overall deadline also bounds Docker inspect / health I/O; no
# deletion on failed health, so a retry starts/adopts the same state. # deletion on failed health, so a retry starts/adopts the same state.
try: try:
@@ -436,6 +480,10 @@ class Provisioner:
if info.get('State', {}).get('Running'): if info.get('State', {}).get('Running'):
connection = self._connection(user, info, key) connection = self._connection(user, info, key)
if await self._healthy(connection): 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 return connection
await asyncio.sleep(self.poll_interval) await asyncio.sleep(self.poll_interval)
except TimeoutError as exc: except TimeoutError as exc:
@@ -446,9 +494,18 @@ class Provisioner:
info = await self._inspect(container_name(user)) info = await self._inspect(container_name(user))
if not info or not info.get('State', {}).get('Running'): if not info or not info.get('State', {}).get('Running'):
return False 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) key = self._read_key(user)
return bool(key) and await self._healthy(self._connection(user, info, key)) return bool(key) and await self._healthy(self._connection(user, info, key))
except (ProvisioningError, ValueError, OSError): except (ProvisioningError, ValueError, OSError, yaml.YAMLError):
return False return False
async def remove(self, user: dict): async def remove(self, user: dict):
+72 -1
View File
@@ -19,6 +19,8 @@ class DockerBoundary:
def __init__(self): def __init__(self):
self.containers, self.networks, self.created, self.deleted = {}, {}, [], [] self.containers, self.networks, self.created, self.deleted = {}, {}, [], []
self.fail_start = False self.fail_start = False
self.restarted = []
self.fail_restart = False
def __call__(self, request): def __call__(self, request):
path, method = request.url.path, request.method path, method = request.url.path, request.method
@@ -51,6 +53,13 @@ class DockerBoundary:
if method == 'GET': if method == 'GET':
return httpx.Response(200, json=info) return httpx.Response(200, json=info)
if method == 'POST': 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: if self.fail_start:
self.fail_start = False self.fail_start = False
return httpx.Response(500) 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['model'] == {'default': 'auto', 'provider': 'custom:hope-upstream'}
assert config['providers']['hope-upstream'] == {'api': 'http://192.168.86.5:2099/v1', 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', '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['agent']['reasoning_effort'] == 'none'
assert config['terminal'] == {'backend': 'local', 'cwd': '/workspace'} assert config['terminal'] == {'backend': 'local', 'cwd': '/workspace'}
assert {'memory', 'browser', 'web', 'terminal', 'file', 'skills'} <= set(config['platform_toolsets']['api_server']) 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 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): def test_upstream_runtime_overrides(monkeypatch):
monkeypatch.setenv('HERMES_UPSTREAM_URL', 'http://127.0.0.1:9999/v1') monkeypatch.setenv('HERMES_UPSTREAM_URL', 'http://127.0.0.1:9999/v1')
monkeypatch.setenv('HERMES_UPSTREAM_MODEL', 'served-model') monkeypatch.setenv('HERMES_UPSTREAM_MODEL', 'served-model')