From 76ca15c4e66dd17d0e124ee1b50463e8b65a6c9d Mon Sep 17 00:00:00 2001 From: Vladyslav Nikonov Date: Tue, 7 Jul 2026 14:45:08 +0300 Subject: [PATCH 1/3] feat(agent): uniget ui broker implementation --- .gitattributes | 35 +- .github/workflows/publish-libraries.yml | 571 +++++++++--------- Cargo.lock | 204 ++++++- crates/unigetui-broker/Cargo.toml | 60 ++ .../samples/corporate-allowlist.policy.json | 134 ++++ .../samples/deny-risky-options.policy.json | 81 +++ .../winget-unknown-install.request.json | 31 + .../winget-vscode-install.request.json | 32 + .../winget-vscode-skiphash.request.json | 31 + .../samples/scenarios/baseline.scenarios.json | 38 ++ .../src/command_builder/mod.rs | 38 ++ .../src/command_builder/powershell.rs | 284 +++++++++ .../src/command_builder/winget.rs | 208 +++++++ .../src/evaluator/constraints.rs | 228 +++++++ .../unigetui-broker/src/evaluator/matching.rs | 214 +++++++ crates/unigetui-broker/src/evaluator/mod.rs | 114 ++++ crates/unigetui-broker/src/evaluator/tests.rs | 213 +++++++ .../unigetui-broker/src/evaluator/version.rs | 107 ++++ .../unigetui-broker/src/evaluator/wildcard.rs | 50 ++ crates/unigetui-broker/src/executor/mod.rs | 85 +++ crates/unigetui-broker/src/executor/output.rs | 145 +++++ .../src/executor/windows/mod.rs | 255 ++++++++ .../src/executor/windows/process.rs | 217 +++++++ .../src/executor/windows/token.rs | 81 +++ crates/unigetui-broker/src/lib.rs | 14 + .../unigetui-broker/src/operation_tracker.rs | 177 ++++++ crates/unigetui-broker/src/pipe.rs | 143 +++++ crates/unigetui-broker/src/policy_loader.rs | 84 +++ crates/unigetui-broker/src/policy_watcher.rs | 136 +++++ .../unigetui-broker/src/server/connection.rs | 30 + .../unigetui-broker/src/server/execution.rs | 60 ++ crates/unigetui-broker/src/server/mod.rs | 260 ++++++++ .../unigetui-broker/src/server/responses.rs | 148 +++++ crates/unigetui-broker/src/task.rs | 157 +++++ .../unigetui-broker/tests/scenario_tests.rs | 130 ++++ crates/unigetui-broker/tools/standalone.rs | 221 +++++++ devolutions-agent/Cargo.toml | 1 + devolutions-agent/src/config.rs | 31 + devolutions-agent/src/service.rs | 14 + devolutions-agent/src/updater/mod.rs | 2 +- 40 files changed, 4757 insertions(+), 307 deletions(-) create mode 100644 crates/unigetui-broker/Cargo.toml create mode 100644 crates/unigetui-broker/assets/samples/corporate-allowlist.policy.json create mode 100644 crates/unigetui-broker/assets/samples/deny-risky-options.policy.json create mode 100644 crates/unigetui-broker/assets/samples/requests/winget-unknown-install.request.json create mode 100644 crates/unigetui-broker/assets/samples/requests/winget-vscode-install.request.json create mode 100644 crates/unigetui-broker/assets/samples/requests/winget-vscode-skiphash.request.json create mode 100644 crates/unigetui-broker/assets/samples/scenarios/baseline.scenarios.json create mode 100644 crates/unigetui-broker/src/command_builder/mod.rs create mode 100644 crates/unigetui-broker/src/command_builder/powershell.rs create mode 100644 crates/unigetui-broker/src/command_builder/winget.rs create mode 100644 crates/unigetui-broker/src/evaluator/constraints.rs create mode 100644 crates/unigetui-broker/src/evaluator/matching.rs create mode 100644 crates/unigetui-broker/src/evaluator/mod.rs create mode 100644 crates/unigetui-broker/src/evaluator/tests.rs create mode 100644 crates/unigetui-broker/src/evaluator/version.rs create mode 100644 crates/unigetui-broker/src/evaluator/wildcard.rs create mode 100644 crates/unigetui-broker/src/executor/mod.rs create mode 100644 crates/unigetui-broker/src/executor/output.rs create mode 100644 crates/unigetui-broker/src/executor/windows/mod.rs create mode 100644 crates/unigetui-broker/src/executor/windows/process.rs create mode 100644 crates/unigetui-broker/src/executor/windows/token.rs create mode 100644 crates/unigetui-broker/src/lib.rs create mode 100644 crates/unigetui-broker/src/operation_tracker.rs create mode 100644 crates/unigetui-broker/src/pipe.rs create mode 100644 crates/unigetui-broker/src/policy_loader.rs create mode 100644 crates/unigetui-broker/src/policy_watcher.rs create mode 100644 crates/unigetui-broker/src/server/connection.rs create mode 100644 crates/unigetui-broker/src/server/execution.rs create mode 100644 crates/unigetui-broker/src/server/mod.rs create mode 100644 crates/unigetui-broker/src/server/responses.rs create mode 100644 crates/unigetui-broker/src/task.rs create mode 100644 crates/unigetui-broker/tests/scenario_tests.rs create mode 100644 crates/unigetui-broker/tools/standalone.rs diff --git a/.gitattributes b/.gitattributes index 3eb52bd81..773c18fd3 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,16 +1,19 @@ -*.rs text eol=lf -*.toml text eol=lf -*.cs text eol=lf -*.js text eol=lf -*.ps1 text eol=lf -*.sln text eol=crlf -*.md text eol=lf -*.mustache text eol=lf -*.yaml text eol=lf - -devolutions-gateway/openapi/doc/index.adoc linguist-generated merge=binary -devolutions-gateway/openapi/dotnet-client/src/** linguist-generated merge=binary -devolutions-gateway/openapi/dotnet-client/docs/** linguist-generated merge=binary -devolutions-gateway/openapi/dotnet-subscriber/src/** linguist-generated merge=binary -devolutions-gateway/openapi/ts-angular-client/api/** linguist-generated merge=binary -devolutions-gateway/openapi/ts-angular-client/model/** linguist-generated merge=binary +*.rs text eol=lf +*.toml text eol=lf +*.cs text eol=lf +*.js text eol=lf +*.ps1 text eol=lf +*.sln text eol=crlf +*.md text eol=lf +*.mustache text eol=lf +*.yaml text eol=lf + +devolutions-gateway/openapi/doc/index.adoc linguist-generated merge=binary +devolutions-gateway/openapi/dotnet-client/src/** linguist-generated merge=binary +devolutions-gateway/openapi/dotnet-client/docs/** linguist-generated merge=binary +devolutions-gateway/openapi/dotnet-subscriber/src/** linguist-generated merge=binary +devolutions-gateway/openapi/ts-angular-client/api/** linguist-generated merge=binary +devolutions-gateway/openapi/ts-angular-client/model/** linguist-generated merge=binary + +# Sample assets produce huge LoC counts; exclude them from language statistics. +crates/unigetui-broker/assets/** linguist-generated diff --git a/.github/workflows/publish-libraries.yml b/.github/workflows/publish-libraries.yml index 4a2d4a53a..19e58efb0 100644 --- a/.github/workflows/publish-libraries.yml +++ b/.github/workflows/publish-libraries.yml @@ -1,286 +1,285 @@ -name: Publish libraries - -on: - workflow_dispatch: - inputs: - dry_run: - description: Dry run - required: true - type: boolean - default: true - schedule: - - cron: '49 3 * * 1' # 3:49 AM UTC every Monday - -jobs: - preflight: - name: Preflight - runs-on: ubuntu-latest - outputs: - dry_run: ${{ steps.get-dry-run.outputs.dry_run }} - - steps: - - name: Get dry run - id: get-dry-run - run: | - Set-PSDebug -Trace 1 - - $IsDryRun = '${{ github.event.inputs.dry_run }}' -Eq 'true' -Or '${{ github.event_name }}' -Eq 'schedule' - - if ($IsDryRun) { - echo "dry_run=true" >> $Env:GITHUB_OUTPUT - } else { - echo "dry_run=false" >> $Env:GITHUB_OUTPUT - } - shell: pwsh - - nuget-build: - name: NuGet package build [${{matrix.library}}] - runs-on: windows-2022 - - strategy: - fail-fast: false - matrix: - library: [ dotnet-client, dotnet-subscriber, utils, pedm-dotnet-client ] - include: - - library: dotnet-client - libpath: ./devolutions-gateway/openapi/dotnet-client - - library: dotnet-subscriber - libpath: ./devolutions-gateway/openapi/dotnet-subscriber - - library: utils - libpath: ./utils/dotnet - - library: pedm-dotnet-client - libpath: ./crates/devolutions-pedm/openapi/dotnet-client - - steps: - - name: Check out ${{ github.repository }} - uses: actions/checkout@v6 - - - name: Build - run: | - Set-PSDebug -Trace 1 - - $Path = '${{matrix.libpath}}' - & "$Path/build.ps1" - - New-Item -ItemType "directory" -Path . -Name "nuget-packages" - Get-ChildItem -Path $Path -Recurse -Include '*.nupkg' | ForEach { Copy-Item $_ "./nuget-packages" } - Get-ChildItem -Path $Path -Recurse -Include '*.snupkg' | ForEach { Copy-Item $_ "./nuget-packages" } - shell: pwsh - - - name: Upload packages - uses: actions/upload-artifact@v7 - with: - name: nupkg-${{matrix.library}} - path: | - nuget-packages/*.nupkg - nuget-packages/*.snupkg - - nuget-merge: - name: NuGet merge artifacts - needs: [nuget-build] - runs-on: ubuntu-latest - - steps: - - name: Merge Artifacts - uses: actions/upload-artifact/merge@v7 - with: - name: nupkg - pattern: nupkg-* - delete-merged: true - - npm-build: - name: NPM package build [${{matrix.library}}] - runs-on: ubuntu-latest - - strategy: - fail-fast: false - matrix: - library: [ ts-angular-client, multi-video-player, shadow-player, web-recorder ] - include: - - library: ts-angular-client - libpath: ./devolutions-gateway/openapi/ts-angular-client - - library: multi-video-player - libpath: ./webapp/packages/multi-video-player - - library: shadow-player - libpath: ./webapp/packages/shadow-player - - library: web-recorder - libpath: ./webapp/packages/web-recorder - - steps: - - name: Check out ${{ github.repository }} - uses: actions/checkout@v6 - - - name: Setup pnpm - uses: pnpm/action-setup@v5 - with: - version: 10 - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: '24' - cache: 'pnpm' - cache-dependency-path: 'webapp/pnpm-lock.yaml' - - - name: Build - run: | - Set-PSDebug -Trace 1 - - $Path = '${{matrix.libpath}}' - & "$Path/build.ps1" - - New-Item -ItemType "directory" -Path . -Name "npm-packages" - Get-ChildItem -Path $Path -Recurse *.tgz | ForEach { Copy-Item $_ "./npm-packages" } - shell: pwsh - - - name: Upload packages - uses: actions/upload-artifact@v7 - with: - name: npm-${{matrix.library}} - path: npm-packages/*.tgz - - npm-merge: - name: NPM merge artifacts - runs-on: ubuntu-latest - needs: [npm-build] - - steps: - - name: Merge Artifacts - uses: actions/upload-artifact/merge@v7 - with: - name: npm - pattern: npm-* - delete-merged: true - - nuget-publish: - name: Publish NuGet packages - environment: publish-prod - if: ${{ needs.preflight.outputs.dry_run == 'false' }} - needs: [preflight, nuget-merge] - runs-on: ubuntu-latest - permissions: - id-token: write - - steps: - - name: Download NuGet packages artifact - uses: actions/download-artifact@v8 - with: - name: nupkg - path: nuget-packages - - - name: NuGet login (OIDC) - id: nuget-login - uses: NuGet/login@v1 - with: - user: ${{ secrets.NUGET_BOT_USERNAME }} - - - name: Publish to nuget.org - run: | - Set-PSDebug -Trace 1 - - $Files = Get-ChildItem -Recurse nuget-packages/*.nupkg - - foreach ($File in $Files) { - $PushCmd = @( - 'dotnet', - 'nuget', - 'push', - "$File", - '--api-key', - '${{ steps.nuget-login.outputs.NUGET_API_KEY }}', - '--source', - 'https://api.nuget.org/v3/index.json', - '--skip-duplicate' - ) - - Write-Host "Publishing $($File.Name)..." - $PushCmd = $PushCmd -Join ' ' - Invoke-Expression $PushCmd - } - shell: pwsh - - npm-publish: - name: Publish NPM packages - environment: publish-prod - if: ${{ needs.preflight.outputs.dry_run == 'false' }} - needs: [preflight, npm-merge, npm-build] - runs-on: ubuntu-latest - permissions: - contents: read - id-token: write - - steps: - - name: Check out ${{ github.repository }} - uses: actions/checkout@v6 - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: 24 - registry-url: https://registry.npmjs.org - - - name: Download NPM packages artifact - uses: actions/download-artifact@v8 - with: - name: npm - path: npm-packages - - - name: Publish - run: | - Set-PSDebug -Trace 1 - - $Files = Get-ChildItem -Recurse npm-packages/*.tgz - - foreach ($File in $Files) { - Write-Host "Publishing $($File.Name)..." - ./ci/npm-publish.ps1 -Tarball "$File" -Access 'public' - } - shell: pwsh - - - name: Update Artifactory Cache - run: gh workflow run update-artifactory-cache.yml --repo Devolutions/scheduled-tasks --field package_name="gateway-client" - env: - GH_TOKEN: ${{ secrets.DEVOLUTIONSBOT_WRITE_TOKEN }} - - remove-labels: - name: Remove publish-required labels - if: ${{ needs.preflight.outputs.dry_run == 'false' }} - needs: [preflight, nuget-build, npm-publish] - runs-on: ubuntu-latest - - steps: - - name: Check out ${{ github.repository }} - uses: actions/checkout@v6 - - - name: Remove labels - run: ./ci/remove-labels.ps1 -Label 'publish-required' - shell: pwsh - env: - GITHUB_TOKEN: ${{ github.token }} - - notify: - name: Notify failure - if: ${{ always() && contains(needs.*.result, 'failure') && github.event_name == 'schedule' }} - needs: [npm-build, nuget-merge] - runs-on: ubuntu-latest - env: - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_ARCHITECTURE }} - SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK - - steps: - - name: Send slack notification - id: slack - uses: slackapi/slack-github-action@v1.24.0 - with: - payload: | - { - "blocks": [ - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": "*${{ github.repository }}* :fire::fire::fire::fire::fire: \n The scheduled build for *${{ github.repository }}* is <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|broken>" - } - } - ] - } +name: Publish libraries + +on: + workflow_dispatch: + inputs: + dry_run: + description: Dry run + required: true + type: boolean + default: true + schedule: + - cron: '49 3 * * 1' # 3:49 AM UTC every Monday + +jobs: + preflight: + name: Preflight + runs-on: ubuntu-latest + outputs: + dry_run: ${{ steps.get-dry-run.outputs.dry_run }} + + steps: + - name: Get dry run + id: get-dry-run + run: | + Set-PSDebug -Trace 1 + + $IsDryRun = '${{ github.event.inputs.dry_run }}' -Eq 'true' -Or '${{ github.event_name }}' -Eq 'schedule' + + if ($IsDryRun) { + echo "dry_run=true" >> $Env:GITHUB_OUTPUT + } else { + echo "dry_run=false" >> $Env:GITHUB_OUTPUT + } + shell: pwsh + + nuget-build: + name: NuGet package build [${{matrix.library}}] + runs-on: windows-2022 + + strategy: + fail-fast: false + matrix: + library: [ dotnet-client, dotnet-subscriber, utils, pedm-dotnet-client ] + include: + - library: dotnet-client + libpath: ./devolutions-gateway/openapi/dotnet-client + - library: dotnet-subscriber + libpath: ./devolutions-gateway/openapi/dotnet-subscriber + - library: utils + libpath: ./utils/dotnet + - library: pedm-dotnet-client + libpath: ./crates/devolutions-pedm/openapi/dotnet-client + steps: + - name: Check out ${{ github.repository }} + uses: actions/checkout@v6 + + - name: Build + run: | + Set-PSDebug -Trace 1 + + $Path = '${{matrix.libpath}}' + & "$Path/build.ps1" + + New-Item -ItemType "directory" -Path . -Name "nuget-packages" + Get-ChildItem -Path $Path -Recurse -Include '*.nupkg' | ForEach { Copy-Item $_ "./nuget-packages" } + Get-ChildItem -Path $Path -Recurse -Include '*.snupkg' | ForEach { Copy-Item $_ "./nuget-packages" } + shell: pwsh + + - name: Upload packages + uses: actions/upload-artifact@v7 + with: + name: nupkg-${{matrix.library}} + path: | + nuget-packages/*.nupkg + nuget-packages/*.snupkg + + nuget-merge: + name: NuGet merge artifacts + needs: [nuget-build] + runs-on: ubuntu-latest + + steps: + - name: Merge Artifacts + uses: actions/upload-artifact/merge@v7 + with: + name: nupkg + pattern: nupkg-* + delete-merged: true + + npm-build: + name: NPM package build [${{matrix.library}}] + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + library: [ ts-angular-client, multi-video-player, shadow-player, web-recorder ] + include: + - library: ts-angular-client + libpath: ./devolutions-gateway/openapi/ts-angular-client + - library: multi-video-player + libpath: ./webapp/packages/multi-video-player + - library: shadow-player + libpath: ./webapp/packages/shadow-player + - library: web-recorder + libpath: ./webapp/packages/web-recorder + + steps: + - name: Check out ${{ github.repository }} + uses: actions/checkout@v6 + + - name: Setup pnpm + uses: pnpm/action-setup@v5 + with: + version: 10 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '24' + cache: 'pnpm' + cache-dependency-path: 'webapp/pnpm-lock.yaml' + + - name: Build + run: | + Set-PSDebug -Trace 1 + + $Path = '${{matrix.libpath}}' + & "$Path/build.ps1" + + New-Item -ItemType "directory" -Path . -Name "npm-packages" + Get-ChildItem -Path $Path -Recurse *.tgz | ForEach { Copy-Item $_ "./npm-packages" } + shell: pwsh + + - name: Upload packages + uses: actions/upload-artifact@v7 + with: + name: npm-${{matrix.library}} + path: npm-packages/*.tgz + + npm-merge: + name: NPM merge artifacts + runs-on: ubuntu-latest + needs: [npm-build] + + steps: + - name: Merge Artifacts + uses: actions/upload-artifact/merge@v7 + with: + name: npm + pattern: npm-* + delete-merged: true + + nuget-publish: + name: Publish NuGet packages + environment: publish-prod + if: ${{ needs.preflight.outputs.dry_run == 'false' }} + needs: [preflight, nuget-merge] + runs-on: ubuntu-latest + permissions: + id-token: write + + steps: + - name: Download NuGet packages artifact + uses: actions/download-artifact@v8 + with: + name: nupkg + path: nuget-packages + + - name: NuGet login (OIDC) + id: nuget-login + uses: NuGet/login@v1 + with: + user: ${{ secrets.NUGET_BOT_USERNAME }} + + - name: Publish to nuget.org + run: | + Set-PSDebug -Trace 1 + + $Files = Get-ChildItem -Recurse nuget-packages/*.nupkg + + foreach ($File in $Files) { + $PushCmd = @( + 'dotnet', + 'nuget', + 'push', + "$File", + '--api-key', + '${{ steps.nuget-login.outputs.NUGET_API_KEY }}', + '--source', + 'https://api.nuget.org/v3/index.json', + '--skip-duplicate' + ) + + Write-Host "Publishing $($File.Name)..." + $PushCmd = $PushCmd -Join ' ' + Invoke-Expression $PushCmd + } + shell: pwsh + + npm-publish: + name: Publish NPM packages + environment: publish-prod + if: ${{ needs.preflight.outputs.dry_run == 'false' }} + needs: [preflight, npm-merge, npm-build] + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + + steps: + - name: Check out ${{ github.repository }} + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + registry-url: https://registry.npmjs.org + + - name: Download NPM packages artifact + uses: actions/download-artifact@v8 + with: + name: npm + path: npm-packages + + - name: Publish + run: | + Set-PSDebug -Trace 1 + + $Files = Get-ChildItem -Recurse npm-packages/*.tgz + + foreach ($File in $Files) { + Write-Host "Publishing $($File.Name)..." + ./ci/npm-publish.ps1 -Tarball "$File" -Access 'public' + } + shell: pwsh + + - name: Update Artifactory Cache + run: gh workflow run update-artifactory-cache.yml --repo Devolutions/scheduled-tasks --field package_name="gateway-client" + env: + GH_TOKEN: ${{ secrets.DEVOLUTIONSBOT_WRITE_TOKEN }} + + remove-labels: + name: Remove publish-required labels + if: ${{ needs.preflight.outputs.dry_run == 'false' }} + needs: [preflight, nuget-build, npm-publish] + runs-on: ubuntu-latest + + steps: + - name: Check out ${{ github.repository }} + uses: actions/checkout@v6 + + - name: Remove labels + run: ./ci/remove-labels.ps1 -Label 'publish-required' + shell: pwsh + env: + GITHUB_TOKEN: ${{ github.token }} + + notify: + name: Notify failure + if: ${{ always() && contains(needs.*.result, 'failure') && github.event_name == 'schedule' }} + needs: [npm-build, nuget-merge] + runs-on: ubuntu-latest + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_ARCHITECTURE }} + SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK + + steps: + - name: Send slack notification + id: slack + uses: slackapi/slack-github-action@v1.24.0 + with: + payload: | + { + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*${{ github.repository }}* :fire::fire::fire::fire::fire: \n The scheduled build for *${{ github.repository }}* is <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|broken>" + } + } + ] + } diff --git a/Cargo.lock b/Cargo.lock index 560224325..0f5da8625 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1185,6 +1185,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f421161cb492475f1661ddc9815a745a1c894592070661180fdec3d4872e9c3" +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -1576,6 +1585,29 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2 1.0.106", + "quote 1.0.45", + "rustc_version", + "syn 2.0.117", + "unicode-xid 0.2.6", +] + [[package]] name = "des" version = "0.9.0-rc.1" @@ -1637,6 +1669,7 @@ dependencies = [ "tonic 0.12.3", "tonic-build", "tracing", + "unigetui-broker", "url", "uuid", "win-api-wrappers", @@ -2303,6 +2336,16 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -3277,6 +3320,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inotify" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd168d97690d0b8c412d6b6c10360277f4d7ee495c5d0d5d5fe0854923255cc" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + [[package]] name = "inotify" version = "0.11.1" @@ -4631,6 +4685,24 @@ version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9737e026353e5cd0736f98eddae28665118eb6f6600902a7f50db585621fecb6" +[[package]] +name = "notify" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c533b4c39709f9ba5005d8002048266593c1cfaf3c5f0739d5b8ab0c6c504009" +dependencies = [ + "bitflags 2.11.1", + "filetime", + "inotify 0.10.2", + "kqueue", + "libc", + "log", + "mio", + "notify-types 1.0.1", + "walkdir", + "windows-sys 0.52.0", +] + [[package]] name = "notify" version = "8.2.0" @@ -4639,12 +4711,12 @@ checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" dependencies = [ "bitflags 2.11.1", "fsevent-sys", - "inotify", + "inotify 0.11.1", "kqueue", "libc", "log", "mio", - "notify-types", + "notify-types 2.1.0", "walkdir", "windows-sys 0.60.2", ] @@ -4656,11 +4728,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a689eb4262184d9a1727f9087cd03883ea716682ab03ed24efec57d7716dccb8" dependencies = [ "log", - "notify", - "notify-types", + "notify 8.2.0", + "notify-types 2.1.0", "tempfile", ] +[[package]] +name = "notify-types" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585d3cb5e12e01aed9e8a1f70d5c6b5e86fe2a6e48fc8cd0b3e0b8df6f6eb174" +dependencies = [ + "instant", +] + [[package]] name = "notify-types" version = "2.1.0" @@ -4670,6 +4751,57 @@ dependencies = [ "bitflags 2.11.1", ] +[[package]] +name = "now-policy" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d87d7ba63d8fcbbae125f975c5e30a54ed4507027dd70d6daa0aa63003cac2e" +dependencies = [ + "chrono", + "schemars", + "semver", + "serde", + "serde_json", + "serde_yaml", + "thiserror 2.0.18", + "url", +] + +[[package]] +name = "now-policy-api" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3837290f7ed48038a8ca4d9dfb98a0c44a5debe141cdd7d740d59eca4df4fa5" +dependencies = [ + "base64 0.22.1", + "chrono", + "derive_more", + "now-policy", + "schemars", + "semver", + "serde", + "serde_json", + "strum", + "thiserror 2.0.18", +] + +[[package]] +name = "now-policy-server-template" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92afa468bf18b66af5d209caac39fcff241b07495c538d032221736e8a7f95c6" +dependencies = [ + "aide", + "async-trait", + "axum 0.8.9", + "now-policy", + "now-policy-api", + "schemars", + "serde", + "serde_json", + "serde_yaml", +] + [[package]] name = "now-proto-pdu" version = "0.4.3" @@ -7098,6 +7230,27 @@ dependencies = [ "unicode-properties", ] +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2 1.0.106", + "quote 1.0.45", + "syn 2.0.117", +] + [[package]] name = "subtle" version = "2.6.1" @@ -8272,6 +8425,12 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + [[package]] name = "unicode-xid" version = "0.1.0" @@ -8284,6 +8443,43 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unigetui-broker" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "axum 0.8.9", + "base64 0.22.1", + "bytes 1.11.1", + "chrono", + "devolutions-gateway-task", + "http-body-util", + "hyper 1.9.0", + "hyper-util", + "notify 7.0.0", + "now-policy", + "now-policy-api", + "now-policy-server-template", + "regex", + "semver", + "serde", + "serde_json", + "serde_yaml", + "thiserror 2.0.18", + "tokio 1.52.3", + "tokio-util", + "tower 0.5.3", + "tower-http 0.5.2", + "tower-service", + "tracing", + "tracing-subscriber", + "url", + "uuid", + "win-api-wrappers", + "windows 0.61.3", +] + [[package]] name = "universal-hash" version = "0.5.1" diff --git a/crates/unigetui-broker/Cargo.toml b/crates/unigetui-broker/Cargo.toml new file mode 100644 index 000000000..bbc85a06a --- /dev/null +++ b/crates/unigetui-broker/Cargo.toml @@ -0,0 +1,60 @@ +[package] +name = "unigetui-broker" +version = "0.1.0" +edition = "2024" +authors = ["Devolutions Inc. "] +description = "UniGetUI package broker - policy evaluation and command execution over named pipe" +publish = false + +[lints] +workspace = true + +[dependencies] +anyhow = "1" +async-trait = "0.1" +base64 = "0.22" +devolutions-gateway-task = { path = "../devolutions-gateway-task" } +axum = { version = "0.8", default-features = false, features = ["http1", "json", "tokio", "original-uri", "matched-path"] } +now-policy = "0.1" +now-policy-api = { version = "0.1", features = ["policy-compat"] } +now-policy-server-template = { version = "0.1", features = ["policy-compat"] } +tower = "0.5" +tower-service = "0.3" +tower-http = { version = "0.5", features = ["timeout"] } +hyper = { version = "1", features = ["http1", "server"] } +hyper-util = { version = "0.1", features = ["tokio", "server", "server-auto", "service"] } +http-body-util = "0.1" +bytes = "1" +semver = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde_yaml = "0.9" +notify = { version = "7", default-features = false, features = ["macos_kqueue"] } +thiserror = "2" +tokio = { version = "1.52", features = ["full"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +url = "2" +uuid = { version = "1.17", features = ["v4"] } +chrono = { version = "0.4", features = ["serde"] } +regex = "1" + +[target.'cfg(windows)'.dependencies] +win-api-wrappers = { path = "../win-api-wrappers" } +windows = { version = "0.61", features = [ + "Win32_Security", + "Win32_Security_Authorization", + "Win32_System_Pipes", + "Win32_System_IO", + "Win32_Storage_FileSystem", + "Win32_Foundation", + "Win32_System_Threading", + "Win32_System_RemoteDesktop", + "Win32_System_Environment", + "Win32_UI_WindowsAndMessaging", +] } +tokio-util = { version = "0.7", features = ["compat"] } + +[[bin]] +name = "unigetui-broker-standalone" +path = "tools/standalone.rs" diff --git a/crates/unigetui-broker/assets/samples/corporate-allowlist.policy.json b/crates/unigetui-broker/assets/samples/corporate-allowlist.policy.json new file mode 100644 index 000000000..55827e552 --- /dev/null +++ b/crates/unigetui-broker/assets/samples/corporate-allowlist.policy.json @@ -0,0 +1,134 @@ +{ + "$schema": "https://devolutions.net/schemas/now-policy.schema.1.0.json", + "PolicyVersion": "1.0.0", + "PolicyType": "PackageBrokerPolicy", + "Metadata": { + "Id": "contoso.desktop.standard-allowlist", + "Publisher": "Contoso IT", + "Revision": 4, + "PublishedAt": "2026-05-05T00:00:00Z", + "Description": "Fail-closed policy for standard workstation package installs." + }, + "Enforcement": { + "DefaultDecision": "Deny", + "RulePrecedence": "PriorityThenDeny" + }, + "Rules": [ + { + "Id": "deny.integrity-bypass", + "Enabled": true, + "Priority": 10, + "Decision": "Deny", + "Reason": "Integrity and publisher checks cannot be bypassed by brokered requests.", + "Match": { + "Operations": [ + "Install", + "Update" + ], + "SkipHashCheck": [ + true + ] + } + }, + { + "Id": "deny.custom-parameters", + "Enabled": true, + "Priority": 20, + "Decision": "Deny", + "Reason": "Custom package-manager parameters are not allowed in the workstation allow list.", + "Match": { + "HasCustomParameters": [ + true + ] + } + }, + { + "Id": "deny.prepost-commands", + "Enabled": true, + "Priority": 30, + "Decision": "Deny", + "Reason": "Pre and post operation commands are not allowed in the workstation allow list.", + "Match": { + "HasPrePostCommands": [ + true + ] + } + }, + { + "Id": "allow.winget.vscode", + "Enabled": true, + "Priority": 100, + "Decision": "Allow", + "Reason": "Visual Studio Code is approved for managed workstations.", + "Match": { + "Operations": [ + "Install", + "Update" + ], + "Managers": [ + "Winget" + ], + "Sources": [ + "winget" + ], + "PackageIdentifiers": [ + "Microsoft.VisualStudioCode" + ], + "Scopes": [ + "User", + "Machine" + ], + "Architectures": [ + "X64", + "Arm64" + ] + }, + "Constraints": { + "AllowInteractive": false, + "AllowSkipHashCheck": false, + "AllowPreRelease": false, + "AllowCustomParameters": false, + "AllowPrePostCommands": false, + "AllowKillBeforeOperation": false + } + }, + { + "Id": "allow.winget.powertoys", + "Enabled": true, + "Priority": 100, + "Decision": "Allow", + "Reason": "PowerToys is approved for developer workstations.", + "Match": { + "Operations": [ + "Install", + "Update" + ], + "Managers": [ + "Winget" + ], + "Sources": [ + "winget" + ], + "PackageIdentifiers": [ + "Microsoft.PowerToys" + ], + "Scopes": [ + "User", + "Machine" + ], + "Architectures": [ + "X64", + "Arm64" + ] + }, + "Constraints": { + "AllowInteractive": false, + "AllowSkipHashCheck": false, + "AllowPreRelease": false, + "AllowCustomParameters": false, + "AllowPrePostCommands": false, + "AllowKillBeforeOperation": false + } + } + ] +} \ No newline at end of file diff --git a/crates/unigetui-broker/assets/samples/deny-risky-options.policy.json b/crates/unigetui-broker/assets/samples/deny-risky-options.policy.json new file mode 100644 index 000000000..76197cb01 --- /dev/null +++ b/crates/unigetui-broker/assets/samples/deny-risky-options.policy.json @@ -0,0 +1,81 @@ +{ + "$schema": "https://devolutions.net/schemas/now-policy.schema.1.0.json", + "PolicyVersion": "1.0.0", + "PolicyType": "PackageBrokerPolicy", + "Metadata": { + "Id": "contoso.desktop.deny-risky-options", + "Publisher": "Contoso IT", + "Revision": 2, + "PublishedAt": "2026-05-05T00:00:00Z", + "Description": "Default-allow policy that blocks risky broker request options." + }, + "Enforcement": { + "DefaultDecision": "Allow", + "RulePrecedence": "PriorityThenDeny" + }, + "Rules": [ + { + "Id": "deny.integrity-bypass", + "Priority": 10, + "Decision": "Deny", + "Reason": "Do not broker installs that skip WinGet hash checks or PowerShell publisher checks.", + "Match": { + "Operations": [ + "Install", + "Update" + ], + "SkipHashCheck": [ + true + ] + } + }, + { + "Id": "deny.manager-custom-parameters", + "Priority": 20, + "Decision": "Deny", + "Reason": "Custom package-manager parameters require a dedicated exception policy.", + "Match": { + "HasCustomParameters": [ + true + ] + } + }, + { + "Id": "deny.prepost-commands", + "Priority": 30, + "Decision": "Deny", + "Reason": "Pre and post operation commands are outside the package manager trust boundary.", + "Match": { + "HasPrePostCommands": [ + true + ] + } + }, + { + "Id": "deny.kill-process-actions", + "Priority": 40, + "Decision": "Deny", + "Reason": "Killing processes before a brokered package operation is not allowed by this policy.", + "Match": { + "HasKillBeforeOperation": [ + true + ] + } + }, + { + "Id": "deny.unapproved-winget-source", + "Priority": 50, + "Decision": "Deny", + "Reason": "Only the default WinGet source is accepted by this deny-list sample.", + "Match": { + "Managers": [ + "Winget" + ], + "Sources": [ + "msstore", + "winget-fonts" + ] + } + } + ] +} \ No newline at end of file diff --git a/crates/unigetui-broker/assets/samples/requests/winget-unknown-install.request.json b/crates/unigetui-broker/assets/samples/requests/winget-unknown-install.request.json new file mode 100644 index 000000000..5396eaa11 --- /dev/null +++ b/crates/unigetui-broker/assets/samples/requests/winget-unknown-install.request.json @@ -0,0 +1,31 @@ +{ + "RequestKind": "PackageRequest", + "RequestVersion": "1.0", + "RequestId": "req-winget-unknown-install", + "CreatedAt": "2026-05-05T12:05:00Z", + "Operation": "Install", + "Manager": "Winget", + "Source": { + "Name": "winget", + "Url": "https://cdn.winget.microsoft.com/cache" + }, + "Package": { + "Id": "Example.UnapprovedTool", + "Architecture": "X64" + }, + "Options": { + "Scope": "User", + "Interactive": false, + "SkipHashCheck": false, + "PreRelease": false, + "CustomParameters": [], + "KillBeforeOperation": [] + }, + "Client": { + "RequestedElevation": "Standard", + "EffectiveUser": "CONTOSO\\alice", + "ClientVersion": "3.2.0", + "Transport": "HttpNamedPipe", + "ClientExecutablePath": "C:\\Program Files\\Devolutions\\NOW\\now-client.exe" + } +} diff --git a/crates/unigetui-broker/assets/samples/requests/winget-vscode-install.request.json b/crates/unigetui-broker/assets/samples/requests/winget-vscode-install.request.json new file mode 100644 index 000000000..479ea486a --- /dev/null +++ b/crates/unigetui-broker/assets/samples/requests/winget-vscode-install.request.json @@ -0,0 +1,32 @@ +{ + "RequestKind": "PackageRequest", + "RequestVersion": "1.0", + "RequestId": "req-winget-vscode-install", + "CreatedAt": "2026-05-05T12:00:00Z", + "Operation": "Install", + "Manager": "Winget", + "Source": { + "Name": "winget", + "Url": "https://cdn.winget.microsoft.com/cache" + }, + "Package": { + "Id": "Microsoft.VisualStudioCode", + "Architecture": "X64" + }, + "Options": { + "Scope": "Machine", + "Interactive": false, + "SkipHashCheck": false, + "PreRelease": false, + "CustomParameters": [], + "KillBeforeOperation": [] + }, + "Client": { + "RequestedElevation": "Elevated", + "EffectiveUser": "CONTOSO\\alice", + "ClientVersion": "3.2.0", + "Transport": "HttpNamedPipe", + "ClientExecutablePath": "C:\\Program Files\\Devolutions\\NOW\\now-client.exe" + }, + "IncludeCommandPreview": true +} diff --git a/crates/unigetui-broker/assets/samples/requests/winget-vscode-skiphash.request.json b/crates/unigetui-broker/assets/samples/requests/winget-vscode-skiphash.request.json new file mode 100644 index 000000000..e180ad2cf --- /dev/null +++ b/crates/unigetui-broker/assets/samples/requests/winget-vscode-skiphash.request.json @@ -0,0 +1,31 @@ +{ + "RequestKind": "PackageRequest", + "RequestVersion": "1.0", + "RequestId": "req-winget-vscode-skiphash", + "CreatedAt": "2026-05-05T12:10:00Z", + "Operation": "Install", + "Manager": "Winget", + "Source": { + "Name": "winget", + "Url": "https://cdn.winget.microsoft.com/cache" + }, + "Package": { + "Id": "Microsoft.VisualStudioCode", + "Architecture": "X64" + }, + "Options": { + "Scope": "Machine", + "Interactive": false, + "SkipHashCheck": true, + "PreRelease": false, + "CustomParameters": [], + "KillBeforeOperation": [] + }, + "Client": { + "RequestedElevation": "Elevated", + "EffectiveUser": "CONTOSO\\alice", + "ClientVersion": "3.2.0", + "Transport": "HttpNamedPipe", + "ClientExecutablePath": "C:\\Program Files\\Devolutions\\NOW\\now-client.exe" + } +} diff --git a/crates/unigetui-broker/assets/samples/scenarios/baseline.scenarios.json b/crates/unigetui-broker/assets/samples/scenarios/baseline.scenarios.json new file mode 100644 index 000000000..c8ba84719 --- /dev/null +++ b/crates/unigetui-broker/assets/samples/scenarios/baseline.scenarios.json @@ -0,0 +1,38 @@ +{ + "ScenarioSet": "baseline", + "Description": "Broad end-to-end scenarios for the gateway-owned package broker policy flow.", + "Scenarios": [ + { + "Id": "baseline.winget.vscode.allow", + "Policy": "corporate-allowlist.policy.json", + "Request": "requests/winget-vscode-install.request.json", + "ExpectedDecision": "Allow", + "ExpectedRuleId": "allow.winget.vscode", + "Tags": ["baseline", "winget", "allowlist"] + }, + { + "Id": "baseline.winget.unknown.default-deny", + "Policy": "corporate-allowlist.policy.json", + "Request": "requests/winget-unknown-install.request.json", + "ExpectedDecision": "Deny", + "ExpectedRuleId": "", + "Tags": ["baseline", "winget", "default-deny"] + }, + { + "Id": "baseline.winget.skiphash.deny", + "Policy": "corporate-allowlist.policy.json", + "Request": "requests/winget-vscode-skiphash.request.json", + "ExpectedDecision": "Deny", + "ExpectedRuleId": "deny.integrity-bypass", + "Tags": ["baseline", "winget", "risky-options"] + }, + { + "Id": "baseline.default-allow.normal-install", + "Policy": "deny-risky-options.policy.json", + "Request": "requests/winget-vscode-install.request.json", + "ExpectedDecision": "Allow", + "ExpectedRuleId": "", + "Tags": ["baseline", "winget", "default-allow"] + } + ] +} \ No newline at end of file diff --git a/crates/unigetui-broker/src/command_builder/mod.rs b/crates/unigetui-broker/src/command_builder/mod.rs new file mode 100644 index 000000000..f50bd0a00 --- /dev/null +++ b/crates/unigetui-broker/src/command_builder/mod.rs @@ -0,0 +1,38 @@ +//! Command-line builders for package manager operations. +//! +//! Constructs the commands the broker would execute from validated request fields. +//! The broker never executes client-supplied commands directly. + +pub mod powershell; +pub mod winget; + +use now_policy_api::{ManagerName, PackageRequest}; + +/// Build a command line from a validated request, dispatching to the appropriate +/// package manager builder. +/// +/// Returns the command as a list of arguments (first element is the executable). +pub fn build_command(request: &PackageRequest) -> Vec { + match request.manager { + ManagerName::Winget => winget::build_winget_command(request), + ManagerName::PowerShell => powershell::build_powershell5_command(request), + ManagerName::PowerShell7 => powershell::build_powershell7_command(request), + } +} + +/// Append `--flag value` to command if value is `Some` and non-empty. +pub(crate) fn set_if_specified(command: &mut Vec, flag: &str, value: Option<&str>) { + if let Some(v) = value + && !v.is_empty() + { + command.push(flag.to_owned()); + command.push(v.to_owned()); + } +} + +/// Append `--flag` to command if value is true. +pub(crate) fn set_if_true(command: &mut Vec, flag: &str, value: bool) { + if value { + command.push(flag.to_owned()); + } +} diff --git a/crates/unigetui-broker/src/command_builder/powershell.rs b/crates/unigetui-broker/src/command_builder/powershell.rs new file mode 100644 index 000000000..57a4c6fa8 --- /dev/null +++ b/crates/unigetui-broker/src/command_builder/powershell.rs @@ -0,0 +1,284 @@ +//! PowerShell command-line builders for WinPS 5.x and PowerShell 7.x. +//! +//! These mirror UniGetUI's own PowerShell manager helpers so the broker runs the +//! same command the unelevated client would have run: +//! - PowerShell 5 uses PowerShellGet (`Install-Module`/`Update-Module`/`Uninstall-Module`) +//! invoked as `powershell.exe -NoProfile -Command