Compare commits

...

7 Commits

Author SHA1 Message Date
rohoswagger
566897ab98 refactor(craft): polish sharing UI and sandbox manager
- Move offline HTML to templates/webapp_offline.html
- Simplify _check_webapp_access (collapse PUBLIC_ORG + PRIVATE branches)
- ShareButton: use Popover component, rename Org → Organization
- Move ShareButton into UrlBar (sessionId/sharingScope/onScopeChange props)
- local_sandbox_manager: use ThreadSafeSet for _nextjs_starting
- auth_check.py: update comment to reflect sharing_scope

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-18 10:09:08 -08:00
rohoswagger
b3b8695716 fix(craft): use String column type for sharing_scope instead of Enum
SQLAlchemy's Enum type validates against member names (PRIVATE) not
values (private). The migration uses plain VARCHAR, so the model should
too — Python-level enum validation comes from the Mapped[SharingScope]
type annotation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-17 18:01:34 -08:00
rohoswagger
e7ce26a18d feat(craft): replace is_public with three-tier sharing_scope
Replaces the binary is_public flag with a SharingScope enum:
- private: owner-only access (default)
- public_org: any authenticated user can view
- public_global: anyone with the link can view (no auth required)

Backend:
- SharingScope enum added to enums.py
- BuildSession.sharing_scope replaces is_public
- _check_webapp_access branches on scope
- PATCH /{session_id}/public now accepts sharing_scope

Frontend:
- ShareButton replaces toggle with 3-option selector
- SharingScope type added to streamingTypes.ts
- setSessionSharing replaces setSessionPublic

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-17 17:34:39 -08:00
rohoswagger
f97c56ffb9 feat(craft): add sharing_scope column to build_session (migration) 2026-02-17 17:17:54 -08:00
rohoswagger
aa14ee3059 fix(craft): simplify offline page and remove dead /_next/ route
- Offline HTML: drop typing animation, show single static message
  "Sandbox is asleep..." with the terminal chrome from page.tsx

- Remove nextjs_assets_router and _extract_session_from_referer:
  _rewrite_asset_paths() already rewrites all /_next/ references to
  /build/sessions/{session_id}/webapp/_next/ in proxied responses, so
  the root-level /_next/ route was never hit. Removing it eliminates
  Referer-based session extraction (broken when header is absent) and
  an unnecessary public endpoint bypass in auth_check.py.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-17 16:11:00 -08:00
rohoswagger
ce69c91ce5 feat(craft): terminal UI for offline sandbox page
Replace the generic offline page with a terminal UI matching the default
Craft web template (page.tsx). Uses the crafting_table title bar with
square traffic-light buttons, emerald /> prompt, dark neutral gradient
background, and a typing animation with messages indicating the sandbox
is asleep and the owner needs to wake it up.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-17 15:56:18 -08:00
rohoswagger
10f731c2f4 feat(craft): shareable webapp URLs
- Add is_public field to BuildSession with migration
- PATCH /sessions/{id}/public endpoint (owner-only)
- Public proxy router: unauthenticated access when is_public=True
- Auto-restart Next.js in background when port is dead (local backend)
- Strip Set-Cookie from proxied responses to prevent cookie pollution
- ShareButton component with popover, link copy, and SWR cache invalidation
- Offline page auto-refreshes every 15s while sandbox warms up
2026-02-17 15:33:47 -08:00
18 changed files with 659 additions and 71 deletions

View File

@@ -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")

View File

@@ -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"

View File

@@ -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])

View File

@@ -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)

View File

@@ -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 — access enforced per-session via sharing_scope in handler
("/build/sessions/{session_id}/webapp", {"GET"}),
("/build/sessions/{session_id}/webapp/{path:path}", {"GET"}),
]

View File

