Skip to content
Open
Changes from all commits
Commits
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
83 changes: 73 additions & 10 deletions cli/engram/commands/web.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,21 @@

A stdlib-only, server-rendered browser for the store. Read-only in this
cut; mutations stay on the CLI / MCP. Binds 127.0.0.1 by default; a
non-loopback bind requires ``--auth`` (DESIGN §7.4, no bypass).
non-loopback bind requires auth (DESIGN §7.4, no bypass).

Credentials arrive by one of two routes. ``--auth USER:PASS`` is the
convenient one and stays for single-user hosts. ``--auth-file PATH`` is
the one to use anywhere else: on a shared machine the command line is
world-readable through ``ps``/``/proc``, so a password passed as an
argument is visible to every other account on the box. The file route
keeps the secret behind filesystem permissions instead.
"""

from __future__ import annotations

import stat
from pathlib import Path

import click

from engram.config_types import GlobalConfig
Expand All @@ -15,6 +25,45 @@
__all__ = ["web_group"]


def _parse_auth_pair(raw: str, source: str) -> tuple[str, str]:
"""Split ``USER:PASS``. ``source`` names the origin for error text."""
if ":" not in raw:
raise click.ClickException(f"{source} must be USER:PASS")
user, _, password = raw.partition(":")
if not user or not password:
raise click.ClickException(f"{source} must be USER:PASS (both non-empty)")
return (user, password)


def _read_auth_file(path: str) -> tuple[str, str]:
"""Read ``USER:PASS`` from the first non-empty line of ``path``.

Warns when the file is readable beyond its owner: the whole point of
this route is that the secret sits behind file permissions, so a
world-readable credential file silently gives back what was gained.
"""
p = Path(path)
try:
mode = p.stat().st_mode
except OSError as exc:
raise click.ClickException(f"--auth-file: cannot stat {path}: {exc}") from exc
if mode & (stat.S_IRWXG | stat.S_IRWXO):
click.echo(
f"warning: {path} is readable beyond its owner "
f"(mode {oct(stat.S_IMODE(mode))}); run: chmod 600 {path}",
err=True,
)
try:
text = p.read_text(encoding="utf-8")
except OSError as exc:
raise click.ClickException(f"--auth-file: cannot read {path}: {exc}") from exc
for line in text.splitlines():
line = line.strip()
if line:
return _parse_auth_pair(line, "--auth-file content")
raise click.ClickException(f"--auth-file: {path} is empty")


@click.group("web", help="Local web UI for browsing the engram store (SPEC §7).")
def web_group() -> None:
pass
Expand All @@ -27,22 +76,36 @@ def web_group() -> None:
"--auth",
default=None,
metavar="USER:PASS",
help="Enable HTTP Basic auth. Required for a non-loopback --host.",
help="Enable HTTP Basic auth. Required for a non-loopback --host. "
"Visible to other accounts via ps on a shared host — prefer --auth-file there.",
)
@click.option(
"--auth-file",
"auth_file",
default=None,
metavar="PATH",
help="Read USER:PASS from the first non-empty line of PATH. "
"Use this instead of --auth on any multi-user machine.",
)
@click.option("--no-open", is_flag=True, default=False, help="Do not open a browser.")
@click.pass_obj
def serve_cmd(
cfg: GlobalConfig, host: str, port: int, auth: str | None, no_open: bool
cfg: GlobalConfig,
host: str,
port: int,
auth: str | None,
auth_file: str | None,
no_open: bool,
) -> None:
root = cfg.resolve_project_root()
if auth is not None and auth_file is not None:
raise click.ClickException("pass either --auth or --auth-file, not both")

auth_pair: tuple[str, str] | None = None
if auth is not None:
if ":" not in auth:
raise click.ClickException("--auth must be USER:PASS")
user, _, password = auth.partition(":")
if not user or not password:
raise click.ClickException("--auth must be USER:PASS (both non-empty)")
auth_pair = (user, password)
if auth_file is not None:
auth_pair = _read_auth_file(auth_file)
elif auth is not None:
auth_pair = _parse_auth_pair(auth, "--auth")

try:
httpd = serve(root, host=host, port=port, auth=auth_pair, open_browser=not no_open)
Expand Down
Loading