diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 40273a54ec91..c71cb18bfc73 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ concurrency: jobs: changes: - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} timeout-minutes: 5 outputs: macos: ${{ steps.detect.outputs.macos }} @@ -82,7 +82,7 @@ jobs: --head-sha "$HEAD_SHA" workflow-guard-tests: - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -299,7 +299,7 @@ jobs: remote-daemon-tests: needs: changes if: ${{ needs.changes.outputs.go == 'true' }} - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -320,7 +320,7 @@ jobs: web-typecheck: needs: changes if: ${{ needs.changes.outputs.web == 'true' }} - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} defaults: run: working-directory: web @@ -356,7 +356,7 @@ jobs: react-apps-check: needs: changes if: ${{ needs.changes.outputs.web == 'true' }} - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -390,7 +390,7 @@ jobs: needs: changes # Generated protocol and streaming benchmarks also cover webview-only diffs. if: ${{ needs.changes.outputs.macos == 'true' || needs.changes.outputs.web == 'true' }} - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} timeout-minutes: 15 steps: - name: Checkout @@ -427,7 +427,7 @@ jobs: web-db-migrations: needs: changes if: ${{ needs.changes.outputs.web == 'true' }} - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} defaults: run: working-directory: web @@ -1225,7 +1225,7 @@ jobs: - swift-package-tests - agent-session-web-resources if: ${{ always() }} - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} timeout-minutes: 5 steps: - name: Check app-host unit test routing @@ -1533,7 +1533,7 @@ jobs: agent-session-web-resources: needs: changes if: ${{ needs.changes.outputs.agent_session_web == 'true' }} - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} timeout-minutes: 10 steps: - name: Checkout @@ -1562,7 +1562,7 @@ jobs: - web-db-migrations - agent-session-web-resources if: ${{ always() }} - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} timeout-minutes: 5 steps: - name: Check cheap CI layer before macOS runners @@ -2153,7 +2153,7 @@ jobs: - tests-build-and-lag - release-build if: ${{ always() }} - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} steps: - name: Check routed CI jobs env: diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 35b2f2282d07..154bf0eb01bf 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -17,7 +17,7 @@ jobs: (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} permissions: contents: read pull-requests: read diff --git a/.github/workflows/cloud-vm-migrate.yml b/.github/workflows/cloud-vm-migrate.yml index 781c718b9ef6..8481f419ac2b 100644 --- a/.github/workflows/cloud-vm-migrate.yml +++ b/.github/workflows/cloud-vm-migrate.yml @@ -22,7 +22,7 @@ concurrency: jobs: preflight: - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} defaults: run: working-directory: web @@ -46,7 +46,7 @@ jobs: migrate-staging: if: ${{ inputs.target == 'staging' || inputs.target == 'production' }} - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} needs: preflight environment: cloud-vm-staging defaults: @@ -103,7 +103,7 @@ jobs: migrate-production: if: ${{ inputs.target == 'production' }} - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} needs: migrate-staging environment: cloud-vm-production defaults: diff --git a/.github/workflows/cloud-vm-smoke.yml b/.github/workflows/cloud-vm-smoke.yml index 9fe0027a67eb..81baa6211a4f 100644 --- a/.github/workflows/cloud-vm-smoke.yml +++ b/.github/workflows/cloud-vm-smoke.yml @@ -39,7 +39,7 @@ concurrency: jobs: smoke: - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} environment: cloud-vm-${{ inputs.target }} defaults: run: diff --git a/.github/workflows/cmux-tui-artifacts.yml b/.github/workflows/cmux-tui-artifacts.yml index 67c7df0befc7..8124a8245a3a 100644 --- a/.github/workflows/cmux-tui-artifacts.yml +++ b/.github/workflows/cmux-tui-artifacts.yml @@ -46,7 +46,7 @@ jobs: # main-push or manual dispatch. if: github.event_name != 'pull_request' needs: build - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} timeout-minutes: 15 permissions: contents: read diff --git a/.github/workflows/cmux-tui-build-package.yml b/.github/workflows/cmux-tui-build-package.yml index ebcabc745571..dc502ea4fa90 100644 --- a/.github/workflows/cmux-tui-build-package.yml +++ b/.github/workflows/cmux-tui-build-package.yml @@ -68,7 +68,7 @@ permissions: {} jobs: plan-build: name: select binary targets - runs-on: ${{ inputs.linux_runner != '' && inputs.linux_runner || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ inputs.linux_runner != '' && inputs.linux_runner || vars.LINUX_RUNNER || 'ubuntu-24.04' }} timeout-minutes: 5 outputs: matrix: ${{ steps.targets.outputs.matrix }} @@ -81,7 +81,7 @@ jobs: PACKAGE_NPM: ${{ inputs.package_npm }} PACKAGE_PYPI: ${{ inputs.package_pypi }} MACOS_RUNNER: ${{ inputs.macos_runner != '' && inputs.macos_runner || vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15' }} - LINUX_RUNNER: ${{ inputs.linux_runner != '' && inputs.linux_runner || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + LINUX_RUNNER: ${{ inputs.linux_runner != '' && inputs.linux_runner || vars.LINUX_RUNNER || 'ubuntu-24.04' }} LINUX_ARM64_RUNNER: ${{ vars.LINUX_ARM64_RUNNER || 'ubuntu-24.04-arm' }} VERIFY_LINUX_ARM64: ${{ inputs.verify_linux_arm64 }} shell: bash @@ -355,7 +355,7 @@ jobs: cloudflare-relay: name: Cloudflare Durable Object relay if: inputs.build_cloudflare_relay - runs-on: ${{ inputs.linux_runner != '' && inputs.linux_runner || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ inputs.linux_runner != '' && inputs.linux_runner || vars.LINUX_RUNNER || 'ubuntu-24.04' }} timeout-minutes: 30 env: RUSTUP_TOOLCHAIN: "1.91.0" @@ -537,7 +537,7 @@ jobs: name: package distributions needs: build if: inputs.package_npm || inputs.package_pypi - runs-on: ${{ inputs.linux_runner != '' && inputs.linux_runner || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ inputs.linux_runner != '' && inputs.linux_runner || vars.LINUX_RUNNER || 'ubuntu-24.04' }} timeout-minutes: 30 permissions: contents: read diff --git a/.github/workflows/cmux-tui-local-mac.yml b/.github/workflows/cmux-tui-local-mac.yml new file mode 100644 index 000000000000..b8597bf1a72b --- /dev/null +++ b/.github/workflows/cmux-tui-local-mac.yml @@ -0,0 +1,152 @@ +name: cmux-tui local Mac +run-name: cmux-tui local Mac ${{ github.actor }} @ ${{ github.sha }} + +on: + workflow_dispatch: + inputs: + commit: + description: "Exact 40-character commit SHA from a branch in teamleaderleo/cmux" + required: true + type: string + mode: + description: "Focused Rust test or broader local macOS pass" + required: true + type: choice + options: + - focused + - full + test_filter: + description: "Rust test-name substring for focused mode" + required: false + default: "" + type: string + issue_comment: + types: [created] + +permissions: + contents: read + issues: read + pull-requests: read + +concurrency: + group: cmux-tui-local-mac + cancel-in-progress: false + +jobs: + verify: + # This is a self-hosted runner on a public fork. Never schedule it for + # arbitrary commenters or arbitrary repositories. + if: >- + github.repository == 'teamleaderleo/cmux' && + github.actor == 'teamleaderleo' && + ( + github.event_name == 'workflow_dispatch' || + startsWith(github.event.comment.body, '/cmux-tui-local ') + ) + runs-on: [self-hosted, macOS, cmux-local-mac] + timeout-minutes: 120 + + steps: + - name: Resolve and validate request + id: request + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + COMMENT_BODY: ${{ github.event.comment.body }} + INPUT_COMMIT: ${{ inputs.commit }} + INPUT_MODE: ${{ inputs.mode }} + INPUT_FILTER: ${{ inputs.test_filter }} + run: | + set -euo pipefail + + if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then + commit="$INPUT_COMMIT" + mode="$INPUT_MODE" + test_filter="$INPUT_FILTER" + else + read -r command commit mode test_filter extra <<< "$COMMENT_BODY" + if [[ "$command" != "/cmux-tui-local" ]]; then + echo "error: unsupported comment command" >&2 + exit 2 + fi + if [[ -n "${extra:-}" ]]; then + echo "error: too many command arguments" >&2 + exit 2 + fi + fi + + if [[ ! "$commit" =~ ^[0-9a-f]{40}$ ]]; then + echo "error: commit must be an exact lowercase 40-character SHA" >&2 + exit 2 + fi + + case "$mode" in + focused) + if [[ ! "$test_filter" =~ ^[A-Za-z0-9_][A-Za-z0-9_:.-]{0,199}$ ]]; then + echo "error: focused mode needs one safe Rust test-name substring" >&2 + exit 2 + fi + ;; + full) + if [[ -n "${test_filter:-}" ]]; then + echo "error: full mode does not accept a test filter" >&2 + exit 2 + fi + ;; + *) + echo "error: mode must be focused or full" >&2 + exit 2 + ;; + esac + + echo "commit=$commit" >> "$GITHUB_OUTPUT" + echo "mode=$mode" >> "$GITHUB_OUTPUT" + echo "test_filter=${test_filter:-}" >> "$GITHUB_OUTPUT" + + - name: Check out trusted control scripts + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + repository: teamleaderleo/cmux + ref: main + path: control + persist-credentials: false + fetch-depth: 1 + submodules: false + + - name: Check out requested fork commit + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + repository: teamleaderleo/cmux + ref: ${{ steps.request.outputs.commit }} + path: work + persist-credentials: false + fetch-depth: 0 + submodules: false + + - name: Require commit to belong to a branch in this fork + working-directory: work + shell: bash + env: + TARGET_COMMIT: ${{ steps.request.outputs.commit }} + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "$TARGET_COMMIT" + git fetch --no-tags origin '+refs/heads/*:refs/remotes/origin/*' + if ! git branch -r --contains "$TARGET_COMMIT" | grep -Eq '^[* ]+origin/'; then + echo "error: requested commit is not contained in a branch of teamleaderleo/cmux" >&2 + exit 1 + fi + + - name: Run on the local Mac + shell: bash + env: + CMUX_TUI_REPO_ROOT: ${{ github.workspace }}/work + MODE: ${{ steps.request.outputs.mode }} + TEST_FILTER: ${{ steps.request.outputs.test_filter }} + run: | + set -euo pipefail + if [[ "$MODE" == "focused" ]]; then + "$GITHUB_WORKSPACE/control/scripts/verify-cmux-tui-local.sh" --filter "$TEST_FILTER" + else + "$GITHUB_WORKSPACE/control/scripts/verify-cmux-tui-local.sh" --full + fi diff --git a/.github/workflows/cmux-tui-nightly.yml b/.github/workflows/cmux-tui-nightly.yml index 10a631f7d8fc..1e4a53469dbe 100644 --- a/.github/workflows/cmux-tui-nightly.yml +++ b/.github/workflows/cmux-tui-nightly.yml @@ -14,7 +14,7 @@ concurrency: jobs: version: name: derive nightly versions - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} permissions: contents: read outputs: @@ -149,7 +149,7 @@ jobs: needs: - version - build-package - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} permissions: contents: read id-token: write diff --git a/.github/workflows/cmux-tui-release-cut.yml b/.github/workflows/cmux-tui-release-cut.yml index 4925899a272e..f72cab95e085 100644 --- a/.github/workflows/cmux-tui-release-cut.yml +++ b/.github/workflows/cmux-tui-release-cut.yml @@ -26,7 +26,7 @@ concurrency: jobs: tag: name: create release tag - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} permissions: contents: write # actions: write lets this job dispatch the downstream build/publish diff --git a/.github/workflows/cmux-tui-release.yml b/.github/workflows/cmux-tui-release.yml index 7cc7141ce7d9..284d6e9bfae8 100644 --- a/.github/workflows/cmux-tui-release.yml +++ b/.github/workflows/cmux-tui-release.yml @@ -38,7 +38,7 @@ concurrency: jobs: version: name: derive package version - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} permissions: contents: read outputs: @@ -87,7 +87,7 @@ jobs: name: dispatch verified artifacts to registries if: ${{ inputs.publish_npm || inputs.publish_pypi }} needs: build-package - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} permissions: actions: write contents: read diff --git a/.github/workflows/cmux-tui-sdks.yml b/.github/workflows/cmux-tui-sdks.yml index 62affe569154..ce38c42cecbf 100644 --- a/.github/workflows/cmux-tui-sdks.yml +++ b/.github/workflows/cmux-tui-sdks.yml @@ -57,7 +57,7 @@ permissions: jobs: contract: name: protocol contract - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} timeout-minutes: 8 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -124,7 +124,7 @@ jobs: packages: name: ${{ matrix.language }} package needs: contract - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} timeout-minutes: 25 strategy: fail-fast: false @@ -300,7 +300,7 @@ jobs: consumers: name: ${{ matrix.language }} consumer needs: contract - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} timeout-minutes: 25 strategy: fail-fast: false @@ -461,7 +461,7 @@ jobs: conformance: name: seven-language live conformance needs: contract - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-8vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} timeout-minutes: 45 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 diff --git a/.github/workflows/cmux-tui-spec.yml b/.github/workflows/cmux-tui-spec.yml index 73f1974387d3..71ec9d6a751a 100644 --- a/.github/workflows/cmux-tui-spec.yml +++ b/.github/workflows/cmux-tui-spec.yml @@ -22,7 +22,7 @@ permissions: jobs: inventory: - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} timeout-minutes: 5 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 diff --git a/.github/workflows/cmux-tui.yml b/.github/workflows/cmux-tui.yml index c7e4825d3ebc..d7c69e4341f2 100644 --- a/.github/workflows/cmux-tui.yml +++ b/.github/workflows/cmux-tui.yml @@ -36,7 +36,7 @@ permissions: jobs: validate-inputs: name: validate exact commit request - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 5 steps: - name: Validate dispatch inputs @@ -82,7 +82,7 @@ jobs: web-frontend: needs: validate-inputs if: inputs.mode == 'full' - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -121,7 +121,7 @@ jobs: name: valgrind-leak-check (${{ matrix.shard }}) needs: validate-inputs if: inputs.mode == 'full' - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 # Full behavior stays in the normal macOS and Linux suites. Valgrind owns # the bounded startup parsers and terminal replay state only. timeout-minutes: 40 @@ -305,7 +305,7 @@ jobs: name: valgrind-leak-check if: always() && inputs.mode == 'full' needs: valgrind-leak-check-shard - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 2 steps: - name: Require every Valgrind shard @@ -325,7 +325,7 @@ jobs: - os: macos runner: blacksmith-6vcpu-macos-15 - os: linux - runner: blacksmith-4vcpu-ubuntu-2404 + runner: ubuntu-24.04 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: @@ -464,7 +464,7 @@ jobs: bindings-e2e: needs: validate-inputs if: inputs.mode == 'full' - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 40 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -564,7 +564,7 @@ jobs: build_cloudflare_relay: false verify_linux_arm64: ${{ inputs.mode == 'full' }} macos_runner: blacksmith-6vcpu-macos-15 - linux_runner: blacksmith-4vcpu-ubuntu-2404 + linux_runner: ubuntu-24.04 windows_runner: windows-latest checkout_ref: ${{ inputs.commit }} @@ -578,7 +578,7 @@ jobs: - test - bindings-e2e - build-artifacts - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 5 env: MODE: ${{ inputs.mode }} diff --git a/.github/workflows/fieldwork-944-focused-suites-52d0a696.yml b/.github/workflows/fieldwork-944-focused-suites-52d0a696.yml new file mode 100644 index 000000000000..ffcb52fc1153 --- /dev/null +++ b/.github/workflows/fieldwork-944-focused-suites-52d0a696.yml @@ -0,0 +1,29 @@ +name: Fieldwork 944 focused Computer Use suites + +on: + push: + branches: + - fieldwork/944-computer-use-generation-refresh-52d0a696 + +permissions: + contents: read + +jobs: + focused-computer-use: + runs-on: macos-15 + timeout-minutes: 80 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + submodules: recursive + - name: Verify current-base ancestry + run: git merge-base --is-ancestor 52d0a69623430c72d7b5db4b4a5d27c692c30a19 HEAD + - name: Select Xcode and GhosttyKit + run: | + ./scripts/select-ci-xcode.sh + ./scripts/download-prebuilt-ghosttykit.sh + - name: Run cross-generation suite + run: ./scripts/test-unit.sh test -only-testing:cmuxTests/ComputerUseCrossGenerationIdentityTests + - name: Run Computer Use UX suite + run: ./scripts/test-unit.sh test -only-testing:cmuxTests/ComputerUseUXTests diff --git a/.github/workflows/fieldwork-944-refresh-52d0a696.yml b/.github/workflows/fieldwork-944-refresh-52d0a696.yml new file mode 100644 index 000000000000..09353d43dc9b --- /dev/null +++ b/.github/workflows/fieldwork-944-refresh-52d0a696.yml @@ -0,0 +1,74 @@ +name: Fieldwork 944 native refresh on 52d0a696 + +on: + push: + branches: [main] + paths: + - .github/workflows/fieldwork-944-refresh-52d0a696.yml + +permissions: + contents: write + +env: + BASE_SHA: 52d0a69623430c72d7b5db4b4a5d27c692c30a19 + +jobs: + issue-944-native: + runs-on: macos-15 + timeout-minutes: 90 + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ env.BASE_SHA }} + fetch-depth: 0 + submodules: recursive + - name: Create exact-base test-first branch + shell: bash + run: | + set -euo pipefail + git config user.name fieldwork-bot + git config user.email fieldwork-bot@users.noreply.github.com + git switch -C fieldwork/944-computer-use-generation-refresh-52d0a696 "$BASE_SHA" + test "$(git rev-parse HEAD)" = "$BASE_SHA" + git cherry-pick 5fa3df624315dad2e7bc3a31a1df572fe9cafe41 + echo "RED_SHA=$(git rev-parse HEAD)" >> "$GITHUB_ENV" + - name: Select Xcode and GhosttyKit + run: | + ./scripts/select-ci-xcode.sh + ./scripts/download-prebuilt-ghosttykit.sh + - name: RED executes production generation branch + shell: bash + run: | + set -euo pipefail + set +e + ./scripts/test-unit.sh test -only-testing:cmuxTests/ComputerUseCrossGenerationIdentityTests/delayedGenerationACompletionCannotResolveGenerationBWithSameLogicalSession 2>&1 | tee /tmp/944-red.log + status=${PIPESTATUS[0]} + set -e + test "$status" -ne 0 + grep -F 'Test run started' /tmp/944-red.log + grep -F 'delayedGenerationACompletionCannotResolveGenerationBWithSameLogicalSession' /tmp/944-red.log + echo "944 RED exit=$status red_sha=$RED_SHA base=$BASE_SHA" + - name: Apply ingress-captured physical generation repair + shell: bash + run: | + set -euo pipefail + git cherry-pick ffa4d1ad5ffc620fa0cf04b5a91e54f3e9aa2b7f + echo "GREEN_SHA=$(git rev-parse HEAD)" >> "$GITHUB_ENV" + git diff --check "$BASE_SHA" HEAD + - name: GREEN exact discriminator + shell: bash + run: | + set -euo pipefail + ./scripts/test-unit.sh test -only-testing:cmuxTests/ComputerUseCrossGenerationIdentityTests/delayedGenerationACompletionCannotResolveGenerationBWithSameLogicalSession 2>&1 | tee /tmp/944-green.log + grep -F 'Test run started' /tmp/944-green.log + grep -F 'delayedGenerationACompletionCannotResolveGenerationBWithSameLogicalSession' /tmp/944-green.log + - name: GREEN full cross-generation and Computer Use UX suites + shell: bash + run: | + set -euo pipefail + ./scripts/test-unit.sh test -only-testing:cmuxTests/ComputerUseCrossGenerationIdentityTests + ./scripts/test-unit.sh test -only-testing:cmuxTests/ComputerUseUXTests + grep -F 'FeedIngressProcessGenerationEvent' Sources/Feed/WorkstreamEvent+FeedIngress.swift + echo "944 GREEN green_sha=$GREEN_SHA base=$BASE_SHA" + - name: Push evidence branch + run: git push --force origin HEAD:refs/heads/fieldwork/944-computer-use-generation-refresh-52d0a696 diff --git a/.github/workflows/fieldwork-cancel-obsolete-01973f78.yml b/.github/workflows/fieldwork-cancel-obsolete-01973f78.yml new file mode 100644 index 000000000000..fea8a9733f81 --- /dev/null +++ b/.github/workflows/fieldwork-cancel-obsolete-01973f78.yml @@ -0,0 +1,15 @@ +name: Retire obsolete Fieldwork 01973f78 verifier +on: + push: + branches: [main] + paths: + - .github/workflows/fieldwork-cancel-obsolete-01973f78.yml +concurrency: + group: fieldwork-refresh-942-945-01973f78 + cancel-in-progress: true +jobs: + retire: + if: ${{ false }} + runs-on: ubuntu-latest + steps: + - run: true diff --git a/.github/workflows/fieldwork-cancel-obsolete-52d0-combined.yml b/.github/workflows/fieldwork-cancel-obsolete-52d0-combined.yml new file mode 100644 index 000000000000..6ff13b831345 --- /dev/null +++ b/.github/workflows/fieldwork-cancel-obsolete-52d0-combined.yml @@ -0,0 +1,17 @@ +name: Retire superseded combined Fieldwork 52d0 run + +on: + push: + branches: [main] + paths: + - .github/workflows/fieldwork-cancel-obsolete-52d0-combined.yml + +concurrency: + group: fieldwork-refresh-942-945-52d0a696 + cancel-in-progress: true + +jobs: + retire: + runs-on: ubuntu-latest + steps: + - run: echo 'superseded combined verifier retired; dedicated exact-base lanes own final evidence' diff --git a/.github/workflows/fieldwork-cloud-fork-identity.yml b/.github/workflows/fieldwork-cloud-fork-identity.yml new file mode 100644 index 000000000000..f52c2ecdcb36 --- /dev/null +++ b/.github/workflows/fieldwork-cloud-fork-identity.yml @@ -0,0 +1,46 @@ +name: Fieldwork cloud fork identity + +on: + pull_request: + branches: + - fieldwork/cloud-fork-verifier-base-8ef183f1 + paths: + - "cmux-tui/crates/cmux-tui/tests/cloud_fork_identity_route.rs" + - ".github/workflows/fieldwork-cloud-fork-identity.yml" + +permissions: + contents: read + +jobs: + discriminator: + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + ref: ${{ github.event.pull_request.head.sha }} + submodules: false + + - name: Require exact PR head + env: + EXPECTED: ${{ github.event.pull_request.head.sha }} + run: test "$(git rev-parse HEAD)" = "$EXPECTED" + + - name: Init Ghostty submodule + run: git submodule update --init --depth 1 ghostty + + - name: Install Linux build dependencies + run: | + sudo apt-get update + sudo apt-get install -y clang libclang-dev pkg-config + + - name: Install Zig + run: ./scripts/install-zig-ci.sh + + - name: Set up pinned Rust + uses: ./.github/actions/setup-cmux-tui-rust + + - name: Run fork identity discriminator + working-directory: cmux-tui + run: cargo test -p cmux-tui --test cloud_fork_identity_route --locked -- --nocapture diff --git a/.github/workflows/fieldwork-refresh-942-945-01973f78.yml b/.github/workflows/fieldwork-refresh-942-945-01973f78.yml new file mode 100644 index 000000000000..5813adece5bd --- /dev/null +++ b/.github/workflows/fieldwork-refresh-942-945-01973f78.yml @@ -0,0 +1,368 @@ +name: Fieldwork refresh 942-945 on upstream 01973f78 + +on: + push: + branches: [main] + paths: + - .github/workflows/fieldwork-refresh-942-945-01973f78.yml + +permissions: + contents: write + +concurrency: + group: fieldwork-refresh-942-945-01973f78 + cancel-in-progress: false + +env: + BASE_SHA: 01973f785ae7ea5b85dea212152b003c67528745 + +jobs: + issue-942: + runs-on: macos-15 + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ env.BASE_SHA }} + fetch-depth: 0 + submodules: recursive + - name: Create exact-base branch and test-only RED + shell: bash + run: | + set -euo pipefail + git config user.name fieldwork-bot + git config user.email fieldwork-bot@users.noreply.github.com + git switch -C fieldwork/942-missing-cwd-refresh-01973f78 "$BASE_SHA" + test "$(git rev-parse HEAD)" = "$BASE_SHA" + cat > cmuxTests/TerminalStartupWorkingDirectoryPrefixRefreshTests.swift <<'SWIFT' + import Foundation + import Testing + #if canImport(cmux_DEV) + @testable import cmux_DEV + #elseif canImport(cmux) + @testable import cmux + #endif + + @Suite("Persisted restore working directory refresh") + struct TerminalStartupWorkingDirectoryPrefixRefreshTests { + @Test + func missingPersistedWorkingDirectoryFailsClosed() throws { + let inherited = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-fieldwork-inherited-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: inherited, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: inherited) } + let missing = inherited.appendingPathComponent("removed-saved-repository").path + let marker = inherited.appendingPathComponent("payload-ran") + let command = TerminalStartupWorkingDirectoryPrefix.prefix( + "printf payload > \(shellQuote(marker.path))", + workingDirectory: missing + ) + let status = try runShell(command, cwd: inherited) + #expect(status != 0) + #expect(!FileManager.default.fileExists(atPath: marker.path)) + #expect(!command.contains("{")) + } + + @Test + func existingPersistedWorkingDirectoryRunsThereWithQuotedPath() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-fieldwork-cwd-\(UUID().uuidString)", isDirectory: true) + let saved = root.appendingPathComponent("repo with ' quote", isDirectory: true) + try FileManager.default.createDirectory(at: saved, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let marker = root.appendingPathComponent("pwd") + let command = TerminalStartupWorkingDirectoryPrefix.prefix( + "pwd > \(shellQuote(marker.path))", + workingDirectory: saved.path + ) + #expect(try runShell(command, cwd: root) == 0) + let observed = try String(contentsOf: marker, encoding: .utf8) + .trimmingCharacters(in: .whitespacesAndNewlines) + #expect(observed == saved.path) + } + + @Test + func optionalPlacementRetainsFallbackSemantics() throws { + let inherited = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-fieldwork-optional-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: inherited, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: inherited) } + let missing = inherited.appendingPathComponent("missing-optional-directory").path + let marker = inherited.appendingPathComponent("optional-pwd") + let prefix = try #require( + TerminalStartupWorkingDirectoryPrefix.optionalChangeDirectoryPrefix(for: missing) + ) + let command = prefix + "pwd > \(shellQuote(marker.path))" + #expect(try runShell(command, cwd: inherited) == 0) + let observed = try String(contentsOf: marker, encoding: .utf8) + .trimmingCharacters(in: .whitespacesAndNewlines) + #expect(observed == inherited.path) + } + + private func runShell(_ command: String, cwd: URL) throws -> Int32 { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/sh") + process.arguments = ["-c", command] + process.currentDirectoryURL = cwd + try process.run() + process.waitUntilExit() + return process.terminationStatus + } + + private func shellQuote(_ value: String) -> String { + "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" + } + } + SWIFT + git add cmuxTests/TerminalStartupWorkingDirectoryPrefixRefreshTests.swift + git commit -m 'test: fail closed when persisted restore cwd disappears' + echo "RED_SHA=$(git rev-parse HEAD)" >> "$GITHUB_ENV" + - name: Select Xcode and GhosttyKit + run: | + ./scripts/select-ci-xcode.sh + ./scripts/download-prebuilt-ghosttykit.sh + - name: Prove current upstream RED executes and fails for payload launch + shell: bash + run: | + set -euo pipefail + set +e + ./scripts/test-unit.sh test -only-testing:cmuxTests/TerminalStartupWorkingDirectoryPrefixRefreshTests/missingPersistedWorkingDirectoryFailsClosed 2>&1 | tee /tmp/942-red.log + status=${PIPESTATUS[0]} + set -e + test "$status" -ne 0 + grep -F 'missingPersistedWorkingDirectoryFailsClosed' /tmp/942-red.log + echo "942 RED exit=$status red_sha=$RED_SHA base=$BASE_SHA" + - name: Apply smallest required-placement repair + shell: bash + run: | + set -euo pipefail + python3 - <<'PY' + from pathlib import Path + p = Path('Sources/RestorableAgentSession.swift') + s = p.read_text() + old = ''' static func prefix(_ command: String, workingDirectory: String?) -> String { + guard let prefix = optionalChangeDirectoryPrefix(for: workingDirectory) else { return command } + return prefix + command + } + ''' + new = ''' static func prefix(_ command: String, workingDirectory: String?) -> String { + guard let workingDirectory = normalized(workingDirectory) else { return command } + let quoted = TerminalStartupShellQuoting.singleQuoted(workingDirectory) + return "cd -- \\(quoted) 2>/dev/null && \\(command)" + } + ''' + if old not in s: + raise SystemExit('RestorableAgentSession prefix owner drifted') + p.write_text(s.replace(old, new, 1)) + + p = Path('CLI/CMUXCLI+SessionsListForkStartupInput.swift') + s = p.read_text() + old = 'return "cd -- \\(quoted) 2>/dev/null || [ ! -d \\(quoted) ] && \\(command)"' + new = 'return "cd -- \\(quoted) 2>/dev/null && \\(command)"' + if old not in s: + raise SystemExit('CLI cwd owner drifted') + p.write_text(s.replace(old, new, 1)) + PY + git add Sources/RestorableAgentSession.swift CLI/CMUXCLI+SessionsListForkStartupInput.swift + git commit -m 'fix: fail closed for persisted restore working directory' + echo "GREEN_SHA=$(git rev-parse HEAD)" >> "$GITHUB_ENV" + git diff --check "$BASE_SHA" HEAD + - name: Prove GREEN and focused controls + shell: bash + run: | + set -euo pipefail + ./scripts/test-unit.sh test -only-testing:cmuxTests/TerminalStartupWorkingDirectoryPrefixRefreshTests 2>&1 | tee /tmp/942-green.log + grep -F 'missingPersistedWorkingDirectoryFailsClosed' /tmp/942-green.log + ./scripts/test-unit.sh test -only-testing:cmuxTests/SessionPersistenceTests + ./scripts/test-unit.sh test -only-testing:cmuxTests/AgentSessionAutoResumeSettingsTests + grep -F 'optionalChangeDirectoryPrefix' Sources/RestorableAgentSession.swift + grep -F '2>/dev/null &&' CLI/CMUXCLI+SessionsListForkStartupInput.swift + echo "942 GREEN green_sha=$GREEN_SHA base=$BASE_SHA" + - name: Push refresh branch + run: git push --force origin HEAD:refs/heads/fieldwork/942-missing-cwd-refresh-01973f78 + + issue-943: + runs-on: macos-15 + timeout-minutes: 70 + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ env.BASE_SHA }} + fetch-depth: 0 + submodules: recursive + - name: Create exact-base branch and replay RED tests + shell: bash + run: | + set -euo pipefail + git config user.name fieldwork-bot + git config user.email fieldwork-bot@users.noreply.github.com + git switch -C fieldwork/943-rpc-target-alias-refresh-01973f78 "$BASE_SHA" + git cherry-pick 702784686141f453454fea2afcda15c9b9573753 + git cherry-pick 0f4e0144398cfda3efd096bc68a560f7b9f2e220 + echo "RED_SHA=$(git rev-parse HEAD)" >> "$GITHUB_ENV" + - name: Select Xcode and GhosttyKit + run: | + ./scripts/select-ci-xcode.sh + ./scripts/download-prebuilt-ghosttykit.sh + - name: Prove parser and app-host RED + shell: bash + run: | + set -euo pipefail + set +e + swift test --package-path Packages/macOS/CmuxControlSocket --filter strictRejectsCamelCaseTargetAlias 2>&1 | tee /tmp/943-parser-red.log + parser_status=${PIPESTATUS[0]} + ./scripts/test-unit.sh test -only-testing:cmuxTests/SocketTerminalBindingRegressionTests/camelCaseSurfaceAliasCannotInjectIntoFocusedTerminal 2>&1 | tee /tmp/943-app-red.log + app_status=${PIPESTATUS[0]} + set -e + test "$parser_status" -ne 0 + test "$app_status" -ne 0 + grep -F 'strictRejectsCamelCaseTargetAlias' /tmp/943-parser-red.log + grep -F 'camelCaseSurfaceAliasCannotInjectIntoFocusedTerminal' /tmp/943-app-red.log + echo "943 RED parser=$parser_status app=$app_status red_sha=$RED_SHA base=$BASE_SHA" + - name: Apply narrow alias repair + shell: bash + run: | + set -euo pipefail + git cherry-pick a36ffbe0b8c3acedeb7ab82454ae6cec65ec5d06 + echo "GREEN_SHA=$(git rev-parse HEAD)" >> "$GITHUB_ENV" + git diff --check "$BASE_SHA" HEAD + - name: Prove GREEN and protocol controls + shell: bash + run: | + set -euo pipefail + swift test --package-path Packages/macOS/CmuxControlSocket 2>&1 | tee /tmp/943-package-green.log + ./scripts/test-unit.sh test -only-testing:cmuxTests/SocketTerminalBindingRegressionTests/camelCaseSurfaceAliasCannotInjectIntoFocusedTerminal 2>&1 | tee /tmp/943-app-green.log + grep -F 'camelCaseSurfaceAliasCannotInjectIntoFocusedTerminal' /tmp/943-app-green.log + echo "943 GREEN green_sha=$GREEN_SHA base=$BASE_SHA" + - name: Push refresh branch + run: git push --force origin HEAD:refs/heads/fieldwork/943-rpc-target-alias-refresh-01973f78 + + issue-944: + runs-on: macos-15 + timeout-minutes: 70 + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ env.BASE_SHA }} + fetch-depth: 0 + submodules: recursive + - name: Create exact-base branch and native test-only RED + shell: bash + run: | + set -euo pipefail + git config user.name fieldwork-bot + git config user.email fieldwork-bot@users.noreply.github.com + git switch -C fieldwork/944-computer-use-generation-refresh-01973f78 "$BASE_SHA" + git cherry-pick 5fa3df624315dad2e7bc3a31a1df572fe9cafe41 + echo "RED_SHA=$(git rev-parse HEAD)" >> "$GITHUB_ENV" + - name: Select Xcode and GhosttyKit + run: | + ./scripts/select-ci-xcode.sh + ./scripts/download-prebuilt-ghosttykit.sh + - name: Prove native production-branch RED + shell: bash + run: | + set -euo pipefail + set +e + ./scripts/test-unit.sh test -only-testing:cmuxTests/ComputerUseCrossGenerationIdentityTests/delayedGenerationACompletionCannotResolveGenerationBWithSameLogicalSession 2>&1 | tee /tmp/944-red.log + status=${PIPESTATUS[0]} + set -e + test "$status" -ne 0 + grep -F 'Test run started' /tmp/944-red.log + grep -F 'Computer Use cross-generation identity' /tmp/944-red.log + grep -F 'delayedGenerationACompletionCannotResolveGenerationBWithSameLogicalSession' /tmp/944-red.log + echo "944 RED exit=$status red_sha=$RED_SHA base=$BASE_SHA" + - name: Apply ingress-captured physical generation repair + shell: bash + run: | + set -euo pipefail + git cherry-pick ffa4d1ad5ffc620fa0cf04b5a91e54f3e9aa2b7f + echo "GREEN_SHA=$(git rev-parse HEAD)" >> "$GITHUB_ENV" + git diff --check "$BASE_SHA" HEAD + - name: Prove GREEN and adjacent current-generation control + shell: bash + run: | + set -euo pipefail + ./scripts/test-unit.sh test -only-testing:cmuxTests/ComputerUseCrossGenerationIdentityTests/delayedGenerationACompletionCannotResolveGenerationBWithSameLogicalSession 2>&1 | tee /tmp/944-green.log + grep -F 'Test run started' /tmp/944-green.log + ./scripts/test-unit.sh test -only-testing:cmuxTests/ComputerUseUXTests/computerUseHookResolutionAcceptsTheCurrentProcessGenerationWhenAgentIDsDiffer + grep -F 'FeedIngressProcessGenerationEvent' Sources/Feed/WorkstreamEvent+FeedIngress.swift + echo "944 GREEN green_sha=$GREEN_SHA base=$BASE_SHA" + - name: Push refresh branch + run: git push --force origin HEAD:refs/heads/fieldwork/944-computer-use-generation-refresh-01973f78 + + issue-945: + runs-on: macos-15 + timeout-minutes: 70 + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ env.BASE_SHA }} + fetch-depth: 0 + submodules: recursive + - name: Create exact-base branch and physical stalled-writer RED + shell: bash + run: | + set -euo pipefail + git config user.name fieldwork-bot + git config user.email fieldwork-bot@users.noreply.github.com + git switch -C fieldwork/945-remote-write-liveness-refresh-01973f78 "$BASE_SHA" + git fetch origin fieldwork/nonlinear-resource-collapse:refs/remotes/origin/fieldwork/nonlinear-resource-collapse + mkdir -p Packages/macOS/CmuxRemoteDaemon/Tests/CmuxRemoteDaemonTests + git show origin/fieldwork/nonlinear-resource-collapse:Packages/macOS/CmuxRemoteDaemon/Tests/CmuxRemoteDaemonTests/RemoteDaemonRPCClientWriteAdmissionScalingTests.swift > Packages/macOS/CmuxRemoteDaemon/Tests/CmuxRemoteDaemonTests/RemoteDaemonRPCClientWriteAdmissionScalingTests.swift + python3 - <<'PY' + from pathlib import Path + p = Path('Packages/macOS/CmuxRemoteDaemon/Tests/CmuxRemoteDaemonTests/RemoteDaemonRPCClientWriteAdmissionScalingTests.swift') + s = p.read_text() + s = s.replace('private let fieldworkWriteScalingEnabled =\n ProcessInfo.processInfo.environment["CMUX_FIELDWORK_SCALING"] == "1"\n', '''private let fieldworkWriteScalingEnabled =\n ProcessInfo.processInfo.environment["CMUX_FIELDWORK_SCALING"] == "1"\n\nprivate let fieldworkWriteScalingCases: [Int] = {\n guard let raw = ProcessInfo.processInfo.environment["CMUX_FIELDWORK_SCALING_CASES"],\n !raw.isEmpty else { return [1, 10, 50, 200] }\n return raw.split(separator: ",").compactMap { Int($0) }\n}()\n''') + s = s.replace('for callers in [1, 10, 50, 200] {', 'for callers in fieldworkWriteScalingCases {') + p.write_text(s) + PY + git add Packages/macOS/CmuxRemoteDaemon/Tests/CmuxRemoteDaemonTests/RemoteDaemonRPCClientWriteAdmissionScalingTests.swift + git commit -m 'test(remote): reproduce physical serialized writer stall' + echo "RED_SHA=$(git rev-parse HEAD)" >> "$GITHUB_ENV" + - name: Prove N=1 physical write-stall RED + shell: bash + env: + CMUX_FIELDWORK_SCALING: '1' + CMUX_FIELDWORK_SCALING_CASES: '1' + run: | + set -euo pipefail + set +e + swift test --package-path Packages/macOS/CmuxRemoteDaemon --filter physicalWriteStallMustBoundQueuedCallers 2>&1 | tee /tmp/945-red.log + status=${PIPESTATUS[0]} + set -e + test "$status" -ne 0 + grep -F 'physical stdio write stall' /tmp/945-red.log + grep -F '1 queued RPC callers remained behind one physical write' /tmp/945-red.log + echo "945 RED exit=$status red_sha=$RED_SHA base=$BASE_SHA" + - name: Apply final historical write-liveness repair as one refresh commit + shell: bash + run: | + set -euo pipefail + git cherry-pick --no-commit 843accd73070a441ae4d24aa88e1a21bbbe02bc7 + git cherry-pick --no-commit ff826debbdcc0a392aefd7e01a77839a2a865cb7 + git cherry-pick --no-commit cc558390af891a0d98b0b1f25c8b0ff435bd26b4 + git commit -m 'fix(remote): bound serialized daemon writes by liveness budget' + echo "GREEN_SHA=$(git rev-parse HEAD)" >> "$GITHUB_ENV" + git diff --check "$BASE_SHA" HEAD + - name: Prove full request and notification scaling plus responsive control + shell: bash + env: + CMUX_FIELDWORK_SCALING: '1' + run: | + set -euo pipefail + swift test --package-path Packages/macOS/CmuxRemoteDaemon --filter RemoteDaemonRPCClientWriteAdmissionScalingTests 2>&1 | tee /tmp/945-scaling-green.log + grep -F 'physical stdio write stall' /tmp/945-scaling-green.log + grep -F 'notification-only PTY writes' /tmp/945-scaling-green.log + grep -F 'responsive stdio transport settles 200 concurrent RPC callers' /tmp/945-scaling-green.log + - name: Run focused package regression controls + shell: bash + run: | + set -euo pipefail + swift test --package-path Packages/macOS/CmuxRemoteDaemon --filter timedOutPTYAttachPreservesHealthyTransportState + swift test --package-path Packages/macOS/CmuxRemoteDaemon --filter timedOutPTYAttachBoundsCancellationWrite + grep -n 'configuration.transport == .websocket' Packages/macOS/CmuxRemoteDaemon/Sources/CmuxRemoteDaemon/Client/RemoteDaemonRPCClient+RPC.swift + echo "945 GREEN green_sha=$GREEN_SHA base=$BASE_SHA" + - name: Push refresh branch + run: git push --force origin HEAD:refs/heads/fieldwork/945-remote-write-liveness-refresh-01973f78 diff --git a/.github/workflows/fieldwork-refresh-942-945-52d0a696.yml b/.github/workflows/fieldwork-refresh-942-945-52d0a696.yml new file mode 100644 index 000000000000..5863ce8774b9 --- /dev/null +++ b/.github/workflows/fieldwork-refresh-942-945-52d0a696.yml @@ -0,0 +1,104 @@ +name: Fieldwork 942 corrected refresh on e9ec596d + +on: + push: + branches: [main] + paths: + - .github/workflows/fieldwork-refresh-942-945-52d0a696.yml + +permissions: + contents: write + +env: + BASE_SHA: e9ec596d12d854d6569b53b38bb21b62f8126d56 + RED_SHA: a50f0480cf88e2574c7ed92e8bad7b28b51ccbb9 + +jobs: + issue-942-corrected: + runs-on: macos-15 + timeout-minutes: 80 + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ env.RED_SHA }} + fetch-depth: 0 + submodules: recursive + - name: Verify exact test-first ancestry + shell: bash + run: | + set -euo pipefail + git config user.name fieldwork-bot + git config user.email fieldwork-bot@users.noreply.github.com + test "$(git rev-parse HEAD^)" = "$BASE_SHA" + git switch -C fieldwork/942-missing-cwd-refresh-e9ec596d "$RED_SHA" + - name: Select Xcode and GhosttyKit + run: | + ./scripts/select-ci-xcode.sh + ./scripts/download-prebuilt-ghosttykit.sh + - name: RED missing persisted cwd launches inherited payload + shell: bash + run: | + set -euo pipefail + set +e + ./scripts/test-unit.sh test -only-testing:cmuxTests/TerminalStartupWorkingDirectoryPrefixRefreshTests/missingPersistedWorkingDirectoryFailsClosed 2>&1 | tee /tmp/942-red.log + status=${PIPESTATUS[0]} + set -e + test "$status" -ne 0 + grep -F 'missingPersistedWorkingDirectoryFailsClosed' /tmp/942-red.log + echo "942 RED exit=$status red_sha=$RED_SHA base=$BASE_SHA" + - name: Apply required-placement repair including retarget stripping + shell: bash + run: | + set -euo pipefail + python3 - <<'PY' + from pathlib import Path + + p = Path('Sources/RestorableAgentSession.swift') + s = p.read_text() + old = ''' static func prefix(_ command: String, workingDirectory: String?) -> String { + guard let prefix = optionalChangeDirectoryPrefix(for: workingDirectory) else { + return command + } + return prefix + command + } + ''' + new = ''' static func prefix(_ command: String, workingDirectory: String?) -> String { + guard let workingDirectory = normalized(workingDirectory) else { return command } + let quoted = TerminalStartupShellQuoting.singleQuoted(workingDirectory) + return "cd -- \\(quoted) 2>/dev/null && \\(command)" + } + ''' + if old not in s: + raise SystemExit('required prefix owner drifted') + s = s.replace(old, new, 1) + old_prefix = ' "cd -- \\(quoted) 2>/dev/null || [ ! -d \\(quoted) ] && ",\n' + new_prefix = old_prefix + ' "cd -- \\(quoted) 2>/dev/null && ",\n' + if old_prefix not in s: + raise SystemExit('required prefix stripper drifted') + p.write_text(s.replace(old_prefix, new_prefix, 1)) + + p = Path('CLI/CMUXCLI+SessionsListForkStartupInput.swift') + s = p.read_text() + old = 'return "cd -- \\(quoted) 2>/dev/null || [ ! -d \\(quoted) ] && \\(command)"' + new = 'return "cd -- \\(quoted) 2>/dev/null && \\(command)"' + if old not in s: + raise SystemExit('CLI cwd owner drifted') + p.write_text(s.replace(old, new, 1)) + PY + git add Sources/RestorableAgentSession.swift CLI/CMUXCLI+SessionsListForkStartupInput.swift + git commit -m 'fix: fail closed for persisted restore working directory' + echo "GREEN_SHA=$(git rev-parse HEAD)" >> "$GITHUB_ENV" + git diff --check "$BASE_SHA" HEAD + - name: GREEN discriminator retarget optional shell and focused restore controls + shell: bash + run: | + set -euo pipefail + ./scripts/test-unit.sh test -only-testing:cmuxTests/TerminalStartupWorkingDirectoryPrefixRefreshTests 2>&1 | tee /tmp/942-green.log + grep -F 'missingPersistedWorkingDirectoryFailsClosed' /tmp/942-green.log + grep -F 'requiredPrefixRetargetsWithoutStackingOldDirectory' /tmp/942-green.log + ./scripts/test-unit.sh test -only-testing:cmuxTests/ShellStartupMatrixTests + ./scripts/test-unit.sh test -only-testing:cmuxTests/SessionPersistenceTests + ./scripts/test-unit.sh test -only-testing:cmuxTests/AgentSessionAutoResumeSettingsTests + echo "942 GREEN green_sha=$GREEN_SHA base=$BASE_SHA" + - name: Push evidence branch + run: git push --force origin HEAD:refs/heads/fieldwork/942-missing-cwd-refresh-e9ec596d diff --git a/.github/workflows/fieldwork-refresh-943-readproof-52d0a696.yml b/.github/workflows/fieldwork-refresh-943-readproof-52d0a696.yml new file mode 100644 index 000000000000..9edaca5a0e75 --- /dev/null +++ b/.github/workflows/fieldwork-refresh-943-readproof-52d0a696.yml @@ -0,0 +1,196 @@ +name: Fieldwork 943 read-side refresh on 52d0a696 + +on: + push: + branches: [main] + paths: + - .github/workflows/fieldwork-refresh-943-readproof-52d0a696.yml + +permissions: + contents: write + +env: + BASE_SHA: 52d0a69623430c72d7b5db4b4a5d27c692c30a19 + +jobs: + issue-943-readproof: + runs-on: macos-15 + timeout-minutes: 80 + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ env.BASE_SHA }} + fetch-depth: 0 + submodules: recursive + - name: Build test-only RED from exact current base + shell: bash + run: | + set -euo pipefail + git config user.name fieldwork-bot + git config user.email fieldwork-bot@users.noreply.github.com + git switch -C fieldwork/943-rpc-target-alias-readproof-52d0a696 "$BASE_SHA" + test "$(git rev-parse HEAD)" = "$BASE_SHA" + git cherry-pick 702784686141f453454fea2afcda15c9b9573753 + git cherry-pick 0f4e0144398cfda3efd096bc68a560f7b9f2e220 + python3 - <<'PY' + from pathlib import Path + + p = Path('cmuxTests/SocketTerminalBindingRegressionTests.swift') + s = p.read_text() + anchor = ' @Test func camelCaseSurfaceAliasCannotInjectIntoFocusedTerminal() async throws {\n' + addition = r''' @Test func camelCaseSurfaceAliasCannotReplayFocusedTerminal() async throws { + try await withAppContext { workspace in + let originalPanel = try #require( + workspace.focusedPanelId.flatMap { workspace.panels[$0] as? TerminalPanel } + ) + let replacement = TerminalSurface( + id: originalPanel.id, + tabId: workspace.id, + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: nil, + initialCommand: "/bin/cat" + ) + defer { + replacement.teardownSurface() + GhosttyApp.terminalSurfaceRegistry.unregister(replacement) + } + + try await waitForLiveSurface(replacement) + let requestedSurfaceID = UUID().uuidString + let envelope = try await socketEnvelopeUsingExecutionPolicy( + method: "terminal.replay", + params: ["surfaceId": requestedSurfaceID] + ) + + if envelope["ok"] as? Bool == true { + let result = try #require(envelope["result"] as? [String: Any]) + let returnedSurfaceID = try #require(result["surface_id"] as? String) + #expect(returnedSurfaceID != requestedSurfaceID) + Issue.record("camelCase surfaceId silently replayed the focused terminal") + return + } + + let error = try #require(envelope["error"] as? [String: Any]) + #expect(error["code"] as? String == "invalid_params") + } + } + + @Test func trueNoTargetReplayStillUsesFocusedTerminal() async throws { + try await withAppContext { workspace in + let originalPanel = try #require( + workspace.focusedPanelId.flatMap { workspace.panels[$0] as? TerminalPanel } + ) + let replacement = TerminalSurface( + id: originalPanel.id, + tabId: workspace.id, + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: nil, + initialCommand: "/bin/cat" + ) + defer { + replacement.teardownSurface() + GhosttyApp.terminalSurfaceRegistry.unregister(replacement) + } + + try await waitForLiveSurface(replacement) + let envelope = try await socketEnvelopeUsingExecutionPolicy( + method: "terminal.replay", + params: [:] + ) + #expect(envelope["ok"] as? Bool == true) + let result = try #require(envelope["result"] as? [String: Any]) + #expect((result["surface_id"] as? String) == replacement.id.uuidString) + } + } + +''' + if anchor not in s: + raise SystemExit('app test anchor drifted') + p.write_text(s.replace(anchor, addition + anchor, 1)) + + p = Path('Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlRequestParserTests.swift') + s = p.read_text() + anchor = ' @Test func strictRejectsCamelCaseTargetAlias() {\n' + addition = r''' @Test func strictRejectsKnownTargetAliasVariants() { + let aliases = [ + "windowId", "groupId", "workspaceId", "surfaceId", + "terminalId", "tabId", "paneId", "surfaceID", "Surface_Id", + ] + for alias in aliases { + let line = "{\"method\":\"terminal.replay\",\"params\":{\"\(alias)\":\"00000000-0000-0000-0000-000000000001\"}}" + #expect((try? parser.request(fromLine: line).get()) == nil) + } + } + + @Test func strictPreservesCanonicalNoTargetAndUnrelatedExtensionKeys() { + let canonical = #"{"method":"terminal.replay","params":{"surface_id":"00000000-0000-0000-0000-000000000001"}}"# + let noTarget = #"{"method":"terminal.replay","params":{}}"# + let extensionKey = #"{"method":"terminal.replay","params":{"totally_bogus_key":1}}"# + #expect((try? parser.request(fromLine: canonical).get()) != nil) + #expect((try? parser.request(fromLine: noTarget).get()) != nil) + #expect((try? parser.request(fromLine: extensionKey).get()) != nil) + } + +''' + if anchor not in s: + raise SystemExit('parser test anchor drifted') + p.write_text(s.replace(anchor, addition + anchor, 1)) + PY + git add cmuxTests/SocketTerminalBindingRegressionTests.swift Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlRequestParserTests.swift + git commit -m 'test: prove read-side RPC target aliases fail closed' + echo "RED_SHA=$(git rev-parse HEAD)" >> "$GITHUB_ENV" + - name: Select Xcode and GhosttyKit + run: | + ./scripts/select-ci-xcode.sh + ./scripts/download-prebuilt-ghosttykit.sh + - name: RED parser aliases are accepted on current main + shell: bash + run: | + set -euo pipefail + set +e + swift test --package-path Packages/macOS/CmuxControlSocket --filter strictRejectsKnownTargetAliasVariants 2>&1 | tee /tmp/943-parser-red-read.log + status=${PIPESTATUS[0]} + set -e + test "$status" -ne 0 + grep -F 'strictRejectsKnownTargetAliasVariants' /tmp/943-parser-red-read.log + echo "943 parser RED exit=$status red_sha=$RED_SHA base=$BASE_SHA" + - name: RED read-only terminal.replay falls through to focus + shell: bash + run: | + set -euo pipefail + set +e + ./scripts/test-unit.sh test -only-testing:cmuxTests/SocketTerminalBindingRegressionTests/camelCaseSurfaceAliasCannotReplayFocusedTerminal 2>&1 | tee /tmp/943-read-red.log + status=${PIPESTATUS[0]} + set -e + test "$status" -ne 0 + grep -F 'camelCase surfaceId silently replayed the focused terminal' /tmp/943-read-red.log + echo "943 read RED exit=$status red_sha=$RED_SHA base=$BASE_SHA" + - name: Apply narrow target-alias repair + shell: bash + run: | + set -euo pipefail + git cherry-pick a36ffbe0b8c3acedeb7ab82454ae6cec65ec5d06 + echo "GREEN_SHA=$(git rev-parse HEAD)" >> "$GITHUB_ENV" + git diff --check "$BASE_SHA" HEAD + - name: GREEN parser contract + shell: bash + run: | + set -euo pipefail + swift test --package-path Packages/macOS/CmuxControlSocket 2>&1 | tee /tmp/943-parser-green-read.log + grep -F 'strictRejectsKnownTargetAliasVariants' /tmp/943-parser-green-read.log + grep -F 'strictPreservesCanonicalNoTargetAndUnrelatedExtensionKeys' /tmp/943-parser-green-read.log + - name: GREEN read no-target and controlled mutation app proofs + shell: bash + run: | + set -euo pipefail + ./scripts/test-unit.sh test \ + -only-testing:cmuxTests/SocketTerminalBindingRegressionTests/camelCaseSurfaceAliasCannotReplayFocusedTerminal \ + -only-testing:cmuxTests/SocketTerminalBindingRegressionTests/trueNoTargetReplayStillUsesFocusedTerminal \ + -only-testing:cmuxTests/SocketTerminalBindingRegressionTests/camelCaseSurfaceAliasCannotInjectIntoFocusedTerminal \ + 2>&1 | tee /tmp/943-app-green-read.log + grep -F 'camelCaseSurfaceAliasCannotReplayFocusedTerminal' /tmp/943-app-green-read.log + grep -F 'trueNoTargetReplayStillUsesFocusedTerminal' /tmp/943-app-green-read.log + grep -F 'camelCaseSurfaceAliasCannotInjectIntoFocusedTerminal' /tmp/943-app-green-read.log + echo "943 GREEN green_sha=$GREEN_SHA base=$BASE_SHA" + - name: Push complete evidence branch + run: git push --force origin HEAD:refs/heads/fieldwork/943-rpc-target-alias-readproof-52d0a696 diff --git a/.github/workflows/fieldwork-rpc-target-verifier.yml b/.github/workflows/fieldwork-rpc-target-verifier.yml new file mode 100644 index 000000000000..f35e47a07b25 --- /dev/null +++ b/.github/workflows/fieldwork-rpc-target-verifier.yml @@ -0,0 +1,258 @@ +name: Fieldwork RPC target verifier + +on: + push: + branches: [main] + paths: + - .github/workflows/fieldwork-rpc-target-verifier.yml + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: fieldwork-rpc-target-verifier + cancel-in-progress: true + +env: + UPSTREAM_BASE_SHA: eaa899cb20bd411019744fbd2bdedeb397f3070b + RED_APP_SHA: 702784686141f453454fea2afcda15c9b9573753 + RED_PARSER_SHA: 0f4e0144398cfda3efd096bc68a560f7b9f2e220 + GREEN_SHA: a36ffbe0b8c3acedeb7ab82454ae6cec65ec5d06 + SWIFT_BACKTRACE: interactive=no,timeout=0s,symbolicate=off,color=no + CMUX_SKIP_ZIG_BUILD: "1" + +jobs: + parser-red-green: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out fork history + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Prove red accepts the ignored alias + run: | + set -euo pipefail + git checkout --detach "$RED_PARSER_SHA" + test "$(git rev-parse HEAD^)" = "$RED_APP_SHA" + + cat > /tmp/cmux-rpc-alias-red.swift <<'SWIFT' + import Foundation + + @main + struct CmuxRPCAliasRedProbe { + static func main() { + let line = #"{"id":7,"method":"terminal.input","params":{"surfaceId":"00000000-0000-0000-0000-000000000001","text":"marker"}}"# + switch ControlRequestParser().request(fromLine: line) { + case .success: + print("accepted") + case .failure: + print("rejected") + } + } + } + SWIFT + + swiftc \ + Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/JSONValue.swift \ + Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlRequest.swift \ + Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlRequestParseError.swift \ + Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlRequestParser.swift \ + /tmp/cmux-rpc-alias-red.swift \ + -o /tmp/cmux-rpc-alias-red + + outcome="$(/tmp/cmux-rpc-alias-red)" + echo "red_parser_outcome=$outcome" + test "$outcome" = "accepted" + + - name: Prove green rejects the alias and preserves extension behavior + run: | + set -euo pipefail + git checkout --detach "$GREEN_SHA" + test "$(git rev-parse HEAD^)" = "$RED_PARSER_SHA" + git merge-base --is-ancestor "$UPSTREAM_BASE_SHA" HEAD + + cat > /tmp/cmux-rpc-alias-green.swift <<'SWIFT' + import Foundation + + @main + struct CmuxRPCAliasGreenProbe { + static func main() { + let parser = ControlRequestParser() + let encoder = ControlResponseEncoder() + let aliased = #"{"id":7,"method":"terminal.input","params":{"surfaceId":"00000000-0000-0000-0000-000000000001","text":"marker"}}"# + + guard case .failure(let error) = parser.request(fromLine: aliased) else { + fatalError("green parser accepted surfaceId") + } + let response = encoder.response(for: error) + guard let data = response.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + object["ok"] as? Bool == false, + let payload = object["error"] as? [String: Any], + payload["code"] as? String == "invalid_params" else { + fatalError("green response was not invalid_params: \(response)") + } + + let canonical = #"{"method":"terminal.input","params":{"surface_id":"00000000-0000-0000-0000-000000000001","text":"marker"}}"# + guard case .success = parser.request(fromLine: canonical) else { + fatalError("canonical surface_id stopped parsing") + } + + let extensionKey = #"{"method":"terminal.input","params":{"totally_bogus_key":1,"text":"marker"}}"# + guard case .success = parser.request(fromLine: extensionKey) else { + fatalError("unrelated extension key stopped parsing") + } + + print("rejected-invalid_params; canonical-and-extension-keys-preserved") + } + } + SWIFT + + swiftc \ + Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/JSONValue.swift \ + Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlRequest.swift \ + Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlRequestParseError.swift \ + Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlRequestParser.swift \ + Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCallResult.swift \ + Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlResponseEncoder.swift \ + /tmp/cmux-rpc-alias-green.swift \ + -o /tmp/cmux-rpc-alias-green + + /tmp/cmux-rpc-alias-green + git diff --check "$UPSTREAM_BASE_SHA" HEAD + + app-host-red: + runs-on: macos-15 + timeout-minutes: 45 + steps: + - name: Check out red app regression + uses: actions/checkout@v4 + with: + ref: ${{ env.RED_APP_SHA }} + fetch-depth: 0 + submodules: recursive + + - name: Verify exact red ancestry + run: | + set -euo pipefail + test "$(git rev-parse HEAD^)" = "$UPSTREAM_BASE_SHA" + + - name: Select Xcode + run: ./scripts/select-ci-xcode.sh + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + + - name: Download pre-built GhosttyKit.xcframework + run: ./scripts/download-prebuilt-ghosttykit.sh + + - name: Install Zig + run: ./scripts/install-zig-ci.sh + + - name: Install Rust + run: ./scripts/install-rust-ci.sh + + - name: Prepare DerivedData + run: | + DERIVED_DATA_PATH="${RUNNER_TEMP}/cmux-rpc-red-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + mkdir -p "$DERIVED_DATA_PATH" + echo "CMUX_DERIVED_DATA_PATH=$DERIVED_DATA_PATH" >> "$GITHUB_ENV" + + - name: Resolve Swift packages + run: | + SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" + mkdir -p "$SOURCE_PACKAGES_DIR" + xcodebuild -project cmux.xcodeproj -scheme cmux-unit -configuration Debug \ + -derivedDataPath "$CMUX_DERIVED_DATA_PATH" \ + -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \ + -resolvePackageDependencies + + - name: Require the focused-terminal injection regression to fail for the intended reason + run: | + set -euo pipefail + SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" + set +e + python3 scripts/ci/xcodebuild_noninteractive.py \ + xcodebuild -project cmux.xcodeproj -scheme cmux-unit -configuration Debug \ + -derivedDataPath "$CMUX_DERIVED_DATA_PATH" \ + -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \ + -disableAutomaticPackageResolution \ + -destination "platform=macOS" \ + EXCLUDED_SOURCE_FILE_NAMES="BrowserPanelTests.swift BrowserPanelViewIdentityTests.swift" \ + -only-testing:cmuxTests/SocketTerminalBindingRegressionTests/camelCaseSurfaceAliasCannotInjectIntoFocusedTerminal \ + test 2>&1 | tee /tmp/rpc-red.log + status=${PIPESTATUS[0]} + set -e + test "$status" -ne 0 + grep -F "camelCase surfaceId silently injected input into the focused terminal" /tmp/rpc-red.log + + app-host-green: + runs-on: macos-15 + timeout-minutes: 45 + steps: + - name: Check out green candidate + uses: actions/checkout@v4 + with: + ref: ${{ env.GREEN_SHA }} + fetch-depth: 0 + submodules: recursive + + - name: Verify exact green ancestry + run: | + set -euo pipefail + test "$(git rev-parse HEAD^)" = "$RED_PARSER_SHA" + git merge-base --is-ancestor "$UPSTREAM_BASE_SHA" HEAD + + - name: Select Xcode + run: ./scripts/select-ci-xcode.sh + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + + - name: Download pre-built GhosttyKit.xcframework + run: ./scripts/download-prebuilt-ghosttykit.sh + + - name: Install Zig + run: ./scripts/install-zig-ci.sh + + - name: Install Rust + run: ./scripts/install-rust-ci.sh + + - name: Prepare DerivedData + run: | + DERIVED_DATA_PATH="${RUNNER_TEMP}/cmux-rpc-green-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + mkdir -p "$DERIVED_DATA_PATH" + echo "CMUX_DERIVED_DATA_PATH=$DERIVED_DATA_PATH" >> "$GITHUB_ENV" + + - name: Resolve Swift packages + run: | + SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" + mkdir -p "$SOURCE_PACKAGES_DIR" + xcodebuild -project cmux.xcodeproj -scheme cmux-unit -configuration Debug \ + -derivedDataPath "$CMUX_DERIVED_DATA_PATH" \ + -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \ + -resolvePackageDependencies + + - name: Require focused-terminal injection regression to pass + run: | + SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" + python3 scripts/ci/xcodebuild_noninteractive.py \ + xcodebuild -project cmux.xcodeproj -scheme cmux-unit -configuration Debug \ + -derivedDataPath "$CMUX_DERIVED_DATA_PATH" \ + -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \ + -disableAutomaticPackageResolution \ + -destination "platform=macOS" \ + EXCLUDED_SOURCE_FILE_NAMES="BrowserPanelTests.swift BrowserPanelViewIdentityTests.swift" \ + -only-testing:cmuxTests/SocketTerminalBindingRegressionTests/camelCaseSurfaceAliasCannotInjectIntoFocusedTerminal \ + test + + - name: Repository guards + run: | + set -euo pipefail + git diff --check "$UPSTREAM_BASE_SHA" HEAD + ./scripts/lint-pbxproj-test-wiring.sh + python3 scripts/check-package-resolved-policy.py + python3 scripts/check-workspace-package-groups.py --check diff --git a/.github/workflows/fork-candidate-ci.yml b/.github/workflows/fork-candidate-ci.yml new file mode 100644 index 000000000000..be7f21c006dd --- /dev/null +++ b/.github/workflows/fork-candidate-ci.yml @@ -0,0 +1,321 @@ +name: Fork candidate CI + +on: + push: + branches: [main] + paths: + - .github/workflows/fork-candidate-ci.yml + +permissions: + contents: write + +env: + UPSTREAM_BASE_SHA: 244adb38efdb8dea6fde624b079a0083738049d5 + BASE_BRANCH: fieldwork/upstream-main-244adb38 + CANDIDATE_BRANCH: fix/ssh-tmux-sendkeys-current-main + SNAPSHOT_BRANCH: fieldwork/sendkeys-snapshot-64055249 + SWIFT_BACKTRACE: interactive=no,timeout=0s,symbolicate=off,color=no + CMUX_SKIP_ZIG_BUILD: "1" + +jobs: + construct-candidate: + runs-on: ubuntu-latest + outputs: + red_sha: ${{ steps.construct.outputs.red_sha }} + green_sha: ${{ steps.construct.outputs.green_sha }} + steps: + - name: Checkout exact upstream base carrier + uses: actions/checkout@v4 + with: + ref: ${{ env.BASE_BRANCH }} + fetch-depth: 0 + + - name: Construct exact red and green commits + id: construct + shell: bash + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "$UPSTREAM_BASE_SHA" + git config user.name "teamleaderleo" + git config user.email "cheerleaderleo@outlook.com" + + python3 - <<'PY' + from pathlib import Path + + path = Path("cmuxTests/RemoteTmuxAuthTests.swift") + text = path.read_text() + test_anchor = " @Test @MainActor func pastePaneRejectsDisconnectedControlStream() {" + if "boundaryPasteIsSplitIntoControlSafeCommands" in text: + raise SystemExit("boundary regression already present on exact base") + if test_anchor not in text: + raise SystemExit("pastePane test anchor drifted") + regression = ''' @Test @MainActor func boundaryPasteIsSplitIntoControlSafeCommands() async throws { + let data = Data((0 ..< 9_995).map { UInt8($0 % 251) }) + let emission = try await captureSendKeysWire( + paneId: 7, + data: data, + maxPendingBytes: 1 << 20 + ) + + #expect(emission.accepted) + #expect(emission.commands.count >= 2) + #expect(emission.commands.allSatisfy { $0.utf8.count < 30_000 }) + #expect(try decodeHexArguments(from: emission.commands, paneId: 7) == data) + } + + ''' + text = text.replace(test_anchor, regression + test_anchor, 1) + + helper_anchor = " /// True when `a` is immediately followed by `b` in `args` — i.e. an ssh" + if helper_anchor not in text: + raise SystemExit("helper insertion anchor drifted") + helpers = ''' @MainActor + private func captureSendKeysWire( + paneId: Int, + data: Data, + maxPendingBytes: Int + ) async throws -> (accepted: Bool, commands: [String]) { + let connection = RemoteTmuxControlConnection( + host: RemoteTmuxHost(destination: "user@input-transport"), + sessionName: "input-transport" + ) + let bootstrapPipe = Pipe() + let bootstrapWriter = RemoteTmuxControlPipeWriter( + handle: bootstrapPipe.fileHandleForWriting, + label: "remote-tmux-send-keys-bootstrap-test", + maxPendingBytes: 1 << 16, + onFailure: {} + ) + let pipe = Pipe() + let writer = RemoteTmuxControlPipeWriter( + handle: pipe.fileHandleForWriting, + label: "remote-tmux-send-keys-wire-test", + maxPendingBytes: maxPendingBytes, + onFailure: {} + ) + defer { + bootstrapWriter.close() + try? bootstrapPipe.fileHandleForReading.close() + writer.close() + try? pipe.fileHandleForReading.close() + } + + connection.installStdinWriterForTesting(bootstrapWriter) + connection.handleMessageForTesting(.enter) + connection.handleMessageForTesting(.commandResult(commandNumber: 0, lines: [], isError: false)) + connection.installStdinWriterForTesting(writer) + + let accepted = connection.sendKeys(paneId: paneId, data: data) + writer.close() + var lineData = Data() + var commands: [String] = [] + for try await byte in pipe.fileHandleForReading.bytes { + guard byte == UInt8(ascii: "\\n") else { + lineData.append(byte) + continue + } + commands.append(String(decoding: lineData, as: UTF8.self)) + lineData.removeAll(keepingCapacity: true) + } + #expect(lineData.isEmpty) + return (accepted, commands) + } + + private func decodeHexArguments(from commands: [String], paneId: Int) throws -> Data { + let prefix = "send-keys -t %\\(paneId) -H " + var decoded = Data() + for command in commands { + #expect(command.hasPrefix(prefix)) + for argument in command.dropFirst(prefix.count).split(separator: " ") { + decoded.append(try #require(UInt8(argument, radix: 16))) + } + } + return decoded + } + + ''' + text = text.replace(helper_anchor, helpers + helper_anchor, 1) + path.write_text(text) + PY + + git add cmuxTests/RemoteTmuxAuthTests.swift + git commit -m "test: prove ssh-tmux paste command boundary" + RED_SHA=$(git rev-parse HEAD) + + git fetch origin "$SNAPSHOT_BRANCH:refs/remotes/origin/$SNAPSHOT_BRANCH" + git checkout "origin/$SNAPSHOT_BRANCH" -- \ + Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/RemoteTmux/RemoteTmuxSendKeysBatchBuilder.swift \ + Sources/RemoteTmuxControlConnection+Commands.swift \ + Sources/RemoteTmuxControlConnection.swift \ + Sources/RemoteTmuxPaneInputForwarder.swift + + python3 - <<'PY' + from pathlib import Path + + path = Path("cmuxTests/RemoteTmuxAuthTests.swift") + text = path.read_text() + old = ''' @Test func sendKeysHexArgumentsAreLowercaseSpaceSeparatedBytes() { + #expect(RemoteTmuxControlConnection.hexByteArguments(Data([0x00, 0x0f, 0x10, 0xff])) == "00 0f 10 ff") + #expect(RemoteTmuxControlConnection.hexByteArguments(Data()) == "") + } + + ''' + new = ''' @Test @MainActor func sendKeysRejectsOverBudgetLogicalInputWithoutPartialDelivery() async throws { + let data = Data((0 ..< 9_995).map { UInt8($0 % 251) }) + let writerBudget = 30_000 + + let emission = try await captureSendKeysWire( + paneId: 7, + data: data, + maxPendingBytes: writerBudget + ) + + #expect(!emission.accepted) + #expect(emission.commands.isEmpty) + } + + @Test @MainActor func sendKeysAcceptsRawAdmissionLimitWithProductionWriterBudget() async throws { + let rawAdmissionLimit = RemoteTmuxPaneInputForwarder.defaultMaximumPendingBytes + let data = Data((0 ..< rawAdmissionLimit).map { UInt8($0 % 251) }) + + let emission = try await captureSendKeysWire( + paneId: 7, + data: data, + maxPendingBytes: RemoteTmuxControlConnection.maxPendingStdinBytes + ) + + #expect(emission.accepted) + #expect(try decodeHexArguments(from: emission.commands, paneId: 7) == data) + } + + ''' + if old not in text: + raise SystemExit("hex helper test anchor drifted") + path.write_text(text.replace(old, new, 1)) + PY + + git add \ + Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/RemoteTmux/RemoteTmuxSendKeysBatchBuilder.swift \ + Sources/RemoteTmuxControlConnection+Commands.swift \ + Sources/RemoteTmuxControlConnection.swift \ + Sources/RemoteTmuxPaneInputForwarder.swift \ + cmuxTests/RemoteTmuxAuthTests.swift + git commit -m "fix: bound ssh-tmux send-key batches" + GREEN_SHA=$(git rev-parse HEAD) + git push --force origin "HEAD:$CANDIDATE_BRANCH" + + echo "red_sha=$RED_SHA" >> "$GITHUB_OUTPUT" + echo "green_sha=$GREEN_SHA" >> "$GITHUB_OUTPUT" + echo "RED_SHA=$RED_SHA" + echo "GREEN_SHA=$GREEN_SHA" + + verify-candidate: + needs: construct-candidate + runs-on: macos-15 + timeout-minutes: 45 + steps: + - name: Checkout exact green candidate + uses: actions/checkout@v4 + with: + ref: ${{ needs.construct-candidate.outputs.green_sha }} + fetch-depth: 0 + submodules: recursive + + - name: Verify candidate ancestry + env: + RED_SHA: ${{ needs.construct-candidate.outputs.red_sha }} + GREEN_SHA: ${{ needs.construct-candidate.outputs.green_sha }} + run: | + set -euo pipefail + test "$(git rev-parse "$RED_SHA^")" = "$UPSTREAM_BASE_SHA" + test "$(git rev-parse "$GREEN_SHA^")" = "$RED_SHA" + + - name: Select Xcode + run: ./scripts/select-ci-xcode.sh + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + + - name: Download pre-built GhosttyKit.xcframework + run: ./scripts/download-prebuilt-ghosttykit.sh + + - name: Install Zig + run: ./scripts/install-zig-ci.sh + + - name: Install Rust + run: ./scripts/install-rust-ci.sh + + - name: Prepare DerivedData + run: | + set -euo pipefail + DERIVED_DATA_PATH="${RUNNER_TEMP}/cmux-fork-candidate-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + mkdir -p "$DERIVED_DATA_PATH" + echo "CMUX_DERIVED_DATA_PATH=$DERIVED_DATA_PATH" >> "$GITHUB_ENV" + + - name: Resolve Swift packages + run: | + set -euo pipefail + SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" + mkdir -p "$SOURCE_PACKAGES_DIR" + xcodebuild -project cmux.xcodeproj -scheme cmux-unit -configuration Debug \ + -derivedDataPath "$CMUX_DERIVED_DATA_PATH" \ + -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \ + -resolvePackageDependencies + + - name: Prove regression commit is red + shell: bash + env: + RED_SHA: ${{ needs.construct-candidate.outputs.red_sha }} + run: | + set -euo pipefail + git checkout --detach "$RED_SHA" + SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" + set +e + python3 scripts/ci/xcodebuild_noninteractive.py \ + xcodebuild -project cmux.xcodeproj -scheme cmux-unit -configuration Debug \ + -derivedDataPath "$CMUX_DERIVED_DATA_PATH" \ + -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \ + -disableAutomaticPackageResolution \ + -destination "platform=macOS" \ + EXCLUDED_SOURCE_FILE_NAMES="BrowserPanelTests.swift BrowserPanelViewIdentityTests.swift" \ + -only-testing:cmuxTests/RemoteTmuxAuthTests/boundaryPasteIsSplitIntoControlSafeCommands \ + test + RED_STATUS=$? + set -e + if [ "$RED_STATUS" -eq 0 ]; then + echo "expected the test-only commit to fail" + exit 1 + fi + echo "observed expected red test exit: $RED_STATUS" + + - name: Prove fix commit is green + env: + GREEN_SHA: ${{ needs.construct-candidate.outputs.green_sha }} + run: | + set -euo pipefail + git checkout --detach "$GREEN_SHA" + SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" + python3 scripts/ci/xcodebuild_noninteractive.py \ + xcodebuild -project cmux.xcodeproj -scheme cmux-unit -configuration Debug \ + -derivedDataPath "$CMUX_DERIVED_DATA_PATH" \ + -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \ + -disableAutomaticPackageResolution \ + -destination "platform=macOS" \ + EXCLUDED_SOURCE_FILE_NAMES="BrowserPanelTests.swift BrowserPanelViewIdentityTests.swift" \ + -only-testing:cmuxTests/RemoteTmuxAuthTests/boundaryPasteIsSplitIntoControlSafeCommands \ + -only-testing:cmuxTests/RemoteTmuxAuthTests/sendKeysRejectsOverBudgetLogicalInputWithoutPartialDelivery \ + -only-testing:cmuxTests/RemoteTmuxAuthTests/sendKeysAcceptsRawAdmissionLimitWithProductionWriterBudget \ + test + + - name: Run package tests + run: swift test --package-path Packages/macOS/CmuxRemoteSession + + - name: Repository guards + env: + GREEN_SHA: ${{ needs.construct-candidate.outputs.green_sha }} + run: | + set -euo pipefail + git diff --check "$UPSTREAM_BASE_SHA" "$GREEN_SHA" + ./scripts/lint-pbxproj-test-wiring.sh + python3 scripts/check-package-resolved-policy.py + python3 scripts/check-workspace-package-groups.py --check diff --git a/.github/workflows/fork-candidate-current-main.yml b/.github/workflows/fork-candidate-current-main.yml new file mode 100644 index 000000000000..4b7a729a3f43 --- /dev/null +++ b/.github/workflows/fork-candidate-current-main.yml @@ -0,0 +1,125 @@ +name: Fork current-main candidate + +on: + push: + branches: [main] + paths: + - .github/workflows/fork-candidate-current-main.yml + +permissions: + contents: read + +env: + UPSTREAM_BASE_SHA: 6b425641ae4d474e77854da535442af2a0d0a475 + RED_SHA: 4719143f4a21ab2442397efe70643435c1f604f6 + GREEN_SHA: a732c5994bb2a698a7330bb0516210411d9ca298 + SWIFT_BACKTRACE: interactive=no,timeout=0s,symbolicate=off,color=no + CMUX_SKIP_ZIG_BUILD: "1" + +jobs: + verify-candidate: + runs-on: macos-14 + timeout-minutes: 45 + steps: + - name: Checkout exact green candidate + uses: actions/checkout@v4 + with: + ref: ${{ env.GREEN_SHA }} + fetch-depth: 0 + submodules: recursive + + - name: Verify candidate ancestry and diff + run: | + set -euo pipefail + test "$(git rev-parse "$RED_SHA^")" = "$UPSTREAM_BASE_SHA" + test "$(git rev-parse "$GREEN_SHA^")" = "$RED_SHA" + test "$(git diff --name-only "$UPSTREAM_BASE_SHA" "$RED_SHA" | wc -l | tr -d ' ')" = "1" + test "$(git diff --name-only "$UPSTREAM_BASE_SHA" "$RED_SHA")" = "cmuxTests/RemoteTmuxAuthTests.swift" + + - name: Select Xcode + run: ./scripts/select-ci-xcode.sh + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + + - name: Download pre-built GhosttyKit.xcframework + run: ./scripts/download-prebuilt-ghosttykit.sh + + - name: Install Zig + run: ./scripts/install-zig-ci.sh + + - name: Install Rust + run: ./scripts/install-rust-ci.sh + + - name: Prepare DerivedData + run: | + set -euo pipefail + DERIVED_DATA_PATH="${RUNNER_TEMP}/cmux-current-candidate-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + mkdir -p "$DERIVED_DATA_PATH" + echo "CMUX_DERIVED_DATA_PATH=$DERIVED_DATA_PATH" >> "$GITHUB_ENV" + + - name: Resolve Swift packages + run: | + set -euo pipefail + SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" + mkdir -p "$SOURCE_PACKAGES_DIR" + xcodebuild -project cmux.xcodeproj -scheme cmux-unit -configuration Debug \ + -derivedDataPath "$CMUX_DERIVED_DATA_PATH" \ + -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \ + -resolvePackageDependencies + + - name: Prove regression commit is red at the assertion + shell: bash + run: | + set -euo pipefail + git checkout --detach "$RED_SHA" + SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" + set +e + python3 scripts/ci/xcodebuild_noninteractive.py \ + xcodebuild -project cmux.xcodeproj -scheme cmux-unit -configuration Debug \ + -derivedDataPath "$CMUX_DERIVED_DATA_PATH" \ + -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \ + -disableAutomaticPackageResolution \ + -destination "platform=macOS" \ + EXCLUDED_SOURCE_FILE_NAMES="BrowserPanelTests.swift BrowserPanelViewIdentityTests.swift" \ + -only-testing:cmuxTests/RemoteTmuxAuthTests/sendKeysChunksBoundaryPasteIntoControlSafeCommands \ + test 2>&1 | tee /tmp/red.log + RED_STATUS=${PIPESTATUS[0]} + set -e + if [ "$RED_STATUS" -eq 0 ]; then + echo "expected the test-only commit to fail" + exit 1 + fi + grep -F "sendKeysChunksBoundaryPasteIntoControlSafeCommands" /tmp/red.log + grep -F "Expectation failed" /tmp/red.log + + - name: Prove fix commit is green + run: | + set -euo pipefail + git checkout --detach "$GREEN_SHA" + SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" + python3 scripts/ci/xcodebuild_noninteractive.py \ + xcodebuild -project cmux.xcodeproj -scheme cmux-unit -configuration Debug \ + -derivedDataPath "$CMUX_DERIVED_DATA_PATH" \ + -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \ + -disableAutomaticPackageResolution \ + -destination "platform=macOS" \ + EXCLUDED_SOURCE_FILE_NAMES="BrowserPanelTests.swift BrowserPanelViewIdentityTests.swift" \ + -only-testing:cmuxTests/RemoteTmuxAuthTests/boundaryPasteIsSplitIntoControlSafeCommands \ + -only-testing:cmuxTests/RemoteTmuxAuthTests/sendKeysRejectsOverBudgetLogicalInputWithoutPartialDelivery \ + -only-testing:cmuxTests/RemoteTmuxAuthTests/sendKeysAcceptsRawAdmissionLimitWithProductionWriterBudget \ + test + + - name: Run package framing tests + run: swift test --package-path Packages/macOS/CmuxRemoteSession --filter RemoteTmuxSendKeysBatchBuilderTests + + - name: Run package tests + run: swift test --package-path Packages/macOS/CmuxRemoteSession + + - name: Repository guards + run: | + set -euo pipefail + git diff --check "$UPSTREAM_BASE_SHA" "$GREEN_SHA" + ./scripts/lint-pbxproj-test-wiring.sh + python3 scripts/check-package-resolved-policy.py + python3 scripts/check-workspace-package-groups.py --check diff --git a/.github/workflows/fork-candidate-review-repair.yml b/.github/workflows/fork-candidate-review-repair.yml new file mode 100644 index 000000000000..61cea17da695 --- /dev/null +++ b/.github/workflows/fork-candidate-review-repair.yml @@ -0,0 +1,115 @@ +name: Repair ssh-tmux candidate ownership + +on: + push: + branches: [main] + paths: + - .github/workflows/fork-candidate-review-repair.yml + +permissions: + contents: write + +env: + RED_SHA: 4719143f4a21ab2442397efe70643435c1f604f6 + PRIOR_GREEN_SHA: a732c5994bb2a698a7330bb0516210411d9ca298 + CANDIDATE_BRANCH: fix/ssh-tmux-sendkeys-current-main + +jobs: + repair: + runs-on: ubuntu-latest + steps: + - name: Checkout red parent + uses: actions/checkout@v4 + with: + ref: ${{ env.RED_SHA }} + fetch-depth: 0 + + - name: Apply paste slice and ownership repair + shell: bash + run: | + set -euo pipefail + git config user.name "teamleaderleo" + git config user.email "cheerleaderleo@outlook.com" + git checkout "$PRIOR_GREEN_SHA" -- \ + Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/RemoteTmux/RemoteTmuxSendKeysBatchBuilder.swift \ + Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/RemoteTmuxSendKeysBatchBuilderTests.swift \ + Sources/RemoteTmuxControlConnection+Commands.swift \ + Sources/RemoteTmuxControlConnection.swift \ + Sources/RemoteTmuxPaneInputForwarder.swift \ + cmuxTests/RemoteTmuxAuthTests.swift + + python3 - <<'PY' + from pathlib import Path + + builder = Path("Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/RemoteTmux/RemoteTmuxSendKeysBatchBuilder.swift") + text = builder.read_text() + text = text.replace( + "public enum RemoteTmuxSendKeysBatchBuilder {\n", + "public struct RemoteTmuxSendKeysBatchBuilder: Sendable {\n public init() {}\n\n", + 1, + ) + text = text.replace("public static func commands(", "public func commands(", 1) + text = text.replace("private static func hexByteArguments(", "private func hexByteArguments(", 1) + if "public enum RemoteTmuxSendKeysBatchBuilder" in text or "public static func commands(" in text: + raise SystemExit("builder ownership transform failed") + builder.write_text(text) + + connection = Path("Sources/RemoteTmuxControlConnection.swift") + text = connection.read_text() + owner_anchor = " private let createIfMissing: Bool\n\n" + if owner_anchor not in text: + raise SystemExit("connection owner anchor drifted") + text = text.replace( + owner_anchor, + " private let createIfMissing: Bool\n let sendKeysBatchBuilder: RemoteTmuxSendKeysBatchBuilder\n\n", + 1, + ) + init_anchor = " pendingPaneSeedByteLimit: Int = RemoteTmuxControlConnection.maximumPendingPaneSeedBytes\n ) {" + if init_anchor not in text: + raise SystemExit("connection init anchor drifted") + text = text.replace( + init_anchor, + " pendingPaneSeedByteLimit: Int = RemoteTmuxControlConnection.maximumPendingPaneSeedBytes,\n sendKeysBatchBuilder: RemoteTmuxSendKeysBatchBuilder = RemoteTmuxSendKeysBatchBuilder()\n ) {", + 1, + ) + assign_anchor = " self.createIfMissing = createIfMissing\n self.pendingPaneSeedByteLimit = max(0, pendingPaneSeedByteLimit)" + if assign_anchor not in text: + raise SystemExit("connection assignment anchor drifted") + text = text.replace( + assign_anchor, + " self.createIfMissing = createIfMissing\n self.pendingPaneSeedByteLimit = max(0, pendingPaneSeedByteLimit)\n self.sendKeysBatchBuilder = sendKeysBatchBuilder", + 1, + ) + connection.write_text(text) + + commands = Path("Sources/RemoteTmuxControlConnection+Commands.swift") + text = commands.read_text() + call = "RemoteTmuxSendKeysBatchBuilder.commands(" + if text.count(call) != 1: + raise SystemExit(f"expected one static builder call, found {text.count(call)}") + commands.write_text(text.replace(call, "sendKeysBatchBuilder.commands(", 1)) + + tests = Path("Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/RemoteTmuxSendKeysBatchBuilderTests.swift") + text = tests.read_text() + suite_anchor = "@Suite struct RemoteTmuxSendKeysBatchBuilderTests {\n" + if suite_anchor not in text: + raise SystemExit("package test suite anchor drifted") + text = text.replace( + suite_anchor, + suite_anchor + " private let builder = RemoteTmuxSendKeysBatchBuilder()\n\n", + 1, + ) + text = text.replace("RemoteTmuxSendKeysBatchBuilder.commands(", "builder.commands(") + tests.write_text(text) + PY + + git diff --check + git add \ + Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/RemoteTmux/RemoteTmuxSendKeysBatchBuilder.swift \ + Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/RemoteTmuxSendKeysBatchBuilderTests.swift \ + Sources/RemoteTmuxControlConnection+Commands.swift \ + Sources/RemoteTmuxControlConnection.swift \ + Sources/RemoteTmuxPaneInputForwarder.swift \ + cmuxTests/RemoteTmuxAuthTests.swift + git commit -m "fix: bound ssh-tmux send-key batches" + git push --force origin "HEAD:$CANDIDATE_BRANCH" diff --git a/.github/workflows/fork-ci-policy.yml b/.github/workflows/fork-ci-policy.yml new file mode 100644 index 000000000000..32f00233990f --- /dev/null +++ b/.github/workflows/fork-ci-policy.yml @@ -0,0 +1,74 @@ +name: Fork CI policy + +on: + pull_request: + paths: + - ".github/workflows/**" + - "docs/ci-runners.md" + - "tests/test_ci_self_hosted_guard.sh" + - "tests/test_tui_publish_workflow_security.py" + push: + branches: + - main + paths: + - ".github/workflows/**" + - "docs/ci-runners.md" + - "tests/test_ci_self_hosted_guard.sh" + - "tests/test_tui_publish_workflow_security.py" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: fork-ci-policy-${{ github.ref }} + cancel-in-progress: true + +jobs: + policy: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Reject unavailable upstream Linux runner providers + run: | + python3 - <<'PY' + from pathlib import Path + import re + + bad = re.compile(r'(?:blacksmith-[0-9]+vcpu-ubuntu-|warp-ubuntu-)') + offenders = [] + for path in sorted(Path('.github/workflows').glob('*.y*ml')): + if path.name == 'fork-ci-policy.yml': + continue + for number, line in enumerate(path.read_text().splitlines(), 1): + if bad.search(line): + offenders.append(f'{path}:{number}: {line.strip()}') + if offenders: + raise SystemExit( + 'Unavailable upstream Linux provider labels are forbidden in this fork:\n' + + '\n'.join(offenders) + ) + PY + + - name: Keep upstream publishing automation opt-in + run: | + python3 - <<'PY' + from pathlib import Path + + for filename in ('nightly.yml', 'ios-testflight.yml'): + path = Path('.github/workflows') / filename + text = path.read_text() + start = text.index('on:\n') + end = text.index('\nconcurrency:\n', start) + block = text[start:end] + assert ' workflow_dispatch:' in block, f'{filename} must remain manually runnable' + assert ' schedule:' not in block, f'{filename} must not schedule itself in this fork' + assert ' push:' not in block, f'{filename} must not auto-run on fork pushes' + PY + + - name: Run runner safety contract + run: bash tests/test_ci_self_hosted_guard.sh diff --git a/.github/workflows/indexnow.yml b/.github/workflows/indexnow.yml index e23e7be6447a..c61667d6831e 100644 --- a/.github/workflows/indexnow.yml +++ b/.github/workflows/indexnow.yml @@ -18,7 +18,7 @@ jobs: # Required to start the secret-bearing workflow_dispatch run. actions: write contents: read - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} timeout-minutes: 2 steps: - name: Dispatch authenticated submission @@ -33,7 +33,7 @@ jobs: notify: name: Notify IndexNow if: github.event_name == 'workflow_dispatch' - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} timeout-minutes: 2 steps: - name: Submit changed URLs diff --git a/.github/workflows/ios-testflight.yml b/.github/workflows/ios-testflight.yml index cc034e4b4554..c1efeb832578 100644 --- a/.github/workflows/ios-testflight.yml +++ b/.github/workflows/ios-testflight.yml @@ -1,14 +1,6 @@ name: iOS TestFlight (CMUX INTERNAL) on: - # Poll main every 20 minutes and batch merges into one upload. The decide job - # skips scheduled runs when main is unchanged or the changes do not affect iOS. - # Manual dispatch remains available for intentional rebuilds. - schedule: - # Check current main for a cmux INTERNAL upload every 20 minutes. - - cron: "7,27,47 * * * *" - # Twice-daily (every 12 hours) cmux DEMO upload of current main. - - cron: "37 5,17 * * *" workflow_dispatch: inputs: build_number: @@ -46,7 +38,7 @@ permissions: jobs: decide: name: Order main uploads and resolve prior build - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} timeout-minutes: 360 outputs: should_build: ${{ steps.decide.outputs.should_build }} @@ -658,7 +650,7 @@ jobs: name: Assign build to internal TestFlight group needs: [decide, upload] if: github.ref == 'refs/heads/main' && needs.upload.result == 'success' && needs.upload.outputs.assign_internal_group == '1' - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} timeout-minutes: 40 env: ASC_API_KEY_ID: ${{ secrets.ASC_API_KEY_ID }} diff --git a/.github/workflows/iroh-relay-minter.yml b/.github/workflows/iroh-relay-minter.yml index 017f95c7d59d..45934430887e 100644 --- a/.github/workflows/iroh-relay-minter.yml +++ b/.github/workflows/iroh-relay-minter.yml @@ -13,7 +13,7 @@ permissions: jobs: test: - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} timeout-minutes: 40 defaults: run: diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 97da4fc1e5ce..b26309ef2030 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -1,13 +1,6 @@ name: Nightly macOS build on: - # Publish every changed main revision, while keeping the scheduled cache warm. - push: - branches: - - main - schedule: - - cron: "17 */6 * * *" - - cron: "47 8 * * *" workflow_dispatch: inputs: force: @@ -31,7 +24,7 @@ env: jobs: decide: - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} outputs: should_build: ${{ steps.decide.outputs.should_build }} head_sha: ${{ steps.decide.outputs.head_sha }} diff --git a/.github/workflows/perf-activation.yml b/.github/workflows/perf-activation.yml index e6f4eb0a9a53..33d4e197e9a0 100644 --- a/.github/workflows/perf-activation.yml +++ b/.github/workflows/perf-activation.yml @@ -44,7 +44,7 @@ concurrency: jobs: activation_changes: - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} timeout-minutes: 5 outputs: macos: ${{ steps.detect.outputs.macos }} @@ -356,7 +356,7 @@ jobs: - activation_changes - activation-session-benchmark if: ${{ always() }} - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} timeout-minutes: 5 steps: - name: Check activation benchmark routing diff --git a/.github/workflows/presence.yml b/.github/workflows/presence.yml index b863ee2124fa..27d0c959e70f 100644 --- a/.github/workflows/presence.yml +++ b/.github/workflows/presence.yml @@ -37,7 +37,7 @@ permissions: jobs: test: - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} defaults: run: working-directory: workers/presence @@ -74,7 +74,7 @@ jobs: deploy: if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' needs: test - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} concurrency: group: presence-deploy cancel-in-progress: false diff --git a/.github/workflows/sdk-bootstrap-crates.yml b/.github/workflows/sdk-bootstrap-crates.yml index 19faed33ef51..caa3557c990a 100644 --- a/.github/workflows/sdk-bootstrap-crates.yml +++ b/.github/workflows/sdk-bootstrap-crates.yml @@ -16,7 +16,7 @@ env: jobs: build: - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} timeout-minutes: 15 permissions: contents: read @@ -123,7 +123,7 @@ jobs: - package: cmux-sidebar artifact: cmux-sidebar-bootstrap-crate decision: cmux-sidebar-bootstrap-decision - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} timeout-minutes: 10 permissions: contents: read @@ -229,7 +229,7 @@ jobs: decisions: needs: - preflight - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} timeout-minutes: 5 permissions: actions: read @@ -275,7 +275,7 @@ jobs: - preflight - decisions if: needs.decisions.outputs.sdk_need_publish == 'true' - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} timeout-minutes: 10 permissions: {} environment: @@ -425,7 +425,7 @@ jobs: needs.preflight.result == 'success' && needs.decisions.result == 'success' && needs.decisions.outputs.sidebar_need_publish == 'true' - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} timeout-minutes: 10 permissions: {} environment: @@ -583,7 +583,7 @@ jobs: artifact: cmux-sdk-bootstrap-crate - package: cmux-sidebar artifact: cmux-sidebar-bootstrap-crate - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} timeout-minutes: 10 permissions: contents: read diff --git a/.github/workflows/sdk-bootstrap-npm.yml b/.github/workflows/sdk-bootstrap-npm.yml index 158a6426166c..4196344d45ee 100644 --- a/.github/workflows/sdk-bootstrap-npm.yml +++ b/.github/workflows/sdk-bootstrap-npm.yml @@ -15,7 +15,7 @@ env: jobs: build: - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} timeout-minutes: 15 permissions: contents: read @@ -91,7 +91,7 @@ jobs: preflight: needs: build - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} timeout-minutes: 10 permissions: contents: read @@ -275,7 +275,7 @@ jobs: needs.build.result == 'success' && needs.preflight.result == 'success' && (needs.publish.result == 'success' || needs.publish.result == 'skipped') - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} timeout-minutes: 10 permissions: contents: read diff --git a/.github/workflows/sdk-bootstrap-pypi.yml b/.github/workflows/sdk-bootstrap-pypi.yml index 645832c2adbf..47519f41f332 100644 --- a/.github/workflows/sdk-bootstrap-pypi.yml +++ b/.github/workflows/sdk-bootstrap-pypi.yml @@ -15,7 +15,7 @@ concurrency: jobs: build: - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} permissions: contents: read outputs: @@ -135,7 +135,7 @@ jobs: preflight: needs: build - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} permissions: contents: read outputs: @@ -276,7 +276,7 @@ jobs: - build - preflight if: needs.preflight.outputs.need_publish == 'true' - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} permissions: id-token: write environment: @@ -350,7 +350,7 @@ jobs: needs.build.result == 'success' && needs.preflight.result == 'success' && (needs.publish.result == 'success' || needs.publish.result == 'skipped') - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} permissions: contents: read steps: diff --git a/.github/workflows/sdk-publish-crates.yml b/.github/workflows/sdk-publish-crates.yml index 6f5452dfb6b5..57a1c89afac3 100644 --- a/.github/workflows/sdk-publish-crates.yml +++ b/.github/workflows/sdk-publish-crates.yml @@ -25,7 +25,7 @@ concurrency: jobs: version: - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} permissions: contents: read outputs: @@ -89,7 +89,7 @@ jobs: bindings-e2e-rust: needs: version - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} timeout-minutes: 40 permissions: contents: read diff --git a/.github/workflows/sdk-publish-go.yml b/.github/workflows/sdk-publish-go.yml index 79e84a8f34ac..33e421c89a70 100644 --- a/.github/workflows/sdk-publish-go.yml +++ b/.github/workflows/sdk-publish-go.yml @@ -32,7 +32,7 @@ concurrency: jobs: version: - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} permissions: contents: read outputs: @@ -129,7 +129,7 @@ jobs: bindings-e2e-go: if: inputs.verify_tag != true needs: version - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} timeout-minutes: 40 permissions: contents: read @@ -179,7 +179,7 @@ jobs: validate-go-module: if: inputs.verify_tag != true needs: bindings-e2e-go - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} permissions: contents: read steps: @@ -202,7 +202,7 @@ jobs: verify-versioned-go-module: if: inputs.verify_tag == true needs: version - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} timeout-minutes: 35 permissions: contents: read diff --git a/.github/workflows/sdk-publish-java.yml b/.github/workflows/sdk-publish-java.yml index 144a319dfb30..9f31eaf202a8 100644 --- a/.github/workflows/sdk-publish-java.yml +++ b/.github/workflows/sdk-publish-java.yml @@ -16,7 +16,7 @@ concurrency: jobs: version: - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} permissions: contents: read outputs: @@ -62,7 +62,7 @@ jobs: bindings-e2e-java: needs: version - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} timeout-minutes: 40 permissions: contents: read @@ -114,7 +114,7 @@ jobs: maven-central-todo: needs: bindings-e2e-java - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} permissions: contents: read steps: diff --git a/.github/workflows/sdk-publish-npm.yml b/.github/workflows/sdk-publish-npm.yml index a3f5c6d536ed..cbe1c37c58c7 100644 --- a/.github/workflows/sdk-publish-npm.yml +++ b/.github/workflows/sdk-publish-npm.yml @@ -32,7 +32,7 @@ concurrency: jobs: version: - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} permissions: contents: read outputs: @@ -95,7 +95,7 @@ jobs: bindings-e2e-typescript: needs: version - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} timeout-minutes: 40 permissions: contents: read diff --git a/.github/workflows/sdk-publish-python.yml b/.github/workflows/sdk-publish-python.yml index a2aaf84c47e0..7251dc770f2b 100644 --- a/.github/workflows/sdk-publish-python.yml +++ b/.github/workflows/sdk-publish-python.yml @@ -32,7 +32,7 @@ concurrency: jobs: version: - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} permissions: contents: read outputs: @@ -95,7 +95,7 @@ jobs: bindings-e2e-python: needs: version - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} timeout-minutes: 40 permissions: contents: read @@ -151,7 +151,7 @@ jobs: build: needs: bindings-e2e-python - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} permissions: contents: read outputs: diff --git a/.github/workflows/sdk-release-cut.yml b/.github/workflows/sdk-release-cut.yml index 33dc2d10e017..a972f88ea79b 100644 --- a/.github/workflows/sdk-release-cut.yml +++ b/.github/workflows/sdk-release-cut.yml @@ -15,7 +15,7 @@ concurrency: jobs: validate-release: - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} permissions: contents: read outputs: @@ -154,7 +154,7 @@ jobs: - rust-preflight - typescript-preflight - python-preflight - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} permissions: actions: read contents: read @@ -356,7 +356,7 @@ jobs: - typescript-preflight - python-preflight - registry-preflight - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} permissions: actions: read contents: read @@ -516,7 +516,7 @@ jobs: needs: - validate-release - revalidate-tags - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} timeout-minutes: 10 permissions: {} environment: @@ -855,7 +855,7 @@ jobs: actions: read contents: read id-token: write - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} timeout-minutes: 30 environment: name: crates-io @@ -953,7 +953,7 @@ jobs: actions: read contents: read id-token: write - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} timeout-minutes: 30 environment: name: crates-io @@ -1190,7 +1190,7 @@ jobs: id-token: write # PyPI trusted publishers cannot authenticate a reusable workflow, so the # OIDC publish job stays directly in this top-level release workflow. - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} timeout-minutes: 30 environment: name: pypi @@ -1313,7 +1313,7 @@ jobs: id-token: write # PyPI trusted publishers cannot authenticate a reusable workflow, so the # OIDC publish job stays directly in this top-level release workflow. - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} timeout-minutes: 30 environment: name: pypi @@ -1547,7 +1547,7 @@ jobs: - publish-python-wheel - publish-python-sdist - verify-stable-provenance - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} permissions: {} steps: - name: Summarize release diff --git a/.github/workflows/test-ios.yml b/.github/workflows/test-ios.yml index 6a673e57d4cc..ee5131a906c3 100644 --- a/.github/workflows/test-ios.yml +++ b/.github/workflows/test-ios.yml @@ -36,7 +36,7 @@ permissions: jobs: detect-ios-changes: - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} timeout-minutes: 5 outputs: should_run: ${{ steps.detect.outputs.should_run }} @@ -82,7 +82,7 @@ jobs: package-conventions-lint: needs: detect-ios-changes if: ${{ needs.detect-ios-changes.outputs.should_lint == 'true' }} - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} timeout-minutes: 5 steps: - name: Checkout @@ -434,7 +434,7 @@ jobs: - mobile-core-package - ios-simulator if: ${{ always() }} - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} timeout-minutes: 5 steps: - name: Check iOS test routing diff --git a/.github/workflows/tmux-corpus.yml b/.github/workflows/tmux-corpus.yml index e38498dd89ec..c3d6b0e29270 100644 --- a/.github/workflows/tmux-corpus.yml +++ b/.github/workflows/tmux-corpus.yml @@ -19,7 +19,7 @@ concurrency: jobs: remote-daemon-fuzz: - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} timeout-minutes: 30 steps: - name: Checkout diff --git a/.github/workflows/tui-publish-npm.yml b/.github/workflows/tui-publish-npm.yml index e628512860db..3f78f4aac1b2 100644 --- a/.github/workflows/tui-publish-npm.yml +++ b/.github/workflows/tui-publish-npm.yml @@ -29,7 +29,7 @@ jobs: # becomes npm `latest`. Only strict stable X.Y.Z may go through here; a # nightly-form version on latest would put a nightly in front of every # `npx cmux` user (nightlies publish via cmux-tui-nightly.yml with --tag nightly). - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} timeout-minutes: 5 permissions: actions: read diff --git a/.github/workflows/tui-publish-pypi.yml b/.github/workflows/tui-publish-pypi.yml index 30d2e955fe5a..5b4f68662c03 100644 --- a/.github/workflows/tui-publish-pypi.yml +++ b/.github/workflows/tui-publish-pypi.yml @@ -21,7 +21,7 @@ concurrency: jobs: validate-version: name: validate release source - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} permissions: actions: read contents: read @@ -128,7 +128,7 @@ jobs: publish: needs: validate-version - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} permissions: actions: read contents: read diff --git a/.github/workflows/update-homebrew.yml b/.github/workflows/update-homebrew.yml index 1df157042cbe..935ad5c2a1a4 100644 --- a/.github/workflows/update-homebrew.yml +++ b/.github/workflows/update-homebrew.yml @@ -17,7 +17,7 @@ permissions: jobs: update-cask: - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} # Only run if the release workflow succeeded (or manual trigger) if: >- github.event_name == 'workflow_dispatch' || diff --git a/.github/workflows/vercel-auth-health.yml b/.github/workflows/vercel-auth-health.yml index 248d07c7b0b8..fd8ad73d927f 100644 --- a/.github/workflows/vercel-auth-health.yml +++ b/.github/workflows/vercel-auth-health.yml @@ -10,7 +10,7 @@ permissions: jobs: check: - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }} timeout-minutes: 5 steps: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 diff --git a/.gitmodules b/.gitmodules index 51853e856536..5d0fe929db5b 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,7 +1,7 @@ [submodule "ghostty"] path = ghostty - url = https://github.com/manaflow-ai/ghostty.git - branch = main + url = ../ghostty.git + branch = cmux-terminal-kit [submodule "homebrew-cmux"] path = homebrew-cmux url = https://github.com/manaflow-ai/homebrew-cmux.git diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Server/SocketControlServer+Configuration.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Server/SocketControlServer+Configuration.swift index d290e75dee7a..417e5ddbfced 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Server/SocketControlServer+Configuration.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Server/SocketControlServer+Configuration.swift @@ -18,11 +18,19 @@ extension SocketControlServer { } } + /// Avoids accepting configuration drift or chmod'ing a replacement inode. + private func ownsConfiguredSocketPath() -> Bool { + let snapshot = listenerStateSnapshot() + return transport.pathExists(snapshot.socketPath, matching: snapshot.boundSocketPathIdentity) + } + /// Replaces the live access policy used by subsequent client decisions. /// /// The policy is published through the server's synchronous state snapshot, /// so connection workers observe the new mode without a listener restart. - /// File permissions are reapplied for an active listener. Configuring + /// File permissions are reapplied only while the listener still owns its + /// bound path. Lost ownership stops the stale listener and returns `false` + /// so the host can rebind through the normal startup policy. Configuring /// ``SocketControlMode/off`` stops the listener instead of leaving an open /// socket whose command checks could accidentally interpret `off` as a /// permissive non-`cmuxOnly` mode. @@ -42,7 +50,7 @@ extension SocketControlServer { if accessMode == .off { stop() - } else if isRunning, !applySocketPermissions() { + } else if isRunning, !ownsConfiguredSocketPath() || !applySocketPermissions() { stop() events.breadcrumb( "socket.listener.configuration.failed_closed", diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Server/SocketControlServer+PathMonitor.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Server/SocketControlServer+PathMonitor.swift index eeea39359020..c6b5d541cb1c 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Server/SocketControlServer+PathMonitor.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Server/SocketControlServer+PathMonitor.swift @@ -61,6 +61,13 @@ extension SocketControlServer { previousSource?.cancel() source.resume() + + // Directory events only cover changes after registration. Check once + // after arming so an unlink/replacement between bind and watch setup + // takes the same generation-validated recovery path as a later event. + socketListenerQueue.async { [weak self] in + self?.handleSocketPathDirectoryEvent(path: path, generation: generation) + } } private nonisolated func handleSocketPathDirectoryEvent(path: String, generation: UInt64) { diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/SocketControlServerTests.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/SocketControlServerTests.swift index 1e9e7fb18165..7a10a8924302 100644 --- a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/SocketControlServerTests.swift +++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/SocketControlServerTests.swift @@ -35,7 +35,11 @@ private final class ServerEventRecorder: Sendable { state.withLock { $0.rearms } } - func makeEvents() -> SocketControlServerEvents { + let missingEvents = AsyncStream<(path: String, generation: UInt64)>.makeStream() + + func makeEvents( + onStart: @escaping @MainActor @Sendable (String) -> Void = { _ in } + ) -> SocketControlServerEvents { SocketControlServerEvents( breadcrumb: { message, _ in self.state.withLock { $0.breadcrumbs.append(message) } @@ -47,6 +51,7 @@ private final class ServerEventRecorder: Sendable { }, listenerDidStart: { path, generation in self.state.withLock { $0.started.append((path: path, generation: generation)) } + onStart(path) }, recordLastSocketPath: { path in self.state.withLock { $0.recordedPaths.append(path) } @@ -67,6 +72,7 @@ private final class ServerEventRecorder: Sendable { }, pathMissingDetected: { path, generation in self.state.withLock { $0.pathMissing.append((path: path, generation: generation)) } + self.missingEvents.continuation.yield((path, generation)) }, rearmRequested: { generation, errnoCode, consecutiveFailures, delayMs in self.state.withLock { @@ -89,7 +95,7 @@ private struct ServerHarness: ~Copyable { let recorder: ServerEventRecorder let server: SocketControlServer - init() throws { + init(onStart: @escaping @MainActor @Sendable (String) -> Void = { _ in }) throws { directory = URL(fileURLWithPath: NSTemporaryDirectory()) .appendingPathComponent("scs-\(UUID().uuidString.prefix(8))", isDirectory: true) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) @@ -98,7 +104,7 @@ private struct ServerHarness: ~Copyable { server = SocketControlServer( initialSocketPath: socketPath, notificationCenter: NotificationCenter(), - events: recorder.makeEvents() + events: recorder.makeEvents(onStart: onStart) ) } @@ -452,6 +458,105 @@ struct SocketControlServerReservationTests { @MainActor @Suite("SocketControlServer path monitor") struct SocketControlServerPathMonitorTests { + @Test func detectsUnlinkBeforeMonitorRegistration() async throws { + let harness = try ServerHarness(onStart: { path in + #expect(unlink(path) == 0) + }) + defer { harness.shutdown() } + #expect(harness.server.start(socketPath: harness.socketPath, accessMode: .cmuxOnly)) + let generation = try #require(harness.recorder.started.last?.generation) + let event = await Self.nextMissingEvent(harness.recorder, generation: generation) + #expect(event?.path == harness.socketPath) + #expect(harness.server.shouldRestartForMissingPath(path: harness.socketPath, generation: generation)) + } + + @Test func repeatedRecoveryRejectsCallbacksFromOlderGenerations() async throws { + let harness = try ServerHarness() + defer { harness.shutdown() } + let server = harness.server + var retiredGenerations: [UInt64] = [] + #expect(server.start(socketPath: harness.socketPath, accessMode: .cmuxOnly)) + for _ in 0..<3 { + let generation = try #require(harness.recorder.started.last?.generation) + #expect(unlink(harness.socketPath) == 0) + _ = try #require(await Self.nextMissingEvent(harness.recorder, generation: generation)) + for retired in retiredGenerations { + #expect(!server.shouldRestartForMissingPath(path: harness.socketPath, generation: retired)) + } + #expect(server.shouldRestartForMissingPath(path: harness.socketPath, generation: generation)) + server.stop() + retiredGenerations.append(generation) + #expect(server.start(socketPath: harness.socketPath, accessMode: .cmuxOnly)) + let fd = connect(to: harness.socketPath) + #expect(fd >= 0) + if fd >= 0 { close(fd) } + } + } + + @Test(arguments: [false, true]) + func replacementInodeFailsReconfigurationAndIsPreserved(isSocket: Bool) async throws { + let harness = try ServerHarness() + defer { harness.shutdown() } + let server = harness.server + #expect(server.start(socketPath: harness.socketPath, accessMode: .cmuxOnly)) + let generation = try #require(harness.recorder.started.last?.generation) + let original = try #require(server.transport.pathIdentity(at: harness.socketPath)) + // Rename keeps the original inode alive, preventing inode reuse in the fixture. + let movedPath = harness.directory.appendingPathComponent("original.sock").path + #expect(rename(harness.socketPath, movedPath) == 0) + let replacementFD: Int32 + if isSocket { + let replacementPath = harness.directory.appendingPathComponent("replacement.sock").path + replacementFD = try UnixSocketFixture.bindListeningSocket(at: replacementPath) + #expect(rename(replacementPath, harness.socketPath) == 0) + } else { + replacementFD = -1 + try Data("replacement".utf8).write(to: URL(fileURLWithPath: harness.socketPath)) + } + defer { if replacementFD >= 0 { close(replacementFD) } } + let replacement = server.transport.pathIdentity(at: harness.socketPath) + _ = try #require(await Self.nextMissingEvent(harness.recorder, generation: generation)) + #expect(server.transport.pathIdentity(at: harness.socketPath) != original) + #expect(server.shouldRestartForMissingPath(path: harness.socketPath, generation: generation)) + #expect(chmod(harness.socketPath, 0o640) == 0) + #expect(!server.reconfigure(accessMode: .allowAll)) + #expect(!server.isRunning) + #expect(server.transport.pathIdentity(at: harness.socketPath) == replacement) + var replacementStat = stat() + #expect(lstat(harness.socketPath, &replacementStat) == 0) + #expect(replacementStat.st_mode & 0o777 == 0o640) + if !isSocket { + #expect(try String(contentsOfFile: harness.socketPath, encoding: .utf8) == "replacement") + } + server.stop() + #expect(unlink(harness.socketPath) == 0) + #expect(server.start(socketPath: harness.socketPath, accessMode: .cmuxOnly)) + #expect(!server.shouldRestartForMissingPath(path: harness.socketPath, generation: generation)) + let fd = connect(to: harness.socketPath) + #expect(fd >= 0) + if fd >= 0 { close(fd) } + } + + private static func nextMissingEvent( + _ recorder: ServerEventRecorder, generation: UInt64 + ) async -> (path: String, generation: UInt64)? { + await withTaskGroup(of: (String, UInt64)?.self) { group in + group.addTask { + for await event in recorder.missingEvents.stream { + if event.generation == generation { return event } + } + return nil + } + group.addTask { + try? await Task.sleep(for: .seconds(5)) + return nil + } + let event = await group.next() ?? nil + group.cancelAll() + return event + } + } + @Test func detectsDeletedSocketPathAndSupportsRestart() throws { let harness = try ServerHarness() defer { harness.shutdown() } diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/ConfigPaths/CmuxGhosttyConfigPathResolver.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/ConfigPaths/CmuxGhosttyConfigPathResolver.swift index d3dcf00577bd..0ddf835e7c80 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/ConfigPaths/CmuxGhosttyConfigPathResolver.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/ConfigPaths/CmuxGhosttyConfigPathResolver.swift @@ -2,8 +2,8 @@ public import Foundation /// Resolves which cmux-managed Ghostty config file under Application Support is /// active for a given bundle identifier, including the release-channel fallback -/// chain (debug/nightly/staging builds read the release config when they have -/// none of their own). +/// chain (debug/nightly/staging and terminal-kit builds read the release config +/// when they have none of their own). /// /// TRANSITIONAL: faithful lift of the app-target config-path namespace cluster /// the engine and ``GhosttyConfig`` recurse through. These stateless @@ -13,7 +13,7 @@ public struct CmuxGhosttyConfigPathResolver { /// The bundle identifier of the released cmux app, used as the canonical /// config location and the fallback for dev/nightly/staging channels. public static let releaseBundleIdentifier = "com.cmuxterm.app" - private static let releaseFallbackChannelSuffixes = ["debug", "nightly", "staging"] + private static let releaseFallbackChannelSuffixes = ["debug", "nightly", "staging", "terminal-kit"] public init() {} diff --git a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/CmuxGhosttyConfigPathResolverTests.swift b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/CmuxGhosttyConfigPathResolverTests.swift new file mode 100644 index 000000000000..ddf9e3463117 --- /dev/null +++ b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/CmuxGhosttyConfigPathResolverTests.swift @@ -0,0 +1,87 @@ +import Foundation +import Testing + +@testable import CmuxFoundation + +@Suite struct CmuxGhosttyConfigPathResolverTests { + @Test func terminalKitBuildFallsBackToReleaseConfig() throws { + let fixture = try Fixture() + defer { fixture.remove() } + + let releaseConfig = try fixture.writeConfig( + bundleIdentifier: CmuxGhosttyConfigPathResolver.releaseBundleIdentifier, + contents: "theme = 0x96f\n" + ) + + let urls = CmuxGhosttyConfigPathResolver().loadConfigURLs( + currentBundleIdentifier: "com.cmuxterm.app.terminal-kit", + appSupportDirectory: fixture.root, + fileManager: fixture.fileManager + ) + + #expect(urls == [releaseConfig]) + } + + @Test func terminalKitSpecificConfigTakesPrecedenceOverReleaseConfig() throws { + let fixture = try Fixture() + defer { fixture.remove() } + + _ = try fixture.writeConfig( + bundleIdentifier: CmuxGhosttyConfigPathResolver.releaseBundleIdentifier, + contents: "theme = release\n" + ) + let taggedConfig = try fixture.writeConfig( + bundleIdentifier: "com.cmuxterm.app.terminal-kit", + contents: "theme = terminal-kit\n" + ) + + let urls = CmuxGhosttyConfigPathResolver().loadConfigURLs( + currentBundleIdentifier: "com.cmuxterm.app.terminal-kit", + appSupportDirectory: fixture.root, + fileManager: fixture.fileManager + ) + + #expect(urls == [taggedConfig]) + } + + @Test func unrelatedTaggedBuildDoesNotSilentlyInheritReleaseConfig() throws { + let fixture = try Fixture() + defer { fixture.remove() } + + _ = try fixture.writeConfig( + bundleIdentifier: CmuxGhosttyConfigPathResolver.releaseBundleIdentifier, + contents: "theme = release\n" + ) + + let urls = CmuxGhosttyConfigPathResolver().loadConfigURLs( + currentBundleIdentifier: "com.cmuxterm.app.some-experiment", + appSupportDirectory: fixture.root, + fileManager: fixture.fileManager + ) + + #expect(urls.isEmpty) + } + + private struct Fixture { + let fileManager = FileManager.default + let root: URL + + init() throws { + root = fileManager.temporaryDirectory + .appendingPathComponent("CmuxGhosttyConfigPathResolverTests-\(UUID().uuidString)", isDirectory: true) + try fileManager.createDirectory(at: root, withIntermediateDirectories: true) + } + + func writeConfig(bundleIdentifier: String, contents: String) throws -> URL { + let directory = root.appendingPathComponent(bundleIdentifier, isDirectory: true) + try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + let url = directory.appendingPathComponent("config.ghostty", isDirectory: false) + try contents.write(to: url, atomically: true, encoding: .utf8) + return url + } + + func remove() { + try? fileManager.removeItem(at: root) + } + } +} diff --git a/Sources/GhosttyTerminalViewSupport.swift b/Sources/GhosttyTerminalViewSupport.swift index 38bdf342283e..33a531db8533 100644 --- a/Sources/GhosttyTerminalViewSupport.swift +++ b/Sources/GhosttyTerminalViewSupport.swift @@ -2,6 +2,14 @@ import AppKit import CmuxTerminal import GhosttyKit +@_silgen_name("ghostty_surface_read_semantic_block") +private func cmuxGhosttyReadSemanticBlock( + _ surface: ghostty_surface_t, + _ x: Double, + _ y: Double, + _ result: UnsafeMutablePointer +) -> Bool + final class GhosttyPassthroughVisualEffectView: NSVisualEffectView { override var acceptsFirstResponder: Bool { false } @@ -67,6 +75,236 @@ final class TerminalLinkHoverIndicatorView: NSView { } } +private final class TerminalSemanticHoverCopyView: NSView { + private weak var surfaceView: GhosttyNSView? + private let blockStartRule = NSView(frame: .zero) + private let copyButton = NSButton(frame: .zero) + private var tracking: NSTrackingArea? + private var activeText: String? + private var activeSummary = "" + private var lastLookupTimestamp: TimeInterval = 0 + private var feedbackGeneration: UInt64 = 0 + + private static let lookupInterval: TimeInterval = 0.04 + private static let buttonHeight: CGFloat = 26 + private static let minimumButtonWidth: CGFloat = 92 + private static let maximumButtonWidth: CGFloat = 280 + + init(surfaceView: GhosttyNSView) { + self.surfaceView = surfaceView + super.init(frame: .zero) + + wantsLayer = true + layer?.backgroundColor = NSColor.clear.cgColor + + blockStartRule.isHidden = true + blockStartRule.wantsLayer = true + blockStartRule.layer?.backgroundColor = NSColor.separatorColor.withAlphaComponent(0.22).cgColor + addSubview(blockStartRule) + + copyButton.isHidden = true + copyButton.isBordered = false + copyButton.imagePosition = .imageLeading + copyButton.image = NSImage(systemSymbolName: "doc.on.doc", accessibilityDescription: "Copy") + copyButton.font = .monospacedSystemFont(ofSize: 10.5, weight: .medium) + copyButton.alignment = .center + copyButton.contentTintColor = .secondaryLabelColor + copyButton.wantsLayer = true + copyButton.layer?.cornerRadius = 6 + copyButton.layer?.borderWidth = 0 + copyButton.layer?.backgroundColor = NSColor.windowBackgroundColor.withAlphaComponent(0.82).cgColor + copyButton.target = self + copyButton.action = #selector(copyHoveredBlock) + copyButton.setAccessibilityLabel("Copy terminal block") + addSubview(copyButton) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) not implemented") + } + + override var acceptsFirstResponder: Bool { false } + + override func updateTrackingAreas() { + super.updateTrackingAreas() + if let tracking { + removeTrackingArea(tracking) + } + let area = NSTrackingArea( + rect: .zero, + options: [.activeInKeyWindow, .inVisibleRect, .mouseMoved, .mouseEnteredAndExited], + owner: self, + userInfo: nil + ) + addTrackingArea(area) + tracking = area + } + + override func resetCursorRects() { + super.resetCursorRects() + guard !copyButton.isHidden else { return } + addCursorRect(copyButton.frame.insetBy(dx: -4, dy: -4), cursor: .pointingHand) + } + + override func hitTest(_ point: NSPoint) -> NSView? { + guard !copyButton.isHidden else { return nil } + let forgivingFrame = copyButton.frame.insetBy(dx: -4, dy: -4) + return forgivingFrame.contains(point) ? copyButton : nil + } + + override func mouseMoved(with event: NSEvent) { + let localPoint = convert(event.locationInWindow, from: nil) + if !copyButton.isHidden, copyButton.frame.insetBy(dx: -5, dy: -5).contains(localPoint) { + return + } + + guard event.timestamp - lastLookupTimestamp >= Self.lookupInterval else { return } + lastLookupTimestamp = event.timestamp + refreshSemanticBlock(at: event.locationInWindow, localPoint: localPoint) + } + + override func mouseExited(with event: NSEvent) { + hideSemanticBlock() + } + + private func refreshSemanticBlock(at windowPoint: NSPoint, localPoint: NSPoint) { + guard let surfaceView, + let terminalSurface = surfaceView.terminalSurface, + let surface = terminalSurface.surface else { + hideSemanticBlock() + return + } + + let point = surfaceView.convert(windowPoint, from: nil) + guard surfaceView.bounds.contains(point) else { + hideSemanticBlock() + return + } + + var text = ghostty_text_s() + guard cmuxGhosttyReadSemanticBlock( + surface, + Double(point.x), + Double(surfaceView.bounds.height - point.y), + &text + ) else { + hideSemanticBlock() + return + } + defer { ghostty_surface_free_text(surface, &text) } + + guard text.text_len > 0, + let bytes = text.text else { + hideSemanticBlock() + return + } + let data = Data(bytes: bytes, count: Int(text.text_len)) + guard let value = String(data: data, encoding: .utf8), + !value.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines).isEmpty else { + hideSemanticBlock() + return + } + + let blockTopY = resolvedBlockTopY(text: text) + if activeText == value { + positionTarget(near: localPoint, blockTopY: blockTopY) + return + } + + activeText = value + activeSummary = Self.summary(for: value) + feedbackGeneration &+= 1 + copyButton.image = NSImage(systemSymbolName: "doc.on.doc", accessibilityDescription: "Copy") + copyButton.title = "Copy · \(activeSummary)" + copyButton.toolTip = "Copy this terminal block: \(activeSummary)" + positionTarget(near: localPoint, blockTopY: blockTopY) + copyButton.isHidden = false + window?.invalidateCursorRects(for: self) + } + + private func resolvedBlockTopY(text: ghostty_text_s) -> CGFloat? { + guard text.tl_px_y.isFinite, text.tl_px_y >= 0 else { return nil } + return min(max(0, bounds.height - CGFloat(text.tl_px_y)), bounds.height) + } + + private func positionTarget(near point: NSPoint, blockTopY: CGFloat?) { + copyButton.sizeToFit() + let width = min( + max(Self.minimumButtonWidth, copyButton.frame.width + 14), + Self.maximumButtonWidth + ) + let size = NSSize(width: width, height: Self.buttonHeight) + let x = max(8, bounds.width - size.width - 10) + + let y: CGFloat + if let blockTopY { + let ruleY = min(max(8, blockTopY), max(8, bounds.height - 8)) + blockStartRule.frame = NSRect( + x: 8, + y: ruleY, + width: max(0, bounds.width - 16), + height: 1 + ) + blockStartRule.isHidden = false + y = min( + max(8, ruleY - size.height - 4), + max(8, bounds.height - size.height - 8) + ) + } else { + blockStartRule.isHidden = true + y = min( + max(8, point.y - size.height / 2), + max(8, bounds.height - size.height - 8) + ) + } + + copyButton.frame = NSRect(origin: NSPoint(x: x, y: y), size: size) + window?.invalidateCursorRects(for: self) + } + + private func hideSemanticBlock() { + activeText = nil + activeSummary = "" + feedbackGeneration &+= 1 + blockStartRule.isHidden = true + copyButton.isHidden = true + window?.invalidateCursorRects(for: self) + } + + @objc private func copyHoveredBlock() { + guard let activeText else { return } + GhosttyApp.terminalPasteboard.writeString(activeText, to: GHOSTTY_CLIPBOARD_STANDARD) + + feedbackGeneration &+= 1 + let generation = feedbackGeneration + copyButton.image = NSImage(systemSymbolName: "checkmark", accessibilityDescription: "Copied") + copyButton.title = "Copied · \(activeSummary)" + DispatchQueue.main.asyncAfter(deadline: .now() + 0.65) { [weak self] in + guard let self, + self.feedbackGeneration == generation, + self.activeText != nil else { return } + self.copyButton.image = NSImage(systemSymbolName: "doc.on.doc", accessibilityDescription: "Copy") + self.copyButton.title = "Copy · \(self.activeSummary)" + self.positionTarget(near: self.copyButton.frame.center, blockTopY: self.blockStartRule.isHidden ? nil : self.blockStartRule.frame.minY) + } + } + + private static func summary(for text: String) -> String { + let line = text + .components(separatedBy: .newlines) + .map { $0.split(whereSeparator: { $0.isWhitespace }).joined(separator: " ") } + .first(where: { !$0.isEmpty }) ?? "terminal block" + let limit = 38 + return line.count > limit ? String(line.prefix(limit - 1)) + "…" : line + } +} + +private extension NSRect { + var center: NSPoint { + NSPoint(x: midX, y: midY) + } +} + extension GhosttySurfaceScrollView { nonisolated static func linkHoverURL(from link: ghostty_action_mouse_over_link_s) -> String? { guard link.len > 0, let bytes = link.url else { return nil } @@ -80,6 +318,25 @@ extension GhosttySurfaceScrollView { } linkHoverIndicatorView.setURL(url) } + + func installSemanticHoverCopy(surfaceView: GhosttyNSView) { + let overlay = TerminalSemanticHoverCopyView(surfaceView: surfaceView) + overlay.translatesAutoresizingMaskIntoConstraints = false + addSubview(overlay, positioned: .above, relativeTo: nil) + NSLayoutConstraint.activate([ + overlay.leadingAnchor.constraint(equalTo: leadingAnchor), + overlay.trailingAnchor.constraint(equalTo: trailingAnchor), + overlay.topAnchor.constraint(equalTo: topAnchor), + overlay.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + } +} + +extension TerminalPanelView { + var completionRootDirectory: String? { + let directory = panel.directory.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) + return directory.isEmpty ? nil : directory + } } func shouldAllowEnsureFocusWindowActivation( diff --git a/Sources/Panels/TerminalPanelView.swift b/Sources/Panels/TerminalPanelView.swift index 9287b146e4df..f0a712aab7b0 100644 --- a/Sources/Panels/TerminalPanelView.swift +++ b/Sources/Panels/TerminalPanelView.swift @@ -7,6 +7,7 @@ import CmuxTestSupport import CmuxTerminal import CmuxFoundation import CmuxSettings +import UniformTypeIdentifiers /// View for rendering a terminal panel struct TerminalPanelView: View { @@ -20,6 +21,9 @@ struct TerminalPanelView: View { @AppStorage(SessionContentWidthSettings.alignmentKey) private var storedSessionContentAlignment = SessionContentAlignment.center.rawValue @State private var terminalFontSize = GhosttyConfig.load(globalFontMagnificationPercent: GlobalFontMagnification.storedPercent).fontSize + @State private var clipboardPreview: TerminalClipboardPreview? + @State private var clipboardPreviewChangeCount = -1 + @State private var pathPeek: TerminalPathPeek? let paneId: PaneID let isFocused: Bool let isVisibleInUI: Bool @@ -152,7 +156,7 @@ struct TerminalPanelView: View { onFocus() }, onToggleFocus: { - _ = panel.focusTextBoxInputOrTerminal() + panel.focusTextBoxInputOrTerminal() }, onSelectSubmitAction: { actionID in panel.textBoxState.selectSubmitAction(actionID) @@ -177,6 +181,39 @@ struct TerminalPanelView: View { } ) .sessionContentWidth(fillsHeight: false) + .overlay(alignment: .bottomLeading) { + if shouldWatchClipboardPreview, + let clipboardPreview { + TerminalClipboardPreviewOverlay( + preview: clipboardPreview, + foregroundColor: appearance.foregroundColor + ) + .id(clipboardPreviewChangeCount) + .transition(.opacity) + } + } + .overlay(alignment: .topLeading) { + if let pathPeek { + TerminalPathPeekOverlay( + peek: pathPeek, + foregroundColor: appearance.foregroundColor + ) + .offset(y: -32) + .transition(.opacity.combined(with: .scale(scale: 0.98, anchor: .bottomLeading))) + } + } + .animation(.easeOut(duration: 0.12), value: clipboardPreviewChangeCount) + .animation(.easeOut(duration: 0.10), value: pathPeek) + .task(id: shouldWatchClipboardPreview) { + if shouldWatchClipboardPreview { + await watchClipboardPreview() + } else { + clipboardPreview = nil + } + } + .task(id: pathPeekTaskKey) { + await updatePathPeekAfterIdle() + } } } .background(Color(nsColor: appearance.contentBackgroundColor)) @@ -185,6 +222,74 @@ struct TerminalPanelView: View { } } + private var shouldWatchClipboardPreview: Bool { + isVisibleInUI + && panel.isTextBoxActive + && panel.textBoxContent.isEmpty + && panel.textBoxAttachments.isEmpty + } + + @MainActor + private func watchClipboardPreview() async { + clipboardPreviewChangeCount = -1 + refreshClipboardPreviewIfNeeded() + + while !Task.isCancelled { + do { + try await Task.sleep(nanoseconds: NSApp.isActive ? 120_000_000 : 450_000_000) + } catch { + break + } + guard !Task.isCancelled else { break } + refreshClipboardPreviewIfNeeded() + } + } + + @MainActor + private func refreshClipboardPreviewIfNeeded() { + let pasteboard = NSPasteboard.general + let changeCount = pasteboard.changeCount + guard changeCount != clipboardPreviewChangeCount else { return } + clipboardPreviewChangeCount = changeCount + clipboardPreview = TerminalClipboardPreview.read(from: pasteboard) + } + + private var pathPeekTaskKey: String { + [ + isVisibleInUI ? "1" : "0", + panel.isTextBoxActive ? "1" : "0", + completionRootDirectory ?? "", + panel.textBoxContent + ].joined(separator: "\u{1f}") + } + + @MainActor + private func updatePathPeekAfterIdle() async { + pathPeek = nil + guard isVisibleInUI, + panel.isTextBoxActive, + let rootDirectory = completionRootDirectory, + let request = TerminalPathPeekRequest.parse( + text: panel.textBoxContent, + rootDirectory: rootDirectory + ) else { + return + } + + do { + try await Task.sleep(nanoseconds: 350_000_000) + } catch { + return + } + guard !Task.isCancelled else { return } + + let result = await Task.detached(priority: .utility) { + request.loadPeek() + }.value + guard !Task.isCancelled else { return } + pathPeek = result + } + private var sessionContentWidthPresentation: SessionContentWidthPresentation { SessionContentWidthPresentation( storedMaximumWidth: storedSessionContentMaximumWidth, @@ -250,6 +355,231 @@ struct TerminalPanelView: View { } } +private struct TerminalClipboardPreview: Equatable { + let label: String + + @MainActor + static func read(from pasteboard: NSPasteboard) -> TerminalClipboardPreview? { + let types = pasteboard.types ?? [] + + if types.contains(.fileURL), + let urls = pasteboard.readObjects(forClasses: [NSURL.self]) as? [URL], + !urls.isEmpty { + if urls.count == 1 { + let name = urls[0].lastPathComponent.isEmpty ? urls[0].path : urls[0].lastPathComponent + return TerminalClipboardPreview(label: "clipboard · \(name)") + } + return TerminalClipboardPreview(label: "clipboard · \(urls.count) files") + } + + if types.contains(where: isImageType) { + return TerminalClipboardPreview(label: "clipboard · image") + } + + if let rawText = GhosttyApp.terminalPasteboard.fallbackPlainTextContents(from: pasteboard) { + let collapsed = rawText + .split(whereSeparator: { $0.isWhitespace }) + .joined(separator: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !collapsed.isEmpty else { return nil } + let limit = 84 + let excerpt = collapsed.count > limit + ? String(collapsed.prefix(limit - 1)) + "…" + : collapsed + return TerminalClipboardPreview(label: "clipboard · “\(excerpt)”") + } + + return nil + } + + private static func isImageType(_ type: NSPasteboard.PasteboardType) -> Bool { + if type == .tiff || type == .png { return true } + guard let utType = UTType(type.rawValue) else { return false } + return utType.conforms(to: .image) + } +} + +private struct TerminalClipboardPreviewOverlay: View { + let preview: TerminalClipboardPreview + let foregroundColor: NSColor + + var body: some View { + Text(preview.label) + .font(.system(size: 11, weight: .regular, design: .monospaced)) + .foregroundStyle(Color(nsColor: foregroundColor).opacity(0.38)) + .lineLimit(1) + .truncationMode(.tail) + .frame(maxWidth: .infinity, minHeight: 30, maxHeight: 30, alignment: .leading) + .padding(.horizontal, 7) + .allowsHitTesting(false) + .accessibilityHidden(true) + } +} + +private struct TerminalPathPeekRequest: Sendable { + let directoryPath: String + let fragment: String + let exactPath: String? + + static func parse(text: String, rootDirectory: String) -> TerminalPathPeekRequest? { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + let parts = trimmed.split(whereSeparator: { $0.isWhitespace }).map(String.init) + guard let rawLast = parts.last else { return nil } + let token = rawLast.trimmingCharacters(in: CharacterSet(charactersIn: "\"'")) + guard !token.isEmpty, !token.hasPrefix("-") else { return nil } + + let pathCommands: Set = [ + "cd", "ls", "ll", "la", "cat", "bat", "less", "open", "head", "tail", + "rg", "fd", "find", "vim", "nvim", "micro", "code", "tree", "du", "wc" + ] + let command = parts.first.map { URL(fileURLWithPath: $0).lastPathComponent } ?? "" + let explicitPath = token.hasPrefix("./") + || token.hasPrefix("../") + || token.hasPrefix("~/") + || token.hasPrefix("/") + || token.contains("/") + guard explicitPath || (parts.count >= 2 && pathCommands.contains(command)) else { + return nil + } + + let expanded: String + if token == "~" { + expanded = FileManager.default.homeDirectoryForCurrentUser.path + } else if token.hasPrefix("~/") { + expanded = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(String(token.dropFirst(2))) + .path + } else if token.hasPrefix("/") { + expanded = token + } else { + expanded = URL(fileURLWithPath: rootDirectory, isDirectory: true) + .appendingPathComponent(token) + .path + } + let standardized = URL(fileURLWithPath: expanded).standardizedFileURL.path + + var isDirectory: ObjCBool = false + if FileManager.default.fileExists(atPath: standardized, isDirectory: &isDirectory) { + if isDirectory.boolValue { + return TerminalPathPeekRequest( + directoryPath: standardized, + fragment: "", + exactPath: standardized + ) + } + return TerminalPathPeekRequest( + directoryPath: URL(fileURLWithPath: standardized).deletingLastPathComponent().path, + fragment: URL(fileURLWithPath: standardized).lastPathComponent, + exactPath: standardized + ) + } + + let url = URL(fileURLWithPath: standardized) + return TerminalPathPeekRequest( + directoryPath: url.deletingLastPathComponent().path, + fragment: url.lastPathComponent, + exactPath: nil + ) + } + + func loadPeek() -> TerminalPathPeek? { + if let exactPath { + var isDirectory: ObjCBool = false + if FileManager.default.fileExists(atPath: exactPath, isDirectory: &isDirectory), + !isDirectory.boolValue { + return .file(Self.fileSummary(path: exactPath)) + } + } + + let directoryURL = URL(fileURLWithPath: directoryPath, isDirectory: true) + guard let children = try? FileManager.default.contentsOfDirectory( + at: directoryURL, + includingPropertiesForKeys: [.isDirectoryKey, .isRegularFileKey], + options: [.skipsHiddenFiles] + ) else { + return nil + } + + let needle = fragment.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: nil) + let matches = children.compactMap { url -> TerminalPathPeek.Entry? in + guard !Task.isCancelled else { return nil } + let name = url.lastPathComponent + let folded = name.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: nil) + guard needle.isEmpty || folded.hasPrefix(needle) || folded.contains(needle) else { return nil } + let values = try? url.resourceValues(forKeys: [.isDirectoryKey, .isRegularFileKey]) + guard values?.isDirectory == true || values?.isRegularFile == true else { return nil } + return TerminalPathPeek.Entry(name: name, isDirectory: values?.isDirectory == true) + } + .sorted { lhs, rhs in + if lhs.isDirectory != rhs.isDirectory { return lhs.isDirectory && !rhs.isDirectory } + let leftPrefix = lhs.name.lowercased().hasPrefix(needle.lowercased()) + let rightPrefix = rhs.name.lowercased().hasPrefix(needle.lowercased()) + if leftPrefix != rightPrefix { return leftPrefix } + return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending + } + + guard !matches.isEmpty else { return nil } + let directoryName = directoryURL.lastPathComponent.isEmpty ? directoryURL.path : directoryURL.lastPathComponent + "/" + return .directory(name: directoryName, entries: Array(matches.prefix(5))) + } + + private static func fileSummary(path: String) -> String { + let url = URL(fileURLWithPath: path) + let name = url.lastPathComponent + let values = try? url.resourceValues(forKeys: [.fileSizeKey]) + let byteCount = values?.fileSize ?? 0 + let size: String + if byteCount >= 1_000_000 { + size = String(format: "%.1f MB", Double(byteCount) / 1_000_000) + } else if byteCount >= 1_000 { + size = String(format: "%.1f KB", Double(byteCount) / 1_000) + } else { + size = "\(byteCount) B" + } + let kind = url.pathExtension.isEmpty ? "file" : url.pathExtension.uppercased() + return "\(name) · \(kind) · \(size)" + } +} + +private enum TerminalPathPeek: Equatable { + struct Entry: Equatable, Sendable { + let name: String + let isDirectory: Bool + } + + case directory(name: String, entries: [Entry]) + case file(String) + + var label: String { + switch self { + case .file(let summary): + return summary + case .directory(let name, let entries): + let children = entries.map { $0.isDirectory ? $0.name + "/" : $0.name }.joined(separator: " ") + return "\(name) · \(children)" + } + } +} + +private struct TerminalPathPeekOverlay: View { + let peek: TerminalPathPeek + let foregroundColor: NSColor + + var body: some View { + Text(peek.label) + .font(.system(size: 11, weight: .regular, design: .monospaced)) + .foregroundStyle(Color(nsColor: foregroundColor).opacity(0.46)) + .lineLimit(1) + .truncationMode(.middle) + .padding(.horizontal, 7) + .frame(height: 25) + .allowsHitTesting(false) + .accessibilityHidden(true) + } +} + private struct AgentHibernationPlaceholderView: View { let state: AgentHibernationPanelState let appearance: PanelAppearance diff --git a/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowView.swift b/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowView.swift index 554db0a33260..c8ce2b182387 100644 --- a/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowView.swift +++ b/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowView.swift @@ -102,6 +102,10 @@ final class SidebarGroupHeaderTableCellView: NSTableCellView { contextMenuDidOpen = nil contextMenuDidClose = nil contextMenuVisible = false + // Recycled headers must reacquire hover from the controller instead of + // carrying a previously revealed plus button into a new row identity. + isPointerHovering = false + plusButton.setRevealed(false) } func configurePresentation(model: SidebarGroupHeaderRowModel) { diff --git a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift index 1773b92f590d..a5956e0c3881 100644 --- a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift +++ b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift @@ -290,6 +290,11 @@ final class SidebarWorkspaceRowTableCellView: NSTableCellView { contextMenuDidOpen = nil contextMenuDidClose = nil contextMenuVisible = false + // A detached cell can be reused for a different workspace after a close. + // Hover belongs to the controller's current pointer row, never to the + // retained cell instance, so retire any revealed close chrome here. + isPointerHovering = false + closeButton.setRevealed(false) pumpCancellables.removeAll() setPresentationActive(false) return postUpdateActions diff --git a/Sources/TerminalSurfaceRuntimeWiring.swift b/Sources/TerminalSurfaceRuntimeWiring.swift index eb88f850b6ef..a58533fb6caf 100644 --- a/Sources/TerminalSurfaceRuntimeWiring.swift +++ b/Sources/TerminalSurfaceRuntimeWiring.swift @@ -38,7 +38,9 @@ struct TerminalSurfaceViewFactory: TerminalSurfaceViewProviding { frame: initialFrame, imageTransferPreparation: imageTransferPreparation ) - return (view, GhosttySurfaceScrollView(surfaceView: view)) + let host = GhosttySurfaceScrollView(surfaceView: view) + host.installSemanticHoverCopy(surfaceView: view) + return (view, host) } } diff --git a/Sources/TextBoxInput.swift b/Sources/TextBoxInput.swift index 3da835197282..464233c4c573 100644 --- a/Sources/TextBoxInput.swift +++ b/Sources/TextBoxInput.swift @@ -2267,11 +2267,9 @@ struct TextBoxInputContainer: View { commentPool.pendingCount(workspaceId: surface.owningWorkspace()?.id) } - private var textBasePointSize: CGFloat { max(14, terminalFont.pointSize / max(GlobalFontMagnification.scale, 0.01) + 2) } + private var textBasePointSize: CGFloat { terminalFont.pointSize } - private var textFont: NSFont { - GlobalFontMagnification.systemFont(ofSize: textBasePointSize, weight: .regular) - } + private var textFont: NSFont { terminalFont } private func heightForLines(_ lines: Int) -> CGFloat { let lineHeight = ceil(textFont.ascender - textFont.descender + textFont.leading) @@ -2316,10 +2314,6 @@ struct TextBoxInputContainer: View { .padding(.top, 6) } HStack(alignment: .bottom, spacing: 6) { - addFilesButton(foreground: foreground) - .offset(x: TextBoxLayout.leadingButtonHorizontalOffset) - .padding(.bottom, TextBoxLayout.buttonBottomPadding) - ZStack(alignment: .leading) { TextBoxInputView( text: $text, @@ -2354,7 +2348,7 @@ struct TextBoxInputContainer: View { attachmentCount: attachments.count, hasMarkedText: hasMarkedText ) { - Text(String(localized: "textbox.placeholder", defaultValue: "Prompt or command")) + Text("") .cmuxFont(size: textBasePointSize) .foregroundStyle(Color(nsColor: terminalForegroundColor).opacity(0.36)) .padding(.leading, TextBoxLayout.textInset.width) @@ -2366,21 +2360,11 @@ struct TextBoxInputContainer: View { .frame(height: clampedHeight) .frame(maxWidth: .infinity) - sendButton(canSend: canSend, presentation: submitActionPresentation) - .offset(x: TextBoxLayout.trailingButtonHorizontalOffset) - .padding(.bottom, TextBoxLayout.buttonBottomPadding) } } - .padding(.horizontal, TextBoxLayout.pillHorizontalPadding) - .padding(.vertical, TextBoxLayout.pillVerticalPadding) - .background( - TextBoxInputGlassPillBackground( - foreground: foreground, - fallbackTint: background - ) - ) - .padding(.horizontal, 10) - .padding(.bottom, 7) + .padding(.horizontal, 6) + .padding(.vertical, 1) + .background(background) .task(id: submitActionImageCacheTaskKey) { await refreshSubmitActionImageCache(keys: submitActionImageCacheKeys) } diff --git a/cmux-tui/AGENTS.md b/cmux-tui/AGENTS.md index 3d345cb05a2c..1e6d20af41dd 100644 --- a/cmux-tui/AGENTS.md +++ b/cmux-tui/AGENTS.md @@ -1,14 +1,43 @@ # cmux-tui agent instructions -Do not run `cargo`, `rustc`, or Zig on Lawrence's Mac. Do not use a local build as a fallback. Commit and push the exact branch head, then use the hosted entry point from the repository root: +Local development on this fork is allowed on the developer's Mac. Do not require a hosted GitHub Actions round trip for ordinary Rust iteration. + +For focused work, prefer the local helper from the repository root: ```bash -./scripts/verify-cmux-tui-hosted.sh --filter -./scripts/verify-cmux-tui-hosted.sh --full +./scripts/verify-cmux-tui-local.sh --filter +``` + +For a broader local macOS pass: + +```bash +./scripts/verify-cmux-tui-local.sh --full +``` + +Direct `cargo`, `rustc`, and Zig commands are also allowed locally when using the repository-pinned toolchain and Ghostty submodule. `rust-toolchain.toml` is the Rust toolchain source of truth. The local helper initializes `ghostty`, installs the pinned Rust components through `rustup`, and installs the Ghostty-required Zig version under the user's cache when a matching Zig is not already available. + +Local verification does not require a clean tree, a commit, or a push. Use it during implementation and review-fix iteration. + +## Fork self-hosted Mac runner + +When an agent is operating through GitHub rather than a shell attached to the Mac, use the fork-only self-hosted workflow in `.github/workflows/cmux-tui-local-mac.yml`. The physical Mac runner must be registered only to `teamleaderleo/cmux` and carry the custom label `cmux-local-mac`. + +The workflow is intentionally owner-only: it schedules the self-hosted job only when `github.actor == 'teamleaderleo'`, and it accepts only exact commit SHAs contained in a branch of this fork. Do not weaken that actor gate and do not add `pull_request` or `pull_request_target` triggers to the self-hosted workflow. + +From the GitHub connector, trigger a focused or full local-Mac run by posting one of these top-level comments on a PR or issue: + +```text +/cmux-tui-local <40-character-fork-commit> focused +/cmux-tui-local <40-character-fork-commit> full ``` -Use `--filter` during focused development. It accepts one Rust test-name substring and verifies that the filter selects at least one test on hosted Linux and macOS. Use `--full` for the merge gate. Full mode runs the complete Linux and macOS suites, package builds, and a Windows-hosted binary execution check. +A manual `workflow_dispatch` form is also available in the fork Actions UI with the same commit/mode/filter fields. The comment form exists so a connected agent can request the run without needing shell access to the Mac. -The script rejects dirty or unpushed work, verifies the exact commit in every hosted job, waits for completion, prints failed logs, and downloads the macOS arm64 binary to `cmux-tui/target/hosted//cmux-tui`. Running that downloaded binary on the Mac is allowed. +Hosted verification remains the final cross-platform gate, not the development loop: + +```bash +./scripts/verify-cmux-tui-hosted.sh --filter +./scripts/verify-cmux-tui-hosted.sh --full +``` -`rust-toolchain.toml` is the single Rust toolchain source for hosted TUI tests, package builds, and live conformance. Change that file instead of adding a workflow-specific Rust version. +Use hosted `--filter` when a platform-specific check is needed or when explicitly requested. Use hosted `--full` before merge when the complete Linux/macOS/Windows gate is required. Do not sit in the main conversation polling hosted CI after a local first pass; return to the user and address concrete CI failures when they arrive. diff --git a/docs/ci-runners.md b/docs/ci-runners.md index c3e228eae1d6..2cc9658f841c 100644 --- a/docs/ci-runners.md +++ b/docs/ci-runners.md @@ -1,5 +1,9 @@ # CI runners +## Fork default + +This personal fork defaults ordinary Linux CI to GitHub-hosted `ubuntu-24.04`. `LINUX_RUNNER` remains an override for deliberate runner experiments. Provider-specific macOS and manual test lanes are separate and must be opted into explicitly. The upstream Nightly macOS and CMUX INTERNAL TestFlight workflows are manual-only in this fork. + Every CI/CD job picks its runner from a repository variable instead of a hardcoded label. Linux uses Blacksmith. macOS uses ephemeral Tart VMs on the cmux Mac fleet. Changing a runner type is a single repository-variable update diff --git a/ghostty b/ghostty index f76c132e526f..3a9447fed044 160000 --- a/ghostty +++ b/ghostty @@ -1 +1 @@ -Subproject commit f76c132e526f124fe4aaebd39f516751656844bc +Subproject commit 3a9447fed044c92901e4c0fa31298ddec6da2281 diff --git a/scripts/verify-cmux-tui-local.sh b/scripts/verify-cmux-tui-local.sh new file mode 100755 index 000000000000..0ef3f6b4114b --- /dev/null +++ b/scripts/verify-cmux-tui-local.sh @@ -0,0 +1,147 @@ +#!/usr/bin/env bash +# Fast local cmux-tui verification for macOS development. +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: + ./scripts/verify-cmux-tui-local.sh --filter + ./scripts/verify-cmux-tui-local.sh --full + +--filter runs matching cmux-tui-core library tests locally on this Mac. +--full runs formatting, clippy, and the local workspace test suite. +Hosted verification remains the final cross-platform merge gate. +EOF +} + +mode="" +test_filter="" +case "${1:-}" in + --filter) + if [[ $# -ne 2 ]]; then + usage >&2 + exit 2 + fi + mode="focused" + test_filter="$2" + ;; + --full) + if [[ $# -ne 1 ]]; then + usage >&2 + exit 2 + fi + mode="full" + ;; + -h|--help) + usage + exit 0 + ;; + *) + usage >&2 + exit 2 + ;; +esac + +if [[ "$mode" == "focused" && ! "$test_filter" =~ ^[A-Za-z0-9_][A-Za-z0-9_:.-]{0,199}$ ]]; then + echo "error: --filter must be one Rust test-name substring without shell syntax" >&2 + exit 2 +fi + +if [[ "$(uname -s)" != "Darwin" ]]; then + echo "error: this helper is for local macOS development; use the hosted verifier for cross-platform checks" >&2 + exit 1 +fi + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if [[ -n "${CMUX_TUI_REPO_ROOT:-}" ]]; then + repo_root="$(git -C "$CMUX_TUI_REPO_ROOT" rev-parse --show-toplevel)" +else + repo_root="$(git -C "$script_dir" rev-parse --show-toplevel)" +fi + +for command_name in git python3 rustup curl tar; do + if ! command -v "$command_name" >/dev/null 2>&1; then + echo "error: required command not found: $command_name" >&2 + exit 1 + fi +done + +# The TUI build links Ghostty's VT library from the repository submodule. +git -C "$repo_root" submodule update --init --depth 1 ghostty + +# Keep local Rust exactly aligned with cmux-tui/rust-toolchain.toml. +rust_info="$(python3 - "$repo_root/cmux-tui/rust-toolchain.toml" <<'PY' +import pathlib +import re +import sys + +text = pathlib.Path(sys.argv[1]).read_text(encoding="utf-8") + +def value(name): + match = re.search(rf'^\s*{name}\s*=\s*"([^"]+)"', text, re.MULTILINE) + if match is None: + raise SystemExit(f"missing {name} in rust-toolchain.toml") + return match.group(1) + +components_match = re.search(r'^\s*components\s*=\s*\[([^]]*)\]', text, re.MULTILINE) +if components_match is None: + raise SystemExit("missing components in rust-toolchain.toml") +components = " ".join(re.findall(r'"([^"]+)"', components_match.group(1))) +print(f"{value('channel')}|{value('profile')}|{components}") +PY +)" +IFS='|' read -r rust_channel rust_profile rust_components <<< "$rust_info" +rustup_args=(toolchain install "$rust_channel" --profile "$rust_profile") +for component in $rust_components; do + rustup_args+=(--component "$component") +done +rustup "${rustup_args[@]}" >/dev/null + +# Use the exact Zig version Ghostty requires. Prefer an existing matching Zig; +# otherwise install it privately under the user's cache without sudo. +# shellcheck source=ghostty-zig-version.sh +source "$repo_root/scripts/ghostty-zig-version.sh" +zig_version="$(ghostty_minimum_zig_version "$repo_root")" +case "$(uname -m)" in + arm64) zig_arch="aarch64" ;; + x86_64) zig_arch="x86_64" ;; + *) + echo "error: unsupported macOS architecture: $(uname -m)" >&2 + exit 1 + ;; +esac + +zig_cache="${CMUX_TUI_LOCAL_ZIG_ROOT:-$HOME/.cache/cmux/zig}" +zig_name="zig-${zig_arch}-macos-${zig_version}" +cached_zig="$zig_cache/$zig_name/zig" +zig_path="" + +system_zig="$(command -v zig 2>/dev/null || true)" +if [[ -n "$system_zig" && "$("$system_zig" version 2>/dev/null || true)" == "$zig_version" ]]; then + zig_path="$system_zig" +elif [[ -x "$cached_zig" && "$("$cached_zig" version 2>/dev/null || true)" == "$zig_version" ]]; then + zig_path="$cached_zig" +else + mkdir -p "$zig_cache" + ZIG_FORCE_LOCAL_INSTALL=1 \ + ZIG_INSTALL_ROOT="$zig_cache" \ + "$repo_root/scripts/install-zig-ci.sh" + zig_path="$cached_zig" +fi + +if [[ ! -x "$zig_path" ]]; then + echo "error: Zig $zig_version was not installed at $zig_path" >&2 + exit 1 +fi +export ZIG="$zig_path" + +echo "Local cmux-tui toolchain: rust $rust_channel, zig $zig_version" +cd "$repo_root/cmux-tui" + +if [[ "$mode" == "focused" ]]; then + cargo test -p cmux-tui-core --lib --locked "$test_filter" -- --nocapture +else + cargo fmt --all -- --check + cargo clippy --workspace --all-targets --locked -- -D warnings + cargo test --workspace --locked +fi diff --git a/tests/test_ci_self_hosted_guard.sh b/tests/test_ci_self_hosted_guard.sh index 3280e8801dbf..446329a59d57 100755 --- a/tests/test_ci_self_hosted_guard.sh +++ b/tests/test_ci_self_hosted_guard.sh @@ -1105,22 +1105,19 @@ check_tmux_terminal_nightly_isolation() { echo "PASS: tmux corpus terminal-nightly uses isolated DerivedData, noninteractive xcodebuild, and expected-failure handling" } -check_no_bare_github_hosted_runners() { - # Every job must route its runner through a repo variable (LINUX_RUNNER, - # MACOS_RUNNER_*) so the Blacksmith<->Warp / Blacksmith<->macos-26 overflow - # switch is a single repo-variable flip with no PR. A bare GitHub-hosted - # label (ubuntu-*, macos-NN) cannot be redirected, so it is forbidden. - # Bare paid-provider labels (blacksmith-*, warp-*, depot-*) stay allowed for - # deliberate single-runner pins such as the testmanagerd-wedged - # `app-host-unit-tests` job. +check_fork_linux_runner_policy() { + # This is a personal fork. Ordinary Linux CI must work on GitHub-hosted + # runners without access to the upstream organization's paid runner fleet. + # LINUX_RUNNER remains an optional override, but stale paid Linux provider + # labels are forbidden because they queue forever when the provider is absent. local hits - hits="$(grep -rnE "runs-on:[[:space:]]*(ubuntu-[a-z0-9.]+|macos-[a-z0-9]+)([[:space:]]*$|[[:space:]]+#)" "$ROOT_DIR/.github/workflows" | grep -v "github-hosted-required" || true)" + hits="$(grep -rnE 'runs-on:.*(blacksmith-[0-9]+vcpu-ubuntu-|warp-ubuntu-)' "$ROOT_DIR/.github/workflows" || true)" if [[ -n "$hits" ]]; then - echo "FAIL: these jobs use a bare GitHub-hosted runner; route them through vars.LINUX_RUNNER / vars.MACOS_RUNNER_IOS so Blacksmith<->overflow stays a repo-variable flip:" + echo "FAIL: workflow still references an unavailable paid Linux runner in this fork:" echo "$hits" exit 1 fi - echo "PASS: no workflow pins a bare GitHub-hosted runner; all route through runner repo variables" + echo "PASS: fork Linux CI defaults to GitHub-hosted runners; no stale paid Linux provider labels remain" } check_no_self_hosted_fleet_runners() { @@ -1238,7 +1235,7 @@ check_no_self_hosted_fleet_runners() { } # ci.yml jobs -check_no_bare_github_hosted_runners +check_fork_linux_runner_policy check_no_self_hosted_fleet_runners check_macos_runner "$CI_FILE" "app-host-unit-tests" check_macos_runner "$CI_FILE" "tests-build-and-lag" diff --git a/tests/test_tui_publish_workflow_security.py b/tests/test_tui_publish_workflow_security.py index caa6bd5d3e96..dceee07f21bc 100644 --- a/tests/test_tui_publish_workflow_security.py +++ b/tests/test_tui_publish_workflow_security.py @@ -118,7 +118,7 @@ def test_npm_bootstrap_preserves_the_first_stable_version() -> None: block = workflow_job(bootstrap, job) assert ( "runs-on: ${{ vars.LINUX_RUNNER || " - "'blacksmith-4vcpu-ubuntu-2404' }}" in block + "'ubuntu-24.04' }}" in block ) assert ( "runs-on: ubuntu-latest # github-hosted-required: npm provenance publishing" @@ -151,7 +151,7 @@ def test_pypi_bootstrap_reserves_the_project_before_release_tags() -> None: assert workflow_triggers(bootstrap) == { "repository_dispatch": {"types": ["sdk-bootstrap-pypi"]} } - assert "runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}" in bootstrap + assert "runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }}" in bootstrap assert "id-token: write" in bootstrap assert "name: pypi-bootstrap" in bootstrap assert "PYPI_BOOTSTRAP_TOKEN" not in bootstrap @@ -214,7 +214,7 @@ def test_crates_bootstrap_preserves_the_first_stable_version() -> None: assert workflow_triggers(bootstrap) == { "repository_dispatch": {"types": ["sdk-bootstrap-crates"]} } - assert "runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}" in bootstrap + assert "runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }}" in bootstrap assert 'RUST_TOOLCHAIN: "1.95.0"' in bootstrap assert 'BOOTSTRAP_VERSION: "0.0.0-bootstrap.0"' in bootstrap assert "CARGO_BOOTSTRAP_TOKEN" in bootstrap @@ -613,7 +613,7 @@ def test_release_app_token_is_scoped_to_the_atomic_push() -> None: assert "SDK_RELEASE_APP_PRIVATE_KEY" not in revalidate_tags assert "actions/create-github-app-token@" not in revalidate_tags - assert "runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}" in cut_tags + assert "runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-24.04' }}" in cut_tags assert "actions/checkout@" not in cut_tags assert "actions/download-artifact@" not in cut_tags assert "actions/setup-node@" not in cut_tags