diff --git a/.github/workflows/cleanup.yml b/.github/workflows/cleanup.yml index f2d3d8f..d32136f 100644 --- a/.github/workflows/cleanup.yml +++ b/.github/workflows/cleanup.yml @@ -30,16 +30,35 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} REPO: ${{ github.repository }} MAX_AGE_DAYS: "60" + # Comma-separated list of glob patterns (matched with bash's [[ == ]]) + # that must never be auto-deleted, even if unprotected and stale. + # This guards branches referenced elsewhere (e.g. tenant YAML + # `branch:` pins per OPENFRAM-005-5/006-5) that are not marked + # "protected" in GitHub but must not be silently removed. + BRANCH_DENYLIST: "release/*,tenants/*,main,master" run: | set -o pipefail CUTOFF=$(date -u -d "$MAX_AGE_DAYS days ago" +%Y-%m-%dT%H:%M:%SZ) echo "Deleting branches with no commit since $CUTOFF" + IFS=',' read -r -a DENY_PATTERNS <<< "$BRANCH_DENYLIST" + gh api --paginate "repos/$REPO/branches?protected=false&per_page=100" \ --jq '.[].name' | while read -r br; do + skip=false + for pattern in "${DENY_PATTERNS[@]}"; do + if [[ "$br" == $pattern ]]; then + echo "Skipping $br (matches denylist pattern '$pattern')" + skip=true + break + fi + done + $skip && continue + last=$(gh api "repos/$REPO/commits/$br" --jq '.commit.committer.date') [[ "$last" < "$CUTOFF" ]] || continue echo "Deleting $br (last commit $last)" gh api --silent -X DELETE "repos/$REPO/git/refs/heads/$br" done + diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c71af51..fd648bd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -283,12 +283,6 @@ jobs: } > RELEASE_HEADER.md cat RELEASE_HEADER.md - - name: Delete existing latest release - if: ${{ github.event_name == 'push' }} - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: gh release delete latest --yes --cleanup-tag || true - - name: Create Release uses: softprops/action-gh-release@v2 with: @@ -300,3 +294,10 @@ jobs: body_path: RELEASE_HEADER.md files: | final-artifacts/* + + - name: Delete previous latest release + if: ${{ github.event_name == 'push' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh release delete latest --yes --cleanup-tag || true + diff --git a/.github/workflows/version.yml b/.github/workflows/version.yml index 3f09d1b..edb6e87 100644 --- a/.github/workflows/version.yml +++ b/.github/workflows/version.yml @@ -39,20 +39,33 @@ jobs: echo "last published release: ${latest:-}" echo "latest=${latest}" >> "$GITHUB_OUTPUT" - - name: Resolve version (push) - id: version_push - if: github.event_name != 'workflow_dispatch' + - name: Compute next patch version + id: next_patch env: LATEST: ${{ steps.published.outputs.latest }} - RUN_NUMBER: ${{ github.run_number }} run: | set -euo pipefail if [[ -n "$LATEST" ]]; then IFS='.' read -r cur_major cur_minor cur_patch <<< "$LATEST" - app_version="${cur_major}.${cur_minor}.$((cur_patch + 1))-dev.${RUN_NUMBER}" + if ! [[ "$cur_major" =~ ^[0-9]+$ && "$cur_minor" =~ ^[0-9]+$ && "$cur_patch" =~ ^[0-9]+$ ]]; then + echo "::error::Invalid published version format: ${LATEST} (expected x.y.z)" + exit 1 + fi + next_patch="${cur_major}.${cur_minor}.$((cur_patch + 1))" else - app_version="0.0.0-dev.${RUN_NUMBER}" + next_patch="0.0.0" fi + echo "next_patch=${next_patch}" >> "$GITHUB_OUTPUT" + + - name: Resolve version (push) + id: version_push + if: github.event_name != 'workflow_dispatch' + env: + NEXT_PATCH: ${{ steps.next_patch.outputs.next_patch }} + RUN_NUMBER: ${{ github.run_number }} + run: | + set -euo pipefail + app_version="${NEXT_PATCH}-dev.${RUN_NUMBER}" echo "push event: version resolves to latest, app version ${app_version}" echo "version=latest" >> "$GITHUB_OUTPUT" echo "app_version=${app_version}" >> "$GITHUB_OUTPUT" @@ -63,6 +76,7 @@ jobs: env: INPUT_VERSION: ${{ inputs.version }} LATEST: ${{ steps.published.outputs.latest }} + NEXT_PATCH: ${{ steps.next_patch.outputs.next_patch }} run: | set -euo pipefail @@ -85,7 +99,7 @@ jobs: IFS='.' read -r cur_major cur_minor cur_patch <<< "$LATEST" next_major="$((cur_major + 1)).0.0" next_minor="${cur_major}.$((cur_minor + 1)).0" - next_patch="${cur_major}.${cur_minor}.$((cur_patch + 1))" + next_patch="$NEXT_PATCH" if [ -z "$INPUT_VERSION" ]; then version="$next_patch" @@ -101,3 +115,4 @@ jobs: echo "version=${version}" >> "$GITHUB_OUTPUT" echo "app_version=${version}" >> "$GITHUB_OUTPUT" + diff --git a/src-tauri/src/macos_wake.rs b/src-tauri/src/macos_wake.rs index c78df6d..c826154 100644 --- a/src-tauri/src/macos_wake.rs +++ b/src-tauri/src/macos_wake.rs @@ -16,6 +16,7 @@ // refreshes the gate then refuses. use std::ptr::NonNull; +use std::sync::atomic::{AtomicBool, Ordering}; use block2::RcBlock; use objc2_app_kit::NSWorkspace; @@ -26,7 +27,16 @@ use tauri::AppHandle; /// nothing from AppKit but the one class it calls. const DID_WAKE: &str = "NSWorkspaceDidWakeNotification"; +/// Guards against registering more than one observer per process: `observe` +/// intentionally leaks its token (see below), so a second call would leak a +/// second observer and duplicate wake refreshes. +static OBSERVED: AtomicBool = AtomicBool::new(false); + pub(crate) fn observe(app: AppHandle) { + if OBSERVED.swap(true, Ordering::SeqCst) { + log::warn!("[wake] observe() called more than once; ignoring duplicate registration"); + return; + } let handler = RcBlock::new(move |_notification: NonNull| { // Before the nudge, not after: the nudge is what would otherwise rotate // at resume+0, ahead of the wake watch's next tick. diff --git a/src-tauri/src/updater.rs b/src-tauri/src/updater.rs index 0cf19ec..6afe74d 100644 --- a/src-tauri/src/updater.rs +++ b/src-tauri/src/updater.rs @@ -379,6 +379,19 @@ pub async fn update_apply_now(app: AppHandle, window: WebviewWindow) -> Result<( } } +/// NATS subjects are dot-delimited, with `*` and `>` reserved as wildcards, so a +/// `user_id` containing any of those could smuggle the message into a different +/// subject hierarchy than the literal `user..installed-agent` we intend. +/// The claim is expected to be an opaque server-issued identifier and should +/// never contain these characters; reject it outright rather than attempt to +/// escape it, since NATS subjects have no escaping mechanism. +fn is_valid_nats_subject_token(user_id: &str) -> bool { + !user_id.is_empty() + && !user_id + .chars() + .any(|c| c == '.' || c == '*' || c == '>' || c.is_whitespace()) +} + pub(crate) async fn publish_version_report(app: &AppHandle, client: &async_nats::Client) { let Some(user_id) = tokens::load_tokens(app) .access_token @@ -388,6 +401,10 @@ pub(crate) async fn publish_version_report(app: &AppHandle, client: &async_nats: log::debug!("[updater] no userId in token — skipping version report"); return; }; + if !is_valid_nats_subject_token(&user_id) { + log::warn!("[updater] userId claim is not a valid NATS subject token — skipping version report"); + return; + } let version = app.package_info().version.to_string(); let subject = format!("user.{user_id}.installed-agent"); let payload = match serde_json::to_vec(&InstalledAgentReport {