From be1e5506e6bed136c19e524bef5e718029fa79b3 Mon Sep 17 00:00:00 2001 From: Can Celik Date: Fri, 23 Oct 2026 15:22:41 +0000 Subject: [PATCH] fix: detect hermes started by the newer installer launcher refs #5068 --- src/detect/mod.rs | 100 ++++++++++++++++-- .../hermes-installer-process-info-5068.json | 26 +++++ 2 files changed, 117 insertions(+), 9 deletions(-) create mode 100644 tests/fixtures/hermes-installer-process-info-5068.json diff --git a/src/detect/mod.rs b/src/detect/mod.rs index 380a41fbd..10f2015a8 100644 --- a/src/detect/mod.rs +++ b/src/detect/mod.rs @@ -450,23 +450,41 @@ fn hermes_installer_agent_name(argv: &[String]) -> Option { return None; } - // Recognize the captured installer bootstrap, not arbitrary Python source. - // Both root literals must agree; reject escapes or quotes rather than parse Python. + let code = code.strip_prefix("import os, re, sys\n")?; + let (expanded, code) = if code.starts_with("import ") { + (true, code.split_once('\n')?.1) + } else { + (false, code) + }; let rest = code.strip_prefix(HERMES_INSTALLER_PREFIX)?; let (root, rest) = rest.split_once("')\n")?; if root.is_empty() || root.contains(['\'', '\\', '\n', '\r']) { return None; } + if expanded { + // Recovery helpers change independently of the interactive entry point. + return (rest.ends_with(HERMES_INSTALLER_ENTRY) + && rest.lines().any(|line| { + matches!( + line, + "import hermes_bootstrap" | " import hermes_bootstrap" + ) + })) + .then(|| agent_label(Agent::Hermes).to_string()); + } let rest = rest .strip_prefix(HERMES_INSTALLER_MIDDLE)? .strip_prefix(root)?; (rest == HERMES_INSTALLER_SUFFIX).then(|| agent_label(Agent::Hermes).to_string()) } -// Installer source captured in #4910. Only the installation root varies. Unknown -// bootstrap revisions deliberately fall back to ordinary process identification. -const HERMES_INSTALLER_PREFIX: &str = "import os, re, sys -os.environ.pop('PYTHONHOME', None) +const HERMES_INSTALLER_ENTRY: &str = r"from hermes_cli.main import main +sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) +sys.exit(main()) +"; + +// Short installer source captured in #4910. Only the installation root varies. +const HERMES_INSTALLER_PREFIX: &str = "os.environ.pop('PYTHONHOME', None) os.environ.pop('PYTHONPATH', None) sys.path.insert(0, '"; const HERMES_INSTALLER_MIDDLE: &str = @@ -1432,11 +1450,13 @@ mod tests { } fn hermes_installer_capture() -> crate::platform::ForegroundJob { - // Exact reporter capture from #4910; private path components were redacted. - let capture: serde_json::Value = serde_json::from_str(include_str!( + hermes_installer_capture_from(include_str!( "../../tests/fixtures/hermes-installer-process-info-4910.json" )) - .unwrap(); + } + + fn hermes_installer_capture_from(capture: &str) -> crate::platform::ForegroundJob { + let capture: serde_json::Value = serde_json::from_str(capture).unwrap(); let info = &capture["result"]["process_info"]; crate::platform::ForegroundJob { process_group_id: info["foreground_process_group_id"].as_u64().unwrap() as u32, @@ -1471,6 +1491,68 @@ mod tests { ); } + #[test] + fn identify_agent_in_job_detects_newer_hermes_installer_capture() { + let mut job = hermes_installer_capture_from(include_str!( + "../../tests/fixtures/hermes-installer-process-info-5068.json" + )); + assert_eq!( + identify_agent_in_job(&job), + Some((Agent::Hermes, "hermes".to_string())) + ); + let process = job.processes.last_mut().unwrap(); + let argv = process.argv.as_mut().unwrap(); + argv[3] = argv[3] + .replace( + "/Users/username/.hermes/hermes-agent", + "/opt/another install/hermes-agent", + ) + .replace( + "import contextlib, hashlib, io, pathlib, shutil, subprocess", + "import contextlib, hashlib, io, pathlib, shutil, subprocess, warnings", + ); + argv.push("--resume".into()); + argv.push("session-id".into()); + assert_eq!( + identify_agent_in_job(&job), + Some((Agent::Hermes, "hermes".to_string())) + ); + } + + #[test] + fn newer_hermes_installer_rejects_non_entry_source_and_helper_modes() { + let job = hermes_installer_capture_from(include_str!( + "../../tests/fixtures/hermes-installer-process-info-5068.json" + )); + let mut argv = job.processes.last().unwrap().argv.clone().unwrap(); + let original = argv[3].clone(); + for code in [ + "print('hermes_cli.main')".to_string(), + format!("'''{original}'''"), + original.lines().map(|line| format!("# {line}\n")).collect(), + original.replace( + "from hermes_cli.main import main", + "from unrelated.main import main", + ), + original.replace("sys.exit(main())", "sys.exit(0)"), + original.replace("import hermes_bootstrap", "import unrelated_bootstrap"), + original.replace("/Users/username", r"/Users/has\escape"), + original.replace("/Users/username", "/Users/has'quote"), + ] { + argv[3] = code; + assert_eq!(hermes_installer_agent_name(&argv), None); + } + argv[3] = original; + for mode in ["--run-module", "--print-runtime-command"] { + argv.truncate(4); + argv.push(mode.into()); + assert_eq!(hermes_installer_agent_name(&argv), None); + } + argv.truncate(4); + argv[1] = "-m".into(); + assert_eq!(hermes_installer_agent_name(&argv), None); + } + #[test] fn hermes_installer_signature_allows_another_install_root() { let job = hermes_installer_capture(); diff --git a/tests/fixtures/hermes-installer-process-info-5068.json b/tests/fixtures/hermes-installer-process-info-5068.json new file mode 100644 index 000000000..e6eb2561f --- /dev/null +++ b/tests/fixtures/hermes-installer-process-info-5068.json @@ -0,0 +1,26 @@ +{ + "id": "cli:pane:process_info", + "result": { + "process_info": { + "foreground_process_group_id": 13565, + "foreground_processes": [ + { + "argv": [ + "/Users/username/.hermes/tools/python-3.14.7+20260901-darwin-arm64/bin/python3", + "-I", + "-c", + "import os, re, sys\nimport contextlib, hashlib, io, pathlib, shutil, subprocess\nos.environ.pop('PYTHONHOME', None)\nos.environ.pop('PYTHONPATH', None)\nsys.path.insert(0, '/Users/username/.hermes/hermes-agent')\ndef _hermes_closure_repair():\n import contextlib, hashlib, io, shutil, subprocess\n from pathlib import Path\n root = Path('/Users/username/.hermes/hermes-agent')\n git = root / '.git'\n try:\n if git.is_file():\n text = git.read_text(encoding='utf-8-sig').strip()\n git = root / text[7:].strip() if text.startswith('gitdir:') else git\n fields = dict(line.partition('=')[::2] for line in (git / 'hermes-update-pull').read_text(encoding='utf-8-sig').splitlines())\n except OSError:\n return\n pre = fields.get('pre', '').strip()\n if not re.fullmatch('[0-9a-f]{40}|[0-9a-f]{64}', pre):\n return\n closure = git / 'hermes-update-recovery' / pre\n files = (*('hermes_cli/_early_recovery.py', 'hermes_cli/update_lock.py', 'hermes_cli/update_custody.py'), 'hermes_cli/__init__.py')\n\n def blob_id(data):\n return hashlib.new('sha1' if len(pre) == 40 else 'sha256', b'blob %d\\0' % len(data) + data).hexdigest()\n\n def verified():\n try:\n listed = dict(line.split(' ', 1)[::-1] for line in (closure / 'MANIFEST').read_text(encoding='utf-8-sig').splitlines())\n return (set(listed) == set(files) and listed['hermes_cli/__init__.py'] == blob_id(b'')\n and all(blob_id((closure / rel).read_bytes()) == oid for rel, oid in listed.items()))\n except (OSError, ValueError):\n return False\n\n if not verified():\n exe = fields.get('git', '').strip()\n if not (os.path.isabs(exe) and os.path.isfile(exe)):\n names = ('git.exe',) if os.name == 'nt' else ('git',)\n exe = next((os.path.join(d, n) for d in os.environ.get('PATH', '').split(os.pathsep) if os.path.isabs(d)\n for n in names if os.path.isfile(os.path.join(d, n))), '')\n if not exe:\n return\n\n def git_out(*args):\n done = subprocess.run([exe, '--no-replace-objects', '-C', str(root), *args], capture_output=True, timeout=60,\n stdin=subprocess.DEVNULL, creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0))\n if done.returncode != 0:\n raise OSError(done.stderr)\n return done.stdout\n\n staging = closure.with_name('.%s.%d.launch' % (pre, os.getpid()))\n try:\n ids = {}\n for entry in git_out('ls-tree', '-z', '--full-tree', pre, '--', *('hermes_cli/_early_recovery.py', 'hermes_cli/update_lock.py', 'hermes_cli/update_custody.py')).split(b'\\0'):\n meta, _, rel = entry.decode('utf-8', 'replace').partition('\\t')\n if meta.split()[1:2] == ['blob']:\n ids[rel] = meta.split()[2]\n blobs = {'hermes_cli/__init__.py': b''}\n for rel in ('hermes_cli/_early_recovery.py', 'hermes_cli/update_lock.py', 'hermes_cli/update_custody.py'):\n blobs[rel] = git_out('cat-file', 'blob', ids[rel])\n if blob_id(blobs[rel]) != ids[rel]: # an object that inflates to bytes pre never had\n raise OSError('%s does not hash to %s' % (rel, ids[rel]))\n blobs['MANIFEST'] = ''.join('%s %s\\n' % (blob_id(data), rel) for rel, data in blobs.items()).encode('utf-8')\n for rel, data in blobs.items():\n (staging / rel).parent.mkdir(parents=True, exist_ok=True)\n (staging / rel).write_bytes(data)\n shutil.rmtree(closure, ignore_errors=True)\n os.replace(staging, closure)\n except (OSError, KeyError, subprocess.SubprocessError):\n pass\n finally:\n shutil.rmtree(staging, ignore_errors=True)\n if not verified():\n return\n for name in [n for n in sys.modules if n.split('.')[0] in ('hermes_cli', 'hermes_bootstrap', 'hermes_constants', 'pm')]:\n del sys.modules[name]\n tree = os.path.normcase(os.path.realpath(root))\n sys.path[:] = [str(closure)] + [p for p in sys.path if p and os.path.normcase(os.path.realpath(p)) != tree\n and not os.path.normcase(os.path.realpath(p)).startswith(tree + os.sep)]\n sys.dont_write_bytecode = True\n said = io.StringIO()\n restored = False\n try:\n with contextlib.redirect_stderr(said):\n from hermes_cli import _early_recovery\n restored = _early_recovery.restore_interrupted_pull(root)\n except Exception as exc:\n said.write('hermes: the checkout cannot start after an interrupted `hermes update`, and it was not '\n 'repaired: %s\\n' % (exc,))\n if restored:\n sys.stderr.write('hermes: the checkout could not start after an interrupted `hermes update`; '\n 'repaired it with the recovery code saved before the update.\\n' + said.getvalue())\n _early_recovery.relaunch_after_restore()\n sys.stderr.write(said.getvalue() or 'hermes: the checkout cannot start after an interrupted `hermes update`; '\n 'launch again once the update that owns it finishes.\\n')\n raise SystemExit(1)\nif sys.argv[1:2] == ['--print-runtime-command']:\n sys.dont_write_bytecode = True\n from pathlib import Path\n try:\n from hermes_cli import _early_recovery\n except Exception as exc:\n if _hermes_closure_repair() or not isinstance(exc, ImportError):\n raise\n _early_recovery = None # a tree without the repair: the imports below report real damage\n if _early_recovery is not None and _early_recovery.restore_interrupted_pull(Path('/Users/username/.hermes/hermes-agent')):\n _early_recovery.relaunch_after_restore()\n from hermes_constants import get_default_hermes_root\n os.environ['HERMES_HOME'] = os.environ.get('HERMES_HOME') or str(get_default_hermes_root())\n from hermes_cli._launchers import print_runtime_command\n print_runtime_command(Path('/Users/username/.hermes/hermes-agent'), sys.argv[2:])\n sys.exit(0)\nsys._hermes_pin_default_home = True\ntry:\n import hermes_bootstrap\nexcept Exception:\n _hermes_closure_repair()\n raise\nif not os.environ.get('HERMES_HOME'):\n from hermes_constants import get_default_hermes_root\n os.environ['HERMES_HOME'] = str(get_default_hermes_root())\nif sys.argv[1:2] == ['--run-module']:\n import runpy\n if len(sys.argv) < 3: sys.exit('hermes: --run-module needs a module')\n module = sys.argv.pop(2)\n del sys.argv[1]\n runpy.run_module(module, run_name='__main__', alter_sys=True)\n sys.exit(0)\nfrom hermes_cli.main import main\nsys.argv[0] = re.sub(r'(-script\\.pyw|\\.exe)?$', '', sys.argv[0])\nsys.exit(main())\n" + ], + "argv0": "python3", + "cmdline": "/Users/username/.hermes/tools/python-3.14.7+20260901-darwin-arm64/bin/python3 -I -c import os, re, sys\nimport contextlib, hashlib, io, pathlib, shutil, subprocess\nos.environ.pop('PYTHONHOME', None)\nos.environ.pop('PYTHONPATH', None)\nsys.path.insert(0, '/Users/username/.hermes/hermes-agent')\ndef _hermes_closure_repair():\n import contextlib, hashlib, io, shutil, subprocess\n from pathlib import Path\n root = Path('/Users/username/.hermes/hermes-agent')\n git = root / '.git'\n try:\n if git.is_file():\n text = git.read_text(encoding='utf-8-sig').strip()\n git = root / text[7:].strip() if text.startswith('gitdir:') else git\n fields = dict(line.partition('=')[::2] for line in (git / 'hermes-update-pull').read_text(encoding='utf-8-sig').splitlines())\n except OSError:\n return\n pre = fields.get('pre', '').strip()\n if not re.fullmatch('[0-9a-f]{40}|[0-9a-f]{64}', pre):\n return\n closure = git / 'hermes-update-recovery' / pre\n files = (*('hermes_cli/_early_recovery.py', 'hermes_cli/update_lock.py', 'hermes_cli/update_custody.py'), 'hermes_cli/__init__.py')\n\n def blob_id(data):\n return hashlib.new('sha1' if len(pre) == 40 else 'sha256', b'blob %d\\0' % len(data) + data).hexdigest()\n\n def verified():\n try:\n listed = dict(line.split(' ', 1)[::-1] for line in (closure / 'MANIFEST').read_text(encoding='utf-8-sig').splitlines())\n return (set(listed) == set(files) and listed['hermes_cli/__init__.py'] == blob_id(b'')\n and all(blob_id((closure / rel).read_bytes()) == oid for rel, oid in listed.items()))\n except (OSError, ValueError):\n return False\n\n if not verified():\n exe = fields.get('git', '').strip()\n if not (os.path.isabs(exe) and os.path.isfile(exe)):\n names = ('git.exe',) if os.name == 'nt' else ('git',)\n exe = next((os.path.join(d, n) for d in os.environ.get('PATH', '').split(os.pathsep) if os.path.isabs(d)\n for n in names if os.path.isfile(os.path.join(d, n))), '')\n if not exe:\n return\n\n def git_out(*args):\n done = subprocess.run([exe, '--no-replace-objects', '-C', str(root), *args], capture_output=True, timeout=60,\n stdin=subprocess.DEVNULL, creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0))\n if done.returncode != 0:\n raise OSError(done.stderr)\n return done.stdout\n\n staging = closure.with_name('.%s.%d.launch' % (pre, os.getpid()))\n try:\n ids = {}\n for entry in git_out('ls-tree', '-z', '--full-tree', pre, '--', *('hermes_cli/_early_recovery.py', 'hermes_cli/update_lock.py', 'hermes_cli/update_custody.py')).split(b'\\0'):\n meta, _, rel = entry.decode('utf-8', 'replace').partition('\\t')\n if meta.split()[1:2] == ['blob']:\n ids[rel] = meta.split()[2]\n blobs = {'hermes_cli/__init__.py': b''}\n for rel in ('hermes_cli/_early_recovery.py', 'hermes_cli/update_lock.py', 'hermes_cli/update_custody.py'):\n blobs[rel] = git_out('cat-file', 'blob', ids[rel])\n if blob_id(blobs[rel]) != ids[rel]: # an object that inflates to bytes pre never had\n raise OSError('%s does not hash to %s' % (rel, ids[rel]))\n blobs['MANIFEST'] = ''.join('%s %s\\n' % (blob_id(data), rel) for rel, data in blobs.items()).encode('utf-8')\n for rel, data in blobs.items():\n (staging / rel).parent.mkdir(parents=True, exist_ok=True)\n (staging / rel).write_bytes(data)\n shutil.rmtree(closure, ignore_errors=True)\n os.replace(staging, closure)\n except (OSError, KeyError, subprocess.SubprocessError):\n pass\n finally:\n shutil.rmtree(staging, ignore_errors=True)\n if not verified():\n return\n for name in [n for n in sys.modules if n.split('.')[0] in ('hermes_cli', 'hermes_bootstrap', 'hermes_constants', 'pm')]:\n del sys.modules[name]\n tree = os.path.normcase(os.path.realpath(root))\n sys.path[:] = [str(closure)] + [p for p in sys.path if p and os.path.normcase(os.path.realpath(p)) != tree\n and not os.path.normcase(os.path.realpath(p)).startswith(tree + os.sep)]\n sys.dont_write_bytecode = True\n said = io.StringIO()\n restored = False\n try:\n with contextlib.redirect_stderr(said):\n from hermes_cli import _early_recovery\n restored = _early_recovery.restore_interrupted_pull(root)\n except Exception as exc:\n said.write('hermes: the checkout cannot start after an interrupted `hermes update`, and it was not '\n 'repaired: %s\\n' % (exc,))\n if restored:\n sys.stderr.write('hermes: the checkout could not start after an interrupted `hermes update`; '\n 'repaired it with the recovery code saved before the update.\\n' + said.getvalue())\n _early_recovery.relaunch_after_restore()\n sys.stderr.write(said.getvalue() or 'hermes: the checkout cannot start after an interrupted `hermes update`; '\n 'launch again once the update that owns it finishes.\\n')\n raise SystemExit(1)\nif sys.argv[1:2] == ['--print-runtime-command']:\n sys.dont_write_bytecode = True\n from pathlib import Path\n try:\n from hermes_cli import _early_recovery\n except Exception as exc:\n if _hermes_closure_repair() or not isinstance(exc, ImportError):\n raise\n _early_recovery = None # a tree without the repair: the imports below report real damage\n if _early_recovery is not None and _early_recovery.restore_interrupted_pull(Path('/Users/username/.hermes/hermes-agent')):\n _early_recovery.relaunch_after_restore()\n from hermes_constants import get_default_hermes_root\n os.environ['HERMES_HOME'] = os.environ.get('HERMES_HOME') or str(get_default_hermes_root())\n from hermes_cli._launchers import print_runtime_command\n print_runtime_command(Path('/Users/username/.hermes/hermes-agent'), sys.argv[2:])\n sys.exit(0)\nsys._hermes_pin_default_home = True\ntry:\n import hermes_bootstrap\nexcept Exception:\n _hermes_closure_repair()\n raise\nif not os.environ.get('HERMES_HOME'):\n from hermes_constants import get_default_hermes_root\n os.environ['HERMES_HOME'] = str(get_default_hermes_root())\nif sys.argv[1:2] == ['--run-module']:\n import runpy\n if len(sys.argv) < 3: sys.exit('hermes: --run-module needs a module')\n module = sys.argv.pop(2)\n del sys.argv[1]\n runpy.run_module(module, run_name='__main__', alter_sys=True)\n sys.exit(0)\nfrom hermes_cli.main import main\nsys.argv[0] = re.sub(r'(-script\\.pyw|\\.exe)?$', '', sys.argv[0])\nsys.exit(main())\n", + "cwd": "/Users/username", + "name": "python3.14", + "pid": 13565 + } + ], + "pane_id": "wH:p38", + "shell_pid": 3742 + }, + "type": "pane_process_info" + } +}