@@ -1,4 +1,5 @@
from collections.abc import Iterator
from pathlib import Path
from uuid import UUID
import httpx
@@ -11,12 +12,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 +220,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 +371,68 @@ def _proxy_request(
raise HTTPException(status_code=502, detail="Bad gateway")
@router.get("/sessions/{session_id}/webapp", response_model=None)
def get_webapp_root(
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 user is None:
raise HTTPException(status_code=401, detail="Authentication required")
if session.sharing_scope == SharingScope.PRIVATE and session.user_id != user.id:
raise HTTPException(status_code=404, detail="Session not found")
return session
_OFFLINE_HTML_PATH = Path(__file__).parent / "templates" / "webapp_offline.html"
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 = _OFFLINE_HTML_PATH.read_text()
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)
@public_build_router.get(
"/sessions/{session_id}/webapp/{path:path}", response_model=None
)
def get_webapp(
session_id: UUID,
request: Request,
_: User = Depends(current_user),
path: str = "",
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 webapp for a specific session (root and subpaths).
@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),
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)
# 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
# =============================================================================

View File

@@ -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 =====

View File

@@ -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,

View File

@@ -0,0 +1,110 @@
<!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">/&gt;</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>

View File

@@ -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,

View File

@@ -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

View File

@@ -15,6 +15,8 @@ from collections.abc import Generator
from pathlib import Path
from uuid import UUID
import httpx
from onyx.db.enums import SandboxStatus
from onyx.file_store.file_store import get_default_file_store
from onyx.server.features.build.configs import DEMO_DATA_PATH
@@ -35,6 +37,7 @@ from onyx.server.features.build.sandbox.models import LLMProviderConfig
from onyx.server.features.build.sandbox.models import SandboxInfo
from onyx.server.features.build.sandbox.models import SnapshotResult
from onyx.utils.logger import setup_logger
from onyx.utils.threadpool_concurrency import ThreadSafeSet
logger = setup_logger()
@@ -89,9 +92,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.
# ThreadSafeSet allows atomic check-and-add without holding _nextjs_lock.
self._nextjs_starting: ThreadSafeSet[tuple[UUID, UUID]] = ThreadSafeSet()
# Lock guarding _nextjs_processes (shared across sessions; hold briefly only)
self._nextjs_lock = threading.Lock()
# Validate templates exist (raises RuntimeError if missing)
self._validate_templates()
@@ -326,16 +337,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 +529,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 +589,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 +781,85 @@ 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.
Lock design: _nextjs_lock is shared across ALL sessions. Holding it during
httpx (1s) or start_nextjs_server (several seconds) would block every other
session's status checks and restarts. We only hold the lock for fast
in-memory ops (dict get, check_and_add). The slow I/O runs in the background
thread without holding any lock.
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:
existing = self._nextjs_processes.get(process_key)
if existing is not None and existing.poll() is None:
return
# Atomic check-and-add: returns True if already in set (another thread is starting)
if self._nextjs_starting.check_and_add(process_key):
return
def _start_in_background() -> None:
try:
# Port check in background to avoid blocking the main thread
try:
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 with restart
logger.info(
f"Starting Next.js for session {session_id} on port {nextjs_port}"
)
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:
self._nextjs_starting.discard(process_key)
threading.Thread(target=_start_in_background, daemon=True).start()
def restore_snapshot(
self,
sandbox_id: UUID,

View File

@@ -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:

View File

@@ -615,6 +615,16 @@ const BuildOutputPanel = memo(({ onClose, isOpen }: BuildOutputPanelProps) => {
onDownload={isMarkdownPreview ? handleDocxDownload : undefined}
isDownloading={isExportingDocx}
onRefresh={handleRefresh}
sessionId={
!isFilePreviewActive &&
activeOutputTab === "preview" &&
session?.id &&
displayUrl?.startsWith("http")
? session.id
: undefined
}
sharingScope={webappInfo?.sharing_scope ?? "private"}
onScopeChange={mutate}
/>
{/* Tab Content */}

View File

@@ -0,0 +1,189 @@
"use client";
import { useState, 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";
import Popover from "@/refresh-components/Popover";
import Truncated from "@/refresh-components/texts/Truncated";
import { Section, LineItemLayout } from "@/layouts/general-layouts";
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: "Organization",
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 isShared = sharingScope !== "private";
const shareUrl =
typeof window !== "undefined"
? webappUrl.startsWith("http")
? webappUrl
: `${window.location.origin}${webappUrl}`
: webappUrl;
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 (
<Section width="fit" height="fit">
<Popover open={isOpen} onOpenChange={setIsOpen}>
<Popover.Trigger asChild>
<Button
action
primary={isShared}
tertiary={!isShared}
leftIcon={SvgLink}
aria-label="Share webapp"
>
{isShared ? "Shared" : "Share"}
</Button>
</Popover.Trigger>
<Popover.Content side="bottom" align="end" width="lg" sideOffset={4}>
<Section
alignItems="stretch"
gap={0.25}
padding={0.25}
width="full"
height="fit"
>
{/* Scope options */}
<Section alignItems="stretch" gap={0.25} width="full">
{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(
"cursor-pointer rounded-08 transition-colors",
sharingScope === opt.value
? "bg-background-tint-03"
: "hover:bg-background-tint-02"
)}
>
<LineItemLayout
title={opt.label}
description={opt.description}
variant="tertiary"
reducedPadding
/>
</div>
))}
</Section>
{/* Copy link — shown when not private */}
{isShared && (
<div className="rounded-08 bg-background-tint-02">
<Section
flexDirection="row"
alignItems="center"
gap={0.25}
padding={0.25}
width="full"
height="fit"
>
<div className="min-w-0 flex-1 overflow-hidden">
<Truncated secondaryBody text03>
{shareUrl}
</Truncated>
</div>
<Button
action
tertiary
size="md"
leftIcon={
copyState === "copied"
? SvgCheck
: copyState === "error"
? SvgX
: SvgCopy
}
onClick={handleCopy}
aria-label="Copy link"
/>
</Section>
</div>
)}
</Section>
</Popover.Content>
</Popover>
</Section>
);
}

