Merge remote-tracking branch 'origin/main' into r7552
This commit is contained in:
commit
31af36153b
5 changed files with 361 additions and 28 deletions
|
|
@ -1888,8 +1888,11 @@ jobs:
|
|||
# (step/substep -> Write-StudioStdoutMirror / Get-StudioAnsi).
|
||||
$script:StudioVtOk = $false
|
||||
$script:UnslothVerbose = $false
|
||||
# Get-HostMachineArch is reached only on the absent path, where
|
||||
# Test-VCRedistInstalled consults it before trusting the System32 DLL, so
|
||||
# part A passes without it and only the clean-box part fails.
|
||||
foreach ($fn in @('Get-StudioAnsi', 'Write-StudioStdoutMirror', 'step', 'substep',
|
||||
'Invoke-SetupCommand', 'Refresh-Environment',
|
||||
'Invoke-SetupCommand', 'Refresh-Environment', 'Get-HostMachineArch',
|
||||
'Test-VCRedistInstalled', 'Ensure-VCRedist')) {
|
||||
$src = Get-FunctionSource -Path $setup -Name $fn
|
||||
if (-not $src) { throw "Function '$fn' not found in setup.ps1" }
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import ipaddress
|
|||
import os
|
||||
import secrets
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, Tuple
|
||||
|
|
@ -30,6 +31,97 @@ _BOOTSTRAP_PW_PATH = DB_PATH.parent / ".bootstrap_password"
|
|||
_bootstrap_password: Optional[str] = None
|
||||
|
||||
|
||||
def _bootstrap_file_bytes(password: str) -> bytes:
|
||||
"""Exact on-disk form: the secret plus one LF.
|
||||
|
||||
Bytes, not text: text mode writes CRLF on Windows, and `$(cat ...)` strips
|
||||
the LF but leaves the CR attached to the credential.
|
||||
"""
|
||||
return (password + "\n").encode("utf-8")
|
||||
|
||||
|
||||
def _persist_bootstrap_password(password: str) -> None:
|
||||
"""Atomically write the bootstrap password 0600, LF terminated on every OS.
|
||||
|
||||
A partial write would destroy the only plaintext recovery credential.
|
||||
"""
|
||||
fd, tmp_name = tempfile.mkstemp(
|
||||
prefix = f".{_BOOTSTRAP_PW_PATH.name}.", dir = _BOOTSTRAP_PW_PATH.parent
|
||||
)
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as f:
|
||||
f.write(_bootstrap_file_bytes(password))
|
||||
try:
|
||||
os.chmod(tmp_name, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
os.replace(tmp_name, _BOOTSTRAP_PW_PATH)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp_name)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def _normalise_bootstrap_file(raw: bytes, password: str) -> None:
|
||||
"""Append the LF a pre-newline release left off.
|
||||
|
||||
Append-only, and only when the file is exactly the credential:
|
||||
clear_bootstrap_password() may unlink or (when unlink fails, notably on
|
||||
Windows while this descriptor is open) truncate through another descriptor
|
||||
after we read, so a rewrite could restore revoked plaintext. An append
|
||||
cannot: worst case is a lone "\\n" over a cleared file, which strips back to
|
||||
no bootstrap password. Pre-newline releases wrote no terminator at all, so
|
||||
that is the only shape in the wild; anything else reads fine, since every
|
||||
reader strips, and is left alone.
|
||||
"""
|
||||
if raw != password.encode("utf-8"):
|
||||
return
|
||||
|
||||
# O_BINARY: without it Windows opens in text mode and turns the LF straight
|
||||
# back into CRLF, the bug being fixed.
|
||||
fd = os.open(
|
||||
_BOOTSTRAP_PW_PATH,
|
||||
os.O_WRONLY | os.O_APPEND | getattr(os, "O_BINARY", 0),
|
||||
)
|
||||
try:
|
||||
os.write(fd, b"\n")
|
||||
try:
|
||||
os.fchmod(fd, 0o600)
|
||||
except (AttributeError, OSError):
|
||||
# fchmod only reached Windows in 3.13.
|
||||
pass
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def _read_persisted_bootstrap_password() -> Optional[str]:
|
||||
"""Read the persisted password, normalising the file if it is malformed."""
|
||||
if not _BOOTSTRAP_PW_PATH.is_file():
|
||||
return None
|
||||
|
||||
# No caller handles a raise, so an unreadable file has to mean "no bootstrap
|
||||
# password", not a dead backend. We write UTF-8, so undecodable bytes are
|
||||
# damage whose plaintext is worthless anyway.
|
||||
try:
|
||||
raw = _BOOTSTRAP_PW_PATH.read_bytes()
|
||||
password = raw.decode("utf-8").strip()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
return None
|
||||
if not password:
|
||||
return None
|
||||
|
||||
# Older releases wrote no terminator; best-effort, a read-only auth dir must
|
||||
# not fail startup.
|
||||
if raw != _bootstrap_file_bytes(password):
|
||||
try:
|
||||
_normalise_bootstrap_file(raw, password)
|
||||
except OSError:
|
||||
pass
|
||||
return password
|
||||
|
||||
|
||||
def generate_bootstrap_password() -> str:
|
||||
"""Generate a 4-word diceware passphrase and persist it to disk.
|
||||
|
||||
|
|
@ -43,10 +135,10 @@ def generate_bootstrap_password() -> str:
|
|||
return _bootstrap_password
|
||||
|
||||
# Persisted from a previous run?
|
||||
if _BOOTSTRAP_PW_PATH.is_file():
|
||||
_bootstrap_password = _BOOTSTRAP_PW_PATH.read_text(encoding = "utf-8").strip()
|
||||
if _bootstrap_password:
|
||||
return _bootstrap_password
|
||||
persisted = _read_persisted_bootstrap_password()
|
||||
if persisted:
|
||||
_bootstrap_password = persisted
|
||||
return _bootstrap_password
|
||||
|
||||
# First startup: generate a fresh passphrase.
|
||||
import diceware
|
||||
|
|
@ -57,11 +149,7 @@ def generate_bootstrap_password() -> str:
|
|||
|
||||
# Persist so the same passphrase survives restarts until password change.
|
||||
ensure_dir(_BOOTSTRAP_PW_PATH.parent)
|
||||
_BOOTSTRAP_PW_PATH.write_text(_bootstrap_password, encoding = "utf-8")
|
||||
try:
|
||||
os.chmod(_BOOTSTRAP_PW_PATH, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
_persist_bootstrap_password(_bootstrap_password)
|
||||
|
||||
return _bootstrap_password
|
||||
|
||||
|
|
@ -72,19 +160,14 @@ def get_bootstrap_password() -> Optional[str]:
|
|||
|
||||
|
||||
def _load_bootstrap_password() -> Optional[str]:
|
||||
"""Load an existing bootstrap password without creating one."""
|
||||
"""Load an existing bootstrap password without creating one.
|
||||
|
||||
Upgrades take this path, not generate_bootstrap_password()
|
||||
(ensure_default_admin short-circuits once the admin row exists), so it has
|
||||
to normalise too.
|
||||
"""
|
||||
global _bootstrap_password
|
||||
_bootstrap_password = None
|
||||
if _BOOTSTRAP_PW_PATH.is_file():
|
||||
# No caller handles a raise, so an unreadable file has to mean "no bootstrap
|
||||
# password", not a dead backend. We write UTF-8, so bytes that will not
|
||||
# decode are damage whose plaintext is worthless anyway.
|
||||
try:
|
||||
bootstrap_password = _BOOTSTRAP_PW_PATH.read_text(encoding = "utf-8").strip()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
return _bootstrap_password
|
||||
if bootstrap_password:
|
||||
_bootstrap_password = bootstrap_password
|
||||
_bootstrap_password = _read_persisted_bootstrap_password()
|
||||
return _bootstrap_password
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -134,6 +134,218 @@ def test_ensure_default_admin_loads_existing_bootstrap_after_restart(monkeypatch
|
|||
assert storage.get_bootstrap_password() == bootstrap_pw
|
||||
|
||||
|
||||
def test_bootstrap_password_file_ends_with_a_newline():
|
||||
# Otherwise `cat` welds the passphrase onto the shell prompt.
|
||||
storage.ensure_default_admin()
|
||||
|
||||
# Bytes: read_text would decode CRLF back to "\n" and hide a CR.
|
||||
raw = storage._BOOTSTRAP_PW_PATH.read_bytes()
|
||||
|
||||
assert raw == storage.get_bootstrap_password().encode("utf-8") + b"\n"
|
||||
|
||||
|
||||
def test_bootstrap_password_round_trips_across_a_restart_with_the_newline():
|
||||
storage.ensure_default_admin()
|
||||
original = storage.get_bootstrap_password()
|
||||
|
||||
storage._bootstrap_password = None
|
||||
|
||||
assert storage.generate_bootstrap_password() == original
|
||||
|
||||
|
||||
def test_upgrade_normalises_the_bootstrap_file():
|
||||
# Upgrade path: the admin row exists, so generate_bootstrap_password() never runs.
|
||||
seed_user()
|
||||
storage._BOOTSTRAP_PW_PATH.write_bytes(b"legacy-bootstrap-secret")
|
||||
|
||||
storage.ensure_default_admin()
|
||||
|
||||
assert storage._BOOTSTRAP_PW_PATH.read_bytes() == b"legacy-bootstrap-secret\n"
|
||||
assert storage.get_bootstrap_password() == "legacy-bootstrap-secret"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"other",
|
||||
[
|
||||
b"legacy-bootstrap-secret\r\n", # only an unreleased build wrote this
|
||||
b"legacy-bootstrap-secret\r",
|
||||
b"legacy-bootstrap-secret ",
|
||||
],
|
||||
)
|
||||
def test_only_an_exactly_unterminated_bootstrap_file_is_touched(other):
|
||||
# Appending is safe only because it is restricted to the one released shape.
|
||||
seed_user()
|
||||
storage._BOOTSTRAP_PW_PATH.write_bytes(other)
|
||||
|
||||
storage.ensure_default_admin()
|
||||
|
||||
assert storage.get_bootstrap_password() == "legacy-bootstrap-secret"
|
||||
assert storage._BOOTSTRAP_PW_PATH.read_bytes() == other
|
||||
|
||||
|
||||
def test_upgrade_normalises_when_the_admin_row_is_missing():
|
||||
storage._BOOTSTRAP_PW_PATH.write_bytes(b"legacy-bootstrap-secret")
|
||||
|
||||
assert storage.generate_bootstrap_password() == "legacy-bootstrap-secret"
|
||||
assert storage._BOOTSTRAP_PW_PATH.read_bytes() == b"legacy-bootstrap-secret\n"
|
||||
|
||||
|
||||
def test_a_well_formed_bootstrap_file_is_not_rewritten():
|
||||
seed_user()
|
||||
storage._BOOTSTRAP_PW_PATH.write_bytes(b"legacy-bootstrap-secret\n")
|
||||
mtime = storage._BOOTSTRAP_PW_PATH.stat().st_mtime_ns
|
||||
|
||||
storage.ensure_default_admin()
|
||||
|
||||
assert storage._BOOTSTRAP_PW_PATH.stat().st_mtime_ns == mtime
|
||||
|
||||
|
||||
def test_migration_failure_does_not_break_startup(monkeypatch):
|
||||
seed_user()
|
||||
storage._BOOTSTRAP_PW_PATH.write_bytes(b"legacy-bootstrap-secret")
|
||||
|
||||
real_open = storage.os.open
|
||||
|
||||
def refuse(path, flags, *args, **kwargs):
|
||||
if str(path) == str(storage._BOOTSTRAP_PW_PATH):
|
||||
raise PermissionError("read-only auth dir")
|
||||
return real_open(path, flags, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(storage.os, "open", refuse)
|
||||
|
||||
storage.ensure_default_admin()
|
||||
|
||||
assert storage.get_bootstrap_password() == "legacy-bootstrap-secret"
|
||||
assert storage._BOOTSTRAP_PW_PATH.read_bytes() == b"legacy-bootstrap-secret"
|
||||
|
||||
|
||||
def test_normalising_never_recreates_a_cleared_bootstrap_file(monkeypatch):
|
||||
# A rename would resurrect revoked plaintext if the password changed after the read.
|
||||
seed_user()
|
||||
storage._BOOTSTRAP_PW_PATH.write_bytes(b"legacy-bootstrap-secret")
|
||||
|
||||
real_open = storage.os.open
|
||||
|
||||
def clear_then_open(path, flags, *args, **kwargs):
|
||||
if str(path) == str(storage._BOOTSTRAP_PW_PATH):
|
||||
storage._BOOTSTRAP_PW_PATH.unlink(missing_ok = True)
|
||||
return real_open(path, flags, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(storage.os, "open", clear_then_open)
|
||||
|
||||
assert storage._read_persisted_bootstrap_password() == "legacy-bootstrap-secret"
|
||||
assert not storage._BOOTSTRAP_PW_PATH.exists()
|
||||
|
||||
|
||||
def test_normalising_does_not_overwrite_a_rotated_bootstrap_file(monkeypatch):
|
||||
seed_user()
|
||||
storage._BOOTSTRAP_PW_PATH.write_bytes(b"legacy-bootstrap-secret")
|
||||
|
||||
real_open = storage.os.open
|
||||
|
||||
def rotate_then_open(path, flags, *args, **kwargs):
|
||||
if str(path) == str(storage._BOOTSTRAP_PW_PATH):
|
||||
storage._BOOTSTRAP_PW_PATH.write_bytes(b"brand-new-secret\n")
|
||||
return real_open(path, flags, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(storage.os, "open", rotate_then_open)
|
||||
|
||||
storage._read_persisted_bootstrap_password()
|
||||
|
||||
# The append may add a second newline; the rotated credential must survive.
|
||||
raw = storage._BOOTSTRAP_PW_PATH.read_bytes()
|
||||
assert raw.strip() == b"brand-new-secret"
|
||||
storage._bootstrap_password = None
|
||||
assert storage._load_bootstrap_password() == "brand-new-secret"
|
||||
|
||||
|
||||
def test_leading_whitespace_bootstrap_file_is_left_alone(monkeypatch):
|
||||
# An in-place rewrite is not atomic, so only the exact unterminated shape is touched.
|
||||
seed_user()
|
||||
storage._BOOTSTRAP_PW_PATH.write_bytes(b" legacy-bootstrap-secret ")
|
||||
|
||||
storage.ensure_default_admin()
|
||||
|
||||
assert storage.get_bootstrap_password() == "legacy-bootstrap-secret"
|
||||
assert storage._BOOTSTRAP_PW_PATH.read_bytes() == b" legacy-bootstrap-secret "
|
||||
|
||||
|
||||
def test_normalising_opens_the_file_in_binary_mode(monkeypatch):
|
||||
# Without O_BINARY, Windows text mode turns the written LF back into CRLF.
|
||||
seed_user()
|
||||
storage._BOOTSTRAP_PW_PATH.write_bytes(b"legacy-bootstrap-secret")
|
||||
monkeypatch.setattr(storage.os, "O_BINARY", 0x8000, raising = False)
|
||||
seen = []
|
||||
real_open = storage.os.open
|
||||
|
||||
def spy(path, flags, *args, **kwargs):
|
||||
if str(path) == str(storage._BOOTSTRAP_PW_PATH):
|
||||
seen.append(flags)
|
||||
return real_open(path, flags & ~0x8000, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(storage.os, "open", spy)
|
||||
|
||||
storage.ensure_default_admin()
|
||||
|
||||
assert seen and all(f & 0x8000 for f in seen), seen
|
||||
|
||||
|
||||
def test_clearing_by_truncation_mid_normalisation_is_not_undone(monkeypatch):
|
||||
# clear_bootstrap_password() truncates through its own descriptor when the unlink
|
||||
# fails (Windows, while ours is open); the append must not restore the plaintext.
|
||||
seed_user()
|
||||
storage._BOOTSTRAP_PW_PATH.write_bytes(b"legacy-bootstrap-secret")
|
||||
|
||||
real_open = storage.os.open
|
||||
|
||||
def truncate_then_open(path, flags, *args, **kwargs):
|
||||
fd = real_open(path, flags, *args, **kwargs)
|
||||
if str(path) == str(storage._BOOTSTRAP_PW_PATH):
|
||||
storage._BOOTSTRAP_PW_PATH.write_text("", encoding = "utf-8")
|
||||
return fd
|
||||
|
||||
monkeypatch.setattr(storage.os, "open", truncate_then_open)
|
||||
|
||||
storage._read_persisted_bootstrap_password()
|
||||
|
||||
# A lone newline over a cleared file still reads back as no password.
|
||||
assert storage._BOOTSTRAP_PW_PATH.read_bytes().strip() == b""
|
||||
storage._bootstrap_password = None
|
||||
assert storage._load_bootstrap_password() is None
|
||||
|
||||
|
||||
def test_normalising_works_without_fchmod(monkeypatch):
|
||||
# os.fchmod only reached Windows in 3.13; its absence must not raise.
|
||||
seed_user()
|
||||
storage._BOOTSTRAP_PW_PATH.write_bytes(b"legacy-bootstrap-secret")
|
||||
monkeypatch.delattr(storage.os, "fchmod", raising = False)
|
||||
|
||||
storage.ensure_default_admin()
|
||||
|
||||
assert storage._BOOTSTRAP_PW_PATH.read_bytes() == b"legacy-bootstrap-secret\n"
|
||||
assert storage.get_bootstrap_password() == "legacy-bootstrap-secret"
|
||||
|
||||
|
||||
def test_persisting_the_bootstrap_password_is_atomic(monkeypatch, tmp_path):
|
||||
# A partial write would destroy the only plaintext recovery credential.
|
||||
storage._persist_bootstrap_password("original-secret")
|
||||
|
||||
def boom(src, dst):
|
||||
raise OSError("crash before replace")
|
||||
|
||||
monkeypatch.setattr(storage.os, "replace", boom)
|
||||
with pytest.raises(OSError):
|
||||
storage._persist_bootstrap_password("new-secret")
|
||||
|
||||
assert storage._BOOTSTRAP_PW_PATH.read_bytes() == b"original-secret\n"
|
||||
leftovers = [
|
||||
p.name
|
||||
for p in storage._BOOTSTRAP_PW_PATH.parent.iterdir()
|
||||
if "bootstrap_password." in p.name
|
||||
]
|
||||
assert leftovers == []
|
||||
|
||||
|
||||
def test_ensure_default_admin_does_not_generate_for_empty_existing_bootstrap():
|
||||
seed_user()
|
||||
storage._BOOTSTRAP_PW_PATH.write_text(" \n", encoding = "utf-8")
|
||||
|
|
@ -358,7 +570,7 @@ def test_write_desktop_secret_file_is_0600_on_unix(tmp_path):
|
|||
|
||||
studio_cli._write_auth_secret(path, "desktop-secret")
|
||||
|
||||
assert path.read_text() == "desktop-secret"
|
||||
assert path.read_bytes() == b"desktop-secret\n"
|
||||
if platform.system() != "Windows":
|
||||
assert oct(path.stat().st_mode & 0o777) == "0o600"
|
||||
|
||||
|
|
@ -525,7 +737,8 @@ if result.exit_code != 0:
|
|||
capture_output = True,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
secret = (auth_dir / ".desktop_secret").read_text()
|
||||
# Strip like the src-tauri readers do.
|
||||
secret = (auth_dir / ".desktop_secret").read_text().strip()
|
||||
assert secret.startswith("desktop-")
|
||||
|
||||
conn = sqlite3.connect(auth_dir / "auth.db")
|
||||
|
|
|
|||
|
|
@ -483,9 +483,12 @@ def _write_auth_secret(path: Path, secret: str) -> None:
|
|||
os.chmod(tmp_path, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
with os.fdopen(fd, "w", encoding = "utf-8") as f:
|
||||
# newline pins LF: text mode writes CRLF on Windows, and `$(cat ...)`
|
||||
# strips the LF but leaves the CR glued to the credential.
|
||||
with os.fdopen(fd, "w", encoding = "utf-8", newline = "\n") as f:
|
||||
fd = -1
|
||||
f.write(secret)
|
||||
# Newline so `cat` doesn't run it into the shell prompt; readers strip.
|
||||
f.write(secret + "\n")
|
||||
os.replace(tmp_path, path)
|
||||
except Exception:
|
||||
if fd >= 0:
|
||||
|
|
|
|||
|
|
@ -251,7 +251,7 @@ def test_studio_default_prompt_rejects_current_password(monkeypatch, tmp_path):
|
|||
studio_mod = _studio()
|
||||
events = _install_prompt_env(monkeypatch, tmp_path, interactive = True)
|
||||
_seed_auth(studio_mod)
|
||||
bootstrap_pw = (tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE).read_text()
|
||||
bootstrap_pw = (tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE).read_text().strip()
|
||||
|
||||
_invoke_studio_default(monkeypatch, events, ["--secure"])
|
||||
|
||||
|
|
@ -1204,6 +1204,37 @@ def test_connect_auth_db_creates_private_files(monkeypatch, tmp_path):
|
|||
assert stat.S_IMODE((auth_dir / "auth.db").stat().st_mode) == 0o600
|
||||
|
||||
|
||||
def test_write_auth_secret_terminates_the_file_with_a_newline(monkeypatch, tmp_path):
|
||||
# Shared by .bootstrap_password and .desktop_secret; every reader strips.
|
||||
studio_mod = _studio()
|
||||
path = tmp_path / ".desktop_secret"
|
||||
|
||||
studio_mod._write_auth_secret(path, "desktop-abc123")
|
||||
|
||||
# Bytes: read_text would decode CRLF back to "\n" and hide a CR.
|
||||
assert path.read_bytes() == b"desktop-abc123\n"
|
||||
|
||||
|
||||
def test_seeded_bootstrap_file_ends_with_a_newline(monkeypatch, tmp_path):
|
||||
studio_mod = _studio()
|
||||
monkeypatch.setattr(studio_mod, "STUDIO_HOME", tmp_path)
|
||||
_seed_auth(studio_mod)
|
||||
|
||||
raw = (tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE).read_bytes()
|
||||
|
||||
assert raw.endswith(b"\n") and not raw.endswith(b"\r\n")
|
||||
|
||||
conn = sqlite3.connect(_auth_db(tmp_path))
|
||||
try:
|
||||
salt, pwd_hash = conn.execute(
|
||||
"SELECT password_salt, password_hash FROM auth_user WHERE username = ?",
|
||||
(studio_mod.DEFAULT_ADMIN_USERNAME,),
|
||||
).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
assert studio_mod._pbkdf2_hex(raw.decode("utf-8").strip(), salt.encode("utf-8")) == pwd_hash
|
||||
|
||||
|
||||
# ── non-interactive --password / UNSLOTH_STUDIO_PASSWORD / stdin ──────
|
||||
|
||||
|
||||
|
|
@ -1284,7 +1315,7 @@ def test_studio_default_password_must_differ_fails_closed(monkeypatch, tmp_path)
|
|||
studio_mod = _studio()
|
||||
events = _install_prompt_env(monkeypatch, tmp_path, interactive = True)
|
||||
_seed_auth(studio_mod)
|
||||
bootstrap_pw = (tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE).read_text()
|
||||
bootstrap_pw = (tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE).read_text().strip()
|
||||
|
||||
result = _invoke_studio_default(monkeypatch, events, ["--secure", "--password", bootstrap_pw])
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue