Skip to content
Merged
Show file tree
Hide file tree
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
1,096 changes: 1,096 additions & 0 deletions scripts/install-octop.sh

Large diffs are not rendered by default.

10 changes: 6 additions & 4 deletions scripts/install.bat
Original file line number Diff line number Diff line change
Expand Up @@ -96,10 +96,12 @@ if %_E% neq 0 exit /b 1
goto :install_verify

:install_from_pypi
set "_PACKAGE=octop"
if defined ARG_VERSION set "_PACKAGE=octop==%ARG_VERSION%"
echo [octop] Installing %_PACKAGE%%EXTRAS_SUFFIX% from PyPI...
uv pip install "%_PACKAGE%%EXTRAS_SUFFIX%" --python "%VENV_PYTHON%"
REM PEP 508: extras must sit between name and version specifier
REM (octop[browser]==x.y.z); octop==x.y.z[browser] is an invalid requirement.
set "_PACKAGE=octop%EXTRAS_SUFFIX%"
if defined ARG_VERSION set "_PACKAGE=octop%EXTRAS_SUFFIX%==%ARG_VERSION%"
echo [octop] Installing %_PACKAGE% from PyPI...
uv pip install "%_PACKAGE%" --python "%VENV_PYTHON%"
if errorlevel 1 (echo [octop] ERROR: install failed & exit /b 1)

:install_verify
Expand Down
8 changes: 5 additions & 3 deletions scripts/install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -233,11 +233,13 @@ if ($FromSource) {
}
}
} else {
$package = if ($Version) { "octop==$Version" } else { "octop" }
Write-Info "Installing ${package}${ExtrasSuffix} from PyPI..."
# PEP 508: extras must sit between name and version specifier
# (octop[browser]==x.y.z); octop==x.y.z[browser] is an invalid requirement.
$package = if ($Version) { "octop${ExtrasSuffix}==$Version" } else { "octop${ExtrasSuffix}" }
Write-Info "Installing ${package} from PyPI..."
$installArgs = @("--python", $VenvPython, "--quiet")
if ($Version -match '(dev|a|b|rc)') { $installArgs += "--prerelease=explicit" }
uv pip install "${package}${ExtrasSuffix}" @installArgs
uv pip install "${package}" @installArgs
}

Pin-McpCompat
Expand Down
300 changes: 152 additions & 148 deletions scripts/install.sh

Large diffs are not rendered by default.

2 changes: 0 additions & 2 deletions src/octop/api/routers/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,6 @@ def _setup_password_required(server: Any) -> bool:
def _enforce_wizard_open(server: Any) -> None:
um = server.user_manager
if um is not None and um.count() != 0:
_wizard.remove_password(Path.home())
raise OctopError(ErrorCode.SETUP_REQUIRED, "setup already completed", status=410)


