diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index fc5e2d586..4fc63138b 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -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 diff --git a/.github/workflows/unified-agent-native.yml b/.github/workflows/unified-agent-native.yml index f776e454a..99ce478a5 100644 --- a/.github/workflows/unified-agent-native.yml +++ b/.github/workflows/unified-agent-native.yml @@ -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 diff --git a/docs/release-control/v6/internal/status.json b/docs/release-control/v6/internal/status.json index a21962cfa..f57f3bd8d 100644 --- a/docs/release-control/v6/internal/status.json +++ b/docs/release-control/v6/internal/status.json @@ -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": [ diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index c931289c2..2a8138fe0 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 6fc4e4dc1..69791dcf2 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index a598889eb..f40366bfe 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -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, diff --git a/frontend-modern/src/utils/__tests__/agentInstallCommand.branchcov0720pm.test.ts b/frontend-modern/src/utils/__tests__/agentInstallCommand.branchcov0720pm.test.ts index 07bb9acfe..8de896bfe 100644 --- a/frontend-modern/src/utils/__tests__/agentInstallCommand.branchcov0720pm.test.ts +++ b/frontend-modern/src/utils/__tests__/agentInstallCommand.branchcov0720pm.test.ts @@ -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', () => { diff --git a/frontend-modern/src/utils/__tests__/agentInstallCommand.test.ts b/frontend-modern/src/utils/__tests__/agentInstallCommand.test.ts index 38d7efbdf..43b0bd089 100644 --- a/frontend-modern/src/utils/__tests__/agentInstallCommand.test.ts +++ b/frontend-modern/src/utils/__tests__/agentInstallCommand.test.ts @@ -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', () => { diff --git a/frontend-modern/src/utils/__tests__/agentInstallCommand.windows.test.ts b/frontend-modern/src/utils/__tests__/agentInstallCommand.windows.test.ts new file mode 100644 index 000000000..bd0592b7c --- /dev/null +++ b/frontend-modern/src/utils/__tests__/agentInstallCommand.windows.test.ts @@ -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((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((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); +}); diff --git a/frontend-modern/src/utils/agentInstallCommand.ts b/frontend-modern/src/utils/agentInstallCommand.ts index d8a8bda8c..588be09d1 100644 --- a/frontend-modern/src/utils/agentInstallCommand.ts +++ b/frontend-modern/src/utils/agentInstallCommand.ts @@ -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 diff --git a/scripts/installtests/build_release_assets_test.go b/scripts/installtests/build_release_assets_test.go index 17f12d044..85c7ea7f4 100644 --- a/scripts/installtests/build_release_assets_test.go +++ b/scripts/installtests/build_release_assets_test.go @@ -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 { diff --git a/scripts/installtests/install_ps1_test.go b/scripts/installtests/install_ps1_test.go index d8b24b462..d7625be35 100644 --- a/scripts/installtests/install_ps1_test.go +++ b/scripts/installtests/install_ps1_test.go @@ -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 { diff --git a/tests/integration/tests/95-release-smoke.spec.ts b/tests/integration/tests/95-release-smoke.spec.ts index 45c6ca306..7d3ca5926 100644 --- a/tests/integration/tests/95-release-smoke.spec.ts +++ b/tests/integration/tests/95-release-smoke.spec.ts @@ -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(); // 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); });