mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Prevent RC regressions at release boundary
This commit is contained in:
@@ -253,7 +253,7 @@ jobs:
|
||||
needs: prepare
|
||||
if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
@@ -277,6 +277,12 @@ jobs:
|
||||
- name: Check frontend copy-paste duplication
|
||||
run: npm --prefix frontend-modern run lint:cpd
|
||||
|
||||
- name: Type-check frontend
|
||||
run: npm --prefix frontend-modern run type-check
|
||||
|
||||
- name: Test frontend
|
||||
run: npm --prefix frontend-modern test
|
||||
|
||||
- name: Build verified frontend bundle
|
||||
run: npm --prefix frontend-modern run build
|
||||
|
||||
@@ -290,6 +296,31 @@ jobs:
|
||||
compression-level: 0
|
||||
overwrite: true
|
||||
|
||||
windows_install_command_smoke:
|
||||
name: Windows PowerShell 5.1 Install Command Smoke
|
||||
needs: prepare
|
||||
if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' }}
|
||||
runs-on: windows-2025
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: 'frontend-modern/package-lock.json'
|
||||
|
||||
- name: Install frontend test dependencies
|
||||
working-directory: frontend-modern
|
||||
run: npm ci
|
||||
|
||||
- name: Execute generated command with Windows PowerShell 5.1
|
||||
working-directory: frontend-modern
|
||||
run: npm test -- --run src/utils/__tests__/agentInstallCommand.windows.test.ts
|
||||
|
||||
# Backend tests run in parallel with frontend checks
|
||||
backend_tests:
|
||||
needs:
|
||||
@@ -764,11 +795,23 @@ jobs:
|
||||
docker logs pulse-test-server || true
|
||||
docker compose -f docker-compose.test.yml down -v || true
|
||||
|
||||
- name: Upload smoke diagnostics
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: release-smoke-failures-${{ github.sha }}
|
||||
path: |
|
||||
tests/integration/test-results/
|
||||
tests/integration/playwright-report/
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
|
||||
create_release:
|
||||
needs:
|
||||
- prepare
|
||||
- build_release_candidate
|
||||
- frontend_checks
|
||||
- windows_install_command_smoke
|
||||
- backend_tests
|
||||
- docker_build
|
||||
- helm_smoke
|
||||
@@ -776,7 +819,7 @@ jobs:
|
||||
- release_smoke
|
||||
# Run if integration_tests passed OR was skipped (prereleases). The
|
||||
# release smoke has no skipped escape: it runs for prereleases too.
|
||||
if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' && always() && needs.build_release_candidate.result == 'success' && needs.frontend_checks.result == 'success' && needs.backend_tests.result == 'success' && needs.docker_build.result == 'success' && needs.helm_smoke.result == 'success' && needs.release_smoke.result == 'success' && (needs.integration_tests.result == 'success' || needs.integration_tests.result == 'skipped') }}
|
||||
if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' && always() && needs.build_release_candidate.result == 'success' && needs.frontend_checks.result == 'success' && needs.windows_install_command_smoke.result == 'success' && needs.backend_tests.result == 'success' && needs.docker_build.result == 'success' && needs.helm_smoke.result == 'success' && needs.release_smoke.result == 'success' && (needs.integration_tests.result == 'success' || needs.integration_tests.result == 'skipped') }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
@@ -1463,6 +1506,7 @@ jobs:
|
||||
needs:
|
||||
- prepare
|
||||
- release_smoke
|
||||
- windows_install_command_smoke
|
||||
- create_release
|
||||
- publish_docker
|
||||
- validate_release_assets
|
||||
@@ -1481,6 +1525,7 @@ jobs:
|
||||
IS_PRERELEASE: ${{ needs.prepare.outputs.is_prerelease }}
|
||||
CREATE_RESULT: ${{ needs.create_release.result }}
|
||||
SMOKE_RESULT: ${{ needs.release_smoke.result }}
|
||||
WINDOWS_INSTALL_COMMAND_RESULT: ${{ needs.windows_install_command_smoke.result }}
|
||||
DOCKER_RESULT: ${{ needs.publish_docker.result }}
|
||||
VALIDATE_RESULT: ${{ needs.validate_release_assets.result }}
|
||||
INSTALL_RESULT: ${{ needs.install_sh_smoke.result }}
|
||||
@@ -1501,6 +1546,7 @@ jobs:
|
||||
}
|
||||
|
||||
require_result "release smoke" "$SMOKE_RESULT" success
|
||||
require_result "Windows install command smoke" "$WINDOWS_INSTALL_COMMAND_RESULT" success
|
||||
require_result "release assembly" "$CREATE_RESULT" success
|
||||
require_result "release asset validation" "$VALIDATE_RESULT" success
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ on:
|
||||
- 'internal/kubernetesagent/**'
|
||||
- 'internal/remoteconfig/**'
|
||||
- 'pkg/agents/**'
|
||||
- 'frontend-modern/src/utils/agentInstallCommand.ts'
|
||||
- 'frontend-modern/src/utils/__tests__/agentInstallCommand.windows.test.ts'
|
||||
- 'scripts/install.sh'
|
||||
- 'scripts/install.ps1'
|
||||
- 'scripts/installtests/**'
|
||||
@@ -30,6 +32,8 @@ on:
|
||||
- 'internal/kubernetesagent/**'
|
||||
- 'internal/remoteconfig/**'
|
||||
- 'pkg/agents/**'
|
||||
- 'frontend-modern/src/utils/agentInstallCommand.ts'
|
||||
- 'frontend-modern/src/utils/__tests__/agentInstallCommand.windows.test.ts'
|
||||
- 'scripts/install.sh'
|
||||
- 'scripts/install.ps1'
|
||||
- 'scripts/installtests/**'
|
||||
@@ -96,6 +100,24 @@ jobs:
|
||||
if: ${{ !matrix.unix }}
|
||||
run: go test ./scripts/installtests -run '^Test(InstallPS1|WindowsAgentLifecycle)'
|
||||
|
||||
- name: Set up Node.js for Windows command proof
|
||||
if: ${{ !matrix.unix }}
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: 'frontend-modern/package-lock.json'
|
||||
|
||||
- name: Install frontend test dependencies
|
||||
if: ${{ !matrix.unix }}
|
||||
working-directory: frontend-modern
|
||||
run: npm ci
|
||||
|
||||
- name: Execute generated command with Windows PowerShell 5.1
|
||||
if: ${{ !matrix.unix }}
|
||||
working-directory: frontend-modern
|
||||
run: npm test -- --run src/utils/__tests__/agentInstallCommand.windows.test.ts
|
||||
|
||||
- name: Build and execute native Unix agent
|
||||
if: matrix.unix
|
||||
shell: bash
|
||||
|
||||
@@ -9227,21 +9227,7 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"work_claims": [
|
||||
{
|
||||
"id": "codex-release-v620rc5-release-gate-single-build-release-promotion-path",
|
||||
"agent_id": "codex-release-v620rc5",
|
||||
"summary": "Prepare and publish v6.2.0-rc.5 through the governed exact-SHA promotion workflow",
|
||||
"target_id": "v6-product-lane-expansion",
|
||||
"claimed_at": "2026-07-31T16:42:32Z",
|
||||
"heartbeat_at": "2026-07-31T16:42:32Z",
|
||||
"expires_at": "2026-08-01T00:42:32Z",
|
||||
"work_item": {
|
||||
"kind": "release-gate",
|
||||
"id": "single-build-release-promotion-path"
|
||||
}
|
||||
}
|
||||
],
|
||||
"work_claims": [],
|
||||
"open_decisions": [],
|
||||
"source_of_truth_file": "docs/release-control/v6/internal/SOURCE_OF_TRUTH.md",
|
||||
"resolved_decisions": [
|
||||
|
||||
@@ -4986,6 +4986,16 @@ clipboard transport: the rendered Linux/macOS/BSD and Windows install snippets
|
||||
must already include the active token choice, custom-CA trust, insecure/plain-
|
||||
HTTP handling, install-profile flags, and command-execution mode instead of
|
||||
displaying one command and mutating it only during copy.
|
||||
Windows first-hop TLS overrides must remain executable on the oldest supported
|
||||
Windows PowerShell runtime. Generated install commands and inline
|
||||
`install.ps1` bootstraps use a compiled .NET
|
||||
`RemoteCertificateValidationCallback`, never a PowerShell scriptblock delegate
|
||||
that can be invoked on a worker thread without a runspace. Their custom-CA
|
||||
loader accepts PEM and DER without relying on `X509Certificate2.CreateFromPem`,
|
||||
which Windows PowerShell 5.1 does not provide. Native Windows verification and
|
||||
the release workflow both execute the generated insecure and PEM custom-CA
|
||||
commands against a self-signed HTTPS installer fixture, including the full
|
||||
preflight-to-install handoff.
|
||||
For Unix-family host installs, that same seamless installer contract requires
|
||||
the copied command to fetch the shared installer into an ephemeral directory,
|
||||
run `install.sh --preflight-only` before privilege escalation, and fail before
|
||||
|
||||
@@ -6318,6 +6318,14 @@ custom-CA or insecure-TLS certificate handling before `install.ps1` is fetched,
|
||||
not only after the installer starts executing. That bootstrap must accept the
|
||||
same PEM/CRT/CER trust input that `scripts/install.ps1` itself accepts, so the
|
||||
shared command contract does not narrow custom-CA behavior on the first fetch.
|
||||
That first-fetch contract includes Windows PowerShell 5.1 execution semantics:
|
||||
the generated API/UI command must install a compiled .NET certificate callback
|
||||
rather than a PowerShell scriptblock delegate, and it must decode PEM without
|
||||
calling runtime APIs absent from Windows PowerShell 5.1. Exact generated
|
||||
commands for insecure TLS and PEM custom-CA trust are executed against a
|
||||
self-signed HTTPS installer fixture in native Windows CI and again as a
|
||||
release-gating Windows smoke; substring assertions alone are not sufficient
|
||||
proof of this transport boundary.
|
||||
That same shell transport contract also applies to the governed setup-completion
|
||||
install handoff in `SetupCompletionPanel`: when the operator supplies a custom CA path
|
||||
or opts into insecure/self-signed transport, the shared Unix install builder
|
||||
|
||||
@@ -1171,6 +1171,16 @@ collection; adds storage, alert-routing, and OpenShift coverage; and carries
|
||||
the post-RC4 compatibility fixes. The exact `main` SHA must pass the integrated
|
||||
release checks and immutable-candidate build before the single-build workflow
|
||||
crosses its public mutation boundary.
|
||||
Every release cut, including a prerelease, now gates that mutation boundary on
|
||||
the complete frontend unit suite, frontend type-checking, and a deterministic
|
||||
render smoke against the verified frontend bundle. The smoke must render
|
||||
Proxmox nodes and workloads, Docker hosts and containers, Kubernetes clusters
|
||||
and pods, and Alert thresholds with an omitted-zero disk payload; an error
|
||||
boundary or uncaught browser error fails the cut. Failures retain Playwright
|
||||
diagnostics. The same release workflow also executes the generated self-signed
|
||||
and custom-CA Windows installer commands through Windows PowerShell 5.1 before
|
||||
release assembly, so the first HTTPS fetch is release proof rather than a
|
||||
string-shape assertion.
|
||||
The `v6.2.0-rc.5` server cut is classified
|
||||
`existing-mobile-build-compatible`. The synchronized Pulse Mobile 1.0.0 iOS
|
||||
build 11 and Android versionCode 9 candidates, both using runtime version 2,
|
||||
|
||||
@@ -84,80 +84,79 @@ describe('buildPowerShellInstallScriptBootstrap — bootstrap script wiring', ()
|
||||
expect(script).toContain('-or -not [string]::IsNullOrWhiteSpace($env:PULSE_CACERT))');
|
||||
});
|
||||
|
||||
it('reads the custom CA bytes from $env:PULSE_CACERT when populated', () => {
|
||||
it('loads the custom CA through the Windows PowerShell 5.1-compatible helper', () => {
|
||||
const script = buildPowerShellInstallScriptBootstrap('https://pulse.example');
|
||||
expect(script).toContain('if (-not [string]::IsNullOrWhiteSpace($env:PULSE_CACERT)) {');
|
||||
expect(script).toContain(
|
||||
'$pulseCustomCaBytes = [System.IO.File]::ReadAllBytes($env:PULSE_CACERT);',
|
||||
'[PulseInstallerCertificateValidator]::LoadCertificate($env:PULSE_CACERT)',
|
||||
);
|
||||
});
|
||||
|
||||
it('routes a PEM-encoded CA through X509Certificate2::CreateFromPem', () => {
|
||||
it('decodes PEM certificates without the newer X509Certificate2::CreateFromPem API', () => {
|
||||
const script = buildPowerShellInstallScriptBootstrap('https://pulse.example');
|
||||
expect(script).toContain('if ($pulseCustomCaText.Contains("-----BEGIN CERTIFICATE-----"))');
|
||||
expect(script).toContain(
|
||||
'$pulseCustomCa = [System.Security.Cryptography.X509Certificates.X509Certificate2]::CreateFromPem($pulseCustomCaText)',
|
||||
);
|
||||
expect(script).toContain('if (text.Contains("-----BEGIN CERTIFICATE-----"))');
|
||||
expect(script).toContain('bytes = Convert.FromBase64String(base64);');
|
||||
expect(script).not.toContain('CreateFromPem');
|
||||
});
|
||||
|
||||
it('routes a DER-encoded CA through X509Certificate2::new (else arm)', () => {
|
||||
it('passes PEM-decoded or raw DER bytes into X509Certificate2', () => {
|
||||
const script = buildPowerShellInstallScriptBootstrap('https://pulse.example');
|
||||
expect(script).toContain(
|
||||
'$pulseCustomCa = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($pulseCustomCaBytes)',
|
||||
'Activator.CreateInstance(typeof(X509Certificate2), new object[] { bytes })',
|
||||
);
|
||||
});
|
||||
|
||||
it('installs the X509 chain-validation ServerCertificateValidationCallback', () => {
|
||||
it('installs the compiled X509 chain-validation callback', () => {
|
||||
const script = buildPowerShellInstallScriptBootstrap('https://pulse.example');
|
||||
expect(script).toContain(
|
||||
'$pulsePrev = [System.Net.ServicePointManager]::ServerCertificateValidationCallback;',
|
||||
);
|
||||
expect(script).toContain(
|
||||
'[System.Net.ServicePointManager]::ServerCertificateValidationCallback = ({ param($sender, $certificate, $chain, $sslPolicyErrors)',
|
||||
'[System.Net.ServicePointManager]::ServerCertificateValidationCallback = [PulseInstallerCertificateValidator]::ValidateCustomCaCallback',
|
||||
);
|
||||
expect(script).toContain(
|
||||
'} finally { [System.Net.ServicePointManager]::ServerCertificateValidationCallback = $pulsePrev }',
|
||||
'} finally { [System.Net.ServicePointManager]::ServerCertificateValidationCallback = $pulsePrev; [PulseInstallerCertificateValidator]::CustomCa = $null }',
|
||||
);
|
||||
});
|
||||
|
||||
it('short-circuits the callback to $true when PULSE_INSECURE_SKIP_VERIFY is "true"', () => {
|
||||
it('uses the compiled accept-any callback when PULSE_INSECURE_SKIP_VERIFY is "true"', () => {
|
||||
const script = buildPowerShellInstallScriptBootstrap('https://pulse.example');
|
||||
expect(script).toContain(
|
||||
'if ($env:PULSE_INSECURE_SKIP_VERIFY -eq "true") { [System.Net.ServicePointManager]::ServerCertificateValidationCallback = { param($sender, $certificate, $chain, $sslPolicyErrors) return $true }',
|
||||
'if ($env:PULSE_INSECURE_SKIP_VERIFY -eq "true") { [System.Net.ServicePointManager]::ServerCertificateValidationCallback = [PulseInstallerCertificateValidator]::AcceptAnyCallback',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the raw sslPolicyErrors verdict when no custom CA was loaded', () => {
|
||||
it('does not embed a PowerShell scriptblock callback', () => {
|
||||
const script = buildPowerShellInstallScriptBootstrap('https://pulse.example');
|
||||
expect(script).toContain(
|
||||
'if ($null -eq $pulseCustomCa) { return $sslPolicyErrors -eq [System.Net.Security.SslPolicyErrors]::None };',
|
||||
);
|
||||
expect(script).not.toContain('ServerCertificateValidationCallback = { param(');
|
||||
expect(script).not.toContain('GetNewClosure');
|
||||
});
|
||||
|
||||
it('returns $false when the server supplied no certificate', () => {
|
||||
it('fails closed when the server supplied no certificate or no custom CA', () => {
|
||||
const script = buildPowerShellInstallScriptBootstrap('https://pulse.example');
|
||||
expect(script).toContain('if ($null -eq $certificate) { return $false };');
|
||||
expect(script).toContain('if (certificate == null || CustomCa == null ||');
|
||||
expect(script).toContain('SslPolicyErrors.RemoteCertificateNameMismatch');
|
||||
expect(script).toContain('SslPolicyErrors.RemoteCertificateNotAvailable');
|
||||
expect(script).toContain('return false;');
|
||||
});
|
||||
|
||||
it('builds an X509Chain with NoCheck revocation and the custom CA in ExtraStore', () => {
|
||||
const script = buildPowerShellInstallScriptBootstrap('https://pulse.example');
|
||||
expect(script).toContain('using (X509Chain candidateChain = new X509Chain())');
|
||||
expect(script).toContain(
|
||||
'$pulseChain = [System.Security.Cryptography.X509Certificates.X509Chain]::new();',
|
||||
'candidateChain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;',
|
||||
);
|
||||
expect(script).toContain(
|
||||
'$pulseChain.ChainPolicy.RevocationMode = [System.Security.Cryptography.X509Certificates.X509RevocationMode]::NoCheck;',
|
||||
);
|
||||
expect(script).toContain('$null = $pulseChain.ChainPolicy.ExtraStore.Add($pulseCustomCa);');
|
||||
expect(script).toContain('$null = $pulseChain.Build($certificate);');
|
||||
expect(script).toContain('candidateChain.ChainPolicy.ExtraStore.Add(CustomCa);');
|
||||
expect(script).toContain('candidateChain.Build(new X509Certificate2(certificate));');
|
||||
});
|
||||
|
||||
it('walks ChainElements and trusts the chain when the custom CA Thumbprint matches', () => {
|
||||
const script = buildPowerShellInstallScriptBootstrap('https://pulse.example');
|
||||
expect(script).toContain('foreach ($pulseElement in $pulseChain.ChainElements) {');
|
||||
expect(script).toContain('foreach (X509ChainElement element in candidateChain.ChainElements)');
|
||||
expect(script).toContain(
|
||||
'if ($pulseElement.Certificate.Thumbprint -eq $pulseCustomCa.Thumbprint) { return $true }',
|
||||
'String.Equals(element.Certificate.Thumbprint, CustomCa.Thumbprint, StringComparison.OrdinalIgnoreCase)',
|
||||
);
|
||||
expect(script).toContain('return $false }).GetNewClosure()');
|
||||
expect(script).toContain('return true;');
|
||||
});
|
||||
|
||||
it('fetches the script via `irm $pulseScriptUrl` inside both the custom-trust and the bare else arm', () => {
|
||||
|
||||
@@ -144,8 +144,10 @@ describe('agentInstallCommand', () => {
|
||||
'Invoke-WebRequest -Uri $pulseScriptUrl -UseBasicParsing -OutFile $pulseInstallScript',
|
||||
);
|
||||
expect(command).toContain(
|
||||
'ServerCertificateValidationCallback={ param($sender,$certificate,$chain,$sslPolicyErrors) return $true }',
|
||||
'ServerCertificateValidationCallback=[PulseInstallerCertificateValidator]::AcceptAnyCallback',
|
||||
);
|
||||
expect(command).toContain('Add-Type -TypeDefinition');
|
||||
expect(command).not.toContain('ServerCertificateValidationCallback={ param(');
|
||||
expect(command).not.toContain('$pulseAllowInsecure');
|
||||
expect(command).toContain(
|
||||
'& $pulsePowerShell -NoProfile -ExecutionPolicy Bypass -File $pulseInstallScript -Url',
|
||||
@@ -158,16 +160,21 @@ describe('agentInstallCommand', () => {
|
||||
expect(command).not.toContain('$env:PULSE_TOKEN=');
|
||||
});
|
||||
|
||||
it('captures a custom CA in the certificate callback when TLS verification stays enabled', () => {
|
||||
it('uses a runspace-independent custom CA callback when TLS verification stays enabled', () => {
|
||||
const command = buildWindowsAgentInstallCommand({
|
||||
baseUrl: 'https://pulse.example',
|
||||
token: null,
|
||||
caCertPath: 'C:\\Pulse\\custom-ca.cer',
|
||||
});
|
||||
|
||||
expect(command).toContain('ServerCertificateValidationCallback=({ param(');
|
||||
expect(command).toContain('return $false }).GetNewClosure()');
|
||||
expect(command).not.toContain('return $true }; if ($null -eq $pulseCustomCa)');
|
||||
expect(command).toContain(
|
||||
'ServerCertificateValidationCallback=[PulseInstallerCertificateValidator]::ValidateCustomCaCallback',
|
||||
);
|
||||
expect(command).toContain(
|
||||
'[PulseInstallerCertificateValidator]::LoadCertificate($pulseCaCertPath)',
|
||||
);
|
||||
expect(command).not.toContain('CreateFromPem');
|
||||
expect(command).not.toContain('GetNewClosure');
|
||||
});
|
||||
|
||||
it('supports tokenless shared Windows install transport for optional auth', () => {
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { createServer, type Server } from 'node:https';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildPowerShellInstallScriptBootstrap,
|
||||
buildWindowsAgentInstallCommand,
|
||||
} from '@/utils/agentInstallCommand';
|
||||
|
||||
const powerShellRuntime = process.platform === 'win32' ? 'powershell.exe' : undefined;
|
||||
|
||||
const installerScript = `
|
||||
param([string]$Url, [string]$TokenFile)
|
||||
$phase = if ($env:PULSE_PREFLIGHT_ONLY -eq "true") { "preflight" } else { "install" }
|
||||
Add-Content -LiteralPath $env:PULSE_TEST_MARKER -Value $phase
|
||||
`.trim();
|
||||
|
||||
const runPowerShell = (command: string, env: NodeJS.ProcessEnv) =>
|
||||
new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
|
||||
const child = spawn(
|
||||
powerShellRuntime!,
|
||||
[
|
||||
'-NoLogo',
|
||||
'-NoProfile',
|
||||
'-NonInteractive',
|
||||
'-ExecutionPolicy',
|
||||
'Bypass',
|
||||
'-Command',
|
||||
command,
|
||||
],
|
||||
{ env: { ...process.env, ...env }, windowsHide: true },
|
||||
);
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout.setEncoding('utf8').on('data', (chunk) => (stdout += chunk));
|
||||
child.stderr.setEncoding('utf8').on('data', (chunk) => (stderr += chunk));
|
||||
child.on('error', reject);
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) resolve({ stdout, stderr });
|
||||
else reject(new Error(`PowerShell exited ${code}.\nstdout:\n${stdout}\nstderr:\n${stderr}`));
|
||||
});
|
||||
});
|
||||
|
||||
describe.runIf(Boolean(powerShellRuntime))('Windows install command TLS runtime', () => {
|
||||
let server: Server | undefined;
|
||||
let baseUrl: string;
|
||||
let certificate: Buffer;
|
||||
let suiteDirectory: string;
|
||||
let testDirectory: string;
|
||||
let markerPath: string;
|
||||
let certificatePath: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
suiteDirectory = await mkdtemp(join(tmpdir(), 'pulse-windows-tls-suite-'));
|
||||
const pfxPath = join(suiteDirectory, 'server.pfx');
|
||||
const derPath = join(suiteDirectory, 'server.cer');
|
||||
const quote = (value: string) => `'${value.replace(/'/g, "''")}'`;
|
||||
await runPowerShell(
|
||||
`$ErrorActionPreference="Stop"; ` +
|
||||
`$certificate=New-SelfSignedCertificate -DnsName "localhost" -CertStoreLocation "Cert:\\CurrentUser\\My" -KeyExportPolicy Exportable; ` +
|
||||
`try { ` +
|
||||
`$password=ConvertTo-SecureString "pulse-test" -AsPlainText -Force; ` +
|
||||
`Export-PfxCertificate -Cert $certificate -FilePath ${quote(pfxPath)} -Password $password | Out-Null; ` +
|
||||
`Export-Certificate -Cert $certificate -FilePath ${quote(derPath)} -Type CERT | Out-Null ` +
|
||||
`} finally { Remove-Item -LiteralPath ("Cert:\\CurrentUser\\My\\"+$certificate.Thumbprint) }`,
|
||||
{},
|
||||
);
|
||||
const [pfx, der] = await Promise.all([readFile(pfxPath), readFile(derPath)]);
|
||||
const lines = der.toString('base64').match(/.{1,64}/g) ?? [];
|
||||
certificate = Buffer.from(
|
||||
`-----BEGIN CERTIFICATE-----\n${lines.join('\n')}\n-----END CERTIFICATE-----\n`,
|
||||
);
|
||||
const testServer = createServer({ pfx, passphrase: 'pulse-test' }, (request, response) => {
|
||||
if (request.url !== '/install.ps1') {
|
||||
response.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
response.writeHead(200, { 'content-type': 'text/plain' }).end(installerScript);
|
||||
});
|
||||
server = testServer;
|
||||
await new Promise<void>((resolve) => testServer.listen(0, resolve));
|
||||
const address = testServer.address();
|
||||
if (!address || typeof address === 'string') throw new Error('HTTPS test server did not bind.');
|
||||
baseUrl = `https://localhost:${address.port}`;
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
testDirectory = await mkdtemp(join(tmpdir(), 'pulse-windows-tls-'));
|
||||
markerPath = join(testDirectory, 'result.txt');
|
||||
certificatePath = join(testDirectory, 'server.crt');
|
||||
await writeFile(certificatePath, certificate);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (testDirectory) await rm(testDirectory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (server) {
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
server!.close((error) => (error ? reject(error) : resolve())),
|
||||
);
|
||||
}
|
||||
if (suiteDirectory) await rm(suiteDirectory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('executes the inline bootstrap over self-signed HTTPS without a callback runspace', async () => {
|
||||
await runPowerShell(buildPowerShellInstallScriptBootstrap(baseUrl), {
|
||||
PULSE_INSECURE_SKIP_VERIFY: 'true',
|
||||
PULSE_TEST_MARKER: markerPath,
|
||||
});
|
||||
|
||||
await expect(readFile(markerPath, 'utf8')).resolves.toMatch(/install\r?\n/);
|
||||
}, 30_000);
|
||||
|
||||
it('executes preflight and install through the insecure Windows command', async () => {
|
||||
await runPowerShell(buildWindowsAgentInstallCommand({ baseUrl, insecure: true }), {
|
||||
PULSE_TEST_MARKER: markerPath,
|
||||
});
|
||||
|
||||
await expect(readFile(markerPath, 'utf8')).resolves.toMatch(/preflight\r?\ninstall\r?\n/);
|
||||
}, 30_000);
|
||||
|
||||
it('executes preflight and install through the PEM custom-CA Windows command', async () => {
|
||||
await runPowerShell(buildWindowsAgentInstallCommand({ baseUrl, caCertPath: certificatePath }), {
|
||||
PULSE_TEST_MARKER: markerPath,
|
||||
});
|
||||
|
||||
await expect(readFile(markerPath, 'utf8')).resolves.toMatch(/preflight\r?\ninstall\r?\n/);
|
||||
}, 30_000);
|
||||
});
|
||||
@@ -2,6 +2,77 @@ const shellQuoteArg = (value: string) => `'${value.replace(/'/g, `'\"'\"'`)}'`;
|
||||
export const powerShellQuote = (value: string) =>
|
||||
value.replace(/`/g, '``').replace(/"/g, '`"').replace(/\$/g, '`$');
|
||||
|
||||
const powerShellSingleQuotedLiteral = (value: string) => `'${value.replace(/'/g, "''")}'`;
|
||||
|
||||
// ServicePointManager can invoke its validation callback on a worker thread
|
||||
// that has no PowerShell runspace. Windows PowerShell 5.1 therefore cannot
|
||||
// safely use a scriptblock delegate here. Keep the callback and PEM parsing in
|
||||
// a compiled .NET type that is compatible with both Windows PowerShell 5.1 and
|
||||
// modern PowerShell runtimes.
|
||||
const powerShellTlsValidatorSource = `
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net.Security;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
public static class PulseInstallerCertificateValidator
|
||||
{
|
||||
public static X509Certificate2 CustomCa;
|
||||
public static readonly RemoteCertificateValidationCallback AcceptAnyCallback = AcceptAny;
|
||||
public static readonly RemoteCertificateValidationCallback ValidateCustomCaCallback = ValidateCustomCa;
|
||||
|
||||
public static X509Certificate2 LoadCertificate(string path)
|
||||
{
|
||||
byte[] bytes = File.ReadAllBytes(path);
|
||||
string text = System.Text.Encoding.ASCII.GetString(bytes);
|
||||
if (text.Contains("-----BEGIN CERTIFICATE-----"))
|
||||
{
|
||||
string base64 = Regex.Replace(text, "-----BEGIN CERTIFICATE-----|-----END CERTIFICATE-----|\\\\s", "");
|
||||
bytes = Convert.FromBase64String(base64);
|
||||
}
|
||||
return (X509Certificate2)Activator.CreateInstance(typeof(X509Certificate2), new object[] { bytes });
|
||||
}
|
||||
|
||||
private static bool AcceptAny(object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors errors)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool ValidateCustomCa(object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors errors)
|
||||
{
|
||||
if (certificate == null || CustomCa == null ||
|
||||
(errors & (SslPolicyErrors.RemoteCertificateNameMismatch | SslPolicyErrors.RemoteCertificateNotAvailable)) != 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
using (X509Chain candidateChain = new X509Chain())
|
||||
{
|
||||
candidateChain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
|
||||
candidateChain.ChainPolicy.ExtraStore.Add(CustomCa);
|
||||
candidateChain.Build(new X509Certificate2(certificate));
|
||||
foreach (X509ChainElement element in candidateChain.ChainElements)
|
||||
{
|
||||
if (String.Equals(element.Certificate.Thumbprint, CustomCa.Thumbprint, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}`.trim();
|
||||
|
||||
const powerShellTlsValidatorBootstrap =
|
||||
`if ($null -eq ("PulseInstallerCertificateValidator" -as [type])) { ` +
|
||||
`Add-Type -TypeDefinition ${powerShellSingleQuotedLiteral(powerShellTlsValidatorSource)} ` +
|
||||
`}; `;
|
||||
|
||||
const powerShellLoadCustomCa = (pathExpression: string) =>
|
||||
`$pulseCustomCa=[PulseInstallerCertificateValidator]::LoadCertificate(${pathExpression}); ` +
|
||||
`[PulseInstallerCertificateValidator]::CustomCa=$pulseCustomCa; `;
|
||||
|
||||
export const normalizeInstallerBaseUrl = (baseUrl: string) => baseUrl.replace(/\/+$/, '');
|
||||
|
||||
export type AgentCommandPlatform = 'linux' | 'macos' | 'freebsd' | 'windows';
|
||||
@@ -119,35 +190,20 @@ export const buildPowerShellInstallScriptBootstrap = (baseUrl: string) => {
|
||||
return (
|
||||
`& { $pulseScriptUrl="${scriptUrl}"; ` +
|
||||
`if ($env:PULSE_INSECURE_SKIP_VERIFY -eq "true" -or -not [string]::IsNullOrWhiteSpace($env:PULSE_CACERT)) { ` +
|
||||
`${powerShellTlsValidatorBootstrap}` +
|
||||
`$pulseCustomCa = $null; ` +
|
||||
`if (-not [string]::IsNullOrWhiteSpace($env:PULSE_CACERT)) { ` +
|
||||
`$pulseCustomCaBytes = [System.IO.File]::ReadAllBytes($env:PULSE_CACERT); ` +
|
||||
`$pulseCustomCaText = [System.Text.Encoding]::ASCII.GetString($pulseCustomCaBytes); ` +
|
||||
`if ($pulseCustomCaText.Contains("-----BEGIN CERTIFICATE-----")) { ` +
|
||||
`$pulseCustomCa = [System.Security.Cryptography.X509Certificates.X509Certificate2]::CreateFromPem($pulseCustomCaText) ` +
|
||||
`} else { ` +
|
||||
`$pulseCustomCa = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($pulseCustomCaBytes) ` +
|
||||
`} ` +
|
||||
`${powerShellLoadCustomCa('$env:PULSE_CACERT')}` +
|
||||
`}; ` +
|
||||
`$pulsePrev = [System.Net.ServicePointManager]::ServerCertificateValidationCallback; ` +
|
||||
`try { ` +
|
||||
`if ($env:PULSE_INSECURE_SKIP_VERIFY -eq "true") { ` +
|
||||
`[System.Net.ServicePointManager]::ServerCertificateValidationCallback = { param($sender, $certificate, $chain, $sslPolicyErrors) return $true } ` +
|
||||
`[System.Net.ServicePointManager]::ServerCertificateValidationCallback = [PulseInstallerCertificateValidator]::AcceptAnyCallback ` +
|
||||
`} else { ` +
|
||||
`[System.Net.ServicePointManager]::ServerCertificateValidationCallback = ({ param($sender, $certificate, $chain, $sslPolicyErrors) ` +
|
||||
`if ($null -eq $pulseCustomCa) { return $sslPolicyErrors -eq [System.Net.Security.SslPolicyErrors]::None }; ` +
|
||||
`if ($null -eq $certificate) { return $false }; ` +
|
||||
`$pulseChain = [System.Security.Cryptography.X509Certificates.X509Chain]::new(); ` +
|
||||
`$pulseChain.ChainPolicy.RevocationMode = [System.Security.Cryptography.X509Certificates.X509RevocationMode]::NoCheck; ` +
|
||||
`$null = $pulseChain.ChainPolicy.ExtraStore.Add($pulseCustomCa); ` +
|
||||
`$null = $pulseChain.Build($certificate); ` +
|
||||
`foreach ($pulseElement in $pulseChain.ChainElements) { ` +
|
||||
`if ($pulseElement.Certificate.Thumbprint -eq $pulseCustomCa.Thumbprint) { return $true } ` +
|
||||
`}; ` +
|
||||
`return $false }).GetNewClosure() ` +
|
||||
`[System.Net.ServicePointManager]::ServerCertificateValidationCallback = [PulseInstallerCertificateValidator]::ValidateCustomCaCallback ` +
|
||||
`}; ` +
|
||||
`irm $pulseScriptUrl ` +
|
||||
`} finally { [System.Net.ServicePointManager]::ServerCertificateValidationCallback = $pulsePrev } ` +
|
||||
`} finally { [System.Net.ServicePointManager]::ServerCertificateValidationCallback = $pulsePrev; [PulseInstallerCertificateValidator]::CustomCa = $null } ` +
|
||||
`} else { irm $pulseScriptUrl } } | iex`
|
||||
);
|
||||
};
|
||||
@@ -176,10 +232,10 @@ export const buildWindowsAgentInstallCommand = ({
|
||||
...(normalizedToken ? ['-TokenFile $pulseTokenFile'] : []),
|
||||
];
|
||||
const certificateValidationCallback = insecure
|
||||
? `{ param($sender,$certificate,$chain,$sslPolicyErrors) return $true }`
|
||||
: `({ param($sender,$certificate,$chain,$sslPolicyErrors) if ($null -eq $pulseCustomCa) { return $sslPolicyErrors -eq [System.Net.Security.SslPolicyErrors]::None }; if ($null -eq $certificate) { return $false }; $pulseChain=[System.Security.Cryptography.X509Certificates.X509Chain]::new(); $pulseChain.ChainPolicy.RevocationMode=[System.Security.Cryptography.X509Certificates.X509RevocationMode]::NoCheck; $null=$pulseChain.ChainPolicy.ExtraStore.Add($pulseCustomCa); $null=$pulseChain.Build($certificate); foreach ($pulseElement in $pulseChain.ChainElements) { if ($pulseElement.Certificate.Thumbprint -eq $pulseCustomCa.Thumbprint) { return $true } }; return $false }).GetNewClosure()`;
|
||||
? `[PulseInstallerCertificateValidator]::AcceptAnyCallback`
|
||||
: `[PulseInstallerCertificateValidator]::ValidateCustomCaCallback`;
|
||||
const customTrustFetch = installerFetchRequiresCustomTrust
|
||||
? `$pulseCustomCa=$null; if (-not [string]::IsNullOrWhiteSpace($pulseCaCertPath)) { $pulseCustomCaBytes=[System.IO.File]::ReadAllBytes($pulseCaCertPath); $pulseCustomCaText=[System.Text.Encoding]::ASCII.GetString($pulseCustomCaBytes); if ($pulseCustomCaText.Contains("-----BEGIN CERTIFICATE-----")) { $pulseCustomCa=[System.Security.Cryptography.X509Certificates.X509Certificate2]::CreateFromPem($pulseCustomCaText) } else { $pulseCustomCa=[System.Security.Cryptography.X509Certificates.X509Certificate2]::new($pulseCustomCaBytes) } }; $pulsePrev=[System.Net.ServicePointManager]::ServerCertificateValidationCallback; try { [System.Net.ServicePointManager]::ServerCertificateValidationCallback=${certificateValidationCallback}; Invoke-WebRequest -Uri $pulseScriptUrl -UseBasicParsing -OutFile $pulseInstallScript } finally { [System.Net.ServicePointManager]::ServerCertificateValidationCallback=$pulsePrev }`
|
||||
? `${powerShellTlsValidatorBootstrap}$pulseCustomCa=$null; if (-not [string]::IsNullOrWhiteSpace($pulseCaCertPath)) { ${powerShellLoadCustomCa('$pulseCaCertPath')}}; $pulsePrev=[System.Net.ServicePointManager]::ServerCertificateValidationCallback; try { [System.Net.ServicePointManager]::ServerCertificateValidationCallback=${certificateValidationCallback}; Invoke-WebRequest -Uri $pulseScriptUrl -UseBasicParsing -OutFile $pulseInstallScript } finally { [System.Net.ServicePointManager]::ServerCertificateValidationCallback=$pulsePrev; [PulseInstallerCertificateValidator]::CustomCa=$null }`
|
||||
: `Invoke-WebRequest -Uri $pulseScriptUrl -UseBasicParsing -OutFile $pulseInstallScript`;
|
||||
|
||||
const tokenBootstrap = normalizedToken
|
||||
|
||||
@@ -1842,6 +1842,52 @@ func TestReleasePipelinePromotesOneImmutableCandidate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseCutGatesCriticalFrontendAndWindowsRuntimeProof(t *testing.T) {
|
||||
content, err := os.ReadFile(repoFile(".github", "workflows", "create-release.yml"))
|
||||
if err != nil {
|
||||
t.Fatalf("read create-release.yml: %v", err)
|
||||
}
|
||||
|
||||
workflow := string(content)
|
||||
frontendJob := workflowJobBlock(t, workflow, "frontend_checks")
|
||||
windowsJob := workflowJobBlock(t, workflow, "windows_install_command_smoke")
|
||||
smokeJob := workflowJobBlock(t, workflow, "release_smoke")
|
||||
createJob := workflowJobBlock(t, workflow, "create_release")
|
||||
verdictJob := workflowJobBlock(t, workflow, "release_verdict")
|
||||
|
||||
for _, needle := range []string{
|
||||
`npm --prefix frontend-modern run type-check`,
|
||||
`npm --prefix frontend-modern test`,
|
||||
} {
|
||||
if !strings.Contains(frontendJob, needle) {
|
||||
t.Fatalf("frontend release gate missing %s", needle)
|
||||
}
|
||||
}
|
||||
for _, needle := range []string{
|
||||
`runs-on: windows-2025`,
|
||||
`agentInstallCommand.windows.test.ts`,
|
||||
} {
|
||||
if !strings.Contains(windowsJob, needle) {
|
||||
t.Fatalf("Windows install-command release gate missing %s", needle)
|
||||
}
|
||||
}
|
||||
for _, needle := range []string{
|
||||
`tests/95-release-smoke.spec.ts`,
|
||||
`release-smoke-failures-${{ github.sha }}`,
|
||||
`tests/integration/test-results/`,
|
||||
} {
|
||||
if !strings.Contains(smokeJob, needle) {
|
||||
t.Fatalf("release render smoke missing %s", needle)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(createJob, `needs.windows_install_command_smoke.result == 'success'`) {
|
||||
t.Fatal("release assembly must fail closed on the Windows install-command smoke")
|
||||
}
|
||||
if !strings.Contains(verdictJob, `require_result "Windows install command smoke" "$WINDOWS_INSTALL_COMMAND_RESULT" success`) {
|
||||
t.Fatal("definitive release verdict must report the Windows install-command smoke")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateReleasePublishesPrivateProRuntime(t *testing.T) {
|
||||
content, err := os.ReadFile(repoFile(".github", "workflows", "create-release.yml"))
|
||||
if err != nil {
|
||||
|
||||
@@ -124,6 +124,24 @@ func TestNativeWindowsSelfTestDoesNotPreseedLifecycleState(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeWindowsExecutesGeneratedInstallCommand(t *testing.T) {
|
||||
content, err := os.ReadFile(repoFile(".github", "workflows", "unified-agent-native.yml"))
|
||||
if err != nil {
|
||||
t.Fatalf("read native agent workflow: %v", err)
|
||||
}
|
||||
|
||||
workflow := string(content)
|
||||
for _, needle := range []string{
|
||||
`frontend-modern/src/utils/agentInstallCommand.ts`,
|
||||
`Execute generated command with Windows PowerShell 5.1`,
|
||||
`agentInstallCommand.windows.test.ts`,
|
||||
} {
|
||||
if !strings.Contains(workflow, needle) {
|
||||
t.Fatalf("native Windows workflow missing generated install-command proof: %s", needle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallPS1OwnsWindowsServiceLoggingAndRecovery(t *testing.T) {
|
||||
content, err := os.ReadFile(repoFile("scripts", "install.ps1"))
|
||||
if err != nil {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { expect, test, type Page } from "@playwright/test";
|
||||
import { ensureAuthenticated } from "./helpers";
|
||||
|
||||
const DESKTOP_VIEWPORT = { width: 1440, height: 900 };
|
||||
const pageErrors = new WeakMap<Page, Error[]>();
|
||||
|
||||
// Release smoke: the minimum bar every cut ships against, prereleases
|
||||
// included. Each check asserts only that a primary surface renders real
|
||||
@@ -20,6 +21,10 @@ async function expectNoErrorBoundary(page: Page) {
|
||||
page.getByText("This page couldn't load"),
|
||||
"an error boundary fired on a release-gating surface",
|
||||
).toHaveCount(0);
|
||||
expect(
|
||||
pageErrors.get(page) ?? [],
|
||||
"an uncaught browser error fired on a release-gating surface",
|
||||
).toEqual([]);
|
||||
}
|
||||
|
||||
test.describe("Release smoke", () => {
|
||||
@@ -31,6 +36,9 @@ test.describe("Release smoke", () => {
|
||||
"Release smoke gates on the desktop shell",
|
||||
);
|
||||
await page.setViewportSize(DESKTOP_VIEWPORT);
|
||||
const errors: Error[] = [];
|
||||
pageErrors.set(page, errors);
|
||||
page.on("pageerror", (error) => errors.push(error));
|
||||
await ensureAuthenticated(page);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user