Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 17 additions & 15 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1896,22 +1896,12 @@ def _preserve_ctrl_enter_newline() -> bool:


def _bind_prompt_submit_keys(kb, handler) -> None:
"""Bind terminal Enter forms to the submit handler.

Enter is always submit. On POSIX we also bind c-j (LF) to submit because
some thin PTYs (docker exec, certain SSH flavors) deliver Enter as LF
instead of CR — without this, Enter appears dead on those terminals.

Exception: on Windows, WSL, SSH sessions, and Windows Terminal,
c-j is the wire encoding of Ctrl+Enter (a distinct keystroke from
plain Enter / c-m). We leave c-j unbound there so the c-j newline
handler registered separately can fire — giving the user an
Enter-involving newline keystroke without terminal settings changes.
See _preserve_ctrl_enter_newline() and issue #22379.
"""Bind terminal Enter to the submit handler.

Only 'enter' is bound here. c-j (LF) handling is done separately
so that Shift+Enter can insert newlines by default on POSIX.
"""
kb.add("enter")(handler)
if sys.platform != "win32" and not _preserve_ctrl_enter_newline():
kb.add("c-j")(handler)


def _disable_prompt_toolkit_cpr_warning(app) -> None:
Expand Down Expand Up @@ -10878,7 +10868,19 @@ def handle_enter(event):
event.app.current_buffer.reset(append_to_history=True)

_bind_prompt_submit_keys(kb, handle_enter)


# On POSIX local terminals (not WSL/SSH/WT), bind c-j based on env:
# - default: insert newline so Shift+Enter works for multiline
# - HERMES_CLI_SUBMIT_ON_LF=1: submit for thin PTYs that deliver Enter as LF
if sys.platform != "win32" and not _preserve_ctrl_enter_newline():
if os.environ.get("HERMES_CLI_SUBMIT_ON_LF") == "1":
kb.add("c-j")(handle_enter)
else:
@kb.add("c-j")
def handle_c_j_newline(event):
"""Shift+Enter inserts a newline on POSIX by default."""
event.current_buffer.insert_text("\n")

@kb.add('escape', 'enter')
def handle_alt_enter(event):
"""Alt+Enter inserts a newline for multi-line input.
Expand Down
15 changes: 15 additions & 0 deletions cron/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -407,12 +407,27 @@ def load_jobs() -> List[Dict[str, Any]]:
try:
with open(JOBS_FILE, 'r', encoding='utf-8') as f:
data = json.load(f)
# Guard against malformed shapes (bare list, null, scalar, string)
if not isinstance(data, dict):
if isinstance(data, list):
logger.warning("jobs.json is a bare list — auto-migrating to dict shape")
save_jobs(data)
return data
logger.error("Malformed jobs.json: expected dict, got %s", type(data).__name__)
return []
return data.get("jobs", [])
except json.JSONDecodeError:
# Retry with strict=False to handle bare control chars in string values
try:
with open(JOBS_FILE, 'r', encoding='utf-8') as f:
data = json.loads(f.read(), strict=False)
if not isinstance(data, dict):
if isinstance(data, list):
logger.warning("jobs.json is a bare list — auto-migrating to dict shape")
save_jobs(data)
return data
logger.error("Malformed jobs.json: expected dict, got %s", type(data).__name__)
return []
jobs = data.get("jobs", [])
if jobs:
# Auto-repair: rewrite with proper escaping
Expand Down
19 changes: 13 additions & 6 deletions hermes_cli/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -1365,12 +1365,19 @@ def _probe_apikey_provider(pname, env_vars, default_url, base_env,
if base_url_host_matches(base, "api.kimi.com") and base.rstrip("/").endswith("/coding"):
base = base.rstrip("/") + "/v1"
url = (base.rstrip("/") + "/models") if base else default_url
headers = {
"Authorization": f"Bearer {key}",
"User-Agent": _HERMES_USER_AGENT,
}
if base_url_host_matches(base, "api.kimi.com"):
headers["User-Agent"] = "claude-code/0.1.0"
# Gemini / Google AI Studio requires x-goog-api-key, not Bearer
if base_url_host_matches(base, "generativelanguage.googleapis.com"):
headers = {
"x-goog-api-key": key,
"User-Agent": _HERMES_USER_AGENT,
}
else:
headers = {
"Authorization": f"Bearer {key}",
"User-Agent": _HERMES_USER_AGENT,
}
if base_url_host_matches(base, "api.kimi.com"):
headers["User-Agent"] = "claude-code/0.1.0"
r = httpx.get(url, headers=headers, timeout=10)
if (
pname == "Alibaba/DashScope"
Expand Down
15 changes: 12 additions & 3 deletions plugins/memory/holographic/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,10 @@ def add_fact(
row = self._conn.execute(
"SELECT fact_id FROM facts WHERE content = ?", (content,)
).fetchone()
if row is None:
raise RuntimeError(
"IntegrityError on insert but no matching row found"
)
return int(row["fact_id"])

# Entity extraction and linking
Expand Down Expand Up @@ -296,9 +300,14 @@ def update_fact(
if content is not None:
self._compute_hrr_vector(fact_id, content)
# Rebuild bank for relevant category
cat = category or self._conn.execute(
"SELECT category FROM facts WHERE fact_id = ?", (fact_id,)
).fetchone()["category"]
cat = category
if cat is None:
row = self._conn.execute(
"SELECT category FROM facts WHERE fact_id = ?", (fact_id,)
).fetchone()
if row is None:
return False
cat = row["category"]
self._rebuild_bank(cat)

return True
Expand Down
94 changes: 94 additions & 0 deletions tests/cli/test_cli_c_j_submit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
"""Test c-j (LF) binding logic for POSIX thin-PTY regression #22908.

Upstream already added install_shift_enter_alias() for CSI-u terminals.
This test covers the remaining c-j binding path for default macOS Terminal
and other POSIX TTYs that deliver Shift+Enter as bare LF.
"""

import os
import sys
import unittest
from unittest.mock import MagicMock


# ── minimal KeyBindings stand-in ───────────────────────────────────────────
class MockKeyBindings:
def __init__(self):
self.bindings = {}

def add(self, *keys):
def decorator(handler):
self.bindings[keys] = handler
return handler
return decorator


# ── copy of the fixed function from cli.py ─────────────────────────────────
def _bind_prompt_submit_keys(kb, handler) -> None:
"""Bind terminal Enter to the submit handler.

Only 'enter' is bound here. c-j (LF) handling is done separately
so that Shift+Enter can insert newlines by default on POSIX.
"""
kb.add("enter")(handler)


class TestBindPromptSubmitKeys(unittest.TestCase):
def test_binds_enter_always(self):
kb = MockKeyBindings()
dummy = MagicMock()
_bind_prompt_submit_keys(kb, dummy)
self.assertIn(("enter",), kb.bindings)

def test_does_not_bind_c_j(self):
kb = MockKeyBindings()
dummy = MagicMock()
_bind_prompt_submit_keys(kb, dummy)
self.assertNotIn(("c-j",), kb.bindings)


class TestCJBindingInContext(unittest.TestCase):
"""Simulate caller-side c-j binding for local POSIX terminals."""

def _simulate_caller_binding(self, kb, submit_handler, preserve_ctrl_enter):
"""Mirror the logic added in cli.py around line 10887."""
if sys.platform != "win32" and not preserve_ctrl_enter:
if os.environ.get("HERMES_CLI_SUBMIT_ON_LF") == "1":
kb.add("c-j")(submit_handler)
else:
@kb.add("c-j")
def handle_c_j_newline(event):
event.current_buffer.insert_text("\n")

def test_default_posix_c_j_newline(self):
kb = MockKeyBindings()
submit = MagicMock()
self._simulate_caller_binding(kb, submit, preserve_ctrl_enter=False)

self.assertIn(("c-j",), kb.bindings)
mock_buffer = MagicMock()
event = MagicMock()
event.current_buffer = mock_buffer
kb.bindings[("c-j",)](event)
mock_buffer.insert_text.assert_called_once_with("\n")

def test_env_submit_on_lf(self):
with unittest.mock.patch.dict(os.environ, {"HERMES_CLI_SUBMIT_ON_LF": "1"}, clear=True):
kb = MockKeyBindings()
submit = MagicMock()
self._simulate_caller_binding(kb, submit, preserve_ctrl_enter=False)

event = MagicMock()
kb.bindings[("c-j",)](event)
submit.assert_called_once_with(event)

def test_wsl_ssh_preserved(self):
"""When _preserve_ctrl_enter_newline() is True, c-j is left alone."""
kb = MockKeyBindings()
submit = MagicMock()
self._simulate_caller_binding(kb, submit, preserve_ctrl_enter=True)
self.assertNotIn(("c-j",), kb.bindings)


if __name__ == "__main__":
unittest.main()
77 changes: 77 additions & 0 deletions tests/cron/test_cron_load_jobs_malformed.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"""Regression tests for #22569 — guard load_jobs against malformed jobs.json shapes."""

import json
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch

# cron/jobs.py uses a module-level JOBS_FILE path.
# We patch it to a temp file for isolation.


class TestLoadJobsMalformedShapes(unittest.TestCase):
"""load_jobs must not crash when jobs.json is not a dict."""

def setUp(self):
self.tmp_dir = tempfile.TemporaryDirectory()
self.jobs_file = Path(self.tmp_dir.name) / "jobs.json"

def tearDown(self):
self.tmp_dir.cleanup()

def _write_json(self, obj):
self.jobs_file.write_text(json.dumps(obj), encoding="utf-8")

def _load(self):
# Import inside test so JOBS_FILE can be patched
import importlib
import cron.jobs as jobs_mod
with patch.object(jobs_mod, "JOBS_FILE", self.jobs_file):
# ensure_dirs writes to the parent, already exists via tmp_dir
return jobs_mod.load_jobs()

def test_bare_list(self):
"""Legacy bare-list format should be auto-migrated."""
self._write_json([{"id": "j1", "name": "test"}])
result = self._load()
self.assertEqual(len(result), 1)
self.assertEqual(result[0]["id"], "j1")
# After migration, file should be rewritten as dict shape
data = json.loads(self.jobs_file.read_text(encoding="utf-8"))
self.assertIn("jobs", data)

def test_null(self):
"""null root should return empty list, not crash."""
self._write_json(None)
result = self._load()
self.assertEqual(result, [])

def test_scalar_string(self):
"""String root should return empty list, not crash."""
self._write_json("corrupted")
result = self._load()
self.assertEqual(result, [])

def test_scalar_number(self):
"""Number root should return empty list, not crash."""
self._write_json(42)
result = self._load()
self.assertEqual(result, [])

def test_normal_dict(self):
"""Normal dict shape should work as before."""
self._write_json({"jobs": [{"id": "j1"}], "updated_at": "2026-01-01T00:00:00"})
result = self._load()
self.assertEqual(len(result), 1)
self.assertEqual(result[0]["id"], "j1")

def test_empty_dict(self):
"""Empty dict should return empty list."""
self._write_json({})
result = self._load()
self.assertEqual(result, [])


if __name__ == "__main__":
unittest.main()
72 changes: 72 additions & 0 deletions tests/hermes_cli/test_gemini_doctor_probe.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"""Regression tests for #22489 — Gemini doctor probe uses x-goog-api-key."""

import os
import re
import unittest


class TestGeminiDoctorProbeHeaders(unittest.TestCase):
"""Verify _probe_apikey_provider sends x-goog-api-key for Gemini endpoints."""

def _get_doctor_source(self):
with open("/tmp/hermes-agent-fork/hermes_cli/doctor.py") as f:
return f.read()

def test_gemini_header_branch_exists(self):
"""doctor.py must contain a Gemini-specific header branch."""
src = self._get_doctor_source()
# Must check for generativelanguage.googleapis.com host
self.assertIn(
'generativelanguage.googleapis.com',
src,
"Missing Gemini host detection in doctor.py",
)
# Must use x-goog-api-key somewhere
self.assertIn(
'x-goog-api-key',
src,
"Missing x-goog-api-key header in doctor.py",
)

def test_gemini_branch_is_conditional(self):
"""The x-goog-api-key header must be conditional, not unconditional."""
src = self._get_doctor_source()
# Find the block around the Gemini check
gemini_check = 'generativelanguage.googleapis.com'
idx = src.find(gemini_check)
self.assertGreater(idx, 0, "Gemini host check not found")
# Look at surrounding 400 chars
block = src[idx - 100:idx + 300]
# Must be inside an if/else — should have both x-goog-api-key and Bearer
self.assertIn('x-goog-api-key', block)
self.assertIn('Authorization', block)
self.assertIn('Bearer', block)
# Must NOT unconditionally overwrite all headers with x-goog-api-key
# (i.e. there should be an else branch)
self.assertIn('else:', block.lower())

def test_no_bearer_sent_to_gemini(self):
"""The Gemini branch must NOT include Authorization: Bearer."""
src = self._get_doctor_source()
# Find the if block for Gemini
lines = src.splitlines()
gemini_line = next(i for i, l in enumerate(lines) if 'generativelanguage.googleapis.com' in l)
# Collect lines until the else
branch_lines = []
for i in range(gemini_line, min(gemini_line + 20, len(lines))):
if lines[i].strip().startswith('else:') or lines[i].strip().startswith('elif '):
break
branch_lines.append(lines[i])
branch = "\n".join(branch_lines)
# Gemini branch should set x-goog-api-key
self.assertIn('x-goog-api-key', branch)
# Gemini branch should NOT set Authorization/Bearer
self.assertNotIn(
'Authorization',
branch,
"Gemini branch incorrectly sends Authorization header",
)


if __name__ == "__main__":
unittest.main()
Loading