Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
24e8ba3
feat(cli): add moss playground command with WASM-based query UI
msranjana Jul 29, 2026
6766c80
fix(playground): remove wildcard CORS and escape doc.id in result HTML
msranjana Jul 29, 2026
6490453
fix(playground): address code review — remove CDN, proxy through serv…
msranjana Jul 29, 2026
5859df3
fix(playground): add CSRF token and Host/Origin checks to protect loc…
msranjana Jul 29, 2026
8fa0c37
fix(playground): pass token via URL fragment, capture currentIndex at…
msranjana Jul 29, 2026
c49513f
fix(playground): print tokenized URL, guard loadIndex with requestId …
msranjana Jul 29, 2026
df550c2
fix(playground): recompute all UI state on selection change, allow lo…
msranjana Jul 29, 2026
7862618
fix(playground): add HTML asset to package-data, add asset-existence …
msranjana Jul 30, 2026
26b9a46
fix(playground): propagate fetch errors to UI, preserve currentIndex …
msranjana Jul 30, 2026
a268bc9
fix: remove extraneous f-prefix on non-interpolated string (ruff F541)
msranjana Jul 30, 2026
74e71e6
fix(playground): update global indexes, clear stale results on index …
msranjana Jul 30, 2026
31a9e3c
fix(playground): use ThreadingHTTPServer for concurrent requests, val…
msranjana Jul 30, 2026
6289eaa
fix(playground): serialize all MossClient calls through a single even…
msranjana Jul 30, 2026
1811ebf
fix(playground): validate POST body and fix worker loop binding
msranjana Jul 30, 2026
405bd6c
fix(playground): correct awaitable check and prevent shutdown hang
msranjana Jul 30, 2026
8b5d972
fix(playground): SDK thread-affinity and unbounded shutdown join
msranjana Jul 30, 2026
9839dd6
fix(playground): serialize SDK results inside worker thread
msranjana Jul 30, 2026
8950ce4
fix(playground): use async def closures in worker.submit() for correc…
msranjana Jul 30, 2026
3d3c970
fix(playground): render result summary via DOM nodes not innerHTML
msranjana Jul 30, 2026
54657b2
fix(playground): serialize worker submissions under an asyncio.Lock
msranjana Jul 30, 2026
72d167c
fix(playground): create asyncio.Lock via coroutine after worker threa…
msranjana Jul 30, 2026
21246bd
docs(moss-cli): document the playground command
msranjana Jul 30, 2026
6edb5e4
fix(playground): validate query params, render metadata, abort stale …
msranjana Jul 31, 2026
7ae2a7b
test(playground): cover param validation, metadata rendering, stale q…
msranjana Jul 31, 2026
af177cb
fix(playground): harden request handling and per-session staleness
msranjana Jul 31, 2026
dabcc0c
fix(playground): persist token in sessionStorage across reloads
msranjana Jul 31, 2026
0182ecf
fix(playground): unload prior index, catch alpha overflow, bind port …
msranjana Aug 1, 2026
45b1ac5
Update packages/moss-cli/src/moss_cli/commands/playground.py
msranjana Aug 1, 2026
623a001
feat(playground): run index loading and queries in-browser via moss-w…
msranjana Aug 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions packages/moss-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ Moss CLI wraps the [Moss Python SDK](https://docs.moss.dev/) so you can build an
- **Multiple output formats** — rich tables for humans, `--json` for scripts
- **Job tracking** — poll background jobs with live progress display
- **Pipe-friendly** — stdin/stdout support for composing with other tools
- **Local Playground** — browser-based UI for interactive querying without any frontend code

## Installation

Expand Down Expand Up @@ -145,6 +146,43 @@ echo "what is AI" | moss query my-index
moss query my-index "query" --json | jq '.docs[0].text'
```

### Playground

Start a local web UI for interactive semantic search — no frontend code required.

```bash
# Start the playground (port 8765 when available, otherwise the next free port)
moss playground

# Use a specific port
moss playground --port 9000

# Use a specific credential profile
moss playground --profile staging

# Skip opening the browser automatically
moss playground --no-open
```

When you run `moss playground`, it prints a URL that includes a per-run token in the
`#<token>` fragment, e.g. `http://127.0.0.1:8765/#Abc123...`. Open that exact URL in
your browser. With `--no-open`, copy the full URL printed by the command — including
the `#<token>` fragment — since the token is required to fetch the project credentials
from the server.

The playground serves a single-page app that loads the Moss WASM SDK
(`@moss-dev/moss-web`) from the unpkg CDN and runs index loading and semantic search
entirely in the browser. You can:

- Browse and load indexes
- Run semantic search with configurable `topK` and `alpha`
- View results with scores and metadata

When credentials are available (via CLI flags, `MOSS_PROJECT_ID`/`MOSS_PROJECT_KEY`
env vars, or a `--profile`), the server injects them through a token-protected
`/api/config` endpoint and the app connects automatically. If no credentials are
configured, the app shows a connection form where you can enter them in the browser.

Comment thread
coderabbitai[bot] marked this conversation as resolved.
### Job Tracking

```bash
Expand Down
3 changes: 3 additions & 0 deletions packages/moss-cli/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ where = ["src"]
[tool.setuptools.package-dir]
"" = "src"

[tool.setuptools.package-data]
moss_cli = ["playground/*.html"]

[tool.black]
line-length = 88
target-version = ['py310']
Expand Down
226 changes: 226 additions & 0 deletions packages/moss-cli/src/moss_cli/commands/playground.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
"""moss playground — local web UI for querying Moss indexes interactively.

Starts a local HTTP server that serves a browser-based playground. The UI
loads ``@moss-dev/moss-web`` (the WASM browser SDK) via an import map from the
unpkg CDN and executes index loading and queries entirely in the browser.

The server only serves the static UI and, when credentials are available,
injects them through a token-protected ``/api/config`` endpoint so the WASM
client can talk to the Moss cloud directly. When no credentials are configured,
the UI falls back to a manual connection form.
"""

from __future__ import annotations

import json
import secrets
import webbrowser
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Optional
from urllib.parse import urlparse

import typer
from rich.console import Console
from rich.markup import escape as rich_escape

from ..config import resolve_credentials

console = Console()
HERE = Path(__file__).resolve().parent.parent

PLAYGROUND_HTML = HERE / "playground" / "index.html"


class DaemonThreadingHTTPServer(ThreadingHTTPServer):
"""ThreadingHTTPServer whose per-request threads are daemons.

By default ThreadingHTTPServer's request threads are non-daemon, so
server_close() blocks waiting for any in-flight request thread to
finish. Daemonizing request threads lets process exit proceed without
waiting on them.
"""

daemon_threads = True


class PlaygroundHandler(SimpleHTTPRequestHandler):
"""HTTP handler — serves the playground UI and injects credentials into the
browser when the server has them, so the WASM client runs entirely in the
browser."""

_token: str = ""
_server_host: str = ""
_project_id: str | None = None
_project_key: str | None = None

def __init__(self, *args, **kwargs):
super().__init__(*args, directory=str(HERE / "playground"), **kwargs)

def _check_api_request(self) -> bool:
token = self.headers.get("X-Moss-Token")
if token is None or not secrets.compare_digest(token, self._token):
self._send_json(403, {"error": "Forbidden: invalid or missing token"})
return False
host = self.headers.get("Host", "")
allowed_hosts = {self._server_host, self._server_host.replace("127.0.0.1", "localhost")}
if host and host not in allowed_hosts:
self._send_json(403, {"error": "Forbidden: invalid Host header"})
return False
origin = self.headers.get("Origin", "")
allowed_origins = {f"http://{h}" for h in allowed_hosts}
if origin and origin not in allowed_origins:
self._send_json(403, {"error": "Forbidden: invalid Origin"})
return False
return True

def _send_json(self, status: int, data: dict) -> None:
body = json.dumps(data).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)

def _send_html(self, html: str) -> None:
body = html.encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)

def do_GET(self) -> None:
path = urlparse(self.path).path
if path == "/":
self._serve_index()
elif path == "/favicon.ico":
self.send_response(204)
self.end_headers()
elif path == "/api/config":
if not self._check_api_request():
return
self._handle_get_config()
else:
super().do_GET()

def _serve_index(self) -> None:
if not PLAYGROUND_HTML.exists():
self._send_json(500, {"error": "Playground HTML not found"})
return
html = PLAYGROUND_HTML.read_text(encoding="utf-8")
self._send_html(html)

def _handle_get_config(self) -> None:
self._send_json(
200,
{
"projectId": self._project_id,
"projectKey": self._project_key,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: The automatic connection exposes the CLI's project key to an untrusted CDN-loaded JavaScript module, turning a CDN/package compromise into project credential theft. Keeping cloud calls behind the local server, or issuing a scoped short-lived browser token and self-hosting/integrity-pinning the client bundle, would preserve the playground flow without exposing the long-lived key.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/moss-cli/src/moss_cli/commands/playground.py, line 119:

<comment>The automatic connection exposes the CLI's project key to an untrusted CDN-loaded JavaScript module, turning a CDN/package compromise into project credential theft. Keeping cloud calls behind the local server, or issuing a scoped short-lived browser token and self-hosting/integrity-pinning the client bundle, would preserve the playground flow without exposing the long-lived key.</comment>

<file context>
@@ -205,42 +111,14 @@ def _serve_index(self) -> None:
+            200,
+            {
+                "projectId": self._project_id,
+                "projectKey": self._project_key,
+            },
+        )
</file context>

},
)

def log_message(self, format, *args):
safe = [rich_escape(str(a)) for a in args]
if len(safe) >= 3:
console.print(f" [dim]{safe[0]} {safe[1]} {safe[2]}[/dim]")
elif len(safe) >= 2:
console.print(f" [dim]{safe[0]} {safe[1]}[/dim]")
elif len(safe) >= 1:
console.print(f" [dim]{safe[0]}[/dim]")


def _create_server(
handler: type,
start: int = 8765,
max_attempts: int = 20,
) -> tuple[DaemonThreadingHTTPServer, int]:
"""Construct the HTTP server on the first free port in the candidate range.

Binding happens inside ``DaemonThreadingHTTPServer``, so a port that is
probed free and then taken by another process is retried instead of
crashing the command (unlike a separate check-then-bind probe).
"""
last_error: OSError | None = None
for port in range(start, start + max_attempts):
try:
return DaemonThreadingHTTPServer(("127.0.0.1", port), handler), port
except OSError as e:
last_error = e
continue
raise RuntimeError(
f"Could not find a free port in range {start}-{start + max_attempts}"
) from last_error


def playground_command(
ctx: typer.Context,
port: int = typer.Option(0, "--port", "-p", help="Port for the HTTP server (0 = auto)"),
profile: Optional[str] = typer.Option(
None, "--profile", help="Credential profile name",
),
no_open: bool = typer.Option(
False, "--no-open", help="Do not open the browser automatically",
),
) -> None:
"""Start the Moss Playground — a browser-based UI for interactive search.

Launches a browser-based playground that loads the Moss WASM SDK
(@moss-dev/moss-web) and runs index loading and queries entirely in the
browser. Credentials from CLI flags, env vars, or a config profile are
injected through a token-protected endpoint; without them the UI shows a
manual connection form.
"""
if profile:
ctx.obj["profile"] = profile

# Resolve credentials if available — the playground still works without
# them by asking the user to connect manually in the browser.
try:
pid, pkey = resolve_credentials(
ctx.obj.get("project_id"), ctx.obj.get("project_key"), ctx.obj.get("profile")
)
except typer.BadParameter:
pid, pkey = None, None

# Start server
if port:
server = DaemonThreadingHTTPServer(("127.0.0.1", port), PlaygroundHandler)
final_port = port
else:
server, final_port = _create_server(PlaygroundHandler)

PlaygroundHandler._token = secrets.token_urlsafe(32)
PlaygroundHandler._server_host = f"127.0.0.1:{final_port}"
PlaygroundHandler._project_id = pid
PlaygroundHandler._project_key = pkey

url = f"http://127.0.0.1:{final_port}"
frag_url = f"{url}/#{PlaygroundHandler._token}"

console.print()
console.print(" [bold]Moss Playground[/bold]")
console.print(f" [dim]Server:[/dim] [cyan]{frag_url}[/cyan]")
if pid:
console.print(f" [dim]Project:[/dim] {pid[:8]}...")
else:
console.print(
" [yellow]No credentials found — enter them in the browser connection form.[/yellow]"
)
console.print(" [dim]Stop:[/dim] Ctrl+C")
console.print()

opened = False
if not no_open:
opened = webbrowser.open(frag_url)
if not opened:
console.print(" [yellow]Open this URL in your browser:[/yellow]")
console.print(f" [cyan]{frag_url}[/cyan]")
console.print()

try:
server.serve_forever()
except KeyboardInterrupt:
console.print("\n[yellow]Shutting down...[/yellow]")
finally:
server.server_close()
2 changes: 2 additions & 0 deletions packages/moss-cli/src/moss_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from .commands.index import index_app
from .commands.init_cmd import init_command
from .commands.job import job_app
from .commands.playground import playground_command
from .commands.profile import profile_app
from .commands.search import query_command
from .commands.sync import sync_command
Expand Down Expand Up @@ -39,6 +40,7 @@
app.command(name="validate")(validate_command)
app.command(name="sync")(sync_command)
app.command(name="completions")(completions_command)
app.command(name="playground")(playground_command)


@app.callback()
Expand Down
Loading
Loading