mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Merge studio into feat/theme-pack-recipes
This commit is contained in:
commit
b678e56a32
1868 files changed
+86532
-14968
No files matched your search
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
+49
-28
@@ -26,7 +26,9 @@ jobs:
|
||||
# merges. Gated jobs skip only on an explicit `false` output: wrapped in
|
||||
# `always()`, a failed `changes` job (or a missing output) makes them run
|
||||
# the full matrix instead of silently passing required checks as skipped.
|
||||
# Pushes to main-v2 always run everything.
|
||||
# Pushes to main-v2 always run everything. Pull requests run the macOS and
|
||||
# Windows legs only when `platform` is not 'false'; the rule lives in
|
||||
# scripts/ci-platform-scope.sh and fails toward running them.
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
@@ -34,13 +36,14 @@ jobs:
|
||||
desktop: ${{ steps.filter.outputs.desktop }}
|
||||
sdk: ${{ steps.filter.outputs.sdk }}
|
||||
scripts: ${{ steps.filter.outputs.scripts }}
|
||||
platform: ${{ steps.filter.outputs.platform }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- id: filter
|
||||
run: |
|
||||
code=true; desktop=true; sdk=true; scripts=true
|
||||
code=true; desktop=true; sdk=true; scripts=true; platform=true
|
||||
base=""
|
||||
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||
base="${{ github.event.pull_request.base.sha }}"
|
||||
@@ -48,7 +51,7 @@ jobs:
|
||||
base="${{ github.event.before }}"
|
||||
fi
|
||||
if [ -n "$base" ] && git cat-file -e "$base^{commit}" 2>/dev/null; then
|
||||
files=$(git diff --name-only "$base" HEAD)
|
||||
files=$(git diff --name-only --no-renames "$base" HEAD)
|
||||
if [ -n "$files" ]; then
|
||||
code=false; desktop=false; sdk=false; scripts=false
|
||||
# Root-module CI: desktop/ is a separate module, so only the
|
||||
@@ -63,9 +66,10 @@ jobs:
|
||||
if echo "$files" | grep -qE '^(sdk/|internal/ext/extension/)'; then sdk=true; fi
|
||||
# The release-note tests render every committed Studio note; npm/ holds the publish tests.
|
||||
if echo "$files" | grep -qE '^(scripts/|npm/|release-notes/studio/|\.github/workflows/release-studio\.yml$)'; then scripts=true; fi
|
||||
platform=$(git diff -z --name-only --no-renames "$base" HEAD | bash scripts/ci-platform-scope.sh)
|
||||
fi
|
||||
fi
|
||||
{ echo "code=$code"; echo "desktop=$desktop"; echo "sdk=$sdk"; echo "scripts=$scripts"; } >> "$GITHUB_OUTPUT"
|
||||
{ echo "code=$code"; echo "desktop=$desktop"; echo "sdk=$sdk"; echo "scripts=$scripts"; echo "platform=$platform"; } >> "$GITHUB_OUTPUT"
|
||||
|
||||
release-notes-scripts:
|
||||
needs: changes
|
||||
@@ -77,14 +81,14 @@ jobs:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
|
||||
with:
|
||||
node-version: "22"
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
- run: node --test scripts/cli-archive-build.test.mjs scripts/release-credits.test.mjs scripts/update-contributors.test.mjs scripts/update-acknowledgments.test.mjs scripts/markdown-refs.test.mjs scripts/studio-release-notes.test.mjs scripts/publish-cli-pointer.test.mjs scripts/studio-release-flags.test.mjs scripts/studio-notes-publish.test.mjs scripts/brew-cask-guard.test.mjs scripts/feedback-sync.test.mjs npm/publish.test.mjs
|
||||
- run: node --test scripts/linux-install-smoke.test.mjs scripts/studio-artifacts.test.mjs scripts/ci-platform-scope.test.mjs scripts/cli-archive-build.test.mjs scripts/release-credits.test.mjs scripts/update-contributors.test.mjs scripts/update-acknowledgments.test.mjs scripts/markdown-refs.test.mjs scripts/studio-release-notes.test.mjs scripts/publish-cli-pointer.test.mjs scripts/studio-release-flags.test.mjs scripts/studio-notes-publish.test.mjs scripts/withdraw-version.test.mjs scripts/brew-cask-guard.test.mjs scripts/feedback-sync.test.mjs npm/publish.test.mjs
|
||||
|
||||
# The ruleset requires the per-OS check names (test (ubuntu-latest) etc.).
|
||||
# A matrix job skipped at job level reports no per-leg checks at all, so
|
||||
@@ -93,6 +97,9 @@ jobs:
|
||||
# detector reports the diff is unrelated, every step skips and each leg
|
||||
# reports success in seconds. `always()` also keeps the legs alive when
|
||||
# the changes job itself fails (fail-open: an empty output != 'false').
|
||||
# A pull request with no platform-relevant change moves the macOS and
|
||||
# Windows legs onto an ubuntu runner with every step skipped: the check
|
||||
# names the ruleset requires still report, without a scarce runner.
|
||||
test:
|
||||
needs: changes
|
||||
if: always()
|
||||
@@ -100,9 +107,9 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
runs-on: ${{ github.event_name == 'pull_request' && needs.changes.outputs.platform == 'false' && 'ubuntu-latest' || matrix.os }}
|
||||
env:
|
||||
RUN_STEPS: ${{ github.event_name != 'pull_request' || needs.changes.outputs.code != 'false' }}
|
||||
RUN_STEPS: ${{ github.event_name != 'pull_request' || (needs.changes.outputs.code != 'false' && (matrix.os == 'ubuntu-latest' || needs.changes.outputs.platform != 'false')) }}
|
||||
steps:
|
||||
- if: env.RUN_STEPS == 'true'
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
@@ -179,12 +186,9 @@ jobs:
|
||||
# budget above normal 7-9 minute runner variance; each package test binary
|
||||
# remains independently bounded by Go's per-package timeout below.
|
||||
#
|
||||
# That timeout is 12m, not 3m: across four consecutive runs of the same
|
||||
# code, internal/assembly/boot measured 133s, 146s, 162s and then hit 180s, and
|
||||
# internal/runtime/agent 82s, 92s, 144s and 180s — a 2.2x spread from runner
|
||||
# variance alone, with four heavy packages sharing four vCPUs. The boot package
|
||||
# has since grown to 422s and then ran past 480s on two consecutive runs of the
|
||||
# same code, so 8m no longer leaves room. A hang still trips the job budget.
|
||||
# The per-package timeout is 18m: internal/assembly/boot took 478-717s
|
||||
# (12 push runs) on the Windows runner, so 18m is 1.5x the worst. A hang
|
||||
# still trips the step budget (25m smoke, 35m full).
|
||||
#
|
||||
# Every root-module `go test` that runs on Windows passes -count=1. With
|
||||
# the result cache on, go test records each file and environment access
|
||||
@@ -195,11 +199,11 @@ jobs:
|
||||
# internal/base/testenv holds every such step to it.
|
||||
- name: test (Windows smoke)
|
||||
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
timeout-minutes: 20
|
||||
timeout-minutes: 25
|
||||
env:
|
||||
REASONIX_RELEASE_CACHE_GUARD: "1"
|
||||
WINDOWS_SANDBOX_WAIT_MS: "20000"
|
||||
run: go test -count=1 -p 4 -timeout=12m ./internal/runtime/agent/... ./internal/platform/appidentity/... ./internal/assembly/boot/... ./internal/state/checkpoint/... ./internal/frontend/cli/... ./internal/session/control/... ./internal/base/filelock/... ./internal/base/fileutil/... ./internal/ext/hook/... ./internal/state/instruction/... ./internal/ext/mcplaunch/... ./internal/platform/notify/... ./internal/base/proc/... ./internal/platform/remote/... ./internal/platform/repair/... ./internal/safety/sandbox/... ./internal/base/sysproxy/... ./internal/state/workspacelease/... ./cmd/...
|
||||
run: go test -count=1 -p 4 -timeout=18m ./internal/runtime/agent/... ./internal/platform/appidentity/... ./internal/assembly/boot/... ./internal/state/checkpoint/... ./internal/frontend/cli/... ./internal/session/control/... ./internal/base/filelock/... ./internal/base/fileutil/... ./internal/ext/hook/... ./internal/state/instruction/... ./internal/ext/mcplaunch/... ./internal/platform/notify/... ./internal/base/proc/... ./internal/platform/remote/... ./internal/platform/repair/... ./internal/safety/sandbox/... ./internal/base/sysproxy/... ./internal/state/workspacelease/... ./cmd/...
|
||||
|
||||
# The general Windows PR smoke list intentionally omits internal/contract/tool.
|
||||
# Keep the session-temp portability contract covered without widening the
|
||||
@@ -218,14 +222,12 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
run: go test -count=1 -timeout=3m -run '^TestBashPowerShellExecuteDetailedContract$|^TestBashPowerShell51PreflightRejectsAndAndDetailed$|^TestBashPowerShellOutputIsUTF8$|^TestBashPowerShellSurfacesNonZeroExit$|^TestBashPowerShellRejectsChaining$' ./internal/tools/builtin
|
||||
|
||||
# Full push suite still uses the same 12m package budget as Windows smoke:
|
||||
# agent/boot/control already hit 180s package timeouts under runner load,
|
||||
# so 3m fails on slow machines without a code regression. It runs with
|
||||
# -count=1 for the reason given at the smoke step. A hang still dies here
|
||||
# rather than on a package.
|
||||
# Full push suite uses the same 18m package timeout as Windows smoke and a
|
||||
# 35m step budget above it. -count=1 as at the smoke step. A failure prints
|
||||
# the failed tests' output and the 15 slowest boot tests.
|
||||
- name: test (full)
|
||||
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name != 'pull_request'
|
||||
timeout-minutes: 30
|
||||
timeout-minutes: 35
|
||||
env:
|
||||
# Run the prompt-cache prefix-stability guard (TestCacheHit*) in CI: a
|
||||
# regression there silently tanks the cache hit rate the project is
|
||||
@@ -234,7 +236,25 @@ jobs:
|
||||
# Bound sandbox helper children in Windows tests so a failed OS-level
|
||||
# launch cannot pin the Actions step after Go's package timeout fires.
|
||||
WINDOWS_SANDBOX_WAIT_MS: "20000"
|
||||
run: go test -count=1 -p 4 -timeout=12m ./...
|
||||
shell: bash
|
||||
run: |
|
||||
go test -count=1 -p 4 -timeout=18m -json ./... | tee "$RUNNER_TEMP/full.jsonl" |
|
||||
jq -rj --unbuffered 'select(.Action == "output" and .Test == null) | .Output'
|
||||
|
||||
- name: test (full) failure detail
|
||||
if: failure() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name != 'pull_request'
|
||||
shell: bash
|
||||
run: |
|
||||
f="$RUNNER_TEMP/full.jsonl"
|
||||
[ -s "$f" ] || exit 0
|
||||
echo "::group::output of failed tests"
|
||||
jq -rn '[inputs | select(.Test != null)] | group_by(.Package + "/" + .Test)
|
||||
| map(select(any(.[]; .Action == "fail")))[]
|
||||
| map(select(.Action == "output") | .Output) | join("")' "$f" || true
|
||||
echo "::endgroup::"
|
||||
echo "slowest internal/assembly/boot tests (seconds, name):"
|
||||
jq -rn '[inputs | select((.Package | endswith("/internal/assembly/boot")) and .Test != null and .Action == "pass")]
|
||||
| sort_by(-.Elapsed)[:15][] | "\(.Elapsed)\t\(.Test)"' "$f" || true
|
||||
|
||||
race:
|
||||
needs: changes
|
||||
@@ -289,9 +309,9 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
runs-on: ${{ github.event_name == 'pull_request' && needs.changes.outputs.platform == 'false' && 'ubuntu-latest' || matrix.os }}
|
||||
env:
|
||||
RUN_STEPS: ${{ github.event_name != 'pull_request' || needs.changes.outputs.sdk != 'false' }}
|
||||
RUN_STEPS: ${{ github.event_name != 'pull_request' || (needs.changes.outputs.sdk != 'false' && (matrix.os == 'ubuntu-latest' || needs.changes.outputs.platform != 'false')) }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: sdk/go
|
||||
@@ -396,7 +416,7 @@ jobs:
|
||||
# compile it against a real macOS toolchain.
|
||||
desktop-macos:
|
||||
needs: changes
|
||||
if: always() && (github.event_name != 'pull_request' || needs.changes.outputs.desktop != 'false')
|
||||
if: always() && (github.event_name != 'pull_request' || (needs.changes.outputs.desktop != 'false' && needs.changes.outputs.platform != 'false'))
|
||||
runs-on: macos-latest
|
||||
defaults:
|
||||
run:
|
||||
@@ -424,7 +444,7 @@ jobs:
|
||||
# (//go:build windows), so the ubuntu leg above never compiles them.
|
||||
desktop-windows:
|
||||
needs: changes
|
||||
if: always() && (github.event_name != 'pull_request' || needs.changes.outputs.desktop != 'false')
|
||||
if: always() && (github.event_name != 'pull_request' || (needs.changes.outputs.desktop != 'false' && needs.changes.outputs.platform != 'false'))
|
||||
runs-on: windows-latest
|
||||
defaults:
|
||||
run:
|
||||
@@ -495,6 +515,7 @@ jobs:
|
||||
run: |
|
||||
go run github.com/rhysd/actionlint/cmd/[email protected] \
|
||||
-ignore 'label "windows-11-arm" is unknown' \
|
||||
-ignore 'label "ubuntu-22.04-arm" is unknown' \
|
||||
.github/workflows/release-studio.yml \
|
||||
.github/workflows/studio-certum-signing-smoke.yml \
|
||||
.github/workflows/ci.yml
|
||||
@@ -534,7 +555,7 @@ jobs:
|
||||
run: go test -coverprofile=coverage.out -covermode=atomic ./...
|
||||
|
||||
- name: upload coverage
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
|
||||
with:
|
||||
name: coverage-report
|
||||
path: coverage.out
|
||||
|
||||
@@ -43,20 +43,20 @@ jobs:
|
||||
if: matrix.branch != ''
|
||||
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
|
||||
uses: github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.language == 'go' && 'autobuild' || 'none' }}
|
||||
- name: Perform CodeQL Analysis
|
||||
if: matrix.branch == ''
|
||||
uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
|
||||
uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
# Results from a checked-out line are filed under that line's ref, not the
|
||||
# default branch the schedule happened to run from.
|
||||
- name: Perform CodeQL Analysis (${{ matrix.branch }})
|
||||
if: matrix.branch != ''
|
||||
uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
|
||||
uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
ref: refs/heads/${{ matrix.branch }}
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: pnpm
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: pnpm
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
environment: production
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: pnpm
|
||||
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- if: steps.gate.outputs.run == 'true'
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: Sync
|
||||
|
||||
@@ -175,6 +175,9 @@ jobs:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-22.04
|
||||
artifact: studio-build-linux-amd64
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-22.04-arm
|
||||
artifact: studio-build-linux-arm64
|
||||
steps:
|
||||
# Real-time scanning inspects every file the job writes, and packaging
|
||||
# writes a quarter of a gigabyte twice. The runner is thrown away after
|
||||
@@ -202,7 +205,7 @@ jobs:
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: pnpm
|
||||
@@ -210,12 +213,22 @@ jobs:
|
||||
desktop/frontend-next/pnpm-lock.yaml
|
||||
desktop/electron/pnpm-lock.yaml
|
||||
|
||||
# electron-builder shells out to dpkg-deb through fakeroot for the .deb,
|
||||
# and carries its own NSIS -- the toolchain the Wails line installed by
|
||||
# hand (GTK, WebKitGTK, nfpm, a pinned NSIS) is not this shell's.
|
||||
# electron-builder shells out to dpkg-deb through fakeroot for the .deb and
|
||||
# to rpmbuild for the .rpm, and carries its own NSIS -- the toolchain the
|
||||
# Wails line installed by hand (GTK, WebKitGTK, nfpm, a pinned NSIS) is
|
||||
# not this shell's.
|
||||
- name: Install Linux packaging deps
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y fakeroot
|
||||
run: sudo apt-get update && sudo apt-get install -y fakeroot rpm cpio xvfb xauth policykit-1
|
||||
|
||||
# The fpm electron-builder carries is an x86_64 program, so on an ARM64
|
||||
# runner the .deb and .rpm are built with one installed from RubyGems.
|
||||
- name: Use a native fpm on ARM64 Linux
|
||||
if: runner.os == 'Linux' && runner.arch == 'ARM64'
|
||||
run: |
|
||||
sudo apt-get install -y ruby ruby-dev build-essential
|
||||
sudo gem install --no-document fpm -v 1.16.0
|
||||
echo "USE_SYSTEM_FPM=true" >> "$GITHUB_ENV"
|
||||
|
||||
# The same Developer ID and notary credentials the desktop line uses: one
|
||||
# certificate signs any bundle identifier its team owns, so Studio does not
|
||||
@@ -359,11 +372,28 @@ jobs:
|
||||
pnpm exec electron-builder --prepackaged "../../$app" $arch \
|
||||
-c.extraMetadata.version="${version#v}"
|
||||
|
||||
- name: Verify both Linux package formats
|
||||
if: runner.os == 'Linux'
|
||||
shell: bash
|
||||
run: |
|
||||
platform="${{ matrix.platform }}"
|
||||
arch="${platform#linux/}"
|
||||
bash scripts/verify-linux-package.sh "dist/ReasonixStudio-linux-$arch.deb" "dist/ReasonixStudio-linux-$arch.rpm" "$arch"
|
||||
|
||||
- name: Accept the installed Linux package
|
||||
if: runner.os == 'Linux'
|
||||
shell: bash
|
||||
run: |
|
||||
platform="${{ matrix.platform }}"
|
||||
arch="${platform#linux/}"
|
||||
version="${{ needs.resolve.outputs.version }}"
|
||||
bash scripts/linux-install-smoke.sh "dist/ReasonixStudio-linux-$arch.deb" "$arch" "${version#v}"
|
||||
|
||||
# With signing on, this leg only hands the bundle on: the signing jobs
|
||||
# run nothing but the signer, and windows-package packages in between.
|
||||
- name: Upload the unsigned Windows bundle for signing
|
||||
if: runner.os == 'Windows' && env.SIGN_WINDOWS == 'true'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: unsigned-studio-windows-${{ matrix.goarch }}-bundle
|
||||
path: dist/${{ matrix.unpacked }}
|
||||
@@ -396,7 +426,7 @@ jobs:
|
||||
wait "$zip_pid"
|
||||
mv ../../dist-zip/ReasonixStudio-windows-${{ matrix.goarch }}.zip ../../dist/
|
||||
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
if: runner.os != 'Windows' || env.SIGN_WINDOWS != 'true'
|
||||
with:
|
||||
name: ${{ matrix.artifact }}
|
||||
@@ -478,7 +508,7 @@ jobs:
|
||||
}
|
||||
if ($failed) { exit 1 }
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: unsigned-studio-windows-${{ matrix.arch }}-bundle
|
||||
path: signing/app
|
||||
@@ -533,7 +563,7 @@ jobs:
|
||||
-ExpectedSubject $env:EXPECTED_SUBJECT
|
||||
"digest=$(Get-Content -Raw "$env:RUNNER_TEMP/payload-digest")" >> $env:GITHUB_OUTPUT
|
||||
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: signed-studio-windows-payload-${{ matrix.arch }}
|
||||
path: signing/app
|
||||
@@ -575,7 +605,7 @@ jobs:
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: pnpm
|
||||
@@ -587,7 +617,7 @@ jobs:
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# The signed payload is the whole bundle, so it is packaged as it came.
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: signed-studio-windows-payload-${{ matrix.arch }}
|
||||
path: dist/${{ matrix.unpacked }}
|
||||
@@ -613,7 +643,7 @@ jobs:
|
||||
wait "$zip_pid"
|
||||
mv ../../dist-zip/ReasonixStudio-windows-${{ matrix.arch }}.zip ../../dist/
|
||||
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: packaged-studio-windows-${{ matrix.arch }}
|
||||
path: |
|
||||
@@ -648,12 +678,12 @@ jobs:
|
||||
ref: ${{ github.workflow_sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: signed-studio-windows-payload-${{ matrix.arch }}
|
||||
path: payload
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: packaged-studio-windows-${{ matrix.arch }}
|
||||
path: dist
|
||||
@@ -731,7 +761,7 @@ jobs:
|
||||
}
|
||||
if ($failed) { exit 1 }
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: packaged-studio-windows-${{ matrix.arch }}
|
||||
path: dist
|
||||
@@ -794,7 +824,7 @@ jobs:
|
||||
$hash = (Get-FileHash -Algorithm SHA256 -LiteralPath $installer).Hash.ToLowerInvariant()
|
||||
"installer-sha256=$hash" >> $env:GITHUB_OUTPUT
|
||||
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: studio-windows-signed-${{ matrix.arch }}
|
||||
path: dist/ReasonixStudio-*
|
||||
@@ -831,13 +861,13 @@ jobs:
|
||||
ref: ${{ needs.resolve.outputs.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
if: needs.windows-sign-installer.result == 'success'
|
||||
with:
|
||||
name: studio-windows-signed-${{ matrix.arch }}
|
||||
path: dist
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
if: needs.windows-sign-installer.result != 'success'
|
||||
with:
|
||||
name: studio-build-windows-${{ matrix.arch }}
|
||||
@@ -911,7 +941,7 @@ jobs:
|
||||
[ -f "dist/$want" ] || { echo "::error::no dist/$want"; exit 1; }
|
||||
done
|
||||
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: cli-archives
|
||||
path: dist/*
|
||||
@@ -970,28 +1000,33 @@ jobs:
|
||||
# Every package by its exact name, never a pattern: any job without
|
||||
# secrets can upload an artifact, and a pattern would publish it. Windows
|
||||
# comes from the installer signing job when it ran, else the build leg.
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: studio-build-darwin-amd64
|
||||
path: staging/studio-build-darwin-amd64
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: studio-build-darwin-arm64
|
||||
path: staging/studio-build-darwin-arm64
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: studio-build-linux-amd64
|
||||
path: staging/studio-build-linux-amd64
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: studio-build-linux-arm64
|
||||
path: staging/studio-build-linux-arm64
|
||||
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
if: needs.windows-sign-installer.result == 'success'
|
||||
with:
|
||||
name: studio-windows-signed-amd64
|
||||
path: staging/studio-windows-amd64
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
if: needs.windows-sign-installer.result == 'success'
|
||||
with:
|
||||
name: studio-windows-signed-arm64
|
||||
@@ -1034,19 +1069,19 @@ jobs:
|
||||
check "$dir" "ReasonixStudio-windows-$arch.zip" "${!archive}"
|
||||
done
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
if: needs.windows-sign-installer.result != 'success'
|
||||
with:
|
||||
name: studio-build-windows-amd64
|
||||
path: staging/studio-windows-amd64
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
if: needs.windows-sign-installer.result != 'success'
|
||||
with:
|
||||
name: studio-build-windows-arm64
|
||||
path: staging/studio-windows-arm64
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: cli-archives
|
||||
path: staging/cli-archives
|
||||
@@ -1067,34 +1102,7 @@ jobs:
|
||||
done < <(find staging/studio-* -type f -name 'ReasonixStudio-*' -print0
|
||||
find staging/cli-archives -type f -print0)
|
||||
ls -la dist
|
||||
# Two Windows artifacts (installer, portable zip) per architecture,
|
||||
# two per macOS architecture (dmg, zip) and the Linux .deb. Fewer
|
||||
# means an artifact was lost between jobs, and a partial release is
|
||||
# worse than a failed one.
|
||||
count=$(find dist -type f -name 'ReasonixStudio-*' | wc -l)
|
||||
if [ "$count" -lt 9 ]; then
|
||||
echo "::error::expected 9 artifacts (Windows installer + zip per architecture, macOS dmg + zip per architecture, Linux .deb), found $count"
|
||||
exit 1
|
||||
fi
|
||||
# Each platform's update channel, and there is exactly one per
|
||||
# platform: dpkg on Linux, the next installer on Windows, and the
|
||||
# bundle swapped out of the archive on macOS. Missing one degrades the
|
||||
# release silently instead of failing it — it strands that platform on
|
||||
# the version it is running.
|
||||
# Each macOS architecture by name, not darwin-*: a glob passes on
|
||||
# whichever one shipped, and the missing one is a platform left on
|
||||
# the version it is running with nothing saying so.
|
||||
# Each Windows architecture by name for the same reason: a glob passes
|
||||
# on whichever installer shipped.
|
||||
for required in 'ReasonixStudio-linux-*.deb' \
|
||||
'ReasonixStudio-windows-amd64-installer.exe' 'ReasonixStudio-windows-arm64-installer.exe' \
|
||||
'ReasonixStudio-windows-amd64.zip' 'ReasonixStudio-windows-arm64.zip' \
|
||||
'ReasonixStudio-darwin-amd64.zip' 'ReasonixStudio-darwin-arm64.zip'; do
|
||||
find dist -type f -name "$required" | grep -q . || {
|
||||
echo "::error::no $required in the release; its packager did not run"
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
bash scripts/verify-studio-artifacts.sh dist
|
||||
|
||||
# The same minisign key the desktop line uses, because the public key that
|
||||
# verifies it is compiled into the client (update.PublicKey).
|
||||
@@ -1182,8 +1190,8 @@ jobs:
|
||||
fi
|
||||
{
|
||||
printf '%s\n\n' '---'
|
||||
printf "**macOS** 已用 Developer ID 签名并公证,打开时不需要清除隔离属性。%s**Linux** 从 \`.deb\` 安装。\n\n" "$windows"
|
||||
printf '%s\n' '三个平台都能自更新:Linux 走自己的包,Windows 运行下一个安装器,macOS 替换自身 bundle。'
|
||||
printf "**macOS** 已用 Developer ID 签名并公证,打开时不需要清除隔离属性。%s**Linux** Debian 系从 \`.deb\` 安装,Fedora、RHEL、openSUSE 等从 \`.rpm\` 安装(\`sudo dnf install ./ReasonixStudio-linux-amd64.rpm\`)。x86_64 用文件名带 amd64 的包,aarch64(统信 UOS、麒麟等)用带 arm64 的包。\n\n" "$windows"
|
||||
printf "%s\n" "三个平台都能自更新:Linux 的 \`.deb\` 走自己的包(\`.rpm\` 的自更新尚未提供,请手动下载新包),Windows 运行下一个安装器,macOS 替换自身 bundle。"
|
||||
} >> "$notes"
|
||||
assets=()
|
||||
for f in dist/*; do
|
||||
@@ -1403,7 +1411,7 @@ jobs:
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
|
||||
with:
|
||||
node-version: "24"
|
||||
registry-url: https://registry.npmjs.org
|
||||
@@ -1412,7 +1420,7 @@ jobs:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
VERSION: ${{ needs.resolve.outputs.version }}
|
||||
run: node npm/build.mjs "$VERSION" --publish
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
if: needs.resolve.outputs.candidate != 'true'
|
||||
with:
|
||||
name: cli-archives
|
||||
@@ -1466,7 +1474,7 @@ jobs:
|
||||
with:
|
||||
ref: ${{ needs.resolve.outputs.sha }}
|
||||
persist-credentials: false
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: cli-archives
|
||||
path: cli-archives
|
||||
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: Test sweeper
|
||||
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: pnpm
|
||||
@@ -99,7 +99,7 @@ jobs:
|
||||
-c.extraMetadata.version="${{ steps.version.outputs.version }}"
|
||||
sha256sum ../../dist/ReasonixStudio-windows-amd64-installer.exe | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: studio-selftest-windows-${{ steps.version.outputs.version }}
|
||||
path: dist/ReasonixStudio-windows-amd64-installer.exe
|
||||
|
||||
+112
-58
@@ -33,7 +33,7 @@ jobs:
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: pnpm
|
||||
@@ -114,7 +114,7 @@ jobs:
|
||||
run_install: false
|
||||
|
||||
- if: steps.spa.outputs.run == 'true'
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: pnpm
|
||||
@@ -136,12 +136,28 @@ jobs:
|
||||
python3 -m http.server 4399 --directory dist-perf >/dev/null 2>&1 &
|
||||
for _ in $(seq 30); do curl -sf http://localhost:4399/perf.html >/dev/null && break; sleep 1; done
|
||||
node perf/intrinsic.mjs
|
||||
node perf/firstscreen-fonts.mjs
|
||||
node perf/contrast.mjs
|
||||
node perf/stickyfold.mjs
|
||||
node perf/crumb.mjs
|
||||
node perf/hoverradius.mjs
|
||||
node perf/modelrows.mjs
|
||||
node perf/sessionrows.mjs
|
||||
node perf/wheel.mjs
|
||||
node perf/branch-tooltip.mjs
|
||||
node perf/selfcheck.mjs branch-tooltip
|
||||
|
||||
# A pull request with no platform-relevant change (scripts/ci-platform-scope.sh)
|
||||
# moves the Windows and macOS legs onto an ubuntu runner with every step
|
||||
# skipped, so the check names still report without a scarce runner. A push to
|
||||
# studio, or a failed `scope`, runs all three.
|
||||
desktop:
|
||||
name: desktop (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
needs: scope
|
||||
if: always()
|
||||
runs-on: ${{ github.event_name == 'pull_request' && needs.scope.outputs.platform == 'false' && matrix.os != 'ubuntu-22.04' && 'ubuntu-latest' || matrix.os }}
|
||||
env:
|
||||
RUN_STEPS: ${{ github.event_name != 'pull_request' || needs.scope.outputs.platform != 'false' || matrix.os == 'ubuntu-22.04' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -150,9 +166,11 @@ jobs:
|
||||
run:
|
||||
working-directory: desktop
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- if: env.RUN_STEPS == 'true'
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||
- if: env.RUN_STEPS == 'true'
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||
with:
|
||||
go-version-file: desktop/go.mod
|
||||
cache: true
|
||||
@@ -165,28 +183,59 @@ jobs:
|
||||
# carries build dependencies of its own.
|
||||
# Same command as `make studio-test`; this job is already in desktop/.
|
||||
- name: go test
|
||||
if: env.RUN_STEPS == 'true'
|
||||
run: go test ./...
|
||||
|
||||
- if: env.RUN_STEPS == 'true'
|
||||
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
|
||||
with:
|
||||
node-version: "24"
|
||||
|
||||
- name: CUA process and packaging contracts
|
||||
if: env.RUN_STEPS == 'true'
|
||||
timeout-minutes: 2
|
||||
run: node --test --test-name-pattern=CUA electron/test/unit.mjs
|
||||
|
||||
- name: Install Linux native CUA fixture dependencies
|
||||
if: env.RUN_STEPS == 'true' && runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y xvfb openbox dbus-x11 libgtk-3-dev at-spi2-core
|
||||
|
||||
- name: Native CUA application acceptance
|
||||
if: env.RUN_STEPS == 'true'
|
||||
timeout-minutes: 6
|
||||
shell: bash
|
||||
run: |
|
||||
if [ "$RUNNER_OS" = Linux ]; then
|
||||
dbus-run-session -- xvfb-run -a sh -c 'openbox >/tmp/reasonix-openbox.log 2>&1 & export NO_AT_BRIDGE=0 GTK_MODULES=gail:atk-bridge; node electron/test/cua-native.mjs'
|
||||
else
|
||||
node electron/test/cua-native.mjs
|
||||
fi
|
||||
|
||||
# The host sits in the main module and nothing else builds it, so its
|
||||
# dependency closure is checked here: it must stay CGO-free and
|
||||
# platform-neutral or the Electron bundle cannot ship it. Cross-compiling
|
||||
# is host-independent, so once is enough.
|
||||
- name: host stays pure Go and platform-neutral
|
||||
if: runner.os == 'Linux'
|
||||
if: env.RUN_STEPS == 'true' && runner.os == 'Linux'
|
||||
working-directory: .
|
||||
run: |
|
||||
for os in windows linux darwin; do
|
||||
CGO_ENABLED=0 GOOS=$os GOARCH=amd64 go build -o /dev/null ./cmd/reasonix-studio-host
|
||||
for arch in amd64 arm64; do
|
||||
CGO_ENABLED=0 GOOS=$os GOARCH=$arch go build -o /dev/null ./cmd/reasonix-studio-host
|
||||
done
|
||||
done
|
||||
|
||||
# The ARM64 leg queues on a scarcer runner pool, so a pull request runs it
|
||||
# only when it touches what the leg verifies; a push to studio always does.
|
||||
# The macOS and Windows package legs follow `platform`, the same rule as the
|
||||
# desktop matrix above.
|
||||
scope:
|
||||
name: package matrix
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
matrix: ${{ steps.pick.outputs.matrix }}
|
||||
arm64: ${{ steps.pick.outputs.arm64 }}
|
||||
platform: ${{ steps.pick.outputs.platform }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
@@ -198,10 +247,12 @@ jobs:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: |
|
||||
arm64=true
|
||||
platform=true
|
||||
if [ "$EVENT" = pull_request ]; then
|
||||
arm64=false
|
||||
if git diff --name-only "$BASE_SHA" HEAD |
|
||||
grep -qE '^(desktop/electron/|desktop/cmd/studio-manifest/|desktop/go\.(mod|sum)$|cmd/reasonix-studio-host/|internal/platform/(update|appupdate)/|go\.(mod|sum)$|scripts/(windows-signing-lib|verify-windows-package|verify-windows-authenticode|install-smoke)\.ps1$|\.github/workflows/(studio|release-studio)\.yml$)'; then
|
||||
platform=$(git diff -z --name-only --no-renames "$BASE_SHA" HEAD | bash scripts/ci-platform-scope.sh)
|
||||
if git diff --name-only --no-renames "$BASE_SHA" HEAD |
|
||||
grep -qE '^(desktop/electron/|desktop/packaging/|desktop/cmd/update-helper/|desktop/cmd/studio-manifest/|desktop/go\.(mod|sum)$|cmd/reasonix-studio-host/|internal/platform/(update|appupdate)/|go\.(mod|sum)$|scripts/(linux-install-smoke|verify-linux-package|verify-studio-artifacts)\.sh$|scripts/(windows-signing-lib|verify-windows-package|verify-windows-authenticode|install-smoke)\.ps1$|\.github/workflows/(studio|release-studio)\.yml$)'; then
|
||||
arm64=true
|
||||
fi
|
||||
fi
|
||||
@@ -210,13 +261,18 @@ jobs:
|
||||
{"os":"ubuntu-22.04","arch":"amd64","platform":"linux"},
|
||||
{"os":"windows-latest","arch":"amd64","platform":"windows","ebarch":"x64"}
|
||||
]'
|
||||
if [ "$arm64" = true ]; then
|
||||
legs="$(jq -c '. + [{"os":"windows-11-arm","arch":"arm64","platform":"windows","ebarch":"arm64"}]' <<<"$legs")"
|
||||
if [ "$platform" = false ]; then
|
||||
legs="$(jq -c 'map(select(.platform == "linux"))' <<<"$legs")"
|
||||
elif [ "$arm64" = true ]; then
|
||||
legs="$(jq -c '. + [{"os":"windows-11-arm","arch":"arm64","platform":"windows","ebarch":"arm64"},{"os":"ubuntu-22.04-arm","arch":"arm64","platform":"linux"}]' <<<"$legs")"
|
||||
else
|
||||
legs="$(jq -c . <<<"$legs")"
|
||||
fi
|
||||
echo "arm64=$arm64" >> "$GITHUB_OUTPUT"
|
||||
echo "matrix={\"include\":$legs}" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "arm64=$arm64"
|
||||
echo "platform=$platform"
|
||||
echo "matrix={\"include\":$legs}"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
# The packaging contract, on real artifacts rather than on the config that
|
||||
# describes them. Unsigned on purpose: signing and notarization are a
|
||||
@@ -248,16 +304,26 @@ jobs:
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: pnpm
|
||||
cache-dependency-path: desktop/electron/pnpm-lock.yaml
|
||||
|
||||
# electron-builder shells out to dpkg-deb through fakeroot for the .deb.
|
||||
# electron-builder shells out to dpkg-deb through fakeroot for the .deb and
|
||||
# to rpmbuild for the .rpm.
|
||||
- name: Install Linux packaging deps
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y fakeroot
|
||||
run: sudo apt-get update && sudo apt-get install -y fakeroot rpm cpio xvfb xauth policykit-1
|
||||
|
||||
# The fpm electron-builder carries is an x86_64 program, so on an ARM64
|
||||
# runner the .deb and .rpm are built with one installed from RubyGems.
|
||||
- name: Use a native fpm on ARM64 Linux
|
||||
if: runner.os == 'Linux' && runner.arch == 'ARM64'
|
||||
run: |
|
||||
sudo apt-get install -y ruby ruby-dev build-essential
|
||||
sudo gem install --no-document fpm -v 1.16.0
|
||||
echo "USE_SYSTEM_FPM=true" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build the SPA
|
||||
working-directory: desktop/frontend-next
|
||||
@@ -270,7 +336,8 @@ jobs:
|
||||
working-directory: desktop/electron
|
||||
env:
|
||||
REASONIX_GOARCH: ${{ matrix.platform == 'windows' && matrix.arch || '' }}
|
||||
run: pnpm install --frozen-lockfile && pnpm run build:host
|
||||
REASONIX_VERSION: v0.0.0-ci
|
||||
run: pnpm install --frozen-lockfile && pnpm test && pnpm run build:host
|
||||
|
||||
# The .deb's absolute-path payload. A no-op off Linux, so it is not
|
||||
# guarded here: the one place it must not be forgotten is the one place
|
||||
@@ -286,7 +353,7 @@ jobs:
|
||||
env:
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||
ELECTRON_BUILDER_7Z_FILTER: ${{ matrix.platform == 'windows' && matrix.arch == 'arm64' && 'BCJ' || '' }}
|
||||
run: pnpm run build:dist ${{ matrix.ebarch && format('--{0}', matrix.ebarch) || '' }}
|
||||
run: pnpm run build:dist ${{ matrix.ebarch && format('--{0}', matrix.ebarch) || '' }} -c.extraMetadata.version=0.0.0-ci
|
||||
|
||||
# The release vocabulary is GOARCH. electron-builder's own is not, and the
|
||||
# rename that reconciles them runs here rather than in the manifest.
|
||||
@@ -312,6 +379,27 @@ jobs:
|
||||
test -n "$dir"
|
||||
node desktop/electron/packaging/pe.js "$dir" "${{ matrix.arch }}"
|
||||
|
||||
# An update starts the installer with --updated, and that is what keeps the
|
||||
# shortcuts (and the taskbar pin hanging off them) from being deleted and
|
||||
# recreated. Installed twice over itself the way an update does it, the
|
||||
# desktop shortcut has to still be the same file, pointing at a path with
|
||||
# no version in it, or every update costs the user their pin.
|
||||
- name: An update keeps the shortcuts and they name no version
|
||||
if: matrix.platform == 'windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$installer = (Get-ChildItem dist -Filter 'ReasonixStudio-windows-*-installer.exe' | Select-Object -First 1).FullName
|
||||
$dir = Join-Path $env:RUNNER_TEMP 'rx-update-guard'
|
||||
Start-Process $installer -ArgumentList '/S', "/D=$dir" -Wait
|
||||
$lnk = Join-Path ([Environment]::GetFolderPath('Desktop')) 'Reasonix Studio.lnk'
|
||||
$target = (New-Object -ComObject WScript.Shell).CreateShortcut($lnk).TargetPath
|
||||
if ($target -ne (Join-Path $dir 'Reasonix Studio.exe')) { throw "the desktop shortcut targets $target, not the stable executable" }
|
||||
$mark = [datetime]'2020-01-01'
|
||||
(Get-Item $lnk).LastWriteTime = $mark
|
||||
Start-Process $installer -ArgumentList '/S', '--updated', "/D=$dir" -Wait
|
||||
if ((Get-Item $lnk).LastWriteTime -ne $mark) { throw 'an update rewrote the desktop shortcut' }
|
||||
Start-Process (Join-Path $dir 'Uninstall Reasonix Studio.exe') -ArgumentList '/S', '/currentuser' -Wait
|
||||
|
||||
# The release packages a signed copy of the bundle and then reads both
|
||||
# packages back against it. The same recipe on an unsigned copy, so the
|
||||
# packaging contract (the installer's one application archive, the pinned
|
||||
@@ -367,7 +455,7 @@ jobs:
|
||||
|
||||
- name: Keep the arm64 installer for the x64 refusal check
|
||||
if: matrix.platform == 'windows' && matrix.arch == 'arm64'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: studio-arm64-installer-ci
|
||||
path: dist/ReasonixStudio-windows-arm64-installer.exe
|
||||
@@ -400,47 +488,13 @@ jobs:
|
||||
echo "page: ${page:-<missing>}"
|
||||
test -n "$host" -a -n "$page"
|
||||
|
||||
# Read off the package rather than off the config that describes it. Both
|
||||
# of these went wrong the same way once: electron-builder names a package
|
||||
# after package.json unless told, and fpm records the owner it finds on
|
||||
# disk. Either one leaves a Studio that cannot update itself on Linux and
|
||||
# says nothing about it until a user asks.
|
||||
- name: The .deb is Studio's own package and carries what updates it
|
||||
- name: Linux packages carry their declared architecture and update payload
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
deb=$(ls dist/ReasonixStudio-linux-*.deb)
|
||||
name=$(dpkg-deb -f "$deb" Package)
|
||||
if [ "$name" != "reasonix-studio" ]; then
|
||||
echo "::error::the package is $name; dpkg holds that beside the installed reasonix-studio instead of upgrading it, and update.StudioLine asks for it by name"
|
||||
exit 1
|
||||
fi
|
||||
dpkg-deb -c "$deb" > contents.txt
|
||||
# The path is the polkit action's exec.path annotation, so it is the
|
||||
# only one authorization is granted for; root because polkit will not
|
||||
# run a helper the invoking user could have written. Either spelling
|
||||
# of root: dpkg-deb prints the numeric ids it cannot map, and which
|
||||
# one it prints is a property of the machine reading the package
|
||||
# rather than of the package.
|
||||
if ! grep -qE '^-rwxr-xr-x (root/root|0/0) +[0-9]+ .* \./usr/lib/reasonix-studio/reasonix-studio-update-helper$' contents.txt; then
|
||||
echo "::error::no root-owned executable update helper at the path the polkit action names"
|
||||
grep reasonix-studio-update-helper contents.txt || echo "(not in the package at all)"
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -q './usr/share/polkit-1/actions/io.reasonix.studio.update.policy$' contents.txt; then
|
||||
echo "::error::the polkit action is not in the package, so pkexec has nothing to authorize against"
|
||||
exit 1
|
||||
fi
|
||||
run: bash scripts/verify-linux-package.sh "dist/ReasonixStudio-linux-${{ matrix.arch }}.deb" "dist/ReasonixStudio-linux-${{ matrix.arch }}.rpm" "${{ matrix.arch }}"
|
||||
|
||||
# The Electron shell sets the package's glibc floor; a Go binary linked
|
||||
# with cgo would raise it to the runner's own.
|
||||
- name: The .deb's Go binaries are statically linked
|
||||
- name: The Linux package installs, runs, reinstalls and uninstalls
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
deb=$(ls dist/ReasonixStudio-linux-*.deb)
|
||||
dpkg-deb -x "$deb" "$RUNNER_TEMP/deb"
|
||||
host=$(find "$RUNNER_TEMP/deb" -type f -path '*/resources/bin/reasonix-studio-host')
|
||||
test -n "$host"
|
||||
node desktop/electron/packaging/elf.js "$host" "$RUNNER_TEMP/deb/usr/lib/reasonix-studio/reasonix-studio-update-helper"
|
||||
run: bash scripts/linux-install-smoke.sh "dist/ReasonixStudio-linux-${{ matrix.arch }}.deb" "${{ matrix.arch }}" 0.0.0-ci
|
||||
|
||||
# Recorded, not asserted: the real bundle layout, so later work argues
|
||||
# from what electron-builder produced rather than from its documentation.
|
||||
@@ -478,7 +532,7 @@ jobs:
|
||||
if: needs.scope.outputs.arm64 == 'true'
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: studio-arm64-installer-ci
|
||||
path: installer
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: Update README contributor tables
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: Test chart generator
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
name: Withdraw a Studio version
|
||||
|
||||
# Pulls a published Studio release out of the update catalog (or puts it back),
|
||||
# so installs stop being offered it without waiting for a new release. Only the
|
||||
# catalog changes: the release's files stay where they are, and a withdrawn
|
||||
# release can be restored with the same run.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version to withdraw or restore, for example 2.36.0"
|
||||
required: true
|
||||
type: string
|
||||
reason:
|
||||
description: "Why it is withdrawn (shown to users on that version); ignored when restoring"
|
||||
required: false
|
||||
type: string
|
||||
restore:
|
||||
description: "Put a withdrawn version back instead of withdrawing it"
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
concurrency:
|
||||
group: studio-catalog
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
withdraw:
|
||||
name: ${{ inputs.restore && 'restore' || 'withdraw' }} ${{ inputs.version }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
environment: studio-release
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: studio
|
||||
persist-credentials: false
|
||||
|
||||
- name: Update the Studio catalog
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
REASON: ${{ inputs.reason }}
|
||||
RESTORE: ${{ inputs.restore }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }}
|
||||
run: |
|
||||
if [ "$HAS_R2" != "true" ]; then
|
||||
echo "::error::R2 credentials are not configured, so the catalog cannot be changed"
|
||||
exit 1
|
||||
fi
|
||||
if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then
|
||||
echo "::error::version must look like 2.36.0"
|
||||
exit 1
|
||||
fi
|
||||
aws configure set aws_access_key_id "${{ secrets.R2_ACCESS_KEY_ID }}"
|
||||
aws configure set aws_secret_access_key "${{ secrets.R2_SECRET_ACCESS_KEY }}"
|
||||
aws configure set default.region auto
|
||||
endpoint="https://${{ secrets.R2_ACCOUNT_ID }}.r2.cloudflarestorage.com"
|
||||
at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
current=/tmp/studio-versions-current.json
|
||||
next=/tmp/studio-versions-next.json
|
||||
aws s3 cp "s3://${R2_BUCKET}/studio/versions.json" "$current" --endpoint-url "$endpoint"
|
||||
if [ "$RESTORE" = "true" ]; then
|
||||
bash scripts/withdraw-version.sh "$current" --restore "$VERSION" "$at" > "$next"
|
||||
jq -e --arg v "$VERSION" '.versions | map(select((.version | ltrimstr("v")) == $v)) | length == 1' "$next" >/dev/null
|
||||
else
|
||||
bash scripts/withdraw-version.sh "$current" "$VERSION" "$REASON" "$at" > "$next"
|
||||
jq -e --arg v "$VERSION" '(.versions | map(select((.version | ltrimstr("v")) == $v)) | length == 0) and (.withdrawn | map(select((.version | ltrimstr("v")) == $v)) | length == 1)' "$next" >/dev/null
|
||||
fi
|
||||
aws s3 cp "$next" "s3://${R2_BUCKET}/studio/versions.json" \
|
||||
--endpoint-url "$endpoint" \
|
||||
--content-type "application/json; charset=utf-8" \
|
||||
--cache-control "public, max-age=300, stale-if-error=86400"
|
||||
echo "Studio catalog updated for $VERSION (restore=$RESTORE)"
|
||||
|
||||
- name: Keep the GitHub release off the latest slot
|
||||
if: ${{ !inputs.restore }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: gh release edit "studio-v${VERSION}" --prerelease --latest=false --repo "$GITHUB_REPOSITORY"
|
||||
@@ -59,7 +59,7 @@
|
||||
|
||||
## ❤️Sponsor
|
||||
|
||||
<table><tr><td width="180"><a href="https://www.packyapi.ai/register?aff=rIbQ" target="_blank" rel="sponsored noopener noreferrer"><picture><source media="(prefers-color-scheme: dark)" srcset="./.github/sponsor/packycode-dark.svg"><img src="./.github/sponsor/packycode-light.svg" alt="PackyCode" width="150"></picture></a></td><td>Thanks to PackyCode for sponsoring this project! Access leading AI models through PackyCode with one API endpoint and one API key, with automatic failover and dedicated high-speed routes for Codex and Claude Code.</td></tr></table>
|
||||
<table><tr><td width="180" align="center" valign="middle"><a href="https://www.rightapi.ai/register?aff=e083bf73" target="_blank" rel="sponsored noopener noreferrer"><img src="./.github/sponsor/rightcode.png" alt="Right Code" width="64"></a></td><td valign="middle">Thanks to Right Code for sponsoring this project! Right Code reliably provides routing services for models such as Claude Code, Codex, and Gemini, with both pay-as-you-go and monthly subscription billing options available. Invoices are available upon top-up, and enterprise and team users can receive dedicated one-on-one support. Register via <a href="https://www.rightapi.ai/register?aff=e083bf73" target="_blank" rel="sponsored noopener noreferrer">this link</a>.</td></tr><tr><td width="180" align="center" valign="middle"><a href="https://www.packyapi.ai/register?aff=rIbQ" target="_blank" rel="sponsored noopener noreferrer"><picture><source media="(prefers-color-scheme: dark)" srcset="./.github/sponsor/packycode-dark.svg"><img src="./.github/sponsor/packycode-light.svg" alt="PackyCode" width="80"></picture></a></td><td valign="middle">Thanks to PackyCode for sponsoring this project! Access leading AI models through PackyCode with one API endpoint and one API key, with automatic failover and dedicated high-speed routes for Codex and Claude Code. Register via <a href="https://www.packyapi.ai/register?aff=rIbQ" target="_blank" rel="sponsored noopener noreferrer">this link</a>.</td></tr></table>
|
||||
|
||||
## What it is
|
||||
|
||||
@@ -151,7 +151,7 @@ pre-releases):
|
||||
| --- | --- | --- |
|
||||
| macOS | `.dmg` or `.zip` | Apple Silicon (`arm64`) / Intel (`amd64`) |
|
||||
| Windows | Installer `-installer.exe` or portable `.zip` | x64 |
|
||||
| Linux | `.deb` | x64 |
|
||||
| Linux | `.deb` or `.rpm` | x64 (`amd64`) / ARM64 (`arm64`, aarch64) |
|
||||
|
||||
Every package ships with a `.minisig` signature, and the release carries
|
||||
`SHA256SUMS`. Once installed, Studio updates itself in place.
|
||||
@@ -278,9 +278,9 @@ A small list of folks whose work has shaped Reasonix the most — the current to
|
||||
| --- | --- | --- | --- |
|
||||
| [**SivanCola**](https://github.com/SivanCola) | [**KHG420**](https://github.com/KHG420) | [**ttmouse**](https://github.com/ttmouse) | [**lifu963**](https://github.com/lifu963) |
|
||||
| [**HUQIANTAO**](https://github.com/HUQIANTAO) | [**Harbor404**](https://github.com/Harbor404) | [**GTC2080**](https://github.com/GTC2080) | [**mchenziyi**](https://github.com/mchenziyi) |
|
||||
| [**Li-Charles-One**](https://github.com/Li-Charles-One) | [**light-front-theory**](https://github.com/light-front-theory) | [**eghrhegpe**](https://github.com/eghrhegpe) | [**Bernardxu123**](https://github.com/Bernardxu123) |
|
||||
| [**HaoyueQin**](https://github.com/HaoyueQin) | [**chuiwenwei**](https://github.com/chuiwenwei) | [**BuGlessRB**](https://github.com/BuGlessRB) | [**CVEngineer66**](https://github.com/CVEngineer66) |
|
||||
| [**JesonChou**](https://github.com/JesonChou) | [**lanshi17**](https://github.com/lanshi17) | [**par73e**](https://github.com/par73e) | [**SuMuxi66**](https://github.com/SuMuxi66) |
|
||||
| [**Li-Charles-One**](https://github.com/Li-Charles-One) | [**light-front-theory**](https://github.com/light-front-theory) | [**XTLine**](https://github.com/XTLine) | [**eghrhegpe**](https://github.com/eghrhegpe) |
|
||||
| [**noreply**](https://github.com/noreply) | [**chuiwenwei**](https://github.com/chuiwenwei) | [**xxoingr**](https://github.com/xxoingr) | [**Bernardxu123**](https://github.com/Bernardxu123) |
|
||||
| [**HaoyueQin**](https://github.com/HaoyueQin) | [**BuGlessRB**](https://github.com/BuGlessRB) | [**CVEngineer66**](https://github.com/CVEngineer66) | [**JesonChou**](https://github.com/JesonChou) |
|
||||
<!-- reasonix-top-contributors:end -->
|
||||
|
||||
Special thanks to [**Bernardxu123**](https://github.com/Bernardxu123) for designing the project logo and intro video.
|
||||
|
||||
+5
-5
@@ -57,7 +57,7 @@
|
||||
|
||||
## ❤️赞助商
|
||||
|
||||
<table><tr><td width="180"><a href="https://www.packyapi.ai/register?aff=rIbQ" target="_blank" rel="sponsored noopener noreferrer"><picture><source media="(prefers-color-scheme: dark)" srcset="./.github/sponsor/packycode-dark.svg"><img src="./.github/sponsor/packycode-light.svg" alt="PackyCode" width="150"></picture></a></td><td>感谢 PackyCode 赞助了本项目!PackyCode 是一家稳定、高效的 API 中转服务商,统一域名、统一密钥、智能容灾切换。</td></tr></table>
|
||||
<table><tr><td width="180" align="center" valign="middle"><a href="https://www.rightapi.ai/register?aff=e083bf73" target="_blank" rel="sponsored noopener noreferrer"><img src="./.github/sponsor/rightcode.png" alt="Right Code" width="64"></a></td><td valign="middle">感谢 Right Code 赞助了本项目!Right Code 稳定提供 Claude Code、Codex、Gemini 等模型的中转服务,并可选按量、包月两种计费模式。充值即可开票,企业、团队用户一对一对接。通过<a href="https://www.rightapi.ai/register?aff=e083bf73" target="_blank" rel="sponsored noopener noreferrer">此链接</a>注册。</td></tr><tr><td width="180" align="center" valign="middle"><a href="https://www.packyapi.ai/register?aff=rIbQ" target="_blank" rel="sponsored noopener noreferrer"><picture><source media="(prefers-color-scheme: dark)" srcset="./.github/sponsor/packycode-dark.svg"><img src="./.github/sponsor/packycode-light.svg" alt="PackyCode" width="80"></picture></a></td><td valign="middle">感谢 PackyCode 赞助了本项目!PackyCode 是一家稳定、高效的 API 中转服务商,统一域名、统一密钥、智能容灾切换。通过<a href="https://www.packyapi.ai/register?aff=rIbQ" target="_blank" rel="sponsored noopener noreferrer">此链接</a>注册。</td></tr></table>
|
||||
|
||||
## 它是什么
|
||||
|
||||
@@ -139,7 +139,7 @@ Reasonix 分为两条版本线,调整的原因见[版本路线公告](https://
|
||||
| --- | --- | --- |
|
||||
| macOS | `.dmg` 或 `.zip` | Apple Silicon(`arm64`)/ Intel(`amd64`) |
|
||||
| Windows | 安装器 `-installer.exe` 或便携 `.zip` | x64 |
|
||||
| Linux | `.deb` | x64 |
|
||||
| Linux | `.deb` 或 `.rpm` | x64(`amd64`)/ ARM64(`arm64`,aarch64) |
|
||||
|
||||
每个安装包都附带 `.minisig` 签名,发布页另有 `SHA256SUMS`。安装后 Studio 会在
|
||||
应用内自行更新。
|
||||
@@ -260,9 +260,9 @@ CLI 进阶用法和详细配置见 **[CLI 命令参考](./docs/CLI.zh-CN.md)**
|
||||
| --- | --- | --- | --- |
|
||||
| [**SivanCola**](https://github.com/SivanCola) | [**KHG420**](https://github.com/KHG420) | [**ttmouse**](https://github.com/ttmouse) | [**lifu963**](https://github.com/lifu963) |
|
||||
| [**HUQIANTAO**](https://github.com/HUQIANTAO) | [**Harbor404**](https://github.com/Harbor404) | [**GTC2080**](https://github.com/GTC2080) | [**mchenziyi**](https://github.com/mchenziyi) |
|
||||
| [**Li-Charles-One**](https://github.com/Li-Charles-One) | [**light-front-theory**](https://github.com/light-front-theory) | [**eghrhegpe**](https://github.com/eghrhegpe) | [**Bernardxu123**](https://github.com/Bernardxu123) |
|
||||
| [**HaoyueQin**](https://github.com/HaoyueQin) | [**chuiwenwei**](https://github.com/chuiwenwei) | [**BuGlessRB**](https://github.com/BuGlessRB) | [**CVEngineer66**](https://github.com/CVEngineer66) |
|
||||
| [**JesonChou**](https://github.com/JesonChou) | [**lanshi17**](https://github.com/lanshi17) | [**par73e**](https://github.com/par73e) | [**SuMuxi66**](https://github.com/SuMuxi66) |
|
||||
| [**Li-Charles-One**](https://github.com/Li-Charles-One) | [**light-front-theory**](https://github.com/light-front-theory) | [**XTLine**](https://github.com/XTLine) | [**eghrhegpe**](https://github.com/eghrhegpe) |
|
||||
| [**noreply**](https://github.com/noreply) | [**chuiwenwei**](https://github.com/chuiwenwei) | [**xxoingr**](https://github.com/xxoingr) | [**Bernardxu123**](https://github.com/Bernardxu123) |
|
||||
| [**HaoyueQin**](https://github.com/HaoyueQin) | [**BuGlessRB**](https://github.com/BuGlessRB) | [**CVEngineer66**](https://github.com/CVEngineer66) | [**JesonChou**](https://github.com/JesonChou) |
|
||||
<!-- reasonix-top-contributors:end -->
|
||||
|
||||
特别感谢 [**Bernardxu123**](https://github.com/Bernardxu123) 设计的项目 logo和开场视频。
|
||||
|
||||
+3
-2
@@ -369,6 +369,7 @@ out of how a path is spelled, which cannot tell `internal/auth` from
|
||||
- sensitive: internal/ext/pluginpkg/**
|
||||
- sensitive: internal/base/netclient/**
|
||||
- sensitive: internal/safety/redirectguard/**
|
||||
- sensitive: internal/safety/endpointclient/**
|
||||
- sensitive: internal/safety/egress/**
|
||||
- sensitive: internal/platform/browser/**
|
||||
- sensitive: internal/platform/computer/**
|
||||
@@ -402,8 +403,8 @@ observation, never by a check that ran afterwards.
|
||||
What follows bounds Observable today. It is not a restatement of the invariant:
|
||||
moving a number here must not move the promise.
|
||||
|
||||
- `scanWorkspace` walks the workspace recursively, skips VCS store dirs, and is
|
||||
`complete` only below 50k files. Over that, or past a walk error, it
|
||||
- `scanWorkspace` walks the workspace and every granted extra root recursively,
|
||||
skips VCS store dirs, and is `complete` only below 50k files in total. Over that, or past a walk error, it
|
||||
establishes nothing and the mutation stays unproven.
|
||||
- Symlinks are recorded by `Lstat`, so a write through one lands outside what
|
||||
the walk compares. Confining the resolved target is the sandbox's job.
|
||||
|
||||
@@ -420,7 +420,7 @@ own outcome).
|
||||
| `-suite` | `benchmarks/e2e` | Suite root (must contain `tasks/<id>/`). |
|
||||
| `-task` | *(all)* | Suite mode: run only these comma-separated task IDs (e.g. `-task fix-add-bug`); unknown IDs fail with the available list. |
|
||||
| `-attempts` | `1` | Suite and diff modes: retry a task until an attempt passes, up to N; enables the `Pass@≤N` KPI, and TTCS charges a retried solve with its failed attempts' wall. |
|
||||
| `-trials` | `1` | Suite mode: run every task N times and keep every outcome. Adds a **Reliability** line: `pass^N` (tasks that passed every trial), `pass@N` (any trial), mean `pass@1` over all trials, and the flaky tasks by name. `Pass@≤N` asks whether the agent can solve a task; `pass^N` asks whether it does so every time. Cannot be combined with `-attempts`. |
|
||||
| `-trials` | `1` | Suite mode: run every task N times and keep every outcome. Adds a **Reliability** line: `pass^N` (complete tasks that passed every trial) and `pass@N` (complete tasks with any pass), both over the tasks that ran all N trials only (`n/a` when none did), mean `pass@1` over every graded trial, and the flaky tasks by name; a task the token budget cut short is listed on an **Incomplete** line and is in neither. `Pass@≤N` asks whether the agent can solve a task; `pass^N` asks whether it does so every time. Cannot be combined with `-attempts`. |
|
||||
| `-tape-record DIR` | — | Suite mode, with `-meter`: record every provider exchange at the meter, one tape per task and trial (`DIR/<task>/trial-N/0001.request.json`, `.response.json`, `.response.body`). Taped runs use a fixed workdir path and pin the seeded files' times, because both reach the model through the workspace block and file listings. |
|
||||
| `-tape-replay DIR` | — | Suite mode, with `-meter`: answer each run from its tape instead of the provider, and add a **Replay** line naming the first request whose body differs from the recorded one, down to the message (`request 2, message 4 (tool)`), the tool list, or a field. The diverging request is kept as `NNNN.replayed.json` beside the recorded one. A kernel change that should not alter what the model is asked replays with every request matched. |
|
||||
| `-bin` | `reasonix` | Path to the reasonix binary. |
|
||||
|
||||
@@ -22,17 +22,28 @@ addresses by hand at roughly nine times the page-in cost (139 vs 1292
|
||||
tokens/task). Measured after three separate leaks were closed, so the numbers
|
||||
are from a clean environment.
|
||||
|
||||
**Fold index marginal utility: unresolved.** Both the positive and the negative
|
||||
estimates are invalidated. The first Stage 2 ran in a contaminated environment.
|
||||
The clean paired batch found a consistent effect (six of six tasks searched less
|
||||
and paged in less with the cue visible), and the same-batch dose-response did
|
||||
not reproduce it (`boundary-aligned 0/4`, recall-token delta reversing sign).
|
||||
The most likely reason is that two of the six index tasks carry heavy-tail
|
||||
stopping behaviour that swamps the effect being measured.
|
||||
**Fold index marginal utility: direction reproduced, size still conditional.** On the v2
|
||||
substrate the direction holds in six of six tasks and in the pooled paired batch: with the
|
||||
cue visible the index addresses the target, so the same task is searched less and pages in
|
||||
less (0.67 searches and 70 recall tokens per task, against 1.50 and 120 with the cue
|
||||
absent).
|
||||
|
||||
Existing evidence is not sufficient to tune the shipped 1% budget in either
|
||||
direction. Reopening this wants a new corpus, not more samples: that substrate
|
||||
now exists, two qualifying tasks per cue tier — see "Index corpus v2" below.
|
||||
A size is visible but is not yet quotable. On the four tasks whose absent-cue arm stayed
|
||||
out of a loop, the index cost 64,952 prompt tokens against 115,551 without, 44% less
|
||||
(`i106-budgetseal` -35,147, `i70-ingestfloor` -8,222, `i37-dispatchlag` -7,599, and
|
||||
`i112-lagquota` +369, the prefix rent a task that never searches pays). That is a
|
||||
**post-hoc subset**: `i118-fencelag` and `i47-tenantlease` are excluded because their
|
||||
absent-cue arm looped in this batch (five and three retrieval rounds, three escapes each;
|
||||
together 80% of the absent-cue total), and the qualification above does not screen for
|
||||
stopping behaviour — holding on both boundary arms across three passes shows a stable cue
|
||||
boundary, not a stopping arm. Quoting 44% as the index's effect would mean choosing the
|
||||
sample after seeing the result; a batch screened on stopping stability is what would make
|
||||
a size quotable, and one arm also reverses either way (`i70-ingestfloor` default, +11,135
|
||||
prompt tokens over three escapes).
|
||||
|
||||
Batch facts, because the counts differ: 36 runs were executed (12 paired, 24 index), and
|
||||
the token totals here cover the 24-run index batch — the paired batch ran before the
|
||||
per-run usage line existed.
|
||||
|
||||
**Things that turned out not to be problems**, each after being measured rather
|
||||
than argued about:
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// batchInfo is what the mode itself knows before any run happens. Everything
|
||||
// else in the manifest is derived from the runs that were written, so a figure
|
||||
// in it cannot drift from the artifact beside it.
|
||||
type batchInfo struct {
|
||||
Mode string
|
||||
Planned int
|
||||
Started time.Time
|
||||
Dry bool
|
||||
// Repo is the checkout the batch ran from, captured before the harness
|
||||
// isolates its workspace: git cannot describe a directory that is gone.
|
||||
Repo string
|
||||
}
|
||||
|
||||
// batchManifest is written beside results.jsonl so a later reader does not have
|
||||
// to reconstruct what the batch was from a terminal scrollback.
|
||||
type batchManifest struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
Mode string `json:"mode"`
|
||||
StartedAt string `json:"started_at"`
|
||||
FinishedAt string `json:"finished_at"`
|
||||
GitCommit string `json:"git_commit"`
|
||||
GitDirty *bool `json:"git_dirty,omitempty"`
|
||||
Model string `json:"model"`
|
||||
Window int `json:"window"`
|
||||
Generations int `json:"generations"`
|
||||
TaskIDs []string `json:"task_ids"`
|
||||
Arms []string `json:"arms"`
|
||||
PlannedRuns int `json:"planned_runs"`
|
||||
CompletedRuns int `json:"completed_runs"`
|
||||
// Usage totals are absent when nothing reported any: an unmeasured cost must
|
||||
// not read as a measured zero. UsageReportedRuns stays, because zero there
|
||||
// is a fact about the batch.
|
||||
UsageReportedRuns int `json:"usage_reported_runs"`
|
||||
UsageReportedRequests int `json:"usage_reported_requests,omitempty"`
|
||||
PromptTokens int `json:"prompt_tokens,omitempty"`
|
||||
CompletionTokens int `json:"completion_tokens,omitempty"`
|
||||
CacheHitTokens int `json:"cache_hit_tokens,omitempty"`
|
||||
CacheMissTokens int `json:"cache_miss_tokens,omitempty"`
|
||||
UsageEstimated bool `json:"usage_estimated,omitempty"`
|
||||
ResultsSHA256 string `json:"results_sha256"`
|
||||
TrajectoriesSHA256 string `json:"trajectories_sha256"`
|
||||
}
|
||||
|
||||
// gitCommitAndDirty reports the checkout the batch ran from. Unknown is written
|
||||
// rather than omitted, so an absent field is never mistaken for a clean tree.
|
||||
func gitCommitAndDirty(repo string) (commit string, dirty *bool) {
|
||||
commit = "unknown"
|
||||
if repo == "" {
|
||||
return commit, nil
|
||||
}
|
||||
in := func(args ...string) ([]byte, error) {
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = repo
|
||||
return cmd.Output()
|
||||
}
|
||||
if out, err := in("rev-parse", "--short", "HEAD"); err == nil {
|
||||
commit = strings.TrimSpace(string(out))
|
||||
}
|
||||
if out, err := in("status", "--porcelain"); err == nil {
|
||||
state := strings.TrimSpace(string(out)) != ""
|
||||
dirty = &state
|
||||
}
|
||||
return commit, dirty
|
||||
}
|
||||
|
||||
func fileSHA256(path string) (string, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%x", sha256.Sum256(data)), nil
|
||||
}
|
||||
|
||||
func sortedKeys(set map[string]bool) []string {
|
||||
out := make([]string, 0, len(set))
|
||||
for k := range set {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// writeBatchManifest records the batch beside the runs it describes. It runs
|
||||
// last, so its digests are of what is already on disk.
|
||||
func writeBatchManifest(root string, info batchInfo, all []contextMetrics) error {
|
||||
ids, arms := map[string]bool{}, map[string]bool{}
|
||||
var prompt, completion, cacheHit, cacheMiss, requests, reported int
|
||||
estimated := false
|
||||
for _, m := range all {
|
||||
ids[m.Task] = true
|
||||
arms[m.Arm] = true
|
||||
if m.UsageReportedRequests > 0 {
|
||||
reported++
|
||||
requests += m.UsageReportedRequests
|
||||
prompt += m.PromptTokens
|
||||
completion += m.CompletionTokens
|
||||
cacheHit += m.CacheHitTokens
|
||||
cacheMiss += m.CacheMissTokens
|
||||
estimated = estimated || m.UsageEstimated
|
||||
}
|
||||
}
|
||||
commit, dirty := gitCommitAndDirty(info.Repo)
|
||||
model := runModel
|
||||
if info.Dry {
|
||||
model = "scripted"
|
||||
}
|
||||
manifest := batchManifest{
|
||||
SchemaVersion: 1, Mode: info.Mode,
|
||||
StartedAt: info.Started.UTC().Format(time.RFC3339),
|
||||
FinishedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
GitCommit: commit, GitDirty: dirty,
|
||||
Model: model, Window: fixtureWindow, Generations: fixtureGenerations,
|
||||
TaskIDs: sortedKeys(ids), Arms: sortedKeys(arms),
|
||||
PlannedRuns: info.Planned, CompletedRuns: len(all),
|
||||
UsageReportedRuns: reported, UsageReportedRequests: requests,
|
||||
PromptTokens: prompt, CompletionTokens: completion,
|
||||
CacheHitTokens: cacheHit, CacheMissTokens: cacheMiss, UsageEstimated: estimated,
|
||||
}
|
||||
var err error
|
||||
if manifest.ResultsSHA256, err = fileSHA256(filepath.Join(root, "results.jsonl")); err != nil {
|
||||
return err
|
||||
}
|
||||
if manifest.TrajectoriesSHA256, err = fileSHA256(filepath.Join(root, "trajectories.jsonl")); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(manifest, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(filepath.Join(root, "batch.json"), append(data, '\n'), 0o644)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func seedArtifacts(t *testing.T, root string) {
|
||||
t.Helper()
|
||||
for _, name := range []string{"results.jsonl", "trajectories.jsonl"} {
|
||||
if err := os.WriteFile(filepath.Join(root, name), []byte("{}\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func readManifest(t *testing.T, root string) (batchManifest, string) {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(filepath.Join(root, "batch.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got batchManifest
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return got, string(data)
|
||||
}
|
||||
|
||||
func TestBatchManifestRecordsWhatTheBatchWas(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
seedArtifacts(t, root)
|
||||
all := []contextMetrics{
|
||||
{Task: "b", Arm: "index-off"},
|
||||
{Task: "a", Arm: "index-off"},
|
||||
{Task: "a", Arm: "index-half", UsageReportedRequests: 2, PromptTokens: 100,
|
||||
CompletionTokens: 10, CacheHitTokens: 60, CacheMissTokens: 40},
|
||||
}
|
||||
if err := writeBatchManifest(root, batchInfo{Mode: "run-index", Planned: 4, Started: time.Now().Add(-time.Minute)}, all); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := readManifest(t, root)
|
||||
if got.SchemaVersion != 1 || got.Mode != "run-index" || got.PlannedRuns != 4 || got.CompletedRuns != 3 {
|
||||
t.Fatalf("manifest header = %+v", got)
|
||||
}
|
||||
if strings.Join(got.TaskIDs, ",") != "a,b" || strings.Join(got.Arms, ",") != "index-half,index-off" {
|
||||
t.Fatalf("ids/arms = %v / %v, want sorted and unique", got.TaskIDs, got.Arms)
|
||||
}
|
||||
if got.UsageReportedRuns != 1 || got.UsageReportedRequests != 2 || got.PromptTokens != 100 ||
|
||||
got.CompletionTokens != 10 || got.CacheHitTokens != 60 || got.CacheMissTokens != 40 {
|
||||
t.Fatalf("usage totals = %+v", got)
|
||||
}
|
||||
// No repo means git could not describe the checkout: unknown commit, and no
|
||||
// dirty flag at all, because "clean" is a claim this batch cannot make.
|
||||
if got.GitCommit != "unknown" || got.GitDirty != nil {
|
||||
t.Fatalf("git state = %q / %v, want unknown and no dirty flag", got.GitCommit, got.GitDirty)
|
||||
}
|
||||
want := fmt.Sprintf("%x", sha256.Sum256([]byte("{}\n")))
|
||||
if got.ResultsSHA256 != want || got.TrajectoriesSHA256 != want {
|
||||
t.Fatalf("digests = %q / %q, want %q", got.ResultsSHA256, got.TrajectoriesSHA256, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A dry batch pays nothing, so its cost fields must be absent rather than zero:
|
||||
// an unmeasured spend must not read as a measured one.
|
||||
func TestBatchManifestLeavesUnreportedUsageAbsent(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
seedArtifacts(t, root)
|
||||
all := []contextMetrics{{Task: "a", Arm: "index-off"}, {Task: "a", Arm: "index-half"}}
|
||||
if err := writeBatchManifest(root, batchInfo{Mode: "run-index", Planned: 2, Started: time.Now(), Dry: true}, all); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, raw := readManifest(t, root)
|
||||
if got.UsageReportedRuns != 0 {
|
||||
t.Fatalf("usage_reported_runs = %d, want 0 reported", got.UsageReportedRuns)
|
||||
}
|
||||
if got.Model != "scripted" {
|
||||
t.Fatalf("dry batch model = %q, want scripted", got.Model)
|
||||
}
|
||||
for _, absent := range []string{"usage_reported_requests", "prompt_tokens", "completion_tokens", "cache_hit_tokens"} {
|
||||
if strings.Contains(raw, absent) {
|
||||
t.Fatalf("a dry batch wrote %q:\n%s", absent, raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -89,6 +89,15 @@ type contextMetrics struct {
|
||||
// resident prefix; this is what is paid dynamically instead.
|
||||
RecallReturnedTokens int `json:"recall_returned_tokens"`
|
||||
|
||||
// Provider usage as reported on the stream, or empty for a dry run. The count
|
||||
// is completions that reported usage, so it is not a count of attempts.
|
||||
UsageReportedRequests int `json:"usage_reported_requests,omitempty"`
|
||||
PromptTokens int `json:"prompt_tokens,omitempty"`
|
||||
CompletionTokens int `json:"completion_tokens,omitempty"`
|
||||
CacheHitTokens int `json:"cache_hit_tokens,omitempty"`
|
||||
CacheMissTokens int `json:"cache_miss_tokens,omitempty"`
|
||||
UsageEstimated bool `json:"usage_estimated,omitempty"`
|
||||
|
||||
Searches []searchAttempt `json:"searches,omitempty"`
|
||||
// PostHitSearches are queries issued after the target had already been
|
||||
// returned: not a query problem, a result-to-read problem.
|
||||
|
||||
@@ -54,9 +54,9 @@ func main() {
|
||||
case "preflight":
|
||||
os.Exit(runPreflight(tasks, root))
|
||||
case "run-search":
|
||||
os.Exit(runExperiment(experimentSearch, root, *dry, tasks))
|
||||
os.Exit(runExperiment(*mode, experimentSearch, root, *dry, tasks))
|
||||
case "run-boundary":
|
||||
os.Exit(runBoundaries(root))
|
||||
os.Exit(runBoundaries(*mode, root))
|
||||
case "snippet":
|
||||
os.Exit(runSnippetAudit(root, 100))
|
||||
case "adversarial":
|
||||
@@ -69,7 +69,7 @@ func main() {
|
||||
// stays measurable on its own.
|
||||
batch = efficiencyTasks()
|
||||
}
|
||||
os.Exit(runExperiment(experimentIndex, root, *dry, batch))
|
||||
os.Exit(runExperiment(*mode, experimentIndex, root, *dry, batch))
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown mode %q\n", *mode)
|
||||
os.Exit(2)
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
|
||||
"reasonix/internal/assembly/boot"
|
||||
"reasonix/internal/contract/ablation"
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/contract/tool"
|
||||
_ "reasonix/internal/model/openai"
|
||||
@@ -132,10 +131,11 @@ func runOne(p provider.Provider, t contextTask, arm ablation.Set, armName, root
|
||||
}
|
||||
boot.ApplyUnifiedProviderToolSurface(reg, false, arm)
|
||||
|
||||
var usage usageSink
|
||||
a := agent.New(p, reg, sess, agent.Options{
|
||||
ContextWindow: fixtureWindow, CompactRatio: 0.5, RecentKeep: 2,
|
||||
SessionPath: f.Path, KeepPolicy: agent.KeepErrors, Ablation: arm,
|
||||
}, event.Discard)
|
||||
}, &usage)
|
||||
a.LoadProjectionSidecar(f.Path)
|
||||
// From here the transcript lives only in memory. On disk it is the answer.
|
||||
sealFixture(f.Path)
|
||||
@@ -161,6 +161,10 @@ func runOne(p provider.Provider, t contextTask, arm ablation.Set, armName, root
|
||||
appended := sess.Snapshot()[before:]
|
||||
m := scoreRun(appended, inst, f.Target, armName, cueVisible)
|
||||
m.trajectory = appended
|
||||
if u, requests := usage.snapshot(); requests > 0 {
|
||||
m.UsageReportedRequests, m.PromptTokens, m.CompletionTokens = requests, u.PromptTokens, u.CompletionTokens
|
||||
m.CacheHitTokens, m.CacheMissTokens, m.UsageEstimated = u.CacheHitTokens, u.CacheMissTokens, u.Estimated
|
||||
}
|
||||
m.scoreAnswer(finalAnswer(appended), inst)
|
||||
if runErr != nil {
|
||||
m.FailureStage = "RunError"
|
||||
@@ -212,7 +216,10 @@ func isolateWorkspace() (func(), error) {
|
||||
|
||||
// The two arms get separate nonces: reusing one would leave the first arm's
|
||||
// answer in a host-readable history for the second to find.
|
||||
func runBoundaries(root string) int {
|
||||
func runBoundaries(mode, root string) int {
|
||||
started := time.Now()
|
||||
planned := 0
|
||||
repo, _ := os.Getwd()
|
||||
root, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
@@ -239,6 +246,7 @@ func runBoundaries(root string) int {
|
||||
has bool
|
||||
}{{cue, true}, {noCue, false}} {
|
||||
name := "index-" + side.scale
|
||||
planned++
|
||||
m, err := runOne(p, t, armFor(side.scale, false), name, root, side.has, false)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s [%s]: %v\n", t.ID, name, err)
|
||||
@@ -249,14 +257,14 @@ func runBoundaries(root string) int {
|
||||
} else {
|
||||
noCueSide = append(noCueSide, m)
|
||||
}
|
||||
fmt.Printf("%-24s %-14s cue=%-3v %-14s search=%d read=%d direct=%-5v recall-tok=%-5d escape=%d\n",
|
||||
fmt.Printf("%-24s %-14s cue=%-3v %-14s search=%d read=%d direct=%-5v recall-tok=%-5d%s escape=%d\n",
|
||||
t.ID, name, side.has, m.FailureStage, m.SearchCalls, m.ReadCalls,
|
||||
m.CueDirectRead, m.RecallReturnedTokens, m.EscapeCalls)
|
||||
m.CueDirectRead, m.RecallReturnedTokens, promptTokenSuffix(m), m.EscapeCalls)
|
||||
}
|
||||
}
|
||||
reportDeltas(cueSide, noCueSide)
|
||||
fmt.Print(reportStopping(all))
|
||||
return writeResults(root, all)
|
||||
return writeResults(root, batchInfo{Mode: mode, Planned: planned, Started: started, Repo: repo}, all)
|
||||
}
|
||||
|
||||
// reportDeltas is the paired reading: each task against itself, then pooled.
|
||||
@@ -298,7 +306,10 @@ func deltaBool(a, b bool) string {
|
||||
// runExperiment runs one experiment's tasks across its arms. A dry run drives
|
||||
// the same pipeline with a scripted provider: it proves fixture, run, scoring
|
||||
// and report hold together before any of it is paid for.
|
||||
func runExperiment(experiment, root string, dry bool, tasks []contextTask) int {
|
||||
func runExperiment(mode, experiment, root string, dry bool, tasks []contextTask) int {
|
||||
started := time.Now()
|
||||
planned := 0
|
||||
repo, _ := os.Getwd()
|
||||
root, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
@@ -334,15 +345,16 @@ func runExperiment(experiment, root string, dry bool, tasks []contextTask) int {
|
||||
// test: a bad minute would land entirely on one arm.
|
||||
for _, arm := range shuffledArms(t) {
|
||||
cueVisible := t.Experiment == experimentIndex && cueExpectedVisible(t, arm.scale)
|
||||
planned++
|
||||
m, err := runOne(p, t, armFor(arm.scale, arm.searchOff), arm.name, root, cueVisible, dry)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s [%s]: %v\n", t.ID, arm.name, err)
|
||||
}
|
||||
byArm[arm.name] = append(byArm[arm.name], m)
|
||||
all = append(all, m)
|
||||
fmt.Printf("%-24s %-14s %-18s search=%d hit=%d read=%d recall-tok=%d %v\n",
|
||||
fmt.Printf("%-24s %-14s %-18s search=%d hit=%d read=%d recall-tok=%d%s %v\n",
|
||||
t.ID, arm.name, m.FailureStage, m.SearchCalls, m.TargetSearchHits, m.ReadCalls,
|
||||
m.RecallReturnedTokens, m.UnexpectedWorkTools)
|
||||
m.RecallReturnedTokens, promptTokenSuffix(m), m.UnexpectedWorkTools)
|
||||
}
|
||||
}
|
||||
reportFunnels(byArm)
|
||||
@@ -355,7 +367,7 @@ func runExperiment(experiment, root string, dry bool, tasks []contextTask) int {
|
||||
if experiment == experimentIndex {
|
||||
reportBoundaries(all)
|
||||
}
|
||||
return writeResults(root, all)
|
||||
return writeResults(root, batchInfo{Mode: mode, Planned: planned, Started: started, Dry: dry, Repo: repo}, all)
|
||||
}
|
||||
|
||||
func cueExpectedVisible(t contextTask, scale string) bool {
|
||||
@@ -410,7 +422,7 @@ func writeTrajectories(root string, all []contextMetrics) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeResults(root string, all []contextMetrics) int {
|
||||
func writeResults(root string, info batchInfo, all []contextMetrics) int {
|
||||
path := filepath.Join(root, "results.jsonl")
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
@@ -428,7 +440,11 @@ func writeResults(root string, all []contextMetrics) int {
|
||||
fmt.Fprintln(os.Stderr, "write trajectories:", err)
|
||||
return 2
|
||||
}
|
||||
fmt.Printf("\n%d runs written to %s (with trajectories.jsonl)\n", len(all), path)
|
||||
if err := writeBatchManifest(root, info, all); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "write batch manifest:", err)
|
||||
return 2
|
||||
}
|
||||
fmt.Printf("\n%d runs written to %s (with trajectories.jsonl and batch.json)\n", len(all), path)
|
||||
return 0
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/provider"
|
||||
)
|
||||
|
||||
// usageSink reads what a run cost from the events the agent already emits. Every
|
||||
// billed completion reports event.Usage — the round path, compaction, command
|
||||
// routing and failure diagnosis all do — so the bench reads the same record the
|
||||
// product reads instead of wrapping a provider and counting a second time.
|
||||
type usageSink struct {
|
||||
mu sync.Mutex
|
||||
requests int
|
||||
usage provider.Usage
|
||||
}
|
||||
|
||||
func (s *usageSink) Emit(e event.Event) {
|
||||
if e.Kind != event.Usage || e.Usage == nil {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
// One event is one completion that reported usage; RequestCount covers a
|
||||
// completion that merged attempts. A request that failed before reporting
|
||||
// leaves no event, so this is not a count of attempts.
|
||||
if e.Usage.RequestCount > 0 {
|
||||
s.requests += e.Usage.RequestCount
|
||||
} else {
|
||||
s.requests++
|
||||
}
|
||||
u := *e.Usage
|
||||
s.usage.PromptTokens += u.PromptTokens
|
||||
s.usage.CompletionTokens += u.CompletionTokens
|
||||
s.usage.TotalTokens += u.TotalTokens
|
||||
s.usage.CacheHitTokens += u.CacheHitTokens
|
||||
s.usage.CacheMissTokens += u.CacheMissTokens
|
||||
s.usage.ReasoningTokens += u.ReasoningTokens
|
||||
s.usage.Estimated = s.usage.Estimated || u.Estimated
|
||||
}
|
||||
|
||||
func (s *usageSink) snapshot() (provider.Usage, int) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.usage, s.requests
|
||||
}
|
||||
|
||||
// promptTokenSuffix renders what a run cost only when something reported it. A
|
||||
// dry run reports nothing, and a trailing "prompt-tok=0" would read as a
|
||||
// measured zero rather than as no measurement.
|
||||
func promptTokenSuffix(m contextMetrics) string {
|
||||
if m.UsageReportedRequests == 0 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf(" prompt-tok=%d", m.PromptTokens)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/provider"
|
||||
)
|
||||
|
||||
// The cost a run reports must be what the agent reported, summed across every
|
||||
// completion it billed — including one that merged attempts and one that could
|
||||
// only estimate. Any other event kind is not a cost.
|
||||
func TestUsageSinkAccumulatesWhatTheAgentReports(t *testing.T) {
|
||||
var sink usageSink
|
||||
sink.Emit(event.Event{}) // a kind that is not Usage
|
||||
sink.Emit(event.Event{Kind: event.Usage, Usage: &provider.Usage{
|
||||
PromptTokens: 100, CompletionTokens: 10, TotalTokens: 110, CacheHitTokens: 60, CacheMissTokens: 40,
|
||||
}})
|
||||
sink.Emit(event.Event{Kind: event.Usage, Usage: &provider.Usage{
|
||||
PromptTokens: 7, CompletionTokens: 3, CacheHitTokens: 2, CacheMissTokens: 5, RequestCount: 2, Estimated: true,
|
||||
}})
|
||||
sink.Emit(event.Event{Kind: event.Usage}) // a Usage event with no payload is not a cost
|
||||
|
||||
usage, requests := sink.snapshot()
|
||||
if requests != 3 {
|
||||
t.Fatalf("requests = %d, want 3 (1 + the merged attempt's 2; the empty event adds none)", requests)
|
||||
}
|
||||
if usage.PromptTokens != 107 || usage.CompletionTokens != 13 || usage.TotalTokens != 110 {
|
||||
t.Fatalf("accumulated usage = %+v", usage)
|
||||
}
|
||||
if usage.CacheHitTokens != 62 || usage.CacheMissTokens != 45 {
|
||||
t.Fatalf("cache split = %+v", usage)
|
||||
}
|
||||
if !usage.Estimated {
|
||||
t.Fatal("Estimated was dropped when one completion reported an estimate")
|
||||
}
|
||||
}
|
||||
|
||||
// A dry run must not claim a cost it never paid, in the JSON or in the line a
|
||||
// person reads: an empty suffix, not "prompt-tok=0".
|
||||
func TestUsageSinkStaysEmptyWithoutUsage(t *testing.T) {
|
||||
var sink usageSink
|
||||
sink.Emit(event.Event{})
|
||||
usage, requests := sink.snapshot()
|
||||
if requests != 0 || usage.PromptTokens != 0 || usage.CompletionTokens != 0 {
|
||||
t.Fatalf("nothing reported usage, got %+v requests=%d", usage, requests)
|
||||
}
|
||||
if suffix := promptTokenSuffix(contextMetrics{}); suffix != "" {
|
||||
t.Fatalf("a run with no reported usage still printed %q", suffix)
|
||||
}
|
||||
if suffix := promptTokenSuffix(contextMetrics{UsageReportedRequests: 1, PromptTokens: 12}); suffix != " prompt-tok=12" {
|
||||
t.Fatalf("a reported cost printed %q", suffix)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
# Desktop surface evaluation
|
||||
|
||||
This protocol supplies the fixed-task trajectory comparison required by
|
||||
[RFC #12584](https://github.com/esengine/DeepSeek-Reasonix/issues/12584).
|
||||
There are no measured results yet. Component tests and scripted providers prove
|
||||
delivery, validation and replay; they do not measure a model's choice of interface
|
||||
or a person's effort.
|
||||
|
||||
## Paired runs
|
||||
|
||||
Run each task twice in fresh desktop sessions: ordinary text/ask interaction and
|
||||
native surfaces. Use the same model, sampling settings, initial workspace,
|
||||
permission mode and task prompt. Record commit, model, configuration and the
|
||||
native-surface capability setting. Alternate condition order across repetitions.
|
||||
Keep the hidden tool out of the provider prefix in both conditions; only its
|
||||
availability through capability discovery differs.
|
||||
|
||||
The baseline may batch questions or accept a single text reply. Never force it to
|
||||
ask one question per field. Do not tell either model how many turns to use or
|
||||
which component to render. If the surface-enabled model chooses text, retain
|
||||
that run in the surface condition. Interface choice is part of the experiment.
|
||||
|
||||
The operator knows the private values below but does not volunteer them before
|
||||
the model requests the corresponding information. Supply all requested values
|
||||
together, regardless of presentation. Allow inspection and local editing before
|
||||
submission. Save the trajectory and an observer ledger locally; raw transcripts
|
||||
and draft content must not enter the aggregate report.
|
||||
|
||||
## Fixed tasks
|
||||
|
||||
| Task | Initial prompt, identical in both conditions | Private operator input | Completion criterion |
|
||||
| --- | --- | --- | --- |
|
||||
| `release-brief` | Prepare a release brief. Ask me for the audience, release date and whether this is a staged rollout before writing it. | audience: existing desktop users; date: 2026-11-03; staged rollout: yes | Final brief contains all three values and adds no release commitment. |
|
||||
| `storage-choice` | Help me choose storage for a small desktop app. Present reasonable choices, ask me to choose and ask whether offline operation is required. Then summarize the decision. | storage: SQLite; offline: yes | Final decision states SQLite and offline operation, with no installation or file changes. |
|
||||
| `draft-revision` | Draft a two-line release announcement and let me revise it before returning the final version verbatim. | Replace with the exact string ` Desktop release ready.\nKeep existing settings.\n` (decode the escapes). | Final returned string has exactly the specified leading spaces and newlines. Formatting wrappers are excluded from the comparison. |
|
||||
| `preview-decision` | Show a plain-text before/after preview changing retry count from 2 to 3. Ask whether to keep that change and whether to include a release note. Return a decision summary; do not change files. | keep change: yes; release note: no | Preview shows 2 to 3; final summary preserves both decisions; workspace is unchanged. |
|
||||
|
||||
Freeze this task set before collecting results. Record a new protocol version if
|
||||
prompts, private inputs, completion criteria or metric definitions change.
|
||||
Neither the model's final claim nor a successful input acknowledgment establishes
|
||||
completion. The observer checks the resulting output against the criterion.
|
||||
|
||||
## Metric definitions (version 1)
|
||||
|
||||
The anchor is acknowledgment of the initial task prompt. The observation ends
|
||||
when the criterion is verified, the operator abandons the task, or the run fails.
|
||||
The initial prompt is excluded from subsequent-input counts because both
|
||||
conditions start from the same request.
|
||||
|
||||
| Metric | Count and population | Exclusions |
|
||||
| --- | --- | --- |
|
||||
| `acknowledged_inputs` | Distinct operator submissions acknowledged after the anchor: chat replies, ask answers, surface answers and surface actions. Includes acknowledged input later withdrawn or superseded. | Initial task prompt, local draft edits, retries with the same acknowledged request identity, automatic dispatch, replay and observer grading. This counts input exchanges, not provider rounds. |
|
||||
| `submission_attempts` | Explicit operator send attempts, including rejected submissions and manual retries. | Automatic transport retries, replay, local edits and model tool calls. |
|
||||
| `withdrawals` | Successful operator withdrawals of admitted input. | Failed withdrawals, automatic cancellation and replay. |
|
||||
| `completed` | Runs whose output meets the fixed criterion. Denominator: every started run in each condition. | None; failures and abandoned runs remain in the denominator. |
|
||||
|
||||
Record these in a content-free ledger: task id, repetition, condition, observer
|
||||
event ordinal, event type, opaque request identity, acknowledgment status and
|
||||
completion verdict. Record the source trajectory reference and observed start
|
||||
and end anchors alongside it. Distinguish manual observation from host-recorded
|
||||
events. Do not copy labels, prompts, private values, drafts or answer strings into
|
||||
the ledger. Identities are local correlation handles, not account identifiers.
|
||||
|
||||
The current pane counters and rendered trajectory export cannot reconstruct all
|
||||
acknowledgments, refusals and withdrawals. Do not infer them from `ask` calls,
|
||||
tool-result counts, final prose, message wording or card mounts. Until structural
|
||||
acknowledgment records cover every channel, an observer ledger is required.
|
||||
|
||||
## Report and decision
|
||||
|
||||
Report the number started, completed, failed and abandoned for each condition,
|
||||
then paired input-count differences for repetitions where **both** conditions
|
||||
meet the same criterion. List excluded pairs and their reason beside that
|
||||
comparison. Also report submission attempts and withdrawals; fewer admitted
|
||||
inputs with more failures is not automatically less effort.
|
||||
|
||||
Keep per-task results visible. Do not combine scripted fixtures with observed
|
||||
model/operator runs or describe this small task set as an account-wide result.
|
||||
Report model/configuration differences and missing evidence as limitations.
|
||||
|
||||
The RFC's benefit remains unproven until observed trajectories show fewer input
|
||||
exchanges to the same verified outcome. These results alone do not justify P3;
|
||||
adaptive layout or MCP Apps needs a measured problem in addition to this result.
|
||||
@@ -203,6 +203,11 @@ func answerLeakDetected(r result) bool {
|
||||
}
|
||||
|
||||
func recordTaskTrajectory(r *result, path string, t task) {
|
||||
if r.SFT != nil && r.Segments <= 1 {
|
||||
if err := bindSFTTrajectory(path, r.SFT); err != nil {
|
||||
r.Note = appendNote(r.Note, "SFT provenance: "+err.Error())
|
||||
}
|
||||
}
|
||||
if summary, err := summarizeTrajectory(path); err == nil {
|
||||
r.Trajectory = summary
|
||||
}
|
||||
|
||||
@@ -0,0 +1,534 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
|
||||
fileencoding "reasonix/internal/base/fileutil/encoding"
|
||||
)
|
||||
|
||||
// benchmarksRoot holds every committed task corpus. Validation discovers the
|
||||
// corpora under it, so a corpus that never reaches the registry fails the check
|
||||
// instead of staying outside every guard silently.
|
||||
const benchmarksRoot = "../../benchmarks"
|
||||
|
||||
// authoringRule names one of the authoring guards a corpus answers to.
|
||||
type authoringRule string
|
||||
|
||||
const (
|
||||
rulePristineSeed authoringRule = "pristine-seed"
|
||||
ruleReference authoringRule = "reference-solution"
|
||||
ruleStopPolicy authoringRule = "stop-policy"
|
||||
)
|
||||
|
||||
// allRules is the validator's closed vocabulary: a registry key outside it is an
|
||||
// unknown rule, not a silently ignored one.
|
||||
var allRules = []authoringRule{rulePristineSeed, ruleReference, ruleStopPolicy}
|
||||
|
||||
// ruleMode records how a rule applies to one corpus. There is no zero value that
|
||||
// means "not listed": omission is not a mode, so a forgotten corpus reads as an
|
||||
// error rather than as an exemption.
|
||||
type ruleMode string
|
||||
|
||||
const (
|
||||
// ruleEnforce applies the whole rule.
|
||||
ruleEnforce ruleMode = "enforce"
|
||||
// ruleEnforceWhenPresent applies only the half that tests what a corpus
|
||||
// ships: the artefact is optional, but a shipped one must still hold up.
|
||||
// Reference rule only, and it needs the reason the corpus is allowed to
|
||||
// omit solutions.
|
||||
ruleEnforceWhenPresent ruleMode = "enforce-when-present"
|
||||
// ruleExempt keeps the corpus outside the rule, for the recorded reason.
|
||||
ruleExempt ruleMode = "exempt"
|
||||
)
|
||||
|
||||
type ruleDecision struct {
|
||||
Mode ruleMode
|
||||
Reason string
|
||||
}
|
||||
|
||||
type corpusPolicy struct {
|
||||
Dir string
|
||||
Rules map[authoringRule]ruleDecision
|
||||
}
|
||||
|
||||
// corpusPolicies is the single registry the authoring guards read. Every
|
||||
// committed corpus appears once, and every corpus/rule pair carries an explicit
|
||||
// decision, including the pairs that are deliberately outside a rule.
|
||||
func corpusPolicies() []corpusPolicy {
|
||||
return []corpusPolicy{
|
||||
{
|
||||
Dir: corpusDir,
|
||||
Rules: map[authoringRule]ruleDecision{
|
||||
rulePristineSeed: {Mode: ruleEnforce},
|
||||
ruleReference: {Mode: ruleEnforceWhenPresent,
|
||||
Reason: "the e2e suite does not commit reference solutions, so a task without one is skipped, not failed"},
|
||||
ruleStopPolicy: {Mode: ruleEnforce},
|
||||
},
|
||||
},
|
||||
{
|
||||
Dir: verificationStressDir,
|
||||
Rules: map[authoringRule]ruleDecision{
|
||||
rulePristineSeed: {Mode: ruleEnforce},
|
||||
ruleReference: {Mode: ruleEnforce},
|
||||
ruleStopPolicy: {Mode: ruleEnforce},
|
||||
},
|
||||
},
|
||||
{
|
||||
Dir: trainCorpusDir,
|
||||
Rules: map[authoringRule]ruleDecision{
|
||||
rulePristineSeed: {Mode: ruleEnforce},
|
||||
ruleReference: {Mode: ruleEnforce},
|
||||
ruleStopPolicy: {Mode: ruleEnforce},
|
||||
},
|
||||
},
|
||||
{
|
||||
Dir: memorybenchDir,
|
||||
Rules: map[authoringRule]ruleDecision{
|
||||
rulePristineSeed: {Mode: ruleEnforce},
|
||||
ruleReference: {Mode: ruleExempt,
|
||||
Reason: "memorybench ships task-local memory facts instead of solution/ fixtures; TestMemorybenchGradersAcceptTheReferenceAnswer supplies a task-derived witness for every task and checks every grader accepts it"},
|
||||
ruleStopPolicy: {Mode: ruleEnforce},
|
||||
},
|
||||
},
|
||||
{
|
||||
Dir: fanoutWidthDir,
|
||||
Rules: map[authoringRule]ruleDecision{
|
||||
rulePristineSeed: {Mode: ruleEnforce},
|
||||
ruleReference: {Mode: ruleEnforce},
|
||||
ruleStopPolicy: {Mode: ruleEnforce},
|
||||
},
|
||||
},
|
||||
{
|
||||
Dir: upstreamEdgeDir,
|
||||
Rules: map[authoringRule]ruleDecision{
|
||||
rulePristineSeed: {Mode: ruleEnforce},
|
||||
ruleReference: {Mode: ruleEnforce},
|
||||
ruleStopPolicy: {Mode: ruleEnforce},
|
||||
},
|
||||
},
|
||||
{
|
||||
Dir: projectCheckDir,
|
||||
Rules: map[authoringRule]ruleDecision{
|
||||
rulePristineSeed: {Mode: ruleEnforce},
|
||||
ruleReference: {Mode: ruleEnforce},
|
||||
ruleStopPolicy: {Mode: ruleEnforce},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// policiesRunning selects the corpora whose decision for a rule runs that rule,
|
||||
// which is what the authoring tests iterate over.
|
||||
func policiesRunning(policies []corpusPolicy, rule authoringRule) []corpusPolicy {
|
||||
var out []corpusPolicy
|
||||
for _, p := range policies {
|
||||
switch p.Rules[rule].Mode {
|
||||
case ruleEnforce, ruleEnforceWhenPresent:
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// committedCorpora lists the directories under root that carry tasks/.
|
||||
func committedCorpora(root string) ([]string, error) {
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []string
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
if dirExists(filepath.Join(root, e.Name(), "tasks")) {
|
||||
out = append(out, e.Name())
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// validateCorpusPolicies checks a registry against the corpora committed under
|
||||
// root and returns one actionable error per problem. Tests supply their own
|
||||
// root, so the discovery rules can be exercised without committing fake
|
||||
// corpora into benchmarks/.
|
||||
func validateCorpusPolicies(root string, policies []corpusPolicy) []error {
|
||||
var errs []error
|
||||
committed, err := committedCorpora(root)
|
||||
if err != nil {
|
||||
return []error{fmt.Errorf("list corpora under %s: %w", root, err)}
|
||||
}
|
||||
registered := map[string]bool{}
|
||||
for _, p := range policies {
|
||||
name := filepath.Base(p.Dir)
|
||||
if registered[name] {
|
||||
errs = append(errs, fmt.Errorf("corpus %q is registered more than once", name))
|
||||
continue
|
||||
}
|
||||
registered[name] = true
|
||||
// Identity is the path under this root, not the directory's name: a
|
||||
// registry entry pointing anywhere else would have the guards check a
|
||||
// corpus that only shares a name with the one committed here.
|
||||
want := filepath.Clean(filepath.Join(root, name))
|
||||
if got := filepath.Clean(p.Dir); got != want {
|
||||
errs = append(errs, fmt.Errorf("corpus %q is registered at %s, but this root's corpus is %s", name, got, want))
|
||||
}
|
||||
if !dirExists(filepath.Join(p.Dir, "tasks")) {
|
||||
errs = append(errs, fmt.Errorf("corpus %q is registered but has no tasks/ directory at %s", name, p.Dir))
|
||||
}
|
||||
for rule := range p.Rules {
|
||||
if !slices.Contains(allRules, rule) {
|
||||
errs = append(errs, fmt.Errorf("corpus %q declares an unknown rule %q", name, rule))
|
||||
}
|
||||
}
|
||||
for _, rule := range allRules {
|
||||
decision, ok := p.Rules[rule]
|
||||
if !ok {
|
||||
errs = append(errs, fmt.Errorf("corpus %q has no decision for rule %q", name, rule))
|
||||
continue
|
||||
}
|
||||
switch decision.Mode {
|
||||
case ruleEnforce:
|
||||
// The full rule applies; no reason is needed or read.
|
||||
case ruleEnforceWhenPresent:
|
||||
if rule != ruleReference {
|
||||
errs = append(errs, fmt.Errorf("corpus %q uses mode %q for rule %q, which only applies to %q",
|
||||
name, decision.Mode, rule, ruleReference))
|
||||
}
|
||||
if strings.TrimSpace(decision.Reason) == "" {
|
||||
errs = append(errs, fmt.Errorf("corpus %q uses mode %q for rule %q without the reason that allows it",
|
||||
name, decision.Mode, rule))
|
||||
}
|
||||
case ruleExempt:
|
||||
if strings.TrimSpace(decision.Reason) == "" {
|
||||
errs = append(errs, fmt.Errorf("corpus %q is exempt from rule %q without a reason", name, rule))
|
||||
}
|
||||
default:
|
||||
errs = append(errs, fmt.Errorf("corpus %q declares an unknown mode %q for rule %q", name, decision.Mode, rule))
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, name := range committed {
|
||||
if !registered[name] {
|
||||
errs = append(errs, fmt.Errorf("corpus %q is committed under %s but is not registered, so no authoring guard covers it", name, root))
|
||||
}
|
||||
}
|
||||
sort.Slice(errs, func(i, j int) bool { return errs[i].Error() < errs[j].Error() })
|
||||
return errs
|
||||
}
|
||||
|
||||
// taskBounds is a task's own declaration of its bounds. Pointers keep a missing
|
||||
// key distinct from a declared zero.
|
||||
type taskBounds struct {
|
||||
MaxSteps *int `toml:"max_steps"`
|
||||
TimeoutSec *int `toml:"timeout_sec"`
|
||||
}
|
||||
|
||||
// stopPolicyViolations reports the tasks in one corpus that break the stop
|
||||
// rule. It reads each task.toml: the rule is about what a task declares, and a
|
||||
// default is not a declaration.
|
||||
func stopPolicyViolations(dir string) ([]string, error) {
|
||||
tasksDir := filepath.Join(dir, "tasks")
|
||||
entries, err := os.ReadDir(tasksDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []string
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
id := entry.Name()
|
||||
data, err := fileencoding.ReadFileUTF8(filepath.Join(tasksDir, id, "task.toml"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", id, err)
|
||||
}
|
||||
var bounds taskBounds
|
||||
if _, err := toml.Decode(string(data), &bounds); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", id, err)
|
||||
}
|
||||
if bounds.MaxSteps != nil && *bounds.MaxSteps > 0 {
|
||||
out = append(out, fmt.Sprintf("%s declares max_steps = %d; let timeout_sec bound the resource and the agent bound the work",
|
||||
id, *bounds.MaxSteps))
|
||||
}
|
||||
switch {
|
||||
case bounds.TimeoutSec == nil:
|
||||
out = append(out, fmt.Sprintf("%s does not declare timeout_sec; with no round cap the wall clock is the only bound left", id))
|
||||
case *bounds.TimeoutSec <= 0:
|
||||
out = append(out, fmt.Sprintf("%s declares timeout_sec = %d; with no round cap the wall clock is the only bound left", id, *bounds.TimeoutSec))
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// The registry must describe every committed corpus, and every corpus/rule pair
|
||||
// in it must be a decision the validator understands.
|
||||
func TestCorpusPolicyRegistryCoversEveryCommittedCorpus(t *testing.T) {
|
||||
for _, err := range validateCorpusPolicies(benchmarksRoot, corpusPolicies()) {
|
||||
t.Error(err)
|
||||
}
|
||||
committed, err := committedCorpora(benchmarksRoot)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(committed) == 0 {
|
||||
t.Fatal("no committed task corpora found; the benchmark suites are missing")
|
||||
}
|
||||
if registered := len(corpusPolicies()); registered != len(committed) {
|
||||
t.Errorf("registry holds %d corpora while %d are committed", registered, len(committed))
|
||||
}
|
||||
}
|
||||
|
||||
// The registry holds one decision per corpus and rule, so the enforcement sets
|
||||
// cannot drift apart silently.
|
||||
func TestCorpusPolicyEnforcementSets(t *testing.T) {
|
||||
policies := corpusPolicies()
|
||||
for _, c := range []struct {
|
||||
rule authoringRule
|
||||
want int
|
||||
exempt []string
|
||||
}{
|
||||
{rulePristineSeed, 7, nil},
|
||||
{ruleReference, 6, []string{memorybenchDir}},
|
||||
{ruleStopPolicy, 7, nil},
|
||||
} {
|
||||
running := policiesRunning(policies, c.rule)
|
||||
if len(running) != c.want {
|
||||
t.Errorf("rule %q runs for %d corpora, want %d", c.rule, len(running), c.want)
|
||||
}
|
||||
for _, dir := range c.exempt {
|
||||
for _, p := range running {
|
||||
if p.Dir == dir {
|
||||
t.Errorf("rule %q runs for %s, which the registry exempts", c.rule, filepath.Base(dir))
|
||||
}
|
||||
}
|
||||
found := false
|
||||
for _, p := range policies {
|
||||
if p.Dir == dir && p.Rules[c.rule].Mode == ruleExempt {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("%s is not recorded as exempt from rule %q", filepath.Base(dir), c.rule)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// tempCorpus lays a minimal corpus under root and returns its path, so negative
|
||||
// cases run in a temporary universe instead of as fake corpora in benchmarks/.
|
||||
func tempCorpus(t *testing.T, root, name, task, taskToml string) string {
|
||||
t.Helper()
|
||||
dir := filepath.Join(root, name)
|
||||
taskDir := filepath.Join(dir, "tasks", task)
|
||||
if err := os.MkdirAll(filepath.Join(taskDir, "workdir"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if taskToml != "" {
|
||||
if err := os.WriteFile(filepath.Join(taskDir, "task.toml"), []byte(taskToml), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func fullDecisions() map[authoringRule]ruleDecision {
|
||||
return map[authoringRule]ruleDecision{
|
||||
rulePristineSeed: {Mode: ruleEnforce},
|
||||
ruleReference: {Mode: ruleEnforce},
|
||||
ruleStopPolicy: {Mode: ruleEnforce},
|
||||
}
|
||||
}
|
||||
|
||||
// Every way a registry can fall out of step with the tree is reported against
|
||||
// the corpus and rule it concerns.
|
||||
func TestCorpusPolicyValidationCatchesGaps(t *testing.T) {
|
||||
universe := func(t *testing.T, corpora ...string) (string, []corpusPolicy) {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
var policies []corpusPolicy
|
||||
for _, name := range corpora {
|
||||
dir := tempCorpus(t, root, name, "t-"+name, "")
|
||||
policies = append(policies, corpusPolicy{Dir: dir, Rules: fullDecisions()})
|
||||
}
|
||||
return root, policies
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
universe func(t *testing.T) (string, []corpusPolicy)
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "unregistered corpus",
|
||||
universe: func(t *testing.T) (string, []corpusPolicy) {
|
||||
root, policies := universe(t, "registered")
|
||||
tempCorpus(t, root, "forgotten", "t-forgotten", "")
|
||||
return root, policies
|
||||
},
|
||||
want: "forgotten",
|
||||
},
|
||||
{
|
||||
name: "registry entry whose tasks/ is gone",
|
||||
universe: func(t *testing.T) (string, []corpusPolicy) {
|
||||
root, policies := universe(t, "registered")
|
||||
if err := os.RemoveAll(filepath.Join(root, "registered")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return root, policies
|
||||
},
|
||||
want: "no tasks/ directory",
|
||||
},
|
||||
{
|
||||
name: "corpus with no decision for a rule",
|
||||
universe: func(t *testing.T) (string, []corpusPolicy) {
|
||||
root, policies := universe(t, "registered")
|
||||
delete(policies[0].Rules, ruleStopPolicy)
|
||||
return root, policies
|
||||
},
|
||||
want: string(ruleStopPolicy) + "\"",
|
||||
},
|
||||
{
|
||||
name: "exemption without a reason",
|
||||
universe: func(t *testing.T) (string, []corpusPolicy) {
|
||||
root, policies := universe(t, "registered")
|
||||
policies[0].Rules[ruleReference] = ruleDecision{Mode: ruleExempt}
|
||||
return root, policies
|
||||
},
|
||||
want: "exempt from rule \"" + string(ruleReference) + "\" without a reason",
|
||||
},
|
||||
{
|
||||
name: "enforce-when-present without a reason",
|
||||
universe: func(t *testing.T) (string, []corpusPolicy) {
|
||||
root, policies := universe(t, "registered")
|
||||
policies[0].Rules[ruleReference] = ruleDecision{Mode: ruleEnforceWhenPresent}
|
||||
return root, policies
|
||||
},
|
||||
want: "without the reason that allows it",
|
||||
},
|
||||
{
|
||||
name: "unknown mode",
|
||||
universe: func(t *testing.T) (string, []corpusPolicy) {
|
||||
root, policies := universe(t, "registered")
|
||||
policies[0].Rules[ruleStopPolicy] = ruleDecision{Mode: "sometimes"}
|
||||
return root, policies
|
||||
},
|
||||
want: "unknown mode",
|
||||
},
|
||||
{
|
||||
name: "a registry entry that shadows the root's corpus by name",
|
||||
universe: func(t *testing.T) (string, []corpusPolicy) {
|
||||
root, policies := universe(t, "registered")
|
||||
elsewhere := tempCorpus(t, t.TempDir(), "registered", "t-impostor", "")
|
||||
policies[0].Dir = elsewhere
|
||||
return root, policies
|
||||
},
|
||||
want: "but this root's corpus is",
|
||||
},
|
||||
{
|
||||
name: "unknown rule",
|
||||
universe: func(t *testing.T) (string, []corpusPolicy) {
|
||||
root, policies := universe(t, "registered")
|
||||
policies[0].Rules["vibes"] = ruleDecision{Mode: ruleEnforce}
|
||||
return root, policies
|
||||
},
|
||||
want: "unknown rule",
|
||||
},
|
||||
{
|
||||
name: "enforce-when-present outside the reference rule",
|
||||
universe: func(t *testing.T) (string, []corpusPolicy) {
|
||||
root, policies := universe(t, "registered")
|
||||
policies[0].Rules[ruleStopPolicy] = ruleDecision{Mode: ruleEnforceWhenPresent, Reason: "because"}
|
||||
return root, policies
|
||||
},
|
||||
want: "only applies to",
|
||||
},
|
||||
{
|
||||
name: "a fully declared universe is accepted",
|
||||
universe: func(t *testing.T) (string, []corpusPolicy) {
|
||||
return universe(t, "one", "two")
|
||||
},
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
root, policies := c.universe(t)
|
||||
errs := validateCorpusPolicies(root, policies)
|
||||
if c.want == "" {
|
||||
if len(errs) != 0 {
|
||||
t.Fatalf("valid registry reported %v", errs)
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(errs) == 0 {
|
||||
t.Fatalf("no error reported, want one mentioning %q", c.want)
|
||||
}
|
||||
var jb strings.Builder
|
||||
for _, err := range errs {
|
||||
jb.WriteString(err.Error() + "\n")
|
||||
}
|
||||
joined := jb.String()
|
||||
if !strings.Contains(joined, c.want) {
|
||||
t.Fatalf("errors do not mention %q:\n%s", c.want, joined)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The stop rule covers every registered corpus, so a cap anywhere is a failure
|
||||
// rather than an unread declaration.
|
||||
func TestStopPolicyAppliesToEveryRegisteredCorpus(t *testing.T) {
|
||||
for _, p := range policiesRunning(corpusPolicies(), ruleStopPolicy) {
|
||||
if p.Rules[ruleStopPolicy].Mode != ruleEnforce {
|
||||
t.Errorf("corpus %s uses mode %q for the stop rule; every registered corpus enforces both assertions",
|
||||
filepath.Base(p.Dir), p.Rules[ruleStopPolicy].Mode)
|
||||
}
|
||||
}
|
||||
|
||||
root := t.TempDir()
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
corpus string
|
||||
toml string
|
||||
want []string
|
||||
}{
|
||||
{"a cap is rejected", "train", "max_steps = 8\ntimeout_sec = 300\n", []string{"max_steps = 8"}},
|
||||
{"an omitted wall clock is rejected", "upstream-edge", "max_steps = 0\n", []string{"does not declare timeout_sec"}},
|
||||
{"a zero wall clock is rejected", "fanout-width", "timeout_sec = 0\n", []string{"declares timeout_sec = 0"}},
|
||||
{"a negative wall clock is rejected", "project-check", "timeout_sec = -1\n", []string{"declares timeout_sec = -1"}},
|
||||
{"a declared bound is accepted", "memorybench", "timeout_sec = 300\n", nil},
|
||||
{"no cap and no declaration of one is accepted", "verification-stress", "max_steps = 0\ntimeout_sec = 300\n", nil},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
dir := tempCorpus(t, root, c.corpus, "t-case", c.toml)
|
||||
violations, err := stopPolicyViolations(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(c.want) == 0 {
|
||||
if len(violations) != 0 {
|
||||
t.Fatalf("accepted task reported %v", violations)
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(violations) == 0 {
|
||||
t.Fatalf("no violation reported, want one mentioning %q", c.want[0])
|
||||
}
|
||||
if !strings.Contains(violations[0], c.want[0]) {
|
||||
t.Fatalf("violation %q does not mention %q", violations[0], c.want[0])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+15
-20
@@ -189,7 +189,8 @@ func TestSolvableCorpusSeedsMustNotGradeClean(t *testing.T) {
|
||||
t.Skipf("%s unavailable; the graders need a POSIX shell and python3", bin)
|
||||
}
|
||||
}
|
||||
for _, dir := range []string{corpusDir, verificationStressDir, trainCorpusDir, memorybenchDir, fanoutWidthDir, upstreamEdgeDir, projectCheckDir} {
|
||||
for _, policy := range policiesRunning(corpusPolicies(), rulePristineSeed) {
|
||||
dir := policy.Dir
|
||||
if !dirExists(dir) {
|
||||
continue
|
||||
}
|
||||
@@ -242,7 +243,9 @@ func TestCorpusGradersPassTheReferenceSolution(t *testing.T) {
|
||||
t.Skipf("%s unavailable; the graders need a POSIX shell and python3", bin)
|
||||
}
|
||||
}
|
||||
for _, dir := range []string{corpusDir, verificationStressDir, trainCorpusDir, fanoutWidthDir, upstreamEdgeDir, projectCheckDir} {
|
||||
for _, policy := range policiesRunning(corpusPolicies(), ruleReference) {
|
||||
dir := policy.Dir
|
||||
decision := policy.Rules[ruleReference]
|
||||
if !dirExists(dir) {
|
||||
continue
|
||||
}
|
||||
@@ -259,10 +262,10 @@ func TestCorpusGradersPassTheReferenceSolution(t *testing.T) {
|
||||
t.Parallel()
|
||||
work := stageSeed(t, task.dir)
|
||||
if !stageSolved(t, task.dir, work) {
|
||||
if dir == trainCorpusDir || dir == verificationStressDir || dir == fanoutWidthDir || dir == upstreamEdgeDir || dir == projectCheckDir {
|
||||
t.Fatal("no solution/: a corpus task must prove its grader can pass")
|
||||
if decision.Mode == ruleEnforceWhenPresent {
|
||||
t.Skip("no solution/: " + decision.Reason)
|
||||
}
|
||||
t.Skip("no solution/: the e2e suite does not commit reference solutions")
|
||||
t.Fatal("no solution/: a corpus task must prove its grader can pass")
|
||||
}
|
||||
if err := gradeSeed(t, work); err != nil {
|
||||
t.Fatalf("the reference solution does not grade clean, so no attempt can: %v", err)
|
||||
@@ -341,25 +344,17 @@ func TestNoSolutionCorpusGradesTheInverseContract(t *testing.T) {
|
||||
|
||||
// A task that caps its own tool rounds measures the cap, not the agent: the
|
||||
// product ships unbounded (agent.Run calls bounding the loop the host's call),
|
||||
// so a capped corpus grades a configuration no default user runs. Only two
|
||||
// committed tasks ever reached a cap, both no-solution ones, where the host's
|
||||
// "summarise your progress" preempts the sentence the honesty score exists for.
|
||||
// so committed benchmark tasks must not set one. With no cap the wall clock is
|
||||
// the only bound left, so every task must declare a positive timeout_sec.
|
||||
func TestCorpusLetsTheAgentDecideWhenToStop(t *testing.T) {
|
||||
for _, dir := range []string{corpusDir, verificationStressDir, memorybenchDir} {
|
||||
tasks, err := loadTasks(dir)
|
||||
for _, policy := range policiesRunning(corpusPolicies(), ruleStopPolicy) {
|
||||
dir := policy.Dir
|
||||
violations, err := stopPolicyViolations(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("load %s: %v", dir, err)
|
||||
}
|
||||
for _, task := range tasks {
|
||||
if task.MaxSteps > 0 {
|
||||
t.Errorf("%s declares max_steps = %d; let timeout_sec bound the resource and the agent bound the work",
|
||||
task.ID, task.MaxSteps)
|
||||
}
|
||||
// Without a round cap the wall clock is the only backstop left, so
|
||||
// every task needs one or a stuck run has nothing to end it.
|
||||
if task.TimeoutSec <= 0 {
|
||||
t.Errorf("%s has no timeout_sec; with no round cap it is the only bound left", task.ID)
|
||||
}
|
||||
for _, violation := range violations {
|
||||
t.Errorf("%s: %s", filepath.Base(dir), violation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+45
-10
@@ -108,21 +108,27 @@ func runOnce(o diffOpts, srcFiles, pkgs []string, prompt string) diffReport {
|
||||
|
||||
var pins []pinResult
|
||||
var mut mutationResult
|
||||
var analysisErr error
|
||||
covered, coverTotal := 0, 0
|
||||
if len(refs) > 0 && testsPass {
|
||||
covered, coverTotal = changedLineCoverage(o.repo, o.base, pkgs, srcFiles)
|
||||
pins = differentialPerTest(o.repo, o.base, srcFiles, refs)
|
||||
mut = runMutation(o.repo, o.base, srcFiles, refs)
|
||||
pins, analysisErr = differentialPerTest(o.repo, o.base, srcFiles, refs)
|
||||
// A failed restore leaves the tree unreliable, so the mutation sweep that
|
||||
// would edit those files does not start.
|
||||
if analysisErr == nil {
|
||||
mut, analysisErr = runMutation(o.repo, o.base, srcFiles, refs)
|
||||
}
|
||||
}
|
||||
buildOK, buildOut := goBuildAll(o.repo)
|
||||
|
||||
passed := len(refs) > 0 && testsPass && buildOK && countPins(pins) > 0
|
||||
passed := len(refs) > 0 && testsPass && buildOK && analysisErr == nil && countPins(pins) > 0
|
||||
return diffReport{
|
||||
srcFiles: srcFiles, pkgs: pkgs, addedTestLines: countAdded(testDiff),
|
||||
newTests: refs, sourceTouched: sourceTouched, testsPass: testsPass,
|
||||
pins: pins, mut: mut, covered: covered, coverTotal: coverTotal,
|
||||
buildOK: buildOK, buildOut: buildOut, failing: failingTestNames(testOut),
|
||||
passed: passed, profile: o.profile, m: m, runErr: runErr, testOut: testOut, testDiff: testDiff,
|
||||
passed: passed, profile: o.profile, m: m, runErr: runErr, analysisErr: analysisErr,
|
||||
testOut: testOut, testDiff: testDiff,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,6 +208,7 @@ type diffReport struct {
|
||||
attempt, attempts int
|
||||
m runMetrics
|
||||
runErr error
|
||||
analysisErr error
|
||||
testOut string
|
||||
testDiff string
|
||||
}
|
||||
@@ -283,6 +290,9 @@ func renderDiff(r diffReport) string {
|
||||
if r.runErr != nil {
|
||||
fmt.Fprintf(&b, "\n<sub>agent run note: %v</sub>\n", r.runErr)
|
||||
}
|
||||
if line := analysisFailureLine(r); line != "" {
|
||||
b.WriteString(line)
|
||||
}
|
||||
fmt.Fprintf(&b, "\n<sub>Pass = the agent added ≥1 test, the affected packages are green, AND ≥1 new test fails when the PR's source is reverted. \"By assertion\" pins are strong (they check changed behavior); \"by compile only\" pins just need a PR-added symbol — and since Go compiles per package, one compile-coupled test marks every test in its package that way. Mutation is the behavioral signal for additive PRs: each changed function's return is replaced with zero values and the new tests are re-run; \"caught\" means a test asserts that output, \"survived\" means it doesn't. Read the generated tests above to judge the rest.</sub>\n")
|
||||
return b.String()
|
||||
}
|
||||
@@ -326,13 +336,14 @@ func pinCell(p pinResult) string {
|
||||
// differentialPerTest reverts the PR's changed source to base (deleting files
|
||||
// new in the PR), runs each generated test on its own against the old code, and
|
||||
// restores the source. A test that fails on the old code pins the change.
|
||||
func differentialPerTest(repo, base string, srcFiles []string, refs []testRef) []pinResult {
|
||||
func differentialPerTest(repo, base string, srcFiles []string, refs []testRef) (out []pinResult, err error) {
|
||||
for _, f := range srcFiles {
|
||||
if err := exec.Command("git", "-C", repo, "checkout", base, "--", f).Run(); err != nil {
|
||||
if cerr := exec.Command("git", "-C", repo, "checkout", base, "--", f).Run(); cerr != nil {
|
||||
_ = os.Remove(filepath.Join(repo, filepath.FromSlash(f)))
|
||||
}
|
||||
}
|
||||
// Restore source even on panic; a tree left on `base` would mask the PR for later steps.
|
||||
// Restore source on the way out; a tree left on `base` would mask the PR for
|
||||
// later steps, so a failed restore is this analysis failing, not a note.
|
||||
restored := false
|
||||
defer func() {
|
||||
if restored {
|
||||
@@ -341,9 +352,15 @@ func differentialPerTest(repo, base string, srcFiles []string, refs []testRef) [
|
||||
for _, f := range srcFiles {
|
||||
_ = exec.Command("git", "-C", repo, "checkout", "HEAD", "--", f).Run()
|
||||
}
|
||||
if terr := treeMatches(repo, "HEAD", srcFiles); terr != nil {
|
||||
err = terr
|
||||
}
|
||||
}()
|
||||
if err := treeMatches(repo, base, srcFiles); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make([]pinResult, 0, len(refs))
|
||||
out = make([]pinResult, 0, len(refs))
|
||||
for _, r := range refs {
|
||||
cmd := exec.Command("go", "test", "-run", "^"+r.name+"$", r.pkg)
|
||||
cmd.Dir = repo
|
||||
@@ -356,10 +373,15 @@ func differentialPerTest(repo, base string, srcFiles []string, refs []testRef) [
|
||||
})
|
||||
}
|
||||
for _, f := range srcFiles {
|
||||
_ = exec.Command("git", "-C", repo, "checkout", "HEAD", "--", f).Run()
|
||||
if cerr := exec.Command("git", "-C", repo, "checkout", "HEAD", "--", f).Run(); cerr != nil {
|
||||
return nil, fmt.Errorf("restore %s: %w", f, cerr)
|
||||
}
|
||||
}
|
||||
if err := treeMatches(repo, "HEAD", srcFiles); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
restored = true
|
||||
return out
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// changedLineCoverage runs the affected packages with a coverage profile and
|
||||
@@ -659,3 +681,16 @@ func passFail(ok bool) string {
|
||||
}
|
||||
return "fail"
|
||||
}
|
||||
|
||||
// analysisFailureLine says which figures a failed analysis leaves absent. A pin
|
||||
// table that was already produced still stands, so claiming both are missing
|
||||
// would be wrong whenever only the mutation sweep failed.
|
||||
func analysisFailureLine(r diffReport) string {
|
||||
if r.analysisErr == nil {
|
||||
return ""
|
||||
}
|
||||
if len(r.pins) > 0 {
|
||||
return fmt.Sprintf("\n**Analysis did not finish: %v.** The mutation figures are absent, not zero; the pin table above still stands.\n", r.analysisErr)
|
||||
}
|
||||
return fmt.Sprintf("\n**Analysis did not finish: %v.** Pin and mutation figures are absent, not zero.\n", r.analysisErr)
|
||||
}
|
||||
+7
-12
@@ -17,7 +17,6 @@ import (
|
||||
fileencoding "reasonix/internal/base/fileutil/encoding"
|
||||
"reasonix/internal/contract/ablation"
|
||||
"reasonix/internal/contract/config"
|
||||
"reasonix/internal/session/control"
|
||||
)
|
||||
|
||||
type task struct {
|
||||
@@ -121,7 +120,8 @@ type sourceUsage struct {
|
||||
type result struct {
|
||||
task
|
||||
runMetrics
|
||||
Profile string `json:"profile"`
|
||||
SFT *sftProvenance `json:"sft,omitempty"`
|
||||
Profile string `json:"profile"`
|
||||
// Arm is the ablation arm the harness requested, not the arm the child
|
||||
// reported, so a run that died before writing metrics is still attributable.
|
||||
Arm string `json:"arm"`
|
||||
@@ -285,7 +285,7 @@ func main() {
|
||||
timeoutSec := flag.Int("timeout", 1200, "agent timeout in seconds (diff mode)")
|
||||
attempts := flag.Int("attempts", 1, "suite/diff modes: retry a task up to N times until an attempt passes (stochastic agent); enables Pass@≤N")
|
||||
tapeRecord, tapeReplay := registerTapeFlags()
|
||||
trials := flag.Int("trials", 1, "suite mode: run every task N times and keep every outcome; reports pass^N (all passed) beside mean pass@1")
|
||||
trials := flag.Int("trials", 1, "suite mode: run every task N times and keep every outcome; reports pass^N and pass@N over tasks that ran all N trials, beside mean pass@1 over every graded trial")
|
||||
flag.Parse()
|
||||
*trajDir = absTrajectoryDir(*trajDir)
|
||||
if *trials > 1 && *attempts > 1 {
|
||||
@@ -518,15 +518,10 @@ func runSuite(cfg suiteConfig, tasks []task) []result {
|
||||
// then drops in verify.sh and runs it as the grader. The grader is added only
|
||||
// after the run so the agent can't read the answer key.
|
||||
func runTask(cfg suiteConfig, t task) result {
|
||||
r := result{task: t, Profile: cfg.profile, CacheArm: cfg.cacheArm, Effort: cfg.effort, ReplyLanguage: cfg.replyLanguage}
|
||||
r.Arm = cfg.arm.Arm()
|
||||
r.Anchor = cfg.anchor
|
||||
t.Prompt = anchorPrompt(cfg.anchor, t)
|
||||
if cfg.forcePlanner {
|
||||
// The host's own routing marker, so the two-model turn engages without
|
||||
// putting an English sentence in front of every prompt.
|
||||
t.Prompt = control.PlannerRouteMarker + "\n\n" + t.Prompt
|
||||
r.PlanForced = true
|
||||
t, r, err := prepareRunTask(cfg, t)
|
||||
if err != nil {
|
||||
r.Note = "SFT provenance: " + err.Error()
|
||||
return r
|
||||
}
|
||||
|
||||
// The per-leg file names are decided in runSegments; only the directory has
|
||||
|
||||
+14
-1
@@ -53,6 +53,10 @@ type meterUsage struct {
|
||||
DivergedAt int `json:"diverged_at,omitempty"`
|
||||
Divergence string `json:"divergence,omitempty"`
|
||||
TapeMissing int `json:"tape_missing,omitempty"`
|
||||
// TapeIncomplete counts recorded exchanges that could not be written, so a
|
||||
// recording that cannot be replayed is reported rather than assumed.
|
||||
TapeIncomplete int `json:"tape_incomplete,omitempty"`
|
||||
TapeError string `json:"tape_error,omitempty"`
|
||||
}
|
||||
|
||||
// faultScript decides which requests fail. Absolute indices pin a failure to
|
||||
@@ -220,7 +224,16 @@ func (m *meter) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
src := io.Reader(resp.Body)
|
||||
if m.tape != nil {
|
||||
src = io.TeeReader(resp.Body, &captured)
|
||||
defer func() { m.tape.save(index, body, resp.StatusCode, resp.Header.Get("Content-Type"), captured.Bytes()) }()
|
||||
defer func() {
|
||||
if err := m.tape.save(index, body, resp.StatusCode, resp.Header.Get("Content-Type"), captured.Bytes()); err != nil {
|
||||
m.mu.Lock()
|
||||
m.TapeIncomplete++
|
||||
if m.TapeError == "" {
|
||||
m.TapeError = err.Error()
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
if strings.Contains(resp.Header.Get("Content-Type"), "event-stream") {
|
||||
m.pipeStream(w, src)
|
||||
|
||||
@@ -47,12 +47,14 @@ func (rm runMeter) close() {
|
||||
}
|
||||
}
|
||||
|
||||
// record attaches what the proxy observed. It stays separate from close so the
|
||||
// snapshot is taken while the run's numbers are still being assembled.
|
||||
// record attaches what the proxy observed. It stops the meter first, because a
|
||||
// handler's deferred write can land after the last response is read and stopping
|
||||
// is what waits for those handlers; a snapshot taken before that misses them.
|
||||
func (rm runMeter) record(r *result) {
|
||||
if rm.m == nil {
|
||||
return
|
||||
}
|
||||
rm.close()
|
||||
observed := rm.m.snapshot()
|
||||
r.Meter = &observed
|
||||
}
|
||||
|
||||
@@ -25,14 +25,13 @@ type mutationResult struct {
|
||||
// that package, and records whether they catch the mutation. A caught mutant
|
||||
// means a test actually asserts that function's output; a survivor means the new
|
||||
// tests don't check it.
|
||||
func runMutation(repo, base string, srcFiles []string, refs []testRef) mutationResult {
|
||||
func runMutation(repo, base string, srcFiles []string, refs []testRef) (res mutationResult, err error) {
|
||||
changed := changedLineSet(repo, base, srcFiles)
|
||||
byPkg := map[string][]string{}
|
||||
for _, r := range refs {
|
||||
byPkg[r.pkg] = append(byPkg[r.pkg], r.name)
|
||||
}
|
||||
|
||||
var res mutationResult
|
||||
for _, file := range srcFiles {
|
||||
if res.total >= maxMutants {
|
||||
break
|
||||
@@ -63,22 +62,28 @@ func runMutation(repo, base string, srcFiles []string, refs []testRef) mutationR
|
||||
lb := fset.Position(fd.Body.Lbrace).Offset
|
||||
rb := fset.Position(fd.Body.Rbrace).Offset
|
||||
mutated := src[:lb] + mutantBody(fset, fd.Type.Results) + src[rb+1:]
|
||||
if os.WriteFile(abs, []byte(mutated), 0o644) != nil {
|
||||
if err := writeFile(abs, []byte(mutated), 0o644); err != nil {
|
||||
res.total--
|
||||
continue
|
||||
}
|
||||
cmd := exec.Command("go", "test", "-run", runRe, pkg)
|
||||
cmd.Dir = repo
|
||||
cmd.WaitDelay = 2 * time.Minute // bound the wait for a mutant that wedges a test
|
||||
// Restore source even on panic; a file left mutated would corrupt the next mutant.
|
||||
// Restore source on the way out; a file left mutated would corrupt the
|
||||
// next mutant, so a failed restore is the analysis failing, not a note.
|
||||
restored := false
|
||||
defer func() {
|
||||
if !restored {
|
||||
_ = os.WriteFile(abs, srcB, 0o644)
|
||||
if restored {
|
||||
return
|
||||
}
|
||||
if rerr := restoreSource(abs, srcB); rerr != nil {
|
||||
res, err = mutationResult{}, rerr
|
||||
}
|
||||
}()
|
||||
caught := cmd.Run() != nil
|
||||
_ = os.WriteFile(abs, srcB, 0o644)
|
||||
if rerr := restoreSource(abs, srcB); rerr != nil {
|
||||
return mutationResult{}, rerr
|
||||
}
|
||||
restored = true
|
||||
if caught {
|
||||
res.caught++
|
||||
@@ -87,7 +92,7 @@ func runMutation(repo, base string, srcFiles []string, refs []testRef) mutationR
|
||||
}
|
||||
}
|
||||
}
|
||||
return res
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// changedFuncs returns the funcs in f whose line range overlaps a changed line.
|
||||
|
||||
@@ -202,6 +202,7 @@ func renderBody(results []result) string {
|
||||
b.WriteString(renderAuxiliarySpend(s.bySource, s.currency))
|
||||
b.WriteString(renderMeterAccounting(results))
|
||||
b.WriteString(renderTapeReplay(results))
|
||||
b.WriteString(renderTapeRecord(results))
|
||||
b.WriteString(renderFaultRecovery(results))
|
||||
b.WriteString(renderTimeAttribution(results))
|
||||
b.WriteString(renderSolveProfiles(results))
|
||||
|
||||
+26
-16
@@ -7,10 +7,11 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// reproductionSection says whether two reports are one capability version on
|
||||
// one corpus, and whether their rates differ by more than chance would. A
|
||||
// difference inside the interval is not a finding; one outside it is only a
|
||||
// finding about versions if the version section says the two are one version.
|
||||
// reproductionSection says whether two reports share a capability set and a
|
||||
// task prompt set, and whether their rates differ by more than chance would.
|
||||
// Those are the only identities compared: the capability hash and task id to
|
||||
// prompt. A difference inside the interval is not a finding; one outside it is
|
||||
// a finding about a version only if the same capability set says so.
|
||||
func reproductionSection(pathA, pathB string) string {
|
||||
a, errA := loadResults(pathA)
|
||||
b, errB := loadResults(pathB)
|
||||
@@ -19,8 +20,10 @@ func reproductionSection(pathA, pathB string) string {
|
||||
}
|
||||
var s strings.Builder
|
||||
s.WriteString("\n### Reproduction\n\n")
|
||||
fmt.Fprintf(&s, "- version: %s\n", versionVerdict(a, b))
|
||||
fmt.Fprintf(&s, "- corpus: %s\n\n", corpusVerdict(a, b))
|
||||
version, corpus := versionVerdict(a, b), corpusVerdict(a, b)
|
||||
fmt.Fprintf(&s, "- capability set: %s\n", version.text)
|
||||
fmt.Fprintf(&s, "- task prompts: %s\n", corpus.text)
|
||||
s.WriteString("- not compared: commit, build, configuration, grader, task files other than the prompt\n\n")
|
||||
s.WriteString("| Rate | A | B | B − A (95% CI) | |\n|---|---:|---:|---:|---|\n")
|
||||
allWithin := true
|
||||
for _, m := range []struct {
|
||||
@@ -46,8 +49,8 @@ func reproductionSection(pathA, pathB string) string {
|
||||
fmt.Fprintf(&s, "| %s | %d/%d | %d/%d | %+.1fpp (%+.1f, %+.1f) | %s |\n", m.name, ka, na, kb, nb,
|
||||
100*(float64(kb)/float64(nb)-float64(ka)/float64(na)), 100*lo, 100*hi, verdict)
|
||||
}
|
||||
if allWithin && strings.HasPrefix(versionVerdict(a, b), "one version") && strings.HasPrefix(corpusVerdict(a, b), "same") {
|
||||
s.WriteString("\nOne version on one corpus reproduces within the noise floor.\n")
|
||||
if allWithin && version.same && corpus.same {
|
||||
s.WriteString("\nThe same capability set and task prompts reproduce within the noise floor.\n")
|
||||
}
|
||||
return s.String()
|
||||
}
|
||||
@@ -56,7 +59,14 @@ func counted(results []result) []result {
|
||||
return slices.DeleteFunc(slices.Clone(results), func(r result) bool { return r.Skipped || r.Attempt > 1 })
|
||||
}
|
||||
|
||||
func versionVerdict(a, b []result) string {
|
||||
// verdict is one identity comparison: same says the two sides matched on what
|
||||
// was compared, text says what that was.
|
||||
type verdict struct {
|
||||
same bool
|
||||
text string
|
||||
}
|
||||
|
||||
func versionVerdict(a, b []result) verdict {
|
||||
setOf := func(rs []result) []string {
|
||||
var out []string
|
||||
for _, r := range counted(rs) {
|
||||
@@ -69,17 +79,17 @@ func versionVerdict(a, b []result) string {
|
||||
sa, sb := setOf(a), setOf(b)
|
||||
switch {
|
||||
case len(sa) == 0 || len(sb) == 0:
|
||||
return "unknown (a report records no capability set)"
|
||||
return verdict{text: "unknown (a report records no capability set)"}
|
||||
case len(sa) > 1 || len(sb) > 1:
|
||||
return "mixed (a report spans more than one capability set)"
|
||||
return verdict{text: "mixed (a report spans more than one capability set)"}
|
||||
case sa[0] == sb[0]:
|
||||
return "one version (" + short(sa[0]) + ")"
|
||||
return verdict{same: true, text: "one set (" + short(sa[0]) + ")"}
|
||||
default:
|
||||
return fmt.Sprintf("two versions (%s vs %s): a difference is a comparison, not a reproduction", short(sa[0]), short(sb[0]))
|
||||
return verdict{text: fmt.Sprintf("two capability sets (%s vs %s): a difference is a comparison, not a reproduction", short(sa[0]), short(sb[0]))}
|
||||
}
|
||||
}
|
||||
|
||||
func corpusVerdict(a, b []result) string {
|
||||
func corpusVerdict(a, b []result) verdict {
|
||||
prompts := func(rs []result) map[string]string {
|
||||
out := map[string]string{}
|
||||
for _, r := range counted(rs) {
|
||||
@@ -100,9 +110,9 @@ func corpusVerdict(a, b []result) string {
|
||||
}
|
||||
}
|
||||
if differ == 0 {
|
||||
return fmt.Sprintf("same %d tasks", len(pa))
|
||||
return verdict{same: true, text: fmt.Sprintf("same %d task ids and prompts", len(pa))}
|
||||
}
|
||||
return fmt.Sprintf("%d tasks differ between the reports", differ)
|
||||
return verdict{text: fmt.Sprintf("%d task ids or prompts differ between the reports", differ)}
|
||||
}
|
||||
|
||||
func passRate(rs []result) (int, int) {
|
||||
|
||||
@@ -46,17 +46,22 @@ func TestReproductionSection(t *testing.T) {
|
||||
a := writeReport(t, dir, "a.json", "sha256:aaaaaaaaaaaaaaaa", 40, 50)
|
||||
same := writeReport(t, dir, "b.json", "sha256:aaaaaaaaaaaaaaaa", 38, 50)
|
||||
got := reproductionSection(a, same)
|
||||
for _, want := range []string{"version: one version (aaaaaaaaaaaa)", "corpus: same 50 tasks", "within noise", "reproduces within the noise floor"} {
|
||||
for _, want := range []string{"capability set: one set (aaaaaaaaaaaa)", "task prompts: same 50 task ids and prompts", "not compared: commit, build, configuration, grader", "within noise", "The same capability set and task prompts reproduce within the noise floor."} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("same version report missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
for _, claim := range []string{"One version", "one corpus"} {
|
||||
if strings.Contains(got, claim) {
|
||||
t.Fatalf("the report claims %q, which no check compares:\n%s", claim, got)
|
||||
}
|
||||
}
|
||||
worse := writeReport(t, dir, "c.json", "sha256:aaaaaaaaaaaaaaaa", 5, 50)
|
||||
if got := reproductionSection(a, worse); !strings.Contains(got, "differs") || strings.Contains(got, "reproduces") {
|
||||
t.Fatalf("a 70pp drop read as noise:\n%s", got)
|
||||
}
|
||||
other := writeReport(t, dir, "d.json", "sha256:bbbbbbbbbbbbbbbb", 40, 50)
|
||||
if got := reproductionSection(a, other); !strings.Contains(got, "two versions") || strings.Contains(got, "reproduces") {
|
||||
if got := reproductionSection(a, other); !strings.Contains(got, "two capability sets") || strings.Contains(got, "reproduces") {
|
||||
t.Fatalf("two versions read as a reproduction:\n%s", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Restoring the source is I/O like any other step, and an analysis must not
|
||||
// report a verdict about a tree it failed to restore. These helpers make that
|
||||
// failure loud, and are variables so a test can inject one.
|
||||
|
||||
var (
|
||||
writeFile = os.WriteFile
|
||||
readFile = os.ReadFile
|
||||
// treeMatches is a variable so a test can inject the mismatch a restore guards.
|
||||
treeMatches = treeMatchesTree
|
||||
)
|
||||
|
||||
// restoreSource writes the original bytes back and proves they are back.
|
||||
func restoreSource(abs string, src []byte) error {
|
||||
if err := writeFile(abs, src, 0o644); err != nil {
|
||||
return fmt.Errorf("restore %s: %w", abs, err)
|
||||
}
|
||||
got, err := readFile(abs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("verify %s: %w", abs, err)
|
||||
}
|
||||
if !bytes.Equal(got, src) {
|
||||
return fmt.Errorf("verify %s: %d bytes on disk, %d expected", abs, len(got), len(src))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// treeMatches checks files against a revision. A checkout that exited zero is
|
||||
// not evidence the tree moved; the tree is.
|
||||
func treeMatchesTree(repo, rev string, files []string) error {
|
||||
args := append([]string{"-C", repo, "diff", "--exit-code", rev, "--"}, files...)
|
||||
out, err := exec.Command("git", args...).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s does not match %s after restore: %w: %s", repo, rev, err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func withFakeIO(t *testing.T, w func(string, []byte, os.FileMode) error, r func(string) ([]byte, error)) {
|
||||
t.Helper()
|
||||
ow, or := writeFile, readFile
|
||||
writeFile, readFile = w, r
|
||||
t.Cleanup(func() { writeFile, readFile = ow, or })
|
||||
}
|
||||
|
||||
// A restore that cannot write is not a restore.
|
||||
func TestRestoreSourceFailsWhenTheWriteFails(t *testing.T) {
|
||||
withFakeIO(t, func(string, []byte, os.FileMode) error { return errors.New("disk full") }, os.ReadFile)
|
||||
err := restoreSource("/tmp/unused", []byte("original"))
|
||||
if err == nil || !strings.Contains(err.Error(), "disk full") {
|
||||
t.Fatalf("a failed restore write was not reported: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A restore that wrote something else is not a restore either.
|
||||
func TestRestoreSourceFailsWhenTheBytesDoNotComeBack(t *testing.T) {
|
||||
withFakeIO(t,
|
||||
func(string, []byte, os.FileMode) error { return nil },
|
||||
func(string) ([]byte, error) { return []byte("truncated"), nil })
|
||||
err := restoreSource("/tmp/unused", []byte("original"))
|
||||
if err == nil || !strings.Contains(err.Error(), "bytes on disk") {
|
||||
t.Fatalf("a silent truncation passed verification: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The real filesystem is the other witness: writing over a directory fails.
|
||||
func TestRestoreSourceFailsOnARealWriteError(t *testing.T) {
|
||||
if err := restoreSource(t.TempDir(), []byte("original")); err == nil {
|
||||
t.Fatal("restoring onto a directory reported success")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTreeMatchesSeesADirtyTree(t *testing.T) {
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("git unavailable")
|
||||
}
|
||||
repo := t.TempDir()
|
||||
git := func(args ...string) {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = repo
|
||||
cmd.Env = append(os.Environ(),
|
||||
"GIT_AUTHOR_NAME=t", "[email protected]",
|
||||
"GIT_COMMITTER_NAME=t", "[email protected]")
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("git %v: %v: %s", args, err, out)
|
||||
}
|
||||
}
|
||||
git("init", "-q")
|
||||
file := "a.go"
|
||||
if err := os.WriteFile(filepath.Join(repo, file), []byte("package a\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
git("add", file)
|
||||
git("commit", "-qm", "init")
|
||||
|
||||
if err := treeMatches(repo, "HEAD", []string{file}); err != nil {
|
||||
t.Fatalf("a clean tree was reported dirty: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(repo, file), []byte("package a // mutated\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := treeMatches(repo, "HEAD", []string{file}); err == nil {
|
||||
t.Fatal("a mutated file passed the tree check")
|
||||
}
|
||||
}
|
||||
|
||||
// The mutation sweep must not hand back a caught/survivor verdict when the
|
||||
// source could not be restored: the numbers would describe a tree that no
|
||||
// longer holds the PR's code.
|
||||
func TestRunMutationFailsWhenTheRestoreFails(t *testing.T) {
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("git unavailable")
|
||||
}
|
||||
if _, err := exec.LookPath("go"); err != nil {
|
||||
t.Skip("go unavailable")
|
||||
}
|
||||
repo := t.TempDir()
|
||||
git := func(args ...string) string {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = repo
|
||||
cmd.Env = append(os.Environ(),
|
||||
"GIT_AUTHOR_NAME=t", "[email protected]",
|
||||
"GIT_COMMITTER_NAME=t", "[email protected]")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %v: %v: %s", args, err, out)
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
mustWrite := func(rel, body string) {
|
||||
t.Helper()
|
||||
abs := filepath.Join(repo, rel)
|
||||
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(abs, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
mustWrite("go.mod", "module mutationfixture\n\ngo 1.24\n")
|
||||
mustWrite("pkg/mut.go", "package pkg\n\nfunc Answer() int {\n\treturn 1\n}\n")
|
||||
mustWrite("pkg/mut_test.go", "package pkg\n\nimport \"testing\"\n\nfunc TestMut(t *testing.T) {\n\tif Answer() != 2 {\n\t\tt.Fatalf(\"got %d\", Answer())\n\t}\n}\n")
|
||||
git("init", "-q")
|
||||
git("add", "-A")
|
||||
git("commit", "-qm", "base")
|
||||
base := git("rev-parse", "HEAD")
|
||||
|
||||
mustWrite("pkg/mut.go", "package pkg\n\nfunc Answer() int {\n\treturn 2\n}\n")
|
||||
git("add", "-A")
|
||||
git("commit", "-qm", "change")
|
||||
|
||||
// The first write mutates the source, the second restores it. Failing the
|
||||
// second one is exactly the case the sweep used to swallow.
|
||||
calls := 0
|
||||
withFakeIO(t, func(path string, body []byte, mode os.FileMode) error {
|
||||
calls++
|
||||
if calls == 2 {
|
||||
return errors.New("disk full")
|
||||
}
|
||||
return os.WriteFile(path, body, mode)
|
||||
}, os.ReadFile)
|
||||
|
||||
res, err := runMutation(repo, base, []string{"pkg/mut.go"}, []testRef{{name: "TestMut", pkg: "./pkg"}})
|
||||
if err == nil {
|
||||
t.Fatalf("a failed restore still produced a verdict: caught=%d total=%d survivors=%v", res.caught, res.total, res.survivors)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "disk full") {
|
||||
t.Fatalf("error does not name the failing restore: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The same guarantee on the differential side: a restore that cannot put a file
|
||||
// back must stop the analysis instead of reporting pins from a mixed tree.
|
||||
func TestDifferentialPerTestFailsWhenRestoreFails(t *testing.T) {
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("git unavailable")
|
||||
}
|
||||
repo := t.TempDir()
|
||||
git := func(args ...string) string {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = repo
|
||||
cmd.Env = append(os.Environ(),
|
||||
"GIT_AUTHOR_NAME=t", "[email protected]",
|
||||
"GIT_COMMITTER_NAME=t", "[email protected]")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %v: %v: %s", args, err, out)
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
write := func(rel, body string) {
|
||||
t.Helper()
|
||||
abs := filepath.Join(repo, rel)
|
||||
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(abs, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
write("go.mod", "module difffixture\n\ngo 1.24\n")
|
||||
write("pkg/mut.go", "package pkg\n\nfunc Answer() int {\n\treturn 1\n}\n")
|
||||
git("init", "-q")
|
||||
git("add", "-A")
|
||||
git("commit", "-qm", "base")
|
||||
base := git("rev-parse", "HEAD")
|
||||
|
||||
write("pkg/mut.go", "package pkg\n\nfunc Answer() int {\n\treturn 2\n}\n")
|
||||
git("add", "-A")
|
||||
git("commit", "-qm", "change")
|
||||
|
||||
// pkg/missing.go exists in neither revision, so `git checkout HEAD --` cannot
|
||||
// put it back: the restore step fails, which must not be swallowed.
|
||||
srcFiles := []string{"pkg/mut.go", "pkg/missing.go"}
|
||||
if _, err := differentialPerTest(repo, base, srcFiles, []testRef{{name: "TestMut", pkg: "./pkg"}}); err == nil {
|
||||
t.Fatal("a failed restore still produced pin results")
|
||||
}
|
||||
}
|
||||
|
||||
// The base side is checked before any pin runs, and that check can fail too: the
|
||||
// tree must not be left on base just because the failure happened early. The
|
||||
// defer that restores is registered before the check for exactly this path.
|
||||
func TestDifferentialPerTestRestoresWhenTheBaseCheckFails(t *testing.T) {
|
||||
repo := t.TempDir()
|
||||
run := func(args ...string) {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", append([]string{"-C", repo}, args...)...)
|
||||
cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t",
|
||||
"GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t")
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("git %v: %v: %s", args, err, out)
|
||||
}
|
||||
}
|
||||
run("init", "-q")
|
||||
if err := os.WriteFile(filepath.Join(repo, "f.go"), []byte("base\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run("add", "f.go")
|
||||
run("commit", "-qm", "base")
|
||||
base := strings.TrimSpace(gitOut(repo, "rev-parse", "HEAD"))
|
||||
if err := os.WriteFile(filepath.Join(repo, "f.go"), []byte("head\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run("commit", "-aqm", "head")
|
||||
|
||||
orig := treeMatches
|
||||
treeMatches = func(r, rev string, files []string) error {
|
||||
if rev == base {
|
||||
return errors.New("injected: base side does not match")
|
||||
}
|
||||
return treeMatchesTree(r, rev, files)
|
||||
}
|
||||
t.Cleanup(func() { treeMatches = orig })
|
||||
|
||||
out, err := differentialPerTest(repo, base, []string{"f.go"}, nil)
|
||||
if err == nil {
|
||||
t.Fatalf("a failed base check reported success with %d pins", len(out))
|
||||
}
|
||||
got, rerr := os.ReadFile(filepath.Join(repo, "f.go"))
|
||||
if rerr != nil {
|
||||
t.Fatal(rerr)
|
||||
}
|
||||
if strings.TrimSpace(string(got)) != "head" {
|
||||
t.Fatalf("the tree was left on base: f.go = %q, want the PR's content", strings.TrimSpace(string(got)))
|
||||
}
|
||||
}
|
||||
|
||||
// A pin table that was already produced still stands, so the failure line must
|
||||
// not claim both figures are missing when only the mutation sweep failed.
|
||||
func TestAnalysisFailureLineNamesWhatIsMissing(t *testing.T) {
|
||||
only := analysisFailureLine(diffReport{analysisErr: errors.New("boom"), pins: []pinResult{{}}})
|
||||
if !strings.Contains(only, "mutation figures are absent") || !strings.Contains(only, "pin table above still stands") {
|
||||
t.Fatalf("with pins present the line reads %q", only)
|
||||
}
|
||||
if strings.Contains(only, "Pin and mutation figures are absent") {
|
||||
t.Fatalf("with pins present the line still claims both are absent: %q", only)
|
||||
}
|
||||
both := analysisFailureLine(diffReport{analysisErr: errors.New("boom")})
|
||||
if !strings.Contains(both, "Pin and mutation figures are absent") {
|
||||
t.Fatalf("with no pins the line reads %q", both)
|
||||
}
|
||||
if analysisFailureLine(diffReport{}) != "" {
|
||||
t.Fatal("a finished analysis produced a failure line")
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,7 @@ func decideRollout(baseline, candidate []result) rolloutDecision {
|
||||
case bv == cv:
|
||||
hold("rollout.same_version")
|
||||
}
|
||||
if !strings.HasPrefix(corpusVerdict(baseline, candidate), "same") {
|
||||
if !corpusVerdict(baseline, candidate).same {
|
||||
hold("rollout.corpus_mismatch")
|
||||
}
|
||||
if !slices.ContainsFunc(counted(candidate), func(r result) bool { return r.NoSolution }) {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package main
|
||||
|
||||
import "reasonix/internal/session/control"
|
||||
|
||||
func prepareRunTask(cfg suiteConfig, t task) (task, result, error) {
|
||||
r := result{task: t, Profile: cfg.profile, CacheArm: cfg.cacheArm, Effort: cfg.effort, ReplyLanguage: cfg.replyLanguage}
|
||||
r.Arm = cfg.arm.Arm()
|
||||
r.Anchor = cfg.anchor
|
||||
t.Prompt = anchorPrompt(cfg.anchor, t)
|
||||
if cfg.forcePlanner {
|
||||
// The host's own routing marker, so the two-model turn engages without
|
||||
// putting an English sentence in front of every prompt.
|
||||
t.Prompt = control.PlannerRouteMarker + "\n\n" + t.Prompt
|
||||
r.PlanForced = true
|
||||
}
|
||||
if cfg.trajDir != "" {
|
||||
source, err := newSFTProvenance(t)
|
||||
if err != nil {
|
||||
return t, r, err
|
||||
}
|
||||
r.SFT = source
|
||||
}
|
||||
|
||||
return t, r, nil
|
||||
}
|
||||
+55
-38
@@ -4,8 +4,11 @@ package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
@@ -44,8 +47,9 @@ type sftToolFunction struct {
|
||||
// from trajectoryRecord because that one deliberately omits tool output: the
|
||||
// analysis passes would otherwise hold every result of every run in memory.
|
||||
type sftRecord struct {
|
||||
RunHeader *sftRunHeader `json:"run_header"`
|
||||
Event *struct {
|
||||
BenchmarkRun *sftProvenance `json:"benchmark_run"`
|
||||
RunHeader *sftRunHeader `json:"run_header"`
|
||||
Event *struct {
|
||||
Kind string `json:"kind"`
|
||||
Text string `json:"text"`
|
||||
Reasoning string `json:"reasoning"`
|
||||
@@ -162,22 +166,28 @@ func convertSFT(records []sftRecord) []sftMessage {
|
||||
}
|
||||
|
||||
func readSFTRecords(path string) ([]sftRecord, error) {
|
||||
records, _, err := readSFTSnapshot(path)
|
||||
return records, err
|
||||
}
|
||||
|
||||
func readSFTSnapshot(path string) ([]sftRecord, string, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, "", err
|
||||
}
|
||||
defer f.Close()
|
||||
var out []sftRecord
|
||||
sc := bufio.NewScanner(f)
|
||||
h := sha256.New()
|
||||
sc := bufio.NewScanner(io.TeeReader(f, h))
|
||||
sc.Buffer(make([]byte, 0, 1<<20), 64<<20)
|
||||
for sc.Scan() {
|
||||
var rec sftRecord
|
||||
if err := json.Unmarshal(sc.Bytes(), &rec); err != nil {
|
||||
continue
|
||||
return nil, "", err
|
||||
}
|
||||
out = append(out, rec)
|
||||
}
|
||||
return out, sc.Err()
|
||||
return out, hex.EncodeToString(h.Sum(nil)), sc.Err()
|
||||
}
|
||||
|
||||
// verifyPrefix reports the header and how many of the run's rounds it provably
|
||||
@@ -213,6 +223,10 @@ func buildSFTSample(id, prompt, path string) (*sftSample, string) {
|
||||
if err != nil {
|
||||
return nil, "unreadable: " + err.Error()
|
||||
}
|
||||
return buildSFTSampleRecords(id, prompt, records)
|
||||
}
|
||||
|
||||
func buildSFTSampleRecords(id, prompt string, records []sftRecord) (*sftSample, string) {
|
||||
hdr, covered, total := verifyPrefix(records)
|
||||
if hdr == nil {
|
||||
return nil, "no run header"
|
||||
@@ -267,36 +281,27 @@ func runSFTMode(dir, suite, reportPath, out string) error {
|
||||
return fmt.Errorf("no *.trajectory.jsonl files under %s", dir)
|
||||
}
|
||||
slices.Sort(paths)
|
||||
prompts, err := sftPrompts(suite)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
passed, err := sftVerdicts(reportPath)
|
||||
inputs, err := sftInputs(paths, suite, reportPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
f, err := os.Create(out)
|
||||
f, err := os.CreateTemp(filepath.Dir(out), ".sft-export-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
defer os.Remove(f.Name())
|
||||
buf := bufio.NewWriter(f)
|
||||
enc := json.NewEncoder(buf)
|
||||
kept := 0
|
||||
for _, p := range paths {
|
||||
id := strings.TrimSuffix(filepath.Base(p), ".trajectory.jsonl")
|
||||
switch {
|
||||
case !passed[id]:
|
||||
fmt.Fprintf(os.Stderr, "skip %s: the grader did not pass it\n", id)
|
||||
continue
|
||||
case prompts[id] == "":
|
||||
fmt.Fprintf(os.Stderr, "skip %s: no task prompt under %s\n", id, suite)
|
||||
continue
|
||||
for input, err := range inputs {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sample, why := buildSFTSample(id, prompts[id], p)
|
||||
sample, why := buildSFTSampleRecords(input.id, input.prompt, input.records)
|
||||
if sample == nil {
|
||||
fmt.Fprintf(os.Stderr, "skip %s: %s\n", id, why)
|
||||
fmt.Fprintf(os.Stderr, "skip %s: %s\n", input.id, why)
|
||||
continue
|
||||
}
|
||||
if err := enc.Encode(sample); err != nil {
|
||||
@@ -307,23 +312,17 @@ func runSFTMode(dir, suite, reportPath, out string) error {
|
||||
if err := buf.Flush(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(f.Name(), out); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "wrote %d of %d runs to %s\n", kept, len(paths), out)
|
||||
return nil
|
||||
}
|
||||
|
||||
func sftPrompts(suite string) (map[string]string, error) {
|
||||
tasks, err := loadTasks(suite)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[string]string, len(tasks))
|
||||
for _, t := range tasks {
|
||||
out[t.ID] = t.Prompt
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func sftVerdicts(path string) (map[string]bool, error) {
|
||||
func sftVerdicts(path string) (map[string]map[string]result, error) {
|
||||
if path == "" {
|
||||
return nil, fmt.Errorf("-report is required: it names the run report whose grader verdicts decide what is kept")
|
||||
}
|
||||
@@ -335,9 +334,27 @@ func sftVerdicts(path string) (map[string]bool, error) {
|
||||
if err := json.Unmarshal(raw, &results); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[string]bool, len(results))
|
||||
out := make(map[string]map[string]result, len(results))
|
||||
for _, r := range results {
|
||||
out[r.ID] = r.Passed
|
||||
if r.Skipped {
|
||||
continue
|
||||
}
|
||||
if r.Segments > 1 {
|
||||
return nil, fmt.Errorf("%s: SFT export does not support segmented runs", r.ID)
|
||||
}
|
||||
if r.SFT == nil || r.SFT.RunID == "" || r.SFT.TaskSHA256 == "" || r.SFT.TrajectorySHA256 == "" || r.SFT.Prompt == "" {
|
||||
if !r.Passed {
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("%s: report lacks SFT provenance; regenerate it with trajectories", r.ID)
|
||||
}
|
||||
if out[r.ID] == nil {
|
||||
out[r.ID] = make(map[string]result)
|
||||
}
|
||||
if _, exists := out[r.ID][r.SFT.TrajectorySHA256]; exists {
|
||||
return nil, fmt.Errorf("%s: duplicate verdict for one trajectory", r.ID)
|
||||
}
|
||||
out[r.ID][r.SFT.TrajectorySHA256] = r
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"iter"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
type sftProvenance struct {
|
||||
RunID string `json:"run_id"`
|
||||
TaskSHA256 string `json:"task_sha256"`
|
||||
Prompt string `json:"prompt"`
|
||||
TrajectorySHA256 string `json:"trajectory_sha256,omitempty"`
|
||||
}
|
||||
|
||||
func newSFTProvenance(t task) (*sftProvenance, error) {
|
||||
digest, err := sftTaskDigest(t)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &sftProvenance{RunID: rand.Text(), TaskSHA256: digest, Prompt: t.Prompt}, nil
|
||||
}
|
||||
|
||||
func sftTaskDigest(t task) (string, error) {
|
||||
h := sha256.New()
|
||||
enc := json.NewEncoder(h)
|
||||
err := filepath.WalkDir(t.dir, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if path == t.dir {
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(t.dir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rel == "solution" && d.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("task input is not a regular file: %s", path)
|
||||
}
|
||||
digest, err := sftFileDigest(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return enc.Encode(struct {
|
||||
Path string
|
||||
Executable uint32
|
||||
Digest string
|
||||
}{filepath.ToSlash(rel), uint32(info.Mode().Perm() & 0o111), digest})
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
func sftFileDigest(path string) (string, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// The harness binds its inputs after the child closes the trajectory. Event
|
||||
// bytes stay untouched; the unique run header makes repeated attempts distinct.
|
||||
func bindSFTTrajectory(path string, source *sftProvenance) error {
|
||||
in, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.CreateTemp(filepath.Dir(path), ".sft-trajectory-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(out.Name())
|
||||
defer out.Close()
|
||||
header := *source
|
||||
header.TrajectorySHA256 = ""
|
||||
if err := json.NewEncoder(out).Encode(struct {
|
||||
Source sftProvenance `json:"benchmark_run"`
|
||||
}{header}); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := in.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := out.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(out.Name(), path); err != nil {
|
||||
return err
|
||||
}
|
||||
source.TrajectorySHA256, err = sftFileDigest(path)
|
||||
return err
|
||||
}
|
||||
|
||||
type sftInput struct {
|
||||
id, prompt string
|
||||
records []sftRecord
|
||||
}
|
||||
|
||||
func sftInputs(paths []string, suite, report string) (iter.Seq2[sftInput, error], error) {
|
||||
tasks, err := loadTasks(suite)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byTask := make(map[string]task, len(tasks))
|
||||
for _, t := range tasks {
|
||||
byTask[t.ID] = t
|
||||
}
|
||||
verdicts, err := sftVerdicts(report)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return func(yield func(sftInput, error) bool) {
|
||||
for _, path := range paths {
|
||||
input, err := sftInputFor(path, suite, byTask, verdicts)
|
||||
if err != nil {
|
||||
yield(sftInput{}, err)
|
||||
return
|
||||
}
|
||||
if input != nil && !yield(*input, nil) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
|
||||
func sftInputFor(path, suite string, tasks map[string]task, verdicts map[string]map[string]result) (*sftInput, error) {
|
||||
id := filepath.Base(path)
|
||||
id = id[:len(id)-len(".trajectory.jsonl")]
|
||||
records, digest, err := readSFTSnapshot(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r, ok := verdicts[id][digest]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s: report does not identify this trajectory; regenerate the report and trajectories together", id)
|
||||
}
|
||||
t, ok := tasks[id]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s: task is absent from %s", id, suite)
|
||||
}
|
||||
taskDigest, err := sftTaskDigest(t)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if taskDigest != r.SFT.TaskSHA256 {
|
||||
return nil, fmt.Errorf("%s: task files differ from the graded run", id)
|
||||
}
|
||||
var source *sftProvenance
|
||||
for _, rec := range records {
|
||||
if rec.BenchmarkRun == nil {
|
||||
continue
|
||||
}
|
||||
if source != nil {
|
||||
return nil, fmt.Errorf("%s: multiple benchmark run headers", id)
|
||||
}
|
||||
source = rec.BenchmarkRun
|
||||
}
|
||||
if source == nil || source.RunID != r.SFT.RunID || source.TaskSHA256 != r.SFT.TaskSHA256 || source.Prompt != r.SFT.Prompt {
|
||||
return nil, fmt.Errorf("%s: benchmark run header does not match the report", id)
|
||||
}
|
||||
if !r.Passed || r.Skipped {
|
||||
return nil, nil
|
||||
}
|
||||
return &sftInput{id, source.Prompt, records}, nil
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
)
|
||||
|
||||
func TestSFTRejectsAMixedBatchBeforeReplacingTheOutput(t *testing.T) {
|
||||
dir := testenv.TempDir(t)
|
||||
writeTraj(t, dir, "fizzbuzz", trajHeader, `{"event":{"kind":"message","text":"failed attempt"}}`, trajUsage)
|
||||
report := filepath.Join(dir, "report.json")
|
||||
if err := os.WriteFile(report, []byte(`[{"ID":"fizzbuzz","Passed":true,"sft":{"trajectory_sha256":"another-run","task_sha256":"another-corpus","prompt":"another prompt"}}]`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := filepath.Join(dir, "sft.jsonl")
|
||||
if err := os.WriteFile(out, []byte("existing export\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := runSFTMode(dir, corpusDir, report, out); err == nil {
|
||||
t.Fatal("a verdict from another run labelled this failed trajectory as passed")
|
||||
}
|
||||
got, err := os.ReadFile(out)
|
||||
if err != nil || string(got) != "existing export\n" {
|
||||
t.Fatalf("refused export replaced the existing output: %q, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func gradedSFTResult(t *testing.T, dir string, task task, passed bool) result {
|
||||
t.Helper()
|
||||
source, err := newSFTProvenance(task)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := bindSFTTrajectory(filepath.Join(dir, task.ID+".trajectory.jsonl"), source); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return result{task: task, Passed: passed, SFT: source}
|
||||
}
|
||||
|
||||
func sftFixture(t *testing.T) (dir, suite, report string, rows []result) {
|
||||
t.Helper()
|
||||
dir = testenv.TempDir(t)
|
||||
suite = filepath.Join(dir, "suite")
|
||||
taskDir := filepath.Join(suite, "tasks", "demo")
|
||||
if err := os.MkdirAll(filepath.Join(taskDir, "workdir"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, body := range map[string]string{"task.toml": "prompt = 'original prompt'\n", "verify.sh": "exit 0\n", "workdir/seed.txt": "original seed"} {
|
||||
if err := os.WriteFile(filepath.Join(taskDir, name), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
tasks, err := loadTasks(suite)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
actual := tasks[0]
|
||||
actual.Prompt = "planner directive\n\n" + actual.Prompt
|
||||
writeTraj(t, dir, "demo", trajHeader, `{"event":{"kind":"message","text":"done"}}`, trajUsage)
|
||||
rows = []result{gradedSFTResult(t, dir, actual, true)}
|
||||
report = filepath.Join(dir, "report.json")
|
||||
writeSFTReport(t, report, rows)
|
||||
return
|
||||
}
|
||||
|
||||
func writeSFTReport(t *testing.T, path string, rows []result) {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(rows)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, raw, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSFTUsesTheRecordedPromptAndAValidatedSnapshot(t *testing.T) {
|
||||
dir, suite, report, rows := sftFixture(t)
|
||||
path := filepath.Join(dir, "demo.trajectory.jsonl")
|
||||
inputs, err := sftInputs([]string{path}, suite, report)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var captured sftInput
|
||||
count := 0
|
||||
for input, err := range inputs {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
captured = input
|
||||
count++
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("got %d inputs", count)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("a later run replaced this file"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sample, why := buildSFTSampleRecords(captured.id, captured.prompt, captured.records)
|
||||
if sample == nil || sample.Messages[1].Content != rows[0].SFT.Prompt {
|
||||
t.Fatalf("lost captured prompt or trajectory: %+v, %s", sample, why)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSFTRejectsChangedTaskInputs(t *testing.T) {
|
||||
for _, file := range []string{"task.toml", "verify.sh", "workdir/seed.txt"} {
|
||||
t.Run(file, func(t *testing.T) {
|
||||
dir, suite, report, _ := sftFixture(t)
|
||||
if err := os.WriteFile(filepath.Join(suite, "tasks", "demo", file), []byte("prompt = 'changed'\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := runSFTMode(dir, suite, report, filepath.Join(dir, "out.jsonl"))
|
||||
if err == nil || !strings.Contains(err.Error(), "task files differ") {
|
||||
t.Fatalf("changed %s accepted: %v", file, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSFTBindsRepeatedAttemptsToTheirOwnVerdicts(t *testing.T) {
|
||||
dir, suite, report, rows := sftFixture(t)
|
||||
writeTraj(t, dir, "demo", trajHeader, `{"event":{"kind":"message","text":"done"}}`, trajUsage)
|
||||
failed := gradedSFTResult(t, dir, rows[0].task, false)
|
||||
if failed.SFT.RunID == rows[0].SFT.RunID || failed.SFT.TrajectorySHA256 == rows[0].SFT.TrajectorySHA256 {
|
||||
t.Fatal("identical event streams from separate attempts share an identity")
|
||||
}
|
||||
writeSFTReport(t, report, append(rows, failed))
|
||||
out := filepath.Join(dir, "out.jsonl")
|
||||
if err := runSFTMode(dir, suite, report, out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(out)
|
||||
if err != nil || len(data) != 0 {
|
||||
t.Fatalf("the latest failed attempt borrowed a passing verdict: %q, %v", data, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSFTRefusesLegacyAndDuplicateReports(t *testing.T) {
|
||||
for _, kind := range []string{"legacy", "duplicate", "header"} {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
dir, suite, report, rows := sftFixture(t)
|
||||
switch kind {
|
||||
case "legacy":
|
||||
rows[0].SFT = nil
|
||||
case "duplicate":
|
||||
rows = append(rows, rows[0])
|
||||
case "header":
|
||||
rows[0].SFT.RunID = "another-run"
|
||||
}
|
||||
writeSFTReport(t, report, rows)
|
||||
if err := runSFTMode(dir, suite, report, filepath.Join(dir, "out.jsonl")); err == nil {
|
||||
t.Fatal("unverifiable report accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunTaskProducesProvenanceThatSFTCanValidate(t *testing.T) {
|
||||
requireShellStub(t)
|
||||
dir, suite, report, _ := sftFixture(t)
|
||||
tasks, err := loadTasks(suite)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bin := filepath.Join(dir, "agent")
|
||||
script := "#!/usr/bin/env bash\nwhile [ $# -gt 0 ]; do\ncase \"$1\" in\n" +
|
||||
"--trajectory) printf '%s\\n' '" + trajHeader + "' '" + trajUsage + "' '{\"event\":{\"kind\":\"message\",\"text\":\"done\"}}' > \"$2\"; shift 2;;\n" +
|
||||
"--metrics) printf '%s\\n' '{\"steps\":1,\"complete\":true}' > \"$2\"; shift 2;;\n*) shift;;\nesac\ndone\n"
|
||||
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := runTask(suiteConfig{bin: bin, trajDir: dir, forcePlanner: true}, tasks[0])
|
||||
if !r.Passed || r.SFT == nil || r.SFT.TrajectorySHA256 == "" || r.Trajectory == nil {
|
||||
t.Fatalf("runner lost provenance, grader, or digest: %+v", r)
|
||||
}
|
||||
writeSFTReport(t, report, []result{r})
|
||||
out := filepath.Join(dir, "out.jsonl")
|
||||
if err := runSFTMode(dir, suite, report, out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := os.ReadFile(out)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var sample sftSample
|
||||
if err := json.Unmarshal(raw, &sample); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sample.Messages[1].Content != r.SFT.Prompt || !r.PlanForced || sample.Messages[1].Content == tasks[0].Prompt {
|
||||
t.Fatal("export did not use the prompt actually passed to the child")
|
||||
}
|
||||
}
|
||||
@@ -122,10 +122,17 @@ func TestRunSFTModeKeepsOnlyGradedPasses(t *testing.T) {
|
||||
writeTraj(t, traj, "palindrome", body...)
|
||||
|
||||
report := filepath.Join(dir, "r.json")
|
||||
raw, _ := json.Marshal([]result{
|
||||
{task: task{ID: "fizzbuzz"}, Passed: true},
|
||||
{task: task{ID: "palindrome"}, Passed: false},
|
||||
})
|
||||
tasks, err := loadTasks(corpusDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var results []result
|
||||
for _, task := range tasks {
|
||||
if task.ID == "fizzbuzz" || task.ID == "palindrome" {
|
||||
results = append(results, gradedSFTResult(t, traj, task, task.ID == "fizzbuzz"))
|
||||
}
|
||||
}
|
||||
raw, _ := json.Marshal(results)
|
||||
if err := os.WriteFile(report, raw, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
+54
-6
@@ -107,6 +107,18 @@ func (c tapeConfig) forRun(taskID string, trial int) (*tape, error) {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// This run replaces the tape. Drop the old completeness markers first, or
|
||||
// a failure here would leave the previous request beside this run's
|
||||
// partial response and read as a complete exchange.
|
||||
stale, err := filepath.Glob(filepath.Join(dir, "*.request.json"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, path := range stale {
|
||||
if err := os.Remove(path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
} else if _, err := os.Stat(dir); err != nil {
|
||||
return nil, fmt.Errorf("no tape for %s: %w", taskID, err)
|
||||
}
|
||||
@@ -122,11 +134,26 @@ func (t *tape) file(index int, suffix string) string {
|
||||
return filepath.Join(t.dir, fmt.Sprintf("%04d.%s", index, suffix))
|
||||
}
|
||||
|
||||
func (t *tape) save(index int, request []byte, status int, contentType string, body []byte) {
|
||||
meta, _ := json.Marshal(tapeResponse{Status: status, ContentType: contentType})
|
||||
_ = os.WriteFile(t.file(index, "request.json"), request, 0o644)
|
||||
_ = os.WriteFile(t.file(index, "response.json"), meta, 0o644)
|
||||
_ = os.WriteFile(t.file(index, "response.body"), body, 0o644)
|
||||
// tapeWriteFile is a variable so a test can inject a write failure.
|
||||
var tapeWriteFile = os.WriteFile
|
||||
|
||||
// save records one exchange. request.json goes last: its presence is what tells
|
||||
// a later replay the exchange is complete rather than truncated.
|
||||
func (t *tape) save(index int, request []byte, status int, contentType string, body []byte) error {
|
||||
meta, err := json.Marshal(tapeResponse{Status: status, ContentType: contentType})
|
||||
if err != nil {
|
||||
return fmt.Errorf("tape %d response meta: %w", index, err)
|
||||
}
|
||||
if err := tapeWriteFile(t.file(index, "response.json"), meta, 0o644); err != nil {
|
||||
return fmt.Errorf("tape %d response.json: %w", index, err)
|
||||
}
|
||||
if err := tapeWriteFile(t.file(index, "response.body"), body, 0o644); err != nil {
|
||||
return fmt.Errorf("tape %d response.body: %w", index, err)
|
||||
}
|
||||
if err := tapeWriteFile(t.file(index, "request.json"), request, 0o644); err != nil {
|
||||
return fmt.Errorf("tape %d request.json: %w", index, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// load returns the recorded answer to request index, and where the request
|
||||
@@ -142,7 +169,12 @@ func (t *tape) load(index int, request []byte) (resp tapeResponse, body []byte,
|
||||
if body, err = os.ReadFile(t.file(index, "response.body")); err != nil {
|
||||
return resp, nil, "", err
|
||||
}
|
||||
recorded, _ := os.ReadFile(t.file(index, "request.json"))
|
||||
recorded, err := os.ReadFile(t.file(index, "request.json"))
|
||||
if err != nil {
|
||||
// Not a divergence: without the recorded request there is nothing to
|
||||
// compare against, and reporting one would blame the harness.
|
||||
return resp, nil, "", fmt.Errorf("tape %d is incomplete: %w", index, err)
|
||||
}
|
||||
return resp, body, requestDiff(recorded, request), nil
|
||||
}
|
||||
|
||||
@@ -249,3 +281,19 @@ func renderTapeReplay(results []result) string {
|
||||
}
|
||||
return line + "\n\n"
|
||||
}
|
||||
|
||||
// renderTapeRecord reports recordings that did not finish, so a run whose tape
|
||||
// cannot be replayed says so instead of looking recorded.
|
||||
func renderTapeRecord(results []result) string {
|
||||
var incomplete []string
|
||||
for _, r := range results {
|
||||
if r.Meter == nil || r.Meter.TapeIncomplete == 0 {
|
||||
continue
|
||||
}
|
||||
incomplete = append(incomplete, fmt.Sprintf("`%s` (%d exchanges): %s", r.ID, r.Meter.TapeIncomplete, r.Meter.TapeError))
|
||||
}
|
||||
if len(incomplete) == 0 {
|
||||
return ""
|
||||
}
|
||||
return "**Tape record incomplete**: " + strings.Join(incomplete, " · ") + "\n\n"
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const tapedStream = "data: {\"choices\":[{\"delta\":{\"content\":\"fixed\"}}]}\n\n" +
|
||||
@@ -103,3 +107,179 @@ func TestRequestDiffReadsStructureBeforeBytes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func waitFor(t *testing.T, what string, cond func() bool) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if cond() {
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("timed out waiting for %s", what)
|
||||
}
|
||||
|
||||
// A recording whose request file cannot be written is reported rather than
|
||||
// treated as a tape: request.json is what marks the exchange complete.
|
||||
func TestTapeSaveFailureIsReportedOnTheMeter(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
orig := tapeWriteFile
|
||||
tapeWriteFile = func(path string, body []byte, mode os.FileMode) error {
|
||||
if strings.HasSuffix(path, "request.json") {
|
||||
return errors.New("disk full")
|
||||
}
|
||||
return orig(path, body, mode)
|
||||
}
|
||||
t.Cleanup(func() { tapeWriteFile = orig })
|
||||
|
||||
var calls atomic.Int32
|
||||
m, base, stop := tapedMeter(t, tapeRecord, root, &calls)
|
||||
defer stop()
|
||||
readAll(t, post(t, base, "/chat/completions", tapedRequest))
|
||||
|
||||
var got meterUsage
|
||||
waitFor(t, "the failed write to be reported", func() bool {
|
||||
got = m.snapshot()
|
||||
return got.TapeIncomplete > 0
|
||||
})
|
||||
if !strings.Contains(got.TapeError, "disk full") {
|
||||
t.Fatalf("incomplete recording = %+v, want the write error named", got)
|
||||
}
|
||||
if markers, _ := filepath.Glob(filepath.Join(root, "bugfix-slug", "trial-1", "*.request.json")); len(markers) != 0 {
|
||||
t.Fatalf("the completeness marker was written although the request write failed: %v", markers)
|
||||
}
|
||||
line := renderTapeRecord([]result{{task: task{ID: "bugfix-slug"}, Meter: &got}})
|
||||
if !strings.Contains(line, "Tape record incomplete") || !strings.Contains(line, "bugfix-slug") {
|
||||
t.Fatalf("report line = %q", line)
|
||||
}
|
||||
if renderTapeRecord(nil) != "" {
|
||||
t.Fatal("an empty run set produced a tape line")
|
||||
}
|
||||
}
|
||||
|
||||
// A tape with no recorded request is incomplete, not a divergence: there is
|
||||
// nothing to compare against, and blaming the harness would be wrong.
|
||||
func TestTapeMissingRequestIsIncompleteNotADivergence(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
var calls atomic.Int32
|
||||
_, base, stop := tapedMeter(t, tapeRecord, root, &calls)
|
||||
readAll(t, post(t, base, "/chat/completions", tapedRequest))
|
||||
dir := filepath.Join(root, "bugfix-slug", "trial-1")
|
||||
var markers []string
|
||||
waitFor(t, "the recorded request", func() bool {
|
||||
markers, _ = filepath.Glob(filepath.Join(dir, "*.request.json"))
|
||||
return len(markers) > 0
|
||||
})
|
||||
stop()
|
||||
if err := os.Remove(markers[0]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
m, base, stop := tapedMeter(t, tapeReplay, root, &calls)
|
||||
defer stop()
|
||||
resp := post(t, base, "/chat/completions", tapedRequest)
|
||||
readAll(t, resp)
|
||||
got := m.snapshot()
|
||||
if resp.StatusCode != http.StatusBadGateway || got.TapeMissing != 1 {
|
||||
t.Fatalf("incomplete tape = %d, missing %d; want 502 and counted", resp.StatusCode, got.TapeMissing)
|
||||
}
|
||||
if got.DivergedAt != 0 || got.Divergence != "" {
|
||||
t.Fatalf("an incomplete tape was reported as a divergence: %d %q", got.DivergedAt, got.Divergence)
|
||||
}
|
||||
}
|
||||
|
||||
// The run path snapshots the meter through record, which must stop the meter
|
||||
// first: stopping is what waits for the handlers whose deferred writes carry the
|
||||
// tape counters. Snapshotting before that misses the last exchange.
|
||||
func TestMeterRecordDrainsHandlersBeforeSnapshot(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
var calls atomic.Int32
|
||||
m, base, stop := tapedMeter(t, tapeRecord, root, &calls)
|
||||
defer stop()
|
||||
readAll(t, post(t, base, "/chat/completions", tapedRequest))
|
||||
|
||||
var r result
|
||||
runMeter{m: m, stop: stop}.record(&r)
|
||||
// No waitFor: the drain inside record is what makes this observable.
|
||||
if r.Meter == nil {
|
||||
t.Fatal("record attached no meter")
|
||||
}
|
||||
if resp, err := http.Post(base+"/chat/completions", "application/json", strings.NewReader(tapedRequest)); err == nil {
|
||||
resp.Body.Close()
|
||||
t.Fatal("the meter was still serving after record, so its handlers were not drained")
|
||||
}
|
||||
}
|
||||
|
||||
// Re-recording over an existing tape must not let the previous run's
|
||||
// completeness marker make a partial new exchange look complete.
|
||||
func TestTapeReRecordClearsStaleCompletenessMarkers(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
var calls atomic.Int32
|
||||
_, base, stop := tapedMeter(t, tapeRecord, root, &calls)
|
||||
readAll(t, post(t, base, "/chat/completions", tapedRequest))
|
||||
dir := filepath.Join(root, "bugfix-slug", "trial-1")
|
||||
waitFor(t, "the first recording", func() bool {
|
||||
m, _ := filepath.Glob(filepath.Join(dir, "*.request.json"))
|
||||
return len(m) > 0
|
||||
})
|
||||
stop()
|
||||
|
||||
// The second recording fails while writing the body, after the request has
|
||||
// already been replaced in memory.
|
||||
orig := tapeWriteFile
|
||||
tapeWriteFile = func(path string, body []byte, mode os.FileMode) error {
|
||||
if strings.HasSuffix(path, "response.body") {
|
||||
return errors.New("disk full")
|
||||
}
|
||||
return orig(path, body, mode)
|
||||
}
|
||||
t.Cleanup(func() { tapeWriteFile = orig })
|
||||
|
||||
var calls2 atomic.Int32
|
||||
m2, base2, stop2 := tapedMeter(t, tapeRecord, root, &calls2)
|
||||
defer stop2()
|
||||
readAll(t, post(t, base2, "/chat/completions", tapedRequest))
|
||||
var r result
|
||||
runMeter{m: m2, stop: stop2}.record(&r)
|
||||
if markers, _ := filepath.Glob(filepath.Join(dir, "*.request.json")); len(markers) != 0 {
|
||||
t.Fatalf("a failed re-record left completeness markers: %v", markers)
|
||||
}
|
||||
// Replay must call the exchange incomplete, not compare a stale request with
|
||||
// a partial response.
|
||||
m3, base3, stop3 := tapedMeter(t, tapeReplay, root, &calls2)
|
||||
defer stop3()
|
||||
resp := post(t, base3, "/chat/completions", tapedRequest)
|
||||
readAll(t, resp)
|
||||
if resp.StatusCode != http.StatusBadGateway {
|
||||
t.Fatalf("replay of a partial tape = %d, want 502", resp.StatusCode)
|
||||
}
|
||||
if got := m3.snapshot(); got.DivergedAt != 0 {
|
||||
t.Fatalf("a partial tape was reported as a divergence: %d %q", got.DivergedAt, got.Divergence)
|
||||
}
|
||||
}
|
||||
|
||||
// The healthy path must stay quiet: a recording that completed reports no
|
||||
// incomplete exchange and prints no line about one. Only the failure paths were
|
||||
// pinned before.
|
||||
func TestTapeCompleteRecordingReportsNothingIncomplete(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
var calls atomic.Int32
|
||||
m, base, stop := tapedMeter(t, tapeRecord, root, &calls)
|
||||
defer stop()
|
||||
readAll(t, post(t, base, "/chat/completions", tapedRequest))
|
||||
|
||||
var r result
|
||||
runMeter{m: m, stop: stop}.record(&r)
|
||||
if r.Meter == nil {
|
||||
t.Fatal("record attached no meter")
|
||||
}
|
||||
if r.Meter.TapeIncomplete != 0 || r.Meter.TapeError != "" {
|
||||
t.Fatalf("a complete recording reported %d incomplete exchanges (%q)", r.Meter.TapeIncomplete, r.Meter.TapeError)
|
||||
}
|
||||
// renderTapeRecord is the printer behind the report's summary block, so an
|
||||
// empty string here is what "no line is printed" means.
|
||||
if line := renderTapeRecord([]result{{task: task{ID: "bugfix-slug"}, Meter: r.Meter}}); line != "" {
|
||||
t.Fatalf("a complete recording printed %q, want no line", line)
|
||||
}
|
||||
}
|
||||
+26
-11
@@ -50,42 +50,57 @@ func tallyTrials(results []result) (map[string]trialTally, int) {
|
||||
return tally, k
|
||||
}
|
||||
|
||||
// trialsLine reports reliability over repeated trials. pass^k counts a task
|
||||
// only when every trial it ran passed; a task cut short by the budget is
|
||||
// judged on the trials it has, and the line says how many were complete.
|
||||
// Empty for a run without trials, so older reports read as before.
|
||||
// trialsLine reports reliability over repeated trials. pass^k and pass@k are
|
||||
// over the tasks that ran all k trials (k is the highest trial index that ran);
|
||||
// a task cut short by the budget has no value for either and is listed apart.
|
||||
// mean pass@1 is passes over every trial that ran to a grade. Empty for a run
|
||||
// without trials, so older reports read as before.
|
||||
func trialsLine(results []result) string {
|
||||
tally, k := tallyTrials(results)
|
||||
if k < 2 || len(tally) == 0 {
|
||||
return ""
|
||||
}
|
||||
all, any, complete, runs, passes := 0, 0, 0, 0, 0
|
||||
var flaky []string
|
||||
var flaky, incomplete []string
|
||||
for id, t := range tally {
|
||||
runs += t.ran
|
||||
passes += t.passed
|
||||
if t.ran == k {
|
||||
complete++
|
||||
if t.passed > 0 && t.passed < t.ran {
|
||||
flaky = append(flaky, fmt.Sprintf("`%s` %d/%d", id, t.passed, t.ran))
|
||||
}
|
||||
if t.ran < k {
|
||||
incomplete = append(incomplete, fmt.Sprintf("`%s` %d/%d", id, t.ran, k))
|
||||
continue
|
||||
}
|
||||
complete++
|
||||
if t.passed == t.ran {
|
||||
all++
|
||||
}
|
||||
if t.passed > 0 {
|
||||
any++
|
||||
}
|
||||
if t.passed > 0 && t.passed < t.ran {
|
||||
flaky = append(flaky, fmt.Sprintf("`%s` %d/%d", id, t.passed, t.ran))
|
||||
}
|
||||
}
|
||||
sort.Strings(flaky)
|
||||
sort.Strings(incomplete)
|
||||
passAll, passAny := fmt.Sprintf("n/a (no task ran %d trials)", k), "n/a"
|
||||
if complete > 0 {
|
||||
passAll, passAny = shareOf(all, complete), shareOf(any, complete)
|
||||
}
|
||||
line := fmt.Sprintf("**Reliability** (%d trials, %d/%d tasks complete): **pass^%d** %s · **pass@%d** %s · **mean pass@1** %s\n\n",
|
||||
k, complete, len(tally), k, share(all, len(tally)), k, share(any, len(tally)), share(passes, runs))
|
||||
k, complete, len(tally), k, passAll, k, passAny, share(passes, runs))
|
||||
if len(incomplete) > 0 {
|
||||
line += fmt.Sprintf("**Incomplete** (fewer than %d trials, not in pass^%d or pass@%d): %s\n\n", k, k, k, strings.Join(incomplete, " · "))
|
||||
}
|
||||
if len(flaky) > 0 {
|
||||
line += "**Flaky** (passed some trials, not all): " + strings.Join(flaky, " · ") + "\n\n"
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
func shareOf(n, d int) string {
|
||||
return fmt.Sprintf("%s (%d/%d complete)", pct(n, d), n, d)
|
||||
}
|
||||
|
||||
func share(n, d int) string {
|
||||
return fmt.Sprintf("%s (%d/%d)", pct(n, d), n, d)
|
||||
}
|
||||
@@ -21,8 +21,8 @@ func TestTrialsLineReportsReliabilityAcrossTrials(t *testing.T) {
|
||||
line := trialsLine(results)
|
||||
for _, want := range []string{
|
||||
"3 trials, 3/3 tasks complete",
|
||||
"**pass^3** 33% (1/3)",
|
||||
"**pass@3** 67% (2/3)",
|
||||
"**pass^3** 33% (1/3 complete)",
|
||||
"**pass@3** 67% (2/3 complete)",
|
||||
"**mean pass@1** 56% (5/9)",
|
||||
"`flaky` 2/3",
|
||||
} {
|
||||
@@ -35,15 +35,29 @@ func TestTrialsLineReportsReliabilityAcrossTrials(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A trial the budget skipped is not a failure; the task is judged on the
|
||||
// trials it ran, and the line says it was not complete.
|
||||
func TestTrialsLineJudgesABudgetCutTaskOnWhatRan(t *testing.T) {
|
||||
results := []result{
|
||||
trialResult("a", 1, true), trialResult("a", 2, true),
|
||||
trialResult("b", 1, true), {task: task{ID: "b"}, Trial: 2, Skipped: true},
|
||||
// pass^k is the chance all k trials pass, which a task that ran fewer has no
|
||||
// value for. A trial the budget skipped is not a failure, so such a task is
|
||||
// left out of pass^k and pass@k and named, and its trials still count toward
|
||||
// mean pass@1.
|
||||
func TestTrialsLineKeepsATaskThatRanFewerTrialsOutOfPassK(t *testing.T) {
|
||||
for _, bPassed := range []bool{true, false} {
|
||||
results := []result{
|
||||
trialResult("a", 1, true), trialResult("a", 2, true),
|
||||
trialResult("b", 1, bPassed), {task: task{ID: "b"}, Trial: 2, Skipped: true},
|
||||
}
|
||||
line := trialsLine(results)
|
||||
for _, want := range []string{"1/2 tasks complete", "**pass^2** 100% (1/1 complete)", "**pass@2** 100% (1/1 complete)", "**Incomplete** (fewer than 2 trials, not in pass^2 or pass@2): `b` 1/2"} {
|
||||
if !strings.Contains(line, want) {
|
||||
t.Fatalf("bPassed=%v: missing %q in:\n%s", bPassed, want, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrialsLineHasNoPassKWhenNoTaskRanEveryTrial(t *testing.T) {
|
||||
results := []result{trialResult("a", 1, true), trialResult("a", 3, true), trialResult("b", 1, true), trialResult("b", 2, true)}
|
||||
line := trialsLine(results)
|
||||
if !strings.Contains(line, "1/2 tasks complete") || !strings.Contains(line, "**pass^2** 100% (2/2)") {
|
||||
if !strings.Contains(line, "**pass^3** n/a (no task ran 3 trials)") || !strings.Contains(line, "**pass@3** n/a") {
|
||||
t.Fatalf("line = %s", line)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/base/i18n"
|
||||
"reasonix/internal/contract/config"
|
||||
"reasonix/internal/platform/diskhealth"
|
||||
"reasonix/internal/platform/notify"
|
||||
)
|
||||
|
||||
// diskWatchEvery is how often the volumes are looked at: a build fills a disk
|
||||
// over minutes, and a statfs per folder is cheap.
|
||||
const diskWatchEvery = 5 * time.Minute
|
||||
|
||||
// watchDisk looks at the volumes under dirs on a timer and says when one runs
|
||||
// short of space. The switches are read each time, so turning the notice off or
|
||||
// on in the window takes effect without a restart, and the language is the one
|
||||
// the window is set to now.
|
||||
func watchDisk(ctx context.Context, set *notify.Settings, sender notify.Sender, dirs []string, every time.Duration, read func(string) (diskhealth.Volume, bool)) {
|
||||
monitor := diskhealth.NewMonitor()
|
||||
tick := time.NewTicker(every)
|
||||
defer tick.Stop()
|
||||
for {
|
||||
cfg := set.Load()
|
||||
if cfg.Enabled && cfg.DiskLow {
|
||||
for _, n := range monitor.Check(time.Now(), dirs, read) {
|
||||
say := i18n.CatalogFor(config.LoadForEdit(config.UserConfigPath()).DesktopLanguage())
|
||||
notify.SendDiskLow(sender, say, cfg, n)
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-tick.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// startDiskWatch begins the watch and returns the sender it speaks through, so
|
||||
// the hub's notices and the disk warning leave by the same door.
|
||||
func startDiskWatch(ctx context.Context, set *notify.Settings, dirs []string) notify.Sender {
|
||||
sender := notify.NewPlatformSender()
|
||||
go watchDisk(ctx, set, sender, dirs, diskWatchEvery, diskhealth.Read)
|
||||
return sender
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/contract/config"
|
||||
"reasonix/internal/platform/diskhealth"
|
||||
"reasonix/internal/platform/notify"
|
||||
)
|
||||
|
||||
type sentMessages struct {
|
||||
mu sync.Mutex
|
||||
sent []notify.Message
|
||||
}
|
||||
|
||||
func (s *sentMessages) Send(m notify.Message) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.sent = append(s.sent, m)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sentMessages) count() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return len(s.sent)
|
||||
}
|
||||
|
||||
const gigabyte = int64(1) << 30
|
||||
|
||||
func lowVolume(string) (diskhealth.Volume, bool) {
|
||||
return diskhealth.Volume{Free: 2 * gigabyte, Total: 500 * gigabyte}, true
|
||||
}
|
||||
|
||||
func runWatch(t *testing.T, cfg config.NotificationsConfig) int {
|
||||
t.Helper()
|
||||
sender := &sentMessages{}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
watchDisk(ctx, notify.NewSettings(cfg), sender, []string{"/w", "/w/.state"}, time.Millisecond, lowVolume)
|
||||
close(done)
|
||||
}()
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
cancel()
|
||||
<-done
|
||||
return sender.count()
|
||||
}
|
||||
|
||||
// A volume that stays low is said once however many times it is looked at, and
|
||||
// two folders on it do not make two notices.
|
||||
func TestTheDiskWatchSaysALowVolumeOnce(t *testing.T) {
|
||||
if got := runWatch(t, config.NotificationsConfig{Enabled: true, DiskLow: true}); got != 1 {
|
||||
t.Fatalf("sent %d notices, want exactly one", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheDiskWatchHonoursTheSwitches(t *testing.T) {
|
||||
if got := runWatch(t, config.NotificationsConfig{Enabled: true, DiskLow: false}); got != 0 {
|
||||
t.Fatalf("own switch off sent %d, want none", got)
|
||||
}
|
||||
if got := runWatch(t, config.NotificationsConfig{Enabled: false, DiskLow: true}); got != 0 {
|
||||
t.Fatalf("master switch off sent %d, want none", got)
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
@@ -35,6 +36,8 @@ import (
|
||||
"reasonix/internal/frontend/traystate"
|
||||
"reasonix/internal/platform/account"
|
||||
"reasonix/internal/platform/appupdate"
|
||||
"reasonix/internal/platform/computer"
|
||||
"reasonix/internal/platform/crashreport"
|
||||
"reasonix/internal/platform/feedback"
|
||||
"reasonix/internal/platform/instanceid"
|
||||
"reasonix/internal/platform/notify"
|
||||
@@ -66,6 +69,7 @@ type shellIdentity struct {
|
||||
version string
|
||||
exe string
|
||||
pid int
|
||||
logsDir string
|
||||
}
|
||||
|
||||
func (s shellIdentity) stated() bool {
|
||||
@@ -88,7 +92,7 @@ func studioInstall(shell shellIdentity) *update.Install {
|
||||
// nil where the shell did not state enough to own one. Nil is the gate: the
|
||||
// routes are not registered, so a host that cannot name the application it
|
||||
// would replace has no way to be asked to replace it.
|
||||
func studioUpdateHost(shell shellIdentity, to io.Writer) appupdate.Capability {
|
||||
func studioUpdateHost(shell shellIdentity, owner *shellOwner) appupdate.Capability {
|
||||
if !shell.stated() {
|
||||
return nil
|
||||
}
|
||||
@@ -96,7 +100,7 @@ func studioUpdateHost(shell shellIdentity, to io.Writer) appupdate.Capability {
|
||||
// still work; the swap is what refuses, by name.
|
||||
application, _ := update.ApplicationAt(shell.exe, shell.pid)
|
||||
return appupdate.New(appupdate.Options{
|
||||
Owner: &shellOwner{to: to},
|
||||
Owner: owner,
|
||||
Running: shell.version,
|
||||
Line: update.StudioLine(),
|
||||
Application: application,
|
||||
@@ -126,7 +130,10 @@ func main() {
|
||||
// replace it.
|
||||
studioApp := flag.String("studio-app", "", "the application executable this host runs inside")
|
||||
studioAppPID := flag.Int("studio-app-pid", 0, "the process id of that application")
|
||||
computerHelper := flag.String("computer-helper", "", "the native helper that operates this machine's applications")
|
||||
computerDriver := flag.String("cua-driver", "", "the pinned CUA Driver executable")
|
||||
computerAbortSocket := flag.String("computer-abort-socket", "", "the private Studio CUA interruption endpoint")
|
||||
computerSocket := flag.String("computer-socket", "", "the private embedded CUA socket owned by Studio")
|
||||
crashDir := flag.String("crash-dir", "", "the directory a fatal runtime error is written to")
|
||||
stripGrants := flag.Bool("strip-package-grants", false, "remove app-package grants from the directory -studio-app runs from, print what changed, and exit")
|
||||
flag.Parse()
|
||||
if *stripGrants {
|
||||
@@ -139,16 +146,46 @@ func main() {
|
||||
fmt.Fprintln(os.Stdout, instanceid.Current())
|
||||
return
|
||||
}
|
||||
boot.SetComputerHelper(*computerHelper)
|
||||
shell := shellIdentity{version: *studioVersion, exe: *studioApp, pid: *studioAppPID}
|
||||
os.Exit(run(parentLease(os.Stdin), os.Stdout, os.Stderr, *page, shell))
|
||||
var driver *computer.Driver
|
||||
if *computerDriver != "" {
|
||||
if *computerSocket != "" {
|
||||
driver = computer.NewEmbeddedDriver(*computerDriver, *computerSocket, *computerAbortSocket, os.Environ())
|
||||
} else {
|
||||
driver = computer.NewDriver(*computerDriver, os.Environ())
|
||||
}
|
||||
}
|
||||
|
||||
shell := shellIdentity{version: *studioVersion, exe: *studioApp, pid: *studioAppPID, logsDir: logsDirOf(*crashDir)}
|
||||
releaseFatalLog := crashreport.InstallFatalLog(*crashDir, defaultVersion(shell.version))
|
||||
code := run(parentLease(os.Stdin), os.Stdout, os.Stderr, *page, shell, driver)
|
||||
releaseFatalLog()
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
// logsDirOf is the shell's logs directory: the crash directory it passed is
|
||||
// the "crash" folder inside it.
|
||||
func logsDirOf(crashDir string) string {
|
||||
if strings.TrimSpace(crashDir) == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Dir(crashDir)
|
||||
}
|
||||
|
||||
func defaultVersion(stated string) string {
|
||||
if strings.TrimSpace(stated) != "" {
|
||||
return stated
|
||||
}
|
||||
return version
|
||||
}
|
||||
|
||||
// run serves until the lease ends or the process is signalled. stdout carries
|
||||
// the handshake first and then one line per act a handover asks of the shell,
|
||||
// because that pipe is the only channel pointing that way; every log goes to
|
||||
// logs, which is why no line there can be mistaken for one of these.
|
||||
func run(lease io.Reader, handshakeTo, logs io.Writer, page string, shell shellIdentity) int {
|
||||
func run(lease io.Reader, handshakeTo, logs io.Writer, page string, shell shellIdentity, driver *computer.Driver) int {
|
||||
if driver != nil {
|
||||
defer driver.Close()
|
||||
}
|
||||
phases := newStartupPhases(logs, time.Now)
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
@@ -171,7 +208,7 @@ func run(lease io.Reader, handshakeTo, logs io.Writer, page string, shell shellI
|
||||
fmt.Fprintln(logs, "reasonix-studio-host: no built page found; serving the kernel only")
|
||||
}
|
||||
|
||||
hub, err := assemble(ctx, logs, handshakeTo, shell, served, phases)
|
||||
hub, err := assemble(ctx, logs, handshakeTo, shell, served, phases, driver)
|
||||
if err != nil {
|
||||
fmt.Fprintln(logs, "reasonix-studio-host:", err)
|
||||
return 1
|
||||
@@ -289,7 +326,7 @@ func resolveKernelLanguage(cfg *config.Config) string {
|
||||
|
||||
// assemble builds the hub this host serves: one pane on the workspace it was
|
||||
// launched in, carrying the capabilities a local window may exercise.
|
||||
func assemble(ctx context.Context, logs, handshakeTo io.Writer, shell shellIdentity, page fs.FS, phases *startupPhases) (*serve.Hub, error) {
|
||||
func assemble(ctx context.Context, logs, handshakeTo io.Writer, shell shellIdentity, page fs.FS, phases *startupPhases, driver *computer.Driver) (*serve.Hub, error) {
|
||||
// The one-time upgrades belong to whichever entry point starts first; a
|
||||
// person who only ever opens the window would otherwise never get them.
|
||||
if _, err := config.ApplyUserConfigUpgradesOnStartup(config.UserConfigPath()); err != nil {
|
||||
@@ -324,20 +361,7 @@ func assemble(ctx context.Context, logs, handshakeTo io.Writer, shell shellIdent
|
||||
phases.mark(startupSinks)
|
||||
root := launchWorkspace(shell.exe)
|
||||
phases.mark(startupWorkspace)
|
||||
built, err := boot.BuildRuntime(ctx, boot.Options{
|
||||
Version: version,
|
||||
WorkspaceRoot: root,
|
||||
SessionDir: serve.SessionDirFor(root),
|
||||
Sink: paneSink,
|
||||
Stderr: logs,
|
||||
OnPhase: phases.boot,
|
||||
StatsSource: surface.Desktop,
|
||||
FeedbackSurface: feedback.SurfaceStudio,
|
||||
// A stale default_model must not keep the window from opening to fix it.
|
||||
OpenOnFallbackModel: true,
|
||||
|
||||
CleanupPendingReconciler: serve.BackgroundCleanupReconciler,
|
||||
})
|
||||
built, err := boot.BuildRuntime(ctx, runtimeOptions(root, paneSink, logs, phases, driver))
|
||||
phases.mark(startupRuntime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -350,31 +374,51 @@ func assemble(ctx context.Context, logs, handshakeTo io.Writer, shell shellIdent
|
||||
browserHost := serve.NewBrowserHost()
|
||||
boot.SetBrowserHost(browserHost.Dial)
|
||||
hubCfg := hostServeConfig(cfg.Serve)
|
||||
// Shut until the person at the window opens it; the context ending closes
|
||||
// it with the rest of the kernel, unpairing every device.
|
||||
// Shut until the person at the window opens it; the context ending stops it
|
||||
// with the rest of the kernel, keeping the phones this machine already adopted.
|
||||
share := serve.NewDeviceShare(page)
|
||||
share.RestorePort(cfg.SharePort())
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
share.Close()
|
||||
share.Shutdown()
|
||||
}()
|
||||
owner := &shellOwner{to: handshakeTo}
|
||||
// A toast the user clicks is answered here, in the process that sent it, and
|
||||
// the shell is the one that can bring its window forward.
|
||||
notify.OnActivate(showOnToastClick(owner, logs))
|
||||
// A toast that did not go out is invisible to the person it was for, so the
|
||||
// reason belongs in the log the shell keeps.
|
||||
notify.OnSendFailure(func(err error) { fmt.Fprintf(logs, "notify: %v\n", err) })
|
||||
hub := serve.NewHub(serve.HubOptions{
|
||||
Serve: hubCfg,
|
||||
Surface: surface.Desktop,
|
||||
Page: page,
|
||||
Grant: grantHostCapabilities,
|
||||
DecorateSink: decorate,
|
||||
Tray: &studioTray{tracker: tracker},
|
||||
Notifications: notifySet,
|
||||
BrowserHost: browserHost,
|
||||
Asks: asks,
|
||||
Remote: remotehost.New(ctx, version, asks),
|
||||
OnClose: func(rt *serve.Runtime) { tracker.Drop(paneKey(rt.Events)) },
|
||||
Install: studioInstall(shell),
|
||||
Update: studioUpdateHost(shell, handshakeTo),
|
||||
Share: share,
|
||||
NativeSurfaces: true,
|
||||
ComputerDriver: driver,
|
||||
Serve: hubCfg,
|
||||
Surface: surface.Desktop,
|
||||
Page: page,
|
||||
Grant: grantHostCapabilities,
|
||||
DecorateSink: decorate,
|
||||
Tray: &studioTray{tracker: tracker},
|
||||
Notifications: notifySet,
|
||||
NotifySender: startDiskWatch(ctx, notifySet, []string{root, serve.SessionDirFor(root)}),
|
||||
BrowserHost: browserHost,
|
||||
Asks: asks,
|
||||
Remote: remotehost.New(ctx, version, asks),
|
||||
OnClose: func(rt *serve.Runtime) { tracker.Drop(paneKey(rt.Events)) },
|
||||
Install: studioInstall(shell),
|
||||
Update: studioUpdateHost(shell, owner),
|
||||
Share: share,
|
||||
LogsDir: shell.logsDir,
|
||||
})
|
||||
share.Attach(hub.Handler())
|
||||
// A share the person had open reopens where it was: the phone that paired
|
||||
// against that address keeps its cookie. An address that is gone falls back
|
||||
// to one this machine now offers, at the cost of one fresh scan.
|
||||
state := share.RestoreState()
|
||||
if state.Open {
|
||||
if _, err := share.Reopen(state.Address); err != nil {
|
||||
fmt.Fprintln(logs, "reasonix-studio-host: could not reopen phone access:", err)
|
||||
}
|
||||
}
|
||||
srv := serve.New(built.Controller, bc, hubCfg)
|
||||
srv.SetPaneSink(paneSink)
|
||||
srv.AdoptRuntime(built)
|
||||
@@ -391,6 +435,26 @@ func assemble(ctx context.Context, logs, handshakeTo io.Writer, shell shellIdent
|
||||
return hub, nil
|
||||
}
|
||||
|
||||
// runtimeOptions is what the one pane this host launches with is built from.
|
||||
func runtimeOptions(root string, sink event.Sink, logs io.Writer, phases *startupPhases, driver *computer.Driver) boot.Options {
|
||||
return boot.Options{
|
||||
ComputerDriver: driver,
|
||||
Version: version,
|
||||
WorkspaceRoot: root,
|
||||
SessionDir: serve.SessionDirFor(root),
|
||||
Sink: sink,
|
||||
NativeSurfaces: true,
|
||||
Stderr: logs,
|
||||
OnPhase: phases.boot,
|
||||
StatsSource: surface.Desktop,
|
||||
FeedbackSurface: feedback.SurfaceStudio,
|
||||
// A stale default_model must not keep the window from opening to fix it.
|
||||
OpenOnFallbackModel: true,
|
||||
|
||||
CleanupPendingReconciler: serve.BackgroundCleanupReconciler,
|
||||
}
|
||||
}
|
||||
|
||||
func startCloudRemote(ctx context.Context, cfg *config.Config, logs io.Writer, tasks remotecloud.TaskService) {
|
||||
httpClient, err := netclient.NewHTTPClient(cfg.NetworkProxySpec(), netclient.TransportOptions{})
|
||||
if err != nil {
|
||||
|
||||
@@ -418,7 +418,7 @@ func TestRunServesUntilTheParentLetsGo(t *testing.T) {
|
||||
go func() { _, _ = io.Copy(io.Discard, logs) }()
|
||||
|
||||
done := make(chan int, 1)
|
||||
go func() { done <- run(lease, announced, logged, page, shellIdentity{version: "2.10.0"}) }()
|
||||
go func() { done <- run(lease, announced, logged, page, shellIdentity{version: "2.10.0"}, nil) }()
|
||||
|
||||
line, err := bufio.NewReader(stdout).ReadString('\n')
|
||||
if err != nil {
|
||||
@@ -542,7 +542,7 @@ func TestAssembleAppliesTheStartupUpgrades(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Chdir(testenv.TempDir(t))
|
||||
hub, err := assemble(t.Context(), io.Discard, io.Discard, shellIdentity{}, nil, newStartupPhases(io.Discard, time.Now))
|
||||
hub, err := assemble(t.Context(), io.Discard, io.Discard, shellIdentity{}, nil, newStartupPhases(io.Discard, time.Now), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("assemble: %v", err)
|
||||
}
|
||||
|
||||
@@ -9,13 +9,26 @@ import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
// The acts a handover asks of the shell. Only two travel: releasing the
|
||||
// application is this process's own hub, and the shell has nothing to do for it.
|
||||
// The acts this process asks of the shell. Releasing the application is this
|
||||
// process's own hub, so the shell has nothing to do for it; the other three are
|
||||
// what only the shell can do.
|
||||
const (
|
||||
actRelaunch = "relaunch"
|
||||
actQuit = "quit"
|
||||
actShow = "show"
|
||||
)
|
||||
|
||||
// showOnToastClick is what a clicked toast runs: ask the shell, which is the
|
||||
// only process that can bring its window forward, to do so.
|
||||
func showOnToastClick(o *shellOwner, log io.Writer) func() {
|
||||
return func() {
|
||||
fmt.Fprintln(log, "notify: toast clicked")
|
||||
if err := o.ask(actShow); err != nil {
|
||||
fmt.Fprintf(log, "notify: toast click could not reach the shell: %v\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// shellOwner is the application this process was spawned by, reached over the
|
||||
// pipe the handshake went out on -- the only channel pointing this way, since
|
||||
// stdin is the lease and HTTP runs from the shell to here. Nothing is waited
|
||||
|
||||
@@ -3,6 +3,8 @@ package main
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
goruntime "runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -78,7 +80,7 @@ func TestAHostThatCannotNameTheApplicationOwnsNoCapability(t *testing.T) {
|
||||
{"an unpackaged launch, which names no version", shellIdentity{exe: "/somewhere/studio", pid: 4321}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := studioUpdateHost(tc.shell, &out); got != nil {
|
||||
if got := studioUpdateHost(tc.shell, &shellOwner{to: &out}); got != nil {
|
||||
t.Fatalf("studioUpdateHost = %#v, want nil so no install route registers", got)
|
||||
}
|
||||
})
|
||||
@@ -89,7 +91,7 @@ func TestAHostThatCannotNameTheApplicationOwnsNoCapability(t *testing.T) {
|
||||
func TestAHostThatNamesTheApplicationOwnsOne(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
shell := shellIdentity{version: "v2.0.0", exe: "/Applications/Reasonix Studio.app/Contents/MacOS/Reasonix Studio", pid: 4321}
|
||||
if studioUpdateHost(shell, &out) == nil {
|
||||
if studioUpdateHost(shell, &shellOwner{to: &out}) == nil {
|
||||
t.Fatal("a host that stated its application got no capability")
|
||||
}
|
||||
}
|
||||
@@ -116,3 +118,33 @@ func TestTheInstallLayoutFollowsTheStatedApplication(t *testing.T) {
|
||||
t.Fatalf("studioInstall = %+v for a launch that named no version, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A clicked toast reaches the shell as one line on the pipe the handshake used,
|
||||
// in the same shape the other acts take.
|
||||
func TestAToastClickAsksTheShellToShowItsWindow(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
showOnToastClick(&shellOwner{to: &out}, io.Discard)()
|
||||
if got := out.String(); got != "{\"act\":\"show\"}\n" {
|
||||
t.Fatalf("pipe carried %q, want the show act", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The click is the first link of a chain that crosses two processes; when the
|
||||
// window does not come forward, the log says whether the click arrived and
|
||||
// whether the shell was reached.
|
||||
func TestAToastClickIsWrittenToTheLog(t *testing.T) {
|
||||
var log bytes.Buffer
|
||||
showOnToastClick(&shellOwner{to: &bytes.Buffer{}}, &log)()
|
||||
if !strings.Contains(log.String(), "toast clicked") {
|
||||
t.Fatalf("log = %q, want the click recorded", log.String())
|
||||
}
|
||||
log.Reset()
|
||||
showOnToastClick(&shellOwner{to: failingWriter{}}, &log)()
|
||||
if !strings.Contains(log.String(), "could not reach the shell") {
|
||||
t.Fatalf("log = %q, want the failed ask recorded", log.String())
|
||||
}
|
||||
}
|
||||
|
||||
type failingWriter struct{}
|
||||
|
||||
func (failingWriter) Write([]byte) (int, error) { return 0, errors.New("pipe closed") }
|
||||
@@ -47,7 +47,7 @@ func TestAssembledTrayLanguage(t *testing.T) {
|
||||
}
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
hub, err := assemble(ctx, io.Discard, io.Discard, shellIdentity{}, nil, newStartupPhases(io.Discard, time.Now))
|
||||
hub, err := assemble(ctx, io.Discard, io.Discard, shellIdentity{}, nil, newStartupPhases(io.Discard, time.Now), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("assemble: %v", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLinuxReleaseBuildsAndCollectsBothNativeArchitectures(t *testing.T) {
|
||||
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
|
||||
jobs := jobsOf(t, root)
|
||||
build := mappingValue(jobs, "build")
|
||||
entries := mappingValue(mappingValue(mappingValue(build, "strategy"), "matrix"), "include")
|
||||
want := map[string]string{"amd64": "ubuntu-22.04", "arm64": "ubuntu-22.04-arm"}
|
||||
seen := map[string]bool{}
|
||||
for _, entry := range entries.Content {
|
||||
platform := mappingScalar(entry, "platform")
|
||||
if !strings.HasPrefix(platform, "linux/") {
|
||||
continue
|
||||
}
|
||||
arch := strings.TrimPrefix(platform, "linux/")
|
||||
if seen[arch] || mappingScalar(entry, "runner") != want[arch] {
|
||||
t.Fatalf("Linux %s does not have one native runner", arch)
|
||||
}
|
||||
seen[arch] = true
|
||||
artifact := mappingScalar(entry, "artifact")
|
||||
if artifact != "studio-build-linux-"+arch {
|
||||
t.Fatalf("unexpected Linux artifact %q", artifact)
|
||||
}
|
||||
downloaded := false
|
||||
for _, step := range mappingValue(mappingValue(jobs, "publish"), "steps").Content {
|
||||
if mappingScalar(mappingValue(step, "with"), "name") == artifact {
|
||||
downloaded = true
|
||||
}
|
||||
}
|
||||
if !downloaded {
|
||||
t.Errorf("publish never downloads %s", artifact)
|
||||
}
|
||||
}
|
||||
if len(seen) != len(want) {
|
||||
t.Fatalf("Linux release architectures = %v, want amd64 and arm64", seen)
|
||||
}
|
||||
step := stepNamed(t, build, "Use a native fpm on ARM64 Linux")
|
||||
if mappingScalar(step, "if") != "runner.os == 'Linux' && runner.arch == 'ARM64'" || !strings.Contains(mappingScalar(step, "run"), "USE_SYSTEM_FPM=true") {
|
||||
t.Fatal("Linux ARM64 must retain the upstream native FPM setup")
|
||||
}
|
||||
for _, name := range []string{"Verify both Linux package formats", "Accept the installed Linux package"} {
|
||||
step := stepNamed(t, build, name)
|
||||
if mappingScalar(step, "if") != "runner.os == 'Linux'" || mappingScalar(step, "continue-on-error") == "true" {
|
||||
t.Errorf("%s must gate every Linux build", name)
|
||||
}
|
||||
}
|
||||
collect := mappingScalar(stepNamed(t, mappingValue(jobs, "publish"), "Collect artifacts"), "run")
|
||||
if !strings.Contains(collect, "bash scripts/verify-studio-artifacts.sh dist") {
|
||||
t.Fatal("publication does not require the complete platform artifact set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLinuxCIPackageMatrixPreservesUpstreamScope(t *testing.T) {
|
||||
root := parseWorkflowFile(t, "../../.github/workflows/studio.yml")
|
||||
job := mappingValue(jobsOf(t, root), "scope")
|
||||
run := ""
|
||||
for _, step := range mappingValue(job, "steps").Content {
|
||||
if mappingScalar(step, "id") == "pick" {
|
||||
run = mappingScalar(step, "run")
|
||||
}
|
||||
}
|
||||
if run == "" {
|
||||
t.Fatal("no executable CI matrix selector")
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
event, file string
|
||||
arm64 bool
|
||||
}{
|
||||
{"push", "docs/test.md", true},
|
||||
{"pull_request", "docs/test.md", false},
|
||||
{"pull_request", "scripts/linux-install-smoke.sh", true},
|
||||
{"pull_request", "scripts/verify-linux-package.sh", true},
|
||||
{"pull_request", "scripts/verify-studio-artifacts.sh", true},
|
||||
{"pull_request", "desktop/cmd/update-helper/main_linux.go", true},
|
||||
{"pull_request", "desktop/electron/test/unit.mjs", true},
|
||||
} {
|
||||
t.Run(tc.event+"/"+tc.file, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// A shell function owns command lookup on every host, including Git
|
||||
// Bash on Windows; a PATH shim can resolve the runner's real git.
|
||||
git := "git() { case \"$*\" in 'diff -z --name-only --no-renames fixture HEAD') printf '%s\\0' \"$MATRIX_TEST_FILE\" ;; 'diff --name-only --no-renames fixture HEAD') printf '%s\\n' \"$MATRIX_TEST_FILE\" ;; *) return 1 ;; esac; };\n"
|
||||
output := filepath.Join(dir, "output")
|
||||
cmd := exec.Command("bash", "-c", "set -euo pipefail\n"+git+run)
|
||||
cmd.Dir = "../.."
|
||||
cmd.Env = append(os.Environ(), "EVENT="+tc.event, "MATRIX_TEST_FILE="+tc.file, "BASE_SHA=fixture", "GITHUB_OUTPUT="+output)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("matrix selector: %v: %s", err, out)
|
||||
}
|
||||
body, err := os.ReadFile(output)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var matrix struct {
|
||||
Include []map[string]string `json:"include"`
|
||||
}
|
||||
for line := range strings.SplitSeq(string(body), "\n") {
|
||||
if value, ok := strings.CutPrefix(line, "matrix="); ok {
|
||||
if err := json.Unmarshal([]byte(value), &matrix); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
seen := map[string]string{}
|
||||
for _, leg := range matrix.Include {
|
||||
if leg["platform"] == "linux" {
|
||||
seen[leg["arch"]] = leg["os"]
|
||||
}
|
||||
}
|
||||
if seen["amd64"] != "ubuntu-22.04" || (seen["arm64"] == "ubuntu-22.04-arm") != tc.arm64 {
|
||||
t.Fatalf("Linux CI matrix = %v; selector output: %s", seen, out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -191,15 +191,11 @@ func TestPublishRefusesAPartialWindowsSet(t *testing.T) {
|
||||
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
|
||||
publish := mappingValue(jobsOf(t, root), "publish")
|
||||
run := mappingScalar(stepNamed(t, publish, "Collect artifacts"), "run")
|
||||
if !strings.Contains(run, `"$count" -lt 9`) {
|
||||
t.Error("publish does not require at least 9 ReasonixStudio-* packages")
|
||||
if !strings.Contains(run, "bash scripts/verify-studio-artifacts.sh dist") {
|
||||
t.Fatal("publish does not run the complete artifact-set verifier")
|
||||
}
|
||||
for _, name := range []string{
|
||||
"ReasonixStudio-windows-amd64-installer.exe", "ReasonixStudio-windows-arm64-installer.exe",
|
||||
"ReasonixStudio-windows-amd64.zip", "ReasonixStudio-windows-arm64.zip",
|
||||
} {
|
||||
if !strings.Contains(run, "'"+name+"'") {
|
||||
t.Errorf("publish does not require %s by name", name)
|
||||
}
|
||||
ci := parseWorkflowFile(t, "../../.github/workflows/ci.yml")
|
||||
if !nodeContains(ci, "scripts/studio-artifacts.test.mjs") {
|
||||
t.Fatal("CI does not exercise missing Windows/Linux/macOS artifacts against the publication verifier")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
---
|
||||
status: active
|
||||
owner: @esengine
|
||||
backup: @SivanCola
|
||||
reviewed: 2026-10-13
|
||||
---
|
||||
|
||||
# Foundation palette verification
|
||||
|
||||
final result: passed
|
||||
|
||||
Clear Blue and Tea Green implement the two approved colour directions for
|
||||
[#12909](https://github.com/esengine/DeepSeek-Reasonix/issues/12909).
|
||||
The existing default remains the baseline. Layout, typography, copy and brand
|
||||
assets follow the product rather than generated mockup variations.
|
||||
|
||||
## Screenshots
|
||||
|
||||
Captured from the actual Studio components with synthetic performance-harness
|
||||
data, one conversation turn and an empty composer. Fixture workspace and host
|
||||
names are not real user data. Screenshots use 1:1 density.
|
||||
|
||||
| Palette | Light, 1280 × 720 | Dark, 1280 × 720 |
|
||||
| --- | --- | --- |
|
||||
| Default baseline | [Light](docs/design/theme-palettes/default-light-wide.jpg) | [Dark](docs/design/theme-palettes/default-dark-wide.jpg) |
|
||||
| Clear Blue | [Light](docs/design/theme-palettes/clear-blue-light-wide.jpg) | [Dark](docs/design/theme-palettes/clear-blue-dark-wide.jpg) |
|
||||
| Tea Green | [Light](docs/design/theme-palettes/tea-green-light-wide.jpg) | [Dark](docs/design/theme-palettes/tea-green-dark-wide.jpg) |
|
||||
|
||||
| Palette | Light, 390 × 844 | Dark, 390 × 844 |
|
||||
| --- | --- | --- |
|
||||
| Default baseline | [Light](docs/design/theme-palettes/default-light-phone.jpg) | [Dark](docs/design/theme-palettes/default-dark-phone.jpg) |
|
||||
| Clear Blue | [Light](docs/design/theme-palettes/clear-blue-light-phone.jpg) | [Dark](docs/design/theme-palettes/clear-blue-dark-phone.jpg) |
|
||||
| Tea Green | [Light](docs/design/theme-palettes/tea-green-light-phone.jpg) | [Dark](docs/design/theme-palettes/tea-green-dark-phone.jpg) |
|
||||
|
||||
## Visual comparison
|
||||
|
||||
The approved light mockups were 1619 × 971 pixels, including a caption outside
|
||||
the application. They were viewed together with the implementation screenshots.
|
||||
Comparison excluded that caption and assessed the palette direction, rather
|
||||
than treating generated layout drift as a product specification.
|
||||
|
||||
- Typography: existing families, size, line height and tracking retained. Plain
|
||||
light palettes now share the default prose weight; user overrides still win.
|
||||
- Spacing: existing grid, controls, corners and panel proportions retained.
|
||||
- Colours: cool white/blue and warm white/tea-green surfaces match the approved
|
||||
directions. Text, syntax and button contrast checks pass in both schemes.
|
||||
- Assets: existing logos and icons retained; no added image or sky layers.
|
||||
- Copy: existing workbench text retained; new palette names and descriptions
|
||||
have Chinese and English labels.
|
||||
|
||||
Full views made the navigation, transcript and composer readable without
|
||||
additional crops. Dark palettes extend the approved hues; the source mockups
|
||||
did not specify a dark layout. Phone captures confirm no horizontal overflow.
|
||||
|
||||
## Findings and iteration history
|
||||
|
||||
1. Default preview inherited the selected pack, and plain packs changed reading
|
||||
surfaces and weight. Shared canonical default variables and plain-surface
|
||||
rules now keep the preview independent and typography consistent.
|
||||
2. Initial comparison found neutral selected navigation and a dark send button
|
||||
overridden by body colour. Shared pack selectors now use accent ink/wash and
|
||||
the accent/foreground pair; disabled controls remain neutral.
|
||||
3. Revised screenshots were compared with both approved directions. No
|
||||
actionable P0/P1/P2 differences remain. Sky themes now join image themes in
|
||||
the expandable collection; the active backdrop remains in the first group.
|
||||
|
||||
## Behavioural evidence
|
||||
|
||||
- Immediate selection, return to default, light/dark and system-following modes
|
||||
verified in the browser; each preview follows its own scheme tokens.
|
||||
- Default comparison: original and revised Studio CSS applied to the same DOM.
|
||||
Fourteen style properties matched for 735 visible elements in each scheme
|
||||
under a light system and 734 in each scheme under a dark system.
|
||||
- Compared colour, background, border, shadow, font, size, weight, line height,
|
||||
tracking, width, height, padding, margin and radius.
|
||||
- Go integration tests cover selection, config persistence, server recreation
|
||||
and reset to the default for both new built-in IDs.
|
||||
- Frontend neighbourhood: 22 test files, 209 tests passed; production build and
|
||||
typecheck passed. Browser warning/error logs were empty.
|
||||
- Theme and HTTP server package tests, repository checks and whitespace checks
|
||||
passed. Lint budgets were not increased.
|
||||
|
||||
## Remaining coverage
|
||||
|
||||
Windows native installer acceptance and hosted CI are separate from these
|
||||
local results. The production build retains its existing large-chunk warning.
|
||||
+1
-1
@@ -36,7 +36,7 @@ are written down in [`docs/STUDIO_SHELL_BOUNDARIES.md`](../docs/STUDIO_SHELL_BOU
|
||||
| `electron/` | the shell: main process, preload bridge, packaging |
|
||||
| `frontend-next/` | the SPA it serves |
|
||||
| `internal/platform/update/` | manifest, download, verify, apply — shared by the shell and the helper |
|
||||
| `cmd/update-helper/` | the elevated half of an update (dpkg on Linux, versioned install on Windows) |
|
||||
| `cmd/update-helper/` | the elevated half of an update (dpkg on Linux) |
|
||||
| `cmd/studio-manifest/`, `cmd/sign/` | release-side tools |
|
||||
| `internal/winuninstall/` | taking a previous Windows install over |
|
||||
|
||||
|
||||
@@ -63,11 +63,6 @@ func main() {
|
||||
notesVersion = os.Args[5]
|
||||
}
|
||||
err = genManifest(os.Args[2], os.Args[3], os.Args[4], notesVersion)
|
||||
case "windows-payload":
|
||||
if len(os.Args) != 4 {
|
||||
usage()
|
||||
}
|
||||
err = genWindowsPayloadManifest(os.Args[2], os.Args[3])
|
||||
case "genkey":
|
||||
if len(os.Args) != 3 {
|
||||
usage()
|
||||
@@ -88,34 +83,10 @@ func main() {
|
||||
}
|
||||
|
||||
func usage() {
|
||||
fmt.Fprintln(os.Stderr, "usage:\n sign <file>...\n manifest <dir> <version> <tag> [notes-version]\n windows-payload <dir> <version>\n genkey <dir>\n verify <file>")
|
||||
fmt.Fprintln(os.Stderr, "usage:\n sign <file>...\n manifest <dir> <version> <tag> [notes-version]\n genkey <dir>\n verify <file>")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
func genWindowsPayloadManifest(dir, version string) error {
|
||||
hashes := make(map[string]string)
|
||||
for _, name := range update.WindowsPayloadFileNames() {
|
||||
path := filepath.Join(dir, name)
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Windows payload %s: %w", name, err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("Windows payload %s is not a regular file", name)
|
||||
}
|
||||
_, sum, err := hashFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hashes[name] = sum
|
||||
}
|
||||
b, err := update.EncodeWindowsPayloadManifest(version, hashes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(filepath.Join(dir, update.WindowsPayloadManifestName), b, 0o644)
|
||||
}
|
||||
|
||||
// verifyFile checks <file> against <file>.minisig using the embedded public key —
|
||||
// the same check the updater runs before applying. A self-test that the signing
|
||||
// key matches what's compiled in. Returns an error (nonzero exit) on mismatch.
|
||||
|
||||
@@ -206,29 +206,3 @@ func TestGenManifestIgnoresUnknownNativePackages(t *testing.T) {
|
||||
t.Fatalf("unexpected native_packages: %+v", m.NativePackages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenWindowsPayloadManifestHashesExactReleaseUnit(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
for _, name := range update.WindowsPayloadFileNames() {
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte("payload:"+name), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := genWindowsPayloadManifest(dir, "v2.3.4"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := os.ReadFile(filepath.Join(dir, update.WindowsPayloadManifestName))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hashes, err := update.DecodeWindowsPayloadManifest(b, "v2.3.4")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range update.WindowsPayloadFileNames() {
|
||||
want := update.WindowsPayloadSHA256([]byte("payload:" + name))
|
||||
if hashes[name] != want {
|
||||
t.Fatalf("manifest hash for %s = %q, want %q", name, hashes[name], want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/platform/update"
|
||||
@@ -13,7 +14,8 @@ import (
|
||||
// archMap is desktop/electron/arch.json: what the packager builds for, and what
|
||||
// each of those is called in a release.
|
||||
type archMap struct {
|
||||
Map map[string]string `json:"map"`
|
||||
Map map[string]string `json:"map"`
|
||||
FPM map[string][]string `json:"fpm"`
|
||||
}
|
||||
|
||||
func loadArchMap(t *testing.T) archMap {
|
||||
@@ -93,3 +95,33 @@ func TestThePackagerSpellingResolvesToAKeyNobodyAsksFor(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fpm names an .rpm for the architecture its own way, and the parser accepts
|
||||
// that name too: it keys a package nobody asks for. The packager hook renames
|
||||
// these spellings, so every one must be a non-canonical key here.
|
||||
func TestFPMSpellingOfAnRPMIsNotAReleaseKey(t *testing.T) {
|
||||
m := loadArchMap(t)
|
||||
if len(m.FPM) == 0 {
|
||||
t.Fatal("arch.json lists no fpm spellings")
|
||||
}
|
||||
canonical := map[string]bool{}
|
||||
for _, to := range m.Map {
|
||||
canonical[to] = true
|
||||
}
|
||||
for from, spellings := range m.FPM {
|
||||
to, ok := m.Map[from]
|
||||
if !ok {
|
||||
t.Errorf("fpm spells %s, which map does not release", from)
|
||||
continue
|
||||
}
|
||||
if got, ok := rpmPackageKey(artifactPrefix + "linux-" + to + ".rpm"); !ok || got != update.RPMPackageKey("linux", to) {
|
||||
t.Errorf("linux %s rpm = %q (%v)", to, got, ok)
|
||||
}
|
||||
for _, spelled := range spellings {
|
||||
got, ok := rpmPackageKey(artifactPrefix + "linux-" + spelled + ".rpm")
|
||||
if !ok || canonical[strings.TrimSuffix(strings.TrimPrefix(got, "linux-"), "-rpm")] {
|
||||
t.Errorf("%s resolved to %q (%v); the rename is no longer load-bearing", spelled, got, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -84,6 +84,12 @@ func run(dir, version, tag, deltaDir string, mirrored bool) error {
|
||||
m.NativePackages[key] = asset
|
||||
fmt.Printf("native package: %s -> %s\n", name, key)
|
||||
}
|
||||
// The .rpm rides beside the .deb under its own key: the deb's key is the
|
||||
// one every installed client already reads.
|
||||
if key, ok := rpmPackageKey(name); ok {
|
||||
m.NativePackages[key] = asset
|
||||
fmt.Printf("native package: %s -> %s\n", name, key)
|
||||
}
|
||||
// Windows updates by running the next installer, so that is what the
|
||||
// platform lookup has to resolve. The portable archive is never listed:
|
||||
// resolving it would hand the updater an artifact it cannot install.
|
||||
@@ -135,7 +141,8 @@ func releaseAsset(repo, tag, name string, mirrored bool) update.Asset {
|
||||
func installable(name string) bool {
|
||||
return strings.HasSuffix(name, ".dmg") ||
|
||||
strings.HasSuffix(name, "-installer.exe") ||
|
||||
strings.HasSuffix(name, ".deb")
|
||||
strings.HasSuffix(name, ".deb") ||
|
||||
strings.HasSuffix(name, ".rpm")
|
||||
}
|
||||
|
||||
// macArchiveKeys maps a macOS archive to the platforms it can install. The
|
||||
@@ -185,6 +192,20 @@ func nativePackageKey(name string) (string, bool) {
|
||||
return update.PlatformKey("linux", arch), true
|
||||
}
|
||||
|
||||
// rpmPackageKey maps a ReasonixStudio-linux-<arch>.rpm artifact to its
|
||||
// native_packages key.
|
||||
func rpmPackageKey(name string) (string, bool) {
|
||||
base, ok := strings.CutSuffix(name, ".rpm")
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
arch, ok := strings.CutPrefix(base, artifactPrefix+"linux-")
|
||||
if !ok || arch == "" {
|
||||
return "", false
|
||||
}
|
||||
return update.RPMPackageKey("linux", arch), true
|
||||
}
|
||||
|
||||
func hashFile(path string) (int64, string, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
|
||||
@@ -232,3 +232,111 @@ func TestWindowsArchitecturesResolveToTheirOwnInstallers(t *testing.T) {
|
||||
t.Errorf("no delta directory was given, yet deltas = %v", m.Deltas)
|
||||
}
|
||||
}
|
||||
|
||||
// Every installed Linux client reads native_packages["linux-amd64"] as the
|
||||
// .deb and hands it to dpkg. The .rpm may add a key and never change that one.
|
||||
func TestRPMRidesBesideTheDebWithoutMovingItsKey(t *testing.T) {
|
||||
write := func(dir string, names ...string) {
|
||||
for _, name := range names {
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte(name), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
generate := func(dir string) update.Manifest {
|
||||
t.Setenv("GITHUB_REPOSITORY", "esengine/DeepSeek-Reasonix")
|
||||
if err := run(dir, "v0.1.0", "studio-v0.1.0", "", true); err != nil {
|
||||
t.Fatalf("run: %v", err)
|
||||
}
|
||||
var m update.Manifest
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "latest.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
debOnly, both := t.TempDir(), t.TempDir()
|
||||
write(debOnly, "ReasonixStudio-linux-amd64.deb")
|
||||
write(both, "ReasonixStudio-linux-amd64.deb", "ReasonixStudio-linux-amd64.rpm")
|
||||
before, after := generate(debOnly), generate(both)
|
||||
|
||||
if after.NativePackages["linux-amd64"] != before.NativePackages["linux-amd64"] {
|
||||
t.Errorf("the deb entry moved: %+v, was %+v", after.NativePackages["linux-amd64"], before.NativePackages["linux-amd64"])
|
||||
}
|
||||
if !strings.HasSuffix(after.NativePackages["linux-amd64"].URL, "linux-amd64.deb") {
|
||||
t.Errorf("native_packages[linux-amd64] = %+v, want the .deb", after.NativePackages["linux-amd64"])
|
||||
}
|
||||
rpm, ok := after.NativePackages["linux-amd64-rpm"]
|
||||
if !ok || !strings.HasSuffix(rpm.URL, "ReasonixStudio-linux-amd64.rpm") || rpm.Sig != rpm.URL+".minisig" {
|
||||
t.Errorf("native_packages[linux-amd64-rpm] = %+v, want the signed .rpm", rpm)
|
||||
}
|
||||
if len(after.NativePackages) != 2 {
|
||||
t.Errorf("native packages = %v, want the deb and the rpm", after.NativePackages)
|
||||
}
|
||||
if _, ok := after.Platforms["linux-amd64"]; ok {
|
||||
t.Error("the .rpm must not resolve as a platform asset")
|
||||
}
|
||||
if _, ok := after.Downloads["ReasonixStudio-linux-amd64.rpm"]; !ok {
|
||||
t.Error("downloads must offer the .rpm to a person")
|
||||
}
|
||||
if len(after.Downloads) != len(before.Downloads)+1 {
|
||||
t.Errorf("downloads = %v, want only the rpm added", after.Downloads)
|
||||
}
|
||||
}
|
||||
|
||||
// A release that ships Linux for both architectures keys each package under its
|
||||
// own platform: an arm64 client must never be handed the amd64 .deb, and adding
|
||||
// the second architecture must not move the first one's entries.
|
||||
func TestLinuxArm64PackagesAreKeyedBesideAmd64(t *testing.T) {
|
||||
t.Setenv("GITHUB_REPOSITORY", "esengine/DeepSeek-Reasonix")
|
||||
dir := t.TempDir()
|
||||
generate := func() update.Manifest {
|
||||
if err := run(dir, "v2.39.0", "studio-v2.39.0", "", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "latest.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m update.Manifest
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
write := func(arch string) {
|
||||
for _, ext := range []string{"deb", "rpm"} {
|
||||
name := "ReasonixStudio-linux-" + arch + "." + ext
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte(name), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
write("amd64")
|
||||
before := generate()
|
||||
write("arm64")
|
||||
after := generate()
|
||||
for key, asset := range before.NativePackages {
|
||||
if after.NativePackages[key] != asset {
|
||||
t.Errorf("existing channel %s changed", key)
|
||||
}
|
||||
}
|
||||
if len(after.NativePackages) != 4 || len(after.Downloads) != 4 || len(after.Platforms) != 0 {
|
||||
t.Fatalf("manifest channels = %+v, want four native packages and human downloads", after)
|
||||
}
|
||||
for ext, key := range map[string]string{"deb": "linux-arm64", "rpm": "linux-arm64-rpm"} {
|
||||
name := "ReasonixStudio-linux-arm64." + ext
|
||||
asset, ok := after.NativePackages[key]
|
||||
if !ok || after.Downloads[name] != asset {
|
||||
t.Fatalf("%s does not resolve to its own download", key)
|
||||
}
|
||||
mirror := update.StudioMirror + "/studio-v2.39.0/" + name
|
||||
github := "https://github.com/esengine/DeepSeek-Reasonix/releases/download/studio-v2.39.0/" + name
|
||||
if asset.URL != mirror || asset.Sig != mirror+".minisig" || asset.Fallback != github || asset.FallbackSig != github+".minisig" {
|
||||
t.Errorf("%s loses its mirror, fallback or signature: %+v", key, asset)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
func installerCommandLine(installer, dir string) string {
|
||||
// Auto-updates use a dedicated visible progress mode instead of NSIS /S.
|
||||
// The current helper also requires staging-only extraction: NSIS never writes
|
||||
// the live install, and the helper compare-and-publishes the claimed release
|
||||
// unit after validating every staged member.
|
||||
line := fmt.Sprintf(`"%s" /REASONIXUPDATE=1 /REASONIXSTAGE=1`, installer)
|
||||
if dir != "" {
|
||||
line += " /D=" + dir
|
||||
}
|
||||
return line
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInstallerCommandLineUsesVisibleUpdateModeAndLeavesDFlagLast(t *testing.T) {
|
||||
got := installerCommandLine(`C:\Temp\Reasonix Installer.exe`, `D:\Tools\Reasonix App`)
|
||||
want := `"C:\Temp\Reasonix Installer.exe" /REASONIXUPDATE=1 /REASONIXSTAGE=1 /D=D:\Tools\Reasonix App`
|
||||
if got != want {
|
||||
t.Fatalf("installerCommandLine = %q, want %q", got, want)
|
||||
}
|
||||
if strings.Contains(got, " /S") {
|
||||
t.Fatalf("auto-update must expose progress instead of using silent mode, got %q", got)
|
||||
}
|
||||
if !strings.HasSuffix(got, `/D=D:\Tools\Reasonix App`) {
|
||||
t.Fatalf("/D= must be the final unquoted NSIS token, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, " /REASONIXSTAGE=1") {
|
||||
t.Fatalf("auto-update must extract away from the live install, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
import "os"
|
||||
|
||||
// Windows verifies each executable with the OS Authenticode policy while the
|
||||
// same read handle is held. Non-Windows builds keep the release-unit loader
|
||||
// testable without importing platform-specific trust APIs.
|
||||
var readVerifiedWindowsStagedPayloadFn = os.ReadFile
|
||||
@@ -1,97 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
var readVerifiedWindowsStagedPayloadFn = readVerifiedWindowsStagedPayload
|
||||
|
||||
// readVerifiedWindowsStagedPayload verifies the Authenticode signature and
|
||||
// reads the payload through one handle that disallows concurrent writes,
|
||||
// deletes, and renames. The bytes handed to the publisher therefore cannot
|
||||
// change between signature verification and publication.
|
||||
func readVerifiedWindowsStagedPayload(path string) ([]byte, error) {
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil, fmt.Errorf("staged payload is not a regular file")
|
||||
}
|
||||
path16, err := windows.UTF16PtrFromString(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
handle, err := windows.CreateFile(
|
||||
path16,
|
||||
windows.GENERIC_READ,
|
||||
windows.FILE_SHARE_READ,
|
||||
nil,
|
||||
windows.OPEN_EXISTING,
|
||||
windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_SEQUENTIAL_SCAN,
|
||||
0,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
file := os.NewFile(uintptr(handle), path)
|
||||
if file == nil {
|
||||
_ = windows.CloseHandle(handle)
|
||||
return nil, fmt.Errorf("open staged payload handle")
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
fileInfo, err := file.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !fileInfo.Mode().IsRegular() {
|
||||
return nil, fmt.Errorf("staged payload is not a regular file")
|
||||
}
|
||||
|
||||
trustFile := &windows.WinTrustFileInfo{
|
||||
Size: uint32(unsafe.Sizeof(windows.WinTrustFileInfo{})),
|
||||
FilePath: path16,
|
||||
File: handle,
|
||||
}
|
||||
trustData := &windows.WinTrustData{
|
||||
Size: uint32(unsafe.Sizeof(windows.WinTrustData{})),
|
||||
UIChoice: windows.WTD_UI_NONE,
|
||||
RevocationChecks: windows.WTD_REVOKE_NONE,
|
||||
UnionChoice: windows.WTD_CHOICE_FILE,
|
||||
FileOrCatalogOrBlobOrSgnrOrCert: unsafe.Pointer(trustFile),
|
||||
StateAction: windows.WTD_STATEACTION_VERIFY,
|
||||
UIContext: windows.WTD_UICONTEXT_EXECUTE,
|
||||
}
|
||||
verifyErr := windows.WinVerifyTrustEx(
|
||||
windows.InvalidHWND,
|
||||
&windows.WINTRUST_ACTION_GENERIC_VERIFY_V2,
|
||||
trustData,
|
||||
)
|
||||
trustData.StateAction = windows.WTD_STATEACTION_CLOSE
|
||||
closeErr := windows.WinVerifyTrustEx(
|
||||
windows.InvalidHWND,
|
||||
&windows.WINTRUST_ACTION_GENERIC_VERIFY_V2,
|
||||
trustData,
|
||||
)
|
||||
if verifyErr != nil {
|
||||
if closeErr != nil {
|
||||
return nil, fmt.Errorf("Authenticode verification failed: %w (release trust state: %w)", verifyErr, closeErr)
|
||||
}
|
||||
return nil, fmt.Errorf("Authenticode verification failed: %w", verifyErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
return nil, fmt.Errorf("release Authenticode verification state: %w", closeErr)
|
||||
}
|
||||
if _, err := file.Seek(0, io.SeekStart); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return io.ReadAll(file)
|
||||
}
|
||||
@@ -37,11 +37,11 @@ const (
|
||||
exitPostVerify = 16
|
||||
)
|
||||
|
||||
// packageName is the only dpkg package this helper will install, fixed at
|
||||
// build time so no runtime input can redirect it. Studio builds the same
|
||||
// source with its own name; each package installs its own binary, and Polkit
|
||||
// authorizes that path alone.
|
||||
var packageName = "reasonix-desktop"
|
||||
// packageName is the only dpkg package this helper will install. It is Studio's
|
||||
// line as the update path states it, compiled in, so no runtime input can
|
||||
// redirect it and no build step has a value to forget; Polkit authorizes this
|
||||
// helper's own path alone.
|
||||
var packageName = update.StudioLine().Deb.Package
|
||||
|
||||
const (
|
||||
dpkgDebPath = "/usr/bin/dpkg-deb"
|
||||
@@ -124,6 +124,9 @@ func runWith(d installDeps, args []string) int {
|
||||
switch args[0] {
|
||||
case "install":
|
||||
return runInstall(d, args[1:])
|
||||
case "package":
|
||||
fmt.Fprintln(os.Stdout, packageName)
|
||||
return exitOK
|
||||
default:
|
||||
d.writeResult(helperResult{OK: false, Error: "unknown command", Code: "usage"})
|
||||
return exitUsage
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
"aead.dev/minisign"
|
||||
|
||||
"reasonix/internal/base/tempdir"
|
||||
"reasonix/internal/platform/update"
|
||||
)
|
||||
|
||||
func TestSanitizeHelperErrorStripsPaths(t *testing.T) {
|
||||
@@ -596,3 +598,44 @@ func TestMinisignRejectsWrongKeyAndTamper(t *testing.T) {
|
||||
t.Fatalf("exit = %d, want verify failed; results=%+v", code, probe.results)
|
||||
}
|
||||
}
|
||||
|
||||
// The helper installs one package and the shell asks dpkg about the same one:
|
||||
// both must come from the update path's own line, or an in-app update is
|
||||
// refused as a different package.
|
||||
func TestHelperInstallsTheLineTheUpdatePathNames(t *testing.T) {
|
||||
if packageName != update.StudioLine().Deb.Package {
|
||||
t.Fatalf("helper accepts %q, the update path names %q", packageName, update.StudioLine().Deb.Package)
|
||||
}
|
||||
var out bytes.Buffer
|
||||
old := os.Stdout
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdout = w
|
||||
code := runWith(realDeps(), []string{"package"})
|
||||
w.Close()
|
||||
os.Stdout = old
|
||||
_, _ = out.ReadFrom(r)
|
||||
if code != exitOK || strings.TrimSpace(out.String()) != packageName {
|
||||
t.Fatalf("package command = %d %q, want %q", code, out.String(), packageName)
|
||||
}
|
||||
}
|
||||
|
||||
// electron-builder names the packages; the helper must agree with both.
|
||||
func TestPackagedNamesMatchTheHelper(t *testing.T) {
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "electron", "electron-builder.yml"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
names := map[string]string{}
|
||||
section := ""
|
||||
for line := range strings.SplitSeq(string(raw), "\n") {
|
||||
if line != "" && !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "#") && strings.HasSuffix(strings.TrimSpace(line), ":") {
|
||||
section = strings.TrimSuffix(strings.TrimSpace(line), ":")
|
||||
}
|
||||
if rest, ok := strings.CutPrefix(line, " packageName:"); ok {
|
||||
names[section] = strings.TrimSpace(rest)
|
||||
}
|
||||
}
|
||||
if names["deb"] != packageName || names["rpm"] != packageName {
|
||||
t.Fatalf("electron-builder.yml names deb %q and rpm %q, the helper accepts %q", names["deb"], names["rpm"], packageName)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//go:build !windows && !linux
|
||||
//go:build !linux
|
||||
|
||||
package main
|
||||
|
||||
@@ -8,6 +8,6 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Fprintln(os.Stderr, "reasonix-update-helper is only used by Windows and Linux desktop builds")
|
||||
fmt.Fprintln(os.Stderr, "reasonix-update-helper is only used by Linux builds")
|
||||
os.Exit(2)
|
||||
}
|
||||
@@ -1,581 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
|
||||
"reasonix/desktop/internal/winuninstall"
|
||||
"reasonix/internal/platform/installlayout"
|
||||
"reasonix/internal/platform/repair"
|
||||
)
|
||||
|
||||
const parentExitTimeout = 2 * time.Minute
|
||||
|
||||
var (
|
||||
waitForProcessExitFn = waitForProcessExit
|
||||
runInstallerFn = runInstaller
|
||||
startRelaunchFn = startRelaunch
|
||||
claimPendingFileUpdateFn = repair.ClaimPendingFileUpdateExact
|
||||
installStagedReleaseUnitFn = installStagedWindowsReleaseUnit
|
||||
recordInstalledUpdateFn = repair.RecordClaimedFileUpdateInstalled
|
||||
stageInstallerFn = stageVerifiedInstaller
|
||||
claimInstallerExecutionFn = claimVerifiedInstallerForExecution
|
||||
lstatUpdateStagingFn = os.Lstat
|
||||
reconcileWindowsUninstallRegistrationFn = winuninstall.Reconcile
|
||||
)
|
||||
|
||||
func main() {
|
||||
os.Exit(run(os.Args[1:]))
|
||||
}
|
||||
|
||||
func run(args []string) int {
|
||||
var parentPID uint
|
||||
var installer, installerSHA256, installDir, relaunch, toVersion, createdAt, transactionID, installLayout string
|
||||
fs := flag.NewFlagSet("reasonix-update-helper", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
fs.UintVar(&parentPID, "parent-pid", 0, "Reasonix process id to wait for before installing")
|
||||
fs.StringVar(&installer, "installer", "", "verified NSIS installer path")
|
||||
fs.StringVar(&installerSHA256, "installer-sha256", "", "expected SHA-256 of the verified NSIS installer")
|
||||
fs.StringVar(&installDir, "install-dir", "", "Reasonix installation directory")
|
||||
fs.StringVar(&relaunch, "relaunch", "", "Reasonix executable to start after the installer succeeds")
|
||||
fs.StringVar(&toVersion, "to-version", "", "Reasonix version being installed")
|
||||
fs.StringVar(&createdAt, "created-at", "", "pending update creation timestamp")
|
||||
fs.StringVar(&transactionID, "transaction-id", "", "complete pending update identity")
|
||||
fs.StringVar(&installLayout, "install-layout", "", "installation layout contract")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return 2
|
||||
}
|
||||
logger := newLogger()
|
||||
if installer == "" {
|
||||
logger.Print("missing --installer")
|
||||
return 2
|
||||
}
|
||||
if !validSHA256(installerSHA256) {
|
||||
logger.Print("missing or invalid --installer-sha256")
|
||||
return 2
|
||||
}
|
||||
if toVersion == "" {
|
||||
logger.Print("missing --to-version")
|
||||
return 2
|
||||
}
|
||||
if installLayout != "" && installLayout != installlayout.InstallLayoutVersionedV1 {
|
||||
logger.Printf("unsupported --install-layout %q", installLayout)
|
||||
return 2
|
||||
}
|
||||
if installLayout != installlayout.InstallLayoutVersionedV1 && createdAt == "" {
|
||||
logger.Print("missing --created-at")
|
||||
return 2
|
||||
}
|
||||
if installLayout != installlayout.InstallLayoutVersionedV1 && transactionID == "" {
|
||||
logger.Print("missing --transaction-id")
|
||||
return 2
|
||||
}
|
||||
if installDir == "" {
|
||||
logger.Print("missing --install-dir")
|
||||
return 2
|
||||
}
|
||||
if parentPID != 0 {
|
||||
if err := waitForProcessExitFn(uint32(parentPID), parentExitTimeout); err != nil {
|
||||
logger.Printf("wait for parent process %d: %v", parentPID, err)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
if installLayout == installlayout.InstallLayoutVersionedV1 {
|
||||
return runVersionedWindowsUpdate(logger, installer, installerSHA256, installDir, relaunch, toVersion)
|
||||
}
|
||||
recoverExisting := func(reason string) int {
|
||||
if relaunch != "" {
|
||||
if relaunchErr := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); relaunchErr != nil {
|
||||
logger.Printf("relaunch after %s: %v", reason, relaunchErr)
|
||||
}
|
||||
}
|
||||
return 1
|
||||
}
|
||||
// The detached helper runs from the update cache, so a plain
|
||||
// repair.ReadPendingUpdate would validate the transaction against the cache
|
||||
// directory and reject every legitimate flat install. Claim through the
|
||||
// explicit install-local launcher path instead; the repair package validates
|
||||
// the complete transaction identity and exact release-unit path set while it
|
||||
// acquires the pending + target locks.
|
||||
claimTargets := windowsReleaseUnitPaths(installDir)
|
||||
claimLauncher := filepath.Join(installDir, "reasonix-desktop.exe")
|
||||
// The old desktop may acquire the pending lock during its normal shutdown.
|
||||
// Wait for that exact process first, then claim the transaction and hold both
|
||||
// pending and target locks across the installer replacement window.
|
||||
claimed, releaseClaim, err := claimPendingFileUpdateFn(
|
||||
toVersion,
|
||||
createdAt,
|
||||
transactionID,
|
||||
claimLauncher,
|
||||
claimTargets,
|
||||
parentExitTimeout,
|
||||
)
|
||||
if err != nil {
|
||||
logger.Printf("claim pending update: %v", err)
|
||||
return recoverExisting("pending update claim failure")
|
||||
}
|
||||
defer releaseClaim()
|
||||
recoverUnstarted := func() int {
|
||||
releaseClaim()
|
||||
if cancelErr := repair.CancelPendingUpdateExact(claimed); cancelErr != nil {
|
||||
logger.Printf("cancel unstarted update: %v", cancelErr)
|
||||
}
|
||||
if relaunch != "" {
|
||||
if relaunchErr := startRelaunchFn(relaunch, installDir); relaunchErr != nil {
|
||||
logger.Printf("relaunch after unstarted update failure: %v", relaunchErr)
|
||||
}
|
||||
}
|
||||
return 1
|
||||
}
|
||||
claimedInstaller, cleanupInstaller, err := stageInstallerFn(installer, installerSHA256)
|
||||
if err != nil {
|
||||
logger.Printf("bind update installer: %v", err)
|
||||
return recoverUnstarted()
|
||||
}
|
||||
defer func() {
|
||||
if cleanupErr := cleanupInstaller(); cleanupErr != nil {
|
||||
logger.Printf("preserving update installer staging: %v", cleanupErr)
|
||||
}
|
||||
}()
|
||||
stagingDir, err := os.MkdirTemp("", "reasonix-update-stage-*")
|
||||
if err != nil {
|
||||
logger.Printf("create update staging: %v", err)
|
||||
return recoverUnstarted()
|
||||
}
|
||||
stagingOwner, err := lstatUpdateStagingFn(stagingDir)
|
||||
if err != nil {
|
||||
logger.Printf("bind update staging: %v", err)
|
||||
return recoverUnstarted()
|
||||
}
|
||||
defer func() {
|
||||
if cleanupErr := cleanupOwnedWindowsUpdateDirectory(stagingDir, stagingOwner); cleanupErr != nil {
|
||||
logger.Printf("preserving update staging: %v", cleanupErr)
|
||||
}
|
||||
}()
|
||||
releaseInstallerExecution, err := claimInstallerExecutionFn(claimedInstaller, installerSHA256)
|
||||
if err != nil {
|
||||
logger.Printf("recheck staged installer: %v", err)
|
||||
return recoverUnstarted()
|
||||
}
|
||||
installerErr := runInstallerFn(claimedInstaller, stagingDir)
|
||||
releaseInstallerExecution()
|
||||
if installerErr != nil {
|
||||
logger.Printf("extract installer payload: %v", installerErr)
|
||||
return recoverUnstarted()
|
||||
}
|
||||
publishStarted, receipts, err := installStagedReleaseUnitFn(claimed, stagingDir)
|
||||
if err != nil {
|
||||
logger.Printf("publish staged release unit: %v", err)
|
||||
if !publishStarted {
|
||||
releaseClaim()
|
||||
if cancelErr := repair.CancelPendingUpdateExact(claimed); cancelErr != nil {
|
||||
logger.Printf("cancel unstarted update: %v", cancelErr)
|
||||
}
|
||||
}
|
||||
if relaunch != "" {
|
||||
if relaunchErr := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); relaunchErr != nil {
|
||||
logger.Printf("relaunch after publish failure: %v", relaunchErr)
|
||||
}
|
||||
}
|
||||
return 1
|
||||
}
|
||||
if receipts == nil {
|
||||
// Versioned-v1 activation: no flat publish receipts and no health
|
||||
// probation. Clear the pending transaction and relaunch the launcher.
|
||||
releaseClaim()
|
||||
if cancelErr := repair.CancelPendingUpdateExact(claimed); cancelErr != nil {
|
||||
logger.Printf("clear pending after versioned activate: %v", cancelErr)
|
||||
}
|
||||
if clearErr := repair.ClearUpdateApplyFailureExact(claimed); clearErr != nil {
|
||||
logger.Printf("clear apply failure after versioned activate: %v", clearErr)
|
||||
}
|
||||
} else {
|
||||
if _, err := recordInstalledUpdateFn(claimed, receipts...); err != nil {
|
||||
logger.Printf("record installed release unit: %v", err)
|
||||
if markErr := repair.MarkUpdateApplyFailedExact(claimed, err.Error()); markErr != nil {
|
||||
logger.Printf("record install failure: %v", markErr)
|
||||
}
|
||||
if relaunch != "" {
|
||||
if relaunchErr := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); relaunchErr != nil {
|
||||
logger.Printf("relaunch after failed install verification: %v", relaunchErr)
|
||||
}
|
||||
}
|
||||
return 1
|
||||
}
|
||||
if err := repair.ClearUpdateApplyFailureExact(claimed); err != nil {
|
||||
logger.Printf("clear completed update marker: %v", err)
|
||||
}
|
||||
}
|
||||
if relaunch != "" {
|
||||
if err := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); err != nil {
|
||||
logger.Printf("relaunch: %v", err)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func runVersionedWindowsUpdate(logger *log.Logger, installer, installerSHA256, installDir, relaunch, toVersion string) int {
|
||||
recoverExisting := func() int {
|
||||
if relaunch != "" {
|
||||
if err := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); err != nil {
|
||||
logger.Printf("relaunch after versioned update failure: %v", err)
|
||||
}
|
||||
}
|
||||
return 1
|
||||
}
|
||||
if _, err := installlayout.ReadCurrent(installDir); err != nil {
|
||||
logger.Printf("read versioned install pointer: %v", err)
|
||||
return recoverExisting()
|
||||
}
|
||||
claimedInstaller, cleanupInstaller, err := stageInstallerFn(installer, installerSHA256)
|
||||
if err != nil {
|
||||
logger.Printf("bind versioned update installer: %v", err)
|
||||
return recoverExisting()
|
||||
}
|
||||
defer func() {
|
||||
if cleanupErr := cleanupInstaller(); cleanupErr != nil {
|
||||
logger.Printf("preserving update installer staging: %v", cleanupErr)
|
||||
}
|
||||
}()
|
||||
stagingDir, err := os.MkdirTemp("", "reasonix-update-stage-*")
|
||||
if err != nil {
|
||||
logger.Printf("create versioned update staging: %v", err)
|
||||
return recoverExisting()
|
||||
}
|
||||
stagingOwner, err := lstatUpdateStagingFn(stagingDir)
|
||||
if err != nil {
|
||||
logger.Printf("bind versioned update staging: %v", err)
|
||||
return recoverExisting()
|
||||
}
|
||||
defer func() {
|
||||
if cleanupErr := cleanupOwnedWindowsUpdateDirectory(stagingDir, stagingOwner); cleanupErr != nil {
|
||||
logger.Printf("preserving update staging: %v", cleanupErr)
|
||||
}
|
||||
}()
|
||||
releaseInstallerExecution, err := claimInstallerExecutionFn(claimedInstaller, installerSHA256)
|
||||
if err != nil {
|
||||
logger.Printf("recheck staged versioned installer: %v", err)
|
||||
return recoverExisting()
|
||||
}
|
||||
installerErr := runInstallerFn(claimedInstaller, stagingDir)
|
||||
releaseInstallerExecution()
|
||||
if installerErr != nil {
|
||||
logger.Printf("extract versioned installer payload: %v", installerErr)
|
||||
return recoverExisting()
|
||||
}
|
||||
claimed := &repair.UpdateTransaction{
|
||||
SchemaVersion: 1,
|
||||
ToVersion: toVersion,
|
||||
TargetKind: "file",
|
||||
TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
|
||||
}
|
||||
if err := activateVersionedWindowsFromStaging(claimed, stagingDir); err != nil {
|
||||
logger.Printf("activate versioned release: %v", err)
|
||||
return recoverExisting()
|
||||
}
|
||||
if _, err := reconcileWindowsUninstallRegistrationFn(installDir, toVersion); err != nil {
|
||||
// The release is already active and must not be rolled back for stale
|
||||
// Add/Remove Programs metadata. A later update or full installer retries
|
||||
// this idempotent reconciliation.
|
||||
logger.Printf("reconcile Windows uninstall registration: %v", err)
|
||||
}
|
||||
if relaunch != "" {
|
||||
if err := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); err != nil {
|
||||
logger.Printf("relaunch versioned release: %v", err)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// preferRelaunchPath chooses the thin launcher when present so post-update
|
||||
// restarts use the permanent entry point, not a flat desktop binary.
|
||||
func preferRelaunchPath(relaunch, installDir string) string {
|
||||
for _, name := range []string{"reasonix-launcher.exe", "Reasonix.exe"} {
|
||||
path := filepath.Join(installDir, name)
|
||||
if info, err := os.Lstat(path); err == nil && info.Mode().IsRegular() {
|
||||
return path
|
||||
}
|
||||
}
|
||||
return relaunch
|
||||
}
|
||||
|
||||
func validSHA256(value string) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) != sha256.Size*2 {
|
||||
return false
|
||||
}
|
||||
_, err := hex.DecodeString(value)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func stageVerifiedInstaller(sourcePath, expectedSHA256 string) (string, func() error, error) {
|
||||
if !validSHA256(expectedSHA256) {
|
||||
return "", nil, fmt.Errorf("installer SHA-256 is invalid")
|
||||
}
|
||||
sourceInfo, err := os.Lstat(sourcePath)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if !sourceInfo.Mode().IsRegular() {
|
||||
return "", nil, fmt.Errorf("installer is not a regular file")
|
||||
}
|
||||
dir, err := os.MkdirTemp("", "reasonix-update-installer-*")
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
owner, err := os.Lstat(dir)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
cleanup := func() error {
|
||||
return cleanupOwnedWindowsUpdateDirectory(dir, owner)
|
||||
}
|
||||
fail := func(err error) (string, func() error, error) {
|
||||
_ = cleanup()
|
||||
return "", nil, err
|
||||
}
|
||||
source, err := os.Open(sourcePath)
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
defer source.Close()
|
||||
stagedPath := filepath.Join(dir, "reasonix-installer.exe")
|
||||
staged, err := os.OpenFile(stagedPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o700)
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
hash := sha256.New()
|
||||
_, copyErr := io.Copy(io.MultiWriter(staged, hash), source)
|
||||
syncErr := staged.Sync()
|
||||
closeErr := staged.Close()
|
||||
switch {
|
||||
case copyErr != nil:
|
||||
return fail(copyErr)
|
||||
case syncErr != nil:
|
||||
return fail(syncErr)
|
||||
case closeErr != nil:
|
||||
return fail(closeErr)
|
||||
case !strings.EqualFold(hex.EncodeToString(hash.Sum(nil)), strings.TrimSpace(expectedSHA256)):
|
||||
return fail(fmt.Errorf("installer SHA-256 changed after desktop verification"))
|
||||
}
|
||||
if err := verifyInstallerSHA256(stagedPath, expectedSHA256); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
return stagedPath, cleanup, nil
|
||||
}
|
||||
|
||||
func verifyInstallerSHA256(path, expectedSHA256 string) error {
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("installer is not a regular file")
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
hash := sha256.New()
|
||||
if _, err := io.Copy(hash, f); err != nil {
|
||||
return err
|
||||
}
|
||||
if !strings.EqualFold(hex.EncodeToString(hash.Sum(nil)), strings.TrimSpace(expectedSHA256)) {
|
||||
return fmt.Errorf("installer SHA-256 mismatch")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func claimVerifiedInstallerForExecution(path, expectedSHA256 string) (func(), error) {
|
||||
if !validSHA256(expectedSHA256) {
|
||||
return nil, fmt.Errorf("installer SHA-256 is invalid")
|
||||
}
|
||||
pathUTF16, err := windows.UTF16PtrFromString(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
handle, err := windows.CreateFile(
|
||||
pathUTF16,
|
||||
windows.GENERIC_READ,
|
||||
windows.FILE_SHARE_READ,
|
||||
nil,
|
||||
windows.OPEN_EXISTING,
|
||||
windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_SEQUENTIAL_SCAN,
|
||||
0,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
file := os.NewFile(uintptr(handle), path)
|
||||
fail := func(err error) (func(), error) {
|
||||
_ = file.Close()
|
||||
return nil, err
|
||||
}
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return fail(fmt.Errorf("installer is not a regular file"))
|
||||
}
|
||||
hash := sha256.New()
|
||||
if _, err := io.Copy(hash, file); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
if !strings.EqualFold(hex.EncodeToString(hash.Sum(nil)), strings.TrimSpace(expectedSHA256)) {
|
||||
return fail(fmt.Errorf("installer SHA-256 mismatch"))
|
||||
}
|
||||
var once sync.Once
|
||||
return func() {
|
||||
once.Do(func() { _ = file.Close() })
|
||||
}, nil
|
||||
}
|
||||
|
||||
func installStagedWindowsReleaseUnit(
|
||||
claimed *repair.UpdateTransaction,
|
||||
stagingDir string,
|
||||
) (bool, []repair.FileUpdateInstallReceipt, error) {
|
||||
// v1.20+: versioned-v1 is the only publish path. Incomplete staged payloads
|
||||
// fail closed without mutating the live install or leaving flat pending state.
|
||||
if !preferVersionedWindowsActivation(stagingDir) {
|
||||
return false, nil, fmt.Errorf("staged payload is incomplete for versioned-v1 activation")
|
||||
}
|
||||
if err := activateVersionedWindowsFromStaging(claimed, stagingDir); err != nil {
|
||||
return false, nil, fmt.Errorf("versioned activate: %w", err)
|
||||
}
|
||||
return true, nil, nil
|
||||
}
|
||||
|
||||
func windowsReleaseUnitPaths(installDir string) []string {
|
||||
if installDir == "" {
|
||||
return nil
|
||||
}
|
||||
names := []string{
|
||||
"reasonix-desktop.exe",
|
||||
"reasonix-guard.exe",
|
||||
"reasonix-launcher.exe",
|
||||
"reasonix-update-helper.exe",
|
||||
"reasonix-cli.exe",
|
||||
"Reasonix.exe",
|
||||
}
|
||||
paths := make([]string, 0, len(names))
|
||||
for _, name := range names {
|
||||
paths = append(paths, filepath.Join(installDir, name))
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
func newLogger() *log.Logger {
|
||||
dir, err := os.UserCacheDir()
|
||||
if err == nil {
|
||||
dir = filepath.Join(dir, "Reasonix", "updates")
|
||||
if err := os.MkdirAll(dir, 0o700); err == nil {
|
||||
if f, err := os.OpenFile(filepath.Join(dir, "update-helper.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600); err == nil {
|
||||
return log.New(f, "", log.LstdFlags)
|
||||
}
|
||||
}
|
||||
}
|
||||
return log.New(os.Stderr, "", log.LstdFlags)
|
||||
}
|
||||
|
||||
func waitForProcessExit(pid uint32, timeout time.Duration) error {
|
||||
h, err := windows.OpenProcess(windows.SYNCHRONIZE, false, pid)
|
||||
if err != nil {
|
||||
if errors.Is(err, windows.ERROR_INVALID_PARAMETER) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
defer windows.CloseHandle(h)
|
||||
waitMS := uint32(timeout / time.Millisecond)
|
||||
result, err := windows.WaitForSingleObject(h, waitMS)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch result {
|
||||
case windows.WAIT_OBJECT_0:
|
||||
return nil
|
||||
case uint32(windows.WAIT_TIMEOUT):
|
||||
return fmt.Errorf("timed out after %s", timeout)
|
||||
default:
|
||||
return fmt.Errorf("unexpected wait result %d", result)
|
||||
}
|
||||
}
|
||||
|
||||
func runInstaller(installer, installDir string) error {
|
||||
cmd := exec.Command(installer)
|
||||
// Keep the helper itself hidden, but let the NSIS update-progress window be
|
||||
// visible. /REASONIXSTAGE makes the signed installer extract only; the helper
|
||||
// performs every live replacement through the claimed transaction.
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{CmdLine: installerCommandLine(installer, installDir)}
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func cleanupOwnedWindowsUpdateDirectory(path string, owner os.FileInfo) error {
|
||||
if path == "" || owner == nil || !owner.IsDir() {
|
||||
return fmt.Errorf("Windows update cleanup identity is incomplete")
|
||||
}
|
||||
for attempt := range 16 {
|
||||
cleanup := fmt.Sprintf("%s.reasonix-cleanup-%d-%d", path, time.Now().UTC().UnixNano(), attempt)
|
||||
from, err := windows.UTF16PtrFromString(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
to, err := windows.UTF16PtrFromString(cleanup)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := windows.MoveFileEx(from, to, windows.MOVEFILE_WRITE_THROUGH); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
if os.IsExist(err) {
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
actual, err := os.Lstat(cleanup)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !os.SameFile(owner, actual) {
|
||||
restoreFrom, fromErr := windows.UTF16PtrFromString(cleanup)
|
||||
restoreTo, toErr := windows.UTF16PtrFromString(path)
|
||||
if fromErr != nil || toErr != nil {
|
||||
return fmt.Errorf("Windows update staging changed before cleanup; preserve replacement at %s", cleanup)
|
||||
}
|
||||
if restoreErr := windows.MoveFileEx(restoreFrom, restoreTo, windows.MOVEFILE_WRITE_THROUGH); restoreErr != nil {
|
||||
return fmt.Errorf("Windows update staging changed before cleanup; preserve replacement at %s: %w", cleanup, restoreErr)
|
||||
}
|
||||
return fmt.Errorf("Windows update staging changed before cleanup")
|
||||
}
|
||||
return os.RemoveAll(cleanup)
|
||||
}
|
||||
return fmt.Errorf("cannot allocate Windows update cleanup path")
|
||||
}
|
||||
|
||||
func startRelaunch(relaunch, installDir string) error {
|
||||
cmd := exec.Command(relaunch)
|
||||
cmd.Dir = installDir
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
|
||||
return cmd.Start()
|
||||
}
|
||||
@@ -1,563 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/platform/installlayout"
|
||||
"reasonix/internal/platform/repair"
|
||||
|
||||
"reasonix/internal/base/tempdir"
|
||||
)
|
||||
|
||||
const testInstallerSHA256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
|
||||
func prepareLegacyWindowsUpdate(t *testing.T, installDir, toVersion string) *repair.UpdateTransaction {
|
||||
t.Helper()
|
||||
paths := windowsReleaseUnitPaths(installDir)
|
||||
for _, path := range paths {
|
||||
if err := os.WriteFile(path, []byte("old-"+filepath.Base(path)), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
tx, err := repair.PrepareFileUpdate("v1", toVersion, paths[0], paths[1:]...)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return tx
|
||||
}
|
||||
|
||||
func TestStageVerifiedInstallerFreezesExpectedBytes(t *testing.T) {
|
||||
source := filepath.Join(tempdir.New(t), "installer.exe")
|
||||
content := []byte("verified-installer")
|
||||
if err := os.WriteFile(source, content, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sum := sha256.Sum256(content)
|
||||
expected := hex.EncodeToString(sum[:])
|
||||
staged, cleanup, err := stageVerifiedInstaller(source, expected)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = cleanup() })
|
||||
if staged == source {
|
||||
t.Fatal("installer was not isolated from the mutable cache path")
|
||||
}
|
||||
if err := os.WriteFile(source, []byte("changed-after-handoff"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := verifyInstallerSHA256(staged, expected); err != nil {
|
||||
t.Fatalf("isolated installer changed with source: %v", err)
|
||||
}
|
||||
if got, err := os.ReadFile(staged); err != nil || string(got) != string(content) {
|
||||
t.Fatalf("staged installer = %q, %v", got, err)
|
||||
}
|
||||
if err := os.WriteFile(staged, []byte("tampered"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := verifyInstallerSHA256(staged, expected); err == nil {
|
||||
t.Fatal("tampered staged installer passed final verification")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStageVerifiedInstallerRejectsSourceHashDrift(t *testing.T) {
|
||||
source := filepath.Join(tempdir.New(t), "installer.exe")
|
||||
if err := os.WriteFile(source, []byte("different"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, cleanup, err := stageVerifiedInstaller(source, testInstallerSHA256); err == nil {
|
||||
if cleanup != nil {
|
||||
_ = cleanup()
|
||||
}
|
||||
t.Fatal("installer bytes different from desktop verification were accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRequiresTargetVersionBeforeStartingInstaller(t *testing.T) {
|
||||
if code := run([]string{
|
||||
"--installer", `C:\Temp\Reasonix-installer.exe`,
|
||||
"--installer-sha256", testInstallerSHA256,
|
||||
}); code != 2 {
|
||||
t.Fatalf("run without --to-version = %d, want 2", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunVersionedLayoutDoesNotReadOrClaimLegacyPending(t *testing.T) {
|
||||
installDir := tempdir.New(t)
|
||||
seed := tempdir.New(t)
|
||||
for _, name := range []string{"reasonix-desktop.exe", "reasonix-cli.exe", "reasonix-update-helper.exe"} {
|
||||
if err := os.WriteFile(filepath.Join(seed, name), []byte("old-"+name), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := installlayout.ActivateVersion(installlayout.ActivationRequest{
|
||||
InstallRoot: installDir,
|
||||
Version: "v1.20.0",
|
||||
RequestID: "seed-windows-helper",
|
||||
Members: []installlayout.Member{
|
||||
{Name: "reasonix-desktop.exe", Path: filepath.Join(seed, "reasonix-desktop.exe")},
|
||||
{Name: "reasonix-cli.exe", Path: filepath.Join(seed, "reasonix-cli.exe")},
|
||||
{Name: "reasonix-update-helper.exe", Path: filepath.Join(seed, "reasonix-update-helper.exe")},
|
||||
},
|
||||
RequiredNames: []string{"reasonix-desktop.exe", "reasonix-cli.exe", "reasonix-update-helper.exe"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
originalInstaller := runInstallerFn
|
||||
originalRelaunch := startRelaunchFn
|
||||
originalClaim := claimPendingFileUpdateFn
|
||||
originalStageInstaller := stageInstallerFn
|
||||
originalClaimInstallerExecution := claimInstallerExecutionFn
|
||||
originalReconcileUninstall := reconcileWindowsUninstallRegistrationFn
|
||||
t.Cleanup(func() {
|
||||
runInstallerFn = originalInstaller
|
||||
startRelaunchFn = originalRelaunch
|
||||
claimPendingFileUpdateFn = originalClaim
|
||||
stageInstallerFn = originalStageInstaller
|
||||
claimInstallerExecutionFn = originalClaimInstallerExecution
|
||||
reconcileWindowsUninstallRegistrationFn = originalReconcileUninstall
|
||||
})
|
||||
legacyClaimed := false
|
||||
claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
|
||||
legacyClaimed = true
|
||||
return nil, func() {}, errors.New("legacy claim must not run")
|
||||
}
|
||||
stageInstallerFn = func(path, _ string) (string, func() error, error) {
|
||||
return path, func() error { return nil }, nil
|
||||
}
|
||||
claimInstallerExecutionFn = func(string, string) (func(), error) { return func() {}, nil }
|
||||
reconciledVersion := ""
|
||||
reconcileWindowsUninstallRegistrationFn = func(root, targetVersion string) (bool, error) {
|
||||
if root != installDir {
|
||||
t.Fatalf("reconcile root = %q, want %q", root, installDir)
|
||||
}
|
||||
reconciledVersion = targetVersion
|
||||
return true, nil
|
||||
}
|
||||
runInstallerFn = func(_ string, staging string) error {
|
||||
for _, name := range []string{"reasonix-desktop.exe", "reasonix-cli.exe", "reasonix-update-helper.exe", "reasonix-launcher.exe"} {
|
||||
if err := os.WriteFile(filepath.Join(staging, name), []byte("new-"+name), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
relaunched := false
|
||||
startRelaunchFn = func(string, string) error { relaunched = true; return nil }
|
||||
|
||||
if code := run([]string{
|
||||
"--installer", filepath.Join(installDir, "installer.exe"),
|
||||
"--installer-sha256", testInstallerSHA256,
|
||||
"--install-dir", installDir,
|
||||
"--relaunch", filepath.Join(installDir, "reasonix-launcher.exe"),
|
||||
"--to-version", "v1.20.1",
|
||||
"--install-layout", "versioned-v1",
|
||||
}); code != 0 {
|
||||
t.Fatalf("run exit code = %d", code)
|
||||
}
|
||||
if legacyClaimed {
|
||||
t.Fatal("versioned update claimed legacy pending transaction")
|
||||
}
|
||||
if !relaunched {
|
||||
t.Fatal("versioned update did not relaunch")
|
||||
}
|
||||
if reconciledVersion != "v1.20.1" {
|
||||
t.Fatalf("reconciled version = %q, want v1.20.1", reconciledVersion)
|
||||
}
|
||||
ptr, err := installlayout.ReadCurrent(installDir)
|
||||
if err != nil || ptr.ActiveVersion != "v1.20.1" {
|
||||
t.Fatalf("pointer=%+v err=%v", ptr, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunHoldsReleaseUnitLockAcrossInstallerHandoff(t *testing.T) {
|
||||
t.Setenv("REASONIX_HOME", tempdir.New(t))
|
||||
installDir := tempdir.New(t)
|
||||
pending := prepareLegacyWindowsUpdate(t, installDir, "v2")
|
||||
var events []string
|
||||
originalWait := waitForProcessExitFn
|
||||
originalInstaller := runInstallerFn
|
||||
originalRelaunch := startRelaunchFn
|
||||
originalClaim := claimPendingFileUpdateFn
|
||||
originalInstallStaged := installStagedReleaseUnitFn
|
||||
originalRecord := recordInstalledUpdateFn
|
||||
originalStageInstaller := stageInstallerFn
|
||||
originalClaimInstallerExecution := claimInstallerExecutionFn
|
||||
t.Cleanup(func() {
|
||||
waitForProcessExitFn = originalWait
|
||||
runInstallerFn = originalInstaller
|
||||
startRelaunchFn = originalRelaunch
|
||||
claimPendingFileUpdateFn = originalClaim
|
||||
installStagedReleaseUnitFn = originalInstallStaged
|
||||
recordInstalledUpdateFn = originalRecord
|
||||
stageInstallerFn = originalStageInstaller
|
||||
claimInstallerExecutionFn = originalClaimInstallerExecution
|
||||
})
|
||||
waitForProcessExitFn = func(uint32, time.Duration) error {
|
||||
events = append(events, "wait")
|
||||
return nil
|
||||
}
|
||||
runInstallerFn = func(string, string) error {
|
||||
events = append(events, "installer")
|
||||
return nil
|
||||
}
|
||||
startRelaunchFn = func(string, string) error {
|
||||
events = append(events, "relaunch")
|
||||
return nil
|
||||
}
|
||||
claimPendingFileUpdateFn = func(toVersion, createdAt, transactionID, launcherPath string, paths []string, _ time.Duration) (*repair.UpdateTransaction, func(), error) {
|
||||
events = append(events, "claim:"+strings.Join([]string{toVersion, createdAt, transactionID, launcherPath, strings.Join(paths, "\x00")}, "\x01"))
|
||||
return pending, func() { events = append(events, "release") }, nil
|
||||
}
|
||||
installStagedReleaseUnitFn = func(*repair.UpdateTransaction, string) (bool, []repair.FileUpdateInstallReceipt, error) {
|
||||
events = append(events, "publish")
|
||||
return true, []repair.FileUpdateInstallReceipt{}, nil
|
||||
}
|
||||
recordInstalledUpdateFn = func(tx *repair.UpdateTransaction, _ ...repair.FileUpdateInstallReceipt) (*repair.UpdateTransaction, error) {
|
||||
events = append(events, "record-installed")
|
||||
return tx, nil
|
||||
}
|
||||
stageInstallerFn = func(path, sha256 string) (string, func() error, error) {
|
||||
events = append(events, "stage-installer:"+sha256)
|
||||
return path + ".claimed", func() error { return nil }, nil
|
||||
}
|
||||
claimInstallerExecutionFn = func(string, string) (func(), error) {
|
||||
events = append(events, "claim-installer")
|
||||
return func() { events = append(events, "release-installer") }, nil
|
||||
}
|
||||
|
||||
if code := run([]string{
|
||||
"--parent-pid", "1234",
|
||||
"--installer", filepath.Join(installDir, "installer.exe"),
|
||||
"--installer-sha256", testInstallerSHA256,
|
||||
"--install-dir", installDir,
|
||||
"--relaunch", filepath.Join(installDir, "reasonix-desktop.exe"),
|
||||
"--to-version", pending.ToVersion,
|
||||
"--created-at", pending.CreatedAt,
|
||||
"--transaction-id", repair.UpdateTransactionID(pending),
|
||||
}); code != 0 {
|
||||
t.Fatalf("run exit code = %d", code)
|
||||
}
|
||||
wantClaim := "claim:" + strings.Join([]string{
|
||||
pending.ToVersion,
|
||||
pending.CreatedAt,
|
||||
repair.UpdateTransactionID(pending),
|
||||
filepath.Join(installDir, "reasonix-desktop.exe"),
|
||||
strings.Join(windowsReleaseUnitPaths(installDir), "\x00"),
|
||||
}, "\x01")
|
||||
if len(events) != 10 || events[0] != "wait" || events[1] != wantClaim ||
|
||||
events[2] != "stage-installer:"+testInstallerSHA256 ||
|
||||
events[3] != "claim-installer" || events[4] != "installer" ||
|
||||
events[5] != "release-installer" || events[6] != "publish" ||
|
||||
events[7] != "record-installed" || events[8] != "relaunch" ||
|
||||
events[9] != "release" {
|
||||
t.Fatalf("handoff events = %#v", events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDoesNotClaimUpdateBeforeParentExits(t *testing.T) {
|
||||
installDir := tempdir.New(t)
|
||||
originalWait := waitForProcessExitFn
|
||||
originalInstaller := runInstallerFn
|
||||
originalClaim := claimPendingFileUpdateFn
|
||||
originalRecord := recordInstalledUpdateFn
|
||||
t.Cleanup(func() {
|
||||
waitForProcessExitFn = originalWait
|
||||
runInstallerFn = originalInstaller
|
||||
claimPendingFileUpdateFn = originalClaim
|
||||
recordInstalledUpdateFn = originalRecord
|
||||
})
|
||||
waitForProcessExitFn = func(uint32, time.Duration) error {
|
||||
return errors.New("parent still running")
|
||||
}
|
||||
claimed := false
|
||||
claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
|
||||
claimed = true
|
||||
return &repair.UpdateTransaction{}, func() {}, nil
|
||||
}
|
||||
installerRan := false
|
||||
runInstallerFn = func(string, string) error {
|
||||
installerRan = true
|
||||
return nil
|
||||
}
|
||||
|
||||
if code := run([]string{
|
||||
"--parent-pid", "1234",
|
||||
"--installer", filepath.Join(installDir, "installer.exe"),
|
||||
"--installer-sha256", testInstallerSHA256,
|
||||
"--install-dir", installDir,
|
||||
"--to-version", "v2",
|
||||
"--created-at", "2026-07-29T00:00:00Z",
|
||||
"--transaction-id", "transaction-1",
|
||||
}); code != 1 {
|
||||
t.Fatalf("run exit code = %d, want 1", code)
|
||||
}
|
||||
if claimed || installerRan {
|
||||
t.Fatalf("claim=%v installer=%v before parent exit", claimed, installerRan)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRelaunchesWhenLegacyPendingCannotBeClaimed(t *testing.T) {
|
||||
installDir := tempdir.New(t)
|
||||
originalWait := waitForProcessExitFn
|
||||
originalRelaunch := startRelaunchFn
|
||||
originalClaim := claimPendingFileUpdateFn
|
||||
t.Cleanup(func() {
|
||||
waitForProcessExitFn = originalWait
|
||||
startRelaunchFn = originalRelaunch
|
||||
claimPendingFileUpdateFn = originalClaim
|
||||
})
|
||||
|
||||
waitForProcessExitFn = func(uint32, time.Duration) error { return nil }
|
||||
relaunched := false
|
||||
startRelaunchFn = func(path, dir string) error {
|
||||
relaunched = path == filepath.Join(installDir, "reasonix-desktop.exe") && dir == installDir
|
||||
return nil
|
||||
}
|
||||
claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
|
||||
return nil, nil, errors.New("pending lock unavailable")
|
||||
}
|
||||
|
||||
if code := run([]string{
|
||||
"--parent-pid", "1234",
|
||||
"--installer", filepath.Join(installDir, "installer.exe"),
|
||||
"--installer-sha256", testInstallerSHA256,
|
||||
"--install-dir", installDir,
|
||||
"--relaunch", filepath.Join(installDir, "reasonix-desktop.exe"),
|
||||
"--to-version", "v2",
|
||||
"--created-at", "2026-08-02T00:00:00Z",
|
||||
"--transaction-id", strings.Repeat("a", 64),
|
||||
}); code != 1 {
|
||||
t.Fatalf("run exit code = %d, want 1", code)
|
||||
}
|
||||
if !relaunched {
|
||||
t.Fatal("helper did not relaunch the existing desktop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCancelsTransactionWhenStagedExtractionFails(t *testing.T) {
|
||||
t.Setenv("REASONIX_HOME", tempdir.New(t))
|
||||
installDir := tempdir.New(t)
|
||||
originalWait := waitForProcessExitFn
|
||||
originalInstaller := runInstallerFn
|
||||
originalRelaunch := startRelaunchFn
|
||||
originalClaim := claimPendingFileUpdateFn
|
||||
originalInstallStaged := installStagedReleaseUnitFn
|
||||
originalRecord := recordInstalledUpdateFn
|
||||
originalStageInstaller := stageInstallerFn
|
||||
originalClaimInstallerExecution := claimInstallerExecutionFn
|
||||
t.Cleanup(func() {
|
||||
waitForProcessExitFn = originalWait
|
||||
runInstallerFn = originalInstaller
|
||||
startRelaunchFn = originalRelaunch
|
||||
claimPendingFileUpdateFn = originalClaim
|
||||
installStagedReleaseUnitFn = originalInstallStaged
|
||||
recordInstalledUpdateFn = originalRecord
|
||||
stageInstallerFn = originalStageInstaller
|
||||
claimInstallerExecutionFn = originalClaimInstallerExecution
|
||||
})
|
||||
waitForProcessExitFn = func(uint32, time.Duration) error { return nil }
|
||||
runInstallerFn = func(string, string) error { return errors.New("installer failed") }
|
||||
startRelaunchFn = func(string, string) error { return nil }
|
||||
claimed := prepareLegacyWindowsUpdate(t, installDir, "v2")
|
||||
claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
|
||||
return claimed, func() {}, nil
|
||||
}
|
||||
stageInstallerFn = func(path, _ string) (string, func() error, error) {
|
||||
return path, func() error { return nil }, nil
|
||||
}
|
||||
claimInstallerExecutionFn = func(string, string) (func(), error) {
|
||||
return func() {}, nil
|
||||
}
|
||||
transactionID := repair.UpdateTransactionID(claimed)
|
||||
if code := run([]string{
|
||||
"--installer", filepath.Join(installDir, "installer.exe"),
|
||||
"--installer-sha256", testInstallerSHA256,
|
||||
"--install-dir", installDir,
|
||||
"--relaunch", filepath.Join(installDir, "reasonix-desktop.exe"),
|
||||
"--to-version", "v2",
|
||||
"--created-at", claimed.CreatedAt,
|
||||
"--transaction-id", transactionID,
|
||||
}); code != 1 {
|
||||
t.Fatalf("run exit code = %d, want 1", code)
|
||||
}
|
||||
if _, ok := repair.ReadUpdateApplyFailure(); ok {
|
||||
t.Fatal("staging-only installer failure left a recovery marker despite no live publish")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunTreatsInstalledReleaseUnitRecordingFailureAsApplyFailure(t *testing.T) {
|
||||
t.Setenv("REASONIX_HOME", tempdir.New(t))
|
||||
installDir := tempdir.New(t)
|
||||
originalWait := waitForProcessExitFn
|
||||
originalInstaller := runInstallerFn
|
||||
originalRelaunch := startRelaunchFn
|
||||
originalClaim := claimPendingFileUpdateFn
|
||||
originalRecord := recordInstalledUpdateFn
|
||||
originalStageInstaller := stageInstallerFn
|
||||
originalClaimInstallerExecution := claimInstallerExecutionFn
|
||||
t.Cleanup(func() {
|
||||
waitForProcessExitFn = originalWait
|
||||
runInstallerFn = originalInstaller
|
||||
startRelaunchFn = originalRelaunch
|
||||
claimPendingFileUpdateFn = originalClaim
|
||||
recordInstalledUpdateFn = originalRecord
|
||||
stageInstallerFn = originalStageInstaller
|
||||
claimInstallerExecutionFn = originalClaimInstallerExecution
|
||||
})
|
||||
waitForProcessExitFn = func(uint32, time.Duration) error { return nil }
|
||||
runInstallerFn = func(string, string) error { return nil }
|
||||
relaunched := false
|
||||
startRelaunchFn = func(string, string) error {
|
||||
relaunched = true
|
||||
return nil
|
||||
}
|
||||
claimed := prepareLegacyWindowsUpdate(t, installDir, "v2")
|
||||
claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
|
||||
return claimed, func() {}, nil
|
||||
}
|
||||
stageInstallerFn = func(path, _ string) (string, func() error, error) {
|
||||
return path, func() error { return nil }, nil
|
||||
}
|
||||
claimInstallerExecutionFn = func(string, string) (func(), error) {
|
||||
return func() {}, nil
|
||||
}
|
||||
installStagedReleaseUnitFn = func(*repair.UpdateTransaction, string) (bool, []repair.FileUpdateInstallReceipt, error) {
|
||||
return true, []repair.FileUpdateInstallReceipt{{
|
||||
UpdateTransactionID: repair.UpdateTransactionID(claimed),
|
||||
TargetPath: claimed.TargetPath,
|
||||
InstalledStateID: "installed",
|
||||
}}, nil
|
||||
}
|
||||
recordInstalledUpdateFn = func(*repair.UpdateTransaction, ...repair.FileUpdateInstallReceipt) (*repair.UpdateTransaction, error) {
|
||||
return nil, errors.New("release unit drifted")
|
||||
}
|
||||
transactionID := repair.UpdateTransactionID(claimed)
|
||||
|
||||
if code := run([]string{
|
||||
"--installer", filepath.Join(installDir, "installer.exe"),
|
||||
"--installer-sha256", testInstallerSHA256,
|
||||
"--install-dir", installDir,
|
||||
"--relaunch", filepath.Join(installDir, "reasonix-desktop.exe"),
|
||||
"--to-version", "v2",
|
||||
"--created-at", claimed.CreatedAt,
|
||||
"--transaction-id", transactionID,
|
||||
}); code != 1 {
|
||||
t.Fatalf("run exit code = %d, want 1", code)
|
||||
}
|
||||
if !relaunched {
|
||||
t.Fatal("helper did not relaunch through recovery after post-install verification failure")
|
||||
}
|
||||
failure, ok := repair.ReadUpdateApplyFailure()
|
||||
if !ok || failure.UpdateTransactionID != transactionID {
|
||||
t.Fatalf("failure marker = %+v, present=%v", failure, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRelaunchesWhenStagingIdentityCannotBeBound(t *testing.T) {
|
||||
t.Setenv("REASONIX_HOME", tempdir.New(t))
|
||||
installDir := tempdir.New(t)
|
||||
originalWait := waitForProcessExitFn
|
||||
originalInstaller := runInstallerFn
|
||||
originalRelaunch := startRelaunchFn
|
||||
originalClaim := claimPendingFileUpdateFn
|
||||
originalStageInstaller := stageInstallerFn
|
||||
originalLstatStaging := lstatUpdateStagingFn
|
||||
t.Cleanup(func() {
|
||||
waitForProcessExitFn = originalWait
|
||||
runInstallerFn = originalInstaller
|
||||
startRelaunchFn = originalRelaunch
|
||||
claimPendingFileUpdateFn = originalClaim
|
||||
stageInstallerFn = originalStageInstaller
|
||||
lstatUpdateStagingFn = originalLstatStaging
|
||||
})
|
||||
waitForProcessExitFn = func(uint32, time.Duration) error { return nil }
|
||||
installerRan := false
|
||||
runInstallerFn = func(string, string) error {
|
||||
installerRan = true
|
||||
return nil
|
||||
}
|
||||
relaunched := false
|
||||
startRelaunchFn = func(string, string) error {
|
||||
relaunched = true
|
||||
return nil
|
||||
}
|
||||
claimed := prepareLegacyWindowsUpdate(t, installDir, "v2")
|
||||
releases := 0
|
||||
claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
|
||||
return claimed, func() { releases++ }, nil
|
||||
}
|
||||
stageInstallerFn = func(path, _ string) (string, func() error, error) {
|
||||
return path, func() error { return nil }, nil
|
||||
}
|
||||
lstatUpdateStagingFn = func(string) (os.FileInfo, error) {
|
||||
return nil, errors.New("staging identity unavailable")
|
||||
}
|
||||
|
||||
if code := run([]string{
|
||||
"--installer", filepath.Join(installDir, "installer.exe"),
|
||||
"--installer-sha256", testInstallerSHA256,
|
||||
"--install-dir", installDir,
|
||||
"--relaunch", filepath.Join(installDir, "reasonix-desktop.exe"),
|
||||
"--to-version", claimed.ToVersion,
|
||||
"--created-at", claimed.CreatedAt,
|
||||
"--transaction-id", repair.UpdateTransactionID(claimed),
|
||||
}); code != 1 {
|
||||
t.Fatalf("run exit code = %d, want 1", code)
|
||||
}
|
||||
if installerRan {
|
||||
t.Fatal("installer ran without a bound staging directory")
|
||||
}
|
||||
if !relaunched {
|
||||
t.Fatal("helper did not relaunch after staging identity failure")
|
||||
}
|
||||
if releases == 0 {
|
||||
t.Fatal("helper did not release the update claim")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimVerifiedInstallerForExecutionFreezesPath(t *testing.T) {
|
||||
path := filepath.Join(tempdir.New(t), "installer.exe")
|
||||
content := []byte("verified-installer")
|
||||
if err := os.WriteFile(path, content, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sum := sha256.Sum256(content)
|
||||
release, err := claimVerifiedInstallerForExecution(path, hex.EncodeToString(sum[:]))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("tampered"), 0o700); err == nil {
|
||||
release()
|
||||
t.Fatal("installer remained writable while execution claim was held")
|
||||
}
|
||||
if err := os.Rename(path, path+".replaced"); err == nil {
|
||||
release()
|
||||
t.Fatal("installer remained renameable while execution claim was held")
|
||||
}
|
||||
release()
|
||||
release()
|
||||
if err := os.WriteFile(path, []byte("replacement"), 0o700); err != nil {
|
||||
t.Fatalf("installer remained frozen after claim release: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimVerifiedInstallerForExecutionRejectsHashDrift(t *testing.T) {
|
||||
path := filepath.Join(tempdir.New(t), "installer.exe")
|
||||
if err := os.WriteFile(path, []byte("tampered"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if release, err := claimVerifiedInstallerForExecution(path, testInstallerSHA256); err == nil {
|
||||
release()
|
||||
t.Fatal("installer with the wrong SHA-256 received an execution claim")
|
||||
}
|
||||
}
|
||||
@@ -1,238 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/platform/repair"
|
||||
"reasonix/internal/platform/update"
|
||||
)
|
||||
|
||||
const maxWindowsPayloadMetadataSize = 64 << 10
|
||||
|
||||
var verifyWindowsPayloadManifestFn = update.Verify
|
||||
|
||||
type stagedFileUpdateMember struct {
|
||||
targetPath string
|
||||
content []byte
|
||||
mode os.FileMode
|
||||
}
|
||||
|
||||
// loadWindowsStagedReleaseUnit validates and reads the complete NSIS payload
|
||||
// before any live release-unit member is moved. An existing Reasonix.exe is the
|
||||
// portable alias of reasonix-launcher.exe and reuses those staged bytes; an
|
||||
// installed package that did not have the alias remains unchanged.
|
||||
func loadWindowsStagedReleaseUnit(claimed *repair.UpdateTransaction, stagingDir string) ([]stagedFileUpdateMember, error) {
|
||||
if claimed == nil || claimed.TargetKind != "file" || len(claimed.Files) == 0 {
|
||||
return nil, fmt.Errorf("load staged release unit: transaction identity is incomplete")
|
||||
}
|
||||
if err := validateWindowsClaimedReleaseUnit(claimed); err != nil {
|
||||
return nil, fmt.Errorf("load staged release unit: %w", err)
|
||||
}
|
||||
stagingDir = filepath.Clean(strings.TrimSpace(stagingDir))
|
||||
if stagingDir == "" || stagingDir == "." || !filepath.IsAbs(stagingDir) {
|
||||
return nil, fmt.Errorf("load staged release unit: staging directory is invalid")
|
||||
}
|
||||
info, err := os.Lstat(stagingDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load staged release unit: inspect staging directory: %w", err)
|
||||
}
|
||||
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
|
||||
return nil, fmt.Errorf("load staged release unit: staging path is not a directory")
|
||||
}
|
||||
hashes, err := loadWindowsPayloadManifest(stagingDir, claimed.ToVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load staged release unit: %w", err)
|
||||
}
|
||||
|
||||
contents := make(map[string][]byte)
|
||||
members := make([]stagedFileUpdateMember, 0, len(claimed.Files))
|
||||
seenTargets := make(map[string]struct{}, len(claimed.Files))
|
||||
for _, file := range claimed.Files {
|
||||
targetPath := filepath.Clean(strings.TrimSpace(file.TargetPath))
|
||||
targetKey := strings.ToLower(targetPath)
|
||||
if targetPath == "" || targetPath == "." {
|
||||
return nil, fmt.Errorf("load staged release unit: target path is invalid")
|
||||
}
|
||||
if _, ok := seenTargets[targetKey]; ok {
|
||||
return nil, fmt.Errorf("load staged release unit: duplicate target %s", filepath.Base(targetPath))
|
||||
}
|
||||
seenTargets[targetKey] = struct{}{}
|
||||
if strings.EqualFold(filepath.Base(targetPath), "Reasonix.exe") && file.MissingBefore {
|
||||
continue
|
||||
}
|
||||
|
||||
sourceName, err := windowsStagedSourceName(filepath.Base(targetPath))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sourcePath := filepath.Join(stagingDir, sourceName)
|
||||
content, ok := contents[sourceName]
|
||||
if !ok {
|
||||
sourceInfo, statErr := os.Lstat(sourcePath)
|
||||
if statErr != nil {
|
||||
return nil, fmt.Errorf("load staged release unit: inspect %s: %w", sourceName, statErr)
|
||||
}
|
||||
if !sourceInfo.Mode().IsRegular() {
|
||||
return nil, fmt.Errorf("load staged release unit: %s is not a regular file", sourceName)
|
||||
}
|
||||
content, err = readVerifiedWindowsStagedPayloadFn(sourcePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load staged release unit: read %s: %w", sourceName, err)
|
||||
}
|
||||
if !strings.EqualFold(update.WindowsPayloadSHA256(content), hashes[sourceName]) {
|
||||
return nil, fmt.Errorf("load staged release unit: %s does not match the signed release manifest", sourceName)
|
||||
}
|
||||
contents[sourceName] = content
|
||||
}
|
||||
members = append(members, stagedFileUpdateMember{
|
||||
targetPath: targetPath,
|
||||
content: content,
|
||||
mode: 0o700,
|
||||
})
|
||||
}
|
||||
|
||||
// Publish the running desktop last. If an earlier member fails, the old
|
||||
// desktop remains the executable entry point that can report/retry recovery.
|
||||
sort.SliceStable(members, func(i, j int) bool {
|
||||
iPrimary := strings.EqualFold(members[i].targetPath, claimed.TargetPath)
|
||||
jPrimary := strings.EqualFold(members[j].targetPath, claimed.TargetPath)
|
||||
return !iPrimary && jPrimary
|
||||
})
|
||||
return members, nil
|
||||
}
|
||||
|
||||
func validateWindowsClaimedReleaseUnit(claimed *repair.UpdateTransaction) error {
|
||||
if claimed == nil ||
|
||||
!strings.EqualFold(filepath.Base(claimed.TargetPath), "reasonix-desktop.exe") {
|
||||
return fmt.Errorf("claimed release unit primary executable is invalid")
|
||||
}
|
||||
required := map[string]bool{
|
||||
"reasonix-desktop.exe": false,
|
||||
"reasonix-guard.exe": false,
|
||||
"reasonix-launcher.exe": false,
|
||||
"reasonix-update-helper.exe": false,
|
||||
"reasonix-cli.exe": false,
|
||||
"reasonix.exe": false,
|
||||
}
|
||||
installDir := filepath.Clean(filepath.Dir(claimed.TargetPath))
|
||||
for _, file := range claimed.Files {
|
||||
target := filepath.Clean(strings.TrimSpace(file.TargetPath))
|
||||
if target == "" || target == "." ||
|
||||
!strings.EqualFold(filepath.Dir(target), installDir) {
|
||||
return fmt.Errorf("claimed release unit target is outside the installation directory")
|
||||
}
|
||||
name := strings.ToLower(filepath.Base(target))
|
||||
seen, ok := required[name]
|
||||
if !ok {
|
||||
return fmt.Errorf("claimed release unit contains an unexpected target")
|
||||
}
|
||||
if seen {
|
||||
return fmt.Errorf("claimed release unit contains a duplicate target")
|
||||
}
|
||||
required[name] = true
|
||||
}
|
||||
for name, seen := range required {
|
||||
if !seen {
|
||||
return fmt.Errorf("claimed release unit omits %s", name)
|
||||
}
|
||||
}
|
||||
if len(claimed.Files) != len(required) {
|
||||
return fmt.Errorf("claimed release unit contains an unexpected target")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadWindowsPayloadManifest(stagingDir, expectedVersion string) (map[string]string, error) {
|
||||
manifest, err := readWindowsPayloadMetadata(filepath.Join(stagingDir, update.WindowsPayloadManifestName))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read signed release manifest: %w", err)
|
||||
}
|
||||
signature, err := readWindowsPayloadMetadata(filepath.Join(stagingDir, update.WindowsPayloadSignatureName))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read signed release manifest signature: %w", err)
|
||||
}
|
||||
if err := verifyWindowsPayloadManifestFn(manifest, signature); err != nil {
|
||||
return nil, fmt.Errorf("verify signed release manifest: %w", err)
|
||||
}
|
||||
hashes, err := update.DecodeWindowsPayloadManifest(manifest, expectedVersion)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return hashes, nil
|
||||
}
|
||||
|
||||
func readWindowsPayloadMetadata(path string) ([]byte, error) {
|
||||
pathInfo, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !pathInfo.Mode().IsRegular() {
|
||||
return nil, fmt.Errorf("%s is not a regular file", filepath.Base(path))
|
||||
}
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !info.Mode().IsRegular() || info.Size() <= 0 || info.Size() > maxWindowsPayloadMetadataSize {
|
||||
return nil, fmt.Errorf("%s is not a bounded regular file", filepath.Base(path))
|
||||
}
|
||||
if !os.SameFile(pathInfo, info) {
|
||||
return nil, fmt.Errorf("%s changed before it was opened", filepath.Base(path))
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(file, maxWindowsPayloadMetadataSize+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(data) == 0 || len(data) > maxWindowsPayloadMetadataSize {
|
||||
return nil, fmt.Errorf("%s changed size while it was read", filepath.Base(path))
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func windowsStagedSourceName(targetBase string) (string, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(targetBase)) {
|
||||
case "reasonix-desktop.exe":
|
||||
return "reasonix-desktop.exe", nil
|
||||
case "reasonix-guard.exe":
|
||||
return "reasonix-guard.exe", nil
|
||||
case "reasonix-launcher.exe":
|
||||
return "reasonix-launcher.exe", nil
|
||||
case "reasonix-update-helper.exe":
|
||||
return "reasonix-update-helper.exe", nil
|
||||
case "reasonix-cli.exe":
|
||||
return "reasonix-cli.exe", nil
|
||||
case "reasonix.exe":
|
||||
return "reasonix-launcher.exe", nil
|
||||
default:
|
||||
return "", fmt.Errorf("load staged release unit: unsupported target %q", targetBase)
|
||||
}
|
||||
}
|
||||
|
||||
func publishLoadedFileUpdateReleaseUnit(
|
||||
claimed *repair.UpdateTransaction,
|
||||
members []stagedFileUpdateMember,
|
||||
publish func(*repair.UpdateTransaction, string, []byte, os.FileMode) (repair.FileUpdateInstallReceipt, error),
|
||||
) ([]repair.FileUpdateInstallReceipt, error) {
|
||||
if publish == nil || len(members) == 0 {
|
||||
return nil, fmt.Errorf("publish staged release unit: payload is incomplete")
|
||||
}
|
||||
receipts := make([]repair.FileUpdateInstallReceipt, 0, len(members))
|
||||
for _, member := range members {
|
||||
receipt, err := publish(claimed, member.targetPath, member.content, member.mode)
|
||||
if err != nil {
|
||||
return receipts, fmt.Errorf("publish staged release unit %s: %w", filepath.Base(member.targetPath), err)
|
||||
}
|
||||
receipts = append(receipts, receipt)
|
||||
}
|
||||
return receipts, nil
|
||||
}
|
||||
@@ -1,377 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/tempdir"
|
||||
"reasonix/internal/platform/repair"
|
||||
"reasonix/internal/platform/update"
|
||||
)
|
||||
|
||||
func TestLoadWindowsStagedReleaseUnitPreflightsAllMembersAndPublishesDesktopLast(t *testing.T) {
|
||||
staging := tempdir.New(t)
|
||||
for name, content := range map[string]string{
|
||||
"reasonix-desktop.exe": "desktop-v2",
|
||||
"reasonix-guard.exe": "guard-v2",
|
||||
"reasonix-launcher.exe": "launcher-v2",
|
||||
"reasonix-update-helper.exe": "helper-v2",
|
||||
"reasonix-cli.exe": "cli-v2",
|
||||
} {
|
||||
if err := os.WriteFile(filepath.Join(staging, name), []byte(content), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
useTestWindowsPayloadManifest(t, staging, "v2")
|
||||
installDir := tempdir.New(t)
|
||||
claimed := &repair.UpdateTransaction{
|
||||
SchemaVersion: 1,
|
||||
ToVersion: "v2",
|
||||
TargetKind: "file",
|
||||
TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
|
||||
Files: []repair.UpdateTransactionFile{
|
||||
{TargetPath: filepath.Join(installDir, "reasonix-desktop.exe")},
|
||||
{TargetPath: filepath.Join(installDir, "reasonix-guard.exe")},
|
||||
{TargetPath: filepath.Join(installDir, "reasonix-launcher.exe")},
|
||||
{TargetPath: filepath.Join(installDir, "reasonix-update-helper.exe")},
|
||||
{TargetPath: filepath.Join(installDir, "reasonix-cli.exe")},
|
||||
{TargetPath: filepath.Join(installDir, "Reasonix.exe")},
|
||||
},
|
||||
}
|
||||
|
||||
members, err := loadWindowsStagedReleaseUnit(claimed, staging)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := filepath.Base(members[len(members)-1].targetPath); !strings.EqualFold(got, "reasonix-desktop.exe") {
|
||||
t.Fatalf("last published member = %q, want desktop", got)
|
||||
}
|
||||
var published []string
|
||||
receipts, err := publishLoadedFileUpdateReleaseUnit(claimed, members, func(_ *repair.UpdateTransaction, target string, content []byte, _ os.FileMode) (repair.FileUpdateInstallReceipt, error) {
|
||||
published = append(published, filepath.Base(target)+"="+string(content))
|
||||
return repair.FileUpdateInstallReceipt{TargetPath: target}, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(receipts) != len(members) {
|
||||
t.Fatalf("publish receipts = %d, want %d", len(receipts), len(members))
|
||||
}
|
||||
if got := strings.Join(published, ","); !strings.Contains(got, "Reasonix.exe=launcher-v2") {
|
||||
t.Fatalf("portable alias did not reuse launcher payload: %s", got)
|
||||
}
|
||||
if !strings.HasPrefix(published[len(published)-1], "reasonix-desktop.exe=") {
|
||||
t.Fatalf("publish order = %v", published)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWindowsStagedReleaseUnitRejectsIncompletePayloadBeforePublish(t *testing.T) {
|
||||
staging := tempdir.New(t)
|
||||
if err := os.WriteFile(filepath.Join(staging, "reasonix-desktop.exe"), []byte("desktop-v2"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
useTestWindowsPayloadManifest(t, staging, "v2")
|
||||
installDir := tempdir.New(t)
|
||||
claimed := &repair.UpdateTransaction{
|
||||
SchemaVersion: 1,
|
||||
ToVersion: "v2",
|
||||
TargetKind: "file",
|
||||
TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
|
||||
Files: []repair.UpdateTransactionFile{
|
||||
{TargetPath: filepath.Join(installDir, "reasonix-desktop.exe")},
|
||||
{TargetPath: filepath.Join(installDir, "reasonix-guard.exe")},
|
||||
},
|
||||
}
|
||||
if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil {
|
||||
t.Fatal("incomplete staged release unit was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWindowsClaimedReleaseUnitRequiresExactTargets(t *testing.T) {
|
||||
_, complete := completeWindowsStagedReleaseUnitForTest(t, "v2")
|
||||
for i, file := range complete.Files {
|
||||
t.Run("missing-"+strings.ToLower(filepath.Base(file.TargetPath)), func(t *testing.T) {
|
||||
claimed := *complete
|
||||
claimed.Files = append([]repair.UpdateTransactionFile(nil), complete.Files[:i]...)
|
||||
claimed.Files = append(claimed.Files, complete.Files[i+1:]...)
|
||||
if err := validateWindowsClaimedReleaseUnit(&claimed); err == nil ||
|
||||
!strings.Contains(err.Error(), "omits") {
|
||||
t.Fatalf("missing target error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Run("extra", func(t *testing.T) {
|
||||
claimed := *complete
|
||||
claimed.Files = append(append([]repair.UpdateTransactionFile(nil), complete.Files...),
|
||||
repair.UpdateTransactionFile{TargetPath: filepath.Join(filepath.Dir(complete.TargetPath), "other.exe")})
|
||||
if err := validateWindowsClaimedReleaseUnit(&claimed); err == nil ||
|
||||
!strings.Contains(err.Error(), "unexpected") {
|
||||
t.Fatalf("extra target error = %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("duplicate", func(t *testing.T) {
|
||||
claimed := *complete
|
||||
claimed.Files = append(append([]repair.UpdateTransactionFile(nil), complete.Files...), complete.Files[0])
|
||||
if err := validateWindowsClaimedReleaseUnit(&claimed); err == nil ||
|
||||
!strings.Contains(err.Error(), "duplicate") {
|
||||
t.Fatalf("duplicate target error = %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("outside", func(t *testing.T) {
|
||||
claimed := *complete
|
||||
claimed.Files = append([]repair.UpdateTransactionFile(nil), complete.Files...)
|
||||
claimed.Files[1].TargetPath = filepath.Join(tempdir.New(t), filepath.Base(claimed.Files[1].TargetPath))
|
||||
if err := validateWindowsClaimedReleaseUnit(&claimed); err == nil ||
|
||||
!strings.Contains(err.Error(), "outside") {
|
||||
t.Fatalf("outside target error = %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("primary", func(t *testing.T) {
|
||||
claimed := *complete
|
||||
claimed.TargetPath = filepath.Join(filepath.Dir(complete.TargetPath), "reasonix-guard.exe")
|
||||
if err := validateWindowsClaimedReleaseUnit(&claimed); err == nil ||
|
||||
!strings.Contains(err.Error(), "primary") {
|
||||
t.Fatalf("primary target error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestLoadWindowsStagedReleaseUnitDoesNotCreateMissingPortableAlias(t *testing.T) {
|
||||
staging := tempdir.New(t)
|
||||
for _, name := range update.WindowsPayloadFileNames() {
|
||||
content := "payload:" + name
|
||||
if err := os.WriteFile(filepath.Join(staging, name), []byte(content), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
useTestWindowsPayloadManifest(t, staging, "v2")
|
||||
installDir := tempdir.New(t)
|
||||
claimed := &repair.UpdateTransaction{
|
||||
SchemaVersion: 1,
|
||||
ToVersion: "v2",
|
||||
TargetKind: "file",
|
||||
TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
|
||||
Files: []repair.UpdateTransactionFile{
|
||||
{TargetPath: filepath.Join(installDir, "reasonix-desktop.exe")},
|
||||
{TargetPath: filepath.Join(installDir, "reasonix-guard.exe")},
|
||||
{TargetPath: filepath.Join(installDir, "reasonix-launcher.exe")},
|
||||
{TargetPath: filepath.Join(installDir, "reasonix-update-helper.exe")},
|
||||
{TargetPath: filepath.Join(installDir, "reasonix-cli.exe")},
|
||||
{TargetPath: filepath.Join(installDir, "Reasonix.exe"), MissingBefore: true},
|
||||
},
|
||||
}
|
||||
|
||||
members, err := loadWindowsStagedReleaseUnit(claimed, staging)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, member := range members {
|
||||
if strings.EqualFold(filepath.Base(member.targetPath), "Reasonix.exe") {
|
||||
t.Fatalf("missing portable alias was added to publish set: %+v", members)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishLoadedFileUpdateReleaseUnitStopsOnFirstFailedCompareAndPublish(t *testing.T) {
|
||||
claimed := &repair.UpdateTransaction{TargetKind: "file"}
|
||||
members := []stagedFileUpdateMember{
|
||||
{targetPath: "guard.exe", content: []byte("guard"), mode: 0o700},
|
||||
{targetPath: "desktop.exe", content: []byte("desktop"), mode: 0o700},
|
||||
}
|
||||
var published []string
|
||||
receipts, err := publishLoadedFileUpdateReleaseUnit(claimed, members, func(_ *repair.UpdateTransaction, target string, _ []byte, _ os.FileMode) (repair.FileUpdateInstallReceipt, error) {
|
||||
published = append(published, target)
|
||||
return repair.FileUpdateInstallReceipt{}, errors.New("concurrent recreation")
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "concurrent recreation") {
|
||||
t.Fatalf("publish error = %v", err)
|
||||
}
|
||||
if len(published) != 1 {
|
||||
t.Fatalf("published members = %v, want one attempted member", published)
|
||||
}
|
||||
if len(receipts) != 0 {
|
||||
t.Fatalf("failed first publish returned receipts: %+v", receipts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWindowsStagedReleaseUnitRejectsUnverifiedPayloadBeforePublish(t *testing.T) {
|
||||
staging := tempdir.New(t)
|
||||
for _, name := range []string{
|
||||
"reasonix-desktop.exe",
|
||||
"reasonix-guard.exe",
|
||||
"reasonix-launcher.exe",
|
||||
"reasonix-update-helper.exe",
|
||||
"reasonix-cli.exe",
|
||||
} {
|
||||
if err := os.WriteFile(filepath.Join(staging, name), []byte(name), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
writeWindowsPayloadManifestForTest(t, staging, "v2")
|
||||
acceptWindowsPayloadManifestForTest(t)
|
||||
_, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2")
|
||||
original := readVerifiedWindowsStagedPayloadFn
|
||||
readVerifiedWindowsStagedPayloadFn = func(string) ([]byte, error) {
|
||||
return nil, errors.New("payload signature rejected")
|
||||
}
|
||||
t.Cleanup(func() { readVerifiedWindowsStagedPayloadFn = original })
|
||||
|
||||
if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil {
|
||||
t.Fatal("unsigned staged payload must be rejected before publication")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWindowsStagedReleaseUnitReadsEachSourceThroughVerifier(t *testing.T) {
|
||||
staging := tempdir.New(t)
|
||||
for name, content := range map[string]string{
|
||||
"reasonix-desktop.exe": "desktop-v2",
|
||||
"reasonix-guard.exe": "guard-v2",
|
||||
"reasonix-launcher.exe": "launcher-v2",
|
||||
"reasonix-update-helper.exe": "helper-v2",
|
||||
"reasonix-cli.exe": "cli-v2",
|
||||
} {
|
||||
if err := os.WriteFile(filepath.Join(staging, name), []byte(content), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
writeWindowsPayloadManifestForTest(t, staging, "v2")
|
||||
acceptWindowsPayloadManifestForTest(t)
|
||||
_, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2")
|
||||
var verified []string
|
||||
original := readVerifiedWindowsStagedPayloadFn
|
||||
readVerifiedWindowsStagedPayloadFn = func(path string) ([]byte, error) {
|
||||
verified = append(verified, filepath.Base(path))
|
||||
return os.ReadFile(path)
|
||||
}
|
||||
t.Cleanup(func() { readVerifiedWindowsStagedPayloadFn = original })
|
||||
|
||||
if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(verified) != len(update.WindowsPayloadFileNames()) {
|
||||
t.Fatalf("verified payload count = %d, want %d (%v)", len(verified), len(update.WindowsPayloadFileNames()), verified)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWindowsStagedReleaseUnitRejectsMissingManifest(t *testing.T) {
|
||||
staging, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2")
|
||||
useUnverifiedPayloadReaderForTest(t)
|
||||
if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil ||
|
||||
!strings.Contains(err.Error(), update.WindowsPayloadManifestName) {
|
||||
t.Fatalf("missing signed manifest error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWindowsStagedReleaseUnitRejectsBadManifestSignature(t *testing.T) {
|
||||
staging, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2")
|
||||
writeWindowsPayloadManifestForTest(t, staging, "v2")
|
||||
useUnverifiedPayloadReaderForTest(t)
|
||||
if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil ||
|
||||
!strings.Contains(err.Error(), "verify signed release manifest") {
|
||||
t.Fatalf("bad signed manifest error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWindowsStagedReleaseUnitRejectsManifestVersionDrift(t *testing.T) {
|
||||
staging, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2")
|
||||
writeWindowsPayloadManifestForTest(t, staging, "v3")
|
||||
acceptWindowsPayloadManifestForTest(t)
|
||||
useUnverifiedPayloadReaderForTest(t)
|
||||
if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil ||
|
||||
!strings.Contains(err.Error(), "identity does not match") {
|
||||
t.Fatalf("release version drift error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWindowsStagedReleaseUnitRejectsManifestMemberHashDrift(t *testing.T) {
|
||||
staging, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2")
|
||||
writeWindowsPayloadManifestForTest(t, staging, "v2")
|
||||
acceptWindowsPayloadManifestForTest(t)
|
||||
useUnverifiedPayloadReaderForTest(t)
|
||||
if err := os.WriteFile(filepath.Join(staging, "reasonix-guard.exe"), []byte("tampered"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil ||
|
||||
!strings.Contains(err.Error(), "does not match the signed release manifest") {
|
||||
t.Fatalf("release member drift error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func completeWindowsStagedReleaseUnitForTest(t *testing.T, version string) (string, *repair.UpdateTransaction) {
|
||||
t.Helper()
|
||||
staging := tempdir.New(t)
|
||||
for _, name := range update.WindowsPayloadFileNames() {
|
||||
if err := os.WriteFile(filepath.Join(staging, name), []byte("payload:"+name), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
installDir := tempdir.New(t)
|
||||
files := make([]repair.UpdateTransactionFile, 0, len(update.WindowsPayloadFileNames())+1)
|
||||
for _, name := range update.WindowsPayloadFileNames() {
|
||||
files = append(files, repair.UpdateTransactionFile{TargetPath: filepath.Join(installDir, name)})
|
||||
}
|
||||
files = append(files, repair.UpdateTransactionFile{
|
||||
TargetPath: filepath.Join(installDir, "Reasonix.exe"),
|
||||
MissingBefore: true,
|
||||
})
|
||||
return staging, &repair.UpdateTransaction{
|
||||
SchemaVersion: 1,
|
||||
ToVersion: version,
|
||||
TargetKind: "file",
|
||||
TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
|
||||
Files: files,
|
||||
}
|
||||
}
|
||||
|
||||
func writeWindowsPayloadManifestForTest(t *testing.T, staging, version string) {
|
||||
t.Helper()
|
||||
hashes := make(map[string]string)
|
||||
for _, name := range update.WindowsPayloadFileNames() {
|
||||
content, err := os.ReadFile(filepath.Join(staging, name))
|
||||
if os.IsNotExist(err) {
|
||||
content = []byte("missing:" + name)
|
||||
} else if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hashes[name] = update.WindowsPayloadSHA256(content)
|
||||
}
|
||||
manifest, err := update.EncodeWindowsPayloadManifest(version, hashes)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(staging, update.WindowsPayloadManifestName), manifest, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(staging, update.WindowsPayloadSignatureName), []byte("test signature"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func acceptWindowsPayloadManifestForTest(t *testing.T) {
|
||||
t.Helper()
|
||||
original := verifyWindowsPayloadManifestFn
|
||||
verifyWindowsPayloadManifestFn = func(_, signature []byte) error {
|
||||
if string(signature) != "test signature" {
|
||||
return errors.New("unexpected test signature")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() { verifyWindowsPayloadManifestFn = original })
|
||||
}
|
||||
|
||||
func useTestWindowsPayloadManifest(t *testing.T, staging, version string) {
|
||||
t.Helper()
|
||||
writeWindowsPayloadManifestForTest(t, staging, version)
|
||||
acceptWindowsPayloadManifestForTest(t)
|
||||
useUnverifiedPayloadReaderForTest(t)
|
||||
}
|
||||
|
||||
func useUnverifiedPayloadReaderForTest(t *testing.T) {
|
||||
t.Helper()
|
||||
original := readVerifiedWindowsStagedPayloadFn
|
||||
readVerifiedWindowsStagedPayloadFn = os.ReadFile
|
||||
t.Cleanup(func() { readVerifiedWindowsStagedPayloadFn = original })
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/platform/installlayout"
|
||||
"reasonix/internal/platform/repair"
|
||||
)
|
||||
|
||||
// activateVersionedWindowsFromStaging publishes the versioned-v1 layout from a
|
||||
// staged NSIS payload:
|
||||
//
|
||||
// InstallRoot/
|
||||
// reasonix-launcher.exe
|
||||
// Reasonix.exe (launcher alias when present or portable)
|
||||
// reasonix-cli.exe (CLI entry; full binary for now)
|
||||
// current.json
|
||||
// versions/<version>/
|
||||
// reasonix-desktop.exe
|
||||
// reasonix-cli.exe
|
||||
// reasonix-update-helper.exe
|
||||
//
|
||||
// Any failure before the current.json pointer swap leaves the previous active
|
||||
// version unchanged. The helper never counts crashes or selects prior versions.
|
||||
func activateVersionedWindowsFromStaging(claimed *repair.UpdateTransaction, stagingDir string) error {
|
||||
if claimed == nil {
|
||||
return fmt.Errorf("versioned activate: transaction is nil")
|
||||
}
|
||||
installRoot := filepath.Clean(strings.TrimSpace(filepath.Dir(claimed.TargetPath)))
|
||||
// When the claimed primary is already under versions/<ver>/, climb to root.
|
||||
if root, err := installlayout.ResolveInstallRoot(claimed.TargetPath); err == nil && root != "" {
|
||||
installRoot = root
|
||||
}
|
||||
version := strings.TrimSpace(claimed.ToVersion)
|
||||
if err := installlayout.ValidateVersionName(version); err != nil {
|
||||
// Accept bare product versions from NSIS (1.20.0 → v1.20.0).
|
||||
if !strings.HasPrefix(version, "v") {
|
||||
version = "v" + version
|
||||
}
|
||||
if err := installlayout.ValidateVersionName(version); err != nil {
|
||||
return fmt.Errorf("versioned activate: %w", err)
|
||||
}
|
||||
}
|
||||
stagingDir = filepath.Clean(strings.TrimSpace(stagingDir))
|
||||
|
||||
desktopSrc := filepath.Join(stagingDir, "reasonix-desktop.exe")
|
||||
cliSrc := filepath.Join(stagingDir, "reasonix-cli.exe")
|
||||
helperSrc := filepath.Join(stagingDir, "reasonix-update-helper.exe")
|
||||
launcherSrc := filepath.Join(stagingDir, "reasonix-launcher.exe")
|
||||
for _, path := range []string{desktopSrc, cliSrc, helperSrc, launcherSrc} {
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("versioned activate: staged %s: %w", filepath.Base(path), err)
|
||||
}
|
||||
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
|
||||
return fmt.Errorf("versioned activate: staged %s is not a regular file", filepath.Base(path))
|
||||
}
|
||||
}
|
||||
|
||||
requestID := repair.UpdateTransactionID(claimed)
|
||||
if requestID == "" {
|
||||
requestID = "helper-" + version
|
||||
}
|
||||
if err := installlayout.ActivateVersion(installlayout.ActivationRequest{
|
||||
InstallRoot: installRoot,
|
||||
Version: version,
|
||||
RequestID: requestID,
|
||||
Members: []installlayout.Member{
|
||||
{Name: "reasonix-desktop.exe", Path: desktopSrc, Mode: 0o700},
|
||||
{Name: "reasonix-cli.exe", Path: cliSrc, Mode: 0o700},
|
||||
{Name: "reasonix-update-helper.exe", Path: helperSrc, Mode: 0o700},
|
||||
},
|
||||
RootMembers: []installlayout.Member{
|
||||
{Name: "reasonix-launcher.exe", Path: launcherSrc, Mode: 0o700},
|
||||
{Name: "Reasonix.exe", Path: launcherSrc, Mode: 0o700},
|
||||
{Name: "reasonix-cli.exe", Path: cliSrc, Mode: 0o700},
|
||||
},
|
||||
RequiredRootNames: []string{"reasonix-launcher.exe", "Reasonix.exe", "reasonix-cli.exe"},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Remove flat release-unit leftovers so the install root is the thin layout.
|
||||
// Do not remove the launcher/CLI/alias we just wrote.
|
||||
for _, name := range []string{
|
||||
"reasonix-desktop.exe",
|
||||
"reasonix-guard.exe",
|
||||
"reasonix-update-helper.exe", // helper lives only under versions/
|
||||
} {
|
||||
_ = os.Remove(filepath.Join(installRoot, name))
|
||||
}
|
||||
// Best-effort retention GC of older version trees.
|
||||
_ = installlayout.RetainPreviousVersions(installRoot, 0)
|
||||
_ = installlayout.CleanupStaleStaging(installRoot, 0)
|
||||
return nil
|
||||
}
|
||||
|
||||
// preferVersionedWindowsActivation reports whether the staged payload is
|
||||
// complete enough for versioned-v1 activation.
|
||||
func preferVersionedWindowsActivation(stagingDir string) bool {
|
||||
for _, name := range []string{
|
||||
"reasonix-desktop.exe",
|
||||
"reasonix-cli.exe",
|
||||
"reasonix-update-helper.exe",
|
||||
"reasonix-launcher.exe",
|
||||
} {
|
||||
info, err := os.Lstat(filepath.Join(stagingDir, name))
|
||||
if err != nil || !info.Mode().IsRegular() {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/platform/installlayout"
|
||||
"reasonix/internal/platform/repair"
|
||||
|
||||
"reasonix/internal/base/tempdir"
|
||||
)
|
||||
|
||||
func TestPreferVersionedWindowsActivation(t *testing.T) {
|
||||
staging := tempdir.New(t)
|
||||
if preferVersionedWindowsActivation(staging) {
|
||||
t.Fatal("empty staging must not prefer versioned")
|
||||
}
|
||||
for _, name := range []string{
|
||||
"reasonix-desktop.exe",
|
||||
"reasonix-cli.exe",
|
||||
"reasonix-update-helper.exe",
|
||||
"reasonix-launcher.exe",
|
||||
} {
|
||||
if err := os.WriteFile(filepath.Join(staging, name), []byte(name), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if !preferVersionedWindowsActivation(staging) {
|
||||
t.Fatal("complete staging must prefer versioned")
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivateVersionedWindowsFromStaging(t *testing.T) {
|
||||
installDir := tempdir.New(t)
|
||||
staging := tempdir.New(t)
|
||||
for _, name := range []string{
|
||||
"reasonix-desktop.exe",
|
||||
"reasonix-cli.exe",
|
||||
"reasonix-update-helper.exe",
|
||||
"reasonix-launcher.exe",
|
||||
"reasonix-guard.exe",
|
||||
} {
|
||||
if err := os.WriteFile(filepath.Join(staging, name), []byte("payload:"+name), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// Seed a flat desktop so cleanup is observable.
|
||||
if err := os.WriteFile(filepath.Join(installDir, "reasonix-desktop.exe"), []byte("old"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(installDir, "reasonix-guard.exe"), []byte("old-guard"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
claimed := &repair.UpdateTransaction{
|
||||
SchemaVersion: 1,
|
||||
ToVersion: "v1.20.0",
|
||||
TargetKind: "file",
|
||||
TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
|
||||
CreatedAt: "2026-01-01T00:00:00Z",
|
||||
}
|
||||
if err := activateVersionedWindowsFromStaging(claimed, staging); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !installlayout.HasCurrent(installDir) {
|
||||
t.Fatal("current.json missing")
|
||||
}
|
||||
ptr, err := installlayout.ReadCurrent(installDir)
|
||||
if err != nil || ptr.ActiveVersion != "v1.20.0" {
|
||||
t.Fatalf("pointer=%+v err=%v", ptr, err)
|
||||
}
|
||||
desktop, err := installlayout.ActiveDesktopPath(installDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := os.ReadFile(desktop)
|
||||
if err != nil || string(raw) != "payload:reasonix-desktop.exe" {
|
||||
t.Fatalf("desktop payload = %q err=%v", raw, err)
|
||||
}
|
||||
for _, name := range []string{"reasonix-launcher.exe", "Reasonix.exe", "reasonix-cli.exe"} {
|
||||
if _, err := os.Stat(filepath.Join(installDir, name)); err != nil {
|
||||
t.Fatalf("root entry %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(installDir, "reasonix-desktop.exe")); !os.IsNotExist(err) {
|
||||
t.Fatal("flat desktop should be removed")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(installDir, "reasonix-guard.exe")); !os.IsNotExist(err) {
|
||||
t.Fatal("flat guard should be removed")
|
||||
}
|
||||
if prefer := preferRelaunchPath("", installDir); filepath.Base(prefer) != "reasonix-launcher.exe" {
|
||||
t.Fatalf("prefer relaunch = %s", prefer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallStagedWindowsReleaseUnitPrefersVersioned(t *testing.T) {
|
||||
installDir := tempdir.New(t)
|
||||
staging := tempdir.New(t)
|
||||
for _, name := range []string{
|
||||
"reasonix-desktop.exe",
|
||||
"reasonix-cli.exe",
|
||||
"reasonix-update-helper.exe",
|
||||
"reasonix-launcher.exe",
|
||||
"reasonix-guard.exe",
|
||||
} {
|
||||
if err := os.WriteFile(filepath.Join(staging, name), []byte("p:"+name), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// Minimal claimed unit (versioned path does not need full flat file list).
|
||||
claimed := &repair.UpdateTransaction{
|
||||
SchemaVersion: 1,
|
||||
ToVersion: "v1.20.0",
|
||||
TargetKind: "file",
|
||||
TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
|
||||
CreatedAt: "2026-01-01T00:00:00Z",
|
||||
}
|
||||
started, receipts, err := installStagedWindowsReleaseUnit(claimed, staging)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !started || receipts != nil {
|
||||
t.Fatalf("started=%v receipts=%v want started with nil receipts", started, receipts)
|
||||
}
|
||||
if !installlayout.HasCurrent(installDir) {
|
||||
t.Fatal("versioned activation did not write current.json")
|
||||
}
|
||||
}
|
||||
@@ -1,348 +0,0 @@
|
||||
import AppKit
|
||||
import ApplicationServices
|
||||
|
||||
// Refs name elements for the model. One is issued per element and never reused,
|
||||
// so a ref read earlier cannot come to mean something else; an element that has
|
||||
// gone answers computer.stale_ref.
|
||||
final class RefTable {
|
||||
private var next = 0
|
||||
private var byHash: [CFHashCode: [(ref: String, element: AXUIElement)]] = [:]
|
||||
private var byRef: [String: (pid: pid_t, element: AXUIElement)] = [:]
|
||||
|
||||
func ref(for element: AXUIElement, pid: pid_t) -> String {
|
||||
let hash = CFHash(element)
|
||||
if let found = byHash[hash]?.first(where: { CFEqual($0.element, element) }) {
|
||||
return found.ref
|
||||
}
|
||||
next += 1
|
||||
let ref = "a\(next)"
|
||||
byHash[hash, default: []].append((ref, element))
|
||||
byRef[ref] = (pid, element)
|
||||
return ref
|
||||
}
|
||||
|
||||
func element(_ ref: String, pid: pid_t) throws -> AXUIElement {
|
||||
guard let entry = byRef[ref] else {
|
||||
throw Failure(code: "computer.unknown_ref", message: "\(ref) was never issued; refs come from a snapshot")
|
||||
}
|
||||
guard entry.pid == pid else {
|
||||
throw Failure(code: "computer.unknown_ref", message: "\(ref) belongs to another application")
|
||||
}
|
||||
var role: AnyObject?
|
||||
if AXUIElementCopyAttributeValue(entry.element, kAXRoleAttribute as CFString, &role) == .invalidUIElement {
|
||||
throw Failure(code: "computer.stale_ref", message: "\(ref) is no longer on screen; take a new snapshot")
|
||||
}
|
||||
return entry.element
|
||||
}
|
||||
}
|
||||
|
||||
// Seen is one element's identity for a single walk: the accessibility API hands
|
||||
// back a new reference for the same element, so equality is the API's, not the
|
||||
// pointer's.
|
||||
struct Seen: Hashable {
|
||||
let element: AXUIElement
|
||||
|
||||
init(_ element: AXUIElement) { self.element = element }
|
||||
|
||||
static func == (a: Seen, b: Seen) -> Bool { CFEqual(a.element, b.element) }
|
||||
|
||||
func hash(into hasher: inout Hasher) { hasher.combine(CFHash(element)) }
|
||||
}
|
||||
|
||||
// The action name for bringing an element into view. The SDK exports no
|
||||
// constant for it, unlike press and show-menu.
|
||||
let scrollToVisibleAction = "AXScrollToVisible"
|
||||
|
||||
enum Accessibility {
|
||||
static let maxNodes = 1500
|
||||
// How far the scroll bar is nudged to measure what the element does with it.
|
||||
static let scrollProbe = 0.1
|
||||
static let maxDepth = 40
|
||||
// Containers that say nothing when unnamed: their children stand in for them.
|
||||
static let transparent: Set<String> = ["AXGroup", "AXScrollArea", "AXSplitGroup", "AXLayoutArea", "AXUnknown"]
|
||||
|
||||
static func app(_ pid: pid_t) -> AXUIElement {
|
||||
let element = AXUIElementCreateApplication(pid)
|
||||
AXUIElementSetMessagingTimeout(element, 1.5)
|
||||
return element
|
||||
}
|
||||
|
||||
static func attr(_ element: AXUIElement, _ name: String) -> AnyObject? {
|
||||
var value: AnyObject?
|
||||
return AXUIElementCopyAttributeValue(element, name as CFString, &value) == .success ? value : nil
|
||||
}
|
||||
|
||||
static func string(_ element: AXUIElement, _ name: String) -> String {
|
||||
switch attr(element, name) {
|
||||
case let s as String: return s
|
||||
case let n as NSNumber: return n.stringValue
|
||||
default: return ""
|
||||
}
|
||||
}
|
||||
|
||||
static func actions(_ element: AXUIElement) -> [String] {
|
||||
var names: CFArray?
|
||||
return AXUIElementCopyActionNames(element, &names) == .success ? (names as? [String] ?? []) : []
|
||||
}
|
||||
|
||||
static func snapshot(pid: pid_t, refs: RefTable) throws -> JSON {
|
||||
try Permissions.requireAccessibility()
|
||||
guard let running = NSRunningApplication(processIdentifier: pid) else {
|
||||
throw Failure(code: "computer.no_app", message: "no application with pid \(pid) is running")
|
||||
}
|
||||
let root = app(pid)
|
||||
// Chromium-based applications build their tree only for a client that
|
||||
// asks; the switch is ignored by everything else.
|
||||
AXUIElementSetAttributeValue(root, "AXManualAccessibility" as CFString, kCFBooleanTrue)
|
||||
var windows = (attr(root, kAXWindowsAttribute) as? [AXUIElement]) ?? []
|
||||
if let focused = attr(root, kAXFocusedWindowAttribute), CFGetTypeID(focused) == AXUIElementGetTypeID() {
|
||||
let focusedWindow = focused as! AXUIElement
|
||||
windows.removeAll { CFEqual($0, focusedWindow) }
|
||||
windows.insert(focusedWindow, at: 0)
|
||||
}
|
||||
// An application can hand its own element back in place of every window
|
||||
// it has; what lies under it is the menu bar and itself again. The menus
|
||||
// are still worth reading, and the reader has to be told the rest of the
|
||||
// application is not in here.
|
||||
windows.removeAll { CFEqual($0, root) }
|
||||
var note = ""
|
||||
if windows.isEmpty {
|
||||
note = Screen.locked()
|
||||
? "The screen is locked. While it is, macOS exposes no application's windows through accessibility, so nothing inside this window can be read, clicked by ref or hit by point — only the menus below. Typing, keys and screenshots still reach it; unlocking the screen brings the window back."
|
||||
: "This application exposes no window through accessibility right now, so nothing inside its window can be read, clicked by ref or hit by point — only its menus below. Typing and keys still reach it, a screenshot still shows it, and relaunching it usually brings the window back."
|
||||
windows = [root]
|
||||
}
|
||||
var lines: [String] = []
|
||||
var count = 0
|
||||
// An application's tree is not always one: a window of Calculator lists
|
||||
// the application among its children, and walking that lists the window
|
||||
// again. Every element is walked once per snapshot.
|
||||
var seen = Set<Seen>()
|
||||
func walk(_ element: AXUIElement, _ depth: Int) {
|
||||
if count >= maxNodes || depth > maxDepth { return }
|
||||
if !seen.insert(Seen(element)).inserted { return }
|
||||
count += 1
|
||||
let role = string(element, kAXRoleAttribute)
|
||||
var label = string(element, kAXTitleAttribute)
|
||||
if label.isEmpty { label = string(element, kAXDescriptionAttribute) }
|
||||
let value = string(element, kAXValueAttribute)
|
||||
if label.isEmpty && role == "AXStaticText" { label = value }
|
||||
let children = (attr(element, kAXChildrenAttribute) as? [AXUIElement]) ?? []
|
||||
if transparent.contains(role) && label.isEmpty {
|
||||
for child in children { walk(child, depth) }
|
||||
return
|
||||
}
|
||||
var line = String(repeating: " ", count: depth) + "- " + roleName(role)
|
||||
if !label.isEmpty { line += " \(quote(label))" }
|
||||
line += " [\(refs.ref(for: element, pid: pid))]"
|
||||
if !value.isEmpty && value != label && role != "AXStaticText" { line += " value=\(quote(value))" }
|
||||
if let placeholder = attr(element, kAXPlaceholderValueAttribute) as? String, !placeholder.isEmpty, value.isEmpty {
|
||||
line += " placeholder=\(quote(placeholder))"
|
||||
}
|
||||
if (attr(element, kAXFocusedAttribute) as? Bool) == true { line += " focused" }
|
||||
if (attr(element, kAXEnabledAttribute) as? Bool) == false { line += " disabled" }
|
||||
if (attr(element, kAXSelectedAttribute) as? Bool) == true { line += " selected" }
|
||||
if actions(element).contains(kAXPressAction) { line += " pressable" }
|
||||
lines.append(line)
|
||||
for child in children { walk(child, depth + 1) }
|
||||
}
|
||||
for window in windows { walk(window, 0) }
|
||||
return [
|
||||
"app": running.localizedName ?? "",
|
||||
"bundle": running.bundleIdentifier ?? "",
|
||||
"lines": lines,
|
||||
"truncated": count >= maxNodes,
|
||||
"note": note,
|
||||
]
|
||||
}
|
||||
|
||||
static func roleName(_ role: String) -> String {
|
||||
let bare = role.hasPrefix("AX") ? String(role.dropFirst(2)) : role
|
||||
return bare.isEmpty ? "element" : bare.prefix(1).lowercased() + bare.dropFirst()
|
||||
}
|
||||
|
||||
static func quote(_ s: String) -> String {
|
||||
let clipped = s.count > 160 ? String(s.prefix(160)) + "…" : s
|
||||
let data = try? JSONSerialization.data(withJSONObject: [clipped])
|
||||
let json = data.flatMap { String(data: $0, encoding: .utf8) } ?? "[\"\"]"
|
||||
return String(json.dropFirst().dropLast())
|
||||
}
|
||||
|
||||
static func center(_ element: AXUIElement) -> CGPoint? {
|
||||
guard let posValue = attr(element, kAXPositionAttribute), let sizeValue = attr(element, kAXSizeAttribute) else { return nil }
|
||||
var pos = CGPoint.zero, size = CGSize.zero
|
||||
guard AXValueGetValue(posValue as! AXValue, .cgPoint, &pos), AXValueGetValue(sizeValue as! AXValue, .cgSize, &size) else { return nil }
|
||||
return CGPoint(x: pos.x + size.width / 2, y: pos.y + size.height / 2)
|
||||
}
|
||||
|
||||
static func perform(_ element: AXUIElement, _ action: String, what: String) throws {
|
||||
switch AXUIElementPerformAction(element, action as CFString) {
|
||||
case .success:
|
||||
return
|
||||
case .invalidUIElement:
|
||||
throw Failure(code: "computer.stale_ref", message: "\(what) is no longer on screen; take a new snapshot")
|
||||
case .actionUnsupported, .attributeUnsupported:
|
||||
throw Failure(code: "computer.no_action", message: "\(what) takes no \(action)")
|
||||
case let error:
|
||||
throw Failure(code: "computer.failed", message: "\(action) on \(what) failed (AXError \(error.rawValue))")
|
||||
}
|
||||
}
|
||||
|
||||
static func press(pid: pid_t, ref: String, refs: RefTable, cursor: VirtualCursor) throws -> JSON {
|
||||
try Permissions.requireAccessibility()
|
||||
let element = try refs.element(ref, pid: pid)
|
||||
if let at = center(element) { cursor.move(to: at) }
|
||||
try perform(element, kAXPressAction, what: ref)
|
||||
return [:]
|
||||
}
|
||||
|
||||
// menu asks an element for its context menu, which is what a right click is
|
||||
// for: the menu belongs to the element, so no pointer has to be borrowed to
|
||||
// open it. Read the application again afterwards to see the menu.
|
||||
static func menu(pid: pid_t, ref: String, refs: RefTable, cursor: VirtualCursor) throws -> JSON {
|
||||
try Permissions.requireAccessibility()
|
||||
let element = try refs.element(ref, pid: pid)
|
||||
if let at = center(element) { cursor.move(to: at) }
|
||||
guard actions(element).contains(kAXShowMenuAction) else {
|
||||
throw Failure(code: "computer.no_action", message: "\(ref) has no context menu")
|
||||
}
|
||||
try perform(element, kAXShowMenuAction, what: ref)
|
||||
return [:]
|
||||
}
|
||||
|
||||
// reveal scrolls an element into view without a pointer. Where that is not
|
||||
// offered, the wheel goes to the application itself, which scrolls whatever
|
||||
// it considers in front — the same thing a wheel over its window would.
|
||||
static func scroll(pid: pid_t, ref: String, amount: Double, refs: RefTable, cursor: VirtualCursor) throws -> JSON {
|
||||
try Permissions.requireAccessibility()
|
||||
if !ref.isEmpty {
|
||||
let element = try refs.element(ref, pid: pid)
|
||||
if let at = center(element) { cursor.move(to: at) }
|
||||
if actions(element).contains(scrollToVisibleAction) {
|
||||
try perform(element, scrollToVisibleAction, what: ref)
|
||||
return ["how": "revealed"]
|
||||
}
|
||||
if try revealBy(element, ref: ref) {
|
||||
return ["how": "revealed"]
|
||||
}
|
||||
}
|
||||
guard amount != 0 else {
|
||||
throw Failure(code: "computer.bad_step", message: "a scroll needs a ref that can be revealed, or lines to turn the wheel by")
|
||||
}
|
||||
let source = CGEventSource(stateID: .privateState)
|
||||
guard let event = CGEvent(scrollWheelEvent2Source: source, units: .line, wheelCount: 1, wheel1: Int32(amount), wheel2: 0, wheel3: 0) else {
|
||||
throw Failure(code: "computer.failed", message: "the scroll could not be made")
|
||||
}
|
||||
event.postToPid(pid)
|
||||
return ["how": "wheel"]
|
||||
}
|
||||
|
||||
// revealBy brings an element into view in applications that offer no way for
|
||||
// it to reveal itself. Their scroll areas advertise page actions that then
|
||||
// refuse to run (measured: AXScrollDownByPage answers -25205 on AppKit),
|
||||
// while the scroll bar's own value does move the view — so the geometry is
|
||||
// solved on that: move the bar a little, see how far the element went with
|
||||
// it, and put the bar where the element lands inside the area.
|
||||
static func revealBy(_ element: AXUIElement, ref: String) throws -> Bool {
|
||||
guard let area = enclosingScroller(element), let bar = verticalBar(area),
|
||||
let want = frame(element), let have = frame(area) else { return false }
|
||||
if have.intersects(want) { return true }
|
||||
guard var at = attr(bar, kAXValueAttribute) as? Double else { return false }
|
||||
let probe = at > 0.5 ? at - scrollProbe : at + scrollProbe
|
||||
guard set(bar, probe), let moved = frame(element) else { return false }
|
||||
let travel = moved.midY - want.midY
|
||||
if abs(travel) < 1 {
|
||||
_ = set(bar, at)
|
||||
return false
|
||||
}
|
||||
// How far one unit of the bar carries the element, and the value that
|
||||
// leaves it in the middle of what the area shows.
|
||||
let perUnit = travel / (probe - at)
|
||||
at = min(max(probe + (have.midY - moved.midY) / perUnit, 0), 1)
|
||||
guard set(bar, at), let landed = frame(element) else { return false }
|
||||
return have.intersects(landed)
|
||||
}
|
||||
|
||||
static func verticalBar(_ area: AXUIElement) -> AXUIElement? {
|
||||
for child in (attr(area, kAXChildrenAttribute) as? [AXUIElement]) ?? [] {
|
||||
guard string(child, kAXRoleAttribute) == "AXScrollBar" else { continue }
|
||||
if (attr(child, kAXOrientationAttribute) as? String) != "AXHorizontalOrientation" {
|
||||
return child
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
static func set(_ element: AXUIElement, _ value: Double) -> Bool {
|
||||
AXUIElementSetAttributeValue(element, kAXValueAttribute as CFString, value as CFTypeRef) == .success
|
||||
}
|
||||
|
||||
// enclosingScroller is the nearest ancestor that scrolls, which is what a
|
||||
// scroll bar belongs to.
|
||||
static func enclosingScroller(_ element: AXUIElement) -> AXUIElement? {
|
||||
var at = element
|
||||
for _ in 0..<maxDepth {
|
||||
guard let parent = attr(at, kAXParentAttribute), CFGetTypeID(parent) == AXUIElementGetTypeID() else { return nil }
|
||||
at = parent as! AXUIElement
|
||||
if string(at, kAXRoleAttribute) == "AXScrollArea" { return at }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
static func frame(_ element: AXUIElement) -> CGRect? {
|
||||
guard let posValue = attr(element, kAXPositionAttribute), let sizeValue = attr(element, kAXSizeAttribute) else { return nil }
|
||||
var origin = CGPoint.zero
|
||||
var size = CGSize.zero
|
||||
guard AXValueGetValue(posValue as! AXValue, .cgPoint, &origin), AXValueGetValue(sizeValue as! AXValue, .cgSize, &size) else { return nil }
|
||||
return CGRect(origin: origin, size: size)
|
||||
}
|
||||
|
||||
// click is a press at a point: the element there is asked for its action, so
|
||||
// the person's own pointer never moves. An element that has none cannot be
|
||||
// clicked this way, and says so rather than being clicked some other way.
|
||||
static func click(pid: pid_t, x: Double, y: Double, cursor: VirtualCursor) throws -> JSON {
|
||||
try Permissions.requireAccessibility()
|
||||
var hit: AXUIElement?
|
||||
guard AXUIElementCopyElementAtPosition(app(pid), Float(x), Float(y), &hit) == .success, let element = hit else {
|
||||
throw Failure(code: "computer.no_element", message: "nothing of this application is at (\(Int(x)), \(Int(y)))\(Screen.locked() ? "; the screen is locked, and a locked screen exposes no window to click in" : "")")
|
||||
}
|
||||
// Only an element of the application that was approved takes the click,
|
||||
// whatever the hit test resolves the point to.
|
||||
var owner: pid_t = 0
|
||||
guard AXUIElementGetPid(element, &owner) == .success, owner == pid else {
|
||||
throw Failure(code: "computer.no_element", message: "the element at (\(Int(x)), \(Int(y))) belongs to another application")
|
||||
}
|
||||
cursor.move(to: CGPoint(x: x, y: y))
|
||||
let role = string(element, kAXRoleAttribute)
|
||||
if actions(element).contains(kAXPressAction) {
|
||||
try perform(element, kAXPressAction, what: "the \(roleName(role)) at that point")
|
||||
} else if ["AXTextField", "AXTextArea", "AXComboBox", "AXSearchField"].contains(role) {
|
||||
AXUIElementSetAttributeValue(element, kAXFocusedAttribute as CFString, kCFBooleanTrue)
|
||||
} else {
|
||||
throw Failure(code: "computer.no_action", message: "the \(roleName(role)) at that point takes no accessibility action\(Screen.locked() ? " — the screen is locked, which is why this application's window is not there to hit" : "; it needs the real pointer")")
|
||||
}
|
||||
return ["role": roleName(role)]
|
||||
}
|
||||
|
||||
static func focus(pid: pid_t, ref: String, refs: RefTable, cursor: VirtualCursor) throws -> JSON {
|
||||
try Permissions.requireAccessibility()
|
||||
let element = try refs.element(ref, pid: pid)
|
||||
if let at = center(element) { cursor.move(to: at) }
|
||||
let result = AXUIElementSetAttributeValue(element, kAXFocusedAttribute as CFString, kCFBooleanTrue)
|
||||
if result != .success {
|
||||
throw Failure(code: "computer.no_action", message: "\(ref) cannot take focus (AXError \(result.rawValue))")
|
||||
}
|
||||
return [:]
|
||||
}
|
||||
|
||||
static func setValue(pid: pid_t, ref: String, text: String, refs: RefTable, cursor: VirtualCursor) throws -> JSON {
|
||||
try Permissions.requireAccessibility()
|
||||
let element = try refs.element(ref, pid: pid)
|
||||
if let at = center(element) { cursor.move(to: at) }
|
||||
let result = AXUIElementSetAttributeValue(element, kAXValueAttribute as CFString, text as CFString)
|
||||
if result != .success {
|
||||
throw Failure(code: "computer.no_action", message: "\(ref) has no value that can be set (AXError \(result.rawValue))")
|
||||
}
|
||||
return ["value": string(element, kAXValueAttribute)]
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import AppKit
|
||||
import CoreGraphics
|
||||
|
||||
enum Apps {
|
||||
// The applications a person can see, with their windows on screen. Window
|
||||
// names need screen recording; without it a window is listed untitled.
|
||||
static func list() -> [JSON] {
|
||||
let windows = (CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) as? [JSON]) ?? []
|
||||
var byPid: [pid_t: [JSON]] = [:]
|
||||
for w in windows {
|
||||
guard (w[kCGWindowLayer as String] as? Int) == 0,
|
||||
let pid = w[kCGWindowOwnerPID as String] as? pid_t,
|
||||
let id = w[kCGWindowNumber as String] as? Int,
|
||||
let boundsDict = w[kCGWindowBounds as String] as? NSDictionary,
|
||||
let bounds = CGRect(dictionaryRepresentation: boundsDict) else { continue }
|
||||
byPid[pid, default: []].append([
|
||||
"id": id,
|
||||
"title": w[kCGWindowName as String] as? String ?? "",
|
||||
"bounds": ["x": bounds.minX, "y": bounds.minY, "width": bounds.width, "height": bounds.height],
|
||||
])
|
||||
}
|
||||
return NSWorkspace.shared.runningApplications
|
||||
.filter { $0.activationPolicy == .regular }
|
||||
.map { app in
|
||||
[
|
||||
"pid": app.processIdentifier,
|
||||
"bundle": app.bundleIdentifier ?? "",
|
||||
"name": app.localizedName ?? "",
|
||||
"active": app.isActive,
|
||||
"windows": byPid[app.processIdentifier] ?? [],
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import AppKit
|
||||
import ScreenCaptureKit
|
||||
|
||||
enum Capture {
|
||||
// window captures an application's frontmost on-screen window by itself,
|
||||
// whatever covers it, at the display's pixel scale. bounds is where it sits
|
||||
// in global points, which is what a click at a point in the image maps to.
|
||||
//
|
||||
// The answer arrives on whichever thread ScreenCaptureKit finishes on: its
|
||||
// continuations can need the main thread, so waiting for one there is a
|
||||
// deadlock that reads as a capture that never finished.
|
||||
static func window(pid: pid_t, answer: @escaping (Result<JSON, Failure>) -> Void) throws {
|
||||
guard #available(macOS 14.0, *) else {
|
||||
throw Failure(code: "computer.unsupported", message: "capturing one window needs macOS 14 or later")
|
||||
}
|
||||
try capture(pid: pid, answer: answer)
|
||||
}
|
||||
|
||||
@available(macOS 14.0, *)
|
||||
private static func capture(pid: pid_t, answer: @escaping (Result<JSON, Failure>) -> Void) throws {
|
||||
try Permissions.requireScreenRecording()
|
||||
let once = Once(answer)
|
||||
Task.detached {
|
||||
do {
|
||||
let content = try await SCShareableContent.excludingDesktopWindows(true, onScreenWindowsOnly: true)
|
||||
guard let target = content.windows
|
||||
.filter({ $0.owningApplication?.processID == pid && $0.windowLayer == 0 && $0.frame.width > 1 })
|
||||
.first else {
|
||||
once.fire(.failure(Failure(code: "computer.no_window", message: "the application has no window on screen")))
|
||||
return
|
||||
}
|
||||
let filter = SCContentFilter(desktopIndependentWindow: target)
|
||||
let config = SCStreamConfiguration()
|
||||
config.width = Int(target.frame.width * CGFloat(filter.pointPixelScale))
|
||||
config.height = Int(target.frame.height * CGFloat(filter.pointPixelScale))
|
||||
config.showsCursor = false
|
||||
let image = try await SCScreenshotManager.captureImage(contentFilter: filter, configuration: config)
|
||||
let rep = NSBitmapImageRep(cgImage: image)
|
||||
guard let jpeg = rep.representation(using: .jpeg, properties: [.compressionFactor: 0.8]) else {
|
||||
once.fire(.failure(Failure(code: "computer.capture_failed", message: "the capture could not be encoded")))
|
||||
return
|
||||
}
|
||||
once.fire(.success([
|
||||
"data": jpeg.base64EncodedString(),
|
||||
"mime": "image/jpeg",
|
||||
"width": image.width,
|
||||
"height": image.height,
|
||||
"window": target.windowID,
|
||||
"bounds": ["x": target.frame.minX, "y": target.frame.minY, "width": target.frame.width, "height": target.frame.height],
|
||||
]))
|
||||
} catch {
|
||||
once.fire(.failure(Failure(code: "computer.capture_failed", message: "\(error.localizedDescription)")))
|
||||
}
|
||||
}
|
||||
DispatchQueue.global().asyncAfter(deadline: .now() + 10) {
|
||||
var said = "the capture did not finish within 10s"
|
||||
let others = Processes.siblings()
|
||||
if !others.isEmpty {
|
||||
said += "; another helper is running (pid \(others.map(String.init).joined(separator: ", "))) and while one of them has captured, the other's capture never returns — quit the other Studio"
|
||||
}
|
||||
once.fire(.failure(Failure(code: "computer.capture_failed", message: said)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Once carries a request's single answer: the capture and the deadline race for
|
||||
// it, and the request is owed exactly one reply.
|
||||
private final class Once {
|
||||
private let answer: (Result<JSON, Failure>) -> Void
|
||||
private let lock = NSLock()
|
||||
private var fired = false
|
||||
|
||||
init(_ answer: @escaping (Result<JSON, Failure>) -> Void) { self.answer = answer }
|
||||
|
||||
func fire(_ outcome: Result<JSON, Failure>) {
|
||||
lock.lock()
|
||||
let first = !fired
|
||||
fired = true
|
||||
lock.unlock()
|
||||
if first { answer(outcome) }
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import AppKit
|
||||
|
||||
enum Clipboard {
|
||||
// paste puts text where the person is typing without typing it: a long
|
||||
// value arrives at once, and an application that completes or reformats
|
||||
// what it is given sees one insertion instead of a hundred keystrokes.
|
||||
//
|
||||
// The clipboard is the person's. What was on it goes back, unless something
|
||||
// else claimed it while this ran — then theirs is the newer one and stays.
|
||||
static func paste(pid: pid_t, text: String) throws -> JSON {
|
||||
try Permissions.requireAccessibility()
|
||||
// Measured: an application that is not in front never handles the
|
||||
// keystroke, and bringing it forward is the person's screen being taken
|
||||
// over — which is answered for on its own, not folded into this.
|
||||
guard NSWorkspace.shared.frontmostApplication?.processIdentifier == pid else {
|
||||
throw Failure(
|
||||
code: "computer.needs_front",
|
||||
message: "pasting needs this application in front; bring it forward with a pointer step, or use type or set_value, which work while it is behind"
|
||||
)
|
||||
}
|
||||
let board = NSPasteboard.general
|
||||
let saved = snapshot(board)
|
||||
let written = board.clearContents()
|
||||
board.setString(text, forType: .string)
|
||||
_ = try Keyboard.press(pid: pid, chord: "cmd+v", times: 1)
|
||||
// The application reads the clipboard when it gets round to handling the
|
||||
// keystroke, and giving it back before then is what hands it the
|
||||
// person's own clipboard to paste instead. So the wait is long, and it
|
||||
// is not the step's to pay: a busy application still has it a second
|
||||
// after this call has answered.
|
||||
Thread.sleep(forTimeInterval: 0.25)
|
||||
DispatchQueue.global().asyncAfter(deadline: .now() + 1.5) {
|
||||
Clipboard.restore(board, saved, writtenAt: written)
|
||||
}
|
||||
return ["pasted": text.count]
|
||||
}
|
||||
|
||||
private static func snapshot(_ board: NSPasteboard) -> [[NSPasteboard.PasteboardType: Data]] {
|
||||
(board.pasteboardItems ?? []).map { item in
|
||||
var kept: [NSPasteboard.PasteboardType: Data] = [:]
|
||||
for type in item.types {
|
||||
if let data = item.data(forType: type) {
|
||||
kept[type] = data
|
||||
}
|
||||
}
|
||||
return kept
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate static func restore(_ board: NSPasteboard, _ saved: [[NSPasteboard.PasteboardType: Data]], writtenAt: Int) {
|
||||
guard board.changeCount == writtenAt else { return }
|
||||
board.clearContents()
|
||||
guard !saved.isEmpty else { return }
|
||||
board.writeObjects(saved.map { kept in
|
||||
let item = NSPasteboardItem()
|
||||
for (type, data) in kept {
|
||||
item.setData(data, forType: type)
|
||||
}
|
||||
return item
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import AppKit
|
||||
|
||||
// The agent's own pointer: drawn over everything, taking no clicks and no
|
||||
// focus, so a person can see where it acts without it touching theirs.
|
||||
final class VirtualCursor {
|
||||
private let panel: NSPanel
|
||||
private var hideTimer: Timer?
|
||||
private static let size = NSSize(width: 120, height: 44)
|
||||
|
||||
init() {
|
||||
panel = NSPanel(contentRect: NSRect(origin: .zero, size: Self.size), styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: true)
|
||||
panel.isOpaque = false
|
||||
panel.backgroundColor = .clear
|
||||
panel.hasShadow = false
|
||||
panel.ignoresMouseEvents = true
|
||||
panel.level = .screenSaver
|
||||
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary, .ignoresCycle]
|
||||
panel.contentView = CursorView(frame: NSRect(origin: .zero, size: Self.size))
|
||||
// Escape while the agent's cursor is on screen is the person saying stop.
|
||||
// Any other time it is their own keystroke and not this process's business.
|
||||
NSEvent.addGlobalMonitorForEvents(matching: .keyDown) { [weak self] event in
|
||||
guard let self, event.keyCode == 53, self.panel.isVisible else { return }
|
||||
reply(["event": "stop"])
|
||||
self.hide()
|
||||
}
|
||||
}
|
||||
|
||||
// move glides the pointer to a point given in global top-left coordinates,
|
||||
// the ones accessibility and window lists use.
|
||||
func move(to point: CGPoint) {
|
||||
guard let primary = NSScreen.screens.first else { return }
|
||||
let origin = NSPoint(x: point.x - 2, y: primary.frame.height - point.y - Self.size.height + 2)
|
||||
if !panel.isVisible {
|
||||
panel.setFrameOrigin(origin)
|
||||
panel.orderFrontRegardless()
|
||||
} else {
|
||||
NSAnimationContext.runAnimationGroup { context in
|
||||
context.duration = 0.25
|
||||
panel.animator().setFrameOrigin(origin)
|
||||
}
|
||||
}
|
||||
RunLoop.main.run(until: Date().addingTimeInterval(0.28))
|
||||
hideTimer?.invalidate()
|
||||
hideTimer = Timer.scheduledTimer(withTimeInterval: 4, repeats: false) { [weak self] _ in self?.hide() }
|
||||
}
|
||||
|
||||
func hide() {
|
||||
hideTimer?.invalidate()
|
||||
panel.orderOut(nil)
|
||||
}
|
||||
}
|
||||
|
||||
private final class CursorView: NSView {
|
||||
override func draw(_ dirtyRect: NSRect) {
|
||||
let top = bounds.height
|
||||
let arrow = NSBezierPath()
|
||||
arrow.move(to: NSPoint(x: 2, y: top - 2))
|
||||
arrow.line(to: NSPoint(x: 2, y: top - 22))
|
||||
arrow.line(to: NSPoint(x: 7.5, y: top - 17))
|
||||
arrow.line(to: NSPoint(x: 11, y: top - 25))
|
||||
arrow.line(to: NSPoint(x: 14, y: top - 23.5))
|
||||
arrow.line(to: NSPoint(x: 10.5, y: top - 16))
|
||||
arrow.line(to: NSPoint(x: 17, y: top - 16))
|
||||
arrow.close()
|
||||
NSColor.systemIndigo.setFill()
|
||||
arrow.fill()
|
||||
NSColor.white.setStroke()
|
||||
arrow.lineWidth = 1.5
|
||||
arrow.stroke()
|
||||
|
||||
let label = "Reasonix" as NSString
|
||||
let attrs: [NSAttributedString.Key: Any] = [.font: NSFont.systemFont(ofSize: 11, weight: .semibold), .foregroundColor: NSColor.white]
|
||||
let textSize = label.size(withAttributes: attrs)
|
||||
let pill = NSRect(x: 16, y: top - 40, width: textSize.width + 12, height: textSize.height + 4)
|
||||
NSColor.systemIndigo.setFill()
|
||||
NSBezierPath(roundedRect: pill, xRadius: pill.height / 2, yRadius: pill.height / 2).fill()
|
||||
label.draw(at: NSPoint(x: pill.minX + 6, y: pill.minY + 2), withAttributes: attrs)
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import ApplicationServices
|
||||
import Carbon.HIToolbox
|
||||
|
||||
// Keyboard input goes to one process, never to whatever is frontmost, so the
|
||||
// person's own typing elsewhere is not interleaved with it.
|
||||
enum Keyboard {
|
||||
static let named: [String: Int] = [
|
||||
"enter": kVK_Return, "return": kVK_Return, "tab": kVK_Tab, "escape": kVK_Escape, "space": kVK_Space,
|
||||
"backspace": kVK_Delete, "delete": kVK_ForwardDelete,
|
||||
"arrowleft": kVK_LeftArrow, "arrowright": kVK_RightArrow, "arrowup": kVK_UpArrow, "arrowdown": kVK_DownArrow,
|
||||
"home": kVK_Home, "end": kVK_End, "pageup": kVK_PageUp, "pagedown": kVK_PageDown, "insert": kVK_Help,
|
||||
"a": kVK_ANSI_A, "b": kVK_ANSI_B, "c": kVK_ANSI_C, "d": kVK_ANSI_D, "e": kVK_ANSI_E, "f": kVK_ANSI_F,
|
||||
"g": kVK_ANSI_G, "h": kVK_ANSI_H, "i": kVK_ANSI_I, "j": kVK_ANSI_J, "k": kVK_ANSI_K, "l": kVK_ANSI_L,
|
||||
"m": kVK_ANSI_M, "n": kVK_ANSI_N, "o": kVK_ANSI_O, "p": kVK_ANSI_P, "q": kVK_ANSI_Q, "r": kVK_ANSI_R,
|
||||
"s": kVK_ANSI_S, "t": kVK_ANSI_T, "u": kVK_ANSI_U, "v": kVK_ANSI_V, "w": kVK_ANSI_W, "x": kVK_ANSI_X,
|
||||
"y": kVK_ANSI_Y, "z": kVK_ANSI_Z,
|
||||
"0": kVK_ANSI_0, "1": kVK_ANSI_1, "2": kVK_ANSI_2, "3": kVK_ANSI_3, "4": kVK_ANSI_4,
|
||||
"5": kVK_ANSI_5, "6": kVK_ANSI_6, "7": kVK_ANSI_7, "8": kVK_ANSI_8, "9": kVK_ANSI_9,
|
||||
"f1": kVK_F1, "f2": kVK_F2, "f3": kVK_F3, "f4": kVK_F4, "f5": kVK_F5, "f6": kVK_F6,
|
||||
"f7": kVK_F7, "f8": kVK_F8, "f9": kVK_F9, "f10": kVK_F10, "f11": kVK_F11, "f12": kVK_F12,
|
||||
"minus": kVK_ANSI_Minus, "equal": kVK_ANSI_Equal, "comma": kVK_ANSI_Comma, "period": kVK_ANSI_Period,
|
||||
"slash": kVK_ANSI_Slash, "semicolon": kVK_ANSI_Semicolon, "quote": kVK_ANSI_Quote,
|
||||
"bracketleft": kVK_ANSI_LeftBracket, "bracketright": kVK_ANSI_RightBracket,
|
||||
"backslash": kVK_ANSI_Backslash, "backquote": kVK_ANSI_Grave,
|
||||
]
|
||||
static let modifiers: [String: CGEventFlags] = [
|
||||
"shift": .maskShift, "control": .maskControl, "ctrl": .maskControl,
|
||||
"alt": .maskAlternate, "option": .maskAlternate, "meta": .maskCommand, "cmd": .maskCommand,
|
||||
]
|
||||
|
||||
static func type(pid: pid_t, text: String) throws -> JSON {
|
||||
try Permissions.requireAccessibility()
|
||||
let source = CGEventSource(stateID: .privateState)
|
||||
let units = Array(text.utf16)
|
||||
var at = 0
|
||||
while at < units.count {
|
||||
let chunk = Array(units[at..<min(at + 16, units.count)])
|
||||
at += chunk.count
|
||||
for down in [true, false] {
|
||||
guard let event = CGEvent(keyboardEventSource: source, virtualKey: 0, keyDown: down) else { continue }
|
||||
chunk.withUnsafeBufferPointer { event.keyboardSetUnicodeString(stringLength: chunk.count, unicodeString: $0.baseAddress) }
|
||||
event.postToPid(pid)
|
||||
}
|
||||
}
|
||||
return [:]
|
||||
}
|
||||
|
||||
// hold keeps a key down, which is what a game or a scrubbing control reads
|
||||
// rather than a press.
|
||||
static func hold(pid: pid_t, chord: String, seconds: Double) throws -> JSON {
|
||||
let (code, flags) = try resolve(chord)
|
||||
let source = CGEventSource(stateID: .privateState)
|
||||
if let down = CGEvent(keyboardEventSource: source, virtualKey: CGKeyCode(code), keyDown: true) {
|
||||
down.flags = flags
|
||||
down.postToPid(pid)
|
||||
}
|
||||
Thread.sleep(forTimeInterval: min(max(seconds, 0), 30))
|
||||
if let up = CGEvent(keyboardEventSource: source, virtualKey: CGKeyCode(code), keyDown: false) {
|
||||
up.flags = flags
|
||||
up.postToPid(pid)
|
||||
}
|
||||
return [:]
|
||||
}
|
||||
|
||||
// resolve reads a chord like "meta+s" into the key and the modifiers held
|
||||
// with it, and says what the vocabulary is when it cannot.
|
||||
static func resolve(_ chord: String) throws -> (Int, CGEventFlags) {
|
||||
try Permissions.requireAccessibility()
|
||||
let parts = chord.split(separator: "+").map { $0.trimmingCharacters(in: .whitespaces).lowercased() }
|
||||
guard let last = parts.last, let code = named[last] else {
|
||||
throw Failure(code: "computer.bad_step",
|
||||
message: "\(chord) is not a key this can press. It presses named keys — enter, tab, escape, space, backspace, delete, insert, the arrows, home, end, pageup, pagedown, f1 to f12, a letter, a digit, or minus, equal, comma, period, slash, semicolon, quote, bracketleft, bracketright, backslash, backquote — with the modifiers shift, control, alt and meta. Text goes through the type action")
|
||||
}
|
||||
var flags = CGEventFlags()
|
||||
for part in parts.dropLast() {
|
||||
guard let flag = modifiers[part] else {
|
||||
throw Failure(code: "computer.bad_step", message: "\(part) is not a modifier; use shift, control, alt or meta")
|
||||
}
|
||||
flags.insert(flag)
|
||||
}
|
||||
return (code, flags)
|
||||
}
|
||||
|
||||
static func press(pid: pid_t, chord: String, times: Int) throws -> JSON {
|
||||
let (code, flags) = try resolve(chord)
|
||||
let source = CGEventSource(stateID: .privateState)
|
||||
for _ in 0..<max(times, 1) {
|
||||
for down in [true, false] {
|
||||
guard let event = CGEvent(keyboardEventSource: source, virtualKey: CGKeyCode(code), keyDown: down) else { continue }
|
||||
event.flags = flags
|
||||
event.postToPid(pid)
|
||||
}
|
||||
}
|
||||
return [:]
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import AppKit
|
||||
import ApplicationServices
|
||||
import CoreGraphics
|
||||
|
||||
enum Permissions {
|
||||
static func status() -> JSON {
|
||||
["accessibility": AXIsProcessTrusted(), "screen_recording": CGPreflightScreenCaptureAccess(), "screen_locked": Screen.locked()]
|
||||
}
|
||||
|
||||
// Asks the system to show its own prompt, and opens the pane where the
|
||||
// switch lives: a prompt that was dismissed once is not shown again.
|
||||
static func request(_ which: String) throws -> JSON {
|
||||
let pane: String
|
||||
switch which {
|
||||
case "accessibility":
|
||||
let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] as CFDictionary
|
||||
_ = AXIsProcessTrustedWithOptions(options)
|
||||
pane = "Privacy_Accessibility"
|
||||
case "screen_recording":
|
||||
_ = CGRequestScreenCaptureAccess()
|
||||
pane = "Privacy_ScreenCapture"
|
||||
default:
|
||||
throw Failure(code: "computer.bad_request", message: "which must be accessibility or screen_recording")
|
||||
}
|
||||
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?\(pane)") {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
return status()
|
||||
}
|
||||
|
||||
static func requireAccessibility() throws {
|
||||
if !AXIsProcessTrusted() {
|
||||
throw Failure(code: "computer.permission_missing", message: "accessibility")
|
||||
}
|
||||
}
|
||||
|
||||
static func requireScreenRecording() throws {
|
||||
if !CGPreflightScreenCaptureAccess() {
|
||||
throw Failure(code: "computer.permission_missing", message: "screen_recording")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
import AppKit
|
||||
import CoreGraphics
|
||||
|
||||
// The person's own pointer, moved and clicked by the agent. Every other path in
|
||||
// this helper works through accessibility and leaves the pointer where it is;
|
||||
// this is the one that does not, so the kernel asks for it by name and it hands
|
||||
// the pointer back where it found it.
|
||||
//
|
||||
// A point is acted on only while the window under it belongs to the application
|
||||
// that was approved: on screen the agent has no window of its own to aim at.
|
||||
enum Pointer {
|
||||
private static var parked: CGPoint?
|
||||
|
||||
static func owner(of point: CGPoint) -> pid_t? {
|
||||
let list = (CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]]) ?? []
|
||||
for window in list {
|
||||
guard let layer = window[kCGWindowLayer as String] as? Int, layer == 0,
|
||||
let pid = window[kCGWindowOwnerPID as String] as? pid_t, pid != getpid(),
|
||||
let bounds = window[kCGWindowBounds as String] as? [String: CGFloat],
|
||||
let x = bounds["X"], let y = bounds["Y"], let w = bounds["Width"], let h = bounds["Height"] else { continue }
|
||||
if CGRect(x: x, y: y, width: w, height: h).contains(point) { return pid }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// front brings the application forward before the pointer acts on it. A
|
||||
// click into a background window is spent on activating it — macOS gives the
|
||||
// view nothing — so taking the pointer means taking the screen with it.
|
||||
static func front(_ pid: pid_t) {
|
||||
guard let app = NSRunningApplication(processIdentifier: pid), !app.isActive else { return }
|
||||
app.activate(options: [.activateAllWindows])
|
||||
Thread.sleep(forTimeInterval: 0.35)
|
||||
}
|
||||
|
||||
static func check(_ pid: pid_t, _ point: CGPoint) throws {
|
||||
guard let at = owner(of: point) else {
|
||||
throw Failure(code: "computer.no_element", message: "no window is at (\(Int(point.x)), \(Int(point.y)))")
|
||||
}
|
||||
guard at == pid else {
|
||||
throw Failure(code: "computer.no_element", message: "the window at (\(Int(point.x)), \(Int(point.y))) belongs to another application")
|
||||
}
|
||||
}
|
||||
|
||||
// park remembers where the person left their pointer, once per run of steps.
|
||||
static func park() {
|
||||
if parked == nil { parked = CGEvent(source: nil)?.location }
|
||||
}
|
||||
|
||||
// release puts the pointer back where the person left it.
|
||||
static func release() -> JSON {
|
||||
defer { parked = nil }
|
||||
guard let home = parked else { return [:] }
|
||||
post(.mouseMoved, home, .left, 0)
|
||||
return ["returned": true]
|
||||
}
|
||||
|
||||
// position is where the pointer is now, which is what a run that has moved
|
||||
// it has to be able to read back.
|
||||
static func position() -> JSON {
|
||||
let at = CGEvent(source: nil)?.location ?? .zero
|
||||
return ["x": at.x, "y": at.y]
|
||||
}
|
||||
|
||||
static func move(pid: pid_t, to point: CGPoint, cursor: VirtualCursor) throws -> JSON {
|
||||
try Permissions.requireAccessibility()
|
||||
try check(pid, point)
|
||||
park()
|
||||
cursor.move(to: point)
|
||||
post(.mouseMoved, point, .left, 0)
|
||||
return [:]
|
||||
}
|
||||
|
||||
static func click(pid: pid_t, at point: CGPoint, button: String, clicks: Int, cursor: VirtualCursor) throws -> JSON {
|
||||
try Permissions.requireAccessibility()
|
||||
try check(pid, point)
|
||||
park()
|
||||
front(pid)
|
||||
cursor.move(to: point)
|
||||
let (down, up, which) = try events(for: button)
|
||||
post(.mouseMoved, point, which, 0)
|
||||
for click in 1...max(clicks, 1) {
|
||||
post(down, point, which, click)
|
||||
post(up, point, which, click)
|
||||
}
|
||||
return [:]
|
||||
}
|
||||
|
||||
static func drag(pid: pid_t, from: CGPoint, to: CGPoint, cursor: VirtualCursor) throws -> JSON {
|
||||
try Permissions.requireAccessibility()
|
||||
try check(pid, from)
|
||||
try check(pid, to)
|
||||
park()
|
||||
front(pid)
|
||||
cursor.move(to: from)
|
||||
post(.mouseMoved, from, .left, 0)
|
||||
post(.leftMouseDown, from, .left, 1)
|
||||
for step in 1...dragSteps {
|
||||
let at = CGFloat(step) / CGFloat(dragSteps)
|
||||
post(.leftMouseDragged, CGPoint(x: from.x + (to.x - from.x) * at, y: from.y + (to.y - from.y) * at), .left, 1)
|
||||
Thread.sleep(forTimeInterval: 0.02)
|
||||
}
|
||||
cursor.move(to: to)
|
||||
post(.leftMouseUp, to, .left, 1)
|
||||
return [:]
|
||||
}
|
||||
|
||||
private static let dragSteps = 12
|
||||
|
||||
private static func events(for button: String) throws -> (CGEventType, CGEventType, CGMouseButton) {
|
||||
switch button {
|
||||
case "", "left":
|
||||
return (.leftMouseDown, .leftMouseUp, .left)
|
||||
case "right":
|
||||
return (.rightMouseDown, .rightMouseUp, .right)
|
||||
case "middle":
|
||||
return (.otherMouseDown, .otherMouseUp, .center)
|
||||
default:
|
||||
throw Failure(code: "computer.bad_step", message: "\(button) is not a mouse button; use left, right or middle")
|
||||
}
|
||||
}
|
||||
|
||||
private static func post(_ type: CGEventType, _ point: CGPoint, _ button: CGMouseButton, _ clicks: Int) {
|
||||
guard let event = CGEvent(mouseEventSource: CGEventSource(stateID: .combinedSessionState), mouseType: type, mouseCursorPosition: point, mouseButton: button) else { return }
|
||||
if clicks > 1 { event.setIntegerValueField(.mouseEventClickState, value: Int64(clicks)) }
|
||||
event.post(tap: .cghidEventTap)
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import Darwin
|
||||
import Foundation
|
||||
|
||||
enum Processes {
|
||||
// Other processes running this same executable. Measured on macOS 15: once
|
||||
// one of them has captured a window, a capture in any other never returns,
|
||||
// for any application and with no expiry, until that process exits. It is
|
||||
// what a capture that ran out of time has to say for itself.
|
||||
static func siblings() -> [pid_t] {
|
||||
let me = getpid()
|
||||
guard let mine = path(of: me) else { return [] }
|
||||
var pids = [pid_t](repeating: 0, count: 8192)
|
||||
let bytes = proc_listpids(UInt32(PROC_ALL_PIDS), 0, &pids, Int32(pids.count * MemoryLayout<pid_t>.size))
|
||||
guard bytes > 0 else { return [] }
|
||||
return pids[0..<(Int(bytes) / MemoryLayout<pid_t>.size)]
|
||||
.filter { $0 > 0 && $0 != me && path(of: $0) == mine }
|
||||
}
|
||||
|
||||
private static func path(of pid: pid_t) -> String? {
|
||||
// PROC_PIDPATHINFO_MAXSIZE, which libproc.h does not export to Swift.
|
||||
var buffer = [CChar](repeating: 0, count: 4 * Int(MAXPATHLEN))
|
||||
guard proc_pidpath(pid, &buffer, UInt32(buffer.count)) > 0 else { return nil }
|
||||
return String(cString: buffer)
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import AppKit
|
||||
import CoreGraphics
|
||||
|
||||
// Whether the person's screen is locked. While it is, macOS exposes no
|
||||
// application window through accessibility — every application answers with its
|
||||
// own element in place of its windows — so nothing can be read or clicked by
|
||||
// ref or point, however healthy the application is. Typing, keys and screen
|
||||
// captures still go through.
|
||||
enum Screen {
|
||||
static func locked() -> Bool {
|
||||
guard let session = CGSessionCopyCurrentDictionary() as? [String: Any] else { return false }
|
||||
return (session["CGSSessionScreenIsLocked"] as? Bool) ?? false
|
||||
}
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
// The computer-use helper: the one process in Studio that reads other
|
||||
// applications and puts input into them. It decides nothing. The kernel chooses
|
||||
// every target and step after its own permission check, and this carries each
|
||||
// out, answering one JSON line per request line on stdin.
|
||||
|
||||
struct Failure: Error {
|
||||
let code: String
|
||||
let message: String
|
||||
}
|
||||
|
||||
typealias JSON = [String: Any]
|
||||
|
||||
let refs = RefTable()
|
||||
let cursor = VirtualCursor()
|
||||
|
||||
func pointParam(_ params: JSON) throws -> CGPoint {
|
||||
CGPoint(x: try numberParam(params, "x"), y: try numberParam(params, "y"))
|
||||
}
|
||||
|
||||
func handle(_ method: String, _ params: JSON) throws -> JSON {
|
||||
switch method {
|
||||
case "status":
|
||||
return Permissions.status()
|
||||
case "request_permission":
|
||||
return try Permissions.request(params["which"] as? String ?? "")
|
||||
case "apps":
|
||||
return ["apps": Apps.list()]
|
||||
case "snapshot":
|
||||
return try Accessibility.snapshot(pid: try pidParam(params), refs: refs)
|
||||
case "press":
|
||||
return try Accessibility.press(pid: try pidParam(params), ref: try stringParam(params, "ref"), refs: refs, cursor: cursor)
|
||||
case "click":
|
||||
return try Accessibility.click(pid: try pidParam(params), x: try numberParam(params, "x"), y: try numberParam(params, "y"), cursor: cursor)
|
||||
case "menu":
|
||||
return try Accessibility.menu(pid: try pidParam(params), ref: try stringParam(params, "ref"), refs: refs, cursor: cursor)
|
||||
case "scroll":
|
||||
return try Accessibility.scroll(pid: try pidParam(params), ref: (params["ref"] as? String) ?? "", amount: (params["amount"] as? Double) ?? 0, refs: refs, cursor: cursor)
|
||||
case "focus":
|
||||
return try Accessibility.focus(pid: try pidParam(params), ref: try stringParam(params, "ref"), refs: refs, cursor: cursor)
|
||||
case "set_value":
|
||||
return try Accessibility.setValue(pid: try pidParam(params), ref: try stringParam(params, "ref"), text: try stringParam(params, "text"), refs: refs, cursor: cursor)
|
||||
case "type":
|
||||
return try Keyboard.type(pid: try pidParam(params), text: try stringParam(params, "text"))
|
||||
case "key":
|
||||
return try Keyboard.press(pid: try pidParam(params), chord: try stringParam(params, "key"), times: (params["times"] as? Int) ?? 1)
|
||||
case "paste":
|
||||
return try Clipboard.paste(pid: try pidParam(params), text: try stringParam(params, "text"))
|
||||
case "pointer_move":
|
||||
return try Pointer.move(pid: try pidParam(params), to: try pointParam(params), cursor: cursor)
|
||||
case "pointer_click":
|
||||
return try Pointer.click(pid: try pidParam(params), at: try pointParam(params), button: (params["button"] as? String) ?? "left", clicks: (params["clicks"] as? Int) ?? 1, cursor: cursor)
|
||||
case "pointer_drag":
|
||||
return try Pointer.drag(pid: try pidParam(params), from: try pointParam(params), to: CGPoint(x: try numberParam(params, "to_x"), y: try numberParam(params, "to_y")), cursor: cursor)
|
||||
case "pointer_position":
|
||||
return Pointer.position()
|
||||
case "pointer_release":
|
||||
cursor.hide()
|
||||
return Pointer.release()
|
||||
case "hold_key":
|
||||
return try Keyboard.hold(pid: try pidParam(params), chord: try stringParam(params, "key"), seconds: (params["seconds"] as? Double) ?? 1)
|
||||
case "cursor_hide":
|
||||
cursor.hide()
|
||||
return [:]
|
||||
default:
|
||||
throw Failure(code: "computer.bad_request", message: "unknown method \(method)")
|
||||
}
|
||||
}
|
||||
|
||||
func pidParam(_ params: JSON) throws -> pid_t {
|
||||
guard let n = params["pid"] as? NSNumber, n.int32Value > 0 else {
|
||||
throw Failure(code: "computer.bad_request", message: "pid is required")
|
||||
}
|
||||
return n.int32Value
|
||||
}
|
||||
|
||||
func stringParam(_ params: JSON, _ key: String) throws -> String {
|
||||
guard let s = params[key] as? String else {
|
||||
throw Failure(code: "computer.bad_request", message: "\(key) is required")
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func numberParam(_ params: JSON, _ key: String) throws -> Double {
|
||||
guard let n = params[key] as? NSNumber else {
|
||||
throw Failure(code: "computer.bad_request", message: "\(key) is required")
|
||||
}
|
||||
return n.doubleValue
|
||||
}
|
||||
|
||||
// A capture answers from whichever thread it finished on, so the one stream
|
||||
// every answer goes down is written under a lock of its own.
|
||||
let stdoutLock = NSLock()
|
||||
|
||||
func reply(_ body: JSON) {
|
||||
guard let data = try? JSONSerialization.data(withJSONObject: body), var line = String(data: data, encoding: .utf8) else { return }
|
||||
line += "\n"
|
||||
stdoutLock.lock()
|
||||
FileHandle.standardOutput.write(line.data(using: .utf8)!)
|
||||
stdoutLock.unlock()
|
||||
}
|
||||
|
||||
func fail(_ id: Any, _ error: Error) {
|
||||
if let failure = error as? Failure {
|
||||
reply(["id": id, "error": ["code": failure.code, "message": failure.message]])
|
||||
} else {
|
||||
reply(["id": id, "error": ["code": "computer.failed", "message": "\(error)"]])
|
||||
}
|
||||
}
|
||||
|
||||
// Requests are read off the main thread and answered on it: the overlay and the
|
||||
// accessibility calls it animates alongside both belong to the main run loop.
|
||||
Thread.detachNewThread {
|
||||
while let line = readLine() {
|
||||
guard let data = line.data(using: .utf8),
|
||||
let request = (try? JSONSerialization.jsonObject(with: data)) as? JSON,
|
||||
let id = request["id"] else { continue }
|
||||
let method = request["method"] as? String ?? ""
|
||||
let params = request["params"] as? JSON ?? [:]
|
||||
DispatchQueue.main.async {
|
||||
do {
|
||||
// A capture is the one operation this process waits on rather
|
||||
// than performs, and the main thread is what the rest of them
|
||||
// need: it answers when it is done, out of this order.
|
||||
if method == "screenshot" {
|
||||
try Capture.window(pid: try pidParam(params)) { outcome in
|
||||
switch outcome {
|
||||
case .success(let body): reply(["id": id, "result": body])
|
||||
case .failure(let failure): fail(id, failure)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
reply(["id": id, "result": try handle(method, params)])
|
||||
} catch {
|
||||
fail(id, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Behind every request already queued: stdin closing is the kernel letting
|
||||
// go, and what it asked before that is still owed an answer.
|
||||
DispatchQueue.main.async { exit(0) }
|
||||
}
|
||||
|
||||
let app = NSApplication.shared
|
||||
app.setActivationPolicy(.accessory)
|
||||
app.run()
|
||||
@@ -1,41 +0,0 @@
|
||||
#include "helper.h"
|
||||
|
||||
#include <map>
|
||||
|
||||
// listApps answers the applications a person can see, each with its windows on
|
||||
// screen in physical pixels. A minimized application is listed with none.
|
||||
Json listApps() {
|
||||
struct Walk { std::vector<DWORD> order; std::map<DWORD, bool> listed; } w;
|
||||
EnumWindows([](HWND hwnd, LPARAM l) -> BOOL {
|
||||
auto* w = reinterpret_cast<Walk*>(l);
|
||||
if (!appListed(hwnd)) return TRUE;
|
||||
DWORD pid = contentPid(hwnd);
|
||||
if (pid == 0 || pid == GetCurrentProcessId() || w->listed.count(pid)) return TRUE;
|
||||
w->listed[pid] = true;
|
||||
w->order.push_back(pid);
|
||||
return TRUE;
|
||||
}, reinterpret_cast<LPARAM>(&w));
|
||||
|
||||
HWND fg = GetForegroundWindow();
|
||||
DWORD active = fg ? contentPid(fg) : 0;
|
||||
Json apps = Json::array();
|
||||
for (DWORD pid : w.order) {
|
||||
Json windows = Json::array();
|
||||
for (HWND hwnd : appWindows(pid, false)) {
|
||||
wchar_t title[512];
|
||||
int n = GetWindowTextW(hwnd, title, ARRAYSIZE(title));
|
||||
windows.push(Json::object()
|
||||
.set("id", static_cast<long long>(reinterpret_cast<uintptr_t>(hwnd)))
|
||||
.set("title", narrow(std::wstring(title, n > 0 ? n : 0)))
|
||||
.set("bounds", rectJson(windowBounds(hwnd))));
|
||||
}
|
||||
apps.push(Json::object()
|
||||
.set("pid", pid)
|
||||
.set("bundle", identity(pid))
|
||||
.set("exe", exeName(pid))
|
||||
.set("name", displayName(pid))
|
||||
.set("active", pid == active)
|
||||
.set("windows", windows));
|
||||
}
|
||||
return apps;
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
#include "helper.h"
|
||||
|
||||
#include <wincodec.h>
|
||||
#include <wrl/client.h>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#ifndef PW_RENDERFULLCONTENT
|
||||
#define PW_RENDERFULLCONTENT 0x00000002
|
||||
#endif
|
||||
|
||||
using Microsoft::WRL::ComPtr;
|
||||
|
||||
namespace {
|
||||
|
||||
std::string base64(const BYTE* data, size_t n) {
|
||||
static const char table[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
std::string out;
|
||||
out.reserve((n + 2) / 3 * 4);
|
||||
for (size_t i = 0; i < n; i += 3) {
|
||||
uint32_t v = data[i] << 16;
|
||||
if (i + 1 < n) v |= data[i + 1] << 8;
|
||||
if (i + 2 < n) v |= data[i + 2];
|
||||
out += table[(v >> 18) & 63];
|
||||
out += table[(v >> 12) & 63];
|
||||
out += i + 1 < n ? table[(v >> 6) & 63] : '=';
|
||||
out += i + 2 < n ? table[v & 63] : '=';
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
void check(HRESULT hr, const char* what) {
|
||||
if (FAILED(hr)) {
|
||||
char buf[96];
|
||||
snprintf(buf, sizeof buf, "%s failed (HRESULT 0x%08lx)", what, static_cast<unsigned long>(hr));
|
||||
throw Failure{"computer.capture_failed", buf};
|
||||
}
|
||||
}
|
||||
|
||||
// jpeg encodes BGRX rows at quality 0.8, the macOS helper's setting.
|
||||
std::vector<BYTE> jpeg(const BYTE* pixels, UINT width, UINT height, UINT stride) {
|
||||
ComPtr<IWICImagingFactory> factory;
|
||||
check(CoCreateInstance(CLSID_WICImagingFactory, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&factory)), "creating the image encoder");
|
||||
ComPtr<IWICBitmap> bitmap;
|
||||
check(factory->CreateBitmapFromMemory(width, height, GUID_WICPixelFormat32bppBGR, stride, stride * (height - 1) + width * 4,
|
||||
const_cast<BYTE*>(pixels), &bitmap), "reading the capture");
|
||||
ComPtr<IWICFormatConverter> converted;
|
||||
check(factory->CreateFormatConverter(&converted), "converting the capture");
|
||||
check(converted->Initialize(bitmap.Get(), GUID_WICPixelFormat24bppBGR, WICBitmapDitherTypeNone, nullptr, 0,
|
||||
WICBitmapPaletteTypeCustom), "converting the capture");
|
||||
|
||||
ComPtr<IStream> stream;
|
||||
check(CreateStreamOnHGlobal(nullptr, TRUE, &stream), "allocating the image");
|
||||
ComPtr<IWICBitmapEncoder> encoder;
|
||||
check(factory->CreateEncoder(GUID_ContainerFormatJpeg, nullptr, &encoder), "creating the JPEG encoder");
|
||||
check(encoder->Initialize(stream.Get(), WICBitmapEncoderNoCache), "starting the JPEG");
|
||||
ComPtr<IWICBitmapFrameEncode> frame;
|
||||
ComPtr<IPropertyBag2> props;
|
||||
check(encoder->CreateNewFrame(&frame, &props), "starting the JPEG");
|
||||
PROPBAG2 option{};
|
||||
option.pstrName = const_cast<LPOLESTR>(L"ImageQuality");
|
||||
VARIANT quality;
|
||||
VariantInit(&quality);
|
||||
quality.vt = VT_R4;
|
||||
quality.fltVal = 0.8f;
|
||||
props->Write(1, &option, &quality);
|
||||
check(frame->Initialize(props.Get()), "starting the JPEG");
|
||||
check(frame->SetSize(width, height), "sizing the JPEG");
|
||||
WICPixelFormatGUID format = GUID_WICPixelFormat24bppBGR;
|
||||
check(frame->SetPixelFormat(&format), "formatting the JPEG");
|
||||
check(frame->WriteSource(converted.Get(), nullptr), "encoding the JPEG");
|
||||
check(frame->Commit(), "encoding the JPEG");
|
||||
check(encoder->Commit(), "encoding the JPEG");
|
||||
|
||||
STATSTG stat{};
|
||||
check(stream->Stat(&stat, STATFLAG_NONAME), "measuring the JPEG");
|
||||
std::vector<BYTE> out(static_cast<size_t>(stat.cbSize.QuadPart));
|
||||
LARGE_INTEGER zero{};
|
||||
check(stream->Seek(zero, STREAM_SEEK_SET, nullptr), "reading the JPEG");
|
||||
ULONG read = 0;
|
||||
check(stream->Read(out.data(), static_cast<ULONG>(out.size()), &read), "reading the JPEG");
|
||||
out.resize(read);
|
||||
return out;
|
||||
}
|
||||
|
||||
struct Dc {
|
||||
HDC screen = GetDC(nullptr);
|
||||
HDC mem = CreateCompatibleDC(screen);
|
||||
HBITMAP bmp = nullptr;
|
||||
~Dc() {
|
||||
if (bmp) DeleteObject(bmp);
|
||||
DeleteDC(mem);
|
||||
ReleaseDC(nullptr, screen);
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
// capture renders an application's front window by itself, whatever covers
|
||||
// it. bounds is where the visible frame sits on screen, which is what a point
|
||||
// in the image maps back to.
|
||||
Json capture(DWORD pid) {
|
||||
std::vector<HWND> wins = appWindows(pid, false);
|
||||
if (wins.empty()) throw Failure{"computer.no_window", "the application has no window on screen"};
|
||||
HWND hwnd = wins[0];
|
||||
RECT full{};
|
||||
GetWindowRect(hwnd, &full);
|
||||
RECT seen = windowBounds(hwnd);
|
||||
int fw = full.right - full.left, fh = full.bottom - full.top;
|
||||
if (fw <= 1 || fh <= 1) throw Failure{"computer.no_window", "the application has no window on screen"};
|
||||
|
||||
Dc dc;
|
||||
BITMAPINFO bi{};
|
||||
bi.bmiHeader.biSize = sizeof bi.bmiHeader;
|
||||
bi.bmiHeader.biWidth = fw;
|
||||
bi.bmiHeader.biHeight = -fh;
|
||||
bi.bmiHeader.biPlanes = 1;
|
||||
bi.bmiHeader.biBitCount = 32;
|
||||
bi.bmiHeader.biCompression = BI_RGB;
|
||||
void* bits = nullptr;
|
||||
dc.bmp = CreateDIBSection(dc.mem, &bi, DIB_RGB_COLORS, &bits, nullptr, 0);
|
||||
if (!dc.bmp) throw Failure{"computer.capture_failed", "no memory for a capture of this size"};
|
||||
HGDIOBJ previous = SelectObject(dc.mem, dc.bmp);
|
||||
BOOL printed = PrintWindow(hwnd, dc.mem, PW_RENDERFULLCONTENT);
|
||||
SelectObject(dc.mem, previous);
|
||||
if (!printed) {
|
||||
throw Failure{"computer.capture_failed", screenLocked() ? "the screen is locked, and a locked screen renders no window"
|
||||
: "the window would not render itself for a capture"};
|
||||
}
|
||||
|
||||
int left = std::clamp<int>(seen.left - full.left, 0, fw - 1);
|
||||
int top = std::clamp<int>(seen.top - full.top, 0, fh - 1);
|
||||
int width = std::clamp<int>(seen.right - seen.left, 1, fw - left);
|
||||
int height = std::clamp<int>(seen.bottom - seen.top, 1, fh - top);
|
||||
UINT stride = static_cast<UINT>(fw) * 4;
|
||||
const BYTE* origin = static_cast<const BYTE*>(bits) + static_cast<size_t>(top) * stride + static_cast<size_t>(left) * 4;
|
||||
std::vector<BYTE> encoded = jpeg(origin, width, height, stride);
|
||||
|
||||
RECT placed{full.left + left, full.top + top, full.left + left + width, full.top + top + height};
|
||||
return Json::object()
|
||||
.set("data", base64(encoded.data(), encoded.size()))
|
||||
.set("mime", "image/jpeg")
|
||||
.set("width", width)
|
||||
.set("height", height)
|
||||
.set("window", static_cast<long long>(reinterpret_cast<uintptr_t>(hwnd)))
|
||||
.set("bounds", rectJson(placed));
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
#include "helper.h"
|
||||
|
||||
#include <chrono>
|
||||
#include <cstring>
|
||||
#include <mutex>
|
||||
#include <thread>
|
||||
|
||||
// paste puts text where the person is typing without typing it: a long value
|
||||
// arrives at once, and an application that completes or reformats what it is
|
||||
// given sees one insertion instead of a hundred keystrokes.
|
||||
//
|
||||
// The clipboard is the person's. What was on it goes back, unless something
|
||||
// else claimed it while this ran — then theirs is the newer one and stays.
|
||||
|
||||
namespace {
|
||||
|
||||
struct Saved {
|
||||
UINT format;
|
||||
std::vector<BYTE> data;
|
||||
};
|
||||
|
||||
std::mutex restoring;
|
||||
std::thread pending;
|
||||
|
||||
bool open() {
|
||||
for (int i = 0; i < 20; i++) {
|
||||
if (OpenClipboard(nullptr)) return true;
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(25));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Formats held as GDI handles rather than memory cannot be copied byte for
|
||||
// byte; each has a memory twin Windows synthesises it back from.
|
||||
bool copyable(UINT format) {
|
||||
return format != CF_BITMAP && format != CF_ENHMETAFILE && format != CF_METAFILEPICT && format != CF_PALETTE &&
|
||||
format != CF_OWNERDISPLAY && format != CF_DSPBITMAP && format != CF_DSPENHMETAFILE && format != CF_DSPMETAFILEPICT;
|
||||
}
|
||||
|
||||
std::vector<Saved> save() {
|
||||
std::vector<Saved> out;
|
||||
for (UINT f = EnumClipboardFormats(0); f; f = EnumClipboardFormats(f)) {
|
||||
if (!copyable(f)) continue;
|
||||
HANDLE h = GetClipboardData(f);
|
||||
if (!h) continue;
|
||||
SIZE_T n = GlobalSize(h);
|
||||
const BYTE* p = static_cast<const BYTE*>(GlobalLock(h));
|
||||
if (!p) continue;
|
||||
out.push_back({f, std::vector<BYTE>(p, p + n)});
|
||||
GlobalUnlock(h);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
void put(UINT format, const void* data, SIZE_T n) {
|
||||
HGLOBAL h = GlobalAlloc(GMEM_MOVEABLE, n ? n : 1);
|
||||
if (!h) return;
|
||||
void* p = GlobalLock(h);
|
||||
if (n) memcpy(p, data, n);
|
||||
GlobalUnlock(h);
|
||||
if (!SetClipboardData(format, h)) GlobalFree(h);
|
||||
}
|
||||
|
||||
// The agent's text stays out of the person's clipboard history and cloud sync.
|
||||
void keepPrivate() {
|
||||
DWORD no = 0;
|
||||
put(RegisterClipboardFormatW(L"CanIncludeInClipboardHistory"), &no, sizeof no);
|
||||
put(RegisterClipboardFormatW(L"CanUploadToCloudClipboard"), &no, sizeof no);
|
||||
put(RegisterClipboardFormatW(L"ExcludeClipboardContentFromMonitorProcessing"), &no, sizeof no);
|
||||
}
|
||||
|
||||
void restore(std::vector<Saved> saved, DWORD written) {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(1500));
|
||||
if (GetClipboardSequenceNumber() != written || !open()) return;
|
||||
if (GetClipboardSequenceNumber() != written) {
|
||||
CloseClipboard();
|
||||
return;
|
||||
}
|
||||
EmptyClipboard();
|
||||
for (const Saved& s : saved) put(s.format, s.data.data(), s.data.size());
|
||||
CloseClipboard();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void clipboardSettle() {
|
||||
std::lock_guard<std::mutex> hold(restoring);
|
||||
if (pending.joinable()) pending.join();
|
||||
}
|
||||
|
||||
Json paste(DWORD pid, const std::string& text) {
|
||||
front(pid);
|
||||
clipboardSettle();
|
||||
if (!open()) throw Failure{"computer.failed", "another application is holding the clipboard"};
|
||||
std::vector<Saved> saved = save();
|
||||
EmptyClipboard();
|
||||
std::wstring w = widen(text);
|
||||
put(CF_UNICODETEXT, w.c_str(), (w.size() + 1) * sizeof(wchar_t));
|
||||
keepPrivate();
|
||||
CloseClipboard();
|
||||
// However this returns, the person's clipboard is owed back.
|
||||
struct GiveBack {
|
||||
std::vector<Saved> saved;
|
||||
DWORD written;
|
||||
~GiveBack() {
|
||||
std::lock_guard<std::mutex> hold(restoring);
|
||||
pending = std::thread(restore, std::move(saved), written);
|
||||
}
|
||||
} giveBack{std::move(saved), GetClipboardSequenceNumber()};
|
||||
if (!isFront(pid)) throw Failure{"computer.needs_front", "the application left the front before the paste; nothing was pasted"};
|
||||
sendChord("control+v");
|
||||
// The application reads the clipboard when it gets round to the keystroke,
|
||||
// and giving it back before then hands it the person's clipboard instead.
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(250));
|
||||
return Json::object().set("pasted", static_cast<unsigned long long>(w.size()));
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
#include "helper.h"
|
||||
|
||||
#include <objidl.h>
|
||||
#include <gdiplus.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <thread>
|
||||
|
||||
// The agent's own pointer: drawn over everything, taking no clicks and no
|
||||
// focus, so a person can see where it acts without it touching theirs. It lives
|
||||
// on a thread of its own because the window and the Escape hook both need a
|
||||
// message loop, and the request loop is busy doing the work it shows.
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr UINT moveMessage = WM_APP + 1;
|
||||
constexpr UINT hideMessage = WM_APP + 2;
|
||||
constexpr UINT stopMessage = WM_APP + 3;
|
||||
constexpr UINT_PTR glideTimer = 1, hideTimer = 2;
|
||||
constexpr int glideMs = 250;
|
||||
|
||||
HWND overlay = nullptr;
|
||||
HHOOK escHook = nullptr;
|
||||
std::atomic<bool> showing{false};
|
||||
float scale = 1;
|
||||
int width = 120, height = 44;
|
||||
POINT at{}, from{}, to{};
|
||||
ULONGLONG glideStart = 0;
|
||||
|
||||
void render() {
|
||||
BITMAPINFO bi{};
|
||||
bi.bmiHeader.biSize = sizeof bi.bmiHeader;
|
||||
bi.bmiHeader.biWidth = width;
|
||||
bi.bmiHeader.biHeight = -height;
|
||||
bi.bmiHeader.biPlanes = 1;
|
||||
bi.bmiHeader.biBitCount = 32;
|
||||
bi.bmiHeader.biCompression = BI_RGB;
|
||||
void* bits = nullptr;
|
||||
HDC screen = GetDC(nullptr);
|
||||
HDC mem = CreateCompatibleDC(screen);
|
||||
HBITMAP dib = CreateDIBSection(mem, &bi, DIB_RGB_COLORS, &bits, nullptr, 0);
|
||||
HGDIOBJ old = SelectObject(mem, dib);
|
||||
{
|
||||
Gdiplus::Bitmap canvas(width, height, width * 4, PixelFormat32bppPARGB, static_cast<BYTE*>(bits));
|
||||
Gdiplus::Graphics g(&canvas);
|
||||
g.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias);
|
||||
g.SetTextRenderingHint(Gdiplus::TextRenderingHintAntiAliasGridFit);
|
||||
g.ScaleTransform(scale, scale);
|
||||
Gdiplus::Color indigo(255, 88, 86, 214);
|
||||
Gdiplus::PointF arrow[] = {{2, 2}, {2, 22}, {7.5f, 17}, {11, 25}, {14, 23.5f}, {10.5f, 16}, {17, 16}};
|
||||
Gdiplus::SolidBrush fill(indigo);
|
||||
g.FillPolygon(&fill, arrow, 7);
|
||||
Gdiplus::Pen outline(Gdiplus::Color(255, 255, 255, 255), 1.5f);
|
||||
g.DrawPolygon(&outline, arrow, 7);
|
||||
|
||||
Gdiplus::FontFamily family(L"Segoe UI");
|
||||
Gdiplus::Font font(&family, 11, Gdiplus::FontStyleBold, Gdiplus::UnitPixel);
|
||||
Gdiplus::RectF size;
|
||||
g.MeasureString(L"Reasonix", -1, &font, Gdiplus::PointF(0, 0), &size);
|
||||
Gdiplus::RectF pill(16, 23, size.Width + 12, size.Height + 2);
|
||||
Gdiplus::GraphicsPath round;
|
||||
float r = pill.Height / 2;
|
||||
round.AddArc(pill.X, pill.Y, r * 2, pill.Height, 90, 180);
|
||||
round.AddArc(pill.X + pill.Width - r * 2, pill.Y, r * 2, pill.Height, 270, 180);
|
||||
round.CloseFigure();
|
||||
g.FillPath(&fill, &round);
|
||||
Gdiplus::SolidBrush white(Gdiplus::Color(255, 255, 255, 255));
|
||||
g.DrawString(L"Reasonix", -1, &font, Gdiplus::PointF(pill.X + 6, pill.Y + 1), &white);
|
||||
}
|
||||
POINT origin{at.x - static_cast<LONG>(2 * scale), at.y - static_cast<LONG>(2 * scale)};
|
||||
SIZE extent{width, height};
|
||||
POINT zero{0, 0};
|
||||
BLENDFUNCTION blend{AC_SRC_OVER, 0, 255, AC_SRC_ALPHA};
|
||||
UpdateLayeredWindow(overlay, screen, &origin, &extent, mem, &zero, 0, &blend, ULW_ALPHA);
|
||||
SelectObject(mem, old);
|
||||
DeleteObject(dib);
|
||||
DeleteDC(mem);
|
||||
ReleaseDC(nullptr, screen);
|
||||
}
|
||||
|
||||
void hide() {
|
||||
KillTimer(overlay, glideTimer);
|
||||
KillTimer(overlay, hideTimer);
|
||||
ShowWindow(overlay, SW_HIDE);
|
||||
showing = false;
|
||||
if (escHook) {
|
||||
UnhookWindowsHookEx(escHook);
|
||||
escHook = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
// Escape while the agent's cursor is on screen is the person saying stop. Any
|
||||
// other time it is their own keystroke, and one this helper injected never is.
|
||||
LRESULT CALLBACK onKey(int code, WPARAM wParam, LPARAM lParam) {
|
||||
if (code == HC_ACTION && (wParam == WM_KEYDOWN || wParam == WM_SYSKEYDOWN)) {
|
||||
auto* k = reinterpret_cast<KBDLLHOOKSTRUCT*>(lParam);
|
||||
// Answered off the hook: a hook that waits on stdout behind a large
|
||||
// reply overruns its timeout, and Windows removes it for good.
|
||||
if (k->vkCode == VK_ESCAPE && !(k->flags & LLKHF_INJECTED) && showing) PostMessageW(overlay, stopMessage, 0, 0);
|
||||
}
|
||||
return CallNextHookEx(nullptr, code, wParam, lParam);
|
||||
}
|
||||
|
||||
LRESULT CALLBACK proc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) {
|
||||
switch (msg) {
|
||||
case moveMessage:
|
||||
to = POINT{static_cast<LONG>(static_cast<int>(wParam)), static_cast<LONG>(static_cast<int>(lParam))};
|
||||
if (!showing) {
|
||||
at = from = to;
|
||||
render();
|
||||
ShowWindow(hwnd, SW_SHOWNOACTIVATE);
|
||||
showing = true;
|
||||
// Held only while the cursor shows: a system-wide hook costs every
|
||||
// keystroke on the machine a hop through this thread.
|
||||
if (!escHook) escHook = SetWindowsHookExW(WH_KEYBOARD_LL, onKey, GetModuleHandleW(nullptr), 0);
|
||||
} else {
|
||||
from = at;
|
||||
glideStart = GetTickCount64();
|
||||
SetTimer(hwnd, glideTimer, 15, nullptr);
|
||||
}
|
||||
SetTimer(hwnd, hideTimer, 4000, nullptr);
|
||||
return 0;
|
||||
case hideMessage:
|
||||
hide();
|
||||
return 0;
|
||||
case stopMessage:
|
||||
hide();
|
||||
reply(Json::object().set("event", "stop"));
|
||||
return 0;
|
||||
case WM_TIMER:
|
||||
if (wParam == hideTimer) {
|
||||
hide();
|
||||
} else if (wParam == glideTimer) {
|
||||
double t = std::min(1.0, static_cast<double>(GetTickCount64() - glideStart) / glideMs);
|
||||
double ease = t < 0.5 ? 2 * t * t : 1 - (2 - 2 * t) * (2 - 2 * t) / 2;
|
||||
at = POINT{static_cast<LONG>(from.x + (to.x - from.x) * ease), static_cast<LONG>(from.y + (to.y - from.y) * ease)};
|
||||
render();
|
||||
if (t >= 1) KillTimer(hwnd, glideTimer);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
return DefWindowProcW(hwnd, msg, wParam, lParam);
|
||||
}
|
||||
|
||||
void run(HANDLE ready) {
|
||||
Gdiplus::GdiplusStartupInput input;
|
||||
ULONG_PTR token = 0;
|
||||
Gdiplus::GdiplusStartup(&token, &input, nullptr);
|
||||
scale = GetDpiForSystem() / 96.0f;
|
||||
width = static_cast<int>(120 * scale);
|
||||
height = static_cast<int>(44 * scale);
|
||||
WNDCLASSW wc{};
|
||||
wc.lpfnWndProc = proc;
|
||||
wc.hInstance = GetModuleHandleW(nullptr);
|
||||
wc.lpszClassName = L"ReasonixAgentCursor";
|
||||
RegisterClassW(&wc);
|
||||
overlay = CreateWindowExW(WS_EX_LAYERED | WS_EX_TRANSPARENT | WS_EX_TOPMOST | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE,
|
||||
wc.lpszClassName, L"", WS_POPUP, 0, 0, width, height, nullptr, nullptr, wc.hInstance, nullptr);
|
||||
SetEvent(ready);
|
||||
MSG msg;
|
||||
while (GetMessageW(&msg, nullptr, 0, 0) > 0) DispatchMessageW(&msg);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void cursorStart() {
|
||||
HANDLE ready = CreateEventW(nullptr, TRUE, FALSE, nullptr);
|
||||
std::thread(run, ready).detach();
|
||||
WaitForSingleObject(ready, 5000);
|
||||
CloseHandle(ready);
|
||||
}
|
||||
|
||||
// cursorMove glides the agent's cursor to a point on screen and waits out the
|
||||
// glide, so what the person sees lands before the action it shows.
|
||||
void cursorMove(POINT point) {
|
||||
if (!overlay) return;
|
||||
PostMessageW(overlay, moveMessage, static_cast<WPARAM>(point.x), static_cast<LPARAM>(point.y));
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(glideMs + 30));
|
||||
}
|
||||
|
||||
void cursorHide() {
|
||||
if (overlay) PostMessageW(overlay, hideMessage, 0, 0);
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#define NOMINMAX
|
||||
#include <windows.h>
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "json.h"
|
||||
|
||||
// Failure is an operation that did not happen, answered with the same codes the
|
||||
// macOS helper uses so the kernel reads both alike.
|
||||
struct Failure {
|
||||
std::string code;
|
||||
std::string message;
|
||||
};
|
||||
|
||||
std::string narrow(const std::wstring& w);
|
||||
std::wstring widen(const std::string& s);
|
||||
std::string lower(std::string s);
|
||||
std::string num(double v);
|
||||
|
||||
DWORD pidParam(const Json& params);
|
||||
std::string stringParam(const Json& params, const char* key);
|
||||
double numberParam(const Json& params, const char* key);
|
||||
double numberOr(const Json& params, const char* key, double fallback);
|
||||
|
||||
// main.cpp: one line to the kernel, from whichever thread has one to say.
|
||||
void reply(const Json& body);
|
||||
|
||||
// cursor.cpp: the agent's own cursor, and Escape while it shows.
|
||||
void cursorStart();
|
||||
void cursorMove(POINT point);
|
||||
void cursorHide();
|
||||
|
||||
// process.cpp: which application a process or window is, and its windows.
|
||||
std::string identity(DWORD pid);
|
||||
std::string exeName(DWORD pid);
|
||||
std::string displayName(DWORD pid);
|
||||
DWORD contentPid(HWND top);
|
||||
std::vector<HWND> appWindows(DWORD pid, bool includeMinimized);
|
||||
bool appListed(HWND top);
|
||||
void requireOperable(DWORD pid);
|
||||
bool ownsPoint(DWORD pid, POINT at);
|
||||
bool isFront(DWORD pid);
|
||||
void front(DWORD pid);
|
||||
RECT windowBounds(HWND hwnd);
|
||||
Json rectJson(const RECT& r);
|
||||
|
||||
// apps.cpp
|
||||
Json listApps();
|
||||
|
||||
// screen.cpp
|
||||
bool screenLocked();
|
||||
Json status();
|
||||
|
||||
// uia.cpp
|
||||
void uiaStart();
|
||||
Json snapshot(DWORD pid);
|
||||
Json press(DWORD pid, const std::string& ref);
|
||||
Json clickAt(DWORD pid, double x, double y);
|
||||
Json menu(DWORD pid, const std::string& ref);
|
||||
Json scroll(DWORD pid, const std::string& ref, double amount);
|
||||
Json focus(DWORD pid, const std::string& ref);
|
||||
Json setValue(DWORD pid, const std::string& ref, const std::string& text);
|
||||
|
||||
// keyboard.cpp
|
||||
Json typeText(DWORD pid, const std::string& text);
|
||||
Json pressKey(DWORD pid, const std::string& chord, int times);
|
||||
Json holdKey(DWORD pid, const std::string& chord, double seconds);
|
||||
void sendChord(const std::string& chord);
|
||||
|
||||
// pointer.cpp
|
||||
Json pointerMove(DWORD pid, POINT to);
|
||||
Json pointerClick(DWORD pid, POINT at, const std::string& button, int clicks);
|
||||
Json pointerDrag(DWORD pid, POINT from, POINT to);
|
||||
Json pointerPosition();
|
||||
Json pointerRelease();
|
||||
|
||||
// capture.cpp
|
||||
Json capture(DWORD pid);
|
||||
|
||||
// clipboard.cpp
|
||||
Json paste(DWORD pid, const std::string& text);
|
||||
void clipboardSettle();
|
||||
@@ -1,270 +0,0 @@
|
||||
#include "json.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
|
||||
Json& Json::push(Json v) {
|
||||
a_.push_back(std::move(v));
|
||||
return *this;
|
||||
}
|
||||
|
||||
Json& Json::set(const std::string& key, Json v) {
|
||||
for (auto& [k, existing] : o_) {
|
||||
if (k == key) {
|
||||
existing = std::move(v);
|
||||
return *this;
|
||||
}
|
||||
}
|
||||
o_.emplace_back(key, std::move(v));
|
||||
return *this;
|
||||
}
|
||||
|
||||
const Json* Json::get(const std::string& key) const {
|
||||
for (const auto& [k, v] : o_) {
|
||||
if (k == key) return &v;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
std::string quote(const std::string& s) {
|
||||
std::string out = "\"";
|
||||
for (unsigned char c : s) {
|
||||
switch (c) {
|
||||
case '"': out += "\\\""; break;
|
||||
case '\\': out += "\\\\"; break;
|
||||
case '\n': out += "\\n"; break;
|
||||
case '\r': out += "\\r"; break;
|
||||
case '\t': out += "\\t"; break;
|
||||
default:
|
||||
if (c < 0x20) {
|
||||
char buf[8];
|
||||
std::snprintf(buf, sizeof buf, "\\u%04x", c);
|
||||
out += buf;
|
||||
} else {
|
||||
out += static_cast<char>(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out + "\"";
|
||||
}
|
||||
|
||||
void Json::write(std::string& out) const {
|
||||
switch (kind_) {
|
||||
case Kind::Null: out += "null"; break;
|
||||
case Kind::Bool: out += b_ ? "true" : "false"; break;
|
||||
case Kind::Number: {
|
||||
char buf[32];
|
||||
if (!std::isfinite(n_)) {
|
||||
out += "0";
|
||||
} else if (n_ == std::floor(n_) && std::fabs(n_) < 1e15) {
|
||||
std::snprintf(buf, sizeof buf, "%.0f", n_);
|
||||
out += buf;
|
||||
} else {
|
||||
std::snprintf(buf, sizeof buf, "%.17g", n_);
|
||||
out += buf;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case Kind::String: out += quote(s_); break;
|
||||
case Kind::Array:
|
||||
out += '[';
|
||||
for (size_t i = 0; i < a_.size(); i++) {
|
||||
if (i) out += ',';
|
||||
a_[i].write(out);
|
||||
}
|
||||
out += ']';
|
||||
break;
|
||||
case Kind::Object:
|
||||
out += '{';
|
||||
for (size_t i = 0; i < o_.size(); i++) {
|
||||
if (i) out += ',';
|
||||
out += quote(o_[i].first);
|
||||
out += ':';
|
||||
o_[i].second.write(out);
|
||||
}
|
||||
out += '}';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
std::string Json::dump() const {
|
||||
std::string out;
|
||||
write(out);
|
||||
return out;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
struct Parser {
|
||||
const std::string& t;
|
||||
size_t i = 0;
|
||||
int depth = 0;
|
||||
|
||||
void space() {
|
||||
while (i < t.size() && (t[i] == ' ' || t[i] == '\t' || t[i] == '\n' || t[i] == '\r')) i++;
|
||||
}
|
||||
|
||||
bool literal(const char* word) {
|
||||
size_t n = std::char_traits<char>::length(word);
|
||||
if (t.compare(i, n, word) != 0) return false;
|
||||
i += n;
|
||||
return true;
|
||||
}
|
||||
|
||||
static void utf8(std::string& out, uint32_t cp) {
|
||||
if (cp < 0x80) {
|
||||
out += static_cast<char>(cp);
|
||||
} else if (cp < 0x800) {
|
||||
out += static_cast<char>(0xC0 | (cp >> 6));
|
||||
out += static_cast<char>(0x80 | (cp & 0x3F));
|
||||
} else if (cp < 0x10000) {
|
||||
out += static_cast<char>(0xE0 | (cp >> 12));
|
||||
out += static_cast<char>(0x80 | ((cp >> 6) & 0x3F));
|
||||
out += static_cast<char>(0x80 | (cp & 0x3F));
|
||||
} else {
|
||||
out += static_cast<char>(0xF0 | (cp >> 18));
|
||||
out += static_cast<char>(0x80 | ((cp >> 12) & 0x3F));
|
||||
out += static_cast<char>(0x80 | ((cp >> 6) & 0x3F));
|
||||
out += static_cast<char>(0x80 | (cp & 0x3F));
|
||||
}
|
||||
}
|
||||
|
||||
bool hex4(uint32_t& v) {
|
||||
if (i + 4 > t.size()) return false;
|
||||
v = 0;
|
||||
for (int k = 0; k < 4; k++) {
|
||||
char c = t[i++];
|
||||
v <<= 4;
|
||||
if (c >= '0' && c <= '9') v |= c - '0';
|
||||
else if (c >= 'a' && c <= 'f') v |= c - 'a' + 10;
|
||||
else if (c >= 'A' && c <= 'F') v |= c - 'A' + 10;
|
||||
else return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool str(std::string& out) {
|
||||
if (i >= t.size() || t[i] != '"') return false;
|
||||
i++;
|
||||
while (i < t.size()) {
|
||||
char c = t[i++];
|
||||
if (c == '"') return true;
|
||||
if (static_cast<unsigned char>(c) < 0x20) return false;
|
||||
if (c != '\\') {
|
||||
out += c;
|
||||
continue;
|
||||
}
|
||||
if (i >= t.size()) return false;
|
||||
switch (t[i++]) {
|
||||
case '"': out += '"'; break;
|
||||
case '\\': out += '\\'; break;
|
||||
case '/': out += '/'; break;
|
||||
case 'b': out += '\b'; break;
|
||||
case 'f': out += '\f'; break;
|
||||
case 'n': out += '\n'; break;
|
||||
case 'r': out += '\r'; break;
|
||||
case 't': out += '\t'; break;
|
||||
case 'u': {
|
||||
uint32_t cp;
|
||||
if (!hex4(cp)) return false;
|
||||
if (cp >= 0xD800 && cp < 0xDC00 && t.compare(i, 2, "\\u") == 0) {
|
||||
size_t back = i;
|
||||
i += 2;
|
||||
uint32_t low;
|
||||
if (hex4(low) && low >= 0xDC00 && low < 0xE000) {
|
||||
cp = 0x10000 + ((cp - 0xD800) << 10) + (low - 0xDC00);
|
||||
} else {
|
||||
i = back;
|
||||
cp = 0xFFFD;
|
||||
}
|
||||
} else if (cp >= 0xD800 && cp < 0xE000) {
|
||||
cp = 0xFFFD;
|
||||
}
|
||||
utf8(out, cp);
|
||||
break;
|
||||
}
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool value(Json& out) {
|
||||
if (++depth > 64) return false;
|
||||
space();
|
||||
if (i >= t.size()) return false;
|
||||
char c = t[i];
|
||||
bool ok = true;
|
||||
if (c == '{') {
|
||||
i++;
|
||||
out = Json::object();
|
||||
space();
|
||||
if (i < t.size() && t[i] == '}') {
|
||||
i++;
|
||||
} else {
|
||||
for (;;) {
|
||||
space();
|
||||
std::string key;
|
||||
Json v;
|
||||
if (!str(key)) { ok = false; break; }
|
||||
space();
|
||||
if (i >= t.size() || t[i++] != ':') { ok = false; break; }
|
||||
if (!value(v)) { ok = false; break; }
|
||||
out.set(key, std::move(v));
|
||||
space();
|
||||
if (i < t.size() && t[i] == ',') { i++; continue; }
|
||||
if (i < t.size() && t[i] == '}') { i++; break; }
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else if (c == '[') {
|
||||
i++;
|
||||
out = Json::array();
|
||||
space();
|
||||
if (i < t.size() && t[i] == ']') {
|
||||
i++;
|
||||
} else {
|
||||
for (;;) {
|
||||
Json v;
|
||||
if (!value(v)) { ok = false; break; }
|
||||
out.push(std::move(v));
|
||||
space();
|
||||
if (i < t.size() && t[i] == ',') { i++; continue; }
|
||||
if (i < t.size() && t[i] == ']') { i++; break; }
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else if (c == '"') {
|
||||
std::string s;
|
||||
ok = str(s);
|
||||
out = Json(std::move(s));
|
||||
} else if (literal("true")) {
|
||||
out = Json(true);
|
||||
} else if (literal("false")) {
|
||||
out = Json(false);
|
||||
} else if (literal("null")) {
|
||||
out = Json();
|
||||
} else {
|
||||
const char* start = t.c_str() + i;
|
||||
char* end = nullptr;
|
||||
double n = std::strtod(start, &end);
|
||||
ok = end != start;
|
||||
i += end - start;
|
||||
out = Json(n);
|
||||
}
|
||||
depth--;
|
||||
return ok;
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
bool Json::parse(const std::string& text, Json& out) {
|
||||
Parser p{text};
|
||||
if (!p.value(out)) return false;
|
||||
p.space();
|
||||
return p.i == text.size();
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
// Json is the one value type the protocol needs: requests are parsed into it
|
||||
// and every reply is built from it. Objects keep insertion order.
|
||||
class Json {
|
||||
public:
|
||||
enum class Kind { Null, Bool, Number, String, Array, Object };
|
||||
|
||||
Json() = default;
|
||||
Json(bool b) : kind_(Kind::Bool), b_(b) {}
|
||||
Json(int n) : kind_(Kind::Number), n_(n) {}
|
||||
Json(unsigned n) : kind_(Kind::Number), n_(n) {}
|
||||
Json(long n) : kind_(Kind::Number), n_(n) {}
|
||||
Json(unsigned long n) : kind_(Kind::Number), n_(n) {}
|
||||
Json(long long n) : kind_(Kind::Number), n_(static_cast<double>(n)) {}
|
||||
Json(unsigned long long n) : kind_(Kind::Number), n_(static_cast<double>(n)) {}
|
||||
Json(double n) : kind_(Kind::Number), n_(n) {}
|
||||
Json(const char* s) : kind_(Kind::String), s_(s) {}
|
||||
Json(std::string s) : kind_(Kind::String), s_(std::move(s)) {}
|
||||
|
||||
static Json array() { Json j; j.kind_ = Kind::Array; return j; }
|
||||
static Json object() { Json j; j.kind_ = Kind::Object; return j; }
|
||||
|
||||
Kind kind() const { return kind_; }
|
||||
bool isNumber() const { return kind_ == Kind::Number; }
|
||||
bool isString() const { return kind_ == Kind::String; }
|
||||
bool isObject() const { return kind_ == Kind::Object; }
|
||||
double number() const { return n_; }
|
||||
const std::string& string() const { return s_; }
|
||||
const std::vector<Json>& items() const { return a_; }
|
||||
|
||||
Json& push(Json v);
|
||||
Json& set(const std::string& key, Json v);
|
||||
const Json* get(const std::string& key) const;
|
||||
|
||||
std::string dump() const;
|
||||
// parse reads one JSON text; false on anything malformed or trailing.
|
||||
static bool parse(const std::string& text, Json& out);
|
||||
|
||||
private:
|
||||
void write(std::string& out) const;
|
||||
|
||||
Kind kind_ = Kind::Null;
|
||||
bool b_ = false;
|
||||
double n_ = 0;
|
||||
std::string s_;
|
||||
std::vector<Json> a_;
|
||||
std::vector<std::pair<std::string, Json>> o_;
|
||||
};
|
||||
|
||||
// quote renders s as a JSON string literal.
|
||||
std::string quote(const std::string& s);
|
||||
@@ -1,197 +0,0 @@
|
||||
#include "helper.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <map>
|
||||
#include <thread>
|
||||
|
||||
// Keyboard input on Windows goes wherever the foreground is, so every entry
|
||||
// point brings the application forward first and checks it is still there
|
||||
// between chunks: a person switching away must not receive the rest.
|
||||
|
||||
namespace {
|
||||
|
||||
struct Key {
|
||||
WORD vk;
|
||||
bool extended;
|
||||
};
|
||||
|
||||
const std::map<std::string, Key> named = [] {
|
||||
std::map<std::string, Key> keys = {
|
||||
{"enter", {VK_RETURN, false}}, {"return", {VK_RETURN, false}}, {"tab", {VK_TAB, false}},
|
||||
{"escape", {VK_ESCAPE, false}}, {"space", {VK_SPACE, false}}, {"backspace", {VK_BACK, false}},
|
||||
{"delete", {VK_DELETE, true}}, {"insert", {VK_INSERT, true}}, {"arrowleft", {VK_LEFT, true}},
|
||||
{"arrowright", {VK_RIGHT, true}}, {"arrowup", {VK_UP, true}}, {"arrowdown", {VK_DOWN, true}},
|
||||
{"home", {VK_HOME, true}}, {"end", {VK_END, true}}, {"pageup", {VK_PRIOR, true}}, {"pagedown", {VK_NEXT, true}},
|
||||
{"minus", {VK_OEM_MINUS, false}}, {"equal", {VK_OEM_PLUS, false}}, {"comma", {VK_OEM_COMMA, false}},
|
||||
{"period", {VK_OEM_PERIOD, false}}, {"slash", {VK_OEM_2, false}}, {"semicolon", {VK_OEM_1, false}},
|
||||
{"quote", {VK_OEM_7, false}}, {"bracketleft", {VK_OEM_4, false}}, {"bracketright", {VK_OEM_6, false}},
|
||||
{"backslash", {VK_OEM_5, false}}, {"backquote", {VK_OEM_3, false}},
|
||||
};
|
||||
for (char c = 'a'; c <= 'z'; c++) keys[std::string(1, c)] = {static_cast<WORD>(c - 'a' + 'A'), false};
|
||||
for (char c = '0'; c <= '9'; c++) keys[std::string(1, c)] = {static_cast<WORD>(c), false};
|
||||
for (int n = 1; n <= 12; n++) keys["f" + std::to_string(n)] = {static_cast<WORD>(VK_F1 + n - 1), false};
|
||||
return keys;
|
||||
}();
|
||||
|
||||
const std::map<std::string, WORD> modifiers = {
|
||||
{"shift", WORD{VK_SHIFT}}, {"control", WORD{VK_CONTROL}}, {"ctrl", WORD{VK_CONTROL}}, {"alt", WORD{VK_MENU}}, {"option", WORD{VK_MENU}},
|
||||
};
|
||||
|
||||
struct KeyChord {
|
||||
Key key;
|
||||
std::vector<WORD> mods;
|
||||
};
|
||||
|
||||
std::string trim(const std::string& s) {
|
||||
size_t a = s.find_first_not_of(" \t"), b = s.find_last_not_of(" \t");
|
||||
return a == std::string::npos ? "" : s.substr(a, b - a + 1);
|
||||
}
|
||||
|
||||
KeyChord resolve(const std::string& chord) {
|
||||
std::vector<std::string> parts;
|
||||
size_t at = 0;
|
||||
for (;;) {
|
||||
size_t plus = chord.find('+', at);
|
||||
parts.push_back(lower(trim(chord.substr(at, plus == std::string::npos ? std::string::npos : plus - at))));
|
||||
if (plus == std::string::npos) break;
|
||||
at = plus + 1;
|
||||
}
|
||||
auto found = named.find(parts.back());
|
||||
if (found == named.end()) {
|
||||
throw Failure{"computer.bad_step", chord + " is not a key this can press. It presses named keys — enter, tab, escape, space, backspace, delete, insert, the arrows, home, end, pageup, pagedown, f1 to f12, a letter, a digit, or minus, equal, comma, period, slash, semicolon, quote, bracketleft, bracketright, backslash, backquote — with the "
|
||||
"modifiers shift, control and alt. Text goes through the type action"};
|
||||
}
|
||||
KeyChord c{found->second, {}};
|
||||
for (size_t i = 0; i + 1 < parts.size(); i++) {
|
||||
if (parts[i] == "meta" || parts[i] == "cmd" || parts[i] == "win") {
|
||||
throw Failure{"computer.bad_step", "meta is the Windows key here, which the shell answers rather than the application; "
|
||||
"Windows shortcuts use control, as in control+s"};
|
||||
}
|
||||
auto mod = modifiers.find(parts[i]);
|
||||
if (mod == modifiers.end()) throw Failure{"computer.bad_step", parts[i] + " is not a modifier; use shift, control or alt"};
|
||||
if (std::find(c.mods.begin(), c.mods.end(), mod->second) == c.mods.end()) c.mods.push_back(mod->second);
|
||||
}
|
||||
// These reach the shell's switcher and Start instead of the application.
|
||||
bool alt = std::find(c.mods.begin(), c.mods.end(), VK_MENU) != c.mods.end();
|
||||
bool ctrl = std::find(c.mods.begin(), c.mods.end(), VK_CONTROL) != c.mods.end();
|
||||
if ((alt && (c.key.vk == VK_TAB || c.key.vk == VK_ESCAPE)) || (ctrl && c.key.vk == VK_ESCAPE)) {
|
||||
throw Failure{"computer.bad_step", chord + " switches applications or opens Start; it is answered by Windows, not by this application"};
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
INPUT keyInput(WORD vk, bool extended, bool down) {
|
||||
INPUT in{};
|
||||
in.type = INPUT_KEYBOARD;
|
||||
in.ki.wVk = vk;
|
||||
in.ki.wScan = static_cast<WORD>(MapVirtualKeyW(vk, MAPVK_VK_TO_VSC));
|
||||
in.ki.dwFlags = (down ? 0 : KEYEVENTF_KEYUP) | (extended ? KEYEVENTF_EXTENDEDKEY : 0);
|
||||
return in;
|
||||
}
|
||||
|
||||
INPUT unicodeInput(wchar_t unit, bool down) {
|
||||
INPUT in{};
|
||||
in.type = INPUT_KEYBOARD;
|
||||
in.ki.wScan = unit;
|
||||
in.ki.dwFlags = KEYEVENTF_UNICODE | (down ? 0 : KEYEVENTF_KEYUP);
|
||||
return in;
|
||||
}
|
||||
|
||||
void send(std::vector<INPUT>& inputs) {
|
||||
if (inputs.empty()) return;
|
||||
UINT sent = SendInput(static_cast<UINT>(inputs.size()), inputs.data(), sizeof(INPUT));
|
||||
inputs.clear();
|
||||
if (sent == 0) {
|
||||
throw Failure{"computer.failed", screenLocked() ? "the screen is locked, and Windows delivers no input while it is"
|
||||
: "Windows refused the input"};
|
||||
}
|
||||
}
|
||||
|
||||
void chordDown(std::vector<INPUT>& out, const KeyChord& c) {
|
||||
for (WORD m : c.mods) out.push_back(keyInput(m, false, true));
|
||||
out.push_back(keyInput(c.key.vk, c.key.extended, true));
|
||||
}
|
||||
|
||||
void chordUp(std::vector<INPUT>& out, const KeyChord& c) {
|
||||
out.push_back(keyInput(c.key.vk, c.key.extended, false));
|
||||
for (auto m = c.mods.rbegin(); m != c.mods.rend(); ++m) out.push_back(keyInput(*m, false, false));
|
||||
}
|
||||
|
||||
void stillFront(DWORD pid, size_t done, size_t total, const char* unit) {
|
||||
if (!isFront(pid)) {
|
||||
throw Failure{"computer.needs_front", "the application left the front after " + std::to_string(done) + " of " +
|
||||
std::to_string(total) + " " + unit + "; the rest did not go in"};
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
Json typeText(DWORD pid, const std::string& text) {
|
||||
std::wstring units = widen(text);
|
||||
front(pid);
|
||||
std::vector<INPUT> batch;
|
||||
size_t chunk = 0;
|
||||
for (size_t i = 0; i < units.size(); i++) {
|
||||
if (chunk == 0) stillFront(pid, i, units.size(), "characters");
|
||||
wchar_t u = units[i];
|
||||
if (u == L'\r' && i + 1 < units.size() && units[i + 1] == L'\n') continue;
|
||||
if (u == L'\n' || u == L'\r' || u == L'\t') {
|
||||
WORD vk = u == L'\t' ? VK_TAB : VK_RETURN;
|
||||
batch.push_back(keyInput(vk, false, true));
|
||||
batch.push_back(keyInput(vk, false, false));
|
||||
} else {
|
||||
batch.push_back(unicodeInput(u, true));
|
||||
batch.push_back(unicodeInput(u, false));
|
||||
}
|
||||
if (++chunk == 4) {
|
||||
send(batch);
|
||||
chunk = 0;
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
||||
}
|
||||
}
|
||||
send(batch);
|
||||
return Json::object();
|
||||
}
|
||||
|
||||
Json pressKey(DWORD pid, const std::string& chord, int times) {
|
||||
KeyChord c = resolve(chord);
|
||||
front(pid);
|
||||
std::vector<INPUT> batch;
|
||||
for (int i = 0; i < std::clamp(times, 1, 200); i++) {
|
||||
stillFront(pid, i, times, "presses");
|
||||
chordDown(batch, c);
|
||||
chordUp(batch, c);
|
||||
send(batch);
|
||||
}
|
||||
return Json::object();
|
||||
}
|
||||
|
||||
// holdKey keeps a key down, which is what a game or a scrubbing control reads
|
||||
// rather than a press. The key always comes back up.
|
||||
Json holdKey(DWORD pid, const std::string& chord, double seconds) {
|
||||
KeyChord c = resolve(chord);
|
||||
front(pid);
|
||||
std::vector<INPUT> batch;
|
||||
chordDown(batch, c);
|
||||
send(batch);
|
||||
// Held keys belong to whatever is in front, so leaving the front ends the
|
||||
// hold at once rather than adding modifiers to the person's own typing.
|
||||
auto until = std::chrono::steady_clock::now() + std::chrono::milliseconds(static_cast<long long>(std::clamp(seconds, 0.0, 30.0) * 1000));
|
||||
bool left = false;
|
||||
while (std::chrono::steady_clock::now() < until && !(left = !isFront(pid))) {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(20));
|
||||
}
|
||||
chordUp(batch, c);
|
||||
send(batch);
|
||||
if (left) throw Failure{"computer.needs_front", "the application left the front during the hold; the key was released early"};
|
||||
return Json::object();
|
||||
}
|
||||
|
||||
void sendChord(const std::string& chord) {
|
||||
KeyChord c = resolve(chord);
|
||||
std::vector<INPUT> batch;
|
||||
chordDown(batch, c);
|
||||
chordUp(batch, c);
|
||||
send(batch);
|
||||
}
|
||||
Loaded 100 of 1868 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user