Refresh gateway runtime source import

This commit is contained in:
HumbleTurtle
2026-05-03 11:27:56 -03:00
parent 02fcf45b47
commit 58d12aad13
14 changed files with 3951 additions and 382 deletions
+11
View File
@@ -0,0 +1,11 @@
__pycache__/
*.py[cod]
.pytest_cache/
.mypy_cache/
.ruff_cache/
.venv/
venv/
dist/
build/
*.egg-info/
.napseer/
+13
View File
@@ -0,0 +1,13 @@
{
"name": "gateway",
"source": "backend-split-import",
"contract_version": "2026-05-02",
"owned_surface": [
"gateway service runtime",
"gateway relay listener",
"terminal runtime",
"gateway vault",
"gateway Docker image"
],
"migration_status": "initial source refresh from backend resources/scripts"
}
+20
View File
@@ -0,0 +1,20 @@
# Napseer Gateway
Gateway runtime source for Napseer.
This repository is intended to become the canonical source for:
- Gateway service runtime.
- Local terminal/chat execution managers.
- Relay listener behavior.
- Gateway vault and passphrase handling.
- Gateway Docker image and gateway runtime tests.
Current state:
- `resources/scripts/napseer_mcp_server.py` is still the pre-split runtime script copied from the backend.
- `resources/scripts/terminal/` contains the terminal runtime helpers.
- `scripts/` contains gateway-focused smoke and runtime tests.
- `Dockerfile` installs the published `nap` command and runs gateway service mode.
The backend remains the protocol and compatibility source of truth through `/v1/gateway-protocol`.
File diff suppressed because one or more lines are too long
+53
View File
@@ -0,0 +1,53 @@
"""Isolated terminal primitives for the gateway remote terminal path."""
from .protocol import (
TERMINAL_CLOSE,
TERMINAL_DATA,
TERMINAL_DETACH,
TERMINAL_ERROR,
TERMINAL_OPEN,
TERMINAL_OPENED,
TERMINAL_OUTPUT,
TERMINAL_REPLAY_GAP,
TERMINAL_RESIZE,
)
from .pty_manager import (
AttachResult,
OutputChunk,
PtySessionManager,
TerminalInfo,
TerminalSession,
close,
default_manager,
list,
open,
resize,
subscribe,
write,
attach,
)
__all__ = [
"TERMINAL_CLOSE",
"TERMINAL_DATA",
"TERMINAL_DETACH",
"TERMINAL_ERROR",
"TERMINAL_OPEN",
"TERMINAL_OPENED",
"TERMINAL_OUTPUT",
"TERMINAL_REPLAY_GAP",
"TERMINAL_RESIZE",
"AttachResult",
"OutputChunk",
"PtySessionManager",
"TerminalInfo",
"TerminalSession",
"attach",
"close",
"default_manager",
"list",
"open",
"resize",
"subscribe",
"write",
]
+97
View File
@@ -0,0 +1,97 @@
"""Terminal protocol constants and small frame helpers.
The relay adapter will encrypt these payloads before sending them over the
gateway relay. Keep this module content-only: no terminal output logging and no
network side effects.
"""
from __future__ import annotations
import base64
from datetime import datetime, timezone
from typing import Any
TERMINAL_OPEN = "terminal.open"
TERMINAL_OPENED = "terminal.opened"
TERMINAL_DATA = "terminal.data"
TERMINAL_OUTPUT = "terminal.output"
TERMINAL_RESIZE = "terminal.resize"
TERMINAL_DETACH = "terminal.detach"
TERMINAL_CLOSE = "terminal.close"
TERMINAL_REPLAY_GAP = "terminal.replay_gap"
TERMINAL_ERROR = "terminal.error"
def utc_timestamp() -> str:
return datetime.now(timezone.utc).isoformat()
def encode_bytes(data: bytes) -> str:
return base64.b64encode(data).decode("ascii")
def decode_bytes(value: str) -> bytes:
return base64.b64decode(value.encode("ascii"), validate=True)
def output_frame(terminal_id: str, seq: int, data: bytes) -> dict[str, Any]:
return {
"type": TERMINAL_OUTPUT,
"terminal_id": terminal_id,
"seq": seq,
"data_b64": encode_bytes(data),
"gateway_output_sent_at": utc_timestamp(),
}
def opened_frame(
terminal_id: str,
*,
rows: int,
cols: int,
output_seq: int = 0,
attached: bool = True,
pid: int | None = None,
) -> dict[str, Any]:
frame: dict[str, Any] = {
"type": TERMINAL_OPENED,
"terminal_id": terminal_id,
"rows": rows,
"cols": cols,
"output_seq": output_seq,
"attached": attached,
}
if pid is not None:
frame["pid"] = pid
return frame
def replay_gap_frame(
terminal_id: str,
*,
requested_after_seq: int | None,
oldest_seq: int | None,
newest_seq: int,
) -> dict[str, Any]:
return {
"type": TERMINAL_REPLAY_GAP,
"terminal_id": terminal_id,
"requested_after_seq": requested_after_seq,
"oldest_seq": oldest_seq,
"newest_seq": newest_seq,
}
def error_frame(
terminal_id: str | None,
code: str,
message: str,
) -> dict[str, Any]:
frame: dict[str, Any] = {
"type": TERMINAL_ERROR,
"code": code,
"message": message,
}
if terminal_id:
frame["terminal_id"] = terminal_id
return frame
+426
View File
@@ -0,0 +1,426 @@
"""Stdlib PTY session manager for the gateway terminal vertical slice."""
from __future__ import annotations
import errno
import fcntl
import os
import select
import shlex
import signal
import struct
import subprocess
import termios
import threading
import time
import uuid
from collections import deque
from dataclasses import dataclass
from typing import Callable, Deque, Iterable
OutputCallback = Callable[[str, int, bytes], None]
UnsubscribeCallback = Callable[[], None]
@dataclass(frozen=True)
class OutputChunk:
seq: int
data: bytes
ts: float
@dataclass(frozen=True)
class AttachResult:
terminal_id: str
chunks: tuple[OutputChunk, ...]
replay_gap: bool
requested_after_seq: int | None
oldest_seq: int | None
newest_seq: int
@dataclass(frozen=True)
class TerminalInfo:
terminal_id: str
pid: int
command: tuple[str, ...]
cwd: str
rows: int
cols: int
output_seq: int
oldest_output_seq: int | None
created_at: float
last_activity: float
closed: bool
exit_code: int | None
class TerminalSession:
def __init__(
self,
*,
terminal_id: str,
process: subprocess.Popen[bytes],
master_fd: int,
command: tuple[str, ...],
cwd: str,
rows: int,
cols: int,
ring_chunks: int,
output_callback: OutputCallback | None,
) -> None:
self.terminal_id = terminal_id
self.process = process
self.master_fd = master_fd
self.command = command
self.cwd = cwd
self.rows = rows
self.cols = cols
self.created_at = time.time()
self.last_activity = self.created_at
self.output_seq = 0
self.last_input_seq: int | None = None
self.closed = False
self.exit_code: int | None = None
self._ring: Deque[OutputChunk] = deque(maxlen=ring_chunks)
self._callbacks: dict[str, OutputCallback] = {}
self._lock = threading.RLock()
self._stop = threading.Event()
self._output_callback = output_callback
self._reader = threading.Thread(
target=self._read_loop,
name=f"napseer-terminal-{terminal_id}",
daemon=True,
)
self._reader.start()
def write(self, data: bytes, input_seq: int | None = None) -> bool:
if not isinstance(data, bytes):
raise TypeError("terminal input data must be bytes")
if not data:
return False
with self._lock:
self._ensure_open()
if input_seq is not None:
if self.last_input_seq is not None and input_seq <= self.last_input_seq:
return False
self.last_input_seq = input_seq
self.last_activity = time.time()
fd = self.master_fd
self._write_all(fd, data)
return True
def resize(self, rows: int, cols: int) -> None:
rows, cols = _normalize_size(rows, cols)
with self._lock:
self._ensure_open()
self.rows = rows
self.cols = cols
self.last_activity = time.time()
fd = self.master_fd
_set_winsize(fd, rows, cols)
try:
os.killpg(os.getpgid(self.process.pid), signal.SIGWINCH)
except ProcessLookupError:
pass
def attach(self, last_output_seq: int | None = None) -> AttachResult:
with self._lock:
chunks = tuple(self._ring)
newest_seq = self.output_seq
oldest_seq = chunks[0].seq if chunks else None
replay_gap = False
if last_output_seq is None:
selected = chunks
elif oldest_seq is not None and last_output_seq < oldest_seq - 1:
selected = chunks
replay_gap = True
else:
selected = tuple(chunk for chunk in chunks if chunk.seq > last_output_seq)
self.last_activity = time.time()
return AttachResult(
terminal_id=self.terminal_id,
chunks=selected,
replay_gap=replay_gap,
requested_after_seq=last_output_seq,
oldest_seq=oldest_seq,
newest_seq=newest_seq,
)
def close(self) -> None:
process: subprocess.Popen[bytes]
fd: int
with self._lock:
if self.closed:
return
self.closed = True
self.last_activity = time.time()
process = self.process
fd = self.master_fd
self._stop.set()
try:
os.close(fd)
except OSError:
pass
if process.poll() is None:
try:
os.killpg(os.getpgid(process.pid), signal.SIGTERM)
except ProcessLookupError:
pass
try:
process.wait(timeout=2.0)
except subprocess.TimeoutExpired:
try:
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
except ProcessLookupError:
pass
process.wait(timeout=2.0)
with self._lock:
self.exit_code = process.poll()
def info(self) -> TerminalInfo:
with self._lock:
oldest_seq = self._ring[0].seq if self._ring else None
exit_code = self.process.poll()
if exit_code is not None:
self.exit_code = exit_code
return TerminalInfo(
terminal_id=self.terminal_id,
pid=self.process.pid,
command=self.command,
cwd=self.cwd,
rows=self.rows,
cols=self.cols,
output_seq=self.output_seq,
oldest_output_seq=oldest_seq,
created_at=self.created_at,
last_activity=self.last_activity,
closed=self.closed,
exit_code=self.exit_code,
)
def _read_loop(self) -> None:
while not self._stop.is_set():
try:
ready, _, _ = select.select([self.master_fd], [], [], 0.05)
except (OSError, ValueError):
break
if not ready:
if self.process.poll() is not None:
break
continue
try:
data = os.read(self.master_fd, 8192)
except OSError as exc:
if exc.errno in (errno.EIO, errno.EBADF):
break
continue
if not data:
break
with self._lock:
self.output_seq += 1
seq = self.output_seq
chunk = OutputChunk(seq=seq, data=data, ts=time.time())
self._ring.append(chunk)
self.last_activity = chunk.ts
callback = self._output_callback
callbacks = tuple(self._callbacks.values())
if callback is not None:
callback(self.terminal_id, seq, data)
for listener in callbacks:
listener(self.terminal_id, seq, data)
with self._lock:
self.exit_code = self.process.poll()
self.closed = True
self._stop.set()
def _ensure_open(self) -> None:
if self.closed or self.process.poll() is not None:
self.closed = True
self.exit_code = self.process.poll()
raise RuntimeError(f"terminal {self.terminal_id} is closed")
def subscribe(self, callback: OutputCallback) -> UnsubscribeCallback:
subscription_id = uuid.uuid4().hex
with self._lock:
self._callbacks[subscription_id] = callback
def unsubscribe() -> None:
with self._lock:
self._callbacks.pop(subscription_id, None)
return unsubscribe
@staticmethod
def _write_all(fd: int, data: bytes) -> None:
view = memoryview(data)
while view:
try:
written = os.write(fd, view)
except BlockingIOError:
select.select([], [fd], [], 1.0)
continue
view = view[written:]
class PtySessionManager:
def __init__(
self,
*,
ring_chunks: int = 512,
output_callback: OutputCallback | None = None,
) -> None:
if ring_chunks < 1:
raise ValueError("ring_chunks must be positive")
self._ring_chunks = ring_chunks
self._output_callback = output_callback
self._sessions: dict[str, TerminalSession] = {}
self._lock = threading.RLock()
def open(
self,
command: str | Iterable[str] | None = None,
cwd: str | None = None,
rows: int = 24,
cols: int = 80,
) -> TerminalInfo:
rows, cols = _normalize_size(rows, cols)
command_tuple = _normalize_command(command)
cwd_path = os.path.abspath(cwd or os.getcwd())
if not os.path.isdir(cwd_path):
raise FileNotFoundError(f"terminal cwd does not exist: {cwd_path}")
master_fd, slave_fd = os.openpty()
try:
_set_winsize(slave_fd, rows, cols)
process = subprocess.Popen(
command_tuple,
stdin=slave_fd,
stdout=slave_fd,
stderr=slave_fd,
cwd=cwd_path,
close_fds=True,
start_new_session=True,
)
finally:
os.close(slave_fd)
_set_nonblocking(master_fd)
terminal_id = f"term_{uuid.uuid4().hex}"
session = TerminalSession(
terminal_id=terminal_id,
process=process,
master_fd=master_fd,
command=command_tuple,
cwd=cwd_path,
rows=rows,
cols=cols,
ring_chunks=self._ring_chunks,
output_callback=self._output_callback,
)
with self._lock:
self._sessions[terminal_id] = session
return session.info()
def write(self, terminal_id: str, data: bytes, input_seq: int | None = None) -> bool:
return self._get(terminal_id).write(data, input_seq=input_seq)
def resize(self, terminal_id: str, rows: int, cols: int) -> TerminalInfo:
session = self._get(terminal_id)
session.resize(rows, cols)
return session.info()
def attach(
self,
terminal_id: str,
last_output_seq: int | None = None,
) -> AttachResult:
return self._get(terminal_id).attach(last_output_seq=last_output_seq)
def subscribe(self, terminal_id: str, callback: OutputCallback) -> UnsubscribeCallback:
return self._get(terminal_id).subscribe(callback)
def close(self, terminal_id: str) -> None:
session = self._get(terminal_id)
session.close()
with self._lock:
self._sessions.pop(terminal_id, None)
def list(self) -> list[TerminalInfo]:
with self._lock:
sessions = tuple(self._sessions.values())
return [session.info() for session in sessions]
def _get(self, terminal_id: str) -> TerminalSession:
with self._lock:
session = self._sessions.get(terminal_id)
if session is None:
raise KeyError(f"unknown terminal: {terminal_id}")
return session
def _normalize_command(command: str | Iterable[str] | None) -> tuple[str, ...]:
if command is None:
shell = os.environ.get("SHELL") or "/bin/sh"
return (shell,)
if isinstance(command, str):
return tuple(shlex.split(command))
command_tuple = tuple(command)
if not command_tuple:
raise ValueError("terminal command cannot be empty")
return command_tuple
def _normalize_size(rows: int, cols: int) -> tuple[int, int]:
rows = int(rows)
cols = int(cols)
if rows < 1 or cols < 1:
raise ValueError("terminal rows and cols must be positive")
return rows, cols
def _set_winsize(fd: int, rows: int, cols: int) -> None:
winsize = struct.pack("HHHH", rows, cols, 0, 0)
fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize)
def _set_nonblocking(fd: int) -> None:
flags = fcntl.fcntl(fd, fcntl.F_GETFL)
fcntl.fcntl(fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
default_manager = PtySessionManager()
def open(
command: str | Iterable[str] | None = None,
cwd: str | None = None,
rows: int = 24,
cols: int = 80,
) -> TerminalInfo:
return default_manager.open(command=command, cwd=cwd, rows=rows, cols=cols)
def write(terminal_id: str, data: bytes, input_seq: int | None = None) -> bool:
return default_manager.write(terminal_id, data, input_seq=input_seq)
def resize(terminal_id: str, rows: int, cols: int) -> TerminalInfo:
return default_manager.resize(terminal_id, rows, cols)
def attach(terminal_id: str, last_output_seq: int | None = None) -> AttachResult:
return default_manager.attach(terminal_id, last_output_seq=last_output_seq)
def subscribe(terminal_id: str, callback: OutputCallback) -> UnsubscribeCallback:
return default_manager.subscribe(terminal_id, callback)
def close(terminal_id: str) -> None:
default_manager.close(terminal_id)
def list() -> list[TerminalInfo]:
return default_manager.list()
+372
View File
@@ -0,0 +1,372 @@
#!/usr/bin/env python3
import base64
import json
import os
import secrets
import socket
import struct
import subprocess
import tempfile
import threading
import time
import urllib.parse
import requests
API_BASE = os.environ.get("NAPSEER_E2E_API_BASE", "http://127.0.0.1:3000")
KEYCLOAK_BASE = os.environ.get("NAPSEER_E2E_KEYCLOAK_BASE", "http://localhost:18080")
KEYCLOAK_HOST_HEADER = os.environ.get("NAPSEER_E2E_KEYCLOAK_HOST")
OPERATOR_USERNAME = os.environ.get("NAPSEER_E2E_USERNAME", "local-operator")
OPERATOR_PASSWORD = os.environ.get("NAPSEER_E2E_PASSWORD", "napseer-password")
def request_json(method, path, token=None, payload=None, headers=None):
req_headers = {"accept": "application/json", **(headers or {})}
if token:
req_headers["authorization"] = f"Bearer {token}"
if payload is not None:
req_headers["content-type"] = "application/json"
response = requests.request(
method,
f"{API_BASE}{path}",
headers=req_headers,
data=json.dumps(payload) if payload is not None else None,
timeout=15,
)
try:
data = response.json()
except Exception:
data = {"body": response.text}
if not response.ok:
raise RuntimeError(f"{method} {path} failed with {response.status_code}: {data}")
return data
def get_operator_token():
response = requests.post(
f"{KEYCLOAK_BASE}/realms/napseer/protocol/openid-connect/token",
headers={
**({"host": KEYCLOAK_HOST_HEADER} if KEYCLOAK_HOST_HEADER else {}),
"content-type": "application/x-www-form-urlencoded",
},
data={
"grant_type": "password",
"client_id": "napseer-frontend",
"username": OPERATOR_USERNAME,
"password": OPERATOR_PASSWORD,
},
timeout=15,
)
if not response.ok:
raise RuntimeError(f"failed to get Keycloak token: {response.status_code} {response.text}")
return response.json()["access_token"]
def enroll_worker():
with tempfile.TemporaryDirectory() as tempdir:
key_path = os.path.join(tempdir, "gateway_ed25519")
subprocess.run(
["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", key_path],
check=True,
)
with open(f"{key_path}.pub", "r", encoding="utf-8") as handle:
public_key = handle.read().strip()
challenge = request_json(
"POST",
"/v1/enrollment/challenges",
payload={
"public_key": public_key,
"worker_name": "local-e2e-gateway",
"device_fingerprint": f"local-e2e-{secrets.token_hex(4)}",
"root_path": f"/tmp/napseer-local-e2e-{secrets.token_hex(4)}",
"worker_capabilities": {"gateway": True, "e2e": True},
},
)
signature = subprocess.run(
["ssh-keygen", "-Y", "sign", "-f", key_path, "-n", "napseer"],
input=challenge["challenge_text"],
text=True,
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
).stdout
verified = request_json(
"POST",
"/v1/enrollment/verify",
payload={"challenge_id": challenge["challenge_id"], "signature": signature},
)
return verified
def create_project(worker_token):
return request_json(
"POST",
"/v1/projects",
token=worker_token,
payload={
"slug": f"local-gateway-e2e-{secrets.token_hex(4)}",
"name": "Local Gateway E2E",
"description": "Isolated compose gateway relay E2E project",
},
headers={"Idempotency-Key": f"local-gateway-e2e-{secrets.token_hex(8)}"},
)
def claim_worker_account(worker_token, operator_token):
claim = request_json(
"POST",
"/v1/account/claim-links",
token=worker_token,
payload={"return_url": "http://localhost:4321/claimed"},
)
claim_token = claim["claim_url"].rstrip("/").split("/")[-1]
return request_json(
"POST",
f"/v1/account/claim-links/{urllib.parse.quote(claim_token)}/complete",
token=operator_token,
)
class WebSocketClient:
def __init__(self, url, headers=None, origin="http://localhost:3000"):
self.url = urllib.parse.urlparse(url)
self.sock = socket.create_connection((self.url.hostname, self.url.port or 80), timeout=10)
key = base64.b64encode(secrets.token_bytes(16)).decode("ascii")
path = self.url.path or "/"
if self.url.query:
path = f"{path}?{self.url.query}"
host = self.url.netloc
lines = [
f"GET {path} HTTP/1.1",
f"Host: {host}",
"Upgrade: websocket",
"Connection: Upgrade",
f"Sec-WebSocket-Key: {key}",
"Sec-WebSocket-Version: 13",
f"Origin: {origin}",
]
for name, value in (headers or {}).items():
lines.append(f"{name}: {value}")
lines.extend(["", ""])
self.sock.sendall("\r\n".join(lines).encode("ascii"))
response = b""
while b"\r\n\r\n" not in response:
chunk = self.sock.recv(4096)
if not chunk:
break
response += chunk
if not response.startswith(b"HTTP/1.1 101"):
raise RuntimeError(f"websocket upgrade failed: {response.decode('utf-8', 'replace')}")
def send_text(self, value):
data = value.encode("utf-8")
header = bytearray([0x81])
if len(data) < 126:
header.append(0x80 | len(data))
elif len(data) <= 0xFFFF:
header.append(0x80 | 126)
header.extend(struct.pack("!H", len(data)))
else:
header.append(0x80 | 127)
header.extend(struct.pack("!Q", len(data)))
mask = secrets.token_bytes(4)
masked = bytes(byte ^ mask[index % 4] for index, byte in enumerate(data))
self.sock.sendall(bytes(header) + mask + masked)
def recv_text(self, timeout=10):
self.sock.settimeout(timeout)
while True:
first = self._read_exact(2)
opcode = first[0] & 0x0F
masked = bool(first[1] & 0x80)
length = first[1] & 0x7F
if length == 126:
length = struct.unpack("!H", self._read_exact(2))[0]
elif length == 127:
length = struct.unpack("!Q", self._read_exact(8))[0]
mask = self._read_exact(4) if masked else b""
payload = self._read_exact(length)
if masked:
payload = bytes(byte ^ mask[index % 4] for index, byte in enumerate(payload))
if opcode == 0x1:
return payload.decode("utf-8")
if opcode == 0x8:
raise RuntimeError("websocket closed")
if opcode == 0x9:
self._send_pong(payload)
def close(self):
try:
self.sock.close()
except Exception:
pass
def _read_exact(self, length):
chunks = []
remaining = length
while remaining:
chunk = self.sock.recv(remaining)
if not chunk:
raise RuntimeError("websocket closed while reading")
chunks.append(chunk)
remaining -= len(chunk)
return b"".join(chunks)
def _send_pong(self, payload):
header = bytearray([0x8A, 0x80 | len(payload)])
mask = secrets.token_bytes(4)
masked = bytes(byte ^ mask[index % 4] for index, byte in enumerate(payload))
self.sock.sendall(bytes(header) + mask + masked)
def expect_ws_json(ws, predicate, timeout=10):
deadline = time.time() + timeout
while time.time() < deadline:
value = json.loads(ws.recv_text(timeout=max(0.2, deadline - time.time())))
if predicate(value):
return value
raise RuntimeError("expected websocket JSON frame was not received")
def wait_for_session_status(project_id, session_id, token, expected, timeout=10):
deadline = time.time() + timeout
last = None
while time.time() < deadline:
last = request_json("GET", f"/v1/projects/{project_id}/gateway-sessions/{session_id}", token=token)
if last["status"] == expected:
return last
time.sleep(0.2)
raise RuntimeError(f"gateway session did not reach {expected}: {last}")
def run():
operator_token = get_operator_token()
operator_account = request_json("GET", "/v1/account", token=operator_token)
enrolled = enroll_worker()
worker_token = enrolled["token"]["access_token"]
worker = enrolled["worker"]
project = create_project(worker_token)
offline = requests.post(
f"{API_BASE}/v1/projects/{project['id']}/gateways/{worker['agent_id']}/connect",
headers={"authorization": f"Bearer {operator_token}", "content-type": "application/json"},
data=json.dumps({"client_id": "local-e2e-offline"}),
timeout=15,
)
if offline.status_code not in (404, 409):
raise RuntimeError(f"offline connect should fail, got {offline.status_code}: {offline.text}")
claim = claim_worker_account(worker_token, operator_token)
projects = request_json("GET", "/v1/projects", token=operator_token)
if not any(item["id"] == project["id"] for item in projects["items"]):
raise RuntimeError("claimed operator cannot see worker-created project")
listener_url = (
f"ws://127.0.0.1:3000/v1/projects/{project['id']}"
f"/gateways/{urllib.parse.quote(worker['agent_id'])}/listener"
)
listener = WebSocketClient(listener_url, headers={"Authorization": f"Bearer {worker_token}"})
listener_state = {}
def listener_loop():
request = json.loads(listener.recv_text(timeout=15))
listener_state["request"] = request
listener.send_text(json.dumps({"type": "connect.accept", "request_id": request["request_id"]}))
thread = threading.Thread(target=listener_loop, daemon=True)
thread.start()
connect = request_json(
"POST",
f"/v1/projects/{project['id']}/gateways/{worker['agent_id']}/connect",
token=operator_token,
payload={"client_id": "local-e2e-client", "metadata": {"source": "local_gateway_e2e.py"}},
)
thread.join(timeout=10)
if "request" not in listener_state:
raise RuntimeError("gateway listener did not receive connect.request")
session_id = connect["session"]["id"]
gateway_ticket = listener_state["request"]["relay_ticket"]
client_ticket = connect["ticket"]
relay_base = f"ws://127.0.0.1:3000/v1/projects/{project['id']}/gateway-sessions/{session_id}/relay"
client_ws = WebSocketClient(f"{relay_base}?ticket={urllib.parse.quote(client_ticket)}")
gateway_ws = WebSocketClient(f"{relay_base}?ticket={urllib.parse.quote(gateway_ticket)}")
client_ws.send_text(json.dumps({
"type": "spake2.result",
"source": "browser",
"session_id": session_id,
"ok": True,
"client_confirmation": "local-e2e-client-confirmation"
}))
gateway_ws.send_text(json.dumps({
"type": "spake2.result",
"source": "gateway",
"session_id": session_id,
"ok": True,
"gateway_confirmation": "local-e2e-gateway-confirmation"
}))
opened = wait_for_session_status(project["id"], session_id, operator_token, "open")
client_frame = {
"type": "encrypted",
"alg": "AES-GCM-256",
"session_id": session_id,
"context_hash": "local-e2e-context",
"direction": "client_to_gateway",
"seq": 1,
"nonce": base64.b64encode(secrets.token_bytes(12)).decode("ascii"),
"ciphertext": base64.b64encode(b"opaque-client-frame").decode("ascii"),
}
client_ws.send_text(json.dumps(client_frame))
gateway_seen = expect_ws_json(
gateway_ws,
lambda value: value.get("type") == "encrypted"
and value.get("direction") == "client_to_gateway"
and value.get("seq") == 1,
)
gateway_frame = {
**client_frame,
"direction": "gateway_to_client",
"seq": 2,
"nonce": base64.b64encode(secrets.token_bytes(12)).decode("ascii"),
"ciphertext": base64.b64encode(b"opaque-gateway-frame").decode("ascii"),
}
gateway_ws.send_text(json.dumps(gateway_frame))
client_seen = expect_ws_json(
client_ws,
lambda value: value.get("type") == "encrypted"
and value.get("direction") == "gateway_to_client"
and value.get("seq") == 2,
)
client_ws.close()
gateway_ws.close()
listener.close()
print(json.dumps({
"status": "ok",
"operator_account_id": operator_account["id"],
"claim_account_id": claim["account_id"],
"project_id": project["id"],
"worker_id": worker["id"],
"gateway_agent_id": worker["agent_id"],
"session_id": session_id,
"offline_connect_status": offline.status_code,
"open_status": opened["status"],
"gateway_received": {
"direction": gateway_seen["direction"],
"seq": gateway_seen["seq"],
},
"client_received": {
"direction": client_seen["direction"],
"seq": client_seen["seq"],
},
}, indent=2))
if __name__ == "__main__":
run()
@@ -0,0 +1,97 @@
#!/usr/bin/env python3
"""Smoke-test persisted gateway container identity behavior."""
import importlib.util
import json
import os
import pathlib
import sys
import tempfile
def load_module():
script_path = pathlib.Path(__file__).resolve().parents[1] / "resources" / "scripts" / "napseer_mcp_server.py"
sys.path.insert(0, str(script_path.parent))
spec = importlib.util.spec_from_file_location("napseer_mcp_server", script_path)
module = importlib.util.module_from_spec(spec)
assert spec and spec.loader
spec.loader.exec_module(module)
return module
def assert_equal(actual, expected, message):
if actual != expected:
raise AssertionError(f"{message}: expected={expected!r} actual={actual!r}")
def run():
mod = load_module()
with tempfile.TemporaryDirectory() as tmp:
os.environ.pop("NAPSEER_GATEWAY_CONTAINER_UUID", None)
os.environ.pop("NAPSEER_CONTAINER_UUID", None)
state_dir = pathlib.Path(tmp) / ".napseer"
mod.AUTH_DIR = state_dir
mod.AUTH_PATH = state_dir / "auth.json"
mod.VAULT_PATH = state_dir / "vault.json"
mod.CONTAINER_IDENTITY_PATH = state_dir / "container-identity.json"
mod.AUTH = {}
mod.BASE_URL = "https://api.test"
mod.DEFAULT_PROJECT_ID = None
mod.TOKEN = None
mod.VAULT_SECRETS = {}
first = mod.ensure_container_uuid()
second = mod.ensure_container_uuid()
assert_equal(second, first, "container uuid persists")
payload = json.loads(mod.CONTAINER_IDENTITY_PATH.read_text(encoding="utf-8"))
assert_equal(payload["container_uuid"], first, "container uuid file content")
env_dir = pathlib.Path(tmp) / "env-state"
env_uuid = "2a164e32-9d98-4b4e-9ad8-eed7307a5b08"
mod.AUTH_DIR = env_dir
mod.CONTAINER_IDENTITY_PATH = env_dir / "container-identity.json"
os.environ["NAPSEER_GATEWAY_CONTAINER_UUID"] = env_uuid
assert_equal(mod.ensure_container_uuid(), env_uuid, "env container uuid")
env_payload = json.loads(mod.CONTAINER_IDENTITY_PATH.read_text(encoding="utf-8"))
assert_equal(env_payload["container_uuid"], env_uuid, "env container uuid is persisted")
os.environ.pop("NAPSEER_GATEWAY_CONTAINER_UUID", None)
mod.AUTH_DIR = state_dir
mod.CONTAINER_IDENTITY_PATH = state_dir / "container-identity.json"
calls = []
mod.vault_exists = lambda: False
mod.gateway_setup = lambda payload: {"status": "configured"}
mod.ensure_local_files = lambda: state_dir / "id_ed25519"
state_dir.mkdir(parents=True, exist_ok=True)
(state_dir / "id_ed25519.pub").write_text("ssh-ed25519 AAAAexample\n", encoding="utf-8")
mod.request_json = lambda method, path, payload=None, **kwargs: calls.append((method, path, payload)) or {
"registration": {
"id": "reg_1",
"project_id": "proj_1",
"agent_id": "agt_1",
"display_name": "gw",
"device_fingerprint": "device",
"root_path": "/workspace",
},
"activation_token": "npa_1",
}
mod.save_auth = lambda updates: None
result = mod.gateway_service_preregister({
"bootstrap_token": "npb_test",
"passphrase": "pw",
"display_name": "gw",
"root_path": "/workspace",
"device_fingerprint": "device",
})
assert_equal(result["status"], "pending_review", "preregister result")
request_payload = calls[0][2]
assert_equal(request_payload["container_uuid"], first, "request container uuid")
assert_equal(request_payload["labels"]["container_uuid"], first, "label container uuid")
assert_equal(request_payload["metadata"]["container_uuid"], first, "metadata container uuid")
print("ok: gateway container identity smoke passed")
if __name__ == "__main__":
run()
@@ -0,0 +1,71 @@
#!/usr/bin/env python3
"""Smoke-test auth refresh while the gateway vault is locked."""
import importlib.util
import pathlib
import sys
def load_module():
script_path = pathlib.Path(__file__).resolve().parents[1] / "resources" / "scripts" / "napseer_mcp_server.py"
sys.path.insert(0, str(script_path.parent))
spec = importlib.util.spec_from_file_location("napseer_mcp_server", script_path)
module = importlib.util.module_from_spec(spec)
assert spec and spec.loader
spec.loader.exec_module(module)
return module
def run():
mod = load_module()
writes = []
mod.VAULT_SECRETS = {}
mod.VAULT_KEY = None
mod.AUTH = {
"base_url": "https://api.example.test",
"project_id": "project_1",
"worker_name": "agent",
"device_fingerprint": "device",
"root_path": "/repo",
"worker_capabilities": {"local_mcp": True},
}
mod.BASE_URL = mod.AUTH["base_url"]
mod.DEFAULT_PROJECT_ID = mod.AUTH["project_id"]
mod.TOKEN = "expired"
mod.TOKEN_EXPIRES_AT = "old"
mod.vault_exists = lambda: True
mod.gateway_is_unlocked = lambda: False
mod.load_public_auth_file = lambda: {
"base_url": "https://api.example.test",
"project_id": "project_1",
"worker_name": "agent",
"device_fingerprint": "device",
"root_path": "/repo",
"worker_capabilities": {"local_mcp": True},
}
mod.write_public_auth = lambda payload: writes.append(dict(payload))
def locked(operation="operation"):
raise RuntimeError(f"gateway is locked; unlock with the master passphrase before {operation}")
mod.require_unlocked = locked
mod.save_auth({"token": "fresh", "token_expires_at": "new", "worker_id": "worker_1"})
assert writes[-1]["token"] == "fresh"
assert writes[-1]["token_expires_at"] == "new"
assert mod.TOKEN == "fresh"
assert mod.TOKEN_EXPIRES_AT == "new"
try:
mod.save_auth({"gateway_default_command": "bash -l"})
except RuntimeError as exc:
assert "updating gateway vault" in str(exc)
else:
raise AssertionError("gateway vault updates must still require unlock")
print("ok: locked gateway auth refresh smoke passed")
if __name__ == "__main__":
run()
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env python3
"""Smoke-test gateway relay AES-GCM without requiring Node.js."""
import importlib.util
import pathlib
import sys
def load_module():
script_path = pathlib.Path(__file__).resolve().parents[1] / "resources" / "scripts" / "napseer_mcp_server.py"
sys.path.insert(0, str(script_path.parent))
spec = importlib.util.spec_from_file_location("napseer_mcp_server", script_path)
module = importlib.util.module_from_spec(spec)
assert spec and spec.loader
spec.loader.exec_module(module)
return module
def run():
mod = load_module()
zero_key = bytes.fromhex("00" * 32)
zero_nonce = bytes.fromhex("00" * 12)
packed = mod.aes_gcm_encrypt(zero_key, zero_nonce, b"", b"")
assert packed.hex() == "530f8afbc74536b9a963b4f1c4cb738b"
assert mod.aes_gcm_decrypt(zero_key, zero_nonce, b"", packed) == b""
packed = mod.aes_gcm_encrypt(zero_key, zero_nonce, b"", bytes(16))
assert packed.hex() == "cea7403d4d606b6e074ec5d3baf39d18d0d1c8a799996bf0265b98b5d48ab919"
assert mod.aes_gcm_decrypt(zero_key, zero_nonce, b"", packed) == bytes(16)
key = bytes.fromhex("feffe9928665731c6d6a8f9467308308feffe9928665731c6d6a8f9467308308")
nonce = bytes.fromhex("cafebabefacedbaddecaf888")
aad = bytes.fromhex("feedfacedeadbeeffeedfacedeadbeefabaddad2")
plaintext = bytes.fromhex("d9313225f88406e5a55909c5aff5269a")
packed = mod.aes_gcm_encrypt(key, nonce, aad, plaintext)
assert packed[:16].hex() == "522dc1f099567d07f47f37a32a84427d"
assert mod.aes_gcm_decrypt(key, nonce, aad, packed) == plaintext
print("ok: gateway relay crypto smoke passed")
if __name__ == "__main__":
run()
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env python3
"""Smoke-test normal gateway capability registration."""
import importlib.util
import pathlib
import sys
def load_module():
script_path = pathlib.Path(__file__).resolve().parents[1] / "resources" / "scripts" / "napseer_mcp_server.py"
sys.path.insert(0, str(script_path.parent))
spec = importlib.util.spec_from_file_location("napseer_mcp_server", script_path)
module = importlib.util.module_from_spec(spec)
assert spec and spec.loader
spec.loader.exec_module(module)
return module
def run():
mod = load_module()
saves = []
renewals = []
mod.AUTH = {
"worker_capabilities": {"local_mcp": True, "setup_script": "napseer_setup.py"},
}
mod.TOKEN = "np_existing"
mod.save_auth = lambda payload: saves.append(payload) or mod.AUTH.update(payload)
mod.renew_auth = lambda: renewals.append(dict(mod.AUTH))
mod.gateway_log = lambda *args, **kwargs: None
result = mod.ensure_gateway_worker_capability(refresh=True)
assert result["status"] == "refreshed"
assert result["worker_capabilities"]["gateway"] is True
assert result["worker_capabilities"]["local_mcp"] is True
assert result["worker_capabilities"]["setup_script"] == "napseer_setup.py"
assert saves[-1]["worker_capabilities"]["gateway"] is True
assert renewals and renewals[-1]["worker_capabilities"]["gateway"] is True
merged = mod.gateway_worker_capabilities({"custom": "kept"})
assert merged["gateway"] is True
assert merged["custom"] == "kept"
assert merged["local_mcp"] is True
print("ok: normal gateway worker capability smoke passed")
if __name__ == "__main__":
run()
+272
View File
@@ -0,0 +1,272 @@
import importlib.util
import json
import sys
from types import SimpleNamespace
from pathlib import Path
def load_gateway_module():
module_path = Path(__file__).resolve().parents[1] / "resources" / "scripts" / "napseer_mcp_server.py"
sys.path.insert(0, str(module_path.parent))
spec = importlib.util.spec_from_file_location("napseer_mcp_server", module_path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def test_route_args(module):
assert module.gateway_terminal_route_args(
{"type": "terminal.key", "terminal_id": " %2 "},
{"target": "napseer:0.0"},
) == {"terminal_id": "%2", "remote_authenticated": True}
assert module.gateway_terminal_route_args(
{"type": "terminal.key"},
{"target": "napseer:1.0"},
) == {
"tmux_target": "napseer:1.0",
"_internal_target": True,
"remote_authenticated": True,
}
def test_output_payload(module):
assert module.gateway_terminal_output_payload(
{"type": "terminal.status", "status": "connected"},
"%2",
) == {"type": "terminal.status", "status": "connected"}
assert module.gateway_terminal_output_payload(
{"type": "terminal.output", "data": "ready"},
"%2",
) == {
"type": "terminal.output",
"data": "ready",
"terminal_id": "%2",
"selected_terminal_id": "%2",
}
def test_gateway_key_routes_to_terminal_id(module):
calls = []
class Result:
returncode = 0
stderr = ""
stdout = ""
def fake_windows(remote_authenticated=False):
assert remote_authenticated is True
return [
{"id": "%1", "name": "agent", "index": "0", "target": "napseer:0.0", "active": True},
{"id": "%2", "name": "codex", "index": "1", "target": "napseer:1.0", "active": False},
]
def fake_tmux_run(args, timeout=10):
calls.append(args)
return Result()
module.gateway_tmux_windows = fake_windows
module.tmux_run = fake_tmux_run
module.gateway_terminal_key({"terminal_id": "%2", "text": "x", "remote_authenticated": True})
assert calls[-1] == ["send-keys", "-t", "napseer:1.0", "-l", "x"]
module.gateway_terminal_key({
"tmux_target": "fallback:0.0",
"_internal_target": True,
"text": "y",
"remote_authenticated": True,
})
assert calls[-1] == ["send-keys", "-t", "fallback:0.0", "-l", "y"]
def test_close_selected_pty_switches_to_existing_terminal(module):
closed = []
opened = []
class FakePtyManager:
def __init__(self):
self.items = [
SimpleNamespace(
terminal_id="term-1",
command=["sh"],
rows=24,
cols=80,
output_seq=0,
oldest_output_seq=0,
created_at=1,
last_activity=1,
closed=False,
exit_code=None,
),
SimpleNamespace(
terminal_id="term-2",
command=["sh"],
rows=24,
cols=80,
output_seq=0,
oldest_output_seq=0,
created_at=2,
last_activity=2,
closed=False,
exit_code=None,
),
]
def list(self):
return list(self.items)
def open(self, *args, **kwargs):
opened.append((args, kwargs))
terminal = SimpleNamespace(
terminal_id="replacement",
command=["sh"],
rows=24,
cols=80,
output_seq=0,
oldest_output_seq=0,
created_at=3,
last_activity=3,
closed=False,
exit_code=None,
)
self.items.append(terminal)
return terminal
def close(self, terminal_id):
closed.append(terminal_id)
self.items = [item for item in self.items if item.terminal_id != terminal_id]
manager = FakePtyManager()
module.gateway_pty_manager = lambda: manager
module.gateway_tmux_windows = lambda remote_authenticated=False: []
result = module.gateway_terminal_close({
"terminal_id": "term-1",
"selected_terminal_id": "term-1",
"create_replacement": True,
"remote_authenticated": True,
})
assert closed == ["term-1"]
assert opened == []
assert result["selected_terminal_id"] == "term-2"
def test_rotation_gate_helpers(module):
normal = module.gateway_rotation_gate_decision("terminal.open", {"allowed_frame_mode": "normal"})
assert normal["allowed"] is True
rotation_allow = module.gateway_rotation_gate_decision(
"gateway.passphrase_rotation.challenge.request",
{"allowed_frame_mode": "rotation_only"},
)
assert rotation_allow["allowed"] is True
blocked = module.gateway_rotation_gate_decision("terminal.input", {"allowed_frame_mode": "rotation_only"})
assert blocked == {
"allowed": False,
"reason": "rotation_required",
"frame_class": "terminal_or_schedule",
}
def test_rotation_result_payload_is_secret_blind(module):
payload = module.gateway_rotation_result_payload(
status="anything",
code="unexpected_sensitive_detail",
retry_required=True,
)
assert payload["type"] == "gateway.passphrase_rotation.result"
assert payload["status"] == "retry_required"
assert payload["code"] == "rotation_retry_required"
assert payload["retry_required"] is True
assert "sent_at" in payload
def test_rotation_required_session_skips_terminal_and_schedule_bootstrap(module):
class FakeSocket:
def close(self):
return None
sent = []
ws_frames = iter([
'{"type":"spake2.message","source":"client","message":"browser"}',
'{"type":"spake2.result","source":"client","ok":true,"client_confirmation":"ok"}',
None,
])
terminal_bootstrap_calls = []
schedule_calls = []
module.AUTH = {"agent_id": "agent-1", "worker_id": "worker-1"}
module.current_project_id = lambda: "project-1"
module.read_gateway_relay_secret = lambda: b"relay-secret"
module.relay_ws_url = lambda project_id, session_id, ticket: "ws://relay.test"
module.ws_connect = lambda url: FakeSocket()
module.ws_read_text = lambda sock: next(ws_frames)
module.ws_send_text = lambda sock, text: sent.append(text)
module.gateway_requires_passphrase_rotation = lambda: True
module.spake2_gateway_finish = lambda secret, context, browser_message: {
"message": "gateway-message",
"expected_client_confirmation": "ok",
"confirmation": "gateway-confirm",
"secret": b"spake-secret",
}
module.relay_key_from_secret = lambda secret, context, direction: b"k" + direction.encode("utf-8")
module.relay_context_hash = lambda context: "ctx"
module.encrypt_relay_frame = (
lambda key, session_id, payload, context_hash, direction, seq: (
'{"type":"encrypted","payload":' + json.dumps(payload, separators=(",", ":")) + "}"
)
)
module.gateway_terminal_backend_from_id = lambda terminal_id, remote_authenticated=False: (
terminal_bootstrap_calls.append((terminal_id, remote_authenticated))
or ("pty", {"id": "term-1", "target": "t", "rows": 24, "cols": 80, "output_seq": 0})
)
module.gateway_schedule_list = lambda args=None: (
schedule_calls.append(args) or {"schedules": [{"id": "sched-1"}]}
)
module.gateway_log = lambda *args, **kwargs: None
module.handle_gateway_relay_session({
"project_id": "project-1",
"session_id": "session-1",
"request_id": "request-1",
"relay_ticket": "ticket-1",
"spake2_context": {},
"client_account_id": "acct-1",
"client_id": "client-1",
})
encrypted_payloads = []
for message in sent:
if '"type":"encrypted"' not in message:
continue
payload_text = message.split('"payload":', 1)[1][:-1]
encrypted_payloads.append(json.loads(payload_text))
encrypted_types = [item.get("type") for item in encrypted_payloads]
assert terminal_bootstrap_calls == []
assert schedule_calls == []
assert "terminal.session_list" not in encrypted_types
assert "terminal.opened" not in encrypted_types
assert "terminal.output" not in encrypted_types
assert "schedule.list" not in encrypted_types
assert "gateway.passphrase_rotation.result" in encrypted_types
def main():
module = load_gateway_module()
test_route_args(module)
test_output_payload(module)
test_gateway_key_routes_to_terminal_id(module)
test_close_selected_pty_switches_to_existing_terminal(module)
test_rotation_gate_helpers(module)
test_rotation_result_payload_is_secret_blind(module)
test_rotation_required_session_skips_terminal_and_schedule_bootstrap(module)
print("gateway remote terminal slice passed")
if __name__ == "__main__":
main()
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env python3
from __future__ import annotations
import os
import sys
import time
REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
sys.path.insert(0, os.path.join(REPO_ROOT, "resources", "scripts"))
from terminal.pty_manager import PtySessionManager # noqa: E402
def fail(message: str) -> None:
print(f"FAIL: {message}", file=sys.stderr)
raise SystemExit(1)
def wait_for_output(manager: PtySessionManager, terminal_id: str, needle: bytes) -> bytes:
deadline = time.time() + 5.0
while time.time() < deadline:
attach = manager.attach(terminal_id)
data = b"".join(chunk.data for chunk in attach.chunks)
if needle in data:
return data
time.sleep(0.05)
return b""
def main() -> int:
manager = PtySessionManager(ring_chunks=16)
info = manager.open(command=["/bin/sh"], rows=12, cols=40)
terminal_id = info.terminal_id
try:
if not manager.write(terminal_id, b"printf 'NAPSEER_PTY_OK\\n'\n", input_seq=1):
fail("initial write was not accepted")
output = wait_for_output(manager, terminal_id, b"NAPSEER_PTY_OK")
if b"NAPSEER_PTY_OK" not in output:
fail("expected shell output was not captured")
resized = manager.resize(terminal_id, rows=30, cols=100)
if resized.rows != 30 or resized.cols != 100:
fail("resize metadata was not updated")
replay = manager.attach(terminal_id, last_output_seq=0)
if not replay.chunks:
fail("attach did not replay buffered output")
if manager.write(terminal_id, b"ignored\n", input_seq=1):
fail("duplicate input_seq was accepted")
listed = manager.list()
if not any(item.terminal_id == terminal_id for item in listed):
fail("terminal was not returned by list()")
finally:
manager.close(terminal_id)
if manager.list():
fail("terminal remained listed after close")
print("terminal PTY manager slice OK")
return 0
if __name__ == "__main__":
raise SystemExit(main())