View File

@@ -14,6 +14,8 @@ import {
} from "@opal/icons";
import { IconProps } from "@opal/types";
import SimpleTooltip from "@/refresh-components/SimpleTooltip";
import ShareButton from "@/app/craft/components/ShareButton";
import type { SharingScope } from "@/app/craft/types/streamingTypes";
/** SvgLoader wrapped with animate-spin so it can be passed as a Button leftIcon */
const SpinningLoader: React.FunctionComponent<IconProps> = (props) => (
@@ -38,6 +40,12 @@ export interface UrlBarProps {
isDownloading?: boolean;
/** Optional refresh callback — shows a refresh icon at the right edge of the URL pill */
onRefresh?: () => void;
/** Session ID — when present with previewUrl, shows share button for webapp */
sessionId?: string;
/** Sharing scope for the webapp (used when sessionId + previewUrl) */
sharingScope?: SharingScope;
/** Callback when sharing scope changes (revalidate webapp info) */
onScopeChange?: () => void;
}
/**
@@ -60,6 +68,9 @@ export default function UrlBar({
onDownload,
isDownloading = false,
onRefresh,
sessionId,
sharingScope = "private",
onScopeChange,
}: UrlBarProps) {
const handleOpenInNewTab = () => {
if (previewUrl) {
@@ -152,6 +163,16 @@ export default function UrlBar({
{isDownloading ? "Exporting..." : "Export to .docx"}
</Button>
)}
{/* Share button — shown when webapp preview is active */}
{previewUrl && sessionId && (
<ShareButton
key={sessionId}
sessionId={sessionId}
webappUrl={previewUrl}
sharingScope={sharingScope}
onScopeChange={onScopeChange}
/>
)}
</div>
</div>
);

View File

@@ -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",

View File

@@ -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 {