hermes(agent): reuse Codex CLI auth for auxiliaries
All checks were successful
Tests / Declarative: Post Actions passed: 233
All checks were successful
Tests / Declarative: Post Actions passed: 233
This commit is contained in:
parent
987c6e4cee
commit
5ecec6c60a
@ -24,7 +24,7 @@ spec:
|
|||||||
ai.bstein.dev/execution: Hermes Kanban with durable direct Codex and Claude Code CLI workers
|
ai.bstein.dev/execution: Hermes Kanban with durable direct Codex and Claude Code CLI workers
|
||||||
ai.bstein.dev/model-policy: Jetson-assisted AUTO routing, low through xhigh, cross-provider fallback
|
ai.bstein.dev/model-policy: Jetson-assisted AUTO routing, low through xhigh, cross-provider fallback
|
||||||
ai.bstein.dev/placement: rpi5 preferred; Jetson deferred until state storage is available
|
ai.bstein.dev/placement: rpi5 preferred; Jetson deferred until state storage is available
|
||||||
ai.bstein.dev/config-rev: "20260811-codex-cross-provider-fallback"
|
ai.bstein.dev/config-rev: "20260811-codex-cli-auxiliary"
|
||||||
vault.hashicorp.com/agent-inject: "true"
|
vault.hashicorp.com/agent-inject: "true"
|
||||||
vault.hashicorp.com/role: hermes-agent
|
vault.hashicorp.com/role: hermes-agent
|
||||||
vault.hashicorp.com/agent-inject-secret-anthropic-token: kv/data/atlas/hermes/agent-tokens
|
vault.hashicorp.com/agent-inject-secret-anthropic-token: kv/data/atlas/hermes/agent-tokens
|
||||||
@ -279,6 +279,8 @@ spec:
|
|||||||
- /patched/chat_completion_helpers.py
|
- /patched/chat_completion_helpers.py
|
||||||
- /opt/hermes/agent/conversation_loop.py
|
- /opt/hermes/agent/conversation_loop.py
|
||||||
- /patched/conversation_loop.py
|
- /patched/conversation_loop.py
|
||||||
|
- /opt/hermes/agent/auxiliary_client.py
|
||||||
|
- /patched/auxiliary_client.py
|
||||||
securityContext:
|
securityContext:
|
||||||
allowPrivilegeEscalation: false
|
allowPrivilegeEscalation: false
|
||||||
runAsUser: 10000
|
runAsUser: 10000
|
||||||
@ -424,6 +426,7 @@ spec:
|
|||||||
- {name: codex-runtime-patch, mountPath: /opt/hermes/agent/codex_runtime.py, subPath: codex_runtime.py}
|
- {name: codex-runtime-patch, mountPath: /opt/hermes/agent/codex_runtime.py, subPath: codex_runtime.py}
|
||||||
- {name: codex-runtime-patch, mountPath: /opt/hermes/agent/chat_completion_helpers.py, subPath: chat_completion_helpers.py}
|
- {name: codex-runtime-patch, mountPath: /opt/hermes/agent/chat_completion_helpers.py, subPath: chat_completion_helpers.py}
|
||||||
- {name: codex-runtime-patch, mountPath: /opt/hermes/agent/conversation_loop.py, subPath: conversation_loop.py}
|
- {name: codex-runtime-patch, mountPath: /opt/hermes/agent/conversation_loop.py, subPath: conversation_loop.py}
|
||||||
|
- {name: codex-runtime-patch, mountPath: /opt/hermes/agent/auxiliary_client.py, subPath: auxiliary_client.py}
|
||||||
- {name: tui-gateway-patch, mountPath: /opt/hermes/tui_gateway/server.py, subPath: server.py}
|
- {name: tui-gateway-patch, mountPath: /opt/hermes/tui_gateway/server.py, subPath: server.py}
|
||||||
- {name: kubeconfig, mountPath: /opt/data/home/.kube/config, subPath: config, readOnly: true}
|
- {name: kubeconfig, mountPath: /opt/data/home/.kube/config, subPath: config, readOnly: true}
|
||||||
- {name: auto-router-plugin, mountPath: /opt/data/plugins/auto-router, readOnly: true}
|
- {name: auto-router-plugin, mountPath: /opt/data/plugins/auto-router, readOnly: true}
|
||||||
@ -574,6 +577,7 @@ spec:
|
|||||||
- {name: codex-runtime-patch, mountPath: /opt/hermes/agent/codex_runtime.py, subPath: codex_runtime.py}
|
- {name: codex-runtime-patch, mountPath: /opt/hermes/agent/codex_runtime.py, subPath: codex_runtime.py}
|
||||||
- {name: codex-runtime-patch, mountPath: /opt/hermes/agent/chat_completion_helpers.py, subPath: chat_completion_helpers.py}
|
- {name: codex-runtime-patch, mountPath: /opt/hermes/agent/chat_completion_helpers.py, subPath: chat_completion_helpers.py}
|
||||||
- {name: codex-runtime-patch, mountPath: /opt/hermes/agent/conversation_loop.py, subPath: conversation_loop.py}
|
- {name: codex-runtime-patch, mountPath: /opt/hermes/agent/conversation_loop.py, subPath: conversation_loop.py}
|
||||||
|
- {name: codex-runtime-patch, mountPath: /opt/hermes/agent/auxiliary_client.py, subPath: auxiliary_client.py}
|
||||||
- {name: tui-gateway-patch, mountPath: /opt/hermes/tui_gateway/server.py, subPath: server.py}
|
- {name: tui-gateway-patch, mountPath: /opt/hermes/tui_gateway/server.py, subPath: server.py}
|
||||||
- {name: kubeconfig, mountPath: /opt/data/home/.kube/config, subPath: config, readOnly: true}
|
- {name: kubeconfig, mountPath: /opt/data/home/.kube/config, subPath: config, readOnly: true}
|
||||||
- {name: auto-router-plugin, mountPath: /opt/data/plugins/auto-router, readOnly: true}
|
- {name: auto-router-plugin, mountPath: /opt/data/plugins/auto-router, readOnly: true}
|
||||||
|
|||||||
@ -223,6 +223,50 @@ FALLBACK_CONTEXT_AFTER = '''def build_cross_provider_codex_prompt(
|
|||||||
def run_codex_app_server_turn(
|
def run_codex_app_server_turn(
|
||||||
'''
|
'''
|
||||||
|
|
||||||
|
AUXILIARY_TOKEN_BEFORE = ''' except Exception as exc:
|
||||||
|
logger.debug("Could not read Codex auth for auxiliary client: %s", exc)
|
||||||
|
return None
|
||||||
|
'''
|
||||||
|
AUXILIARY_TOKEN_AFTER = ''' except Exception as exc:
|
||||||
|
logger.debug("Could not read Hermes Codex auth for auxiliary client: %s", exc)
|
||||||
|
|
||||||
|
# Agent Hermes deliberately runs Codex through the authenticated CLI
|
||||||
|
# app-server and does not duplicate those credentials into Hermes'
|
||||||
|
# provider auth store. Auxiliary tasks still use Hermes' native Codex
|
||||||
|
# Responses adapter, so read the current CLI access token without
|
||||||
|
# copying or refreshing it here. The Codex CLI remains the sole owner
|
||||||
|
# of refresh-token rotation.
|
||||||
|
try:
|
||||||
|
codex_home = os.environ.get("CODEX_HOME", "").strip()
|
||||||
|
if not codex_home:
|
||||||
|
codex_home = str(Path.home() / ".codex")
|
||||||
|
auth_path = Path(codex_home).expanduser() / "auth.json"
|
||||||
|
payload = json.loads(auth_path.read_text(encoding="utf-8"))
|
||||||
|
tokens = payload.get("tokens") or {}
|
||||||
|
access_token = tokens.get("access_token")
|
||||||
|
if not isinstance(access_token, str) or not access_token.strip():
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Match the native expiry check above. An expired CLI token is not
|
||||||
|
# refreshed from this side channel because that would race the
|
||||||
|
# app-server's canonical refresh-token owner.
|
||||||
|
try:
|
||||||
|
import base64
|
||||||
|
jwt_payload = access_token.split(".")[1]
|
||||||
|
jwt_payload += "=" * (-len(jwt_payload) % 4)
|
||||||
|
claims = json.loads(base64.urlsafe_b64decode(jwt_payload))
|
||||||
|
expires_at = claims.get("exp", 0)
|
||||||
|
if expires_at and time.time() > expires_at:
|
||||||
|
logger.debug("Codex CLI access token is expired; skipping auxiliary route")
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return access_token.strip()
|
||||||
|
except Exception as cli_exc:
|
||||||
|
logger.debug("Could not read Codex CLI auth for auxiliary client: %s", cli_exc)
|
||||||
|
return None
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
def _replace_once(content: str, before: str, after: str, label: str) -> str:
|
def _replace_once(content: str, before: str, after: str, label: str) -> str:
|
||||||
"""Apply one exact replacement and fail closed when upstream drifts."""
|
"""Apply one exact replacement and fail closed when upstream drifts."""
|
||||||
@ -311,6 +355,21 @@ def patch_loop(source: Path, destination: Path) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def patch_auxiliary(source: Path, destination: Path) -> None:
|
||||||
|
"""Let auxiliary tasks reuse the current Codex CLI access token safely."""
|
||||||
|
content = source.read_text(encoding="utf-8")
|
||||||
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
destination.write_text(
|
||||||
|
_replace_once(
|
||||||
|
content,
|
||||||
|
AUXILIARY_TOKEN_BEFORE,
|
||||||
|
AUXILIARY_TOKEN_AFTER,
|
||||||
|
"Codex CLI auxiliary auth",
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
parser.add_argument("provider_source", type=Path)
|
parser.add_argument("provider_source", type=Path)
|
||||||
@ -323,12 +382,15 @@ def main() -> int:
|
|||||||
parser.add_argument("fallback_destination", type=Path)
|
parser.add_argument("fallback_destination", type=Path)
|
||||||
parser.add_argument("loop_source", type=Path)
|
parser.add_argument("loop_source", type=Path)
|
||||||
parser.add_argument("loop_destination", type=Path)
|
parser.add_argument("loop_destination", type=Path)
|
||||||
|
parser.add_argument("auxiliary_source", type=Path)
|
||||||
|
parser.add_argument("auxiliary_destination", type=Path)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
patch_provider(args.provider_source, args.provider_destination)
|
patch_provider(args.provider_source, args.provider_destination)
|
||||||
patch_session(args.session_source, args.session_destination)
|
patch_session(args.session_source, args.session_destination)
|
||||||
patch_turn(args.turn_source, args.turn_destination)
|
patch_turn(args.turn_source, args.turn_destination)
|
||||||
patch_fallback(args.fallback_source, args.fallback_destination)
|
patch_fallback(args.fallback_source, args.fallback_destination)
|
||||||
patch_loop(args.loop_source, args.loop_destination)
|
patch_loop(args.loop_source, args.loop_destination)
|
||||||
|
patch_auxiliary(args.auxiliary_source, args.auxiliary_destination)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -957,6 +957,70 @@ def test_codex_runtime_patch_uses_cli_and_forwards_route(tmp_path: Path):
|
|||||||
assert 'if agent.api_mode == "codex_app_server"' in loop_content
|
assert 'if agent.api_mode == "codex_app_server"' in loop_content
|
||||||
assert "build_cross_provider_codex_prompt" in loop_content
|
assert "build_cross_provider_codex_prompt" in loop_content
|
||||||
|
|
||||||
|
auxiliary = tmp_path / "auxiliary_client.py"
|
||||||
|
auxiliary.write_text(
|
||||||
|
codex_runtime_patch.AUXILIARY_TOKEN_BEFORE,
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
auxiliary_out = tmp_path / "patched/auxiliary_client.py"
|
||||||
|
codex_runtime_patch.patch_auxiliary(auxiliary, auxiliary_out)
|
||||||
|
auxiliary_content = auxiliary_out.read_text()
|
||||||
|
assert 'os.environ.get("CODEX_HOME"' in auxiliary_content
|
||||||
|
assert 'Path(codex_home).expanduser() / "auth.json"' in auxiliary_content
|
||||||
|
assert "sole owner" in auxiliary_content
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_mounts_codex_auxiliary_runtime_patch():
|
||||||
|
deployment = _agent_deployment()
|
||||||
|
pod = deployment["spec"]["template"]["spec"]
|
||||||
|
patch_init = next(
|
||||||
|
item for item in pod["initContainers"]
|
||||||
|
if item["name"] == "patch-codex-runtime"
|
||||||
|
)
|
||||||
|
assert patch_init["command"][-2:] == [
|
||||||
|
"/opt/hermes/agent/auxiliary_client.py",
|
||||||
|
"/patched/auxiliary_client.py",
|
||||||
|
]
|
||||||
|
expected_mount = {
|
||||||
|
"name": "codex-runtime-patch",
|
||||||
|
"mountPath": "/opt/hermes/agent/auxiliary_client.py",
|
||||||
|
"subPath": "auxiliary_client.py",
|
||||||
|
}
|
||||||
|
containers = {item["name"]: item for item in pod["containers"]}
|
||||||
|
for name in ("hermes", "terminal"):
|
||||||
|
assert expected_mount in containers[name]["volumeMounts"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_codex_auxiliary_patch_reads_cli_token_without_copying_it(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
codex_home = tmp_path / ".codex"
|
||||||
|
codex_home.mkdir()
|
||||||
|
(codex_home / "auth.json").write_text(
|
||||||
|
json.dumps({"tokens": {"access_token": "cli-access-token"}}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
monkeypatch.setenv("CODEX_HOME", str(codex_home))
|
||||||
|
|
||||||
|
source = tmp_path / "auxiliary_client.py"
|
||||||
|
source.write_text(
|
||||||
|
"import json, logging, os, time\n"
|
||||||
|
"from pathlib import Path\n"
|
||||||
|
"logger = logging.getLogger(__name__)\n"
|
||||||
|
"def read_token():\n"
|
||||||
|
" try:\n"
|
||||||
|
" raise RuntimeError('Hermes provider store intentionally empty')\n"
|
||||||
|
+ codex_runtime_patch.AUXILIARY_TOKEN_BEFORE,
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
destination = tmp_path / "patched/auxiliary_client.py"
|
||||||
|
codex_runtime_patch.patch_auxiliary(source, destination)
|
||||||
|
namespace: dict = {}
|
||||||
|
exec(compile(destination.read_text(), str(destination), "exec"), namespace)
|
||||||
|
|
||||||
|
assert namespace["read_token"]() == "cli-access-token"
|
||||||
|
|
||||||
|
|
||||||
def test_codex_runtime_patch_fails_closed_on_upstream_drift(tmp_path: Path):
|
def test_codex_runtime_patch_fails_closed_on_upstream_drift(tmp_path: Path):
|
||||||
source = tmp_path / "runtime_provider.py"
|
source = tmp_path / "runtime_provider.py"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user