Skip to content

Commit 086efd8

Browse files
authored
MOTO v1.0.8 Bug Fix (#41)
## Bug Fix Summary - Fixes local Lean proof certificate downloads being rejected by desktop API-token middleware. - Keeps hosted/generic proxy auth unchanged. - Hardens proof certificate export errors for malformed proof IDs.
1 parent cd40103 commit 086efd8

5 files changed

Lines changed: 88 additions & 29 deletions

File tree

.cursor/rules/hosted-web-contract.mdc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ One process pair = one MOTO instance (local or sandbox). Env inputs:
5454
- `MOTO_INSTANCE_ID`, `MOTO_BACKEND_HOST`/`HOST`, `MOTO_BACKEND_PORT`/`PORT`
5555
- `MOTO_DATA_ROOT`, optional `MOTO_LOG_ROOT`, optional `MOTO_SECRET_NAMESPACE`
5656
- optional `MOTO_FRONTEND_STORAGE_PREFIX`, optional `MOTO_CORS_ORIGINS`, optional `MOTO_LM_STUDIO_BASE_URL`
57-
- Default desktop launches bind backend and bundled Vite frontend to loopback and require `MOTO_DESKTOP_API_TOKEN` / `VITE_MOTO_DESKTOP_API_TOKEN` on protected HTTP routes. Desktop WebSockets use one-time tickets minted by authenticated `POST /api/ws-ticket`; hosted generic mode continues to use proxy HMAC auth instead.
57+
- Default desktop launches bind backend and bundled Vite frontend to loopback and require `MOTO_DESKTOP_API_TOKEN` / `VITE_MOTO_DESKTOP_API_TOKEN` on protected HTTP routes, except read-only proof certificate exports (`/api/proofs/{id}/certificate[.lean]`) which may be direct local browser downloads. Desktop WebSockets use one-time tickets minted by authenticated `POST /api/ws-ticket`; hosted generic mode continues to use proxy HMAC auth instead.
5858

5959
Hosted sandboxes reuse this exact contract (`MOTO_DATA_ROOT=/app/backend/data`). No separate hosted-only env model.
6060

backend/api/middleware.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"""
44
import hmac
55
import os
6+
import re
67
from urllib.parse import urlparse
78
from fastapi import FastAPI, Request
89
from fastapi.middleware.cors import CORSMiddleware
@@ -31,6 +32,16 @@
3132
]
3233
DESKTOP_API_TOKEN_HEADER = "X-Moto-Desktop-Token"
3334
UNSAFE_HTTP_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
35+
DESKTOP_PUBLIC_PROOF_EXPORT_RE = re.compile(r"^/api/proofs/[^/]+/certificate(?:\.lean)?$")
36+
37+
38+
def _is_desktop_public_export(method: str, path: str) -> bool:
39+
"""Allow direct local browser downloads for read-only generated artifacts."""
40+
normalized_method = (method or "").upper()
41+
normalized_path = path or ""
42+
if normalized_method not in {"GET", "HEAD"}:
43+
return False
44+
return bool(DESKTOP_PUBLIC_PROOF_EXPORT_RE.fullmatch(normalized_path))
3445

3546

3647
def _origin_from_url(value: str) -> str:
@@ -45,6 +56,8 @@ def _validate_desktop_token(request: Request, allowed_origins: list[str]) -> Non
4556
"""Require the launcher-provided desktop API token outside public routes."""
4657
if is_proxy_auth_allowlisted(request.method, request.url.path):
4758
return
59+
if _is_desktop_public_export(request.method, request.url.path):
60+
return
4861

4962
expected = (system_config.desktop_api_token or "").strip()
5063
if not expected:

backend/api/routes/proofs.py

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,23 @@ def _safe_path_label(path_value: str) -> str:
5151
return "[configured]"
5252

5353

54+
async def _get_export_proof_or_404(proof_id: str):
55+
try:
56+
proof = await proof_database.get_proof(proof_id)
57+
except ValueError:
58+
raise HTTPException(status_code=404, detail="Proof not found")
59+
if proof is None:
60+
raise HTTPException(status_code=404, detail="Proof not found")
61+
return proof
62+
63+
64+
async def _get_export_lean_code(proof_id: str) -> str:
65+
try:
66+
return await proof_database.get_lean_code(proof_id)
67+
except ValueError:
68+
raise HTTPException(status_code=404, detail="Proof not found")
69+
70+
5471
def _build_model_config(role: ProofRoleConfigSnapshot) -> ModelConfig:
5572
return ModelConfig(
5673
provider=role.provider,
@@ -530,21 +547,19 @@ async def get_library_proof(session_id: str, proof_id: str):
530547
@router.get("/{proof_id}/certificate")
531548
async def get_proof_certificate(proof_id: str):
532549
"""Return a machine-readable proof certificate JSON payload."""
533-
proof = await proof_database.get_proof(proof_id)
534-
if proof is None:
535-
raise HTTPException(status_code=404, detail="Proof not found")
550+
proof = await _get_export_proof_or_404(proof_id)
536551

537552
lean_version = ""
538553
mathlib_commit = ""
539554
if system_config.lean4_enabled:
540555
try:
541556
client = get_lean4_client()
542-
lean_version = await client.get_version()
557+
lean_version = await asyncio.wait_for(client.get_version(), timeout=5.0)
543558
mathlib_commit = client.get_mathlib_commit()
544-
except Exception:
545-
pass
559+
except (asyncio.TimeoutError, Exception) as exc:
560+
logger.warning("Lean 4 certificate metadata lookup timed out or failed: %s", exc)
546561

547-
lean_code = await proof_database.get_lean_code(proof_id)
562+
lean_code = await _get_export_lean_code(proof_id)
548563
payload = {
549564
"proof_id": proof.proof_id,
550565
"theorem_statement": proof.theorem_statement,
@@ -574,11 +589,9 @@ async def get_proof_certificate(proof_id: str):
574589
@router.get("/{proof_id}/certificate.lean")
575590
async def get_proof_certificate_lean(proof_id: str):
576591
"""Return the raw saved Lean file for a proof."""
577-
proof = await proof_database.get_proof(proof_id)
578-
if proof is None:
579-
raise HTTPException(status_code=404, detail="Proof not found")
592+
proof = await _get_export_proof_or_404(proof_id)
580593

581-
lean_code = await proof_database.get_lean_code(proof_id)
594+
lean_code = await _get_export_lean_code(proof_id)
582595
return PlainTextResponse(
583596
content=lean_code or proof.lean_code,
584597
headers={

frontend/src/components/autonomous/MathematicalProofs.jsx

Lines changed: 38 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
buildCurrentProofRuntimeConfig,
66
isProofRuntimeConfigComplete,
77
} from '../../hooks/useProofCheckRuntime';
8+
import { downloadTextFile } from '../../utils/downloadHelpers';
89

910
function formatDate(isoString) {
1011
if (!isoString) {
@@ -419,6 +420,31 @@ function MathematicalProofs({ api, refreshToken = 0, selectedProofId = null, lat
419420
}
420421
};
421422

423+
const handleDownloadLeanProof = async (proof) => {
424+
try {
425+
const leanCode = await api.getProofLeanSource(proof.proof_id);
426+
if (!leanCode) {
427+
throw new Error('Lean source is unavailable for this proof.');
428+
}
429+
downloadTextFile(leanCode, `${proof.proof_id}.lean`, 'text/plain');
430+
} catch (err) {
431+
setError(`Failed to download Lean proof: ${err.message}`);
432+
}
433+
};
434+
435+
const handleDownloadCertificate = async (proof) => {
436+
try {
437+
const certificate = await api.getProofCertificate(proof.proof_id);
438+
downloadTextFile(
439+
JSON.stringify(certificate, null, 2),
440+
`${proof.proof_id}_certificate.json`,
441+
'application/json'
442+
);
443+
} catch (err) {
444+
setError(`Failed to download proof certificate: ${err.message}`);
445+
}
446+
};
447+
422448
return (
423449
<div className="math-proofs-view">
424450
<div className="math-proofs-header">
@@ -646,13 +672,13 @@ function MathematicalProofs({ api, refreshToken = 0, selectedProofId = null, lat
646672
</div>
647673

648674
<div className="math-proof-card-actions">
649-
<a
675+
<button
676+
type="button"
650677
className="math-proof-download math-proof-download--compact"
651-
href={api.getProofLeanDownloadUrl(proof.proof_id)}
652-
download={`${proof.proof_id}.lean`}
678+
onClick={() => handleDownloadLeanProof(proof)}
653679
>
654680
Download .lean
655-
</a>
681+
</button>
656682
<button
657683
className="math-proof-expand"
658684
onClick={() => setExpandedProofId(isExpanded ? null : proof.proof_id)}
@@ -671,20 +697,20 @@ function MathematicalProofs({ api, refreshToken = 0, selectedProofId = null, lat
671697
{isExpanded && (
672698
<div className="math-proof-details">
673699
<div className="math-proof-actions">
674-
<a
700+
<button
701+
type="button"
675702
className="math-proof-download"
676-
href={api.getProofCertificateUrl(proof.proof_id)}
677-
download={`${proof.proof_id}_certificate.json`}
703+
onClick={() => handleDownloadCertificate(proof)}
678704
>
679705
Download Certificate (JSON)
680-
</a>
681-
<a
706+
</button>
707+
<button
708+
type="button"
682709
className="math-proof-download"
683-
href={api.getProofLeanDownloadUrl(proof.proof_id)}
684-
download={`${proof.proof_id}.lean`}
710+
onClick={() => handleDownloadLeanProof(proof)}
685711
>
686712
Download .lean
687-
</a>
713+
</button>
688714
</div>
689715

690716
{proof.theorem_name && (

frontend/src/services/api.js

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -592,13 +592,20 @@ export const autonomousAPI = {
592592
return response.json();
593593
},
594594

595-
// Download URLs for machine-readable proof certificates
596-
getProofCertificateUrl(proofId) {
597-
return `${API_BASE}/proofs/${encodeURIComponent(proofId)}/certificate`;
595+
async getProofCertificate(proofId) {
596+
const response = await fetch(`${API_BASE}/proofs/${encodeURIComponent(proofId)}/certificate`);
597+
if (!response.ok) {
598+
await throwFromResponse(response, `Failed to get proof certificate for ${proofId}`);
599+
}
600+
return response.json();
598601
},
599602

600-
getProofLeanDownloadUrl(proofId) {
601-
return `${API_BASE}/proofs/${encodeURIComponent(proofId)}/certificate.lean`;
603+
async getProofLeanSource(proofId) {
604+
const response = await fetch(`${API_BASE}/proofs/${encodeURIComponent(proofId)}/certificate.lean`);
605+
if (!response.ok) {
606+
await throwFromResponse(response, `Failed to get Lean source for ${proofId}`);
607+
}
608+
return response.text();
602609
},
603610

604611
async getProofLibrary(novelOnly = true) {

0 commit comments

Comments
 (0)