1222 lines
46 KiB
Bash
Executable File
1222 lines
46 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
MODEL_REVISION="541d1f99c6b0c3cd0b11a95167540bb8edefd82b"
|
|
MODEL_SHA256="3cbdc85877e668ca7b82d0d56770eb1fac76691f55d6b97545e8d61ca588d10d"
|
|
MODEL_FILENAME="parakeet-tdt-0.6b-v3.nemo"
|
|
|
|
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
|
REPOSITORY_ROOT="$(cd -- "$SCRIPT_DIR/../.." && pwd)"
|
|
# Keep the install roots deliberately fixed below $HOME. In particular, do not
|
|
# let an environment variable turn an uninstall into rm -rf /.
|
|
INSTALL_ROOT="$HOME/.local/share/pi-dictate"
|
|
CONFIG_ROOT="$HOME/.config/pi-dictate"
|
|
CREDENTIAL_DIR="$CONFIG_ROOT/credentials"
|
|
CONFIG_PATH="$CONFIG_ROOT/config.json"
|
|
PROFILE_MARKER="$INSTALL_ROOT/.dependency-profile"
|
|
UNIT_DIR="$HOME/.config/systemd/user"
|
|
DROPIN_DIR="$UNIT_DIR/pi-dictate.service.d"
|
|
DROPIN_PATH="$DROPIN_DIR/10-pi-dictate-network.conf"
|
|
MODEL_DIR="$INSTALL_ROOT/models/parakeet-tdt-0.6b-v3"
|
|
MODEL_PATH="$MODEL_DIR/$MODEL_FILENAME"
|
|
DICTIONARY_SOURCE="$SCRIPT_DIR/coding-terms.txt"
|
|
DICTIONARY_PATH="$INSTALL_ROOT/config/coding-terms.txt"
|
|
DRY_RUN=false
|
|
MODEL_SOURCE=""
|
|
MODEL_SHA256_OVERRIDE=""
|
|
API_KEY_FILE=""
|
|
BACKEND=""
|
|
PURGE_CONFIG=false
|
|
ACTION=install
|
|
|
|
usage() {
|
|
cat <<'EOF'
|
|
Usage:
|
|
install.sh install [--backend local|meta|openai] [--model-source PATH]
|
|
[--model-sha256 SHA256] [--api-key-file PATH] [--dry-run]
|
|
install.sh configure --backend meta|openai --api-key-file PATH [--dry-run]
|
|
install.sh configure --backend local [--model-source PATH --model-sha256 SHA256] [--dry-run]
|
|
install.sh check
|
|
install.sh uninstall [--purge-config] [--dry-run]
|
|
|
|
The default install backend is local. A cloud key must be supplied by path;
|
|
the key contents are never accepted as an argument. Cloud installs do not
|
|
provision the local model or NeMo/CUDA dependencies.
|
|
EOF
|
|
}
|
|
|
|
run() {
|
|
printf '+ '
|
|
printf '%q ' "$@"
|
|
printf '\n'
|
|
if ! $DRY_RUN; then
|
|
"$@"
|
|
fi
|
|
}
|
|
|
|
is_legacy_service_unit() {
|
|
local path="$1"
|
|
[[ -f "$path" && ! -L "$path" ]] || return 1
|
|
LEGACY_UNIT_PATH="$path" python3 - <<'PY'
|
|
import hashlib
|
|
import os
|
|
import re
|
|
from pathlib import Path
|
|
|
|
raw = Path(os.environ["LEGACY_UNIT_PATH"]).read_bytes()
|
|
normalized, count = re.subn(
|
|
rb"^Environment=PI_DICTATE_IDLE_SECONDS=[0-9]+$",
|
|
b"Environment=PI_DICTATE_IDLE_SECONDS=900",
|
|
raw,
|
|
count=1,
|
|
flags=re.MULTILINE,
|
|
)
|
|
if count != 1:
|
|
raise SystemExit(1)
|
|
idle_match = re.search(rb"^Environment=PI_DICTATE_IDLE_SECONDS=([0-9]+)$", raw, re.MULTILINE)
|
|
if idle_match is None or not 60 <= int(idle_match.group(1)) <= 86400:
|
|
raise SystemExit(1)
|
|
# Exact previous checked-in unit, allowing only its documented idle override.
|
|
expected = "eca5297563ae5e337b0b28a036218cee6289e5bbaaec89ddda0c762bd05dfd62"
|
|
raise SystemExit(0 if hashlib.sha256(normalized).hexdigest() == expected else 1)
|
|
PY
|
|
}
|
|
|
|
read_legacy_idle_seconds() {
|
|
local path="$1"
|
|
is_legacy_service_unit "$path" || return 1
|
|
grep '^Environment=PI_DICTATE_IDLE_SECONDS=' "$path" | cut -d= -f3
|
|
}
|
|
|
|
is_owned_unit() {
|
|
local destination="$1"
|
|
local source="$2"
|
|
if [[ -L "$destination" ]]; then
|
|
[[ "$(readlink -f -- "$destination")" == "$(readlink -f -- "$source")" ]]
|
|
else
|
|
# Recognize the exact current unit or the exact previous service template.
|
|
# A copied marker alone is never ownership proof.
|
|
[[ -f "$destination" ]] && {
|
|
cmp -s "$destination" "$source" ||
|
|
{ [[ "$(basename -- "$source")" == pi-dictate.service ]] && is_legacy_service_unit "$destination"; }
|
|
}
|
|
fi
|
|
}
|
|
|
|
install_owned_unit() {
|
|
local source="$1"
|
|
local destination="$2"
|
|
if [[ -L "$destination" && "$(readlink -f -- "$destination")" == "$(readlink -f -- "$source")" ]]; then
|
|
run rm "$destination"
|
|
elif [[ -L "$destination" || -e "$destination" ]]; then
|
|
if ! is_owned_unit "$destination" "$source"; then
|
|
printf 'Refusing to replace unmanaged or modified systemd unit: %s\n' "$destination" >&2
|
|
return 1
|
|
fi
|
|
fi
|
|
run install -m 0644 "$source" "$destination"
|
|
}
|
|
|
|
remove_owned_unit() {
|
|
local destination="$1"
|
|
local source="${2:-}"
|
|
if [[ -n "$source" && -L "$destination" && "$(readlink -f -- "$destination")" == "$(readlink -f -- "$source")" ]]; then
|
|
run rm "$destination"
|
|
elif [[ -n "$source" && ! -L "$destination" && -f "$destination" ]] && is_owned_unit "$destination" "$source"; then
|
|
run rm "$destination"
|
|
elif [[ -e "$destination" || -L "$destination" ]]; then
|
|
printf 'Leaving unmanaged or modified unit in place: %s\n' "$destination" >&2
|
|
fi
|
|
}
|
|
|
|
fail() {
|
|
printf '%s\n' "$*" >&2
|
|
exit 1
|
|
}
|
|
|
|
valid_backend() {
|
|
case "$1" in
|
|
local|meta|openai) return 0 ;;
|
|
*) return 1 ;;
|
|
esac
|
|
}
|
|
|
|
while (($#)); do
|
|
case "$1" in
|
|
install|configure|check|uninstall)
|
|
ACTION="$1"
|
|
;;
|
|
--dry-run) DRY_RUN=true ;;
|
|
--purge-config) PURGE_CONFIG=true ;;
|
|
--backend)
|
|
shift
|
|
(($#)) || fail '--backend requires local, meta, or openai'
|
|
BACKEND="$1"
|
|
valid_backend "$BACKEND" || fail "Unsupported backend: $BACKEND"
|
|
;;
|
|
--model-source)
|
|
shift
|
|
(($#)) || fail '--model-source requires a path'
|
|
MODEL_SOURCE="$1"
|
|
;;
|
|
--model-sha256)
|
|
shift
|
|
(($#)) || fail '--model-sha256 requires a SHA-256 digest'
|
|
MODEL_SHA256_OVERRIDE="$1"
|
|
;;
|
|
--api-key-file)
|
|
shift
|
|
(($#)) || fail '--api-key-file requires a path'
|
|
API_KEY_FILE="$1"
|
|
;;
|
|
-h|--help) usage; exit 0 ;;
|
|
*) printf 'Unknown argument: %s\n' "$1" >&2; usage >&2; exit 2 ;;
|
|
esac
|
|
shift
|
|
done
|
|
|
|
if [[ "$ACTION" == check ]]; then
|
|
[[ -z "$BACKEND" ]] || fail '--backend is not valid for check'
|
|
[[ -z "$MODEL_SOURCE" ]] || fail '--model-source is only valid for install'
|
|
[[ -z "$MODEL_SHA256_OVERRIDE" ]] || fail '--model-sha256 is only valid for install'
|
|
[[ -z "$API_KEY_FILE" ]] || fail '--api-key-file is only valid for install or configure'
|
|
[[ "$PURGE_CONFIG" == false ]] || fail '--purge-config is only valid for uninstall'
|
|
elif [[ "$ACTION" == uninstall ]]; then
|
|
[[ -z "$BACKEND" ]] || fail '--backend is not valid for uninstall'
|
|
[[ -z "$API_KEY_FILE" ]] || fail '--api-key-file is not valid for uninstall'
|
|
[[ -z "$MODEL_SHA256_OVERRIDE" ]] || fail '--model-sha256 is not valid for uninstall'
|
|
elif [[ "$ACTION" == configure ]]; then
|
|
[[ "$PURGE_CONFIG" == false ]] || fail '--purge-config is only valid for uninstall'
|
|
else
|
|
[[ "$PURGE_CONFIG" == false ]] || fail '--purge-config is only valid for uninstall'
|
|
fi
|
|
|
|
if [[ -n "$MODEL_SHA256_OVERRIDE" && ! "$MODEL_SHA256_OVERRIDE" =~ ^[[:xdigit:]]{64}$ ]]; then
|
|
fail '--model-sha256 must be exactly 64 hexadecimal characters'
|
|
fi
|
|
if [[ -n "$MODEL_SOURCE" && -z "$MODEL_SHA256_OVERRIDE" ]]; then
|
|
fail '--model-source requires --model-sha256 (custom archives must be explicitly verified)'
|
|
fi
|
|
|
|
# This parser is intentionally duplicated in the installer rather than relying
|
|
# on jq or the service virtualenv. It makes check useful before provisioning a
|
|
# virtualenv and means malformed configuration fails closed.
|
|
validate_config() {
|
|
local path="$1"
|
|
CONFIG_TO_VALIDATE="$path" python3 - <<'PY'
|
|
import json
|
|
import math
|
|
import os
|
|
import re
|
|
import stat
|
|
from pathlib import Path
|
|
|
|
path = Path(os.environ["CONFIG_TO_VALIDATE"])
|
|
fd = -1
|
|
try:
|
|
fd = os.open(path, os.O_RDONLY | os.O_CLOEXEC | getattr(os, "O_NOFOLLOW", 0))
|
|
info = os.fstat(fd)
|
|
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o077:
|
|
raise ValueError("configuration ownership, permissions, or file type is insecure")
|
|
if info.st_size > 65536:
|
|
raise ValueError("configuration is too large")
|
|
raw = bytearray()
|
|
while len(raw) <= 65536:
|
|
chunk = os.read(fd, min(4096, 65537 - len(raw)))
|
|
if not chunk:
|
|
break
|
|
raw.extend(chunk)
|
|
if len(raw) > 65536:
|
|
raise ValueError("configuration is too large")
|
|
data = json.loads(bytes(raw).decode("utf-8"))
|
|
except (OSError, UnicodeError, json.JSONDecodeError, ValueError) as exc:
|
|
raise SystemExit(f"Invalid pi-dictate configuration: {exc}")
|
|
finally:
|
|
if fd >= 0:
|
|
os.close(fd)
|
|
|
|
if not isinstance(data, dict):
|
|
raise SystemExit("Invalid pi-dictate configuration: root must be an object")
|
|
if set(data) - {"version", "backend", "idleSeconds", "local", "meta", "openai"}:
|
|
raise SystemExit("Invalid pi-dictate configuration: unknown root key")
|
|
version = data.get("version")
|
|
if isinstance(version, bool) or version != 1:
|
|
raise SystemExit("Invalid pi-dictate configuration: unsupported version")
|
|
backend = data.get("backend")
|
|
if not isinstance(backend, str) or backend not in {"local", "meta", "openai"}:
|
|
raise SystemExit("Invalid pi-dictate configuration: unsupported backend")
|
|
idle = data.get("idleSeconds", 900)
|
|
if isinstance(idle, bool) or not isinstance(idle, int) or not 60 <= idle <= 86400:
|
|
raise SystemExit("Invalid pi-dictate configuration: idleSeconds must be an integer in bounds")
|
|
|
|
schemas = {
|
|
"local": {"adapter", "modelPath", "modelSha256", "termsPath", "boostAlpha"},
|
|
"meta": {"model", "apiKeyFile", "termsPath", "languageBias"},
|
|
"openai": {"model", "apiKeyFile", "prompt", "termsPath", "languages", "delay"},
|
|
}
|
|
section = data.get(backend)
|
|
if not isinstance(section, dict):
|
|
raise SystemExit("Invalid pi-dictate configuration: selected backend section is required")
|
|
if set(section) - schemas[backend]:
|
|
raise SystemExit("Invalid pi-dictate configuration: unknown selected-backend key")
|
|
|
|
if backend == "local":
|
|
if section.get("adapter") != "nemo-parakeet":
|
|
raise SystemExit("Invalid pi-dictate configuration: unsupported local adapter")
|
|
if not isinstance(section.get("modelPath"), str) or not section["modelPath"] or not (section["modelPath"].startswith("/") or section["modelPath"].startswith("~/")) or "\x00" in section["modelPath"] or "\n" in section["modelPath"] or "\r" in section["modelPath"] or not section["modelPath"].endswith(".nemo"):
|
|
raise SystemExit("Invalid pi-dictate configuration: modelPath must name an absolute .nemo archive")
|
|
if not isinstance(section.get("modelSha256"), str) or not re.fullmatch(r"[0-9a-fA-F]{64}", section["modelSha256"]):
|
|
raise SystemExit("Invalid pi-dictate configuration: modelSha256 is required")
|
|
if not isinstance(section.get("termsPath"), str) or not section["termsPath"] or not (section["termsPath"].startswith("/") or section["termsPath"].startswith("~/")) or "\x00" in section["termsPath"] or "\n" in section["termsPath"] or "\r" in section["termsPath"]:
|
|
raise SystemExit("Invalid pi-dictate configuration: termsPath must be absolute")
|
|
boost = section.get("boostAlpha")
|
|
if isinstance(boost, bool) or not isinstance(boost, (int, float)) or not 0 < boost <= 10:
|
|
raise SystemExit("Invalid pi-dictate configuration: boostAlpha out of bounds")
|
|
else:
|
|
expected_model = "muse-voice-transcribe-1.0" if backend == "meta" else None
|
|
if backend == "meta" and section.get("model") != expected_model:
|
|
raise SystemExit("Invalid pi-dictate configuration: unsupported Meta model")
|
|
if backend == "openai" and (not isinstance(section.get("model"), str) or not section["model"] or any(control in section["model"] for control in ("\x00", "\n", "\r")) or len(section["model"].encode("utf-8")) > 128 or section["model"] == "whisper-1"):
|
|
raise SystemExit("Invalid pi-dictate configuration: unsupported OpenAI model")
|
|
expected_key = f"~/.config/pi-dictate/credentials/{backend}-api-key"
|
|
if section.get("apiKeyFile") != expected_key:
|
|
raise SystemExit("Invalid pi-dictate configuration: credential path must use the managed directory")
|
|
if not isinstance(section.get("termsPath"), str) or not section["termsPath"] or not (section["termsPath"].startswith("/") or section["termsPath"].startswith("~/")) or "\x00" in section["termsPath"] or "\n" in section["termsPath"] or "\r" in section["termsPath"]:
|
|
raise SystemExit("Invalid pi-dictate configuration: termsPath must be absolute")
|
|
if backend == "meta":
|
|
languages = section.get("languageBias", [])
|
|
if not isinstance(languages, list) or len(languages) > 16 or any(not isinstance(item, str) or not item or len(item.encode("utf-8")) > 32 or any(control in item for control in ("\x00", "\n", "\r")) for item in languages):
|
|
raise SystemExit("Invalid pi-dictate configuration: invalid Meta languageBias")
|
|
if backend == "openai":
|
|
delay = section.get("delay")
|
|
if delay is not None and (not isinstance(delay, str) or delay not in {"minimal", "low", "medium", "high", "xhigh"}):
|
|
raise SystemExit("Invalid pi-dictate configuration: invalid OpenAI delay")
|
|
prompt = section.get("prompt")
|
|
if prompt is not None and (not isinstance(prompt, str) or not prompt or any(control in prompt for control in ("\x00", "\n", "\r")) or len(prompt.encode("utf-8")) > 4096):
|
|
raise SystemExit("Invalid pi-dictate configuration: invalid OpenAI prompt")
|
|
languages = section.get("languages", [])
|
|
if not isinstance(languages, list) or ("languages" in section and not languages) or len(languages) > 16 or any(not isinstance(item, str) or not item or len(item.encode("utf-8")) > 32 or any(control in item for control in ("\x00", "\n", "\r")) for item in languages):
|
|
raise SystemExit("Invalid pi-dictate configuration: invalid OpenAI languages")
|
|
PY
|
|
}
|
|
|
|
read_config_backend() {
|
|
CONFIG_TO_READ="$CONFIG_PATH" python3 - <<'PY'
|
|
import json
|
|
import os
|
|
import stat
|
|
from pathlib import Path
|
|
|
|
path = Path(os.environ["CONFIG_TO_READ"])
|
|
fd = -1
|
|
try:
|
|
fd = os.open(path, os.O_RDONLY | os.O_CLOEXEC | getattr(os, "O_NOFOLLOW", 0))
|
|
info = os.fstat(fd)
|
|
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o077 or info.st_size > 65536:
|
|
raise ValueError
|
|
raw = os.read(fd, 65537)
|
|
if len(raw) > 65536:
|
|
raise ValueError
|
|
data = json.loads(raw.decode("utf-8"))
|
|
backend = data["backend"]
|
|
except (OSError, UnicodeError, json.JSONDecodeError, KeyError, TypeError, ValueError):
|
|
raise SystemExit("Cannot determine backend from invalid pi-dictate configuration")
|
|
finally:
|
|
if fd >= 0:
|
|
os.close(fd)
|
|
if backend not in {"local", "meta", "openai"}:
|
|
raise SystemExit("Cannot determine backend from invalid pi-dictate configuration")
|
|
print(backend)
|
|
PY
|
|
}
|
|
|
|
read_config_model_sha() {
|
|
CONFIG_TO_READ="$CONFIG_PATH" python3 - <<'PY'
|
|
import json
|
|
import os
|
|
import stat
|
|
from pathlib import Path
|
|
|
|
fd = -1
|
|
try:
|
|
path = Path(os.environ["CONFIG_TO_READ"])
|
|
fd = os.open(path, os.O_RDONLY | os.O_CLOEXEC | getattr(os, "O_NOFOLLOW", 0))
|
|
info = os.fstat(fd)
|
|
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o077 or info.st_size > 65536:
|
|
raise ValueError
|
|
raw = os.read(fd, 65537)
|
|
if len(raw) > 65536:
|
|
raise ValueError
|
|
data = json.loads(raw.decode("utf-8"))
|
|
value = data.get("local", {}).get("modelSha256", "")
|
|
except (OSError, UnicodeError, json.JSONDecodeError, AttributeError, ValueError):
|
|
value = ""
|
|
finally:
|
|
if fd >= 0:
|
|
os.close(fd)
|
|
print(value if isinstance(value, str) else "")
|
|
PY
|
|
}
|
|
|
|
read_config_model_path() {
|
|
CONFIG_TO_READ="$CONFIG_PATH" python3 - <<'PY'
|
|
import json
|
|
import os
|
|
import stat
|
|
from pathlib import Path
|
|
|
|
fd = -1
|
|
try:
|
|
path = Path(os.environ["CONFIG_TO_READ"])
|
|
fd = os.open(path, os.O_RDONLY | os.O_CLOEXEC | getattr(os, "O_NOFOLLOW", 0))
|
|
info = os.fstat(fd)
|
|
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o077 or info.st_size > 65536:
|
|
raise ValueError
|
|
raw = os.read(fd, 65537)
|
|
if len(raw) > 65536:
|
|
raise ValueError
|
|
data = json.loads(raw.decode("utf-8"))
|
|
value = data.get("local", {}).get("modelPath", "")
|
|
except (OSError, UnicodeError, json.JSONDecodeError, AttributeError, ValueError):
|
|
value = ""
|
|
finally:
|
|
if fd >= 0:
|
|
os.close(fd)
|
|
if not value:
|
|
# A cloud configuration has no local section. Switching explicitly to
|
|
# local should then use the pinned default rather than a stale value.
|
|
print("")
|
|
elif not isinstance(value, str) or "\n" in value or "\r" in value:
|
|
raise SystemExit("Invalid local model path")
|
|
else:
|
|
print(Path(value).expanduser())
|
|
PY
|
|
}
|
|
|
|
validate_credential() {
|
|
local path="$1"
|
|
CREDENTIAL_TO_VALIDATE="$path" python3 - <<'PY'
|
|
import os
|
|
import stat
|
|
from pathlib import Path
|
|
|
|
path = Path(os.environ["CREDENTIAL_TO_VALIDATE"])
|
|
fd = -1
|
|
try:
|
|
fd = os.open(path, os.O_RDONLY | os.O_CLOEXEC | getattr(os, "O_NOFOLLOW", 0))
|
|
info = os.fstat(fd)
|
|
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o077:
|
|
raise ValueError("credential ownership, permissions, or file type is insecure")
|
|
if stat.S_IMODE(info.st_mode) != 0o600:
|
|
raise ValueError("credential ownership or permissions are insecure")
|
|
if info.st_size > 4096:
|
|
raise ValueError("credential is too large")
|
|
raw = bytearray()
|
|
while len(raw) <= 4096:
|
|
chunk = os.read(fd, min(4096, 4097 - len(raw)))
|
|
if not chunk:
|
|
break
|
|
raw.extend(chunk)
|
|
if len(raw) > 4096:
|
|
raise ValueError("credential is too large")
|
|
value = bytes(raw).decode("utf-8")
|
|
except (OSError, UnicodeError, ValueError) as exc:
|
|
raise SystemExit(f"Invalid pi-dictate credential: {exc}")
|
|
finally:
|
|
if fd >= 0:
|
|
os.close(fd)
|
|
if value.endswith("\n"):
|
|
value = value[:-1]
|
|
if not value or "\n" in value or "\r" in value or "\x00" in value:
|
|
raise SystemExit("Invalid pi-dictate credential: empty or multi-line value")
|
|
PY
|
|
}
|
|
|
|
copy_credential() {
|
|
local source="$1"
|
|
local destination="$2"
|
|
[[ -f "$source" && ! -L "$source" ]] || fail "API key source is not a regular file: $source"
|
|
if ! $DRY_RUN; then
|
|
# Normalize one trailing newline while copying. No key value is passed in
|
|
# argv, inherited environment, or output.
|
|
CREDENTIAL_SOURCE="$source" CREDENTIAL_DESTINATION="$destination" python3 - <<'PY'
|
|
import os
|
|
import stat
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
source = Path(os.environ["CREDENTIAL_SOURCE"])
|
|
destination = Path(os.environ["CREDENTIAL_DESTINATION"])
|
|
fd = -1
|
|
try:
|
|
flags = os.O_RDONLY | os.O_CLOEXEC | getattr(os, "O_NOFOLLOW", 0)
|
|
fd = os.open(source, flags)
|
|
info = os.fstat(fd)
|
|
if not stat.S_ISREG(info.st_mode):
|
|
raise ValueError("source is not a regular file")
|
|
if info.st_size > 4096:
|
|
raise ValueError("source is too large")
|
|
with os.fdopen(fd, "rb") as stream:
|
|
fd = -1
|
|
raw = stream.read(4097)
|
|
if len(raw) > 4096:
|
|
raise ValueError("source is too large")
|
|
value = raw.decode("utf-8")
|
|
except (OSError, UnicodeError, ValueError) as exc:
|
|
if fd >= 0:
|
|
os.close(fd)
|
|
raise SystemExit(f"Invalid API key source: {exc}")
|
|
if value.endswith("\n"):
|
|
value = value[:-1]
|
|
if not value or "\n" in value or "\r" in value or "\x00" in value:
|
|
raise SystemExit("Invalid API key source: empty or multi-line value")
|
|
fd, temporary_name = tempfile.mkstemp(prefix=f".{destination.name}.install.", dir=destination.parent)
|
|
tmp = Path(temporary_name)
|
|
try:
|
|
with os.fdopen(fd, "w", encoding="utf-8") as stream:
|
|
stream.write(value)
|
|
stream.flush()
|
|
os.fsync(stream.fileno())
|
|
os.chmod(tmp, 0o600)
|
|
os.replace(tmp, destination)
|
|
except BaseException:
|
|
tmp.unlink(missing_ok=True)
|
|
raise
|
|
PY
|
|
else
|
|
printf '+ copy API key from %q to %q (mode 0600; value redacted)\n' "$source" "$destination"
|
|
fi
|
|
}
|
|
|
|
write_config() {
|
|
if $DRY_RUN; then
|
|
printf '+ atomically write %q for backend %q (mode 0600; credentials redacted)\n' "$CONFIG_PATH" "$BACKEND"
|
|
return
|
|
fi
|
|
CONFIG_TO_WRITE="$CONFIG_PATH" CONFIG_BACKEND="$BACKEND" CONFIG_MODEL_SHA256="$EFFECTIVE_MODEL_SHA256" \
|
|
CONFIG_MODEL_PATH="$MODEL_PATH" python3 - <<'PY'
|
|
import json
|
|
import os
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
path = Path(os.environ["CONFIG_TO_WRITE"])
|
|
backend = os.environ["CONFIG_BACKEND"]
|
|
model_sha = os.environ["CONFIG_MODEL_SHA256"]
|
|
old = {}
|
|
if path.exists():
|
|
try:
|
|
old = json.loads(path.read_text(encoding="utf-8"))
|
|
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
|
raise SystemExit(f"Refusing to replace invalid pi-dictate configuration: {exc}")
|
|
if not isinstance(old, dict):
|
|
raise SystemExit("Refusing to replace invalid pi-dictate configuration")
|
|
|
|
section = old.get(backend, {})
|
|
if not isinstance(section, dict):
|
|
section = {}
|
|
legacy_idle = os.environ.get("CONFIG_LEGACY_IDLE_SECONDS", "")
|
|
config = {
|
|
"version": 1,
|
|
"backend": backend,
|
|
"idleSeconds": old.get("idleSeconds", int(legacy_idle) if legacy_idle else 900),
|
|
}
|
|
# Keep inactive provider settings so switching back does not discard a
|
|
# deliberately configured model, prompt, or terms path. Only the selected
|
|
# section is validated and consumed by the service.
|
|
for inactive in ("local", "meta", "openai"):
|
|
if inactive != backend and isinstance(old.get(inactive), dict):
|
|
config[inactive] = old[inactive]
|
|
if backend == "local":
|
|
section = {
|
|
"adapter": section.get("adapter", "nemo-parakeet"),
|
|
"modelPath": section.get("modelPath", "~/.local/share/pi-dictate/models/parakeet-tdt-0.6b-v3/parakeet-tdt-0.6b-v3.nemo"),
|
|
"modelSha256": section.get("modelSha256", model_sha),
|
|
"termsPath": section.get("termsPath", "~/.local/share/pi-dictate/config/coding-terms.txt"),
|
|
"boostAlpha": section.get("boostAlpha", 1.0),
|
|
}
|
|
# A replaced archive stays at the selected destination. Preserve custom
|
|
# paths rather than rewriting them to the managed default.
|
|
if os.environ.get("CONFIG_MODEL_REPLACED") == "1":
|
|
selected_model = Path(os.environ["CONFIG_MODEL_PATH"])
|
|
managed_model = Path("~/.local/share/pi-dictate/models/parakeet-tdt-0.6b-v3/parakeet-tdt-0.6b-v3.nemo").expanduser()
|
|
section["modelPath"] = (
|
|
"~/.local/share/pi-dictate/models/parakeet-tdt-0.6b-v3/parakeet-tdt-0.6b-v3.nemo"
|
|
if selected_model == managed_model else str(selected_model)
|
|
)
|
|
section["modelSha256"] = model_sha
|
|
else:
|
|
if backend == "meta":
|
|
section = {
|
|
"model": section.get("model", "muse-voice-transcribe-1.0"),
|
|
"apiKeyFile": "~/.config/pi-dictate/credentials/meta-api-key",
|
|
"termsPath": section.get("termsPath", "~/.local/share/pi-dictate/config/coding-terms.txt"),
|
|
"languageBias": section.get("languageBias", []),
|
|
}
|
|
else:
|
|
delay = section.get("delay")
|
|
section = {
|
|
"model": section.get("model", "gpt-live-transcribe"),
|
|
"apiKeyFile": "~/.config/pi-dictate/credentials/openai-api-key",
|
|
"prompt": section.get("prompt", "Coding dictation"),
|
|
"termsPath": section.get("termsPath", "~/.local/share/pi-dictate/config/coding-terms.txt"),
|
|
"languages": section.get("languages", ["en"]),
|
|
}
|
|
if delay is not None:
|
|
section["delay"] = delay
|
|
config[backend] = section
|
|
|
|
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
fd, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.install.", dir=path.parent)
|
|
tmp = Path(temporary_name)
|
|
try:
|
|
with os.fdopen(fd, "w", encoding="utf-8") as stream:
|
|
stream.write(json.dumps(config, indent=2, sort_keys=True) + "\n")
|
|
stream.flush()
|
|
os.fsync(stream.fileno())
|
|
os.chmod(tmp, 0o600)
|
|
os.replace(tmp, path)
|
|
except BaseException:
|
|
tmp.unlink(missing_ok=True)
|
|
raise
|
|
PY
|
|
}
|
|
|
|
write_profile_marker() {
|
|
if $DRY_RUN; then
|
|
printf '+ atomically write dependency profile %q (%q; mode 0600)\n' "$PROFILE_MARKER" "$DEPENDENCY_PROFILE"
|
|
return
|
|
fi
|
|
PROFILE_MARKER_VALUE="$DEPENDENCY_PROFILE" PROFILE_MARKER_PATH="$PROFILE_MARKER" python3 - <<'PY'
|
|
import os
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
path = Path(os.environ["PROFILE_MARKER_PATH"])
|
|
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
fd, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.install.", dir=path.parent)
|
|
tmp = Path(temporary_name)
|
|
try:
|
|
with os.fdopen(fd, "w", encoding="ascii") as stream:
|
|
stream.write(os.environ["PROFILE_MARKER_VALUE"] + "\n")
|
|
stream.flush()
|
|
os.fsync(stream.fileno())
|
|
os.chmod(tmp, 0o600)
|
|
os.replace(tmp, path)
|
|
except BaseException:
|
|
tmp.unlink(missing_ok=True)
|
|
raise
|
|
PY
|
|
}
|
|
|
|
reject_symlink_ancestry() {
|
|
local path="$1" current="$1" info
|
|
while [[ "$current" != "/" ]]; do
|
|
if [[ -L "$current" ]]; then
|
|
fail "Refusing symlinked path ancestry: $path"
|
|
fi
|
|
current="$(dirname -- "$current")"
|
|
done
|
|
}
|
|
|
|
render_config_bind_paths() {
|
|
local backend="$1"
|
|
NETWORK_DROPIN_BACKEND="$backend" NETWORK_DROPIN_CONFIG_PATH="$CONFIG_PATH" python3 - <<'PY'
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
|
|
data = json.loads(Path(os.environ["NETWORK_DROPIN_CONFIG_PATH"]).read_text(encoding="utf-8"))
|
|
backend = os.environ["NETWORK_DROPIN_BACKEND"]
|
|
section = data[backend]
|
|
paths = []
|
|
if backend == "local":
|
|
paths.append(section["modelPath"])
|
|
else:
|
|
paths.append(section["apiKeyFile"])
|
|
paths.append(section["termsPath"])
|
|
for value in paths:
|
|
path = str(Path(value).expanduser()).replace("%", "%%")
|
|
print("BindReadOnlyPaths=" + json.dumps(path, ensure_ascii=False))
|
|
PY
|
|
}
|
|
|
|
write_network_dropin() {
|
|
local backend="$1"
|
|
local network_mode
|
|
if [[ "$backend" == local ]]; then
|
|
network_mode=offline
|
|
else
|
|
network_mode=cloud
|
|
fi
|
|
if $DRY_RUN; then
|
|
printf '+ atomically generate %q (%s network hardening)\n' "$DROPIN_PATH" "$network_mode"
|
|
return
|
|
fi
|
|
reject_symlink_ancestry "$DROPIN_DIR"
|
|
mkdir -p "$DROPIN_DIR"
|
|
chmod 0700 "$DROPIN_DIR"
|
|
if [[ -e "$DROPIN_PATH" || -L "$DROPIN_PATH" ]]; then
|
|
if [[ -L "$DROPIN_PATH" || ! -f "$DROPIN_PATH" ]] || ! grep -qxF '# Managed by pi-dictate install.sh' "$DROPIN_PATH"; then
|
|
fail "Refusing to replace unmanaged systemd drop-in: $DROPIN_PATH"
|
|
fi
|
|
fi
|
|
local temporary
|
|
temporary="$(mktemp "$DROPIN_DIR/.10-pi-dictate-network.conf.install.XXXXXX")"
|
|
{
|
|
printf '%s\n' '# Managed by pi-dictate install.sh'
|
|
printf '%s\n' '[Service]'
|
|
printf 'Environment=PI_DICTATE_NETWORK_MODE=%s\n' "$network_mode"
|
|
if [[ "$backend" == local ]]; then
|
|
cat <<'EOF'
|
|
Environment=HF_HUB_OFFLINE=1
|
|
Environment=TRANSFORMERS_OFFLINE=1
|
|
Environment=NEMO_HOME=/tmp/pi-dictate-nemo
|
|
Environment=HF_HOME=/tmp/pi-dictate-huggingface
|
|
Environment=TRITON_CACHE_DIR=/tmp/pi-dictate-triton
|
|
Environment=NUMBA_CACHE_DIR=/tmp/pi-dictate-numba
|
|
RestrictAddressFamilies=AF_UNIX
|
|
IPAddressDeny=any
|
|
EOF
|
|
else
|
|
cat <<'EOF'
|
|
# Cloud backends need outbound DNS and TLS, but never a listening IP socket.
|
|
Environment=HF_HUB_OFFLINE=
|
|
Environment=TRANSFORMERS_OFFLINE=
|
|
Environment=NEMO_HOME=
|
|
Environment=HF_HOME=
|
|
Environment=TRITON_CACHE_DIR=
|
|
Environment=NUMBA_CACHE_DIR=
|
|
Environment=PI_DICTATE_MODEL_PATH=
|
|
Environment=PI_DICTATE_BOOST_ALPHA=
|
|
RestrictAddressFamilies=
|
|
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
|
IPAddressDeny=
|
|
EOF
|
|
fi
|
|
render_config_bind_paths "$backend"
|
|
} > "$temporary"
|
|
chmod 0644 "$temporary"
|
|
mv -f "$temporary" "$DROPIN_PATH"
|
|
}
|
|
|
|
validate_network_dropin() {
|
|
local backend="$1"
|
|
reject_symlink_ancestry "$DROPIN_DIR"
|
|
[[ -f "$DROPIN_PATH" && ! -L "$DROPIN_PATH" ]] || fail "Systemd network drop-in is not a regular managed file"
|
|
NETWORK_DROPIN_BACKEND="$backend" NETWORK_DROPIN_PATH="$DROPIN_PATH" NETWORK_DROPIN_CONFIG_PATH="$CONFIG_PATH" python3 - <<'PY'
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
|
|
backend = os.environ["NETWORK_DROPIN_BACKEND"]
|
|
path = Path(os.environ["NETWORK_DROPIN_PATH"])
|
|
config = json.loads(Path(os.environ["NETWORK_DROPIN_CONFIG_PATH"]).read_text(encoding="utf-8"))
|
|
section = config[backend]
|
|
|
|
def bind_line(value: str) -> str:
|
|
expanded = str(Path(value).expanduser()).replace("%", "%%")
|
|
return "BindReadOnlyPaths=" + json.dumps(expanded, ensure_ascii=False)
|
|
expected = [
|
|
"# Managed by pi-dictate install.sh",
|
|
"[Service]",
|
|
f"Environment=PI_DICTATE_NETWORK_MODE={'offline' if backend == 'local' else 'cloud'}",
|
|
]
|
|
if backend == "local":
|
|
expected.extend([
|
|
"Environment=HF_HUB_OFFLINE=1",
|
|
"Environment=TRANSFORMERS_OFFLINE=1",
|
|
"Environment=NEMO_HOME=/tmp/pi-dictate-nemo",
|
|
"Environment=HF_HOME=/tmp/pi-dictate-huggingface",
|
|
"Environment=TRITON_CACHE_DIR=/tmp/pi-dictate-triton",
|
|
"Environment=NUMBA_CACHE_DIR=/tmp/pi-dictate-numba",
|
|
"RestrictAddressFamilies=AF_UNIX",
|
|
"IPAddressDeny=any",
|
|
bind_line(section["modelPath"]),
|
|
bind_line(section["termsPath"]),
|
|
])
|
|
else:
|
|
expected.extend([
|
|
"# Cloud backends need outbound DNS and TLS, but never a listening IP socket.",
|
|
"Environment=HF_HUB_OFFLINE=",
|
|
"Environment=TRANSFORMERS_OFFLINE=",
|
|
"Environment=NEMO_HOME=",
|
|
"Environment=HF_HOME=",
|
|
"Environment=TRITON_CACHE_DIR=",
|
|
"Environment=NUMBA_CACHE_DIR=",
|
|
"Environment=PI_DICTATE_MODEL_PATH=",
|
|
"Environment=PI_DICTATE_BOOST_ALPHA=",
|
|
"RestrictAddressFamilies=",
|
|
"RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6",
|
|
"IPAddressDeny=",
|
|
bind_line(section["apiKeyFile"]),
|
|
bind_line(section["termsPath"]),
|
|
])
|
|
try:
|
|
if path.is_symlink() or path.read_text(encoding="utf-8") != "\n".join(expected) + "\n":
|
|
raise ValueError
|
|
except (OSError, UnicodeError, ValueError):
|
|
raise SystemExit("Systemd network drop-in is not the expected managed policy")
|
|
PY
|
|
}
|
|
|
|
network_dropin_is_owned() {
|
|
# The marker is not ownership proof. Accept only one of the exact policies
|
|
# this installer writes, so a same-named tampered drop-in is never removed.
|
|
validate_network_dropin local >/dev/null 2>&1 ||
|
|
validate_network_dropin meta >/dev/null 2>&1 ||
|
|
validate_network_dropin openai >/dev/null 2>&1
|
|
}
|
|
|
|
remove_network_dropin() {
|
|
if [[ -f "$DROPIN_PATH" ]] && network_dropin_is_owned; then
|
|
run rm "$DROPIN_PATH"
|
|
elif [[ -e "$DROPIN_PATH" || -L "$DROPIN_PATH" ]]; then
|
|
fail "Refusing to remove unmanaged or modified systemd drop-in: $DROPIN_PATH"
|
|
fi
|
|
}
|
|
|
|
check_credential_destination() {
|
|
local backend="$1"
|
|
local path="$CREDENTIAL_DIR/${backend}-api-key"
|
|
[[ -e "$path" || -L "$path" ]] || fail "Missing credential for $backend: $path"
|
|
validate_credential "$path"
|
|
}
|
|
|
|
check_installation() {
|
|
reject_symlink_ancestry "$DROPIN_DIR"
|
|
for pair in "$socket_link:$socket_source" "$service_link:$service_source"; do
|
|
local destination="${pair%%:*}"
|
|
local source="${pair#*:}"
|
|
[[ -f "$destination" ]] || fail "Missing installed systemd unit: $destination"
|
|
is_owned_unit "$destination" "$source" || fail "Unmanaged installed systemd unit: $destination"
|
|
cmp -s "$destination" "$source" || fail "Modified installed systemd unit: $destination"
|
|
done
|
|
[[ -f "$CONFIG_PATH" && ! -L "$CONFIG_PATH" ]] || fail "Missing pi-dictate configuration: $CONFIG_PATH"
|
|
[[ ! -L "$CONFIG_ROOT" && ! -L "$CREDENTIAL_DIR" ]] || fail "Configuration path contains a symlinked directory"
|
|
CONFIG_TO_VALIDATE="$CONFIG_PATH" python3 - <<'PY'
|
|
import os
|
|
from pathlib import Path
|
|
|
|
path = Path(os.environ["CONFIG_TO_VALIDATE"])
|
|
info = path.lstat()
|
|
if info.st_uid != os.getuid() or info.st_mode & 0o777 != 0o600:
|
|
raise SystemExit("Invalid pi-dictate configuration: ownership or permissions are insecure")
|
|
PY
|
|
validate_config "$CONFIG_PATH"
|
|
local selected
|
|
selected="$(read_config_backend)"
|
|
if [[ "$selected" == local ]]; then
|
|
MODEL_PATH="$(read_config_model_path)"
|
|
fi
|
|
case "$selected" in
|
|
meta|openai) check_credential_destination "$selected" ;;
|
|
esac
|
|
|
|
[[ -f "$PROFILE_MARKER" && ! -L "$PROFILE_MARKER" ]] || fail "Missing dependency profile marker: $PROFILE_MARKER"
|
|
PROFILE_MARKER_TO_VALIDATE="$PROFILE_MARKER" PROFILE_MARKER_BACKEND="$selected" python3 - <<'PY'
|
|
import os
|
|
from pathlib import Path
|
|
|
|
path = Path(os.environ["PROFILE_MARKER_TO_VALIDATE"])
|
|
info = path.lstat()
|
|
if info.st_uid != os.getuid() or info.st_mode & 0o777 != 0o600:
|
|
raise SystemExit("Invalid dependency profile marker: ownership or permissions are insecure")
|
|
expected = b"local\n" if os.environ["PROFILE_MARKER_BACKEND"] == "local" else b"cloud\n"
|
|
if path.read_bytes() != expected:
|
|
raise SystemExit("Installed dependency profile does not match configured backend")
|
|
PY
|
|
[[ -f "$DROPIN_PATH" && ! -L "$DROPIN_PATH" ]] || fail "Missing generated systemd network hardening: $DROPIN_PATH"
|
|
local expected_mode
|
|
expected_mode=cloud
|
|
[[ "$selected" == local ]] && expected_mode=offline
|
|
grep -q "^Environment=PI_DICTATE_NETWORK_MODE=$expected_mode$" "$DROPIN_PATH" ||
|
|
fail "Systemd network mode does not match configured backend"
|
|
validate_network_dropin "$selected"
|
|
[[ -f "$DICTIONARY_PATH" && ! -L "$DICTIONARY_PATH" ]] || fail "Missing coding dictionary: $DICTIONARY_PATH"
|
|
if [[ "$selected" == local ]]; then
|
|
local model_path model_sha actual_sha
|
|
model_path="$MODEL_PATH"
|
|
[[ -f "$model_path" && ! -L "$model_path" ]] || fail "Missing local model archive: $model_path"
|
|
model_sha="$(read_config_model_sha)"
|
|
model_sha="${model_sha,,}"
|
|
actual_sha="$(sha256sum "$model_path" | cut -d' ' -f1)"
|
|
[[ "$actual_sha" == "$model_sha" ]] || fail "Local model checksum mismatch"
|
|
fi
|
|
printf 'pi-dictate check: OK (%s backend, %s network)\n' "$selected" "$expected_mode"
|
|
}
|
|
|
|
socket_source="$REPOSITORY_ROOT/systemd/user/pi-dictate.socket"
|
|
service_source="$REPOSITORY_ROOT/systemd/user/pi-dictate.service"
|
|
socket_link="$UNIT_DIR/pi-dictate.socket"
|
|
service_link="$UNIT_DIR/pi-dictate.service"
|
|
|
|
# Recursive writes and removals are safe only when every existing ancestor is
|
|
# a real directory. Checking the final path alone does not prevent redirection
|
|
# through ~/.local, ~/.config, or a custom XDG config root.
|
|
reject_symlink_ancestry "$INSTALL_ROOT"
|
|
reject_symlink_ancestry "$CONFIG_ROOT"
|
|
reject_symlink_ancestry "$UNIT_DIR"
|
|
|
|
if [[ "$ACTION" == uninstall ]]; then
|
|
for pair in "$socket_link:$socket_source" "$service_link:$service_source"; do
|
|
destination="${pair%%:*}"
|
|
source="${pair#*:}"
|
|
if [[ ( -e "$destination" || -L "$destination" ) ]] && ! is_owned_unit "$destination" "$source"; then
|
|
printf 'Refusing to disable or remove unmanaged systemd unit: %s\n' "$destination" >&2
|
|
exit 1
|
|
fi
|
|
done
|
|
if [[ -e "$DROPIN_PATH" || -L "$DROPIN_PATH" ]]; then
|
|
[[ -f "$DROPIN_PATH" && ! -L "$DROPIN_PATH" ]] && network_dropin_is_owned ||
|
|
fail "Refusing to remove unmanaged or modified systemd drop-in: $DROPIN_PATH"
|
|
fi
|
|
command -v systemctl >/dev/null || fail 'systemctl is required'
|
|
run systemctl --user disable --now pi-dictate.socket
|
|
remove_owned_unit "$socket_link" "$socket_source"
|
|
remove_owned_unit "$service_link" "$service_source"
|
|
remove_network_dropin
|
|
run systemctl --user daemon-reload
|
|
run rm -rf "$INSTALL_ROOT"
|
|
if $PURGE_CONFIG; then
|
|
[[ ! -L "$CONFIG_ROOT" ]] || fail "Refusing to purge symlinked configuration directory: $CONFIG_ROOT"
|
|
run rm -rf "$CONFIG_ROOT"
|
|
printf 'Purging host configuration and credentials: %s\n' "$CONFIG_ROOT"
|
|
else
|
|
printf 'Preserving host configuration and credentials: %s\n' "$CONFIG_ROOT"
|
|
fi
|
|
exit 0
|
|
fi
|
|
|
|
if [[ "$ACTION" == check ]]; then
|
|
check_installation
|
|
exit 0
|
|
fi
|
|
|
|
[[ -n "$BACKEND" ]] || {
|
|
if [[ -f "$CONFIG_PATH" ]]; then
|
|
BACKEND="$(read_config_backend)"
|
|
else
|
|
BACKEND=local
|
|
fi
|
|
}
|
|
valid_backend "$BACKEND" || fail "Unsupported backend: $BACKEND"
|
|
if [[ "$BACKEND" == local && -f "$CONFIG_PATH" ]]; then
|
|
configured_model_path="$(read_config_model_path)"
|
|
if [[ -n "$configured_model_path" ]]; then
|
|
MODEL_PATH="$configured_model_path"
|
|
MODEL_DIR="$(dirname -- "$MODEL_PATH")"
|
|
fi
|
|
fi
|
|
if [[ "$BACKEND" == local ]]; then
|
|
reject_symlink_ancestry "$MODEL_DIR"
|
|
fi
|
|
if [[ "$BACKEND" == local ]]; then
|
|
[[ -z "$API_KEY_FILE" ]] || fail '--api-key-file is only valid for meta or openai'
|
|
[[ "$ACTION" == configure || "$ACTION" == install ]] || fail "Unsupported operation: $ACTION"
|
|
else
|
|
[[ -z "$MODEL_SOURCE" ]] || fail '--model-source is only valid for local installs'
|
|
[[ -z "$MODEL_SHA256_OVERRIDE" ]] || fail '--model-sha256 is only valid for local installs'
|
|
[[ "$ACTION" == configure || "$ACTION" == install ]] || fail "Unsupported operation: $ACTION"
|
|
[[ -n "$API_KEY_FILE" || -f "$CREDENTIAL_DIR/$BACKEND-api-key" ]] ||
|
|
fail "Cloud backend requires --api-key-file or an existing managed credential"
|
|
fi
|
|
|
|
command -v python3 >/dev/null || fail 'python3 is required'
|
|
command -v uv >/dev/null || fail 'uv is required'
|
|
command -v systemctl >/dev/null || fail 'systemctl is required'
|
|
[[ ! -L "$INSTALL_ROOT" ]] || fail "Refusing to use symlinked install directory: $INSTALL_ROOT"
|
|
[[ ! -L "$CONFIG_ROOT" ]] || fail "Refusing to use symlinked configuration directory: $CONFIG_ROOT"
|
|
[[ ! -L "$CREDENTIAL_DIR" ]] || fail "Refusing to use symlinked credential directory: $CREDENTIAL_DIR"
|
|
if [[ -f "$CONFIG_PATH" || -L "$CONFIG_PATH" ]]; then
|
|
[[ ! -L "$CONFIG_PATH" ]] || fail "Refusing to use symlinked configuration file: $CONFIG_PATH"
|
|
validate_config "$CONFIG_PATH"
|
|
fi
|
|
CONFIG_LEGACY_IDLE_SECONDS=""
|
|
if [[ ! -f "$CONFIG_PATH" && -f "$service_link" && ! -L "$service_link" ]]; then
|
|
CONFIG_LEGACY_IDLE_SECONDS="$(read_legacy_idle_seconds "$service_link" || true)"
|
|
fi
|
|
export CONFIG_LEGACY_IDLE_SECONDS
|
|
|
|
# Keep all installer-owned files restorable until systemd has accepted the new
|
|
# configuration. This makes a failed backend switch leave the previous
|
|
# configuration, credentials, hardening, and units usable.
|
|
TRANSACTION_DIR=""
|
|
MODEL_TEMP=""
|
|
STAGED_VENV=""
|
|
OLD_VENV_BACKUP=""
|
|
VENV_SWAPPED=false
|
|
SOCKET_WAS_ACTIVE=false
|
|
SOCKET_WAS_ENABLED=false
|
|
SERVICE_WAS_ACTIVE=false
|
|
declare -a SNAPSHOT_PATHS=()
|
|
declare -a SNAPSHOT_BACKUPS=()
|
|
declare -a SNAPSHOT_EXISTS=()
|
|
declare -a SNAPSHOT_HARDLINKS=()
|
|
snapshot_file() {
|
|
local path="$1" name
|
|
name="$(printf '%s' "$path" | sha256sum | cut -c1-16)"
|
|
SNAPSHOT_PATHS+=("$path")
|
|
SNAPSHOT_BACKUPS+=("$TRANSACTION_DIR/$name")
|
|
SNAPSHOT_HARDLINKS+=(0)
|
|
if [[ -e "$path" || -L "$path" ]]; then
|
|
SNAPSHOT_EXISTS+=(1)
|
|
cp -a -- "$path" "$TRANSACTION_DIR/$name"
|
|
else
|
|
SNAPSHOT_EXISTS+=(0)
|
|
fi
|
|
}
|
|
snapshot_replace_only_file() {
|
|
local path="$1" backup
|
|
backup="${path}.pi-dictate-rollback.$$"
|
|
[[ ! -e "$backup" && ! -L "$backup" ]] || fail "Refusing existing replace-only snapshot: $backup"
|
|
SNAPSHOT_PATHS+=("$path")
|
|
SNAPSHOT_BACKUPS+=("$backup")
|
|
SNAPSHOT_HARDLINKS+=(1)
|
|
if [[ -e "$path" || -L "$path" ]]; then
|
|
[[ -f "$path" && ! -L "$path" ]] || fail "Refusing non-regular replace-only snapshot: $path"
|
|
SNAPSHOT_EXISTS+=(1)
|
|
# Keep the backup beside the archive so hard-link snapshots also work
|
|
# when a custom model lives on a filesystem other than $HOME.
|
|
ln -- "$path" "$backup"
|
|
else
|
|
SNAPSHOT_EXISTS+=(0)
|
|
fi
|
|
}
|
|
rollback_transaction() {
|
|
set +e
|
|
local index path backup
|
|
for index in "${!SNAPSHOT_PATHS[@]}"; do
|
|
path="${SNAPSHOT_PATHS[$index]}"
|
|
backup="${SNAPSHOT_BACKUPS[$index]}"
|
|
rm -f -- "$path"
|
|
if [[ "${SNAPSHOT_EXISTS[$index]}" == 1 ]]; then
|
|
mkdir -p -- "$(dirname -- "$path")"
|
|
if [[ "${SNAPSHOT_HARDLINKS[$index]}" == 1 ]]; then
|
|
mv -- "$backup" "$path"
|
|
else
|
|
cp -a -- "$backup" "$path"
|
|
fi
|
|
fi
|
|
done
|
|
[[ -z "$MODEL_TEMP" ]] || rm -f -- "$MODEL_TEMP"
|
|
if [[ -n "$OLD_VENV_BACKUP" && -e "$OLD_VENV_BACKUP" ]]; then
|
|
rm -rf -- "$INSTALL_ROOT/venv"
|
|
mv -- "$OLD_VENV_BACKUP" "$INSTALL_ROOT/venv"
|
|
elif $VENV_SWAPPED; then
|
|
rm -rf -- "$INSTALL_ROOT/venv"
|
|
else
|
|
[[ -z "$STAGED_VENV" ]] || rm -rf -- "$STAGED_VENV"
|
|
fi
|
|
systemctl --user daemon-reload >/dev/null 2>&1
|
|
if $SOCKET_WAS_ENABLED; then
|
|
systemctl --user enable pi-dictate.socket >/dev/null 2>&1
|
|
else
|
|
systemctl --user disable pi-dictate.socket >/dev/null 2>&1
|
|
fi
|
|
if $SOCKET_WAS_ACTIVE; then
|
|
systemctl --user start pi-dictate.socket >/dev/null 2>&1
|
|
else
|
|
systemctl --user stop pi-dictate.socket >/dev/null 2>&1
|
|
fi
|
|
if $SERVICE_WAS_ACTIVE; then
|
|
systemctl --user start pi-dictate.service >/dev/null 2>&1
|
|
else
|
|
systemctl --user stop pi-dictate.service >/dev/null 2>&1
|
|
fi
|
|
[[ -z "$TRANSACTION_DIR" ]] || rm -rf -- "$TRANSACTION_DIR"
|
|
}
|
|
on_exit() {
|
|
local status=$? index
|
|
trap - EXIT
|
|
if (( status != 0 )) && [[ -n "$TRANSACTION_DIR" ]]; then
|
|
rollback_transaction
|
|
elif [[ -n "$TRANSACTION_DIR" ]]; then
|
|
[[ -z "$MODEL_TEMP" ]] || rm -f -- "$MODEL_TEMP"
|
|
for index in "${!SNAPSHOT_PATHS[@]}"; do
|
|
if [[ "${SNAPSHOT_HARDLINKS[$index]}" == 1 ]]; then
|
|
rm -f -- "${SNAPSHOT_BACKUPS[$index]}"
|
|
fi
|
|
done
|
|
rm -rf -- "$TRANSACTION_DIR"
|
|
fi
|
|
exit "$status"
|
|
}
|
|
if ! $DRY_RUN; then
|
|
systemctl --user is-active --quiet pi-dictate.socket && SOCKET_WAS_ACTIVE=true
|
|
systemctl --user is-enabled --quiet pi-dictate.socket && SOCKET_WAS_ENABLED=true
|
|
systemctl --user is-active --quiet pi-dictate.service && SERVICE_WAS_ACTIVE=true
|
|
TRANSACTION_DIR="$(mktemp -d "$HOME/.pi-dictate-install.XXXXXX")"
|
|
chmod 0700 "$TRANSACTION_DIR"
|
|
snapshot_file "$CONFIG_PATH"
|
|
snapshot_file "$PROFILE_MARKER"
|
|
snapshot_file "$DROPIN_PATH"
|
|
snapshot_file "$socket_link"
|
|
snapshot_file "$service_link"
|
|
snapshot_file "$DICTIONARY_PATH"
|
|
if [[ "$BACKEND" == meta || "$BACKEND" == openai ]]; then
|
|
snapshot_file "$CREDENTIAL_DIR/$BACKEND-api-key"
|
|
fi
|
|
if [[ "$BACKEND" == local ]]; then
|
|
snapshot_replace_only_file "$MODEL_PATH"
|
|
fi
|
|
trap on_exit EXIT
|
|
fi
|
|
run mkdir -p "$INSTALL_ROOT" "$CONFIG_ROOT" "$CREDENTIAL_DIR" "$UNIT_DIR"
|
|
run chmod 0700 "$INSTALL_ROOT" "$CONFIG_ROOT" "$CREDENTIAL_DIR"
|
|
|
|
DEPENDENCY_PROFILE=local
|
|
[[ "$BACKEND" == local ]] || DEPENDENCY_PROFILE=cloud
|
|
if $DRY_RUN; then
|
|
STAGED_VENV="$INSTALL_ROOT/venv"
|
|
else
|
|
STAGED_VENV="$INSTALL_ROOT/.venv.install.$$"
|
|
OLD_VENV_BACKUP="$TRANSACTION_DIR/venv.previous"
|
|
fi
|
|
run_uv_sync() {
|
|
if $DRY_RUN; then
|
|
printf '+ uv venv --relocatable %q\n' "$STAGED_VENV"
|
|
printf '+ UV_PROJECT_ENVIRONMENT=%q uv sync --frozen --no-dev --no-editable --reinstall-package pi-dictate-service --extra %q --project %q\n' \
|
|
"$STAGED_VENV" "$DEPENDENCY_PROFILE" "$SCRIPT_DIR"
|
|
else
|
|
uv venv --relocatable "$STAGED_VENV"
|
|
UV_PROJECT_ENVIRONMENT="$STAGED_VENV" uv sync --frozen --no-dev --no-editable \
|
|
--reinstall-package pi-dictate-service --extra "$DEPENDENCY_PROFILE" --project "$SCRIPT_DIR"
|
|
fi
|
|
}
|
|
run_uv_sync
|
|
|
|
EFFECTIVE_MODEL_SHA256="$MODEL_SHA256"
|
|
MODEL_REPLACED=false
|
|
if [[ -n "$MODEL_SHA256_OVERRIDE" ]]; then
|
|
EFFECTIVE_MODEL_SHA256="${MODEL_SHA256_OVERRIDE,,}"
|
|
fi
|
|
if [[ "$BACKEND" == local && -z "$MODEL_SOURCE" && -f "$CONFIG_PATH" ]]; then
|
|
existing_model_sha="$(read_config_model_sha)"
|
|
[[ "$existing_model_sha" =~ ^[[:xdigit:]]{64}$ ]] && EFFECTIVE_MODEL_SHA256="${existing_model_sha,,}"
|
|
fi
|
|
|
|
if [[ "$BACKEND" == local ]]; then
|
|
run mkdir -p "$MODEL_DIR" "$INSTALL_ROOT/config"
|
|
run chmod 0700 "$MODEL_DIR" "$INSTALL_ROOT/config"
|
|
model_is_reusable=false
|
|
if [[ -z "$MODEL_SOURCE" && -f "$MODEL_PATH" && ! -L "$MODEL_PATH" && "$DRY_RUN" == false ]]; then
|
|
[[ "$(sha256sum "$MODEL_PATH" | cut -d' ' -f1)" == "$EFFECTIVE_MODEL_SHA256" ]] && model_is_reusable=true
|
|
fi
|
|
if $model_is_reusable; then
|
|
printf 'Keeping existing verified model archive: %s\n' "$MODEL_PATH"
|
|
else
|
|
if $DRY_RUN; then
|
|
MODEL_TEMP="$MODEL_DIR/.${MODEL_FILENAME}.install.$$"
|
|
else
|
|
MODEL_TEMP="$(mktemp "$MODEL_DIR/.${MODEL_FILENAME}.install.XXXXXX")"
|
|
fi
|
|
if [[ -n "$MODEL_SOURCE" ]]; then
|
|
[[ -f "$MODEL_SOURCE" && ! -L "$MODEL_SOURCE" ]] || fail "Model source not found or is a symlink: $MODEL_SOURCE"
|
|
run cp --reflink=auto "$MODEL_SOURCE" "$MODEL_TEMP"
|
|
elif $DRY_RUN; then
|
|
printf '+ download nvidia/parakeet-tdt-0.6b-v3@%s to %q\n' "$MODEL_REVISION" "$MODEL_TEMP"
|
|
else
|
|
MODEL_REVISION="$MODEL_REVISION" MODEL_PATH="$MODEL_TEMP" \
|
|
"$STAGED_VENV/bin/python" - <<'PY'
|
|
import os
|
|
import shutil
|
|
from pathlib import Path
|
|
from huggingface_hub import hf_hub_download
|
|
|
|
source = hf_hub_download(
|
|
repo_id="nvidia/parakeet-tdt-0.6b-v3",
|
|
filename="parakeet-tdt-0.6b-v3.nemo",
|
|
revision=os.environ["MODEL_REVISION"],
|
|
)
|
|
destination = Path(os.environ["MODEL_PATH"])
|
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copyfile(source, destination)
|
|
PY
|
|
fi
|
|
if ! $DRY_RUN; then
|
|
actual_sha256="$(sha256sum "$MODEL_TEMP" | cut -d' ' -f1)"
|
|
if [[ "$actual_sha256" != "$EFFECTIVE_MODEL_SHA256" ]]; then
|
|
rm -f "$MODEL_TEMP"
|
|
printf 'Model checksum mismatch: expected %s, got %s\n' "$EFFECTIVE_MODEL_SHA256" "$actual_sha256" >&2
|
|
exit 1
|
|
fi
|
|
fi
|
|
run chmod 0400 "$MODEL_TEMP"
|
|
run mv -f "$MODEL_TEMP" "$MODEL_PATH"
|
|
MODEL_REPLACED=true
|
|
fi
|
|
|
|
fi
|
|
|
|
# The coding dictionary is small and is useful to every backend. It is never
|
|
# copied into Pi's sandbox; only the host service reads it.
|
|
run mkdir -p "$INSTALL_ROOT/config"
|
|
run chmod 0700 "$INSTALL_ROOT/config"
|
|
if [[ -L "$DICTIONARY_PATH" || ( -e "$DICTIONARY_PATH" && ! -f "$DICTIONARY_PATH" ) ]]; then
|
|
fail "Refusing to use non-regular coding dictionary: $DICTIONARY_PATH"
|
|
elif [[ ! -e "$DICTIONARY_PATH" ]]; then
|
|
run cp "$DICTIONARY_SOURCE" "$DICTIONARY_PATH"
|
|
run chmod 0600 "$DICTIONARY_PATH"
|
|
else
|
|
printf 'Keeping existing coding dictionary: %s\n' "$DICTIONARY_PATH"
|
|
fi
|
|
|
|
if [[ "$BACKEND" == meta || "$BACKEND" == openai ]]; then
|
|
key_destination="$CREDENTIAL_DIR/$BACKEND-api-key"
|
|
if [[ -n "$API_KEY_FILE" ]]; then
|
|
copy_credential "$API_KEY_FILE" "$key_destination"
|
|
elif ! $DRY_RUN; then
|
|
validate_credential "$key_destination"
|
|
fi
|
|
fi
|
|
|
|
if [[ "$BACKEND" == local ]]; then
|
|
export CONFIG_MODEL_REPLACED=0
|
|
if $MODEL_REPLACED; then
|
|
export CONFIG_MODEL_REPLACED=1
|
|
fi
|
|
fi
|
|
write_config
|
|
if ! $DRY_RUN; then
|
|
validate_config "$CONFIG_PATH"
|
|
fi
|
|
|
|
install_owned_unit "$socket_source" "$socket_link"
|
|
install_owned_unit "$service_source" "$service_link"
|
|
write_network_dropin "$BACKEND"
|
|
if ! $DRY_RUN; then
|
|
validate_network_dropin "$BACKEND"
|
|
fi
|
|
# Commit the profile marker only after dependencies, credentials, config, and
|
|
# hardening have all been staged and validated. A failed switch stays fail-closed
|
|
# against the previous marker instead of poisoning an otherwise working install.
|
|
# Make the newly resolved dependency environment visible only after every
|
|
# generated file has been validated. Stop activation during the swap; rollback
|
|
# restores both the files and the previous systemd activation state.
|
|
run systemctl --user stop pi-dictate.socket
|
|
run systemctl --user stop pi-dictate.service
|
|
if ! $DRY_RUN; then
|
|
if [[ -e "$INSTALL_ROOT/venv" || -L "$INSTALL_ROOT/venv" ]]; then
|
|
[[ ! -L "$INSTALL_ROOT/venv" ]] || fail "Refusing to replace symlinked dependency environment"
|
|
mv -- "$INSTALL_ROOT/venv" "$OLD_VENV_BACKUP"
|
|
fi
|
|
mv -- "$STAGED_VENV" "$INSTALL_ROOT/venv"
|
|
VENV_SWAPPED=true
|
|
STAGED_VENV=""
|
|
fi
|
|
write_profile_marker
|
|
run systemctl --user daemon-reload
|
|
run systemctl --user enable --now pi-dictate.socket
|
|
# Starting the socket alone defers ExecStartPre until first use. Start the
|
|
# service once so this transaction commits only after startup validation passes.
|
|
run systemctl --user start pi-dictate.service
|
|
|
|
if $DRY_RUN; then
|
|
printf 'Would install pi-dictate with %s backend (%s dependency profile, %s network).\n' \
|
|
"$BACKEND" "$DEPENDENCY_PROFILE" "$([[ "$BACKEND" == local ]] && printf offline || printf cloud)"
|
|
else
|
|
printf 'Installed pi-dictate with %s backend (%s dependency profile).\n' "$BACKEND" "$DEPENDENCY_PROFILE"
|
|
printf 'Check it with: %s check\n' "$0"
|
|
fi
|