diff --git a/.github/workflows/setup-ca-linux.yml b/.github/workflows/setup-ca-linux.yml
new file mode 100644
index 0000000000..e385be24f9
--- /dev/null
+++ b/.github/workflows/setup-ca-linux.yml
@@ -0,0 +1,41 @@
+name: Linux setup CA commands
+
+# Focused instruction qualification; not a new publication prerequisite.
+on:
+ pull_request:
+ paths:
+ - 'amplifier_web/setup_page.py'
+ - 'scripts/check_setup_ca_linux.py'
+ - 'scripts/qualify_setup_ca_linux_ci.py'
+ - 'tests/test_setup_ca_linux_harness.py'
+ - 'docs/DEPLOYMENT.md'
+ - '.github/workflows/setup-ca-linux.yml'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ setup-ca-linux:
+ runs-on: ubuntu-24.04
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ ref: ${{ github.event.pull_request.head.sha || github.sha }}
+ persist-credentials: false
+ - uses: actions/setup-python@v5
+ with:
+ python-version: '3.13'
+ - name: Exercise exact rendered commands in one disposable Debian guest
+ env:
+ EXPECTED_SOURCE: ${{ github.event.pull_request.head.sha || github.sha }}
+ run: python scripts/qualify_setup_ca_linux_ci.py
+ - name: Retain checks, negative control and exact cleanup evidence
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: setup-ca-linux-${{ github.run_id }}-${{ github.run_attempt }}
+ path: ${{ runner.temp }}/setup-ca-evidence/
+ if-no-files-found: error
+ retention-days: 14
\ No newline at end of file
diff --git a/amplifier_web/setup_page.py b/amplifier_web/setup_page.py
index 659b798c91..71c1e95a76 100644
--- a/amplifier_web/setup_page.py
+++ b/amplifier_web/setup_page.py
@@ -10,6 +10,7 @@ class Platform(str, Enum):
WINDOWS = "windows"
ANDROID = "android"
IOS = "ios"
+ LINUX = "linux"
OTHER = "other"
@@ -25,6 +26,8 @@ def detect_platform(user_agent: str | None) -> Platform:
if "macintosh" in value or "mac os x" in value:
# iPad desktop mode identifies itself as Macintosh. Do not overclaim.
return Platform.MACOS
+ if "linux" in value:
+ return Platform.LINUX
return Platform.OTHER
@@ -69,9 +72,48 @@ def detect_platform(user_agent: str | None) -> Platform:
""",
Platform.OTHER: """
- - Install the verified CA using this operating system’s native trust-store tool. On Debian/Ubuntu, copy it to
/usr/local/share/ca-certificates/ and run sudo update-ca-certificates.
- - Restart the browser. Firefox may use a separate certificate store.
+ - Install the verified CA using this operating system’s approved trust-store tool. For Ubuntu or Debian, use the Linux section.
+ - Check your browser’s certificate settings; browser trust can differ from operating-system trust.
+""",
+ Platform.LINUX: """
+These commands are for Ubuntu and Debian, on the computer running your browser, not the Unified server. Other Linux distributions need their own approved trust-store procedure. WSL trust does not configure a Windows browser.
+Use a normal-user terminal with administrator access through sudo. The tools openssl, install and update-ca-certificates must be available (Ubuntu/Debian packages openssl, coreutils and ca-certificates). Ask your administrator if they are missing or policy blocks installation.
+
+ - Set the exact path of the public CA obtained through the trusted transfer above. For a browser download on an already trusted connection:
+
ca_file="$HOME/Downloads/amplifier-unified-ca.crt"
+If the browser saved a duplicate, use this instead:
+ca_file="$HOME/Downloads/amplifier-unified-ca (1).crt"
+Keep the quotes around spaces and parentheses. Adjust the directory if needed; do not use a wildcard or select an arbitrary downloaded certificate.
+openssl x509 -in "$ca_file" -noout -subject -issuer -fingerprint -sha256
+ - Stop and compare the SHA-256 fingerprint with
amplifier-unified setup-tls status run on the intended Unified host through a trusted console or verified SSH connection. Use that host’s actual data directory if non-default. This page and the downloaded file are not independent proof. Do not install if the fingerprints differ or the CA’s origin is uncertain. A trusted root can authenticate other sites, not just Unified.
+ - Only after that comparison, install this one verified PEM certificate into the system trust store. The destination starts with
/usr, not ~/usr (which means a folder under your home). Use the same terminal:
+ca_dest=/usr/local/share/ca-certificates/amplifier-unified-ca.crt
+if [ -e "$ca_dest" ] || [ -L "$ca_dest" ]; then
+ printf '%s\\n' "Stop: $ca_dest already exists; ask your administrator to check it." >&2
+ false
+elif [ "$(grep -c -- '-----BEGIN ' "$ca_file")" != 1 ] ||
+ [ "$(grep -c -- '-----END ' "$ca_file")" != 1 ]; then
+ printf '%s\\n' "Stop: expected exactly one PEM certificate, not a bundle." >&2
+ false
+else
+ sudo install -d -m 0755 /usr/local/share/ca-certificates &&
+ sudo install -m 0644 -- "$ca_file" "$ca_dest" &&
+ sudo update-ca-certificates
+fi
+This uses a stable .crt filename and readable public-certificate permissions. An existing destination is not overwritten; another host’s CA needs a distinct name. Stop on any command error.
+ - Check OS trust for this CA, using the generated certificate bundle:
+
openssl verify -CAfile /etc/ssl/certs/ca-certificates.crt "$ca_file"
+Expect the selected filename followed by OK. OpenSSL can also consult its configured default trust locations. This checks OS trust for the CA, not the remote server’s hostname, connection, or browser trust.
+ - Save your browser work, then fully quit and reopen it yourself. Open the intended HTTPS address and confirm there is no certificate warning before signing in. Firefox, Chromium, Chrome and Edge on Linux can use different certificate stores, depending on version, packaging, profile and policy (including confined Snap packages). OS success is not proof of browser success.
+
+Still “Not secure”? Open the browser’s connection/certificate details and read the exact error before importing anything again. If the browser says security warnings were turned off for this site, save any unsent work, turn warnings back on, and reload without proceeding past a warning. Check that the served certificate belongs to the intended host and chains to the verified CA. Match the URL hostname against the certificate’s Subject Alternative Name (SAN), not just its Common Name (CN). A hostname mismatch needs the correct URL or a server-certificate repair; a date error needs a clock/validity check. Neither is fixed by another CA import.
+If the error is an untrusted issuer and the host/CA identity checks match, inspect the browser’s certificate manager for that verified CA. Firefox has a separate Authorities store; Chromium/Chrome and Edge versions can expose different local-certificate controls. Use the installed browser’s certificate settings or your administrator’s approved procedure to import the same verified CA for website trust. Do not disable verification, bypass the warning, or run NSS/certutil commands against a guessed profile/database.
+A rehash warning about ca-certificates.crt containing more than one certificate normally refers to the generated multi-certificate bundle. It does not prove your CA or browser is correct. A warning naming your individual CA file needs investigation: each local .crt file must contain exactly one PEM certificate.
+Undo only this installation: if you installed the new file at the exact destination above, remove only that file and rebuild the bundle. Do not remove a pre-existing CA or use a wildcard.
+sudo rm -- /usr/local/share/ca-certificates/amplifier-unified-ca.crt &&
+sudo update-ca-certificates --fresh
+Any separate browser import must also be removed from that browser’s certificate settings. Save work before restarting it.
""",
}
@@ -100,6 +142,7 @@ def render_setup_page(platform: Platform, certificate_available: bool, fingerpri
(Platform.WINDOWS, "Windows"),
(Platform.ANDROID, "Android"),
(Platform.IOS, "iOS and iPadOS"),
+ (Platform.LINUX, "Linux — Ubuntu and Debian"),
(Platform.OTHER, "Other platforms"),
)
)
@@ -110,6 +153,7 @@ def render_setup_page(platform: Platform, certificate_available: bool, fingerpri
body {{ color: #17212b; font: 16px/1.5 system-ui, sans-serif; margin: 0; background: #f4f7f9; }}
main {{ background: white; box-sizing: border-box; margin: 2rem auto; max-width: 52rem; padding: clamp(1rem, 4vw, 3rem); }}
h1 {{ line-height: 1.2; }} code {{ overflow-wrap: anywhere; }} details {{ border-top: 1px solid #ccd5dd; padding: .75rem 0; }}
+pre {{ white-space: pre-wrap; overflow-wrap: anywhere; padding: .75rem; background: #f4f7f9; }}
summary {{ cursor: pointer; font-weight: 700; }} .download {{ font-size: 1.1rem; font-weight: 700; }}
.warning {{ border-left: .3rem solid #a95700; padding-left: 1rem; }} .note {{ color: #4a5560; }}
@media (max-width: 35rem) {{ body {{ background: white; }} main {{ margin: 0; }} ol {{ padding-left: 1.4rem; }} }}
diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md
index b0580c9877..e1afedd16f 100644
--- a/docs/DEPLOYMENT.md
+++ b/docs/DEPLOYMENT.md
@@ -72,10 +72,115 @@ scp '@:/absolute/path/amplifier-unified-ca.crt' .
openssl x509 -in amplifier-unified-ca.crt -noout -fingerprint -sha256
```
-Compare that fingerprint with the trusted host's `amplifier-unified setup-tls status` output. Import the CA into the user trust store on each client device, then restart the browser. On macOS, use Keychain Access's **login** keychain and trust it for SSL. Follow your organization's approved process for mobile-device certificate trust.
+Compare that fingerprint with the trusted host's `amplifier-unified setup-tls status` output. Use the same data directory as the running host if non-default. On macOS, use Keychain Access's **login** keychain and trust it for SSL. Windows can use the current user's Trusted Root Certification Authorities store. Follow your organization's approved process for mobile-device certificate trust.
The `/setup` page can provide platform-specific follow-up instructions after TLS is trusted. Its displayed fingerprint or an anonymous certificate download is not independent proof of authenticity.
+### Ubuntu and Debian clients
+
+Run these steps on the computer running the browser, not the Unified server.
+WSL trust does not configure a Windows browser. Other Linux distributions need
+their own approved procedure. Use a normal-user terminal with `sudo` access and
+the `openssl`, `coreutils` and `ca-certificates` packages. If tools are missing or
+policy blocks installation, ask your administrator; the page installs nothing.
+
+1. Set the exact public CA path from the trusted transfer. For a download from an
+ **already trusted** connection:
+
+```sh
+ca_file="$HOME/Downloads/amplifier-unified-ca.crt"
+```
+
+If the browser saved a duplicate, use this **instead**:
+
+```sh
+ca_file="$HOME/Downloads/amplifier-unified-ca (1).crt"
+```
+
+Keep the quotes around spaces and parentheses. Adjust the directory if needed;
+do not use a wildcard or select an arbitrary downloaded certificate.
+
+```sh
+openssl x509 -in "$ca_file" -noout -subject -issuer -fingerprint -sha256
+```
+
+2. **Stop and compare the SHA-256 fingerprint** with `amplifier-unified setup-tls
+ status` on the intended host, through a trusted console or verified SSH. Do
+ not install if the fingerprints differ or origin is uncertain. A root CA can
+ authenticate other sites, not just Unified.
+3. After verification, install the single PEM certificate using the same terminal.
+ The destination is absolute `/usr/...`, **not `~/usr/...`** under your home:
+
+```sh
+ca_dest=/usr/local/share/ca-certificates/amplifier-unified-ca.crt
+if [ -e "$ca_dest" ] || [ -L "$ca_dest" ]; then
+ printf '%s\n' "Stop: $ca_dest already exists; ask your administrator to check it." >&2
+ false
+elif [ "$(grep -c -- '-----BEGIN ' "$ca_file")" != 1 ] ||
+ [ "$(grep -c -- '-----END ' "$ca_file")" != 1 ]; then
+ printf '%s\n' "Stop: expected exactly one PEM certificate, not a bundle." >&2
+ false
+else
+ sudo install -d -m 0755 /usr/local/share/ca-certificates &&
+ sudo install -m 0644 -- "$ca_file" "$ca_dest" &&
+ sudo update-ca-certificates
+fi
+```
+
+The stable `.crt` destination holds exactly one PEM certificate, readable by
+applications. Do not overwrite an existing CA; another host needs a distinct
+destination. Stop on any error. A rehash warning about the generated
+`ca-certificates.crt` bundle containing multiple certificates is normally benign,
+but a warning naming your individual CA file needs investigation.
+
+4. Check OS trust:
+
+```sh
+openssl verify -CAfile /etc/ssl/certs/ca-certificates.crt "$ca_file"
+```
+
+Expect the selected filename followed by `OK`. OpenSSL can also consult its
+configured default trust locations. This checks OS trust for the CA, not the
+server's hostname/connection or the browser's trust.
+
+5. Save browser work, fully quit and reopen the browser yourself, and open the
+ intended HTTPS URL. Confirm there is no certificate warning before signing in.
+ Firefox, Chromium/Chrome and Edge can use different stores depending on version,
+ packaging, profile and policy. Confined Snap packages may not inherit OS trust.
+ OS success is **not** proof of browser success.
+
+If the browser still says **Not secure**, read its connection/certificate details
+before importing anything again. If warnings were turned off for this site,
+save unsent work, turn warnings back on and reload without proceeding past a
+warning. Check the served host and chain against the verified CA. Match the
+URL hostname against the Subject Alternative Name (SAN), not just the Common
+Name (CN). A hostname mismatch needs the correct URL or server-certificate
+repair; a date error needs a clock/validity check, not another CA import. For an
+untrusted issuer with matching host/CA identities, inspect the installed browser's
+certificate manager and follow its local-CA/Authorities import procedure or your
+administrator's policy. Firefox has a separate Authorities store. Do not disable
+verification, bypass warnings or run NSS/certutil against a guessed database.
+Chromium's documented Linux NSS location has changed across versions; it is not
+evidence of the location used by a particular Edge installation.
+
+**Back out only your new installation**, after checking that this exact path is
+the file you installed (not a pre-existing CA):
+
+```sh
+sudo rm -- /usr/local/share/ca-certificates/amplifier-unified-ca.crt &&
+sudo update-ca-certificates --fresh
+```
+
+Remove any separately imported browser copy in that browser's certificate
+settings too. OS removal alone does not prove browser removal.
+
+References: [Ubuntu trust-store guide](https://ubuntu.com/server/docs/how-to/security/install-a-root-ca-certificate-in-the-trust-store/),
+[Debian update-ca-certificates](https://manpages.debian.org/testing/ca-certificates/update-ca-certificates.8.en.html),
+[Chromium Linux certificate management](https://chromium.googlesource.com/chromium/src/+/refs/heads/main/docs/linux/cert_management.md),
+[Firefox certificate policies](https://firefox-admin-docs.mozilla.org/reference/policies/certificates/),
+and [Edge root-store policy history](https://learn.microsoft.com/en-us/deployedge/microsoft-edge-policies/microsoftrootstoreenabled).
+The obsolete Edge root-store policy is not a workaround.
+
## Keep the host running
On Linux, including a WSL distribution with systemd enabled, install and manage
diff --git a/scripts/check_setup_ca_linux.py b/scripts/check_setup_ca_linux.py
new file mode 100644
index 0000000000..e80fd027ed
--- /dev/null
+++ b/scripts/check_setup_ca_linux.py
@@ -0,0 +1,247 @@
+"""Opt-in acceptance of /setup commands in ONE disposable Debian/Ubuntu guest.
+
+Never run on a host or a real user's machine. Requires a writable system trust
+store, root, sudo, OpenSSL and ca-certificates inside an owned isolated guest.
+No package installation, network, real certificate, or browser-profile changes.
+The caller owns guest creation, evidence export and teardown.
+"""
+from __future__ import annotations
+
+import argparse
+from contextlib import contextmanager
+from html.parser import HTMLParser
+import json
+import os
+from pathlib import Path
+import runpy
+import shutil
+import subprocess
+import tempfile
+
+
+DEST = Path('/usr/local/share/ca-certificates/amplifier-unified-ca.crt')
+BUNDLE = '/etc/ssl/certs/ca-certificates.crt'
+
+
+class Commands(HTMLParser):
+ def __init__(self, page):
+ super().__init__()
+ self.blocks = {}
+ self.current = None
+ self.feed(page)
+
+ def handle_starttag(self, tag, attrs):
+ identity = dict(attrs).get('id', '')
+ if tag == 'code' and identity.startswith('linux-ca-'):
+ self.current = identity
+ self.blocks[identity] = ''
+
+ def handle_data(self, data):
+ if self.current:
+ self.blocks[self.current] += data
+
+ def handle_endtag(self, tag):
+ if tag == 'code':
+ self.current = None
+
+
+def cleanup_trust_store(owned, cleanup_bytes, result, run):
+ """Remove only this run's exact public bytes; record progress before rebuilding."""
+ result['failureCleanup'] = 'not required'
+ result['bundleRebuild'] = 'not required'
+ if not owned:
+ return
+ result['failureCleanup'] = 'pending ownership check'
+ if not DEST.exists() and not DEST.is_symlink():
+ result['failureCleanup'] = 'destination already absent'
+ elif not DEST.is_symlink() and DEST.read_bytes() in cleanup_bytes:
+ DEST.unlink()
+ result['failureCleanup'] = 'removed exact synthetic CA'
+ else:
+ result['failureCleanup'] = 'not removed: bytes changed or symlink; destroy owned guest'
+ raise RuntimeError(result['failureCleanup'])
+ result['bundleRebuild'] = 'pending'
+ try:
+ run(['update-ca-certificates', '--fresh'])
+ except BaseException:
+ result['bundleRebuild'] = 'failed'
+ raise
+ result['bundleRebuild'] = 'completed'
+
+
+@contextmanager
+def reported_checks(result, report, cleanup):
+ """Keep the check failure authoritative while attempting every finalization step."""
+ primary = None
+ finalization_errors = []
+ directory = None
+ result.update(status='running', primaryFailure=None, cleanupFailures=[],
+ temporaryDirectoryCleanup='not created', destinationAbsent=None)
+
+ def failure(exc):
+ return {'type': type(exc).__name__, 'message': str(exc)}
+
+ def finalize(phase, action):
+ try:
+ action()
+ except BaseException as exc:
+ finalization_errors.append(exc)
+ result['cleanupFailures'].append({'phase': phase, **failure(exc)})
+
+ try:
+ directory = tempfile.TemporaryDirectory(prefix='unified-ca-guest-')
+ result['temporaryDirectoryCleanup'] = 'pending'
+ yield directory.name
+ except BaseException as exc:
+ primary = exc
+ result['primaryFailure'] = failure(exc)
+ finally:
+ finalize('trust-store', cleanup)
+ if directory is not None:
+ def cleanup_directory():
+ try:
+ directory.cleanup()
+ except BaseException:
+ result['temporaryDirectoryCleanup'] = 'failed'
+ raise
+ result['temporaryDirectoryCleanup'] = 'completed'
+ finalize('temporary-directory', cleanup_directory)
+
+ def inspect_destination():
+ result['destinationAbsent'] = not DEST.exists() and not DEST.is_symlink()
+ finalize('destination-observation', inspect_destination)
+ result['status'] = 'failed' if primary is not None or finalization_errors else 'passed'
+ # A reporting failure must not replace the primary or earlier cleanup error.
+ finalize('report-write', lambda: report.write_text(json.dumps(result, indent=2) + '\n'))
+ if finalization_errors:
+ result['status'] = 'failed'
+ if primary is not None:
+ raise primary.with_traceback(primary.__traceback__)
+ if finalization_errors:
+ raise finalization_errors[0]
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('--allow-disposable-system-trust-store', action='store_true')
+ parser.add_argument('--inject-failure-after-install', action='store_true',
+ help='Exercise exact-byte failure cleanup; intentionally exits nonzero')
+ parser.add_argument('--report', type=Path, required=True)
+ args = parser.parse_args()
+ if not args.allow_disposable_system_trust_store:
+ parser.error('Explicit disposable-guest opt-in is required')
+ if os.geteuid() != 0 or not (
+ Path('/.dockerenv').exists() or Path('/run/systemd/container').exists()
+ ):
+ parser.error('Requires root inside an isolated disposable container, never the host')
+ release = Path('/etc/os-release').read_text()
+ if not any(line in release.splitlines() for line in ('ID=ubuntu', 'ID=debian')):
+ parser.error('Requires an actual Ubuntu or Debian guest')
+ for tool in ('sh', 'grep', 'openssl', 'sudo', 'install', 'update-ca-certificates'):
+ if not shutil.which(tool):
+ parser.error(f'Missing guest prerequisite: {tool}')
+ if DEST.exists() or DEST.is_symlink():
+ parser.error('Destination already exists; refusing to overwrite or remove it')
+ if not os.access(DEST.parent, os.W_OK):
+ parser.error('System trust directory is not writable; do not bypass runner restrictions')
+
+ result = {'status': 'running', 'osRelease': release, 'commands': [], 'checks': [],
+ 'limitations': ['Synthetic guest only, not a real browser trust-store check',
+ 'Root invokes real sudo; interactive sudo authentication is not tested']}
+ source = Path(__file__).resolve().parents[1] / 'amplifier_web/setup_page.py'
+ module = runpy.run_path(str(source))
+ commands = Commands(module['render_setup_page'](module['Platform'].LINUX, True, 'AA:BB')).blocks
+ assert len(commands) == 6
+ result['copiedCommands'] = commands
+ args.report.parent.mkdir(parents=True, exist_ok=True)
+
+ def run(argv, *, expected=0, env=None, demote=False):
+ def unprivileged():
+ os.setgroups([])
+ os.setgid(65534)
+ os.setuid(65534)
+ completed = subprocess.run(argv, text=True, capture_output=True, env=env,
+ preexec_fn=unprivileged if demote else None, timeout=60)
+ result['commands'].append({'argv': argv, 'returncode': completed.returncode,
+ 'stdout': completed.stdout, 'stderr': completed.stderr})
+ assert (completed.returncode == 0) if expected == 0 else (completed.returncode != 0), result['commands'][-1]
+ return completed.stdout
+
+ def check(name):
+ result['checks'].append(name)
+
+ owned = False
+ owned_bytes = None
+ cleanup_bytes = ()
+ with reported_checks(
+ result, args.report, lambda: cleanup_trust_store(owned, cleanup_bytes, result, run),
+ ) as directory:
+ root = Path(directory)
+ root.chmod(0o755)
+ downloads = root / 'Downloads'
+ downloads.mkdir(mode=0o755)
+ ca = downloads / 'amplifier-unified-ca (1).crt'
+ wrong = root / 'wrong.crt'
+ for cert, key in ((ca, root / 'ca.key'), (wrong, root / 'wrong.key')):
+ run(['openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-days', '1',
+ '-subj', '/CN=Disposable Unified Test CA', '-addext', 'basicConstraints=critical,CA:TRUE',
+ '-keyout', str(key), '-out', str(cert)])
+ key.chmod(0o600)
+ cert.chmod(0o644)
+ owned_bytes = ca.read_bytes()
+ leaf = root / 'leaf.crt'
+ run(['openssl', 'req', '-new', '-newkey', 'rsa:2048', '-nodes',
+ '-subj', '/CN=unified.test', '-keyout', str(root / 'leaf.key'),
+ '-out', str(root / 'leaf.csr')])
+ (root / 'extensions').write_text('subjectAltName=DNS:unified.test\nbasicConstraints=critical,CA:FALSE\nextendedKeyUsage=serverAuth\n')
+ run(['openssl', 'x509', '-req', '-in', str(root / 'leaf.csr'), '-CA', str(ca),
+ '-CAkey', str(root / 'ca.key'), '-CAcreateserial', '-days', '1',
+ '-extfile', str(root / 'extensions'), '-out', str(leaf)])
+ env = {**os.environ, 'HOME': str(root)} # disposable client's home inside this guest
+ prefix = commands['linux-ca-duplicate'] + '\n'
+ copied = lambda name: ['sh', '-eu', '-c', prefix + commands[name]]
+ expected_fp = run(['openssl', 'x509', '-in', str(ca), '-noout', '-fingerprint', '-sha256']).strip()
+ assert expected_fp in run(copied('linux-ca-fingerprint'), env=env)
+ assert expected_fp not in run(['openssl', 'x509', '-in', str(wrong), '-noout', '-fingerprint', '-sha256'])
+ check('duplicate filename renders and executes; wrong CA fingerprint differs')
+ verify_leaf = ['openssl', 'verify', '-CAfile', BUNDLE, '-no-CApath', '-no-CAstore',
+ '-verify_hostname', 'unified.test', str(leaf)]
+ run(verify_leaf, expected=1)
+ # Negative controls are potential writers too if the behavior regresses.
+ cleanup_bytes = (owned_bytes, owned_bytes + wrong.read_bytes())
+ owned = True
+ run(['install', '-m', '0644', '--', str(ca), str(DEST)], expected=1, demote=True)
+ assert not DEST.exists()
+ check('leaf untrusted before install; non-root system-store write denied')
+ ca.write_bytes(owned_bytes + wrong.read_bytes())
+ run(copied('linux-ca-install'), env=env, expected=1)
+ assert not DEST.exists()
+ ca.write_bytes(owned_bytes)
+ check('concatenated CA bundle refused before any trust-store write')
+ run(copied('linux-ca-install'), env=env)
+ assert DEST.read_bytes() == ca.read_bytes()
+ assert DEST.stat().st_uid == 0 and DEST.stat().st_mode & 0o777 == 0o644
+ if args.inject_failure_after_install:
+ raise RuntimeError('Injected post-install failure to check exact-byte cleanup')
+ run(copied('linux-ca-verify'), env=env)
+ run(verify_leaf)
+ check('exact displayed install/verify commands: root-owned 0644 CA and trusted hostname chain')
+ run(['openssl', 'verify', '-CAfile', BUNDLE, '-no-CApath', '-no-CAstore', str(wrong)], expected=1)
+ run([*verify_leaf[:-3], '-verify_hostname', 'wrong.test', str(leaf)], expected=1)
+ check('wrong CA and wrong hostname rejected after install')
+ before = DEST.read_bytes()
+ run(copied('linux-ca-install'), env=env, expected=1)
+ assert DEST.read_bytes() == before
+ check('existing destination refuses overwrite')
+ run(['sh', '-eu', '-c', commands['linux-ca-remove']], env=env)
+ assert not DEST.exists()
+ owned = False
+ run(verify_leaf, expected=1)
+ run(copied('linux-ca-verify'), env=env, expected=1)
+ check('exact displayed backout removes CA and trust; leaf and CA rejected')
+ print(json.dumps({'status': result['status'], 'checks': result['checks'],
+ 'report': str(args.report)}))
+
+
+if __name__ == '__main__':
+ main()
diff --git a/scripts/qualify_setup_ca_linux_ci.py b/scripts/qualify_setup_ca_linux_ci.py
new file mode 100644
index 0000000000..4df263838a
--- /dev/null
+++ b/scripts/qualify_setup_ca_linux_ci.py
@@ -0,0 +1,215 @@
+"""CI-only, one-container qualification of the exact rendered /setup CA commands.
+
+No application installs, secrets, host mounts, privileged containers, or host trust
+changes. The runner controls Docker; only guest-local synthetic stores are changed.
+"""
+from __future__ import annotations
+
+import hashlib
+import json
+import os
+from pathlib import Path
+import re
+import subprocess
+import sys
+import uuid
+
+
+IMAGE = 'docker.io/library/python:3.13-slim@sha256:70729b46c69b4f1e97c4822c1af3df53a1476cf5ddc6c087c0c10bc3a5678c2f'
+INPUTS = ('amplifier_web/setup_page.py', 'scripts/check_setup_ca_linux.py')
+LABEL = 'io.amplifier.setup-ca-owner'
+INJECTED = 'Injected post-install failure to check exact-byte cleanup'
+OBSERVE = """
+import hashlib,json,pathlib
+p=pathlib.Path('/usr/local/share/ca-certificates/amplifier-unified-ca.crt')
+print(json.dumps({
+ 'destinationAbsent':not p.exists() and not p.is_symlink(),
+ 'bundleSha256':hashlib.sha256(pathlib.Path('/etc/ssl/certs/ca-certificates.crt').read_bytes()).hexdigest(),
+ 'temporaryFiles':[p.name for p in pathlib.Path('/test-tmp').iterdir()],
+ 'inputs':{p:hashlib.sha256(pathlib.Path('/opt/setup-ca',p).read_bytes()).hexdigest()
+ for p in ('amplifier_web/setup_page.py','scripts/check_setup_ca_linux.py')}
+}))
+"""
+
+
+def main():
+ if (os.environ.get('GITHUB_ACTIONS') != 'true'
+ or os.environ.get('RUNNER_ENVIRONMENT') != 'github-hosted'
+ or sys.platform != 'linux'):
+ raise SystemExit('Requires a GitHub-hosted Linux CI runner; never run on a live host')
+ expected = os.environ['EXPECTED_SOURCE']
+ if not re.fullmatch('[0-9a-f]{40}', expected):
+ raise SystemExit('Expected exact source commit')
+ owner = f"setup-ca-{os.environ['GITHUB_RUN_ID']}-{os.environ['GITHUB_RUN_ATTEMPT']}"
+ if not re.fullmatch('setup-ca-[0-9]+-[0-9]+', owner):
+ raise SystemExit('Invalid CI owner identity')
+ output = Path(os.environ['RUNNER_TEMP']) / 'setup-ca-evidence'
+ stage = Path(os.environ['RUNNER_TEMP']) / 'setup-ca-input'
+ output.mkdir(exist_ok=False)
+ stage.mkdir(exist_ok=False)
+ nonce = uuid.uuid4().hex
+ result = {'status': 'running', 'owner': owner, 'ownershipNonce': nonce, 'imageReference': IMAGE,
+ 'primaryFailure': None, 'cleanupFailures': [], 'removed': False,
+ 'checks': {}, 'limits': ['Synthetic Debian OS trust, not browser trust',
+ 'APT versions recorded, not transitively pinned',
+ 'Real sudo as root; interactive authentication untested']}
+ sequence = 0
+ container = None
+ create_attempted = False
+ create_uncertain = False
+
+ def save():
+ (output / 'result.json').write_text(json.dumps(result, indent=2) + '\n')
+
+ def run(argv, *, expected_rc=0, timeout=120):
+ nonlocal sequence
+ sequence += 1
+ prefix = output / f'{sequence:02d}'
+ record = {'argv': argv}
+ try:
+ completed = subprocess.run(argv, capture_output=True, text=True, timeout=timeout)
+ record['returncode'] = completed.returncode
+ prefix.with_suffix('.stdout').write_text(completed.stdout)
+ prefix.with_suffix('.stderr').write_text(completed.stderr)
+ except subprocess.TimeoutExpired as exc:
+ record['timeoutSeconds'] = timeout
+ for suffix, value in (('stdout', exc.stdout), ('stderr', exc.stderr)):
+ prefix.with_suffix('.' + suffix).write_bytes(
+ value if isinstance(value, bytes) else (value or '').encode())
+ raise
+ finally:
+ prefix.with_suffix('.json').write_text(json.dumps(record, indent=2) + '\n')
+ if expected_rc is not None and completed.returncode != expected_rc:
+ raise RuntimeError(f'Command {sequence} returned {completed.returncode}, expected {expected_rc}')
+ return completed
+
+ def inspect_owned():
+ info, = json.loads(run(['docker', 'inspect', owner]).stdout)
+ if info['Name'] != '/' + owner or info['Config']['Labels'].get(LABEL) != nonce:
+ raise RuntimeError('Container ownership mismatch; refusing mutation')
+ if container is not None and info['Id'] != container:
+ raise RuntimeError('Container identity changed; refusing mutation')
+ return info
+
+ def observe(name):
+ state = json.loads(run(['docker', 'exec', container, 'python', '-I', '-c', OBSERVE]).stdout)
+ (output / f'{name}-store.json').write_text(json.dumps(state, indent=2) + '\n')
+ assert state['destinationAbsent'] and not state['temporaryFiles'], state
+ assert state['inputs'] == result['inputSha256'], state
+ return state
+
+ try:
+ save() # retain the intended owner before any Docker effect
+ revision = run(['git', 'rev-parse', 'HEAD']).stdout.strip()
+ assert revision == expected, (revision, expected)
+ assert not run(['git', 'status', '--porcelain']).stdout.strip(), 'Source must be clean'
+ result['source'] = revision
+ result['tree'] = run(['git', 'rev-parse', 'HEAD^{tree}']).stdout.strip()
+ result['inputSha256'] = {}
+ for name in INPUTS:
+ data = run(['git', 'show', f'HEAD:{name}']).stdout.encode()
+ destination = stage / name
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ destination.write_bytes(data)
+ result['inputSha256'][name] = hashlib.sha256(data).hexdigest()
+ for name in ('.github/workflows/setup-ca-linux.yml', 'scripts/qualify_setup_ca_linux_ci.py'):
+ result.setdefault('adapterSha256', {})[name] = hashlib.sha256(Path(name).read_bytes()).hexdigest()
+ assert not run(['docker', 'ps', '-aq', '--filter', f'name=^/{owner}$']).stdout.strip(), 'Owner already exists; no replay'
+ run(['docker', 'pull', '--platform', 'linux/amd64', IMAGE], timeout=180)
+ result['image'] = json.loads(run(['docker', 'image', 'inspect', IMAGE]).stdout)[0]
+ save()
+ create_attempted = True
+ try:
+ created = run(['docker', 'create', '--name', owner, '--label', f'{LABEL}={nonce}',
+ '--platform', 'linux/amd64', '--user', '0:0', '--cpus', '2',
+ '--memory', '1g', '--pids-limit', '128', IMAGE, 'sleep', 'infinity'])
+ except BaseException:
+ create_uncertain = True
+ result['createUncertain'] = True
+ raise
+ container = created.stdout.strip()
+ assert re.fullmatch('[0-9a-f]{64}', container), container
+ result['containerId'] = container
+ save()
+ info = inspect_owned()
+ assert not info['HostConfig']['Privileged'] and not info['Mounts'], info
+ run(['docker', 'start', container])
+ run(['docker', 'exec', container, 'sh', '-ec',
+ 'apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends sudo openssl ca-certificates coreutils grep && mkdir -p /opt/setup-ca /evidence /test-tmp && update-ca-certificates --fresh'], timeout=240)
+ run(['docker', 'exec', container, 'sh', '-ec',
+ 'cat /etc/os-release; python --version; openssl version; dpkg-query -W sudo openssl ca-certificates coreutils grep'])
+ run(['docker', 'cp', str(stage) + '/.', container + ':/opt/setup-ca'])
+ run(['docker', 'network', 'disconnect', 'bridge', container])
+ info = inspect_owned()
+ assert not info['NetworkSettings']['Networks'], 'Guest must be disconnected before testing'
+ result['networkDisconnected'] = True
+ baseline = observe('before')
+ for name, flags, returncode in (('normal', [], 0), ('injected', ['--inject-failure-after-install'], 1)):
+ invocation = run(['docker', 'exec', '-e', 'TMPDIR=/test-tmp', container,
+ 'python', '-I', '/opt/setup-ca/scripts/check_setup_ca_linux.py',
+ '--allow-disposable-system-trust-store', '--report', f'/evidence/{name}.json',
+ *flags], expected_rc=None, timeout=180)
+ run(['docker', 'cp', f'{container}:/evidence/{name}.json', str(output / f'{name}.json')])
+ report = json.loads((output / f'{name}.json').read_text())
+ result['checks'][name] = {'returncode': invocation.returncode, 'report': report}
+ save()
+ assert invocation.returncode == returncode, result['checks'][name]
+ assert report['cleanupFailures'] == [] and report['destinationAbsent'], report
+ assert report['temporaryDirectoryCleanup'] == 'completed', report
+ if name == 'normal':
+ assert report['status'] == 'passed' and report['primaryFailure'] is None, report
+ else:
+ assert report['status'] == 'failed', report # expected negative stays failed
+ assert report['primaryFailure'] == {'type': 'RuntimeError', 'message': INJECTED}, report
+ assert report['failureCleanup'] == 'removed exact synthetic CA', report
+ assert report['bundleRebuild'] == 'completed', report
+ restored = observe(name)
+ assert restored == baseline, (baseline, restored)
+ result['checks'][name]['qualified'] = True
+ except BaseException as exc:
+ result['primaryFailure'] = {'type': type(exc).__name__, 'message': str(exc)}
+ finally:
+ # Reconcile a create timeout using the same owner, never a new name.
+ try:
+ ids = run(['docker', 'ps', '-aq', '--filter', f'name=^/{owner}$']).stdout.split()
+ if ids and create_attempted:
+ info = inspect_owned()
+ container = info['Id']
+ result['containerId'] = container
+ if info['State']['Running']:
+ run(['docker', 'stop', '--time', '10', container], timeout=30)
+ assert not inspect_owned()['State']['Running']
+ # Report-export failures do not prevent exact owned removal.
+ for name in ('normal', 'injected'):
+ if not (output / f'{name}.json').exists():
+ try:
+ exported = run(['docker', 'cp', f'{container}:/evidence/{name}.json',
+ str(output / f'{name}.json')], expected_rc=None)
+ if exported.returncode:
+ result.setdefault('exportFailures', []).append(name + ': unavailable')
+ except BaseException as exc:
+ result.setdefault('exportFailures', []).append(
+ {'report': name, 'type': type(exc).__name__, 'message': str(exc)})
+ inspect_owned()
+ run(['docker', 'rm', container])
+ if create_attempted:
+ assert not run(['docker', 'ps', '-aq', '--filter', f'name=^/{owner}$']).stdout.strip()
+ if create_uncertain and not ids:
+ result['cleanupDisposition'] = 'Unknown create outcome; observed absence is not settled cleanup'
+ else:
+ result['removed'] = True
+ else:
+ result['cleanupDisposition'] = 'No create attempted; existing containers left untouched'
+ except BaseException as exc:
+ result['cleanupFailures'].append({'type': type(exc).__name__, 'message': str(exc)})
+ result['status'] = ('passed' if result['primaryFailure'] is None
+ and not result['cleanupFailures'] and result['removed']
+ and all(result['checks'].get(n, {}).get('qualified') for n in ('normal', 'injected'))
+ else 'failed')
+ save()
+ print(json.dumps({'status': result['status'], 'removed': result['removed'], 'evidence': str(output)}))
+ return 0 if result['status'] == 'passed' else 1
+
+
+if __name__ == '__main__':
+ sys.exit(main())
diff --git a/tests/test_server_auth.py b/tests/test_server_auth.py
index 772e9204b8..16bc859191 100644
--- a/tests/test_server_auth.py
+++ b/tests/test_server_auth.py
@@ -32,6 +32,13 @@ async def test_only_bootstrap_paths_are_anonymous_and_control_bearer_protects_ap
assert 'data-platform="android" open' in setup_body
assert "Mozilla/5.0" not in setup_body
assert "attacker.example" not in setup_body
+ linux_setup = await client.get("/setup", headers={"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) Edg/150"})
+ assert linux_setup.status == 200
+ linux_body = await linux_setup.text()
+ assert 'data-platform="linux" open' in linux_body
+ assert 'ca_file="$HOME/Downloads/amplifier-unified-ca (1).crt"' in linux_body
+ assert "ca_dest=/usr/local/share/ca-certificates/amplifier-unified-ca.crt" in linux_body
+ assert "OS success is not proof of browser success" in linux_body
assert not (tmp_path / "config" / "tls").exists()
login = await client.get("/login")
assert login.status == 200 and "csrf" in (await login.text())
diff --git a/tests/test_setup_ca_linux_harness.py b/tests/test_setup_ca_linux_harness.py
new file mode 100644
index 0000000000..9ffa3deb5d
--- /dev/null
+++ b/tests/test_setup_ca_linux_harness.py
@@ -0,0 +1,150 @@
+"""Failure reporting only: temporary files and fake commands, never a trust store."""
+import importlib.util
+import json
+from pathlib import Path
+import subprocess
+
+import pytest
+
+
+@pytest.fixture
+def harness(monkeypatch, tmp_path):
+ spec = importlib.util.spec_from_file_location(
+ "setup_ca_linux_harness",
+ Path(__file__).parents[1] / "scripts/check_setup_ca_linux.py",
+ )
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ monkeypatch.setattr(module, "DEST", tmp_path / "synthetic-ca.crt")
+
+ def no_process(*args, **kwargs):
+ pytest.fail("Reporting tests must not execute system-trust commands")
+ monkeypatch.setattr(module.subprocess, "run", no_process)
+ return module
+
+
+@pytest.mark.parametrize("timeout", [False, True])
+def test_primary_error_survives_bundle_cleanup_failure(harness, tmp_path, timeout):
+ expected = b"synthetic public certificate"
+ harness.DEST.write_bytes(expected)
+ report = tmp_path / "report.json"
+ result = {}
+ primary = RuntimeError("Injected post-install failure to check exact-byte cleanup")
+ cleanup_error = (
+ subprocess.TimeoutExpired(["update-ca-certificates", "--fresh"], 60)
+ if timeout else OSError("bundle rebuild failed")
+ )
+ calls = []
+
+ def failed_rebuild(argv):
+ calls.append(argv)
+ raise cleanup_error
+
+ with pytest.raises(RuntimeError) as caught:
+ with harness.reported_checks(
+ result, report,
+ lambda: harness.cleanup_trust_store(True, (expected,), result, failed_rebuild),
+ ) as directory:
+ raise primary
+
+ assert caught.value is primary
+ saved = json.loads(report.read_text())
+ assert saved["status"] == "failed"
+ assert saved["primaryFailure"] == {"type": "RuntimeError", "message": str(primary)}
+ assert saved["cleanupFailures"] == [{
+ "phase": "trust-store", "type": type(cleanup_error).__name__,
+ "message": str(cleanup_error),
+ }]
+ assert saved["failureCleanup"] == "removed exact synthetic CA"
+ assert saved["bundleRebuild"] == "failed"
+ assert saved["destinationAbsent"] is True
+ assert saved["temporaryDirectoryCleanup"] == "completed"
+ assert not Path(directory).exists()
+ assert calls == [["update-ca-certificates", "--fresh"]]
+
+
+def test_temporary_directory_cleanup_only_failure_cannot_report_passed(harness, monkeypatch, tmp_path):
+ result = {}
+ report = tmp_path / "report.json"
+ cleanup_error = OSError("temporary directory cleanup failed")
+ calls = []
+
+ class Directory:
+ name = str(tmp_path / "temporary")
+
+ def cleanup(self):
+ calls.append("directory")
+ assert result["status"] == "running"
+ assert not report.exists()
+ raise cleanup_error
+
+ monkeypatch.setattr(harness.tempfile, "TemporaryDirectory", lambda **kwargs: Directory())
+ with pytest.raises(OSError) as caught:
+ with harness.reported_checks(result, report, lambda: calls.append("trust-store")):
+ result["checks"] = ["primary checks completed"]
+
+ assert caught.value is cleanup_error
+ saved = json.loads(report.read_text())
+ assert saved["status"] == "failed"
+ assert saved["primaryFailure"] is None
+ assert saved["cleanupFailures"] == [{
+ "phase": "temporary-directory", "type": "OSError", "message": str(cleanup_error),
+ }]
+ assert saved["destinationAbsent"] is True
+ assert saved["temporaryDirectoryCleanup"] == "failed"
+ assert calls == ["trust-store", "directory"]
+
+
+def test_success_is_reported_only_after_finalization(harness, tmp_path):
+ result = {}
+ report = tmp_path / "report.json"
+
+ def cleanup():
+ assert result["status"] == "running"
+ assert not report.exists()
+
+ with harness.reported_checks(result, report, cleanup) as directory:
+ assert Path(directory).is_dir()
+ saved = json.loads(report.read_text())
+ assert saved["status"] == "passed"
+ assert saved["primaryFailure"] is None
+ assert saved["cleanupFailures"] == []
+ assert saved["temporaryDirectoryCleanup"] == "completed"
+ assert saved["destinationAbsent"] is True
+ assert not Path(directory).exists()
+
+
+@pytest.mark.parametrize("kind", ["changed-file", "symlink", "dangling-symlink"])
+def test_cleanup_refuses_changed_bytes_and_symlinks(harness, tmp_path, kind):
+ target = tmp_path / "unowned.crt"
+ target.write_bytes(b"synthetic public certificate")
+ if kind == "changed-file":
+ harness.DEST.write_bytes(b"unexpected bytes")
+ else:
+ harness.DEST.symlink_to(target)
+ if kind == "dangling-symlink":
+ target.unlink()
+ calls = []
+ result = {}
+ report = tmp_path / "report.json"
+ with pytest.raises(RuntimeError, match="bytes changed or symlink"):
+ with harness.reported_checks(
+ result, report,
+ lambda: harness.cleanup_trust_store(
+ True, (b"synthetic public certificate",), result, calls.append,
+ ),
+ ):
+ pass
+ saved = json.loads(report.read_text())
+ assert saved["status"] == "failed"
+ assert saved["primaryFailure"] is None
+ assert saved["cleanupFailures"][0]["phase"] == "trust-store"
+ assert saved["destinationAbsent"] is False
+ assert saved["bundleRebuild"] == "not required"
+ assert calls == []
+ if kind == "changed-file":
+ assert harness.DEST.read_bytes() == b"unexpected bytes"
+ else:
+ assert harness.DEST.is_symlink()
+ if kind == "symlink":
+ assert target.read_bytes() == b"synthetic public certificate"
\ No newline at end of file
diff --git a/tests/test_setup_page.py b/tests/test_setup_page.py
index 8083316623..77e9df1744 100644
--- a/tests/test_setup_page.py
+++ b/tests/test_setup_page.py
@@ -1,4 +1,7 @@
from html.parser import HTMLParser
+import os
+from pathlib import Path
+import subprocess
import pytest
@@ -23,7 +26,10 @@ def handle_starttag(self, tag, attrs):
("Mozilla/5.0 (Windows NT 10.0; Win64; x64)", Platform.WINDOWS),
("Mozilla/5.0 (Linux; Android 14)", Platform.ANDROID),
("Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X)", Platform.IOS),
- ("Mozilla/5.0 (X11; Linux x86_64)", Platform.OTHER),
+ ("Mozilla/5.0 (X11; Ubuntu; Linux x86_64) Edg/150", Platform.LINUX),
+ ("Mozilla/5.0 (X11; Linux aarch64)", Platform.LINUX),
+ (None, Platform.OTHER),
+ ("Unknown system", Platform.OTHER),
],
)
def test_detect_platform_uses_only_the_closed_platform_enum(user_agent, expected):
@@ -76,7 +82,7 @@ def test_renderer_instructions_use_trusted_transfer_then_minimal_user_trust():
assert page.index("Before browser use") < page.index("Choose your device")
assert page.count("setup-tls export") == 1
- assert page.count("verified SSH") == 1
+ assert page.count("Transfer that file over verified SSH") == 1
assert "<trusted-host>" in page
assert "openssl x509 -in amplifier-unified-ca.crt -noout -fingerprint -sha256" in page
assert "security add-trusted-cert -r trustRoot -p ssl -k \"$HOME/Library/Keychains/login.keychain-db\"" in page
@@ -117,5 +123,100 @@ async def test_renderer_fits_narrow_and_wide_chromium_viewports():
assert await page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
await page.screenshot()
await page.close()
+ finally:
+ await browser.close()
+
+
+class CommandBlocks(HTMLParser):
+ """Read precisely the text a user selects, decoding HTML entities once."""
+
+ def __init__(self, html):
+ super().__init__()
+ self.commands = {}
+ self.current = None
+ self.feed(html)
+
+ def handle_starttag(self, tag, attrs):
+ identity = dict(attrs).get("id", "")
+ if tag == "code" and identity.startswith("linux-ca-"):
+ self.current = identity
+ self.commands[identity] = ""
+
+ def handle_data(self, data):
+ if self.current:
+ self.commands[self.current] += data
+
+ def handle_endtag(self, tag):
+ if tag == "code":
+ self.current = None
+
+
+def test_linux_commands_are_copyable_shell_with_safe_paths():
+ html = render_setup_page(Platform.LINUX, True, "AA:BB")
+ commands = CommandBlocks(html).commands
+ assert len(commands) == 6
+ assert commands["linux-ca-duplicate"] == 'ca_file="$HOME/Downloads/amplifier-unified-ca (1).crt"'
+ install = commands["linux-ca-install"]
+ assert "ca_dest=/usr/local/share/ca-certificates/amplifier-unified-ca.crt" in install
+ assert "sudo install -d -m 0755 /usr/local/share/ca-certificates &&" in install
+ assert 'sudo install -m 0644 -- "$ca_file" "$ca_dest" &&' in install
+ assert '[ -e "$ca_dest" ] || [ -L "$ca_dest" ]' in install
+ assert "grep -c -- '-----BEGIN ' \"$ca_file\"" in install
+ assert "Stop: expected exactly one PEM certificate, not a bundle." in install
+ assert ">&2\n false" in install
+ assert "~/usr" not in install
+ assert "*" not in "".join(commands.values())
+ for command in commands.values():
+ subprocess.run(["sh", "-n"], input=command, text=True, check=True)
+ assert html.index("Stop and compare") < html.index('id="linux-ca-install"')
+ assert "OS success is not proof of browser success" in html
+ assert "read the exact error before importing anything again" in html
+ assert "hostname mismatch" in html and "date error" in html
+ assert "WSL trust does not configure a Windows browser" in html
+
+
+def test_deployment_guide_keeps_linux_command_blocks_identical():
+ commands = CommandBlocks(render_setup_page(Platform.LINUX, True, "AA:BB")).commands
+ guide = (Path(__file__).parents[1] / "docs/DEPLOYMENT.md").read_text()
+ for command in commands.values():
+ assert f"```sh\n{command}\n```" in guide
+
+
+async def test_linux_rendered_selection_preserves_exact_commands():
+ # Qualification sets this flag so a missing browser cannot turn into a pass.
+ required = os.environ.get("AMPLIFIER_REQUIRE_SETUP_BROWSER") == "1"
+ if required:
+ from playwright import async_api as playwright
+ else:
+ playwright = pytest.importorskip("playwright.async_api")
+ html = render_setup_page(Platform.LINUX, True, "AA:BB")
+ commands = CommandBlocks(html).commands
+ async with playwright.async_playwright() as driver:
+ try:
+ browser = await driver.chromium.launch(headless=True)
+ except playwright.Error as exc:
+ if required:
+ raise
+ pytest.skip(f"Chromium is unavailable: {exc}")
+ try:
+ for width in (320, 1280):
+ page = await browser.new_page(viewport={"width": width, "height": 800})
+ await page.set_content(html)
+ assert await page.locator('details[data-platform="linux"][open]').count() == 1
+ for identity, command in commands.items():
+ block = page.locator(f"#{identity}")
+ assert await block.inner_text() == command
+ selected = await block.evaluate("""element => {
+ const selection = window.getSelection();
+ selection.removeAllRanges();
+ selection.selectAllChildren(element);
+ return selection.toString();
+ }""")
+ assert selected == command
+ assert await page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
+ artifacts = os.environ.get("AMPLIFIER_TEST_ARTIFACTS")
+ if artifacts:
+ await page.screenshot(path=str(Path(artifacts) / f"setup-linux-{width}.png"), full_page=True)
+ await page.close()
finally:
await browser.close()
\ No newline at end of file