diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md index bfb5a1af2..209e50584 100644 --- a/docs/LOGBOOK.md +++ b/docs/LOGBOOK.md @@ -48913,3 +48913,20 @@ production browser smoke: **下一個 P0**: - 使用既有 Gitea `workflow_dispatch` / controlled CD lane 對含 `6a933e3f0` source 變更的最新 `main` 觸發 CD 與 code-review;CD 成功後再做 production GET/POST readback 與 `/zh-TW/iwooos` desktop / mobile smoke。 + +## 2026-06-28 — 20:43 non-110 user runner 註冊前置實作與 188 prepare-only readback + +**完成內容**: +- 接續 `5f20d654d` non-110 user runner installer,補上安全 registration helper;installer 的 `REGISTER_NOW=1` 也改成只從互動 TTY 隱藏讀 token,透過 stdin 餵給 `act_runner register`,不使用 `--token` argv、不列印 token、不讀 `.runner` 內容。 +- 188 已套用 user-level Docker runner prepare-only:`/home/ollama/awoooi-non110-runner/data/config.yaml`、user systemd service、rollback service 與 `gitea/act_runner:latest` image 都可讀回;service 維持 `loaded/inactive/disabled`。 +- 舊 `/home/ollama/act-runner-awoooi` 裸 binary scaffold 確認沒有 `.runner` 後移到 disabled quarantine,避免 verifier 同時看見兩套 runner source。 + +**驗證結果**: +- 本地:`bash -n`、runner focused pytest `9 passed`、`guard-gitea-runner-pressure.py`、`git diff --check`。 +- 188 verifier:`READY_CONFIG_COUNT=1`、`READY_BINARY_COUNT=1`、`READY_SERVICE_COUNT=1`、`READY_REGISTRATION_COUNT=0`、`READY_ACTIVE_SERVICE_COUNT=0`、`BLOCKER_COUNT=3`,safe next step 指向 safe registration helper。 +- 110 readback:runner units 維持 masked/inactive,`ACTIVE_JOB_CONTAINERS=0`,failclosed enforcer timer active/enabled。 + +**仍維持**: +- 沒有讀、複製、貼上、外送 runner token / secret / `.env` / raw sessions / SQLite / auth。 +- 沒有啟動 188 runner service、沒有重開 110 runner、沒有 GitHub API / gh / GitHub Actions、沒有 force push。 +- Gitea CD 仍需等 non-110 runner 完成外部安全註冊並讀回 `AWOOOI_NON110_RUNNER_READY=1` 後才可承接。 diff --git a/ops/runner/check-awoooi-non110-runner-readiness.sh b/ops/runner/check-awoooi-non110-runner-readiness.sh index 7dcd89b04..d8743b142 100755 --- a/ops/runner/check-awoooi-non110-runner-readiness.sh +++ b/ops/runner/check-awoooi-non110-runner-readiness.sh @@ -412,7 +412,7 @@ main() { && [ "$READY_BINARY_COUNT" -gt 0 ] \ && [ "$READY_SERVICE_COUNT" -gt 0 ] \ && [ "$READY_REGISTRATION_COUNT" -eq 0 ]; then - printf 'safe_next_step=complete_runner_registration_without_printing_token_then_enable_service_and_rerun_this_verifier\n' + printf 'safe_next_step=run_register_awoooi_non110_runner_script_without_printing_token_then_enable_service_and_rerun_this_verifier\n' return 1 fi printf 'safe_next_step=install_or_fix_non110_runner_config_service_rollback_then_rerun_this_verifier\n' diff --git a/ops/runner/install-awoooi-non110-user-runner.sh b/ops/runner/install-awoooi-non110-user-runner.sh index fa8c4a236..241eb4717 100755 --- a/ops/runner/install-awoooi-non110-user-runner.sh +++ b/ops/runner/install-awoooi-non110-user-runner.sh @@ -2,8 +2,9 @@ set -euo pipefail # Controlled prepare-only installer for the 192.168.0.188 user-level AWOOOI -# Gitea runner. It never reads, prints, stores, or passes runner registration -# tokens. Registration must happen through an external credentialed channel. +# Gitea runner. It never prints, stores, or passes runner registration tokens +# through argv. Registration requires an interactive TTY and feeds the token to +# act_runner over stdin with terminal echo disabled. TARGET_HOST_IP="${TARGET_HOST_IP:-192.168.0.188}" FORBIDDEN_HOST_IPS="${FORBIDDEN_HOST_IPS:-192.168.0.110}" @@ -13,6 +14,8 @@ SERVICE_NAME="${SERVICE_NAME:-awoooi-non110-runner.service}" ROLLBACK_SERVICE_NAME="${ROLLBACK_SERVICE_NAME:-awoooi-non110-runner-rollback.service}" RUNNER_CONTAINER_NAME="${RUNNER_CONTAINER_NAME:-awoooi-non110-runner}" ACT_RUNNER_IMAGE="${ACT_RUNNER_IMAGE:-gitea/act_runner:latest}" +GITEA_INSTANCE="${GITEA_INSTANCE:-http://192.168.0.110:3001}" +RUNNER_NAME="${RUNNER_NAME:-awoooi-non110-188}" RUNNER_LABELS="${RUNNER_LABELS:-awoooi-non110-host:host,awoooi-non110-ubuntu:docker://192.168.0.110:5000/awoooi/ci-runner:act-22.04}" REGISTER_NOW="${REGISTER_NOW:-0}" START_NOW="${START_NOW:-0}" @@ -156,11 +159,56 @@ EOF_ROLLBACK systemctl --user daemon-reload } +require_tty() { + if [ -t 0 ]; then + return 0 + fi + { : /dev/null || { echo "BLOCKER interactive_tty_required"; return 1; } +} + +read_token_from_tty() { + local prompt="Gitea runner token: " + set +x + HISTFILE=/dev/null + export HISTFILE + if { : /dev/null; then + IFS= read -r -s -p "$prompt" RUNNER_TOKEN /dev/tty + else + IFS= read -r -s -p "$prompt" RUNNER_TOKEN + printf '\n' + fi + if [ -z "${RUNNER_TOKEN:-}" ]; then + echo "BLOCKER runner_token_empty" + return 1 + fi +} + register_runner() { - echo "BLOCKER runner_registration_requires_external_credentialed_channel" - echo "reason=act_runner_register_only_accepts_token_argument_no_stdin_or_token_file" - echo "content_read=false" - return 2 + if [ -s "${DATA_DIR}/.runner" ] && [ "${ALLOW_REREGISTER:-0}" != "1" ]; then + echo "RUNNER_REGISTRATION present=1 content_read=false" + return 0 + fi + + require_tty + read_token_from_tty + set +x + printf '%s\n' "$RUNNER_TOKEN" | docker run --rm -i \ + -v "${DATA_DIR}:/data" \ + -e CONFIG_FILE=/data/config.yaml \ + "$ACT_RUNNER_IMAGE" register \ + --instance "$GITEA_INSTANCE" \ + --name "$RUNNER_NAME" \ + --labels "$RUNNER_LABELS" \ + --config /data/config.yaml + unset RUNNER_TOKEN + + if [ ! -s "${DATA_DIR}/.runner" ]; then + echo "BLOCKER runner_registration_not_created" + return 1 + fi + chmod go-rwx "${DATA_DIR}/.runner" 2>/dev/null || true + echo "RUNNER_REGISTRATION present=1 content_read=false" } for forbidden in $FORBIDDEN_HOST_IPS; do diff --git a/ops/runner/register-awoooi-non110-runner.sh b/ops/runner/register-awoooi-non110-runner.sh new file mode 100755 index 000000000..8724bc8a7 --- /dev/null +++ b/ops/runner/register-awoooi-non110-runner.sh @@ -0,0 +1,143 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Register an AWOOOI non-110 Gitea act_runner without putting the runner token +# in shell history, argv, logs, or command output. Run this from an interactive +# SSH TTY on the target host after the user-level runner installer has prepared +# config and systemd units. Codex must not run the token entry path. + +GITEA_INSTANCE="${GITEA_INSTANCE:-http://192.168.0.110:3001}" +RUNNER_NAME="${RUNNER_NAME:-awoooi-non110-188}" +RUNNER_LABELS="${RUNNER_LABELS:-awoooi-non110-host:host,awoooi-non110-ubuntu:docker://192.168.0.110:5000/awoooi/ci-runner:act-22.04}" +RUNNER_DIR="${RUNNER_DIR:-${HOME}/awoooi-non110-runner}" +DATA_DIR="${DATA_DIR:-${RUNNER_DIR}/data}" +ACT_RUNNER_IMAGE="${ACT_RUNNER_IMAGE:-gitea/act_runner:latest}" +RUNNER_CONFIG="${RUNNER_CONFIG:-${DATA_DIR}/config.yaml}" +RUNNER_REGISTRATION="${RUNNER_REGISTRATION:-${DATA_DIR}/.runner}" + +usage() { + cat <<'EOF' +Usage: + DRY_RUN=1 ops/runner/register-awoooi-non110-runner.sh + ops/runner/register-awoooi-non110-runner.sh + +Environment overrides: + GITEA_INSTANCE, RUNNER_NAME, RUNNER_LABELS, RUNNER_DIR, DATA_DIR, + ACT_RUNNER_IMAGE, RUNNER_CONFIG, RUNNER_REGISTRATION + +Safety contract: + - requires an interactive TTY for token entry + - reads the runner token with terminal echo disabled + - feeds the token to act_runner over stdin, not argv + - never prints the token or .runner content + - refuses to overwrite an existing non-empty registration unless + ALLOW_REREGISTER=1 is set +EOF +} + +log() { + printf '%s\n' "$*" +} + +die() { + printf 'ERROR %s\n' "$*" >&2 + exit 1 +} + +metadata() { + log "secret_values_collected_by_codex=false" + log "runner_token_echoed=false" + log "runner_token_in_argv=false" + log "raw_runner_registration_read=false" + log "gitea_instance=${GITEA_INSTANCE}" + log "runner_name=${RUNNER_NAME}" + log "runner_dir=${RUNNER_DIR}" + log "data_dir=${DATA_DIR}" + log "act_runner_image=${ACT_RUNNER_IMAGE}" + log "runner_config=${RUNNER_CONFIG}" + log "runner_registration=${RUNNER_REGISTRATION}" +} + +preflight() { + command -v docker >/dev/null 2>&1 || die "docker_missing" + [ -d "$RUNNER_DIR" ] || die "runner_dir_missing:${RUNNER_DIR}" + [ -d "$DATA_DIR" ] || die "data_dir_missing:${DATA_DIR}" + [ -r "$RUNNER_CONFIG" ] || die "runner_config_missing_or_not_readable:${RUNNER_CONFIG}" + docker image inspect "$ACT_RUNNER_IMAGE" >/dev/null 2>&1 || die "act_runner_image_missing:${ACT_RUNNER_IMAGE}" + + if [ -s "$RUNNER_REGISTRATION" ] && [ "${ALLOW_REREGISTER:-0}" != "1" ]; then + log "runner_registration_present=1" + log "safe_next_step=enable_service_and_rerun_non110_readiness_verifier" + exit 0 + fi +} + +require_tty() { + if [ -t 0 ]; then + return 0 + fi + { : /dev/null || die "interactive_tty_required" +} + +read_token_from_tty() { + local prompt="Gitea runner token: " + set +x + HISTFILE=/dev/null + export HISTFILE + if { : /dev/null; then + IFS= read -r -s -p "$prompt" RUNNER_TOKEN /dev/tty + else + IFS= read -r -s -p "$prompt" RUNNER_TOKEN + printf '\n' + fi + [ -n "${RUNNER_TOKEN:-}" ] || die "runner_token_empty" +} + +register_runner() { + cd "$RUNNER_DIR" + set +x + printf '%s\n' "$RUNNER_TOKEN" | docker run --rm -i \ + -v "${DATA_DIR}:/data" \ + -e CONFIG_FILE=/data/config.yaml \ + "$ACT_RUNNER_IMAGE" register \ + --instance "$GITEA_INSTANCE" \ + --name "$RUNNER_NAME" \ + --labels "$RUNNER_LABELS" \ + --config /data/config.yaml + unset RUNNER_TOKEN +} + +postcheck() { + [ -s "$RUNNER_REGISTRATION" ] || die "runner_registration_not_created:${RUNNER_REGISTRATION}" + chmod go-rwx "$RUNNER_REGISTRATION" 2>/dev/null || true + log "runner_registration_present=1" + log "runner_registration_content_read=false" + log "safe_next_step=enable_service_and_rerun_non110_readiness_verifier" +} + +main() { + if [ "${1:-}" = "--help" ] || [ "${1:-}" = "-h" ]; then + usage + exit 0 + fi + + umask 077 + trap 'unset RUNNER_TOKEN 2>/dev/null || true' EXIT + + metadata + preflight + + if [ "${DRY_RUN:-0}" = "1" ]; then + log "dry_run=true" + log "safe_next_step=run_this_script_from_interactive_tty_without_capturing_token" + exit 0 + fi + + require_tty + read_token_from_tty + register_runner + postcheck +} + +main "$@" diff --git a/ops/runner/test_check_awoooi_non110_runner_readiness.py b/ops/runner/test_check_awoooi_non110_runner_readiness.py index 6518be9f7..aa1cfa6dd 100644 --- a/ops/runner/test_check_awoooi_non110_runner_readiness.py +++ b/ops/runner/test_check_awoooi_non110_runner_readiness.py @@ -153,7 +153,7 @@ def test_non110_readiness_blocks_without_registration_state(tmp_path: Path) -> N assert "AWOOOI_NON110_RUNNER_READY=0" in result.stdout assert "raw_runner_registration_read=false" in result.stdout assert ( - "safe_next_step=complete_runner_registration_without_printing_token_then_enable_service_and_rerun_this_verifier" + "safe_next_step=run_register_awoooi_non110_runner_script_without_printing_token_then_enable_service_and_rerun_this_verifier" in result.stdout ) diff --git a/ops/runner/test_register_awoooi_non110_runner.py b/ops/runner/test_register_awoooi_non110_runner.py new file mode 100644 index 000000000..e2455108a --- /dev/null +++ b/ops/runner/test_register_awoooi_non110_runner.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +REGISTER = ROOT / "ops/runner/register-awoooi-non110-runner.sh" +INSTALLER = ROOT / "ops/runner/install-awoooi-non110-user-runner.sh" + + +def test_register_helper_has_no_token_argv_path() -> None: + text = REGISTER.read_text(encoding="utf-8") + assert "--token" not in text + assert "runner_token_in_argv=false" in text + assert "runner_token_echoed=false" in text + assert "raw_runner_registration_read=false" in text + + +def test_user_installer_has_no_token_argv_path() -> None: + text = INSTALLER.read_text(encoding="utf-8") + assert "--token" not in text + assert "printf '%s\\n' \"$RUNNER_TOKEN\" | docker run --rm -i" in text + assert "runner_registration_requires_external_credentialed_channel" not in text + + +def test_register_helper_dry_run_requires_no_token(tmp_path: Path) -> None: + runner_dir = tmp_path / "runner" + data_dir = runner_dir / "data" + data_dir.mkdir(parents=True) + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + docker = fake_bin / "docker" + docker.write_text( + """#!/usr/bin/env bash +if [ "${1:-}" = "image" ] && [ "${2:-}" = "inspect" ]; then exit 0; fi +exit 99 +""", + encoding="utf-8", + ) + docker.chmod(0o755) + config = data_dir / "config.yaml" + config.write_text("runner:\n capacity: 1\n", encoding="utf-8") + + result = subprocess.run( + ["bash", str(REGISTER)], + check=False, + env={ + **os.environ, + "DRY_RUN": "1", + "PATH": f"{fake_bin}:{os.environ['PATH']}", + "RUNNER_DIR": str(runner_dir), + "DATA_DIR": str(data_dir), + "RUNNER_CONFIG": str(config), + "RUNNER_REGISTRATION": str(data_dir / ".runner"), + }, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + assert result.returncode == 0 + assert "dry_run=true" in result.stdout + assert "safe_next_step=run_this_script_from_interactive_tty_without_capturing_token" in result.stdout + assert "runner_token_in_argv=false" in result.stdout + assert "--token" not in result.stdout + assert "fake-token" not in result.stdout