diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td new file mode 100644 index 000000000..b1e4a2c75 --- /dev/null +++ b/.github/VOUCHED.td @@ -0,0 +1,3 @@ +# Trusted external contributors, one GitHub username per line without @. +# Maintainers, bots, and collaborators with write access are allowed automatically. +sirouk diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 413250177..a5cc05f05 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,3 +1,9 @@ + + ## Ownership compliance diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 03e0b5b09..8ce8ab58b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,8 @@ # CI gates for the prime-agent Rust port: the tip-compiles-tests guarantee # plus PR gating, in one workflow (the shape of the TS product's ci.yml). -# +# `.github/workflows/` is the one home for workflow files; the +# windows-cross/windows/deny jobs run live in this file alongside the unix +# gates. # Two triggers, one # gate set mirroring `make check` exactly (fmt --all --check, clippy --workspace # --all-targets -- -D warnings, cargo test --workspace) on the pinned @@ -57,9 +59,15 @@ # assigned unit executed — and merges the failure reports into one table # naming the failing binaries; the gate is green only when all shards are. # -# Strictly read-only: contents: read, persist-credentials: false, no secrets. -# Windows coverage runs via the on-demand windows-runtime-triage.yml battery -# (any ref); there is no windows or deny job in this workflow. +# Strictly read-only: contents: read, persist-credentials: false, no secrets +# (the trust job's vouch step reads the GitHub API with the ambient +# GITHUB_TOKEN). The contributor-trust gate (the `trust` job, ported from +# main's ci.yml — the vouch mechanism) precedes every job: push events are +# always allowed; PR authors must be a bot, a collaborator with write +# access, or a vouched contributor (mitchellh/vouch; the vouched list +# resolves from the repo's default branch — `.github/VOUCHED.td` on main, +# nothing local on this branch), so unvouched PR authors never bill a +# runner minute. The auto-close enforcement lives in contribution-gate.yml. # name: ci @@ -110,9 +118,10 @@ jobs: # feeds test shards' --crates; empty means "the full union". changes: name: touched crates (PR) / full selection (push + queue) + needs: trust runs-on: ubuntu-latest timeout-minutes: 10 - if: github.event_name != 'pull_request' || !github.event.pull_request.draft + if: needs.trust.result == 'success' && needs.trust.outputs.allowed == 'true' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) outputs: crates: ${{ steps.select.outputs.crates }} scope: ${{ steps.select.outputs.scope }} @@ -153,13 +162,40 @@ jobs: echo "crates under test: ${{ steps.select.outputs.crates }}" fi + # The contributor-trust gate (this PR's port from main's TS-era ci.yml): + # the `allowed` output every job below keys on — push events are always + # allowed, PR authors must be a bot, a collaborator with write access, + # or vouched. allow-fail keeps this job green: the gate is the output, + # not the step. + trust: + name: contributor trust + runs-on: ubuntu-24.04 + timeout-minutes: 10 + outputs: + # merge_group auto-allows: the queued candidate already passed the + # vouch-gated pull_request wave (the queue only takes vouched PRs); + # the merge-group run is the full-selection validation of the + # already-vouched candidate. + allowed: ${{ github.event_name == 'push' || github.event_name == 'merge_group' || steps.vouch.outputs.vouched == 'true' }} + steps: + - name: Check pull request author + id: vouch + if: github.event_name == 'pull_request' + uses: mitchellh/vouch/action/check-user@d66fa29a64600490892131ad87597c30c91fcac4 # v1 + with: + user: ${{ github.event.pull_request.user.login }} + allow-fail: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + fmt: name: fmt + needs: trust runs-on: ubuntu-24.04 timeout-minutes: 30 # Draft PRs do not gate (no billing on work-in-progress pushes); the # ready_for_review trigger runs the gates once a draft is marked ready. - if: github.event_name != 'pull_request' || !github.event.pull_request.draft + if: needs.trust.result == 'success' && needs.trust.outputs.allowed == 'true' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: @@ -171,9 +207,10 @@ jobs: clippy-test: name: check + clippy (workspace) + needs: trust runs-on: ubuntu-24.04 timeout-minutes: 45 - if: github.event_name != 'pull_request' || !github.event.pull_request.draft + if: needs.trust.result == 'success' && needs.trust.outputs.allowed == 'true' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: @@ -207,13 +244,13 @@ jobs: # The shard legs need the wave's selection (the changes job's crates # output) before they can decide what to run — and whether the # sibling-binary build is needed at all. - needs: [changes] + needs: [changes, trust] # strategy.job-total (the matrix leg count) auto-derives the displayed # total; the env context is not reachable from a job name. name: test shard ${{ matrix.shard }}/${{ strategy.job-total }} runs-on: ubuntu-24.04 timeout-minutes: 45 - if: github.event_name != 'pull_request' || !github.event.pull_request.draft + if: needs.trust.result == 'success' && needs.trust.outputs.allowed == 'true' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) strategy: # One red shard must not cancel the others: their own conclusions are # the per-shard signal the summary merges. @@ -295,8 +332,8 @@ jobs: name: test summary runs-on: ubuntu-24.04 timeout-minutes: 10 - needs: [test-shard] - if: always() && (github.event_name != 'pull_request' || !github.event.pull_request.draft) + needs: [trust, test-shard] + if: always() && needs.trust.result == 'success' && needs.trust.outputs.allowed == 'true' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: @@ -309,5 +346,90 @@ jobs: - name: Audit the shards and merge their failure reports run: python3 scripts/ci_test_shard_summary.py --total ${{ env.SHARD_COUNT }} --dir manifests - # Windows coverage: the on-demand windows-runtime-triage.yml battery (any - # ref); no windows job runs in this workflow. + # The Windows job set + the supply-chain deny gate: + # + # windows-cross — the cfg-hygiene cross-target gate, the CI mirror of + # `make windows-cross`. The Sep-23..25 + # unix-only test regressions that broke it were fixed by #2833, so it + # type-checks every crate (including the cfg(windows) implementations + # and the Windows-only tests) for x86_64-pc-windows-gnu; ring's C needs + # the mingw cross compiler even for check builds. + # + # windows-latest — NOT in this promotion set. The full MSVC battery + # surfaced tip-side windows product debt the trunk has carried invisibly + # (the on-demand windows-runtime-triage.yml battery never ran on main; + # its first dispatches read deep red across crates, and the ACP + # daemon-attach e2e deadlocks on the named-pipe transport). The + # windows-latest promotion lands with the windows-readiness lane's + # product fixes; windows-cross below stays this PR's windows authority + # (the compile + clippy proof for the whole workspace incl. the + # cfg(windows) implementations). + # + # deny — cargo-deny advisories + licenses, the CI mirror of `make deny`, + # the same check continuous.yml's deny job runs on every push. + # Promoted on the operator's 2026-09-28 policy decision: the advisory + # DB drifts daily, so a drifted advisory can gate PRs — that is the + # accepted trade. + windows-cross: + name: windows cross-check + clippy (x86_64-pc-windows-gnu) + needs: trust + if: needs.trust.result == 'success' && needs.trust.outputs.allowed == 'true' && (github.event_name == 'push' || !github.event.pull_request.draft) + runs-on: ubuntu-24.04 + timeout-minutes: 60 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@ce678459e9fc7500d337468f904b95f1b5c10b5e # the ref IS the 1.98.1 toolchain; the action ignores the toolchain input + with: + targets: x86_64-pc-windows-gnu + components: clippy + - name: Install the mingw-w64 cross toolchain (ring C for the target) + run: sudo apt-get update && sudo apt-get install -y gcc-mingw-w64-x86-64 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + key: windows-cross + - run: cargo check --locked --workspace --target x86_64-pc-windows-gnu --all-targets + - run: cargo clippy --locked --workspace --target x86_64-pc-windows-gnu --all-targets -- -D warnings + + deny: + name: deny (cargo-deny advisories + licenses) + needs: trust + if: needs.trust.result == 'success' && needs.trust.outputs.allowed == 'true' && (github.event_name == 'push' || !github.event.pull_request.draft) + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + - name: Dependency audit (cargo-deny advisories + licenses) + uses: EmbarkStudios/cargo-deny-action@3c6349835b2b7b196a839186cb8b78e02f7b5f25 # v2.1.1 + with: + command: check + arguments: --all-features --workspace + command-arguments: advisories licenses + + # The trust gate's own failure made visible: a vouch-step error (the + # action's setup, the runner) would otherwise leave every dependent job + # skipped, and skipped required checks read as passing to branch + # protection - a green-looking wave with no gates run. This audit runs + # unconditionally (always()) and fails the required-check set when the + # trust job itself did not conclude success. The deliberate not-vouched + # path is unaffected: there the trust job SUCCEEDS with allowed=false, + # the dependents skip by design, and the contribution-gate auto-close + # enforces. + trust-audit: + name: trust gate audit + needs: [trust, changes, fmt, clippy-test, test-shard, test-summary, windows-cross, deny] + if: always() + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Audit the contributor-trust gate's own outcome + run: | + set -euo pipefail + if [ "${{ needs.trust.result }}" != "success" ]; then + echo "::error::the contributor-trust gate did not conclude cleanly (result: ${{ needs.trust.result }}) - the wave is NOT trustworthy: every gate below it skipped" + exit 1 + fi + echo "trust gate concluded cleanly (the allowed verdict: ${{ needs.trust.outputs.allowed }})" diff --git a/.github/workflows/codebase-health.yml b/.github/workflows/codebase-health.yml index cbd3823db..4e0b907d4 100644 --- a/.github/workflows/codebase-health.yml +++ b/.github/workflows/codebase-health.yml @@ -5,7 +5,31 @@ on: permissions: contents: read jobs: + # The contributor-trust gate (ported from main's ci.yml — the vouch + # mechanism): unvouched PR authors never bill a runner minute, not even + # for guidance-only jobs. allow-fail keeps the job green; the gate is the + # `allowed` output the loc job keys on. + trust: + name: contributor trust + runs-on: ubuntu-latest + outputs: + # merge_group auto-allows: the queued candidate already passed the + # vouch-gated pull_request wave; the merge-group run is the + # full-selection validation of the already-vouched candidate. + allowed: ${{ github.event_name == 'push' || github.event_name == 'merge_group' || steps.vouch.outputs.vouched == 'true' }} + steps: + - name: Check pull request author + id: vouch + if: github.event_name == 'pull_request' + uses: mitchellh/vouch/action/check-user@d66fa29a64600490892131ad87597c30c91fcac4 # v1 + with: + user: ${{ github.event.pull_request.user.login }} + allow-fail: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} loc: + needs: trust + if: needs.trust.result == 'success' && needs.trust.outputs.allowed == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 @@ -68,3 +92,23 @@ jobs: print(f' {before} -> {after} lines: {path}') print(f'{len(findings)} changed Rust file(s) grew beyond 2,000 lines (guidance only).') PY + + # The trust gate's own failure made visible (see ci.yml's trust-audit + # for the full rationale): always() runs even when the loc job skips or + # the trust job itself fails, and this audit fails the required-check + # set when the trust gate did not conclude cleanly. + trust-audit: + name: trust gate audit + needs: [trust, loc] + if: always() + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Audit the contributor-trust gate's own outcome + run: | + set -euo pipefail + if [ "${{ needs.trust.result }}" != "success" ]; then + echo "::error::the contributor-trust gate did not conclude cleanly (result: ${{ needs.trust.result }}) - the wave is NOT trustworthy: the loc gates skipped" + exit 1 + fi + echo "trust gate concluded cleanly (the allowed verdict: ${{ needs.trust.outputs.allowed }})" diff --git a/.github/workflows/contribution-gate.yml b/.github/workflows/contribution-gate.yml new file mode 100644 index 000000000..0661b446c --- /dev/null +++ b/.github/workflows/contribution-gate.yml @@ -0,0 +1,40 @@ +name: Contribution gate + +on: + issues: + types: [opened, reopened] + pull_request_target: + types: [opened, reopened, ready_for_review] + +permissions: {} + +jobs: + issue: + name: Check issue author + if: github.event_name == 'issues' + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + steps: + - uses: mitchellh/vouch/action/check-issue@d66fa29a64600490892131ad87597c30c91fcac4 # v1 + with: + issue-number: ${{ github.event.issue.number }} + auto-close: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + pull-request: + name: Check pull request author + if: github.event_name == 'pull_request_target' + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + steps: + - uses: mitchellh/vouch/action/check-pr@d66fa29a64600490892131ad87597c30c91fcac4 # v1 + with: + pr-number: ${{ github.event.pull_request.number }} + auto-close: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 42947b512..e3eca687a 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -192,6 +192,8 @@ jobs: exit 1 fi while IFS="$(printf '\t')" read -r run_id candidate_sha; do + # the continue is the intended else arm + # shellcheck disable=SC2015 [ -n "${run_id}" ] && [ -n "${candidate_sha}" ] || continue jobs_json="$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/jobs" 2>/dev/null || echo '{"jobs":[]}')" read -r built total < <(printf '%s\n' "${jobs_json}" | jq -r \ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..871d6a9ef --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,43 @@ +# Contributing to Prime Agent + +Thanks for your interest in contributing to Prime Agent! Prime Agent is developed in public, and we welcome bug reports, feature requests, questions, and other feedback. To keep the project maintainable, public contributions begin in [GitHub Discussions](https://github.com/PrimeIntellect-ai/prime-agent/discussions). + +With the influx of agent-generated contributions, we do not review unsolicited pull requests or use public Issues as the initial intake queue. While we are open to contributions by agents, you are responsible for your code and must understand how it interacts with the entire project. + +## Start with a Discussion + +Choose the category that best matches what you want to share: + +- [General discussion or question](https://github.com/PrimeIntellect-ai/prime-agent/discussions/categories/general) +- [Bug report](https://github.com/PrimeIntellect-ai/prime-agent/discussions/categories/bug-reports) +- [Feature request](https://github.com/PrimeIntellect-ai/prime-agent/discussions/categories/feature-requests) + +Search existing Discussions before creating a new one. Include enough detail for someone else to understand and reproduce the problem, but do not share API keys, tokens, private prompts, or other sensitive information. + +For security vulnerabilities, report them privately to the maintainers instead of posting publicly. + +## Issues + +GitHub Issues track work that maintainers have accepted and intend to investigate or implement. A maintainer may create an Issue from a Discussion when the scope is clear and the work fits the roadmap. An existing Issue does not automatically mean that an external pull request is wanted. Wait for a maintainer to invite implementation before starting substantial work. + +Issues opened by unapproved contributors are automatically closed and redirected to Discussions. To contribute, share interest in Discussions or corresponding issues, and maintainers can invite implementation for requested work. + +## Pull Requests and Trusted Contributors + +Prime Agent runs on user machines and can execute code with the user's permissions. We therefore limit pull requests to maintainers and trusted contributors who have been explicitly vouched for. Maintainers may vouch for someone after they have consistently demonstrated a useful understanding of the project through Discussions, issue investigation, testing, documentation, or other collaboration. There is no separate application process and no guarantee that participation will result in approval. + +Pull requests from unvouched contributors are automatically closed. If you are interested in contributing code, begin with a Discussion and work with the maintainers on the problem first. + +## Preparing an Approved Pull Request + +If a maintainer has invited a pull request: + +1. Keep the change focused on the accepted Issue or Discussion. +2. Follow the repository's development rules and existing conventions. +3. Add or update tests for behavioral changes. +4. Run the relevant checks locally and describe the validation in the pull request. +5. Avoid unrelated refactors or dependency changes. + +Development rules, setup, and commands for this branch are documented in [AGENTS.md](AGENTS.md) and the Makefile gate targets (`make check`, `make deny`, `make windows-cross`). + +Maintainers may close a pull request that changes scope, cannot be validated safely, or no longer fits the project roadmap. diff --git a/Makefile b/Makefile index 4affa0252..3af0bda89 100644 --- a/Makefile +++ b/Makefile @@ -15,7 +15,8 @@ deny: cargo deny --all-features --workspace check advisories licenses # Windows cfg-hygiene gate: cross-target check + -# clippy at -D warnings for every crate and test. Fails loudly when the +# clippy at -D warnings for every crate and test, the local mirror of the +# ci.yml windows-cross job (.github/workflows/ci.yml). Fails loudly when the # target is missing instead of silently skipping the gate. windows-cross: @rustup target list --installed | grep -q x86_64-pc-windows-gnu || { echo "x86_64-pc-windows-gnu target not installed (rustup target add x86_64-pc-windows-gnu)"; exit 1; } @@ -35,11 +36,12 @@ windows-msvc-cross: @command -v cargo-xwin >/dev/null 2>&1 || { echo "cargo-xwin not installed (cargo install cargo-xwin --locked; it provisions the MSVC CRT + expects clang-cl/llvm-lib on PATH)"; exit 1; } cargo xwin check --workspace --target x86_64-pc-windows-msvc --all-targets -# Lints the live workflow files (.github/workflows/). +# Lints every workflow file (.github/workflows/ is the one home for +# workflow files). actionlint: @command -v actionlint >/dev/null 2>&1 || { echo "actionlint not installed (see rhysd/actionlint releases)"; exit 1; } - actionlint .github/workflows/ci.yml .github/workflows/continuous.yml \ - .github/workflows/release.yml .github/workflows/windows-runtime-triage.yml \ + actionlint .github/workflows/ci.yml .github/workflows/codebase-health.yml .github/workflows/contribution-gate.yml \ + .github/workflows/continuous.yml .github/workflows/release.yml .github/workflows/windows-runtime-triage.yml \ .github/workflows/release-prepare.yml .github/workflows/nightly.yml # GLIBC baseline gate (the continuous.yml/release.yml build-gnu jobs): a diff --git a/crates/pa-cli/src/daemon_command.rs b/crates/pa-cli/src/daemon_command.rs index 04334d256..4a60b7824 100644 --- a/crates/pa-cli/src/daemon_command.rs +++ b/crates/pa-cli/src/daemon_command.rs @@ -692,6 +692,9 @@ mod tests { list.iter().map(std::string::ToString::to_string).collect() } + // The socket-path/separator shapes are unix paths (the windows transport + // is named pipes; the tilde/socket spellings do not exist there). + #[cfg(unix)] #[test] fn parse_consumes_socket_json_and_separator() { let parsed = diff --git a/crates/pa-cli/src/interactive_mode/tests.rs b/crates/pa-cli/src/interactive_mode/tests.rs index 78db45705..405c6b3ad 100644 --- a/crates/pa-cli/src/interactive_mode/tests.rs +++ b/crates/pa-cli/src/interactive_mode/tests.rs @@ -713,6 +713,8 @@ fn fork_startup_selection_reports_the_ts_contracts() { ); } +// The tilde selector expands a unix HOME path. +#[cfg(unix)] #[test] fn fork_startup_selection_expands_a_tilde_selector() { // The resume selector's convention: a leading `~` resolves against diff --git a/crates/pa-core/src/auth/storage.rs b/crates/pa-core/src/auth/storage.rs index 73bf33d37..c31058fbc 100644 --- a/crates/pa-core/src/auth/storage.rs +++ b/crates/pa-core/src/auth/storage.rs @@ -187,7 +187,7 @@ fn stat_identity(metadata: &fs::Metadata) -> Option { dev: 0, ino: 0, mtime_sec: since.as_secs() as i64, - mtime_nsec: since.subsec_nanos() as i64, + mtime_nsec: i64::from(since.subsec_nanos()), len: metadata.len(), }) } diff --git a/crates/pa-core/src/models/private_auth.rs b/crates/pa-core/src/models/private_auth.rs index 7005577d4..85fdf1eff 100644 --- a/crates/pa-core/src/models/private_auth.rs +++ b/crates/pa-core/src/models/private_auth.rs @@ -171,7 +171,7 @@ fn cache_file_identity(metadata: &std::fs::Metadata) -> Option FILETIME. The Windows epoch trails the Unix /// epoch by 11644473600 seconds; the sub-second part is nanoseconds /// against FILETIME's 100ns ticks. + // `tv_sec`/`tv_nsec` are the POSIX timespec spellings the callers + // pass through; the pair is the domain's own vocabulary. + #[allow(clippy::similar_names)] fn unix_to_filetime(tv_sec: i64, tv_nsec: i64) -> FileTime { const EPOCH_DELTA_TICKS: i64 = 11_644_473_600 * 10_000_000; let ticks = tv_sec * 10_000_000 + EPOCH_DELTA_TICKS + tv_nsec / 100; @@ -138,7 +141,9 @@ mod win32 { } } - /// Set the directory's last-write time. + /// Set the directory's last-write time. The `tv_sec`/`tv_nsec` pair + /// is the POSIX timespec vocabulary, same as `unix_to_filetime`. + #[allow(clippy::similar_names)] pub(crate) fn set_last_write_time(path: &Path, tv_sec: i64, tv_nsec: i64) -> io::Result<()> { let wide: Vec = path.as_os_str().encode_wide().chain([0]).collect(); let handle = unsafe { @@ -156,7 +161,14 @@ mod win32 { return Err(io::Error::last_os_error()); } let last_write = unix_to_filetime(tv_sec, tv_nsec); - let ok = unsafe { SetFileTime(handle, std::ptr::null(), std::ptr::null(), &last_write) }; + let ok = unsafe { + SetFileTime( + handle, + std::ptr::null(), + std::ptr::null(), + std::ptr::from_ref(&last_write), + ) + }; unsafe { CloseHandle(handle) }; if ok == 0 { return Err(io::Error::last_os_error()); diff --git a/crates/pa-core/src/platform/perms.rs b/crates/pa-core/src/platform/perms.rs index 8d6a4071b..ea85b56ee 100644 --- a/crates/pa-core/src/platform/perms.rs +++ b/crates/pa-core/src/platform/perms.rs @@ -82,6 +82,7 @@ pub fn file_mode(path: &Path) -> Option { } #[cfg(not(unix))] +#[must_use] pub fn file_mode(_path: &Path) -> Option { None } @@ -95,6 +96,7 @@ pub fn is_executable(path: &Path) -> bool { } #[cfg(not(unix))] +#[must_use] pub fn is_executable(path: &Path) -> bool { path.is_file() } @@ -112,6 +114,7 @@ pub fn is_readable_writable(path: &Path) -> bool { } #[cfg(not(unix))] +#[must_use] pub fn is_readable_writable(path: &Path) -> bool { // Windows: a create-open probe is the equivalent permission test. OpenOptions::new().read(true).write(true).open(path).is_ok() diff --git a/crates/pa-core/src/platform/process.rs b/crates/pa-core/src/platform/process.rs index 2fc4684de..b033212ea 100644 --- a/crates/pa-core/src/platform/process.rs +++ b/crates/pa-core/src/platform/process.rs @@ -137,6 +137,10 @@ pub fn raise_open_file_limit() -> std::io::Result> { } /// No per-process descriptor limit to raise. +/// +/// # Errors +/// +/// Never: the not-unix arm has no descriptor limit to raise. #[cfg(not(unix))] pub fn raise_open_file_limit() -> std::io::Result> { Ok(None) @@ -163,6 +167,7 @@ pub fn kill_pid(pid: i32, signal: Signal) -> bool { /// collapses there). Descendants are NOT killed: teardown paths that need /// tree kills use [`kill_process_group_or_pid`], like the TS callers. #[cfg(windows)] +#[must_use] pub fn kill_pid(pid: i32, signal: Signal) -> bool { if pid <= 0 { return false; @@ -202,6 +207,7 @@ pub fn kill_process_group_or_pid(pid: i32) -> bool { /// [`set_new_process_group`] are irrelevant here. True only when taskkill /// exited 0, the same proof TS's `result.status === 0` requires. #[cfg(windows)] +#[must_use] pub fn kill_process_group_or_pid(pid: i32) -> bool { if pid <= 0 { return false; @@ -329,6 +335,7 @@ pub fn pidfd_signal(_fd: i32, _signal: Signal) -> bool { } #[cfg(not(unix))] +#[must_use] pub fn pidfd_signal(_fd: i32, _signal: Signal) -> bool { false } @@ -502,6 +509,7 @@ pub async fn wait_for_exit(_pid: u32) -> std::io::Result<()> { /// checks the same `STILL_ACTIVE` exit code). A query that fails outright /// reads as gone. #[cfg(windows)] +#[must_use] pub fn pid_exists(pid: u32) -> bool { pa_types::platform::process::is_process_alive(pid).unwrap_or(false) } @@ -521,6 +529,7 @@ pub fn termination_signal(status: &std::process::ExitStatus) -> Option { } #[cfg(not(unix))] +#[must_use] pub fn termination_signal(_status: &std::process::ExitStatus) -> Option { // Windows terminations surface as exit codes, not signals. None diff --git a/crates/pa-core/src/session_engine/request_timing/payload.rs b/crates/pa-core/src/session_engine/request_timing/payload.rs index 481234459..1a30e98eb 100644 --- a/crates/pa-core/src/session_engine/request_timing/payload.rs +++ b/crates/pa-core/src/session_engine/request_timing/payload.rs @@ -80,7 +80,10 @@ pub(crate) const REQUEST_PAYLOAD_BUDGET_BYTES: u64 = 64 * 1024 * 1024; /// agent dir). static CAPTURE_SEQ: AtomicU64 = AtomicU64::new(0); -/// One queued capture, as the dispatch path hands it off. +/// One queued capture, as the dispatch path hands it off. Unix-only at +/// the readers (the writer machinery below); the capture is disabled on +/// non-Unix by the confidentiality design, so the fields read only there. +#[cfg_attr(not(unix), allow(dead_code))] struct CaptureJob { root: PathBuf, dir: PathBuf, @@ -137,7 +140,7 @@ fn capture_writer() -> Option<&'static CaptureWriter> { tracing::debug!( "payload capture disabled: owner-only modes are not enforceable on this platform" ); - return None; + None } #[cfg(unix)] { @@ -197,7 +200,7 @@ impl RequestPayloadCapture { /// The capture at an explicit directory and ring size (tests): the /// directory's parent is the trust root. - #[cfg(test)] + #[cfg(all(test, unix))] #[must_use] pub(crate) fn at(dir: impl Into, keep: usize) -> Self { let dir: PathBuf = dir.into(); @@ -296,6 +299,7 @@ impl RequestPayloadCapture { /// into place (a reader never sees a partial body), and prune the ring. /// The body's retained bytes release once its write settles (the job's /// payload is dropped right after). +#[cfg_attr(not(unix), allow(dead_code))] fn drain_writer(queued: &Arc, retained: &Arc, jobs: &Receiver) { while let Ok(job) = jobs.recv() { queued.fetch_sub(1, Ordering::Relaxed); @@ -339,6 +343,7 @@ pub(crate) fn payload_bytes(value: &Value) -> u64 { /// The capture file's correlation envelope: the same identity fields the /// request-timing entries carry, so a capture correlates with its /// timeline by sequence number; empty or absent fields stay omitted. +#[cfg_attr(not(unix), allow(dead_code))] fn capture_envelope(job: &CaptureJob) -> Value { let mut envelope = Map::new(); envelope.insert("ts".to_string(), json!(format_iso(job.now_ms as i64))); @@ -367,6 +372,7 @@ fn capture_envelope(job: &CaptureJob) -> Value { /// other local users), the durable rename through the platform wall, /// then the ring prune. Best-effort: every failure is the caller's to /// swallow — and a failed write takes its temp file with it. +#[cfg_attr(not(unix), allow(dead_code))] fn write_capture(root: &Path, dir: &Path, keep: usize, job: &CaptureJob) -> std::io::Result<()> { use std::io::Write; refuse_symlinked_components(root, dir)?; @@ -419,6 +425,7 @@ fn write_capture(root: &Path, dir: &Path, keep: usize, job: &CaptureJob) -> std: /// attacker-owned tree. `symlink_metadata` inspects each component /// without following it; a missing component is fine (the create below /// makes it, privately), but a symlink ends the capture. +#[cfg_attr(not(unix), allow(dead_code))] fn refuse_symlinked_components(root: &Path, dir: &Path) -> std::io::Result<()> { // Only the components below the trust root are walked: the root // itself is the user's configured agent dir (its own symlinks are @@ -460,6 +467,7 @@ fn refuse_symlinked_components(root: &Path, dir: &Path) -> std::io::Result<()> { /// crashed write's leftover) counts as one of the ring's files and ages /// out the same way; a mid-write temp file carries the newest name and /// never evicts. +#[cfg_attr(not(unix), allow(dead_code))] fn prune(dir: &Path, keep: usize) { let Ok(entries) = std::fs::read_dir(dir) else { return; diff --git a/crates/pa-core/src/session_engine/request_timing/tests.rs b/crates/pa-core/src/session_engine/request_timing/tests.rs index abcc06bcf..a5c0be3ea 100644 --- a/crates/pa-core/src/session_engine/request_timing/tests.rs +++ b/crates/pa-core/src/session_engine/request_timing/tests.rs @@ -852,7 +852,9 @@ async fn engine_sessions_emit_the_timeline_only_when_the_flag_is_on() { /// A provider seam that invokes the payload hook once with the TS /// `PAYLOAD` fixture, then settles with a zero-usage message (the pin is -/// the capture, not the turn). +/// the capture, not the turn). Unix-only like its callers: the capture +/// the payload tests prove is Unix-only by the confidentiality design. +#[cfg(unix)] fn payload_calling_provider() -> StreamFn { Arc::new(move |model, _context, options| { Box::pin(async move { diff --git a/crates/pa-core/src/settings/storage.rs b/crates/pa-core/src/settings/storage.rs index 91ffbd88e..4608b3051 100644 --- a/crates/pa-core/src/settings/storage.rs +++ b/crates/pa-core/src/settings/storage.rs @@ -182,7 +182,7 @@ fn stat_identity(metadata: &fs::Metadata) -> Option { dev: 0, ino: 0, mtime_sec: since.as_secs() as i64, - mtime_nsec: since.subsec_nanos() as i64, + mtime_nsec: i64::from(since.subsec_nanos()), len: metadata.len(), }) } diff --git a/crates/pa-core/src/update/download.rs b/crates/pa-core/src/update/download.rs index 98ee33e66..bda8099bc 100644 --- a/crates/pa-core/src/update/download.rs +++ b/crates/pa-core/src/update/download.rs @@ -590,7 +590,10 @@ fn make_executable(path: &Path) -> Result<()> { Ok(()) } +// The signature stays Result for the shared unix callers; the non-unix +// arm has nothing to chmod (the ACLs carry the executable bit). #[cfg(not(unix))] +#[allow(clippy::unnecessary_wraps)] fn make_executable(_path: &Path) -> Result<()> { Ok(()) } diff --git a/crates/pa-core/tests/kernel_startup_join.rs b/crates/pa-core/tests/kernel_startup_join.rs index 03827c2e2..9f63e2351 100644 --- a/crates/pa-core/tests/kernel_startup_join.rs +++ b/crates/pa-core/tests/kernel_startup_join.rs @@ -1,4 +1,8 @@ //! TS provisioner parity: all first-touch callers join one in-flight boot. +// Unix-only: the permission bits the provisioner stamps are unix mode +// bits (std::os::unix::fs::PermissionsExt). +#![cfg(unix)] + use pa_core::kernel::cancellation::AbortSignal; use pa_core::kernel::provisioner::{IpythonKernelProvisioner, IpythonKernelProvisionerOptions}; use std::os::unix::fs::PermissionsExt; diff --git a/crates/pa-daemon/src/boot_reap.rs b/crates/pa-daemon/src/boot_reap.rs index e46390666..2ff28fc93 100644 --- a/crates/pa-daemon/src/boot_reap.rs +++ b/crates/pa-daemon/src/boot_reap.rs @@ -658,7 +658,9 @@ pub(crate) fn supervisor_argv_names_socket(argv: &[String], socket: &str) -> boo /// removes endpoints only - a regular file at a matching name is never /// touched). UNIX-wide on purpose (the caller is unconditional): the /// std `os::unix` socket-file probe compiles on every unix - darwin -/// included. +/// included. No unix socket files exist on the other targets, so the +/// probe answers false there and the unlink never fires (the not(unix) +/// reaping stubs already collect zero targets). #[cfg(unix)] fn is_unix_socket_file(path: &Path) -> bool { use std::os::unix::fs::FileTypeExt; @@ -756,8 +758,8 @@ fn protected_worker_pids(agent_dir: &Path, socket_path: &Path) -> HashSet { /// (`/a/b/../c/daemon.sock` vs `/a/c/daemon.sock`, a symlinked tmpdir) /// is still a same-socket predecessor - its lease is held either way. /// Pure `std` (canonicalize + components): it compiles on every unix - -/// darwin included, which the unconditional `supervisor_argv_names_socket` -/// (the argv-only view the supervisor census normalizes with) requires. +/// darwin included, which `supervisor_argv_names_socket` (the argv-only +/// view the supervisor census normalizes with) requires. #[cfg(unix)] pub(crate) fn normalize_socket_spelling(path: &Path) -> String { if let Ok(canonical) = path.canonicalize() { diff --git a/crates/pa-daemon/src/herdr.rs b/crates/pa-daemon/src/herdr.rs index 0211401db..7158866f1 100644 --- a/crates/pa-daemon/src/herdr.rs +++ b/crates/pa-daemon/src/herdr.rs @@ -35,7 +35,10 @@ //! lower-seq reports per source, which would stick a pane at working). use std::collections::BTreeMap; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::atomic::{AtomicU64, Ordering}; +// AtomicBool feeds the unix-gated SOCKET_REFUSAL_LOGGED static only. +#[cfg(unix)] +use std::sync::atomic::AtomicBool; use std::time::Duration; use serde_json::{json, Map, Value}; @@ -725,6 +728,7 @@ fn rand_suffix() -> String { /// passes unchanged: Herdr's own socket under the user's runtime /// directory is that user's own socket (TS parity). Named pipes on /// Windows keep their connect-time ACL model (no lstat to check). +#[cfg(unix)] static SOCKET_REFUSAL_LOGGED: AtomicBool = AtomicBool::new(false); /// The fence's pure decision (unit-testable without `chown`): the @@ -799,6 +803,9 @@ async fn pane_socket_acceptable(socket_target: &str) -> FenceVerdict { } #[cfg(windows)] +// The async stays for the shared call site (the unix arm awaits); the +// windows pipe arm needs no await for its connect-time ACL verdict. +#[allow(clippy::unused_async)] async fn pane_socket_acceptable(_socket_target: &str) -> FenceVerdict { // Named pipes carry their own ACL model at connect time. FenceVerdict::Accepted @@ -916,7 +923,10 @@ pub(crate) fn error_hold_message(messages: &[Value]) -> Option { Some(message.to_string()) } -#[cfg(test)] +// Unix-only tests: the fake server binds a unix socket and the fence +// cases use unix symlink identity (the windows pipe arm's connect-time ACL +// model has no lstat equivalent to exercise here). +#[cfg(all(test, unix))] mod tests { use super::*; diff --git a/crates/pa-daemon/src/platform/paths.rs b/crates/pa-daemon/src/platform/paths.rs index 69da53ccf..eb824d727 100644 --- a/crates/pa-daemon/src/platform/paths.rs +++ b/crates/pa-daemon/src/platform/paths.rs @@ -24,6 +24,7 @@ pub fn socket_dir() -> PathBuf { /// discovery never sweeps it (the socket-dir scan returns nothing on /// Windows). #[cfg(not(unix))] +#[must_use] pub fn socket_dir() -> PathBuf { std::env::temp_dir().join("prime-agent-user") } @@ -53,6 +54,7 @@ pub fn default_daemon_socket_path() -> PathBuf { } #[cfg(not(unix))] +#[must_use] pub fn default_daemon_socket_path() -> PathBuf { PathBuf::from(r"\\.\pipe\prime-agent-daemon") } @@ -70,6 +72,7 @@ pub fn worker_socket_path(supervisor_socket_path: &Path, worker_id: &str) -> Pat } #[cfg(not(unix))] +#[must_use] pub fn worker_socket_path(supervisor_socket_path: &Path, worker_id: &str) -> PathBuf { let key = hash_key(&supervisor_socket_path.to_string_lossy(), 12); PathBuf::from(format!( diff --git a/crates/pa-daemon/src/session_store/info.rs b/crates/pa-daemon/src/session_store/info.rs index 01a9d4469..a8d1ee08e 100644 --- a/crates/pa-daemon/src/session_store/info.rs +++ b/crates/pa-daemon/src/session_store/info.rs @@ -314,8 +314,17 @@ impl SessionScanState { } /// TS `seedRosterLedger`-side identity: the resume requires the same - /// file (dev/ino) with a grown-or-equal length. - fn same_file_identity(&self, generation: &SessionInfoGeneration) -> bool { + /// file (dev/ino) with a grown-or-equal length (the grown-or-equal + /// length check itself lives at the call sites; this predicate is the + /// same-file part only). + // The unix arm reads `self.generation`; the not-unix arm carries no + // dev/ino identity at all, so it always answers false (every grown + // file rescans whole - see the arm's own comment). + #[cfg_attr(not(unix), allow(clippy::unused_self))] + fn same_file_identity( + &self, + #[cfg_attr(not(unix), allow(unused_variables))] generation: &SessionInfoGeneration, + ) -> bool { #[cfg(unix)] { self.generation.dev == generation.dev && self.generation.ino == generation.ino diff --git a/crates/pa-daemon/src/socket.rs b/crates/pa-daemon/src/socket.rs index 146d2fc82..d33545db7 100644 --- a/crates/pa-daemon/src/socket.rs +++ b/crates/pa-daemon/src/socket.rs @@ -181,7 +181,10 @@ async fn unlink_stale_socket(path: &Path, expected: SocketIdentity) -> Result<() /// # Errors /// /// Does not error: there is no path to prepare for a named pipe. +// The signature stays async for the shared unix callers (the await is +// the unix arm's own; the pipe arm has no path to prepare). #[cfg(not(unix))] +#[cfg_attr(not(unix), allow(clippy::unused_async))] pub async fn prepare_socket_path(_path: &Path) -> Result<()> { Ok(()) } diff --git a/crates/pa-daemon/src/supervisor/clients.rs b/crates/pa-daemon/src/supervisor/clients.rs index 880e68052..99a1cccc7 100644 --- a/crates/pa-daemon/src/supervisor/clients.rs +++ b/crates/pa-daemon/src/supervisor/clients.rs @@ -1089,10 +1089,15 @@ impl Supervisor { #[cfg(all(test, unix))] mod tests { + #[cfg(unix)] use super::*; + #[cfg(unix)] use crate::supervisor::SupervisorOptions; + #[cfg(unix)] use pa_types::platform::transport::TransportStream; + #[cfg(unix)] use serde_json::json; + #[cfg(unix)] use std::sync::Arc; use std::time::Duration; diff --git a/crates/pa-telemetry/src/sinks/file.rs b/crates/pa-telemetry/src/sinks/file.rs index 6d32c7418..515686b94 100644 --- a/crates/pa-telemetry/src/sinks/file.rs +++ b/crates/pa-telemetry/src/sinks/file.rs @@ -96,7 +96,13 @@ impl FileSink { self.set_private().await } - /// 0600 on unix; on other platforms the agent dir ACLs apply. + /// 0600 on unix; on other platforms the agent dir ACLs apply. The + /// signature stays async for the shared call site; the non-unix arm + /// is legitimately await-free (the ACLs carry the restriction). + #[cfg_attr( + not(unix), + allow(clippy::unused_async, clippy::unused_async_trait_impl) + )] async fn set_private(&self) -> Result<()> { #[cfg(unix)] { diff --git a/crates/pa-types/src/memory_release.rs b/crates/pa-types/src/memory_release.rs index 37e623839..8f31e838f 100644 --- a/crates/pa-types/src/memory_release.rs +++ b/crates/pa-types/src/memory_release.rs @@ -34,7 +34,7 @@ pub fn trim_freed_heap() { /// Trim only when a phase actually allocated: `bytes` is the transient's /// size (a serialized frame, a loaded file); small responses skip the /// arena walk entirely. -pub fn trim_freed_heap_if_large(bytes: usize) { +pub fn trim_freed_heap_if_large(#[allow(unused_variables)] bytes: usize) { #[cfg(all(target_os = "linux", target_env = "gnu"))] if bytes >= (1 << 20) { trim_freed_heap(); diff --git a/crates/pa-types/src/platform/identity.rs b/crates/pa-types/src/platform/identity.rs index 233d6adff..46ffd233c 100644 --- a/crates/pa-types/src/platform/identity.rs +++ b/crates/pa-types/src/platform/identity.rs @@ -24,6 +24,7 @@ pub fn socket_identity(path: &Path) -> Option { } #[cfg(not(unix))] +#[must_use] pub fn socket_identity(_path: &Path) -> Option { None } diff --git a/crates/pa-types/src/platform/process.rs b/crates/pa-types/src/platform/process.rs index 3a02f55f6..85a17d443 100644 --- a/crates/pa-types/src/platform/process.rs +++ b/crates/pa-types/src/platform/process.rs @@ -89,6 +89,7 @@ fn ps_process_start_id(pid: u32) -> Option { /// (TS `getWindowsProcessStartId`); a recycled pid has a different /// creation time, so the identity check is exact. #[cfg(windows)] +#[must_use] pub fn process_start_id(pid: u32) -> Option { if pid == 0 { return None; @@ -169,17 +170,24 @@ pub fn process_executable_path(_pid: u32) -> Option { /// Windows: `QueryFullProcessImageNameW` under the same query access the /// identity ladder uses. #[cfg(windows)] +#[must_use] pub fn process_executable_path(pid: u32) -> Option { if pid == 0 { return None; } let handle = winapi::open_process(winapi::PROCESS_QUERY_LIMITED_INFORMATION, pid)?; let mut buffer = [0u16; 1024]; + // The buffer is a fixed [u16; 1024]: the length never exceeds u32. + #[allow(clippy::cast_possible_truncation)] let mut size = buffer.len() as u32; // Writes the process image path into `buffer` (at most `size` wide // chars, NUL-terminated); a 0 return means the query failed. The // hand-declared `winapi` wrappers are safe fns, so no `unsafe` here. - let written = winapi::query_full_process_image_name(handle, buffer.as_mut_ptr(), &mut size); + let written = winapi::query_full_process_image_name( + handle, + buffer.as_mut_ptr(), + std::ptr::from_mut(&mut size), + ); winapi::close_handle(handle); if written == 0 { return None; @@ -489,7 +497,7 @@ mod winapi { impl FileTime { fn ticks(self) -> u64 { - (self.dwHighDateTime as u64) << 32 | self.dwLowDateTime as u64 + (u64::from(self.dwHighDateTime)) << 32 | u64::from(self.dwLowDateTime) } } @@ -542,8 +550,15 @@ mod winapi { let mut exit = FileTime::default(); let mut kernel = FileTime::default(); let mut user = FileTime::default(); - let ok = - unsafe { GetProcessTimes(handle, &mut creation, &mut exit, &mut kernel, &mut user) }; + let ok = unsafe { + GetProcessTimes( + handle, + std::ptr::from_mut(&mut creation), + std::ptr::from_mut(&mut exit), + std::ptr::from_mut(&mut kernel), + std::ptr::from_mut(&mut user), + ) + }; (ok != 0).then(|| creation.ticks()) } @@ -557,7 +572,7 @@ mod winapi { return unsafe { GetLastError() } == ERROR_ACCESS_DENIED; }; let mut exit_code = 0; - let ok = unsafe { GetExitCodeProcess(handle, &mut exit_code) }; + let ok = unsafe { GetExitCodeProcess(handle, std::ptr::from_mut(&mut exit_code)) }; close_handle(handle); ok != 0 && exit_code == STILL_ACTIVE } diff --git a/crates/pa-types/src/platform/terminal.rs b/crates/pa-types/src/platform/terminal.rs index 0ede366b7..70da7d7ba 100644 --- a/crates/pa-types/src/platform/terminal.rs +++ b/crates/pa-types/src/platform/terminal.rs @@ -88,7 +88,7 @@ fn make_sane(attrs: &mut libc::termios) { /// The reopen-stdin fallback for the no-controlling-terminal case: /// Linux exposes open descriptors under /proc, macOS through its /// fdesc `/dev/fd` (there is no /proc on the Mac). -#[cfg(target_os = "linux")] +#[cfg(all(unix, target_os = "linux"))] const STDIN_TTY_PATH: &str = "/proc/self/fd/0"; #[cfg(all(unix, not(target_os = "linux")))] const STDIN_TTY_PATH: &str = "/dev/fd/0"; @@ -178,6 +178,7 @@ pub fn ensure_cooked_tty() -> TtyCooked { } #[cfg(not(unix))] +#[must_use] pub fn ensure_cooked_tty() -> TtyCooked { TtyCooked::Unavailable } diff --git a/crates/pa-types/src/platform/windows_pipe.rs b/crates/pa-types/src/platform/windows_pipe.rs index 93508c882..a1c2577b2 100644 --- a/crates/pa-types/src/platform/windows_pipe.rs +++ b/crates/pa-types/src/platform/windows_pipe.rs @@ -129,7 +129,7 @@ impl std::fmt::Debug for BlockingPipeClient { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("BlockingPipeClient") .field("name", &self.name) - .finish() + .finish_non_exhaustive() } }