Compose returned release browser safety candidate with current source

Preserve the reviewed denial and recovery corrections for an additive least-privilege registration repair. No native acceptance is inferred.

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-10-04 04:46:24 +01:00
8 changed files with 1294 additions and 16 deletions
@@ -0,0 +1,165 @@
name: Qualify Published RC Browser Update
run-name: Browser update ${{ inputs.tag }} at ${{ inputs.expected_workflow_sha }}
# Control runs only from reviewed public main. No source build, production
# target, publishing credential, old stopped-target retry or arbitrary URL.
on:
workflow_dispatch:
inputs:
tag:
description: 'Exact published immutable vX.Y.Z-rc.N'
required: true
type: string
expected_workflow_sha:
description: 'Exact reviewed main control SHA'
required: true
type: string
permissions:
contents: read
concurrency:
group: browser-release-${{ inputs.tag }}
cancel-in-progress: false
jobs:
journey:
runs-on: ubuntu-24.04
timeout-minutes: 40
steps:
- name: Checkout reviewed control without credentials
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Bind control and exact published release pair
id: pair
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ inputs.tag }}
EXPECTED_CONTROL: ${{ inputs.expected_workflow_sha }}
run: |
set -euo pipefail
test "$GITHUB_REPOSITORY" = rcourtman/Pulse
test "$GITHUB_REF" = refs/heads/main
test "$GITHUB_WORKFLOW_SHA" = "$EXPECTED_CONTROL"
test "$GITHUB_SHA" = "$EXPECTED_CONTROL"
test "$(git rev-parse HEAD)" = "$EXPECTED_CONTROL"
[[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[1-9][0-9]*$ ]]
mkdir -m 0700 "$RUNNER_TEMP/browser-release"
gh api repos/rcourtman/Pulse/releases/latest > "$RUNNER_TEMP/browser-release/baseline.json"
gh api "repos/rcourtman/Pulse/releases/tags/$TAG" > "$RUNNER_TEMP/browser-release/candidate.json"
from=$(jq -er '.tag_name | select(test("^v[0-9]+\\.[0-9]+\\.[0-9]+$"))' "$RUNNER_TEMP/browser-release/baseline.json")
peel() {
local object type sha
object=$(gh api "repos/rcourtman/Pulse/git/ref/tags/$1")
type=$(jq -er '.object.type' <<<"$object")
sha=$(jq -er '.object.sha' <<<"$object")
if [[ "$type" == tag ]]; then
object=$(gh api "repos/rcourtman/Pulse/git/tags/$sha")
test "$(jq -er '.tag' <<<"$object")" = "$1"
type=$(jq -er '.object.type' <<<"$object")
sha=$(jq -er '.object.sha' <<<"$object")
fi
test "$type" = commit
[[ "$sha" =~ ^[0-9a-f]{40}$ ]]
printf '%s' "$sha"
}
from_commit=$(peel "$from")
to_commit=$(peel "$TAG")
python3 scripts/release_browser_packet.py \
--baseline "$RUNNER_TEMP/browser-release/baseline.json" \
--candidate "$RUNNER_TEMP/browser-release/candidate.json" \
--from-commit "$from_commit" --to-commit "$to_commit" --tag "$TAG" \
--output "$RUNNER_TEMP/browser-release/packet.json"
python3 -c 'import json,os,pathlib; keys=("GITHUB_WORKFLOW_SHA","GITHUB_SHA","GITHUB_RUN_ID","GITHUB_RUN_ATTEMPT"); pathlib.Path(os.environ["RUNNER_TEMP"]+"/browser-release/control.json").write_text(json.dumps({k:os.environ[k] for k in keys},indent=2)+"\n")'
python3 scripts/write_github_output.py from "$from"
python3 scripts/write_github_output.py from_commit "$from_commit"
python3 scripts/write_github_output.py to_commit "$to_commit"
- name: Authenticate both release archives before execution
env:
FROM: ${{ steps.pair.outputs.from }}
TAG: ${{ inputs.tag }}
FROM_COMMIT: ${{ steps.pair.outputs.from_commit }}
TO_COMMIT: ${{ steps.pair.outputs.to_commit }}
run: |
set -euo pipefail
root="$RUNNER_TEMP/browser-release"
key=$(grep -oE 'ssh-ed25519 [A-Za-z0-9+/=]+ pulse-installer' README.md | head -1)
test -n "$key"
printf 'pulse-installer %s\n' "$key" > "$root/allowed_signers"
for tag in "$FROM" "$TAG"; do
mkdir -m 0700 "$root/$tag"
name="pulse-$tag-linux-amd64.tar.gz"
for file in "$name" "$name.sshsig"; do
curl --fail --silent --show-error --location --retry 0 --max-time 120 --max-filesize 536870912 \
"https://github.com/rcourtman/Pulse/releases/download/$tag/$file" --output "$root/$tag/$file"
done
ssh-keygen -Y verify -f "$root/allowed_signers" -I pulse-installer -n pulse-install \
-s "$root/$tag/$name.sshsig" < "$root/$tag/$name"
done
python3 scripts/release_browser_packet.py --baseline "$root/baseline.json" --candidate "$root/candidate.json" \
--from-commit "$FROM_COMMIT" --to-commit "$TO_COMMIT" --tag "$TAG" \
--archives "$root" --output "$root/packet.json"
- name: Prepare pinned browser without lifecycle hooks
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '24.20.0'
package-manager-cache: false
- name: Prepare Chromium and native tools
env:
PLAYWRIGHT_BROWSERS_PATH: ${{ runner.temp }}/browser-release/browsers
run: |
set -euo pipefail
cd tests/integration
npm ci --ignore-scripts
npx --no-install playwright install --with-deps chromium
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends jq curl openssh-client
- name: Register a fresh ephemeral project CI node
uses: tailscale/github-action@780049a30b6ff5c378a9e7b389d15ece7a204888 # v4.1.3
with:
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
tags: tag:infra
version: '1.94.2'
- name: Serve only this disposable install
id: serve
run: |
set -euo pipefail
# Never enumerate peer/private targets or use a supplied hostname.
origin=$(tailscale status --json | python3 -c 'import json,re,sys; n=json.load(sys.stdin)["Self"]["DNSName"].rstrip("."); assert re.fullmatch(r"[a-z0-9-]+\.tawny-powan\.ts\.net", n); print("https://"+n)')
tailscale serve --bg --https=443 http://127.0.0.1:7655
echo "origin=$origin" >> "$GITHUB_OUTPUT"
- name: Exercise native browser update and documented recovery
env:
FROM: ${{ steps.pair.outputs.from }}
TAG: ${{ inputs.tag }}
ORIGIN: ${{ steps.serve.outputs.origin }}
run: |
set -euo pipefail
sudo env -i PATH=/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin LANG=C.UTF-8 \
GITHUB_ACTIONS=true RUNNER_ENVIRONMENT=github-hosted GITHUB_REPOSITORY=rcourtman/Pulse GITHUB_REF=refs/heads/main \
PULSE_REHEARSAL_WORKDIR="$RUNNER_TEMP/browser-release/journey" \
PULSE_REHEARSAL_BROWSER_ORIGIN="$ORIGIN" PULSE_REHEARSAL_NODE="$(command -v node)" \
PULSE_REHEARSAL_PACKET_MANIFEST="$RUNNER_TEMP/browser-release/packet.json" \
PLAYWRIGHT_BROWSERS_PATH="$RUNNER_TEMP/browser-release/browsers" \
bash scripts/release_lifecycle_rehearsal.sh --hosted-browser --from "$FROM" --to "$TAG"
- name: Close ephemeral Serve route
if: always() && steps.serve.outcome == 'success'
run: tailscale serve reset
- name: Retain bounded identity and terminal receipts, never credentials
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: browser-release-${{ github.run_id }}
path: |
${{ runner.temp }}/browser-release/control.json
${{ runner.temp }}/browser-release/packet.json
${{ runner.temp }}/browser-release/journey/state/browser-upgrade.json
${{ runner.temp }}/browser-release/journey/state/browser-recovery.json
${{ runner.temp }}/browser-release/journey/state/browser-cleanup.json
${{ runner.temp }}/browser-release/journey/state/lifecycle-result.json
${{ runner.temp }}/browser-release/journey/state/installer.json
${{ runner.temp }}/browser-release/journey/state/browser-*.png
${{ runner.temp }}/browser-release/journey/state/installed-*.json
${{ runner.temp }}/browser-release/journey/state/settings.*.json
${{ runner.temp }}/browser-release/journey/state/auto-updates.*.tsv
if-no-files-found: error
retention-days: 7
@@ -6360,7 +6360,7 @@ defaulted and nothing newer is published, the pair is the previous stable to
the latest stable. An explicit pair must be published and move forward, so a
same-version or missing-target run fails instead of passing.
`scripts/release_lifecycle_rehearsal.sh` runs every phase inside the same
`scripts/release_lifecycle_rehearsal.sh` defaults to running every phase inside the same
digest-pinned `jrei/systemd-debian:12` container as the install smoke.
1. Install FROM with that release's own `install.sh --version`, after the
@@ -6419,6 +6419,87 @@ passed all four phases. An earlier run correctly failed phase 1 on a new failed
unit (`systemd-hostnamed`), which led to the container-only list above. The
hosted Docker run is not yet claimed.
### Exact published-RC Tailscale browser acceptance
`qualify-browser-update-release.yml` uses the lifecycle harness's separate
`--hosted-browser` mode on a **fresh public hosted Ubuntu 24.04 VM**. The shadow
container mode above remains unchanged in purpose. A real native install is
required for browser updates: Docker detection stays truthful, with no marker
removal, mock updater, TLS bypass or `PULSE_ALLOW_DOCKER_UPDATES` override.
Control is reviewed `main`, bound to `expected_workflow_sha`, its checkout and
`GITHUB_WORKFLOW_SHA`. Product inputs are only the latest published immutable
stable and the requested published immutable RC. Peeled commit, release/asset
IDs, sizes and SHA-256 digests are checked by `release_browser_packet.py`; SSH
signatures verify before execution. Archives are bounded to512MiB (current
stable/RC archives are about265MiB), signatures to16KiB, the fixed regular
server binary to1GiB and expanded inventory to2GiB/4096 entries. Streaming
hashes avoid allocating an entire archive; anonymous downloads have the same
archive-size ceiling. Setup-action caches are explicitly disabled in this
CI-credential-bearing job, and derived command-file outputs use the reviewed
collision-safe writer. Each installed binary hash must match its
signed archive, rather than just a reported version. No rebuilt main binary,
frozen-product insertion, candidate re-cut or stable promotion is performed.
A pinned Tailscale action registers an ephemeral project CI node; Serve exposes
only its own loopback Pulse install through its derived `tawny-powan.ts.net`
HTTPS identity. No caller-selected endpoint, peer or previous stopped target is
used. Registration alone receives the existing CI OAuth credential; acceptance
commands receive a scrubbed environment with no provider, production or
publishing credential. State is synthetic, disabled resources and throwaway
admin/API authentication, with the existing telemetry opt-out.
The real Chromium UI signs in once, chooses Preview, checks the actual feed's
exact RC/archive, and confirms one Install Update. The observer requires real
EventSource open/progress, exact served version, healthy status, authenticated
readiness and progress-modal completion; wrong selection, silent progress,
rejected apply or a stuck modal fails. API interception and fabricated version
or progress are not acceptance. HTTP401/403 on main-frame navigation, login,
or any same-origin API/SSE request started after successful login stops browser
access immediately and closes the context to prevent UI/SSE reconnects. The
receipt records only bounded endpoint classes, numeric status and the fixed
journey step, never the URL, response body or credentials. Anonymous pre-login
auth probes are not authenticated refusals; their request-start identity remains
anonymous even if a response arrives after login. Version/health denial is not
a restart transport gap and is never polled again. Recovery requires a readable
terminal upgrade receipt explicitly recording `access_refused:false`; missing,
malformed or incomplete evidence also stops before another auth file or browser
process. No alternate identity, recovery login after refusal or apply replay is
used.
The same harness checks systemd, the seeded settings/encrypted stores and the
original auto-update/timer intent after forward update and documented CLI
`/bin/update --version FROM` recovery. Browser authentication/health readback
then checks the restored baseline. This is CLI recovery plus browser readback,
**not** the separate history-based UI rollback action. Cleanup stops the
throwaway service/timer, records its result and fails the process on failure;
the workflow resets the new Serve route and its hosted VM is disposed of.
Bounded control, signed packet, installer/binary, phase, browser, cleanup and
redacted settings receipts are retained for seven days. Successful screenshots
contain only synthetic state. Auth files, bootstrap/API/node tokens, encrypted
file manifests and raw installer/updater/service logs are not artifacts.
Delivery must reconcile terminal logs and all receipts with the exact RC;
a workflow conclusion alone is insufficient. Existing qualification, soak,
release-steward judgment and publication boundaries remain unchanged. The
previous target's undiagnosed HTTP401 is not diagnosed or cleared by this mode.
Verification: `scripts/tests/test_release_browser_packet.py` executes immutable
release/asset/binary identity rejection, worker-mode exclusion, scrubbed command
environment and cleanup exit controls. `test_release_lifecycle_rehearsal.py`
executes the recovery-access stop before another browser process can start.
`tests/integration/scripts/release-browser-journey.test.cjs` exercises the real
observer against offline Chromium success/recovery, anonymous auth probes,
restart transport gaps, wrong-target, silent-SSE and stuck-modal fixtures, plus
navigation (before/after login), login, initial/readiness version, health,
authenticated API, check/apply, SSE and protected-read HTTP401/403. Each browser
upgrade receipt is then passed to the actual shell recovery guard with a fixed
local process sentinel, proving denied access prevents later auth preparation
and browser launch while non-refused recovery remains available. These are observer
and tooling proofs, **not native Tailscale/update/recovery acceptance**. That
requires reviewed publication, infrastructure activation and a terminal hosted
collection through the fixed Delivery dispatcher.
### Release-line dependency advisory watch (1 October 2026)
On 30 September and 1 October 2026 `GHSA-q2hr-2g5m-vwhr` (`brace-expansion`)
+104
View File
@@ -0,0 +1,104 @@
#!/usr/bin/env python3
"""Bind a hosted browser journey to two exact published, signed release assets.
Reads local metadata and archives; performs no network, extraction or execution.
The workflow separately verifies SSH signatures before --archives is supplied.
"""
import argparse
import hashlib
import json
from pathlib import Path
import re
import tarfile
MAX_ARCHIVE = 512 * 1024 * 1024
MAX_SIGNATURE = 16 * 1024
MAX_BINARY = 1024 * 1024 * 1024
MAX_EXPANDED = 2 * MAX_BINARY
def release_identity(release, *, tag, commit, prerelease):
pattern = r'v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)'
if prerelease:
pattern += r'-rc\.[1-9][0-9]*'
if (not re.fullmatch(pattern, tag) or not re.fullmatch(r'[0-9a-f]{40}', commit)
or release.get('tag_name') != tag or release.get('target_commitish') != commit
or release.get('draft') is not False or release.get('prerelease') is not prerelease
or release.get('immutable') is not True or not release.get('published_at')
or type(release.get('id')) is not int or release['id'] <= 0):
raise ValueError('Exact published immutable release/tag identity required')
asset_name = f'pulse-{tag}-linux-amd64.tar.gz'
assets = []
for name in (asset_name, asset_name + '.sshsig'):
matches = [a for a in release.get('assets', []) if a.get('name') == name]
if len(matches) != 1:
raise ValueError('Missing or ambiguous release asset')
asset = matches[0]
if (asset.get('browser_download_url') != f'https://github.com/rcourtman/Pulse/releases/download/{tag}/{name}'
or type(asset.get('id')) is not int or asset['id'] <= 0
or type(asset.get('size')) is not int or not 0 < asset['size'] <= (MAX_SIGNATURE if name.endswith('.sshsig') else MAX_ARCHIVE)
or not re.fullmatch(r'sha256:[0-9a-f]{64}', str(asset.get('digest')))):
raise ValueError('Unsafe or unbound release asset')
assets.append({k: asset[k] for k in ['name', 'id', 'size', 'digest']})
return {'tag': tag, 'release_id': release['id'], 'commit': commit, 'assets': assets}
def file_sha256(path):
with path.open('rb') as stream:
return hashlib.file_digest(stream, 'sha256').hexdigest()
def archive_identity(path):
# Inspect only one bounded regular binary; do not extract the package.
with tarfile.open(path, 'r:gz') as archive:
matches = []
total_size = 0
for i, member in enumerate(archive):
total_size += member.size
if total_size > MAX_EXPANDED:
raise ValueError('Oversized expanded release archive')
if i >= 4096:
raise ValueError('Oversized release archive inventory')
if member.name in ('./bin/pulse', 'bin/pulse'):
matches.append(member)
if len(matches) != 1 or not matches[0].isfile() or not 0 < matches[0].size <= MAX_BINARY:
raise ValueError('Missing or ambiguous regular server binary')
stream = archive.extractfile(matches[0])
return hashlib.file_digest(stream, 'sha256').hexdigest()
def packet(baseline, candidate, from_commit, to_commit, tag, archives=None):
stable_tag = baseline.get('tag_name', '')
releases = [release_identity(baseline, tag=stable_tag, commit=from_commit, prerelease=False),
release_identity(candidate, tag=tag, commit=to_commit, prerelease=True)]
if tuple(map(int, stable_tag[1:].split('.'))) >= tuple(map(int, tag[1:].split('-')[0].split('.'))):
raise ValueError('The candidate must be newer than its published stable baseline')
if archives is not None:
for release in releases:
for asset in release['assets']:
p = archives / release['tag'] / asset['name']
if p.is_symlink() or not p.is_file() or p.stat().st_size != asset['size']:
raise ValueError('Release asset size/type differs from live identity')
if file_sha256(p) != asset['digest'].split(':')[1]:
raise ValueError('Release asset digest differs from live identity')
release['binary_sha256'] = archive_identity(archives / release['tag'] / release['assets'][0]['name'])
return {'schema_version': 1, 'repository': 'rcourtman/Pulse', 'releases': releases}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--baseline', required=True, type=Path)
parser.add_argument('--candidate', required=True, type=Path)
parser.add_argument('--from-commit', required=True)
parser.add_argument('--to-commit', required=True)
parser.add_argument('--tag', required=True)
parser.add_argument('--archives', type=Path)
parser.add_argument('--output', required=True, type=Path)
args = parser.parse_args()
result = packet(json.loads(args.baseline.read_text()), json.loads(args.candidate.read_text()),
args.from_commit, args.to_commit, args.tag, args.archives)
args.output.write_text(json.dumps(result, indent=2) + '\n')
if __name__ == '__main__':
main()
+167 -14
View File
@@ -1,7 +1,9 @@
#!/usr/bin/env bash
#
# Release lifecycle rehearsal: install, upgrade and roll back Pulse between two
# PUBLISHED releases on a real systemd host (a privileged systemd container).
# PUBLISHED releases on a real systemd host. Default: shadow systemd container.
# --hosted-browser: fresh public GitHub VM, authenticated Tailscale Serve UI
# update/SSE, documented CLI recovery and browser/session readback.
#
# 1. install FROM with that release's signature-verified install.sh --version
# 2. assert FROM identity, health and unit state
@@ -49,10 +51,12 @@ SEED_ADMIN_USER="rehearsal-admin"
FROM_TAG=""
TO_TAG=""
HOSTED_BROWSER=false
CHECK_SNAPSHOT=""
COMPARE_DATADIR=()
while [[ $# -gt 0 ]]; do
case "$1" in
--hosted-browser) HOSTED_BROWSER=true; shift ;;
--from) FROM_TAG="${2:-}"; shift 2 ;;
--to) TO_TAG="${2:-}"; shift 2 ;;
# Offline self-tests used by the contract tests: apply the fixed
@@ -78,6 +82,30 @@ if [[ -z "$CHECK_SNAPSHOT" && ${#COMPARE_DATADIR[@]} -eq 0 ]]; then
fi
fi
# The browser route is a real first-level public CI host, not a container
# overriding Docker detection. It must never install onto a maintainer host.
if [[ "$HOSTED_BROWSER" == true ]]; then
if [[ "$EUID" != 0 || "${GITHUB_ACTIONS:-}" != true \
|| "${RUNNER_ENVIRONMENT:-}" != github-hosted \
|| "${GITHUB_REPOSITORY:-}" != rcourtman/Pulse \
|| "${GITHUB_REF:-}" != refs/heads/main \
|| -e /opt/pulse || -e /etc/pulse \
|| -e /etc/systemd/system/pulse.service || -e /rehearsal ]]; then
echo "::error::Browser lifecycle requires a fresh disposable public hosted runner."
exit 2
fi
if [[ ! -f "${PULSE_REHEARSAL_PACKET_MANIFEST:-}" ]]; then
echo "::error::A verified immutable release asset manifest is required."
exit 2
fi
# The workflow derives this from the new runner's own Tailscale identity.
# No caller endpoint, old stopped target or TLS-verification bypass exists.
if [[ ! "${PULSE_REHEARSAL_BROWSER_ORIGIN:-}" =~ ^https://[a-z0-9-]+\.tawny-powan\.ts\.net$ ]]; then
echo "::error::Browser lifecycle requires the ephemeral runner's HTTPS Serve origin."
exit 2
fi
fi
REPO="${PULSE_REHEARSAL_REPO:-rcourtman/Pulse}"
if [[ ! "$REPO" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then
echo "::error::invalid PULSE_REHEARSAL_REPO '${REPO}'" >&2
@@ -126,11 +154,22 @@ cexec() {
for pair in "$@"; do
env_args+=(-e "$pair")
done
"$ENGINE" exec "${env_args[@]}" "$CONTAINER" bash -c "$program"
if [[ "$HOSTED_BROWSER" == true ]]; then
# Never pass workflow, GitHub or tailnet-registration credentials to
# the installed application or an acceptance command.
env -i PATH=/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin LANG=C.UTF-8 \
"${@}" bash -c "$program"
else
"$ENGINE" exec "${env_args[@]}" "$CONTAINER" bash -c "$program"
fi
}
print_diagnostics() {
[[ "$CONTAINER_STARTED" == "true" ]] || return 0
if [[ "$HOSTED_BROWSER" == true ]]; then
echo "::error::Hosted lifecycle failed; inspect bounded browser/identity receipts. No raw credentials or journal retained."
return
fi
echo "::group::Diagnostics: pulse journal (tail)"
cexec 'journalctl -u pulse --no-pager | tail -n 150' || true
echo "::endgroup::"
@@ -155,8 +194,28 @@ cleanup() {
print_diagnostics
fi
if [[ "$CONTAINER_STARTED" == "true" ]]; then
"$ENGINE" rm -f "$CONTAINER" >/dev/null 2>&1 || true
if [[ "$HOSTED_BROWSER" == true ]]; then
local cleanup_status=0
systemctl stop pulse.service || cleanup_status=1
if [[ "$(systemctl show pulse-update.timer --property=LoadState --value)" != not-found ]]; then
systemctl stop pulse-update.timer || cleanup_status=1
fi
if systemctl is-active --quiet pulse.service; then cleanup_status=1; fi
if systemctl is-active --quiet pulse-update.timer; then cleanup_status=1; fi
printf '{"cleanup_exit":%s,"host_disposal":"github-hosted-job"}\n' "$cleanup_status" > "${WORK_DIR}/state/browser-cleanup.json"
[[ "$cleanup_status" == 0 ]] || status=1
else
"$ENGINE" rm -f "$CONTAINER" >/dev/null 2>&1 || true
fi
fi
if [[ "$HOSTED_BROWSER" == true ]]; then
# Ephemeral application credentials are never retained as evidence.
if [[ -f "${WORK_DIR}/state/browser-auth.json" ]]; then
: > "${WORK_DIR}/state/browser-auth.json"
fi
fi
trap - EXIT
exit "$status"
}
trap cleanup EXIT
@@ -165,7 +224,11 @@ write_summary() {
table=$(
echo "## Release lifecycle rehearsal"
echo
echo "\`${FROM_TAG}\` -> \`${TO_TAG}\` -> \`${FROM_TAG}\` on a systemd host (${SYSTEMD_IMAGE%%@*}). Shadow run, not a release gate."
if [[ "$HOSTED_BROWSER" == true ]]; then
echo "\`${FROM_TAG}\` -> \`${TO_TAG}\` -> \`${FROM_TAG}\` on a fresh public hosted VM with HTTPS Tailscale Serve. Browser acceptance evidence, not a stable promotion decision."
else
echo "\`${FROM_TAG}\` -> \`${TO_TAG}\` -> \`${FROM_TAG}\` on a systemd host (${SYSTEMD_IMAGE%%@*}). Shadow run, not a release gate."
fi
echo
echo "| Phase | Expected | Reported version | Health | Settings | Data dir | Units | Result |"
echo "| --- | --- | --- | --- | --- | --- | --- | --- |"
@@ -182,6 +245,12 @@ write_summary() {
done
fi
)
if [[ "$HOSTED_BROWSER" == true ]]; then
jq -cn --argjson overall_status "$OVERALL_STATUS" \
--argjson phases "$(printf '%s\n' "${PHASE_ROWS[@]}" | jq -Rsc 'split("\n") | map(select(length > 0))')" \
'{schema_version:1, overall_status:$overall_status, phases:$phases}' \
> "${WORK_DIR}/state/lifecycle-result.json"
fi
echo
echo "$table"
if [[ -n "$SUMMARY_FILE" ]]; then
@@ -216,8 +285,10 @@ fetch_and_verify_installer() {
local dest="${WORK_DIR}/assets"
local base="https://github.com/${REPO}/releases/download/${tag}"
local asset
local -a retry_args=(--retry 5 --retry-delay 5 --retry-all-errors)
if [[ "$HOSTED_BROWSER" == true ]]; then retry_args=(--retry 0 --max-time 120); fi
for asset in install.sh install.sh.sshsig; do
if ! curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors \
if ! curl -fsSL "${retry_args[@]}" \
-o "${dest}/${asset}" "${base}/${asset}"; then
echo "::error::could not download ${base}/${asset}"
return 1
@@ -246,6 +317,13 @@ fetch_and_verify_installer() {
echo "::error::${tag} install.sh is not the Pulse server installer with --version support"
return 1
fi
if [[ "$HOSTED_BROWSER" == true ]]; then
jq -cn --arg tag "$tag" \
--arg installer_sha256 "$(sha256sum "${dest}/install.sh" | cut -d' ' -f1)" \
--arg signature_sha256 "$(sha256sum "${dest}/install.sh.sshsig" | cut -d' ' -f1)" \
'{tag:$tag,installer_sha256:$installer_sha256,signature_sha256:$signature_sha256,signature_verified:true}' \
> "${WORK_DIR}/state/installer.json"
fi
log "${tag} install.sh signature verifies against the README-pinned key"
}
@@ -254,6 +332,14 @@ fetch_and_verify_installer() {
# ---------------------------------------------------------------------------
start_container() {
if [[ "$HOSTED_BROWSER" == true ]]; then
CONTAINER_STARTED=true
BASELINE_FAILED_UNITS=$(cexec 'systemctl list-units --state=failed --no-legend --plain | awk "{print \$1}" | sort')
# Keep the same telemetry opt-out, without changing deployment identity,
# enabling Docker updates, mocking feeds or weakening authentication.
cexec 'mkdir -p /etc/systemd/system/pulse.service.d && printf "[Service]\nEnvironment=PULSE_TELEMETRY=false\n" > /etc/systemd/system/pulse.service.d/50-rehearsal-no-telemetry.conf'
return
fi
local -a run_args=(run -d --name "$CONTAINER" --privileged
-v "${WORK_DIR}/assets:/rehearsal:ro")
if [[ "$ENGINE" == "docker" ]]; then
@@ -349,6 +435,15 @@ assert_runtime() {
note_failure "/opt/pulse/bin/pulse --version reported '${binary_version:-none}', expected ${expected_tag}"
fi
if [[ "$HOSTED_BROWSER" == true ]]; then
local observed_digest expected_digest
observed_digest=$(cexec 'sha256sum /opt/pulse/bin/pulse | cut -d" " -f1')
expected_digest=$(jq -er --arg tag "$expected_tag" '.releases[] | select(.tag == $tag) | .binary_sha256' "$PULSE_REHEARSAL_PACKET_MANIFEST")
printf '{"tag":"%s","binary_sha256":"%s","expected_binary_sha256":"%s"}\n' \
"$expected_tag" "$observed_digest" "$expected_digest" > "${WORK_DIR}/state/installed-${expected_tag}.json"
[[ "$observed_digest" == "$expected_digest" ]] || note_failure "Installed binary differs from the verified published archive"
fi
local enabled failed_now new_failed
enabled=$(cexec 'systemctl is-enabled pulse' 2>/dev/null || true)
failed_now=$(cexec 'systemctl list-units --state=failed --no-legend --plain | awk "{print \$1}" | sort' 2>/dev/null || true)
@@ -605,12 +700,27 @@ check_settings() {
# Phases
# ---------------------------------------------------------------------------
install_baseline() {
if [[ "$HOSTED_BROWSER" == true ]]; then
cexec 'bash /rehearsal/install.sh --version "$FROM_TAG"' "FROM_TAG=${FROM_TAG}" \
> "${WORK_DIR}/state/private-install.log" 2>&1
else
cexec 'bash /rehearsal/install.sh --version "$FROM_TAG"' "FROM_TAG=${FROM_TAG}" \
2>&1 | tee "${WORK_DIR}/state/install-${FROM_TAG}.log"
fi
}
phase_install() {
reset_phase_cells
log "Phase 1: install ${FROM_TAG} with its published install.sh --version"
# No TTY: install.sh's prompts take their defaults, as for curl | bash.
if ! cexec 'bash /rehearsal/install.sh --version "$FROM_TAG"' "FROM_TAG=${FROM_TAG}" \
2>&1 | tee "${WORK_DIR}/state/install-${FROM_TAG}.log"; then
if [[ "$HOSTED_BROWSER" == true ]]; then
# /rehearsal is the fixed mount spelling used by the existing harness.
# It is absent on a fresh hosted runner; never overwrite an existing path.
[[ ! -e /rehearsal ]] || return 1
ln -s "${WORK_DIR}/assets" /rehearsal
fi
if ! install_baseline; then
note_failure "install.sh --version ${FROM_TAG} exited non-zero"
fi
assert_runtime "$FROM_TAG"
@@ -635,7 +745,7 @@ phase_seed() {
if ! setup_response=$(cexec 'curl -fsS -X POST -H "Content-Type: application/json" -H "X-Setup-Token: $SETUP_TOKEN" --data "$BODY" "$API/api/security/quick-setup"' \
"API=${API}" "SETUP_TOKEN=${setup_token}" "BODY=${setup_body}") \
|| [[ "$(jq -r '.success // false' <<<"$setup_response" 2>/dev/null)" != "true" ]]; then
note_failure "first-run security setup failed: ${setup_response:-no response}"
note_failure "first-run security setup did not return success"
fi
local webhook_body node_body
@@ -695,28 +805,71 @@ run_updater() {
fi
# /bin/update downloads the latest published install.sh, verifies it with
# its embedded signer key and runs it with the given arguments.
if ! cexec '/bin/update --version "$TARGET"' "TARGET=${target}" \
if [[ "$HOSTED_BROWSER" == true ]]; then
if ! cexec '/bin/update --version "$TARGET"' "TARGET=${target}" \
> "${WORK_DIR}/state/private-updater.log" 2>&1; then
note_failure "/bin/update --version ${target} exited non-zero"
fi
elif ! cexec '/bin/update --version "$TARGET"' "TARGET=${target}" \
2>&1 | tee "${WORK_DIR}/state/${label}-${target}.log"; then
note_failure "/bin/update --version ${target} exited non-zero"
fi
}
run_browser_journey() {
local mode="$1" expected="$2"
if [[ "$mode" == recovery ]]; then
# An unreadable/missing/incomplete receipt cannot authorise another
# browser process either. Only an explicit non-refused terminal result
# permits readback after the independent documented CLI recovery.
local receipt="${WORK_DIR}/state/browser-upgrade.json" reason
if ! jq -e 'type == "object" and .access_refused == false
and (.status == "passed" or .status == "failed")' "$receipt" >/dev/null 2>&1; then
reason=upgrade-receipt-unavailable
if jq -e '.access_refused == true' "$receipt" >/dev/null 2>&1; then
reason=stopped-access-no-reauthentication
fi
printf '{"status":"not-executed","reason":"%s"}\n' "$reason" \
> "${WORK_DIR}/state/browser-recovery.json"
return 1
fi
fi
(umask 077; jq -cn --arg username "$SEED_ADMIN_USER" --arg password "$ADMIN_PASSWORD" \
'{username:$username,password:$password}' > "${WORK_DIR}/state/browser-auth.json")
env -i PATH=/usr/local/bin:/usr/bin:/bin HOME="${WORK_DIR}" \
PLAYWRIGHT_BROWSERS_PATH="${PLAYWRIGHT_BROWSERS_PATH:-/opt/pulse-browser}" \
"${PULSE_REHEARSAL_NODE:?}" "${ROOT_DIR}/tests/integration/scripts/release-browser-journey.cjs" \
--origin "$PULSE_REHEARSAL_BROWSER_ORIGIN" --mode "$mode" \
--from "$FROM_TAG" --to "$TO_TAG" --expected "$expected" \
--auth-file "${WORK_DIR}/state/browser-auth.json" --output-dir "${WORK_DIR}/state"
}
phase_upgrade() {
reset_phase_cells
log "Phase 3: upgrade with /bin/update --version ${TO_TAG}"
run_updater "$TO_TAG" upgrade || true
if [[ "$HOSTED_BROWSER" == true ]]; then
log "Phase 3: upgrade to ${TO_TAG} through the authenticated Tailscale Serve browser"
run_browser_journey upgrade "$TO_TAG" || note_failure "Tailscale browser upgrade failed; no apply replay"
else
log "Phase 3: upgrade with /bin/update --version ${TO_TAG}"
run_updater "$TO_TAG" upgrade || true
fi
assert_runtime "$TO_TAG"
check_auto_update_intent upgrade || true
check_settings upgrade || true
check_datadir upgrade || true
record_phase "3. upgrade (/bin/update)" "$TO_TAG" "$PHASE_VERSION" "$PHASE_HEALTH" "$PHASE_SETTINGS" "$PHASE_DATADIR" "$PHASE_UNITS"
record_phase "3. upgrade ($([[ "$HOSTED_BROWSER" == true ]] && printf browser || printf /bin/update))" "$TO_TAG" "$PHASE_VERSION" "$PHASE_HEALTH" "$PHASE_SETTINGS" "$PHASE_DATADIR" "$PHASE_UNITS"
}
phase_rollback() {
reset_phase_cells
log "Phase 4: roll back with /bin/update --version ${FROM_TAG}"
# The documented recovery command is exercised even after an adverse
# browser update. It is not a second attempt to apply the candidate.
run_updater "$FROM_TAG" rollback || true
assert_runtime "$FROM_TAG"
if [[ "$HOSTED_BROWSER" == true ]]; then
run_browser_journey recovery "$FROM_TAG" || note_failure "Tailscale browser recovery/readback failed"
fi
check_auto_update_intent rollback || true
check_settings rollback || true
check_datadir rollback || true
@@ -724,13 +877,13 @@ phase_rollback() {
}
main() {
log "Rehearsing ${FROM_TAG} -> ${TO_TAG} -> ${FROM_TAG} from ${REPO} with ${ENGINE}"
log "Rehearsing ${FROM_TAG} -> ${TO_TAG} -> ${FROM_TAG} from ${REPO}; hosted browser=${HOSTED_BROWSER}"
if ! fetch_and_verify_installer "$FROM_TAG"; then
CURRENT_FAILURE="published install.sh for ${FROM_TAG} failed verification"
record_phase "0. verify installer" "$FROM_TAG" "-" "-" "-" "-" "-" || abort_run
fi
if ! start_container; then
CURRENT_FAILURE="systemd container did not start"
CURRENT_FAILURE="disposable systemd host did not start"
record_phase "0. systemd host" "-" "-" "-" "-" "-" "-" || abort_run
fi
@@ -0,0 +1,167 @@
"""Exact release identity and fail-closed hosted browser control tests, offline."""
import copy
import hashlib
import importlib.util
import io
import json
from pathlib import Path
import subprocess
import tarfile
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[2]
spec = importlib.util.spec_from_file_location('browser_packet', ROOT / 'scripts/release_browser_packet.py')
packet = importlib.util.module_from_spec(spec)
spec.loader.exec_module(packet)
def release(tag, commit):
return {'id': 123, 'tag_name': tag, 'target_commitish': commit, 'draft': False,
'prerelease': '-rc.' in tag, 'immutable': True, 'published_at': '2026-10-02T19:21:28Z',
'assets': [{'id': i + 1, 'name': name, 'size': 5, 'digest': 'sha256:' + 'b' * 64,
'browser_download_url': f'https://github.com/rcourtman/Pulse/releases/download/{tag}/{name}'}
for i, name in enumerate((f'pulse-{tag}-linux-amd64.tar.gz', f'pulse-{tag}-linux-amd64.tar.gz.sshsig'))]}
class BrowserPacketTest(unittest.TestCase):
def setUp(self):
self.baseline = release('v6.4.5', 'a' * 40)
self.candidate = release('v6.4.6-rc.1', 'c' * 40)
def bind(self, archives=None):
return packet.packet(self.baseline, self.candidate, 'a' * 40, 'c' * 40, 'v6.4.6-rc.1', archives)
def test_binds_two_immutable_exact_commits_not_control_head(self):
result = self.bind()
self.assertEqual(['a' * 40, 'c' * 40], [r['commit'] for r in result['releases']])
self.assertEqual('rcourtman/Pulse', result['repository'])
self.assertNotIn('assets', result) # Only bounded constituent identities are retained.
def test_rejects_unpublished_mutable_wrong_maturity_or_ref(self):
for field, value in [('draft', True), ('immutable', False), ('prerelease', False),
('target_commitish', 'main'), ('tag_name', 'v6.4.6-rc.2'),
('published_at', None), ('id', True)]:
with self.subTest(field=field), self.assertRaises(ValueError):
candidate = self.candidate | {field: value}
packet.packet(self.baseline, candidate, 'a' * 40, 'c' * 40, 'v6.4.6-rc.1')
with self.assertRaises(ValueError):
packet.packet(self.baseline, release('v6.4.5-rc.9', 'c' * 40), 'a' * 40, 'c' * 40, 'v6.4.5-rc.9')
def test_current_published_archives_fit_bounded_admission(self):
# Actual GitHub metadata readback: stable276780330 and RC277779060
# bytes both exceed the original256MiB limit. No asset bytes executed.
self.baseline['assets'][0]['size'] = 276780330
self.candidate['assets'][0]['size'] = 277779060
self.assertEqual(2, len(self.bind()['releases']))
self.candidate['assets'][1]['size'] = packet.MAX_SIGNATURE + 1
with self.assertRaises(ValueError):
self.bind()
def test_rejects_redirect_asset_duplicate_oversize_or_unbound_digest(self):
for field, value in [('browser_download_url', 'https://example.com/archive'), ('size', 0),
('size', 513 * 1024 * 1024), ('digest', None), ('id', True)]:
with self.subTest(field=field), self.assertRaises(ValueError):
candidate = copy.deepcopy(self.candidate)
candidate['assets'][0][field] = value
packet.packet(self.baseline, candidate, 'a' * 40, 'c' * 40, 'v6.4.6-rc.1')
self.candidate['assets'].append(self.candidate['assets'][0])
with self.assertRaises(ValueError):
self.bind()
def make_assets(self, root):
for r in (self.baseline, self.candidate):
folder = root / r['tag_name']; folder.mkdir()
for asset in r['assets']:
path = folder / asset['name']
if asset['name'].endswith('.sshsig'):
path.write_bytes(b'fixture signature - cryptographic verification belongs to SSH workflow')
else:
data = ('fixture-' + r['tag_name']).encode()
with tarfile.open(path, 'w:gz') as archive:
member = tarfile.TarInfo('./bin/pulse'); member.size = len(data)
archive.addfile(member, io.BytesIO(data))
asset['size'] = path.stat().st_size
asset['digest'] = 'sha256:' + hashlib.sha256(path.read_bytes()).hexdigest()
def test_asset_digest_and_binary_digest_both_checked_without_extraction(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp); self.make_assets(root)
result = self.bind(root)
self.assertEqual(hashlib.sha256(b'fixture-v6.4.6-rc.1').hexdigest(), result['releases'][1]['binary_sha256'])
target = root / self.candidate['tag_name'] / self.candidate['assets'][1]['name']
target.write_bytes(b'X' * target.stat().st_size)
with self.assertRaisesRegex(ValueError, 'digest'):
self.bind(root)
def test_symlink_and_duplicate_binary_are_rejected(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp); self.make_assets(root)
target = root / self.candidate['tag_name'] / self.candidate['assets'][1]['name']
real = target.with_suffix('.real'); target.rename(real); target.symlink_to(real)
with self.assertRaisesRegex(ValueError, 'size/type'):
self.bind(root)
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / 'bad.tar.gz'
for names in (['./bin/pulse', 'bin/pulse'], ['./bin/pulse']):
with self.subTest(names=names):
with tarfile.open(path, 'w:gz') as archive:
for name in names:
member = tarfile.TarInfo(name); member.type = tarfile.SYMTYPE; member.linkname = '/etc/passwd'
archive.addfile(member)
with self.assertRaises(ValueError):
packet.archive_identity(path)
def test_native_mode_refuses_worker_before_install_or_network(self):
result = subprocess.run(['bash', ROOT / 'scripts/release_lifecycle_rehearsal.sh', '--hosted-browser',
'--from', 'v6.4.5', '--to', 'v6.4.6-rc.1'],
env={'PATH': '/usr/bin:/bin'}, capture_output=True, text=True)
self.assertEqual(2, result.returncode)
self.assertIn('fresh disposable public hosted runner', result.stdout)
def test_cleanup_failure_sets_process_exit_and_scrubs_only_ephemeral_auth(self):
source = (ROOT / 'scripts/release_lifecycle_rehearsal.sh').read_text()
cleanup = source.split('cleanup() {', 1)[1].split('\ntrap cleanup EXIT', 1)[0]
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp); (root / 'state').mkdir(); (root / 'state/browser-auth.json').write_text('throwaway')
# Execute the real cleanup function with fixture systemctl: not a system/installation proof.
script = 'set -u\nOVERALL_STATUS=0; CONTAINER_STARTED=true; HOSTED_BROWSER=true\n'
script += f'WORK_DIR={json.dumps(tmp)}\nprint_diagnostics() {{ :; }}\nsystemctl() {{ return 1; }}\n'
script += 'cleanup() {' + cleanup + '\ntrap cleanup EXIT\nexit 0\n'
result = subprocess.run(['bash', '-c', script], capture_output=True, text=True)
self.assertEqual(1, result.returncode, result.stderr)
self.assertEqual('', (root / 'state/browser-auth.json').read_text())
self.assertEqual(1, json.loads((root / 'state/browser-cleanup.json').read_text())['cleanup_exit'])
def test_cexec_native_environment_drops_provider_and_registration_secrets(self):
source = (ROOT / 'scripts/release_lifecycle_rehearsal.sh').read_text()
function = source.split('cexec() {', 1)[1].split('\nprint_diagnostics()', 1)[0]
script = 'HOSTED_BROWSER=true\ncexec() {' + function + '\ncexec env EXTRA=fixture\n'
result = subprocess.run(['bash', '-c', script], env={'PATH': '/usr/bin:/bin',
'GH_TOKEN': 'synthetic-secret', 'TS_OAUTH_SECRET': 'synthetic-secret'},
capture_output=True, text=True)
self.assertEqual(0, result.returncode, result.stderr)
self.assertNotIn('synthetic-secret', result.stdout)
self.assertNotIn('PULSE_INSTALL_ALLOW_DOCKER', result.stdout)
self.assertIn('EXTRA=fixture', result.stdout)
def test_workflow_has_no_arbitrary_target_build_publish_or_credential_in_acceptance(self):
source = (ROOT / '.github/workflows/qualify-browser-update-release.yml').read_text()
self.assertNotIn('pull_request:', source)
self.assertNotIn('contents: write', source)
self.assertNotIn('ignoreHTTPSErrors', (ROOT / 'tests/integration/scripts/release-browser-journey.cjs').read_text())
self.assertIn('test "$GITHUB_WORKFLOW_SHA" = "$EXPECTED_CONTROL"', source)
self.assertIn('runs-on: ubuntu-24.04', source)
self.assertIn('"Self"]["DNSName"]', source)
self.assertIn('ssh-keygen -Y verify', source)
acceptance = source.split('- name: Exercise native browser update', 1)[1].split('- name: Close', 1)[0]
self.assertIn('sudo env -i', acceptance)
for name in ('GH_TOKEN', 'TS_OAUTH_SECRET', 'PULSE_ALLOW_DOCKER_UPDATES', 'ignoreHTTPSErrors'):
self.assertNotIn(name, acceptance)
artifact = source.split('- name: Retain bounded', 1)[1]
for private in ('browser-auth.json', 'private-install.log', 'private-updater.log', 'datadir.'):
self.assertNotIn(private, artifact)
if __name__ == '__main__':
unittest.main()
@@ -177,6 +177,58 @@ class HarnessContractTest(unittest.TestCase):
self.assertEqual(result.returncode, 2, result.stdout + result.stderr)
class HostedWorkflowCommandFilesTest(unittest.TestCase):
def test_bound_pair_uses_the_reviewed_collision_safe_output_writer(self) -> None:
source = (REPO_ROOT / '.github/workflows/qualify-browser-update-release.yml').read_text()
for field in ('from', 'from_commit', 'to_commit'):
self.assertIn(f'python3 scripts/write_github_output.py {field} "${field}"', source)
self.assertNotIn('echo "to_commit=', source)
node_setup = source.split('uses: actions/setup-node@', 1)[1].split(' - name:', 1)[0]
self.assertNotRegex(node_setup, r'(?m)^\s*cache:')
self.assertIn('package-manager-cache: false', source)
class BrowserRecoveryAccessStopTest(unittest.TestCase):
def test_refused_upgrade_does_not_launch_another_login(self) -> None:
source = HARNESS_PATH.read_text(encoding="utf-8")
function = "run_browser_journey() {" + source.split("run_browser_journey() {", 1)[1].split("\n}\n", 1)[0] + "\n}"
for status in (401, 403):
with self.subTest(status=status), tempfile.TemporaryDirectory() as tmp:
work = Path(tmp)
(work / "state").mkdir()
(work / "state/browser-upgrade.json").write_text(json.dumps({"status": "failed", "access_refused": True, "login_status": status}))
script = "set -euo pipefail\nWORK_DIR=" + json.dumps(tmp) + "\n"
script += function + "\nrun_browser_journey recovery v6.4.5\n"
# NODE/auth/origin variables are intentionally absent. Reaching
# process/auth preparation would fail this exact guard test.
result = subprocess.run(["bash", "-c", script], capture_output=True, text=True)
self.assertEqual(1, result.returncode, result.stderr)
self.assertEqual({"status": "not-executed", "reason": "stopped-access-no-reauthentication"},
json.loads((work / "state/browser-recovery.json").read_text()))
self.assertFalse((work / "state/browser-auth.json").exists())
self.assertEqual("", result.stderr)
def test_missing_or_malformed_upgrade_receipt_stops_before_auth_preparation(self) -> None:
source = HARNESS_PATH.read_text(encoding="utf-8")
function = "run_browser_journey() {" + source.split("run_browser_journey() {", 1)[1].split("\n}\n", 1)[0] + "\n}"
for receipt in (None, "not JSON", "[]", '{"status":"failed"}',
'{"status":"incomplete","access_refused":false}',
'{"status":"failed","access_refused":"false"}'):
with self.subTest(receipt=receipt), tempfile.TemporaryDirectory() as tmp:
work = Path(tmp)
(work / "state").mkdir()
if receipt is not None:
(work / "state/browser-upgrade.json").write_text(receipt)
result = subprocess.run(["bash", "-c", "set -euo pipefail\nWORK_DIR="
+ json.dumps(tmp) + "\n" + function + "\nrun_browser_journey recovery v6.4.5\n"],
capture_output=True, text=True)
self.assertEqual(1, result.returncode, result.stderr)
self.assertEqual({"status": "not-executed", "reason": "upgrade-receipt-unavailable"},
json.loads((work / "state/browser-recovery.json").read_text()))
self.assertFalse((work / "state/browser-auth.json").exists())
self.assertEqual("", result.stderr)
class AutoUpdateIntentTest(unittest.TestCase):
"""Execute the real observer/comparator, with guest systemctl observations."""
@@ -425,5 +477,73 @@ class WorkflowContractTest(unittest.TestCase):
self.assertNotIn("release-lifecycle-rehearsal", path.read_text(encoding="utf-8"))
def verify_browser_recovery_controls(controls_path: Path) -> None:
"""Feed exact Chromium receipts into the real guard in a jq-equipped proof VM.
The guard's permitted process is a local sentinel, not native recovery.
Source proof binds both this source and the browser input's exact hash.
"""
controls = json.loads(controls_path.read_text(encoding="utf-8"))
assert controls["schema_version"] == 1
assert controls["scope"] == "offline adversarial observer controls only"
assert controls["client_version"] == "1.56.1"
fixed = {(kind, "upgrade") for kind in ("success", "anonymous-probes", "restart-gap",
"wrong-preview", "silent-stream", "stuck-modal")}
fixed.add(("success", "recovery"))
fixed.update((f"cleanup-{status}", "upgrade") for status in (401, 403))
for endpoint in ("navigation", "authenticated-navigation", "login", "initial-version",
"authenticated-api", "check", "apply", "stream", "version", "health", "read"):
fixed.update((f"{endpoint}-{status}", "upgrade") for status in (401, 403))
results = controls["results"]
assert len(results) == len(fixed) == 31
assert {(row["fixture"], row["mode"]) for row in results} == fixed
source = HARNESS_PATH.read_text(encoding="utf-8")
function = "run_browser_journey() {" + source.split("run_browser_journey() {", 1)[1].split("\n}\n", 1)[0] + "\n}"
observed = []
for row in results:
kind, mode, receipt = row["fixture"], row["mode"], row["receipt"]
refused = re.search(r"-(401|403)$", kind) is not None
assert receipt["mode"] == mode and receipt["context_closed"] is True
assert receipt["access_refused"] is refused
assert row["requests_after_denial"] == 0
if refused:
assert receipt["status"] == "failed" and row["denials"] == 1
assert receipt["access_refusals"][0]["status"] == int(kind[-3:])
if mode != "upgrade":
continue
with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp)
(work / "state").mkdir()
(work / "state/browser-upgrade.json").write_text(json.dumps(receipt), encoding="utf-8")
started = work / "process-started"
sentinel = work / "process-sentinel.sh"
sentinel.write_text("#!/bin/sh\nprintf started > " + json.dumps(str(started)) + "\n", encoding="utf-8")
sentinel.chmod(0o700)
result = subprocess.run(["bash", "-c", "set -euo pipefail\n" + function
+ "\nrun_browser_journey recovery v6.4.5\n"], capture_output=True, text=True, timeout=5,
env={"PATH": "/usr/local/bin:/usr/bin:/bin", "WORK_DIR": tmp, "ROOT_DIR": str(REPO_ROOT),
"SEED_ADMIN_USER": "synthetic", "ADMIN_PASSWORD": "synthetic", "PULSE_REHEARSAL_NODE": str(sentinel),
"PULSE_REHEARSAL_BROWSER_ORIGIN": "https://ephemeral.tawny-powan.ts.net",
"FROM_TAG": "v6.4.5", "TO_TAG": "v6.4.6-rc.1"})
assert result.returncode == (1 if refused else 0) and result.stderr == ""
assert started.exists() is (not refused)
auth_prepared = (work / "state/browser-auth.json").exists()
assert auth_prepared is (not refused)
if refused:
assert json.loads((work / "state/browser-recovery.json").read_text()) == {
"status": "not-executed", "reason": "stopped-access-no-reauthentication"}
observed.append({"fixture": kind, "guard_exit": result.returncode,
"browser_process_started": started.exists(), "auth_prepared": auth_prepared})
print(json.dumps({"scope": "actual shell guard with bound offline Chromium receipts, not native recovery",
"browser_controls": len(results), "executed_recovery_guards": len(observed), "results": observed}))
if __name__ == "__main__":
unittest.main()
if len(sys.argv) == 3 and sys.argv[1] == "--browser-controls":
try:
verify_browser_recovery_controls(Path(sys.argv[2]))
except Exception:
print("Bound browser receipts failed recovery controls; no installed proof.", file=sys.stderr)
sys.exit(1)
else:
unittest.main()
@@ -0,0 +1,276 @@
/* Real, bounded browser observation of the disposable release install.
* No intercepted API responses, mocked versions, TLS bypass or apply replay.
* The CLI accepts only the new runner's HTTPS Tailscale Serve origin. Exported
* functions allow offline adversarial fixtures to test the observer itself.
*/
const fs = require('node:fs');
const path = require('node:path');
const assert = require('node:assert/strict');
const { chromium } = require('playwright');
function validate(options) {
assert.match(options.origin, /^https:\/\/[a-z0-9-]+\.tawny-powan\.ts\.net$/);
assert.match(options.from, /^v\d+\.\d+\.\d+$/);
assert.match(options.to, /^v\d+\.\d+\.\d+-rc\.[1-9]\d*$/);
assert.ok(['upgrade', 'recovery'].includes(options.mode));
assert.equal(options.expected, options.mode === 'upgrade' ? options.to : options.from);
}
function judge(record) {
assert.equal(record.access_refused, false, 'denied access is never acceptance');
assert.equal(record.login_status, 200, 'authenticated UI login must succeed');
assert.equal(record.observed_version, record.expected.replace(/^v/, ''), 'wrong served version');
assert.equal(record.health, 'healthy', 'installed server is not healthy');
assert.equal(record.protected_read_status, 200, 'session did not survive/recover');
if (record.mode === 'upgrade') {
assert.equal(record.apply_requests, 1, 'exactly one UI apply is required');
assert.equal(record.apply_status, 200, 'apply was not accepted');
assert.equal(record.selected_version, record.to.replace(/^v/, ''), 'wrong release selected');
assert.ok(record.sse_opens > 0, 'no real EventSource connection opened');
assert.ok(record.sse_progress.some(p => p.status !== 'idle' && p.progress > 0),
'no live SSE progress received');
assert.equal(record.progress_dialog_visible, false, 'progress modal remained stuck after readiness');
}
}
async function runJourney(browser, options) {
const record = { schema_version: 1, mode: options.mode, from: options.from, to: options.to,
expected: options.expected, browser_version: browser.version(),
client_version: require('playwright/package.json').version, active_step: 'start', login_status: null, apply_requests: 0, apply_status: null,
sse_opens: 0, sse_progress: [], access_refused: false, access_refusals: [],
navigation_statuses: [], status: 'incomplete' };
assert.equal(record.client_version, '1.56.1', 'browser client must match the reviewed lock');
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
const page = await context.newPage();
page.setDefaultTimeout(options.timeout || 30000);
const origin = new URL(options.origin).origin;
const authenticatedRequests = new WeakSet();
let stopAccess;
const stopped = new Promise(resolve => { stopAccess = resolve; });
let closing;
const closeContext = () => closing ||= context.close().then(
() => { record.context_closed = true; },
() => { record.context_closed = false; record.status = 'failed'; record.failure_type = 'ContextCleanupError'; },
);
const requireAccess = () => assert.equal(record.access_refused, false,
'access refused; stop without another login, read or apply');
const step = async operation => {
requireAccess();
const value = await Promise.race([operation(), stopped.then(requireAccess)]);
requireAccess();
return value;
};
const refuse = (endpoint, status) => {
if (![401, 403].includes(status)) return;
if (record.access_refusals.length < 16) {
record.access_refusals.push({ endpoint, status, step: record.active_step });
}
record.access_refused = true; // Monotonic, including automatic UI reloads.
stopAccess();
// Stop the page too: EventSource/UI code must not reconnect after denial.
// No replacement response, alternate context or reauthentication is used.
void closeContext();
};
// Retain only closed status/progress fields, not messages, headers, cookies,
// request bodies, full response JSON or private resource data.
await page.exposeFunction('__observeReleaseStream', item => {
if (item.open === true) record.sse_opens++;
if (['idle', 'downloading', 'verifying', 'extracting', 'installing', 'restarting',
'completed', 'error', 'failed', 'checking', 'backing_up'].includes(item.status)
&& Number.isFinite(item.progress) && item.progress >= 0 && item.progress <= 100
&& record.sse_progress.length < 1000) {
record.sse_progress.push({ status: item.status, progress: item.progress });
}
});
await page.addInitScript(() => {
const NativeEventSource = window.EventSource;
window.EventSource = class extends NativeEventSource {
constructor(url, config) {
super(url, config);
if (new URL(url, location.href).pathname === '/api/updates/stream') {
this.addEventListener('open', () => window.__observeReleaseStream({ open: true }));
this.addEventListener('message', event => {
try {
const data = JSON.parse(event.data);
void window.__observeReleaseStream({ status: data.status, progress: data.progress });
} catch (_) { /* malformed data is not progress evidence */ }
});
}
}
};
});
page.on('request', request => {
const url = new URL(request.url());
if (url.origin !== origin) return;
// Bind authentication at request start, not response arrival. An anonymous
// auth probe can legitimately return 401, even just after login succeeds.
if (record.login_status === 200) authenticatedRequests.add(request);
if (url.pathname === '/api/updates/apply' && request.method() === 'POST') {
record.apply_requests++;
}
});
page.on('response', response => {
const url = new URL(response.url());
if (url.origin !== origin) return;
const endpoint = url.pathname;
const request = response.request();
if (request.isNavigationRequest() && request.frame() === page.mainFrame()) {
if (record.navigation_statuses.length < 16) record.navigation_statuses.push(response.status());
refuse('navigation', response.status());
}
if (endpoint === '/api/login' && request.method() === 'POST') {
record.login_status = response.status();
refuse('login', response.status());
}
if (endpoint === '/api/updates/apply' && request.method() === 'POST') {
record.apply_status = response.status();
refuse('apply', response.status());
} else if (authenticatedRequests.has(request) && endpoint.startsWith('/api/')) {
if (endpoint === '/api/config/nodes') record.protected_read_status = response.status();
// Closed endpoint classes only: never retain a path, query, URL, body,
// error message, header, cookie or resource identity from the install.
const classes = { '/api/version': 'version', '/api/health': 'health',
'/api/config/nodes': 'protected_read', '/api/updates/check': 'update_check',
'/api/updates/stream': 'update_stream' };
refuse(classes[endpoint] || 'authenticated_api', response.status());
}
});
const navigate = async () => {
const response = await step(() => page.goto(`${options.origin}/settings/system-updates`,
{ waitUntil: 'domcontentloaded' }));
assert.equal(response?.status(), 200, 'navigation did not serve the UI');
};
try {
record.active_step = 'navigate_login';
await navigate();
record.active_step = 'login';
await step(() => page.getByPlaceholder('Username', { exact: true }).fill(options.auth.username));
await step(() => page.getByPlaceholder('Password', { exact: true }).fill(options.auth.password));
const login = page.waitForResponse(r => new URL(r.url()).pathname === '/api/login'
&& r.request().method() === 'POST').catch(() => null);
await step(() => page.getByRole('button', { name: 'Sign in to Pulse', exact: true }).click());
assert.equal((await step(() => login))?.status(), 200, 'login rejected; do not retry or change identity');
await step(() => page.getByPlaceholder('Username', { exact: true }).waitFor({ state: 'hidden' }));
record.active_step = 'navigate_authenticated';
await navigate();
if (options.mode === 'upgrade') {
record.active_step = 'select_exact_release';
const initial = await step(() => page.evaluate(async () => {
const response = await fetch('/api/version');
return { status: response.status, version: response.ok ? await response.json() : null };
}));
assert.equal(initial.status, 200, 'initial version read rejected');
assert.equal(initial.version.version.replace(/^v/, ''), options.from.replace(/^v/, ''));
assert.equal(initial.version.isDocker, false, 'a Docker override is not native acceptance');
assert.equal(initial.version.isSourceBuild, false, 'must run the published binary');
await step(() => page.getByRole('button', { name: /^Preview\b/ }).click());
await step(() => page.getByRole('button', { name: 'Save Changes', exact: true }).click());
const check = page.waitForResponse(r => new URL(r.url()).pathname === '/api/updates/check'
&& new URL(r.url()).searchParams.get('channel') === 'rc').catch(() => null);
await step(() => page.getByRole('button', { name: /Check for Updates/i }).click());
const checked = await step(() => check);
assert.equal(checked?.status(), 200, 'update check rejected; no alternate route');
const selected = await step(() => checked.json());
record.selected_version = selected.latestVersion?.replace(/^v/, '');
assert.equal(record.selected_version, options.to.replace(/^v/, ''), 'refuse a different preview');
assert.equal(selected.downloadUrl,
`https://github.com/rcourtman/Pulse/releases/download/${options.to}/pulse-${options.to}-linux-amd64.tar.gz`);
await step(() => page.getByRole('button', { name: 'Install Update', exact: true }).first().click());
const confirmation = page.getByRole('dialog').filter({ has: page.getByRole('button', { name: 'Start Update' }) });
record.active_step = 'confirm_apply_once';
await step(() => confirmation.getByRole('checkbox').check());
// No command is replayed after a timeout, rejected POST or lost response.
await step(() => confirmation.getByRole('button', { name: 'Start Update', exact: true }).click());
await step(() => page.getByRole('dialog').filter({ hasText: /Downloading update|Updating Pulse|Update Progress/i })
.waitFor({ state: 'visible' }));
}
record.active_step = 'version_health_session_readiness';
const deadline = Date.now() + (options.readinessTimeout || 600000);
let ready = false;
while (Date.now() < deadline) {
// Read-only observations during a known restart may temporarily have no
// listener. An HTTP401 is not such a transport gap and stops this journey.
const observed = await step(() => page.evaluate(async () => {
const result = {};
try {
const version = await fetch('/api/version');
result.versionStatus = version.status;
if ([401, 403].includes(version.status)) return result;
const health = await fetch('/api/health');
result.healthStatus = health.status;
if ([401, 403].includes(health.status)) return result;
const protectedRead = await fetch('/api/config/nodes');
result.protectedStatus = protectedRead.status;
if ([401, 403].includes(protectedRead.status)) return result;
try { result.version = await version.json(); result.health = await health.json(); }
catch (_) { result.body_unavailable = true; }
return result;
} catch (_) { return { ...result, transport_unavailable: true }; }
}).catch(() => ({ transport_unavailable: true }))); // automatic reload is a transport gap, not denial
if (observed.protectedStatus !== undefined) record.protected_read_status = observed.protectedStatus;
// Also check returned statuses in case page destruction preceded the
// Playwright response event. No health/read retry follows either denial.
for (const [endpoint, status] of [['version', observed.versionStatus],
['health', observed.healthStatus], ['protected_read', observed.protectedStatus]]) refuse(endpoint, status);
requireAccess();
if (observed.versionStatus === 200 && observed.healthStatus === 200
&& observed.protectedStatus === 200 && typeof observed.version?.version === 'string'
&& observed.version.version.replace(/^v/, '') === options.expected.replace(/^v/, '')
&& observed.health?.status === 'healthy') {
record.observed_version = observed.version.version.replace(/^v/, '');
record.health = observed.health.status;
record.protected_read_status = observed.protectedStatus;
ready = true;
break;
}
await step(() => new Promise(resolve => setTimeout(resolve, 1000)));
}
assert.ok(ready, 'readiness deadline expired');
// The UI gets its own bounded window to dismiss/reload after version readiness.
record.active_step = 'progress_modal_completion';
const progress = page.getByRole('dialog').filter({ hasText: /Downloading update|Updating Pulse|Update Progress/i });
await step(() => progress.waitFor({ state: 'hidden', timeout: options.timeout || 60000 }));
record.progress_dialog_visible = await step(() => progress.isVisible());
if (options.outputDir) await step(() => page.screenshot({ path: path.join(options.outputDir, `browser-${options.mode}.png`), fullPage: true }));
judge(record);
record.active_step = 'complete';
record.status = 'passed';
} catch (error) {
record.status = 'failed';
// Playwright errors can contain URLs, page text or values. Preserve a fixed
// type, not arbitrary error text or credentials, in the public receipt.
record.failure_type = error.name === 'TimeoutError' ? 'TimeoutError' : 'AssertionOrOperationError';
throw error;
} finally {
await closeContext();
if (record.access_refused) {
record.status = 'failed';
record.failure_type ||= 'AssertionOrOperationError';
}
if (options.outputDir) fs.writeFileSync(path.join(options.outputDir, `browser-${options.mode}.json`), JSON.stringify(record, null, 2) + '\n');
assert.equal(record.context_closed, true, 'browser context cleanup failed');
requireAccess(); // A response received during cleanup cannot become a pass.
}
return record;
}
async function main() {
const args = process.argv.slice(2);
const fields = { '--origin': 'origin', '--mode': 'mode', '--from': 'from', '--to': 'to',
'--expected': 'expected', '--auth-file': 'authFile', '--output-dir': 'outputDir' };
// Seven fixed options; no endpoint, credential, browser or command override.
assert.equal(args.length, 14);
const options = {};
for (let i = 0; i < args.length; i += 2) {
assert.ok(fields[args[i]] && options[fields[args[i]]] === undefined);
options[fields[args[i]]] = args[i + 1];
}
validate(options);
options.auth = JSON.parse(fs.readFileSync(options.authFile, 'utf8'));
fs.mkdirSync(options.outputDir, { recursive: true });
const browser = await chromium.launch({ headless: true, channel: 'chromium', args: ['--no-sandbox'] });
try { await runJourney(browser, options); } finally { await browser.close(); }
}
module.exports = { validate, judge, runJourney };
if (require.main === module) main().catch(() => { console.error('Release browser journey failed; inspect bounded receipt. No replay.'); process.exitCode = 1; });
@@ -0,0 +1,212 @@
/* Offline adversarial observer controls. These fixtures are not Pulse release,
* systemd, installed update, Tailscale or qualification evidence. */
const assert = require('node:assert/strict');
const http = require('node:http');
const fs = require('node:fs');
const path = require('node:path');
const { chromium } = require('playwright');
const { validate, judge, runJourney } = require('./release-browser-journey.cjs');
const from = 'v6.4.5', to = 'v6.4.6-rc.1';
const downloadUrl = `https://github.com/rcourtman/Pulse/releases/download/${to}/pulse-${to}-linux-amd64.tar.gz`;
function pureControls() {
const valid = { origin: 'https://ephemeral.tawny-powan.ts.net', mode: 'upgrade', from, to, expected: to };
validate(valid);
for (const origin of ['http://ephemeral.tawny-powan.ts.net', 'https://private.example.com',
'https://ephemeral.other.ts.net', 'https://ephemeral.tawny-powan.ts.net:443', 'https://ephemeral.tawny-powan.ts.net/path']) {
assert.throws(() => validate({ ...valid, origin }));
}
for (const delta of [{ mode: 'apply' }, { to: 'v6.4.6' }, { expected: from }]) assert.throws(() => validate({ ...valid, ...delta }));
const receipt = { ...valid, access_refused: false, login_status: 200, observed_version: to.slice(1), health: 'healthy',
protected_read_status: 200, apply_requests: 1, apply_status: 200, selected_version: to.slice(1),
sse_opens: 1, sse_progress: [{ status: 'downloading', progress: 10 }], progress_dialog_visible: false };
judge(receipt);
for (const delta of [{ access_refused: true }, { login_status: 401 }, { observed_version: from.slice(1) }, { health: 'unhealthy' },
{ protected_read_status: 403 }, { apply_requests: 0 }, { apply_requests: 2 }, { apply_status: 500 },
{ selected_version: '6.5.0-rc.1' }, { sse_opens: 0 }, { sse_progress: [] }, { progress_dialog_visible: true }]) {
assert.throws(() => judge({ ...receipt, ...delta }));
}
}
function fixture(kind) {
let version = from.slice(1), applied = false, denied = false;
const counters = { applies: 0, logins: 0, version_reads: 0, health_reads: 0,
protected_reads: 0, streams: 0, denials: 0, anonymous_probes: 0,
requests_after_denial: 0, transport_gaps: 0 };
const denial = /^(.*)-(401|403)$/.exec(kind);
const sockets = new Set(), timers = new Set();
const later = (fn, ms) => { const timer = setTimeout(() => { timers.delete(timer); fn(); }, ms); timers.add(timer); };
const server = http.createServer((req, res) => {
if (denied) counters.requests_after_denial++;
const url = new URL(req.url, 'http://localhost');
const signedIn = /fixture-session=true/.test(req.headers.cookie || '');
const json = (code, data) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(data)); };
const refuse = endpoint => {
if (denial?.[1] !== endpoint) return false;
denied = true; counters.denials++;
json(Number(denial[2]), { error: 'DO NOT RETAIN FIXTURE PRIVATE DENIAL' });
return true;
};
if (url.pathname === '/api/login') {
counters.logins++;
if (refuse('login')) return;
res.setHeader('Set-Cookie', 'fixture-session=true; Path=/; HttpOnly; SameSite=Lax');
return json(200, { success: true });
}
if (url.pathname === '/api/security/status' || url.pathname === '/api/config/nodes' && !signedIn) {
counters.anonymous_probes++;
return json(url.pathname === '/api/security/status' ? 401 : 403, { error: 'normal anonymous auth probe' });
}
if (url.pathname === '/api/version') {
counters.version_reads++;
if (refuse(applied ? 'version' : 'initial-version')) return;
if (applied && kind === 'restart-gap' && counters.transport_gaps === 0) {
counters.transport_gaps++; res.destroy(); return;
}
return json(200, { version, isDocker: false, isSourceBuild: false });
}
if (url.pathname === '/api/health') {
counters.health_reads++;
if (refuse('health')) return;
return json(200, { status: 'healthy' });
}
if (url.pathname === '/api/config/nodes') {
counters.protected_reads++;
if (applied && refuse('read')) return;
return json(200, []);
}
if (url.pathname === '/api/settings/system') {
if (refuse('authenticated-api')) return;
return json(200, {});
}
if (url.pathname === '/api/cleanup-control') { refuse('cleanup'); return; }
if (url.pathname === '/api/updates/check') {
if (refuse('check')) return;
return json(200, { latestVersion: kind === 'wrong-preview' ? '6.5.0-rc.1' : to.slice(1), downloadUrl });
}
if (url.pathname === '/api/updates/apply') {
counters.applies++;
if (refuse('apply')) return;
applied = true;
later(() => { version = to.slice(1); }, 350);
return json(200, { status: 'started' });
}
if (url.pathname === '/api/updates/stream') {
counters.streams++;
if (refuse('stream')) return;
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' });
res.write(': real loopback fixture stream\n\n');
if (kind !== 'silent-stream') res.write(`data: ${JSON.stringify({ status: 'downloading', progress: 10, message: 'DO NOT RETAIN FIXTURE PRIVATE MESSAGE' })}\n\n`);
req.on('close', () => res.end()); return;
}
if (refuse(signedIn ? 'authenticated-navigation' : 'navigation')) return;
res.writeHead(200, { 'content-type': 'text/html' });
res.end(`<!doctype html><html><head><title>Offline release observer control</title></head>
<body><h1>Observer fixture — not Pulse/native qualification</h1>${signedIn ? `
<h2>System updates</h2><button id="preview">Preview beta builds</button><button id="save">Save Changes</button>
<button id="check">Check for Updates</button><button id="install" hidden>Install Update</button>
<main id="dialogs"></main><p id="state">Published fixture ${version}</p>
<script>
document.querySelector('#save').onclick = async () => { await fetch('/api/settings/system', {method:'POST'}); };
document.querySelector('#check').onclick = async () => { await fetch('/api/updates/check?channel=rc'); document.querySelector('#install').hidden=false; };
document.querySelector('#install').onclick = () => {
document.querySelector('#dialogs').innerHTML='<section role="dialog" aria-label="Confirm update"><h2>Confirm Update</h2><label><input type="checkbox"> Acknowledge</label><button id="start">Start Update</button></section>';
document.querySelector('#start').onclick = async () => {
const response = await fetch('/api/updates/apply', {method:'POST'}); if (!response.ok) return;
document.querySelector('#dialogs').innerHTML='<section role="dialog" aria-label="Updating Pulse"><h2>Updating Pulse</h2><p>Downloading update 10%</p></section>';
const stream=new EventSource('/api/updates/stream');
setTimeout(() => { ${kind === 'stuck-modal' ? '' : "document.querySelector('#dialogs').innerHTML='';"}
document.querySelector('#state').textContent='Fixture ready'; stream.close(); }, 700);
};
};
</script>` : `
<input placeholder="Username"><input placeholder="Password" type="password"><button id="login">Sign in to Pulse</button>
<script>
const probes = ${kind === 'anonymous-probes' ? "Promise.all([fetch('/api/security/status'), fetch('/api/config/nodes')])" : 'Promise.resolve()'};
document.querySelector('#login').onclick = async () => { await probes; const response=await fetch('/api/login', {method:'POST'}); if(response.ok)location.reload(); };
</script>`}</body></html>`);
});
server.on('connection', socket => { sockets.add(socket); socket.on('close', () => sockets.delete(socket)); });
return { server, counts: () => ({ ...counters }), close: async () => {
for (const timer of timers) clearTimeout(timer);
for (const socket of sockets) socket.destroy();
await new Promise(resolve => server.close(resolve));
} };
}
async function runControls(outputDir) {
pureControls();
const browser = await chromium.launch({ headless: true, channel: 'chromium', args: ['--no-sandbox'] });
const results = [];
try {
const controls = [
['success', 'upgrade', true, 1, 1], ['success', 'recovery', true, 0, 1],
['anonymous-probes', 'upgrade', true, 1, 1], ['restart-gap', 'upgrade', true, 1, 1],
['wrong-preview', 'upgrade', false, 0, 1], ['silent-stream', 'upgrade', false, 1, 1],
['stuck-modal', 'upgrade', false, 1, 1],
['cleanup-401', 'upgrade', false, 1, 1], ['cleanup-403', 'upgrade', false, 1, 1],
];
for (const endpoint of ['navigation', 'authenticated-navigation', 'login', 'initial-version',
'authenticated-api', 'check', 'apply', 'stream', 'version', 'health', 'read']) {
for (const status of [401, 403]) {
controls.push([`${endpoint}-${status}`, 'upgrade', false,
['apply', 'stream', 'version', 'health', 'read'].includes(endpoint) ? 1 : 0,
endpoint === 'navigation' ? 0 : 1]);
}
}
for (const [kind, mode, expectedPass, expectedApplies, expectedLogins] of controls) {
const target = fixture(kind);
await new Promise(resolve => target.server.listen(0, '127.0.0.1', resolve));
const folder = path.resolve(outputDir, `${kind}-${mode}`); fs.mkdirSync(folder, { recursive: true });
let passed = false;
try {
// A real denied HTTP response deliberately arrives during cleanup,
// after the main checks. The observer must not leave a passed receipt.
// Only this fixture delays context disposal; no response is fabricated.
const testBrowser = kind.startsWith('cleanup-') ? {
version: () => browser.version(),
newContext: async options => {
const context = await browser.newContext(options);
const close = context.close.bind(context);
context.close = async () => {
await context.pages()[0].evaluate(() => fetch('/api/cleanup-control'));
await close();
};
return context;
},
} : browser;
await runJourney(testBrowser, { origin: `http://127.0.0.1:${target.server.address().port}`, mode, from, to,
expected: mode === 'upgrade' ? to : from, auth: { username: 'synthetic', password: 'synthetic' },
timeout: 1500, readinessTimeout: 3000, outputDir: folder });
passed = true;
} catch (_) { /* observer must reject each deliberately adverse fixture */ }
finally { await target.close(); }
assert.equal(passed, expectedPass, `${kind}/${mode} observer verdict`);
const counts = target.counts();
assert.equal(counts.applies, expectedApplies, 'one apply, no replay');
assert.equal(counts.logins, expectedLogins, 'no login after navigation denial');
assert.equal(counts.requests_after_denial, 0, 'page stops after denial, including SSE reconnect');
const receipt = JSON.parse(fs.readFileSync(path.join(folder, `browser-${mode}.json`)));
assert.ok(!JSON.stringify(receipt).includes('PRIVATE MESSAGE'));
assert.ok(!JSON.stringify(receipt).includes('synthetic'));
assert.equal(receipt.context_closed, true, 'browser context cleaned up');
const refusal = /-(401|403)$/.test(kind);
assert.equal(receipt.access_refused, refusal, 'only actual access refusal stops later access');
if (refusal) {
assert.equal(receipt.access_refusals[0].status, Number(kind.slice(-3)));
assert.equal(counts.denials, 1, 'denied request is not repeated');
assert.ok(!JSON.stringify(receipt).includes('PRIVATE DENIAL'));
}
if (kind.startsWith('read-')) assert.equal(receipt.protected_read_status, Number(kind.slice(-3)));
if (kind === 'anonymous-probes') assert.equal(counts.anonymous_probes, 2, 'normal pre-login denial controls ran');
if (kind === 'restart-gap') assert.equal(counts.transport_gaps, 1, 'restart transport control ran');
results.push({ fixture: kind, mode, expected_rejection: !expectedPass, observed_status: receipt.status,
active_step: receipt.active_step, ...counts, receipt });
}
fs.writeFileSync(path.join(outputDir, 'observer-controls.json'), JSON.stringify({ schema_version: 1, scope: 'offline adversarial observer controls only',
browser_version: browser.version(), client_version: require('playwright/package.json').version, results }, null, 2) + '\n');
console.log(JSON.stringify({ observer_controls: results.length, scope: 'fixtures, not native qualification', results }));
} finally { await browser.close(); }
}
module.exports = { runControls, pureControls };
if (require.main === module) runControls(path.resolve(process.argv[2] || 'tmp/browser-journey-proof')).catch(error => { console.error(error); process.exitCode = 1; });