Refresh gateway runtime source import
This commit is contained in:
+11
@@ -0,0 +1,11 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.egg-info/
|
||||||
|
.napseer/
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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`.
|
||||||
+2360
-382
File diff suppressed because one or more lines are too long
@@ -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",
|
||||||
|
]
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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())
|
||||||
Reference in New Issue
Block a user