Files
fenrir/tools/codex_bridge.py

351 lines
15 KiB
Python

#!/usr/bin/env python3
"""Opt-in Codex hooks: relay completed messages to a selected Fenrir override.
No speech, model feedback, tool output, or transcript copies are produced. A
single detached follower per conversation reads the transcript supplied by
Codex and keeps at most nine completed assistant messages in memory.
"""
import collections
import fcntl
import hashlib
import json
import os
from pathlib import Path
import re
import socket
import stat
import subprocess
import sys
import tempfile
import time
MAX_MESSAGE = 1024 * 1024
MAX_LINE = 4 * MAX_MESSAGE
STATUSES = {
"SessionStart": "idle", "UserPromptSubmit": "working",
"PermissionRequest": "permission", "PostToolUse": "working",
"Stop": "done", "Interrupt": "interrupted", "SessionEnd": "closed",
}
def process_info(pid):
# The process name may itself contain spaces or parentheses.
fields = Path(f"/proc/{pid}/stat").read_text().rsplit(")", 1)[1].split()
return int(fields[1]), int(fields[4]), fields[19]
def codex_process():
pid = os.getppid()
while pid > 1:
parent, tty, started = process_info(pid)
executable = os.path.basename(os.readlink(f"/proc/{pid}/exe"))
if executable == "codex":
return {"pid": pid, "started": started, "tty": tty}
pid = parent
raise ValueError("no Codex ancestor; run this command as a Codex hook")
def private_directory():
runtimeRoot = Path(os.environ.get("XDG_RUNTIME_DIR", tempfile.gettempdir()))
directory = runtimeRoot / f"fenrir-codex-{os.getuid()}"
directory.mkdir(mode=0o700, exist_ok=True)
info = directory.lstat()
if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o077:
raise ValueError("unsafe bridge runtime directory")
return directory
def write_status(path, status):
write_json(path, {"status": status})
def write_json(path, data):
descriptor, temporary = tempfile.mkstemp(prefix=".status-", dir=path.parent)
try:
with os.fdopen(descriptor, "w") as stream:
json.dump(data, stream)
os.replace(temporary, path)
finally:
if os.path.exists(temporary):
os.unlink(temporary)
def hook(payload):
eventName = payload.get("hook_event_name")
if eventName not in STATUSES:
return
session = payload.get("session_id", "")
transcript = payload.get("transcript_path")
if not isinstance(session, str) or not session or not isinstance(transcript, str):
raise ValueError("Codex hook did not supply a session and transcript")
owner = codex_process()
window = os.environ.get("WINDOWID", "")
# Linux virtual consoles use major 4, minor 1..63. Never guess a VT from a PTY.
terminal = 1 if window else os.minor(owner["tty"])
if not window and (os.major(owner["tty"]) != 4 or not 1 <= terminal <= 63):
raise ValueError("no X11 window or Linux console identifies this session")
directory = private_directory()
key = hashlib.sha256(f"{session}:{owner['pid']}:{owner['started']}".encode()).hexdigest()
statusPath = directory / f"{key}.json"
status = STATUSES[eventName]
if eventName == "Stop":
lines = str(payload.get("last_assistant_message") or "").lstrip().splitlines()
if lines and lines[0].strip() == "I have a question.":
status = "question"
write_status(statusPath, status)
if eventName not in ("SessionStart", "UserPromptSubmit"):
return
terminalKey = hashlib.sha256(f"{window}:{terminal}".encode()).hexdigest()
activePath = directory / f"active-{terminalKey}.json"
descriptor = os.open(str(activePath) + ".lock", os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
with os.fdopen(descriptor, "w") as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
active = json.loads(activePath.read_text()) if activePath.exists() else {}
if active.get("key") != key:
write_json(activePath, {"key": key, "epoch": max(time.time_ns(), active.get("epoch", 0) + 1)})
config = dict(owner, window=window, terminal=terminal, session=session,
transcript=transcript, status_path=str(statusPath),
lock_path=str(directory / f"{key}.lock"), active_path=str(activePath), key=key)
subprocess.Popen([sys.executable, str(Path(__file__).resolve()), "--watch", json.dumps(config)],
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, start_new_session=True, close_fds=True)
def completed_message(record, offset):
if record.get("type") != "event_msg":
return None
payload = record.get("payload", {})
if payload.get("type") == "agent_message":
text = payload.get("message")
return (str(offset), clean_message(text)) if isinstance(text, str) and text.strip() else None
if payload.get("type") == "item_completed":
item = payload.get("item", {})
if item.get("type") == "AgentMessage":
text = "".join(part.get("text", "") for part in item.get("content", [])
if part.get("type") == "Text")
return (str(offset), clean_message(text)) if text.strip() else None
return None
def clean_message(text):
# Codex appends machine-readable memory attribution to some replies.
return re.sub(r"<oai-mem-citation>.*?</oai-mem-citation>", "", text, flags=re.DOTALL).strip()
def record_status(record):
if record.get("type") != "event_msg":
return None
return {"task_started": "working", "task_complete": "done",
"turn_aborted": "interrupted"}.get(record.get("payload", {}).get("type"))
def reader_sockets(config, registry=None):
if registry is None:
registry = Path(tempfile.gettempdir()) / "fenrirscreenreader-instances"
for path in registry.glob("*.json"):
try:
info = path.lstat()
if not stat.S_ISREG(info.st_mode) or info.st_uid not in (0, os.getuid()):
continue
if info.st_size > 16384:
continue
data = json.loads(path.read_text())
if time.time() - data["updated_at"] > 30:
continue
if config["window"]:
matches = (data["screen_driver"] == "ptyDriver"
and data.get("x11_window_id") == config["window"]
and info.st_uid == os.getuid())
else:
matches = data["screen_driver"] == "vcsaDriver"
if not matches:
continue
try:
os.kill(data["pid"], 0)
except PermissionError:
pass # A user may connect to the root console reader.
for name in data["socket_files"]:
# An explicit socket_file is also private. Never use the shared
# router: it may belong to another window or console reader.
if Path(name).is_absolute() and Path(name).name != "fenrirscreenreader-daemon.sock":
socketInfo = os.lstat(name)
if stat.S_ISSOCK(socketInfo.st_mode) and socketInfo.st_uid == info.st_uid:
yield name, data["pid"]
except (OSError, ValueError, KeyError, TypeError):
continue
def send_event(destination, event):
path, expectedPID = destination
encoded = ("APPLICATION CODEX " + json.dumps(event, ensure_ascii=False) + "\n").encode()
if len(encoded) > 8129:
raise ValueError("bridge packet exceeds Fenrir protocol limit")
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
connection.settimeout(0.5)
connection.connect(path)
import struct
peerPID, _, _ = struct.unpack("3i", connection.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12))
if peerPID != expectedPID:
return False
connection.sendall(encoded)
connection.shutdown(socket.SHUT_WR)
return connection.recv(1024).strip() == b""
def message_events(base, message):
identifier, text, baseline = message
# Bound JSON expansion as well as UTF-8 length (including control characters).
chunks, current, size = [], [], 0
for char in text:
charSize = len(json.dumps(char, ensure_ascii=False).encode()) - 2
if size + charSize > 5000 and current:
chunks.append("".join(current))
current, size = [], 0
current.append(char)
size += charSize
if current:
chunks.append("".join(current))
return [dict(base, kind="message", id=identifier, text=chunk, part=index,
parts=len(chunks), baseline=baseline) for index, chunk in enumerate(chunks)]
class Follower:
def __init__(self, session):
self.session = session
self.offset = 0
self.history = collections.deque(maxlen=9)
self.status = "idle"
self.verified = False
def read_available(self, stream, baseline=False):
stream.seek(self.offset)
while True:
offset = stream.tell()
line = stream.readline(MAX_LINE + 1)
if len(line) > MAX_LINE:
raise ValueError("Codex transcript record exceeds 4 MiB")
if not line or not line.endswith(b"\n"):
return # A partial JSON record is not a completed message.
self.offset = stream.tell()
record = json.loads(line)
if not self.verified:
if record.get("type") != "session_meta" or record.get("payload", {}).get("id") != self.session:
raise ValueError("Codex transcript belongs to another session")
self.verified = True
continue
message = completed_message(record, offset)
if message:
if len(message[1].encode()) > MAX_MESSAGE:
raise ValueError("Codex message exceeds 1 MiB")
self.history.append([message[0], message[1], baseline])
status = record_status(record)
if status:
if status == "done" and self.history and self.history[-1][1].splitlines()[0].strip() == "I have a question.":
status = "question"
self.status = status
def watch(config):
descriptor = os.open(config["lock_path"], os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
with os.fdopen(descriptor, "w") as lock:
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
return
follower = Follower(config["session"])
base = {key: config[key] for key in ("window", "terminal", "session")}
base["version"] = 1
active = json.loads(Path(config["active_path"]).read_text())
if active.get("key") != config["key"]:
return
base["epoch"] = active["epoch"]
statusStamp, lastSend, lastState = None, 0, None
stream = None
try:
while True:
if json.loads(Path(config["active_path"]).read_text()).get("key") != config["key"]:
break # Another conversation now owns this terminal.
alive = False
try:
alive = process_info(config["pid"])[2] == config["started"]
except OSError:
pass
if stream is None and alive:
try:
descriptor = os.open(config["transcript"], os.O_RDONLY | os.O_NOFOLLOW)
stream = os.fdopen(descriptor, "rb")
info = os.fstat(stream.fileno())
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid():
raise ValueError("unsafe Codex transcript")
follower.read_available(stream, baseline=True)
except FileNotFoundError:
pass # SessionStart may precede the first durable record.
elif stream is not None:
follower.read_available(stream)
statusPath = Path(config["status_path"])
stamp = statusPath.stat().st_mtime_ns
if stamp != statusStamp:
follower.status = json.loads(statusPath.read_text())["status"]
statusStamp = stamp
if not alive:
follower.status = "closed"
state = (follower.status, tuple((message[0], message[2]) for message in follower.history))
if state != lastState or time.monotonic() - lastSend >= 2:
for destination in reader_sockets(config):
try:
if not send_event(destination, dict(base, kind="status", status=follower.status)):
continue
success = True
for message in follower.history:
for event in message_events(base, message):
if not send_event(destination, event):
success = False
break
if not success:
break
if success:
for message in follower.history:
message[2] = True
except (OSError, ValueError):
continue
lastState, lastSend = state, time.monotonic()
if not alive or follower.status == "closed":
break
time.sleep(0.2)
finally:
if stream is not None:
stream.close()
# Keep the lock inode stable: another watcher may already have it open.
# Runtime state contains status only and is overwritten on the next hook.
def main():
if len(sys.argv) == 3 and sys.argv[1] == "--watch":
config = json.loads(sys.argv[2])
try:
watch(config)
except (OSError, ValueError, KeyError, TypeError):
write_status(Path(config["status_path"]), "error")
event = {key: config[key] for key in ("window", "terminal", "session")}
active = json.loads(Path(config["active_path"]).read_text())
if active.get("key") != config["key"]:
return
event.update(version=1, epoch=active["epoch"], kind="status", status="error")
for destination in reader_sockets(config):
try:
send_event(destination, event)
except OSError:
pass
return
try:
hook(json.load(sys.stdin))
except (OSError, ValueError, KeyError, TypeError) as error:
print(f"Fenrir Codex bridge: {error}", file=sys.stderr)
# Neutral output: no context injection, approval decisions, or continuation.
print("{}")
if __name__ == "__main__":
main()