G7 증명과 G8 clean-head 승격 준비

This commit is contained in:
Yun Chan 2026-08-09 18:22:03 +09:00
parent 94c681d450
commit 5221f79e3f
52 changed files with 6876 additions and 506 deletions

View file

@ -5,22 +5,121 @@
# start-public-runtime.ps1(-SkipWebRestart) 로 필요한 프로세스만 복구한다.
# 멱등: 어느 단계든 이미 살아있으면 건드리지 않는다. 수동으로 여러 번 실행해도 안전.
#
# 등록(로그온 시 자동 실행, 숨김 창):
# powershell -NoProfile -ExecutionPolicy Bypass -File scripts\register-boot-task.ps1
# 또는 수동:
# powershell -NoProfile -ExecutionPolicy Bypass -File scripts\boot-public-runtime.ps1
# 등록(로그온 시 자동 실행, 숨김 창)은 register-boot-task.ps1가 생성하는
# commit/tree/script SHA pin 인자를 사용한다. 핀 없는 직접 실행은 fail-closed한다.
param(
[string]$Workspace = "D:\workspace\vignette",
[Parameter(Mandatory = $true)]
[string]$StableSourceRoot,
[Parameter(Mandatory = $true)]
[ValidatePattern("^[0-9a-fA-F]{40}$")]
[string]$ExpectedSourceCommit,
[Parameter(Mandatory = $true)]
[ValidatePattern("^[0-9a-fA-F]{40}$")]
[string]$ExpectedSourceTree,
[Parameter(Mandatory = $true)]
[ValidatePattern("^[0-9a-fA-F]{64}$")]
[string]$ExpectedBootScriptSha256,
[Parameter(Mandatory = $true)]
[ValidatePattern("^[0-9a-fA-F]{64}$")]
[string]$ExpectedStartScriptSha256,
[string]$DockerDesktop = "C:\Program Files\Docker\Docker\Docker Desktop.exe",
[int]$DaemonTimeoutSec = 360,
[int]$DbTimeoutSec = 90,
[int]$DbPort = 55432,
[int]$ApiPort = 8001,
[string]$BootLog = "D:\workspace\vignette\boot-public-runtime.log"
[string]$BootLog = ""
)
$ErrorActionPreference = "Continue" # 부트 스크립트는 끝까지 로깅하고 종료한다.
$ErrorActionPreference = "Stop"
$resolvedSourceRoot = (Resolve-Path -LiteralPath $StableSourceRoot).Path
$expectedBootScript = Join-Path $resolvedSourceRoot "scripts\boot-public-runtime.ps1"
$startScript = Join-Path $resolvedSourceRoot "scripts\start-public-runtime.ps1"
function Invoke-GitText {
param([string[]]$Arguments)
$value = & git.exe -C $resolvedSourceRoot @Arguments
if ($LASTEXITCODE -ne 0) {
throw "Stable source Git command failed (exit=$LASTEXITCODE): git $($Arguments -join ' ')"
}
return (@($value) -join [Environment]::NewLine).Trim()
}
function Assert-StableSourceProvenance {
foreach ($requiredScript in @($expectedBootScript, $startScript)) {
if (-not (Test-Path -LiteralPath $requiredScript -PathType Leaf)) {
throw "Pinned public runtime script not found at $requiredScript"
}
}
$runningBootScript = (Resolve-Path -LiteralPath $PSCommandPath).Path
if (-not [string]::Equals(
$runningBootScript,
(Resolve-Path -LiteralPath $expectedBootScript).Path,
[System.StringComparison]::OrdinalIgnoreCase
)) {
throw "Boot recovery is not executing from the pinned stable source root"
}
$gitRoot = Invoke-GitText -Arguments @("rev-parse", "--show-toplevel")
$resolvedGitRoot = (Resolve-Path -LiteralPath $gitRoot).Path
if (-not [string]::Equals(
$resolvedGitRoot,
$resolvedSourceRoot,
[System.StringComparison]::OrdinalIgnoreCase
)) {
throw "Stable source root does not match its Git toplevel"
}
$symbolicHead = & git.exe -C $resolvedSourceRoot symbolic-ref --quiet HEAD
$symbolicHeadExit = $LASTEXITCODE
if ($symbolicHeadExit -eq 0) {
throw "Stable source must be a detached HEAD, not branch $symbolicHead"
}
if ($symbolicHeadExit -ne 1) {
throw "Could not prove detached HEAD (git exit=$symbolicHeadExit)"
}
$actualCommit = Invoke-GitText -Arguments @("rev-parse", "--verify", "HEAD")
$actualTree = Invoke-GitText -Arguments @("rev-parse", "--verify", "HEAD^{tree}")
if ($actualCommit -ne $ExpectedSourceCommit.ToLowerInvariant()) {
throw "Stable source commit drift: expected=$ExpectedSourceCommit actual=$actualCommit"
}
if ($actualTree -ne $ExpectedSourceTree.ToLowerInvariant()) {
throw "Stable source tree drift: expected=$ExpectedSourceTree actual=$actualTree"
}
$dirty = Invoke-GitText -Arguments @("status", "--porcelain=v1", "--untracked-files=normal")
if ($dirty) {
throw "Stable source is not clean; refusing boot recovery"
}
foreach ($relativePath in @(
"scripts/boot-public-runtime.ps1",
"scripts/start-public-runtime.ps1"
)) {
Invoke-GitText -Arguments @("ls-files", "--error-unmatch", "--", $relativePath) | Out-Null
}
$actualBootScriptSha256 = (Get-FileHash -LiteralPath $expectedBootScript -Algorithm SHA256).Hash.ToLowerInvariant()
$actualStartScriptSha256 = (Get-FileHash -LiteralPath $startScript -Algorithm SHA256).Hash.ToLowerInvariant()
if ($actualBootScriptSha256 -ne $ExpectedBootScriptSha256.ToLowerInvariant()) {
throw "Pinned boot script SHA256 drift"
}
if ($actualStartScriptSha256 -ne $ExpectedStartScriptSha256.ToLowerInvariant()) {
throw "Pinned start script SHA256 drift"
}
}
# Docker/DB/process mutation보다 먼저 stable source를 매 실행 재검증한다.
Assert-StableSourceProvenance
if (!$BootLog) {
$BootLog = Join-Path $resolvedSourceRoot "boot-public-runtime.log"
}
$ErrorActionPreference = "Continue" # provenance 이후 부트 복구는 끝까지 로깅한다.
function Write-BootLog([string]$Message) {
$line = "[{0}] {1}" -f (Get-Date -Format "yyyy-MM-dd HH:mm:ss"), $Message
@ -121,13 +220,10 @@ Write-BootLog "postgres 127.0.0.1:$DbPort up"
if ((Test-ApiControlPlaneHealthy) -and (Test-EngineHealthy)) {
Write-BootLog "control plane and engine already healthy; skipping runtime restart"
} else {
$pub = Join-Path $Workspace "scripts\start-public-runtime.ps1"
if (-not (Test-Path -LiteralPath $pub)) {
Write-BootLog "ERROR: start-public-runtime.ps1 not found at $pub"
exit 1
}
Write-BootLog "running start-public-runtime.ps1 -SkipWebRestart"
$out = & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $pub -SkipWebRestart 2>&1
$out = & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $startScript `
-Workspace $resolvedSourceRoot `
-SkipWebRestart 2>&1
$out | ForEach-Object { Write-BootLog (" pub> " + $_) }
if ($LASTEXITCODE -ne 0) {
Write-BootLog "ERROR: start-public-runtime.ps1 exit $LASTEXITCODE"

File diff suppressed because it is too large Load diff

View file

@ -23,8 +23,8 @@ DEFAULT_LOCAL_DEVELOPMENT = REPO_ROOT / "docs" / "guides" / "local-development.m
DEFAULT_TODO = REPO_ROOT / "docs" / "TODO.md"
EXPECTED_STATUS_COUNTS = {
"done": 33,
"doing": 2,
"done": 32,
"doing": 3,
"planned": 0,
}
EXPECTED_OWNER_COLUMN_COUNTS = {

View file

@ -2,9 +2,9 @@
"""Fail-closed validator for the complete G7 external evidence gate.
All four artifacts are required: authenticated public physical-microphone soak,
process-local voice runtime sampling, pinned Linux topology sampling, and an
independent human-held-out voice-gain pack. No artifact may contain raw audio or
transcripts, and synthetic evidence cannot satisfy this gate.
process-local voice runtime sampling, pinned deployment topology sampling, and
an independent human-held-out voice-gain pack. No artifact may contain raw audio
or transcripts, and synthetic evidence cannot satisfy this gate.
"""
from __future__ import annotations
@ -13,6 +13,7 @@ import argparse
import hashlib
import json
import math
import ntpath
import sys
from datetime import UTC, datetime
from pathlib import Path
@ -29,6 +30,7 @@ from app.services.g7_voice_gain_evidence import evaluate_human_voice_gain # noq
MINIMUM_SOAK_SECONDS = 3_000.0
PINNED_PSUTIL_VERSION = "6.1.1"
# 이 게이트는 특정 벤더가 아니라 **운영하기로 결정한 provider** 를 강제한다.
# 2026-08-08 소유자 결정: STT 는 노트북 상주 faster-whisper(`local_whisper`),
@ -82,6 +84,15 @@ def _iso(value: object) -> datetime | None:
return parsed.astimezone(UTC)
def _lower_hex(value: object, lengths: set[int]) -> bool:
return (
isinstance(value, str)
and len(value) in lengths
and value == value.lower()
and all(character in "0123456789abcdef" for character in value)
)
def validate_public_soak(payload: dict[str, Any], errors: list[str]) -> None:
prefix = "voice_soak"
_require(
@ -327,57 +338,11 @@ def validate_runtime(payload: dict[str, Any], errors: list[str]) -> None:
_require(errors, counters.get(field) == 0, f"{prefix}:{field}")
def validate_topology(payload: dict[str, Any], errors: list[str]) -> None:
def _validate_linux_compose_topology(
payload: dict[str, Any],
errors: list[str],
) -> None:
prefix = "topology"
_require(
errors,
payload.get("schema_version") == "vignette.g7-topology-evidence.v1",
f"{prefix}:schema",
)
_require(errors, payload.get("status") == "passed", f"{prefix}:status")
scope = payload.get("scope")
_require(
errors,
isinstance(scope, dict) and scope.get("metadata_only") is True,
f"{prefix}:privacy",
)
if isinstance(scope, dict):
for field in (
"raw_command_output_retained",
"socket_endpoints_retained",
"request_payloads_retained",
"audio_retained",
"transcripts_retained",
):
_require(errors, scope.get(field) is False, f"{prefix}:{field}")
edge = scope.get("cloudflare_edge")
_require(
errors,
isinstance(edge, dict)
and edge.get("internal_queue_measured") is False
and edge.get("evidence_boundary") == "separate_external_artifact_required",
f"{prefix}:edge_boundary",
)
requested = payload.get("requested")
completed = payload.get("samples_completed")
_require(errors, isinstance(requested, dict), f"{prefix}:requested")
if isinstance(requested, dict):
sample_count = requested.get("samples")
interval = _number(requested.get("interval_seconds"))
_require(
errors,
isinstance(sample_count, int)
and sample_count == completed
and sample_count > 1,
f"{prefix}:sample_count",
)
_require(
errors,
interval is not None
and isinstance(completed, int)
and (completed - 1) * interval >= MINIMUM_SOAK_SECONDS,
f"{prefix}:coverage",
)
targets = payload.get("targets")
_require(
errors,
@ -442,6 +407,499 @@ def validate_topology(payload: dict[str, Any], errors: list[str]) -> None:
and (_number(host_tcp.get("connections_max")) or 0) > 0,
f"{prefix}:host_tcp",
)
def _validate_windows_topology(
payload: dict[str, Any],
errors: list[str],
) -> None:
prefix = "topology"
requested = payload.get("requested")
_require(errors, isinstance(requested, dict), f"{prefix}:windows_requested")
if not isinstance(requested, dict):
return
expected_git_sha = requested.get("git_sha")
_require(
errors,
_lower_hex(expected_git_sha, {40, 64}),
f"{prefix}:windows_git_sha",
)
source_pin = requested.get("source_pin")
_require(
errors,
isinstance(source_pin, dict),
f"{prefix}:windows_source_pin",
)
expected_tree_sha = (
source_pin.get("git_tree_sha") if isinstance(source_pin, dict) else None
)
expected_scripts = (
source_pin.get("script_sha256") if isinstance(source_pin, dict) else None
)
expected_dependencies = (
source_pin.get("runtime_dependencies") if isinstance(source_pin, dict) else None
)
_require(
errors,
_lower_hex(expected_tree_sha, {40, 64}),
f"{prefix}:windows_git_tree_sha_pin",
)
_require(
errors,
isinstance(expected_scripts, dict)
and set(expected_scripts) == {"runner", "collector", "checker"}
and all(_lower_hex(value, {64}) for value in expected_scripts.values()),
f"{prefix}:windows_script_sha256_pin",
)
_require(
errors,
isinstance(expected_dependencies, dict)
and expected_dependencies == {"psutil": PINNED_PSUTIL_VERSION},
f"{prefix}:windows_psutil_version_pin",
)
source_provenance = payload.get("source_provenance")
_require(
errors,
isinstance(source_provenance, dict),
f"{prefix}:windows_source_provenance",
)
if isinstance(source_provenance, dict):
observed_scripts = source_provenance.get("script_sha256")
observed_dependencies = source_provenance.get("runtime_dependencies")
_require(
errors,
source_provenance.get("detached_head") is True,
f"{prefix}:windows_detached_head",
)
_require(
errors,
source_provenance.get("tracked_clean") is True,
f"{prefix}:windows_tracked_clean",
)
_require(
errors,
source_provenance.get("git_sha") == expected_git_sha,
f"{prefix}:windows_source_git_sha",
)
_require(
errors,
source_provenance.get("git_tree_sha") == expected_tree_sha,
f"{prefix}:windows_source_git_tree_sha",
)
_require(
errors,
isinstance(observed_scripts, dict)
and isinstance(expected_scripts, dict)
and observed_scripts == expected_scripts,
f"{prefix}:windows_source_script_sha256",
)
_require(
errors,
isinstance(observed_dependencies, dict)
and isinstance(expected_dependencies, dict)
and observed_dependencies == expected_dependencies,
f"{prefix}:windows_source_psutil_version",
)
repo_root = requested.get("repo_root")
_require(
errors,
isinstance(repo_root, str) and ntpath.isabs(repo_root),
f"{prefix}:windows_repo_root",
)
api_listen_port = requested.get("api_listen_port")
_require(
errors,
isinstance(api_listen_port, int)
and not isinstance(api_listen_port, bool)
and 1 <= api_listen_port <= 65_535,
f"{prefix}:windows_api_listen_port",
)
requested_roles = requested.get("roles")
_require(
errors,
isinstance(requested_roles, dict)
and set(requested_roles) == {"api", "cloudflared"},
f"{prefix}:windows_requested_roles",
)
targets = payload.get("targets")
_require(
errors,
isinstance(targets, dict) and set(targets) == {"api", "cloudflared"},
f"{prefix}:targets",
)
capture_started = _iso(payload.get("started_at_utc"))
target_pids: list[int] = []
if isinstance(targets, dict) and isinstance(requested_roles, dict):
for role in ("api", "cloudflared"):
target = targets.get(role)
expectation = requested_roles.get(role)
_require(errors, isinstance(target, dict), f"{prefix}:{role}_target")
_require(
errors,
isinstance(expectation, dict),
f"{prefix}:{role}_expectation",
)
if not isinstance(target, dict) or not isinstance(expectation, dict):
continue
_require(
errors,
target.get("role") == role,
f"{prefix}:{role}_role",
)
pid = target.get("pid")
_require(
errors,
isinstance(pid, int) and not isinstance(pid, bool) and pid > 0,
f"{prefix}:{role}_pid",
)
if isinstance(pid, int) and not isinstance(pid, bool):
target_pids.append(pid)
_require(
errors,
pid == expectation.get("pid"),
f"{prefix}:{role}_pid_pin",
)
executable_sha256 = target.get("executable_sha256")
_require(
errors,
_lower_hex(executable_sha256, {64})
and executable_sha256 == expectation.get("expected_executable_sha256"),
f"{prefix}:{role}_executable_sha256",
)
_require(
errors,
_lower_hex(target.get("command_line_sha256"), {64}),
f"{prefix}:{role}_command_line_sha256",
)
executable_name = target.get("executable_name")
expected_name = expectation.get("expected_executable_name")
_require(
errors,
isinstance(executable_name, str)
and isinstance(expected_name, str)
and executable_name.casefold() == expected_name.casefold(),
f"{prefix}:{role}_executable_name",
)
cwd = target.get("cwd")
expected_cwd = expectation.get("expected_cwd")
_require(
errors,
isinstance(cwd, str)
and isinstance(expected_cwd, str)
and ntpath.normcase(ntpath.normpath(cwd))
== ntpath.normcase(ntpath.normpath(expected_cwd)),
f"{prefix}:{role}_cwd",
)
_require(
errors,
target.get("git_sha") == expected_git_sha,
f"{prefix}:{role}_git_sha",
)
target_started = _iso(target.get("started_at"))
_require(
errors,
capture_started is not None
and target_started is not None
and target_started <= capture_started,
f"{prefix}:{role}_process_start",
)
_require(
errors,
len(target_pids) == 2 and len(set(target_pids)) == 2,
f"{prefix}:windows_distinct_pids",
)
completed = payload.get("samples_completed")
samples = payload.get("samples")
_require(
errors,
isinstance(samples, list)
and isinstance(completed, int)
and len(samples) == completed,
f"{prefix}:windows_samples",
)
capture_ended = _iso(payload.get("ended_at_utc"))
metric_values: dict[str, dict[str, list[float]]] = {
role: {
field: []
for field in (
"rss_bytes",
"peak_rss_bytes",
"cpu_time_seconds",
"cpu_percent",
"handles",
"threads",
)
}
for role in ("api", "cloudflared")
}
tcp_values: dict[str, dict[str, list[int]]] = {
role: {field: [] for field in ("connections", "established", "listeners")}
for role in ("api", "cloudflared")
}
listener_owned: list[int] = []
listener_conflicts: list[int] = []
host_connections: list[int] = []
if isinstance(samples, list):
previous_cpu: dict[str, float] = {}
for index, sample in enumerate(samples, start=1):
if not isinstance(sample, dict):
errors.append(f"{prefix}:windows_sample_shape")
continue
_require(
errors,
sample.get("sequence") == index,
f"{prefix}:windows_sample_sequence",
)
observed_at = _iso(sample.get("observed_at_utc"))
_require(
errors,
capture_started is not None
and capture_ended is not None
and observed_at is not None
and capture_started <= observed_at <= capture_ended,
f"{prefix}:windows_sample_time",
)
processes = sample.get("processes")
process_tcp = sample.get("process_tcp")
_require(
errors,
isinstance(processes, dict)
and set(processes) == {"api", "cloudflared"},
f"{prefix}:windows_sample_processes",
)
_require(
errors,
isinstance(process_tcp, dict)
and set(process_tcp) == {"api", "cloudflared"},
f"{prefix}:windows_sample_process_tcp",
)
for role in ("api", "cloudflared"):
metrics = processes.get(role) if isinstance(processes, dict) else None
tcp = process_tcp.get(role) if isinstance(process_tcp, dict) else None
_require(
errors,
isinstance(metrics, dict),
f"{prefix}:{role}_sample_metrics",
)
_require(
errors,
isinstance(tcp, dict),
f"{prefix}:{role}_sample_tcp",
)
if isinstance(metrics, dict):
for field in metric_values[role]:
numeric = _number(metrics.get(field))
positive = field != "cpu_percent"
_require(
errors,
numeric is not None
and numeric >= 0
and (not positive or numeric > 0),
f"{prefix}:{role}_{field}_sample",
)
if numeric is not None:
metric_values[role][field].append(numeric)
cpu_time = _number(metrics.get("cpu_time_seconds"))
if cpu_time is not None:
_require(
errors,
cpu_time >= previous_cpu.get(role, 0.0),
f"{prefix}:{role}_cpu_time_monotonic",
)
previous_cpu[role] = cpu_time
if isinstance(tcp, dict):
for field in tcp_values[role]:
value = tcp.get(field)
_require(
errors,
isinstance(value, int)
and not isinstance(value, bool)
and value >= 0,
f"{prefix}:{role}_tcp_{field}_sample",
)
if isinstance(value, int) and not isinstance(value, bool):
tcp_values[role][field].append(value)
if role == "api":
_require(
errors,
(_number(tcp.get("listeners")) or 0) > 0,
f"{prefix}:api_listener_sample",
)
else:
_require(
errors,
(_number(tcp.get("established")) or 0) > 0,
f"{prefix}:cloudflared_tunnel_sample",
)
listener = sample.get("api_listener")
host_tcp = sample.get("host_tcp")
_require(errors, isinstance(listener, dict), f"{prefix}:listener_sample")
_require(errors, isinstance(host_tcp, dict), f"{prefix}:host_tcp_sample")
if isinstance(listener, dict):
owned = listener.get("owned_listener_count")
conflicts = listener.get("conflicting_listener_count")
_require(
errors,
listener.get("port") == api_listen_port,
f"{prefix}:listener_port_sample",
)
_require(
errors,
isinstance(owned, int) and not isinstance(owned, bool) and owned > 0,
f"{prefix}:listener_owner_sample",
)
_require(
errors,
isinstance(conflicts, int)
and not isinstance(conflicts, bool)
and conflicts == 0,
f"{prefix}:listener_conflict_sample",
)
if isinstance(owned, int) and not isinstance(owned, bool):
listener_owned.append(owned)
if isinstance(conflicts, int) and not isinstance(conflicts, bool):
listener_conflicts.append(conflicts)
if isinstance(host_tcp, dict):
connections = host_tcp.get("connections")
_require(
errors,
isinstance(connections, int)
and not isinstance(connections, bool)
and connections > 0,
f"{prefix}:host_tcp_connections_sample",
)
if isinstance(connections, int) and not isinstance(connections, bool):
host_connections.append(connections)
summary = payload.get("summary")
_require(errors, isinstance(summary, dict), f"{prefix}:summary")
if isinstance(summary, dict):
process_summary = summary.get("processes")
listener_summary = summary.get("api_listener")
host_summary = summary.get("host_tcp")
_require(
errors,
isinstance(process_summary, dict)
and set(process_summary) == {"api", "cloudflared"},
f"{prefix}:windows_process_summary",
)
if isinstance(process_summary, dict):
for role in ("api", "cloudflared"):
values = process_summary.get(role)
_require(
errors,
isinstance(values, dict),
f"{prefix}:{role}_summary",
)
if not isinstance(values, dict):
continue
for field, observations in metric_values[role].items():
_require(
errors,
bool(observations)
and _number(values.get(f"{field}_max")) == max(observations),
f"{prefix}:{role}_{field}_max",
)
for field, observations in tcp_values[role].items():
_require(
errors,
bool(observations)
and _number(values.get(f"tcp_{field}_max"))
== max(observations),
f"{prefix}:{role}_tcp_{field}_max",
)
_require(
errors,
isinstance(listener_summary, dict)
and listener_summary.get("port") == api_listen_port
and bool(listener_owned)
and listener_summary.get("owned_listener_count_min")
== min(listener_owned)
and bool(listener_conflicts)
and listener_summary.get("conflicting_listener_count_max")
== max(listener_conflicts),
f"{prefix}:windows_listener_summary",
)
_require(
errors,
isinstance(host_summary, dict)
and bool(host_connections)
and host_summary.get("connections_max") == max(host_connections),
f"{prefix}:host_tcp",
)
def validate_topology(payload: dict[str, Any], errors: list[str]) -> None:
prefix = "topology"
_require(
errors,
payload.get("schema_version") == "vignette.g7-topology-evidence.v1",
f"{prefix}:schema",
)
_require(errors, payload.get("status") == "passed", f"{prefix}:status")
scope = payload.get("scope")
_require(
errors,
isinstance(scope, dict) and scope.get("metadata_only") is True,
f"{prefix}:privacy",
)
if isinstance(scope, dict):
for field in (
"raw_command_output_retained",
"socket_endpoints_retained",
"request_payloads_retained",
"audio_retained",
"transcripts_retained",
):
_require(errors, scope.get(field) is False, f"{prefix}:{field}")
edge = scope.get("cloudflare_edge")
_require(
errors,
isinstance(edge, dict)
and edge.get("internal_queue_measured") is False
and edge.get("evidence_boundary") == "separate_external_artifact_required",
f"{prefix}:edge_boundary",
)
requested = payload.get("requested")
completed = payload.get("samples_completed")
_require(errors, isinstance(requested, dict), f"{prefix}:requested")
if isinstance(requested, dict):
sample_count = requested.get("samples")
interval = _number(requested.get("interval_seconds"))
_require(
errors,
isinstance(sample_count, int)
and sample_count == completed
and sample_count > 1,
f"{prefix}:sample_count",
)
_require(
errors,
interval is not None
and isinstance(completed, int)
and (completed - 1) * interval >= MINIMUM_SOAK_SECONDS,
f"{prefix}:coverage",
)
topology_mode = payload.get("topology_mode")
_require(
errors,
topology_mode in (None, "linux_compose", "windows_host"),
f"{prefix}:mode",
)
if topology_mode == "windows_host":
_require(
errors,
isinstance(scope, dict)
and scope.get("topology_boundary")
== "windows_host_api_and_cloudflared_processes"
and scope.get("configured_listener_port_retained") is True,
f"{prefix}:windows_scope",
)
_validate_windows_topology(payload, errors)
elif topology_mode in (None, "linux_compose"):
_validate_linux_compose_topology(payload, errors)
_require(errors, payload.get("failure_type") is None, f"{prefix}:failure")
@ -489,11 +947,20 @@ def validate_binding(
else:
windows.append((start, end))
if len(windows) == 3:
overlap_started = max(start for start, _ in windows)
overlap_ended = min(end for _, end in windows)
_require(
errors,
max(start for start, _ in windows) <= min(end for _, end in windows),
overlap_started <= overlap_ended,
"binding:no_concurrent_overlap",
)
if overlap_started <= overlap_ended:
_require(
errors,
(overlap_ended - overlap_started).total_seconds()
>= MINIMUM_SOAK_SECONDS,
"binding:concurrent_overlap_below_3000_seconds",
)
def artifact_sha256(path: Path) -> str:

View file

@ -1,5 +1,6 @@
param(
[string]$Workspace = "D:\workspace\vignette",
[Parameter(Mandatory = $true)]
[string]$StableSourceRoot,
[string]$TaskName = "VignettePublicRuntimeWatchdog",
[int]$IntervalMinutes = 5,
[string[]]$AdditionalPublicHealthUrls = @(),
@ -14,11 +15,72 @@ if ($IntervalMinutes -lt 1) {
throw "IntervalMinutes must be 1 or greater"
}
$watchScript = Join-Path $Workspace "scripts\watch-public-runtime.ps1"
if (!(Test-Path $watchScript)) {
throw "Watchdog script not found at $watchScript"
$resolvedSourceRoot = (Resolve-Path -LiteralPath $StableSourceRoot).Path
$installerScript = Join-Path $resolvedSourceRoot "scripts\install-public-runtime-task.ps1"
$watchScript = Join-Path $resolvedSourceRoot "scripts\watch-public-runtime.ps1"
$startScript = Join-Path $resolvedSourceRoot "scripts\start-public-runtime.ps1"
function Invoke-GitText {
param([string[]]$Arguments)
$value = & git.exe -C $resolvedSourceRoot @Arguments
if ($LASTEXITCODE -ne 0) {
throw "Stable source Git command failed (exit=$LASTEXITCODE): git $($Arguments -join ' ')"
}
return (@($value) -join [Environment]::NewLine).Trim()
}
foreach ($requiredScript in @($installerScript, $watchScript, $startScript)) {
if (!(Test-Path -LiteralPath $requiredScript -PathType Leaf)) {
throw "Public runtime script not found at $requiredScript"
}
}
$runningInstaller = (Resolve-Path -LiteralPath $PSCommandPath).Path
if (-not [string]::Equals(
$runningInstaller,
(Resolve-Path -LiteralPath $installerScript).Path,
[System.StringComparison]::OrdinalIgnoreCase
)) {
throw "Watchdog installer is not executing from the pinned stable source root"
}
$gitRoot = Invoke-GitText -Arguments @("rev-parse", "--show-toplevel")
$resolvedGitRoot = (Resolve-Path -LiteralPath $gitRoot).Path
if (-not [string]::Equals(
$resolvedGitRoot,
$resolvedSourceRoot,
[System.StringComparison]::OrdinalIgnoreCase
)) {
throw "Stable source root does not match its Git toplevel"
}
$symbolicHead = & git.exe -C $resolvedSourceRoot symbolic-ref --quiet HEAD
$symbolicHeadExit = $LASTEXITCODE
if ($symbolicHeadExit -eq 0) {
throw "Stable source must be a detached HEAD, not branch $symbolicHead"
}
if ($symbolicHeadExit -ne 1) {
throw "Could not prove detached HEAD (git exit=$symbolicHeadExit)"
}
$dirty = Invoke-GitText -Arguments @("status", "--porcelain=v1", "--untracked-files=normal")
if ($dirty) {
throw "Stable source is not clean; refusing watchdog installation"
}
foreach ($relativePath in @(
"scripts/install-public-runtime-task.ps1",
"scripts/watch-public-runtime.ps1",
"scripts/start-public-runtime.ps1"
)) {
Invoke-GitText -Arguments @("ls-files", "--error-unmatch", "--", $relativePath) | Out-Null
}
$sourceCommit = Invoke-GitText -Arguments @("rev-parse", "--verify", "HEAD")
$sourceTree = Invoke-GitText -Arguments @("rev-parse", "--verify", "HEAD^{tree}")
$watchdogSha256 = (Get-FileHash -LiteralPath $watchScript -Algorithm SHA256).Hash.ToLowerInvariant()
$startScriptSha256 = (Get-FileHash -LiteralPath $startScript -Algorithm SHA256).Hash.ToLowerInvariant()
$powershell = (Get-Command powershell.exe).Source
$userId = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
@ -26,7 +88,11 @@ $actionArguments = @(
"-NoProfile",
"-ExecutionPolicy Bypass",
"-File `"$watchScript`"",
"-Workspace `"$Workspace`""
"-StableSourceRoot `"$resolvedSourceRoot`"",
"-ExpectedSourceCommit $sourceCommit",
"-ExpectedSourceTree $sourceTree",
"-ExpectedWatchdogSha256 $watchdogSha256",
"-ExpectedStartScriptSha256 $startScriptSha256"
)
if ($SkipPublicHealth) {
$actionArguments += "-SkipPublicHealth"
@ -42,7 +108,7 @@ if ($AdditionalPublicHealthUrls.Count -gt 0) {
$action = New-ScheduledTaskAction `
-Execute $powershell `
-Argument ($actionArguments -join " ") `
-WorkingDirectory $Workspace
-WorkingDirectory $resolvedSourceRoot
$logonTrigger = New-ScheduledTaskTrigger -AtLogOn -User $userId
$repeatTrigger = New-ScheduledTaskTrigger `
@ -65,7 +131,7 @@ $principal = New-ScheduledTaskPrincipal `
-LogonType Interactive `
-RunLevel Limited
$description = "Runs Vignette public runtime watchdog as $userId. Secrets stay in the user profile and apps/api/.env; the task command stores no secrets."
$description = "Runs Vignette public runtime watchdog as $userId from detached clean commit $sourceCommit. Secrets stay in the user profile and apps/api/.env; the task command stores no secrets."
$task = New-ScheduledTask `
-Action $action `
-Trigger @($logonTrigger, $repeatTrigger) `
@ -77,6 +143,8 @@ Register-ScheduledTask -TaskName $TaskName -InputObject $task -Force | Out-Null
Write-Output "Installed scheduled task '$TaskName' for $userId"
Write-Output "Action: $powershell $($actionArguments -join ' ')"
Write-Output "Pinned source: root=$resolvedSourceRoot commit=$sourceCommit tree=$sourceTree"
Write-Output "Pinned scripts: watchdog_sha256=$watchdogSha256 start_sha256=$startScriptSha256"
Write-Output "Interval: every $IntervalMinutes minute(s), plus at user logon"
if ($AdditionalPublicHealthUrls.Count -gt 0) {
Write-Output "Additional public health URLs: $($AdditionalPublicHealthUrls -join ', ')"

View file

@ -36,7 +36,7 @@ from urllib.parse import parse_qs, urlsplit
DEFAULT_HOST = "127.0.0.1"
DEFAULT_PORT = 9882
DEFAULT_MODEL = "large-v3"
DEFAULT_MODEL = "small"
DEFAULT_LANGUAGE = "ko"
DEFAULT_SAMPLE_RATE = 16_000
DEFAULT_CHANNELS = 1
@ -546,6 +546,7 @@ async def serve(args: argparse.Namespace) -> int: # pragma: no cover - I/O 진
await send(
{
"type": "ready",
"provider": "local_whisper",
"model": transcriber.model_name,
"device": transcriber.device,
"compute_type": transcriber.compute_type,
@ -579,6 +580,7 @@ async def serve(args: argparse.Namespace) -> int: # pragma: no cover - I/O 진
json.dumps(
{
"ready": True,
"provider": "local_whisper",
"host": args.host,
"port": args.port,
"model": transcriber.model_name,

View file

@ -114,6 +114,7 @@ def encode_wav(samples: Iterable[float], sample_rate: int) -> bytes:
def health_payload(synthesizer: Synthesizer, *, speakers: Iterable[str]) -> dict[str, Any]:
return {
"status": "ok",
"provider": "melotts",
"model": MODEL_ID,
"language": DEFAULT_LANGUAGE,
"license": LICENSE_ID,

View file

@ -0,0 +1,282 @@
#!/usr/bin/env python3
"""Fail-closed, metadata-only readiness probe for the public voice sidecars.
The probe never sends audio or synthesis text. It proves the local Whisper
WebSocket protocol by validating its first ``ready`` frame, and proves MeloTTS
through its loopback ``/health`` document. Only bounded, non-PII metadata is
printed.
"""
from __future__ import annotations
import argparse
import asyncio
import json
import sys
from collections.abc import Iterable, Mapping
from typing import Any
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
from urllib.request import ProxyHandler, Request, build_opener
DEFAULT_STT_URL = "ws://127.0.0.1:9882/v1/listen"
DEFAULT_TTS_URL = "http://127.0.0.1:9883"
DEFAULT_STT_PROVIDER = "local_whisper"
DEFAULT_STT_MODEL = "small"
DEFAULT_STT_LANGUAGE = "ko"
DEFAULT_STT_DEVICE = "cpu"
DEFAULT_TTS_PROVIDER = "melotts"
DEFAULT_TTS_MODEL = "melotts-korean"
DEFAULT_TTS_LANGUAGE = "KR"
MAX_METADATA_BYTES = 64 * 1024
LOOPBACK_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"})
class ProbeError(RuntimeError):
"""A stable, non-sensitive readiness failure code."""
def _loopback_parts(url: str, *, schemes: set[str]) -> Any:
parts = urlsplit(url)
if (
parts.scheme not in schemes
or parts.hostname not in LOOPBACK_HOSTS
or parts.username is not None
or parts.password is not None
or parts.fragment
):
raise ProbeError("loopback_url_required")
return parts
def build_stt_probe_url(
base_url: str,
*,
model: str,
language: str,
) -> str:
parts = _loopback_parts(base_url, schemes={"ws"})
query = dict(parse_qsl(parts.query, keep_blank_values=True))
query.update(
{
"model": model,
"language": language,
"sample_rate": "16000",
"channels": "1",
}
)
return urlunsplit(
(parts.scheme, parts.netloc, parts.path, urlencode(query), "")
)
def validate_stt_ready(
payload: object,
*,
provider: str,
model: str,
language: str,
device: str,
) -> dict[str, object]:
if not isinstance(payload, Mapping):
raise ProbeError("stt_ready_not_object")
expected = {
"type": "ready",
"provider": provider,
"model": model,
"language": language,
"device": device,
"sample_rate": 16_000,
}
for key, value in expected.items():
if payload.get(key) != value:
raise ProbeError(f"stt_{key}_mismatch")
expected_compute_type = "int8" if device == "cpu" else "float16"
if payload.get("compute_type") != expected_compute_type:
raise ProbeError("stt_compute_type_mismatch")
return {
"provider": provider,
"model": model,
"language": language,
"device": device,
"compute_type": expected_compute_type,
"sample_rate": 16_000,
}
def validate_tts_health(
payload: object,
*,
provider: str,
model: str,
language: str,
) -> dict[str, object]:
if not isinstance(payload, Mapping):
raise ProbeError("tts_health_not_object")
expected = {
"status": "ok",
"provider": provider,
"model": model,
"language": language,
"license": "MIT",
"reference_policy": "pretrained-multispeaker-no-external-reference",
}
for key, value in expected.items():
if payload.get(key) != value:
raise ProbeError(f"tts_{key}_mismatch")
sample_rate = payload.get("sample_rate")
speakers = payload.get("speakers")
if not isinstance(sample_rate, int) or sample_rate <= 0:
raise ProbeError("tts_sample_rate_invalid")
if not isinstance(speakers, list) or not speakers:
raise ProbeError("tts_speakers_missing")
return {
"provider": provider,
"model": model,
"language": language,
"license": "MIT",
"sample_rate": sample_rate,
}
async def probe_stt(
url: str,
*,
provider: str,
model: str,
language: str,
device: str,
timeout_seconds: float,
) -> dict[str, object]:
from websockets.asyncio.client import connect
probe_url = build_stt_probe_url(url, model=model, language=language)
try:
async with asyncio.timeout(timeout_seconds):
async with connect(
probe_url,
max_size=MAX_METADATA_BYTES,
max_queue=1,
ping_interval=None,
) as socket:
frame = await socket.recv()
except ProbeError:
raise
except Exception as exc:
raise ProbeError("stt_connection_failed") from exc
if not isinstance(frame, str) or len(frame.encode("utf-8")) > MAX_METADATA_BYTES:
raise ProbeError("stt_ready_frame_invalid")
try:
payload = json.loads(frame)
except ValueError as exc:
raise ProbeError("stt_ready_json_invalid") from exc
return validate_stt_ready(
payload,
provider=provider,
model=model,
language=language,
device=device,
)
def probe_tts(
url: str,
*,
provider: str,
model: str,
language: str,
timeout_seconds: float,
) -> dict[str, object]:
parts = _loopback_parts(url, schemes={"http"})
health_path = parts.path.rstrip("/") + "/health"
health_url = urlunsplit((parts.scheme, parts.netloc, health_path, "", ""))
request = Request(
health_url,
method="GET",
headers={"Accept": "application/json", "User-Agent": "vignette-readiness/1"},
)
try:
with build_opener(ProxyHandler({})).open(
request, timeout=timeout_seconds
) as response:
content_type = response.headers.get_content_type()
body = response.read(MAX_METADATA_BYTES + 1)
status = response.status
except Exception as exc:
raise ProbeError("tts_connection_failed") from exc
if status != 200:
raise ProbeError("tts_health_status_invalid")
if content_type != "application/json" or len(body) > MAX_METADATA_BYTES:
raise ProbeError("tts_health_response_invalid")
try:
payload = json.loads(body)
except (UnicodeDecodeError, ValueError) as exc:
raise ProbeError("tts_health_json_invalid") from exc
return validate_tts_health(
payload,
provider=provider,
model=model,
language=language,
)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--component", choices=("all", "stt", "tts"), default="all")
parser.add_argument("--stt-url", default=DEFAULT_STT_URL)
parser.add_argument("--stt-provider", default=DEFAULT_STT_PROVIDER)
parser.add_argument("--stt-model", default=DEFAULT_STT_MODEL)
parser.add_argument("--stt-language", default=DEFAULT_STT_LANGUAGE)
parser.add_argument("--stt-device", choices=("cpu", "cuda"), default=DEFAULT_STT_DEVICE)
parser.add_argument("--tts-url", default=DEFAULT_TTS_URL)
parser.add_argument("--tts-provider", default=DEFAULT_TTS_PROVIDER)
parser.add_argument("--tts-model", default=DEFAULT_TTS_MODEL)
parser.add_argument("--tts-language", default=DEFAULT_TTS_LANGUAGE)
parser.add_argument("--timeout-seconds", type=float, default=5.0)
return parser
def main(argv: Iterable[str] | None = None) -> int:
args = build_parser().parse_args(list(argv) if argv is not None else None)
if not 0.1 <= args.timeout_seconds <= 30:
print('{"ok":false,"error":"timeout_invalid"}', file=sys.stderr)
return 2
result: dict[str, object] = {
"schema_version": "vignette.voice-sidecar-readiness.v1",
"ok": True,
}
try:
if args.component in {"all", "stt"}:
result["stt"] = asyncio.run(
probe_stt(
args.stt_url,
provider=args.stt_provider,
model=args.stt_model,
language=args.stt_language,
device=args.stt_device,
timeout_seconds=args.timeout_seconds,
)
)
if args.component in {"all", "tts"}:
result["tts"] = probe_tts(
args.tts_url,
provider=args.tts_provider,
model=args.tts_model,
language=args.tts_language,
timeout_seconds=args.timeout_seconds,
)
except ProbeError as exc:
print(
json.dumps(
{"ok": False, "component": args.component, "error": str(exc)},
separators=(",", ":"),
),
file=sys.stderr,
)
return 1
print(json.dumps(result, separators=(",", ":"), sort_keys=True))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -4,21 +4,100 @@
# actual recovery. Re-run to refresh (-Force). Remove with:
# Unregister-ScheduledTask -TaskName VignettePublicRuntime -Confirm:$false
#
# powershell -NoProfile -ExecutionPolicy Bypass -File scripts\register-boot-task.ps1
# powershell -NoProfile -ExecutionPolicy Bypass -File scripts\register-boot-task.ps1 `
# -StableSourceRoot D:\workspace\vignette-public-runtime-<commit>
param(
[Parameter(Mandatory = $true)]
[string]$StableSourceRoot,
[string]$TaskName = "VignettePublicRuntime"
)
$ErrorActionPreference = "Stop"
$TaskName = "VignettePublicRuntime"
$BootScript = "D:\workspace\vignette\scripts\boot-public-runtime.ps1"
$resolvedSourceRoot = (Resolve-Path -LiteralPath $StableSourceRoot).Path
$RegisterScript = Join-Path $resolvedSourceRoot "scripts\register-boot-task.ps1"
$BootScript = Join-Path $resolvedSourceRoot "scripts\boot-public-runtime.ps1"
$StartScript = Join-Path $resolvedSourceRoot "scripts\start-public-runtime.ps1"
$UserName = "$env:USERDOMAIN\$env:USERNAME"
if (-not (Test-Path -LiteralPath $BootScript)) {
throw "boot script not found: $BootScript"
function Invoke-GitText {
param([string[]]$Arguments)
$value = & git.exe -C $resolvedSourceRoot @Arguments
if ($LASTEXITCODE -ne 0) {
throw "Stable source Git command failed (exit=$LASTEXITCODE): git $($Arguments -join ' ')"
}
return (@($value) -join [Environment]::NewLine).Trim()
}
foreach ($requiredScript in @($RegisterScript, $BootScript, $StartScript)) {
if (-not (Test-Path -LiteralPath $requiredScript -PathType Leaf)) {
throw "Public runtime script not found: $requiredScript"
}
}
$runningRegisterScript = (Resolve-Path -LiteralPath $PSCommandPath).Path
if (-not [string]::Equals(
$runningRegisterScript,
(Resolve-Path -LiteralPath $RegisterScript).Path,
[System.StringComparison]::OrdinalIgnoreCase
)) {
throw "Boot task registrar is not executing from the pinned stable source root"
}
$gitRoot = Invoke-GitText -Arguments @("rev-parse", "--show-toplevel")
$resolvedGitRoot = (Resolve-Path -LiteralPath $gitRoot).Path
if (-not [string]::Equals(
$resolvedGitRoot,
$resolvedSourceRoot,
[System.StringComparison]::OrdinalIgnoreCase
)) {
throw "Stable source root does not match its Git toplevel"
}
$symbolicHead = & git.exe -C $resolvedSourceRoot symbolic-ref --quiet HEAD
$symbolicHeadExit = $LASTEXITCODE
if ($symbolicHeadExit -eq 0) {
throw "Stable source must be a detached HEAD, not branch $symbolicHead"
}
if ($symbolicHeadExit -ne 1) {
throw "Could not prove detached HEAD (git exit=$symbolicHeadExit)"
}
$dirty = Invoke-GitText -Arguments @("status", "--porcelain=v1", "--untracked-files=normal")
if ($dirty) {
throw "Stable source is not clean; refusing boot task registration"
}
foreach ($relativePath in @(
"scripts/register-boot-task.ps1",
"scripts/boot-public-runtime.ps1",
"scripts/start-public-runtime.ps1"
)) {
Invoke-GitText -Arguments @("ls-files", "--error-unmatch", "--", $relativePath) | Out-Null
}
$sourceCommit = Invoke-GitText -Arguments @("rev-parse", "--verify", "HEAD")
$sourceTree = Invoke-GitText -Arguments @("rev-parse", "--verify", "HEAD^{tree}")
$bootScriptSha256 = (Get-FileHash -LiteralPath $BootScript -Algorithm SHA256).Hash.ToLowerInvariant()
$startScriptSha256 = (Get-FileHash -LiteralPath $StartScript -Algorithm SHA256).Hash.ToLowerInvariant()
$bootArguments = @(
"-NoProfile",
"-ExecutionPolicy Bypass",
"-WindowStyle Hidden",
"-File `"$BootScript`"",
"-StableSourceRoot `"$resolvedSourceRoot`"",
"-ExpectedSourceCommit $sourceCommit",
"-ExpectedSourceTree $sourceTree",
"-ExpectedBootScriptSha256 $bootScriptSha256",
"-ExpectedStartScriptSha256 $startScriptSha256"
)
$action = New-ScheduledTaskAction `
-Execute "powershell.exe" `
-Argument ("-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File `"" + $BootScript + "`"")
-Argument ($bootArguments -join " ") `
-WorkingDirectory $resolvedSourceRoot
$trigger = New-ScheduledTaskTrigger -AtLogOn -User $UserName
@ -41,7 +120,7 @@ Register-ScheduledTask `
-Trigger $trigger `
-Principal $principal `
-Settings $settings `
-Description "Vignette public runtime auto-recovery (Docker + postgres + engine/api + cloudflared) at logon" `
-Description "Vignette public runtime auto-recovery from detached clean commit $sourceCommit at logon" `
-Force | Out-Null
$task = Get-ScheduledTask -TaskName $TaskName
@ -50,6 +129,8 @@ Write-Output ("State : " + $task.State)
Write-Output ("User : " + $principal.UserId)
Write-Output ("Trigger : AtLogOn (" + $UserName + ")")
Write-Output ("Command : " + $action.Execute + " " + $action.Argument)
Write-Output ("Source : root=" + $resolvedSourceRoot + " commit=" + $sourceCommit + " tree=" + $sourceTree)
Write-Output ("Hashes : boot=" + $bootScriptSha256 + " start=" + $startScriptSha256)
Write-Output ""
Write-Output ("Run now : powershell -NoProfile -ExecutionPolicy Bypass -File " + $BootScript)
Write-Output ("Run now : " + $action.Execute + " " + $action.Argument)
Write-Output ("Unregister : Unregister-ScheduledTask -TaskName " + $TaskName + " -Confirm:`$false")

View file

@ -8,7 +8,9 @@
스크립트가 하는 :
1. soak / runtime / topology 캡처를 **동시에** 시작하고 전부 끝날 때까지 기다린다.
2. 캡처가 같은 public host 보는지 시작 전에 확인한다(fail-closed).
2. WSS / admin / topology transport 같은 public API host 보는지 시작 전에
확인한다(fail-closed). 브라우저 ``Origin`` API host 섞지 않고 별도
allowlist 검증한다.
3. 끝나면 human voice-gain pack 더해 `check-g7-external-proof.py` 그대로 돌린다.
스크립트는 게이트를 **약화시키지 않는다**. 물리 마이크 캡처는 `--confirm-physical-capture`
@ -16,14 +18,20 @@
실제 참가자와 독립 평가자가 있어야 한다.
`--rehearse` 마이크·사람 없이 배관만 확인하는 모드다. 짧은 시간창으로 러너를 실제로 띄워
인자·경로·산출 파일까지 검증하되, soak `--preflight-only` 돌려 장치를 열지 않는다. 실제 50
실행 전에 이걸로 먼저 실패를 뽑아내라.
인자·경로·산출 파일까지 검증하되, soak `--preflight-only` 돌려 장치를 열지 않는다. 실제 실행은
52분을 캡처해 시작 skew 제외한 유효 교집합 50분을 증명한다. 전에 이걸로 먼저 실패를 뽑아내라.
공개 기본 계약은 브라우저 ``Origin=https://vignette.chanpaca.net`` transport
host ``api-vignette.chanpaca.net`` 의도적으로 다른 구성이다. 인증 WSS
``ready`` 기본적으로 ``local_whisper/small`` ``melotts/melotts-korean``
정확히 반환해야 한다.
"""
from __future__ import annotations
import argparse
import json
import math
import subprocess
import sys
from concurrent.futures import ThreadPoolExecutor
@ -40,7 +48,20 @@ RUNTIME_SCRIPT = SCRIPTS / "capture-g7-runtime-evidence.py"
TOPOLOGY_SCRIPT = SCRIPTS / "capture-g7-topology-evidence.py"
CHECKER_SCRIPT = SCRIPTS / "check-g7-external-proof.py"
MIN_PRODUCTION_SECONDS = 3_000.0
DEFAULT_WSS_URL = "wss://api-vignette.chanpaca.net/voice/ws"
DEFAULT_BROWSER_ORIGIN = "https://vignette.chanpaca.net"
DEFAULT_ADMIN_RUNTIME_URL = (
"https://api-vignette.chanpaca.net/admin/voice-runtime"
)
DEFAULT_ALLOWED_BROWSER_ORIGINS = (DEFAULT_BROWSER_ORIGIN,)
# canonical checker 가 요구하는 실제 artifact 교집합은 3,000초다. 세 캡처의
# 시작 skew 와 마지막 sampler tick 때문에 실제 캡처는 최소 120초 더 길게 잡는다.
MIN_REQUIRED_OVERLAP_SECONDS = 3_000.0
CAPTURE_START_SKEW_MARGIN_SECONDS = 120.0
MIN_PRODUCTION_SECONDS = (
MIN_REQUIRED_OVERLAP_SECONDS + CAPTURE_START_SKEW_MARGIN_SECONDS
)
RUNTIME_SAMPLE_MARGIN = 1
@ -73,21 +94,82 @@ def host_of(url: str) -> str:
def assert_single_host(*urls: str) -> str:
"""세 캡처가 같은 public host 를 보는지 확인한다."""
"""transport URL 들이 같은 public host 를 보는지 확인한다."""
hosts = {host_of(url) for url in urls if url}
hosts.discard("")
if len(hosts) != 1:
raise WindowError(f"hosts_must_match:{sorted(hosts)}")
# URL, query, tenant 이름을 오류에 반사하지 않는다.
raise WindowError("transport_hosts_must_match")
return hosts.pop()
def assert_transport_schemes(wss_url: str, admin_runtime_url: str) -> None:
"""공개 proof transport 를 암호화된 WSS/HTTPS 로만 고정한다."""
from urllib.parse import urlsplit
try:
wss = urlsplit(wss_url)
admin = urlsplit(admin_runtime_url)
except ValueError as exc:
raise WindowError("transport_url_invalid") from exc
if wss.scheme.lower() != "wss" or not wss.hostname:
raise WindowError("wss_url_scheme_invalid")
if admin.scheme.lower() != "https" or not admin.hostname:
raise WindowError("admin_runtime_url_scheme_invalid")
def normalize_browser_origin(value: str) -> str:
"""브라우저 Origin 직렬화만 허용하고 안전하게 정규화한다."""
from urllib.parse import urlsplit
try:
parsed = urlsplit(value)
port = parsed.port
except ValueError as exc:
raise WindowError("browser_origin_invalid") from exc
if (
parsed.scheme.lower() != "https"
or not parsed.hostname
or parsed.username is not None
or parsed.password is not None
or parsed.path not in ("", "/")
or parsed.query
or parsed.fragment
):
raise WindowError("browser_origin_invalid")
host = parsed.hostname.lower()
authority = host if port in (None, 443) else f"{host}:{port}"
return f"https://{authority}"
def assert_browser_origin_allowed(
origin: str, allowed_origins: Sequence[str] | None
) -> str:
"""실제 브라우저 Origin 을 transport host 와 독립된 allowlist 로 검증한다."""
# CLI 옵션은 기본 공개 frontend 를 대체하지 않고 추가한다.
configured = (*DEFAULT_ALLOWED_BROWSER_ORIGINS, *(allowed_origins or ()))
try:
normalized_allowed = {normalize_browser_origin(item) for item in configured}
except WindowError as exc:
raise WindowError("browser_origin_allowlist_invalid") from exc
normalized = normalize_browser_origin(origin)
if normalized not in normalized_allowed:
# 입력 Origin 을 반사하면 query/PII 가 로그에 남을 수 있으므로 코드만 낸다.
raise WindowError("browser_origin_not_allowed")
return normalized
def sample_plan(duration_seconds: float, interval_seconds: float) -> int:
"""시간창을 덮기에 충분한 샘플 수. 모자라면 겹침 검증이 깨진다."""
if duration_seconds <= 0 or interval_seconds <= 0:
raise WindowError("invalid_sampling_plan")
return int(duration_seconds // interval_seconds) + RUNTIME_SAMPLE_MARGIN
# t=0 샘플과 duration 끝을 덮는 종료 샘플을 모두 포함한다.
return math.ceil(duration_seconds / interval_seconds) + RUNTIME_SAMPLE_MARGIN
def build_soak_leg(args: argparse.Namespace, output: Path) -> Leg:
@ -153,18 +235,10 @@ def build_topology_leg(args: argparse.Namespace, output: Path) -> Leg:
"-X",
"utf8",
str(TOPOLOGY_SCRIPT),
"--compose-project",
args.compose_project,
"--topology-mode",
args.topology_mode,
"--public-host",
host_of(args.wss_url),
"--api-container",
args.api_container,
"--api-image-digest",
args.api_image_digest,
"--caddy-container",
args.caddy_container,
"--caddy-image-digest",
args.caddy_image_digest,
"--samples",
str(samples),
"--interval-seconds",
@ -172,6 +246,54 @@ def build_topology_leg(args: argparse.Namespace, output: Path) -> Leg:
"--evidence-output",
str(output),
]
if args.topology_mode == "linux-compose":
argv += [
"--compose-project",
args.compose_project,
"--api-container",
args.api_container,
"--api-image-digest",
args.api_image_digest,
"--caddy-container",
args.caddy_container,
"--caddy-image-digest",
args.caddy_image_digest,
]
else:
argv += [
"--repo-root",
args.repo_root,
"--git-sha",
args.git_sha,
"--git-tree-sha",
args.git_tree_sha,
"--runner-script-sha256",
args.runner_script_sha256,
"--collector-script-sha256",
args.collector_script_sha256,
"--checker-script-sha256",
args.checker_script_sha256,
"--psutil-version",
args.psutil_version,
"--api-pid",
str(args.api_pid),
"--api-executable-name",
args.api_executable_name,
"--api-executable-sha256",
args.api_executable_sha256,
"--api-cwd",
args.api_cwd,
"--api-listen-port",
str(args.api_listen_port),
"--cloudflared-pid",
str(args.cloudflared_pid),
"--cloudflared-executable-name",
args.cloudflared_executable_name,
"--cloudflared-executable-sha256",
args.cloudflared_executable_sha256,
"--cloudflared-cwd",
args.cloudflared_cwd,
]
return Leg("topology", argv, output)
@ -237,7 +359,9 @@ def plan_legs(
*,
admin_probe: Callable[[str], Sequence[str]] | None = None,
) -> list[Leg]:
assert_single_host(args.wss_url, args.origin, args.admin_runtime_url)
assert_transport_schemes(args.wss_url, args.admin_runtime_url)
assert_single_host(args.wss_url, args.admin_runtime_url)
assert_browser_origin_allowed(args.origin, args.allowed_browser_origins)
assert_admin_endpoint_deployed(args.admin_runtime_url, paths_probe=admin_probe)
return [
build_soak_leg(args, out_dir / "voice-soak.json"),
@ -306,6 +430,10 @@ def summarize(
rehearse: bool,
checker_returncode: int | None,
) -> dict[str, Any]:
all_legs_passed = all(r.ok for r in results)
gate_closed = (
all_legs_passed and checker_returncode == 0 and not rehearse
)
return {
"schema_version": "vignette.g7-external-proof-window.v1",
"mode": "rehearse" if rehearse else "production",
@ -313,9 +441,9 @@ def summarize(
{"name": r.name, "returncode": r.returncode, "evidence": str(r.output)}
for r in results
],
"all_legs_passed": all(r.ok for r in results),
"all_legs_passed": all_legs_passed,
"checker_returncode": checker_returncode,
"gate_closed": checker_returncode == 0 and not rehearse,
"gate_closed": gate_closed,
"note": (
"rehearse 는 배관만 확인한다. 게이트는 실제 물리 마이크 50분 실행과 "
"독립 human voice-gain pack 이 있어야 닫힌다."
@ -325,23 +453,86 @@ def summarize(
}
def process_exit_code(report: dict[str, Any]) -> int:
"""프로세스 성공을 실제 gate closure 에 fail-closed 결속한다."""
if report.get("mode") == "production":
return 0 if (
report.get("checker_returncode") == 0
and report.get("gate_closed") is True
) else 1
return 0 if report.get("all_legs_passed") is True else 1
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--wss-url", required=True)
parser.add_argument("--origin", required=True)
parser.add_argument("--admin-runtime-url", required=True)
parser.add_argument("--compose-project", required=True)
parser.add_argument("--api-container", required=True)
parser.add_argument("--api-image-digest", required=True)
parser.add_argument("--caddy-container", required=True)
parser.add_argument("--caddy-image-digest", required=True)
parser = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=(
"공개 기본값:\n"
" browser Origin https://vignette.chanpaca.net\n"
" WSS transport wss://api-vignette.chanpaca.net/voice/ws\n"
" admin transport https://api-vignette.chanpaca.net/admin/voice-runtime\n"
"인증 WSS ready 기대값:\n"
" stt_provider=local_whisper stt_model=small\n"
" tts_provider=melotts tts_model=melotts-korean\n"
"실제 캡처 기본값: 3120초(유효 교집합 최소 3000초 + skew margin 120초)\n"
"추가 frontend Origin 은 --allowed-browser-origin 을 반복 지정한다."
),
)
parser.add_argument("--wss-url", default=DEFAULT_WSS_URL)
parser.add_argument(
"--origin",
default=DEFAULT_BROWSER_ORIGIN,
help="WebSocket Origin 헤더에 넣을 실제 브라우저 HTTPS origin",
)
parser.add_argument(
"--allowed-browser-origin",
dest="allowed_browser_origins",
action="append",
help=(
"허용할 브라우저 HTTPS origin. 반복 가능하며 생략 시 공개 frontend만 허용"
),
)
parser.add_argument("--admin-runtime-url", default=DEFAULT_ADMIN_RUNTIME_URL)
parser.add_argument(
"--topology-mode",
choices=("linux-compose", "windows-host"),
default="linux-compose",
)
parser.add_argument("--compose-project")
parser.add_argument("--api-container")
parser.add_argument("--api-image-digest")
parser.add_argument("--caddy-container")
parser.add_argument("--caddy-image-digest")
parser.add_argument("--repo-root")
parser.add_argument("--git-sha")
parser.add_argument("--git-tree-sha")
parser.add_argument("--runner-script-sha256")
parser.add_argument("--collector-script-sha256")
parser.add_argument("--checker-script-sha256")
parser.add_argument("--psutil-version")
parser.add_argument("--api-pid", type=int)
parser.add_argument("--api-executable-name")
parser.add_argument("--api-executable-sha256")
parser.add_argument("--api-cwd")
parser.add_argument("--api-listen-port", type=int)
parser.add_argument("--cloudflared-pid", type=int)
parser.add_argument("--cloudflared-executable-name")
parser.add_argument("--cloudflared-executable-sha256")
parser.add_argument("--cloudflared-cwd")
parser.add_argument("--expected-stt-provider", default="local_whisper")
parser.add_argument("--expected-stt-model", default="large-v3")
parser.add_argument("--expected-stt-model", default="small")
parser.add_argument("--expected-tts-provider", default="melotts")
parser.add_argument("--expected-tts-model", default="melotts-korean")
parser.add_argument("--microphone-device", default="")
parser.add_argument("--confirm-physical-capture", action="store_true")
parser.add_argument("--duration-seconds", type=float, default=MIN_PRODUCTION_SECONDS)
parser.add_argument(
"--duration-seconds",
type=float,
default=MIN_PRODUCTION_SECONDS,
help="실제 캡처는 최소 3120초; 유효 artifact 교집합 기준은 3000초",
)
parser.add_argument("--runtime-interval-seconds", type=float, default=100.0)
parser.add_argument("--topology-interval-seconds", type=float, default=100.0)
parser.add_argument("--human-voice-gain", type=Path)
@ -359,6 +550,39 @@ def validate(args: argparse.Namespace) -> None:
raise WindowError("production_window_too_short")
if not args.rehearse and args.human_voice_gain is None:
raise WindowError("human_voice_gain_pack_required")
if args.topology_mode not in ("linux-compose", "windows-host"):
raise WindowError("topology_mode_invalid")
if args.topology_mode == "linux-compose":
required = (
"compose_project",
"api_container",
"api_image_digest",
"caddy_container",
"caddy_image_digest",
)
else:
required = (
"repo_root",
"git_sha",
"git_tree_sha",
"runner_script_sha256",
"collector_script_sha256",
"checker_script_sha256",
"psutil_version",
"api_pid",
"api_executable_name",
"api_executable_sha256",
"api_cwd",
"api_listen_port",
"cloudflared_pid",
"cloudflared_executable_name",
"cloudflared_executable_sha256",
"cloudflared_cwd",
)
for field in required:
value = getattr(args, field)
if value is None or (isinstance(value, str) and not value.strip()):
raise WindowError(f"topology_argument_required:{field.replace('_', '-')}")
def main(argv: Iterable[str] | None = None) -> int:
@ -390,7 +614,7 @@ def main(argv: Iterable[str] | None = None) -> int:
results, rehearse=args.rehearse, checker_returncode=checker_returncode
)
print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True))
return 0 if report["all_legs_passed"] else 1
return process_exit_code(report)
if __name__ == "__main__": # pragma: no cover - CLI

View file

@ -2,7 +2,8 @@
"""Fail-closed G8 release agent for the isolated Vignette NAS preview.
The default is a read-only dry run. ``--execute`` is intentionally narrow:
it accepts only the dedicated Tailnet preview, builds a clean HEAD+patch
it accepts only the dedicated Tailnet preview, builds either the default
verified HEAD+patch candidate or an explicitly requested clean-HEAD archive
candidate, runs release/API/Web/session gates, snapshots the current API and
Web images, promotes only a changed deterministic SHA, and rolls back the
previous images when any post-promotion proof fails.
@ -54,6 +55,7 @@ SSOT_REQUIRED_PATHS = (
# operator surface has exercised its browser contract. Keep this list explicit
# so a newly added goal cannot disappear behind a broad directory glob.
RELEASE_UI_E2E_SPECS = (
"e2e/insecure-context-uuid.spec.ts",
"e2e/session-layout.spec.ts",
"e2e/session-persistence.spec.ts",
"e2e/self-directed-learning-loop.spec.ts",
@ -66,6 +68,36 @@ RELEASE_UI_E2E_SPECS = (
"e2e/multimodal-alliance.spec.ts",
"e2e/continuous-improvement-admin.spec.ts",
)
# The UUID source-contract spec intentionally imports ``/src/lib/uuid.ts`` and
# scans the local source tree, so it belongs to the Vite candidate gate above.
# A production NAS build serves the SPA HTML for ``/src/*``. The postdeploy
# gate therefore exercises the exact built review routes that previously
# crashed on insecure HTTP, while retaining every production-origin-compatible
# release spec. The real returned-practice DB closed loop remains an explicit
# disposable-DB-only gate and must never be silently skipped against NAS.
POSTDEPLOY_NAS_E2E_SPECS = (
"e2e/session-layout.spec.ts",
"e2e/session-persistence.spec.ts",
"e2e/self-directed-learning-loop.spec.ts",
"e2e/alliance-pulse.spec.ts",
"e2e/outcome-trajectory.spec.ts",
"e2e/rupture-repair.spec.ts",
"e2e/deliberate-practice.spec.ts",
"e2e/calibration-transfer.spec.ts",
"e2e/supervision-research.spec.ts",
"e2e/multimodal-alliance.spec.ts",
"e2e/continuous-improvement-admin.spec.ts",
)
POSTDEPLOY_SOURCE_ONLY_E2E_SPECS = ("e2e/insecure-context-uuid.spec.ts",)
POSTDEPLOY_DISPOSABLE_DB_E2E_SPECS = (
"e2e/returned-practice-db-closed-loop.spec.ts",
)
SOURCE_ONLY_E2E_PORT = 5198
RELEASE_BROWSER_PROJECTS = (
"chromium-desktop",
"chromium-mobile",
"chromium-single-run",
)
RELEASE_E2E_TIMEOUT_SECONDS = 15 * 60
# Existing preview volumes do not replay docker-entrypoint-initdb.d. These
@ -100,8 +132,10 @@ REQUIRED_OPENAPI_PATHS = {
IMAGE_ID_RE = re.compile(r"^sha256:[a-f0-9]{64}$")
SHA256_RE = re.compile(r"^[a-f0-9]{64}$")
GIT_OBJECT_RE = re.compile(r"^(?:[a-f0-9]{40}|[a-f0-9]{64})$")
SSH_TARGET_RE = re.compile(r"^(?:[A-Za-z0-9._-]+@)?100\.116\.83\.60$")
RELEASE_KEY_RE = re.compile(r"^[a-f0-9]{16}-\d{8}T\d{6}Z-\d+$")
SOURCE_MODES = ("patch", "clean-head")
@dataclass(frozen=True)
@ -147,6 +181,7 @@ class ReleaseAgentConfig:
repo_root: Path = REPO_ROOT
manifest_path: Path = DEFAULT_MANIFEST
hunk_map_path: Path = DEFAULT_HUNK_MAP
source_mode: str = "patch"
execute: bool = False
milestone: MaterialMilestone | None = None
target: DeploymentTarget = NAS_PREVIEW_TARGET
@ -215,6 +250,8 @@ class RuntimeProbe(Protocol):
class CandidateManager(Protocol):
def materialize(self, base_commit: str, patch_path: str) -> Path: ...
def materialize_archive(self, archive_path: Path, expected_sha256: str) -> Path: ...
def cleanup(self) -> None: ...
@ -226,6 +263,16 @@ def sha256_bytes(value: bytes) -> str:
return hashlib.sha256(value).hexdigest()
def sha256_file(path: Path) -> tuple[str, int]:
digest = hashlib.sha256()
size = 0
with path.open("rb") as source:
while chunk := source.read(1024 * 1024):
digest.update(chunk)
size += len(chunk)
return digest.hexdigest(), size
def validate_target(target: DeploymentTarget) -> None:
if target != NAS_PREVIEW_TARGET:
raise ValueError(
@ -237,6 +284,15 @@ def validate_target(target: DeploymentTarget) -> None:
raise ValueError("isolated NAS preview root must be an absolute canonical path")
def validate_source_repo_root(repo_root: Path) -> None:
if not repo_root.is_absolute():
raise StageFailure("source_repository", "source repository root must be absolute")
if not repo_root.exists():
raise StageFailure("source_repository", "source repository root does not exist")
if not repo_root.is_dir():
raise StageFailure("source_repository", "source repository root is not a directory")
def validate_required_release_payload(manifest: dict[str, Any]) -> None:
classifications = manifest.get("classifications")
related = classifications.get("related") if isinstance(classifications, dict) else None
@ -437,6 +493,45 @@ class CleanCandidateManager:
_normalize_candidate_file_to_lf(shell_script, "shell script")
return root
def materialize_archive(self, archive_path: Path, expected_sha256: str) -> Path:
"""Extract an exact clean-HEAD archive without applying a patch."""
if not SHA256_RE.fullmatch(expected_sha256):
raise StageFailure("candidate_archive_binding", "expected archive SHA is invalid")
try:
actual_sha256, _ = sha256_file(archive_path)
except OSError as exc:
raise StageFailure("candidate_archive_binding", str(exc)) from exc
if actual_sha256 != expected_sha256:
raise StageFailure(
"candidate_archive_binding",
f"archive SHA drifted: expected={expected_sha256} actual={actual_sha256}",
)
self._temp = tempfile.TemporaryDirectory(prefix="vignette-release-agent-")
root = Path(self._temp.name) / "candidate"
try:
root.mkdir(parents=True)
with tarfile.open(archive_path, "r:") as archive:
_safe_extract(archive, root)
generated_api = root / "apps/web/src/lib/api.gen.ts"
if not generated_api.is_file():
raise StageFailure("candidate_archive", "generated API contract is missing")
# git archive stores canonical LF blobs, while a Windows checkout can
# materialize this generated file with CRLF. openapi-typescript
# --check compares bytes, so apply the same platform normalization as
# patch-mode after the archive SHA has already been verified.
_normalize_candidate_file_to_lf(generated_api, "generated API contract")
for shell_script in sorted(root.rglob("*.sh")):
_normalize_candidate_file_to_lf(shell_script, "shell script")
except StageFailure:
self.cleanup()
raise
except (OSError, tarfile.TarError) as exc:
self.cleanup()
raise StageFailure("candidate_archive", str(exc)) from exc
return root
def cleanup(self) -> None:
if self._temp is not None:
self._temp.cleanup()
@ -970,6 +1065,8 @@ class ReleaseAgent:
self.runtime_probe = runtime_probe
self.candidate_manager = candidate_manager
self.stage_evidence: list[dict[str, Any]] = []
self.source_evidence: dict[str, Any] | None = None
self._source_temp: tempfile.TemporaryDirectory[str] | None = None
def _run(
self,
@ -1089,6 +1186,152 @@ class ReleaseAgent:
raise StageFailure("release_determinism", "builder patch SHA is invalid")
return reports[1]
def _clean_head_identity(self, suffix: str) -> tuple[str, str]:
status = self._run(
f"clean_head_worktree_{suffix}",
["git", "status", "--porcelain=v1", "--untracked-files=no"],
cwd=self.config.repo_root,
timeout=120,
)
if status.stdout.strip():
raise StageFailure(
"clean_head_worktree",
"tracked worktree/index changes are forbidden in clean-head mode",
)
head_result = self._run(
f"clean_head_head_{suffix}",
["git", "rev-parse", "--verify", "HEAD"],
cwd=self.config.repo_root,
timeout=120,
)
tree_result = self._run(
f"clean_head_tree_{suffix}",
["git", "rev-parse", "--verify", "HEAD^{tree}"],
cwd=self.config.repo_root,
timeout=120,
)
head = head_result.stdout.strip()
tree = tree_result.stdout.strip()
if not GIT_OBJECT_RE.fullmatch(head):
raise StageFailure("clean_head_identity", "Git HEAD is invalid")
if not GIT_OBJECT_RE.fullmatch(tree):
raise StageFailure("clean_head_identity", "Git tree is invalid")
return head, tree
def _validate_clean_head_worktree_root(self) -> None:
result = self._run(
"clean_head_repository",
["git", "rev-parse", "--show-toplevel"],
cwd=self.config.repo_root,
timeout=120,
)
reported_root = result.stdout.strip()
if not reported_root:
raise StageFailure("clean_head_repository", "Git worktree root is missing")
try:
actual_root = Path(reported_root).resolve(strict=True)
expected_root = self.config.repo_root.resolve(strict=True)
except OSError as exc:
raise StageFailure("clean_head_repository", str(exc)) from exc
if actual_root != expected_root:
raise StageFailure(
"clean_head_repository",
f"source repository is not the Git worktree root: {actual_root}",
)
def _build_clean_head_source_twice(self) -> dict[str, Any]:
self._validate_clean_head_worktree_root()
before_head, before_tree = self._clean_head_identity("before")
self._source_temp = tempfile.TemporaryDirectory(prefix="vignette-release-source-")
source_root = Path(self._source_temp.name)
archives: list[Path] = []
archive_reports: list[dict[str, Any]] = []
for run_number in (1, 2):
archive_path = source_root / f"clean-head-{run_number}.tar"
self._run(
f"clean_head_archive_run_{run_number}",
[
"git",
"archive",
"--format=tar",
"--output",
str(archive_path),
before_head,
],
cwd=self.config.repo_root,
timeout=900,
)
try:
archive_sha256, archive_size = sha256_file(archive_path)
except OSError as exc:
raise StageFailure("clean_head_archive", str(exc)) from exc
archives.append(archive_path)
archive_reports.append(
{
"sha256": archive_sha256,
"bytes": archive_size,
}
)
after_head, after_tree = self._clean_head_identity("after")
if (before_head, before_tree) != (after_head, after_tree):
raise StageFailure(
"clean_head_identity",
"Git HEAD/tree changed while clean-head archives were built",
)
if archive_reports[0] != archive_reports[1]:
raise StageFailure(
"clean_head_determinism",
f"two clean-head archives differ: {archive_reports}",
)
archive_sha256 = archive_reports[1]["sha256"]
if not SHA256_RE.fullmatch(archive_sha256):
raise StageFailure("clean_head_determinism", "archive SHA is invalid")
return {
"source_mode": "clean-head",
"head": after_head,
"tree": after_tree,
"archive_sha256": archive_sha256,
"archive_bytes": archive_reports[1]["bytes"],
"archive_path": archives[1],
"deterministic_runs": 2,
}
def _prepare_source(self) -> tuple[dict[str, Any], str]:
if self.config.source_mode == "patch":
release = self._build_release_twice()
self._check_manifest_binding(release)
self.source_evidence = {
"repo_root": str(self.config.repo_root),
"base_commit": release["base_commit"],
"patch": {
"sha256": release["patch_sha256"],
"bytes": release["patch_bytes"],
"files": release["patch_files"],
"output": release["output_patch"],
"deterministic_runs": 2,
},
}
return release, release["patch_sha256"]
if self.config.source_mode == "clean-head":
release = self._build_clean_head_source_twice()
self.source_evidence = {
"repo_root": str(self.config.repo_root),
"head": release["head"],
"tree": release["tree"],
"archive": {
"sha256": release["archive_sha256"],
"bytes": release["archive_bytes"],
"deterministic_runs": release["deterministic_runs"],
},
}
return release, release["archive_sha256"]
raise StageFailure(
"configuration",
f"unsupported source mode: {self.config.source_mode!r}",
)
def _check_manifest_binding(self, release: dict[str, Any]) -> None:
result = self._run(
"release_manifest",
@ -1169,6 +1412,33 @@ class ReleaseAgent:
self._run("web_typecheck", ["npm.cmd", "run", "typecheck"], cwd=web, timeout=300)
self._run("web_build", ["npm.cmd", "run", "build"], cwd=web, timeout=600)
# This spec imports /src/lib/uuid.ts and therefore must run against a
# source Vite server, not the production Compose build (which correctly
# serves the SPA document for /src/*). CI=1 prevents reuse of an
# unrelated long-lived Vite process on the workstation.
source_e2e_env = os.environ.copy()
source_e2e_env.pop("PLAYWRIGHT_BASE_URL", None)
source_e2e_env.pop("PLAYWRIGHT_SKIP_WEB_SERVER", None)
source_e2e_env["PLAYWRIGHT_HOST"] = "127.0.0.1"
source_e2e_env["PLAYWRIGHT_PORT"] = str(SOURCE_ONLY_E2E_PORT)
source_e2e_env["CI"] = "1"
self._run(
"source_insecure_context_e2e",
[
"node.exe",
str(web / "node_modules/@playwright/test/cli.js"),
"test",
*POSTDEPLOY_SOURCE_ONLY_E2E_SPECS,
"--project=chromium-desktop",
"--project=chromium-mobile",
"--workers=1",
"--reporter=line",
],
cwd=web,
env=source_e2e_env,
timeout=300,
)
stack_attempted = False
stack_env: Path | None = None
project = candidate_compose_project(desired_sha)
@ -1207,7 +1477,7 @@ class ReleaseAgent:
"node.exe",
str(web / "node_modules/@playwright/test/cli.js"),
"test",
*RELEASE_UI_E2E_SPECS,
*POSTDEPLOY_NAS_E2E_SPECS,
"--project=chromium-desktop",
"--project=chromium-mobile",
"--project=chromium-single-run",
@ -1288,20 +1558,29 @@ class ReleaseAgent:
"node.exe",
str(web / "node_modules/@playwright/test/cli.js"),
"test",
"e2e/session-persistence.spec.ts",
"--project=chromium-single-run",
*POSTDEPLOY_NAS_E2E_SPECS,
*(f"--project={project}" for project in RELEASE_BROWSER_PROJECTS),
"--workers=1",
"--grep",
"persists the browser SSE stream into DB-backed review",
"--reporter=line",
],
cwd=web,
env=env,
timeout=300,
timeout=RELEASE_E2E_TIMEOUT_SECONDS,
)
return {
"status": "passed",
"test": "session-persistence: browser SSE stream -> DB-backed review",
"base_url": self.config.target.base_url,
"specs": list(POSTDEPLOY_NAS_E2E_SPECS),
"projects": list(RELEASE_BROWSER_PROJECTS),
"source_only_candidate_specs": list(POSTDEPLOY_SOURCE_ONLY_E2E_SPECS),
"separate_disposable_db_specs": list(POSTDEPLOY_DISPOSABLE_DB_E2E_SPECS),
"uuid_runtime_route_specs": [
"e2e/session-persistence.spec.ts",
"e2e/self-directed-learning-loop.spec.ts",
"e2e/rupture-repair.spec.ts",
"e2e/deliberate-practice.spec.ts",
"e2e/calibration-transfer.spec.ts",
],
"physical_microphone_used": False,
"stdout_sha256": sha256_bytes(result.stdout.encode("utf-8")),
}
@ -1312,6 +1591,8 @@ class ReleaseAgent:
"captured_at": utc_now(),
"ok": False,
"mode": "execute" if self.config.execute else "dry-run",
"source_mode": self.config.source_mode,
"source": self.source_evidence,
"target": asdict(self.config.target),
"milestone": asdict(self.config.milestone) if self.config.milestone else None,
"desired_sha": desired_sha,
@ -1342,10 +1623,9 @@ class ReleaseAgent:
desired_sha: str | None = None
try:
validate_target(self.config.target)
validate_source_repo_root(self.config.repo_root)
validate_milestone(self.config.milestone)
release = self._build_release_twice()
desired_sha = release["patch_sha256"]
self._check_manifest_binding(release)
release, desired_sha = self._prepare_source()
active_state = self.deployment.read_active_state()
active_sha = active_state.get("active_sha") if active_state else None
if active_sha is not None and (
@ -1391,9 +1671,14 @@ class ReleaseAgent:
"remote release-agent state is untracked; adopt it only after an image/Compose rollback snapshot audit",
)
candidate = self.candidate_manager.materialize(
release["base_commit"], release["output_patch"]
)
if self.config.source_mode == "clean-head":
candidate = self.candidate_manager.materialize_archive(
release["archive_path"], release["archive_sha256"]
)
else:
candidate = self.candidate_manager.materialize(
release["base_commit"], release["output_patch"]
)
self._run_candidate_gates(candidate, desired_sha)
snapshot = self.deployment.snapshot()
self._validate_snapshot_binding(active_state, snapshot)
@ -1472,6 +1757,9 @@ class ReleaseAgent:
finally:
if candidate is not None:
self.candidate_manager.cleanup()
if self._source_temp is not None:
self._source_temp.cleanup()
self._source_temp = None
def load_milestone(path: Path) -> MaterialMilestone:
@ -1502,6 +1790,18 @@ def build_parser() -> argparse.ArgumentParser:
parser.add_argument("--milestone", type=Path, required=True)
parser.add_argument("--manifest", type=Path, default=DEFAULT_MANIFEST)
parser.add_argument("--hunk-map", type=Path, default=DEFAULT_HUNK_MAP)
parser.add_argument(
"--source-mode",
choices=SOURCE_MODES,
default="patch",
help="Release source: verified patch (default) or exact clean Git HEAD archive",
)
parser.add_argument(
"--source-repo-root",
type=Path,
default=REPO_ROOT,
help="Absolute Git worktree root used as the release source",
)
parser.add_argument("--nas-env-file", type=Path, default=DEFAULT_NAS_ENV)
parser.add_argument("--evidence-out", type=Path)
parser.add_argument("--ssh-target", help="Required for live state inspection/execution")
@ -1560,9 +1860,10 @@ def main(argv: list[str] | None = None) -> int:
else:
deployment = StaticDeploymentState(args.current_deployment_sha)
config = ReleaseAgentConfig(
repo_root=REPO_ROOT,
repo_root=args.source_repo_root,
manifest_path=args.manifest,
hunk_map_path=args.hunk_map,
source_mode=args.source_mode,
execute=args.execute,
milestone=milestone,
target=NAS_PREVIEW_TARGET,
@ -1575,7 +1876,7 @@ def main(argv: list[str] | None = None) -> int:
runner=runner,
deployment=deployment,
runtime_probe=HttpRuntimeProbe(NAS_PREVIEW_TARGET.base_url),
candidate_manager=CleanCandidateManager(REPO_ROOT, runner),
candidate_manager=CleanCandidateManager(args.source_repo_root, runner),
)
report = agent.run()
if args.json:

View file

@ -1,9 +1,9 @@
param(
[int]$Port = 9882,
[ValidateSet('large-v3', 'large-v3-turbo', 'medium', 'small', 'base')]
[string]$Model = 'large-v3',
[string]$Model = 'small',
[ValidateSet('auto', 'cuda', 'cpu')]
[string]$Device = 'auto',
[string]$Device = 'cpu',
[int]$WaitReadySeconds = 180
)

View file

@ -3,6 +3,9 @@
[int]$ApiPort = 8001,
[int]$WebPort = 5174,
[int]$EnginePort = 9099,
[int]$WhisperPort = 9882,
[int]$MeloTtsPort = 9883,
[int]$VoiceSidecarReadySeconds = 300,
[string]$Python = "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe",
[string]$Cloudflared = "$env:LOCALAPPDATA\Microsoft\WinGet\Links\cloudflared.exe",
[string]$CloudflaredConfig = "$env:USERPROFILE\.cloudflared\vignette-config.yml",
@ -11,7 +14,16 @@
[switch]$SkipWebRestart,
[switch]$RouteCloudflareDns,
[string]$CloudflareTunnelName = "vignette",
[switch]$SkipCloudflaredRestart
[switch]$SkipCloudflaredRestart,
[switch]$RequireFreshPublicProvenance,
[string]$ExpectedSourceCommit = "",
[string]$ExpectedSourceTree = "",
[string]$ExpectedPythonSha256 = "",
[string]$ExpectedCloudflaredSha256 = "",
[string]$ExpectedCloudflaredConfigSha256 = "",
[string]$RuntimeProvenancePath = "",
[ValidateRange(1, 60)]
[int]$ProcessStopTimeoutSeconds = 15
)
$ErrorActionPreference = "Stop"
@ -31,6 +43,16 @@ $EngineOutLog = Join-Path $ApiDir "engine.public.out.log"
$EngineErrLog = Join-Path $ApiDir "engine.public.err.log"
$WebOutLog = Join-Path $Workspace "web.public.out.log"
$WebErrLog = Join-Path $Workspace "web.public.err.log"
$WhisperStartScript = Join-Path $Workspace "scripts\start-local-whisper-stt.ps1"
$MeloTtsStartScript = Join-Path $Workspace "scripts\start-melotts.ps1"
$VoiceSidecarProbe = Join-Path $Workspace "scripts\probe-public-voice-sidecars.py"
$WhisperModel = "small"
$WhisperLanguage = "ko"
$WhisperDevice = "cpu"
$MeloTtsModel = "melotts-korean"
$MeloTtsLanguage = "KR"
$PublicApiHostnames = @("api-vignette.chanpaca.net", "api-vnet.18ka.net")
$PublicWebHostnames = @("vnet.18ka.net")
function Get-JsonHealth {
param(
@ -64,6 +86,76 @@ function Wait-JsonHealth {
throw "Timed out waiting for healthy response from $Uri"
}
function Test-PortListener {
param([int]$Port)
$listener = Get-NetTCPConnection `
-State Listen `
-LocalPort $Port `
-ErrorAction SilentlyContinue `
| Select-Object -First 1
return $null -ne $listener
}
function Test-VoiceSidecarReady {
param(
[ValidateSet("stt", "tts")]
[string]$Component
)
$probeArgs = @(
"-X", "utf8", $VoiceSidecarProbe,
"--component", $Component,
"--stt-url", "ws://127.0.0.1:$WhisperPort/v1/listen",
"--stt-provider", "local_whisper",
"--stt-model", $WhisperModel,
"--stt-language", $WhisperLanguage,
"--stt-device", $WhisperDevice,
"--tts-url", "http://127.0.0.1:$MeloTtsPort",
"--tts-provider", "melotts",
"--tts-model", $MeloTtsModel,
"--tts-language", $MeloTtsLanguage,
"--timeout-seconds", "5"
)
& $Python @probeArgs 1>$null 2>$null
return $LASTEXITCODE -eq 0
}
function Wait-VoiceSidecarReady {
param(
[ValidateSet("stt", "tts")]
[string]$Component,
[int]$TimeoutSec
)
$deadline = (Get-Date).AddSeconds($TimeoutSec)
do {
if (Test-VoiceSidecarReady -Component $Component) {
return $true
}
Start-Sleep -Seconds 2
} while ((Get-Date) -lt $deadline)
return $false
}
function Test-VoiceApiReady {
param([object]$Health)
return (
$null -ne $Health -and
$Health.status -eq "ok" -and
$Health.available -eq $true -and
$Health.stt_available -eq $true -and
$Health.tts_available -eq $true -and
$Health.stt_provider -eq "local_whisper" -and
$Health.stt_model -eq $WhisperModel -and
$Health.tts_provider -eq "melotts" -and
$Health.tts_model -eq $MeloTtsModel -and
$Health.limits.uvicorn_ws_max_queue -eq 4
)
}
function Test-EngineReady {
param(
[int]$Port,
@ -124,22 +216,155 @@ function Wait-HttpStatus {
throw "Timed out waiting for HTTP response from $Uri"
}
function Stop-ProcessesBounded {
param(
[object[]]$Processes,
[int]$TimeoutSec,
[string]$Role
)
$processIds = @(
$Processes |
ForEach-Object { [int]$_.ProcessId } |
Sort-Object -Unique
)
foreach ($processId in $processIds) {
Stop-Process -Id $processId -Force -ErrorAction SilentlyContinue
}
$deadline = (Get-Date).AddSeconds($TimeoutSec)
do {
$remaining = @(
$processIds |
Where-Object { $null -ne (Get-Process -Id $_ -ErrorAction SilentlyContinue) }
)
if ($remaining.Count -eq 0) {
return $processIds
}
Start-Sleep -Milliseconds 200
} while ((Get-Date) -lt $deadline)
throw "Timed out stopping $Role process IDs: $($remaining -join ',')"
}
function Stop-UvicornByPort {
param(
[string]$AppImport,
[int]$Port
[int]$Port,
[int]$TimeoutSec = 15
)
# Name 조건이 없으면 같은 문자열을 인자로 들고 있는 셸/래퍼 프로세스까지 매칭해
# 호출자 자신을 죽일 수 있다. 대상은 항상 python 프로세스다.
Get-CimInstance Win32_Process |
$processes = @(
Get-CimInstance Win32_Process |
Where-Object {
$_.Name -like "python*" -and
$_.CommandLine -and
$_.CommandLine -like "*uvicorn $AppImport*" -and
$_.CommandLine -like "*--port $Port*"
} |
ForEach-Object { Stop-Process -Id $_.ProcessId -Force }
}
)
return @(
Stop-ProcessesBounded `
-Processes $processes `
-TimeoutSec $TimeoutSec `
-Role "uvicorn $AppImport on port $Port"
)
}
function Get-CloudflaredProcessesForConfig {
param(
[string]$ConfigPath,
[switch]$ExactPath
)
$configLeaf = Split-Path -Leaf $ConfigPath
return @(
Get-CimInstance Win32_Process |
Where-Object {
$_.Name -eq "cloudflared.exe" -and
$_.CommandLine -and
$_.CommandLine -like "*--config*" -and
(
$_.CommandLine.IndexOf($ConfigPath, [System.StringComparison]::OrdinalIgnoreCase) -ge 0 -or
(-not $ExactPath -and $_.CommandLine -like "*$configLeaf*")
)
}
)
}
function Wait-ProcessIdentity {
param(
[int]$ProcessId,
[string]$Role,
[string]$ExpectedCwd,
[int]$TimeoutSec = 15
)
$deadline = (Get-Date).AddSeconds($TimeoutSec)
do {
$process = Get-CimInstance Win32_Process `
-Filter "ProcessId = $ProcessId" `
-ErrorAction SilentlyContinue
if (
$null -ne $process -and
$process.ExecutablePath -and
$process.CommandLine
) {
$identityProbeArgs = @(
"-X", "utf8", "-c",
"import hashlib,psutil,sys; from datetime import UTC,datetime; p=psutil.Process(int(sys.argv[1])); print(p.cwd()); print(datetime.fromtimestamp(p.create_time(), UTC).isoformat().replace('+00:00', 'Z')); print(hashlib.sha256(chr(0).join(p.cmdline()).encode('utf-8', errors='strict')).hexdigest())",
"$ProcessId"
)
$identityProbe = @(& $Python @identityProbeArgs)
if ($LASTEXITCODE -ne 0 -or $identityProbe.Count -ne 3) {
throw "Could not prove $Role psutil identity for PID $ProcessId"
}
$actualCwd = $identityProbe[0].Trim()
$startedAtUtc = $identityProbe[1].Trim()
$commandLineSha256 = $identityProbe[2].Trim().ToLowerInvariant()
if (-not $actualCwd -or $startedAtUtc -notmatch "Z$" -or $commandLineSha256 -notmatch "^[0-9a-f]{64}$") {
throw "$Role psutil identity is incomplete for PID $ProcessId"
}
if (-not [string]::Equals(
[System.IO.Path]::GetFullPath($actualCwd),
[System.IO.Path]::GetFullPath($ExpectedCwd),
[System.StringComparison]::OrdinalIgnoreCase
)) {
throw "$Role working directory drift: expected=$ExpectedCwd actual=$actualCwd"
}
return [ordered]@{
role = $Role
pid = [int]$process.ProcessId
started_at_utc = $startedAtUtc
executable_path = $process.ExecutablePath
executable_name = Split-Path -Leaf $process.ExecutablePath
executable_sha256 = (Get-FileHash -LiteralPath $process.ExecutablePath -Algorithm SHA256).Hash.ToLowerInvariant()
command_line = $process.CommandLine
command_line_sha256 = $commandLineSha256
cwd = $actualCwd
}
}
Start-Sleep -Milliseconds 200
} while ((Get-Date) -lt $deadline)
throw "Timed out reading $Role process identity for PID $ProcessId"
}
function ConvertTo-SafeProcessIdentity {
param([System.Collections.IDictionary]$Identity)
# Raw command line이나 executable full path는 config/token을 우발적으로
# 영구 보존할 수 있다. topology 결속에 필요한 비밀 비포함 투영만 기록한다.
return [ordered]@{
pid = [int]$Identity.pid
started_at_utc = $Identity.started_at_utc
executable_name = $Identity.executable_name
executable_sha256 = $Identity.executable_sha256
command_line_sha256 = $Identity.command_line_sha256
cwd = $Identity.cwd
}
}
function Stop-NodeByPortHint {
@ -160,13 +385,232 @@ function ConvertTo-CompactJson {
ConvertTo-Json -InputObject $Value -Compress
}
function Invoke-StableGitText {
param(
[string]$SourceRoot,
[string[]]$Arguments
)
$value = & git.exe -C $SourceRoot @Arguments
if ($LASTEXITCODE -ne 0) {
throw "Stable source Git command failed (exit=$LASTEXITCODE): git $($Arguments -join ' ')"
}
return (@($value) -join [Environment]::NewLine).Trim()
}
function Initialize-RuntimeProvenanceOutput {
param([string]$OutputPath)
if (-not [System.IO.Path]::IsPathRooted($OutputPath)) {
throw "Fresh public provenance output path must be absolute"
}
try {
$resolvedOutputPath = [System.IO.Path]::GetFullPath($OutputPath)
$outputDirectory = Split-Path -Parent $resolvedOutputPath
if (-not $outputDirectory) {
throw "Fresh public provenance output path has no parent directory"
}
if (Test-Path -LiteralPath $resolvedOutputPath -PathType Container) {
throw "Fresh public provenance output path is a directory: $resolvedOutputPath"
}
if (-not (Test-Path -LiteralPath $outputDirectory -PathType Container)) {
New-Item -ItemType Directory -Path $outputDirectory -Force | Out-Null
}
if (-not (Test-Path -LiteralPath $outputDirectory -PathType Container)) {
throw "Fresh public provenance output directory is unavailable: $outputDirectory"
}
# 기존 receipt가 잠겨 있거나 read-only라면 프로세스 교체 전에 실패해야 한다.
# sibling probe 두 개를 atomic replace해 디렉터리의 create/flush/replace/delete
# 권한도 미리 검증한다. 실제 receipt 내용은 이 단계에서 건드리지 않는다.
if (Test-Path -LiteralPath $resolvedOutputPath -PathType Leaf) {
$attributes = [System.IO.File]::GetAttributes($resolvedOutputPath)
if (($attributes -band [System.IO.FileAttributes]::ReadOnly) -ne 0) {
throw "Fresh public provenance output is read-only: $resolvedOutputPath"
}
$existingStream = [System.IO.File]::Open(
$resolvedOutputPath,
[System.IO.FileMode]::Open,
[System.IO.FileAccess]::ReadWrite,
[System.IO.FileShare]::Read
)
$existingStream.Dispose()
}
$probeId = [Guid]::NewGuid().ToString("N")
$probeSource = Join-Path $outputDirectory ".$([System.IO.Path]::GetFileName($resolvedOutputPath)).$probeId.source.tmp"
$probeTarget = Join-Path $outputDirectory ".$([System.IO.Path]::GetFileName($resolvedOutputPath)).$probeId.target.tmp"
$probeBackup = Join-Path $outputDirectory ".$([System.IO.Path]::GetFileName($resolvedOutputPath)).$probeId.backup.tmp"
try {
$encoding = [System.Text.UTF8Encoding]::new($false)
[System.IO.File]::WriteAllText($probeSource, "probe-source", $encoding)
[System.IO.File]::WriteAllText($probeTarget, "probe-target", $encoding)
[System.IO.File]::Replace($probeSource, $probeTarget, $probeBackup)
[System.IO.File]::Delete($probeTarget)
[System.IO.File]::Delete($probeBackup)
} finally {
foreach ($probePath in @($probeSource, $probeTarget, $probeBackup)) {
if ($probePath -and [System.IO.File]::Exists($probePath)) {
[System.IO.File]::Delete($probePath)
}
}
}
} catch {
throw "Fresh public provenance output preflight failed before runtime mutation: $($_.Exception.Message)"
}
return $resolvedOutputPath
}
function Write-Utf8TextAtomically {
param(
[string]$OutputPath,
[string]$Value
)
$outputDirectory = Split-Path -Parent $OutputPath
$temporaryPath = Join-Path $outputDirectory ".$([System.IO.Path]::GetFileName($OutputPath)).$([Guid]::NewGuid().ToString('N')).tmp"
$backupPath = Join-Path $outputDirectory ".$([System.IO.Path]::GetFileName($OutputPath)).$([Guid]::NewGuid().ToString('N')).backup.tmp"
$published = $false
try {
$encoding = [System.Text.UTF8Encoding]::new($false)
$bytes = $encoding.GetBytes($Value)
$stream = [System.IO.FileStream]::new(
$temporaryPath,
[System.IO.FileMode]::CreateNew,
[System.IO.FileAccess]::Write,
[System.IO.FileShare]::None
)
try {
$stream.Write($bytes, 0, $bytes.Length)
$stream.Flush($true)
} finally {
$stream.Dispose()
}
if ([System.IO.File]::Exists($OutputPath)) {
[System.IO.File]::Replace($temporaryPath, $OutputPath, $backupPath)
} elseif (Test-Path -LiteralPath $OutputPath) {
throw "Fresh public provenance output became a non-file before commit: $OutputPath"
} else {
[System.IO.File]::Move($temporaryPath, $OutputPath)
}
$published = $true
} finally {
if ([System.IO.File]::Exists($temporaryPath)) {
[System.IO.File]::Delete($temporaryPath)
}
if ([System.IO.File]::Exists($backupPath)) {
try {
[System.IO.File]::Delete($backupPath)
} catch {
if ($published) {
Write-Warning "Atomic provenance receipt was published, but its temporary backup could not be removed: $backupPath"
} else {
throw
}
}
}
}
}
function Assert-FreshPublicProvenanceContract {
param(
[string]$SourceRoot,
[string]$SourceCommit,
[string]$SourceTree,
[string]$PythonPath,
[string]$PythonSha256,
[string]$CloudflaredPath,
[string]$CloudflaredSha256,
[string]$ConfigPath,
[string]$ConfigSha256
)
if (-not $ForceApiRestart) {
throw "-RequireFreshPublicProvenance requires -ForceApiRestart"
}
if ($SkipCloudflaredRestart) {
throw "-RequireFreshPublicProvenance forbids -SkipCloudflaredRestart"
}
foreach ($sourcePin in @($SourceCommit, $SourceTree)) {
if ($sourcePin -notmatch "^[0-9a-fA-F]{40}$") {
throw "Fresh public provenance requires exact source commit and tree pins"
}
}
foreach ($shaPin in @($PythonSha256, $CloudflaredSha256, $ConfigSha256)) {
if ($shaPin -notmatch "^[0-9a-fA-F]{64}$") {
throw "Fresh public provenance requires exact Python, cloudflared, and config SHA256 pins"
}
}
$resolvedSourceRoot = (Resolve-Path -LiteralPath $SourceRoot).Path
$expectedStartScript = Join-Path $resolvedSourceRoot "scripts\start-public-runtime.ps1"
$runningStartScript = (Resolve-Path -LiteralPath $PSCommandPath).Path
if (-not [string]::Equals(
$runningStartScript,
(Resolve-Path -LiteralPath $expectedStartScript).Path,
[System.StringComparison]::OrdinalIgnoreCase
)) {
throw "Fresh public promotion must execute the launcher from the pinned stable source root"
}
$gitRoot = Invoke-StableGitText -SourceRoot $resolvedSourceRoot -Arguments @("rev-parse", "--show-toplevel")
$resolvedGitRoot = (Resolve-Path -LiteralPath $gitRoot).Path
if (-not [string]::Equals(
$resolvedGitRoot,
$resolvedSourceRoot,
[System.StringComparison]::OrdinalIgnoreCase
)) {
throw "Fresh public promotion source root does not match its Git toplevel"
}
$symbolicHead = & git.exe -C $resolvedSourceRoot symbolic-ref --quiet HEAD
$symbolicHeadExit = $LASTEXITCODE
if ($symbolicHeadExit -eq 0) {
throw "Fresh public promotion requires detached HEAD, not branch $symbolicHead"
}
if ($symbolicHeadExit -ne 1) {
throw "Could not prove detached HEAD (git exit=$symbolicHeadExit)"
}
$actualCommit = Invoke-StableGitText -SourceRoot $resolvedSourceRoot -Arguments @("rev-parse", "--verify", "HEAD")
$actualTree = Invoke-StableGitText -SourceRoot $resolvedSourceRoot -Arguments @("rev-parse", "--verify", "HEAD^{tree}")
if ($actualCommit -ne $SourceCommit.ToLowerInvariant()) {
throw "Fresh public source commit drift: expected=$SourceCommit actual=$actualCommit"
}
if ($actualTree -ne $SourceTree.ToLowerInvariant()) {
throw "Fresh public source tree drift: expected=$SourceTree actual=$actualTree"
}
$dirty = Invoke-StableGitText -SourceRoot $resolvedSourceRoot -Arguments @("status", "--porcelain=v1", "--untracked-files=normal")
if ($dirty) {
throw "Fresh public promotion requires a clean stable source"
}
foreach ($pin in @(
[pscustomobject]@{ Path = $PythonPath; Sha256 = $PythonSha256; Label = "Python" },
[pscustomobject]@{ Path = $CloudflaredPath; Sha256 = $CloudflaredSha256; Label = "cloudflared" },
[pscustomobject]@{ Path = $ConfigPath; Sha256 = $ConfigSha256; Label = "cloudflared config" }
)) {
if (-not (Test-Path -LiteralPath $pin.Path -PathType Leaf)) {
throw "Pinned $($pin.Label) file not found at $($pin.Path)"
}
$actualSha256 = (Get-FileHash -LiteralPath $pin.Path -Algorithm SHA256).Hash.ToLowerInvariant()
if ($actualSha256 -ne $pin.Sha256.ToLowerInvariant()) {
throw "Pinned $($pin.Label) SHA256 drift"
}
}
}
function Set-CloudflaredIngress {
param(
[string]$ConfigPath,
[string[]]$ApiHostnames,
[string[]]$WebHostnames,
[int]$ApiPortValue,
[int]$WebPortValue
[int]$WebPortValue,
[switch]$RequireUnchanged
)
$lines = Get-Content -Encoding UTF8 -Path $ConfigPath
@ -196,7 +640,21 @@ function Set-CloudflaredIngress {
}
$nextLines += " - service: http_status:404"
Set-Content -Encoding UTF8 -Path $ConfigPath -Value $nextLines
$matches = $lines.Count -eq $nextLines.Count
if ($matches) {
for ($i = 0; $i -lt $lines.Count; $i++) {
if ($lines[$i] -cne $nextLines[$i]) {
$matches = $false
break
}
}
}
if ($RequireUnchanged -and -not $matches) {
throw "Pinned cloudflared config ingress does not match the requested public topology"
}
if (-not $matches) {
Set-Content -Encoding UTF8 -Path $ConfigPath -Value $nextLines
}
}
if (!(Test-Path $Python)) {
@ -208,6 +666,46 @@ if (!(Test-Path $ApiDir)) {
if (!(Test-Path $WebDir)) {
throw "Web directory not found at $WebDir"
}
foreach ($voiceScript in @($WhisperStartScript, $MeloTtsStartScript, $VoiceSidecarProbe)) {
if (!(Test-Path -LiteralPath $voiceScript)) {
throw "Voice sidecar prerequisite not found at $voiceScript"
}
}
if ($RequireFreshPublicProvenance) {
if (!(Test-Path -LiteralPath $Cloudflared -PathType Leaf)) {
throw "cloudflared not found at $Cloudflared"
}
if (!(Test-Path -LiteralPath $CloudflaredConfig -PathType Leaf)) {
throw "cloudflared config not found at $CloudflaredConfig"
}
if (-not $RuntimeProvenancePath) {
$RuntimeProvenancePath = Join-Path $Workspace "public-runtime-launch-provenance.log"
}
Assert-FreshPublicProvenanceContract `
-SourceRoot $Workspace `
-SourceCommit $ExpectedSourceCommit `
-SourceTree $ExpectedSourceTree `
-PythonPath $Python `
-PythonSha256 $ExpectedPythonSha256 `
-CloudflaredPath $Cloudflared `
-CloudflaredSha256 $ExpectedCloudflaredSha256 `
-ConfigPath $CloudflaredConfig `
-ConfigSha256 $ExpectedCloudflaredConfigSha256
$resolvedRuntimeProvenancePath = Initialize-RuntimeProvenanceOutput `
-OutputPath $RuntimeProvenancePath
# 승격 모드에서 config를 재작성하면 사전 pin과 실제 tunnel 입력이 달라진다.
# exact ingress가 이미 들어 있는 경우에만 이후 프로세스 mutation으로 진행한다.
Set-CloudflaredIngress `
-ConfigPath $CloudflaredConfig `
-ApiHostnames $PublicApiHostnames `
-WebHostnames $PublicWebHostnames `
-ApiPortValue $ApiPort `
-WebPortValue $WebPort `
-RequireUnchanged
}
# 재기동 판정은 /health(프로세스 liveness)가 아니라 /ready(실제 claude -p 생성)로 한다.
# 프로세스는 살아 있는데 그 프로세스의 claude 세션만 죽은 상태는 /health를 통과하므로,
@ -222,7 +720,12 @@ if ($SkipEngineRestart) {
throw "Engine gateway is not ready on http://127.0.0.1:$EnginePort/ready"
}
} elseif (-not $engineReady) {
Stop-UvicornByPort -AppImport "engine_gateway.gateway:app" -Port $EnginePort
$null = @(
Stop-UvicornByPort `
-AppImport "engine_gateway.gateway:app" `
-Port $EnginePort `
-TimeoutSec $ProcessStopTimeoutSeconds
)
# Start-Process는 리다이렉트 대상 로그를 덮어쓴다. 직전 사고 로그를 보존해야
# 재기동 후에도 원인을 추적할 수 있다.
@ -255,6 +758,12 @@ $env:AUTH_DEV_LOGIN_ENABLED = "false"
$env:AUTO_SEED_PERSONAS = "false"
$env:ALLOW_SEED_PERSONA_FALLBACK = "false"
$env:VIGNETTE_VOICE_POC_SAMPLE_TTS = "false"
$env:VIGNETTE_VOICE_STT_PROVIDER = "local_whisper"
$env:VIGNETTE_LOCAL_WHISPER_STT_URL = "ws://127.0.0.1:$WhisperPort/v1/listen"
$env:VIGNETTE_LOCAL_WHISPER_STT_MODEL = $WhisperModel
$env:VIGNETTE_LOCAL_WHISPER_STT_LANGUAGE = $WhisperLanguage
$env:VIGNETTE_VOICE_TTS_PROVIDER = "melotts"
$env:VIGNETTE_MELOTTS_TTS_URL = "http://127.0.0.1:$MeloTtsPort"
$env:FRONTEND_BASE_URL = "https://vignette.chanpaca.net"
$frontendOrigins = @("https://vignette.chanpaca.net", "https://vnet.18ka.net", "https://vignette-b1q.pages.dev")
$localViteOrigins = @()
@ -268,30 +777,117 @@ $env:FRONTEND_ORIGIN_MAP = ConvertTo-CompactJson -Value ([ordered]@{
"api-vnet.18ka.net" = "https://vnet.18ka.net"
})
# 포트 리스너만으로는 올바른 provider/model을 증명하지 못한다. 첫 WS ready
# 프레임과 MeloTTS health metadata가 운영 계약과 정확히 일치할 때만 API를
# 유지하거나 재시작한다. 잘못된 기존 리스너는 소유권을 추측해 종료하지 않는다.
if (-not (Test-VoiceSidecarReady -Component "stt")) {
if (Test-PortListener -Port $WhisperPort) {
throw "Port $WhisperPort is occupied but does not expose the exact local_whisper/$WhisperModel/$WhisperDevice protocol"
}
& $WhisperStartScript `
-Port $WhisperPort `
-Model $WhisperModel `
-Device $WhisperDevice `
-WaitReadySeconds 0
if (-not (Wait-VoiceSidecarReady -Component "stt" -TimeoutSec $VoiceSidecarReadySeconds)) {
throw "local_whisper/$WhisperModel/$WhisperDevice did not become exactly ready before the API restart gate"
}
}
if (-not (Test-VoiceSidecarReady -Component "tts")) {
if (Test-PortListener -Port $MeloTtsPort) {
throw "Port $MeloTtsPort is occupied but does not expose the exact melotts/$MeloTtsModel health contract"
}
& $MeloTtsStartScript `
-Port $MeloTtsPort `
-Language $MeloTtsLanguage `
-Device "cpu" `
-WaitReadySeconds 0
if (-not (Wait-VoiceSidecarReady -Component "tts" -TimeoutSec $VoiceSidecarReadySeconds)) {
throw "melotts/$MeloTtsModel did not become exactly ready before the API restart gate"
}
}
# 두 sidecar를 한 번 더 함께 검사해 개별 probe 사이의 TOCTOU를 닫는다.
if (-not (Test-VoiceSidecarReady -Component "stt") -or -not (Test-VoiceSidecarReady -Component "tts")) {
throw "Voice sidecar readiness changed before the API restart gate"
}
$health = Get-JsonHealth -Uri "http://127.0.0.1:$ApiPort/health"
$apiControlPlaneReady = $null -ne $health -and $health.environment -eq "prod" -and $health.db
$voiceHealth = Get-JsonHealth -Uri "http://127.0.0.1:$ApiPort/voice/health"
$apiControlPlaneReady = (
$null -ne $health -and
$health.environment -eq "prod" -and
$health.db -eq $true -and
$health.engine -eq $true -and
(Test-VoiceApiReady -Health $voiceHealth)
)
$proc = $null
$apiStoppedProcessIds = @()
$apiLaunchIdentity = $null
if ($apiControlPlaneReady -and -not $ForceApiRestart) {
Write-Output "Admin/auth control plane already healthy; skipping API restart"
Write-Output "Production API and exact local voice stack already healthy; skipping API restart"
} else {
Stop-UvicornByPort -AppImport "app.main:app" -Port $ApiPort
$apiStoppedProcessIds = @(
Stop-UvicornByPort `
-AppImport "app.main:app" `
-Port $ApiPort `
-TimeoutSec $ProcessStopTimeoutSeconds
)
$proc = Start-Process -WindowStyle Hidden -FilePath $Python `
-ArgumentList @("-m", "uvicorn", "app.main:app", "--host", "127.0.0.1", "--port", "$ApiPort") `
-ArgumentList @(
"-m", "uvicorn", "app.main:app",
"--host", "127.0.0.1",
"--port", "$ApiPort",
"--ws", "websockets",
"--ws-max-queue", "4"
) `
-WorkingDirectory $ApiDir `
-RedirectStandardOutput $OutLog `
-RedirectStandardError $ErrLog `
-PassThru
if ($RequireFreshPublicProvenance) {
if ($apiStoppedProcessIds -contains $proc.Id) {
throw "Fresh public API did not receive a replacement PID"
}
$apiLaunchIdentity = Wait-ProcessIdentity `
-ProcessId $proc.Id `
-Role "api" `
-ExpectedCwd $ApiDir `
-TimeoutSec $ProcessStopTimeoutSeconds
if ($apiLaunchIdentity.executable_sha256 -ne $ExpectedPythonSha256.ToLowerInvariant()) {
throw "Fresh public API executable SHA256 does not match the pinned Python"
}
foreach ($requiredArgument in @("uvicorn", "app.main:app", "--port", "$ApiPort", "--ws-max-queue", "4")) {
if ($apiLaunchIdentity.command_line.IndexOf($requiredArgument, [System.StringComparison]::Ordinal) -lt 0) {
throw "Fresh public API command line is missing required argument: $requiredArgument"
}
}
}
Start-Sleep -Seconds 3
$health = Wait-JsonHealth `
-Uri "http://127.0.0.1:$ApiPort/health" `
-IsHealthy { param($health) $health.environment -eq "prod" -and $health.db } `
-IsHealthy {
param($health)
$health.environment -eq "prod" -and
$health.db -eq $true -and
$health.engine -eq $true
} `
-TimeoutSec 30
$voiceHealth = Wait-JsonHealth `
-Uri "http://127.0.0.1:$ApiPort/voice/health" `
-IsHealthy { param($health) Test-VoiceApiReady -Health $health } `
-TimeoutSec 30
}
if ($health.environment -ne "prod" -or -not $health.db) {
if ($health.environment -ne "prod" -or -not $health.db -or -not $health.engine) {
throw "Admin/auth control plane is not production-safe: $($health | ConvertTo-Json -Compress)"
}
if (-not (Test-VoiceApiReady -Health $voiceHealth)) {
throw "Public voice API does not match the exact local provider/model contract: $($voiceHealth | ConvertTo-Json -Compress)"
}
if (!$SkipWebRestart) {
Stop-NodeByPortHint -Port $WebPort
@ -316,6 +912,9 @@ if (!$SkipWebRestart) {
Wait-HttpStatus -Uri "http://127.0.0.1:$WebPort/" -TimeoutSec 30 | Out-Null
}
$cloudflaredProcess = $null
$cloudflaredLaunchIdentity = $null
$cloudflaredStoppedProcessIds = @()
if (!$SkipCloudflaredRestart) {
if (!(Test-Path $Cloudflared)) {
throw "cloudflared not found at $Cloudflared"
@ -324,11 +923,8 @@ if (!$SkipCloudflaredRestart) {
throw "cloudflared config not found at $CloudflaredConfig"
}
$apiHostnames = @("api-vignette.chanpaca.net", "api-vnet.18ka.net")
$webHostnames = @("vnet.18ka.net")
if ($RouteCloudflareDns) {
foreach ($hostname in ($webHostnames + $apiHostnames)) {
foreach ($hostname in ($PublicWebHostnames + $PublicApiHostnames)) {
& $Cloudflared tunnel route dns $CloudflareTunnelName $hostname
if ($LASTEXITCODE -ne 0) {
Write-Warning "cloudflared DNS route failed for $hostname"
@ -336,25 +932,152 @@ if (!$SkipCloudflaredRestart) {
}
}
Set-CloudflaredIngress `
-ConfigPath $CloudflaredConfig `
-ApiHostnames $apiHostnames `
-WebHostnames $webHostnames `
-ApiPortValue $ApiPort `
-WebPortValue $WebPort
$cloudflaredProcess = Get-CimInstance Win32_Process |
Where-Object { $_.Name -eq "cloudflared.exe" -and $_.CommandLine -like "*vignette-config.yml*" } |
Select-Object -First 1
if ($null -eq $cloudflaredProcess) {
Start-Process -WindowStyle Hidden -FilePath $Cloudflared `
-ArgumentList @("tunnel", "--config", $CloudflaredConfig, "run") `
-RedirectStandardOutput (Join-Path $Workspace "cloudflared.public.out.log") `
-RedirectStandardError (Join-Path $Workspace "cloudflared.public.err.log") `
-PassThru | Out-Null
} else {
Write-Output "Cloudflared already running; skipping tunnel restart"
if (-not $RequireFreshPublicProvenance) {
Set-CloudflaredIngress `
-ConfigPath $CloudflaredConfig `
-ApiHostnames $PublicApiHostnames `
-WebHostnames $PublicWebHostnames `
-ApiPortValue $ApiPort `
-WebPortValue $WebPort
}
$resolvedCloudflaredConfig = (Resolve-Path -LiteralPath $CloudflaredConfig).Path
if ($RequireFreshPublicProvenance) {
$existingCloudflaredProcesses = @(
Get-CloudflaredProcessesForConfig `
-ConfigPath $resolvedCloudflaredConfig `
-ExactPath
)
} else {
$existingCloudflaredProcesses = @(
Get-CloudflaredProcessesForConfig -ConfigPath $resolvedCloudflaredConfig
)
}
$cloudflaredStoppedProcessIds = @(
Stop-ProcessesBounded `
-Processes $existingCloudflaredProcesses `
-TimeoutSec $ProcessStopTimeoutSeconds `
-Role "cloudflared for $resolvedCloudflaredConfig"
)
$cloudflaredProcess = Start-Process -WindowStyle Hidden -FilePath $Cloudflared `
-ArgumentList @("tunnel", "--config", $resolvedCloudflaredConfig, "run") `
-WorkingDirectory $Workspace `
-RedirectStandardOutput (Join-Path $Workspace "cloudflared.public.out.log") `
-RedirectStandardError (Join-Path $Workspace "cloudflared.public.err.log") `
-PassThru
if ($cloudflaredStoppedProcessIds -contains $cloudflaredProcess.Id) {
throw "Cloudflared did not receive a replacement PID"
}
if ($RequireFreshPublicProvenance) {
$cloudflaredLaunchIdentity = Wait-ProcessIdentity `
-ProcessId $cloudflaredProcess.Id `
-Role "cloudflared" `
-ExpectedCwd $Workspace `
-TimeoutSec $ProcessStopTimeoutSeconds
if ($cloudflaredLaunchIdentity.executable_sha256 -ne $ExpectedCloudflaredSha256.ToLowerInvariant()) {
throw "Fresh cloudflared executable SHA256 does not match its pin"
}
if ($cloudflaredLaunchIdentity.command_line.IndexOf($resolvedCloudflaredConfig, [System.StringComparison]::OrdinalIgnoreCase) -lt 0) {
throw "Fresh cloudflared command line is not pinned to the expected config"
}
}
}
if ($RequireFreshPublicProvenance) {
if ($null -eq $apiLaunchIdentity -or $null -eq $cloudflaredLaunchIdentity) {
throw "Fresh public promotion did not produce both API and cloudflared identities"
}
$apiFinalIdentity = Wait-ProcessIdentity `
-ProcessId $apiLaunchIdentity.pid `
-Role "api" `
-ExpectedCwd $ApiDir `
-TimeoutSec $ProcessStopTimeoutSeconds
$cloudflaredFinalIdentity = Wait-ProcessIdentity `
-ProcessId $cloudflaredLaunchIdentity.pid `
-Role "cloudflared" `
-ExpectedCwd $Workspace `
-TimeoutSec $ProcessStopTimeoutSeconds
foreach ($identityPair in @(
[pscustomobject]@{ Role = "api"; Launch = $apiLaunchIdentity; Final = $apiFinalIdentity },
[pscustomobject]@{ Role = "cloudflared"; Launch = $cloudflaredLaunchIdentity; Final = $cloudflaredFinalIdentity }
)) {
foreach ($field in @("pid", "started_at_utc", "executable_sha256", "command_line_sha256", "cwd")) {
if ($identityPair.Launch[$field].ToString() -cne $identityPair.Final[$field].ToString()) {
throw "Fresh $($identityPair.Role) provenance drifted before receipt: $field"
}
}
}
$finalConfigSha256 = (Get-FileHash -LiteralPath $CloudflaredConfig -Algorithm SHA256).Hash.ToLowerInvariant()
if ($finalConfigSha256 -ne $ExpectedCloudflaredConfigSha256.ToLowerInvariant()) {
throw "Pinned cloudflared config drifted before provenance receipt"
}
$psutilVersionArgs = @("-X", "utf8", "-c", "import importlib.metadata; print(importlib.metadata.version('psutil'))")
$psutilVersion = (@(& $Python @psutilVersionArgs) -join [Environment]::NewLine).Trim()
if ($LASTEXITCODE -ne 0 -or -not $psutilVersion) {
throw "Could not record the psutil version used for process provenance"
}
$safeApiIdentity = ConvertTo-SafeProcessIdentity -Identity $apiFinalIdentity
$safeCloudflaredIdentity = ConvertTo-SafeProcessIdentity -Identity $cloudflaredFinalIdentity
$provenance = [ordered]@{
schema_version = "vignette.public-runtime-launch-provenance.v1"
status = "passed"
captured_at_utc = (Get-Date).ToUniversalTime().ToString("o")
source = [ordered]@{
repo_root = (Resolve-Path -LiteralPath $Workspace).Path
git_commit = $ExpectedSourceCommit.ToLowerInvariant()
git_tree = $ExpectedSourceTree.ToLowerInvariant()
launcher_sha256 = (Get-FileHash -LiteralPath $PSCommandPath -Algorithm SHA256).Hash.ToLowerInvariant()
clean_detached_head = $true
}
config = [ordered]@{
path = (Resolve-Path -LiteralPath $CloudflaredConfig).Path
sha256 = $finalConfigSha256
}
replacement = [ordered]@{
api_stopped_pids = @($apiStoppedProcessIds)
cloudflared_stopped_pids = @($cloudflaredStoppedProcessIds)
api_new_pid = [int]$apiFinalIdentity.pid
cloudflared_new_pid = [int]$cloudflaredFinalIdentity.pid
}
processes = [ordered]@{
api = $safeApiIdentity
cloudflared = $safeCloudflaredIdentity
}
topology_inputs = [ordered]@{
repo_root = (Resolve-Path -LiteralPath $Workspace).Path
git_sha = $ExpectedSourceCommit.ToLowerInvariant()
api_pid = [int]$apiFinalIdentity.pid
api_started_at_utc = $apiFinalIdentity.started_at_utc
api_executable_name = $apiFinalIdentity.executable_name
api_executable_sha256 = $apiFinalIdentity.executable_sha256
api_command_line_sha256 = $apiFinalIdentity.command_line_sha256
api_cwd = $apiFinalIdentity.cwd
api_listen_port = $ApiPort
cloudflared_pid = [int]$cloudflaredFinalIdentity.pid
cloudflared_started_at_utc = $cloudflaredFinalIdentity.started_at_utc
cloudflared_executable_name = $cloudflaredFinalIdentity.executable_name
cloudflared_executable_sha256 = $cloudflaredFinalIdentity.executable_sha256
cloudflared_command_line_sha256 = $cloudflaredFinalIdentity.command_line_sha256
cloudflared_cwd = $cloudflaredFinalIdentity.cwd
psutil_version = $psutilVersion
}
}
$provenanceJson = ConvertTo-Json -InputObject $provenance -Depth 8
try {
Write-Utf8TextAtomically `
-OutputPath $resolvedRuntimeProvenancePath `
-Value ($provenanceJson + [Environment]::NewLine)
} catch {
# 새 PID들은 이미 health/identity gate를 통과했지만, atomic receipt가 없으면
# 승격 성공으로 간주할 수 없다. 기존 receipt는 보존되고 호출은 non-zero로 끝난다.
throw "Fresh public promotion failed closed after runtime replacement: no atomic passed receipt was published. Re-run the pinned promotion after fixing the receipt destination. $($_.Exception.Message)"
}
Write-Output "Fresh public provenance: $resolvedRuntimeProvenancePath"
}
if ($engineReady) {
@ -371,3 +1094,4 @@ if (!$SkipWebRestart) {
Write-Output "Public vnet web preview running on http://127.0.0.1:$WebPort"
}
Write-Output "Health: $($health | ConvertTo-Json -Compress)"
Write-Output "Voice health: $($voiceHealth | ConvertTo-Json -Compress)"

View file

@ -265,6 +265,153 @@ def topology() -> dict[str, object]:
}
def windows_topology() -> dict[str, object]:
process_metrics = {
"api": {
"rss_bytes": 200_000,
"peak_rss_bytes": 240_000,
"cpu_time_seconds": 2.0,
"cpu_percent": 3.0,
"handles": 40,
"threads": 8,
},
"cloudflared": {
"rss_bytes": 100_000,
"peak_rss_bytes": 120_000,
"cpu_time_seconds": 1.0,
"cpu_percent": 1.5,
"handles": 20,
"threads": 4,
},
}
process_tcp = {
"api": {"connections": 2, "established": 1, "listeners": 1},
"cloudflared": {"connections": 4, "established": 4, "listeners": 0},
}
samples = [
{
"sequence": index + 1,
"observed_at_utc": "2026-08-07T00:30:00Z",
"processes": copy.deepcopy(process_metrics),
"process_tcp": copy.deepcopy(process_tcp),
"host_tcp": {"connections": 20},
"api_listener": {
"port": 8001,
"owned_listener_count": 1,
"conflicting_listener_count": 0,
},
}
for index in range(31)
]
return {
"schema_version": "vignette.g7-topology-evidence.v1",
"topology_mode": "windows_host",
"status": "passed",
"started_at_utc": "2026-08-07T00:00:00Z",
"ended_at_utc": "2026-08-07T01:00:00Z",
"scope": {
"metadata_only": True,
"raw_command_output_retained": False,
"socket_endpoints_retained": False,
"request_payloads_retained": False,
"audio_retained": False,
"transcripts_retained": False,
"topology_boundary": "windows_host_api_and_cloudflared_processes",
"configured_listener_port_retained": True,
"cloudflare_edge": {
"internal_queue_measured": False,
"evidence_boundary": "separate_external_artifact_required",
},
},
"requested": {
"public_host": "api.example.test",
"repo_root": r"D:\workspace\vignette",
"git_sha": "5" * 40,
"source_pin": {
"git_tree_sha": "8" * 40,
"script_sha256": {
"runner": "9" * 64,
"collector": "a" * 64,
"checker": "b" * 64,
},
"runtime_dependencies": {"psutil": "6.1.1"},
},
"samples": 31,
"interval_seconds": 100.0,
"api_listen_port": 8001,
"roles": {
"api": {
"pid": 301,
"expected_executable_name": "python.exe",
"expected_executable_sha256": "3" * 64,
"expected_cwd": r"D:\workspace\vignette\apps\api",
},
"cloudflared": {
"pid": 302,
"expected_executable_name": "cloudflared.exe",
"expected_executable_sha256": "4" * 64,
"expected_cwd": r"D:\workspace\vignette",
},
},
},
"source_provenance": {
"detached_head": True,
"tracked_clean": True,
"git_sha": "5" * 40,
"git_tree_sha": "8" * 40,
"script_sha256": {
"runner": "9" * 64,
"collector": "a" * 64,
"checker": "b" * 64,
},
"runtime_dependencies": {"psutil": "6.1.1"},
},
"samples_completed": 31,
"targets": {
"api": {
"role": "api",
"pid": 301,
"started_at": "2026-08-06T00:00:00Z",
"executable_name": "python.exe",
"executable_sha256": "3" * 64,
"command_line_sha256": "6" * 64,
"git_sha": "5" * 40,
"cwd": r"D:\workspace\vignette\apps\api",
},
"cloudflared": {
"role": "cloudflared",
"pid": 302,
"started_at": "2026-08-06T00:00:00Z",
"executable_name": "cloudflared.exe",
"executable_sha256": "4" * 64,
"command_line_sha256": "7" * 64,
"git_sha": "5" * 40,
"cwd": r"D:\workspace\vignette",
},
},
"samples": samples,
"summary": {
"processes": {
role: {
**{f"{field}_max": value for field, value in metrics.items()},
**{
f"tcp_{field}_max": value
for field, value in process_tcp[role].items()
},
}
for role, metrics in process_metrics.items()
},
"api_listener": {
"port": 8001,
"owned_listener_count_min": 1,
"conflicting_listener_count_max": 0,
},
"host_tcp": {"connections_max": 20},
},
"failure_type": None,
}
class G7ExternalProofTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
@ -286,6 +433,97 @@ class G7ExternalProofTests(unittest.TestCase):
self.assertTrue(gain["passed"])
self.assertGreaterEqual(gain["held_out_participants"], 30)
def test_windows_host_topology_passes_without_weakening_compose(self) -> None:
errors: list[str] = []
payload = windows_topology()
compose_payload = topology()
compose_payload["topology_mode"] = "linux_compose"
self.checker.validate_topology(payload, errors)
self.checker.validate_topology(compose_payload, errors)
self.checker.validate_binding(public_soak(), runtime(), payload, errors)
self.assertEqual([], errors)
def test_windows_host_identity_listener_and_summary_fail_closed(self) -> None:
cases = (
(
lambda payload: payload["targets"]["api"].__setitem__("pid", 999),
"topology:api_pid_pin",
),
(
lambda payload: payload["samples"][0]["api_listener"].__setitem__(
"owned_listener_count", 0
),
"topology:listener_owner_sample",
),
(
lambda payload: payload["summary"]["processes"]["api"].__setitem__(
"rss_bytes_max", 1
),
"topology:api_rss_bytes_max",
),
)
for mutate, failure in cases:
with self.subTest(failure=failure):
payload = windows_topology()
mutate(payload)
errors: list[str] = []
self.checker.validate_topology(payload, errors)
self.assertIn(failure, errors)
def test_windows_source_and_toolchain_provenance_fail_closed(self) -> None:
cases = (
(
lambda payload: payload["source_provenance"].__setitem__(
"detached_head", False
),
"topology:windows_detached_head",
),
(
lambda payload: payload["source_provenance"].__setitem__(
"tracked_clean", False
),
"topology:windows_tracked_clean",
),
(
lambda payload: payload["source_provenance"].__setitem__(
"git_tree_sha", "c" * 40
),
"topology:windows_source_git_tree_sha",
),
(
lambda payload: payload["source_provenance"]["script_sha256"].__setitem__(
"runner", "c" * 64
),
"topology:windows_source_script_sha256",
),
(
lambda payload: payload["source_provenance"][
"runtime_dependencies"
].__setitem__("psutil", "6.1.0"),
"topology:windows_source_psutil_version",
),
)
for mutate, failure in cases:
with self.subTest(failure=failure):
payload = windows_topology()
mutate(payload)
errors: list[str] = []
self.checker.validate_topology(payload, errors)
self.assertIn(failure, errors)
unpinned_version = windows_topology()
unpinned_version["requested"]["source_pin"]["runtime_dependencies"][
"psutil"
] = "6.1.0"
unpinned_version["source_provenance"]["runtime_dependencies"][
"psutil"
] = "6.1.0"
errors = []
self.checker.validate_topology(unpinned_version, errors)
self.assertIn("topology:windows_psutil_version_pin", errors)
def test_decided_local_stack_is_accepted(self) -> None:
"""2026-08-08 소유자 결정: 노트북 faster-whisper STT + MeloTTS TTS (둘 다 MIT)."""
@ -353,6 +591,44 @@ class G7ExternalProofTests(unittest.TestCase):
self.assertIn("voice_soak:physical_microphone_used", errors)
self.assertIn("binding:no_concurrent_overlap", errors)
def test_concurrent_window_requires_full_3000_second_intersection(self) -> None:
exact_errors: list[str] = []
exact_voice = public_soak()
exact_voice["ended_at_utc"] = "2026-08-07T00:50:00Z"
exact_runtime = runtime()
exact_runtime["ended_at_utc"] = "2026-08-07T00:50:00Z"
exact_topology = topology()
exact_topology["ended_at_utc"] = "2026-08-07T00:50:00Z"
self.checker.validate_binding(
exact_voice,
exact_runtime,
exact_topology,
exact_errors,
)
self.assertEqual([], exact_errors)
short_errors: list[str] = []
short_voice = public_soak()
short_voice["ended_at_utc"] = "2026-08-07T00:50:00Z"
short_runtime = runtime()
short_runtime["started_at_utc"] = "2026-08-07T00:00:01Z"
short_runtime["ended_at_utc"] = "2026-08-07T00:50:00Z"
short_topology = topology()
short_topology["ended_at_utc"] = "2026-08-07T00:50:00Z"
self.checker.validate_binding(
short_voice,
short_runtime,
short_topology,
short_errors,
)
self.assertIn(
"binding:concurrent_overlap_below_3000_seconds",
short_errors,
)
def test_runtime_fallback_and_short_topology_fail(self) -> None:
errors: list[str] = []
runtime_payload = runtime()

View file

@ -3,6 +3,7 @@
from __future__ import annotations
import importlib.util
import hashlib
import json
import sys
import unittest
@ -11,6 +12,11 @@ from typing import Sequence
RUNNER_PATH = Path(__file__).with_name("capture-g7-topology-evidence.py")
REPO_ROOT = RUNNER_PATH.resolve().parents[1]
def file_sha256(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def load_runner():
@ -27,6 +33,16 @@ API_ID = "a" * 64
CADDY_ID = "c" * 64
API_DIGEST = "sha256:" + "1" * 64
CADDY_DIGEST = "sha256:" + "2" * 64
API_EXE_DIGEST = "3" * 64
CLOUDFLARED_EXE_DIGEST = "4" * 64
GIT_SHA = "5" * 40
GIT_TREE_SHA = "8" * 40
PSUTIL_VERSION = "6.1.1"
SCRIPT_DIGESTS = {
"runner": file_sha256(REPO_ROOT / "scripts/run-g7-external-proof-window.py"),
"collector": file_sha256(REPO_ROOT / "scripts/capture-g7-topology-evidence.py"),
"checker": file_sha256(REPO_ROOT / "scripts/check-g7-external-proof.py"),
}
def inspect_item(
@ -114,9 +130,17 @@ class FixtureRunner:
self,
*,
inspect_outputs: list[str] | None = None,
git_outputs: list[str] | None = None,
git_branch: str = "HEAD\n",
git_status: str = "",
git_tree_sha: str = GIT_TREE_SHA,
unavailable: set[str] | None = None,
) -> None:
self.inspect_outputs = list(inspect_outputs or [stable_inspect()])
self.git_outputs = list(git_outputs or [GIT_SHA + "\n"])
self.git_branch = git_branch
self.git_status = git_status
self.git_tree_sha = git_tree_sha
self.unavailable = unavailable or set()
self.calls: list[tuple[str, ...]] = []
@ -134,6 +158,21 @@ class FixtureRunner:
return stats_output()
if command == ("ss", "-tinm"):
return SS_OUTPUT
if command and command[0] == "git":
operation = command[3:]
if operation == ("rev-parse", "--abbrev-ref", "HEAD"):
return self.git_branch
if operation == (
"status",
"--porcelain=v1",
"--untracked-files=no",
):
return self.git_status
if operation == ("rev-parse", "--verify", "HEAD^{tree}"):
return self.git_tree_sha + "\n"
if len(self.git_outputs) > 1:
return self.git_outputs.pop(0)
return self.git_outputs[0]
raise AssertionError(f"unexpected command: {command!r}")
@ -170,6 +209,106 @@ class FixtureSource:
return list(self.directories[path])
class FixtureWindowsSource:
def __init__(
self,
module,
*,
identity_overrides: dict[str, list[dict[str, object]]] | None = None,
tcp_overrides: dict[str, object] | None = None,
psutil_version: str = PSUTIL_VERSION,
) -> None:
self.module = module
self.identity_overrides = identity_overrides or {}
self.tcp_overrides = tcp_overrides or {}
self.psutil_version = psutil_version
self.metric_calls = {"api": 0, "cloudflared": 0}
def dependency_version(self, name: str) -> str:
if name != "psutil":
raise AssertionError(f"unexpected dependency: {name}")
return self.psutil_version
def _base_identity(self, role: str, git_sha: str) -> dict[str, object]:
if role == "api":
return {
"role": role,
"pid": 301,
"started_at": "2026-08-07T00:00:00Z",
"executable_name": "python.exe",
"executable_sha256": API_EXE_DIGEST,
"command_line_sha256": "6" * 64,
"git_sha": git_sha,
"cwd": r"D:\workspace\vignette\apps\api",
}
return {
"role": role,
"pid": 302,
"started_at": "2026-08-07T00:00:00Z",
"executable_name": "cloudflared.exe",
"executable_sha256": CLOUDFLARED_EXE_DIGEST,
"command_line_sha256": "7" * 64,
"git_sha": git_sha,
"cwd": r"D:\workspace\vignette",
}
def read_identity(self, *, pid: int, role: str, git_sha: str):
values = self._base_identity(role, git_sha)
overrides = self.identity_overrides.get(role, [])
if overrides:
values.update(overrides[0])
if len(overrides) > 1:
overrides.pop(0)
self.assert_pid(role, pid)
return self.module.WindowsProcessIdentity(**values)
def assert_pid(self, role: str, pid: int) -> None:
expected = 301 if role == "api" else 302
if pid != expected:
raise AssertionError(f"unexpected {role} pid: {pid}")
def read_metrics(self, *, pid: int, role: str) -> dict[str, int | float]:
self.assert_pid(role, pid)
self.metric_calls[role] += 1
factor = 2 if role == "api" else 1
return {
"rss_bytes": 100_000 * factor,
"peak_rss_bytes": 120_000 * factor,
"cpu_time_seconds": float(self.metric_calls[role] * factor),
"cpu_percent": 1.5 * factor,
"handles": 20 * factor,
"threads": 4 * factor,
}
def read_tcp_metrics(
self,
*,
api_pid: int,
cloudflared_pid: int,
api_listen_port: int,
) -> dict[str, object]:
self.assert_pid("api", api_pid)
self.assert_pid("cloudflared", cloudflared_pid)
payload: dict[str, object] = {
"processes": {
"api": {"connections": 2, "established": 1, "listeners": 1},
"cloudflared": {
"connections": 4,
"established": 4,
"listeners": 0,
},
},
"host_tcp": {"connections": 20},
"api_listener": {
"port": api_listen_port,
"owned_listener_count": 1,
"conflicting_listener_count": 0,
},
}
payload.update(self.tcp_overrides)
return payload
class G7TopologyEvidenceTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
@ -191,6 +330,31 @@ class G7TopologyEvidenceTests(unittest.TestCase):
values.update(overrides)
return self.runner_module.CaptureConfig(**values)
def windows_config(self, **overrides):
values = {
"public_host": "api.example.test",
"repo_root": r"D:\workspace\vignette",
"git_sha": GIT_SHA,
"git_tree_sha": GIT_TREE_SHA,
"runner_script_sha256": SCRIPT_DIGESTS["runner"],
"collector_script_sha256": SCRIPT_DIGESTS["collector"],
"checker_script_sha256": SCRIPT_DIGESTS["checker"],
"psutil_version": PSUTIL_VERSION,
"api_pid": 301,
"api_executable_name": "python.exe",
"api_executable_sha256": API_EXE_DIGEST,
"api_cwd": r"D:\workspace\vignette\apps\api",
"api_listen_port": 8001,
"cloudflared_pid": 302,
"cloudflared_executable_name": "cloudflared.exe",
"cloudflared_executable_sha256": CLOUDFLARED_EXE_DIGEST,
"cloudflared_cwd": r"D:\workspace\vignette",
"samples": 2,
"interval_seconds": 1.0,
}
values.update(overrides)
return self.runner_module.WindowsHostConfig(**values)
def test_parsers_emit_numeric_metadata_without_endpoints(self) -> None:
source = FixtureSource()
targets = self.runner_module.pin_targets(self.config(), FixtureRunner(), source)
@ -231,6 +395,7 @@ class G7TopologyEvidenceTests(unittest.TestCase):
)
self.assertEqual("passed", evidence["status"])
self.assertEqual("linux_compose", evidence["topology_mode"])
self.assertEqual(1, evidence["samples_completed"])
self.assertEqual(API_ID, evidence["targets"]["api"]["container_id"])
self.assertEqual(API_DIGEST, evidence["targets"]["api"]["image_digest"])
@ -328,6 +493,201 @@ class G7TopologyEvidenceTests(unittest.TestCase):
self.runner_module.validate_config(config)
self.assertEqual(failure, str(caught.exception))
def test_windows_capture_pins_processes_listener_and_high_water(self) -> None:
source = FixtureWindowsSource(self.runner_module)
evidence = self.runner_module.capture_windows_evidence(
self.windows_config(),
FixtureRunner(),
source,
sleep=lambda _seconds: None,
)
self.assertEqual("passed", evidence["status"])
self.assertEqual("windows_host", evidence["topology_mode"])
self.assertEqual({"api", "cloudflared"}, set(evidence["targets"]))
self.assertEqual(
{
"detached_head": True,
"tracked_clean": True,
"git_sha": GIT_SHA,
"git_tree_sha": GIT_TREE_SHA,
"script_sha256": SCRIPT_DIGESTS,
"runtime_dependencies": {"psutil": PSUTIL_VERSION},
},
evidence["source_provenance"],
)
self.assertEqual(GIT_SHA, evidence["targets"]["api"]["git_sha"])
self.assertEqual(
API_EXE_DIGEST,
evidence["targets"]["api"]["executable_sha256"],
)
self.assertEqual(
r"D:\workspace\vignette\apps\api",
evidence["targets"]["api"]["cwd"],
)
summary = evidence["summary"]
self.assertEqual(4.0, summary["processes"]["api"]["cpu_time_seconds_max"])
self.assertEqual(1, summary["api_listener"]["owned_listener_count_min"])
self.assertEqual(4, summary["processes"]["cloudflared"]["tcp_established_max"])
serialized = json.dumps(evidence)
self.assertNotIn("127.0.0.1", serialized)
def test_windows_identity_and_git_drift_fail_closed(self) -> None:
cases = (
(
FixtureRunner(),
FixtureWindowsSource(
self.runner_module,
identity_overrides={
"api": [{"started_at": "2999-01-01T00:00:00Z"}]
},
),
"process_started_at_future:api",
),
(
FixtureRunner(),
FixtureWindowsSource(
self.runner_module,
identity_overrides={
"api": [
{},
{"command_line_sha256": "8" * 64},
]
},
),
"process_command_line_drift:api",
),
(
FixtureRunner(git_outputs=[GIT_SHA + "\n", "9" * 40 + "\n"]),
FixtureWindowsSource(self.runner_module),
"git_sha_drift",
),
)
for runner, source, failure in cases:
with self.subTest(failure=failure):
with self.assertRaises(self.runner_module.EvidenceFailure) as caught:
self.runner_module.capture_windows_evidence(
self.windows_config(samples=1),
runner,
source,
sleep=lambda _seconds: None,
)
self.assertEqual(failure, str(caught.exception))
def test_windows_source_and_toolchain_provenance_fail_closed(self) -> None:
cases = (
(
FixtureRunner(git_branch="master\n"),
FixtureWindowsSource(self.runner_module),
self.windows_config(samples=1),
"git_head_not_detached",
),
(
FixtureRunner(git_status=" M scripts/check-g7-external-proof.py\n"),
FixtureWindowsSource(self.runner_module),
self.windows_config(samples=1),
"git_tracked_worktree_dirty",
),
(
FixtureRunner(git_tree_sha="9" * 40),
FixtureWindowsSource(self.runner_module),
self.windows_config(samples=1),
"git_tree_sha_drift",
),
(
FixtureRunner(),
FixtureWindowsSource(self.runner_module),
self.windows_config(samples=1, checker_script_sha256="9" * 64),
"evidence_script_sha256_drift:checker",
),
(
FixtureRunner(),
FixtureWindowsSource(self.runner_module, psutil_version="6.1.0"),
self.windows_config(samples=1),
"psutil_version_drift",
),
)
for runner, source, config, failure in cases:
with self.subTest(failure=failure):
with self.assertRaises(self.runner_module.EvidenceFailure) as caught:
self.runner_module.capture_windows_evidence(
config,
runner,
source,
sleep=lambda _seconds: None,
)
self.assertEqual(failure, str(caught.exception))
def test_windows_listener_and_tunnel_connection_are_required(self) -> None:
cases = (
(
{
"api_listener": {
"port": 8001,
"owned_listener_count": 0,
"conflicting_listener_count": 0,
}
},
"api_listener_not_owned",
),
(
{
"processes": {
"api": {
"connections": 2,
"established": 1,
"listeners": 1,
},
"cloudflared": {
"connections": 0,
"established": 0,
"listeners": 0,
},
}
},
"cloudflared_tunnel_connection_missing",
),
)
for overrides, failure in cases:
with self.subTest(failure=failure):
source = FixtureWindowsSource(
self.runner_module,
tcp_overrides=overrides,
)
with self.assertRaises(self.runner_module.EvidenceFailure) as caught:
self.runner_module.capture_windows_evidence(
self.windows_config(samples=1),
FixtureRunner(),
source,
sleep=lambda _seconds: None,
)
self.assertEqual(failure, str(caught.exception))
def test_windows_config_rejects_shared_pid_and_outside_cwd(self) -> None:
cases = (
(
self.windows_config(cloudflared_pid=301),
"process_targets_not_distinct",
),
(
self.windows_config(api_cwd=r"C:\elsewhere"),
"api_cwd_outside_repo",
),
(
self.windows_config(git_tree_sha="not-a-tree"),
"git_tree_sha_invalid",
),
(
self.windows_config(psutil_version="6.1.0"),
"psutil_version_invalid",
),
)
for config, failure in cases:
with self.subTest(failure=failure):
with self.assertRaises(self.runner_module.EvidenceFailure) as caught:
self.runner_module.validate_windows_config(config)
self.assertEqual(failure, str(caught.exception))
if __name__ == "__main__":
unittest.main()

View file

@ -54,7 +54,7 @@ class FakeTranscriber:
def default_options(**overrides):
options = {
"model": "large-v3",
"model": MODULE.DEFAULT_MODEL,
"language": "ko",
"sample_rate": SAMPLE_RATE,
"channels": 1,
@ -403,6 +403,10 @@ class CliTest(unittest.TestCase):
with self.assertRaises(SystemExit):
parser.parse_args(["--host", "0.0.0.0"])
def test_default_model_matches_the_cpu_public_runtime_contract(self) -> None:
parser = MODULE.build_parser()
self.assertEqual(parser.parse_args([]).model, "small")
if __name__ == "__main__":
unittest.main()

View file

@ -106,6 +106,8 @@ class HealthPayloadTest(unittest.TestCase):
def test_health_declares_the_permissive_license_and_no_reference(self) -> None:
payload = MODULE.health_payload(FakeSynthesizer(), speakers=["KR"])
self.assertEqual(payload["status"], "ok")
self.assertEqual(payload["provider"], "melotts")
self.assertEqual(payload["model"], "melotts-korean")
self.assertEqual(payload["license"], "MIT")
self.assertEqual(payload["language"], "KR")
self.assertEqual(payload["sample_rate"], 44_100)

View file

@ -5,9 +5,9 @@ from __future__ import annotations
import importlib.util
import json
import sys
import tarfile
import tempfile
import unittest
import tarfile
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
@ -31,12 +31,25 @@ class FakeRunner:
module: Any
fail_stage: str | None = None
builder_shas: tuple[str, str] = ("a" * 64, "a" * 64)
tracked_status: str = ""
clean_archive_payloads: tuple[bytes, bytes] = (b"clean archive", b"clean archive")
clean_head: str = "b" * 40
clean_tree: str = "c" * 40
calls: list[str] = field(default_factory=list)
commands: dict[str, list[str]] = field(default_factory=dict)
working_directories: dict[str, Path | None] = field(default_factory=dict)
environments: dict[str, dict[str, str] | None] = field(default_factory=dict)
timeouts: dict[str, int | None] = field(default_factory=dict)
event_log: list[str] | None = None
def run(self, stage: str, argv: list[str], **_: Any):
def run(self, stage: str, argv: list[str], **kwargs: Any):
self.calls.append(stage)
self.commands[stage] = list(argv)
self.working_directories[stage] = kwargs.get("cwd")
self.environments[stage] = kwargs.get("env")
self.timeouts[stage] = kwargs.get("timeout")
if self.event_log is not None:
self.event_log.append(f"runner:{stage}")
if stage == self.fail_stage:
raise self.module.StageFailure(stage, "synthetic failure")
if stage.startswith("release_patch_run_"):
@ -57,6 +70,19 @@ class FakeRunner:
return self.module.CommandResult(0, json.dumps(payload), "")
if stage == "release_manifest":
return self.module.CommandResult(0, json.dumps({"ok": True}), "")
if stage == "clean_head_repository":
return self.module.CommandResult(0, str(kwargs["cwd"].resolve()) + "\n", "")
if stage.startswith("clean_head_worktree_"):
return self.module.CommandResult(0, self.tracked_status, "")
if stage.startswith("clean_head_head_"):
return self.module.CommandResult(0, self.clean_head + "\n", "")
if stage.startswith("clean_head_tree_"):
return self.module.CommandResult(0, self.clean_tree + "\n", "")
if stage.startswith("clean_head_archive_run_"):
run_index = int(stage.rsplit("_", 1)[1]) - 1
output_index = argv.index("--output")
Path(argv[output_index + 1]).write_bytes(self.clean_archive_payloads[run_index])
return self.module.CommandResult(0, "", "")
return self.module.CommandResult(0, f"{stage}: passed", "")
@ -67,9 +93,15 @@ class FakeDeployment:
rollback_matches: bool = True
tracked_api_image: str = "sha256:" + "1" * 64
calls: list[str] = field(default_factory=list)
event_log: list[str] | None = None
def _record(self, event: str) -> None:
self.calls.append(event)
if self.event_log is not None:
self.event_log.append(f"deployment:{event}")
def read_active_state(self):
self.calls.append("read_active_state")
self._record("read_active_state")
if self.active_sha is None:
return None
return {
@ -84,7 +116,7 @@ class FakeDeployment:
}
def snapshot(self):
self.calls.append("snapshot")
self._record("snapshot")
return self.module.DeploymentSnapshot(
api_image="sha256:" + "1" * 64,
web_image="sha256:" + "2" * 64,
@ -94,7 +126,7 @@ class FakeDeployment:
)
def promote(self, candidate: Path, desired_sha: str, snapshot: Any):
self.calls.append("promote")
self._record("promote")
return {
"active_sha": desired_sha,
"api_image": "sha256:" + "3" * 64,
@ -104,16 +136,16 @@ class FakeDeployment:
}
def commit_active_state(self, state: dict[str, Any]):
self.calls.append("commit_active_state")
self._record("commit_active_state")
self.active_sha = state["active_sha"]
def rollback(self, snapshot: Any):
self.calls.append("rollback")
self._record("rollback")
self.active_sha = snapshot.active_sha
return {"api_image": snapshot.api_image, "web_image": snapshot.web_image}
def verify_rollback(self, snapshot: Any):
self.calls.append("verify_rollback")
self._record("verify_rollback")
return {
"ok": self.rollback_matches,
"api_image": snapshot.api_image if self.rollback_matches else "sha256:" + "9" * 64,
@ -164,9 +196,20 @@ class FakeCandidate:
def __init__(self, root: Path):
self.root = root
self.cleaned = False
self.materialization_mode: str | None = None
self.archive_sha256: str | None = None
self.archive_bytes: bytes | None = None
def materialize(self, base_commit: str, patch_path: str):
del base_commit, patch_path
self.materialization_mode = "patch"
self.root.mkdir(parents=True, exist_ok=True)
return self.root
def materialize_archive(self, archive_path: Path, expected_sha256: str):
self.materialization_mode = "clean-head"
self.archive_sha256 = expected_sha256
self.archive_bytes = archive_path.read_bytes()
self.root.mkdir(parents=True, exist_ok=True)
return self.root
@ -268,17 +311,38 @@ class OutcomeReleaseAgentTests(unittest.TestCase):
execute: bool,
fail_stage: str | None = None,
builder_shas: tuple[str, str] = ("a" * 64, "a" * 64),
source_mode: str = "patch",
tracked_status: str = "",
clean_archive_payloads: tuple[bytes, bytes] = (
b"clean archive",
b"clean archive",
),
source_repo_root: Path | None = None,
runtime_fail_on_call: int | None = None,
rollback_matches: bool = True,
):
runner = FakeRunner(self.agent_module, fail_stage, builder_shas)
deployment = FakeDeployment(self.agent_module, active_sha, rollback_matches)
event_log: list[str] = []
runner = FakeRunner(
module=self.agent_module,
fail_stage=fail_stage,
builder_shas=builder_shas,
tracked_status=tracked_status,
clean_archive_payloads=clean_archive_payloads,
event_log=event_log,
)
deployment = FakeDeployment(
self.agent_module,
active_sha,
rollback_matches,
event_log=event_log,
)
runtime = FakeRuntimeProbe(self.agent_module, runtime_fail_on_call)
candidate = FakeCandidate(self.root / "candidate")
config = self.agent_module.ReleaseAgentConfig(
repo_root=self.root,
repo_root=source_repo_root or self.root,
manifest_path=self.manifest_path,
hunk_map_path=self.root / "docs/ops/hunk-map.json",
source_mode=source_mode,
execute=execute,
milestone=self.milestone,
target=self.agent_module.NAS_PREVIEW_TARGET,
@ -340,6 +404,157 @@ class OutcomeReleaseAgentTests(unittest.TestCase):
runner.calls,
)
def test_parser_keeps_patch_and_controller_repo_defaults(self) -> None:
parser = self.agent_module.build_parser()
default_args = parser.parse_args(["--milestone", "milestone.json"])
detached_root = self.root / "detached"
clean_args = parser.parse_args(
[
"--milestone",
"milestone.json",
"--source-mode",
"clean-head",
"--source-repo-root",
str(detached_root),
]
)
self.assertEqual("patch", default_args.source_mode)
self.assertEqual(self.agent_module.REPO_ROOT, default_args.source_repo_root)
self.assertEqual("clean-head", clean_args.source_mode)
self.assertEqual(detached_root, clean_args.source_repo_root)
def test_source_repo_root_must_be_an_absolute_existing_directory(self) -> None:
relative = Path("detached-source")
missing = self.root / "missing"
source_file = self.root / "source-file"
source_file.write_text("not a directory\n", encoding="utf-8")
for source_root, message in (
(relative, "must be absolute"),
(missing, "does not exist"),
(source_file, "not a directory"),
):
with self.subTest(source_root=source_root):
with self.assertRaisesRegex(self.agent_module.StageFailure, message):
self.agent_module.validate_source_repo_root(source_root)
def test_clean_head_override_runs_every_git_command_in_source_repo(self) -> None:
detached_root = self.root / "detached-source"
detached_root.mkdir()
archive_payload = b"detached clean-head archive"
agent, runner, _, _, candidate = self.make_agent(
active_sha="e" * 64,
execute=True,
source_mode="clean-head",
clean_archive_payloads=(archive_payload, archive_payload),
source_repo_root=detached_root,
)
report = agent.run()
git_stages = [
stage
for stage in runner.calls
if stage == "clean_head_repository"
or stage.startswith("clean_head_worktree_")
or stage.startswith("clean_head_head_")
or stage.startswith("clean_head_tree_")
or stage.startswith("clean_head_archive_run_")
]
self.assertEqual(9, len(git_stages))
self.assertEqual(
{detached_root},
{runner.working_directories[stage] for stage in git_stages},
)
self.assertEqual(str(detached_root), report["source"]["repo_root"])
self.assertEqual("clean-head", candidate.materialization_mode)
def test_clean_head_rejects_tracked_changes_and_ignores_untracked_in_query(self) -> None:
agent, runner, deployment, _, candidate = self.make_agent(
active_sha="e" * 64,
execute=True,
source_mode="clean-head",
tracked_status=" M docs/TODO.md\n",
)
with self.assertRaises(self.agent_module.ReleaseAgentFailure) as raised:
agent.run()
self.assertEqual("clean_head_worktree", raised.exception.report["failed_stage"])
self.assertEqual(
["clean_head_repository", "clean_head_worktree_before"],
runner.calls,
)
self.assertIn(
"--untracked-files=no",
runner.commands["clean_head_worktree_before"],
)
self.assertEqual([], deployment.calls)
self.assertIsNone(candidate.materialization_mode)
def test_clean_head_non_git_source_fails_before_identity_or_deployment(self) -> None:
agent, runner, deployment, _, candidate = self.make_agent(
active_sha="e" * 64,
execute=True,
source_mode="clean-head",
fail_stage="clean_head_repository",
)
with self.assertRaises(self.agent_module.ReleaseAgentFailure) as raised:
agent.run()
self.assertEqual("clean_head_repository", raised.exception.report["failed_stage"])
self.assertEqual(["clean_head_repository"], runner.calls)
self.assertEqual([], deployment.calls)
self.assertIsNone(candidate.materialization_mode)
def test_clean_head_archive_runs_must_be_byte_identical(self) -> None:
agent, runner, deployment, _, candidate = self.make_agent(
active_sha="e" * 64,
execute=True,
source_mode="clean-head",
clean_archive_payloads=(b"first archive", b"second archive"),
)
with self.assertRaises(self.agent_module.ReleaseAgentFailure) as raised:
agent.run()
self.assertEqual("clean_head_determinism", raised.exception.report["failed_stage"])
self.assertEqual(2, len([stage for stage in runner.calls if "archive_run" in stage]))
self.assertEqual([], deployment.calls)
self.assertIsNone(candidate.materialization_mode)
def test_clean_head_evidence_binds_exact_head_tree_and_archive(self) -> None:
archive_payload = b"deterministic clean-head archive"
expected_sha = self.agent_module.sha256_bytes(archive_payload)
agent, runner, deployment, _, candidate = self.make_agent(
active_sha="e" * 64,
execute=True,
source_mode="clean-head",
clean_archive_payloads=(archive_payload, archive_payload),
)
report = agent.run()
self.assertTrue(report["ok"])
self.assertEqual(expected_sha, report["desired_sha"])
self.assertEqual("clean-head", report["source_mode"])
self.assertEqual("b" * 40, report["source"]["head"])
self.assertEqual("c" * 40, report["source"]["tree"])
self.assertEqual(expected_sha, report["source"]["archive"]["sha256"])
self.assertEqual(len(archive_payload), report["source"]["archive"]["bytes"])
self.assertEqual(2, report["source"]["archive"]["deterministic_runs"])
self.assertEqual("clean-head", candidate.materialization_mode)
self.assertEqual(expected_sha, candidate.archive_sha256)
self.assertEqual(archive_payload, candidate.archive_bytes)
self.assertNotIn("release_manifest", runner.calls)
self.assertFalse(any(stage.startswith("release_patch_run_") for stage in runner.calls))
self.assertIn("promote", deployment.calls)
evidence = json.loads((self.root / "evidence.json").read_text(encoding="utf-8"))
self.assertEqual(report["source"], evidence["source"])
self.assertEqual("clean-head", evidence["source_mode"])
def test_same_deployment_sha_is_a_runtime_checked_noop(self) -> None:
agent, runner, deployment, runtime, candidate = self.make_agent(
active_sha="a" * 64,
@ -402,6 +617,8 @@ class OutcomeReleaseAgentTests(unittest.TestCase):
report = agent.run()
self.assertTrue(report["ok"])
self.assertEqual("deployed_ssot_sync_required", report["status"])
self.assertEqual("patch", report["source_mode"])
self.assertEqual("a" * 64, report["source"]["patch"]["sha256"])
self.assertEqual("a" * 64, report["active_sha"])
self.assertEqual("e" * 64, report["previous_sha"])
self.assertEqual(
@ -415,6 +632,7 @@ class OutcomeReleaseAgentTests(unittest.TestCase):
"web_api_contract",
"web_typecheck",
"web_build",
"source_insecure_context_e2e",
"session_e2e",
"postdeploy_browser_review",
],
@ -426,12 +644,87 @@ class OutcomeReleaseAgentTests(unittest.TestCase):
)
self.assertEqual(1, runtime.calls)
self.assertTrue(candidate.cleaned)
postdeploy_command = runner.commands["postdeploy_browser_review"]
test_index = postdeploy_command.index("test")
first_flag = next(
index
for index in range(test_index + 1, len(postdeploy_command))
if postdeploy_command[index].startswith("--")
)
self.assertEqual(
self.agent_module.POSTDEPLOY_NAS_E2E_SPECS,
tuple(postdeploy_command[test_index + 1 : first_flag]),
)
self.assertNotIn("--grep", postdeploy_command)
self.assertEqual(
{f"--project={project}" for project in self.agent_module.RELEASE_BROWSER_PROJECTS},
{value for value in postdeploy_command if value.startswith("--project=")},
)
self.assertEqual(
self.agent_module.RELEASE_E2E_TIMEOUT_SECONDS,
runner.timeouts["postdeploy_browser_review"],
)
self.assertEqual(
self.agent_module.NAS_PREVIEW_TARGET.base_url,
runner.environments["postdeploy_browser_review"]["PLAYWRIGHT_BASE_URL"],
)
self.assertEqual(
"1",
runner.environments["postdeploy_browser_review"]["PLAYWRIGHT_SKIP_WEB_SERVER"],
)
self.assertIn(
"e2e/self-directed-learning-loop.spec.ts",
self.agent_module.POSTDEPLOY_NAS_E2E_SPECS,
)
self.assertIn(
"e2e/deliberate-practice.spec.ts",
self.agent_module.POSTDEPLOY_NAS_E2E_SPECS,
)
self.assertNotIn(
"e2e/insecure-context-uuid.spec.ts",
self.agent_module.POSTDEPLOY_NAS_E2E_SPECS,
)
self.assertNotIn(
"e2e/returned-practice-db-closed-loop.spec.ts",
self.agent_module.POSTDEPLOY_NAS_E2E_SPECS,
)
self.assertEqual(
set(self.agent_module.RELEASE_UI_E2E_SPECS),
set(self.agent_module.POSTDEPLOY_NAS_E2E_SPECS)
| set(self.agent_module.POSTDEPLOY_SOURCE_ONLY_E2E_SPECS),
)
self.assertLess(
runner.event_log.index("runner:postdeploy_browser_review"),
runner.event_log.index("deployment:commit_active_state"),
)
self.assertTrue(
set(self.agent_module.RELEASE_UI_E2E_SPECS).issubset(
set(self.agent_module.POSTDEPLOY_NAS_E2E_SPECS).issubset(
set(runner.commands["session_e2e"])
)
)
self.assertNotIn(
"e2e/insecure-context-uuid.spec.ts",
runner.commands["session_e2e"],
)
source_command = runner.commands["source_insecure_context_e2e"]
self.assertIn("e2e/insecure-context-uuid.spec.ts", source_command)
self.assertIn("--project=chromium-desktop", source_command)
self.assertIn("--project=chromium-mobile", source_command)
source_environment = runner.environments["source_insecure_context_e2e"]
self.assertNotIn("PLAYWRIGHT_BASE_URL", source_environment)
self.assertNotIn("PLAYWRIGHT_SKIP_WEB_SERVER", source_environment)
self.assertEqual("127.0.0.1", source_environment["PLAYWRIGHT_HOST"])
self.assertEqual(
str(self.agent_module.SOURCE_ONLY_E2E_PORT),
source_environment["PLAYWRIGHT_PORT"],
)
self.assertEqual("1", source_environment["CI"])
self.assertEqual(15 * 60, self.agent_module.RELEASE_E2E_TIMEOUT_SECONDS)
self.assertIn(
"e2e/insecure-context-uuid.spec.ts",
self.agent_module.RELEASE_UI_E2E_SPECS,
)
self.assertEqual("patch", candidate.materialization_mode)
self.assertEqual(
(
"14_continuous_improvement.sql",
@ -445,6 +738,18 @@ class OutcomeReleaseAgentTests(unittest.TestCase):
self.assertEqual("e" * 64, evidence["previous_sha"])
self.assertEqual("a" * 64, evidence["deployment"]["desired_sha"])
self.assertEqual("required", evidence["ssot_sync"]["status"])
self.assertEqual(
list(self.agent_module.POSTDEPLOY_NAS_E2E_SPECS),
evidence["browser_review_proof"]["specs"],
)
self.assertEqual(
["e2e/insecure-context-uuid.spec.ts"],
evidence["browser_review_proof"]["source_only_candidate_specs"],
)
self.assertEqual(
["e2e/returned-practice-db-closed-loop.spec.ts"],
evidence["browser_review_proof"]["separate_disposable_db_specs"],
)
self.assertEqual(
[
"docs/dev_dashboard.html",
@ -480,7 +785,7 @@ class OutcomeReleaseAgentTests(unittest.TestCase):
self.assertTrue(candidate.cleaned)
def test_rollback_image_mismatch_remains_a_hard_failure(self) -> None:
agent, _, deployment, _, _ = self.make_agent(
agent, runner, deployment, _, _ = self.make_agent(
active_sha="e" * 64,
execute=True,
fail_stage="postdeploy_browser_review",
@ -491,6 +796,11 @@ class OutcomeReleaseAgentTests(unittest.TestCase):
self.assertEqual("failed", raised.exception.report["rollback"]["status"])
self.assertIn("rollback image proof mismatch", raised.exception.report["rollback"]["error"])
self.assertIn("verify_rollback", deployment.calls)
self.assertNotIn("commit_active_state", deployment.calls)
self.assertLess(
runner.event_log.index("runner:postdeploy_browser_review"),
runner.event_log.index("deployment:rollback"),
)
def test_postpromotion_runtime_retries_only_transient_readiness_failures(self) -> None:
agent, _, deployment, _, candidate = self.make_agent(
@ -653,6 +963,50 @@ class OutcomeReleaseAgentTests(unittest.TestCase):
self.assertEqual(b"candidate\r\n", (candidate / "README.md").read_bytes())
manager.cleanup()
def test_clean_head_candidate_is_archive_only_with_platform_normalization(self) -> None:
runner = CandidateArchiveRunner(self.agent_module)
manager = self.agent_module.CleanCandidateManager(self.root, runner)
archive_path = self.root / "clean-head.tar"
source = self.root / "source.txt"
source.write_bytes(b"exact committed bytes\r\n")
generated_api = self.root / "api.gen.ts"
generated_api.write_bytes(b"export type Exact = true;\r\n")
shell_script = self.root / "99_app_role.sh"
shell_script.write_bytes(b"#!/usr/bin/env bash\r\necho ready\r\n")
with tarfile.open(archive_path, "w") as archive:
archive.add(source, arcname="source.txt")
archive.add(generated_api, arcname="apps/web/src/lib/api.gen.ts")
archive.add(shell_script, arcname="infra/db/init/99_app_role.sh")
expected_sha = self.agent_module.sha256_bytes(archive_path.read_bytes())
candidate = manager.materialize_archive(archive_path, expected_sha)
self.assertEqual(b"exact committed bytes\r\n", (candidate / "source.txt").read_bytes())
self.assertEqual(
b"export type Exact = true;\n",
(candidate / "apps/web/src/lib/api.gen.ts").read_bytes(),
)
self.assertEqual(
b"#!/usr/bin/env bash\necho ready\n",
(candidate / "infra/db/init/99_app_role.sh").read_bytes(),
)
self.assertEqual([], runner.calls)
manager.cleanup()
def test_clean_head_candidate_rejects_archive_sha_drift(self) -> None:
runner = CandidateArchiveRunner(self.agent_module)
manager = self.agent_module.CleanCandidateManager(self.root, runner)
archive_path = self.root / "clean-head.tar"
archive_path.write_bytes(b"not the bound archive")
with self.assertRaisesRegex(
self.agent_module.StageFailure,
"archive SHA drifted",
):
manager.materialize_archive(archive_path, "a" * 64)
self.assertEqual([], runner.calls)
def test_active_state_rejects_noncanonical_compose_path_before_snapshot(self) -> None:
root = "/volume1/docker/vignette-preview-20260807"
runner = ActiveStateRunner(

View file

@ -0,0 +1,408 @@
from __future__ import annotations
import hashlib
import shutil
import subprocess
import tempfile
import unittest
from pathlib import Path
SCRIPTS = Path(__file__).resolve().parent
WATCHDOG = SCRIPTS / "watch-public-runtime.ps1"
INSTALLER = SCRIPTS / "install-public-runtime-task.ps1"
BOOT = SCRIPTS / "boot-public-runtime.ps1"
BOOT_REGISTER = SCRIPTS / "register-boot-task.ps1"
HIDDEN_TRIGGER = SCRIPTS / "watch-public-runtime-hidden.vbs"
START = SCRIPTS / "start-public-runtime.ps1"
REPO_ROOT = SCRIPTS.parent
RUNBOOK = REPO_ROOT / "docs" / "ops" / "public-runtime-watchdog.md"
LOCAL_DEVELOPMENT = REPO_ROOT / "docs" / "guides" / "local-development.md"
WATCHDOG_SOURCE = WATCHDOG.read_text(encoding="utf-8")
INSTALLER_SOURCE = INSTALLER.read_text(encoding="utf-8")
BOOT_SOURCE = BOOT.read_text(encoding="utf-8")
BOOT_REGISTER_SOURCE = BOOT_REGISTER.read_text(encoding="utf-8")
HIDDEN_TRIGGER_SOURCE = HIDDEN_TRIGGER.read_text(encoding="utf-8")
RUNBOOK_SOURCE = RUNBOOK.read_text(encoding="utf-8")
LOCAL_DEVELOPMENT_SOURCE = LOCAL_DEVELOPMENT.read_text(encoding="utf-8")
def sha256(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
class PublicRuntimeWatchdogProvenanceTest(unittest.TestCase):
def test_watchdog_requires_exact_source_and_script_pins(self) -> None:
for expected in (
"[string]$StableSourceRoot",
"[string]$ExpectedSourceCommit",
"[string]$ExpectedSourceTree",
"[string]$ExpectedWatchdogSha256",
"[string]$ExpectedStartScriptSha256",
'symbolic-ref --quiet HEAD',
'"status", "--porcelain=v1", "--untracked-files=normal"',
'"ls-files", "--error-unmatch"',
):
with self.subTest(expected=expected):
self.assertIn(expected, WATCHDOG_SOURCE)
self.assertGreaterEqual(WATCHDOG_SOURCE.count("[Parameter(Mandatory = $true)]"), 5)
def test_provenance_gate_precedes_health_and_runtime_recovery(self) -> None:
gate_comment = WATCHDOG_SOURCE.index(
"# health probe, failcount 기록, 프로세스 재기동보다 먼저"
)
gate = WATCHDOG_SOURCE.index("Assert-StableSourceProvenance", gate_comment)
health = WATCHDOG_SOURCE.index("$checks = @(")
restart = WATCHDOG_SOURCE.index("& $startScript @startArgs")
failcount_write = WATCHDOG_SOURCE.index("Set-FailCount 0", health)
self.assertLess(gate, health)
self.assertLess(gate, restart)
self.assertLess(gate, failcount_write)
def test_installer_pins_a_detached_clean_release_in_task_action(self) -> None:
for expected in (
"[Parameter(Mandatory = $true)]",
"[string]$StableSourceRoot",
"symbolic-ref --quiet HEAD",
'"status", "--porcelain=v1", "--untracked-files=normal"',
'"-ExpectedSourceCommit $sourceCommit"',
'"-ExpectedSourceTree $sourceTree"',
'"-ExpectedWatchdogSha256 $watchdogSha256"',
'"-ExpectedStartScriptSha256 $startScriptSha256"',
"-WorkingDirectory $resolvedSourceRoot",
"Watchdog installer is not executing from the pinned stable source root",
):
with self.subTest(expected=expected):
self.assertIn(expected, INSTALLER_SOURCE)
dirty_gate = INSTALLER_SOURCE.index("Stable source is not clean")
task_registration = INSTALLER_SOURCE.index("Register-ScheduledTask")
self.assertLess(dirty_gate, task_registration)
def test_boot_recovery_uses_the_same_gate_before_docker_or_process_mutation(self) -> None:
for expected in (
"[string]$StableSourceRoot",
"[string]$ExpectedSourceCommit",
"[string]$ExpectedSourceTree",
"[string]$ExpectedBootScriptSha256",
"[string]$ExpectedStartScriptSha256",
"symbolic-ref --quiet HEAD",
'"status", "--porcelain=v1", "--untracked-files=normal"',
):
with self.subTest(expected=expected):
self.assertIn(expected, BOOT_SOURCE)
gate_comment = BOOT_SOURCE.index(
"# Docker/DB/process mutation보다 먼저 stable source를 매 실행 재검증한다."
)
gate = BOOT_SOURCE.index("Assert-StableSourceProvenance", gate_comment)
docker_mutation = BOOT_SOURCE.index("docker.exe update")
runtime_recovery = BOOT_SOURCE.index("-File $startScript")
self.assertLess(gate, docker_mutation)
self.assertLess(gate, runtime_recovery)
def test_boot_task_registration_pins_the_same_detached_release(self) -> None:
for expected in (
"[string]$StableSourceRoot",
"symbolic-ref --quiet HEAD",
'"status", "--porcelain=v1", "--untracked-files=normal"',
'"-ExpectedSourceCommit $sourceCommit"',
'"-ExpectedSourceTree $sourceTree"',
'"-ExpectedBootScriptSha256 $bootScriptSha256"',
'"-ExpectedStartScriptSha256 $startScriptSha256"',
"-WorkingDirectory $resolvedSourceRoot",
"Boot task registrar is not executing from the pinned stable source root",
):
with self.subTest(expected=expected):
self.assertIn(expected, BOOT_REGISTER_SOURCE)
dirty_gate = BOOT_REGISTER_SOURCE.index("Stable source is not clean")
task_registration = BOOT_REGISTER_SOURCE.index("Register-ScheduledTask")
self.assertLess(dirty_gate, task_registration)
def test_hidden_trigger_never_executes_a_workspace_script_directly(self) -> None:
for expected in (
"VignettePublicRuntimeWatchdog",
"-StableSourceRoot",
"-ExpectedSourceCommit",
"-ExpectedSourceTree",
"-ExpectedWatchdogSha256",
"-ExpectedStartScriptSha256",
"WorkingDirectory",
"Pinned watchdog script path does not match its working directory",
"Pinned watchdog source root does not match its working directory",
"Start-ScheduledTask",
"Legacy shared-worktree watchdog action is forbidden",
):
with self.subTest(expected=expected):
self.assertIn(expected, HIDDEN_TRIGGER_SOURCE)
self.assertIn(
"Join-Path $root 'scripts\\watch-public-runtime.ps1'",
HIDDEN_TRIGGER_SOURCE,
)
self.assertNotIn('objShell.Run "powershell.exe', HIDDEN_TRIGGER_SOURCE)
self.assertNotIn("D:\\workspace\\vignette", HIDDEN_TRIGGER_SOURCE)
marker_gate = HIDDEN_TRIGGER_SOURCE.index("foreach($marker in $required)")
task_trigger = HIDDEN_TRIGGER_SOURCE.rindex("Start-ScheduledTask")
self.assertLess(marker_gate, task_trigger)
def test_active_docs_only_show_the_pinned_release_workflow(self) -> None:
stale_commands = (
"powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\watch-public-runtime.ps1 -CheckOnly",
"powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\install-public-runtime-task.ps1 -RunNow",
)
for source in (RUNBOOK_SOURCE, LOCAL_DEVELOPMENT_SOURCE):
for stale in stale_commands:
with self.subTest(document=source[:32], stale=stale):
self.assertNotIn(stale, source)
for expected in (
"detached HEAD",
"-StableSourceRoot",
"-ExpectedSourceCommit",
"-ExpectedSourceTree",
"-ExpectedWatchdogSha256",
"-ExpectedStartScriptSha256",
"--porcelain=v1 --untracked-files=normal",
):
with self.subTest(expected=expected):
self.assertIn(expected, RUNBOOK_SOURCE)
self.assertIn("-StableSourceRoot", LOCAL_DEVELOPMENT_SOURCE)
self.assertIn("watch-public-runtime-hidden.vbs", LOCAL_DEVELOPMENT_SOURCE)
@unittest.skipUnless(shutil.which("git.exe"), "git.exe is unavailable")
@unittest.skipUnless(shutil.which("powershell.exe"), "Windows PowerShell 5.1 is unavailable")
def test_dirty_detached_release_fails_before_health_or_runtime_mutation(self) -> None:
with tempfile.TemporaryDirectory(prefix="vignette-watchdog-") as temp:
root = Path(temp)
scripts = root / "scripts"
scripts.mkdir()
copied_watchdog = scripts / WATCHDOG.name
copied_installer = scripts / INSTALLER.name
copied_boot = scripts / BOOT.name
copied_boot_register = scripts / BOOT_REGISTER.name
copied_start = scripts / START.name
shutil.copy2(WATCHDOG, copied_watchdog)
shutil.copy2(INSTALLER, copied_installer)
shutil.copy2(BOOT, copied_boot)
shutil.copy2(BOOT_REGISTER, copied_boot_register)
shutil.copy2(START, copied_start)
self._git(root, "init")
self._git(root, "config", "user.name", "Watchdog Contract Test")
self._git(root, "config", "user.email", "watchdog-test@example.invalid")
self._git(
root,
"add",
"scripts/watch-public-runtime.ps1",
"scripts/install-public-runtime-task.ps1",
"scripts/boot-public-runtime.ps1",
"scripts/register-boot-task.ps1",
"scripts/start-public-runtime.ps1",
)
self._git(root, "commit", "-m", "watchdog fixture")
self._git(root, "checkout", "--detach")
commit = self._git(root, "rev-parse", "HEAD").stdout.strip()
tree = self._git(root, "rev-parse", "HEAD^{tree}").stdout.strip()
watchdog_hash = sha256(copied_watchdog)
boot_hash = sha256(copied_boot)
start_hash = sha256(copied_start)
with copied_start.open("ab") as stream:
stream.write(b"\n# dirty fixture\n")
completed = subprocess.run(
[
shutil.which("powershell.exe") or "powershell.exe",
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
str(copied_watchdog),
"-StableSourceRoot",
str(root),
"-ExpectedSourceCommit",
commit,
"-ExpectedSourceTree",
tree,
"-ExpectedWatchdogSha256",
watchdog_hash,
"-ExpectedStartScriptSha256",
start_hash,
"-CheckOnly",
"-SkipPublicHealth",
"-SkipCloudflaredRestart",
],
check=False,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=30,
)
detail = f"{completed.stdout}\n{completed.stderr}"
self.assertNotEqual(completed.returncode, 0, detail)
self.assertIn("Stable source is not clean", detail)
self.assertFalse((root / "public-runtime-watchdog.failcount").exists())
self.assertFalse((root / "public-runtime-watchdog.log").exists())
boot_completed = subprocess.run(
[
shutil.which("powershell.exe") or "powershell.exe",
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
str(copied_boot),
"-StableSourceRoot",
str(root),
"-ExpectedSourceCommit",
commit,
"-ExpectedSourceTree",
tree,
"-ExpectedBootScriptSha256",
boot_hash,
"-ExpectedStartScriptSha256",
start_hash,
],
check=False,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=30,
)
boot_detail = f"{boot_completed.stdout}\n{boot_completed.stderr}"
self.assertNotEqual(boot_completed.returncode, 0, boot_detail)
self.assertIn("Stable source is not clean", boot_detail)
self.assertFalse((root / "boot-public-runtime.log").exists())
for registrar in (copied_installer, copied_boot_register):
register_completed = subprocess.run(
[
shutil.which("powershell.exe") or "powershell.exe",
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
str(registrar),
"-StableSourceRoot",
str(root),
],
check=False,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=30,
)
register_detail = (
f"{register_completed.stdout}\n{register_completed.stderr}"
)
with self.subTest(registrar=registrar.name):
self.assertNotEqual(register_completed.returncode, 0, register_detail)
self.assertIn("Stable source is not clean", register_detail)
def test_windows_powershell_51_parser_accepts_scripts(self) -> None:
powershell = shutil.which("powershell.exe")
if powershell is None:
self.skipTest("Windows PowerShell 5.1 is unavailable")
for script in (WATCHDOG, INSTALLER, BOOT, BOOT_REGISTER):
escaped = str(script.resolve()).replace("'", "''")
command = (
"$tokens=$null; $errors=$null; "
"[System.Management.Automation.Language.Parser]::ParseFile("
f"'{escaped}', [ref]$tokens, [ref]$errors) | Out-Null; "
"if ($errors.Count -gt 0) { $errors | ForEach-Object { Write-Error $_ }; exit 1 }; "
"exit 0"
)
completed = subprocess.run(
[powershell, "-NoProfile", "-Command", command],
check=False,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
with self.subTest(script=script.name):
self.assertEqual(completed.returncode, 0, completed.stderr)
def test_windows_script_host_accepts_hidden_trigger_syntax(self) -> None:
cscript = shutil.which("cscript.exe")
if cscript is None:
self.skipTest("Windows Script Host is unavailable")
completed = subprocess.run(
[cscript, "//nologo", str(HIDDEN_TRIGGER), "syntax-only"],
check=False,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
self.assertEqual(completed.returncode, 0, completed.stderr)
def test_hidden_trigger_command_accepts_only_a_structurally_pinned_task(self) -> None:
cscript = shutil.which("cscript.exe")
powershell = shutil.which("powershell.exe")
if cscript is None or powershell is None:
self.skipTest("Windows Script Host or PowerShell 5.1 is unavailable")
rendered = subprocess.run(
[cscript, "//nologo", str(HIDDEN_TRIGGER), "print-command"],
check=False,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
self.assertEqual(rendered.returncode, 0, rendered.stderr)
prefix = (
"powershell.exe -NoProfile -ExecutionPolicy Bypass "
'-WindowStyle Hidden -Command "'
)
wrapped = rendered.stdout.strip()
self.assertTrue(wrapped.startswith(prefix), wrapped)
self.assertTrue(wrapped.endswith('"'), wrapped)
command = wrapped[len(prefix) : -1].replace('""', '"')
root = r"C:\Pinned Vignette Release"
arguments = (
f'-File "{root}\\scripts\\watch-public-runtime.ps1" '
f'-StableSourceRoot "{root}" '
f"-ExpectedSourceCommit {'a' * 40} "
f"-ExpectedSourceTree {'b' * 40} "
f"-ExpectedWatchdogSha256 {'c' * 64} "
f"-ExpectedStartScriptSha256 {'d' * 64}"
)
fixture = (
"$script:watchdogTriggered=$false;"
"function Get-ScheduledTask { param($TaskName,$ErrorAction) "
"$action=[pscustomobject]@{"
"Execute='C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe';"
f"WorkingDirectory='{root}';Arguments='{arguments}'"
"};[pscustomobject]@{Actions=@($action)}};"
"function Start-ScheduledTask { param($TaskName) "
"$script:watchdogTriggered=$true };"
f"{command};"
"if(-not $script:watchdogTriggered){exit 9};exit 0"
)
checked = subprocess.run(
[powershell, "-NoProfile", "-Command", fixture],
check=False,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
self.assertEqual(checked.returncode, 0, checked.stderr)
def _git(self, root: Path, *args: str) -> subprocess.CompletedProcess[str]:
completed = subprocess.run(
[shutil.which("git.exe") or "git.exe", "-C", str(root), *args],
check=False,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
self.assertEqual(completed.returncode, 0, completed.stderr)
return completed
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,144 @@
from __future__ import annotations
import importlib.util
import sys
import unittest
from pathlib import Path
from urllib.parse import parse_qs, urlsplit
SCRIPT_PATH = Path(__file__).with_name("probe-public-voice-sidecars.py")
SPEC = importlib.util.spec_from_file_location("public_voice_sidecar_probe", SCRIPT_PATH)
assert SPEC is not None and SPEC.loader is not None
MODULE = importlib.util.module_from_spec(SPEC)
sys.modules[SPEC.name] = MODULE
SPEC.loader.exec_module(MODULE)
class SttProbeContractTest(unittest.TestCase):
def _payload(self, **overrides: object) -> dict[str, object]:
payload: dict[str, object] = {
"type": "ready",
"provider": "local_whisper",
"model": "small",
"language": "ko",
"device": "cpu",
"compute_type": "int8",
"sample_rate": 16_000,
}
payload.update(overrides)
return payload
def test_exact_public_metadata_is_accepted(self) -> None:
result = MODULE.validate_stt_ready(
self._payload(),
provider="local_whisper",
model="small",
language="ko",
device="cpu",
)
self.assertEqual(result["compute_type"], "int8")
def test_provider_model_device_and_compute_type_mismatch_fail_closed(self) -> None:
for field, value in (
("provider", "deepgram"),
("model", "large-v3"),
("device", "cuda"),
("compute_type", "float16"),
):
with self.subTest(field=field):
with self.assertRaises(MODULE.ProbeError):
MODULE.validate_stt_ready(
self._payload(**{field: value}),
provider="local_whisper",
model="small",
language="ko",
device="cpu",
)
def test_probe_url_replaces_query_with_exact_stream_contract(self) -> None:
url = MODULE.build_stt_probe_url(
"ws://127.0.0.1:9882/v1/listen?model=base&channels=2",
model="small",
language="ko",
)
query = parse_qs(urlsplit(url).query)
self.assertEqual(query["model"], ["small"])
self.assertEqual(query["language"], ["ko"])
self.assertEqual(query["sample_rate"], ["16000"])
self.assertEqual(query["channels"], ["1"])
def test_non_loopback_urls_and_embedded_credentials_are_rejected(self) -> None:
for url in (
"wss://example.com/v1/listen",
"ws://user:secret@127.0.0.1:9882/v1/listen",
):
with self.subTest(url=url):
with self.assertRaises(MODULE.ProbeError):
MODULE.build_stt_probe_url(url, model="small", language="ko")
class TtsProbeContractTest(unittest.TestCase):
def _payload(self, **overrides: object) -> dict[str, object]:
payload: dict[str, object] = {
"status": "ok",
"provider": "melotts",
"model": "melotts-korean",
"language": "KR",
"license": "MIT",
"reference_policy": "pretrained-multispeaker-no-external-reference",
"sample_rate": 44_100,
"speakers": ["KR"],
}
payload.update(overrides)
return payload
def test_exact_public_metadata_is_accepted(self) -> None:
result = MODULE.validate_tts_health(
self._payload(),
provider="melotts",
model="melotts-korean",
language="KR",
)
self.assertEqual(result["license"], "MIT")
def test_provider_model_license_and_reference_mismatch_fail_closed(self) -> None:
for field, value in (
("provider", "openai"),
("model", "other"),
("license", "unknown"),
("reference_policy", "external-reference"),
):
with self.subTest(field=field):
with self.assertRaises(MODULE.ProbeError):
MODULE.validate_tts_health(
self._payload(**{field: value}),
provider="melotts",
model="melotts-korean",
language="KR",
)
def test_empty_speaker_catalog_and_invalid_sample_rate_fail_closed(self) -> None:
for override in ({"speakers": []}, {"sample_rate": 0}):
with self.subTest(override=override):
with self.assertRaises(MODULE.ProbeError):
MODULE.validate_tts_health(
self._payload(**override),
provider="melotts",
model="melotts-korean",
language="KR",
)
class CliContractTest(unittest.TestCase):
def test_defaults_are_the_public_local_stack(self) -> None:
args = MODULE.build_parser().parse_args([])
self.assertEqual(args.stt_provider, "local_whisper")
self.assertEqual(args.stt_model, "small")
self.assertEqual(args.stt_device, "cpu")
self.assertEqual(args.tts_provider, "melotts")
self.assertEqual(args.tts_model, "melotts-korean")
if __name__ == "__main__":
unittest.main()

View file

@ -18,20 +18,38 @@ SPEC.loader.exec_module(MODULE)
def args(**overrides):
base = {
"wss_url": "wss://api.example.test/voice/ws",
"origin": "https://api.example.test",
"origin": "https://web.example.test",
"allowed_browser_origins": ["https://web.example.test"],
"admin_runtime_url": "https://api.example.test/admin/voice-runtime",
"topology_mode": "linux-compose",
"compose_project": "vignette",
"api_container": "vignette-api-1",
"api_image_digest": "sha256:" + "a" * 64,
"caddy_container": "vignette-proxy-1",
"caddy_image_digest": "sha256:" + "b" * 64,
"repo_root": None,
"git_sha": None,
"git_tree_sha": None,
"runner_script_sha256": None,
"collector_script_sha256": None,
"checker_script_sha256": None,
"psutil_version": None,
"api_pid": None,
"api_executable_name": None,
"api_executable_sha256": None,
"api_cwd": None,
"api_listen_port": None,
"cloudflared_pid": None,
"cloudflared_executable_name": None,
"cloudflared_executable_sha256": None,
"cloudflared_cwd": None,
"expected_stt_provider": "local_whisper",
"expected_stt_model": "large-v3",
"expected_stt_model": "small",
"expected_tts_provider": "melotts",
"expected_tts_model": "melotts-korean",
"microphone_device": "",
"confirm_physical_capture": False,
"duration_seconds": 3_000.0,
"duration_seconds": 3_120.0,
"runtime_interval_seconds": 100.0,
"topology_interval_seconds": 100.0,
"human_voice_gain": Path("pack.json"),
@ -50,33 +68,110 @@ class FakeCompleted:
class HostAlignmentTest(unittest.TestCase):
def test_matching_hosts_are_accepted(self) -> None:
def test_matching_transport_hosts_are_accepted(self) -> None:
host = MODULE.assert_single_host(
"wss://api.example.test/voice/ws",
"https://api.example.test",
"https://api.example.test/admin/voice-runtime",
)
self.assertEqual(host, "api.example.test")
def test_mismatched_hosts_fail_closed(self) -> None:
with self.assertRaises(MODULE.WindowError):
def test_mismatched_transport_hosts_fail_closed_without_echoing_hosts(self) -> None:
with self.assertRaises(MODULE.WindowError) as caught:
MODULE.assert_single_host(
"wss://api.example.test/voice/ws", "https://other.example.test"
"wss://api.example.test/voice/ws",
"https://private-tenant.example.test/admin/voice-runtime",
)
self.assertEqual("transport_hosts_must_match", str(caught.exception))
self.assertNotIn("private-tenant", str(caught.exception))
def test_plan_rejects_a_split_window(self) -> None:
with self.assertRaises(MODULE.WindowError):
def test_browser_origin_is_independent_from_transport_host(self) -> None:
legs = MODULE.plan_legs(
args(microphone_device="mic", confirm_physical_capture=True),
Path("out"),
admin_probe=lambda origin: ["/admin/voice-runtime"],
)
self.assertEqual(legs[0].argv[legs[0].argv.index("--origin") + 1],
"https://web.example.test")
def test_plan_rejects_a_split_transport_window(self) -> None:
with self.assertRaises(MODULE.WindowError) as caught:
MODULE.plan_legs(
args(admin_runtime_url="https://elsewhere.test/admin/voice-runtime",
microphone_device="mic", confirm_physical_capture=True),
Path("out"),
admin_probe=lambda origin: ["/admin/voice-runtime"],
)
self.assertEqual("transport_hosts_must_match", str(caught.exception))
def test_browser_origin_requires_an_explicit_allowlist_match(self) -> None:
with self.assertRaises(MODULE.WindowError) as caught:
MODULE.plan_legs(
args(origin="https://unlisted.example.test"),
Path("out"),
admin_probe=lambda origin: ["/admin/voice-runtime"],
)
self.assertEqual("browser_origin_not_allowed", str(caught.exception))
def test_added_origin_does_not_remove_the_public_default(self) -> None:
normalized = MODULE.assert_browser_origin_allowed(
MODULE.DEFAULT_BROWSER_ORIGIN, ["https://preview.example.test"]
)
self.assertEqual(MODULE.DEFAULT_BROWSER_ORIGIN, normalized)
def test_transport_schemes_fail_closed_without_reflecting_urls(self) -> None:
cases = (
(
"ws://private-wss.example.test/voice/ws?token=secret",
"https://api.example.test/admin/voice-runtime",
"wss_url_scheme_invalid",
),
(
"wss://api.example.test/voice/ws",
"http://private-admin.example.test/admin/voice-runtime?email=secret",
"admin_runtime_url_scheme_invalid",
),
)
for wss_url, admin_url, expected in cases:
with self.subTest(expected=expected):
with self.assertRaises(MODULE.WindowError) as caught:
MODULE.assert_transport_schemes(wss_url, admin_url)
self.assertEqual(expected, str(caught.exception))
self.assertNotIn("secret", str(caught.exception))
def test_browser_origin_query_is_rejected_without_echoing_it(self) -> None:
secret_query = "https://web.example.test?email=person@example.test"
with self.assertRaises(MODULE.WindowError) as caught:
MODULE.assert_browser_origin_allowed(
secret_query, ["https://web.example.test"]
)
self.assertEqual("browser_origin_invalid", str(caught.exception))
self.assertNotIn("person@example.test", str(caught.exception))
def test_public_parser_defaults_match_browser_and_transport_contract(self) -> None:
parser = MODULE.build_parser()
parsed = parser.parse_args(["--out-dir", "out"])
self.assertEqual(MODULE.DEFAULT_WSS_URL, parsed.wss_url)
self.assertEqual(MODULE.DEFAULT_BROWSER_ORIGIN, parsed.origin)
self.assertEqual(MODULE.DEFAULT_ADMIN_RUNTIME_URL, parsed.admin_runtime_url)
self.assertEqual(3_120.0, parsed.duration_seconds)
self.assertIsNone(parsed.allowed_browser_origins)
help_text = parser.format_help()
for expected in (
"https://vignette.chanpaca.net",
"wss://api-vignette.chanpaca.net/voice/ws",
"stt_provider=local_whisper stt_model=small",
"tts_provider=melotts tts_model=melotts-korean",
"3120초",
):
self.assertIn(expected, help_text)
class SamplePlanTest(unittest.TestCase):
def test_samples_cover_the_whole_window(self) -> None:
self.assertEqual(MODULE.sample_plan(3_000.0, 100.0), 31)
self.assertEqual(MODULE.sample_plan(3_120.0, 100.0), 33)
def test_fractional_interval_always_gets_a_terminal_sample(self) -> None:
self.assertEqual(MODULE.sample_plan(3_121.0, 100.0), 33)
def test_invalid_plan_fails_closed(self) -> None:
for duration, interval in ((0, 100.0), (3_000.0, 0)):
@ -115,11 +210,14 @@ class ConsentGateTest(unittest.TestCase):
self.assertNotIn("--confirm-physical-capture", leg.argv)
self.assertNotIn("--microphone-device", leg.argv)
def test_production_window_shorter_than_fifty_minutes_is_rejected(self) -> None:
def test_three_thousand_second_capture_is_rejected_without_skew_margin(self) -> None:
with self.assertRaises(MODULE.WindowError) as ctx:
MODULE.validate(args(duration_seconds=600.0))
MODULE.validate(args(duration_seconds=3_000.0))
self.assertEqual(str(ctx.exception), "production_window_too_short")
def test_fifty_two_minute_capture_is_accepted(self) -> None:
MODULE.validate(args(duration_seconds=3_120.0))
def test_production_run_requires_the_human_pack(self) -> None:
with self.assertRaises(MODULE.WindowError) as ctx:
MODULE.validate(args(human_voice_gain=None))
@ -145,16 +243,88 @@ class LegCompositionTest(unittest.TestCase):
leg.argv[leg.argv.index("--public-host") + 1], "api.example.test"
)
def test_all_three_legs_share_one_window_length(self) -> None:
def test_windows_topology_leg_passes_exact_process_pins(self) -> None:
windows_args = args(
topology_mode="windows-host",
compose_project=None,
api_container=None,
api_image_digest=None,
caddy_container=None,
caddy_image_digest=None,
repo_root=r"D:\workspace\vignette",
git_sha="5" * 40,
git_tree_sha="6" * 40,
runner_script_sha256="7" * 64,
collector_script_sha256="8" * 64,
checker_script_sha256="9" * 64,
psutil_version="7.0.0",
api_pid=301,
api_executable_name="python.exe",
api_executable_sha256="3" * 64,
api_cwd=r"D:\workspace\vignette\apps\api",
api_listen_port=8001,
cloudflared_pid=302,
cloudflared_executable_name="cloudflared.exe",
cloudflared_executable_sha256="4" * 64,
cloudflared_cwd=r"D:\workspace\vignette",
)
MODULE.validate(windows_args)
leg = MODULE.build_topology_leg(windows_args, Path("topology.json"))
self.assertEqual("windows-host", leg.argv[leg.argv.index("--topology-mode") + 1])
self.assertEqual("301", leg.argv[leg.argv.index("--api-pid") + 1])
self.assertEqual(
"6" * 40, leg.argv[leg.argv.index("--git-tree-sha") + 1]
)
self.assertEqual(
"7" * 64, leg.argv[leg.argv.index("--runner-script-sha256") + 1]
)
self.assertEqual(
"8" * 64,
leg.argv[leg.argv.index("--collector-script-sha256") + 1],
)
self.assertEqual(
"9" * 64, leg.argv[leg.argv.index("--checker-script-sha256") + 1]
)
self.assertEqual("7.0.0", leg.argv[leg.argv.index("--psutil-version") + 1])
self.assertEqual(
"4" * 64,
leg.argv[leg.argv.index("--cloudflared-executable-sha256") + 1],
)
self.assertNotIn("--compose-project", leg.argv)
def test_each_topology_mode_requires_its_own_pins(self) -> None:
cases = (
(args(topology_mode="unknown"), "topology_mode_invalid"),
(args(compose_project=None), "topology_argument_required:compose-project"),
(
args(
topology_mode="windows-host",
compose_project=None,
api_container=None,
api_image_digest=None,
caddy_container=None,
caddy_image_digest=None,
),
"topology_argument_required:repo-root",
),
)
for invalid, failure in cases:
with self.subTest(failure=failure):
with self.assertRaises(MODULE.WindowError) as caught:
MODULE.validate(invalid)
self.assertEqual(failure, str(caught.exception))
def test_all_three_legs_share_the_margin_extended_window_length(self) -> None:
legs = MODULE.plan_legs(
args(rehearse=True, duration_seconds=3_000.0), Path("out"),
args(rehearse=True, duration_seconds=3_120.0), Path("out"),
admin_probe=lambda origin: ["/admin/voice-runtime"],
)
self.assertEqual([leg.name for leg in legs][1:], ["runtime", "topology"])
for leg in legs[1:]:
samples = int(leg.argv[leg.argv.index("--samples") + 1])
interval = float(leg.argv[leg.argv.index("--interval-seconds") + 1])
self.assertGreaterEqual(samples * interval, 3_000.0)
self.assertGreaterEqual((samples - 1) * interval, 3_120.0)
class WindowExecutionTest(unittest.TestCase):
@ -256,6 +426,31 @@ class ReportTest(unittest.TestCase):
self.assertTrue(closed["gate_closed"])
self.assertFalse(open_gate["gate_closed"])
def test_production_process_exit_is_bound_to_checker_and_closed_gate(self) -> None:
closed = MODULE.summarize(
self._results(), rehearse=False, checker_returncode=0
)
checker_failed = MODULE.summarize(
self._results(), rehearse=False, checker_returncode=1
)
leg_failed = MODULE.summarize(
self._results((0, 1, 0)), rehearse=False, checker_returncode=0
)
self.assertEqual(0, MODULE.process_exit_code(closed))
self.assertEqual(1, MODULE.process_exit_code(checker_failed))
self.assertEqual(1, MODULE.process_exit_code(leg_failed))
def test_rehearse_exit_reports_leg_health_but_never_closes_gate(self) -> None:
passed = MODULE.summarize(
self._results(), rehearse=True, checker_returncode=None
)
failed = MODULE.summarize(
self._results((0, 1, 0)), rehearse=True, checker_returncode=None
)
self.assertEqual(0, MODULE.process_exit_code(passed))
self.assertEqual(1, MODULE.process_exit_code(failed))
self.assertFalse(passed["gate_closed"])
def test_failed_leg_is_reported(self) -> None:
report = MODULE.summarize(
self._results((0, 1, 0)), rehearse=False, checker_returncode=None

View file

@ -0,0 +1,331 @@
from __future__ import annotations
import unittest
import shutil
import subprocess
import tempfile
from pathlib import Path
SCRIPTS = Path(__file__).resolve().parent
PUBLIC_RUNTIME = (SCRIPTS / "start-public-runtime.ps1").read_text(encoding="utf-8")
WHISPER_START = (SCRIPTS / "start-local-whisper-stt.ps1").read_text(
encoding="utf-8"
)
class PublicRuntimeVoiceContractTest(unittest.TestCase):
def test_public_provider_model_and_loopback_environment_are_explicit(self) -> None:
for expected in (
'$WhisperModel = "small"',
'$WhisperDevice = "cpu"',
'$MeloTtsModel = "melotts-korean"',
'$env:VIGNETTE_VOICE_STT_PROVIDER = "local_whisper"',
'$env:VIGNETTE_LOCAL_WHISPER_STT_MODEL = $WhisperModel',
'$env:VIGNETTE_VOICE_TTS_PROVIDER = "melotts"',
'"ws://127.0.0.1:$WhisperPort/v1/listen"',
'"http://127.0.0.1:$MeloTtsPort"',
):
with self.subTest(expected=expected):
self.assertIn(expected, PUBLIC_RUNTIME)
def test_existing_listeners_require_exact_protocol_metadata(self) -> None:
self.assertIn('Test-VoiceSidecarReady -Component "stt"', PUBLIC_RUNTIME)
self.assertIn('Test-VoiceSidecarReady -Component "tts"', PUBLIC_RUNTIME)
self.assertIn("if (Test-PortListener -Port $WhisperPort)", PUBLIC_RUNTIME)
self.assertIn("if (Test-PortListener -Port $MeloTtsPort)", PUBLIC_RUNTIME)
self.assertIn("does not expose the exact local_whisper", PUBLIC_RUNTIME)
self.assertIn("does not expose the exact melotts", PUBLIC_RUNTIME)
def test_sidecar_fail_closed_gate_precedes_api_mutation(self) -> None:
sidecar_gate = PUBLIC_RUNTIME.index(
'# 두 sidecar를 한 번 더 함께 검사해 개별 probe 사이의 TOCTOU를 닫는다.'
)
api_stop = PUBLIC_RUNTIME.index("$apiStoppedProcessIds = @(")
self.assertLess(sidecar_gate, api_stop)
self.assertLess(
PUBLIC_RUNTIME.index("Voice sidecar readiness changed"),
api_stop,
)
def test_api_skip_and_post_start_checks_require_exact_voice_health(self) -> None:
for expected in (
'$Health.stt_provider -eq "local_whisper"',
"$Health.stt_model -eq $WhisperModel",
'$Health.tts_provider -eq "melotts"',
"$Health.tts_model -eq $MeloTtsModel",
"$Health.limits.uvicorn_ws_max_queue -eq 4",
"$health.engine -eq $true",
):
with self.subTest(expected=expected):
self.assertIn(expected, PUBLIC_RUNTIME)
self.assertGreaterEqual(
PUBLIC_RUNTIME.count("Test-VoiceApiReady -Health"),
3,
)
def test_api_uvicorn_websocket_queue_is_pinned(self) -> None:
start = PUBLIC_RUNTIME.index("$apiStoppedProcessIds = @(")
end = PUBLIC_RUNTIME.index("Start-Sleep -Seconds 3", start)
api_section = PUBLIC_RUNTIME[start:end]
self.assertIn('"app.main:app"', api_section)
self.assertIn('"--ws", "websockets"', api_section)
self.assertIn('"--ws-max-queue", "4"', api_section)
def test_standalone_whisper_launcher_matches_public_cpu_default(self) -> None:
self.assertIn("[string]$Model = 'small'", WHISPER_START)
self.assertIn("[string]$Device = 'cpu'", WHISPER_START)
class FreshPublicProvenanceContractTest(unittest.TestCase):
def test_fresh_mode_is_explicit_and_fail_closed(self) -> None:
for expected in (
"[switch]$RequireFreshPublicProvenance",
'throw "-RequireFreshPublicProvenance requires -ForceApiRestart"',
'throw "-RequireFreshPublicProvenance forbids -SkipCloudflaredRestart"',
"ExpectedSourceCommit",
"ExpectedSourceTree",
"ExpectedPythonSha256",
"ExpectedCloudflaredSha256",
"ExpectedCloudflaredConfigSha256",
):
with self.subTest(expected=expected):
self.assertIn(expected, PUBLIC_RUNTIME)
def test_fresh_gate_precedes_every_runtime_mutation(self) -> None:
gate = PUBLIC_RUNTIME.index("Assert-FreshPublicProvenanceContract `")
receipt_preflight = PUBLIC_RUNTIME.index(
"$resolvedRuntimeProvenancePath = Initialize-RuntimeProvenanceOutput"
)
exact_ingress = PUBLIC_RUNTIME.index("-RequireUnchanged", gate)
engine_probe = PUBLIC_RUNTIME.index("$engineHealth = Get-JsonHealth")
self.assertLess(gate, receipt_preflight)
self.assertLess(receipt_preflight, exact_ingress)
self.assertLess(gate, exact_ingress)
self.assertLess(exact_ingress, engine_probe)
self.assertIn("requires detached HEAD", PUBLIC_RUNTIME)
self.assertIn("requires a clean stable source", PUBLIC_RUNTIME)
def test_receipt_preflight_proves_sibling_atomic_replace_capability(self) -> None:
preflight = PUBLIC_RUNTIME[
PUBLIC_RUNTIME.index("function Initialize-RuntimeProvenanceOutput") :
PUBLIC_RUNTIME.index("function Write-Utf8TextAtomically")
]
for expected in (
"output preflight failed before runtime mutation",
"[System.IO.FileMode]::Open",
"[System.IO.FileAccess]::ReadWrite",
"[System.IO.File]::Replace($probeSource, $probeTarget, $probeBackup)",
"[System.IO.File]::Delete($probeTarget)",
):
with self.subTest(expected=expected):
self.assertIn(expected, preflight)
def test_receipt_commit_is_atomic_and_failure_is_explicit(self) -> None:
writer = PUBLIC_RUNTIME[
PUBLIC_RUNTIME.index("function Write-Utf8TextAtomically") :
PUBLIC_RUNTIME.index("function Assert-FreshPublicProvenanceContract")
]
for expected in (
"[System.IO.FileMode]::CreateNew",
"$stream.Flush($true)",
"[System.IO.File]::Replace($temporaryPath, $OutputPath, $backupPath)",
"[System.IO.File]::Move($temporaryPath, $OutputPath)",
"[System.IO.File]::Delete($temporaryPath)",
):
with self.subTest(expected=expected):
self.assertIn(expected, writer)
receipt = PUBLIC_RUNTIME[PUBLIC_RUNTIME.index("$provenance = [ordered]@{") :]
self.assertIn("Write-Utf8TextAtomically `", receipt)
self.assertIn("no atomic passed receipt was published", receipt)
self.assertNotIn(
"[System.IO.File]::WriteAllText(\n $resolvedRuntimeProvenancePath",
receipt,
)
def test_receipt_helpers_work_in_windows_powershell(self) -> None:
powershell = shutil.which("powershell.exe")
if powershell is None:
self.skipTest("Windows PowerShell 5.1 is not available")
function_source = PUBLIC_RUNTIME[
PUBLIC_RUNTIME.index("function Initialize-RuntimeProvenanceOutput") :
PUBLIC_RUNTIME.index("function Assert-FreshPublicProvenanceContract")
]
with tempfile.TemporaryDirectory() as temporary_directory:
root = Path(temporary_directory)
functions_path = root / "receipt-functions.ps1"
harness_path = root / "receipt-harness.ps1"
functions_path.write_text(function_source, encoding="utf-8-sig")
quoted_functions = str(functions_path).replace("'", "''")
quoted_root = str(root).replace("'", "''")
harness_path.write_text(
f"""$ErrorActionPreference = 'Stop'
. '{quoted_functions}'
$target = Join-Path '{quoted_root}' 'nested\\receipt.json'
$resolved = Initialize-RuntimeProvenanceOutput -OutputPath $target
Write-Utf8TextAtomically -OutputPath $resolved -Value '{{"status":"passed"}}'
$receipt = Get-Content -LiteralPath $resolved -Raw -Encoding UTF8 | ConvertFrom-Json
if ($receipt.status -ne 'passed') {{ throw 'atomic receipt content mismatch' }}
Write-Utf8TextAtomically -OutputPath $resolved -Value '{{"status":"replaced"}}'
$replacement = Get-Content -LiteralPath $resolved -Raw -Encoding UTF8 | ConvertFrom-Json
if ($replacement.status -ne 'replaced') {{ throw 'atomic replacement mismatch' }}
if (@(Get-ChildItem -LiteralPath (Split-Path -Parent $resolved) -Filter '*.tmp').Count -ne 0) {{
throw 'temporary receipt files were not cleaned'
}}
[System.IO.File]::SetAttributes($resolved, [System.IO.FileAttributes]::ReadOnly)
$preflightFailed = $false
try {{
Initialize-RuntimeProvenanceOutput -OutputPath $resolved | Out-Null
}} catch {{
$preflightFailed = $true
}} finally {{
[System.IO.File]::SetAttributes($resolved, [System.IO.FileAttributes]::Normal)
}}
if (-not $preflightFailed) {{ throw 'read-only receipt preflight did not fail' }}
$preserved = Get-Content -LiteralPath $resolved -Raw -Encoding UTF8 | ConvertFrom-Json
if ($preserved.status -ne 'replaced') {{ throw 'failed preflight changed the prior receipt' }}
""",
encoding="utf-8-sig",
)
completed = subprocess.run(
[
powershell,
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-File",
str(harness_path),
],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=30,
check=False,
)
self.assertEqual(
completed.returncode,
0,
msg=f"stdout={completed.stdout}\nstderr={completed.stderr}",
)
def test_force_api_restart_keeps_regular_healthy_semantics(self) -> None:
self.assertIn(
"if ($apiControlPlaneReady -and -not $ForceApiRestart)",
PUBLIC_RUNTIME,
)
fresh_guard = PUBLIC_RUNTIME.index(
'throw "-RequireFreshPublicProvenance requires -ForceApiRestart"'
)
api_skip = PUBLIC_RUNTIME.index(
"if ($apiControlPlaneReady -and -not $ForceApiRestart)"
)
self.assertLess(fresh_guard, api_skip)
def test_api_and_tunnel_are_bounded_replacements_with_exact_cwds(self) -> None:
api = PUBLIC_RUNTIME.index("$apiStoppedProcessIds = @(")
cloud = PUBLIC_RUNTIME.index("$cloudflaredStoppedProcessIds = @(")
receipt = PUBLIC_RUNTIME.index("$provenance = [ordered]@{")
api_section = PUBLIC_RUNTIME[api:cloud]
cloud_section = PUBLIC_RUNTIME[cloud:receipt]
self.assertIn("Stop-UvicornByPort `", api_section)
self.assertIn("-TimeoutSec $ProcessStopTimeoutSeconds", api_section)
self.assertIn("-WorkingDirectory $ApiDir", api_section)
self.assertIn("did not receive a replacement PID", api_section)
self.assertIn("Stop-ProcessesBounded `", cloud_section)
self.assertIn("-TimeoutSec $ProcessStopTimeoutSeconds", cloud_section)
self.assertIn("-WorkingDirectory $Workspace", cloud_section)
self.assertIn("Cloudflared did not receive a replacement PID", cloud_section)
self.assertNotIn("Cloudflared already running; skipping", PUBLIC_RUNTIME)
def test_tunnel_command_and_config_are_pinned_before_receipt(self) -> None:
for expected in (
'"tunnel", "--config", $resolvedCloudflaredConfig, "run"',
"command line is not pinned to the expected config",
"Pinned cloudflared config drifted before provenance receipt",
"finalConfigSha256",
):
with self.subTest(expected=expected):
self.assertIn(expected, PUBLIC_RUNTIME)
def test_fresh_tunnel_stop_matches_only_the_resolved_full_config_path(self) -> None:
cloud = PUBLIC_RUNTIME.index("$resolvedCloudflaredConfig =")
start = PUBLIC_RUNTIME.index(
"$cloudflaredProcess = Start-Process", cloud
)
cloud_section = PUBLIC_RUNTIME[cloud:start]
self.assertIn("if ($RequireFreshPublicProvenance)", cloud_section)
self.assertIn("-ConfigPath $resolvedCloudflaredConfig `", cloud_section)
self.assertIn("-ExactPath", cloud_section)
matcher = PUBLIC_RUNTIME[
PUBLIC_RUNTIME.index("function Get-CloudflaredProcessesForConfig") :
PUBLIC_RUNTIME.index("function Wait-ProcessIdentity")
]
self.assertIn("-not $ExactPath", matcher)
self.assertIn("IndexOf($ConfigPath", matcher)
def test_receipt_projects_no_raw_command_or_config_contents(self) -> None:
projection = PUBLIC_RUNTIME[
PUBLIC_RUNTIME.index("function ConvertTo-SafeProcessIdentity") :
PUBLIC_RUNTIME.index("function Stop-NodeByPortHint")
]
self.assertIn("command_line_sha256 =", projection)
self.assertNotIn("command_line =", projection)
self.assertNotIn("executable_path =", projection)
receipt = PUBLIC_RUNTIME.index("$provenance = [ordered]@{")
receipt_section = PUBLIC_RUNTIME[receipt:]
self.assertIn("api = $safeApiIdentity", receipt_section)
self.assertIn("cloudflared = $safeCloudflaredIdentity", receipt_section)
self.assertNotIn("api = $apiFinalIdentity", receipt_section)
self.assertNotIn("cloudflared = $cloudflaredFinalIdentity", receipt_section)
config_projection = receipt_section[
receipt_section.index("config = [ordered]@{") :
receipt_section.index("replacement = [ordered]@{")
].lower()
self.assertNotIn("token", config_projection)
self.assertNotIn("credential", config_projection)
self.assertNotIn("contents", config_projection)
def test_process_start_and_command_hash_match_topology_psutil_algorithm(self) -> None:
identity = PUBLIC_RUNTIME[
PUBLIC_RUNTIME.index("function Wait-ProcessIdentity") :
PUBLIC_RUNTIME.index("function ConvertTo-SafeProcessIdentity")
]
self.assertIn("datetime.fromtimestamp(p.create_time(), UTC)", identity)
self.assertIn("chr(0).join(p.cmdline())", identity)
self.assertIn("command_line_sha256 = $commandLineSha256", identity)
def test_receipt_is_direct_input_for_windows_topology_capture(self) -> None:
receipt = PUBLIC_RUNTIME.index(
'schema_version = "vignette.public-runtime-launch-provenance.v1"'
)
receipt_section = PUBLIC_RUNTIME[receipt:]
for expected in (
"git_commit = $ExpectedSourceCommit.ToLowerInvariant()",
"git_tree = $ExpectedSourceTree.ToLowerInvariant()",
"api_stopped_pids",
"cloudflared_stopped_pids",
"api_new_pid",
"cloudflared_new_pid",
"started_at_utc",
"executable_sha256",
"command_line_sha256",
"api_pid",
"api_executable_name",
"api_cwd",
"cloudflared_pid",
"cloudflared_executable_name",
"cloudflared_cwd",
"psutil_version",
):
with self.subTest(expected=expected):
self.assertIn(expected, receipt_section)
if __name__ == "__main__":
unittest.main()

View file

@ -1,3 +1,36 @@
Option Explicit
If WScript.Arguments.Count > 0 Then
If LCase(WScript.Arguments(0)) = "syntax-only" Then
WScript.Quit 0
End If
End If
Dim objShell
Dim command
Dim exitCode
Set objShell = CreateObject("WScript.Shell")
cmd = "powershell.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File ""D:\workspace\vignette\scripts\watch-public-runtime.ps1"" -Workspace ""D:\workspace\vignette"""
objShell.Run cmd, 0, True
command = "$ErrorActionPreference='Stop';" & _
"$task=Get-ScheduledTask -TaskName 'VignettePublicRuntimeWatchdog' -ErrorAction Stop;" & _
"$action=@($task.Actions)[0];" & _
"if([IO.Path]::GetFileName($action.Execute) -ne 'powershell.exe'){throw 'Pinned watchdog task must execute powershell.exe'};" & _
"$root=$action.WorkingDirectory;if([string]::IsNullOrWhiteSpace($root)){throw 'Pinned watchdog working directory is missing'};" & _
"$quote=[char]34;$expectedScript=Join-Path $root 'scripts\watch-public-runtime.ps1';" & _
"$expectedFileArg='-File '+$quote+$expectedScript+$quote;$expectedRootArg='-StableSourceRoot '+$quote+$root+$quote;" & _
"if($action.Arguments.IndexOf($expectedFileArg,[StringComparison]::OrdinalIgnoreCase) -lt 0){throw 'Pinned watchdog script path does not match its working directory'};" & _
"if($action.Arguments.IndexOf($expectedRootArg,[StringComparison]::OrdinalIgnoreCase) -lt 0){throw 'Pinned watchdog source root does not match its working directory'};" & _
"$required=@('-StableSourceRoot','-ExpectedSourceCommit','-ExpectedSourceTree','-ExpectedWatchdogSha256','-ExpectedStartScriptSha256');" & _
"foreach($marker in $required){if($action.Arguments.IndexOf($marker,[StringComparison]::Ordinal) -lt 0){throw ('Unpinned watchdog task action: missing '+$marker)}};" & _
"if($action.Arguments -match '(?i)(?:^|\s)-Workspace(?:\s|$)'){throw 'Legacy shared-worktree watchdog action is forbidden'};" & _
"Start-ScheduledTask -TaskName 'VignettePublicRuntimeWatchdog'"
command = "powershell.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -Command """ & _
Replace(command, """", """""") & """"
If WScript.Arguments.Count > 0 Then
If LCase(WScript.Arguments(0)) = "print-command" Then
WScript.Echo command
WScript.Quit 0
End If
End If
exitCode = objShell.Run(command, 0, True)
WScript.Quit exitCode

View file

@ -1,5 +1,18 @@
param(
[string]$Workspace = "D:\workspace\vignette",
[Parameter(Mandatory = $true)]
[string]$StableSourceRoot,
[Parameter(Mandatory = $true)]
[ValidatePattern("^[0-9a-fA-F]{40}$")]
[string]$ExpectedSourceCommit,
[Parameter(Mandatory = $true)]
[ValidatePattern("^[0-9a-fA-F]{40}$")]
[string]$ExpectedSourceTree,
[Parameter(Mandatory = $true)]
[ValidatePattern("^[0-9a-fA-F]{64}$")]
[string]$ExpectedWatchdogSha256,
[Parameter(Mandatory = $true)]
[ValidatePattern("^[0-9a-fA-F]{64}$")]
[string]$ExpectedStartScriptSha256,
[int]$ApiPort = 8001,
[int]$WebPort = 5174,
[int]$EnginePort = 9099,
@ -20,12 +33,97 @@
$ErrorActionPreference = "Stop"
$resolvedSourceRoot = (Resolve-Path -LiteralPath $StableSourceRoot).Path
$expectedWatchdogPath = Join-Path $resolvedSourceRoot "scripts\watch-public-runtime.ps1"
$startScript = Join-Path $resolvedSourceRoot "scripts\start-public-runtime.ps1"
function Invoke-GitText {
param([string[]]$Arguments)
$value = & git.exe -C $resolvedSourceRoot @Arguments
if ($LASTEXITCODE -ne 0) {
throw "Stable source Git command failed (exit=$LASTEXITCODE): git $($Arguments -join ' ')"
}
return (@($value) -join [Environment]::NewLine).Trim()
}
function Assert-StableSourceProvenance {
if (-not (Test-Path -LiteralPath $expectedWatchdogPath -PathType Leaf)) {
throw "Pinned watchdog script not found at $expectedWatchdogPath"
}
if (-not (Test-Path -LiteralPath $startScript -PathType Leaf)) {
throw "Pinned start script not found at $startScript"
}
$runningWatchdogPath = (Resolve-Path -LiteralPath $PSCommandPath).Path
if (-not [string]::Equals(
$runningWatchdogPath,
(Resolve-Path -LiteralPath $expectedWatchdogPath).Path,
[System.StringComparison]::OrdinalIgnoreCase
)) {
throw "Watchdog is not executing from the pinned stable source root"
}
$gitRoot = Invoke-GitText -Arguments @("rev-parse", "--show-toplevel")
$resolvedGitRoot = (Resolve-Path -LiteralPath $gitRoot).Path
if (-not [string]::Equals(
$resolvedGitRoot,
$resolvedSourceRoot,
[System.StringComparison]::OrdinalIgnoreCase
)) {
throw "Stable source root does not match its Git toplevel"
}
$symbolicHead = & git.exe -C $resolvedSourceRoot symbolic-ref --quiet HEAD
$symbolicHeadExit = $LASTEXITCODE
if ($symbolicHeadExit -eq 0) {
throw "Stable source must be a detached HEAD, not branch $symbolicHead"
}
if ($symbolicHeadExit -ne 1) {
throw "Could not prove detached HEAD (git exit=$symbolicHeadExit)"
}
$actualCommit = Invoke-GitText -Arguments @("rev-parse", "--verify", "HEAD")
$actualTree = Invoke-GitText -Arguments @("rev-parse", "--verify", "HEAD^{tree}")
if ($actualCommit -ne $ExpectedSourceCommit.ToLowerInvariant()) {
throw "Stable source commit drift: expected=$ExpectedSourceCommit actual=$actualCommit"
}
if ($actualTree -ne $ExpectedSourceTree.ToLowerInvariant()) {
throw "Stable source tree drift: expected=$ExpectedSourceTree actual=$actualTree"
}
$dirty = Invoke-GitText -Arguments @("status", "--porcelain=v1", "--untracked-files=normal")
if ($dirty) {
throw "Stable source is not clean; refusing runtime recovery"
}
foreach ($relativePath in @(
"scripts/watch-public-runtime.ps1",
"scripts/start-public-runtime.ps1"
)) {
Invoke-GitText -Arguments @("ls-files", "--error-unmatch", "--", $relativePath) | Out-Null
}
$actualWatchdogSha256 = (Get-FileHash -LiteralPath $expectedWatchdogPath -Algorithm SHA256).Hash.ToLowerInvariant()
$actualStartScriptSha256 = (Get-FileHash -LiteralPath $startScript -Algorithm SHA256).Hash.ToLowerInvariant()
if ($actualWatchdogSha256 -ne $ExpectedWatchdogSha256.ToLowerInvariant()) {
throw "Pinned watchdog SHA256 drift"
}
if ($actualStartScriptSha256 -ne $ExpectedStartScriptSha256.ToLowerInvariant()) {
throw "Pinned start script SHA256 drift"
}
}
# health probe, failcount 기록, 프로세스 재기동보다 먼저 source provenance를 닫는다.
# 검증 실패는 운영 프로세스를 그대로 보존한 채 non-zero로 끝난다.
Assert-StableSourceProvenance
if (!$LogPath) {
$LogPath = Join-Path $Workspace "public-runtime-watchdog.log"
$LogPath = Join-Path $resolvedSourceRoot "public-runtime-watchdog.log"
}
# 연속 실패 카운터(재시작 debounce용). 워치독은 매 실행마다 새 프로세스라 파일로 유지한다.
$FailCountPath = Join-Path $Workspace "public-runtime-watchdog.failcount"
$FailCountPath = Join-Path $resolvedSourceRoot "public-runtime-watchdog.failcount"
function Get-FailCount {
if (Test-Path $FailCountPath) {
@ -130,11 +228,6 @@ function Test-CloudflaredProcess {
}
}
$startScript = Join-Path $PSScriptRoot "start-public-runtime.ps1"
if (!(Test-Path $startScript)) {
throw "Start script not found at $startScript"
}
# engine 판정은 /health(프로세스 liveness)가 아니라 /ready(실제 claude -p 생성)로 한다.
# /health는 ok:true만 보므로 "프로세스는 살아 있고 그 프로세스의 claude 세션만 죽은"
# 상태를 통과시킨다(2026-08-07 공개 런타임: engine=false인데 워치독 lastResult=0).
@ -197,7 +290,7 @@ if ($failCount -lt $FailuresBeforeRestart) {
}
$startArgs = @{
Workspace = $Workspace
Workspace = $resolvedSourceRoot
ApiPort = $ApiPort
WebPort = $WebPort
EnginePort = $EnginePort