chore: import upstream snapshot with attribution
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Backend release / release (push) Has been cancelled
Bandit Security Scan / bandit_scan (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / manifest (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / manifest (push) Has been cancelled
Python linting / ruff (push) Has been cancelled
Run python tests with pytest / Run tests and count coverage (3.12) (push) Has been cancelled
React Widget Build / build (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Backend release / release (push) Has been cancelled
Bandit Security Scan / bandit_scan (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / manifest (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / manifest (push) Has been cancelled
Python linting / ruff (push) Has been cancelled
Run python tests with pytest / Run tests and count coverage (3.12) (push) Has been cancelled
React Widget Build / build (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
"""Flask blueprint for the Remote Device feature."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Blueprint
|
||||
|
||||
from .routes import register as register_routes
|
||||
|
||||
|
||||
devices_bp = Blueprint("devices", __name__)
|
||||
register_routes(devices_bp)
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Device session-token verification + machine-key signature check."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import logging
|
||||
import time
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from flask import jsonify, make_response, request
|
||||
|
||||
from application.core.settings import settings
|
||||
from application.storage.db.repositories.devices import DevicesRepository
|
||||
from application.storage.db.session import db_readonly, db_session
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def hash_session_token(token: str) -> str:
|
||||
"""SHA-256 over the opaque session token. Stored in ``devices.token_hash``."""
|
||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def fingerprint_pubkey(pubkey_b64: str) -> str:
|
||||
"""SHA-256 over the raw public-key bytes; stored as the fingerprint."""
|
||||
raw = base64.b64decode(pubkey_b64)
|
||||
return hashlib.sha256(raw).hexdigest()
|
||||
|
||||
|
||||
def _canonical_payload(method: str, path: str, ts: str, body: bytes) -> str:
|
||||
"""Canonical string the device signs / the server verifies.
|
||||
|
||||
Format: ``"{method} {path} {ts} {sha256_hex(body)}"``. The body hash
|
||||
binds the request body into the signature so a captured signature can't
|
||||
be replayed with a tampered body inside the timestamp window. For a GET
|
||||
(empty body) this is the SHA-256 of the empty string.
|
||||
|
||||
KEEP IN SYNC with the DocsGPT-cli signer
|
||||
(``internal/host/identity.go`` ``CanonicalPayload`` / ``SignRequest``).
|
||||
The hex encoding and single-space separators must match byte-for-byte.
|
||||
"""
|
||||
body_hash = hashlib.sha256(body or b"").hexdigest()
|
||||
return f"{method} {path} {ts} {body_hash}"
|
||||
|
||||
|
||||
def verify_device_session(*, touch: bool = True) -> Tuple[Optional[dict], Optional[tuple]]:
|
||||
"""Validate the device session token on the current request.
|
||||
|
||||
Returns ``(device_row, None)`` on success or ``(None, response)`` on
|
||||
failure, where ``response`` is a ``(json, status)`` Flask tuple.
|
||||
|
||||
Args:
|
||||
touch: When True, bump ``last_seen_at`` on the device row.
|
||||
"""
|
||||
auth = request.headers.get("Authorization", "")
|
||||
if not auth.lower().startswith("bearer "):
|
||||
return None, _error("missing_token", 401)
|
||||
token = auth.split(" ", 1)[1].strip()
|
||||
if not token:
|
||||
return None, _error("missing_token", 401)
|
||||
|
||||
token_hash = hash_session_token(token)
|
||||
with db_readonly() as conn:
|
||||
device = DevicesRepository(conn).find_by_token_hash(token_hash)
|
||||
if device is None:
|
||||
return None, _error("invalid_token", 401)
|
||||
|
||||
if settings.REMOTE_DEVICE_REQUIRE_SIGNATURE:
|
||||
sig_error = _verify_signature(device)
|
||||
if sig_error is not None:
|
||||
return None, sig_error
|
||||
|
||||
if touch:
|
||||
try:
|
||||
with db_session() as conn:
|
||||
DevicesRepository(conn).touch_last_seen(device["id"])
|
||||
except Exception:
|
||||
logger.exception("touch_last_seen failed for device %s", device["id"])
|
||||
|
||||
return device, None
|
||||
|
||||
|
||||
def _verify_signature(device: dict) -> Optional[tuple]:
|
||||
"""Verify ``X-Device-Signature`` against the stored machine pubkey."""
|
||||
sig_b64 = request.headers.get("X-Device-Signature")
|
||||
ts = request.headers.get("X-Device-Timestamp")
|
||||
fp = request.headers.get("X-Device-Machine-Key")
|
||||
if not sig_b64 or not ts or not fp:
|
||||
return _error("missing_signature", 401)
|
||||
|
||||
try:
|
||||
ts_int = int(ts)
|
||||
except (TypeError, ValueError):
|
||||
return _error("invalid_timestamp", 401)
|
||||
if abs(time.time() - ts_int) > 300:
|
||||
return _error("timestamp_skew", 401)
|
||||
|
||||
if fp != device.get("machine_pubkey_fingerprint"):
|
||||
return _error("fingerprint_mismatch", 401)
|
||||
|
||||
# Defer cryptography import to keep cold-start light when signatures
|
||||
# are disabled (the dev default).
|
||||
try:
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
||||
Ed25519PublicKey,
|
||||
)
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
except ImportError:
|
||||
logger.error(
|
||||
"Signature verification requested but ``cryptography`` is not installed."
|
||||
)
|
||||
return _error("signature_unsupported", 500)
|
||||
|
||||
# The full public key isn't stored — only the fingerprint. For signature
|
||||
# verification we accept the pubkey in a header too; we trust it iff
|
||||
# its fingerprint matches the stored one. This avoids a separate pubkey
|
||||
# column for MVP.
|
||||
pubkey_b64 = request.headers.get("X-Device-Machine-Pubkey")
|
||||
if not pubkey_b64:
|
||||
return _error("missing_pubkey", 401)
|
||||
if fingerprint_pubkey(pubkey_b64) != device["machine_pubkey_fingerprint"]:
|
||||
return _error("pubkey_fingerprint_mismatch", 401)
|
||||
|
||||
payload = _canonical_payload(
|
||||
request.method, request.path, ts, request.get_data()
|
||||
).encode("utf-8")
|
||||
try:
|
||||
pubkey = Ed25519PublicKey.from_public_bytes(base64.b64decode(pubkey_b64))
|
||||
pubkey.verify(base64.b64decode(sig_b64), payload)
|
||||
except (InvalidSignature, ValueError):
|
||||
return _error("invalid_signature", 401)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _error(code: str, status: int) -> tuple:
|
||||
return make_response(
|
||||
jsonify({"success": False, "error": code}), status
|
||||
)
|
||||
@@ -0,0 +1,518 @@
|
||||
"""Pairing flow (RFC 8628 device authorization grant)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from flask import jsonify, make_response, request
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from application.api.devices.auth import fingerprint_pubkey, hash_session_token
|
||||
from application.cache import get_redis_instance
|
||||
from application.core.settings import settings
|
||||
from application.storage.db.repositories.devices import DevicesRepository
|
||||
from application.storage.db.repositories.user_tools import UserToolsRepository
|
||||
from application.storage.db.session import db_session
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_USER_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
_USER_CODE_LEN = 8 # ABCD-WXYZ (8 chars, displayed with a single dash)
|
||||
_PAIRING_REDIS_PREFIX = "docsgpt:device_pairing:"
|
||||
_ALLOWED_APPROVAL_MODES = {"ask", "full"}
|
||||
# Attempts to find a non-colliding device name before releasing the claim.
|
||||
_DEVICE_NAME_RETRIES = 5
|
||||
|
||||
# Atomic redeem claim: read the pairing JSON, and only if its ``status`` is
|
||||
# ``pending`` flip it to ``redeemed`` and write it back (TTL preserved).
|
||||
# Returns 1 if this caller won the claim, 0 otherwise (missing/not pending).
|
||||
# Run server-side so two concurrent redeems of one code can't both win.
|
||||
_CLAIM_PAIRING_LUA = """
|
||||
local raw = redis.call('GET', KEYS[1])
|
||||
if not raw then
|
||||
return 0
|
||||
end
|
||||
local ok, state = pcall(cjson.decode, raw)
|
||||
if not ok then
|
||||
return 0
|
||||
end
|
||||
if state['status'] ~= 'pending' then
|
||||
return 0
|
||||
end
|
||||
state['status'] = 'redeemed'
|
||||
local ttl = redis.call('TTL', KEYS[1])
|
||||
local encoded = cjson.encode(state)
|
||||
if ttl and ttl > 0 then
|
||||
redis.call('SET', KEYS[1], encoded, 'EX', ttl)
|
||||
else
|
||||
redis.call('SET', KEYS[1], encoded)
|
||||
end
|
||||
return 1
|
||||
"""
|
||||
|
||||
|
||||
def _redis():
|
||||
return get_redis_instance()
|
||||
|
||||
|
||||
def _generate_user_code() -> str:
|
||||
return "".join(secrets.choice(_USER_CODE_ALPHABET) for _ in range(_USER_CODE_LEN))
|
||||
|
||||
|
||||
def _format_user_code(code: str) -> str:
|
||||
return f"{code[:4]}-{code[4:]}"
|
||||
|
||||
|
||||
def _normalize_user_code(code: str) -> str:
|
||||
return code.replace("-", "").replace(" ", "").upper()
|
||||
|
||||
|
||||
def _user_code_index_key(user_code: str) -> str:
|
||||
return f"docsgpt:device_pairing_code:{user_code}"
|
||||
|
||||
|
||||
def create_pairing() -> tuple:
|
||||
"""Create a new pairing code for the calling user.
|
||||
|
||||
Optional body fields ``name``, ``description``, and ``approval_mode``
|
||||
are stashed in Redis alongside the pairing state and consumed by
|
||||
``redeem_pairing`` when the device row is created. Unknown values
|
||||
fall back to: name -> CLI hostname; description -> empty; approval_mode
|
||||
-> ``ask``.
|
||||
"""
|
||||
decoded = getattr(request, "decoded_token", None)
|
||||
user_id = decoded.get("sub") if isinstance(decoded, dict) else None
|
||||
if not user_id:
|
||||
return _error("authentication_required", 401)
|
||||
|
||||
body = request.get_json(silent=True) or {}
|
||||
raw_name = body.get("name")
|
||||
if raw_name is not None and not isinstance(raw_name, str):
|
||||
return _error("invalid_name", 400)
|
||||
requested_name = (raw_name or "").strip() or None
|
||||
requested_description = body.get("description")
|
||||
if isinstance(requested_description, str):
|
||||
requested_description = requested_description.strip() or None
|
||||
else:
|
||||
requested_description = None
|
||||
requested_mode = body.get("approval_mode")
|
||||
if requested_mode is not None and requested_mode not in _ALLOWED_APPROVAL_MODES:
|
||||
return _error("invalid_approval_mode", 400)
|
||||
|
||||
redis_client = _redis()
|
||||
if redis_client is None:
|
||||
return _error("redis_unavailable", 503)
|
||||
|
||||
device_code = f"dc_{uuid.uuid4().hex}"
|
||||
user_code = _generate_user_code()
|
||||
ttl = int(settings.REMOTE_DEVICE_PAIRING_TTL_SECONDS)
|
||||
|
||||
state = {
|
||||
"device_code": device_code,
|
||||
"user_code": _format_user_code(user_code),
|
||||
"user_code_raw": user_code,
|
||||
"user_id": user_id,
|
||||
"status": "pending",
|
||||
"created_at": int(time.time()),
|
||||
"requested_name": requested_name,
|
||||
"requested_description": requested_description,
|
||||
"requested_approval_mode": requested_mode,
|
||||
}
|
||||
try:
|
||||
redis_client.setex(
|
||||
_PAIRING_REDIS_PREFIX + device_code, ttl, json.dumps(state)
|
||||
)
|
||||
redis_client.setex(_user_code_index_key(user_code), ttl, device_code)
|
||||
except Exception:
|
||||
logger.exception("redis setex failed during pairing create")
|
||||
return _error("redis_unavailable", 503)
|
||||
|
||||
base_url = settings.API_URL or ""
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"device_code": device_code,
|
||||
"user_code": _format_user_code(user_code),
|
||||
"verification_uri": base_url,
|
||||
"expires_in": ttl,
|
||||
"interval": 3,
|
||||
}
|
||||
),
|
||||
200,
|
||||
)
|
||||
|
||||
|
||||
def get_pairing(device_code: str) -> tuple:
|
||||
"""Poll pairing status (UI side)."""
|
||||
decoded = getattr(request, "decoded_token", None)
|
||||
user_id = decoded.get("sub") if isinstance(decoded, dict) else None
|
||||
if not user_id:
|
||||
return _error("authentication_required", 401)
|
||||
|
||||
state = _load_pairing(device_code)
|
||||
if state is None:
|
||||
return _error("pairing_not_found", 404)
|
||||
if state.get("user_id") != user_id:
|
||||
return _error("pairing_not_found", 404)
|
||||
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"device_code": state["device_code"],
|
||||
"user_code": state.get("user_code"),
|
||||
"status": state.get("status", "pending"),
|
||||
"device_id": state.get("device_id"),
|
||||
"device_name": state.get("device_name"),
|
||||
}
|
||||
),
|
||||
200,
|
||||
)
|
||||
|
||||
|
||||
def delete_pairing(device_code: str) -> tuple:
|
||||
decoded = getattr(request, "decoded_token", None)
|
||||
user_id = decoded.get("sub") if isinstance(decoded, dict) else None
|
||||
if not user_id:
|
||||
return _error("authentication_required", 401)
|
||||
state = _load_pairing(device_code)
|
||||
if state is None:
|
||||
return _error("pairing_not_found", 404)
|
||||
if state.get("user_id") != user_id:
|
||||
return _error("pairing_not_found", 404)
|
||||
redis_client = _redis()
|
||||
if redis_client is None:
|
||||
return _error("redis_unavailable", 503)
|
||||
try:
|
||||
redis_client.delete(_PAIRING_REDIS_PREFIX + device_code)
|
||||
if state.get("user_code_raw"):
|
||||
redis_client.delete(_user_code_index_key(state["user_code_raw"]))
|
||||
except Exception:
|
||||
logger.exception("redis delete failed during pairing cancel")
|
||||
return make_response(jsonify({"success": True}), 200)
|
||||
|
||||
|
||||
def redeem_pairing() -> tuple:
|
||||
"""CLI submits the user_code and machine info to claim a device row.
|
||||
|
||||
Returns ``device_id`` + opaque ``session_token`` (shown once). The
|
||||
token is stored only as ``token_hash`` so a Redis snapshot leak +
|
||||
DB snapshot leak still can't reconstruct it.
|
||||
"""
|
||||
body = request.get_json(silent=True) or {}
|
||||
user_code_raw = _normalize_user_code(str(body.get("user_code", "")))
|
||||
if not user_code_raw or len(user_code_raw) != _USER_CODE_LEN:
|
||||
return _error("invalid_user_code", 400)
|
||||
hostname = body.get("hostname") or "unknown"
|
||||
os_name = body.get("os") or ""
|
||||
arch = body.get("arch") or ""
|
||||
cli_version = body.get("cli_version") or ""
|
||||
pubkey_b64 = body.get("machine_pubkey")
|
||||
if not pubkey_b64:
|
||||
return _error("missing_machine_pubkey", 400)
|
||||
|
||||
redis_client = _redis()
|
||||
if redis_client is None:
|
||||
return _error("redis_unavailable", 503)
|
||||
try:
|
||||
device_code = redis_client.get(_user_code_index_key(user_code_raw))
|
||||
if device_code is None:
|
||||
return _error("pairing_not_found", 404)
|
||||
if isinstance(device_code, (bytes, bytearray)):
|
||||
device_code = device_code.decode("utf-8")
|
||||
except Exception:
|
||||
logger.exception("redis get failed during pairing redeem")
|
||||
return _error("redis_unavailable", 503)
|
||||
|
||||
state = _load_pairing(device_code)
|
||||
if state is None:
|
||||
return _error("pairing_not_found", 404)
|
||||
if state.get("status") != "pending":
|
||||
return _error("pairing_already_redeemed", 409)
|
||||
|
||||
# Atomically claim the pairing before doing any work. Two concurrent
|
||||
# redeems of the same code can both pass the check above; only the one
|
||||
# that flips ``pending`` -> ``redeemed`` here proceeds to create a device.
|
||||
claimed = _claim_pairing(redis_client, device_code)
|
||||
if claimed is None:
|
||||
return _error("redis_unavailable", 503)
|
||||
if not claimed:
|
||||
return _error("pairing_already_redeemed", 409)
|
||||
|
||||
user_id = state["user_id"]
|
||||
|
||||
try:
|
||||
fingerprint = fingerprint_pubkey(pubkey_b64)
|
||||
except Exception:
|
||||
return _error("invalid_machine_pubkey", 400)
|
||||
session_token = "tok_" + secrets.token_urlsafe(32)
|
||||
token_hash = hash_session_token(session_token)
|
||||
device_id = "dev_" + uuid.uuid4().hex
|
||||
|
||||
# Apply UI-side overrides stashed at pairing create time, with
|
||||
# sensible fallbacks.
|
||||
requested_name = state.get("requested_name") or None
|
||||
description = state.get("requested_description") or None
|
||||
approval_mode = state.get("requested_approval_mode") or "ask"
|
||||
if approval_mode not in _ALLOWED_APPROVAL_MODES:
|
||||
approval_mode = "ask"
|
||||
|
||||
name_source = requested_name or hostname
|
||||
# The claim has already been consumed, so a failed device insert would
|
||||
# strand a one-time code. ``_next_device_name`` only adds a 16-bit
|
||||
# suffix, so a ``UNIQUE (user_id, name)`` collision is possible; retry
|
||||
# with a fresh name a few times, and release the claim if every attempt
|
||||
# collides so the user can redeem the code again.
|
||||
name = None
|
||||
created = False
|
||||
for _attempt in range(_DEVICE_NAME_RETRIES):
|
||||
candidate = _next_device_name(user_id, name_source)
|
||||
try:
|
||||
with db_session() as conn:
|
||||
DevicesRepository(conn).create(
|
||||
device_id=device_id,
|
||||
user_id=user_id,
|
||||
name=candidate,
|
||||
machine_pubkey_fingerprint=fingerprint,
|
||||
token_hash=token_hash,
|
||||
hostname=hostname,
|
||||
os=os_name,
|
||||
arch=arch,
|
||||
cli_version=cli_version,
|
||||
approval_mode=approval_mode,
|
||||
description=description,
|
||||
)
|
||||
_upsert_remote_device_user_tool(
|
||||
conn, user_id=user_id, device_id=device_id,
|
||||
device_name=candidate, description=description,
|
||||
)
|
||||
name = candidate
|
||||
created = True
|
||||
break
|
||||
except IntegrityError:
|
||||
# Name collision on this attempt; the transaction rolled back.
|
||||
# Loop to try a freshly-generated name.
|
||||
logger.warning(
|
||||
"device name collision on redeem (attempt %d); retrying",
|
||||
_attempt + 1,
|
||||
)
|
||||
continue
|
||||
except Exception:
|
||||
logger.exception("failed to create device row during redeem")
|
||||
_release_claim(redis_client, device_code, state, user_code_raw)
|
||||
return _error("internal_error", 500)
|
||||
|
||||
if not created:
|
||||
logger.error(
|
||||
"device create exhausted name retries during redeem; "
|
||||
"releasing claim",
|
||||
)
|
||||
_release_claim(redis_client, device_code, state, user_code_raw)
|
||||
return _error("internal_error", 500)
|
||||
|
||||
state["status"] = "redeemed"
|
||||
state["device_id"] = device_id
|
||||
state["device_name"] = name
|
||||
try:
|
||||
redis_client.setex(
|
||||
_PAIRING_REDIS_PREFIX + device_code, 300, json.dumps(state)
|
||||
)
|
||||
if state.get("user_code_raw"):
|
||||
redis_client.delete(_user_code_index_key(state["user_code_raw"]))
|
||||
except Exception:
|
||||
logger.exception("redis update failed after redeem (non-fatal)")
|
||||
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"device_id": device_id,
|
||||
"session_token": session_token,
|
||||
"name": name,
|
||||
}
|
||||
),
|
||||
200,
|
||||
)
|
||||
|
||||
|
||||
def _claim_pairing(redis_client, device_code: str) -> Optional[bool]:
|
||||
"""Atomically transition a pairing from ``pending`` to ``redeemed``.
|
||||
|
||||
Returns ``True`` if this caller won the claim, ``False`` if the pairing
|
||||
is already non-pending or gone, and ``None`` if the atomic op itself
|
||||
failed (caller should treat that as Redis-unavailable rather than
|
||||
proceeding, to avoid a non-atomic double-redeem).
|
||||
"""
|
||||
key = _PAIRING_REDIS_PREFIX + device_code
|
||||
try:
|
||||
result = redis_client.eval(_CLAIM_PAIRING_LUA, 1, key)
|
||||
except Exception:
|
||||
logger.exception("redis eval failed during pairing claim")
|
||||
return None
|
||||
return bool(result)
|
||||
|
||||
|
||||
def _release_claim(
|
||||
redis_client, device_code: str, state: dict, user_code_raw: str,
|
||||
) -> None:
|
||||
"""Revert a consumed claim so the one-time code is redeemable again.
|
||||
|
||||
Called when the device row can't be created after the atomic claim
|
||||
already flipped the pairing to ``redeemed``. Restores ``pending`` and
|
||||
re-points the user-code index at the device code, preserving the
|
||||
remaining TTL where possible. Best-effort: failures are logged, not
|
||||
raised, since the caller is already returning an error.
|
||||
"""
|
||||
key = _PAIRING_REDIS_PREFIX + device_code
|
||||
try:
|
||||
ttl = redis_client.ttl(key)
|
||||
revert = dict(state)
|
||||
revert["status"] = "pending"
|
||||
revert.pop("device_id", None)
|
||||
revert.pop("device_name", None)
|
||||
encoded = json.dumps(revert)
|
||||
if isinstance(ttl, int) and ttl > 0:
|
||||
redis_client.setex(key, ttl, encoded)
|
||||
if user_code_raw:
|
||||
redis_client.setex(
|
||||
_user_code_index_key(user_code_raw), ttl, device_code
|
||||
)
|
||||
else:
|
||||
redis_client.set(key, encoded)
|
||||
if user_code_raw:
|
||||
redis_client.set(_user_code_index_key(user_code_raw), device_code)
|
||||
except Exception:
|
||||
logger.exception("failed to release pairing claim for %s", device_code)
|
||||
|
||||
|
||||
def _load_pairing(device_code: str) -> Optional[dict]:
|
||||
redis_client = _redis()
|
||||
if redis_client is None:
|
||||
return None
|
||||
try:
|
||||
raw = redis_client.get(_PAIRING_REDIS_PREFIX + device_code)
|
||||
except Exception:
|
||||
return None
|
||||
if raw is None:
|
||||
return None
|
||||
if isinstance(raw, (bytes, bytearray)):
|
||||
raw = raw.decode("utf-8")
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _next_device_name(user_id: str, hostname: str) -> str:
|
||||
"""Return a candidate name for the new device row.
|
||||
|
||||
Adds a random 16-bit suffix to reduce collisions on the DB
|
||||
``UNIQUE (user_id, name)`` constraint. Collisions are still possible,
|
||||
so ``redeem_pairing`` retries with a fresh candidate on conflict.
|
||||
"""
|
||||
base = hostname or "device"
|
||||
base = base.strip() or "device"
|
||||
return f"{base}-{os.urandom(2).hex()}"
|
||||
|
||||
|
||||
def _upsert_remote_device_user_tool(
|
||||
conn, *, user_id: str, device_id: str,
|
||||
device_name: str, description: Optional[str],
|
||||
) -> None:
|
||||
"""Create or update the ``user_tools`` row that fronts this device."""
|
||||
repo = UserToolsRepository(conn)
|
||||
existing = _find_remote_device_tool_row(conn, user_id, device_id)
|
||||
actions = [
|
||||
{
|
||||
"name": "run_command",
|
||||
"description": (
|
||||
f"Execute a shell command on the remote device '{device_name}'. "
|
||||
f"{description or ''}".strip()
|
||||
),
|
||||
"active": True,
|
||||
# ``tool_executor`` consults ``RemoteDeviceTool.preview_requires_approval``
|
||||
# live for ``remote_device`` calls, so the static snapshot must
|
||||
# stay neutral. The tool's own ``execute_action`` still yields
|
||||
# the awaiting-approval pause when the live decision says so.
|
||||
"require_approval": False,
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {
|
||||
"type": "string",
|
||||
"description": "Shell command to run.",
|
||||
"filled_by_llm": True,
|
||||
"value": "",
|
||||
},
|
||||
"working_directory": {
|
||||
"type": "string",
|
||||
"description": "Working directory on the remote.",
|
||||
"filled_by_llm": True,
|
||||
"value": "",
|
||||
},
|
||||
"timeout_ms": {
|
||||
"type": "integer",
|
||||
"description": "Timeout in milliseconds (max 600000).",
|
||||
"filled_by_llm": True,
|
||||
"value": "",
|
||||
},
|
||||
},
|
||||
"required": ["command"],
|
||||
},
|
||||
}
|
||||
]
|
||||
config = {"device_id": device_id}
|
||||
display_name = f"Remote device — {device_name}"
|
||||
if existing:
|
||||
repo.update(
|
||||
str(existing["id"]),
|
||||
user_id,
|
||||
{
|
||||
"display_name": display_name,
|
||||
"description": description or "",
|
||||
"config": config,
|
||||
"actions": actions,
|
||||
"status": True,
|
||||
},
|
||||
)
|
||||
else:
|
||||
repo.create(
|
||||
user_id=user_id,
|
||||
name="remote_device",
|
||||
display_name=display_name,
|
||||
description=description or "",
|
||||
config=config,
|
||||
actions=actions,
|
||||
status=True,
|
||||
)
|
||||
|
||||
|
||||
def _find_remote_device_tool_row(conn, user_id: str, device_id: str):
|
||||
"""Locate the user_tools row whose config.device_id matches."""
|
||||
from sqlalchemy import text
|
||||
row = conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT *
|
||||
FROM user_tools
|
||||
WHERE user_id = :user_id
|
||||
AND name = 'remote_device'
|
||||
AND config ->> 'device_id' = :device_id
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{"user_id": user_id, "device_id": device_id},
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return dict(row._mapping)
|
||||
|
||||
|
||||
def _error(code: str, status: int) -> tuple:
|
||||
return make_response(jsonify({"success": False, "error": code}), status)
|
||||
@@ -0,0 +1,327 @@
|
||||
"""Device CRUD + auto-approve pattern routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from flask import Blueprint, jsonify, make_response, request
|
||||
|
||||
from application.api.devices.pairing import (
|
||||
create_pairing,
|
||||
delete_pairing,
|
||||
get_pairing,
|
||||
redeem_pairing,
|
||||
)
|
||||
from application.api.devices.session import (
|
||||
ack_invocation,
|
||||
me,
|
||||
poll,
|
||||
session_events,
|
||||
submit_output,
|
||||
)
|
||||
from application.devices.normalizer import normalize_command
|
||||
from application.storage.db.repositories.device_audit_log import (
|
||||
DeviceAuditLogRepository,
|
||||
)
|
||||
from application.storage.db.repositories.device_auto_approve_patterns import (
|
||||
DeviceAutoApprovePatternsRepository,
|
||||
)
|
||||
from application.storage.db.repositories.devices import DevicesRepository
|
||||
from application.storage.db.session import db_readonly, db_session
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_ALLOWED_APPROVAL_MODES = {"ask", "full"}
|
||||
|
||||
|
||||
def _authed_user_id():
|
||||
decoded = getattr(request, "decoded_token", None)
|
||||
if isinstance(decoded, dict):
|
||||
return decoded.get("sub")
|
||||
return None
|
||||
|
||||
|
||||
def _serialize_device(row: dict) -> dict:
|
||||
"""Strip server-internal fields before returning to the UI."""
|
||||
out = {
|
||||
"id": row.get("id"),
|
||||
"name": row.get("name"),
|
||||
"hostname": row.get("hostname"),
|
||||
"os": row.get("os"),
|
||||
"arch": row.get("arch"),
|
||||
"cli_version": row.get("cli_version"),
|
||||
"approval_mode": row.get("approval_mode"),
|
||||
"description": row.get("description"),
|
||||
"status": row.get("status"),
|
||||
"paired_at": row.get("paired_at"),
|
||||
"last_seen_at": row.get("last_seen_at"),
|
||||
"revoked_at": row.get("revoked_at"),
|
||||
}
|
||||
# JSON-ify datetimes if SQLAlchemy returned them raw.
|
||||
for key in ("paired_at", "last_seen_at", "revoked_at"):
|
||||
value = out.get(key)
|
||||
if value is not None and not isinstance(value, str):
|
||||
out[key] = value.isoformat()
|
||||
return out
|
||||
|
||||
|
||||
def list_devices():
|
||||
user_id = _authed_user_id()
|
||||
if not user_id:
|
||||
return make_response(jsonify({"success": False, "error": "auth"}), 401)
|
||||
with db_readonly() as conn:
|
||||
rows = DevicesRepository(conn).list_for_user(user_id)
|
||||
return make_response(
|
||||
jsonify({"devices": [_serialize_device(r) for r in rows]}),
|
||||
200,
|
||||
)
|
||||
|
||||
|
||||
def get_device(device_id: str):
|
||||
user_id = _authed_user_id()
|
||||
if not user_id:
|
||||
return make_response(jsonify({"success": False, "error": "auth"}), 401)
|
||||
with db_readonly() as conn:
|
||||
row = DevicesRepository(conn).get(device_id, user_id=user_id)
|
||||
if row is None:
|
||||
return make_response(jsonify({"success": False, "error": "not_found"}), 404)
|
||||
return make_response(jsonify(_serialize_device(row)), 200)
|
||||
|
||||
|
||||
def update_device(device_id: str):
|
||||
user_id = _authed_user_id()
|
||||
if not user_id:
|
||||
return make_response(jsonify({"success": False, "error": "auth"}), 401)
|
||||
body = request.get_json(silent=True) or {}
|
||||
update_fields: dict = {}
|
||||
if "name" in body:
|
||||
name = body["name"]
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
return make_response(
|
||||
jsonify({"success": False, "error": "invalid_name"}), 400
|
||||
)
|
||||
update_fields["name"] = name.strip()
|
||||
if "description" in body:
|
||||
description = body["description"]
|
||||
if not isinstance(description, str):
|
||||
return make_response(
|
||||
jsonify({"success": False, "error": "invalid_description"}), 400
|
||||
)
|
||||
update_fields["description"] = description.strip()
|
||||
if "approval_mode" in body:
|
||||
mode = body["approval_mode"]
|
||||
if mode not in _ALLOWED_APPROVAL_MODES:
|
||||
return make_response(
|
||||
jsonify({"success": False, "error": "invalid_approval_mode"}), 400
|
||||
)
|
||||
update_fields["approval_mode"] = mode
|
||||
if not update_fields:
|
||||
return make_response(jsonify({"success": False, "error": "no_fields"}), 400)
|
||||
with db_session() as conn:
|
||||
ok = DevicesRepository(conn).update(device_id, user_id, update_fields)
|
||||
if not ok:
|
||||
return make_response(
|
||||
jsonify({"success": False, "error": "not_found"}), 404
|
||||
)
|
||||
# Reflect name/description into the user_tools row that fronts this device.
|
||||
if "name" in update_fields or "description" in update_fields:
|
||||
from application.api.devices.pairing import (
|
||||
_upsert_remote_device_user_tool,
|
||||
)
|
||||
row = DevicesRepository(conn).get(device_id, user_id=user_id)
|
||||
if row is not None:
|
||||
_upsert_remote_device_user_tool(
|
||||
conn,
|
||||
user_id=user_id,
|
||||
device_id=device_id,
|
||||
device_name=row.get("name") or "device",
|
||||
description=row.get("description"),
|
||||
)
|
||||
row = DevicesRepository(conn).get(device_id, user_id=user_id)
|
||||
return make_response(jsonify(_serialize_device(row)), 200)
|
||||
|
||||
|
||||
def delete_device(device_id: str):
|
||||
user_id = _authed_user_id()
|
||||
if not user_id:
|
||||
return make_response(jsonify({"success": False, "error": "auth"}), 401)
|
||||
with db_session() as conn:
|
||||
ok = DevicesRepository(conn).revoke(device_id, user_id)
|
||||
if not ok:
|
||||
return make_response(
|
||||
jsonify({"success": False, "error": "not_found"}), 404
|
||||
)
|
||||
# Drop the corresponding user_tools row so the tool picker stops showing it.
|
||||
from sqlalchemy import text
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
DELETE FROM user_tools
|
||||
WHERE user_id = :user_id
|
||||
AND name = 'remote_device'
|
||||
AND config ->> 'device_id' = :device_id
|
||||
"""
|
||||
),
|
||||
{"user_id": user_id, "device_id": device_id},
|
||||
)
|
||||
DeviceAutoApprovePatternsRepository(conn).clear_for_device(device_id)
|
||||
return make_response(jsonify({"success": True}), 200)
|
||||
|
||||
|
||||
def add_auto_approve_pattern(device_id: str):
|
||||
"""Server-side normalization: client sends the raw command, we store the pattern."""
|
||||
user_id = _authed_user_id()
|
||||
if not user_id:
|
||||
return make_response(jsonify({"success": False, "error": "auth"}), 401)
|
||||
body = request.get_json(silent=True) or {}
|
||||
command = body.get("command") or body.get("pattern")
|
||||
if not command:
|
||||
return make_response(
|
||||
jsonify({"success": False, "error": "missing_command"}), 400
|
||||
)
|
||||
if not isinstance(command, str):
|
||||
return make_response(
|
||||
jsonify({"success": False, "error": "invalid_command"}), 400
|
||||
)
|
||||
pattern = normalize_command(command)
|
||||
if not pattern:
|
||||
return make_response(
|
||||
jsonify({"success": False, "error": "invalid_command"}), 400
|
||||
)
|
||||
with db_session() as conn:
|
||||
if DevicesRepository(conn).get(device_id, user_id=user_id) is None:
|
||||
return make_response(
|
||||
jsonify({"success": False, "error": "not_found"}), 404
|
||||
)
|
||||
DeviceAutoApprovePatternsRepository(conn).add(device_id, user_id, pattern)
|
||||
return make_response(jsonify({"pattern": pattern}), 200)
|
||||
|
||||
|
||||
def list_auto_approve_patterns(device_id: str):
|
||||
user_id = _authed_user_id()
|
||||
if not user_id:
|
||||
return make_response(jsonify({"success": False, "error": "auth"}), 401)
|
||||
with db_readonly() as conn:
|
||||
if DevicesRepository(conn).get(device_id, user_id=user_id) is None:
|
||||
return make_response(
|
||||
jsonify({"success": False, "error": "not_found"}), 404
|
||||
)
|
||||
patterns = DeviceAutoApprovePatternsRepository(conn).list_for_device(
|
||||
device_id, user_id
|
||||
)
|
||||
return make_response(jsonify({"patterns": patterns}), 200)
|
||||
|
||||
|
||||
def delete_auto_approve_pattern(device_id: str):
|
||||
user_id = _authed_user_id()
|
||||
if not user_id:
|
||||
return make_response(jsonify({"success": False, "error": "auth"}), 401)
|
||||
body = request.get_json(silent=True) or {}
|
||||
pattern = body.get("pattern")
|
||||
if not pattern:
|
||||
return make_response(
|
||||
jsonify({"success": False, "error": "missing_pattern"}), 400
|
||||
)
|
||||
with db_session() as conn:
|
||||
ok = DeviceAutoApprovePatternsRepository(conn).remove(
|
||||
device_id, user_id, pattern
|
||||
)
|
||||
return make_response(jsonify({"success": ok}), 200)
|
||||
|
||||
|
||||
def list_audit(device_id: str):
|
||||
user_id = _authed_user_id()
|
||||
if not user_id:
|
||||
return make_response(jsonify({"success": False, "error": "auth"}), 401)
|
||||
with db_readonly() as conn:
|
||||
if DevicesRepository(conn).get(device_id, user_id=user_id) is None:
|
||||
return make_response(
|
||||
jsonify({"success": False, "error": "not_found"}), 404
|
||||
)
|
||||
rows = DeviceAuditLogRepository(conn).list_for_device(device_id, user_id)
|
||||
# Normalize datetimes to ISO strings.
|
||||
serialized = []
|
||||
for row in rows:
|
||||
out = dict(row)
|
||||
for key in ("issued_at", "started_at", "finished_at", "created_at"):
|
||||
v = out.get(key)
|
||||
if v is not None and not isinstance(v, str):
|
||||
out[key] = v.isoformat()
|
||||
serialized.append(out)
|
||||
return make_response(jsonify({"entries": serialized}), 200)
|
||||
|
||||
|
||||
def register(bp: Blueprint) -> None:
|
||||
"""Attach all device routes to ``bp``."""
|
||||
bp.add_url_rule(
|
||||
"/api/devices", view_func=list_devices, methods=["GET"], endpoint="list_devices",
|
||||
)
|
||||
bp.add_url_rule(
|
||||
"/api/devices/<device_id>", view_func=get_device, methods=["GET"],
|
||||
endpoint="get_device",
|
||||
)
|
||||
bp.add_url_rule(
|
||||
"/api/devices/<device_id>", view_func=update_device, methods=["PATCH"],
|
||||
endpoint="update_device",
|
||||
)
|
||||
bp.add_url_rule(
|
||||
"/api/devices/<device_id>", view_func=delete_device, methods=["DELETE"],
|
||||
endpoint="delete_device",
|
||||
)
|
||||
bp.add_url_rule(
|
||||
"/api/devices/<device_id>/auto-approve",
|
||||
view_func=add_auto_approve_pattern, methods=["POST"],
|
||||
endpoint="add_auto_approve_pattern",
|
||||
)
|
||||
bp.add_url_rule(
|
||||
"/api/devices/<device_id>/auto-approve",
|
||||
view_func=list_auto_approve_patterns, methods=["GET"],
|
||||
endpoint="list_auto_approve_patterns",
|
||||
)
|
||||
bp.add_url_rule(
|
||||
"/api/devices/<device_id>/auto-approve",
|
||||
view_func=delete_auto_approve_pattern, methods=["DELETE"],
|
||||
endpoint="delete_auto_approve_pattern",
|
||||
)
|
||||
bp.add_url_rule(
|
||||
"/api/devices/<device_id>/audit", view_func=list_audit, methods=["GET"],
|
||||
endpoint="list_audit",
|
||||
)
|
||||
# Pairing endpoints
|
||||
bp.add_url_rule(
|
||||
"/api/devices/pairings", view_func=create_pairing, methods=["POST"],
|
||||
endpoint="create_pairing",
|
||||
)
|
||||
bp.add_url_rule(
|
||||
"/api/devices/pairings/redeem", view_func=redeem_pairing, methods=["POST"],
|
||||
endpoint="redeem_pairing",
|
||||
)
|
||||
bp.add_url_rule(
|
||||
"/api/devices/pairings/<device_code>", view_func=get_pairing, methods=["GET"],
|
||||
endpoint="get_pairing",
|
||||
)
|
||||
bp.add_url_rule(
|
||||
"/api/devices/pairings/<device_code>", view_func=delete_pairing,
|
||||
methods=["DELETE"], endpoint="delete_pairing",
|
||||
)
|
||||
# Session endpoints (device-token auth)
|
||||
bp.add_url_rule(
|
||||
"/api/devices/poll", view_func=poll, methods=["GET"], endpoint="device_poll",
|
||||
)
|
||||
bp.add_url_rule(
|
||||
"/api/devices/me", view_func=me, methods=["GET"], endpoint="device_me",
|
||||
)
|
||||
bp.add_url_rule(
|
||||
"/api/devices/sessions/<session_id>/events", view_func=session_events,
|
||||
methods=["GET"], endpoint="device_session_events",
|
||||
)
|
||||
bp.add_url_rule(
|
||||
"/api/devices/sessions/<session_id>/invocations/<invocation_id>/ack",
|
||||
view_func=ack_invocation, methods=["POST"], endpoint="device_ack",
|
||||
)
|
||||
bp.add_url_rule(
|
||||
"/api/devices/sessions/<session_id>/invocations/<invocation_id>/output",
|
||||
view_func=submit_output, methods=["POST"], endpoint="device_output",
|
||||
)
|
||||
@@ -0,0 +1,272 @@
|
||||
"""Device session endpoints: poll, SSE, ack, output."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import gzip
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Iterator
|
||||
|
||||
from flask import Response, jsonify, make_response, request, stream_with_context
|
||||
|
||||
from application.api.devices.auth import verify_device_session
|
||||
from application.core.settings import settings
|
||||
from application.core.shutdown import is_shutting_down
|
||||
from application.devices.broker import get_broker
|
||||
from application.storage.db.repositories.device_audit_log import (
|
||||
DeviceAuditLogRepository,
|
||||
)
|
||||
from application.storage.db.session import db_session
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Window (seconds) the CLI has to upgrade a poll-issued ticket to an SSE
|
||||
# stream. Advertised to the CLI as ``expires_in`` and used as the broker
|
||||
# ticket TTL so the two never drift.
|
||||
_SESSION_TICKET_TTL_SECONDS = 30
|
||||
|
||||
|
||||
def poll() -> Response:
|
||||
"""``GET /api/devices/poll`` — long-poll for queued invocations.
|
||||
|
||||
Returns ``202`` with empty body when nothing is queued and ``200`` with
|
||||
a session ticket payload when work is waiting.
|
||||
"""
|
||||
device, err = verify_device_session()
|
||||
if err is not None:
|
||||
return err
|
||||
broker = get_broker()
|
||||
ticket = broker.claim_ticket(device["id"], _SESSION_TICKET_TTL_SECONDS)
|
||||
if ticket is None:
|
||||
return make_response("", 202)
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"session_ticket": ticket,
|
||||
"session_url": f"/api/devices/sessions/{ticket}/events",
|
||||
"expires_in": _SESSION_TICKET_TTL_SECONDS,
|
||||
}
|
||||
),
|
||||
200,
|
||||
)
|
||||
|
||||
|
||||
def me() -> Response:
|
||||
"""``GET /api/devices/me`` — return the calling device's own record.
|
||||
|
||||
Auth: device session token (same as ``/poll``). Used by ``docsgpt-cli
|
||||
host status`` to show live server state.
|
||||
"""
|
||||
device, err = verify_device_session()
|
||||
if err is not None:
|
||||
return err
|
||||
out = {
|
||||
"id": device.get("id"),
|
||||
"name": device.get("name"),
|
||||
"hostname": device.get("hostname"),
|
||||
"os": device.get("os"),
|
||||
"status": device.get("status"),
|
||||
"approval_mode": device.get("approval_mode"),
|
||||
"description": device.get("description"),
|
||||
"paired_at": device.get("paired_at"),
|
||||
"last_seen_at": device.get("last_seen_at"),
|
||||
}
|
||||
for key in ("paired_at", "last_seen_at"):
|
||||
value = out.get(key)
|
||||
if value is not None and not isinstance(value, str):
|
||||
out[key] = value.isoformat()
|
||||
return make_response(jsonify(out), 200)
|
||||
|
||||
|
||||
def session_events(session_id: str) -> Response:
|
||||
"""``GET /api/devices/sessions/{id}/events`` — SSE invocation stream.
|
||||
|
||||
The ``session_id`` must be the ``session_ticket`` the device's own
|
||||
``/poll`` just issued (the path it was handed as ``session_url``). A
|
||||
stale, mismatched, or fabricated ticket is rejected with ``410 Gone``
|
||||
before any stream is opened.
|
||||
"""
|
||||
device, err = verify_device_session()
|
||||
if err is not None:
|
||||
return err
|
||||
broker = get_broker()
|
||||
if not broker.validate_ticket(device["id"], session_id):
|
||||
return make_response(
|
||||
jsonify({"success": False, "error": "session_ticket_invalid"}), 410
|
||||
)
|
||||
sess = broker.register_session(device["id"], device["user_id"])
|
||||
|
||||
keepalive_interval = float(settings.SSE_KEEPALIVE_SECONDS)
|
||||
idle_seconds = float(settings.REMOTE_DEVICE_SESSION_IDLE_SECONDS)
|
||||
|
||||
@stream_with_context
|
||||
def generate() -> Iterator[str]:
|
||||
try:
|
||||
last_keepalive = time.time()
|
||||
while not sess.closed.is_set():
|
||||
# Break promptly on shutdown (see application/core/shutdown.py).
|
||||
if is_shutting_down():
|
||||
break
|
||||
now = time.time()
|
||||
if now - sess.last_activity_at > idle_seconds:
|
||||
yield _sse_event(
|
||||
"session_end",
|
||||
{"reason": "inactivity_timeout"},
|
||||
sess.last_event_id + 1,
|
||||
)
|
||||
sess.last_event_id += 1
|
||||
broker.close_session(sess.session_id, reason="idle")
|
||||
return
|
||||
envelope = broker.next_command(sess, timeout=1.0)
|
||||
if envelope is None:
|
||||
if time.time() - last_keepalive >= keepalive_interval:
|
||||
last_keepalive = time.time()
|
||||
yield ": heartbeat\n\n"
|
||||
continue
|
||||
sess.last_event_id += 1
|
||||
sess.last_activity_at = time.time()
|
||||
yield _sse_event("invocation", envelope, sess.last_event_id)
|
||||
last_keepalive = time.time()
|
||||
except GeneratorExit:
|
||||
logger.debug("device SSE generator exiting for session %s", sess.session_id)
|
||||
raise
|
||||
finally:
|
||||
broker.close_session(sess.session_id, reason="generator_exit")
|
||||
|
||||
return Response(
|
||||
generate(),
|
||||
mimetype="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def ack_invocation(session_id: str, invocation_id: str) -> Response:
|
||||
"""CLI acks (accepted / denied / auto_approved) the invocation."""
|
||||
device, err = verify_device_session()
|
||||
if err is not None:
|
||||
return err
|
||||
body = request.get_json(silent=True) or {}
|
||||
decision = body.get("decision")
|
||||
reason = body.get("reason")
|
||||
if decision not in {"accepted", "denied", "auto_approved"}:
|
||||
return make_response(
|
||||
jsonify({"success": False, "error": "invalid_decision"}), 400
|
||||
)
|
||||
broker = get_broker()
|
||||
inv = broker.get_invocation(invocation_id)
|
||||
if inv is None or inv.device_id != device["id"]:
|
||||
return make_response(
|
||||
jsonify({"success": False, "error": "invocation_not_found"}), 404
|
||||
)
|
||||
broker.submit_ack(invocation_id, decision, reason)
|
||||
if decision == "denied":
|
||||
# A denial is terminal and produces no device output, so submit_output's
|
||||
# audit write is never reached. Record the outcome here from locally
|
||||
# known facts (not re-read Redis state the agent's drain races to clean
|
||||
# up), so the audit row reflects the denial instead of staying
|
||||
# "dispatched". Accepted/auto_approved runs record via submit_output.
|
||||
from datetime import datetime, timezone
|
||||
try:
|
||||
with db_session() as conn:
|
||||
DeviceAuditLogRepository(conn).record_result(
|
||||
invocation_id,
|
||||
finished_at=datetime.now(timezone.utc),
|
||||
error="denied",
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("audit record_result (denied) failed for %s", invocation_id)
|
||||
return make_response(jsonify({"success": True}), 200)
|
||||
|
||||
|
||||
def submit_output(session_id: str, invocation_id: str) -> Response:
|
||||
"""CLI streams stdout/stderr/control chunks (NDJSON, gzip-aware)."""
|
||||
device, err = verify_device_session()
|
||||
if err is not None:
|
||||
return err
|
||||
broker = get_broker()
|
||||
inv = broker.get_invocation(invocation_id)
|
||||
if inv is None or inv.device_id != device["id"]:
|
||||
return make_response(
|
||||
jsonify({"success": False, "error": "invocation_not_found"}), 404
|
||||
)
|
||||
|
||||
body = request.get_data() or b""
|
||||
if request.headers.get("Content-Encoding", "").lower() == "gzip":
|
||||
try:
|
||||
body = gzip.decompress(body)
|
||||
except OSError:
|
||||
return make_response(
|
||||
jsonify({"success": False, "error": "invalid_gzip"}), 400
|
||||
)
|
||||
|
||||
received = 0
|
||||
control_chunk = None
|
||||
for line in io.BytesIO(body):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
chunk = json.loads(line.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
continue
|
||||
if not isinstance(chunk, dict):
|
||||
continue
|
||||
if chunk.get("stream") == "control":
|
||||
control_chunk = chunk
|
||||
broker.submit_output_chunk(invocation_id, chunk)
|
||||
received += 1
|
||||
|
||||
# Persist the outcome when the closing control chunk arrived in this POST.
|
||||
# Its fields are captured locally so the audit write survives the draining
|
||||
# (worker) process racing to delete the invocation's Redis state. Byte
|
||||
# totals / started_at live in the hash, read best-effort (the functional
|
||||
# exit_code/error/duration still land even if the hash is already gone).
|
||||
if control_chunk is not None:
|
||||
from datetime import datetime, timezone
|
||||
snap = broker.get_invocation(invocation_id)
|
||||
try:
|
||||
with db_session() as conn:
|
||||
DeviceAuditLogRepository(conn).record_result(
|
||||
invocation_id,
|
||||
started_at=(
|
||||
datetime.fromtimestamp(snap.started_at, tz=timezone.utc)
|
||||
if snap is not None and snap.started_at else None
|
||||
),
|
||||
finished_at=datetime.now(timezone.utc),
|
||||
exit_code=_as_opt_int(control_chunk.get("exit_code")),
|
||||
duration_ms=_as_opt_int(control_chunk.get("duration_ms")),
|
||||
stdout_bytes=(snap.stdout_bytes if snap is not None else 0),
|
||||
stderr_bytes=(snap.stderr_bytes if snap is not None else 0),
|
||||
error=control_chunk.get("error"),
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("audit record_result failed for %s", invocation_id)
|
||||
|
||||
return make_response(
|
||||
jsonify({"success": True, "received": received}), 200
|
||||
)
|
||||
|
||||
|
||||
def _as_opt_int(value) -> int | None:
|
||||
"""Coerce a CLI-supplied JSON value to int for an INTEGER audit column."""
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _sse_event(name: str, payload: dict, event_id: int) -> str:
|
||||
return (
|
||||
f"event: {name}\n"
|
||||
f"id: {event_id}\n"
|
||||
f"data: {json.dumps(payload)}\n\n"
|
||||
)
|
||||
Reference in New Issue
Block a user