Expand Down Expand Up @@ -366,7 +365,6 @@ async def initial_admin(
display_name=body.display_name,
locale=locale,
)
_wizard.remove_password(Path.home())
secret = server.services.secret_repo.get("jwt")
ttl = server.services.config.access_token_ttl_seconds
access_token = sign_token(
Expand Down
2 changes: 0 additions & 2 deletions src/octop/infra/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -271,8 +271,6 @@ def _emit_wizard_password(self, *, user_count: int) -> None:
logger.warning("wizard self-heal failed: %s", err)
new_pw = None
else:
if user_count == 0:
_wizard_pw.remove_password(wizard_home)
new_pw = None
if new_pw is not None:
banner = (
Expand Down
11 changes: 3 additions & 8 deletions src/octop/infra/setup/password_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

from __future__ import annotations

import contextlib
import os
import secrets
from pathlib import Path
Expand Down Expand Up @@ -66,21 +65,17 @@ def read_password(home: Path) -> str | None:
return None


def remove_password(home: Path) -> None:
target = _password_path(home)
with contextlib.suppress(FileNotFoundError):
target.unlink()


def boot_self_heal(home: Path, user_count: int) -> str | None:
"""Reconcile the wizard password file at server boot.

When setup is still open (``user_count == 0``), ensure a password file exists
and return the password so the CLI can print it — including when the file
already exists (e.g. the DB was wiped but ``octop-login.txt`` was not).

The file is intentionally kept forever as a permanent record and is never
deleted, even after setup completes.
"""
if user_count > 0:
remove_password(home)
return None
created = ensure_password(home)
return created if created is not None else read_password(home)
12 changes: 7 additions & 5 deletions tests/integration/test_setup_wizard.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Integration tests for the 4-step wizard backend.

Covers: verify-password, token-protected initial-admin, finish endpoint,
and the 410-with-cleanup behavior on completed setups.
and the 410 behavior on completed setups.
"""

from __future__ import annotations
Expand Down Expand Up @@ -76,7 +76,8 @@ async def test_initial_admin_succeeds_with_token(env: Any) -> None:
assert r.status_code == 201
body = r.json()
assert isinstance(body["access_token"], str)
assert not (Path.home() / WIZARD_FILE_NAME).exists()
# The wizard password file is kept permanently, even after setup completes.
assert (Path.home() / WIZARD_FILE_NAME).exists()


async def test_initial_admin_rejects_weak_password(env: Any) -> None:
Expand Down Expand Up @@ -355,7 +356,7 @@ async def test_finish_saves_provider_with_admin_jwt(env: Any) -> None:
assert last_state == "running"


# ─── 410 cleanup ───────────────────────────────────────────────────
# ─── 410 guard ─────────────────────────────────────────────────────


async def test_setup_410_after_admin_exists(env: Any) -> None:
Expand All @@ -367,11 +368,12 @@ async def test_setup_410_after_admin_exists(env: Any) -> None:
json={"username": "admin", "password": "TestPass12"},
headers={"Authorization": f"Bearer {tok}"},
)
# Plant a stale file to verify the cleanup branch.
# Plant a stale file to verify the 410 guard does not touch it.
(Path.home() / WIZARD_FILE_NAME).write_text("stale\n", encoding="utf-8")
r = await c.post("/api/setup/verify-password", json={"password": "stale"})
assert r.status_code == 410
assert not (Path.home() / WIZARD_FILE_NAME).exists()
# The wizard password file is never deleted by the app, even on the 410 path.
assert (Path.home() / WIZARD_FILE_NAME).exists()


# ─── lockdown middleware ───────────────────────────────────────────
Expand Down
16 changes: 2 additions & 14 deletions tests/unit/test_wizard_password.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
boot_self_heal,
ensure_password,
read_password,
remove_password,
)


Expand Down Expand Up @@ -53,17 +52,6 @@ def test_read_password_returns_none_when_missing(tmp_path: Path) -> None:
assert read_password(tmp_path) is None


def test_remove_password_unlinks_file(tmp_path: Path) -> None:
ensure_password(tmp_path)
remove_password(tmp_path)
assert not _file(tmp_path).exists()


def test_remove_password_is_idempotent(tmp_path: Path) -> None:
remove_password(tmp_path)
remove_password(tmp_path)


def test_boot_self_heal_generates_when_no_users(tmp_path: Path) -> None:
pw = boot_self_heal(tmp_path, user_count=0)
assert pw is not None
Expand All @@ -78,11 +66,11 @@ def test_boot_self_heal_redisplays_existing_password_with_no_users(tmp_path: Pat
assert read_password(tmp_path) == first


def test_boot_self_heal_removes_stale_file_when_users_exist(tmp_path: Path) -> None:
def test_boot_self_heal_keeps_file_when_users_exist(tmp_path: Path) -> None:
ensure_password(tmp_path)
pw = boot_self_heal(tmp_path, user_count=1)
assert pw is None
assert not _file(tmp_path).exists()
assert _file(tmp_path).exists()


def test_boot_self_heal_no_op_when_users_exist_and_no_file(tmp_path: Path) -> None:
Expand Down
Loading