mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-02-24 03:05:48 +00:00
Compare commits
6 Commits
ci_helm
...
experiment
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3b8695716 | ||
|
|
e7ce26a18d | ||
|
|
f97c56ffb9 | ||
|
|
aa14ee3059 | ||
|
|
ce69c91ce5 | ||
|
|
10f731c2f4 |
@@ -0,0 +1,31 @@
|
||||
"""add sharing_scope to build_session
|
||||
|
||||
Revision ID: c7f2e1b4a9d3
|
||||
Revises: 19c0ccb01687
|
||||
Create Date: 2026-02-17 12:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "c7f2e1b4a9d3"
|
||||
down_revision = "19c0ccb01687"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"build_session",
|
||||
sa.Column(
|
||||
"sharing_scope",
|
||||
sa.String(),
|
||||
nullable=False,
|
||||
server_default="private",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("build_session", "sharing_scope")
|
||||
@@ -232,6 +232,12 @@ class BuildSessionStatus(str, PyEnum):
|
||||
IDLE = "idle"
|
||||
|
||||
|
||||
class SharingScope(str, PyEnum):
|
||||
PRIVATE = "private"
|
||||
PUBLIC_ORG = "public_org"
|
||||
PUBLIC_GLOBAL = "public_global"
|
||||
|
||||
|
||||
class SandboxStatus(str, PyEnum):
|
||||
PROVISIONING = "provisioning"
|
||||
RUNNING = "running"
|
||||
|
||||
@@ -77,6 +77,7 @@ from onyx.db.enums import (
|
||||
ThemePreference,
|
||||
DefaultAppMode,
|
||||
SwitchoverType,
|
||||
SharingScope,
|
||||
)
|
||||
from onyx.configs.constants import NotificationType
|
||||
from onyx.configs.constants import SearchFeedbackType
|
||||
@@ -4712,6 +4713,12 @@ class BuildSession(Base):
|
||||
demo_data_enabled: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, server_default=text("true")
|
||||
)
|
||||
sharing_scope: Mapped[SharingScope] = mapped_column(
|
||||
String,
|
||||
nullable=False,
|
||||
default=SharingScope.PRIVATE,
|
||||
server_default="private",
|
||||
)
|
||||
|
||||
# Relationships
|
||||
user: Mapped[User | None] = relationship("User", foreign_keys=[user_id])
|
||||
|
||||
@@ -64,7 +64,7 @@ from onyx.server.documents.connector import router as connector_router
|
||||
from onyx.server.documents.credential import router as credential_router
|
||||
from onyx.server.documents.document import router as document_router
|
||||
from onyx.server.documents.standard_oauth import router as standard_oauth_router
|
||||
from onyx.server.features.build.api.api import nextjs_assets_router
|
||||
from onyx.server.features.build.api.api import public_build_router
|
||||
from onyx.server.features.build.api.api import router as build_router
|
||||
from onyx.server.features.default_assistant.api import (
|
||||
router as default_assistant_router,
|
||||
@@ -378,8 +378,8 @@ def get_application(lifespan_override: Lifespan | None = None) -> FastAPI:
|
||||
include_router_with_global_prefix_prepended(application, admin_input_prompt_router)
|
||||
include_router_with_global_prefix_prepended(application, cc_pair_router)
|
||||
include_router_with_global_prefix_prepended(application, projects_router)
|
||||
include_router_with_global_prefix_prepended(application, public_build_router)
|
||||
include_router_with_global_prefix_prepended(application, build_router)
|
||||
include_router_with_global_prefix_prepended(application, nextjs_assets_router)
|
||||
include_router_with_global_prefix_prepended(application, document_set_router)
|
||||
include_router_with_global_prefix_prepended(application, hierarchy_router)
|
||||
include_router_with_global_prefix_prepended(application, search_settings_router)
|
||||
|
||||
@@ -59,6 +59,9 @@ PUBLIC_ENDPOINT_SPECS = [
|
||||
# anonymous user on cloud
|
||||
("/tenants/anonymous-user", {"POST"}),
|
||||
("/metrics", {"GET"}), # added by prometheus_fastapi_instrumentator
|
||||
# craft webapp proxy — public when session.is_public=True (access enforced in handler)
|
||||
("/build/sessions/{session_id}/webapp", {"GET"}),
|
||||
("/build/sessions/{session_id}/webapp/{path:path}", {"GET"}),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -11,12 +11,14 @@ from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from onyx.auth.users import current_user
|
||||
from onyx.auth.users import optional_user
|
||||
from onyx.configs.constants import DocumentSource
|
||||
from onyx.db.connector_credential_pair import get_connector_credential_pairs_for_user
|
||||
from onyx.db.engine.sql_engine import get_session
|
||||
from onyx.db.enums import ConnectorCredentialPairStatus
|
||||
from onyx.db.enums import IndexingStatus
|
||||
from onyx.db.enums import ProcessingMode
|
||||
from onyx.db.enums import SharingScope
|
||||
from onyx.db.index_attempt import get_latest_index_attempt_for_cc_pair_id
|
||||
from onyx.db.models import BuildSession
|
||||
from onyx.db.models import User
|
||||
@@ -217,12 +219,15 @@ def get_build_connectors(
|
||||
return BuildConnectorListResponse(connectors=connectors)
|
||||
|
||||
|
||||
# Headers to skip when proxying (hop-by-hop headers)
|
||||
# Headers to skip when proxying.
|
||||
# Hop-by-hop headers must not be forwarded, and set-cookie is stripped to
|
||||
# prevent LLM-generated apps from setting cookies on the parent Onyx domain.
|
||||
EXCLUDED_HEADERS = {
|
||||
"content-encoding",
|
||||
"content-length",
|
||||
"transfer-encoding",
|
||||
"connection",
|
||||
"set-cookie",
|
||||
}
|
||||
|
||||
|
||||
@@ -365,71 +370,176 @@ def _proxy_request(
|
||||
raise HTTPException(status_code=502, detail="Bad gateway")
|
||||
|
||||
|
||||
@router.get("/sessions/{session_id}/webapp", response_model=None)
|
||||
def _check_webapp_access(
|
||||
session_id: UUID, user: User | None, db_session: Session
|
||||
) -> BuildSession:
|
||||
"""Check if user can access a session's webapp.
|
||||
|
||||
- public_global: accessible by anyone (no auth required)
|
||||
- public_org: accessible by any authenticated user
|
||||
- private: only accessible by the session owner
|
||||
"""
|
||||
session = db_session.get(BuildSession, session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
if session.sharing_scope == SharingScope.PUBLIC_GLOBAL:
|
||||
return session
|
||||
if session.sharing_scope == SharingScope.PUBLIC_ORG:
|
||||
if user is None:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
return session
|
||||
# PRIVATE: require owner
|
||||
if user is None:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
if session.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
return session
|
||||
|
||||
|
||||
def _offline_html_response() -> Response:
|
||||
"""Return a branded Craft HTML page when the sandbox is not reachable.
|
||||
|
||||
Design mirrors the default Craft web template (outputs/web/app/page.tsx):
|
||||
terminal window aesthetic with Minecraft-themed typing animation.
|
||||
"""
|
||||
html = """<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="refresh" content="15" />
|
||||
<title>Craft — Starting up</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
||||
background: linear-gradient(to bottom right, #030712, #111827, #030712);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1.5rem;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.terminal {
|
||||
width: 100%;
|
||||
max-width: 580px;
|
||||
border: 2px solid #374151;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.titlebar {
|
||||
background: #1f2937;
|
||||
padding: 0.5rem 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border-bottom: 1px solid #374151;
|
||||
}
|
||||
|
||||
.btn { width: 12px; height: 12px; border-radius: 2px; flex-shrink: 0; }
|
||||
.btn-red { background: #ef4444; }
|
||||
.btn-yellow { background: #eab308; }
|
||||
.btn-green { background: #22c55e; }
|
||||
|
||||
.title-label {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
margin-right: 36px;
|
||||
}
|
||||
|
||||
.body {
|
||||
background: #111827;
|
||||
padding: 1.5rem;
|
||||
min-height: 200px;
|
||||
font-size: 0.875rem;
|
||||
color: #d1d5db;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.prompt { color: #10b981; user-select: none; }
|
||||
|
||||
.tagline {
|
||||
font-size: 0.8125rem;
|
||||
color: #4b5563;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="terminal">
|
||||
<div class="titlebar">
|
||||
<div class="btn btn-red"></div>
|
||||
<div class="btn btn-yellow"></div>
|
||||
<div class="btn btn-green"></div>
|
||||
<span class="title-label">crafting_table</span>
|
||||
</div>
|
||||
<div class="body">
|
||||
<span class="prompt">/></span>
|
||||
<span>Sandbox is asleep...</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="tagline">Ask the owner to open their Craft session to wake it up.</p>
|
||||
</body>
|
||||
</html>"""
|
||||
return Response(content=html, status_code=503, media_type="text/html")
|
||||
|
||||
|
||||
# Public router for webapp proxy — no authentication required
|
||||
# (access controlled per-session via sharing_scope)
|
||||
public_build_router = APIRouter(prefix="/build")
|
||||
|
||||
|
||||
@public_build_router.get("/sessions/{session_id}/webapp", response_model=None)
|
||||
def get_webapp_root(
|
||||
session_id: UUID,
|
||||
request: Request,
|
||||
_: User = Depends(current_user),
|
||||
user: User | None = Depends(optional_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> StreamingResponse | Response:
|
||||
"""Proxy the root path of the webapp for a specific session."""
|
||||
return _proxy_request("", request, session_id, db_session)
|
||||
"""Proxy the root path of the webapp for a specific session.
|
||||
|
||||
Accessible without authentication when sharing_scope is public_global.
|
||||
Returns a friendly offline page when the sandbox is not running.
|
||||
"""
|
||||
_check_webapp_access(session_id, user, db_session)
|
||||
try:
|
||||
return _proxy_request("", request, session_id, db_session)
|
||||
except HTTPException as e:
|
||||
if e.status_code in (502, 503, 504):
|
||||
return _offline_html_response()
|
||||
raise
|
||||
|
||||
|
||||
@router.get("/sessions/{session_id}/webapp/{path:path}", response_model=None)
|
||||
@public_build_router.get(
|
||||
"/sessions/{session_id}/webapp/{path:path}", response_model=None
|
||||
)
|
||||
def get_webapp_path(
|
||||
session_id: UUID,
|
||||
path: str,
|
||||
request: Request,
|
||||
_: User = Depends(current_user),
|
||||
user: User | None = Depends(optional_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> StreamingResponse | Response:
|
||||
"""Proxy any subpath of the webapp (static assets, etc.) for a specific session."""
|
||||
return _proxy_request(path, request, session_id, db_session)
|
||||
"""Proxy any subpath of the webapp for a specific session.
|
||||
|
||||
|
||||
# Separate router for Next.js static assets at /_next/*
|
||||
# This is needed because Next.js apps may reference assets with root-relative paths
|
||||
# that don't get rewritten. The session_id is extracted from the Referer header.
|
||||
nextjs_assets_router = APIRouter()
|
||||
|
||||
|
||||
def _extract_session_from_referer(request: Request) -> UUID | None:
|
||||
"""Extract session_id from the Referer header.
|
||||
|
||||
Expects Referer to contain /api/build/sessions/{session_id}/webapp
|
||||
Accessible without authentication when sharing_scope is public_global.
|
||||
Returns a friendly offline page when the sandbox is not running.
|
||||
"""
|
||||
import re
|
||||
|
||||
referer = request.headers.get("referer", "")
|
||||
match = re.search(r"/api/build/sessions/([a-f0-9-]+)/webapp", referer)
|
||||
if match:
|
||||
try:
|
||||
return UUID(match.group(1))
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
@nextjs_assets_router.get("/_next/{path:path}", response_model=None)
|
||||
def get_nextjs_assets(
|
||||
path: str,
|
||||
request: Request,
|
||||
_: User = Depends(current_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> StreamingResponse | Response:
|
||||
"""Proxy Next.js static assets requested at root /_next/ path.
|
||||
|
||||
The session_id is extracted from the Referer header since these requests
|
||||
come from within the iframe context.
|
||||
"""
|
||||
session_id = _extract_session_from_referer(request)
|
||||
if not session_id:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Could not determine session from request context",
|
||||
)
|
||||
return _proxy_request(f"_next/{path}", request, session_id, db_session)
|
||||
_check_webapp_access(session_id, user, db_session)
|
||||
try:
|
||||
return _proxy_request(path, request, session_id, db_session)
|
||||
except HTTPException as e:
|
||||
if e.status_code in (502, 503, 504):
|
||||
return _offline_html_response()
|
||||
raise
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -10,6 +10,7 @@ from onyx.configs.constants import MessageType
|
||||
from onyx.db.enums import ArtifactType
|
||||
from onyx.db.enums import BuildSessionStatus
|
||||
from onyx.db.enums import SandboxStatus
|
||||
from onyx.db.enums import SharingScope
|
||||
from onyx.server.features.build.sandbox.models import (
|
||||
FilesystemEntry as FileSystemEntry,
|
||||
)
|
||||
@@ -107,6 +108,7 @@ class SessionResponse(BaseModel):
|
||||
nextjs_port: int | None
|
||||
sandbox: SandboxResponse | None
|
||||
artifacts: list[ArtifactResponse]
|
||||
sharing_scope: SharingScope
|
||||
|
||||
@classmethod
|
||||
def from_model(
|
||||
@@ -129,6 +131,7 @@ class SessionResponse(BaseModel):
|
||||
nextjs_port=session.nextjs_port,
|
||||
sandbox=(SandboxResponse.from_model(sandbox) if sandbox else None),
|
||||
artifacts=[ArtifactResponse.from_model(a) for a in session.artifacts],
|
||||
sharing_scope=session.sharing_scope,
|
||||
)
|
||||
|
||||
|
||||
@@ -159,6 +162,19 @@ class SessionListResponse(BaseModel):
|
||||
sessions: list[SessionResponse]
|
||||
|
||||
|
||||
class SetSessionSharingRequest(BaseModel):
|
||||
"""Request to set the sharing scope of a session."""
|
||||
|
||||
sharing_scope: SharingScope
|
||||
|
||||
|
||||
class SetSessionSharingResponse(BaseModel):
|
||||
"""Response after setting session sharing scope."""
|
||||
|
||||
session_id: str
|
||||
sharing_scope: SharingScope
|
||||
|
||||
|
||||
# ===== Message Models =====
|
||||
class MessageRequest(BaseModel):
|
||||
"""Request to send a message to the CLI agent."""
|
||||
@@ -244,6 +260,7 @@ class WebappInfo(BaseModel):
|
||||
webapp_url: str | None # URL to access the webapp (e.g., http://localhost:3015)
|
||||
status: str # Sandbox status (running, terminated, etc.)
|
||||
ready: bool # Whether the NextJS dev server is actually responding
|
||||
sharing_scope: SharingScope
|
||||
|
||||
|
||||
# ===== File Upload Models =====
|
||||
|
||||
@@ -30,6 +30,8 @@ from onyx.server.features.build.api.models import SessionListResponse
|
||||
from onyx.server.features.build.api.models import SessionNameGenerateResponse
|
||||
from onyx.server.features.build.api.models import SessionResponse
|
||||
from onyx.server.features.build.api.models import SessionUpdateRequest
|
||||
from onyx.server.features.build.api.models import SetSessionSharingRequest
|
||||
from onyx.server.features.build.api.models import SetSessionSharingResponse
|
||||
from onyx.server.features.build.api.models import SuggestionBubble
|
||||
from onyx.server.features.build.api.models import SuggestionTheme
|
||||
from onyx.server.features.build.api.models import UploadResponse
|
||||
@@ -38,6 +40,7 @@ from onyx.server.features.build.configs import SANDBOX_BACKEND
|
||||
from onyx.server.features.build.configs import SandboxBackend
|
||||
from onyx.server.features.build.db.build_session import allocate_nextjs_port
|
||||
from onyx.server.features.build.db.build_session import get_build_session
|
||||
from onyx.server.features.build.db.build_session import set_build_session_sharing_scope
|
||||
from onyx.server.features.build.db.sandbox import get_latest_snapshot_for_session
|
||||
from onyx.server.features.build.db.sandbox import get_sandbox_by_user_id
|
||||
from onyx.server.features.build.db.sandbox import update_sandbox_heartbeat
|
||||
@@ -294,6 +297,25 @@ def update_session_name(
|
||||
return SessionResponse.from_model(session, sandbox)
|
||||
|
||||
|
||||
@router.patch("/{session_id}/public")
|
||||
def set_session_public(
|
||||
session_id: UUID,
|
||||
request: SetSessionSharingRequest,
|
||||
user: User = Depends(current_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> SetSessionSharingResponse:
|
||||
"""Set the sharing scope of a build session's webapp."""
|
||||
updated = set_build_session_sharing_scope(
|
||||
session_id, user.id, request.sharing_scope, db_session
|
||||
)
|
||||
if not updated:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
return SetSessionSharingResponse(
|
||||
session_id=str(session_id),
|
||||
sharing_scope=updated.sharing_scope,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{session_id}", response_model=None)
|
||||
def delete_session(
|
||||
session_id: UUID,
|
||||
|
||||
@@ -13,6 +13,7 @@ from sqlalchemy.orm import Session
|
||||
from onyx.configs.constants import MessageType
|
||||
from onyx.db.enums import BuildSessionStatus
|
||||
from onyx.db.enums import SandboxStatus
|
||||
from onyx.db.enums import SharingScope
|
||||
from onyx.db.models import Artifact
|
||||
from onyx.db.models import BuildMessage
|
||||
from onyx.db.models import BuildSession
|
||||
@@ -159,6 +160,26 @@ def update_session_status(
|
||||
logger.info(f"Updated build session {session_id} status to {status}")
|
||||
|
||||
|
||||
def set_build_session_sharing_scope(
|
||||
session_id: UUID,
|
||||
user_id: UUID,
|
||||
sharing_scope: SharingScope,
|
||||
db_session: Session,
|
||||
) -> BuildSession | None:
|
||||
"""Set the sharing scope of a build session.
|
||||
|
||||
Only the session owner can change this setting.
|
||||
Returns the updated session, or None if not found/unauthorized.
|
||||
"""
|
||||
session = get_build_session(session_id, user_id, db_session)
|
||||
if not session:
|
||||
return None
|
||||
session.sharing_scope = sharing_scope
|
||||
db_session.commit()
|
||||
logger.info(f"Set build session {session_id} sharing_scope={sharing_scope}")
|
||||
return session
|
||||
|
||||
|
||||
def delete_build_session__no_commit(
|
||||
session_id: UUID,
|
||||
user_id: UUID,
|
||||
|
||||
@@ -474,6 +474,23 @@ class SandboxManager(ABC):
|
||||
"""
|
||||
...
|
||||
|
||||
def ensure_nextjs_running(
|
||||
self,
|
||||
sandbox_id: UUID,
|
||||
session_id: UUID,
|
||||
nextjs_port: int,
|
||||
) -> None:
|
||||
"""Ensure the Next.js server is running for a session.
|
||||
|
||||
Default is a no-op — only meaningful for local backends that manage
|
||||
process lifecycles directly (e.g., LocalSandboxManager).
|
||||
|
||||
Args:
|
||||
sandbox_id: The sandbox ID
|
||||
session_id: The session ID
|
||||
nextjs_port: The port the Next.js server should be listening on
|
||||
"""
|
||||
|
||||
|
||||
# Singleton instance cache for the factory
|
||||
_sandbox_manager_instance: SandboxManager | None = None
|
||||
|
||||
@@ -89,9 +89,17 @@ class LocalSandboxManager(SandboxManager):
|
||||
self._acp_clients: dict[tuple[UUID, UUID], ACPAgentClient] = {}
|
||||
|
||||
# Track Next.js processes - keyed by (sandbox_id, session_id) tuple
|
||||
# Used for clean shutdown when sessions are deleted
|
||||
# Used for clean shutdown when sessions are deleted.
|
||||
# Mutated from background threads; all access must hold _nextjs_lock.
|
||||
self._nextjs_processes: dict[tuple[UUID, UUID], subprocess.Popen[bytes]] = {}
|
||||
|
||||
# Track sessions currently being (re)started - prevents concurrent restarts.
|
||||
# Mutated from background threads; all access must hold _nextjs_lock.
|
||||
self._nextjs_starting: set[tuple[UUID, UUID]] = set()
|
||||
|
||||
# Lock guarding both _nextjs_processes and _nextjs_starting
|
||||
self._nextjs_lock = threading.Lock()
|
||||
|
||||
# Validate templates exist (raises RuntimeError if missing)
|
||||
self._validate_templates()
|
||||
|
||||
@@ -326,16 +334,18 @@ class LocalSandboxManager(SandboxManager):
|
||||
RuntimeError: If termination fails
|
||||
"""
|
||||
# Stop all Next.js processes for this sandbox (keyed by (sandbox_id, session_id))
|
||||
processes_to_stop = [
|
||||
(key, process)
|
||||
for key, process in self._nextjs_processes.items()
|
||||
if key[0] == sandbox_id
|
||||
]
|
||||
with self._nextjs_lock:
|
||||
processes_to_stop = [
|
||||
(key, process)
|
||||
for key, process in self._nextjs_processes.items()
|
||||
if key[0] == sandbox_id
|
||||
]
|
||||
for key, process in processes_to_stop:
|
||||
session_id = key[1]
|
||||
try:
|
||||
self._stop_nextjs_process(process, session_id)
|
||||
del self._nextjs_processes[key]
|
||||
with self._nextjs_lock:
|
||||
self._nextjs_processes.pop(key, None)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to stop Next.js for sandbox {sandbox_id}, "
|
||||
@@ -516,7 +526,8 @@ class LocalSandboxManager(SandboxManager):
|
||||
web_dir, nextjs_port
|
||||
)
|
||||
# Store process for clean shutdown on session delete
|
||||
self._nextjs_processes[(sandbox_id, session_id)] = nextjs_process
|
||||
with self._nextjs_lock:
|
||||
self._nextjs_processes[(sandbox_id, session_id)] = nextjs_process
|
||||
logger.info("Next.js server started successfully")
|
||||
|
||||
# Setup venv and AGENTS.md
|
||||
@@ -575,7 +586,8 @@ class LocalSandboxManager(SandboxManager):
|
||||
"""
|
||||
# Stop Next.js dev server - try stored process first, then fallback to port lookup
|
||||
process_key = (sandbox_id, session_id)
|
||||
nextjs_process = self._nextjs_processes.pop(process_key, None)
|
||||
with self._nextjs_lock:
|
||||
nextjs_process = self._nextjs_processes.pop(process_key, None)
|
||||
if nextjs_process is not None:
|
||||
self._stop_nextjs_process(nextjs_process, session_id)
|
||||
elif nextjs_port is not None:
|
||||
@@ -766,6 +778,87 @@ class LocalSandboxManager(SandboxManager):
|
||||
outputs_path = session_path / "outputs"
|
||||
return outputs_path.exists()
|
||||
|
||||
def ensure_nextjs_running(
|
||||
self,
|
||||
sandbox_id: UUID,
|
||||
session_id: UUID,
|
||||
nextjs_port: int,
|
||||
) -> None:
|
||||
"""Start Next.js server for a session if not already running.
|
||||
|
||||
Called when the server is detected as unreachable (e.g., after API server restart).
|
||||
Returns immediately — the actual startup runs in a background daemon thread.
|
||||
A per-session guard prevents concurrent restarts from racing.
|
||||
|
||||
Args:
|
||||
sandbox_id: The sandbox ID
|
||||
session_id: The session ID
|
||||
nextjs_port: The port number for the Next.js server
|
||||
"""
|
||||
process_key = (sandbox_id, session_id)
|
||||
|
||||
with self._nextjs_lock:
|
||||
# Already have a live tracked process
|
||||
existing = self._nextjs_processes.get(process_key)
|
||||
if existing is not None and existing.poll() is None:
|
||||
return
|
||||
|
||||
# Already being started by another thread
|
||||
if process_key in self._nextjs_starting:
|
||||
return
|
||||
|
||||
# Check if the port is already responding (orphan process that survived restart)
|
||||
try:
|
||||
import httpx
|
||||
|
||||
with httpx.Client(timeout=1.0) as client:
|
||||
client.get(f"http://localhost:{nextjs_port}")
|
||||
logger.info(
|
||||
f"Port {nextjs_port} already alive for session {session_id} "
|
||||
"(orphan process) — skipping restart"
|
||||
)
|
||||
return
|
||||
except Exception:
|
||||
pass # Port is dead; proceed to restart
|
||||
|
||||
self._nextjs_starting.add(process_key)
|
||||
|
||||
logger.info(
|
||||
f"Scheduling background Next.js restart for session {session_id} "
|
||||
f"on port {nextjs_port}"
|
||||
)
|
||||
|
||||
def _start_in_background() -> None:
|
||||
try:
|
||||
sandbox_path = self._get_sandbox_path(sandbox_id)
|
||||
web_dir = self._directory_manager.get_web_path(
|
||||
sandbox_path, str(session_id)
|
||||
)
|
||||
if not web_dir.exists():
|
||||
logger.warning(
|
||||
f"Web dir missing for session {session_id}: {web_dir} — "
|
||||
"cannot restart Next.js"
|
||||
)
|
||||
return
|
||||
process = self._process_manager.start_nextjs_server(
|
||||
web_dir, nextjs_port
|
||||
)
|
||||
with self._nextjs_lock:
|
||||
self._nextjs_processes[process_key] = process
|
||||
logger.info(
|
||||
f"Auto-restarted Next.js for session {session_id} "
|
||||
f"on port {nextjs_port}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to auto-restart Next.js for session {session_id}: {e}"
|
||||
)
|
||||
finally:
|
||||
with self._nextjs_lock:
|
||||
self._nextjs_starting.discard(process_key)
|
||||
|
||||
threading.Thread(target=_start_in_background, daemon=True).start()
|
||||
|
||||
def restore_snapshot(
|
||||
self,
|
||||
sandbox_id: UUID,
|
||||
|
||||
@@ -1765,6 +1765,7 @@ class SessionManager:
|
||||
"webapp_url": None,
|
||||
"status": "no_sandbox",
|
||||
"ready": False,
|
||||
"sharing_scope": session.sharing_scope,
|
||||
}
|
||||
|
||||
# Return the proxy URL - the proxy handles routing to the correct sandbox
|
||||
@@ -1777,11 +1778,21 @@ class SessionManager:
|
||||
# Quick health check: can the API server reach the NextJS dev server?
|
||||
ready = self._check_nextjs_ready(sandbox.id, session.nextjs_port)
|
||||
|
||||
# If not ready, ask the sandbox manager to ensure Next.js is running.
|
||||
# For the local backend this triggers a background restart so that the
|
||||
# frontend poll loop eventually sees ready=True without the user having
|
||||
# to manually recreate the session.
|
||||
if not ready:
|
||||
self._sandbox_manager.ensure_nextjs_running(
|
||||
sandbox.id, session_id, session.nextjs_port
|
||||
)
|
||||
|
||||
return {
|
||||
"has_webapp": session.nextjs_port is not None,
|
||||
"webapp_url": webapp_url,
|
||||
"status": sandbox.status.value,
|
||||
"ready": ready,
|
||||
"sharing_scope": session.sharing_scope,
|
||||
}
|
||||
|
||||
def _check_nextjs_ready(self, sandbox_id: UUID, port: int) -> bool:
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
} from "@opal/icons";
|
||||
import { IconProps } from "@opal/types";
|
||||
import CraftingLoader from "@/app/craft/components/CraftingLoader";
|
||||
import ShareButton from "@/app/craft/components/ShareButton";
|
||||
|
||||
// Output panel sub-components
|
||||
import UrlBar from "@/app/craft/components/output-panel/UrlBar";
|
||||
@@ -615,6 +616,20 @@ const BuildOutputPanel = memo(({ onClose, isOpen }: BuildOutputPanelProps) => {
|
||||
onDownload={isMarkdownPreview ? handleDocxDownload : undefined}
|
||||
isDownloading={isExportingDocx}
|
||||
onRefresh={handleRefresh}
|
||||
shareButton={
|
||||
!isFilePreviewActive &&
|
||||
activeOutputTab === "preview" &&
|
||||
session?.id &&
|
||||
displayUrl?.startsWith("http") ? (
|
||||
<ShareButton
|
||||
key={session.id}
|
||||
sessionId={session.id}
|
||||
webappUrl={displayUrl}
|
||||
sharingScope={webappInfo?.sharing_scope ?? "private"}
|
||||
onScopeChange={mutate}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Tab Content */}
|
||||
|
||||
181
web/src/app/craft/components/ShareButton.tsx
Normal file
181
web/src/app/craft/components/ShareButton.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import Button from "@/refresh-components/buttons/Button";
|
||||
import { SvgLink, SvgCopy, SvgCheck, SvgX } from "@opal/icons";
|
||||
import { setSessionSharing } from "@/app/craft/services/apiServices";
|
||||
import type { SharingScope } from "@/app/craft/types/streamingTypes";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ShareButtonProps {
|
||||
sessionId: string;
|
||||
webappUrl: string;
|
||||
sharingScope: SharingScope;
|
||||
onScopeChange?: () => void;
|
||||
}
|
||||
|
||||
const SCOPE_OPTIONS: {
|
||||
value: SharingScope;
|
||||
label: string;
|
||||
description: string;
|
||||
}[] = [
|
||||
{
|
||||
value: "private",
|
||||
label: "Private",
|
||||
description: "Only you can view this app.",
|
||||
},
|
||||
{
|
||||
value: "public_org",
|
||||
label: "Org",
|
||||
description: "Anyone logged into your Onyx can view this app.",
|
||||
},
|
||||
{
|
||||
value: "public_global",
|
||||
label: "Public",
|
||||
description: "Anyone with the link can view this app.",
|
||||
},
|
||||
];
|
||||
|
||||
export default function ShareButton({
|
||||
sessionId,
|
||||
webappUrl,
|
||||
sharingScope: initialScope,
|
||||
onScopeChange,
|
||||
}: ShareButtonProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [sharingScope, setSharingScope] = useState<SharingScope>(initialScope);
|
||||
const [copyState, setCopyState] = useState<"idle" | "copied" | "error">(
|
||||
"idle"
|
||||
);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const isShared = sharingScope !== "private";
|
||||
|
||||
const shareUrl = webappUrl.startsWith("http")
|
||||
? webappUrl
|
||||
: `${window.location.origin}${webappUrl}`;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (
|
||||
popoverRef.current &&
|
||||
!popoverRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [isOpen]);
|
||||
|
||||
const handleSelect = async (scope: SharingScope) => {
|
||||
if (scope === sharingScope || isLoading) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await setSessionSharing(sessionId, scope);
|
||||
setSharingScope(scope);
|
||||
onScopeChange?.();
|
||||
} catch (err) {
|
||||
console.error("Failed to update sharing:", err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
let success = false;
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareUrl);
|
||||
success = true;
|
||||
} catch {
|
||||
try {
|
||||
const el = document.createElement("textarea");
|
||||
el.value = shareUrl;
|
||||
el.style.cssText = "position:fixed;opacity:0";
|
||||
document.body.appendChild(el);
|
||||
el.focus();
|
||||
el.select();
|
||||
success = document.execCommand("copy");
|
||||
document.body.removeChild(el);
|
||||
} catch {}
|
||||
}
|
||||
setCopyState(success ? "copied" : "error");
|
||||
setTimeout(() => setCopyState("idle"), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex-shrink-0" ref={popoverRef}>
|
||||
<Button
|
||||
action
|
||||
primary={isShared}
|
||||
tertiary={!isShared}
|
||||
leftIcon={SvgLink}
|
||||
onClick={() => setIsOpen((v) => !v)}
|
||||
aria-label="Share webapp"
|
||||
>
|
||||
{isShared ? "Shared" : "Share"}
|
||||
</Button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute top-full right-0 mt-2 z-50 w-80 rounded-12 border border-border-01 bg-background-neutral-00 shadow-lg p-4 flex flex-col gap-3">
|
||||
<Text mainUiAction text04>
|
||||
Share app
|
||||
</Text>
|
||||
|
||||
{/* Scope selector */}
|
||||
<div className="flex flex-col gap-1">
|
||||
{SCOPE_OPTIONS.map((opt) => (
|
||||
<div
|
||||
key={opt.value}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => handleSelect(opt.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSelect(opt.value)}
|
||||
aria-disabled={isLoading}
|
||||
className={cn(
|
||||
"flex flex-col items-start gap-0.5 px-3 py-2 rounded-08 text-left transition-colors cursor-pointer",
|
||||
sharingScope === opt.value
|
||||
? "bg-background-tint-03"
|
||||
: "hover:bg-background-tint-02"
|
||||
)}
|
||||
>
|
||||
<Text mainUiAction text04>
|
||||
{opt.label}
|
||||
</Text>
|
||||
<Text secondaryBody text03>
|
||||
{opt.description}
|
||||
</Text>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Copy link row — shown when not private */}
|
||||
{isShared && (
|
||||
<div className="flex items-center gap-2 p-2 rounded-08 bg-background-tint-02">
|
||||
<Text secondaryBody text03 className="flex-1 truncate">
|
||||
{shareUrl}
|
||||
</Text>
|
||||
<Button
|
||||
action
|
||||
tertiary
|
||||
size="md"
|
||||
leftIcon={
|
||||
copyState === "copied"
|
||||
? SvgCheck
|
||||
: copyState === "error"
|
||||
? SvgX
|
||||
: SvgCopy
|
||||
}
|
||||
onClick={handleCopy}
|
||||
aria-label="Copy link"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -38,6 +38,8 @@ export interface UrlBarProps {
|
||||
isDownloading?: boolean;
|
||||
/** Optional refresh callback — shows a refresh icon at the right edge of the URL pill */
|
||||
onRefresh?: () => void;
|
||||
/** Optional share button node — shown on the far right of the URL bar */
|
||||
shareButton?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -60,6 +62,7 @@ export default function UrlBar({
|
||||
onDownload,
|
||||
isDownloading = false,
|
||||
onRefresh,
|
||||
shareButton,
|
||||
}: UrlBarProps) {
|
||||
const handleOpenInNewTab = () => {
|
||||
if (previewUrl) {
|
||||
@@ -152,6 +155,8 @@ export default function UrlBar({
|
||||
{isDownloading ? "Exporting..." : "Export to .docx"}
|
||||
</Button>
|
||||
)}
|
||||
{/* Share button — shown on the far right when a webapp is active */}
|
||||
{shareButton}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
StreamPacket,
|
||||
UsageLimits,
|
||||
DirectoryListing,
|
||||
SharingScope,
|
||||
} from "@/app/craft/types/streamingTypes";
|
||||
|
||||
// =============================================================================
|
||||
@@ -198,6 +199,23 @@ export async function updateSessionName(
|
||||
}
|
||||
}
|
||||
|
||||
export async function setSessionSharing(
|
||||
sessionId: string,
|
||||
sharingScope: SharingScope
|
||||
): Promise<{ session_id: string; sharing_scope: SharingScope }> {
|
||||
const res = await fetch(`${API_BASE}/sessions/${sessionId}/public`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sharing_scope: sharingScope }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to update session sharing: ${res.status}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function deleteSession(sessionId: string): Promise<void> {
|
||||
const res = await fetch(`${API_BASE}/sessions/${sessionId}`, {
|
||||
method: "DELETE",
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
// =============================================================================
|
||||
// Sharing Types
|
||||
// =============================================================================
|
||||
|
||||
export type SharingScope = "private" | "public_org" | "public_global";
|
||||
|
||||
// =============================================================================
|
||||
// Session Error Constants
|
||||
// =============================================================================
|
||||
@@ -165,6 +171,7 @@ export interface ApiSessionResponse {
|
||||
last_activity_at: string;
|
||||
sandbox: ApiSandboxResponse | null;
|
||||
artifacts: ApiArtifactResponse[];
|
||||
sharing_scope: SharingScope;
|
||||
}
|
||||
|
||||
export interface ApiDetailedSessionResponse extends ApiSessionResponse {
|
||||
@@ -196,6 +203,7 @@ export interface ApiWebappInfoResponse {
|
||||
webapp_url: string | null;
|
||||
status: string;
|
||||
ready: boolean;
|
||||
sharing_scope: SharingScope;
|
||||
}
|
||||
|
||||
export interface FileSystemEntry {
|
||||
|
||||
Reference in New Issue
Block a user