Refresh gateway runtime source import
This commit is contained in:
@@ -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