confluence_web/agent/supervisor
Artur Mukhamadiev 3751ab26b5 deadline: configurable protocol maximum (default 900 s); book favicon
The 180 s query cap was enforced independently by the backend clamp, the
agent limits, and the container supervisor. All three now follow
CONFLUENCE_WEB_MAX_DEADLINE_SECONDS (default 900, allowed 60-3600): the
backend passes it into the container at launch, the supervisor reads it and
forwards it to the bridge, and both fall back to 900 s on invalid input. The
query timeout must not exceed it (startup fails otherwise). The supervisor
keeps a separate 180 s guard for a container that never receives a start
frame.

Add assets/book.svg as the tab icon: the backend serves assets/ and the CSP
allows same-origin images (the sanitizer still never emits <img>).
2026-09-15 13:38:44 +03:00

163 lines
5.7 KiB
Python
Executable File

#!/usr/bin/env python3
"""Immutable Linux PID-1 supervisor; host mode is development-only."""
import ctypes
import json
import os
import select
import signal
import socket
import subprocess
import sys
import time
from pathlib import Path
libc = ctypes.CDLL(None, use_errno=True)
# Adopt double-forked descendants on hosts, as well as under container PID 1.
if libc.prctl(36, 1, 0, 0, 0) != 0: # PR_SET_CHILD_SUBREAPER
sys.exit(1)
if libc.prctl(4, 0, 0, 0, 0) != 0: # PR_SET_DUMPABLE: deny same-UID proc memory/fd access
sys.exit(1)
container = os.getpid() == 1
if not container and '--dev' not in sys.argv:
sys.stderr.write('Supervisor requires container PID 1; use --dev only for host tests.\n')
sys.exit(1)
# PID 1 ignores namespace-local SIGKILL/SIGSTOP. Other signals terminate the
# run or are ignored; none can pause or extend the deadline. No exec after prctl.
for sig in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP, signal.SIGUSR1, signal.SIGUSR2):
signal.signal(sig, signal.SIG_IGN)
def max_run_seconds():
"""Protocol maximum for one query, set by the backend at container start.
Matches LIMITS.MAX_DEADLINE_MS in the bridge; invalid or absent values fall back to 900."""
raw = os.environ.get('CONFLUENCE_WEB_MAX_DEADLINE_SECONDS', '')
try:
value = int(raw)
except ValueError:
return 900
return value if 60 <= value <= 3600 else 900
MAX_RUN_SECONDS = max_run_seconds()
START_GRACE_SECONDS = 180 # a container that never receives a start frame ends here
started = time.monotonic()
deadline = started + MAX_RUN_SECONDS
parent, child = socket.socketpair()
child.set_inheritable(True)
root = Path(__file__).resolve().parent
entry = root / 'dist' / 'bridge.js'
args = ['node', str(entry)]
env = {'PATH': '/usr/local/bin:/usr/bin:/bin', 'HOME': '/home/agent', 'LANG': 'C.UTF-8',
'AGENT_SUPERVISOR_FD': str(child.fileno()),
'CONFLUENCE_WEB_MAX_DEADLINE_SECONDS': str(MAX_RUN_SECONDS)}
if '--dev' in sys.argv:
env['PATH'] = os.environ.get('PATH', env['PATH'])
env['HOME'] = os.environ.get('HOME', '/tmp')
env['AGENT_WORK_DIR'] = os.environ.get('AGENT_WORK_DIR', '/work')
bridge = subprocess.Popen(args, env=env, pass_fds=(child.fileno(),))
child.close()
parent.setblocking(False)
protected = {os.getpid(), bridge.pid}
buffer = b''
def descendants():
"""Never signal unrelated host processes. Adopted children retain this ancestry."""
parents = {}
for item in Path('/proc').iterdir():
if item.name.isdecimal():
try:
fields = (item / 'stat').read_text().rsplit(')', 1)[1].split()
parents[int(item.name)] = int(fields[1])
except (OSError, ValueError, IndexError):
pass
owned = {os.getpid()}
while True:
extra = {pid for pid, ppid in parents.items() if ppid in owned} - owned
if not extra:
break
owned.update(extra)
return owned - protected
def reap():
# Re-scan until every descendant is gone, including children forked during
# termination. Reap only adopted children, leaving Popen to reap the bridge.
while time.monotonic() < deadline:
victims = descendants()
if not victims:
return
for pid in victims:
try:
os.kill(pid, signal.SIGKILL)
except ProcessLookupError:
pass
try:
os.waitpid(pid, os.WNOHANG)
except ChildProcessError:
pass
time.sleep(0.01)
raise TimeoutError()
code = 1
try:
deadline_set = False
collecting = False
while time.monotonic() < deadline:
if not deadline_set and time.monotonic() >= started + START_GRACE_SECONDS:
deadline = time.monotonic() # missing start frame: treat as expired
break
if bridge.poll() is not None:
code = bridge.returncode
break
readable, _, _ = select.select([parent], [], [], min(0.05, max(0, deadline - time.monotonic())))
if not readable:
continue
data = parent.recv(4096)
if not data:
try:
code = bridge.wait(timeout=min(1, max(0.01, deadline - time.monotonic())))
except subprocess.TimeoutExpired:
pass
break
buffer += data
if len(buffer) > 8192:
break
while b'\n' in buffer:
line, buffer = buffer.split(b'\n', 1)
msg = json.loads(line)
if not isinstance(msg, dict):
raise ValueError()
if msg.get('type') == 'set_deadline' and not deadline_set:
ms = msg.get('remaining_ms')
if type(ms) is not int or not 1 <= ms <= MAX_RUN_SECONDS * 1000:
raise ValueError()
deadline = min(deadline, time.monotonic() + ms / 1000)
deadline_set = True
elif msg == {'type': 'reap_descendants'} and not collecting:
reap()
collecting = True
parent.sendall(b'{"type":"reaped"}\n')
else:
raise ValueError()
except (OSError, ValueError, TimeoutError):
pass
finally:
if container and time.monotonic() >= deadline:
sys.exit(1) # Kernel destroys the namespace; do not add cleanup time.
# Killing PID 1 ends the container namespace. Host development cleanup is
# still restricted to descendants of this supervisor.
protected.discard(bridge.pid)
deadline = max(deadline, time.monotonic() + 1)
try:
reap()
except (OSError, TimeoutError):
pass
try:
bridge.wait(timeout=1)
except subprocess.TimeoutExpired:
bridge.kill()
parent.close()
sys.exit(code if code >= 0 else 1)