argus-hermes-bridge 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,16 @@
1
+ I downloaded the Argus iPhone beta from TestFlight. Please connect it to my existing Hermes on this Mac or Linux/VPS host. Do the setup for me and keep the steps I need to do myself short.
2
+
3
+ Use the published Argus bridge package from npm with this exact command on the host where Hermes runs:
4
+ npx --yes argus-hermes-bridge@0.1.0 setup
5
+ The command includes the installer. You do not need to ask me for a separate download or private repository access. Use exactly this package name and version. If npm cannot retrieve it, report the actual error.
6
+
7
+ 1. Inspect this host and locate the existing working Hermes installation, its Python runtime and config home. The beta supports macOS with a logged-in desktop user, or Linux with a working systemd user service manager. If Hermes runs in a container, Windows, or a restricted environment without these capabilities, explain the blocker before making changes. Use the same user account as Hermes; do not run the installer as root or reinstall Hermes.
8
+ 2. Check Node.js 22+ and Tailscale. Help me install missing prerequisites using their official instructions. I need Tailscale on my iPhone and the Hermes host, signed into the same tailnet. Let me complete sign-in, HTTPS enablement, or administrator prompts myself. Preserve existing Tailscale routes. Use private Tailscale Serve, never public Funnel or an open internet port.
9
+ 3. Inspect the package README from npm, then run the exact pinned setup command above. If a bridge already runs on this host, inspect its service before updating it and preserve existing device access. Finish active phone requests before a service restart. Pass ARGUS_HERMES_HOME, ARGUS_HERMES_SOURCE or ARGUS_HERMES_PYTHON only if auto-discovery needs the paths from this installation. Do not copy provider keys or passwords into chat, the phone, or the package. Keep the default strict phone approvals. Do not enable monitoring, automatic tool approvals, vault writes, or change my model/provider settings for setup.
10
+ 4. Resolve setup errors, then run npx --yes argus-hermes-bridge@0.1.0 doctor. On Linux, check user-service lingering and help me enable it if needed so the bridge survives logout/reboot. On a Mac the user must stay logged in and the host must stay awake; explain any macOS file-access prompt. Do not claim a successful HTTPS check proves inference or tool approvals.
11
+ 5. Issue the pairing code last, using npx --yes argus-hermes-bridge@0.1.0 pair if the setup code is close to expiry. Return its exact three-line block in one copyable code block:
12
+ ARGUS-PAIR/1
13
+ https://<actual-host>.<actual-tailnet>.ts.net:8443
14
+ <actual 12-character pairing code>
15
+ Do not invent these values. The code is single-use and expires in five minutes. Tell me to open Argus → Set up Argus, tap Paste, review the address and tap Pair this iPhone. If it expires, generate a fresh code without reinstalling.
16
+ 6. Have me ask a harmless question in Argus using text first, then optionally set up voice. Confirm I received a real Hermes reply. Before testing tools, help me check a harmless tool request with Deny and then Approve once; do not execute external actions merely to test pairing. If anything cannot be verified, say exactly what is still needed.
package/README.md CHANGED
@@ -1,3 +1,105 @@
1
- # Temporary Holding Version
1
+ # Argus beta bridge
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Connect the Argus TestFlight app to **your own existing Hermes**. Supports a
4
+ logged-in macOS desktop user or Linux with systemd user services. Containers,
5
+ Windows, and hosts without a systemd user manager are outside this beta path.
6
+
7
+ ## Start here
8
+
9
+ Open Argus → **Set up Argus** → **Copy setup prompt**. Send that prompt to
10
+ Hermes. The prompt includes the pinned npm command. Hermes can do the host setup and
11
+ return a three-line connection block for the phone. Install Tailscale on your
12
+ iPhone and sign in to the same tailnet as your Hermes host.
13
+
14
+ The companion package has no npm dependencies or install hooks. Downloading it
15
+ does not start a service; `setup` explicitly installs the bridge. It copies only
16
+ the bridge runtime into `~/.config/argus/service`, so it survives npx cache cleanup.
17
+ It does not need the private Argus repository.
18
+
19
+ ## Run with npx
20
+
21
+ On the Hermes host, using its normal user account and Node.js 22 or newer:
22
+
23
+ ```sh
24
+ npx --yes argus-hermes-bridge@0.1.0 setup
25
+ ```
26
+
27
+ The package is [argus-hermes-bridge on npm](https://www.npmjs.com/package/argus-hermes-bridge).
28
+ Use `npx --yes argus-hermes-bridge@0.1.0` followed by any command below.
29
+ A supplied tarball also works with `npx --yes --package /path/to/package.tgz argus-bridge setup`.
30
+
31
+ `setup` finds the actual Tailscale hostname, checks runtime imports, installs a
32
+ user service, and adds a private HTTPS Serve route on port 8443 to the loopback
33
+ API on port 4179. It checks the authenticated API boundary locally and through
34
+ HTTPS before issuing a five-minute, one-use code. Review any system permission
35
+ or Tailscale HTTPS sign-in prompt yourself, then rerun `setup`.
36
+
37
+ On the phone, copy the **three-line ARGUS-PAIR/1 block**, open **Set up Argus**,
38
+ tap **Paste**, review the host and tap **Pair this iPhone**.
39
+ Only tapping Pair connects the phone; pasting does not make a request. Start
40
+ with a text question to confirm a real Hermes reply, then optionally enable voice.
41
+
42
+ ## Runtime discovery and persistence
43
+
44
+ Hermes must already work. The installer checks `~/.hermes/hermes-agent` and its
45
+ virtual environments, then Hermes-managed Python under `~/.hermes/tools`. For a
46
+ custom installation, set these **paths** before the setup command:
47
+
48
+ ```sh
49
+ ARGUS_HERMES_HOME=/path/to/hermes-config \
50
+ ARGUS_HERMES_SOURCE=/path/to/hermes-agent \
51
+ ARGUS_HERMES_PYTHON=/path/to/venv/bin/python3 \
52
+ npx --yes argus-hermes-bridge@0.1.0 setup
53
+ ```
54
+
55
+ These selections persist in the service definition and `~/.config/argus/bridge.json`.
56
+ Provider credentials stay in your existing Hermes configuration. Setup does not
57
+ select a different provider, enable monitoring, or authorize phone vault writes.
58
+
59
+ - **macOS:** LaunchAgent `com.tachyon.argus.mobile` starts at user login. Keep
60
+ the user logged in and the Mac awake. Logs are
61
+ `~/.config/argus/mobile-service{,-error}.log`. A Hermes installation under
62
+ Documents may require macOS file access; the installer cannot bypass it.
63
+ - **Linux/VPS:** `systemctl --user` manages `argus-mobile.service`; logs are
64
+ available with `journalctl --user -u argus-mobile.service`. To survive logout
65
+ and reboot, check `loginctl show-user "$(id -u)" --property=Linger --value`.
66
+ If needed, an administrator can run `sudo loginctl enable-linger "$(id -u)"`.
67
+ Setup reports when this step remains. Use the same user as Hermes, not root.
68
+
69
+ Keep Tailscale running on both devices. Setup preserves other Serve routes and
70
+ refuses a conflicting 8443 route or public Funnel on that port. It never
71
+ uses Funnel or opens the API on an internet-facing address. See the official
72
+ [Tailscale Serve command](https://tailscale.com/docs/reference/tailscale-cli/serve).
73
+
74
+ ## Recovery and access
75
+
76
+ ```text
77
+ argus-bridge pair New code without reinstalling (invalidates the previous code)
78
+ argus-bridge doctor Service, runtime import and private HTTPS diagnostics
79
+ argus-bridge devices List paired devices
80
+ argus-bridge revoke ID Remove access for one listed device
81
+ ```
82
+
83
+ Run these with the same npx package prefix as setup. Transport and import checks
84
+ do not prove inference, each tool, or approval compatibility. Before beta tool
85
+ use and after every Hermes update, try a harmless tool request with **Deny** and
86
+ then **Approve once** in Argus. A blocked phone policy must be fixed on the
87
+ host; do not turn off Hermes approvals to bypass it.
88
+
89
+ To update the bridge, run `setup` with the new invitation package after finishing
90
+ active phone requests. Existing device access is retained. Updates are manual.
91
+
92
+ ## Remove the bridge
93
+
94
+ Stop the service on macOS with
95
+ `launchctl bootout gui/$(id -u)/com.tachyon.argus.mobile`, then remove
96
+ `~/Library/LaunchAgents/com.tachyon.argus.mobile.plist`. On Linux use
97
+ `systemctl --user disable --now argus-mobile.service`, remove
98
+ `~/.config/systemd/user/argus-mobile.service`, and run
99
+ `systemctl --user daemon-reload`.
100
+
101
+ If 8443 still points only to Argus, remove that dedicated route with
102
+ `tailscale serve --https=8443 off` using your installed Tailscale executable.
103
+ Do not reset the host's other Serve routes. Remove `~/.config/argus/service`
104
+ and `bridge.json` if desired. `mobile-devices.json` contains the separate
105
+ device registry; deleting it revokes all phones. Hermes itself stays installed.
package/package.json CHANGED
@@ -1,6 +1,23 @@
1
1
  {
2
2
  "name": "argus-hermes-bridge",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Private Tailscale connection between the Argus iPhone beta and your existing Hermes",
5
+ "type": "module",
6
+ "license": "UNLICENSED",
7
+ "engines": {
8
+ "node": ">=22"
9
+ },
10
+ "os": [
11
+ "darwin",
12
+ "linux"
13
+ ],
14
+ "bin": {
15
+ "argus-bridge": "scripts/argus-bridge.mjs"
16
+ },
17
+ "files": [
18
+ "scripts",
19
+ "prototypes",
20
+ "HermesSetupPrompt.txt",
21
+ "README.md"
22
+ ]
23
+ }
@@ -0,0 +1,55 @@
1
+ """Small adapters to Hermes' public model catalog and encrypted vault store.
2
+
3
+ Used by hermes-admin.py (phone model settings and Add Login) and by
4
+ hermes-worker.py (validating a per-phone model choice before a request runs).
5
+ No credential value or conversation content is returned by these operations.
6
+ """
7
+ from urllib.parse import urlsplit
8
+
9
+
10
+ def catalog(default, provider, models):
11
+ def valid(value):
12
+ return isinstance(value, str) and 0 < len(value) <= 200 and not any(ord(c) < 32 for c in value)
13
+ if not valid(default) or not valid(provider):
14
+ raise ValueError('Invalid configured model')
15
+ choices = list(dict.fromkeys([default] + [m for m in models if valid(m)]))[:120]
16
+ return {'provider': provider, 'defaultModel': default, 'models': choices}
17
+
18
+
19
+ def selected_model(default, provider, override, provider_model_ids):
20
+ """Validate a stale per-phone override before a conversation runs."""
21
+ if not override or override == default:
22
+ return default
23
+ if override not in provider_model_ids(provider):
24
+ raise ValueError('The selected model is no longer available for this configured provider')
25
+ return override
26
+
27
+
28
+ def save_login(payload, store):
29
+ if set(payload) != {'origin', 'label', 'identifier', 'password'}:
30
+ raise ValueError('Invalid login fields')
31
+ for key, limit in [('origin', 500), ('label', 80), ('identifier', 320), ('password', 4096)]:
32
+ value = payload[key]
33
+ if not isinstance(value, str) or not value or len(value) > limit or '\x00' in value:
34
+ raise ValueError('Invalid login')
35
+ origin = urlsplit(payload['origin'])
36
+ if origin.scheme != 'https' or not origin.hostname or origin.username or origin.password or origin.query or origin.fragment or origin.path not in ('', '/'):
37
+ raise ValueError('HTTPS origin required')
38
+ identifier = payload['identifier'].strip()
39
+ if not identifier or not payload['label'].strip():
40
+ raise ValueError('Login identifier and label required')
41
+ secret = {'identifier': identifier, 'identifier_type': 'email' if '@' in identifier else 'username', 'password': payload['password']}
42
+ try:
43
+ meta = store.add_item('login', payload['label'].strip(), secret, origin=payload['origin'])
44
+ return {'status': 'saved', 'handle': meta.id, 'origin': meta.origin, 'storage': 'hermes-local'}
45
+ finally:
46
+ secret.clear()
47
+ payload.clear()
48
+
49
+
50
+ def existing_vault_guard(store):
51
+ """Require existing storage and prohibit key generation even if the key disappears mid-save."""
52
+ from unittest.mock import patch
53
+ if not store._key_path.is_file() or not store._vault_path.is_file():
54
+ raise RuntimeError('Initialize the Hermes vault on the Mac before granting phone writes')
55
+ return patch('cryptography.fernet.Fernet.generate_key', side_effect=RuntimeError('Phone saves cannot provision a vault key'))
@@ -0,0 +1,164 @@
1
+ """Argus phone policy for full Hermes tools: fail closed, and ask the phone first.
2
+
3
+ A paired iPhone reaches Hermes with the Hermes host's configured toolsets. On its own,
4
+ Hermes prompts only for actions its pattern checks flag, and `approvals.mode: off`
5
+ or yolo approve everything without asking. This module refuses to run in those
6
+ states and registers a `pre_tool_call` hook that sends every tool outside a short
7
+ read-only list through Hermes's own human-approval gate, which the phone answers
8
+ with Approve once / Deny. Hook escalations skip Hermes's smart (LLM) approver, so
9
+ a person always decides. In host-selected personal monitor mode, the hook
10
+ records only tool identifiers/classifications and returns no directive; Hermes
11
+ keeps its own configured approvals and other plugin policies. Both modes refuse
12
+ bypass/unattended configurations. Anything this module cannot verify refuses.
13
+
14
+ Checked against hermes-agent main at b2360086; an installed Hermes that lacks
15
+ these interfaces refuses phone mode instead of running ungated.
16
+ """
17
+ import json
18
+ import uuid
19
+
20
+ # Tools that only read public information or manage Hermes's own turn. Reading
21
+ # local files is not here: file contents could leave the Hermes host through the model
22
+ # provider or a later web request.
23
+ READ_ONLY_TOOLS = frozenset({
24
+ 'clarify', 'todo_list', 'web_search', 'web_extract', 'x_search', 'skills_list', 'skill_view',
25
+ })
26
+
27
+ PLAIN_ACTIONS = {
28
+ 'terminal': 'run a command on your Hermes host',
29
+ 'execute_code': 'run a program on your Hermes host',
30
+ 'write_file': 'change a file on your Hermes host',
31
+ 'patch': 'change a file on your Hermes host',
32
+ 'read_file': 'read a file on your Hermes host',
33
+ 'search_files': 'search the files on your Hermes host',
34
+ 'computer_use': "control your host's screen, mouse and keyboard",
35
+ 'memory': "change what Hermes remembers",
36
+ 'session_search': 'search your past Hermes conversations',
37
+ 'cronjob_manage': 'schedule a task on your Hermes host',
38
+ 'delegate_task': 'start a helper agent',
39
+ 'send_message': 'send a message',
40
+ 'react_to_message': 'react to a message',
41
+ }
42
+
43
+
44
+ def describe(tool_name, args):
45
+ """Plain words for the approval card, followed by the exact arguments."""
46
+ name = str(tool_name)
47
+ if name in PLAIN_ACTIONS:
48
+ action = PLAIN_ACTIONS[name]
49
+ elif name.startswith('browser'):
50
+ action = 'use the web browser on your Hermes host'
51
+ else:
52
+ action = f'use the tool "{name}"'
53
+ try:
54
+ detail = json.dumps(args, ensure_ascii=False, default=str)
55
+ except (TypeError, ValueError):
56
+ detail = repr(args)
57
+ if len(detail) > 600:
58
+ detail = detail[:600] + '…'
59
+ return f'Hermes wants to {action}. Details: {detail}'
60
+
61
+
62
+ def gate(tool_name='', args=None, **_):
63
+ """`pre_tool_call` hook. Must never raise: Hermes treats a failing guard as no decision."""
64
+ try:
65
+ if tool_name in READ_ONLY_TOOLS:
66
+ return None
67
+ # A fresh rule key per call: no earlier "always" or session approval can match it.
68
+ return {'action': 'approve', 'message': describe(tool_name, args),
69
+ 'rule_key': 'argus-phone-' + uuid.uuid4().hex}
70
+ except Exception:
71
+ return {'action': 'block', 'message': 'Argus could not describe this action for approval, so it did not run.'}
72
+
73
+
74
+ def refusal():
75
+ """Why phone mode must not run right now, or None. Any doubt refuses."""
76
+ try:
77
+ from tools import approval, approval_context
78
+ from tools.terminal_tool import _get_approval_callback
79
+ mode = approval_context._get_approval_mode()
80
+ if mode not in ('manual', 'smart'):
81
+ return (f'Hermes approvals.mode is "{mode}", which approves actions without asking. '
82
+ 'Set approvals.mode to manual or smart in ~/.hermes/config.yaml.')
83
+ if approval._YOLO_MODE_FROZEN or approval.is_current_session_yolo_enabled():
84
+ return 'Hermes yolo mode is on, which approves actions without asking. Remove HERMES_YOLO_MODE from the service.'
85
+ aggregate = getattr(approval_context, '_no_user_can_answer', None)
86
+ if aggregate is not None:
87
+ unattended = aggregate()
88
+ else:
89
+ # Some Hermes releases expose the three context checks separately.
90
+ # Require all three: missing checks still fail closed below.
91
+ checks = [approval_context._is_single_query_approval_context,
92
+ approval_context._is_cron_approval_context,
93
+ approval_context._is_unattended_platform_approval_context]
94
+ unattended = any(check() for check in checks)
95
+ if unattended:
96
+ return 'This Hermes process is marked single-query, cron or unattended, so no one could answer an approval.'
97
+ if not approval_context._is_interactive_cli():
98
+ return 'Hermes is not in interactive mode, so approvals could not reach the phone.'
99
+ if _get_approval_callback() is None:
100
+ return 'No phone approval callback is registered.'
101
+ except Exception as error:
102
+ return f'Could not verify Hermes approval settings ({type(error).__name__}). This Hermes version may not match Argus.'
103
+ return None
104
+
105
+
106
+ def _hooks():
107
+ from hermes_cli import plugins
108
+ return plugins.get_plugin_manager()._hooks.setdefault('pre_tool_call', [])
109
+
110
+
111
+ class MonitorGate:
112
+ """Observe Argus's tool classification without adding an approval directive."""
113
+ def __init__(self, report):
114
+ self.report = report
115
+ self.probing = False
116
+ self.probe_seen = False
117
+
118
+ def __call__(self, tool_name='', args=None, **_):
119
+ if self.probing:
120
+ self.probe_seen = True
121
+ else:
122
+ # No argument values, command text, transcript or credentials in observations.
123
+ self.report({'tool': str(tool_name)[:80],
124
+ 'outcome': 'read_only' if tool_name in READ_ONLY_TOOLS else 'would_request_approval'})
125
+ return None
126
+
127
+
128
+ def install(mode='strict', report=None):
129
+ """Install only the host-selected mode; Hermes's approvals remain unchanged."""
130
+ if mode == 'monitor' and not callable(report):
131
+ raise ValueError('Monitor requires an observation callback')
132
+ if mode not in ('strict', 'monitor'):
133
+ raise ValueError('Unknown Argus guardrail mode')
134
+ from hermes_cli import plugins
135
+ plugins.get_plugin_manager().discover_and_load()
136
+ hook = gate if mode == 'strict' else MonitorGate(report)
137
+ hooks = _hooks()
138
+ if hook not in hooks:
139
+ hooks.append(hook)
140
+ verify(hook)
141
+ return hook
142
+
143
+
144
+ def verify(hook=gate):
145
+ from hermes_cli.plugins import _get_pre_tool_call_directive_details
146
+ if not still_installed(hook):
147
+ raise RuntimeError('Argus phone hook is not registered')
148
+ if isinstance(hook, MonitorGate):
149
+ hook.probing, hook.probe_seen = True, False
150
+ try:
151
+ _get_pre_tool_call_directive_details('terminal', {'command': 'true'})
152
+ if not hook.probe_seen:
153
+ raise RuntimeError('Hermes did not dispatch the Argus monitor')
154
+ finally:
155
+ hook.probing = False
156
+ elif _get_pre_tool_call_directive_details('terminal', {'command': 'true'}).action not in ('approve', 'block'):
157
+ raise RuntimeError('Hermes did not route a tool call through the Argus approval gate')
158
+
159
+
160
+ def still_installed(hook=gate):
161
+ try:
162
+ return hook in _hooks()
163
+ except Exception:
164
+ return False
@@ -0,0 +1,153 @@
1
+ """Bounded presentation contract; no layouts, code, styles or action authority.
2
+
3
+ Hermes answers the phone with {answer, ui}. `ui` is `argus-ui/1`: a title, up to
4
+ six label/value cards, and one question with visible reply choices. Four
5
+ optional sections extend a finished answer: `steps`, `list`, `notice` and
6
+ `sources`. Everything is plain text that the phone draws with its own styles.
7
+ The only URLs allowed anywhere are the https pages Hermes actually read with a
8
+ tool while answering (or that the spoken answer already names), so a card can
9
+ never invent a citation. Anything that breaks a bound is dropped, never repaired
10
+ into a guess, and the plain answer still arrives.
11
+ """
12
+ import json
13
+ import re
14
+ from urllib.parse import urlsplit
15
+
16
+ # Decorative SF Symbols a card may name. The Mac removes unknown names before the
17
+ # phone sees them; the phone and bridge treat any other name as invalid.
18
+ SYMBOLS = frozenset({
19
+ 'thermometer', 'cloud.sun', 'cloud.rain', 'sun.max', 'moon', 'wind', 'drop',
20
+ 'clock', 'calendar', 'bell', 'mappin', 'figure.walk', 'car', 'bus', 'tram',
21
+ 'fork.knife', 'cup.and.saucer', 'leaf', 'house', 'bed.double', 'pills', 'heart',
22
+ 'stethoscope', 'dollarsign', 'creditcard', 'phone', 'envelope', 'message',
23
+ 'person', 'person.2', 'globe', 'book', 'checkmark', 'exclamationmark.triangle',
24
+ 'info.circle', 'questionmark.circle', 'star', 'tag', 'lightbulb', 'shield',
25
+ })
26
+
27
+ PRESENTATION_PROMPT = '''For your final response, return only a JSON object with keys answer and ui.
28
+ answer is your concise plain-text spoken answer (at most 120 words). Argus reads it aloud and shows it as the lead paragraph, so put no headings, bullet lists, tables, code or links in it.
29
+ ui is {"schema":"argus-ui/1","title":"Short task-specific title","cards":[{"label":"Short label","value":"Useful factual detail","symbol":"thermometer"}],"steps":["First step","Second step"],"list":[{"title":"Option","detail":"Why it fits"}],"notice":"What was not checked live, or a caveat","sources":[{"title":"Page title","url":"https://example.org/page"}],"question":"The single next useful preference question, or null","choices":["Visible reply one","Visible reply two"]}.
30
+ Use at most 6 cards (label <=80 characters, value <=600), a title <=120, a question <=240, and at most 6 distinct choices <=160 characters. Empty cards/choices are fine.
31
+ steps, list, notice and sources are optional: leave each out unless this answer needs it. steps: up to 8 short strings in order. list: up to 8 items with a short title (<=80) and an optional detail (<=200). notice: one sentence (<=240) naming what was not checked live or a caveat. sources: up to 5 pages you actually read with a tool while answering this request, with their exact https URLs; never invent a source, and never put URLs anywhere else in ui. symbol is optional on a card and must be one of: ''' + ', '.join(sorted(SYMBOLS)) + '''.
32
+ The spoken answer should include the next question when there is one. Use unique card labels. Compose only what this actual task currently needs; preserve user choices from the conversation. Do not repeat the spoken answer in every card. Never invent live facts or completed actions. Choices are conversational preferences, not commands or confirmations: no approve, confirm, send, delete, purchase, pay, book, execute or run-command choices. Actions and approvals must use the existing clarify/approval tools, never presentation data. No Markdown fences, HTML, code, colors, layout instructions or extra keys in ui. If structure is unsuitable, ui may be null.'''
33
+
34
+ CONVERSATION_ONLY_NOTE = ' No tools are available in this connection: never include sources, and say in notice when a live fact was not checked.'
35
+
36
+ FORBIDDEN_CHOICE = re.compile(r'\b(approve|confirm|send|delete|purchase|pay|book|execute|run command)\b', re.I)
37
+ _URL = re.compile(r'https://[^\s<>"\'\]\)\}]+')
38
+
39
+
40
+ def plain(value, limit):
41
+ return isinstance(value, str) and bool(value.strip()) and len(value) <= limit and not re.search(r'[<>\x00-\x1f]', value)
42
+
43
+
44
+ def source_url(value):
45
+ """An https URL without credentials, whitespace or quote characters, or None."""
46
+ if not isinstance(value, str) or len(value) > 300 or re.search(r'[\s<>"\']', value):
47
+ return None
48
+ parts = urlsplit(value)
49
+ if parts.scheme != 'https' or not parts.hostname or parts.username or parts.password:
50
+ return None
51
+ return value
52
+
53
+
54
+ def urls_in(value, limit=20000):
55
+ """https URLs mentioned in a tool result or answer. Only a bounded prefix is scanned."""
56
+ if value is None:
57
+ return set()
58
+ if not isinstance(value, str):
59
+ try:
60
+ value = json.dumps(value, ensure_ascii=False, default=str)
61
+ except (TypeError, ValueError):
62
+ value = repr(value)
63
+ found = set()
64
+ for match in _URL.findall(value[:limit]):
65
+ url = source_url(match.rstrip('.,;:!?'))
66
+ if url:
67
+ found.add(url)
68
+ return found
69
+
70
+
71
+ def _unique_texts(values, limit, count):
72
+ return (isinstance(values, list) and 1 <= len(values) <= count
73
+ and all(plain(v, limit) for v in values) and len(set(values)) == len(values))
74
+
75
+
76
+ def valid_ui(ui):
77
+ return (isinstance(ui, dict) and set(ui) <= {'schema', 'title', 'cards', 'question', 'choices', 'steps', 'list', 'notice', 'sources'}
78
+ and ui.get('schema') == 'argus-ui/1' and plain(ui.get('title'), 120)
79
+ and isinstance(ui.get('cards'), list) and len(ui['cards']) <= 6
80
+ and all(isinstance(c, dict) and {'label', 'value'} <= set(c) <= {'label', 'value', 'symbol'}
81
+ and plain(c['label'], 80) and plain(c['value'], 600) and ('symbol' not in c or c['symbol'] in SYMBOLS) for c in ui['cards'])
82
+ and len(set(c['label'] for c in ui['cards'])) == len(ui['cards'])
83
+ and ('steps' not in ui or _unique_texts(ui['steps'], 200, 8))
84
+ and ('list' not in ui or (isinstance(ui['list'], list) and 1 <= len(ui['list']) <= 8
85
+ and all(isinstance(i, dict) and {'title'} <= set(i) <= {'title', 'detail'} and plain(i['title'], 80)
86
+ and ('detail' not in i or plain(i['detail'], 200)) for i in ui['list'])))
87
+ and ('notice' not in ui or plain(ui['notice'], 240))
88
+ and ('sources' not in ui or (isinstance(ui['sources'], list) and 1 <= len(ui['sources']) <= 5
89
+ and all(isinstance(s, dict) and set(s) == {'title', 'url'} and plain(s['title'], 80) and source_url(s['url']) for s in ui['sources'])
90
+ and len(set(s['url'] for s in ui['sources'])) == len(ui['sources'])))
91
+ and (ui.get('question') is None or plain(ui['question'], 240))
92
+ and isinstance(ui.get('choices'), list) and len(ui['choices']) <= 6
93
+ and all(plain(c, 160) and not FORBIDDEN_CHOICE.search(c) for c in ui['choices'])
94
+ and len(set(ui['choices'])) == len(ui['choices'])
95
+ and (not ui['choices'] or plain(ui.get('question'), 240)))
96
+
97
+
98
+ def prune(ui, allowed_urls=()):
99
+ """Remove what the model does not get to decide, then leave the rest to valid_ui.
100
+
101
+ Empty optional sections disappear, an unknown symbol is removed from its card,
102
+ and a source survives only when Hermes read that page (or the spoken answer
103
+ names it). Nothing else is repaired: a bound that is still broken drops the
104
+ whole ui and the plain answer stands alone.
105
+ """
106
+ if not isinstance(ui, dict):
107
+ return ui
108
+ ui = dict(ui)
109
+ for key in ('steps', 'list', 'notice', 'sources'):
110
+ if key in ui and not ui[key]:
111
+ del ui[key]
112
+ if isinstance(ui.get('cards'), list):
113
+ ui['cards'] = [{k: v for k, v in card.items() if k != 'symbol' or v in SYMBOLS} if isinstance(card, dict) else card
114
+ for card in ui['cards']]
115
+ if isinstance(ui.get('sources'), list):
116
+ allowed, kept, seen = set(allowed_urls), [], set()
117
+ for source in ui['sources']:
118
+ url = source_url(source.get('url')) if isinstance(source, dict) else None
119
+ if not url or url not in allowed or url in seen:
120
+ continue
121
+ title = source.get('title') if plain(source.get('title'), 80) else (urlsplit(url).hostname or url)[:80]
122
+ kept.append({'title': title, 'url': url})
123
+ seen.add(url)
124
+ if kept:
125
+ ui['sources'] = kept
126
+ else:
127
+ del ui['sources']
128
+ return ui
129
+
130
+
131
+ def presentation(final_response, allowed_urls=()):
132
+ """Split Hermes' final response into the spoken answer and a validated ui.
133
+
134
+ `allowed_urls` are the https pages Hermes actually read with a tool during
135
+ this request; together with any URL the spoken answer names, they are the
136
+ only sources a card may cite.
137
+ """
138
+ text = final_response.strip()
139
+ candidate = text
140
+ if text.startswith('```') and text.endswith('```'):
141
+ candidate = text.split('\n', 1)[-1].rsplit('```', 1)[0].strip()
142
+ try:
143
+ envelope = json.loads(candidate)
144
+ except (ValueError, TypeError):
145
+ envelope = None
146
+ if isinstance(envelope, dict) and isinstance(envelope.get('answer'), str) and envelope['answer'].strip():
147
+ answer = envelope['answer'].strip()
148
+ ui = envelope.get('ui')
149
+ else:
150
+ answer, ui = text, None
151
+ ui = prune(ui, set(allowed_urls) | urls_in(answer))
152
+ return {'answer': answer if len(answer) <= 4000 else answer[:3900] + '\n[Long answer shortened on iPhone.]',
153
+ 'ui': ui if valid_ui(ui) else None}
@@ -0,0 +1,7 @@
1
+ import { parentPort,workerData } from 'node:worker_threads';
2
+ import { runHermesAdminDirect } from './hermes-admin.mjs';
3
+ let run,cancelled=false;
4
+ parentPort.on('message',message=>{if(message.cancel){cancelled=true;run?.cancel();}});
5
+ try{run=runHermesAdminDirect(workerData);if(cancelled)run.cancel();const value=await run.result;parentPort.postMessage({value});}
6
+ catch(error){parentPort.postMessage({failed:true,terminationConfirmed:!run||error?.terminationConfirmed===true});}
7
+ parentPort.close();
@@ -0,0 +1,41 @@
1
+ import { Worker } from 'node:worker_threads';
2
+ import { spawn } from 'node:child_process';
3
+ import { readdirSync,existsSync } from 'node:fs';
4
+ import { homedir } from 'node:os';
5
+ import { resolve } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ const unconfirmed=(terminationConfirmed=false)=>Object.assign(new Error('Administrative outcome unconfirmed.'),{terminationConfirmed});
8
+ export function runHermesAdminDirect(input) {
9
+ const home=process.env.ARGUS_HERMES_HOME||resolve(homedir(),'.hermes');
10
+ const executable=process.env.ARGUS_HERMES_PYTHON||readdirSync(resolve(home,'tools')).filter(n=>n.startsWith('python-')).sort().reverse().map(n=>resolve(home,'tools',n,'bin/python3')).find(existsSync);
11
+ if(!executable)throw unconfirmed(true);
12
+ const child=spawn(executable,['-I',fileURLToPath(new URL('./hermes-admin.py',import.meta.url))],{stdio:['pipe','pipe','pipe']});
13
+ let output='',stopping=false,cancelRun;
14
+ const result=new Promise((accept,reject)=>{
15
+ let killTimer;
16
+ const stop=()=>{if(stopping)return;stopping=true;child.kill('SIGTERM');killTimer=setTimeout(()=>child.kill('SIGKILL'),2000);};
17
+ cancelRun=stop;
18
+ const timer=setTimeout(stop,20000);
19
+ child.stdout.on('data',chunk=>{output+=chunk;if(output.length>64000)stop();});
20
+ child.stderr.resume();
21
+ child.stdin.on('error',stop);child.on('error',stop);
22
+ // Settle only after close: cancellation is not proof that the child can no longer write.
23
+ child.on('close',code=>{clearTimeout(timer);clearTimeout(killTimer);if(stopping){reject(unconfirmed(true));return;}
24
+ try{const value=JSON.parse(output);if(code!==0||!value||value.error)throw new Error();accept(value);}catch{reject(unconfirmed(true));}});
25
+ child.stdin.end(JSON.stringify(input)+'\n');
26
+ });
27
+ return {result,cancel:()=>cancelRun(),pid:child.pid};
28
+ }
29
+ const activeHosts=new Set();
30
+ export function runHermesAdmin(input) {
31
+ if(activeHosts.size>=2)throw unconfirmed(true);
32
+ const host=new Worker(new URL('./hermes-admin-host.mjs',import.meta.url),{workerData:input});activeHosts.add(host);
33
+ let settled=false;
34
+ const result=new Promise((accept,reject)=>{
35
+ const timer=setTimeout(()=>{if(!settled)host.postMessage({cancel:true});},25000);
36
+ host.on('message',message=>{if(settled)return;settled=true;clearTimeout(timer);message.value?accept(message.value):reject(unconfirmed(message.terminationConfirmed===true));});
37
+ host.on('error',()=>{clearTimeout(timer);if(!settled){settled=true;reject(unconfirmed(false));}});
38
+ host.on('exit',()=>{activeHosts.delete(host);clearTimeout(timer);if(!settled){settled=true;reject(unconfirmed(false));}});
39
+ });
40
+ return {result,cancel:()=>host.postMessage({cancel:true})};
41
+ }
@@ -0,0 +1,40 @@
1
+ """Isolated administrative operations. Secret input uses stdin only; output is metadata only."""
2
+ import contextlib
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ import sys
7
+
8
+ wire = sys.stdout
9
+ home = Path(os.environ.get('ARGUS_HERMES_HOME', Path.home() / '.hermes')).resolve()
10
+ source = Path(os.environ.get('ARGUS_HERMES_SOURCE', home / 'hermes-agent')).resolve()
11
+ os.environ['HERMES_HOME'] = str(home)
12
+ sys.path.insert(0, str(source))
13
+ sys.path.append(str(Path(__file__).resolve().parent)) # -I omits the script folder
14
+ value = None
15
+ try:
16
+ request = json.loads(sys.stdin.readline(64001))
17
+ with contextlib.redirect_stdout(sys.stderr):
18
+ import hermes_bootstrap # the same dependency activation hermes-worker.py uses
19
+ from argus_admin import catalog, save_login, existing_vault_guard
20
+ action = request.get('action')
21
+ if action == 'save_login':
22
+ from agent.vault_store import get_vault_store
23
+ store = get_vault_store()
24
+ with existing_vault_guard(store):
25
+ value = save_login(request['login'], store)
26
+ elif action == 'catalog':
27
+ from hermes_cli.config import load_config
28
+ from hermes_cli.runtime_provider import resolve_runtime_provider
29
+ from hermes_cli.models import provider_model_ids
30
+ config = load_config()
31
+ default = config['model']['default']
32
+ runtime = resolve_runtime_provider(requested=config['model'].get('provider'), target_model=default)
33
+ value = catalog(default, runtime['provider'], provider_model_ids(runtime['provider']))
34
+ else:
35
+ raise ValueError('Unknown administrative operation')
36
+ except Exception:
37
+ # Never serialize exceptions: provider/vault failures may contain sensitive values.
38
+ value = {'error': 'Hermes administrative operation did not complete.'}
39
+ wire.write(json.dumps(value, ensure_ascii=False) + '\n')
40
+ wire.flush()