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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:28:29 +08:00
commit fed8b2eed7
1531 changed files with 1107494 additions and 0 deletions
+11
View File
@@ -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)
+141
View File
@@ -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
)
+518
View File
@@ -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)
+327
View File
@@ -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",
)
+272
View File
@@ -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"
)